From 3f077397569ab361f3622fe16c33b23f35f6223c Mon Sep 17 00:00:00 2001 From: mind-one001 Date: Wed, 13 May 2026 16:33:34 +0900 Subject: [PATCH 001/185] =?UTF-8?q?=EC=9C=A0=ED=95=98=EB=B0=A9=ED=96=A5=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/(map-layout)/map/page.tsx | 270 +++++++++++++++++++------ apps/sn/app/(map-layout)/map2/page.tsx | 1 + 2 files changed, 213 insertions(+), 58 deletions(-) create mode 100644 apps/sn/app/(map-layout)/map2/page.tsx diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index c053ed707..f2ec117f5 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -17,10 +17,9 @@ "use client"; -import Gis from "@common/business_components/ui/map/Gis"; -import { getData, getRawJson } from "../../components/lib/api"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import GisApp from "@common/business_components/ui/map/lib/gis.core"; +import { usePathname } from "next/navigation"; + import GeoJSON from "ol/format/GeoJSON.js"; import Point from "ol/geom/Point.js"; import VectorLayer from "ol/layer/Vector.js"; @@ -28,32 +27,39 @@ import { unByKey } from "ol/Observable.js"; import VectorSource from "ol/source/Vector.js"; import Stroke from "ol/style/Stroke.js"; import Style from "ol/style/Style.js"; -import SearchModal from "./searchModal/searchModal"; -import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup"; -import { WaterDepth, WATER_DEPTH_LAYER_TYPES } from "./waterDepth/waterDepth"; + +import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; +import Gis from "@common/business_components/ui/map/Gis"; +import GisApp from "@common/business_components/ui/map/lib/gis.core"; import { ProcSearchBar } from "@common/business_components/ui/map/searchBar"; import { Button } from "@common/components"; -import useSearchModalStore from "@/store/useSearchModalStore"; -import useStore from "@/store/useStore"; -import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; + +import { getData, getRawJson } from "../../components/lib/api"; + import AgedPipeLayerSettingPanel from "./components/agedPipe/AgedPipeLayerSettingPanel"; import { + AGED_PIPE_DEFAULT_FILTERS, AGED_PIPE_EXCEL_HEADER, - AGED_PIPE_MIN_ZOOM, AGED_PIPE_LAYER_TYPE, + AGED_PIPE_MIN_ZOOM, AGED_PIPE_PAGE_SIZE, + type AgedPipeFilters, + type AgedPipeRow, buildAgedPipeCqlFilters, isAgedPipeLayerNode, matchesAgedPipeFilters, normalizeAgedPipeFilters, toDisplayPipeRow, validateElapsedYears, - type AgedPipeFilters, - type AgedPipeRow, - AGED_PIPE_DEFAULT_FILTERS, } from "./components/agedPipe/agedPipeUtils"; -import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; -import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup"; +import SearchModal from "./searchModal/searchModal"; +import { WATER_DEPTH_LAYER_TYPES,WaterDepth } from "./waterDepth/waterDepth"; + +import useSearchModalStore from "@/store/useSearchModalStore"; +import useStore from "@/store/useStore"; const AGED_PIPE_CONTROL_NODE_ID = "SN_AGED_PIPE"; const AGED_PIPE_HIGHLIGHT_LAYER_ID = "SN_AGED_PIPE_HIGHLIGHT"; @@ -70,18 +76,27 @@ const SOUTH_KOREA_VIEW_EXTENT: [number, number, number, number] = [ // 유하방향은 GeoServer WMS/WFS 레이어가 아니라 BE 보정 GeoJSON API로 그리는 가상 레이어이다. const FLOW_DIRECTION_LAYER_TYPE = "tb_sn_swage_ppl_flow_direction"; const FLOW_DIRECTION_LAYER_NAME = "유하방향"; +const FLOW_DIRECTION_PIPE_LAYER_TYPE = "tb_ms_swage_ppl"; const FLOW_DIRECTION_SRS = "EPSG:5179"; const FLOW_DIRECTION_DEFAULT_MIN_ZOOM = 13; -const FLOW_DIRECTION_DEFAULT_LIMIT = 5000; const FLOW_DIRECTION_DEFAULT_COLOR = "rgba(40, 90, 180, 0.84)"; +const FLOW_DIRECTION_NETWORK_CORRECTION_COLOR = "rgba(0, 168, 132, 0.95)"; const FLOW_DIRECTION_BBOX_BUFFER_RATIO = 0.35; -const FLOW_DIRECTION_CACHE_MAX_FEATURES = 10000; type FlowDirectionUsageStyle = { color: string; lineColor: string; }; +type FlowDirectionMode = "PIPE_START_END" | "PIPE_DEPTH"; + +// /map은 관로 깊이 기준, /map2는 관로 원천 geometry 시점->종점 기준만 유지한다. 두 화면 모두 BBOX 조회만 사용한다. +const getFlowDirectionModeByPathname = (pathname: string | null): FlowDirectionMode => { + const normalizedPathname = String(pathname ?? "").replace(/\/$/, ""); + if (normalizedPathname.endsWith("/map2")) return "PIPE_START_END"; + return "PIPE_DEPTH"; +}; + const AGED_PIPE_FOCUS_PADDING: [number, number, number, number] = [120, 120, 120, 120]; const DEFAULT_FEATURE_FIT_PADDING: [number, number, number, number] = [50, 50, 50, 50]; @@ -132,15 +147,19 @@ const normalizeFlowDirectionLayerInfos = (data: any[]): any[] => { }; // 레이어 설정의 최소 표출 줌을 읽되, 값이 없으면 유하방향 기본 줌 기준을 적용한다. +const getFiniteNumber = (value: unknown) => { + if (value === null || value === undefined || String(value).trim() === "") return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + const getFlowDirectionMinZoom = (layerInfo: any) => { - const minZoom = Number(layerInfo?.minViewSize ?? layerInfo?.minZoom); - return Number.isFinite(minZoom) ? minZoom : FLOW_DIRECTION_DEFAULT_MIN_ZOOM; + return getFiniteNumber(layerInfo?.minViewSize ?? layerInfo?.minZoom) ?? FLOW_DIRECTION_DEFAULT_MIN_ZOOM; }; // 유하방향 화살표는 관로 위에 얹는 보조 레이어라 레이어 설정의 drawOrdr를 우선 사용하고 없으면 높은 zIndex를 준다. const getFlowDirectionZIndex = (layerInfo: any) => { - const drawOrder = Number(layerInfo?.drawOrdr); - return Number.isFinite(drawOrder) ? drawOrder : 9998; + return getFiniteNumber(layerInfo?.drawOrdr) ?? 9998; }; // BE 응답이 FeatureCollection 그대로 오거나 공통 응답 wrapper의 data 안에 들어오는 두 계약을 모두 처리한다. @@ -357,8 +376,19 @@ const createFlowDirectionStyle = ( const usageCode = String(feature.get("usgCd") ?? feature.get("usg_cd") ?? "").trim(); const usageStyle = usageStyleMap[usageCode]; const baseColor = usageStyle?.color ?? FLOW_DIRECTION_DEFAULT_COLOR; + const isNetworkCorrected = String( + feature.get("networkCorrectionYn") ?? + feature.get("network_correction_yn") ?? + "", + ).toUpperCase() === "Y"; - const styleByConfidence: Record = { + const styleByConfidence: Record = { + HIGH: { + color: baseColor, + lineColor: usageStyle?.lineColor ?? withAlpha(baseColor, 0.42), + scale: 1, + width: 1.3, + }, MEDIUM: { color: baseColor, lineColor: usageStyle?.lineColor ?? withAlpha(baseColor, 0.42), @@ -372,12 +402,21 @@ const createFlowDirectionStyle = ( width: 1.1, }, }; - const style = styleByConfidence[confidence] ?? styleByConfidence.LOW!; + const style = isNetworkCorrected + ? { + color: FLOW_DIRECTION_NETWORK_CORRECTION_COLOR, + lineColor: withAlpha(FLOW_DIRECTION_NETWORK_CORRECTION_COLOR, 0.5), + scale: 1.08, + width: 2, + lineDash: [8, 4], + } + : styleByConfidence[confidence] ?? styleByConfidence.LOW!; const styles = [ new Style({ stroke: new Stroke({ color: style.lineColor, width: style.width, + lineDash: style.lineDash, lineCap: "round", lineJoin: "round", }), @@ -426,8 +465,8 @@ const extractUsageCodesFromLayerInfo = (layerInfo: any) => { // 현재 줌에서 실제 표출 가능한 관로 레이어인지 확인해 숨겨진 관종의 유하방향 요청을 막는다. const isLayerVisibleAtZoom = (layerInfo: any, zoom: number) => { - const minZoom = Number(layerInfo?.minViewSize ?? layerInfo?.minZoom); - return !Number.isFinite(minZoom) || zoom >= minZoom; + const minZoom = getFiniteNumber(layerInfo?.minViewSize ?? layerInfo?.minZoom); + return minZoom === null || zoom >= minZoom; }; // 현재 체크되어 화면에 보이는 관로 레이어의 관종 코드와 선 색상을 유하방향 조회/스타일 기준으로 변환한다. @@ -437,6 +476,7 @@ const extractCheckedPipeUsageStyles = (checkedNodesData: any[], zoom: number) => checkedNodesData.forEach((node) => { const data = node?.data ?? node; if (!data || isFlowDirectionLayerInfo(data)) return; + if (getLayerTypeName(data) !== FLOW_DIRECTION_PIPE_LAYER_TYPE) return; if (!isLayerVisibleAtZoom(data, zoom)) return; const usageCodes = extractUsageCodesFromLayerInfo(data); @@ -498,6 +538,8 @@ const appendAgedPipeControlNode = (data: any[]) => { }; const Map = () => { + const pathname = usePathname(); + const flowDirectionMode = useMemo(() => getFlowDirectionModeByPathname(pathname), [pathname]); const { isSearchModalOpen, setIsSearchModalOpen } = useSearchModalStore(); const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정 @@ -541,9 +583,13 @@ const Map = () => { // 같은 관로가 겹치는 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); - // 지도 이동이 연속으로 발생하면 이전 bbox 요청을 중단해 늦은 응답과 불필요한 네트워크 대기를 줄인다. + // 지도 이동이 연속으로 발생하면 이전 유하방향 요청을 중단해 늦은 응답과 불필요한 네트워크 대기를 줄인다. const flowDirectionAbortControllerRef = useRef(null); // 같은 장애 상황에서 alert가 반복 노출되지 않도록 실패 안내 여부를 기억한다. const flowDirectionFailureAlertedRef = useRef(false); @@ -621,6 +667,7 @@ const Map = () => { flowDirectionSourceRef.current = null; flowDirectionFeatureKeySetRef.current.clear(); flowDirectionFeatureKeyQueueRef.current = []; + flowDirectionVisibleUsageCodesRef.current.clear(); }, []); // 유하방향 cache/source만 비워 관종 변경처럼 조건이 달라진 경우 이전 조건의 피처가 남지 않게 한다. @@ -628,8 +675,46 @@ const Map = () => { 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; + }, []); + + useEffect(() => { + if (!flowDirectionCheckedRef.current) return; + clearFlowDirectionFeatureCache(); + flowDirectionSelectionRequestUsageCodesRef.current = null; + setFlowDirectionSelectionVersion((version) => version + 1); + }, [clearFlowDirectionFeatureCache, flowDirectionMode]); + // 유하방향 레이어는 하나만 유지하고 source에 피처를 누적한다. const ensureFlowDirectionLayer = useCallback((layerInfo: any) => { const map = gisMaps.current; @@ -695,23 +780,11 @@ const Map = () => { source.addFeatures(nextFeatures); } - while (flowDirectionFeatureKeyQueueRef.current.length > FLOW_DIRECTION_CACHE_MAX_FEATURES) { - const staleKey = flowDirectionFeatureKeyQueueRef.current.shift(); - if (!staleKey) break; - - const staleFeature = source.getFeatures().find((feature: any) => - feature.get("flowDirectionCacheKey") === staleKey, - ); - if (staleFeature) { - source.removeFeature(staleFeature); - } - flowDirectionFeatureKeySetRef.current.delete(staleKey); - } }, [ensureFlowDirectionLayer]); - // 현재 지도 BBOX와 체크된 관종 필터로 유하방향 GeoJSON을 조회하고, 최신 요청만 지도에 반영한다. + // 현재 지도 BBOX와 체크된 관종별 필터로 유하방향 GeoJSON을 각각 조회하고, 최신 요청만 지도에 반영한다. const loadFlowDirectionLayer = useCallback( - async (layerInfo: any) => { + async (layerInfo: any, requestOnlyUsageCodes?: string[]) => { const map = gisMaps.current; if (!map) return; @@ -739,45 +812,113 @@ const Map = () => { 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 params = new URLSearchParams({ - bbox: requestBbox, + const baseParams = new URLSearchParams({ srs: FLOW_DIRECTION_SRS, - limit: String(FLOW_DIRECTION_DEFAULT_LIMIT), - usgCd: usageCodes.join(","), + directionMode: flowDirectionMode, + searchStrategy: "BBOX", + bbox: requestBbox, }); try { - const payload = await getRawJson( - `/sewerage-service/api/v1/map/flowDirectionGeometry?${params.toString()}`, - { signal: abortController.signal }, - ); + 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 featureCollection = getFeatureCollection(payload); - renderFlowDirectionLayer(featureCollection, layerInfo, usageStyleMap); + 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>( + (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, - returnedFeatures: featureCollection.features?.length ?? 0, + requestUsageCodes, + requestCount: responses.length, + returnedFeaturesByUsage, + returnedFeatures: Object.values(returnedFeaturesByUsage).reduce((sum, count) => sum + count, 0), + networkCorrectedFeatures, cachedFeatures: flowDirectionSourceRef.current?.getFeatures?.().length ?? 0, }; - flowDirectionFailureAlertedRef.current = false; + 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); - removeFlowDirectionLayer(); + if (!isPartialUsageRequest) { + removeFlowDirectionLayer(); + } if (!flowDirectionFailureAlertedRef.current) { - alert("유하방향 정보를 불러오지 못했습니다."); flowDirectionFailureAlertedRef.current = true; } } finally { @@ -786,7 +927,7 @@ const Map = () => { } } }, - [removeFlowDirectionLayer, renderFlowDirectionLayer], + [flowDirectionMode, removeFlowDirectionLayer, renderFlowDirectionLayer, retainFlowDirectionUsageCodes], ); // SearchModal, GisContent 등에서 호출 @@ -1057,12 +1198,22 @@ const Map = () => { setIsFlowDirectionChecked(Boolean(nextFlowDirectionLayerInfo)); setFlowDirectionLayerInfo(nextFlowDirectionLayerInfo); if (nextFlowDirectionLayerInfo) { - clearFlowDirectionFeatureCache(); + 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; @@ -1072,7 +1223,7 @@ const Map = () => { handleWaterDepthVisibility(checkedIds, checkedNodesData); setIsAgedPipeChecked(checkedNodesData.some((node) => isAgedPipeLayerNode(node))); - }, [clearFlowDirectionFeatureCache, handleWaterDepthVisibility, removeFlowDirectionLayer]); + }, [handleWaterDepthVisibility, removeFlowDirectionLayer, retainFlowDirectionUsageCodes]); const handleOpenLayerPopup = useCallback( (_layerNm: string, node?: any) => { @@ -1149,7 +1300,10 @@ const Map = () => { // 트리 체크 상태나 관종 레이어 선택이 바뀌면 유하방향 API vector layer를 추가/갱신/제거한다. if (isFlowDirectionChecked) { - loadFlowDirectionLayer(flowDirectionLayerInfo); + const requestUsageCodes = flowDirectionSelectionRequestUsageCodesRef.current; + flowDirectionSelectionRequestUsageCodesRef.current = null; + if (requestUsageCodes && requestUsageCodes.length === 0) return; + loadFlowDirectionLayer(flowDirectionLayerInfo, requestUsageCodes ?? undefined); } else { removeFlowDirectionLayer(); } diff --git a/apps/sn/app/(map-layout)/map2/page.tsx b/apps/sn/app/(map-layout)/map2/page.tsx new file mode 100644 index 000000000..972464ec9 --- /dev/null +++ b/apps/sn/app/(map-layout)/map2/page.tsx @@ -0,0 +1 @@ +export { default } from "../map/page"; From 6d0461eaef3549079d13f02f8766f708528a6055 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Fri, 15 May 2026 15:03:00 +0900 Subject: [PATCH 002/185] =?UTF-8?q?=ED=95=98=EC=88=98=EA=B4=80=EB=A1=9C=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysisMenu/AnalysisPopupMenu.tsx | 28 +- .../gisEdit/GisEditSessionPanel.tsx | 142 ++ .../map/components/gisEdit/GisEditToolbar.tsx | 119 ++ .../map/components/gisEdit/gisEditTypes.ts | 62 + .../map/components/gisEdit/useGisEdit.ts | 710 +++++++++ apps/sn/app/(map-layout)/map/page.tsx | 1331 ++++++++++++++++- apps/sn/docs/기준문서/005-상태관리정책.md | 15 + apps/sn/docs/기준문서/006-검증정책.md | 12 +- apps/sn/docs/기준문서/008-설계정책.md | 14 + apps/sn/docs/기준문서/014-문서관리정책.md | 155 ++ .../docs/기준문서/015-하네스엔지니어링정책.md | 126 ++ .../001-qa-SFR-012-003-하수관로편집-통합검증계획.md | 157 ++ .../002-qa-SFR-012-003-하수관로편집-통합검증결과.md | 165 ++ .../001-fe-SFR-012-003-하수관로편집-작업카드.md | 216 +++ .../001-fe-SFR-012-003-하수관로편집-통합구현기록.md | 227 +++ apps/sn/package.json | 1 + .../windowPopup/model/popupDefinitions.ts | 6 + .../src/features/windowPopup/model/types.ts | 5 + apps/sn/src/features/windowPopup/registry.tsx | 5 + .../ui/GisEditSessionPopupContent.tsx | 74 + apps/sn/src/types/lucide-react.d.ts | 44 + .../business_components/src/ui/map/Gis.js | 2 +- .../src/ui/map/GisSelect.js | 16 +- 23 files changed, 3621 insertions(+), 11 deletions(-) create mode 100644 apps/sn/app/(map-layout)/map/components/gisEdit/GisEditSessionPanel.tsx create mode 100644 apps/sn/app/(map-layout)/map/components/gisEdit/GisEditToolbar.tsx create mode 100644 apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts create mode 100644 apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts create mode 100644 apps/sn/docs/기준문서/014-문서관리정책.md create mode 100644 apps/sn/docs/기준문서/015-하네스엔지니어링정책.md create mode 100644 apps/sn/docs/작업검증/SFR-012-003-하수관로편집/001-qa-SFR-012-003-하수관로편집-통합검증계획.md create mode 100644 apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md create mode 100644 apps/sn/docs/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md create mode 100644 apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md create mode 100644 apps/sn/src/features/windowPopup/ui/GisEditSessionPopupContent.tsx create mode 100644 apps/sn/src/types/lucide-react.d.ts diff --git a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx index 53cf75946..b7686f4b0 100644 --- a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx +++ b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { ChartColumnBig, ChevronLeft, @@ -18,6 +18,8 @@ import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitio type Props = { onOpenPopup: (popupKey: WindowPopupKey) => void; onOpenInfoSearch: () => void; + onToggleGisEdit?: () => void; + isGisEditActive?: boolean; }; const ANALYSIS_POPUP_ITEMS: Array<{ @@ -34,10 +36,22 @@ const ANALYSIS_POPUP_ITEMS: Array<{ { key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert }, ]; -export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Props) { +export default function AnalysisPopupMenu({ + onOpenPopup, + onOpenInfoSearch, + onToggleGisEdit, + isGisEditActive = false, +}: Props) { const [isOpen, setIsOpen] = useState(true); const [activeKey, setActiveKey] = useState(null); + // GIS 편집 모드가 꺼지면 메뉴 강조 상태도 함께 정리해 사용자가 현재 모드를 오해하지 않게 한다. + useEffect(() => { + if (!isGisEditActive && activeKey === "gisEditPopup") { + setActiveKey(null); + } + }, [activeKey, isGisEditActive]); + return (
@@ -53,7 +67,9 @@ export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Pro
{ANALYSIS_POPUP_ITEMS.map((item) => { const Icon = item.icon; - const isActive = activeKey === item.key; + const isActive = item.key === "gisEditPopup" + ? isGisEditActive + : activeKey === item.key; return ( + ))} + +); + +export default function GisEditSessionPanel({ + isEditMode, + selectedLayerName, + activeTool, + changes, + lastSavedPayload, + canUndo, + focusedChangeId, + onRemoveLastChange, + onClearChanges, + onFocusChange, + className = "", +}: Props) { + return ( +
+
+
+
편집 세션
+
+ 레이어: {selectedLayerName || "미선택"} / 모드: {isEditMode ? (activeTool ? TOOL_LABELS[activeTool] : "대기") : "편집 모드 종료"} +
+
+ {isEditMode ? ( +
+ FE 로컬 세션 +
+ ) : ( +
+ 지도 미리보기 유지 +
+ )} +
+ +
+ + +
+ 총 {changes.length}건 +
+
+ +
+ {changes.length === 0 ? ( +
+ 저장할 편집 변경분이 없습니다. +
+ ) : ( +
+
+ +
+
+ )} +
+ + {lastSavedPayload ? ( +
+
저장 payload 미리보기
+
+            {JSON.stringify(lastSavedPayload, null, 2)}
+          
+
+ ) : null} +
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditToolbar.tsx b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditToolbar.tsx new file mode 100644 index 000000000..e1d4b6930 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditToolbar.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { + Move, + PenTool, + Plus, + Trash2, +} from "lucide-react"; + +import type { GisEditLayerInfo, GisEditTool } from "./gisEditTypes"; + +type Props = { + isEditMode: boolean; + editableLayers: GisEditLayerInfo[]; + selectedLayerId: string; + selectedLayer: GisEditLayerInfo | null; + activeTool: GisEditTool | null; + canUseTools: boolean; + canSave: boolean; + statusMessage: string; + onSelectLayer: (layerId: string) => void; + onSelectTool: (tool: GisEditTool) => void; + onSave: () => void; +}; + +const TOOL_BUTTONS: Array<{ + tool: GisEditTool; + label: string; + icon: typeof Plus; +}> = [ + { tool: "ADD", label: "추가", icon: Plus }, + { tool: "MOVE", label: "이동", icon: Move }, + { tool: "VERTEX_EDIT", label: "꼭지점편집", icon: PenTool }, + { tool: "DELETE", label: "삭제", icon: Trash2 }, +]; + +export default function GisEditToolbar({ + isEditMode, + editableLayers, + selectedLayerId, + selectedLayer, + activeTool, + canUseTools, + canSave, + statusMessage, + onSelectLayer, + onSelectTool, + onSave, +}: Props) { + if (!isEditMode) return null; + + return ( +
+
+
+ + + {TOOL_BUTTONS.map(({ tool, label, icon: Icon }) => { + const isActive = activeTool === tool; + const isToolEnabled = + canUseTools && Boolean(selectedLayer?.allowedTools.includes(tool)); + return ( + + ); + })} + + +
+
+ +
+ {statusMessage} +
+ {activeTool === "MOVE" ? ( +
+ 이동 대상 선택 후 파란 미리보기 객체를 드래그해 위치를 조정하세요. +
+ ) : null} + {activeTool === "VERTEX_EDIT" ? ( +
+ 편집 대상 선택 후 주황색 꼭지점 handle을 드래그해 형상을 조정하세요. +
+ ) : null} +
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts b/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts new file mode 100644 index 000000000..34e61e77c --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts @@ -0,0 +1,62 @@ +export type GisEditTool = "ADD" | "MOVE" | "VERTEX_EDIT" | "DELETE"; + +export type GisEditGeometryType = "Point" | "LineString" | "Polygon"; + +export type GisEditPreviewColors = { + pointColor: string; + lineColor: string; + fillColor: string; +}; + +export type GisEditLayerInfo = { + layerId: string; + layerName: string; + geometryType: GisEditGeometryType; + editableYn: "Y" | "N"; + allowedTools: GisEditTool[]; + previewColors: GisEditPreviewColors; + disabledReason?: string; +}; + +export type GisEditChange = { + changeId: string; + editType: GisEditTool; + layerId: string; + layerName: string; + objectId?: string; + objectLabel: string; + beforeGeometry?: Record | null; + afterGeometry?: Record | null; + summary: string; + createdAt: string; + source: "MAP_SELECTION" | "MAP_CENTER" | "MAP_DRAW"; +}; + +export type GisEditSavePayload = { + screenId: string; + layerId: string; + layerName: string; + saveTargetType: "VERSION"; + view: { + srs: string; + bbox: string | null; + }; + changes: Array<{ + editType: GisEditTool; + objectId?: string; + clientTempId?: string; + beforeGeometry?: Record | null; + afterGeometry?: Record | null; + geometry?: Record | null; + }>; +}; + +export type GisEditSessionSnapshot = { + isEditMode: boolean; + selectedLayerName: string; + activeTool: GisEditTool | null; + changes: GisEditChange[]; + lastSavedPayload: GisEditSavePayload | null; + canUndo: boolean; + focusedChangeId: string | null; +}; diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts b/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts new file mode 100644 index 000000000..e99c7c4d9 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts @@ -0,0 +1,710 @@ +"use client"; + +import { useCallback, useMemo, useRef, useState } from "react"; +import GeoJSON from "ol/format/GeoJSON.js"; + +import type { + GisEditChange, + GisEditGeometryType, + GisEditLayerInfo, + GisEditPreviewColors, + GisEditSavePayload, + GisEditTool, +} from "./gisEditTypes"; + +type GisEditInteractionRequest = { + changeId: string; + editType: "MOVE" | "VERTEX_EDIT"; +}; + +const buildPreviewFocusMessage = ( + change: Pick, +) => { + if (change.editType === "MOVE") { + return `${change.layerName} ${change.objectLabel} 미리보기 객체를 드래그해 위치를 조정하세요.`; + } + if (change.editType === "VERTEX_EDIT") { + return `${change.layerName} ${change.objectLabel}의 주황색 실꼭지점을 드래그하거나, 선분 위 흰색 midpoint를 끌어 새 꼭지점을 추가하세요.`; + } + if (change.editType === "DELETE") { + return `${change.layerName} ${change.objectLabel} 삭제 미리보기를 유지 중입니다.`; + } + return `${change.layerName} ${change.objectLabel} 신규 객체 미리보기를 확인하세요.`; +}; + +const EDITABLE_LAYER_RULES: Record< + string, + Omit +> = { + tb_ms_swage_ppl: { + layerName: "하수관로", + geometryType: "LineString", + allowedTools: ["ADD", "MOVE", "VERTEX_EDIT", "DELETE"], + previewColors: { + pointColor: "rgba(22, 163, 74, 0.92)", + lineColor: "rgba(22, 163, 74, 0.95)", + fillColor: "rgba(22, 163, 74, 0.18)", + }, + }, + tb_ms_swage_mhl: { + layerName: "하수맨홀", + geometryType: "Point", + allowedTools: ["ADD", "MOVE", "DELETE"], + previewColors: { + pointColor: "rgba(5, 150, 105, 0.92)", + lineColor: "rgba(5, 150, 105, 0.95)", + fillColor: "rgba(5, 150, 105, 0.18)", + }, + }, + tb_ms_swage_pmstn: { + layerName: "하수펌프장", + geometryType: "Point", + allowedTools: ["ADD", "MOVE", "DELETE"], + previewColors: { + pointColor: "rgba(59, 130, 246, 0.92)", + lineColor: "rgba(59, 130, 246, 0.95)", + fillColor: "rgba(59, 130, 246, 0.18)", + }, + }, + tb_il_swage_prcs_fclt: { + layerName: "하수처리시설", + geometryType: "Point", + allowedTools: ["ADD", "MOVE", "DELETE"], + previewColors: { + pointColor: "rgba(168, 85, 247, 0.92)", + lineColor: "rgba(168, 85, 247, 0.95)", + fillColor: "rgba(168, 85, 247, 0.18)", + }, + }, + tb_ms_swage_prcs_zone: { + layerName: "하수처리구역", + geometryType: "Polygon", + allowedTools: ["ADD", "MOVE", "VERTEX_EDIT", "DELETE"], + previewColors: { + pointColor: "rgba(249, 115, 22, 0.92)", + lineColor: "rgba(249, 115, 22, 0.95)", + fillColor: "rgba(249, 115, 22, 0.22)", + }, + }, + tb_ms_stofcbr: { + layerName: "우수토실", + geometryType: "Point", + allowedTools: ["ADD", "MOVE", "DELETE"], + previewColors: { + pointColor: "rgba(234, 88, 12, 0.92)", + lineColor: "rgba(234, 88, 12, 0.95)", + fillColor: "rgba(234, 88, 12, 0.18)", + }, + }, + om_dt_untrt_swg_rain: { + layerName: "중점우수토실", + geometryType: "Point", + allowedTools: ["ADD", "MOVE", "DELETE"], + previewColors: { + pointColor: "rgba(220, 38, 38, 0.92)", + lineColor: "rgba(220, 38, 38, 0.95)", + fillColor: "rgba(220, 38, 38, 0.18)", + }, + }, +}; + +const FLOW_DIRECTION_SRS = "EPSG:5179"; +const SCREEN_ID = "SN_MN010301"; + +const normalizeLayerIdCandidate = (value: unknown) => { + const raw = String(value ?? "").trim(); + if (!raw) return ""; + + const withoutNamespace = raw.includes(":") ? raw.split(":").pop() ?? raw : raw; + return withoutNamespace.split(".")[0] ?? withoutNamespace; +}; + +// 현재 레이어 트리 구조 안에서 실제 편집 대상으로 풀 수 있는 레이어만 추출한다. +const getLayerTypeName = (layerInfo: any) => + String(layerInfo?.lyrTyNm ?? layerInfo?.lyrTypeNm ?? layerInfo?.lyr_type_nm ?? "").trim(); + +const getLayerName = (layerInfo: any) => + String(layerInfo?.lyrNm ?? layerInfo?.name ?? "").trim(); + +const normalizeGeometryType = (value: unknown): GisEditGeometryType => { + const normalized = String(value ?? "").toLowerCase(); + if (normalized.includes("polygon")) return "Polygon"; + if (normalized.includes("line")) return "LineString"; + return "Point"; +}; + +const getColorString = (value: unknown) => { + const resolved = String(value ?? "").trim(); + return resolved || null; +}; + +const withAlpha = (color: string | null | undefined, alpha: number) => { + const resolvedColor = String(color ?? "").trim(); + if (!resolvedColor) return null; + + const rgbaMatch = resolvedColor.match(/^rgba?\(([^)]+)\)$/i); + if (rgbaMatch) { + const [red = "40", green = "90", blue = "180"] = (rgbaMatch[1] ?? "") + .split(",") + .map((part) => part.trim()); + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; + } + + const hexMatch = resolvedColor.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (hexMatch) { + const hex = hexMatch[1] ?? ""; + const normalized = hex.length === 3 + ? hex.split("").map((char) => char + char).join("") + : hex; + const red = parseInt(normalized.slice(0, 2), 16); + const green = parseInt(normalized.slice(2, 4), 16); + const blue = parseInt(normalized.slice(4, 6), 16); + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; + } + + return resolvedColor; +}; + +const resolvePreviewColors = ( + layerInfo: any, + fallback: GisEditPreviewColors, +): GisEditPreviewColors => { + const lineColor = + withAlpha( + getColorString( + layerInfo?.lineColr ?? + layerInfo?.line_colr ?? + layerInfo?.lineColor ?? + layerInfo?.fontInsdColr ?? + layerInfo?.font_insd_colr, + ), + 0.95, + ) ?? fallback.lineColor; + const pointColor = + withAlpha( + getColorString( + layerInfo?.lineColr ?? + layerInfo?.line_colr ?? + layerInfo?.fontInsdColr ?? + layerInfo?.font_insd_colr ?? + layerInfo?.fontExtnlColr ?? + layerInfo?.font_extnl_colr, + ), + 0.92, + ) ?? fallback.pointColor; + const fillColor = + withAlpha( + getColorString( + layerInfo?.spacColr ?? + layerInfo?.spac_colr ?? + layerInfo?.spaceColor ?? + layerInfo?.lineColr ?? + layerInfo?.line_colr, + ), + 0.22, + ) ?? fallback.fillColor; + + return { + pointColor, + lineColor, + fillColor, + }; +}; + +// FE 1차 구현은 시설 레이어 주요 7종을 기본 편집 후보로 열고, treeData에 없더라도 fallback 후보를 제공한다. +const collectEditableLayers = (treeData: any[]): GisEditLayerInfo[] => { + const results = new Map(); + + const visit = (items: any[]) => { + items.forEach((item) => { + const data = item?.data ?? item; + const layerTypeName = getLayerTypeName(data); + const layerRule = EDITABLE_LAYER_RULES[layerTypeName]; + + if (layerRule && !results.has(layerTypeName)) { + results.set(layerTypeName, { + layerId: layerTypeName, + layerName: layerRule.layerName, + geometryType: layerRule.geometryType, + editableYn: "Y", + allowedTools: layerRule.allowedTools, + previewColors: resolvePreviewColors(data, layerRule.previewColors), + }); + } + + if (Array.isArray(item?.children)) { + visit(item.children); + } + if (Array.isArray(data?.children)) { + visit(data.children); + } + }); + }; + + visit(Array.isArray(treeData) ? treeData : []); + + if (results.size === 0) { + Object.entries(EDITABLE_LAYER_RULES).forEach(([layerId, layerRule]) => { + results.set(layerId, { + layerId, + layerName: layerRule.layerName, + geometryType: layerRule.geometryType, + editableYn: "Y", + allowedTools: layerRule.allowedTools, + previewColors: layerRule.previewColors, + }); + }); + } + + return Array.from(results.values()); +}; + +// 지도에서 선택한 OpenLayers Feature의 현재 geometry를 저장 payload 형태로 스냅샷한다. +const toGeometryObject = (featureData: any) => { + if (!featureData || typeof featureData.getGeometry !== "function") return null; + + try { + return new GeoJSON().writeGeometryObject(featureData.getGeometry(), { + featureProjection: FLOW_DIRECTION_SRS, + dataProjection: FLOW_DIRECTION_SRS, + }) as Record; + } catch (error) { + console.error("GIS 편집 geometry 변환 실패:", error); + return null; + } +}; + +// 저장 시점의 지도 범위를 함께 남겨 BE가 편집 세션 컨텍스트를 해석할 수 있게 한다. +const buildBbox = (map: any) => { + try { + const size = map?.getSize?.(); + const view = map?.getView?.(); + if (!size || !view?.calculateExtent) return null; + return view.calculateExtent(size).map((value: number) => value.toFixed(3)).join(","); + } catch (error) { + console.error("GIS 편집 BBOX 계산 실패:", error); + return null; + } +}; + +const getFeatureLayerId = (featureData: any) => { + if (typeof featureData?.get === "function") { + const previewLayerId = normalizeLayerIdCandidate(featureData.get("layerId")); + if (previewLayerId) return previewLayerId; + } + if (typeof featureData?.getId === "function") { + return normalizeLayerIdCandidate(featureData.getId()); + } + if (typeof featureData?.id_ === "string") { + return normalizeLayerIdCandidate(featureData.id_); + } + return normalizeLayerIdCandidate(featureData?.lyrTypeNm ?? featureData?.lyrTyNm ?? ""); +}; + +const getFeatureObjectId = (featureData: any) => + String( + featureData?.get?.("objectId") ?? + featureData?.values_?.fclt_mng_no ?? + featureData?.values_?.swtrmt_fclt_cd ?? + featureData?.fcltMngNo ?? + featureData?.fid ?? + "", + ).trim(); + +const createChangeId = (prefix: string, sequence: number) => { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return `${prefix}-${crypto.randomUUID()}`; + } + return `${prefix}-${Date.now()}-${sequence}`; +}; + +export const useGisEdit = (treeData: any[]) => { + const editableLayers = useMemo(() => collectEditableLayers(treeData), [treeData]); + // GIS 편집 모드 on/off는 우측 분석 메뉴의 GIS 편집 버튼과 직접 연결된다. + const [isEditMode, setIsEditMode] = useState(false); + // 현재 선택 레이어를 기준으로 허용 도구와 저장 payload의 layerId를 결정한다. + const [selectedLayerId, setSelectedLayerId] = useState(""); + // 1차 구현에서는 activeTool과 지도 선택 이벤트를 조합해 변경분을 로컬 세션에 누적한다. + const [activeTool, setActiveTool] = useState(null); + // 저장 전까지의 변경분은 FE 로컬 편집 세션으로만 유지하고 BE API 연결 전 payload 검토 근거로 사용한다. + const [changes, setChanges] = useState([]); + const [statusMessage, setStatusMessage] = useState( + "GIS편집 버튼을 켜면 레이어를 선택하고 편집 도구를 사용할 수 있습니다.", + ); + // 실제 저장 대신 마지막으로 조립한 payload를 세션 패널에서 미리보기로 노출한다. + const [lastSavedPayload, setLastSavedPayload] = useState(null); + const [pendingInteraction, setPendingInteraction] = useState(null); + const [focusedChangeId, setFocusedChangeId] = useState(null); + const changeSequenceRef = useRef(0); + const latestSelectionRef = useRef<{ signature: string; timestamp: number } | null>(null); + + const selectedLayer = useMemo( + () => editableLayers.find((layer) => layer.layerId === selectedLayerId) ?? null, + [editableLayers, selectedLayerId], + ); + + const canUseTools = Boolean(selectedLayer); + const canSave = changes.length > 0 && Boolean(selectedLayer); + + const appendChange = useCallback((change: GisEditChange) => { + setLastSavedPayload(null); + setFocusedChangeId(change.changeId); + setChanges((previous) => [change, ...previous]); + }, []); + + const focusChange = useCallback((changeId: string) => { + const targetChange = changes.find((change) => change.changeId === changeId); + if (!targetChange) return; + + setFocusedChangeId(changeId); + if (targetChange.editType === "MOVE" || targetChange.editType === "VERTEX_EDIT") { + setPendingInteraction({ changeId, editType: targetChange.editType }); + } else { + setPendingInteraction(null); + } + setStatusMessage(buildPreviewFocusMessage(targetChange)); + }, [changes]); + + const findExistingObjectChange = useCallback(( + editType: GisEditTool, + layerId: string, + objectId: string, + ) => + changes.find( + (change) => + change.editType === editType && + change.layerId === layerId && + String(change.objectId ?? "") === objectId, + ) ?? null, + [changes]); + + // 편집 모드를 끄더라도 작업 중 세션을 잃지 않도록 변경분은 유지하고 도구 상태만 정리한다. + const toggleEditMode = useCallback(() => { + setIsEditMode((previous) => { + const next = !previous; + setActiveTool(null); + setPendingInteraction(null); + setFocusedChangeId(null); + latestSelectionRef.current = null; + setStatusMessage( + next + ? "편집 대상 레이어를 선택한 뒤 지도에서 편집 작업을 진행하세요." + : "GIS 편집 모드를 종료했습니다. 편집 세션은 유지됩니다.", + ); + return next; + }); + }, []); + + const selectLayer = useCallback((layerId: string) => { + setSelectedLayerId(layerId); + setActiveTool(null); + setPendingInteraction(null); + setFocusedChangeId(null); + latestSelectionRef.current = null; + setLastSavedPayload(null); + if (!layerId) { + setStatusMessage("편집 대상 레이어를 선택하세요."); + return; + } + + const layer = editableLayers.find((item) => item.layerId === layerId); + if (!layer) { + setStatusMessage("선택한 레이어 정보를 찾을 수 없습니다."); + return; + } + + setStatusMessage( + `${layer.layerName} 레이어를 편집 대상으로 설정했습니다. 추가 또는 기존 객체 선택 기반 편집을 진행하세요.`, + ); + }, [editableLayers]); + + // ADD는 즉시 로컬 초안을 만들고, 나머지 도구는 다음 지도 선택을 편집 세션 추가 이벤트로 해석한다. + const activateTool = useCallback((tool: GisEditTool, map: any) => { + if (!selectedLayer) { + setStatusMessage("레이어를 먼저 선택해야 편집 도구를 사용할 수 있습니다."); + return; + } + + if (!selectedLayer.allowedTools.includes(tool)) { + setStatusMessage(`${selectedLayer.layerName} 레이어에서는 ${tool} 도구를 사용할 수 없습니다.`); + return; + } + + setActiveTool(tool); + setPendingInteraction(null); + latestSelectionRef.current = null; + + if (tool === "ADD") { + const addGuideMessage = + selectedLayer.geometryType === "Point" + ? `${selectedLayer.layerName} 신규 위치를 지도에서 클릭하세요.` + : selectedLayer.geometryType === "LineString" + ? `${selectedLayer.layerName} 선형을 지도에서 연속 클릭으로 입력한 뒤 더블클릭으로 마무리하세요.` + : `${selectedLayer.layerName} 영역을 지도에서 연속 클릭으로 입력한 뒤 더블클릭으로 마무리하세요.`; + setStatusMessage(addGuideMessage); + return; + } + + const toolLabel = tool === "MOVE" + ? "이동" + : tool === "VERTEX_EDIT" + ? "꼭지점편집" + : "삭제"; + const vertexGuideMessage = + tool === "VERTEX_EDIT" + ? `지도에서 ${selectedLayer.layerName} 대상 객체를 클릭하면 실꼭지점은 주황색, 새 점 삽입용 midpoint는 흰색으로 표시됩니다.` + : null; + setStatusMessage( + vertexGuideMessage ?? + `지도에서 ${selectedLayer.layerName} 대상 객체를 클릭하면 ${toolLabel} 변경분을 편집 세션에 추가합니다.`, + ); + }, [appendChange, selectedLayer]); + + const registerAddGeometry = useCallback((geometry: Record | null) => { + if (!selectedLayer || !geometry) { + setStatusMessage("추가 geometry를 확인하지 못했습니다."); + return null; + } + + changeSequenceRef.current += 1; + const clientTempId = `NEW-${changeSequenceRef.current}`; + const changeId = createChangeId(`ADD-${selectedLayer.layerId}`, changeSequenceRef.current); + + appendChange({ + changeId, + editType: "ADD", + layerId: selectedLayer.layerId, + layerName: selectedLayer.layerName, + objectId: clientTempId, + objectLabel: clientTempId, + beforeGeometry: null, + afterGeometry: geometry, + summary: `${selectedLayer.layerName} 신규 객체를 지도에서 직접 입력했습니다.`, + createdAt: new Date().toISOString(), + source: "MAP_DRAW", + }); + setStatusMessage( + `${selectedLayer.layerName} 신규 객체를 추가했습니다. 계속 추가하려면 지도에서 다음 형상을 입력하세요.`, + ); + return changeId; + }, [appendChange, selectedLayer]); + + // 편집 모드 중 지도에서 선택한 객체를 현재 activeTool 기준 변경분으로 변환한다. + const registerFeatureSelection = useCallback((featureData: any) => { + if (!isEditMode) return { handled: false }; + + const previewChangeId = String(featureData?.get?.("changeId") ?? "").trim(); + if (previewChangeId) { + const previewChange = changes.find((change) => change.changeId === previewChangeId); + if (!previewChange) return { handled: true }; + + focusChange(previewChangeId); + return { + handled: true, + changeId: previewChangeId, + editType: previewChange.editType, + }; + } + + if (!activeTool || !selectedLayer) return { handled: false }; + if (activeTool === "ADD") return { handled: true }; + + const featureLayerId = getFeatureLayerId(featureData); + if (featureLayerId !== selectedLayer.layerId) { + setStatusMessage(`현재 선택 레이어는 ${selectedLayer.layerName}입니다. 같은 레이어의 객체를 선택하세요.`); + return { handled: true }; + } + + const objectId = getFeatureObjectId(featureData); + if (!objectId) { + setStatusMessage("선택 객체의 관리번호를 찾을 수 없어 편집 세션에 추가하지 못했습니다."); + return { handled: true }; + } + + const selectionSignature = `${activeTool}:${selectedLayer.layerId}:${objectId}`; + const now = Date.now(); + if ( + latestSelectionRef.current?.signature === selectionSignature && + now - latestSelectionRef.current.timestamp < 400 + ) { + return { handled: true }; + } + latestSelectionRef.current = { signature: selectionSignature, timestamp: now }; + + const existingChange = findExistingObjectChange(activeTool, selectedLayer.layerId, objectId); + if (existingChange) { + focusChange(existingChange.changeId); + return { + handled: true, + changeId: existingChange.changeId, + editType: existingChange.editType, + }; + } + + const geometryObject = toGeometryObject(featureData); + const geometryType = normalizeGeometryType(geometryObject?.type); + const nextGeometry = + activeTool === "MOVE" + ? geometryObject + : activeTool === "VERTEX_EDIT" + ? geometryObject + : activeTool === "DELETE" + ? null + : geometryObject; + const summaryMap: Record = { + MOVE: `${selectedLayer.layerName} ${objectId} 객체를 선택했습니다. 지도에서 드래그해 위치를 조정하세요.`, + VERTEX_EDIT: `${selectedLayer.layerName} ${objectId} 객체를 선택했습니다. 주황색 실꼭지점을 드래그하거나 흰색 midpoint를 끌어 새 꼭지점을 추가하세요.`, + DELETE: `${selectedLayer.layerName} ${objectId} 객체를 임시 삭제 대상으로 표시했습니다.`, + ADD: `${selectedLayer.layerName} ${objectId} 객체를 추가했습니다.`, + }; + changeSequenceRef.current += 1; + const changeId = createChangeId(`${activeTool}-${objectId}`, changeSequenceRef.current); + + appendChange({ + changeId, + editType: activeTool, + layerId: selectedLayer.layerId, + layerName: selectedLayer.layerName, + objectId, + objectLabel: objectId, + beforeGeometry: geometryObject, + afterGeometry: nextGeometry, + summary: + activeTool === "VERTEX_EDIT" && geometryType === "Point" + ? `${selectedLayer.layerName} ${objectId}는 점형 객체여서 꼭지점편집 대신 geometry 검토 대상으로 기록했습니다.` + : summaryMap[activeTool], + createdAt: new Date().toISOString(), + source: "MAP_SELECTION", + }); + setStatusMessage(summaryMap[activeTool]); + setFocusedChangeId(changeId); + if (activeTool === "MOVE" || activeTool === "VERTEX_EDIT") { + if (activeTool === "VERTEX_EDIT" && geometryType === "Point") { + setPendingInteraction(null); + } else { + setPendingInteraction({ changeId, editType: activeTool }); + } + } else { + setPendingInteraction(null); + } + return { handled: true, changeId, editType: activeTool }; + }, [activeTool, appendChange, findExistingObjectChange, focusChange, isEditMode, selectedLayer]); + + // 저장 API가 붙기 전까지는 실제 전송 대신 최종 payload를 조립해 검증 패널에 남긴다. + const save = useCallback((map: any) => { + if (!selectedLayer || changes.length === 0) { + setStatusMessage("저장할 편집 변경분이 없습니다."); + return null; + } + + const payload: GisEditSavePayload = { + screenId: SCREEN_ID, + layerId: selectedLayer.layerId, + layerName: selectedLayer.layerName, + saveTargetType: "VERSION", + view: { + srs: FLOW_DIRECTION_SRS, + bbox: buildBbox(map), + }, + changes: changes.map((change) => ({ + editType: change.editType, + objectId: change.editType === "ADD" ? undefined : change.objectId, + clientTempId: change.editType === "ADD" ? change.objectId : undefined, + beforeGeometry: change.beforeGeometry, + afterGeometry: change.editType === "DELETE" ? null : change.afterGeometry, + geometry: change.editType === "ADD" ? change.afterGeometry : undefined, + })), + }; + + setLastSavedPayload(payload); + setStatusMessage("백엔드 저장 API가 아직 없어 payload 미리보기만 생성했습니다. 이 구조를 기준으로 BE와 연동하면 됩니다."); + return payload; + }, [changes, selectedLayer]); + + const removeLastChange = useCallback(() => { + setLastSavedPayload(null); + setChanges((previous) => { + if (previous.length === 0) return previous; + const [removedChange, ...rest] = previous; + if (!removedChange) return previous; + if (focusedChangeId === removedChange.changeId) { + setFocusedChangeId(rest[0]?.changeId ?? null); + } + if (pendingInteraction?.changeId === removedChange.changeId) { + setPendingInteraction(null); + } + latestSelectionRef.current = null; + return rest; + }); + setStatusMessage("가장 최근 편집 변경분을 세션에서 제거했습니다."); + }, [focusedChangeId, pendingInteraction]); + + const clearChanges = useCallback(() => { + setLastSavedPayload(null); + setChanges([]); + setActiveTool(null); + setPendingInteraction(null); + setFocusedChangeId(null); + latestSelectionRef.current = null; + setStatusMessage("편집 세션을 초기화했습니다."); + }, []); + + const updateChangeGeometry = useCallback(( + changeId: string, + afterGeometry: Record | null, + summary: string, + ) => { + setLastSavedPayload(null); + setChanges((previous) => + previous.map((change) => + change.changeId === changeId + ? { + ...change, + afterGeometry, + summary, + } + : change, + ), + ); + setFocusedChangeId(changeId); + setStatusMessage(summary); + }, []); + + const clearPendingInteraction = useCallback(() => { + setPendingInteraction(null); + }, []); + + const announceStatus = useCallback((message: string) => { + setStatusMessage(message); + }, []); + + return { + isEditMode, + editableLayers, + selectedLayer, + selectedLayerId, + activeTool, + changes, + statusMessage, + canUseTools, + canSave, + lastSavedPayload, + pendingInteraction, + focusedChangeId, + pendingApiMessage: + "현재 구현은 FE 로컬 편집 세션과 저장 payload 미리보기까지 포함합니다. BE 저장 API(`/api/v1/map/gisEditSessions/save`) 연결 전까지는 실제 저장 대신 payload를 검토합니다.", + canUndo: changes.length > 0, + toggleEditMode, + selectLayer, + activateTool, + registerAddGeometry, + registerFeatureSelection, + save, + removeLastChange, + clearChanges, + updateChangeGeometry, + clearPendingInteraction, + focusChange, + announceStatus, + }; +}; diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 23a6eefc7..2a2b6f0a0 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -21,13 +21,20 @@ import dynamic from "next/dynamic"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { usePathname } from "next/navigation"; +import Collection from "ol/Collection.js"; +import Feature from "ol/Feature.js"; import GeoJSON from "ol/format/GeoJSON.js"; +import Draw from "ol/interaction/Draw.js"; +import Translate from "ol/interaction/Translate.js"; +import Fill from "ol/style/Fill.js"; import Point from "ol/geom/Point.js"; import VectorLayer from "ol/layer/Vector.js"; import { unByKey } from "ol/Observable.js"; import VectorSource from "ol/source/Vector.js"; +import CircleStyle from "ol/style/Circle.js"; import Stroke from "ol/style/Stroke.js"; import Style from "ol/style/Style.js"; +import Text from "ol/style/Text.js"; import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions"; @@ -42,6 +49,9 @@ import useSearchModalStore from "@/store/useSearchModalStore"; import useStore from "@/store/useStore"; import AgedPipeLayerSettingPanel from "./components/agedPipe/AgedPipeLayerSettingPanel"; +import GisEditToolbar from "./components/gisEdit/GisEditToolbar"; +import { useGisEdit } from "./components/gisEdit/useGisEdit"; +import type { GisEditSessionSnapshot } from "./components/gisEdit/gisEditTypes"; import { PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE, type PipeAnalysisDetailRow, @@ -92,6 +102,8 @@ const FLOW_DIRECTION_DEFAULT_MIN_ZOOM = 13; const FLOW_DIRECTION_DEFAULT_COLOR = "rgba(40, 90, 180, 0.84)"; const FLOW_DIRECTION_NETWORK_CORRECTION_COLOR = "rgba(0, 168, 132, 0.95)"; const FLOW_DIRECTION_BBOX_BUFFER_RATIO = 0.35; +const GIS_EDIT_PREVIEW_LAYER_ID = "SN_GIS_EDIT_PREVIEW"; +const GIS_EDIT_VERTEX_LAYER_ID = "SN_GIS_EDIT_VERTEX_HANDLES"; type FlowDirectionUsageStyle = { color: string; @@ -206,6 +218,625 @@ const getFlowDirectionFeatureCacheKey = (feature: any) => { return `geom:${String(usageCode).trim()}:${geometryKey}`; }; +const getGeometryExtentCenterPoint = (geometry: any) => { + const extent = geometry?.getExtent?.(); + if (!extent || extent.length < 4) return null; + + const [minX = 0, minY = 0, maxX = 0, maxY = 0] = extent; + return new Point([(minX + maxX) / 2, (minY + maxY) / 2]); +}; + +// MultiPolygon/MultiLineString preview 라벨이 각 조각마다 반복되지 않도록 대표 지점 1곳에만 텍스트를 배치한다. +const getGisEditPreviewLabelGeometry = (geometry: any) => { + const geometryType = geometry?.getType?.(); + if (!geometryType) return null; + + if (geometryType === "Point") { + return geometry; + } + + if (geometryType === "Polygon") { + return geometry.getInteriorPoint?.() ?? getGeometryExtentCenterPoint(geometry); + } + + if (geometryType === "MultiPolygon") { + const polygons = geometry.getPolygons?.() ?? []; + if (polygons.length > 0) { + const representativePolygon = polygons.reduce((largest: any, current: any) => + (current?.getArea?.() ?? 0) > (largest?.getArea?.() ?? 0) ? current : largest, + polygons[0]); + return representativePolygon?.getInteriorPoint?.() ?? getGeometryExtentCenterPoint(representativePolygon); + } + return getGeometryExtentCenterPoint(geometry); + } + + return getGeometryExtentCenterPoint(geometry); +}; + +const createGisEditPreviewStyle = (feature: any) => { + const editType = String(feature.get("editType") ?? ""); + const geometry = feature.getGeometry?.(); + const geometryType = geometry?.getType?.(); + const previewLabel = String(feature.get("previewLabel") ?? "").trim(); + const isInteractionTarget = Boolean(feature.get("isInteractionTarget")); + const isDragging = Boolean(feature.get("isDragging")); + const layerPointColor = String(feature.get("layerPointColor") ?? "").trim(); + const layerLineColor = String(feature.get("layerLineColor") ?? "").trim(); + const layerFillColor = String(feature.get("layerFillColor") ?? "").trim(); + const pointColor = editType === "DELETE" + ? "rgba(220, 38, 38, 0.85)" + : editType === "VERTEX_EDIT" + ? "rgba(245, 158, 11, 0.92)" + : editType === "MOVE" + ? layerPointColor || "rgba(37, 99, 235, 0.92)" + : layerPointColor || "rgba(22, 163, 74, 0.92)"; + const lineColor = editType === "DELETE" + ? "rgba(220, 38, 38, 0.9)" + : editType === "VERTEX_EDIT" + ? "rgba(245, 158, 11, 0.95)" + : editType === "MOVE" + ? layerLineColor || "rgba(37, 99, 235, 0.95)" + : layerLineColor || "rgba(22, 163, 74, 0.95)"; + const fillColor = editType === "DELETE" + ? "rgba(220, 38, 38, 0.18)" + : editType === "VERTEX_EDIT" + ? "rgba(245, 158, 11, 0.2)" + : editType === "MOVE" + ? layerFillColor || "rgba(37, 99, 235, 0.18)" + : layerFillColor || "rgba(22, 163, 74, 0.18)"; + const highlightColor = isDragging + ? "rgba(14, 116, 144, 0.98)" + : isInteractionTarget + ? "rgba(15, 23, 42, 0.9)" + : lineColor; + const emphasisWidth = isDragging ? 7 : isInteractionTarget ? 6 : 4; + const textStyle = previewLabel + ? new Text({ + text: previewLabel, + font: "600 12px sans-serif", + fill: new Fill({ color: "#ffffff" }), + backgroundFill: new Fill({ color: highlightColor }), + padding: [3, 6, 3, 6], + offsetY: geometryType === "Point" ? -18 : -12, + overflow: true, + }) + : undefined; + const labelGeometry = previewLabel ? getGisEditPreviewLabelGeometry(geometry) : null; + + if (geometryType === "Point") { + return new Style({ + image: new CircleStyle({ + radius: isInteractionTarget ? 10 : 8, + fill: new Fill({ color: pointColor }), + stroke: new Stroke({ + color: isDragging ? "rgba(255,255,255,1)" : "rgba(255,255,255,0.95)", + width: isInteractionTarget ? 3 : 2, + }), + }), + text: textStyle, + zIndex: 10001, + }); + } + + if (geometryType === "Polygon") { + const polygonStyles: Style[] = [ + new Style({ + fill: new Fill({ + color: isInteractionTarget && editType !== "DELETE" + ? fillColor.replace(/0\.\d+\)$/, "0.28)") + : fillColor, + }), + stroke: new Stroke({ + color: highlightColor, + width: emphasisWidth, + lineDash: editType === "DELETE" ? [8, 6] : undefined, + }), + zIndex: 10001, + }), + ]; + + if (textStyle && labelGeometry) { + polygonStyles.push( + new Style({ + geometry: labelGeometry, + text: textStyle, + zIndex: 10002, + }), + ); + } + + return polygonStyles; + } + + const lineStyles: Style[] = [ + new Style({ + stroke: new Stroke({ + color: highlightColor, + width: emphasisWidth, + lineDash: editType === "DELETE" ? [10, 6] : undefined, + }), + zIndex: 10001, + }), + ]; + + if (textStyle && labelGeometry) { + lineStyles.push( + new Style({ + geometry: labelGeometry, + text: textStyle, + zIndex: 10002, + }), + ); + } + + return lineStyles; +}; + +const GIS_EDIT_VERTEX_HANDLE_STYLE = new Style({ + image: new CircleStyle({ + radius: 10, + fill: new Fill({ color: "rgba(245, 158, 11, 0.98)" }), + stroke: new Stroke({ color: "rgba(255,255,255,0.98)", width: 3 }), + }), + zIndex: 10005, +}); + +const GIS_EDIT_VERTEX_MIDPOINT_STYLE = new Style({ + image: new CircleStyle({ + radius: 6, + fill: new Fill({ color: "rgba(255, 255, 255, 0.98)" }), + stroke: new Stroke({ color: "rgba(245, 158, 11, 0.98)", width: 2.5 }), + }), + zIndex: 10004, +}); + +const GIS_EDIT_VERTEX_DRAGGING_STYLE = new Style({ + image: new CircleStyle({ + radius: 11, + fill: new Fill({ color: "rgba(59, 130, 246, 0.95)" }), + stroke: new Stroke({ color: "rgba(255,255,255,0.98)", width: 3 }), + }), + zIndex: 10006, +}); + +const geometryObjectToKey = (geometry: Record | null) => + geometry ? JSON.stringify(geometry) : ""; + +type VertexHandleMeta = { + kind: "vertex" | "midpoint"; + geometryType: string; + partIndex?: number; + polygonIndex?: number; + ringIndex?: number; + coordIndex: number; +}; + +type VertexHandleCandidate = { + coordinate: number[]; + meta: VertexHandleMeta; +}; + +const cloneCoordinates = (value: T): T => JSON.parse(JSON.stringify(value)) as T; + +const getMidpoint = (start: number[], end: number[]) => [ + (Number(start?.[0] ?? 0) + Number(end?.[0] ?? 0)) / 2, + (Number(start?.[1] ?? 0) + Number(end?.[1] ?? 0)) / 2, +]; + +const buildVertexCoordinateKey = (coordinate: number[]) => + `${Number(coordinate?.[0] ?? 0)}:${Number(coordinate?.[1] ?? 0)}`; + +const getSquaredPixelDistance = ( + map: any, + startCoordinate: number[], + endCoordinate: number[], +) => { + const [startX = 0, startY = 0] = map?.getPixelFromCoordinate?.(startCoordinate) ?? []; + const [endX = 0, endY = 0] = map?.getPixelFromCoordinate?.(endCoordinate) ?? []; + return (endX - startX) ** 2 + (endY - startY) ** 2; +}; + +const getPixelDistanceToSegment = ( + map: any, + coordinate: number[], + startCoordinate: number[], + endCoordinate: number[], +) => { + const [pointX = 0, pointY = 0] = map?.getPixelFromCoordinate?.(coordinate) ?? []; + const [startX = 0, startY = 0] = map?.getPixelFromCoordinate?.(startCoordinate) ?? []; + const [endX = 0, endY = 0] = map?.getPixelFromCoordinate?.(endCoordinate) ?? []; + const deltaX = endX - startX; + const deltaY = endY - startY; + const segmentLengthSquared = deltaX ** 2 + deltaY ** 2; + + if (segmentLengthSquared === 0) { + return Math.hypot(pointX - startX, pointY - startY); + } + + const ratio = Math.max( + 0, + Math.min( + 1, + ((pointX - startX) * deltaX + (pointY - startY) * deltaY) / segmentLengthSquared, + ), + ); + const projectedX = startX + deltaX * ratio; + const projectedY = startY + deltaY * ratio; + return Math.hypot(pointX - projectedX, pointY - projectedY); +}; + +const createVertexHandleFeature = ( + coordinate: number[], + meta: VertexHandleMeta, + metaList: VertexHandleMeta[] = [meta], +) => { + const handleFeature = new Feature({ + geometry: new Point(coordinate), + }); + handleFeature.set("handleKind", meta.kind); + handleFeature.set("handleMeta", meta); + handleFeature.set("handleMetaList", metaList); + handleFeature.setStyle( + meta.kind === "midpoint" ? GIS_EDIT_VERTEX_MIDPOINT_STYLE : GIS_EDIT_VERTEX_HANDLE_STYLE, + ); + return handleFeature; +}; + +const collectLineVertexHandleCandidates = ( + coordinates: number[][], + geometryType: string, + partIndex?: number, +) => { + const candidates: VertexHandleCandidate[] = []; + coordinates.forEach((coordinate, coordIndex) => { + candidates.push({ + coordinate, + meta: { + kind: "vertex", + geometryType, + partIndex, + coordIndex, + }, + }); + }); + return candidates; +}; + +const collectPolygonRingVertexHandleCandidates = ( + coordinates: number[][], + geometryType: string, + polygonIndex?: number, + ringIndex?: number, +) => { + const candidates: VertexHandleCandidate[] = []; + const logicalVertexCount = Math.max(0, coordinates.length - 1); + for (let coordIndex = 0; coordIndex < logicalVertexCount; coordIndex += 1) { + const coordinate = coordinates[coordIndex]; + const nextCoordinate = coordinates[(coordIndex + 1) % logicalVertexCount] ?? coordinate; + if (!coordinate || !nextCoordinate) continue; + candidates.push({ + coordinate, + meta: { + kind: "vertex", + geometryType, + polygonIndex, + ringIndex, + coordIndex, + }, + }); + } + return candidates; +}; + +const collectVertexHandleCandidates = (geometry: any) => { + const geometryType = geometry?.getType?.(); + if (!geometryType) return []; + + if (geometryType === "LineString") { + return collectLineVertexHandleCandidates(geometry.getCoordinates?.() ?? [], geometryType); + } + + if (geometryType === "MultiLineString") { + const parts = geometry.getCoordinates?.() ?? []; + return parts.flatMap((part: number[][], partIndex: number) => + collectLineVertexHandleCandidates(part, geometryType, partIndex), + ); + } + + if (geometryType === "Polygon") { + const rings = geometry.getCoordinates?.() ?? []; + return rings.flatMap((ring: number[][], ringIndex: number) => + collectPolygonRingVertexHandleCandidates(ring, geometryType, undefined, ringIndex), + ); + } + + if (geometryType === "MultiPolygon") { + const polygons = geometry.getCoordinates?.() ?? []; + return polygons.flatMap((polygon: number[][][], polygonIndex: number) => + polygon.flatMap((ring: number[][], ringIndex: number) => + collectPolygonRingVertexHandleCandidates(ring, geometryType, polygonIndex, ringIndex), + ), + ); + } + + return []; +}; + +const buildVertexHandleFeatures = (geometry: any): Feature[] => { + const groupedCandidates = new globalThis.Map(); + + collectVertexHandleCandidates(geometry).forEach((candidate: VertexHandleCandidate) => { + const key = buildVertexCoordinateKey(candidate.coordinate); + const existing = groupedCandidates.get(key); + if (existing) { + existing.metaList.push(candidate.meta); + return; + } + groupedCandidates.set(key, { + coordinate: candidate.coordinate, + metaList: [candidate.meta], + }); + }); + + return Array.from(groupedCandidates.values()).map(({ coordinate, metaList }: { coordinate: number[]; metaList: VertexHandleMeta[] }) => + createVertexHandleFeature(coordinate, metaList[0]!, metaList), + ); +}; + +const collectLineMidpointCandidates = ( + coordinates: number[][], + geometryType: string, + partIndex?: number, +) => { + const candidates: VertexHandleCandidate[] = []; + for (let coordIndex = 0; coordIndex < coordinates.length - 1; coordIndex += 1) { + const startCoordinate = coordinates[coordIndex]; + const endCoordinate = coordinates[coordIndex + 1]; + if (!startCoordinate || !endCoordinate) continue; + candidates.push({ + coordinate: getMidpoint(startCoordinate, endCoordinate), + meta: { + kind: "midpoint", + geometryType, + partIndex, + coordIndex, + }, + }); + } + return candidates; +}; + +const collectPolygonRingMidpointCandidates = ( + coordinates: number[][], + geometryType: string, + polygonIndex?: number, + ringIndex?: number, +) => { + const candidates: VertexHandleCandidate[] = []; + const logicalVertexCount = Math.max(0, coordinates.length - 1); + for (let coordIndex = 0; coordIndex < logicalVertexCount; coordIndex += 1) { + const startCoordinate = coordinates[coordIndex]; + const endCoordinate = coordinates[(coordIndex + 1) % logicalVertexCount]; + if (!startCoordinate || !endCoordinate) continue; + candidates.push({ + coordinate: getMidpoint(startCoordinate, endCoordinate), + meta: { + kind: "midpoint", + geometryType, + polygonIndex, + ringIndex, + coordIndex, + }, + }); + } + return candidates; +}; + +const collectMidpointCandidates = (geometry: any) => { + const geometryType = geometry?.getType?.(); + if (!geometryType) return []; + + if (geometryType === "LineString") { + return collectLineMidpointCandidates(geometry.getCoordinates?.() ?? [], geometryType); + } + + if (geometryType === "MultiLineString") { + const parts = geometry.getCoordinates?.() ?? []; + return parts.flatMap((part: number[][], partIndex: number) => + collectLineMidpointCandidates(part, geometryType, partIndex), + ); + } + + if (geometryType === "Polygon") { + const rings = geometry.getCoordinates?.() ?? []; + return rings.flatMap((ring: number[][], ringIndex: number) => + collectPolygonRingMidpointCandidates(ring, geometryType, undefined, ringIndex), + ); + } + + if (geometryType === "MultiPolygon") { + const polygons = geometry.getCoordinates?.() ?? []; + return polygons.flatMap((polygon: number[][][], polygonIndex: number) => + polygon.flatMap((ring: number[][], ringIndex: number) => + collectPolygonRingMidpointCandidates(ring, geometryType, polygonIndex, ringIndex), + ), + ); + } + + return []; +}; + +const getNearestVertexPixelDistance = ( + map: any, + geometry: any, + coordinate: number[], +): number => { + return buildVertexHandleFeatures(geometry).reduce((minimumDistance: number, feature: Feature) => { + const vertexCoordinate = (feature.getGeometry?.() as any)?.getCoordinates?.(); + if (!Array.isArray(vertexCoordinate)) return minimumDistance; + return Math.min( + minimumDistance, + Math.sqrt(getSquaredPixelDistance(map, coordinate, vertexCoordinate)), + ); + }, Number.POSITIVE_INFINITY); +}; + +const getSegmentCoordinatesFromMeta = ( + geometry: any, + meta: VertexHandleMeta, +): [number[], number[]] | null => { + const geometryType = geometry?.getType?.(); + if (!geometryType) return null; + + if (geometryType === "LineString") { + const coordinates = geometry.getCoordinates?.() ?? []; + return [coordinates[meta.coordIndex], coordinates[meta.coordIndex + 1]]; + } + + if (geometryType === "MultiLineString") { + const parts = geometry.getCoordinates?.() ?? []; + const targetPart = parts[meta.partIndex ?? 0] ?? []; + return [targetPart[meta.coordIndex], targetPart[meta.coordIndex + 1]]; + } + + if (geometryType === "Polygon") { + const rings = geometry.getCoordinates?.() ?? []; + const targetRing = rings[meta.ringIndex ?? 0] ?? []; + const logicalVertexCount = Math.max(0, targetRing.length - 1); + if (logicalVertexCount === 0) return null; + return [targetRing[meta.coordIndex], targetRing[(meta.coordIndex + 1) % logicalVertexCount]]; + } + + if (geometryType === "MultiPolygon") { + const polygons = geometry.getCoordinates?.() ?? []; + const targetPolygon = polygons[meta.polygonIndex ?? 0] ?? []; + const targetRing = targetPolygon[meta.ringIndex ?? 0] ?? []; + const logicalVertexCount = Math.max(0, targetRing.length - 1); + if (logicalVertexCount === 0) return null; + return [targetRing[meta.coordIndex], targetRing[(meta.coordIndex + 1) % logicalVertexCount]]; + } + + return null; +}; + +const findHoveredMidpointCandidate = ( + map: any, + geometry: any, + coordinate: number[], + tolerancePx = 18, +): VertexHandleCandidate | null => { + const nearestVertexDistance = getNearestVertexPixelDistance(map, geometry, coordinate); + if (nearestVertexDistance <= 14) { + return null; + } + + const midpointCandidates = collectMidpointCandidates(geometry); + let bestCandidate: VertexHandleCandidate | null = null; + let minimumDistance = Number.POSITIVE_INFINITY; + + midpointCandidates.forEach((candidate: VertexHandleCandidate) => { + const segmentCoordinates = getSegmentCoordinatesFromMeta(geometry, candidate.meta); + if (!segmentCoordinates) return; + const [startCoordinate, endCoordinate] = segmentCoordinates; + if (!Array.isArray(startCoordinate) || !Array.isArray(endCoordinate)) return; + + const segmentDistance = getPixelDistanceToSegment( + map, + coordinate, + startCoordinate, + endCoordinate, + ); + if (segmentDistance < minimumDistance) { + minimumDistance = segmentDistance; + bestCandidate = candidate; + } + }); + + return minimumDistance <= tolerancePx ? bestCandidate : null; +}; + +const updateLineCoordinates = ( + coordinates: number[][], + meta: VertexHandleMeta, + nextCoordinate: number[], +) => { + const nextCoordinates = cloneCoordinates(coordinates); + if (meta.kind === "vertex") { + nextCoordinates[meta.coordIndex] = nextCoordinate; + } else { + nextCoordinates.splice(meta.coordIndex + 1, 0, nextCoordinate); + } + return nextCoordinates; +}; + +const updatePolygonRingCoordinates = ( + coordinates: number[][], + meta: VertexHandleMeta, + nextCoordinate: number[], +) => { + const nextCoordinates = cloneCoordinates(coordinates); + const logicalVertexCount = Math.max(0, nextCoordinates.length - 1); + if (logicalVertexCount === 0) return nextCoordinates; + + if (meta.kind === "vertex") { + nextCoordinates[meta.coordIndex] = nextCoordinate; + if (meta.coordIndex === 0) { + nextCoordinates[logicalVertexCount] = nextCoordinate; + } + } else { + const insertIndex = meta.coordIndex === logicalVertexCount - 1 + ? logicalVertexCount + : meta.coordIndex + 1; + nextCoordinates.splice(insertIndex, 0, nextCoordinate); + } + + return nextCoordinates; +}; + +const applyVertexHandleMove = ( + geometry: any, + meta: VertexHandleMeta, + nextCoordinate: number[], +) => { + const geometryType = geometry?.getType?.(); + if (!geometryType) return; + + if (geometryType === "LineString") { + geometry.setCoordinates( + updateLineCoordinates(geometry.getCoordinates?.() ?? [], meta, nextCoordinate), + ); + return; + } + + if (geometryType === "MultiLineString") { + const parts = cloneCoordinates(geometry.getCoordinates?.() ?? []); + const targetPartIndex = meta.partIndex ?? 0; + parts[targetPartIndex] = updateLineCoordinates(parts[targetPartIndex] ?? [], meta, nextCoordinate); + geometry.setCoordinates(parts); + return; + } + + if (geometryType === "Polygon") { + const rings = cloneCoordinates(geometry.getCoordinates?.() ?? []); + const targetRingIndex = meta.ringIndex ?? 0; + rings[targetRingIndex] = updatePolygonRingCoordinates(rings[targetRingIndex] ?? [], meta, nextCoordinate); + geometry.setCoordinates(rings); + return; + } + + if (geometryType === "MultiPolygon") { + const polygons = cloneCoordinates(geometry.getCoordinates?.() ?? []); + const targetPolygonIndex = meta.polygonIndex ?? 0; + const targetRingIndex = meta.ringIndex ?? 0; + polygons[targetPolygonIndex][targetRingIndex] = updatePolygonRingCoordinates( + polygons[targetPolygonIndex][targetRingIndex] ?? [], + meta, + nextCoordinate, + ); + geometry.setCoordinates(polygons); + } +}; + // 관종별 원본 레이어 색을 유하방향 선/화살표에 재사용할 때 투명도만 조정한다. const withAlpha = (color: string | null | undefined, alpha: number) => { const resolvedColor = String(color ?? "").trim(); @@ -573,9 +1204,12 @@ const Map = () => { const [isAgedPipeListLoading, setIsAgedPipeListLoading] = useState(false); const [agedPipeDownloadProgressText, setAgedPipeDownloadProgressText] = useState(""); const { ctpv, sgg } = useStore(); + // GIS 편집은 map/page.tsx 내부 기존 지도 상태를 재사용하되, 세부 상태와 payload 조립은 별도 hook으로 분리한다. + const gisEdit = useGisEdit(treeData); const shouldFocusAgedPipeOnNextSelectRef = useRef(false); const agedPipePopupWindowRef = useRef(null); - const pipeAnalysisPopupWindowRef = useRef(null); + const pipeAnalysisPopupWindowRef = useRef(null); + const gisEditSessionPopupWindowRef = useRef(null); // 유하방향 레이어 체크 여부를 React 렌더링 흐름에 반영하기 위한 state이다. const [isFlowDirectionChecked, setIsFlowDirectionChecked] = useState(false); // 체크된 유하방향 트리 노드의 레이어 설정을 보관해 API 조회와 vector layer 스타일에 사용한다. @@ -604,6 +1238,53 @@ const Map = () => { const flowDirectionAbortControllerRef = useRef(null); // 같은 장애 상황에서 alert가 반복 노출되지 않도록 실패 안내 여부를 기억한다. const flowDirectionFailureAlertedRef = useRef(false); + const gisEditPreviewLayerRef = useRef(null); + const gisEditPreviewSourceRef = useRef(null); + const gisEditVertexLayerRef = useRef | null>(null); + const gisEditVertexSourceRef = useRef(null); + const gisEditDrawSourceRef = useRef(null); + const gisEditDrawRef = useRef(null); + const gisEditTranslateRef = useRef(null); + const gisEditModifyRef = useRef(null); + const gisEditSessionSnapshot = useMemo(() => ({ + isEditMode: gisEdit.isEditMode, + selectedLayerName: gisEdit.selectedLayer?.layerName ?? "", + activeTool: gisEdit.activeTool, + changes: gisEdit.changes, + lastSavedPayload: gisEdit.lastSavedPayload, + canUndo: gisEdit.canUndo, + focusedChangeId: gisEdit.focusedChangeId, + }), [ + gisEdit.activeTool, + gisEdit.canUndo, + gisEdit.changes, + gisEdit.focusedChangeId, + gisEdit.isEditMode, + gisEdit.lastSavedPayload, + gisEdit.selectedLayer, + ]); + const syncGisEditSessionPopup = useCallback(() => { + const popup = gisEditSessionPopupWindowRef.current; + if (!popup || popup.closed) { + gisEditSessionPopupWindowRef.current = null; + return; + } + + popup.postMessage( + { + type: WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_SYNC, + payload: gisEditSessionSnapshot, + }, + window.location.origin, + ); + }, [gisEditSessionSnapshot]); + + const setGisEditPreviewCursor = useCallback((cursor: string) => { + const targetElement = gisMaps.current?.getTargetElement?.(); + if (targetElement?.style) { + targetElement.style.cursor = cursor; + } + }, []); useEffect(() => { fetchData(); @@ -656,6 +1337,69 @@ const Map = () => { }; }, []); + useEffect(() => { + const shouldKeepPopupOpen = gisEdit.isEditMode || gisEdit.changes.length > 0; + if (shouldKeepPopupOpen) return; + + const popup = gisEditSessionPopupWindowRef.current; + if (!popup || popup.closed) { + gisEditSessionPopupWindowRef.current = null; + return; + } + + popup.close(); + gisEditSessionPopupWindowRef.current = null; + }, [gisEdit.changes.length, gisEdit.isEditMode]); + + useEffect(() => { + return () => { + const popup = gisEditSessionPopupWindowRef.current; + if (popup && !popup.closed) { + popup.close(); + } + gisEditSessionPopupWindowRef.current = null; + }; + }, []); + + useEffect(() => { + if (!(gisEdit.isEditMode || gisEdit.changes.length > 0)) return; + syncGisEditSessionPopup(); + }, [gisEdit.changes.length, gisEdit.isEditMode, 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_FOCUS_CHANGE) { + const changeId = String(data.payload?.changeId ?? "").trim(); + if (changeId) { + gisEdit.focusChange(changeId); + } + 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); + }; + }, [gisEdit, syncGisEditSessionPopup]); + const fetchData = async () => { try { const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK"); @@ -943,6 +1687,19 @@ const Map = () => { // SearchModal, GisContent 등에서 호출 const onFeatureSelect = (data: any) => { + // 편집 모드 + activeTool 조합일 때는 상세 팝업 대신 편집 세션 누적을 우선한다. + const editSelection = gisEdit.registerFeatureSelection(data); + if (editSelection.handled) { + // 편집 대상으로 넘긴 뒤에는 기존 상세 팝업/원본 선택 강조를 정리해 + // 실제 편집 대상인 preview feature가 시각적으로 가려지지 않게 한다. + const select = GisApp.Module.select as any; + select?.resetSelectedFeature?.(); + setFeatureData(null); + setIsOpen(false); + setIsSearchModalOpen(false); + return; + } + let map = gisMaps.current; if (!map) { console.warn("지도가 초기화되지 않았습니다."); @@ -1353,6 +2110,218 @@ const Map = () => { } }, []); + 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; + window.focus?.(); + return popup; + }, []); + + const ensureGisEditPreviewLayer = useCallback(() => { + const map = gisMaps.current; + if (!map) return null; + + if (gisEditPreviewLayerRef.current && gisEditPreviewSourceRef.current) { + return gisEditPreviewSourceRef.current; + } + + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + zIndex: 10001, + updateWhileAnimating: true, + updateWhileInteracting: true, + }); + + layer.set("id", GIS_EDIT_PREVIEW_LAYER_ID); + layer.set("lyrNm", "GIS 편집 미리보기"); + layer.set("lyrTyNm", GIS_EDIT_PREVIEW_LAYER_ID); + // MOVE/VERTEX_EDIT는 preview 객체를 실제 상호작용 대상으로 써야 하므로 클릭 가능 상태를 유지한다. + // 이전에 N으로 바꿨다가 점/선/면 시설물 전반에서 이동 드래그가 끊기는 회귀가 발생했다. + layer.set("clickPsblYn", "Y"); + + const core = GisApp.Module.core as any; + core?.deleteLayer?.(GIS_EDIT_PREVIEW_LAYER_ID); + map.addLayer(layer); + gisEditPreviewLayerRef.current = layer; + gisEditPreviewSourceRef.current = source; + return source; + }, []); + + const clearGisEditPreviewLayer = useCallback(() => { + gisEditPreviewSourceRef.current?.clear(); + setGisEditPreviewCursor(""); + }, [setGisEditPreviewCursor]); + + const ensureGisEditVertexLayer = useCallback(() => { + const map = gisMaps.current; + if (!map) return null; + + if (gisEditVertexLayerRef.current && gisEditVertexSourceRef.current) { + return gisEditVertexSourceRef.current; + } + + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + zIndex: 10005, + updateWhileAnimating: true, + updateWhileInteracting: true, + }); + + layer.set("id", GIS_EDIT_VERTEX_LAYER_ID); + layer.set("lyrNm", "GIS 편집 꼭지점"); + layer.set("lyrTyNm", GIS_EDIT_VERTEX_LAYER_ID); + layer.set("clickPsblYn", "N"); + + const core = GisApp.Module.core as any; + core?.deleteLayer?.(GIS_EDIT_VERTEX_LAYER_ID); + map.addLayer(layer); + gisEditVertexLayerRef.current = layer; + gisEditVertexSourceRef.current = source; + return source; + }, []); + + const clearGisEditVertexLayer = useCallback(() => { + gisEditVertexSourceRef.current?.clear(); + }, []); + + const ensureGisEditDrawSource = useCallback(() => { + if (gisEditDrawSourceRef.current) { + return gisEditDrawSourceRef.current; + } + + gisEditDrawSourceRef.current = new VectorSource(); + return gisEditDrawSourceRef.current; + }, []); + + const removeGisEditDrawInteraction = useCallback(() => { + const map = gisMaps.current; + if (!map) return; + + if (gisEditDrawRef.current) { + map.removeInteraction(gisEditDrawRef.current); + gisEditDrawRef.current = null; + } + gisEditDrawSourceRef.current?.clear(); + }, []); + + const removeGisEditInteractions = useCallback(() => { + const map = gisMaps.current; + if (!map) return; + + if (gisEditTranslateRef.current) { + map.removeInteraction(gisEditTranslateRef.current); + gisEditTranslateRef.current = null; + } + if (gisEditModifyRef.current) { + const modifyInteractions = Array.isArray(gisEditModifyRef.current) + ? gisEditModifyRef.current + : [gisEditModifyRef.current]; + modifyInteractions.forEach((interaction: any) => { + if (interaction) { + map.removeInteraction(interaction); + } + }); + gisEditModifyRef.current = null; + } + clearGisEditVertexLayer(); + setGisEditPreviewCursor(""); + }, [clearGisEditVertexLayer, setGisEditPreviewCursor]); + + // GIS 편집은 map 화면의 로컬 편집 모드를 토글하면서 세션 상태는 별도 popup 창에 동기화한다. + const handleToggleGisEdit = useCallback(() => { + if (!gisEdit.isEditMode) { + const popup = openGisEditSessionPopup(); + if (!popup) { + alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); + } + } + gisEdit.toggleEditMode(); + setIsOpen(false); + }, [gisEdit, openGisEditSessionPopup]); + + // 툴바 버튼은 현재 지도 중심 또는 다음 피처 선택을 기준으로 로컬 변경분을 만든다. + const handleSelectGisEditTool = useCallback((tool: "ADD" | "MOVE" | "VERTEX_EDIT" | "DELETE") => { + gisEdit.activateTool(tool, gisMaps.current); + setIsOpen(false); + }, [gisEdit]); + + // 1차 구현은 저장 API 호출 대신 payload 미리보기만 만든다. + const handleSaveGisEdit = useCallback(() => { + gisEdit.save(gisMaps.current); + }, [gisEdit]); + + useEffect(() => { + if (!mapReady || !gisMaps.current || !gisEdit.isEditMode) return; + + const map = gisMaps.current; + + if (gisEdit.activeTool !== "ADD" || !gisEdit.selectedLayer) { + removeGisEditDrawInteraction(); + return; + } + + removeGisEditInteractions(); + removeGisEditDrawInteraction(); + + const drawSource = ensureGisEditDrawSource(); + const drawType = + gisEdit.selectedLayer.geometryType === "Point" + ? "Point" + : gisEdit.selectedLayer.geometryType === "Polygon" + ? "Polygon" + : "LineString"; + + const draw = new Draw({ + source: drawSource, + type: drawType, + }); + + draw.on("drawstart", () => { + setGisEditPreviewCursor("crosshair"); + }); + + draw.on("drawend", (event: any) => { + try { + const geometryObject = new GeoJSON().writeGeometryObject(event.feature.getGeometry(), { + featureProjection: GisApp.TO_PROJ, + dataProjection: FLOW_DIRECTION_SRS, + }) as Record; + gisEdit.registerAddGeometry(geometryObject); + } catch (error) { + console.error("GIS 편집 ADD geometry 변환 실패:", error); + gisEdit.announceStatus("신규 geometry를 추가하지 못했습니다. 다시 시도해 주세요."); + } finally { + drawSource.clear(); + setGisEditPreviewCursor("crosshair"); + } + }); + + map.addInteraction(draw); + gisEditDrawRef.current = draw; + + return () => { + removeGisEditDrawInteraction(); + }; + }, [ + ensureGisEditDrawSource, + gisEdit, + mapReady, + removeGisEditDrawInteraction, + removeGisEditInteractions, + setGisEditPreviewCursor, + ]); + useEffect(() => { if (!mapReady || !flowDirectionLayerInfo) return; @@ -1388,6 +2357,351 @@ const Map = () => { }; }, [loadFlowDirectionLayer, mapReady]); + useEffect(() => { + if (!mapReady || !gisMaps.current) return; + + const source = ensureGisEditPreviewLayer(); + if (!source) return; + + if (gisEdit.changes.length === 0) { + clearGisEditPreviewLayer(); + return; + } + + const geoJson = new GeoJSON(); + const layerPreviewColorMap = new globalThis.Map( + gisEdit.editableLayers.map((layer) => [layer.layerId, layer.previewColors] as const), + ); + const existingFeatureMap = new globalThis.Map( + source.getFeatures().map((feature: any) => [String(feature.get("changeId") ?? ""), feature] as const), + ); + const nextChangeIdSet = new Set(); + + gisEdit.changes.forEach((change) => { + const geometry = change.editType === "DELETE" ? change.beforeGeometry : change.afterGeometry; + if (!geometry) return; + + const previewLabel = + gisEdit.focusedChangeId === change.changeId && change.editType === "MOVE" + ? "드래그 이동" + : gisEdit.focusedChangeId === change.changeId && change.editType === "VERTEX_EDIT" + ? "꼭지점 편집" + : change.editType === "DELETE" + ? "삭제 예정" + : change.editType === "MOVE" + ? "이동 예정" + : change.editType === "VERTEX_EDIT" + ? "편집 예정" + : "신규 추가"; + + let feature = existingFeatureMap.get(change.changeId); + const previewColors = layerPreviewColorMap.get(change.layerId); + if (!feature) { + feature = geoJson.readFeature( + { + type: "Feature", + geometry, + properties: { + editType: change.editType, + objectId: change.objectId ?? change.objectLabel, + layerId: change.layerId, + }, + }, + { + dataProjection: FLOW_DIRECTION_SRS, + featureProjection: GisApp.TO_PROJ, + }, + ); + feature.setId(`gis-edit:${change.changeId}`); + source.addFeature(feature); + } else { + feature.setGeometry( + geoJson.readGeometry(geometry, { + dataProjection: FLOW_DIRECTION_SRS, + featureProjection: GisApp.TO_PROJ, + }), + ); + } + + feature.set("editType", change.editType); + feature.set("objectId", change.objectId ?? change.objectLabel); + feature.set("layerId", change.layerId); + feature.set("changeId", change.changeId); + feature.set("layerPointColor", previewColors?.pointColor ?? ""); + feature.set("layerLineColor", previewColors?.lineColor ?? ""); + feature.set("layerFillColor", previewColors?.fillColor ?? ""); + feature.set("isInteractionTarget", gisEdit.focusedChangeId === change.changeId); + feature.set("isDragging", Boolean(feature.get("isDragging"))); + feature.set("previewLabel", previewLabel); + feature.setStyle(createGisEditPreviewStyle(feature)); + nextChangeIdSet.add(change.changeId); + }); + + existingFeatureMap.forEach((feature: any, changeId: string) => { + if (!nextChangeIdSet.has(changeId)) { + source.removeFeature(feature); + } + }); + + }, [ + clearGisEditPreviewLayer, + ensureGisEditPreviewLayer, + gisEdit.changes, + gisEdit.editableLayers, + gisEdit.focusedChangeId, + gisEdit.isEditMode, + mapReady, + ]); + + useEffect(() => { + if (!mapReady || !gisMaps.current || !gisEdit.isEditMode || !gisEdit.pendingInteraction) return; + + const source = ensureGisEditPreviewLayer(); + const map = gisMaps.current; + if (!source || !map) return; + + const targetFeature = source + .getFeatures() + .find((feature: any) => feature.get("changeId") === gisEdit.pendingInteraction?.changeId); + if (!targetFeature) { + gisEdit.clearPendingInteraction(); + return; + } + + removeGisEditInteractions(); + const features = new Collection([targetFeature]); + let cleanupPointerMove: (() => void) | undefined; + const writeGeometryObject = () => { + const geometry = targetFeature.getGeometry(); + if (!geometry) return null; + return new GeoJSON().writeGeometryObject(geometry, { + featureProjection: GisApp.TO_PROJ, + dataProjection: FLOW_DIRECTION_SRS, + }) as Record; + }; + + if (gisEdit.pendingInteraction.editType === "MOVE") { + const translate = new Translate({ features, hitTolerance: 10 }); + translate.on("translatestart", () => { + targetFeature.set("isDragging", true); + targetFeature.set("previewLabel", "이동 중"); + targetFeature.setStyle(createGisEditPreviewStyle(targetFeature)); + setGisEditPreviewCursor("grabbing"); + targetFeature.changed?.(); + source.changed?.(); + map.renderSync?.(); + }); + translate.on("translating", () => { + targetFeature.changed?.(); + source.changed?.(); + map.renderSync?.(); + }); + translate.on("translateend", () => { + const objectId = String(targetFeature.get("objectId") ?? ""); + const layerName = gisEdit.selectedLayer?.layerName ?? "선택 레이어"; + targetFeature.set("isDragging", false); + targetFeature.set("previewLabel", "드래그 이동"); + targetFeature.setStyle(createGisEditPreviewStyle(targetFeature)); + setGisEditPreviewCursor("grab"); + gisEdit.updateChangeGeometry( + gisEdit.pendingInteraction!.changeId, + writeGeometryObject(), + `${layerName} ${objectId} 객체 위치를 지도에서 이동했습니다.`, + ); + targetFeature.changed?.(); + source.changed?.(); + map.renderSync?.(); + }); + map.addInteraction(translate); + gisEditTranslateRef.current = translate; + setGisEditPreviewCursor("grab"); + map.renderSync?.(); + } + + if (gisEdit.pendingInteraction.editType === "VERTEX_EDIT") { + const geometry = targetFeature.getGeometry?.(); + const geometryType = geometry?.getType?.(); + const objectId = String(targetFeature.get("objectId") ?? ""); + const layerName = gisEdit.selectedLayer?.layerName ?? "선택 레이어"; + + if (!geometry || !geometryType || geometryType === "Point" || geometryType === "MultiPoint") { + gisEdit.announceStatus(`${layerName} ${objectId} 객체는 꼭지점편집 대상 geometry가 아닙니다.`); + gisEdit.clearPendingInteraction(); + return; + } + + const vertexSource = ensureGisEditVertexLayer(); + const vertexLayer = gisEditVertexLayerRef.current; + if (!vertexSource || !vertexLayer) { + gisEdit.announceStatus("꼭지점 편집 레이어를 준비하지 못했습니다."); + gisEdit.clearPendingInteraction(); + return; + } + + const renderVertexHandles = (hoveredMidpoint: VertexHandleCandidate | null = null) => { + vertexSource.clear(); + buildVertexHandleFeatures(targetFeature.getGeometry?.()).forEach((handleFeature: Feature) => { + vertexSource.addFeature(handleFeature); + }); + if (hoveredMidpoint) { + vertexSource.addFeature( + createVertexHandleFeature(hoveredMidpoint.coordinate, hoveredMidpoint.meta), + ); + } + vertexSource.changed?.(); + }; + + let geometryBeforeModifyKey = geometryObjectToKey(writeGeometryObject()); + let activeMidpointKey = ""; + let isDraggingHandle = false; + let originalGeometrySnapshot = targetFeature.getGeometry?.()?.clone?.() ?? null; + renderVertexHandles(); + targetFeature.set("previewLabel", "꼭지점 편집"); + targetFeature.setStyle(createGisEditPreviewStyle(targetFeature)); + setGisEditPreviewCursor("crosshair"); + + const pointerMoveKey = map.on("pointermove", (event: any) => { + if (isDraggingHandle || event?.dragging) return; + + const hoveredMidpoint = findHoveredMidpointCandidate( + map, + targetFeature.getGeometry?.(), + event.coordinate, + ); + const nextMidpointKey = hoveredMidpoint + ? `${hoveredMidpoint.meta.geometryType}:${hoveredMidpoint.meta.partIndex ?? -1}:${hoveredMidpoint.meta.polygonIndex ?? -1}:${hoveredMidpoint.meta.ringIndex ?? -1}:${hoveredMidpoint.meta.coordIndex}` + : ""; + + if (nextMidpointKey === activeMidpointKey) return; + + activeMidpointKey = nextMidpointKey; + renderVertexHandles(hoveredMidpoint); + setGisEditPreviewCursor(hoveredMidpoint ? "copy" : "crosshair"); + map.renderSync?.(); + }); + + const vertexTranslate = new Translate({ + layers: [vertexLayer], + hitTolerance: 14, + }); + + vertexTranslate.on("translatestart", (event: any) => { + isDraggingHandle = true; + geometryBeforeModifyKey = geometryObjectToKey(writeGeometryObject()); + originalGeometrySnapshot = targetFeature.getGeometry?.()?.clone?.() ?? null; + const handleFeature = event.features?.item?.(0); + if (handleFeature) { + handleFeature.setStyle(GIS_EDIT_VERTEX_DRAGGING_STYLE); + } + targetFeature.set("previewLabel", "꼭지점 편집 중"); + targetFeature.setStyle(createGisEditPreviewStyle(targetFeature)); + setGisEditPreviewCursor("grabbing"); + map.renderSync?.(); + }); + + vertexTranslate.on("translating", (event: any) => { + const handleFeature = event.features?.item?.(0); + const handleGeometry = handleFeature?.getGeometry?.(); + const nextCoordinate = handleGeometry?.getCoordinates?.(); + const handleMeta = handleFeature?.get?.("handleMeta") as VertexHandleMeta | undefined; + const handleMetaList = + (handleFeature?.get?.("handleMetaList") as VertexHandleMeta[] | undefined) ?? + (handleMeta ? [handleMeta] : []); + + if (handleMetaList.length === 0 || !Array.isArray(nextCoordinate) || !originalGeometrySnapshot?.clone) { + return; + } + + targetFeature.setGeometry(originalGeometrySnapshot.clone()); + handleMetaList.forEach((meta) => { + applyVertexHandleMove(targetFeature.getGeometry?.(), meta, nextCoordinate); + }); + targetFeature.changed?.(); + source.changed?.(); + map.renderSync?.(); + }); + + vertexTranslate.on("translateend", (event: any) => { + isDraggingHandle = false; + const handleFeature = event.features?.item?.(0); + const handleGeometry = handleFeature?.getGeometry?.(); + const nextCoordinate = handleGeometry?.getCoordinates?.(); + const handleMeta = handleFeature?.get?.("handleMeta") as VertexHandleMeta | undefined; + const handleMetaList = + (handleFeature?.get?.("handleMetaList") as VertexHandleMeta[] | undefined) ?? + (handleMeta ? [handleMeta] : []); + + targetFeature.set("previewLabel", "꼭지점 편집"); + targetFeature.setStyle(createGisEditPreviewStyle(targetFeature)); + setGisEditPreviewCursor("crosshair"); + activeMidpointKey = ""; + + if (handleMetaList.length === 0 || !Array.isArray(nextCoordinate)) { + renderVertexHandles(); + map.renderSync?.(); + return; + } + + handleMetaList.forEach((meta) => { + applyVertexHandleMove(targetFeature.getGeometry?.(), meta, nextCoordinate); + }); + const nextGeometry = writeGeometryObject(); + const geometryChanged = geometryBeforeModifyKey !== geometryObjectToKey(nextGeometry); + renderVertexHandles(); + if (!geometryChanged) { + gisEdit.announceStatus( + `${layerName} ${objectId} 객체는 아직 형상이 바뀌지 않았습니다. 주황색 꼭지점 또는 가상 꼭지점을 직접 드래그해 주세요.`, + ); + map.renderSync?.(); + return; + } + + gisEdit.updateChangeGeometry( + gisEdit.pendingInteraction!.changeId, + nextGeometry, + `${layerName} ${objectId} 객체 꼭지점을 지도에서 조정했습니다.`, + ); + targetFeature.changed?.(); + source.changed?.(); + map.renderSync?.(); + }); + + map.addInteraction(vertexTranslate); + gisEditModifyRef.current = vertexTranslate; + gisMaps.current?.renderSync?.(); + cleanupPointerMove = () => { + unByKey(pointerMoveKey); + }; + } + + gisEdit.clearPendingInteraction(); + return cleanupPointerMove; + }, [ + clearGisEditVertexLayer, + ensureGisEditVertexLayer, + ensureGisEditPreviewLayer, + gisEdit.pendingInteraction, + gisEdit.selectedLayer, + gisEdit.updateChangeGeometry, + gisEdit.announceStatus, + gisEdit.clearPendingInteraction, + gisEdit.isEditMode, + mapReady, + removeGisEditInteractions, + ]); + + useEffect(() => { + if (!gisEdit.isEditMode || (gisEdit.activeTool !== "MOVE" && gisEdit.activeTool !== "VERTEX_EDIT")) { + removeGisEditInteractions(); + } + }, [gisEdit.activeTool, gisEdit.isEditMode, removeGisEditInteractions]); + + useEffect(() => { + if (!gisEdit.isEditMode || gisEdit.activeTool !== "ADD") { + removeGisEditDrawInteraction(); + } + }, [gisEdit.activeTool, gisEdit.isEditMode, removeGisEditDrawInteraction]); + return ( <> @@ -1405,6 +2719,21 @@ const Map = () => { setIsSearchModalOpen(true)} + onToggleGisEdit={handleToggleGisEdit} + isGisEditActive={gisEdit.isEditMode} + /> + /` +- 파일 + - `001-de-<기능명>-설계.md` +- 규칙 + - 설계는 기능당 1개만 유지한다. + - 후속 설계 변경, 상태 재검토, QGIS 반영, API 계약 보완도 기존 설계 문서에 새 헤더로 누적한다. + +### 3.2 `sn` 작업내용 문서 + +- 경로 + - `FE_APP_DOCS_ROOT/작업내용/<기능폴더>/` +- 파일 + - `001-fe-<기능명>-통합구현기록.md` +- 규칙 + - 구현 이력, 라운드별 변경, 결정사항, 리스크, BE 공유사항을 한 문서에 누적한다. + - 기존 `001`, `002`, `003` 개별 구현 문서는 통합 문서에 흡수 후 정리한다. + +### 3.3 `sn` 작업검증 문서 + +- 경로 + - `FE_APP_DOCS_ROOT/작업검증/<기능폴더>/` +- 파일 + - `001-qa-<기능명>-통합검증계획.md` + - `002-qa-<기능명>-통합검증결과.md` +- 규칙 + - 검증 계획은 `통합검증계획` 1개에만 누적한다. + - 검증 결과는 `통합검증결과` 1개에만 누적한다. + - 회귀 검증, 후속 검증, 도구별 검증도 새 파일이 아니라 각 문서 내부 헤더로 구분한다. + +## 4. 문서 내부 헤더 규칙 + +### 4.1 설계 문서 + +- 권장 섹션 + - 목적 + - 입력 근거 + - 현재 확인 상태 + - 기능 범위 + - 사용자 흐름 + - 화면/UI 계약 + - API 계약 + - 검증 기준 + - 남은 리스크 +- 하네스 정보 배치 + - 도구 상태 전이표 + - 지도 상호작용 관측 포인트 + - 자동검증 가능 범위와 수동 검증 경계 +- 후속 반영은 아래 형식 중 하나로 누적한다. + - `## 2-1. 외부 참조 기준` + - `### 6.6 도구별 세부 계약` + - `### 6.8 지도 상호작용 관측 포인트` + - `### 7.5 도구 상태 전이표` + - `## YYYY-MM-DD 설계 변경 메모` + +### 4.2 통합 구현 기록 + +- 권장 섹션 + - 문서 목적 + - 입력 근거 + - 공통 계약 + - 구현 라운드 요약 + - 기능별 현재 상태 + - BE 공유 사항 + - 남은 리스크 + - 흡수한 기존 문서 +- 하네스 정보 배치 + - 자동화 차단 요인 + - 관측 가능성 확보를 위해 추가한 상태/메시지/표식 + - 하네스 친화화 부채 +- 라운드별 구현은 `### 4.x` 식으로 누적한다. + +### 4.3 통합 검증 계획 + +- 권장 섹션 + - 문서 목적 + - 검증 범위 + - 검증 기준 + - 라운드별 검증 계획 + - 다음 검증 순서 + - 흡수한 기존 문서 +- 하네스 정보 배치 + - 브라우저 검증 선행조건 + - 관측 포인트 표 + - 자동검증 항목과 수동검증 항목 구분 + - 차단 사유 분류 기준 + +### 4.4 통합 검증 결과 + +- 권장 섹션 + - 문서 목적 + - 수행한 검증 + - 라운드별 검증 결과 + - 현재 검증 상태 + - 보류/리스크 + - 흡수한 기존 문서 +- 하네스 정보 배치 + - 실제 관측 증적 요약 + - 차단 사유 분류 + - 자동화 실패와 기능 실패 구분 결과 + +## 5. 기존 개별 문서 흡수 규칙 + +- 기존 개별 문서를 유지할 필요가 없으면 다음 순서로 정리한다. + 1. 통합 문서에 핵심 내용을 요약/흡수 + 2. 통합 문서 `흡수한 기존 문서` 섹션에 파일명을 기록 + 3. 기존 개별 문서를 정리한다 +- 흡수 시 원래 문서의 모든 문장을 그대로 복사하기보다, 결정사항과 검증 결과를 구조화해서 재작성한다. + +## 6. 새 기능 시작 시 문서 생성 순서 + +1. 공통 설계 문서 1개 생성 +2. `sn` 작업카드 1개 생성 +3. `sn` 통합 구현 기록 1개 생성 +4. `sn` 통합 검증 계획 1개 생성 +5. `sn` 통합 검증 결과 1개 생성 +6. 이후부터는 기존 5개 문서만 계속 갱신 + +## 7. 금지 사항 + +- 구현 라운드마다 `002-fe-...`, `003-fe-...`, `004-qa-...`를 계속 추가하지 않는다. +- 같은 기능에서 `후속검증계획.md`, `후속검증결과.md`, `MOVE실드래그검증계획.md`, `VERTEX_EDIT실조작검증결과.md` 같은 파생 검증 파일을 반복 증식시키지 않는다. +- `상태재검토`, `후속정리`, `보완메모`를 별도 파일로 분리하지 않는다. + +## 8. 예외 처리 + +- 사용자 요청으로 개별 문서 분리가 명시된 경우 +- 외부 전달용 산출물이라 통합 문서 내부 헤더로 대체할 수 없는 경우 +- 법적/감사/계약상 독립 파일 보관이 필요한 경우 + +예외 상황에서도 먼저 통합 문서에 “왜 별도 파일이 필요한지”를 기록한다. diff --git a/apps/sn/docs/기준문서/015-하네스엔지니어링정책.md b/apps/sn/docs/기준문서/015-하네스엔지니어링정책.md new file mode 100644 index 000000000..2499f94ff --- /dev/null +++ b/apps/sn/docs/기준문서/015-하네스엔지니어링정책.md @@ -0,0 +1,126 @@ +# 015. 하네스엔지니어링정책 + +## 1. 목적 + +- `sn` 앱 기능을 브라우저 자동검증, 수동 회귀 검증, FE-BE 연동 관찰이 가능한 구조로 문서화하는 기준을 통일한다. +- 구현 코드 구조를 바로 바꾸기 전에도, 문서 단계에서 관측 포인트와 차단 요인을 먼저 드러내어 하네스 친화성을 높인다. +- 기능별 문서에 하네스 정보가 중복되거나 누락되지 않도록 공통 배치 기준을 제공한다. + +## 2. 적용 범위 + +- `FE_APP_DOCS_ROOT/기준문서/**` +- `FE_APP_DOCS_ROOT/작업계획/**` +- `FE_APP_DOCS_ROOT/작업내용/**` +- `FE_APP_DOCS_ROOT/작업검증/**` +- `COMMON_DOCS_ROOT/작업계획/**` 중 `sn` 화면/상호작용 계약을 담는 설계 문서 + +## 3. 기본 원칙 + +- 하네스 정보는 기능별로 새 md를 늘리기보다 기존 4문서 체계 안에 배치한다. +- 자동검증 가능 여부와 무관하게, 사용자가 실제로 무엇을 보고 성공/실패를 판단하는지 먼저 문서화한다. +- 브라우저 자동화 한계, 인증 차단, 외부 시스템 장애는 기능 결함과 분리해 기록한다. +- 지도 상호작용 기능은 도구 상태, 선택 상태, 세션 상태, 네트워크 상태를 따로 관측할 수 있어야 한다. +- 코드 구조가 과밀해도 문서에서는 책임 경계, 상태 전이, 관측 포인트를 먼저 고정한다. + +## 4. 자동검증 선행조건 + +- 접속 URL +- 로그인 경로와 권한 +- 필수 쿠키 또는 토큰 존재 여부 +- 서버 기동 상태와 대상 API base URL +- 테스트용 레이어, 객체 ID, BBOX, 샘플 데이터 +- 브라우저 자동화 가능 범위와 수동 검증 전환 조건 + +인증 또는 외부 연동 문제로 위 조건이 충족되지 않으면, 이를 기능 실패로 단정하지 말고 `환경 차단`, `인증 차단`, `데이터 차단`, `자동화 한계` 중 하나로 분류한다. + +## 5. 관측 포인트 표준 + +### 5.1 화면 관측 + +- 버튼 활성/비활성 +- 툴바 노출/숨김 +- 상태 메시지 텍스트 +- 선택 레이어명 +- 변경 목록 행 수 +- 오류 또는 안내 메시지 + +### 5.2 지도 상호작용 관측 + +- 현재 활성 도구 +- 선택 객체 존재 여부 +- preview feature 존재 여부 +- vertex marker 또는 modify handle 노출 여부 +- drag 또는 modify 종료 후 geometry 변경 여부 +- 삭제 대상 임시 스타일 적용 여부 + +### 5.3 네트워크 관측 + +- 요청 URL, method, query/body +- 응답 status +- 편집 세션 payload shape +- 인증 실패 또는 권한 실패 응답 + +### 5.4 세션 관측 + +- 로컬 변경분 누적 여부 +- undo/reset 가능 여부 +- save 가능 여부 +- save pending/save error 상태 + +## 6. 자동검증과 수동검증 경계 + +- DOM, 텍스트, 버튼 상태, 네트워크 호출, payload 구조는 자동검증 우선 대상으로 본다. +- canvas 기반 drag, vertex 이동, 지도 좌표 조작은 자동화가 불안정하면 수동 검증으로 전환할 수 있다. +- 수동 전환 시에는 이유를 `자동화 한계`로 명시하고, 어떤 관측 포인트를 사람 손으로 확인했는지 남긴다. + +## 7. 인증·세션 기준 + +- 인증 장애는 기능 결함과 분리해 기록한다. +- 로컬 브라우저 검증이 인증 때문에 막히면 `COMMON_DOCS_ROOT/작업내용/공통-로그인/001-de-공통-로그인-로컬임시로그인-검토계획.md`를 우선 참조한다. +- 기능 문서에는 해당 기능이 요구하는 최소 권한, 로그인 여부, 세션 유지 시간, 재로그인 필요 조건만 요약한다. + +## 8. 기능 문서 배치 규칙 + +### 8.1 설계 문서 + +- 도구 상태 전이표 +- 지도 상호작용 관측 포인트 +- 자동검증 가능 범위와 수동 검증 경계 + +### 8.2 작업카드 + +- 브라우저 검증 선행조건 +- 테스트 데이터와 샘플 진입 조건 +- 인증 차단 시 대체 경로 + +### 8.3 통합 구현 기록 + +- 현재 자동화 차단 요인 +- 하네스 친화화 부채 +- 관측 가능성 확보를 위해 추가된 상태/메시지/표식 + +### 8.4 통합 검증 계획 + +- 관측 포인트 표 +- 자동검증 항목과 수동검증 항목 구분 +- 차단 사유 분류 기준 + +### 8.5 통합 검증 결과 + +- 실제 관측 증적 요약 +- 차단 사유 분류 +- 자동화 실패와 기능 실패의 구분 결과 + +## 9. 차단 사유 분류 + +- `기능 결함`: 화면 동작, 상태 전이, payload, 응답 처리 자체가 기대와 다름 +- `인증 차단`: 로그인, 토큰, 권한 부족으로 기능 진입 불가 +- `환경 차단`: 서버 미기동, 잘못된 env, CORS, 프록시, 네트워크 장애 +- `데이터 차단`: 테스트 데이터, 레이어 메타, 객체 샘플 부족 +- `자동화 한계`: 브라우저 자동화 도구로 canvas drag, vertex 조작이 안정적으로 재현되지 않음 + +## 10. 금지 사항 + +- 같은 주제라고 해서 `브라우저자동검증기준.md`, `인증검증기준.md`, `상태머신.md`를 기능마다 별도 생성하지 않는다. +- 관측 포인트 없이 `수동 확인 필요`만 적고 끝내지 않는다. +- 인증 실패, 환경 실패, 자동화 한계를 모두 기능 결함으로 뭉뚱그려 기록하지 않는다. diff --git a/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/001-qa-SFR-012-003-하수관로편집-통합검증계획.md b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/001-qa-SFR-012-003-하수관로편집-통합검증계획.md new file mode 100644 index 000000000..be0225469 --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/001-qa-SFR-012-003-하수관로편집-통합검증계획.md @@ -0,0 +1,157 @@ +# SFR-012-003 하수관로편집 통합 검증 계획 + +## 1. 문서 목적 + +- `SFR-012-003 하수관로편집`의 검증 계획을 기능당 1개 문서로 누적 관리한다. +- 기존 `001`, `003`, `005`, `007`, `009`, `011` 계획 문서를 이 문서로 흡수한다. +- 이후 라운드부터는 새 검증 계획 파일을 추가하지 않고 이 문서 내부 헤더로만 구분한다. + +## 2. 검증 기준 요약 + +- 레이어 선택 전 편집 도구 비활성 +- `ADD`, `MOVE`, `VERTEX_EDIT`, `DELETE` 변경분은 저장 전까지 로컬 세션 유지 +- `MOVE` + - preview drag 후 geometry 반영 +- `VERTEX_EDIT` + - vertex marker 표시 + - 양 끝점 이동 가능 + - geometry 반영 +- 저장 시 원본 직접 수정 금지 + +## 3. 라운드별 검증 계획 요약 + +### 3.1 초기/후속 검증 + +- `GIS 편집` 버튼 진입/종료, 메뉴 강조 잔류 재확인 +- 기본 툴바 노출과 상태 메시지 확인 + +### 3.2 세션도구 확장 검증 + +- 7개 편집 레이어 노출 확인 +- 레이어별 도구 활성 규칙 확인 +- `undo`, `reset` 확인 + +### 3.3 실제지도조작 확장 검증 + +- `MOVE`, `VERTEX_EDIT`, `DELETE`가 실제 지도 조작 구조를 받을 수 있는지 코드/브라우저 기준으로 점검 +- 자동 브라우저 drag는 한계가 있어 수동 검증 필요로 기록 + +### 3.4 `MOVE` + +- preview drag 구조와 세션 반영 흐름은 상대적으로 안정적 +- 유지 기준으로 본다 + +### 3.5 `VERTEX_EDIT` + +- 가장 많은 변동이 있었고 현재도 재검증 필요 +- 핵심 검증 기준 + - 선택 직후 vertex marker 표시 + - 양 끝점 이동 가능 + - 이동 후 geometry가 실제로 변경 + +### 3.6 2026-05-15 세션 유지 / `MOVE` / `VERTEX_EDIT` 회귀 검증 + +- 사용자 피드백 기준으로 아래 항목을 우선 재검증한다. + - `ADD`, `MOVE`, `VERTEX_EDIT` 후 세션 기록과 지도 preview가 바로 사라지지 않는지 + - 신규 추가 preview가 레이어별로 다른 색상으로 보여 구분 가능한지 + - 편집 세션 기본 패널이 최근 5건 정도 높이만 보이고, 아래 이력은 패널 내부 스크롤로 바로 확인 가능한지 + - `MOVE` 대상 선택 후 preview drag가 실제로 반응하는지 + - `VERTEX_EDIT` 대상 선택 후 모든 실꼭지점 marker와 midpoint handle이 드러나는지 + - `VERTEX_EDIT` 양 끝점 drag가 실제로 반응하는지 + - `VERTEX_EDIT` midpoint drag 후 새 vertex가 삽입되는지 + - `VERTEX_EDIT` 드래그 후 geometry 변경이 세션 요약과 preview에 반영되는지 + - `하수처리시설`이 point 기준으로 `VERTEX_EDIT` 비대상으로 처리되는지 + +### 3.7 2026-05-15 QGIS 유사 `VERTEX_EDIT` 재구현 검증 + +- QGIS 비교 기준으로 아래 항목을 별도 확인한다. + - 실꼭지점은 항상 보이되, midpoint는 모든 segment에 상시 표시되지 않고 hover segment 기준으로만 나타나는지 + - endpoint와 중간 vertex가 동일한 주황색 handle 규칙으로 이동되는지 + - midpoint drag가 새 vertex 삽입으로 반영되는지 + - feature 선택이 유지된 상태에서 연속 꼭지점 편집이 가능한지 + - `MOVE` 도구 회귀 없이 preview drag가 기존처럼 유지되는지 + +### 3.8 2026-05-15 편집 세션 팝업 전환 및 도구 아이콘 검증 + +- 편집 세션 고정 패널이 더 이상 지도 위에 남지 않는지 +- GIS 편집 진입 시 `GIS 편집 세션` window popup이 열리는지 +- 팝업에서 변경 목록, 마지막 변경 취소, 세션 초기화, 항목 focus가 메인 지도와 동기화되는지 +- 세션 안내 문구 영역이 제거됐는지 +- `추가`, `이동`, `꼭지점편집`, `삭제` 버튼에 `lucide-react` 아이콘이 표시되는지 + +## 4. 현재 검증 범위 + +### 4.1 진행 중 + +- `ADD` geometry family별 직접 캡처 +- `VERTEX_EDIT`의 QGIS 기준 사용자 경험 재정렬 + +### 4.2 이후 범위 + +- 실제 저장 API 연결 이후 통합 저장 검증 +- 원본 보호와 편집본 참조 갱신 검증 + +## 5. 현재 테스트 메모 + +- dev 서버가 켜져 있으면 `next build`는 수행하지 않는다. +- `next lint`는 환경 이슈로 안정적인 회귀 수단이 되지 못하고 있다. +- 지도 캔버스 기반 drag/vertex 테스트는 수동 브라우저 검증이 핵심이다. + +## 5-1. 브라우저 검증 선행조건 + +- `/map` 진입 가능한 로그인 상태 또는 로컬 임시 로그인 경로가 준비되어 있어야 한다. +- FE dev 서버와 관련 BE API가 모두 응답해야 한다. +- 편집 대상 레이어와 샘플 객체를 재현 가능한 테스트 데이터로 확보해야 한다. +- 인증 또는 외부 연동 문제로 진입이 막히면 `기능 결함`이 아니라 `인증 차단` 또는 `환경 차단`으로 분류한다. +- 로컬 인증 우회 필요 시 `COMMON_DOCS_ROOT/작업내용/공통-로그인/001-de-공통-로그인-로컬임시로그인-검토계획.md`를 참조한다. + +## 5-2. 브라우저 자동검증 관측 포인트 + +| 구분 | 관측 포인트 | 기대 결과 | 검증 방식 | +| --- | --- | --- | --- | +| 공통 | `GIS편집` 버튼 | 편집 모드 진입 시 강조, 종료 시 해제 | 자동 | +| 공통 | 편집 툴바 | 편집 모드에서만 노출 | 자동 | +| 공통 | 레이어 선택 | 미선택 시 도구 비활성, 선택 시 활성 | 자동 | +| 세션 | 변경 목록 | 조작 후 항목 누적 | 자동 | +| 세션 | `undo`, `reset` | 상태 복구 또는 초기화 | 자동 | +| `ADD` | 상태 메시지/신규 항목 | geometry family별 신규 추가 반영 | 자동 중심 | +| `MOVE` | preview label, 목록 요약 | drag 후 geometry 변경 반영 | 자동+수동 | +| `VERTEX_EDIT` | vertex marker 또는 modify handle | 선택 직후 노출, 이동 후 geometry 변경 | 수동 중심 | +| `DELETE` | 임시 삭제 스타일 | 저장 전까지 유지 | 자동+수동 | +| `SAVE` | payload/응답 처리 | 성공 또는 실패 메시지와 세션 상태 반영 | 자동 | + +## 5-3. 차단 사유 분류 기준 + +- `기능 결함`: 기대한 상태 전이 또는 응답 처리가 깨짐 +- `인증 차단`: 로그인, 토큰, 권한 문제로 진입 또는 호출이 막힘 +- `환경 차단`: 서버 미기동, 프록시/네트워크 문제 +- `데이터 차단`: 레이어 메타, 샘플 객체, 테스트 BBOX 부족 +- `자동화 한계`: canvas drag, vertex 조작을 도구가 안정적으로 재현하지 못함 + +## 6. 다음 검증 순서 + +1. `ADD` + - 점형 클릭 추가 + - 선형 연속 클릭 추가 + - 면형 폐합 추가 + - 편집 모드 종료 후에도 세션/preview 유지 확인 +2. `MOVE` + - preview drag 회귀 없는지 확인 + - drag 종료 후 요약/geometry 반영 확인 +3. `VERTEX_EDIT` + - 실꼭지점 marker 표시 + - hover segment midpoint 표시 + - 양 끝점 이동 + - 중간 vertex 이동 + - 드래그 후 geometry/요약 반영 확인 +4. `DELETE` + - 임시 삭제 스타일 유지 + +## 7. 흡수한 기존 문서 + +- `001-qa-SFR-012-003-하수관로편집-검증계획.md` +- `003-qa-SFR-012-003-하수관로편집-후속검증계획.md` +- `005-qa-SFR-012-003-하수관로편집-세션도구확장검증계획.md` +- `007-qa-SFR-012-003-하수관로편집-실제지도조작검증계획.md` +- `009-qa-SFR-012-003-하수관로편집-MOVE실드래그검증계획.md` +- `011-qa-SFR-012-003-하수관로편집-VERTEX_EDIT실조작검증계획.md` diff --git a/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md new file mode 100644 index 000000000..3fd2b6a96 --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md @@ -0,0 +1,165 @@ +# SFR-012-003 하수관로편집 통합 검증 결과 + +## 1. 문서 목적 + +- `SFR-012-003 하수관로편집`의 검증 결과를 기능당 1개 문서로 누적 관리한다. +- 기존 `002`, `004`, `006`, `008`, `010`, `012` 결과 문서를 이 문서로 흡수한다. +- 이후 라운드부터는 새 검증 결과 파일을 추가하지 않고 이 문서 내부 헤더로만 구분한다. + +## 2. 수행한 검증 요약 + +### 2.1 초기/후속 검증 + +- `GIS 편집` 모드 진입/종료 +- 메뉴 강조 잔류 재확인 + +### 2.2 세션도구 확장 검증 + +- 7개 편집 레이어 노출 +- 도구 활성 규칙 +- `undo`, `reset` + +### 2.3 실제지도조작 확장 검증 + +- `MOVE`, `VERTEX_EDIT`, `DELETE` 구조 점검 +- 자동 브라우저 drag 한계 확인 + +### 2.4 `MOVE` + +- preview drag 구조와 세션 반영 흐름은 상대적으로 안정적 + +### 2.5 `VERTEX_EDIT` + +- 여러 차례 보강했지만 현재는 재검증 필요 상태 + +### 2.6 2026-05-15 세션 유지 / 상호작용 회귀 대응 점검 + +- 코드 기준 점검 + - `GIS편집` 모드 종료 후에도 변경분이 있으면 세션 패널과 지도 preview를 유지하도록 수정했다. + - 신규 추가 preview는 레이어별 색상을 우선 사용하도록 보강했다. + - 편집 세션 기본 패널은 최근 5건 정도 높이만 보이도록 고정하고, 아래 이력은 패널 내부 스크롤로 바로 확인하도록 바꿨다. + - 추가 조정으로 세션 패널 높이를 더 줄이고, 하단 안내 문구는 제거했다. + - preview layer는 상호작용 중 redraw되도록 `updateWhileInteracting` 보강이 들어갔다. + - `MOVE`, `VERTEX_EDIT` interaction effect는 `gisEdit` 전체 객체 의존 대신 실제 pending interaction 중심으로 좁혔다. + - 원본 피처 선택 시 layerId 정규화를 추가해 namespace 또는 feature id 형식 차이로 편집 진입이 막히지 않게 했다. + - `하수처리시설(tb_il_swage_prcs_fclt)`은 point 기준으로 정정해 `VERTEX_EDIT` 대상에서 빠지도록 맞췄다. +- 정적 검증 + - `node node_modules/typescript/bin/tsc --noEmit --incremental false --pretty false` + - 결과: 기존 저장소 오류로 실패 + - 확인된 기존 오류 예시 + - `components/common/index.ts` 타입 불일치 + - `PipeAnalysisPopupPage.tsx` 타입 변환 경고 + - 공통 chart 컴포넌트 implicit any +- 로컬 라우트 응답 확인 + - `http://127.0.0.1:3002/map` 응답 200 + - `http://127.0.0.1:3002/auth/login` 응답 200 +- 자동 브라우저 검증 + - Playwright headless 실행 시 `spawn EPERM`으로 실패해 실제 canvas drag 자동검증은 수행하지 못했다. + - 따라서 `MOVE`, `VERTEX_EDIT`의 실조작 확인은 수동 브라우저 재검증이 남아 있다. + +### 2.7 2026-05-15 QGIS 유사 `VERTEX_EDIT` 재구현 점검 + +- 코드 기준 점검 + - `VERTEX_EDIT`는 custom vertex handle layer를 유지하되, 실꼭지점 상시 표시와 hover segment midpoint 1건 표시 구조로 단순화했다. + - midpoint 후보는 `LineString`, `MultiLineString`, `Polygon`, `MultiPolygon` 모두 계산 가능하도록 분리했다. + - vertex 근처에서는 midpoint를 숨기고, segment 근처에서만 midpoint를 띄우도록 pixel tolerance를 적용했다. + - endpoint와 일반 vertex는 같은 drag 규칙을 사용하고, midpoint drag는 새 좌표 삽입으로 처리한다. + - `MOVE` preview layer의 `clickPsblYn="Y"` 기준은 유지했고, vertex layer는 별도 레이어로 계속 분리했다. +- 정적 검증 + - `pnpm exec tsc --noEmit --incremental false --pretty false` + - 결과: 이번 변경분 관련 신규 타입 오류는 정리했고, 저장소 기존 오류만 남았다. + - 남은 기존 오류 예시 + - `components/common/index.ts` 타입 불일치 + - `PipeAnalysisPopupPage.tsx`의 `JSON[]` 캐스팅 경고 + - 공통 chart 컴포넌트 implicit any +- 로컬 환경 확인 + - `http://127.0.0.1:3002/map` 응답 200 + - `http://127.0.0.1:3002/auth/login` 응답 200 + - `http://127.0.0.1:3002/popup/gisEditSessionPopup` 응답 200 + - 현재 세션에서는 브라우저 전용 자동화 도구를 확보하지 못해 canvas 기반 midpoint hover/drag 실조작은 수동 재검증 필요로 남긴다. + +### 2.8 2026-05-15 편집 세션 팝업 전환 및 도구 아이콘 점검 + +- 코드 기준 점검 + - 지도 좌하단 고정 세션 패널 렌더링은 제거했다. + - `GIS 편집 세션` popup definition, registry, popup content를 추가해 기존 window popup 체계에 맞췄다. + - 메인 `/map`은 GIS 편집 진입 시 세션 팝업을 열고, postMessage로 세션 snapshot을 동기화하도록 바꿨다. + - 팝업에서 `마지막 변경 취소`, `세션 초기화`, `항목 focus`를 메인 지도에 다시 전달하도록 연결했다. + - 세션 패널의 안내 문구 영역은 제거했다. + - `추가`, `이동`, `꼭지점편집`, `삭제` 버튼은 `lucide-react` 아이콘을 사용하도록 바꿨다. +- 정적 검증 + - `pnpm exec tsc --noEmit --incremental false --pretty false` + - 결과: 여전히 저장소 기존 오류로 실패 + - 기존 오류 범주 + - 기존 공통 타입 불일치 + - 기존 chart implicit any +- 수동 재검증 필요 항목 + - 브라우저 팝업 차단 여부 + - 세션 팝업과 메인 지도 간 메시지 동기화 + - 도구 아이콘 실제 렌더링 + +## 3. 현재 검증 상태 + +### 3.1 통과 + +- `GIS 편집` 모드 진입/종료 +- 레이어별 도구 활성 규칙 +- 세션 `undo`, `reset` +- `MOVE`의 기본 preview drag + +### 3.2 진행 중 + +- `ADD` geometry family별 직접 캡처 +- `VERTEX_EDIT`의 QGIS 기준 사용자 경험 재정렬 +- `MOVE`, `VERTEX_EDIT` 실조작 수동 재검증 + +### 3.3 보류 + +- 실제 저장 API 연결 이후 통합 저장 검증 +- 원본 보호와 편집본 참조 갱신 검증 + +## 4. 보류/리스크 + +- dev 서버가 켜져 있으면 `next build`를 함께 돌리기 어렵다. +- `next lint`는 환경 이슈로 안정적인 회귀 수단이 되지 못하고 있다. +- 지도 캔버스 기반 drag/vertex 테스트는 수동 브라우저 검증 의존도가 높다. +- `VERTEX_EDIT`는 하수관로 `LineString`/`MultiLineString` 처리와 vertex marker UX를 다시 고정해야 한다. +- 현재 저장소의 기존 TypeScript 오류 때문에 변경분만 분리한 정적 통과 여부를 보장하기 어렵다. + +## 4-1. 관측 증적 요약 + +- 자동으로 확인된 항목 + - `GIS편집` 모드 진입/종료 + - 레이어별 도구 활성 규칙 + - 세션 `undo`, `reset` + - `MOVE`의 기본 preview drag 준비 구조 +- 수동 또는 후속 확인이 필요한 항목 + - 실제 canvas drag 정확도 + - `VERTEX_EDIT`의 vertex marker 노출과 꼭지점 직접 이동 + - 저장 API 연결 후 응답 처리와 원본 보호 +- 간접 관측 표식 + - 상태 메시지 + - 변경 목록 + - preview label + - 임시 삭제 스타일 + +## 4-2. 차단 사유 분류 + +- `자동화 한계` + - 지도 캔버스 기반 drag/vertex 조작은 자동 브라우저 재현 안정성이 낮다. + - 이번 세션에서는 Playwright 또는 브라우저 플러그인 기반 canvas 자동화 경로를 확보하지 못했다. +- `인증 차단` + - 실제 브라우저 통합 검증은 로그인/세션 준비 여부에 따라 막힐 수 있다. +- `환경 차단` + - dev 서버 실행 중 build 병행이 어렵고, lint도 안정적 회귀 수단이 아니다. +- `기능 결함 또는 미완료` + - `VERTEX_EDIT`는 아직 요구사항 기준으로 다시 고정이 필요하다. + +## 5. 흡수한 기존 문서 + +- `002-qa-SFR-012-003-하수관로편집-검증결과.md` +- `004-qa-SFR-012-003-하수관로편집-후속검증결과.md` +- `006-qa-SFR-012-003-하수관로편집-세션도구확장검증결과.md` +- `008-qa-SFR-012-003-하수관로편집-실제지도조작검증결과.md` +- `010-qa-SFR-012-003-하수관로편집-MOVE실드래그검증결과.md` +- `012-qa-SFR-012-003-하수관로편집-VERTEX_EDIT실조작검증결과.md` diff --git a/apps/sn/docs/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md b/apps/sn/docs/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md new file mode 100644 index 000000000..c3aec0802 --- /dev/null +++ b/apps/sn/docs/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md @@ -0,0 +1,216 @@ +# SFR-012-003 하수관로편집 작업카드 + +## 1. 작업 개요 +- 작업 ID: SFR-012-003-FE +- 작업명: 하수관망도 GIS편집 프론트엔드 구현 계획 +- 상태: 진행 +- 작성일: 2026-05-14 +- 담당 역할: FE +- 관련 역할: DE, BE, QA +- 담당 범위: `apps/sn` 지도형 화면에서 GIS편집 진입, 레이어 선택, 추가/이동/꼭지점편집/삭제, 저장 payload 준비까지 구현 +- 근거 문서: + - `COMMON_DOCS_ROOT/작업계획/SFR-012-003-하수관로편집/001-de-SFR-012-003-하수관로편집-설계.md` + - `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_요구사항명세서_v1_1.docx` + - `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_화면시나리오_v1_1.md` + - `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_화면_v1_1.html` + - `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_화면설계서_v1_1.png` + +## 1-2. 최근 진행 갱신 +- 2026-05-14 + - 7개 편집 레이어 확장, 레이어별 허용 도구 규칙, 로컬 세션 undo/reset, `MOVE`/`VERTEX_EDIT`/`DELETE` 임시 변경분 누적까지 반영했다. + - 이번 라운드에서 `MOVE`를 preview feature 기반 실제 drag 흐름으로 올리고, 지도 위 preview 강조와 재선택 UX를 보강했다. + - `VERTEX_EDIT` 구현은 QGIS 기준 사용자 경험과 어긋나는 구간이 확인되어 재설계가 필요하다. + - 재구현 기준은 `ADD=geometry family별 직접 캡처`, `MOVE=현 방식 유지`, `VERTEX_EDIT=양 끝점 포함 vertex marker 조작`으로 다시 고정한다. + - 다음 핵심 잔여 범위는 설계 문서 기준 재정렬 후 `ADD`, `VERTEX_EDIT`, `DELETE`를 순서대로 안정화하는 것이다. + +## 1-3. 브라우저 검증 선행조건 +- 접속 URL: `sn` 지도 메인 화면 진입이 가능해야 한다. +- 로그인/권한: `/map` 진입과 지도 API 호출이 가능한 계정 또는 로컬 임시 로그인 경로가 준비되어야 한다. +- 서버 조건: FE dev 서버, BE API, 프록시 또는 gateway 경로가 응답해야 한다. +- 데이터 조건: 편집 대상 레이어, 샘플 객체, 테스트 BBOX를 확보해야 한다. +- 인증 차단 시 대체 경로: `COMMON_DOCS_ROOT/작업내용/공통-로그인/001-de-공통-로그인-로컬임시로그인-검토계획.md` 기준으로 로컬 로그인 가능 여부를 먼저 확인한다. +- 자동화 한계 예상 항목: canvas drag, vertex 직접 조작은 수동 검증 전환 가능 항목으로 본다. + +## 1-1. 공통 동기화 확인 +- 공통 작업현황판 확인 여부: 확인 +- 파일점유현황 확인 여부: 확인 +- 결정로그 확인 여부: 확인 +- 기존 진행 중 작업과의 관계: 지도 메인 화면 `map/page.tsx`는 최근 유하방향표출 작업 이력이 있어 동일 파일 충돌 주의가 필요하다. + +## 2. 작업 목적 +- 기존 하수관망도 지도 화면에 `GIS편집` 기능을 추가해 사용자가 편집 대상 레이어를 고른 뒤 객체 추가, 이동, 꼭지점편집, 삭제, 저장까지 한 흐름에서 수행할 수 있게 한다. +- 프론트는 저장 전까지 편집 변경분을 로컬 세션으로 관리하고, 저장 시 BE가 제공할 편집 저장 API에 맞춰 payload를 전달한다. +- 백엔드 개발자가 바로 이어받을 수 있도록 필요한 API, 화면 상태, 예외 흐름을 FE 기준으로 구체화한다. +- 실제 API 구현은 범위 밖이며, 백엔드 공유용 요청 사항은 `작업내용` 문서로 분리해 관리한다. + +## 3. 수정 대상 파일 +| 구분 | 파일 경로 | 수정 여부 | 근거 | +| --- | --- | --- | --- | +| 화면 | `FE_APP_ROOT/app/(map-layout)/map/page.tsx` | 예정 | 지도 메인 화면에서 우측 기능 버튼, 편집 모드 상태, OpenLayers interaction 연결 필요 | +| 컴포넌트 | `FE_APP_ROOT/app/(map-layout)/map/components/gisEdit/*` | 예정 | 툴바, 세션 패널, 편집 훅 분리 후보 | +| 팝업/선택 | `FE_APP_ROOT/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx` | 가능성 있음 | 편집 모드 중 기존 상세 팝업 충돌 차단 필요 | +| API 호출 | `FE_APP_ROOT/app/(map-layout)/map/page.tsx` 또는 `components/gisEdit/*` | 부분 완료 | 현재는 저장 payload 미리보기까지 구현, 실제 API 호출은 후속 | +| 공통 비즈니스 컴포넌트 | `FE_BC_ROOT/**` | 기본 제외 | 초기 구현은 `apps/sn` 내부 해결 우선 | + +## 3-1. 쓰기 범위와 충돌 주의 +| 구분 | 경로 | 담당 역할 | 처리 기준 | +| --- | --- | --- | --- | +| 쓰기 허용 | `apps/sn/**` | FE | 지도 화면, 편집 상태, API 호출 작업 | +| 쓰기 허용 | `packages/common/business_components/**` | FE | 꼭 필요한 경우에만 최소 범위 수정 | +| 쓰기 금지 | `packages/common/business_components` 외 공통 패키지 | FE | 문제 원인과 영향 범위만 기록 | +| 쓰기 금지 | 타 앱 `apps/*` | FE | 문제 원인과 영향 범위만 기록 | + +- 충돌 주의 파일: + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` + - `FE_APP_ROOT/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx` +- 인수인계 메모: + - `map/page.tsx`는 이미 레이어 트리, 검색, 유하방향표출까지 담당 중이라 편집 기능을 그대로 누적하면 과도하게 비대해질 수 있다. + - 가능하면 `components/gisEdit` 하위로 상태/뷰를 나눠야 한다. +- 파일점유 등록 여부: 공통 파일점유현황에 계획 작업 반영 + +## 4. 화면 흐름 +- 진입 경로: `관망도편집 > 하수관망도 편집`의 지도형 메인 화면 +- 사용자 조작: + - 우측 `GIS편집` 클릭 + - 편집 대상 레이어 선택 + - `추가`, `이동`, `꼭지점편집`, `삭제` 중 선택 + - 저장 클릭 +- 정상 흐름: + - 편집 모드 진입 + - 레이어 선택 전 도구 비활성 + - 레이어 선택 후 도구 활성 + - `추가`는 geometry family별로 사용자가 지도에서 직접 형상을 캡처 + - `이동`은 preview drag로 위치 조정 + - `꼭지점편집`은 선택 객체의 vertex marker를 노출하고 양 끝점을 포함해 이동 + - 변경분이 로컬 편집 세션에 누적 + - 저장 시 원본과 분리된 편집본 저장 요청 +- 빈 결과 흐름: + - 변경분이 없으면 저장 비활성 또는 안내 메시지 표시 +- 오류 흐름: + - 편집 대상 미선택 + - 미지원 레이어 선택 + - 객체 미선택 + - geometry 오류 + - 저장 실패 시 임시 변경분 유지 + +## 5. API 연동 +| 기능 | Method | URL | 요청 | 응답 | 확인 상태 | +| --- | --- | --- | --- | --- | --- | +| 기존 레이어 트리 조회 | `GET` | `/api/v1/map/lyrStngs/SN_NETWORK` | 기존 지도 레이어 그룹 | 레이어 트리 데이터 | 기존 사용 중 | +| 편집 가능 레이어 메타 조회 | `GET` | `/api/v1/map/gisEditLayers` | `lyrGrp`, `screenId` | 편집 가능 레이어, geometry 타입, 허용 도구 | BE 신규 필요 | +| 편집 저장 | `POST` | `/api/v1/map/gisEditSessions/save` | 레이어 정보, view, `changes[]` | `sessionId`, `editFileId`, `versionNo`, `savedCount` | BE 신규 필요 | + +- FE 메모: + - 저장 전까지 변경분은 FE 로컬 상태로 보관한다. + - 현재 구현은 실제 호출 대신 payload 미리보기만 생성한다. + - BE 요청 사항은 `FE_APP_DOCS_ROOT/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-프론트공유API요청.md`에 별도 정리한다. + +## 6. 영향 범위 +- `apps/sn` 화면 영향: + - 지도 메인 화면 상단 툴바 + - 우측 기능 버튼 + - 편집 세션 패널 + - 기존 피처 클릭 상세와의 충돌 제어 +- `business_components` 영향: + - 초기 계획상 없음 + - `Gis` 공통 확장이 꼭 필요해지면 별도 승인/영향분석 후 검토 +- 백엔드 API 영향: + - 편집 가능 레이어 메타 API + - 저장 API + - 원본 분리 저장 및 참조 갱신 로직 +- 다른 앱 영향 가능성: + - 현재 계획상 없음 + - `apps/sn` 내부 구현 우선 + +## 6-1. 멀티에이전트 작업 분리 +- FE 담당 산출물: + - 편집 모드 토글 + - 툴바 및 세션 패널 + - OpenLayers 편집 interaction + - 저장 payload 조립 +- BE 담당 산출물: + - 편집 가능 레이어 메타 조회 + - 저장 API + - 원본 분리 저장, 편집본 참조 갱신 +- QA 담당 산출물: + - 편집 대상 미선택/객체 미선택/geometry 오류/저장 실패 검증 + - 저장 후 원본 보호 검증 +- 병렬 진행 가능 항목: + - FE 로컬 상태/툴바 UI 설계 + - BE API 명세 확정 +- 순차 진행 필요 항목: + - 저장 payload 최종 필드 확정 후 FE-BE 연동 검증 + +## 6-2. 채팅방 간 충돌 방지 +- 같은 기능을 다른 채팅방에서 진행 중인지: 현재 공통 작업현황판에는 신규 기능 구현 행이 아직 없다. 이번 문서 작성으로 최초 등록한다. +- 겹치는 파일 또는 폴더: + - `map/page.tsx` + - `featureClickEventPopup.tsx` + - `FE_APP_DOCS_ROOT/작업계획/**`는 문서 재정리 흔적이 있어 경로 유지 주의 +- 점유 충돌 시 처리 방식: + - 문서와 실제 코드 작업을 분리하고, 코드 착수 전에 파일점유현황을 다시 갱신한다. + +## 7. 검증 방법 +- 빌드 명령: + - 개발 서버 미실행 확인 후 `pnpm --filter sn run build` + - 필요 시 `pnpm turbo run build --filter=sn` +- 브라우저 URL: + - 기본 `FE_APP_ROOT/app/(map-layout)/map/page.tsx` 진입 화면 +- 사용자 조작: + - `GIS편집` 클릭 + - 레이어 선택 전/후 도구 활성 상태 확인 + - `추가`, `이동`, `꼭지점편집`, `삭제` 각각 1회 수행 + - 저장 클릭 +- 기대 결과: + - 레이어 선택 전 편집 도구 비활성 + - 변경분 누적 시 저장 활성 + - 저장 실패 시 임시 변경분 유지 + - 저장 성공 시 원본 보존 메시지 표시 +- 네트워크 응답 확인 기준: + - `gisEditLayers` 응답에 editable 여부와 도구 목록 포함 + - `gisEditSessions/save` 요청에 `changes[]`가 변경 유형별로 정확히 담김 + - 저장 성공 응답에 편집본 식별자와 버전 정보 포함 + +## 7-1. 하네스 관측 포인트 +- 화면 관측 + - `GIS편집` 버튼 강조 여부 + - 툴바 노출 여부 + - 선택 레이어명 + - 상태 메시지 + - 변경 목록 행 수 +- 지도 상호작용 관측 + - `ADD` 완료 후 신규 항목 생성 + - `MOVE` drag 종료 후 preview label과 목록 요약 변화 + - `VERTEX_EDIT` 시 vertex marker 또는 modify handle 노출 + - `DELETE` 시 삭제 예정 스타일 유지 +- 세션 관측 + - `undo`, `reset`, `canSave` + - 저장 실패 시 임시 변경분 유지 +- 차단 사유 분류 + - 기능 결함 + - 인증 차단 + - 환경 차단 + - 데이터 차단 + - 자동화 한계 + +## 8. 롤백 방법 +- `GIS편집` 토글과 편집 툴바 UI 제거 +- `components/gisEdit/*` 신규 파일 제거 +- 편집 저장 API 호출 분기 제거 +- 기존 지도 조회/레이어 트리/피처 상세 흐름만 남기기 + +## 9. 예상 위험 +- `map/page.tsx`가 이미 크기와 책임이 커서 기능 누적 시 회귀 위험이 높다. +- 점형/선형 객체의 편집 interaction이 달라 공통 훅 설계가 어색해질 수 있다. +- 편집 가능 레이어/geometry 타입/저장 대상이 확정되지 않으면 FE 구현이 임의 가정을 하게 될 수 있다. +- 편집 모드 중 기존 피처 클릭 상세 팝업이 뜨면 객체 선택/이동과 충돌할 수 있다. + +## 10. 완료 기준 +- `GIS편집` 버튼으로 편집 모드 on/off 가능 +- 레이어 선택 전 편집 도구 비활성 +- 선택 레이어 기준으로 `추가`, `이동`, `꼭지점편집`, `삭제` 최소 동작 가능 +- 변경분이 로컬 편집 세션에 누적되고 목록으로 확인 가능 +- 저장 payload가 설계 문서 계약과 맞게 구성됨 +- BE 공유용 API 요청 문서가 별도 정리됨 +- 브라우저에서 정상/예외 시나리오를 확인할 수 있음 +- 결과 보고 시 작업 목적, 수정 대상 파일, 영향 범위, 검증 방법, 롤백, 리스크를 정리할 수 있음 diff --git a/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md b/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md new file mode 100644 index 000000000..2a99ad3aa --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md @@ -0,0 +1,227 @@ +# SFR-012-003 하수관로편집 통합 구현 기록 + +## 1. 문서 목적 + +- `SFR-012-003 하수관로편집`의 FE 구현 이력, 결정사항, 남은 리스크를 기능당 1개 문서로 누적 관리한다. +- 기존 `001~006` 구현 문서를 이 문서로 흡수한다. +- 이후 라운드부터는 새 파일을 추가하지 않고 이 문서 내부 헤더로만 구분한다. + +## 2. 입력 근거 + +- `COMMON_DOCS_ROOT/작업계획/SFR-012-003-하수관로편집/001-de-SFR-012-003-하수관로편집-설계.md` +- `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_화면시나리오_v1_1.md` +- `FE_APP_DOCS_ROOT/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md` + +## 3. 공통 계약 + +- 편집 결과는 원본 GIS를 직접 수정하지 않고 FE 로컬 세션에 우선 누적한다. +- `ADD`, `MOVE`, `VERTEX_EDIT`, `DELETE`는 동시에 활성화하지 않는다. +- `ADD` + - geometry family별 직접 캡처 방식으로 구현한다. + - `POINT`는 클릭 1회, `LINE`은 연속 클릭 + 종료, `POLYGON`은 연속 클릭 + 폐합 종료를 따른다. +- `MOVE` + - preview feature drag 기반으로 유지한다. +- `VERTEX_EDIT` + - QGIS vertex tool 기준으로 vertex marker를 노출하고, 양 끝점 포함 vertex 이동이 가능해야 한다. +- `DELETE` + - 원본을 즉시 지우지 않고 임시 삭제 상태로만 유지한다. + +## 3-1. 편집도구 아이콘 기준 + +현재 구현과 문서 표기는 화면 시안 기준 편집도구 아이콘 의미를 따른다. + +| 도구 | 아이콘 | 메모 | +| --- | --- | --- | +| 추가 | | geometry family별 직접 캡처 대상 | +| 이동 | | preview drag 유지 기준 | +| 꼭지점편집 | | 양 끝점 포함 vertex 조정 목표 | +| 삭제 | | 임시 삭제 상태 유지 | +| 저장 | | 원본 분리 저장 계약 | + +## 4. 구현 라운드 요약 + +### 4.1 초기 범위 정리 + +- FE 로컬 세션, 툴바, 저장 payload 미리보기 구조를 준비했다. +- 백엔드 공유용 편집 메타/저장 API 초안을 정리했다. + +### 4.2 레이어/도구 확장 + +- 편집 후보 레이어를 7종으로 확장했다. +- 레이어별 허용 도구 활성 규칙을 추가했다. +- 세션 `undo`, `reset`을 추가했다. + +### 4.3 `MOVE` + +- preview feature 기반 `Translate` interaction을 연결했다. +- 같은 객체 재선택 시 중복 change 누적을 줄이는 focus 흐름을 추가했다. +- 현재 `MOVE`는 유지 기준으로 본다. + +### 4.4 `VERTEX_EDIT` + +- 여러 차례 interaction 보강을 시도했으나, 요구사항과 다른 구현 흔적이 남았다. +- 현재 핵심 재정렬 기준: + - vertex marker가 보일 것 + - 양 끝점 이동 가능 + - `MOVE`와 서로 회귀를 일으키지 않을 것 + +### 4.5 현재 라운드 `ADD` 재구현 시작 + +- 기존 지도 중심 임시 생성 방식을 폐기했다. +- `ADD` 도구 선택 시 geometry type별 `Draw` interaction을 연결하는 방향으로 재구현 중이다. + +### 4.6 2026-05-15 세션 유지 및 상호작용 회귀 대응 + +- 사용자 피드백 기준 이슈 + - `ADD`, `MOVE`, `VERTEX_EDIT` 변경분이 화면에서 바로 사라져 세션 추적이 어렵다. + - `MOVE` 선택 후 드래그가 실제로 움직이지 않는다. + - `VERTEX_EDIT`는 주황색 점이 기대보다 빈약하게 보이고 드래그 편집이 체감되지 않는다. +- 이번 라운드 반영 + - `GIS편집` 모드를 꺼도 변경분이 있으면 세션 패널과 지도 preview를 유지하도록 조정했다. + - preview layer를 `updateWhileInteracting=true`로 바꾸고, interaction start/end 시 `renderSync`와 source 변경 반영을 보강했다. + - 원본 피처 선택 시 layerId 비교가 namespace/feature id 형식 차이로 깨지지 않도록 layerId 정규화를 추가했다. + - interaction effect가 `gisEdit` 전체 객체 변경에 과민하게 반응하지 않도록 의존성을 좁혀 `MOVE`, `VERTEX_EDIT` 재연결 흐름을 안정화했다. + - 세션 패널은 편집 모드 종료 후에도 변경분이 남아 있으면 계속 노출되도록 바꿨다. + - 신규 추가 preview는 레이어별 색상을 우선 사용하도록 보강해 같은 화면에서 여러 시설을 편집해도 사용자가 구분할 수 있게 했다. + - `하수처리시설(tb_il_swage_prcs_fclt)`은 실제 레이어 설정 기준에 맞춰 `Point`로 정정하고 `VERTEX_EDIT` 대상에서 제외했다. + +### 4.7 2026-05-15 편집 세션 패널 축소/확장 UX 보강 + +- 편집 세션이 길어질 때 지도를 과도하게 가리는 문제가 있어 기본 패널은 최근 5건만 표시하도록 줄였다. +- 기본 패널에는 `총 N건` 배지와 `전체 보기` 버튼을 추가했다. +- `전체 보기`는 드래그 가능한 팝업으로 분리해 사용자가 지도 위에서 위치를 옮기며 확인할 수 있게 했다. +- 편집 모드를 종료한 뒤에도 세션이 남아 있으면 compact 패널과 전체 보기 팝업에서 계속 확인할 수 있다. + +### 4.8 2026-05-15 편집 세션 패널 단순화 + +- 사용자 피드백에 따라 `전체 보기` 팝업은 제거했다. +- 대신 기본 패널 높이를 최근 5건 정도만 보이는 크기로 고정하고, 패널 내부 스크롤로 아래 이력을 바로 확인할 수 있게 바꿨다. +- 결과적으로 지도 맥락은 유지하면서도 추가 팝업 조작 없이 세션 전체를 훑을 수 있는 구조로 단순화했다. +- 후속 조정으로 패널 높이를 한 번 더 줄이고, 하단 안내 문구 영역도 제거해 화면 점유를 더 낮췄다. + +### 4.9 2026-05-15 QGIS Vertex Tool 기준 재설계 착수 + +- QGIS 공식 문서 기준 확인 결과, Vertex Tool은 “모든 실꼭지점 표시”, “끝점 이동”, “segment 중간 가상점 추가”, “selection persistent”를 기본으로 제공한다. +- 기존 구현은 OpenLayers `Modify` 기본 overlay 위주여서 marker 표시와 endpoint drag 체감이 QGIS와 크게 달랐다. +- 이번 라운드부터 `VERTEX_EDIT`는 아래 구조로 재구성한다. + - preview geometry와 분리된 vertex handle 전용 레이어 + - 실꼭지점 handle + - segment 중간 가상 handle + - handle drag 후 geometry 재조립 및 세션 반영 + +### 4.10 2026-05-15 `VERTEX_EDIT` QGIS 유사화 재구현 + +- 이번 라운드 결정 + - `ADD`, `MOVE`는 기준선으로 유지하고 `VERTEX_EDIT`만 재구현 대상으로 제한했다. + - OpenLayers `Modify` 기본 overlay로 되돌리지 않고, 기존 custom handle layer 방향을 유지하되 실험 코드를 단순화했다. + - “모든 midpoint 상시 표시” 방식은 버리고 “실꼭지점 상시 + hover segment midpoint 1건” 구조로 바꿨다. +- 코드 반영 + - 선택 preview feature의 실제 vertex만 항상 렌더링하도록 handle 생성 함수를 분리했다. + - `LineString`, `MultiLineString`, `Polygon`, `MultiPolygon`에서 segment 후보를 계산하는 midpoint candidate 수집 유틸을 추가했다. + - pointer move 기준으로 가장 가까운 segment 1건만 midpoint handle을 노출하고, vertex 근처에서는 midpoint를 숨기도록 pixel tolerance 로직을 넣었다. + - midpoint drag 시 해당 segment 사이에 새 좌표를 삽입하고, vertex drag 시 기존 좌표만 이동하도록 geometry 재조립 흐름을 유지했다. + - interaction 시작/종료 중 midpoint hover가 섞여 드래그가 흔들리지 않도록 dragging flag와 pointer move cleanup을 분리했다. + - `MOVE` preview layer의 `clickPsblYn="Y"` 기준은 그대로 유지했고, vertex layer는 계속 별도 레이어로 유지했다. +- 사용자 체감 개선 목표 + - 어떤 점이 실꼭지점인지 즉시 보인다. + - midpoint는 필요한 segment 근처에서만 나타나 화면이 덜 산개된다. + - endpoint도 일반 vertex와 같은 방식으로 조작된다. + +### 4.11 2026-05-15 편집 세션창 팝업 전환 및 도구 아이콘 정리 + +- 사용자 추가 요청 기준 반영 + - 편집 세션창의 “현재 구현 ... 검토합니다” 안내 영역을 제거했다. + - 지도 좌하단 고정 세션 패널은 제거하고, 기존 `window popup` 체계의 `GIS 편집 세션` 팝업으로 전환했다. + - 팝업 기본 크기는 세션 목록과 payload 미리보기를 함께 볼 수 있게 `520 x 820`으로 잡았다. + - `추가`, `이동`, `꼭지점편집`, `삭제` 버튼은 분석도구와 같은 `lucide-react` 아이콘을 붙였다. +- 구현 방식 + - 메인 `/map` 화면은 GIS 편집 모드 진입 시 세션 팝업을 열고, postMessage로 세션 snapshot을 동기화한다. + - 팝업에서 `마지막 변경 취소`, `세션 초기화`, `항목 focus`를 누르면 다시 메인 창으로 메시지를 보내 실제 편집 세션을 제어한다. + - 기존 `gisEditPopup` placeholder는 유지하고, 세션 전용 팝업은 `gisEditSessionPopup`으로 별도 등록했다. + +### 4.12 2026-05-15 중복 endpoint handle 통합 + +- 사용자 피드백 기준 이슈 + - 관로 시점/종점 주변에 같은 위치의 주황색 꼭지점이 2개씩 겹쳐 보이는 경우가 있었다. +- 이번 라운드 반영 + - vertex handle 생성 시 raw vertex를 그대로 전부 노출하지 않고, 같은 좌표를 공유하는 vertex들은 화면에서 handle 1개로 묶었다. + - 단순 표시만 합친 것이 아니라, 묶인 handle을 드래그하면 같은 좌표를 공유하던 raw vertex들을 함께 이동시키도록 처리했다. + - 내부 `MultiLineString`/분리 part 구조는 유지하고, 표시와 드래그 동작만 통합했다. + +### 4.13 2026-05-15 외부 제출용 단일 산출물 생성 + +- 사용자 요청으로 오늘 작업 내역, 설계 변경, 검증 계획, 검증 결과를 묶은 제출용 단일 `md` 파일을 별도 생성한다. +- 이는 통합 문서 체계 예외 산출물이며, 기능 운영 문서는 기존 canonical 4문서 체계를 계속 기준으로 유지한다. + +## 5. 기능별 현재 상태 + +### 5.1 `ADD` + +- 요구사항 + - 사용자가 지도 위에서 직접 점/선/면을 캡처해야 한다. +- 이전 상태 + - 지도 중심 기준 임시 geometry 자동 생성 +- 현재 상태 + - `Draw` interaction 기반으로 전환 중 +- 남은 과제 + - 선형/면형 종료 UX + - 캡처 취소 UX + - 세션 summary와 preview label 정교화 + - 신규 추가 색상은 레이어 메타 우선, 없으면 FE fallback 색으로 처리 + +### 5.2 `MOVE` + +- preview drag 방식으로 동작 확인 +- 유지 기준 + - 선택 객체 1건 기준 preview 생성 + - drag 종료 후 세션 geometry 갱신 +- 2026-05-15 보강 + - preview layer redraw와 interaction 재바인딩 타이밍을 안정화했다. + - 실제 브라우저 수동 drag 재확인은 아직 남아 있다. + +### 5.3 `VERTEX_EDIT` + +- 요구사항 + - 선택 객체의 vertex marker가 표시되어야 함 + - 양 끝점 이동 가능 + - 중간 vertex 이동 가능 +- 현재 리스크 + - 하수관로 geometry가 `LineString`/`MultiLineString`으로 섞여 들어온다. + - interaction 구현이 흔들리면 `MOVE`까지 회귀할 수 있다. +- 다음 기준 + - 하수관로를 기준으로 QGIS형 vertex handle 흐름부터 고정 +- 2026-05-15 보강 + - preview geometry와 별도인 vertex handle 레이어를 유지하되, 실꼭지점만 상시 표시하고 midpoint는 hover segment에 대해서만 노출하도록 재구현했다. + - endpoint drag, midpoint drag 후 vertex 삽입, geometry 재조립 흐름을 하나의 custom handle 규칙으로 통일했다. + - `하수처리시설`은 point라서 `VERTEX_EDIT` 대상이 아니라는 기준을 코드와 문서에 함께 맞췄다. + +### 5.4 `DELETE` + +- 임시 삭제 세션 흐름은 존재 +- 삭제 대상 시각 구분 강화는 후속 + +## 6. BE 공유 사항 + +- `GET /api/v1/map/gisEditLayers` + - geometry type/family + - editable 여부 + - allowed tools +- `POST /api/v1/map/gisEditSessions/save` + - 변경 유형별 geometry payload + - 원본 분리 저장 + +## 7. 남은 리스크 + +- `map/page.tsx`에 interaction 책임이 많이 몰려 있어 회귀 위험이 높다. +- `VERTEX_EDIT`는 하수관로 기준 단순화가 먼저 필요하다. +- `ADD`가 QGIS capture 경험과 완전히 같아지려면 종료/취소 UX를 더 다듬어야 한다. +- canvas 기반 `MOVE`, `VERTEX_EDIT`는 자동화 브라우저 검증이 제한적이라 실제 드래그 체감은 수동 확인이 남아 있다. + +## 8. 흡수한 기존 문서 + +- `001-fe-SFR-012-003-하수관로편집-프론트공유API요청.md` +- `002-fe-SFR-012-003-하수관로편집-GIS편집상태재검토.md` +- `003-fe-SFR-012-003-하수관로편집-세션도구확장.md` +- `004-fe-SFR-012-003-하수관로편집-실제지도조작확장.md` +- `005-fe-SFR-012-003-하수관로편집-MOVE실드래그구현.md` +- `006-fe-SFR-012-003-하수관로편집-VERTEX_EDIT실조작구현.md` diff --git a/apps/sn/package.json b/apps/sn/package.json index 20aa6581d..fd74ab346 100644 --- a/apps/sn/package.json +++ b/apps/sn/package.json @@ -28,6 +28,7 @@ "js-cookie": "^3.0.5", "moment": "^2.30.1", "next": "15.3.8", + "lucide-react": "^0.474.0", "react-chartjs-2": "^5.3.0", "react-hot-toast": "^2.5.2", "react-dnd": "^16.0.1", diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts index fe2ee66ba..6e88666cb 100644 --- a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -103,6 +103,12 @@ export const windowPopupDefinitions = { width: 1080, height: 760, }, + gisEditSessionPopup: { + key: "gisEditSessionPopup", + title: "GIS 편집 세션", + width: 520, + height: 820, + }, gisManagementPopup: { key: "gisManagementPopup", title: "GIS 관리", diff --git a/apps/sn/src/features/windowPopup/model/types.ts b/apps/sn/src/features/windowPopup/model/types.ts index 8387e03c1..f46e747d6 100644 --- a/apps/sn/src/features/windowPopup/model/types.ts +++ b/apps/sn/src/features/windowPopup/model/types.ts @@ -31,6 +31,11 @@ export const WINDOW_POPUP_MESSAGE_TYPE = { FLOOD_PRE_UPLOAD_COMPLETED: "sn:popup:floodPreUploadCompleted", MAP_SEARCH_SELECT_FEATURE: "sn:popup:mapSearchSelectFeature", MAP_SEARCH_SHOW_MULTIPLE: "sn:popup:mapSearchShowMultiple", + GIS_EDIT_SESSION_READY: "sn:popup:gisEditSessionReady", + GIS_EDIT_SESSION_SYNC: "sn:popup:gisEditSessionSync", + GIS_EDIT_SESSION_FOCUS_CHANGE: "sn:popup:gisEditSessionFocusChange", + GIS_EDIT_SESSION_REMOVE_LAST_CHANGE: "sn:popup:gisEditSessionRemoveLastChange", + GIS_EDIT_SESSION_CLEAR_CHANGES: "sn:popup:gisEditSessionClearChanges", } as const; export type WindowPopupMessageType = diff --git a/apps/sn/src/features/windowPopup/registry.tsx b/apps/sn/src/features/windowPopup/registry.tsx index c76d9064f..cffa5a5c6 100644 --- a/apps/sn/src/features/windowPopup/registry.tsx +++ b/apps/sn/src/features/windowPopup/registry.tsx @@ -13,6 +13,7 @@ import SearchModalPopupContent from "./ui/SearchModalPopupContent"; import SscDetailPopupContent from "./ui/SscDetailPopupContent"; import type { WindowPopupContentProps } from "./model/types"; import FeaturePlaceholderPopupContent from "./ui/FeaturePlaceholderPopupContent"; +import GisEditSessionPopupContent from "./ui/GisEditSessionPopupContent"; import PipeAnalysisPopupContent from "./ui/PipeAnalysisPopupContent"; import PipeAnalysisDetailPopupContent from "./ui/PipeAnalysisDetailPopupContent"; @@ -107,6 +108,10 @@ export const windowPopupRegistry = { ...windowPopupDefinitions.gisEditPopup, component: GisEditPopupContent, }, + gisEditSessionPopup: { + ...windowPopupDefinitions.gisEditSessionPopup, + component: GisEditSessionPopupContent, + }, gisManagementPopup: { ...windowPopupDefinitions.gisManagementPopup, component: GisManagementPopupContent, diff --git a/apps/sn/src/features/windowPopup/ui/GisEditSessionPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/GisEditSessionPopupContent.tsx new file mode 100644 index 000000000..54b035702 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/GisEditSessionPopupContent.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import GisEditSessionPanel from "@/app/(map-layout)/map/components/gisEdit/GisEditSessionPanel"; +import type { GisEditSessionSnapshot } from "@/app/(map-layout)/map/components/gisEdit/gisEditTypes"; +import { + WINDOW_POPUP_MESSAGE_TYPE, + type WindowPopupContentProps, +} from "../model/types"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; + +const EMPTY_SNAPSHOT: GisEditSessionSnapshot = { + isEditMode: false, + selectedLayerName: "", + activeTool: null, + changes: [], + lastSavedPayload: null, + canUndo: false, + focusedChangeId: null, +}; + +const postMessageToOpener = (type: string, payload?: Record) => { + if (typeof window === "undefined" || !window.opener) return; + window.opener.postMessage({ type, payload }, window.location.origin); +}; + +export default function GisEditSessionPopupContent(_props: WindowPopupContentProps) { + const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT); + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + const data = event.data; + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_SYNC) { + setSnapshot((data.payload ?? EMPTY_SNAPSHOT) as GisEditSessionSnapshot); + } + }; + + window.addEventListener("message", handleMessage); + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_READY); + return () => { + window.removeEventListener("message", handleMessage); + }; + }, []); + + return ( +
+
+

+ {windowPopupDefinitions.gisEditSessionPopup.title} +

+
+ +
+ { + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_REMOVE_LAST_CHANGE); + }} + onClearChanges={() => { + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_CLEAR_CHANGES); + }} + onFocusChange={(changeId) => { + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_FOCUS_CHANGE, { + changeId, + }); + }} + /> +
+
+ ); +} diff --git a/apps/sn/src/types/lucide-react.d.ts b/apps/sn/src/types/lucide-react.d.ts new file mode 100644 index 000000000..708828bd1 --- /dev/null +++ b/apps/sn/src/types/lucide-react.d.ts @@ -0,0 +1,44 @@ +declare module "lucide-react" { + import type { ComponentType, SVGProps } from "react"; + + export type LucideProps = SVGProps & { + size?: string | number; + color?: string; + strokeWidth?: string | number; + absoluteStrokeWidth?: boolean; + }; + + type LucideIcon = ComponentType; + + export const ChartColumnBig: LucideIcon; + export const ArrowLeft: LucideIcon; + export const ArrowRight: LucideIcon; + export const CalendarIcon: LucideIcon; + export const Check: LucideIcon; + export const CheckIcon: LucideIcon; + export const ChevronDown: LucideIcon; + export const ChevronDownIcon: LucideIcon; + export const ChevronLeft: LucideIcon; + export const ChevronLeftIcon: LucideIcon; + export const ChevronRight: LucideIcon; + export const ChevronRightIcon: LucideIcon; + export const ChevronUp: LucideIcon; + export const Circle: LucideIcon; + export const CloudUpload: LucideIcon; + export const History: LucideIcon; + export const Move: LucideIcon; + export const MoreHorizontal: LucideIcon; + export const PanelsTopLeft: LucideIcon; + export const PanelLeft: LucideIcon; + export const Paperclip: LucideIcon; + export const PenTool: LucideIcon; + export const PencilRuler: LucideIcon; + export const Plus: LucideIcon; + export const Search: LucideIcon; + export const Settings2: LucideIcon; + export const Trash2: LucideIcon; + export const TriangleAlert: LucideIcon; + export const Waves: LucideIcon; + export const X: LucideIcon; + export const XIcon: LucideIcon; +} diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index 4b903d087..2fae15fc9 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -111,7 +111,7 @@ function Gis({ (data) => { onFeatureSelect?.(data); // 선택된 피쳐 데이터 콜백 호출 }, - [mapInstanceRef], + [onFeatureSelect], ); // 지도 생성 및 초기화 diff --git a/packages/common/business_components/src/ui/map/GisSelect.js b/packages/common/business_components/src/ui/map/GisSelect.js index 38729b2b2..c17563724 100644 --- a/packages/common/business_components/src/ui/map/GisSelect.js +++ b/packages/common/business_components/src/ui/map/GisSelect.js @@ -1,25 +1,33 @@ -import React, { useContext, useEffect } from "react"; +import React, { useContext, useEffect, useRef } from "react"; import { GisContext } from "./Gis"; import GisApp from "./lib/gis.core"; import GisOlSelect from "./lib/gis.select"; export default React.memo(function GisSelect({ onFeatureSelect }) { const { gisMap } = useContext(GisContext); + const onFeatureSelectRef = useRef(onFeatureSelect); + + useEffect(() => { + onFeatureSelectRef.current = onFeatureSelect; + }, [onFeatureSelect]); useEffect(() => { if (!gisMap) return; // 아직 초기화 안 됐으면 아무것도 안 함 - // 이미 select가 있으면 재생성하지 않음 const select = new GisOlSelect(gisMap); GisApp.Module.select = select; // 피처 선택 시 콜백 등록 select.getSelectedInfo(function (features) { if (features?.[0]) { - onFeatureSelect(features[0]); + onFeatureSelectRef.current?.(features[0]); } }); - }, [gisMap, onFeatureSelect]); + + return () => { + select.removeInteration?.(); + }; + }, [gisMap]); // const select = new GisOlSelect(gisMap); // GisApp.Module.select = select; From d2a1f5e48bd5b473c8cdbddc2e8b1a2261229a1a Mon Sep 17 00:00:00 2001 From: kiminchan Date: Fri, 15 May 2026 15:34:25 +0900 Subject: [PATCH 003/185] =?UTF-8?q?=EC=A0=95=EB=B3=B4=EA=B2=80=EC=83=89,?= =?UTF-8?q?=20=EA=B4=80=EB=A1=9C=EB=B6=84=EC=84=9D(=EA=B4=80=EC=9E=AC?= =?UTF-8?q?=EC=A7=88)=20=ED=8C=9D=EC=97=85=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=20=EA=B0=80=EB=A1=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx | 6 +++--- apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx index 97cb703cd..da8248a1d 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx @@ -411,7 +411,7 @@ export default function PipeAnalysisPopupPage() { <>

- 寃쎄낵?꾨룄&?ъ쭏 愿€濡쒗쁽?? 珥?{yearMaterialGroups.length.toLocaleString("ko-KR")}嫄? + 경과년도&재질 관로현황 총 {yearMaterialGroups.length.toLocaleString("ko-KR")}건

{resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 관로현황 총 {yearMaterialGroups.length.toLocaleString("ko-KR")}건 @@ -485,7 +485,7 @@ export default function PipeAnalysisPopupPage() { ) : ( - 議고쉶??寃곌낵媛€ ?놁뒿?덈떎. + 조회 결과가 없습니다. )} @@ -499,7 +499,7 @@ export default function PipeAnalysisPopupPage() { className={appliedFilters.analysisType === "YEAR_MATERIAL" ? "hidden" : useHorizontalScrollTable - ? "[&>div.relative.w-full]:overflow-x-auto [&>div.relative.w-full]:overflow-y-visible" + ? "overflow-x-auto overflow-y-visible [&_table]:min-w-full [&_table]:w-max" : ""} headName={ appliedFilters.analysisType === "YEAR_MATERIAL" diff --git a/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx b/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx index 809647bf6..82f9f7fe3 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx @@ -113,7 +113,7 @@ const SearchModal: React.FC = ({ renderMode = "launcher" }) => }); if (!popupWindow) { - alert('?앹뾽??李⑤떒?섏뿀?듬땲?? 釉뚮씪?곗? ?앹뾽 ?덉슜 ?ㅼ젙???뺤씤?댁<?몄슂.'); + alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요."); } }; @@ -183,9 +183,9 @@ const SearchModal: React.FC = ({ renderMode = "launcher" }) => {/* 📋 결과 리스트 */} {/* 기본정보 탭 */} {activeTab === 'basic' && ( -
+
- { const label = BASIC_TYPES.find((type) => type.val === basicType)?.text return (<>{label} 조회 결과); From ef7bae98d40477d21a7e75af7ce308de435a54ba Mon Sep 17 00:00:00 2001 From: kiminchan Date: Mon, 18 May 2026 10:23:33 +0900 Subject: [PATCH 004/185] =?UTF-8?q?=ED=95=98=EC=88=98=EA=B4=80=EB=A7=9D?= =?UTF-8?q?=EB=8F=84=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysisMenu/AnalysisPopupMenu.tsx | 24 +- .../gisManagement/GisManagementPopupPage.tsx | 539 ++++++++++++++++++ apps/sn/app/(map-layout)/map/page.tsx | 5 +- .../001-qa-SFR-012-002-하수관망도 업로드-통합검증계획.md | 65 +++ .../002-qa-SFR-012-002-하수관망도 업로드-통합검증결과.md | 63 ++ .../001-fe-SFR-012-002-하수관망도 업로드-작업카드.md | 174 ++++++ .../001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md | 108 ++++ .../windowPopup/model/popupDefinitions.ts | 4 +- apps/sn/src/features/windowPopup/registry.tsx | 7 +- .../ui/GisManagementPopupContent.tsx | 10 + 10 files changed, 988 insertions(+), 11 deletions(-) create mode 100644 apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx create mode 100644 apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/001-qa-SFR-012-002-하수관망도 업로드-통합검증계획.md create mode 100644 apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/002-qa-SFR-012-002-하수관망도 업로드-통합검증결과.md create mode 100644 apps/sn/docs/작업계획/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-작업카드.md create mode 100644 apps/sn/docs/작업내용/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md create mode 100644 apps/sn/src/features/windowPopup/ui/GisManagementPopupContent.tsx diff --git a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx index b7686f4b0..902be2916 100644 --- a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx +++ b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx @@ -16,10 +16,11 @@ import { import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions"; type Props = { - onOpenPopup: (popupKey: WindowPopupKey) => void; + onOpenPopup: (popupKey: WindowPopupKey) => Window | null; onOpenInfoSearch: () => void; onToggleGisEdit?: () => void; isGisEditActive?: boolean; + isInfoSearchActive?: boolean; }; const ANALYSIS_POPUP_ITEMS: Array<{ @@ -41,6 +42,7 @@ export default function AnalysisPopupMenu({ onOpenInfoSearch, onToggleGisEdit, isGisEditActive = false, + isInfoSearchActive = false, }: Props) { const [isOpen, setIsOpen] = useState(true); const [activeKey, setActiveKey] = useState(null); @@ -52,6 +54,12 @@ export default function AnalysisPopupMenu({ } }, [activeKey, isGisEditActive]); + useEffect(() => { + if (!isInfoSearchActive && activeKey === "infoSearch") { + setActiveKey(null); + } + }, [activeKey, isInfoSearchActive]); + return (
@@ -94,7 +102,19 @@ export default function AnalysisPopupMenu({ return; } - onOpenPopup(item.key); + const popupWindow = onOpenPopup(item.key); + if (!popupWindow) { + setActiveKey(null); + return; + } + + const closeWatcher = window.setInterval(() => { + if (!popupWindow.closed) return; + window.clearInterval(closeWatcher); + setActiveKey((currentKey) => + currentKey === item.key ? null : currentKey, + ); + }, 300); }} > { + const lastDotIndex = fileName.lastIndexOf("."); + if (lastDotIndex < 0) return ""; + return fileName.slice(lastDotIndex).toLowerCase(); +}; + +const buildTodayDate = () => new Date().toISOString().slice(0, 10); +const GIS_UPLOAD_PAGE_SIZE = 10; + +export default function GisManagementPopupPage() { + // 브라우저 파일 picker는 제어형 input보다 ref 제어가 안전하다. + const fileInputRef = useRef(null); + + const [selectedFacilityTypeCode, setSelectedFacilityTypeCode] = useState(""); + const [selectedFile, setSelectedFile] = useState(null); + const [uploadHistory, setUploadHistory] = useState( + INITIAL_UPLOAD_HISTORY, + ); + const [selectedUploadId, setSelectedUploadId] = useState( + INITIAL_UPLOAD_HISTORY[0]?.uploadId ?? "", + ); + const [currentPage, setCurrentPage] = useState(1); + + const selectedFacility = useMemo( + () => + UPLOAD_FACILITY_TYPES.find( + (facilityType) => + facilityType.facilityTypeCode === selectedFacilityTypeCode, + ) ?? null, + [selectedFacilityTypeCode], + ); + + const selectedHistory = useMemo( + () => + uploadHistory.find((history) => history.uploadId === selectedUploadId) ?? + null, + [selectedUploadId, uploadHistory], + ); + + const pagedUploadHistory = useMemo(() => { + const startIndex = (currentPage - 1) * GIS_UPLOAD_PAGE_SIZE; + return uploadHistory.slice(startIndex, startIndex + GIS_UPLOAD_PAGE_SIZE); + }, [currentPage, uploadHistory]); + + const resetSelectedFile = () => { + setSelectedFile(null); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + const handleFacilityTypeChange = ( + event: ChangeEvent, + ) => { + setSelectedFacilityTypeCode(event.target.value); + resetSelectedFile(); + }; + + const handleOpenFilePicker = () => { + if (!selectedFacility) { + window.alert("시설구분을 먼저 선택하세요."); + return; + } + + fileInputRef.current?.click(); + }; + + const handleFileSelected = (event: ChangeEvent) => { + const nextFile = event.target.files?.[0] ?? null; + + if (!selectedFacility) { + resetSelectedFile(); + window.alert("시설구분을 먼저 선택한 뒤 파일을 선택하세요."); + return; + } + + if (!nextFile) { + resetSelectedFile(); + return; + } + + const fileExtension = getFileExtension(nextFile.name); + const isAllowedExtension = selectedFacility.allowedExtensions.includes( + fileExtension, + ); + + if (!isAllowedExtension) { + resetSelectedFile(); + window.alert( + `업로드 가능한 GIS 파일 형식이 아닙니다. 허용 형식: ${selectedFacility.allowedExtensions.join(", ")}`, + ); + return; + } + + const maxFileSizeInBytes = selectedFacility.maxFileSizeMb * 1024 * 1024; + if (nextFile.size > maxFileSizeInBytes) { + resetSelectedFile(); + window.alert( + `파일 크기가 제한을 초과했습니다. 최대 ${selectedFacility.maxFileSizeMb}MB까지 업로드할 수 있습니다.`, + ); + return; + } + + setSelectedFile(nextFile); + }; + + const handleUpload = () => { + if (!selectedFacility) { + window.alert("시설구분을 선택하세요."); + return; + } + + if (!selectedFile) { + window.alert("업로드할 파일을 선택하세요."); + return; + } + + const isDuplicateFile = uploadHistory.some( + (history) => + history.facilityTypeCode === selectedFacility.facilityTypeCode && + history.fileName.toLowerCase() === selectedFile.name.toLowerCase(), + ); + + if (isDuplicateFile) { + window.alert("동일 시설구분에 같은 파일명이 존재합니다."); + return; + } + + const nextUploadHistory: UploadHistoryItem = { + uploadId: `GUP-MOCK-${Date.now()}`, + facilityTypeCode: selectedFacility.facilityTypeCode, + facilityTypeName: selectedFacility.facilityTypeName, + fileName: selectedFile.name, + registeredAt: buildTodayDate(), + registeredBy: "admin", + }; + + setUploadHistory((currentHistory) => [ + nextUploadHistory, + ...currentHistory, + ]); + setCurrentPage(1); + setSelectedUploadId(nextUploadHistory.uploadId); + window.alert("GIS 업로드가 완료되었습니다."); + resetSelectedFile(); + }; + + const handleDownload = () => { + if (!selectedHistory) { + window.alert("다운로드할 파일을 선택하세요."); + return; + } + + window.alert(`${selectedHistory.fileName} 다운로드를 요청했습니다.`); + }; + + return ( +
+
+
+

+ GIS 관리 +

+
+ +
+
+ GIS 업로드 +
+ +
+
+ + + +
+ +
+ + + +
+
+ + + +
+
+ GIS 업로드 현황 +
+ + +
+ +
+ + + + + no + + + 시설구분 + + + 파일명 + + + 등록일자 + + + 등록자 + + + + + + {pagedUploadHistory.map((history, index) => { + const isSelected = history.uploadId === selectedUploadId; + const backgroundColor = isSelected + ? "#eef6ff" + : index % 2 === 0 + ? "#ffffff" + : "#f9fbfd"; + const rowNumber = + (currentPage - 1) * GIS_UPLOAD_PAGE_SIZE + index + 1; + + return ( + setSelectedUploadId(history.uploadId)} + style={{ backgroundColor }} + > + + {rowNumber} + + + {history.facilityTypeName} + + + {history.fileName} + + + {history.registeredAt} + + + {history.registeredBy} + + + ); + })} + +
+
+ + {uploadHistory.length > GIS_UPLOAD_PAGE_SIZE ? ( +
+ +
+ ) : null} +
+
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 2a2b6f0a0..6d6944f20 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -2102,12 +2102,14 @@ const Map = () => { const popup = openWindowPopup(popupKey); if (!popup) { alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); - return; + return null; } if (popupKey === "pipeAnalysisPopup") { pipeAnalysisPopupWindowRef.current = popup; } + + return popup; }, []); const openGisEditSessionPopup = useCallback(() => { @@ -2721,6 +2723,7 @@ const Map = () => { onOpenInfoSearch={() => setIsSearchModalOpen(true)} onToggleGisEdit={handleToggleGisEdit} isGisEditActive={gisEdit.isEditMode} + isInfoSearchActive={isSearchModalOpen} /> ( ); -const GisManagementPopupContent = (_props: WindowPopupContentProps) => ( - -); - const FlowRiskPopupContent = (_props: WindowPopupContentProps) => ( ); diff --git a/apps/sn/src/features/windowPopup/ui/GisManagementPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/GisManagementPopupContent.tsx new file mode 100644 index 000000000..9e2dce7d7 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/GisManagementPopupContent.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react"; +import GisManagementPopupPage from "@/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage"; + +export default function GisManagementPopupContent() { + return ( + + + + ); +} From 0c66a8f3b7bdd1e0a13726f6b5005d6627b12e00 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Tue, 19 May 2026 13:39:25 +0900 Subject: [PATCH 005/185] =?UTF-8?q?=ED=86=B5=EC=88=98=EC=9C=84=ED=97=98?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=ED=94=84=EB=A1=9C=ED=86=A0=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../waterRisk/WaterRiskPopupPage.tsx | 876 ++++++++++++++++ .../components/waterRisk/waterRiskTypes.ts | 936 ++++++++++++++++++ apps/sn/app/(map-layout)/map/page.tsx | 799 ++++++++++++++- .../001-qa-SFR-011-007-통수위험분석-통합검증계획.md | 93 ++ .../002-qa-SFR-011-007-통수위험분석-통합검증결과.md | 116 +++ .../001-fe-SFR-011-007-통수위험분석-작업카드.md | 175 ++++ .../001-fe-SFR-011-007-통수위험분석-통합구현기록.md | 161 +++ .../windowPopup/model/popupDefinitions.ts | 2 +- .../src/features/windowPopup/model/types.ts | 8 + apps/sn/src/features/windowPopup/registry.tsx | 7 +- .../windowPopup/ui/WaterRiskPopupContent.tsx | 12 + 11 files changed, 3177 insertions(+), 8 deletions(-) create mode 100644 apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx create mode 100644 apps/sn/app/(map-layout)/map/components/waterRisk/waterRiskTypes.ts create mode 100644 apps/sn/docs/작업검증/SFR-011-007-통수위험분석/001-qa-SFR-011-007-통수위험분석-통합검증계획.md create mode 100644 apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md create mode 100644 apps/sn/docs/작업계획/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-작업카드.md create mode 100644 apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md create mode 100644 apps/sn/src/features/windowPopup/ui/WaterRiskPopupContent.tsx diff --git a/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx new file mode 100644 index 000000000..a8671987a --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx @@ -0,0 +1,876 @@ +"use client"; + +import { Fragment, useEffect, useMemo, useState } from "react"; +import UiPageNations from "@common/business_components/ui/table/uiPageNations"; + +import { Button } from "@common/components"; + +import { + WINDOW_POPUP_MESSAGE_TYPE, + type WindowPopupContentProps, +} from "@/features/windowPopup/model/types"; +import { + buildWaterRiskMethodLabel, + buildWaterRiskSettingAreaName, + buildWaterRiskStatusLabel, + EMPTY_WATER_RISK_SESSION_SNAPSHOT, + WATER_RISK_RESULT_ROWS, + WATER_RISK_SETTING_ROWS, + WATER_RISK_SGG_OPTIONS_BY_SIDO, + WATER_RISK_SIDO_OPTIONS, + type WaterRiskResultRow, + type WaterRiskSessionSnapshot, + type WaterRiskSettingRow, + type WaterRiskStagedPipe, +} from "./waterRiskTypes"; + +type WaterRiskView = "result" | "setting"; + +type ResultFilters = { + analysisDate: string; + analysisTime: string; + sidoName: string; + sggName: string; + areaId: string; + riskStatus: "ALL" | "DANGER" | "NORMAL"; +}; + +type SettingFilters = { + sidoName: string; + sggName: string; + areaId: string; +}; + +const DEFAULT_RESULT_FILTERS: ResultFilters = { + analysisDate: "2026-05-18", + analysisTime: "09:00", + sidoName: "전체", + sggName: "전체", + areaId: "ALL", + riskStatus: "ALL", +}; + +const DEFAULT_SETTING_FILTERS: SettingFilters = { + sidoName: "전체", + sggName: "전체", + areaId: "ALL", +}; + +const TOOLBAR_INPUT_CLASS_NAME = + "h-[30px] rounded-none border border-[#8f98a3] bg-white px-2 text-[12px] text-[#1f2937] outline-none"; + +const TOOLBAR_BUTTON_CLASS_NAME = + "h-[30px] min-w-[44px] rounded-none border border-[#7c5a20] bg-[#ef8d2f] px-3 text-[12px] font-semibold text-white hover:bg-[#df7f24]"; + +const SECONDARY_BUTTON_CLASS_NAME = + "h-[28px] rounded-none border border-[#8f98a3] bg-white px-3 text-[12px] text-[#334155] hover:bg-[#f8fafc]"; + +const ACTION_BUTTON_CLASS_NAME = + "h-[28px] rounded-[6px] border border-[#2e77bc] bg-[#3f8fd7] px-3 text-[12px] font-semibold text-white hover:bg-[#337ec1]"; +const WATER_RISK_PAGE_SIZE = 10; +const RESULT_TABLE_CONTAINER_CLASS_NAME = + "min-h-0 overflow-hidden rounded-[10px] border border-[#d6e1ee] bg-[linear-gradient(180deg,#ffffff_0%,#f9fbfe_100%)] shadow-[0_6px_16px_rgba(31,59,87,0.06)]"; +const SETTING_TABLE_CONTAINER_CLASS_NAME = + "min-h-0 overflow-hidden rounded-[10px] border border-[#d6e1ee] bg-[linear-gradient(180deg,#ffffff_0%,#f9fbfe_100%)] shadow-[0_6px_16px_rgba(31,59,87,0.06)]"; + +const postMessageToOpener = (type: string, payload?: Record) => { + if (typeof window === "undefined" || !window.opener) return; + window.opener.postMessage({ type, payload }, window.location.origin); +}; + +const buildPreviewTimestamp = () => new Date().toISOString(); + +const buildResultDownloadPreview = ( + filters: ResultFilters, + rows: WaterRiskResultRow[], +) => ({ + endpoint: "/api/v1/map/wraResults/download", + method: "POST", + generatedAt: buildPreviewTimestamp(), + filters, + resultCount: rows.length, +}); + +const buildSettingDownloadPreview = ( + filters: SettingFilters, + rows: WaterRiskSettingRow[], +) => ({ + endpoint: "/api/v1/map/wraOutletPipeSettings/download", + method: "POST", + generatedAt: buildPreviewTimestamp(), + filters, + resultCount: rows.length, +}); + +const buildSavePreview = ( + snapshot: WaterRiskSessionSnapshot, + stagedPipes: WaterRiskStagedPipe[], +) => ({ + endpoint: "/api/v1/map/wraOutletPipeSettings/save", + method: "POST", + generatedAt: buildPreviewTimestamp(), + body: { + focusAreaId: snapshot.selectedSettingAreaId, + focusAreaName: snapshot.selectedSettingAreaName, + pipes: stagedPipes.map((pipe, index) => ({ + pipeId: pipe.pipeId, + setSeq: index + 1, + })), + }, +}); + +const getRiskBadgeClassName = (riskStatus: "DANGER" | "NORMAL") => + riskStatus === "DANGER" + ? "bg-[#ff1c15] text-white" + : "bg-white text-[#111827]"; + +export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { + const [activeView, setActiveView] = useState("result"); + const [snapshot, setSnapshot] = useState( + EMPTY_WATER_RISK_SESSION_SNAPSHOT, + ); + const [draftResultFilters, setDraftResultFilters] = useState( + DEFAULT_RESULT_FILTERS, + ); + const [appliedResultFilters, setAppliedResultFilters] = useState( + DEFAULT_RESULT_FILTERS, + ); + const [draftSettingFilters, setDraftSettingFilters] = useState( + DEFAULT_SETTING_FILTERS, + ); + const [appliedSettingFilters, setAppliedSettingFilters] = useState( + DEFAULT_SETTING_FILTERS, + ); + const [selectedResultAreaId, setSelectedResultAreaId] = useState( + WATER_RISK_RESULT_ROWS[0]?.areaId ?? "", + ); + const [selectedSettingAreaId, setSelectedSettingAreaId] = useState(""); + const [resultCurrentPage, setResultCurrentPage] = useState(1); + const [settingCurrentPage, setSettingCurrentPage] = useState(1); + const [noticeMessage, setNoticeMessage] = useState(""); + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + + if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SYNC) return; + + const nextSnapshot = (event.data.payload ?? + EMPTY_WATER_RISK_SESSION_SNAPSHOT) as WaterRiskSessionSnapshot; + setSnapshot(nextSnapshot); + if (nextSnapshot.selectedAreaId) { + setSelectedResultAreaId(nextSnapshot.selectedAreaId); + } + if (nextSnapshot.selectedSettingAreaId) { + setSelectedSettingAreaId(nextSnapshot.selectedSettingAreaId); + } + }; + + window.addEventListener("message", handleMessage); + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_READY); + + return () => { + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_STOP_SETTING_EDIT); + window.removeEventListener("message", handleMessage); + }; + }, []); + + const visibleResultRows = useMemo(() => { + return WATER_RISK_RESULT_ROWS.filter((row) => { + if ( + appliedResultFilters.sidoName !== "전체" && + row.sidoName !== appliedResultFilters.sidoName + ) { + return false; + } + + if ( + appliedResultFilters.sggName !== "전체" && + row.sggName !== appliedResultFilters.sggName + ) { + return false; + } + + if ( + appliedResultFilters.areaId !== "ALL" && + row.areaId !== appliedResultFilters.areaId + ) { + return false; + } + + if ( + appliedResultFilters.riskStatus !== "ALL" && + row.riskStatus !== appliedResultFilters.riskStatus + ) { + return false; + } + + return true; + }); + }, [appliedResultFilters]); + + const visibleSettingRows = useMemo(() => { + return WATER_RISK_SETTING_ROWS.filter((row) => { + if ( + appliedSettingFilters.sidoName !== "전체" && + row.sidoName !== appliedSettingFilters.sidoName + ) { + return false; + } + + if ( + appliedSettingFilters.sggName !== "전체" && + row.sggName !== appliedSettingFilters.sggName + ) { + return false; + } + + if ( + appliedSettingFilters.areaId !== "ALL" && + row.areaId !== appliedSettingFilters.areaId + ) { + return false; + } + + return true; + }); + }, [appliedSettingFilters]); + + const maxPipeCount = useMemo( + () => + visibleResultRows.reduce( + (maxCount, row) => Math.max(maxCount, row.pipeSummaries.length), + 0, + ), + [visibleResultRows], + ); + + const resultAreaOptions = useMemo( + () => + WATER_RISK_RESULT_ROWS.map((row) => ({ + areaId: row.areaId, + areaName: row.areaName, + })), + [], + ); + + const settingAreaOptions = useMemo(() => { + const areaMap = new Map(); + WATER_RISK_SETTING_ROWS.forEach((row) => { + areaMap.set(row.areaId, { + areaId: row.areaId, + areaName: row.areaName, + }); + }); + return Array.from(areaMap.values()); + }, []); + + const resultSggOptions = + WATER_RISK_SGG_OPTIONS_BY_SIDO[draftResultFilters.sidoName] ?? + WATER_RISK_SGG_OPTIONS_BY_SIDO["전체"] ?? + []; + const settingSggOptions = + WATER_RISK_SGG_OPTIONS_BY_SIDO[draftSettingFilters.sidoName] ?? + WATER_RISK_SGG_OPTIONS_BY_SIDO["전체"] ?? + []; + + const currentSettingAreaName = + snapshot.selectedSettingAreaName || + buildWaterRiskSettingAreaName(selectedSettingAreaId || snapshot.selectedSettingAreaId); + + const pagedResultRows = useMemo(() => { + const startIndex = (resultCurrentPage - 1) * WATER_RISK_PAGE_SIZE; + return visibleResultRows.slice(startIndex, startIndex + WATER_RISK_PAGE_SIZE); + }, [resultCurrentPage, visibleResultRows]); + + const pagedSettingRows = useMemo(() => { + const startIndex = (settingCurrentPage - 1) * WATER_RISK_PAGE_SIZE; + return visibleSettingRows.slice(startIndex, startIndex + WATER_RISK_PAGE_SIZE); + }, [settingCurrentPage, visibleSettingRows]); + + const showSettingEditor = snapshot.isSettingEditMode || snapshot.stagedPipes.length > 0; + const resultPipeGroupCount = Math.max(1, maxPipeCount); + const resultTableMinWidth = 560 + resultPipeGroupCount * 180; + const settingTableMinWidth = 900; + + useEffect(() => { + if (typeof window === "undefined" || !window.resizeTo) return; + + const popupWidth = + activeView === "result" + ? Math.min(1320, Math.max(860, 620 + resultPipeGroupCount * 180)) + : showSettingEditor + ? 1180 + : 980; + const popupHeight = activeView === "setting" && showSettingEditor ? 900 : 720; + + window.resizeTo(popupWidth, popupHeight); + }, [activeView, resultPipeGroupCount, showSettingEditor]); + + const handleApplyResultFilters = () => { + if (!draftResultFilters.analysisDate || !draftResultFilters.analysisTime) { + setNoticeMessage("기준 날짜와 시간을 입력해야 조회할 수 있습니다."); + return; + } + + setResultCurrentPage(1); + setAppliedResultFilters(draftResultFilters); + setNoticeMessage("통수위험 분석 결과를 갱신했습니다."); + }; + + const handleOpenSettingView = () => { + setActiveView("setting"); + setNoticeMessage(""); + }; + + const handleReturnToResultView = () => { + setActiveView("result"); + setNoticeMessage(""); + }; + + const handleSelectResultRow = (row: WaterRiskResultRow) => { + setSelectedResultAreaId(row.areaId); + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT, { + areaId: row.areaId, + }); + setNoticeMessage(`${row.areaName}을 지도에서 강조했습니다.`); + }; + + const handlePreviewResultDownload = () => { + const preview = buildResultDownloadPreview(appliedResultFilters, visibleResultRows); + console.info("WaterRisk result download preview", preview); + setNoticeMessage("결과 다운로드 preview를 생성했습니다. 콘솔을 확인하세요."); + }; + + const handleApplySettingFilters = () => { + setSettingCurrentPage(1); + setAppliedSettingFilters(draftSettingFilters); + setNoticeMessage("설정 현황 목록을 갱신했습니다."); + }; + + const handleStopSettingEdit = () => { + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_STOP_SETTING_EDIT); + setNoticeMessage("통수위험 설정 편집 모드를 종료했습니다."); + }; + + const handleRemoveStagedPipe = (pipeId: string) => { + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_REMOVE_STAGED_PIPE, { + pipeId, + }); + }; + + const handleMoveStagedPipe = (pipeId: string, direction: "UP" | "DOWN") => { + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_REORDER_STAGED_PIPE, { + pipeId, + direction, + }); + }; + + const handleClearStagedPipes = () => { + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_CLEAR_STAGED_PIPES); + }; + + const handlePreviewSave = () => { + if (!snapshot.selectedSettingAreaId || snapshot.stagedPipes.length === 0) { + setNoticeMessage("저장할 유출관로가 없습니다."); + return; + } + + const preview = buildSavePreview(snapshot, snapshot.stagedPipes); + console.info("WaterRisk save preview", preview); + setNoticeMessage("설정 저장 payload preview를 생성했습니다. 콘솔을 확인하세요."); + }; + + const handleSelectSettingArea = (areaId: string) => { + setSelectedSettingAreaId(areaId); + + const targetAreaName = + settingAreaOptions.find((item) => item.areaId === areaId)?.areaName ?? + buildWaterRiskSettingAreaName(areaId); + + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_START_SETTING_EDIT, { + areaId, + areaName: targetAreaName, + }); + setNoticeMessage(`${targetAreaName} 설정 편집 대상을 선택했습니다.`); + }; + + return ( +
+
+ {activeView === "setting" ? ( + + ) : null} +

+ {activeView === "result" ? "통수위험분석" : "통수위험분석 설정"} +

+
+ +
+ {activeView === "result" ? ( +
+
+
+
+ + setDraftResultFilters((current) => ({ + ...current, + analysisDate: event.target.value, + })) + } + aria-label="년월일" + className={`${TOOLBAR_INPUT_CLASS_NAME} w-[120px]`} + /> + + setDraftResultFilters((current) => ({ + ...current, + analysisTime: event.target.value, + })) + } + aria-label="시간" + className={`${TOOLBAR_INPUT_CLASS_NAME} w-[100px]`} + /> + + + + +
+
+ + +
+
+
+ +
+
+ + + + + + + + + {Array.from({ length: maxPipeCount }).map((_, index) => ( + + ))} + + + + {Array.from({ length: maxPipeCount }).map((_, index) => ( + + + + + ))} + + + + {pagedResultRows.map((row, index) => { + const isSelected = row.areaId === selectedResultAreaId; + const rowNumber = + (resultCurrentPage - 1) * WATER_RISK_PAGE_SIZE + index + 1; + + return ( + handleSelectResultRow(row)} + > + + + + + + {Array.from({ length: maxPipeCount }).map((_, pipeIndex) => { + const pipe = row.pipeSummaries[pipeIndex]; + return ( + + + + + ); + })} + + + ); + })} + +
no시도시군구중점관리지역예측강우량(mm) + 관로 ID {index + 1} + 통수위험
유출유량(㎥/hr)통수량(㎥/hr)
{rowNumber}{row.sidoName}{row.sggName}{row.areaName}{row.forecastRainQty.toFixed(1)} + {pipe ? pipe.runoffFlowQty.toFixed(1) : ""} + + {pipe ? pipe.conveyQty.toFixed(1) : ""} + + + {buildWaterRiskStatusLabel(row.riskStatus)} + +
+
+
+ + {visibleResultRows.length > WATER_RISK_PAGE_SIZE ? ( +
+ +
+ ) : null} +
+ ) : ( +
+
+
+
+ + + +
+
+ +
+
+
+ +
+
+ + + + + + + + + + + + + + + + {pagedSettingRows.map((row, index) => { + const isSelected = + row.areaId === (selectedSettingAreaId || snapshot.selectedSettingAreaId); + + return ( + handleSelectSettingArea(row.areaId)} + > + + + + + + + + + + + ); + })} + +
시도시군구중점관리지역유출관로ID관경연장재질설정순번설정완료여부
{row.sidoName}{row.sggName}{row.areaName}{row.pipeId}{row.pipeDia}{row.pipeLen}{row.pipeMaterial}{row.setSeq} + {row.setCompleteYn === "Y" ? "완료" : "미완료"} +
+
+
+ + {visibleSettingRows.length > WATER_RISK_PAGE_SIZE ? ( +
+ +
+ ) : null} + + {showSettingEditor ? ( +
+
+
+
+ 설정 편집 대상: {currentSettingAreaName || "선택 전"} +
+
+ + + +
+
+ +
+ + + + + + + + + + + + + + {snapshot.stagedPipes.length === 0 ? ( + + + + ) : ( + snapshot.stagedPipes.map((pipe, index) => ( + + + + + + + + + + )) + )} + +
순번유출관로ID관경연장재질선택원천조작
+ staged 유출관로가 없습니다. 지도에서 하수관로를 선택하세요. +
{index + 1}{pipe.pipeId}{pipe.pipeDia}{pipe.pipeLen}{pipe.pipeMaterial}{pipe.source} +
+ + + +
+
+
+
+
+ ) : null} +
+ )} +
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/waterRisk/waterRiskTypes.ts b/apps/sn/app/(map-layout)/map/components/waterRisk/waterRiskTypes.ts new file mode 100644 index 000000000..ad94da2e3 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/waterRisk/waterRiskTypes.ts @@ -0,0 +1,936 @@ +"use client"; + +export type WaterRiskStatus = "DANGER" | "NORMAL"; +export type WaterRiskCalculationMethod = "METHOD_1" | "METHOD_2"; +export type WaterRiskTab = "result" | "setting"; + +export type WaterRiskPipeSummary = { + pipeId: string; + runoffFlowQty: number; + conveyQty: number; +}; + +export type WaterRiskResultRow = { + areaId: string; + areaName: string; + sidoName: string; + sggName: string; + forecastRainQty: number; + riskStatus: WaterRiskStatus; + calculationMethod: WaterRiskCalculationMethod; + pipeSummaries: WaterRiskPipeSummary[]; +}; + +export type WaterRiskSettingRow = { + areaId: string; + areaName: string; + sidoName: string; + sggName: string; + pipeId: string; + pipeDia: string; + pipeLen: string; + pipeMaterial: string; + setSeq: number; + setCompleteYn: "Y" | "N"; +}; + +export type WaterRiskStagedPipe = { + pipeId: string; + pipeDia: string; + pipeLen: string; + pipeMaterial: string; + setSeq: number; + source: "MAP" | "MOCK"; +}; + +export type WaterRiskGridCell = { + gridId: string; + rainQty: number; + colorLevel: "LEVEL_1" | "LEVEL_2" | "LEVEL_3" | "LEVEL_4"; + polygonCoordinates: number[][][]; +}; + +export type WaterRiskAreaMapData = { + areaId: string; + areaName: string; + polygonCoordinates: number[][][]; + gridCells: WaterRiskGridCell[]; +}; + +export type WaterRiskMapPipe = { + pipeId: string; + displayLabel: string; + lineCoordinates: number[][]; +}; + +export type WaterRiskSessionSnapshot = { + selectedAreaId: string | null; + selectedSettingAreaId: string | null; + selectedSettingAreaName: string; + isSettingEditMode: boolean; + stagedPipes: WaterRiskStagedPipe[]; + statusMessage: string; +}; + +export const EMPTY_WATER_RISK_SESSION_SNAPSHOT: WaterRiskSessionSnapshot = { + selectedAreaId: null, + selectedSettingAreaId: null, + selectedSettingAreaName: "", + isSettingEditMode: false, + stagedPipes: [], + statusMessage: "", +}; + +const buildRectanglePolygon = ( + centerX: number, + centerY: number, + halfWidth: number, + halfHeight: number, +) => { + return [[ + [centerX - halfWidth, centerY - halfHeight], + [centerX + halfWidth, centerY - halfHeight], + [centerX + halfWidth, centerY + halfHeight], + [centerX - halfWidth, centerY + halfHeight], + [centerX - halfWidth, centerY - halfHeight], + ]]; +}; + +const buildGridCells = ( + centerX: number, + centerY: number, + values: number[], +): WaterRiskGridCell[] => { + const cellHalfSize = 180; + const offsets = [ + [-190, 190], + [190, 190], + [-190, -190], + [190, -190], + ] as const; + + return offsets.map(([offsetX, offsetY], index) => { + const rainQty = values[index] ?? values[values.length - 1] ?? 0; + const colorLevel = rainQty >= 40 + ? "LEVEL_4" + : rainQty >= 30 + ? "LEVEL_3" + : rainQty >= 20 + ? "LEVEL_2" + : "LEVEL_1"; + + return { + gridId: `GRID-${index + 1}`, + rainQty, + colorLevel, + polygonCoordinates: buildRectanglePolygon( + centerX + offsetX, + centerY + offsetY, + cellHalfSize, + cellHalfSize, + ), + }; + }); +}; + +export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [ + { + areaId: "AREA-001", + areaName: "A 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + forecastRainQty: 42.0, + riskStatus: "DANGER", + calculationMethod: "METHOD_1", + pipeSummaries: [ + { pipeId: "PIPE-10284", runoffFlowQty: 18.5, conveyQty: 15.2 }, + { pipeId: "PIPE-10291", runoffFlowQty: 11.3, conveyQty: 12.8 }, + ], + }, + { + areaId: "AREA-002", + areaName: "B 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + forecastRainQty: 31.4, + riskStatus: "NORMAL", + calculationMethod: "METHOD_1", + pipeSummaries: [ + { pipeId: "PIPE-45103", runoffFlowQty: 9.8, conveyQty: 14.7 }, + ], + }, + { + areaId: "AREA-003", + areaName: "C 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + forecastRainQty: 37.8, + riskStatus: "DANGER", + calculationMethod: "METHOD_2", + pipeSummaries: [ + { pipeId: "PIPE-60014", runoffFlowQty: 16.1, conveyQty: 12.9 }, + { pipeId: "PIPE-60018", runoffFlowQty: 10.2, conveyQty: 11.1 }, + { pipeId: "PIPE-60021", runoffFlowQty: 8.7, conveyQty: 9.5 }, + ], + }, + { + areaId: "AREA-004", + areaName: "D 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + forecastRainQty: 24.3, + riskStatus: "NORMAL", + calculationMethod: "METHOD_2", + pipeSummaries: [ + { pipeId: "PIPE-72010", runoffFlowQty: 7.2, conveyQty: 11.4 }, + { pipeId: "PIPE-72018", runoffFlowQty: 6.1, conveyQty: 9.8 }, + ], + }, + { + areaId: "AREA-005", + areaName: "E 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + forecastRainQty: 28.6, + riskStatus: "NORMAL", + calculationMethod: "METHOD_1", + pipeSummaries: [ + { pipeId: "PIPE-83014", runoffFlowQty: 8.3, conveyQty: 12.1 }, + ], + }, + { + areaId: "AREA-006", + areaName: "F 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + forecastRainQty: 45.1, + riskStatus: "DANGER", + calculationMethod: "METHOD_2", + pipeSummaries: [ + { pipeId: "PIPE-83018", runoffFlowQty: 17.4, conveyQty: 12.3 }, + { pipeId: "PIPE-83019", runoffFlowQty: 13.6, conveyQty: 10.1 }, + ], + }, + { + areaId: "AREA-007", + areaName: "G 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + forecastRainQty: 33.2, + riskStatus: "NORMAL", + calculationMethod: "METHOD_1", + pipeSummaries: [ + { pipeId: "PIPE-91004", runoffFlowQty: 9.7, conveyQty: 12.5 }, + ], + }, + { + areaId: "AREA-008", + areaName: "H 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + forecastRainQty: 39.4, + riskStatus: "DANGER", + calculationMethod: "METHOD_2", + pipeSummaries: [ + { pipeId: "PIPE-91010", runoffFlowQty: 15.5, conveyQty: 11.6 }, + { pipeId: "PIPE-91012", runoffFlowQty: 12.4, conveyQty: 10.8 }, + { pipeId: "PIPE-91014", runoffFlowQty: 9.2, conveyQty: 9.0 }, + ], + }, + { + areaId: "AREA-009", + areaName: "I 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + forecastRainQty: 26.1, + riskStatus: "NORMAL", + calculationMethod: "METHOD_1", + pipeSummaries: [ + { pipeId: "PIPE-99001", runoffFlowQty: 6.7, conveyQty: 10.4 }, + ], + }, + { + areaId: "AREA-010", + areaName: "J 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + forecastRainQty: 41.8, + riskStatus: "DANGER", + calculationMethod: "METHOD_2", + pipeSummaries: [ + { pipeId: "PIPE-99007", runoffFlowQty: 14.8, conveyQty: 10.9 }, + { pipeId: "PIPE-99009", runoffFlowQty: 10.1, conveyQty: 9.7 }, + ], + }, + { + areaId: "AREA-011", + areaName: "K 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + forecastRainQty: 29.7, + riskStatus: "NORMAL", + calculationMethod: "METHOD_1", + pipeSummaries: [ + { pipeId: "PIPE-11001", runoffFlowQty: 8.8, conveyQty: 12.5 }, + { pipeId: "PIPE-11002", runoffFlowQty: 7.9, conveyQty: 11.4 }, + ], + }, + { + areaId: "AREA-012", + areaName: "L 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + forecastRainQty: 43.6, + riskStatus: "DANGER", + calculationMethod: "METHOD_2", + pipeSummaries: [ + { pipeId: "PIPE-12001", runoffFlowQty: 16.7, conveyQty: 12.2 }, + ], + }, + { + areaId: "AREA-013", + areaName: "M 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + forecastRainQty: 35.2, + riskStatus: "DANGER", + calculationMethod: "METHOD_1", + pipeSummaries: [ + { pipeId: "PIPE-13001", runoffFlowQty: 14.4, conveyQty: 11.6 }, + { pipeId: "PIPE-13002", runoffFlowQty: 10.5, conveyQty: 10.9 }, + { pipeId: "PIPE-13003", runoffFlowQty: 8.6, conveyQty: 9.3 }, + ], + }, + { + areaId: "AREA-014", + areaName: "N 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + forecastRainQty: 21.9, + riskStatus: "NORMAL", + calculationMethod: "METHOD_2", + pipeSummaries: [ + { pipeId: "PIPE-14001", runoffFlowQty: 5.8, conveyQty: 8.7 }, + ], + }, + { + areaId: "AREA-015", + areaName: "O 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + forecastRainQty: 47.3, + riskStatus: "DANGER", + calculationMethod: "METHOD_2", + pipeSummaries: [ + { pipeId: "PIPE-15001", runoffFlowQty: 18.2, conveyQty: 13.4 }, + { pipeId: "PIPE-15002", runoffFlowQty: 12.7, conveyQty: 10.8 }, + ], + }, + { + areaId: "AREA-016", + areaName: "P 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + forecastRainQty: 27.5, + riskStatus: "NORMAL", + calculationMethod: "METHOD_1", + pipeSummaries: [ + { pipeId: "PIPE-16001", runoffFlowQty: 7.1, conveyQty: 10.3 }, + { pipeId: "PIPE-16002", runoffFlowQty: 6.2, conveyQty: 9.8 }, + ], + }, + { + areaId: "AREA-017", + areaName: "Q 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + forecastRainQty: 32.8, + riskStatus: "NORMAL", + calculationMethod: "METHOD_1", + pipeSummaries: [ + { pipeId: "PIPE-17001", runoffFlowQty: 9.9, conveyQty: 11.7 }, + ], + }, + { + areaId: "AREA-018", + areaName: "R 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + forecastRainQty: 44.9, + riskStatus: "DANGER", + calculationMethod: "METHOD_2", + pipeSummaries: [ + { pipeId: "PIPE-18001", runoffFlowQty: 17.1, conveyQty: 12.4 }, + { pipeId: "PIPE-18002", runoffFlowQty: 13.4, conveyQty: 10.6 }, + { pipeId: "PIPE-18003", runoffFlowQty: 9.8, conveyQty: 9.1 }, + ], + }, + { + areaId: "AREA-019", + areaName: "S 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + forecastRainQty: 23.4, + riskStatus: "NORMAL", + calculationMethod: "METHOD_1", + pipeSummaries: [ + { pipeId: "PIPE-19001", runoffFlowQty: 6.4, conveyQty: 9.6 }, + ], + }, + { + areaId: "AREA-020", + areaName: "T 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + forecastRainQty: 40.7, + riskStatus: "DANGER", + calculationMethod: "METHOD_2", + pipeSummaries: [ + { pipeId: "PIPE-20001", runoffFlowQty: 15.2, conveyQty: 11.4 }, + { pipeId: "PIPE-20002", runoffFlowQty: 11.9, conveyQty: 10.1 }, + ], + }, +]; + +export const WATER_RISK_SETTING_ROWS: WaterRiskSettingRow[] = [ + { + areaId: "AREA-001", + areaName: "A 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + pipeId: "PIPE-10284", + pipeDia: "900 mm", + pipeLen: "186 m", + pipeMaterial: "콘크리트", + setSeq: 1, + setCompleteYn: "Y", + }, + { + areaId: "AREA-001", + areaName: "A 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + pipeId: "PIPE-10291", + pipeDia: "700 mm", + pipeLen: "142 m", + pipeMaterial: "흄관", + setSeq: 2, + setCompleteYn: "Y", + }, + { + areaId: "AREA-002", + areaName: "B 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + pipeId: "PIPE-45103", + pipeDia: "600 mm", + pipeLen: "98 m", + pipeMaterial: "PE", + setSeq: 1, + setCompleteYn: "Y", + }, + { + areaId: "AREA-003", + areaName: "C 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + pipeId: "PIPE-60014", + pipeDia: "800 mm", + pipeLen: "154 m", + pipeMaterial: "강관", + setSeq: 1, + setCompleteYn: "Y", + }, + { + areaId: "AREA-003", + areaName: "C 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + pipeId: "PIPE-60018", + pipeDia: "700 mm", + pipeLen: "119 m", + pipeMaterial: "흄관", + setSeq: 2, + setCompleteYn: "Y", + }, + { + areaId: "AREA-004", + areaName: "D 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + pipeId: "PIPE-72010", + pipeDia: "500 mm", + pipeLen: "101 m", + pipeMaterial: "PVC", + setSeq: 1, + setCompleteYn: "N", + }, + { + areaId: "AREA-005", + areaName: "E 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + pipeId: "PIPE-83014", + pipeDia: "650 mm", + pipeLen: "133 m", + pipeMaterial: "PE", + setSeq: 1, + setCompleteYn: "Y", + }, + { + areaId: "AREA-006", + areaName: "F 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + pipeId: "PIPE-83018", + pipeDia: "900 mm", + pipeLen: "176 m", + pipeMaterial: "흄관", + setSeq: 1, + setCompleteYn: "Y", + }, + { + areaId: "AREA-006", + areaName: "F 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + pipeId: "PIPE-83019", + pipeDia: "800 mm", + pipeLen: "167 m", + pipeMaterial: "콘크리트", + setSeq: 2, + setCompleteYn: "Y", + }, + { + areaId: "AREA-007", + areaName: "G 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + pipeId: "PIPE-91004", + pipeDia: "700 mm", + pipeLen: "111 m", + pipeMaterial: "PVC", + setSeq: 1, + setCompleteYn: "Y", + }, + { + areaId: "AREA-008", + areaName: "H 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + pipeId: "PIPE-91010", + pipeDia: "1000 mm", + pipeLen: "201 m", + pipeMaterial: "강관", + setSeq: 1, + setCompleteYn: "Y", + }, + { + areaId: "AREA-008", + areaName: "H 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + pipeId: "PIPE-91012", + pipeDia: "900 mm", + pipeLen: "184 m", + pipeMaterial: "흄관", + setSeq: 2, + setCompleteYn: "Y", + }, + { + areaId: "AREA-008", + areaName: "H 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + pipeId: "PIPE-91014", + pipeDia: "800 mm", + pipeLen: "159 m", + pipeMaterial: "콘크리트", + setSeq: 3, + setCompleteYn: "Y", + }, + { + areaId: "AREA-009", + areaName: "I 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + pipeId: "PIPE-99001", + pipeDia: "600 mm", + pipeLen: "94 m", + pipeMaterial: "PE", + setSeq: 1, + setCompleteYn: "N", + }, + { + areaId: "AREA-010", + areaName: "J 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + pipeId: "PIPE-99007", + pipeDia: "850 mm", + pipeLen: "143 m", + pipeMaterial: "흄관", + setSeq: 1, + setCompleteYn: "Y", + }, + { + areaId: "AREA-010", + areaName: "J 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + pipeId: "PIPE-99009", + pipeDia: "750 mm", + pipeLen: "128 m", + pipeMaterial: "강관", + setSeq: 2, + setCompleteYn: "Y", + }, + { + areaId: "AREA-011", + areaName: "K 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + pipeId: "PIPE-11001", + pipeDia: "700 mm", + pipeLen: "121 m", + pipeMaterial: "콘크리트", + setSeq: 1, + setCompleteYn: "Y", + }, + { + areaId: "AREA-012", + areaName: "L 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + pipeId: "PIPE-12001", + pipeDia: "820 mm", + pipeLen: "147 m", + pipeMaterial: "강관", + setSeq: 1, + setCompleteYn: "N", + }, + { + areaId: "AREA-013", + areaName: "M 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + pipeId: "PIPE-13001", + pipeDia: "900 mm", + pipeLen: "163 m", + pipeMaterial: "흄관", + setSeq: 1, + setCompleteYn: "Y", + }, + { + areaId: "AREA-013", + areaName: "M 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + pipeId: "PIPE-13002", + pipeDia: "800 mm", + pipeLen: "152 m", + pipeMaterial: "콘크리트", + setSeq: 2, + setCompleteYn: "Y", + }, + { + areaId: "AREA-014", + areaName: "N 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + pipeId: "PIPE-14001", + pipeDia: "500 mm", + pipeLen: "88 m", + pipeMaterial: "PVC", + setSeq: 1, + setCompleteYn: "Y", + }, + { + areaId: "AREA-015", + areaName: "O 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + pipeId: "PIPE-15001", + pipeDia: "980 mm", + pipeLen: "194 m", + pipeMaterial: "강관", + setSeq: 1, + setCompleteYn: "Y", + }, +]; + +export const WATER_RISK_MAP_AREAS: WaterRiskAreaMapData[] = [ + { + areaId: "AREA-001", + areaName: "A 중점관리지역", + polygonCoordinates: buildRectanglePolygon(955300, 1948770, 360, 240), + gridCells: buildGridCells(955300, 1948770, [38.2, 42.0, 31.5, 28.4]), + }, + { + areaId: "AREA-002", + areaName: "B 중점관리지역", + polygonCoordinates: buildRectanglePolygon(954480, 1948350, 320, 210), + gridCells: buildGridCells(954480, 1948350, [22.4, 24.1, 20.5, 18.9]), + }, + { + areaId: "AREA-003", + areaName: "C 중점관리지역", + polygonCoordinates: buildRectanglePolygon(956260, 1948220, 390, 250), + gridCells: buildGridCells(956260, 1948220, [35.1, 37.8, 32.4, 29.6]), + }, + { + areaId: "AREA-004", + areaName: "D 중점관리지역", + polygonCoordinates: buildRectanglePolygon(957020, 1948660, 300, 210), + gridCells: buildGridCells(957020, 1948660, [19.3, 24.3, 21.7, 17.8]), + }, + { + areaId: "AREA-005", + areaName: "E 중점관리지역", + polygonCoordinates: buildRectanglePolygon(954900, 1949020, 290, 200), + gridCells: buildGridCells(954900, 1949020, [24.8, 28.6, 22.5, 20.7]), + }, + { + areaId: "AREA-006", + areaName: "F 중점관리지역", + polygonCoordinates: buildRectanglePolygon(955980, 1949040, 330, 220), + gridCells: buildGridCells(955980, 1949040, [41.7, 45.1, 36.2, 31.5]), + }, + { + areaId: "AREA-007", + areaName: "G 중점관리지역", + polygonCoordinates: buildRectanglePolygon(956760, 1947990, 280, 190), + gridCells: buildGridCells(956760, 1947990, [27.4, 33.2, 24.2, 21.1]), + }, + { + areaId: "AREA-008", + areaName: "H 중점관리지역", + polygonCoordinates: buildRectanglePolygon(957420, 1948090, 340, 230), + gridCells: buildGridCells(957420, 1948090, [34.7, 39.4, 32.8, 28.6]), + }, + { + areaId: "AREA-009", + areaName: "I 중점관리지역", + polygonCoordinates: buildRectanglePolygon(954020, 1947900, 260, 180), + gridCells: buildGridCells(954020, 1947900, [18.4, 26.1, 17.6, 15.9]), + }, + { + areaId: "AREA-010", + areaName: "J 중점관리지역", + polygonCoordinates: buildRectanglePolygon(957980, 1948900, 300, 210), + gridCells: buildGridCells(957980, 1948900, [37.9, 41.8, 33.8, 29.4]), + }, +]; + +export const WATER_RISK_MAP_PIPES_BY_AREA: Record = { + "AREA-001": [ + { + pipeId: "PIPE-10284", + displayLabel: "관로 ID 1", + lineCoordinates: [ + [955020, 1948450], + [955210, 1948570], + [955320, 1948690], + ], + }, + { + pipeId: "PIPE-10291", + displayLabel: "관로 ID 2", + lineCoordinates: [ + [955350, 1948820], + [955470, 1948680], + [955560, 1948550], + ], + }, + ], + "AREA-002": [ + { + pipeId: "PIPE-45103", + displayLabel: "관로 ID 1", + lineCoordinates: [ + [954230, 1948500], + [954360, 1948420], + [954520, 1948340], + ], + }, + ], + "AREA-003": [ + { + pipeId: "PIPE-60014", + displayLabel: "관로 ID 1", + lineCoordinates: [ + [956000, 1948420], + [956110, 1948320], + [956220, 1948210], + ], + }, + { + pipeId: "PIPE-60018", + displayLabel: "관로 ID 2", + lineCoordinates: [ + [956180, 1948480], + [956270, 1948360], + [956410, 1948240], + ], + }, + { + pipeId: "PIPE-60021", + displayLabel: "관로 ID 3", + lineCoordinates: [ + [956420, 1948420], + [956520, 1948340], + [956650, 1948240], + ], + }, + ], + "AREA-004": [ + { + pipeId: "PIPE-72010", + displayLabel: "관로 ID 1", + lineCoordinates: [ + [956770, 1948790], + [956900, 1948720], + [957050, 1948650], + ], + }, + { + pipeId: "PIPE-72018", + displayLabel: "관로 ID 2", + lineCoordinates: [ + [956980, 1948840], + [957090, 1948740], + [957210, 1948640], + ], + }, + ], + "AREA-005": [ + { + pipeId: "PIPE-83014", + displayLabel: "관로 ID 1", + lineCoordinates: [ + [954710, 1949160], + [954840, 1949080], + [954980, 1949000], + ], + }, + ], + "AREA-006": [ + { + pipeId: "PIPE-83018", + displayLabel: "관로 ID 1", + lineCoordinates: [ + [955760, 1949180], + [955910, 1949100], + [956030, 1949020], + ], + }, + { + pipeId: "PIPE-83019", + displayLabel: "관로 ID 2", + lineCoordinates: [ + [955980, 1949240], + [956120, 1949140], + [956240, 1949040], + ], + }, + ], + "AREA-007": [ + { + pipeId: "PIPE-91004", + displayLabel: "관로 ID 1", + lineCoordinates: [ + [956520, 1948120], + [956660, 1948050], + [956820, 1947980], + ], + }, + ], + "AREA-008": [ + { + pipeId: "PIPE-91010", + displayLabel: "관로 ID 1", + lineCoordinates: [ + [957150, 1948220], + [957300, 1948160], + [957470, 1948100], + ], + }, + { + pipeId: "PIPE-91012", + displayLabel: "관로 ID 2", + lineCoordinates: [ + [957350, 1948290], + [957480, 1948200], + [957610, 1948110], + ], + }, + { + pipeId: "PIPE-91014", + displayLabel: "관로 ID 3", + lineCoordinates: [ + [957540, 1948340], + [957670, 1948220], + [957790, 1948100], + ], + }, + ], + "AREA-009": [ + { + pipeId: "PIPE-99001", + displayLabel: "관로 ID 1", + lineCoordinates: [ + [953810, 1948010], + [953940, 1947960], + [954080, 1947910], + ], + }, + ], + "AREA-010": [ + { + pipeId: "PIPE-99007", + displayLabel: "관로 ID 1", + lineCoordinates: [ + [957750, 1949040], + [957900, 1948970], + [958040, 1948900], + ], + }, + { + pipeId: "PIPE-99009", + displayLabel: "관로 ID 2", + lineCoordinates: [ + [957980, 1949120], + [958120, 1949010], + [958260, 1948900], + ], + }, + ], +}; + +export const WATER_RISK_SIDO_OPTIONS = ["전체", "서울특별시", "부산광역시"]; + +export const WATER_RISK_SGG_OPTIONS_BY_SIDO: Record = { + 전체: ["전체", "강남구", "해운대구"], + 서울특별시: ["전체", "강남구"], + 부산광역시: ["전체", "해운대구"], +}; + +export const WATER_RISK_PIPE_LAYER_TYPE = "tb_ms_swage_ppl"; + +export const WATER_RISK_COLOR_LABELS: Record = { + LEVEL_1: "0.0~20.0 미만", + LEVEL_2: "20.0~30.0 미만", + LEVEL_3: "30.0~40.0 미만", + LEVEL_4: "40.0 이상", +}; + +export const buildWaterRiskStatusLabel = (status: WaterRiskStatus) => + status === "DANGER" ? "위험" : "정상"; + +export const buildWaterRiskMethodLabel = (method: WaterRiskCalculationMethod) => + method === "METHOD_1" ? "방법 1" : "방법 2"; + +export const buildWaterRiskSettingAreaName = (areaId: string | null) => { + if (!areaId) return ""; + return WATER_RISK_RESULT_ROWS.find((row) => row.areaId === areaId)?.areaName ?? ""; +}; diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 6d6944f20..90bfb5cae 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -18,7 +18,7 @@ "use client"; import dynamic from "next/dynamic"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { usePathname } from "next/navigation"; import Collection from "ol/Collection.js"; @@ -27,7 +27,9 @@ import GeoJSON from "ol/format/GeoJSON.js"; import Draw from "ol/interaction/Draw.js"; import Translate from "ol/interaction/Translate.js"; import Fill from "ol/style/Fill.js"; +import LineString from "ol/geom/LineString.js"; import Point from "ol/geom/Point.js"; +import Polygon from "ol/geom/Polygon.js"; import VectorLayer from "ol/layer/Vector.js"; import { unByKey } from "ol/Observable.js"; import VectorSource from "ol/source/Vector.js"; @@ -75,6 +77,17 @@ import { downloadAgedPipeCsv } from "./components/agedPipe/agedPipeExport"; import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup"; import SearchModal from "./searchModal/searchModal"; import { WATER_DEPTH_LAYER_TYPES, WaterDepth } from "./waterDepth/waterDepth"; +import { + buildWaterRiskStatusLabel, + EMPTY_WATER_RISK_SESSION_SNAPSHOT, + WATER_RISK_MAP_AREAS, + WATER_RISK_MAP_PIPES_BY_AREA, + WATER_RISK_PIPE_LAYER_TYPE, + WATER_RISK_RESULT_ROWS, + WATER_RISK_SETTING_ROWS, + type WaterRiskSessionSnapshot, + type WaterRiskStagedPipe, +} from "./components/waterRisk/waterRiskTypes"; const AnalysisPopupMenu = dynamic( () => import("./components/analysisMenu/AnalysisPopupMenu"), @@ -104,6 +117,9 @@ const FLOW_DIRECTION_NETWORK_CORRECTION_COLOR = "rgba(0, 168, 132, 0.95)"; const FLOW_DIRECTION_BBOX_BUFFER_RATIO = 0.35; const GIS_EDIT_PREVIEW_LAYER_ID = "SN_GIS_EDIT_PREVIEW"; const GIS_EDIT_VERTEX_LAYER_ID = "SN_GIS_EDIT_VERTEX_HANDLES"; +const WATER_RISK_AREA_LAYER_ID = "SN_WATER_RISK_AREA"; +const WATER_RISK_GRID_LAYER_ID = "SN_WATER_RISK_GRID"; +const WATER_RISK_PIPE_LAYER_ID = "SN_WATER_RISK_PIPE"; type FlowDirectionUsageStyle = { color: string; @@ -402,6 +418,180 @@ const GIS_EDIT_VERTEX_DRAGGING_STYLE = new Style({ const geometryObjectToKey = (geometry: Record | null) => geometry ? JSON.stringify(geometry) : ""; +const getWaterRiskAreaMapData = (areaId: string | null) => + WATER_RISK_MAP_AREAS.find((area) => area.areaId === areaId) ?? null; + +const getWaterRiskResultRow = (areaId: string | null) => + WATER_RISK_RESULT_ROWS.find((row) => row.areaId === areaId) ?? null; + +const buildWaterRiskInfoAnchor = (areaId: string | null) => { + const areaData = getWaterRiskAreaMapData(areaId); + if (!areaData) return null; + + const polygon = new Polygon(areaData.polygonCoordinates); + const extent = polygon.getExtent(); + const [, minY = 0, maxX = 0, maxY = 0] = extent; + return [maxX, (minY + maxY) / 2] as [number, number]; +}; + +const buildWaterRiskSeedPipes = (areaId: string) => + WATER_RISK_SETTING_ROWS + .filter((row) => row.areaId === areaId) + .sort((a, b) => a.setSeq - b.setSeq) + .map((row) => ({ + pipeId: row.pipeId, + pipeDia: row.pipeDia, + pipeLen: row.pipeLen, + pipeMaterial: row.pipeMaterial, + setSeq: row.setSeq, + source: "MOCK", + })); + +const buildWaterRiskStatusFillColor = (riskStatus: "DANGER" | "NORMAL") => + riskStatus === "DANGER" ? "rgba(198, 40, 40, 0.24)" : "rgba(21, 101, 192, 0.2)"; + +const buildWaterRiskStatusStrokeColor = (riskStatus: "DANGER" | "NORMAL") => + riskStatus === "DANGER" ? "rgba(198, 40, 40, 0.92)" : "rgba(21, 101, 192, 0.92)"; + +const buildWaterRiskGridFillColor = (colorLevel: string) => { + switch (colorLevel) { + case "LEVEL_4": + return "rgba(198, 40, 40, 0.28)"; + case "LEVEL_3": + return "rgba(245, 124, 0, 0.24)"; + case "LEVEL_2": + return "rgba(251, 192, 45, 0.22)"; + default: + return "rgba(33, 150, 243, 0.18)"; + } +}; + +const createWaterRiskAreaStyle = (feature: any) => { + const areaName = String(feature.get("areaName") ?? ""); + const riskStatus = String(feature.get("riskStatus") ?? "NORMAL") as "DANGER" | "NORMAL"; + return new Style({ + fill: new Fill({ color: buildWaterRiskStatusFillColor(riskStatus) }), + stroke: new Stroke({ + color: buildWaterRiskStatusStrokeColor(riskStatus), + width: 3, + }), + text: new Text({ + text: `${areaName}\n${buildWaterRiskStatusLabel(riskStatus)}`, + font: "700 13px sans-serif", + fill: new Fill({ color: "#ffffff" }), + backgroundFill: new Fill({ color: buildWaterRiskStatusStrokeColor(riskStatus) }), + padding: [5, 8, 5, 8], + overflow: true, + }), + zIndex: 9996, + }); +}; + +const createWaterRiskGridStyle = (feature: any) => { + const rainQty = Number(feature.get("rainQty") ?? 0); + const colorLevel = String(feature.get("colorLevel") ?? "LEVEL_1"); + return new Style({ + fill: new Fill({ color: buildWaterRiskGridFillColor(colorLevel) }), + stroke: new Stroke({ + color: "rgba(71, 85, 105, 0.36)", + width: 1, + }), + text: new Text({ + text: `${rainQty.toFixed(1)}mm`, + font: "600 11px sans-serif", + fill: new Fill({ color: "#0f172a" }), + overflow: true, + }), + zIndex: 9995, + }); +}; + +const createWaterRiskPipeStyle = (feature: any) => { + const displayLabel = String(feature.get("displayLabel") ?? ""); + return [ + new Style({ + stroke: new Stroke({ + color: "rgba(255,255,255,0.98)", + width: 9, + lineCap: "round", + lineJoin: "round", + }), + zIndex: 9997, + }), + new Style({ + stroke: new Stroke({ + color: "rgba(205, 52, 52, 0.96)", + width: 4, + lineCap: "round", + lineJoin: "round", + }), + zIndex: 9998, + text: new Text({ + text: displayLabel, + font: "700 12px sans-serif", + fill: new Fill({ color: "#ffffff" }), + backgroundFill: new Fill({ color: "rgba(60, 60, 60, 0.9)" }), + padding: [3, 6, 3, 6], + overflow: true, + }), + }), + ]; +}; + +const getSelectedFeatureLayerTypeName = (data: any) => { + if (typeof data?.getId === "function") { + return String(data.getId() ?? "").split(".")[0]; + } + + if (typeof data?.id_ === "string") { + return String(data.id_).split(".")[0]; + } + + return String(data?.lyrTypeNm ?? data?.values_?.lyrTypeNm ?? "").trim(); +}; + +const formatWaterRiskPipeField = (value: unknown, suffix: string) => { + const normalized = String(value ?? "").trim(); + if (!normalized) return "-"; + if (normalized.endsWith(suffix) || suffix === "") return normalized; + return `${normalized} ${suffix}`.trim(); +}; + +const buildWaterRiskPipeFromFeature = (data: any): WaterRiskStagedPipe | null => { + const values = data?.values_ ?? data; + const pipeId = String( + values?.fclt_mng_no ?? + values?.fcltMngNo ?? + values?.pipeId ?? + values?.id ?? + data?.fid ?? + "", + ).trim(); + + if (!pipeId) return null; + + return { + pipeId, + pipeDia: formatWaterRiskPipeField( + values?.pipe_diameter ?? values?.pdmt_vl ?? values?.pdmtVl, + "mm", + ), + pipeLen: formatWaterRiskPipeField( + values?.pipe_lt ?? values?.pipeLt ?? values?.extn_lt ?? values?.extnLt, + "m", + ), + pipeMaterial: String( + values?.pipe_material ?? + values?.pipeMaterial ?? + values?.mqlt_cd ?? + values?.mqltCd ?? + "-", + ).trim() || "-", + setSeq: 0, + source: "MAP", + }; +}; + type VertexHandleMeta = { kind: "vertex" | "midpoint"; geometryType: string; @@ -1210,6 +1400,8 @@ const Map = () => { const agedPipePopupWindowRef = useRef(null); const pipeAnalysisPopupWindowRef = useRef(null); const gisEditSessionPopupWindowRef = useRef(null); + const waterRiskPopupWindowRef = useRef(null); + const waterRiskPopupCloseWatcherRef = useRef(null); // 유하방향 레이어 체크 여부를 React 렌더링 흐름에 반영하기 위한 state이다. const [isFlowDirectionChecked, setIsFlowDirectionChecked] = useState(false); // 체크된 유하방향 트리 노드의 레이어 설정을 보관해 API 조회와 vector layer 스타일에 사용한다. @@ -1242,10 +1434,21 @@ const Map = () => { const gisEditPreviewSourceRef = useRef(null); const gisEditVertexLayerRef = useRef | null>(null); const gisEditVertexSourceRef = useRef(null); + const waterRiskAreaLayerRef = useRef | null>(null); + const waterRiskAreaSourceRef = useRef(null); + const waterRiskGridLayerRef = useRef | null>(null); + const waterRiskGridSourceRef = useRef(null); + const waterRiskPipeLayerRef = useRef | null>(null); + const waterRiskPipeSourceRef = useRef(null); const gisEditDrawSourceRef = useRef(null); const gisEditDrawRef = useRef(null); const gisEditTranslateRef = useRef(null); const gisEditModifyRef = useRef(null); + const [waterRiskSession, setWaterRiskSession] = useState( + EMPTY_WATER_RISK_SESSION_SNAPSHOT, + ); + const [waterRiskInfoAnchor, setWaterRiskInfoAnchor] = useState<[number, number] | null>(null); + const [waterRiskInfoCardStyle, setWaterRiskInfoCardStyle] = useState | null>(null); const gisEditSessionSnapshot = useMemo(() => ({ isEditMode: gisEdit.isEditMode, selectedLayerName: gisEdit.selectedLayer?.layerName ?? "", @@ -1263,6 +1466,10 @@ const Map = () => { gisEdit.lastSavedPayload, gisEdit.selectedLayer, ]); + const waterRiskSessionSnapshot = useMemo( + () => waterRiskSession, + [waterRiskSession], + ); const syncGisEditSessionPopup = useCallback(() => { const popup = gisEditSessionPopupWindowRef.current; if (!popup || popup.closed) { @@ -1279,6 +1486,61 @@ const Map = () => { ); }, [gisEditSessionSnapshot]); + const syncWaterRiskPopup = useCallback(() => { + const popup = waterRiskPopupWindowRef.current; + if (!popup || popup.closed) { + waterRiskPopupWindowRef.current = null; + return; + } + + popup.postMessage( + { + type: WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SYNC, + payload: waterRiskSessionSnapshot, + }, + window.location.origin, + ); + }, [waterRiskSessionSnapshot]); + + const selectedWaterRiskResultRow = useMemo( + () => getWaterRiskResultRow(waterRiskSession.selectedAreaId), + [waterRiskSession.selectedAreaId], + ); + + const syncWaterRiskInfoCardPosition = useCallback(() => { + const map = gisMaps.current; + if (!map || !waterRiskInfoAnchor) { + setWaterRiskInfoCardStyle(null); + return; + } + + const [mapWidth = 0, mapHeight = 0] = map.getSize?.() ?? []; + const [pixelX = 0, pixelY = 0] = map.getPixelFromCoordinate?.(waterRiskInfoAnchor) ?? []; + const cardWidth = 244; + const cardHeight = 236; + const left = pixelX + 22; + const top = pixelY - cardHeight / 2; + + if ( + pixelX < 0 || + pixelY < 0 || + pixelX > mapWidth || + pixelY > mapHeight || + left > mapWidth || + top > mapHeight || + left + cardWidth < 0 || + top + cardHeight < 0 + ) { + setWaterRiskInfoCardStyle(null); + return; + } + + setWaterRiskInfoCardStyle({ + left, + top, + }); + }, [waterRiskInfoAnchor]); + const setGisEditPreviewCursor = useCallback((cursor: string) => { const targetElement = gisMaps.current?.getTargetElement?.(); if (targetElement?.style) { @@ -1414,6 +1676,363 @@ const Map = () => { } }; + const clearWaterRiskPopupCloseWatcher = useCallback(() => { + if (waterRiskPopupCloseWatcherRef.current !== null) { + window.clearInterval(waterRiskPopupCloseWatcherRef.current); + waterRiskPopupCloseWatcherRef.current = null; + } + }, []); + + const ensureWaterRiskAreaLayer = useCallback(() => { + const map = gisMaps.current; + if (!map) return null; + + if (waterRiskAreaLayerRef.current && waterRiskAreaSourceRef.current) { + return waterRiskAreaSourceRef.current; + } + + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + zIndex: 9996, + }); + + layer.set("id", WATER_RISK_AREA_LAYER_ID); + layer.set("lyrNm", "통수위험 중점관리지역"); + layer.set("lyrTyNm", WATER_RISK_AREA_LAYER_ID); + map.addLayer(layer); + waterRiskAreaLayerRef.current = layer; + waterRiskAreaSourceRef.current = source; + return source; + }, []); + + const ensureWaterRiskGridLayer = useCallback(() => { + const map = gisMaps.current; + if (!map) return null; + + if (waterRiskGridLayerRef.current && waterRiskGridSourceRef.current) { + return waterRiskGridSourceRef.current; + } + + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + zIndex: 9995, + }); + + layer.set("id", WATER_RISK_GRID_LAYER_ID); + layer.set("lyrNm", "통수위험 예측강우 격자"); + layer.set("lyrTyNm", WATER_RISK_GRID_LAYER_ID); + map.addLayer(layer); + waterRiskGridLayerRef.current = layer; + waterRiskGridSourceRef.current = source; + return source; + }, []); + + const ensureWaterRiskPipeLayer = useCallback(() => { + const map = gisMaps.current; + if (!map) return null; + + if (waterRiskPipeLayerRef.current && waterRiskPipeSourceRef.current) { + return waterRiskPipeSourceRef.current; + } + + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + zIndex: 9998, + }); + + layer.set("id", WATER_RISK_PIPE_LAYER_ID); + layer.set("lyrNm", "통수위험 유출관로"); + layer.set("lyrTyNm", WATER_RISK_PIPE_LAYER_ID); + map.addLayer(layer); + waterRiskPipeLayerRef.current = layer; + waterRiskPipeSourceRef.current = source; + return source; + }, []); + + const clearWaterRiskLayers = useCallback(() => { + waterRiskAreaSourceRef.current?.clear(); + waterRiskGridSourceRef.current?.clear(); + waterRiskPipeSourceRef.current?.clear(); + }, []); + + const renderWaterRiskSelection = useCallback((areaId: string | null) => { + const map = gisMaps.current; + if (!map) return; + + const areaSource = ensureWaterRiskAreaLayer(); + const gridSource = ensureWaterRiskGridLayer(); + if (!areaSource || !gridSource) return; + + areaSource.clear(); + gridSource.clear(); + + if (!areaId) return; + + const areaData = getWaterRiskAreaMapData(areaId); + const resultRow = getWaterRiskResultRow(areaId); + if (!areaData || !resultRow) return; + + const areaFeature = new Feature({ + geometry: new Polygon(areaData.polygonCoordinates), + areaId: areaData.areaId, + areaName: areaData.areaName, + riskStatus: resultRow.riskStatus, + }); + areaFeature.setStyle(createWaterRiskAreaStyle(areaFeature)); + areaSource.addFeature(areaFeature); + + areaData.gridCells.forEach((cell) => { + const cellFeature = new Feature({ + geometry: new Polygon(cell.polygonCoordinates), + gridId: cell.gridId, + rainQty: cell.rainQty, + colorLevel: cell.colorLevel, + }); + cellFeature.setStyle(createWaterRiskGridStyle(cellFeature)); + gridSource.addFeature(cellFeature); + }); + + const pipeSource = ensureWaterRiskPipeLayer(); + const pipes = WATER_RISK_MAP_PIPES_BY_AREA[areaId] ?? []; + pipeSource?.clear(); + pipes.forEach((pipe) => { + if (!pipeSource) return; + const pipeFeature = new Feature({ + geometry: new LineString(pipe.lineCoordinates), + pipeId: pipe.pipeId, + displayLabel: pipe.displayLabel, + }); + pipeFeature.setStyle(createWaterRiskPipeStyle(pipeFeature)); + pipeSource.addFeature(pipeFeature); + }); + + const areaGeometry = areaFeature.getGeometry(); + if (!areaGeometry) return; + + map.getView().fit(areaGeometry.getExtent(), { + duration: 300, + padding: [120, 120, 120, 120], + maxZoom: 15, + constrainResolution: true, + nearest: true, + }); + }, [ensureWaterRiskAreaLayer, ensureWaterRiskGridLayer, ensureWaterRiskPipeLayer]); + + const setWaterRiskStatusMessage = useCallback((statusMessage: string) => { + setWaterRiskSession((current) => ({ + ...current, + statusMessage, + })); + }, []); + + const startWaterRiskSettingEdit = useCallback((areaId: string, areaName: string) => { + setWaterRiskSession((current) => { + const shouldSeedPipes = + current.selectedSettingAreaId !== areaId || current.stagedPipes.length === 0; + + return { + ...current, + selectedAreaId: areaId, + selectedSettingAreaId: areaId, + selectedSettingAreaName: areaName, + isSettingEditMode: true, + stagedPipes: shouldSeedPipes ? buildWaterRiskSeedPipes(areaId) : current.stagedPipes, + statusMessage: `${areaName} 설정 편집 모드입니다. 지도에서 하수관로를 클릭하세요.`, + }; + }); + setIsOpen(false); + setFeatureData(null); + }, []); + + const stopWaterRiskSettingEdit = useCallback(() => { + setWaterRiskSession((current) => ({ + ...current, + isSettingEditMode: false, + statusMessage: "", + })); + }, []); + + const removeWaterRiskStagedPipe = useCallback((pipeId: string) => { + setWaterRiskSession((current) => ({ + ...current, + stagedPipes: current.stagedPipes + .filter((pipe) => pipe.pipeId !== pipeId) + .map((pipe, index) => ({ + ...pipe, + setSeq: index + 1, + })), + statusMessage: `${pipeId} 관로를 staged 목록에서 제거했습니다.`, + })); + }, []); + + const reorderWaterRiskStagedPipe = useCallback((pipeId: string, direction: "UP" | "DOWN") => { + setWaterRiskSession((current) => { + const currentIndex = current.stagedPipes.findIndex((pipe) => pipe.pipeId === pipeId); + if (currentIndex < 0) return current; + + const nextIndex = + direction === "UP" ? currentIndex - 1 : currentIndex + 1; + if (nextIndex < 0 || nextIndex >= current.stagedPipes.length) { + return current; + } + + const reordered = [...current.stagedPipes]; + const [targetPipe] = reordered.splice(currentIndex, 1); + if (!targetPipe) { + return current; + } + reordered.splice(nextIndex, 0, targetPipe); + + return { + ...current, + stagedPipes: reordered.map((pipe, index) => ({ + ...pipe, + setSeq: index + 1, + })), + statusMessage: `${pipeId} 관로 순번을 조정했습니다.`, + }; + }); + }, []); + + const clearWaterRiskStagedPipes = useCallback(() => { + setWaterRiskSession((current) => ({ + ...current, + stagedPipes: [], + statusMessage: "staged 유출관로 목록을 초기화했습니다.", + })); + }, []); + + useEffect(() => { + if (!mapReady || !gisMaps.current) return; + if (!waterRiskSession.selectedAreaId) { + clearWaterRiskLayers(); + setWaterRiskInfoAnchor(null); + setWaterRiskInfoCardStyle(null); + return; + } + + renderWaterRiskSelection(waterRiskSession.selectedAreaId); + }, [ + clearWaterRiskLayers, + mapReady, + renderWaterRiskSelection, + waterRiskSession.selectedAreaId, + ]); + + useEffect(() => { + setWaterRiskInfoAnchor(buildWaterRiskInfoAnchor(waterRiskSession.selectedAreaId)); + }, [waterRiskSession.selectedAreaId]); + + useEffect(() => { + const popup = waterRiskPopupWindowRef.current; + if (!popup || popup.closed) return; + syncWaterRiskPopup(); + }, [syncWaterRiskPopup, waterRiskSession]); + + useEffect(() => { + if (!mapReady || !gisMaps.current || !waterRiskInfoAnchor) return; + + const map = gisMaps.current; + const syncDuringMapRender = () => { + syncWaterRiskInfoCardPosition(); + }; + + syncWaterRiskInfoCardPosition(); + map.on?.("postrender", syncDuringMapRender); + map.on?.("moveend", syncDuringMapRender); + + return () => { + map.un?.("postrender", syncDuringMapRender); + map.un?.("moveend", syncDuringMapRender); + }; + }, [mapReady, syncWaterRiskInfoCardPosition, waterRiskInfoAnchor]); + + useEffect(() => { + const handleWaterRiskPopupMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + + const data = event.data; + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_READY) { + syncWaterRiskPopup(); + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT) { + const areaId = String(data.payload?.areaId ?? "").trim(); + if (!areaId) return; + setWaterRiskInfoAnchor(buildWaterRiskInfoAnchor(areaId)); + setWaterRiskSession((current) => ({ + ...current, + selectedAreaId: areaId, + statusMessage: `${getWaterRiskResultRow(areaId)?.areaName ?? areaId} 지도 강조를 갱신했습니다.`, + })); + setIsOpen(false); + setFeatureData(null); + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_START_SETTING_EDIT) { + const areaId = String(data.payload?.areaId ?? "").trim(); + const areaName = String(data.payload?.areaName ?? "").trim(); + if (!areaId) return; + startWaterRiskSettingEdit(areaId, areaName || getWaterRiskResultRow(areaId)?.areaName || areaId); + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_STOP_SETTING_EDIT) { + stopWaterRiskSettingEdit(); + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_REMOVE_STAGED_PIPE) { + const pipeId = String(data.payload?.pipeId ?? "").trim(); + if (pipeId) { + removeWaterRiskStagedPipe(pipeId); + } + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_REORDER_STAGED_PIPE) { + const pipeId = String(data.payload?.pipeId ?? "").trim(); + const direction = data.payload?.direction === "UP" ? "UP" : "DOWN"; + if (pipeId) { + reorderWaterRiskStagedPipe(pipeId, direction); + } + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_CLEAR_STAGED_PIPES) { + clearWaterRiskStagedPipes(); + } + }; + + window.addEventListener("message", handleWaterRiskPopupMessage); + return () => { + window.removeEventListener("message", handleWaterRiskPopupMessage); + }; + }, [ + clearWaterRiskStagedPipes, + removeWaterRiskStagedPipe, + reorderWaterRiskStagedPipe, + startWaterRiskSettingEdit, + stopWaterRiskSettingEdit, + syncWaterRiskPopup, + ]); + + useEffect(() => { + return () => { + clearWaterRiskPopupCloseWatcher(); + const popup = waterRiskPopupWindowRef.current; + if (popup && !popup.closed) { + popup.close(); + } + waterRiskPopupWindowRef.current = null; + }; + }, [clearWaterRiskPopupCloseWatcher]); + // 유하방향은 일반 Gis core 레이어 등록 흐름을 타지 않으므로 전용 layer/source/cache를 직접 정리한다. const removeFlowDirectionLayer = useCallback(() => { const core = GisApp.Module.core as any; @@ -1700,6 +2319,59 @@ const Map = () => { return; } + if (waterRiskSession.isSettingEditMode) { + const layerTypeName = getSelectedFeatureLayerTypeName(data); + if (layerTypeName !== WATER_RISK_PIPE_LAYER_TYPE) { + setWaterRiskStatusMessage("통수위험 설정 모드에서는 하수관로만 선택할 수 있습니다."); + setFeatureData(null); + setIsOpen(false); + setIsSearchModalOpen(false); + return; + } + + const nextPipe = buildWaterRiskPipeFromFeature(data); + if (!nextPipe) { + setWaterRiskStatusMessage("선택한 하수관로 정보를 staged 목록으로 옮기지 못했습니다."); + setFeatureData(null); + setIsOpen(false); + setIsSearchModalOpen(false); + return; + } + + setWaterRiskSession((current) => { + if (current.stagedPipes.some((pipe) => pipe.pipeId === nextPipe.pipeId)) { + return { + ...current, + statusMessage: `${nextPipe.pipeId} 관로는 이미 staged 목록에 있습니다.`, + }; + } + + if (current.stagedPipes.length >= 3) { + return { + ...current, + statusMessage: "유출관로는 최대 3건까지만 staged 목록에 담을 수 있습니다.", + }; + } + + return { + ...current, + stagedPipes: [ + ...current.stagedPipes, + { + ...nextPipe, + setSeq: current.stagedPipes.length + 1, + }, + ], + statusMessage: `${nextPipe.pipeId} 관로를 staged 목록에 추가했습니다.`, + }; + }); + + setFeatureData(null); + setIsOpen(false); + setIsSearchModalOpen(false); + return; + } + let map = gisMaps.current; if (!map) { console.warn("지도가 초기화되지 않았습니다."); @@ -2109,8 +2781,19 @@ const Map = () => { pipeAnalysisPopupWindowRef.current = popup; } + if (popupKey === "flowRiskPopup") { + clearWaterRiskPopupCloseWatcher(); + waterRiskPopupWindowRef.current = popup; + waterRiskPopupCloseWatcherRef.current = window.setInterval(() => { + if (!popup.closed) return; + clearWaterRiskPopupCloseWatcher(); + waterRiskPopupWindowRef.current = null; + stopWaterRiskSettingEdit(); + }, 300); + } + return popup; - }, []); + }, [clearWaterRiskPopupCloseWatcher, stopWaterRiskSettingEdit]); const openGisEditSessionPopup = useCallback(() => { const existingPopup = gisEditSessionPopupWindowRef.current; @@ -2718,6 +3401,118 @@ const Map = () => { {agedPipeDownloadProgressText}
) : null} + {waterRiskSession.isSettingEditMode ? ( +
+
+ 통수위험 설정 모드: {waterRiskSession.selectedSettingAreaName || "중점관리지역 선택 전"} +
+
+ 지도에서 하수관로를 클릭하면 staged 목록에 추가됩니다. 최대 3건까지 선택할 수 있습니다. +
+
+ ) : null} + {selectedWaterRiskResultRow && waterRiskInfoCardStyle ? ( +
+
+
+
중점관리지역
+
+ {selectedWaterRiskResultRow.areaName} +
+
+ + {buildWaterRiskStatusLabel(selectedWaterRiskResultRow.riskStatus)} + +
+ +
+
+ + + + + + + + + + + + + {selectedWaterRiskResultRow.pipeSummaries.map((pipe, index) => { + const isDanger = pipe.runoffFlowQty > pipe.conveyQty; + const isLast = index === selectedWaterRiskResultRow.pipeSummaries.length - 1; + + return ( + + + + + + + + + + + + ); + })} + +
+ 예측강우량(mm/hr) + + {selectedWaterRiskResultRow.forecastRainQty.toFixed(1)} +
+
+
+
+ 관로 +
+ ID {index + 1} +
+
+ {pipe.pipeId} +
+
+ + {isDanger ? "위험" : "정상"} + +
+
+ 통수량 +
+ (㎥/hr) +
+ {pipe.conveyQty.toFixed(1)} +
+ 유출유량 +
+ (㎥/hr) +
+ {pipe.runoffFlowQty.toFixed(1)} +
+
+
+
+ ) : null} setIsSearchModalOpen(true)} diff --git a/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/001-qa-SFR-011-007-통수위험분석-통합검증계획.md b/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/001-qa-SFR-011-007-통수위험분석-통합검증계획.md new file mode 100644 index 000000000..bf4692aba --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/001-qa-SFR-011-007-통수위험분석-통합검증계획.md @@ -0,0 +1,93 @@ +# SFR-011-007 통수위험분석 통합 검증 계획 + +## 1. 문서 목적 + +- `SFR-011-007 통수위험분석`의 FE 검증 계획을 기능당 1개 문서로 누적 관리한다. +- 현재 문서는 FE-only 프로토타입 구현 완료 기준의 검증 범위와 후속 통합 검증 포인트를 정의한다. + +## 2. 검증 기준 요약 + +- `통수위험` 아이콘으로 기능 진입 가능 +- 결과 조회 popup 열림/닫힘 동작 +- 날짜/시간 필수값 검증 +- 위험상태 필터 동작 +- 관로 1건, 2건, 3건 동적 컬럼 렌더링 +- 결과 행 선택 시 지도 강조 및 상세 popup 갱신 +- 예측강우 sample grid overlay 노출 +- 결과/설정 목록 페이지네이션 노출 +- 설정 현황 조회와 staged 설정 편집 진입 +- 유출관로 중복 선택 차단 +- 최소 1건, 최대 3건 저장 규칙 +- 저장 실패 시 staged 목록 유지 + +## 3. 라운드별 검증 계획 + +### 3.1 설계 승인 직후 FE-only 검증 + +- popup 진입/전환/닫기 +- 조회조건 유효성 +- 결과 그리드 렌더링 +- 위험/정상 스타일 전환 +- 예측강우 sample overlay 가시성 +- 설정 현황 목록과 staged 유출관로 편집 +- 저장 payload preview +- 상세정보 카드의 표 구조, 위치, 강조 규칙 + +### 3.2 BE 연동 후 통합 검증 + +- 실제 조회조건 전달 +- 실제 geometry와 격자 overlay 정합성 +- 설정 저장/재조회 +- 결과/설정 다운로드 +- 권한 범위 제어 + +## 4. 현재 검증 범위 + +### 4.1 진행 전제 + +- FE-only 프로토타입 코드는 구현 완료 상태다. +- 자동 브라우저 검증은 환경 제약으로 제한적이므로, 현재는 코드 점검과 사용자 피드백 기반 수동 검증 포인트를 우선 기록한다. + +### 4.2 자동 또는 반자동 관측 포인트 + +| 구분 | 관측 포인트 | 기대 결과 | +| --- | --- | --- | +| 진입 | `통수위험` 버튼 | popup 열림 | +| 결과 | 날짜/시간 누락 | 조회 차단 메시지 | +| 결과 | 위험상태 필터 | 결과 건수와 목록 반영 | +| 결과 | 동적 관로 컬럼 | 1~3건 반복 렌더링 | +| 결과 | 페이지네이션 | 결과 2페이지, 설정 3페이지 이상 노출 | +| 지도 | 선택 지역 | 중심 이동, 강조 색상 반영 | +| 지도 | 예측강우 격자 | sample overlay 표시 | +| 지도 | 상세정보 카드 | 표 구조, 숫자 강조, 중점관리지역 오른쪽 anchor 동작 확인 | +| 설정 | 설정 현황 전환 | 탭 또는 화면 전환 성공 | +| 설정 | 유출관로 선택 | staged 목록 누적 | +| 설정 | 4번째 관로 선택 | 차단 메시지 | +| 저장 | payload preview | focusAreaId, pipes, setSeq 구조 확인 | + +## 5. 차단 사유 분류 기준 + +- `기능 결함` + - 기대한 상태 전이나 렌더링이 깨짐 +- `인증 차단` + - 로그인 또는 권한 문제로 진입 불가 +- `환경 차단` + - dev 서버, API, 지도 리소스 미기동 +- `데이터 차단` + - 지역 목록, 샘플 결과, geometry 부족 +- `자동화 한계` + - 지도 canvas overlay 체감 확인이 도구상 제한적 + +## 6. 다음 검증 순서 + +1. 결과 조회 popup 열림/닫힘 +2. 조회조건 필수값/초기화 +3. 동적 관로 컬럼 렌더링 +4. 결과/설정 목록 페이지네이션 +5. 결과 행 선택과 지도 강조 +6. 예측강우 sample overlay +7. 상세정보 카드 레이아웃과 중점관리지역 오른쪽 anchor 규칙 +8. 설정 현황 목록 조회 +9. staged 유출관로 1~3건 제어 +10. 저장 payload preview +11. BE 연동 후 실제 저장/다운로드 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 new file mode 100644 index 000000000..add9608c9 --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md @@ -0,0 +1,116 @@ +# SFR-011-007 통수위험분석 통합 검증 결과 + +## 1. 문서 목적 + +- `SFR-011-007 통수위험분석`의 FE 검증 결과를 기능당 1개 문서로 누적 관리한다. +- 현재 문서는 설계 초안 점검부터 FE-only 프로토타입 코드 점검, 수동 피드백 반영 상태까지의 결과를 기록한다. + +## 2. 수행한 검증 요약 + +### 2.1 2026-05-18 설계 단계 점검 + +- 수행 범위 + - 작업지시 문서 해석 + - 템플릿 문서 구조 확인 + - 공통/FE 문서 세트 정의 + - 예상 수정 파일, FE-only 범위, BE 필요 범위 분리 +- 미수행 범위 + - FE 코드 구현 + - 브라우저 검증 + - API 응답 확인 + +### 2.2 2026-05-18 FE-only 1차 구현 점검 + +- 코드 기준 확인 + - `flowRiskPopup` registry가 실제 `WaterRiskPopupContent`로 연결됐다. + - `WaterRiskPopupPage.tsx`에서 결과 조회/설정 현황/staged 편집/preview UI가 구현됐다. + - `map/page.tsx`에서 popup sync, 선택 지역 강조, sample rain grid overlay, 설정 편집 중 하수관로 선택 인터셉트가 구현됐다. +- 타입체크 + - `NODE_OPTIONS=--max-old-space-size=4096 pnpm exec tsc --noEmit --incremental false --pretty false` + - 결과: 이번 변경분 관련 신규 오류는 제거됐다. + - 잔여 오류: 저장소 기존 오류만 남음 + - `components/common/index.ts` 타입 불일치 + - `PipeAnalysisPopupPage.tsx`의 `JSON[]` 캐스팅 경고 + - `app/html/**` 기존 타입/모듈 오류 + - `packages/common/business_components` chart implicit any +- 미수행 + - 실제 브라우저 동작 검증 + - 실제 API 연동 검증 + +### 2.3 2026-05-19 FE 프로토타입 UI 보완 점검 + +- 코드 기준 확인 + - 결과 조회/설정 현황 mock 데이터가 확대되어 페이지네이션이 실제로 노출되는 상태로 바뀌었다. + - 결과/설정 toolbar 버튼 정렬, 헤더 컬러, 본문 행 배경이 프로토타입 기준으로 정리됐다. + - 상세정보 카드는 표 구조, compact spacing, 유출유량 초과 강조 등 사용자 피드백 기반 보완이 반영됐다. +- 수동 피드백 반영 + - 페이지네이션과 상세정보 카드 UI는 사용자 확인 기준으로 여러 차례 보정됐다. + - 상세정보 카드의 지도 기준 고정 규칙은 현재도 추가 브라우저 확인이 필요한 항목으로 남아 있다. +- 미수행 + - 자동 브라우저 캡처 기반 검증 + - 실제 API 응답/저장/다운로드 검증 + +### 2.4 2026-05-19 FE 추가 검증 실행 + +- 로컬 응답 확인 + - `http://localhost:3002/popup/flowRiskPopup` + - 결과: HTTP 200 응답 확인 +- 타입체크 + - 실행 위치: `frontend/apps/sn` + - 실행 명령: `.\node_modules\.bin\tsc.cmd --noEmit --incremental false --pretty false` + - 결과: 이번 통수위험분석 변경으로 새로 유입된 타입 오류 1건(`buildWaterRiskInfoAnchor`)을 수정했고, 재실행 후 남은 오류는 저장소 기존 오류만 확인됐다. + - 잔여 기존 오류 + - `app/(map-layout)/map/components/common/index.ts` props 타입 불일치 + - `app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx` `JSON[]` 캐스팅 경고 + - `app/html/(map-layout)/map/components/common/index.ts` props 타입 불일치 + - `app/html/(with-base-layout)/layout.tsx` 모듈 경로 오류 + - `packages/common/business_components/src/ui/chart/*.tsx` implicit any +- 미수행 + - 지도 상호작용을 포함한 실제 브라우저 자동 검증 + - 실제 API 연동 검증 + +### 2.5 2026-05-19 상세정보 카드 상호작용 보정 + +- 코드 기준 확인 + - 상세정보 카드는 선택된 중점관리지역 geometry를 기준으로 오른쪽 anchor를 잡는 방식으로 재조정됐다. + - 지도 렌더링 중에는 같은 anchor를 기준으로 카드 위치를 갱신하는 구조로 정리됐다. +- 수동 피드백 반영 + - 사용자 기준으로 “화면 고정”이 아니라 “중점관리지역 오른쪽에 붙은 상태로 이동”해야 한다는 요구를 반영했다. +- 미수행 + - 실제 브라우저 drag/zoom 상황에서 최종 체감 확인 + +## 3. 현재 검증 상태 + +### 3.1 통과 + +- 문서 구조가 `SFR-012-003` 템플릿 기준과 맞는지 확인 +- 공통 설계서, 공유기록, FE 작업카드, 구현기록, 검증 계획/결과 문서 세트 정의 완료 +- 구현 승인 전 코드 미수정 원칙 반영 +- FE-only 프로토타입 범위의 결과 조회/설정 현황/지도 강조/staged 편집 코드 반영 완료 +- 로컬 `flowRiskPopup` 경로의 HTTP 200 응답 확인 +- 이번 통수위험분석 변경으로 인한 신규 타입 오류 제거 + +### 3.2 미실행 + +- 실제 브라우저에서의 `통수위험` 아이콘 진입 +- 실제 popup 렌더링 재확인 +- 실제 지도 drag/zoom 상태에서 상세정보 카드 anchor 규칙 재확인 +- 실제 API 연동 검증 + +### 3.3 보류 + +- 실제 BE 연동 검증 +- 다운로드 파일 검증 +- 권한 범위 검증 + +## 4. 보류/리스크 + +- 상세정보 카드 anchor 규칙은 FE 프로토타입 단계에서 반복 조정된 항목이라 운영 규칙 확정 전 재검토가 필요하다. +- 동적 관로 컬럼과 지도 overlay는 실제 API 응답 구조 확정 전까지 mock 기준으로만 검토 가능하다. +- 실제 다운로드/저장 후처리와 파일 결과는 아직 확인되지 않았다. + +## 5. 다음 검증 착수 조건 + +- dev 서버 또는 로컬 미리보기 진입 가능 +- mock 데이터 또는 초기 API 응답 구조 준비 +- 인증 가능한 브라우저 환경에서 지도 조작 수동 검증 가능 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 new file mode 100644 index 000000000..ca84bc821 --- /dev/null +++ b/apps/sn/docs/작업계획/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-작업카드.md @@ -0,0 +1,175 @@ +# SFR-011-007 통수위험분석 작업카드 + +## 1. 작업 개요 + +- 작업 ID: `SFR-011-007-FE` +- 작업명: 통수위험분석 프론트엔드 구현 계획 +- 상태: 완료 +- 작성일: 2026-05-18 +- 담당 역할: FE +- 관련 역할: DE, BE, QA +- 담당 범위: `apps/sn` 지도 화면에서 통수위험 진입, 결과 조회 popup, 지도보기, 설정 현황, staged 유출관로 선택, 저장 payload 준비까지 +- 근거 문서 + - `COMMON_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/001-de-SFR-011-007-통수위험분석-설계.md` + - `COMMON_DOCS_ROOT/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합공유기록.md` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-007-통수위험분석/통수위험분석_요구사항명세서_v1_2.docx` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-007-통수위험분석/통수위험분석_화면_v1.1.html` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-007-통수위험분석/통수위험분석_통수위험분석결과 조회_화면설계서.png` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-007-통수위험분석/통수위험분석_통수위험분석결과 지도보기_화면설계서.png` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-007-통수위험분석/통합위험분석_화면구성시나리오_v1.1.md` + +## 1-1. 공통 동기화 확인 + +- 공통 작업현황판 확인 여부: 확인 +- 파일점유현황 확인 여부: 확인 +- 결정로그 확인 여부: 확인 +- 기존 진행 중 작업과의 관계: `map/page.tsx`, `AnalysisPopupMenu.tsx`, `windowPopup/**`는 최근 지도형 기능 작업 이력이 있어 충돌 주의가 필요하다. + +## 2. 작업 목적 + +- 기존 하수관망도 지도 화면에 `통수위험` 기능을 추가해 사용자가 결과 조회, 지도보기, 설정 현황 조회, 유출관로 설정 편집을 한 흐름 안에서 수행할 수 있게 한다. +- 프론트는 백엔드 연결 전 단계에서 popup UI, 동적 관로 컬럼, mock 지도 강조, staged 설정 목록, 저장 payload preview까지 먼저 구현한다. +- 실제 산정 로직, geometry 데이터, 다운로드 파일, 저장 API는 BE 범위로 분리하고 FE는 그 계약을 맞출 준비 상태까지 수행한다. + +## 2-1. 최근 진행 갱신 + +- 2026-05-18 + - `통수위험` 메뉴의 placeholder popup을 실제 `WaterRiskPopupContent`로 교체했다. + - popup 안에서 결과 조회, 설정 현황, staged 유출관로 목록, 결과/설정 다운로드 preview, 저장 payload preview가 동작하도록 FE-only mock 흐름을 연결했다. + - `map/page.tsx`에는 popup과 동기화되는 선택 지역 강조, 예측강우 sample grid overlay, 설정 편집 모드, 지도 하수관로 클릭 기반 staged 추가 흐름을 연결했다. + - 실제 API 호출은 아직 붙이지 않았고, BE 신규 API 계약과 mock 상태를 기준으로만 동작한다. +- 2026-05-19 + - 결과 조회/설정 현황 mock 데이터를 확대해 페이지네이션이 실제로 보이도록 조정했다. + - 결과 조회와 설정 현황 toolbar 버튼을 우측 정렬로 정리하고, 본문 행 배경과 헤더 컬러를 관로분석 계열 톤으로 맞췄다. + - 지도 상세정보 카드는 프로토타입 표 구조, 위험/정상 배지, 유출유량 초과 강조, compact spacing을 기준으로 재정리했다. + - 지도 상세정보 카드는 선택 중점관리지역의 오른쪽에 붙어 움직이는 프로토타입 기준으로 다시 조정했다. + - 지도 강조와 상세정보 카드는 계속 실험이 반복된 항목이라, 최종 운영 규칙은 BE 연동 및 수동 브라우저 검증 단계에서 한 번 더 확정한다. + +## 3. 수정 대상 파일 + +| 구분 | 파일 경로 | 수정 여부 | 근거 | +| --- | --- | --- | --- | +| 화면 | `FE_APP_ROOT/app/(map-layout)/map/page.tsx` | 완료 | 지도 이동, 강조 overlay, 상태 연결, 상세정보 카드 프로토타입 | +| 우측 메뉴 | `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx` | 완료 | `통수위험` 아이콘 진입점 추가 | +| popup 정의 | `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts` | 완료 | 신규 popup 정의 | +| popup 등록 | `FE_APP_ROOT/src/features/windowPopup/registry.tsx` | 완료 | popup content 연결 | +| 신규 컴포넌트 | `FE_APP_ROOT/app/(map-layout)/map/components/waterRisk/*` | 완료 | 결과/설정 UI, mock 데이터, 타입 분리 | +| popup content | `FE_APP_ROOT/src/features/windowPopup/ui/WaterRiskPopupContent.tsx` | 완료 | window popup 본문 연결 | +| 공통 비즈니스 컴포넌트 | `FE_BC_ROOT/**` | 기본 제외 | 초기 구현은 `apps/sn` 내부 해결 우선 | + +## 4. 화면 흐름 + +- 진입 경로 + - 하수관망도 지도 화면 우측 기능 아이콘 `통수위험` +- 정상 흐름 + - 결과 조회 popup 열기 + - 조회조건 입력 + - 결과 목록 표시 + - 결과 행 선택 후 지도 강조/상세정보 표시 + - `설정`으로 화면 전환 + - 설정 현황 조회 + - 중점관리지역 선택 후 유출관로 staged 편집 + - 저장 payload 생성 +- 오류 흐름 + - 날짜/시간 누락 + - 결과 없음 + - 중복 유출관로 선택 + - 3건 초과 선택 + - 저장 실패 시 staged 목록 유지 + +## 5. API 연동 + +| 기능 | Method | URL | 요청 | 응답 | 확인 상태 | +| --- | --- | --- | --- | --- | --- | +| 결과 조회 | `GET` | `/api/v1/map/wraResults` | 분석일시, 지역, 위험상태 | 요약 + 결과 목록 + 관로 반복형 데이터 | BE 신규 필요 | +| 지도 상세 | `GET` | `/api/v1/map/wraResults/{focusAreaId}/mapView` | 중점관리지역 식별값 | polygon + rain grid + 상세 popup 데이터 | BE 신규 필요 | +| 설정 현황 조회 | `GET` | `/api/v1/map/wraOutletPipeSettings` | 지역 조건 | 설정 목록 | BE 신규 필요 | +| 설정 저장 | `POST` | `/api/v1/map/wraOutletPipeSettings/save` | 중점관리지역 + 유출관로 1~3건 + 순번 | 저장 결과 + 최신 상태 | BE 신규 필요 | +| 결과 다운로드 | `POST` | `/api/v1/map/wraResults/download` | 현재 조회조건 | 파일 또는 파일 토큰 | BE 신규 필요 | +| 설정 다운로드 | `POST` | `/api/v1/map/wraOutletPipeSettings/download` | 현재 조회조건 | 파일 또는 파일 토큰 | BE 신규 필요 | + +- FE 메모 + - 1차 구현은 실제 네트워크 대신 payload preview 또는 mock service 기준으로 상태를 먼저 맞춘다. + - 동적 관로 그룹은 `pipeSummaries[]` 반복 렌더링 기준으로 구현한다. + +## 6. 영향 범위 + +- `apps/sn` 영향 + - 지도 메인 화면 + - 우측 분석 기능 메뉴 + - popup registry와 popup content + - 지도 선택/강조/overlay 상태 + - 지도 상세정보 카드와 표형 mock UI +- `business_components` 영향 + - 현재 계획상 없음 + - overlay 또는 popup wrapper 요구가 기존 패턴으로 해결되지 않을 때만 추가 검토 +- 백엔드 영향 + - 결과 조회/상세/설정/저장/다운로드 API + - 권한과 지역 조건 cascade + +## 7. 구현 범위 분리 + +### 7.1 FE 단독 구현 범위 + +- popup 레이아웃 +- 조회/설정 폼 +- mock 결과/설정 데이터 +- 동적 관로 그룹 컬럼 +- 위험/정상 스타일 변경 +- 예측강우 sample overlay +- staged 유출관로 목록 +- 저장/다운로드 payload preview + +### 7.2 BE 필요 범위 + +- 실제 분석 결과 수치 +- geometry와 grid 데이터 +- 저장/다운로드 +- 권한 검증 +- 중복/유효성 서버 검증 + +## 8. 검증 방법 + +- 브라우저 기준 + - `통수위험` 아이콘 클릭 + - 결과 popup 열림 + - 날짜/시간 필수값 검증 + - 위험상태 필터 동작 + - 1건/2건/3건 동적 관로 그룹 표시 + - 페이지네이션 노출과 행 수 변화 + - 결과 행 선택 후 지도 강조 + - 설정 화면 전환 + - 유출관로 staged 목록 추가/삭제/순번 변경 + - 저장 payload preview 확인 +- 정적 확인 + - FE-only 프로토타입 코드는 완료됐고, 실제 브라우저 검증/BE 연동은 후속으로 진행한다. + +## 9. 현재 결정 상태 + +- popup 구조는 결과 조회와 설정 현황을 같은 popup 내부 전환 구조로 유지한다. +- 격자 overlay는 FE 1차 범위에 sample overlay로 포함했다. +- 다운로드는 실제 파일 처리 없이 preview까지를 FE 프로토타입 범위로 본다. +- staged 설정 편집 화면은 popup 내부 + 지도 연동 구조를 유지한다. + +## 10. 롤백 방법 + +- `통수위험` 아이콘 진입점 제거 +- waterRisk 전용 popup/hook/overlay 제거 +- 기존 지도 기본 기능만 남기기 + +## 11. 예상 위험 + +- 동적 관로 그룹 헤더/컬럼 렌더링이 테이블 공통 컴포넌트와 맞물리며 복잡해질 수 있다. +- 지도 강조와 예측강우 격자 overlay가 동시에 올라가면 렌더 성능 이슈가 생길 수 있다. +- 설정 편집 모드에서 기존 피처 클릭 상세와 충돌 가능성이 있다. +- 상세정보 카드의 지도 기준 고정 규칙은 사용자 피드백에 따라 다시 조정될 수 있다. + +## 12. 완료 기준 + +- `통수위험` 진입과 popup UI가 동작한다. +- 결과/설정 화면 전환이 가능하다. +- 동적 관로 그룹 렌더링이 1~3건 모두 자연스럽다. +- 지도 강조/상세정보/mock 격자 표시가 가능하다. +- staged 설정 목록과 저장 payload preview가 준비된다. +- FE-only 프로토타입 기준 구현이 완료되고, BE 연동 및 실제 통합 검증 후속 범위가 문서로 정리된다. +- 지도 상세정보 카드 위치 규칙은 현재 프로토타입 기준 동작으로 마감하고, 운영 기준 확정은 후속 검증으로 넘긴다. 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 new file mode 100644 index 000000000..272371f54 --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md @@ -0,0 +1,161 @@ +# SFR-011-007 통수위험분석 통합 구현 기록 + +## 1. 문서 목적 + +- `SFR-011-007 통수위험분석`의 FE 구현 이력과 남은 리스크를 기능당 1개 문서로 누적 관리한다. +- 현재 문서는 설계 초안부터 FE-only 프로토타입 구현까지의 누적 이력을 기록하며, 이후 실제 BE 연동과 통합 검증 결과를 계속 추가한다. + +## 2. 입력 근거 + +- `COMMON_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/001-de-SFR-011-007-통수위험분석-설계.md` +- `COMMON_DOCS_ROOT/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합공유기록.md` +- `FE_APP_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-작업카드.md` + +## 3. 공통 계약 + +- 작성자 승인 전에는 코드 수정 없이 문서 설계까지만 진행한다. +- FE 1차 구현은 실제 백엔드 연결 없이도 검토 가능한 범위를 우선한다. +- 결과 목록의 관로 정보는 1~3건 반복형 렌더링으로 구현하고, 고정 3세트 컬럼 가정은 두지 않는다. +- 설정 편집은 중점관리지역 1건 기준 staged 유출관로 1~3건을 FE 로컬 상태에 유지한다. +- 저장 실패 시 staged 목록과 지도 선택 상태는 유지한다. + +## 4. 구현 라운드 요약 + +### 4.1 2026-05-18 설계/초안 라운드 + +- 수행 내용 + - 작업지시 DOCX, HTML, PNG, 시나리오를 기준으로 기능 범위를 재정리했다. + - `SFR-012-003` 문서 구조를 기준으로 공통 설계서, 공통 공유기록, FE 작업카드, FE 구현기록, QA 계획/결과 문서 세트를 정의했다. + - 결과 조회, 지도보기, 설정 현황, 설정 편집/저장 흐름을 FE 단독 범위와 BE 필요 범위로 분리했다. + - 예상 수정 파일과 승인 필요 항목을 정리했다. +- 아직 하지 않은 내용 + - FE 코드 수정 + - popup 등록 + - mock 데이터 연결 + - 지도 overlay 구현 + - staged 설정 상태 구현 + +### 4.2 2026-05-18 FE-only 1차 구현 + +- 반영 파일 + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` + - `FE_APP_ROOT/app/(map-layout)/map/components/waterRisk/waterRiskTypes.ts` + - `FE_APP_ROOT/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx` + - `FE_APP_ROOT/src/features/windowPopup/model/types.ts` + - `FE_APP_ROOT/src/features/windowPopup/registry.tsx` + - `FE_APP_ROOT/src/features/windowPopup/ui/WaterRiskPopupContent.tsx` +- 구현 내용 + - `통수위험` popup registry 연결 + - 결과 조회/설정 현황 2탭 popup UI + - 관로 1~3건 동적 컬럼 렌더링 + - 결과 행 선택 시 main map 강조와 sample rain grid overlay 동기화 + - 설정 편집 모드 시작/종료, 지도 하수관로 클릭 기반 staged 목록 추가 + - staged 목록 순번 조정/삭제/초기화 + - 결과 다운로드 preview, 설정 다운로드 preview, 설정 저장 payload preview +- 아직 mock 기준인 항목 + - 실제 통수위험 결과 API + - 실제 중점관리지역 geometry/예측강우 grid 연계 + - 실제 설정 저장/재조회 + - 실제 파일 다운로드 + +### 4.3 2026-05-19 FE 프로토타입 마감 정리 + +- 반영 파일 + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` + - `FE_APP_ROOT/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx` + - `FE_APP_ROOT/app/(map-layout)/map/components/waterRisk/waterRiskTypes.ts` +- 구현 내용 + - 결과 조회/설정 현황 더미데이터를 확대해 페이지네이션이 실제로 보이도록 정리했다. + - 결과 조회와 설정 현황 toolbar 버튼 정렬, 본문 배경, 헤더 컬러를 기존 지도형 기능과 유사한 톤으로 맞췄다. + - 지도 상세정보 카드를 compact 표 구조로 재구성하고, 유출유량이 통수량을 초과하는 경우 강조 표시가 가능하게 정리했다. + - 지도 상세정보 카드 위치는 여러 차례 사용자 피드백을 반영해 프로토타입 기준 위치 조정이 가능한 상태까지 정리했다. +- 아직 mock 또는 후속 검증이 필요한 항목 + - 상세정보 카드의 최종 고정/앵커 규칙 확정 + - 실제 API 응답 구조에 맞춘 결과/상세/설정 데이터 치환 + - 실제 다운로드 및 저장 성공/실패 후처리 + +### 4.4 2026-05-19 FE 추가 보정 + +- 반영 파일 + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` +- 구현 내용 + - 상세정보 카드 위치 규칙은 사용자 피드백 기준으로 반복 보정되었고, 현재는 직접 추가 조정이 가능한 상태로 유지한다. + - 상세정보 카드 표 구조, 헤더/본문 spacing, 유출유량 초과 강조, 페이지네이션 노출 상태를 사용자 피드백에 맞춰 세부 조정했다. +- 확인 메모 + - UI 세부 보정 과정에서 타입 오류 1건(`buildWaterRiskInfoAnchor`)이 발생했으나 바로 수정했다. + +### 4.5 2026-05-19 상세정보 카드 위치 규칙 보정 + +- 반영 파일 + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` +- 구현 내용 + - 상세정보 카드는 선택된 중점관리지역의 오른쪽에 붙어 보이는 프로토타입 기준으로 재조정했다. + - 카드 anchor 좌표는 선택 대상 중점관리지역 geometry 기준으로 유지하고, 지도 렌더링 중에는 같은 anchor 기준으로 픽셀 위치를 다시 계산하도록 보정했다. + - 화면 고정형 팝업이 아니라 지도 대상 옆에서 같이 움직이는 정보 카드 방향으로 정리했다. +- 남은 메모 + - 실제 운영에서 요구되는 카드 고정 규칙과 사용성은 브라우저 수동 검증에서 한 번 더 확정할 필요가 있다. + +## 5. 기능별 현재 상태 + +### 5.1 결과 조회 + +- 설계 완료 +- FE-only 프로토타입 구현 완료 +- 핵심 구현 메모 + - 날짜/시간/지역/위험상태 필터 + - 결과 목록 + - 관로 1~3건 동적 컬럼 + - 페이지네이션 및 mock 행 렌더링 + +### 5.2 지도보기 + +- 설계 완료 +- FE-only 프로토타입 구현 완료 +- 핵심 구현 메모 + - 결과 행 선택 기반 지도 이동 + - 위험/정상 polygon 스타일 + - 예측강우 sample grid overlay + - 상세정보 카드 + +### 5.3 설정 현황 + +- 설계 완료 +- FE-only 프로토타입 구현 완료 +- 핵심 구현 메모 + - 결과 popup에서 같은 흐름으로 전환 + - 설정 목록 조회 + - 설정 목록 다운로드 조건 유지 + - 페이지네이션 및 mock 설정 행 렌더링 + +### 5.4 설정 편집/저장 + +- 설계 완료 +- FE-only 프로토타입 구현 완료 +- 핵심 구현 메모 + - 지도 유출관로 선택 + - staged 목록 1~3건 + - 순번 조정 + - 저장 payload preview + +## 6. 예상 구현 순서 + +1. `통수위험` 아이콘과 popup 진입점 연결 +2. 결과 조회/설정 현황 화면 구조 구현 +3. mock 결과 데이터와 동적 관로 컬럼 렌더링 +4. 결과 행 선택 기반 지도 강조와 sample grid overlay +5. staged 유출관로 편집 상태와 저장 payload preview +6. BE 계약 확정 후 실제 API 연결 + +## 7. 남은 리스크 + +- popup 1개 탭 구조와 popup 2개 분리 구조 중 하나를 먼저 확정해야 구현 방향이 흔들리지 않는다. +- 격자 overlay는 mock 단계와 실제 데이터 단계의 렌더 방식 차이가 클 수 있다. +- 설정 편집 모드에서 기존 지도 클릭 기능과 충돌 가능성이 있다. +- 상세정보 카드 위치 규칙은 FE 프로토타입 단계에서 반복 조정된 항목이라 운영 규칙 확정 전 재검토가 필요하다. +- 지도 drag/zoom 중 상세정보 카드의 체감 동작은 실제 사용자 손 기준으로 재확인이 필요하다. + +## 8. 다음 라운드 착수 조건 + +- 실제 API 응답 구조 확정 +- BE 저장/다운로드 계약 확정 +- 인증 가능한 브라우저 환경에서 수동 검증 가능 상태 확보 diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts index e982bdfb3..ff6c1207e 100644 --- a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -118,7 +118,7 @@ export const windowPopupDefinitions = { flowRiskPopup: { key: "flowRiskPopup", title: "통수 위험", - width: 1080, + width: 920, height: 760, }, } as const satisfies Record; diff --git a/apps/sn/src/features/windowPopup/model/types.ts b/apps/sn/src/features/windowPopup/model/types.ts index f46e747d6..220988c12 100644 --- a/apps/sn/src/features/windowPopup/model/types.ts +++ b/apps/sn/src/features/windowPopup/model/types.ts @@ -36,6 +36,14 @@ export const WINDOW_POPUP_MESSAGE_TYPE = { GIS_EDIT_SESSION_FOCUS_CHANGE: "sn:popup:gisEditSessionFocusChange", GIS_EDIT_SESSION_REMOVE_LAST_CHANGE: "sn:popup:gisEditSessionRemoveLastChange", GIS_EDIT_SESSION_CLEAR_CHANGES: "sn:popup:gisEditSessionClearChanges", + WATER_RISK_READY: "sn:popup:waterRiskReady", + WATER_RISK_SYNC: "sn:popup:waterRiskSync", + WATER_RISK_SELECT_RESULT: "sn:popup:waterRiskSelectResult", + WATER_RISK_START_SETTING_EDIT: "sn:popup:waterRiskStartSettingEdit", + WATER_RISK_STOP_SETTING_EDIT: "sn:popup:waterRiskStopSettingEdit", + WATER_RISK_REMOVE_STAGED_PIPE: "sn:popup:waterRiskRemoveStagedPipe", + WATER_RISK_REORDER_STAGED_PIPE: "sn:popup:waterRiskReorderStagedPipe", + WATER_RISK_CLEAR_STAGED_PIPES: "sn:popup:waterRiskClearStagedPipes", } as const; export type WindowPopupMessageType = diff --git a/apps/sn/src/features/windowPopup/registry.tsx b/apps/sn/src/features/windowPopup/registry.tsx index dfc55092e..adfc14fc7 100644 --- a/apps/sn/src/features/windowPopup/registry.tsx +++ b/apps/sn/src/features/windowPopup/registry.tsx @@ -17,6 +17,7 @@ import GisEditSessionPopupContent from "./ui/GisEditSessionPopupContent"; import GisManagementPopupContent from "./ui/GisManagementPopupContent"; import PipeAnalysisPopupContent from "./ui/PipeAnalysisPopupContent"; import PipeAnalysisDetailPopupContent from "./ui/PipeAnalysisDetailPopupContent"; +import WaterRiskPopupContent from "./ui/WaterRiskPopupContent"; const FloodPredictionMapPopupContent = (_props: WindowPopupContentProps) => ( ( ); -const FlowRiskPopupContent = (_props: WindowPopupContentProps) => ( - -); - export const windowPopupRegistry = { oldPpUpload: { ...windowPopupDefinitions.oldPpUpload, @@ -113,6 +110,6 @@ export const windowPopupRegistry = { }, flowRiskPopup: { ...windowPopupDefinitions.flowRiskPopup, - component: FlowRiskPopupContent, + component: WaterRiskPopupContent, }, } as const satisfies Record; diff --git a/apps/sn/src/features/windowPopup/ui/WaterRiskPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/WaterRiskPopupContent.tsx new file mode 100644 index 000000000..eb8cf33ee --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/WaterRiskPopupContent.tsx @@ -0,0 +1,12 @@ +import { Suspense } from "react"; + +import WaterRiskPopupPage from "@/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage"; +import type { WindowPopupContentProps } from "../model/types"; + +export default function WaterRiskPopupContent(props: WindowPopupContentProps) { + return ( + + + + ); +} From b4033071a17a8a99132944802d6e17bada373efa Mon Sep 17 00:00:00 2001 From: kiminchan Date: Thu, 21 May 2026 13:57:16 +0900 Subject: [PATCH 006/185] =?UTF-8?q?=ED=95=98=EC=88=98=EA=B4=80=EB=A1=9C=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EC=83=81=EC=84=B8=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EC=B6=94=EA=B0=80,=20=ED=95=98=EC=88=98?= =?UTF-8?q?=EA=B4=80=EB=A7=9D=EB=8F=84=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=8B=9C=EC=84=A4=20=EC=84=A0=ED=83=9D=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gisEdit/GisEditPipeSessionPopup.tsx | 228 +++++++++++++++++ .../gisEdit/GisEditSessionPanel.tsx | 17 +- .../map/components/gisEdit/gisEditTypes.ts | 25 ++ .../map/components/gisEdit/useGisEdit.ts | 112 ++++++++- .../gisManagement/GisManagementPopupPage.tsx | 124 --------- .../components/popupComponent/MsSwagePpl.tsx | 2 +- apps/sn/app/(map-layout)/map/page.tsx | 236 +++++++++++++++++- .../001-qa-SFR-012-002-하수관망도 업로드-통합검증계획.md | 5 +- .../001-qa-SFR-012-003-하수관로편집-통합검증계획.md | 14 ++ .../002-qa-SFR-012-003-하수관로편집-통합검증결과.md | 44 ++++ .../001-fe-SFR-012-002-하수관망도 업로드-작업카드.md | 3 +- .../001-fe-SFR-012-003-하수관로편집-작업카드.md | 17 +- .../001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md | 10 +- .../001-fe-SFR-012-003-하수관로편집-통합구현기록.md | 34 +++ .../business_components/src/ui/map/Gis.js | 3 +- .../src/ui/map/GisSelect.js | 12 +- 16 files changed, 728 insertions(+), 158 deletions(-) create mode 100644 apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx new file mode 100644 index 000000000..83bd8564f --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +import { Button } from "@common/components/ui"; + +import { getData } from "@/components/lib/api"; +import { VerticalTable } from "@/components/ui/verticalTable"; +import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue"; + +import type { GisEditChange, GisEditPipeBasicInfo } from "./gisEditTypes"; +import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES, MsSwagePplCol } from "../common/table"; + +type Props = { + change: GisEditChange; + featureData: any; + onClose: () => void; + onSave: (changeId: string, draft: GisEditPipeBasicInfo) => void; +}; + +const PIPE_INPUT_INFO_FIELDS = [ + { 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: "layCd", title: "매설위치", type: "select", options: ["", "도로", "하상"] }, +]; + +const PIPE_BASIC_INFO_FIELDS = MsSwagePplCol.map((column) => { + if (column.id === "usg_cd") { + return { + ...column, + type: "select", + options: ["", ...MS_SWAGE_PPL_USE_CODES.map((item) => item.text)], + }; + } + + if (column.id === "mqlt_cd") { + return { + ...column, + type: "select", + options: ["", ...MS_SWAGE_PPL_CODES.map((item) => item.text)], + }; + } + + return { + ...column, + type: "input", + }; +}); + +const buildBaseFeatureValues = (featureData: any) => { + const resolveUsageText = (value: unknown) => { + const raw = String(value ?? "").trim(); + const matched = MS_SWAGE_PPL_USE_CODES.find((item) => item.val === raw || item.text === raw); + return matched?.text ?? raw; + }; + + const resolveMaterialText = (value: unknown) => { + const raw = String(value ?? "").trim(); + const matched = MS_SWAGE_PPL_CODES.find((item) => item.val === raw || item.text === raw); + return matched?.text ?? raw; + }; + + if (featureData?.values_) { + const formatted = formatDisplayData(featureData.values_); + return { + ...formatted, + usg_cd: resolveUsageText(featureData.values_?.usg_cd ?? formatted.usg_cd), + mqlt_cd: resolveMaterialText(featureData.values_?.mqlt_cd ?? formatted.mqlt_cd), + }; + } + + if (featureData) { + const formatted = formatDisplayData(featureData); + return { + ...formatted, + usg_cd: resolveUsageText(featureData?.usg_cd ?? formatted.usg_cd), + mqlt_cd: resolveMaterialText(featureData?.mqlt_cd ?? formatted.mqlt_cd), + }; + } + + return {}; +}; + +const buildInitialInputDraft = (change: GisEditChange, baseValues: Record) => ({ + fcltMngNo: String(change.basicInfo?.fcltMngNo ?? change.featureRef?.fcltMngNo ?? baseValues.fclt_mng_no ?? baseValues.fcltMngNo ?? ""), + mngInstCd: String(change.basicInfo?.mngInstCd ?? change.featureRef?.mngInstCd ?? baseValues.mng_inst_cd ?? baseValues.mngInstCd ?? ""), + instlMbdyCd: String(change.basicInfo?.instlMbdyCd ?? ""), + swtrmtZoneNm: String(change.basicInfo?.swtrmtZoneNm ?? ""), + mltvZoneNm: String(change.basicInfo?.mltvZoneNm ?? ""), + mltvDtdvNm: String(change.basicInfo?.mltvDtdvNm ?? ""), + lineCd: String(change.basicInfo?.lineCd ?? ""), + floCd: String(change.basicInfo?.floCd ?? ""), + layCd: String(change.basicInfo?.layCd ?? ""), +}); + +export default function GisEditPipeSessionPopup({ + change, + featureData, + onClose, + onSave, +}: Props) { + const isNewPipe = change.editType === "ADD"; + const baseFeatureValues = useMemo(() => buildBaseFeatureValues(featureData), [featureData]); + const [basicInfoTableData, setBasicInfoTableData] = useState>({}); + const [inputInfoTableData, setInputInfoTableData] = useState>( + buildInitialInputDraft(change, baseFeatureValues), + ); + + useEffect(() => { + const initialBasicValues: Record = { + ...baseFeatureValues, + ...(change.basicInfo ?? {}), + }; + + if (isNewPipe) { + delete initialBasicValues.instl_ymd; + delete initialBasicValues.elapsedYears; + } + + setBasicInfoTableData(initialBasicValues); + setInputInfoTableData(buildInitialInputDraft(change, baseFeatureValues)); + }, [baseFeatureValues, change.changeId, isNewPipe]); + + useEffect(() => { + if (isNewPipe) return; + + const fcltMngNo = String(change.featureRef?.fcltMngNo ?? "").trim(); + const mngInstCd = String(change.featureRef?.mngInstCd ?? "").trim(); + if (!fcltMngNo || !mngInstCd) return; + + let isMounted = true; + const fetchPipeInputInfo = async () => { + try { + const result = await getData(`v1/fcltppl/${fcltMngNo}/${mngInstCd}`); + if (!isMounted || !result) return; + + setInputInfoTableData((previous) => ({ + ...previous, + ...result, + ...(change.basicInfo ?? {}), + fcltMngNo, + mngInstCd, + })); + } catch (error) { + console.error("GIS 편집 관로 입력정보 조회 실패:", error); + } + }; + + fetchPipeInputInfo(); + return () => { + isMounted = false; + }; + }, [change.changeId, change.featureRef?.fcltMngNo, change.featureRef?.mngInstCd, isNewPipe]); + + const editableBasicInfoFields = useMemo( + () => PIPE_BASIC_INFO_FIELDS.filter((field) => !(isNewPipe && (field.id === "instl_ymd" || field.id === "elapsedYears"))), + [isNewPipe], + ); + + const buildMergedDraft = ( + nextBasicInfoTableData: Record, + nextInputInfoTableData: Record, + ): GisEditPipeBasicInfo => ({ + ...nextBasicInfoTableData, + ...nextInputInfoTableData, + fcltMngNo: String(nextInputInfoTableData.fcltMngNo ?? change.featureRef?.fcltMngNo ?? change.objectId ?? ""), + mngInstCd: String(nextInputInfoTableData.mngInstCd ?? change.featureRef?.mngInstCd ?? ""), + }); + + const handleBasicInfoChange = (nextFormData: Record) => { + setBasicInfoTableData(nextFormData); + onSave(change.changeId, buildMergedDraft(nextFormData, inputInfoTableData)); + }; + + const handleInputInfoChange = (nextFormData: Record) => { + setInputInfoTableData(nextFormData); + onSave(change.changeId, buildMergedDraft(basicInfoTableData, nextFormData)); + }; + + return ( + + ); +} diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditSessionPanel.tsx b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditSessionPanel.tsx index 0900d875b..0ed92687d 100644 --- a/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditSessionPanel.tsx +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditSessionPanel.tsx @@ -42,11 +42,18 @@ const ChangeList = ({ changes, focusedChangeId, onFocusChange }: ChangeListProps
{index + 1}. {TOOL_LABELS[change.editType]} / {change.objectLabel}
- {focusedChangeId === change.changeId ? ( - - 지도 미리보기 선택 - - ) : null} +
+ {change.basicInfoSavedAt ? ( + + 기본정보 세션저장 + + ) : null} + {focusedChangeId === change.changeId ? ( + + 지도 미리보기 선택 + + ) : null} +
{change.summary}
diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts b/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts index 34e61e77c..dd2bfd3ed 100644 --- a/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts @@ -8,6 +8,25 @@ export type GisEditPreviewColors = { fillColor: string; }; +export type GisEditFeatureRef = { + fcltMngNo?: string; + mngInstCd?: string; + fid?: string; +}; + +export type GisEditPipeBasicInfo = { + [key: string]: string | undefined; + fcltMngNo?: string; + mngInstCd?: string; + instlMbdyCd?: string; + swtrmtZoneNm?: string; + mltvZoneNm?: string; + mltvDtdvNm?: string; + lineCd?: string; + floCd?: string; + layCd?: string; +}; + export type GisEditLayerInfo = { layerId: string; layerName: string; @@ -24,9 +43,12 @@ export type GisEditChange = { layerId: string; layerName: string; objectId?: string; + featureRef?: GisEditFeatureRef; objectLabel: string; beforeGeometry?: Record | null; afterGeometry?: Record | null; + basicInfo?: GisEditPipeBasicInfo | null; + basicInfoSavedAt?: string | null; summary: string; createdAt: string; source: "MAP_SELECTION" | "MAP_CENTER" | "MAP_DRAW"; @@ -42,9 +64,12 @@ export type GisEditSavePayload = { bbox: string | null; }; changes: Array<{ + changeId: string; editType: GisEditTool; objectId?: string; clientTempId?: string; + featureRef?: GisEditFeatureRef; + basicInfo?: GisEditPipeBasicInfo | null; beforeGeometry?: Record | null; afterGeometry?: Record | null; geometry?: Record | null; diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts b/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts index e99c7c4d9..75de19699 100644 --- a/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts @@ -5,8 +5,10 @@ import GeoJSON from "ol/format/GeoJSON.js"; import type { GisEditChange, + GisEditFeatureRef, GisEditGeometryType, GisEditLayerInfo, + GisEditPipeBasicInfo, GisEditPreviewColors, GisEditSavePayload, GisEditTool, @@ -110,6 +112,19 @@ const EDITABLE_LAYER_RULES: Record< const FLOW_DIRECTION_SRS = "EPSG:5179"; const SCREEN_ID = "SN_MN010301"; +const GIS_EDIT_ALLOWED_LAYER_IDS = new Set(["tb_ms_swage_ppl"]); + +export const EMPTY_GIS_EDIT_PIPE_BASIC_INFO: GisEditPipeBasicInfo = { + fcltMngNo: "", + mngInstCd: "", + instlMbdyCd: "", + swtrmtZoneNm: "", + mltvZoneNm: "", + mltvDtdvNm: "", + lineCd: "", + floCd: "", + layCd: "", +}; const normalizeLayerIdCandidate = (value: unknown) => { const raw = String(value ?? "").trim(); @@ -211,7 +226,7 @@ const resolvePreviewColors = ( }; }; -// FE 1차 구현은 시설 레이어 주요 7종을 기본 편집 후보로 열고, treeData에 없더라도 fallback 후보를 제공한다. +// 현행 운영 기준상 GIS 편집은 하수관로만 다루므로 treeData에서도 해당 레이어만 추린다. const collectEditableLayers = (treeData: any[]): GisEditLayerInfo[] => { const results = new Map(); @@ -221,7 +236,11 @@ const collectEditableLayers = (treeData: any[]): GisEditLayerInfo[] => { const layerTypeName = getLayerTypeName(data); const layerRule = EDITABLE_LAYER_RULES[layerTypeName]; - if (layerRule && !results.has(layerTypeName)) { + if ( + layerRule && + GIS_EDIT_ALLOWED_LAYER_IDS.has(layerTypeName) && + !results.has(layerTypeName) + ) { results.set(layerTypeName, { layerId: layerTypeName, layerName: layerRule.layerName, @@ -245,6 +264,7 @@ const collectEditableLayers = (treeData: any[]): GisEditLayerInfo[] => { if (results.size === 0) { Object.entries(EDITABLE_LAYER_RULES).forEach(([layerId, layerRule]) => { + if (!GIS_EDIT_ALLOWED_LAYER_IDS.has(layerId)) return; results.set(layerId, { layerId, layerName: layerRule.layerName, @@ -311,6 +331,36 @@ const getFeatureObjectId = (featureData: any) => "", ).trim(); +const getFeatureRef = (featureData: any): GisEditFeatureRef | undefined => { + const mapValues = featureData?.values_ ?? {}; + const featureRef: GisEditFeatureRef = { + fcltMngNo: String( + featureData?.get?.("fclt_mng_no") ?? + mapValues?.fclt_mng_no ?? + featureData?.fcltMngNo ?? + "", + ).trim() || undefined, + mngInstCd: String( + featureData?.get?.("mng_inst_cd") ?? + mapValues?.mng_inst_cd ?? + featureData?.mngInstCd ?? + "", + ).trim() || undefined, + fid: String( + featureData?.getId?.() ?? + featureData?.id_ ?? + featureData?.fid ?? + "", + ).trim() || undefined, + }; + + if (!featureRef.fcltMngNo && !featureRef.mngInstCd && !featureRef.fid) { + return undefined; + } + + return featureRef; +}; + const createChangeId = (prefix: string, sequence: number) => { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return `${prefix}-${crypto.randomUUID()}`; @@ -476,9 +526,18 @@ export const useGisEdit = (treeData: any[]) => { layerId: selectedLayer.layerId, layerName: selectedLayer.layerName, objectId: clientTempId, + featureRef: undefined, objectLabel: clientTempId, beforeGeometry: null, afterGeometry: geometry, + basicInfo: + selectedLayer.layerId === "tb_ms_swage_ppl" + ? { + ...EMPTY_GIS_EDIT_PIPE_BASIC_INFO, + fcltMngNo: clientTempId, + } + : null, + basicInfoSavedAt: null, summary: `${selectedLayer.layerName} 신규 객체를 지도에서 직접 입력했습니다.`, createdAt: new Date().toISOString(), source: "MAP_DRAW", @@ -501,17 +560,22 @@ export const useGisEdit = (treeData: any[]) => { focusChange(previewChangeId); return { handled: true, + isPreviewSelection: true, changeId: previewChangeId, editType: previewChange.editType, }; } if (!activeTool || !selectedLayer) return { handled: false }; - if (activeTool === "ADD") return { handled: true }; + if (activeTool === "ADD") return { handled: true, isPreviewSelection: false }; const featureLayerId = getFeatureLayerId(featureData); if (featureLayerId !== selectedLayer.layerId) { - setStatusMessage(`현재 선택 레이어는 ${selectedLayer.layerName}입니다. 같은 레이어의 객체를 선택하세요.`); + setStatusMessage( + selectedLayer.layerId === "tb_ms_swage_ppl" + ? "하수관로를 선택해주세요." + : `현재 선택 레이어는 ${selectedLayer.layerName}입니다. 같은 레이어의 객체를 선택하세요.`, + ); return { handled: true }; } @@ -536,6 +600,7 @@ export const useGisEdit = (treeData: any[]) => { focusChange(existingChange.changeId); return { handled: true, + isPreviewSelection: false, changeId: existingChange.changeId, editType: existingChange.editType, }; @@ -543,6 +608,7 @@ export const useGisEdit = (treeData: any[]) => { const geometryObject = toGeometryObject(featureData); const geometryType = normalizeGeometryType(geometryObject?.type); + const featureRef = getFeatureRef(featureData); const nextGeometry = activeTool === "MOVE" ? geometryObject @@ -566,9 +632,12 @@ export const useGisEdit = (treeData: any[]) => { layerId: selectedLayer.layerId, layerName: selectedLayer.layerName, objectId, + featureRef, objectLabel: objectId, beforeGeometry: geometryObject, afterGeometry: nextGeometry, + basicInfo: null, + basicInfoSavedAt: null, summary: activeTool === "VERTEX_EDIT" && geometryType === "Point" ? `${selectedLayer.layerName} ${objectId}는 점형 객체여서 꼭지점편집 대신 geometry 검토 대상으로 기록했습니다.` @@ -587,7 +656,7 @@ export const useGisEdit = (treeData: any[]) => { } else { setPendingInteraction(null); } - return { handled: true, changeId, editType: activeTool }; + return { handled: true, isPreviewSelection: false, changeId, editType: activeTool }; }, [activeTool, appendChange, findExistingObjectChange, focusChange, isEditMode, selectedLayer]); // 저장 API가 붙기 전까지는 실제 전송 대신 최종 payload를 조립해 검증 패널에 남긴다. @@ -607,9 +676,12 @@ export const useGisEdit = (treeData: any[]) => { bbox: buildBbox(map), }, changes: changes.map((change) => ({ + changeId: change.changeId, editType: change.editType, objectId: change.editType === "ADD" ? undefined : change.objectId, clientTempId: change.editType === "ADD" ? change.objectId : undefined, + featureRef: change.featureRef, + basicInfo: change.basicInfo ?? undefined, beforeGeometry: change.beforeGeometry, afterGeometry: change.editType === "DELETE" ? null : change.afterGeometry, geometry: change.editType === "ADD" ? change.afterGeometry : undefined, @@ -617,7 +689,7 @@ export const useGisEdit = (treeData: any[]) => { }; setLastSavedPayload(payload); - setStatusMessage("백엔드 저장 API가 아직 없어 payload 미리보기만 생성했습니다. 이 구조를 기준으로 BE와 연동하면 됩니다."); + setStatusMessage("관로 기본정보와 geometry를 함께 담은 최종 저장 payload를 생성했습니다. 현재는 FE 세션 검토 단계이며, 실제 DB 반영은 백엔드 저장 API가 필요합니다."); return payload; }, [changes, selectedLayer]); @@ -670,6 +742,31 @@ export const useGisEdit = (treeData: any[]) => { setStatusMessage(summary); }, []); + const updateChangeBasicInfo = useCallback(( + changeId: string, + basicInfo: GisEditPipeBasicInfo, + ) => { + setLastSavedPayload(null); + setChanges((previous) => + previous.map((change) => + change.changeId === changeId + ? { + ...change, + featureRef: { + ...change.featureRef, + fcltMngNo: basicInfo.fcltMngNo || change.featureRef?.fcltMngNo, + mngInstCd: basicInfo.mngInstCd || change.featureRef?.mngInstCd, + }, + basicInfo, + basicInfoSavedAt: new Date().toISOString(), + } + : change, + ), + ); + setFocusedChangeId(changeId); + setStatusMessage("관로 기본정보를 편집 세션에 저장했습니다. 최종 GIS 저장 전까지는 DB에 반영되지 않습니다."); + }, []); + const clearPendingInteraction = useCallback(() => { setPendingInteraction(null); }, []); @@ -692,7 +789,7 @@ export const useGisEdit = (treeData: any[]) => { pendingInteraction, focusedChangeId, pendingApiMessage: - "현재 구현은 FE 로컬 편집 세션과 저장 payload 미리보기까지 포함합니다. BE 저장 API(`/api/v1/map/gisEditSessions/save`) 연결 전까지는 실제 저장 대신 payload를 검토합니다.", + "현재 구현은 FE 로컬 편집 세션, 관로 기본정보 draft, 저장 payload 미리보기까지 포함합니다. BE 저장 API(`/api/v1/map/gisEditSessions/save`) 연결 전까지는 실제 저장 대신 payload를 검토합니다.", canUndo: changes.length > 0, toggleEditMode, selectLayer, @@ -703,6 +800,7 @@ export const useGisEdit = (treeData: any[]) => { removeLastChange, clearChanges, updateChangeGeometry, + updateChangeBasicInfo, clearPendingInteraction, focusChange, announceStatus, diff --git a/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx index 33eae9683..a838764d8 100644 --- a/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx @@ -29,18 +29,6 @@ const UPLOAD_FACILITY_TYPES: UploadFacilityType[] = [ allowedExtensions: [".zip", ".shp", ".geojson", ".json", ".dxf"], maxFileSizeMb: 200, }, - { - facilityTypeCode: "MHL", - facilityTypeName: "하수맨홀", - allowedExtensions: [".zip", ".shp", ".geojson", ".json"], - maxFileSizeMb: 100, - }, - { - facilityTypeCode: "SWTRMT_FCLT", - facilityTypeName: "하수처리시설", - allowedExtensions: [".zip", ".shp", ".geojson"], - maxFileSizeMb: 150, - }, ]; const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [ @@ -52,22 +40,6 @@ const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [ registeredAt: "2026-05-04", registeredBy: "admin", }, - { - uploadId: "GUP-MOCK-20260430-0002", - facilityTypeCode: "MHL", - facilityTypeName: "하수맨홀", - fileName: "asan_manhole_20260430.zip", - registeredAt: "2026-04-30", - registeredBy: "admin", - }, - { - uploadId: "GUP-MOCK-20260421-0003", - facilityTypeCode: "SWTRMT_FCLT", - facilityTypeName: "하수처리시설", - fileName: "00000", - registeredAt: "", - registeredBy: "", - }, { uploadId: "GUP-MOCK-20260418-0004", facilityTypeCode: "PPL", @@ -76,22 +48,6 @@ const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [ registeredAt: "2026-04-18", registeredBy: "admin", }, - { - uploadId: "GUP-MOCK-20260415-0005", - facilityTypeCode: "MHL", - facilityTypeName: "하수맨홀", - fileName: "asan_manhole_20260415.zip", - registeredAt: "2026-04-15", - registeredBy: "admin", - }, - { - uploadId: "GUP-MOCK-20260412-0006", - facilityTypeCode: "SWTRMT_FCLT", - facilityTypeName: "하수처리시설", - fileName: "asan_swtrmt_20260412.zip", - registeredAt: "2026-04-12", - registeredBy: "admin", - }, { uploadId: "GUP-MOCK-20260409-0007", facilityTypeCode: "PPL", @@ -100,22 +56,6 @@ const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [ registeredAt: "2026-04-09", registeredBy: "admin", }, - { - uploadId: "GUP-MOCK-20260406-0008", - facilityTypeCode: "MHL", - facilityTypeName: "하수맨홀", - fileName: "asan_manhole_20260406.zip", - registeredAt: "2026-04-06", - registeredBy: "admin", - }, - { - uploadId: "GUP-MOCK-20260403-0009", - facilityTypeCode: "SWTRMT_FCLT", - facilityTypeName: "하수처리시설", - fileName: "asan_treatment_20260403.zip", - registeredAt: "2026-04-03", - registeredBy: "admin", - }, { uploadId: "GUP-MOCK-20260331-0010", facilityTypeCode: "PPL", @@ -124,22 +64,6 @@ const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [ registeredAt: "2026-03-31", registeredBy: "admin", }, - { - uploadId: "GUP-MOCK-20260328-0011", - facilityTypeCode: "MHL", - facilityTypeName: "하수맨홀", - fileName: "asan_manhole_20260328.zip", - registeredAt: "2026-03-28", - registeredBy: "admin", - }, - { - uploadId: "GUP-MOCK-20260325-0012", - facilityTypeCode: "SWTRMT_FCLT", - facilityTypeName: "하수처리시설", - fileName: "asan_swtrmt_20260325.zip", - registeredAt: "2026-03-25", - registeredBy: "admin", - }, { uploadId: "GUP-MOCK-20260322-0013", facilityTypeCode: "PPL", @@ -148,22 +72,6 @@ const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [ registeredAt: "2026-03-22", registeredBy: "admin", }, - { - uploadId: "GUP-MOCK-20260319-0014", - facilityTypeCode: "MHL", - facilityTypeName: "하수맨홀", - fileName: "asan_manhole_20260319.zip", - registeredAt: "2026-03-19", - registeredBy: "admin", - }, - { - uploadId: "GUP-MOCK-20260316-0015", - facilityTypeCode: "SWTRMT_FCLT", - facilityTypeName: "하수처리시설", - fileName: "asan_treatment_20260316.zip", - registeredAt: "2026-03-16", - registeredBy: "admin", - }, { uploadId: "GUP-MOCK-20260313-0016", facilityTypeCode: "PPL", @@ -172,22 +80,6 @@ const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [ registeredAt: "2026-03-13", registeredBy: "admin", }, - { - uploadId: "GUP-MOCK-20260310-0017", - facilityTypeCode: "MHL", - facilityTypeName: "하수맨홀", - fileName: "asan_manhole_20260310.zip", - registeredAt: "2026-03-10", - registeredBy: "admin", - }, - { - uploadId: "GUP-MOCK-20260307-0018", - facilityTypeCode: "SWTRMT_FCLT", - facilityTypeName: "하수처리시설", - fileName: "asan_swtrmt_20260307.zip", - registeredAt: "2026-03-07", - registeredBy: "admin", - }, { uploadId: "GUP-MOCK-20260304-0019", facilityTypeCode: "PPL", @@ -196,22 +88,6 @@ const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [ registeredAt: "2026-03-04", registeredBy: "admin", }, - { - uploadId: "GUP-MOCK-20260301-0020", - facilityTypeCode: "MHL", - facilityTypeName: "하수맨홀", - fileName: "asan_manhole_20260301.zip", - registeredAt: "2026-03-01", - registeredBy: "admin", - }, - { - uploadId: "GUP-MOCK-20260226-0021", - facilityTypeCode: "SWTRMT_FCLT", - facilityTypeName: "하수처리시설", - fileName: "asan_treatment_20260226.zip", - registeredAt: "2026-02-26", - registeredBy: "admin", - }, ]; const getFileExtension = (fileName: string) => { diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx index 0e2ec3780..2fda0b285 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx @@ -160,4 +160,4 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic ) } -export default MsSwagePpl \ No newline at end of file +export default MsSwagePpl diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 90bfb5cae..73ef17a3c 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -52,8 +52,9 @@ import useStore from "@/store/useStore"; import AgedPipeLayerSettingPanel from "./components/agedPipe/AgedPipeLayerSettingPanel"; import GisEditToolbar from "./components/gisEdit/GisEditToolbar"; +import GisEditPipeSessionPopup from "./components/gisEdit/GisEditPipeSessionPopup"; import { useGisEdit } from "./components/gisEdit/useGisEdit"; -import type { GisEditSessionSnapshot } from "./components/gisEdit/gisEditTypes"; +import type { GisEditPipeBasicInfo, GisEditSessionSnapshot } from "./components/gisEdit/gisEditTypes"; import { PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE, type PipeAnalysisDetailRow, @@ -626,6 +627,19 @@ const getSquaredPixelDistance = ( return (endX - startX) ** 2 + (endY - startY) ** 2; }; +const GIS_EDIT_VERTEX_CLUSTER_PIXEL_THRESHOLD = 12; + +const getAverageCoordinate = (coordinates: number[][]) => { + if (coordinates.length === 0) return [0, 0]; + + const [sumX, sumY] = coordinates.reduce<[number, number]>( + ([accX, accY], [x = 0, y = 0]) => [accX + Number(x), accY + Number(y)], + [0, 0], + ); + + return [sumX / coordinates.length, sumY / coordinates.length]; +}; + const getPixelDistanceToSegment = ( map: any, coordinate: number[], @@ -752,23 +766,40 @@ const collectVertexHandleCandidates = (geometry: any) => { return []; }; -const buildVertexHandleFeatures = (geometry: any): Feature[] => { - const groupedCandidates = new globalThis.Map(); +const buildVertexHandleFeatures = (geometry: any, map?: any): Feature[] => { + const groupedCandidates: Array<{ + coordinate: number[]; + metaList: VertexHandleMeta[]; + sourceCoordinates: number[][]; + }> = []; collectVertexHandleCandidates(geometry).forEach((candidate: VertexHandleCandidate) => { - const key = buildVertexCoordinateKey(candidate.coordinate); - const existing = groupedCandidates.get(key); + const existing = groupedCandidates.find((group) => { + if (!map) { + return buildVertexCoordinateKey(group.coordinate) === buildVertexCoordinateKey(candidate.coordinate); + } + + return ( + getSquaredPixelDistance(map, group.coordinate, candidate.coordinate) <= + GIS_EDIT_VERTEX_CLUSTER_PIXEL_THRESHOLD ** 2 + ); + }); + if (existing) { existing.metaList.push(candidate.meta); + existing.sourceCoordinates.push(candidate.coordinate); + existing.coordinate = getAverageCoordinate(existing.sourceCoordinates); return; } - groupedCandidates.set(key, { + + groupedCandidates.push({ coordinate: candidate.coordinate, metaList: [candidate.meta], + sourceCoordinates: [candidate.coordinate], }); }); - return Array.from(groupedCandidates.values()).map(({ coordinate, metaList }: { coordinate: number[]; metaList: VertexHandleMeta[] }) => + return groupedCandidates.map(({ coordinate, metaList }) => createVertexHandleFeature(coordinate, metaList[0]!, metaList), ); }; @@ -861,7 +892,7 @@ const getNearestVertexPixelDistance = ( geometry: any, coordinate: number[], ): number => { - return buildVertexHandleFeatures(geometry).reduce((minimumDistance: number, feature: Feature) => { + return buildVertexHandleFeatures(geometry, map).reduce((minimumDistance: number, feature: Feature) => { const vertexCoordinate = (feature.getGeometry?.() as any)?.getCoordinates?.(); if (!Array.isArray(vertexCoordinate)) return minimumDistance; return Math.min( @@ -1400,6 +1431,10 @@ const Map = () => { const agedPipePopupWindowRef = useRef(null); const pipeAnalysisPopupWindowRef = useRef(null); const gisEditSessionPopupWindowRef = useRef(null); + const [gisEditDetailChangeId, setGisEditDetailChangeId] = useState(null); + const gisEditDetailChangeRef = useRef(null); + const gisEditDetailFeatureDataRef = useRef(null); + const gisEditIsEditModeRef = useRef(false); const waterRiskPopupWindowRef = useRef(null); const waterRiskPopupCloseWatcherRef = useRef(null); // 유하방향 레이어 체크 여부를 React 렌더링 흐름에 반영하기 위한 state이다. @@ -1466,6 +1501,44 @@ const Map = () => { gisEdit.lastSavedPayload, gisEdit.selectedLayer, ]); + const gisEditDetailChange = useMemo( + () => gisEdit.changes.find((change) => change.changeId === gisEditDetailChangeId) ?? null, + [gisEdit.changes, gisEditDetailChangeId], + ); + const gisEditDetailFeatureData = useMemo(() => { + if (!gisEditDetailChange) return null; + if (featureData) return featureData; + + if (gisEditDetailChange.layerId !== "tb_ms_swage_ppl") return null; + + return { + lyrTypeNm: "tb_ms_swage_ppl", + fcltMngNo: gisEditDetailChange.featureRef?.fcltMngNo ?? gisEditDetailChange.objectId ?? "", + mngInstCd: gisEditDetailChange.featureRef?.mngInstCd ?? "", + fclt_mng_no: gisEditDetailChange.featureRef?.fcltMngNo ?? gisEditDetailChange.objectId ?? "", + mng_inst_cd: gisEditDetailChange.featureRef?.mngInstCd ?? "", + inst_mng_no: "", + instl_ymd: "", + mapsht_no: "", + pdmt_vl: "", + prlg_vl: "", + usg_cd: "", + mqlt_cd: "", + avg_grdn: "", + drtw_mltv_area: "", + excln_mltv_area: "", + tmpt_dpth: "", + tmpt_mhl_mng_no: "", + scend_dpth: "", + scend_mhl_mng_no: "", + trfln_cnt: "", + vrtc_len: "", + wdth_len: "", + }; + }, [featureData, gisEditDetailChange]); + gisEditDetailChangeRef.current = gisEditDetailChange; + gisEditDetailFeatureDataRef.current = gisEditDetailFeatureData; + gisEditIsEditModeRef.current = gisEdit.isEditMode; const waterRiskSessionSnapshot = useMemo( () => waterRiskSession, [waterRiskSession], @@ -1486,6 +1559,14 @@ const Map = () => { ); }, [gisEditSessionSnapshot]); + const openGisEditPipeDetail = useCallback((changeId: string | null) => { + if (!changeId) { + setGisEditDetailChangeId(null); + return; + } + setGisEditDetailChangeId(changeId); + }, []); + const syncWaterRiskPopup = useCallback(() => { const popup = waterRiskPopupWindowRef.current; if (!popup || popup.closed) { @@ -1623,6 +1704,20 @@ const Map = () => { }; }, []); + useEffect(() => { + if (!gisEdit.isEditMode) { + setGisEditDetailChangeId(null); + return; + } + + if (!gisEditDetailChangeId) return; + if (!gisEditDetailChange) return; + + if (gisEditDetailChange.layerId !== "tb_ms_swage_ppl" || gisEditDetailChange.editType === "DELETE") { + setGisEditDetailChangeId(null); + } + }, [gisEdit.isEditMode, gisEditDetailChange, gisEditDetailChangeId]); + useEffect(() => { if (!(gisEdit.isEditMode || gisEdit.changes.length > 0)) return; syncGisEditSessionPopup(); @@ -1642,6 +1737,7 @@ const Map = () => { const changeId = String(data.payload?.changeId ?? "").trim(); if (changeId) { gisEdit.focusChange(changeId); + openGisEditPipeDetail(changeId); } return; } @@ -1660,7 +1756,7 @@ const Map = () => { return () => { window.removeEventListener("message", handleGisEditSessionPopupMessage); }; - }, [gisEdit, syncGisEditSessionPopup]); + }, [gisEdit, openGisEditPipeDetail, syncGisEditSessionPopup]); const fetchData = async () => { try { @@ -2306,9 +2402,44 @@ const Map = () => { // SearchModal, GisContent 등에서 호출 const onFeatureSelect = (data: any) => { + const clickedLayerTypeName = getSelectedFeatureLayerTypeName(data); + 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; + } + // 편집 모드 + activeTool 조합일 때는 상세 팝업 대신 편집 세션 누적을 우선한다. const editSelection = gisEdit.registerFeatureSelection(data); if (editSelection.handled) { + const shouldOpenPipeDetail = + clickedLayerTypeName === "tb_ms_swage_ppl" && + typeof editSelection.changeId === "string" && + editSelection.changeId && + editSelection.isPreviewSelection !== true; + + if (typeof editSelection.changeId === "string" && editSelection.changeId) { + openGisEditPipeDetail(editSelection.changeId); + } + if (shouldOpenPipeDetail) { + const select = GisApp.Module.select as any; + select?.resetSelectedFeature?.(); + setFeatureData(data); + window.setTimeout(() => { + setGisEditDetailChangeId(String(editSelection.changeId)); + setIsOpen(true); + }, 0); + setIsSearchModalOpen(false); + return; + } + // 편집 대상으로 넘긴 뒤에는 기존 상세 팝업/원본 선택 강조를 정리해 // 실제 편집 대상인 preview feature가 시각적으로 가려지지 않게 한다. const select = GisApp.Module.select as any; @@ -2327,7 +2458,7 @@ const Map = () => { setIsOpen(false); setIsSearchModalOpen(false); return; - } + } const nextPipe = buildWaterRiskPipeFromFeature(data); if (!nextPipe) { @@ -2946,6 +3077,53 @@ const Map = () => { gisEdit.save(gisMaps.current); }, [gisEdit]); + const handleApplyGisEditPipeBasicInfo = useCallback((changeId: string, basicInfo: GisEditPipeBasicInfo) => { + gisEdit.updateChangeBasicInfo(changeId, basicInfo); + }, [gisEdit]); + + const canSelectFeatureDuringGisEdit = useCallback((feature: any) => { + if (!gisEdit.isEditMode) return true; + if (gisEdit.activeTool !== "MOVE" && gisEdit.activeTool !== "VERTEX_EDIT") return true; + if (gisEdit.selectedLayer?.layerId !== "tb_ms_swage_ppl") return true; + + const layerTypeName = getSelectedFeatureLayerTypeName(feature); + if (layerTypeName === "tb_ms_swage_ppl") return true; + + gisEdit.announceStatus("하수관로를 선택해주세요."); + return false; + }, [gisEdit]); + + const GisEditAwareDetailView = useCallback((props: any) => { + const currentChange = gisEditDetailChangeRef.current; + const currentFeatureData = gisEditDetailFeatureDataRef.current; + const isEditMode = gisEditIsEditModeRef.current; + + if (isEditMode && currentChange && currentChange.layerId === "tb_ms_swage_ppl" && currentChange.editType !== "DELETE") { + return ( + { + setGisEditDetailChangeId(null); + props.onClose?.(); + }} + onSave={handleApplyGisEditPipeBasicInfo} + /> + ); + } + + return ( + { + setGisEditDetailChangeId(null); + props.onClose?.(); + }} + /> + ); + }, [handleApplyGisEditPipeBasicInfo]); + useEffect(() => { if (!mapReady || !gisMaps.current || !gisEdit.isEditMode) return; @@ -2982,7 +3160,37 @@ const Map = () => { featureProjection: GisApp.TO_PROJ, dataProjection: FLOW_DIRECTION_SRS, }) as Record; - gisEdit.registerAddGeometry(geometryObject); + const changeId = gisEdit.registerAddGeometry(geometryObject); + if (changeId && gisEdit.selectedLayer?.layerId === "tb_ms_swage_ppl") { + openGisEditPipeDetail(changeId); + setFeatureData({ + lyrTypeNm: "tb_ms_swage_ppl", + fcltMngNo: "", + mngInstCd: "", + fclt_mng_no: "", + inst_mng_no: "", + instl_ymd: "", + mapsht_no: "", + pdmt_vl: "", + prlg_vl: "", + usg_cd: "", + mqlt_cd: "", + avg_grdn: "", + drtw_mltv_area: "", + excln_mltv_area: "", + tmpt_dpth: "", + tmpt_mhl_mng_no: "", + scend_dpth: "", + scend_mhl_mng_no: "", + trfln_cnt: "", + vrtc_len: "", + wdth_len: "", + }); + window.setTimeout(() => { + setGisEditDetailChangeId(changeId); + setIsOpen(true); + }, 0); + } } catch (error) { console.error("GIS 편집 ADD geometry 변환 실패:", error); gisEdit.announceStatus("신규 geometry를 추가하지 못했습니다. 다시 시도해 주세요."); @@ -3001,6 +3209,7 @@ const Map = () => { }, [ ensureGisEditDrawSource, gisEdit, + openGisEditPipeDetail, mapReady, removeGisEditDrawInteraction, removeGisEditInteractions, @@ -3225,7 +3434,7 @@ const Map = () => { const renderVertexHandles = (hoveredMidpoint: VertexHandleCandidate | null = null) => { vertexSource.clear(); - buildVertexHandleFeatures(targetFeature.getGeometry?.()).forEach((handleFeature: Feature) => { + buildVertexHandleFeatures(targetFeature.getGeometry?.(), map).forEach((handleFeature: Feature) => { vertexSource.addFeature(handleFeature); }); if (hoveredMidpoint) { @@ -3539,7 +3748,8 @@ const Map = () => { gisMaps={gisMaps} isMiniMap={false} searchView={SearchModal} - detailView={FeatureClickEventPopup} + detailView={GisEditAwareDetailView} + canSelectFeature={canSelectFeatureDuringGisEdit} onFeatureSelect={onFeatureSelect} featureData={featureData} setFeatureData={setFeatureData} diff --git a/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/001-qa-SFR-012-002-하수관망도 업로드-통합검증계획.md b/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/001-qa-SFR-012-002-하수관망도 업로드-통합검증계획.md index 62738f50c..fe8b0e784 100644 --- a/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/001-qa-SFR-012-002-하수관망도 업로드-통합검증계획.md +++ b/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/001-qa-SFR-012-002-하수관망도 업로드-통합검증계획.md @@ -8,7 +8,7 @@ ## 2. 검증 기준 요약 - `GIS 관리` popup이 정상적으로 열린다. -- 시설구분 미선택 시 `찾기` 또는 `업로드`가 차단된다. +- 하수관로만 시설구분 대상으로 노출된다. - 파일 선택 시 파일명이 표시된다. - 업로드 성공 시 현황 목록이 즉시 갱신된다. - 다운로드는 선택 행이 있을 때만 가능 상태가 된다. @@ -24,7 +24,8 @@ ### 3.2 업로드 폼 -- 시설구분 미선택 상태에서 `찾기` 클릭 차단 +- 하수관로 단일 시설구분이 기본 표시되는지 확인 +- 하수맨홀, 하수처리시설 등 다른 시설구분이 노출되지 않는지 확인 - 파일 미선택 상태에서 `업로드` 클릭 차단 - 파일 선택 후 파일명 input 반영 diff --git a/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/001-qa-SFR-012-003-하수관로편집-통합검증계획.md b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/001-qa-SFR-012-003-하수관로편집-통합검증계획.md index be0225469..221bf0c55 100644 --- a/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/001-qa-SFR-012-003-하수관로편집-통합검증계획.md +++ b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/001-qa-SFR-012-003-하수관로편집-통합검증계획.md @@ -79,6 +79,20 @@ - 세션 안내 문구 영역이 제거됐는지 - `추가`, `이동`, `꼭지점편집`, `삭제` 버튼에 `lucide-react` 아이콘이 표시되는지 +### 3.9 2026-05-21 관로 기본정보 세션 편집 검증 + +- 편집 대상 레이어가 `하수관로`일 때만 편집 상세정보창이 열리는지 +- `MOVE`, `VERTEX_EDIT`, `ADD` change 선택 후 상세정보창 상단 `기본정보` 테이블에서 7개 필드가 직접 수정 가능한지 +- 신규 추가일 때 `경과년도`, `설치일자 확인불가` 같은 조회 전용 표식이 노출되지 않는지 +- 상세정보창 저장이 즉시 DB 저장이 아니라 `basicInfo` 세션 저장으로만 처리되는지 +- 입력 중 스크롤이 상단으로 점프하지 않고 현재 편집 위치를 유지하는지 +- `GIS 편집 세션` 목록에 `기본정보 세션저장` 표식이 나타나는지 +- 최종 저장 payload에 `featureRef`, `basicInfo`, `beforeGeometry`, `afterGeometry`가 함께 들어가는지 +- `DELETE` change나 관로 외 레이어에서는 상세정보 세션 편집창이 열리지 않는지 +- 편집 세션 모드에서는 `보수 및 갱신`, `청소 및 준설`, `점검 및 진단` 섹션이 숨겨지는지 +- `MOVE`, `VERTEX_EDIT` 중 비하수관로 클릭 시 선택/줌 이동 없이 차단되고 상태문구만 바뀌는지 +- `MOVE`, `VERTEX_EDIT`에서 하수관로 선택 즉시 편집 상세정보창이 열리는지 + ## 4. 현재 검증 범위 ### 4.1 진행 중 diff --git a/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md index 3fd2b6a96..3caacaa1d 100644 --- a/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md @@ -98,6 +98,50 @@ - 세션 팝업과 메인 지도 간 메시지 동기화 - 도구 아이콘 실제 렌더링 +### 2.9 2026-05-21 관로 기본정보 세션 편집 점검 + +- 코드 기준 점검 + - `GisEditChange`에 `featureRef`, `basicInfo`, `basicInfoSavedAt`가 추가돼 geometry change와 관로 기본정보 draft를 함께 담을 수 있게 했다. + - 하수관로 change를 선택하면 편집 전용 상세정보창을 열고, 기존 `GET /api/v1/fcltppl/{fcltMngNo}/{mngInstCd}`로 기본정보를 읽어 와 세션 저장할 수 있게 했다. + - 상세정보창 저장은 기존 즉시 `POST /api/v1/fcltppl`이 아니라 `updateChangeBasicInfo`로 change draft만 갱신한다. + - 최종 저장 payload는 `changeId`, `featureRef`, `basicInfo`, `beforeGeometry`, `afterGeometry`를 함께 조립하도록 바뀌었다. +- 정적 검증 + - `node node_modules/typescript/bin/tsc --noEmit --incremental false --pretty false` + - 결과: 기존 저장소 오류로 실패 + - 확인된 기존 오류 + - `app/(map-layout)/map/components/common/index.ts` 타입 불일치 + - `PipeAnalysisPopupPage.tsx`의 `JSON[]` 캐스팅 경고 + - html 라우트 경로 import 누락 + - 공통 chart 컴포넌트 implicit any + - 이번 변경 파일 관련 신규 오류는 별도 출력되지 않았다. +- 수동 재검증 필요 항목 + - 편집 모드에서 실제 하수관로 선택 시 상세정보창이 예상대로 열리는지 + - 기본정보 저장 후 세션 목록 뱃지가 정상 반영되는지 + - 최종 payload 미리보기에 관로 기본정보와 geometry가 함께 담기는지 + - 백엔드 geometry 저장 API 부재 상태에서 사용자가 최종 저장 한계를 어떻게 인지할지 + +### 2.10 2026-05-21 자동저장 루프/선택 제한 보강 점검 + +- 코드 기준 점검 + - 입력값 변경을 `useEffect` 자동 저장으로 처리하던 구조를 제거하고, 실제 `input/select` 변경 이벤트에서만 세션 draft를 갱신하도록 바꿨다. + - 편집 팝업 wrapper를 안정화해 입력 중 remount로 인한 스크롤 상단 점프 가능성을 줄였다. + - 공통 `GisSelect`에 `canSelectFeature` 가드를 연결해 `MOVE`, `VERTEX_EDIT` + 하수관로 레이어 조합일 때 비하수관로 선택을 선택 단계에서 차단하도록 확장했다. + - 비하수관로 차단 시 지도 줌 이동이나 상세정보 전환 없이 상태문구 `하수관로를 선택해주세요.`만 남기도록 정리했다. + - `MOVE`, `VERTEX_EDIT`의 하수관로 선택 시 상세정보창 오픈은 preview 준비 완료 대기 대신 `setTimeout(0)` 기준 즉시 오픈으로 단순화했다. +- 정적 검증 + - `node node_modules/typescript/bin/tsc --noEmit --incremental false --pretty false` + - 결과: 기존 저장소 오류로 실패 + - 확인된 기존 오류 + - `app/(map-layout)/map/components/common/index.ts` 타입 불일치 + - `PipeAnalysisPopupPage.tsx`의 `JSON[]` 캐스팅 경고 + - html 라우트 경로 import 누락 + - 공통 chart 컴포넌트 implicit any +- 수동 재검증 필요 항목 + - 하수관로 편집 팝업의 입력 중 스크롤 위치 유지 + - `MOVE`, `VERTEX_EDIT`에서 하수관로 선택 즉시 팝업 오픈 + - 비하수관로 클릭 시 줌 이동 완전 차단 + - `ADD` 미리보기와 `MOVE`, `VERTEX_EDIT` interaction 회귀 여부 + ## 3. 현재 검증 상태 ### 3.1 통과 diff --git a/apps/sn/docs/작업계획/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-작업카드.md b/apps/sn/docs/작업계획/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-작업카드.md index 15cc96f6e..af3d4b3e6 100644 --- a/apps/sn/docs/작업계획/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-작업카드.md +++ b/apps/sn/docs/작업계획/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-작업카드.md @@ -9,6 +9,7 @@ - 담당 역할: FE - 관련 역할: DE, BE, QA - 담당 범위: `GIS 관리` popup UI, 시설구분 선택, 파일 찾기, mock 업로드/다운로드 흐름, 업로드 payload 준비 상태까지 +- 운영 제약: `GIS 관리`는 하수관로만 업로드 대상으로 사용하고, 다른 GIS 테이블은 FE 시설구분 목록에서 제외 - 구현 제외: 실제 서버 업로드/다운로드 연결, 백엔드 저장 로직, DB 반영 - 근거 문서 - `COMMON_DOCS_ROOT/작업계획/SFR-012-002-하수관망도 업로드/001-de-SFR-012-002-하수관망도 업로드-설계.md` @@ -46,7 +47,7 @@ ## 2. 작업 목적 - 기존 지도 화면의 `GIS 관리` 기능을 실제 업로드 관리 popup으로 구체화한다. -- 사용자가 시설구분을 선택하고 GIS 파일을 찾은 뒤 업로드 이력과 다운로드 대상을 관리할 수 있는 FE 화면 흐름을 마련한다. +- 사용자가 하수관로 GIS 파일을 찾은 뒤 업로드 이력과 다운로드 대상을 관리할 수 있는 FE 화면 흐름을 마련한다. - 실제 백엔드 연결 전에도 UI, 상태, mock 데이터, 업로드 요청 payload 구조를 검토할 수 있도록 준비한다. ## 3. 예상 수정 대상 파일 diff --git a/apps/sn/docs/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md b/apps/sn/docs/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md index c3aec0802..bef95282e 100644 --- a/apps/sn/docs/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md +++ b/apps/sn/docs/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md @@ -7,7 +7,7 @@ - 작성일: 2026-05-14 - 담당 역할: FE - 관련 역할: DE, BE, QA -- 담당 범위: `apps/sn` 지도형 화면에서 GIS편집 진입, 레이어 선택, 추가/이동/꼭지점편집/삭제, 저장 payload 준비까지 구현 +- 담당 범위: `apps/sn` 지도형 화면에서 GIS편집 진입, 레이어 선택, 추가/이동/꼭지점편집/삭제, 관로 기본정보 세션 편집, 저장 payload 준비까지 구현 - 근거 문서: - `COMMON_DOCS_ROOT/작업계획/SFR-012-003-하수관로편집/001-de-SFR-012-003-하수관로편집-설계.md` - `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_요구사항명세서_v1_1.docx` @@ -16,6 +16,13 @@ - `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_화면설계서_v1_1.png` ## 1-2. 최근 진행 갱신 +- 2026-05-21 + - 실제 구현 코드를 다시 점검한 결과, 현재 GIS편집은 geometry 세션만 관리하고 관로 기본정보 세션은 없는 상태로 정리했다. + - `tb_ms_swage_ppl` geometry와 `tb_sn_swage_ppl` 기본정보 저장 구조가 분리돼 있어, 최종 저장 payload는 `featureRef + basicInfo + geometryPatch` 기준으로 재설계했다. + - 편집 모드에서 하수관로 change를 선택하면 기본정보만 세션 저장할 수 있는 편집 상세정보창을 추가하고, 최종 저장은 geometry와 기본정보를 함께 조립하는 방향으로 구현 중이다. + - 하수관로 편집 팝업의 입력정보 저장버튼은 제거하고, 입력 변경 시 세션 draft가 바로 반영되도록 수정했다. + - 편집 팝업 입력 시 스크롤 상단 점프와 `Maximum update depth exceeded` 에러 원인을 자동저장 effect 루프로 보고 제거했다. + - `MOVE`, `VERTEX_EDIT` 중에는 하수관로만 선택 가능하게 차단하고, 하수관로 선택 즉시 편집 상세정보창이 열리도록 보강했다. - 2026-05-14 - 7개 편집 레이어 확장, 레이어별 허용 도구 규칙, 로컬 세션 undo/reset, `MOVE`/`VERTEX_EDIT`/`DELETE` 임시 변경분 누적까지 반영했다. - 이번 라운드에서 `MOVE`를 preview feature 기반 실제 drag 흐름으로 올리고, 지도 위 preview 강조와 재선택 UX를 보강했다. @@ -42,6 +49,9 @@ - 프론트는 저장 전까지 편집 변경분을 로컬 세션으로 관리하고, 저장 시 BE가 제공할 편집 저장 API에 맞춰 payload를 전달한다. - 백엔드 개발자가 바로 이어받을 수 있도록 필요한 API, 화면 상태, 예외 흐름을 FE 기준으로 구체화한다. - 실제 API 구현은 범위 밖이며, 백엔드 공유용 요청 사항은 `작업내용` 문서로 분리해 관리한다. +- 추가 운영 제약: + - `GIS편집`, `GIS관리`는 하수관로만 대상으로 제한한다. + - 다른 GIS 테이블은 타회사 소유 범위로 보고 FE에서 선택/업로드 대상으로 노출하지 않는다. ## 3. 수정 대상 파일 | 구분 | 파일 경로 | 수정 여부 | 근거 | @@ -83,6 +93,7 @@ - `이동`은 preview drag로 위치 조정 - `꼭지점편집`은 선택 객체의 vertex marker를 노출하고 양 끝점을 포함해 이동 - 변경분이 로컬 편집 세션에 누적 + - 하수관로 편집은 기존 상세정보창 구조를 재사용하고, `입력정보` 저장은 세션 저장으로 처리 - 저장 시 원본과 분리된 편집본 저장 요청 - 빈 결과 흐름: - 변경분이 없으면 저장 비활성 또는 안내 메시지 표시 @@ -98,11 +109,13 @@ | --- | --- | --- | --- | --- | --- | | 기존 레이어 트리 조회 | `GET` | `/api/v1/map/lyrStngs/SN_NETWORK` | 기존 지도 레이어 그룹 | 레이어 트리 데이터 | 기존 사용 중 | | 편집 가능 레이어 메타 조회 | `GET` | `/api/v1/map/gisEditLayers` | `lyrGrp`, `screenId` | 편집 가능 레이어, geometry 타입, 허용 도구 | BE 신규 필요 | +| 관로 기본정보 조회 | `GET` | `/api/v1/fcltppl/{fcltMngNo}/{mngInstCd}` | 관로 식별자 | `PplDto` 기본정보 7개 필드 | 기존 사용 중 | | 편집 저장 | `POST` | `/api/v1/map/gisEditSessions/save` | 레이어 정보, view, `changes[]` | `sessionId`, `editFileId`, `versionNo`, `savedCount` | BE 신규 필요 | - FE 메모: - 저장 전까지 변경분은 FE 로컬 상태로 보관한다. - - 현재 구현은 실제 호출 대신 payload 미리보기만 생성한다. + - 관로 상세정보창의 저장은 즉시 API 호출이 아니라 change별 세션 저장으로 해석한다. + - 현재 구현은 실제 최종 저장 호출 대신 payload 미리보기만 생성한다. - BE 요청 사항은 `FE_APP_DOCS_ROOT/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-프론트공유API요청.md`에 별도 정리한다. ## 6. 영향 범위 diff --git a/apps/sn/docs/작업내용/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md b/apps/sn/docs/작업내용/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md index e20f818a9..8f9d81d59 100644 --- a/apps/sn/docs/작업내용/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md @@ -60,7 +60,7 @@ - 구현 완료 범위 - `GIS 관리` popup 진입 - - 시설구분 선택 + - 하수관로 단일 시설구분 표시/사용 - 파일 찾기 - 확장자/크기 1차 검증 - mock 업로드 이력 누적 @@ -74,6 +74,14 @@ - 업로드 성공 후 서버 이력 재조회 - 실제 시설물 레이어 기반 시설구분 동기화 +### 4.5 2026-05-21 업로드 대상 하수관로 단일화 + +- 운영 제약 기준으로 `GIS 관리` 업로드 대상은 하수관로만 사용한다. +- 이에 따라 `UPLOAD_FACILITY_TYPES` mock 상수에서 하수맨홀, 하수처리시설 항목을 제거했다. +- 업로드 현황 mock 목록도 하수관로 이력만 남기고 정리했다. +- FE 문서와 공통 공유 기록에서도 다른 GIS 테이블은 타회사 소유 범위라 업로드 대상에서 제외한다는 근거를 함께 남긴다. +- 현재 구조는 `/map` 진입 시 전체 레이어 트리를 읽더라도 `GIS 관리` popup 소비 단계에서는 하수관로만 노출하는 방식으로 유지한다. + ## 5. BE 공유 초안 ### 5.1 FE가 기대하는 메타 정보 diff --git a/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md b/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md index 2a99ad3aa..7c74014d8 100644 --- a/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md @@ -153,6 +153,40 @@ - 사용자 요청으로 오늘 작업 내역, 설계 변경, 검증 계획, 검증 결과를 묶은 제출용 단일 `md` 파일을 별도 생성한다. - 이는 통합 문서 체계 예외 산출물이며, 기능 운영 문서는 기존 canonical 4문서 체계를 계속 기준으로 유지한다. +### 4.14 2026-05-21 관로 기본정보 세션 편집 결합 + +- 실제 구현 재점검 결과 + - `useGisEdit`는 geometry change 세션만 관리하고 있었다. + - 관로 상세정보 저장은 기존 `FeatureClickEventPopup` 내부 `POST /api/v1/fcltppl` 즉시 저장 구조였다. + - 편집 모드에서는 피처 클릭이 상세 팝업 대신 session change 누적으로 우선 처리돼, 이동/꼭지점편집 중 기존 상세정보를 보며 수정하는 흐름이 없었다. +- 이번 라운드 반영 + - `GisEditChange`에 `featureRef`, `basicInfo`, `basicInfoSavedAt`를 추가했다. + - 기존 상세정보 구현 파일은 직접 수정하지 않는 방향으로 정리하고, GIS 편집 전용 하수관로 팝업을 별도 컴포넌트로 분리했다. + - 신규 추가는 같은 상세정보 형태의 편집 팝업을 빈 값으로 열고, 기존 관로 이동/꼭지점편집은 기존 값이 채워진 상태로 연다. + - 편집 전용 팝업은 `기본정보` editable 테이블과 `입력정보` editable 테이블만 표시하고, `보수 및 갱신`, `청소 및 준설`, `점검 및 진단` 섹션은 렌더링하지 않는다. + - 관로 기본정보는 기존 `GET /api/v1/fcltppl/{fcltMngNo}/{mngInstCd}`로 읽어 오되, 저장은 즉시 API 호출이 아니라 change draft 갱신으로만 처리한다. + - 최종 저장 payload는 `featureRef + basicInfo + geometryPatch` 구조로 다시 조립하도록 바꿨다. +- 남긴 판단 + - 현재 백엔드에는 geometry batch save API가 없어 FE는 payload 조립과 저장 지점 정렬까지 수행한다. + - 실제 DB 반영은 `tb_ms_swage_ppl` geometry와 `tb_sn_swage_ppl` 기본정보 저장 구조를 함께 처리하는 백엔드 계약이 추가로 필요하다. + +### 4.15 2026-05-21 편집 팝업 자동저장 루프 및 선택 제한 보강 + +- 사용자 피드백 기준 이슈 + - 편집 팝업 입력 시 스크롤이 상단으로 튀고, 일부 입력에서는 `Maximum update depth exceeded` 런타임 에러가 발생했다. + - `MOVE`, `VERTEX_EDIT` 중 하수관로 외 객체를 클릭하면 지도 기본 선택 흐름이 타면서 줌 이동이 발생했다. + - `MOVE`, `VERTEX_EDIT`에서 하수관로를 선택해도 편집 상세정보창이 항상 즉시 뜨지 않았다. +- 원인 정리 + - 편집 팝업 draft를 `useEffect`에서 자동 저장하도록 구성하면서 `팝업 입력 -> parent change 갱신 -> 팝업 재초기화 -> 재저장` 루프가 생겼다. + - `detailView` wrapper가 부모 렌더마다 재생성되어 스크롤/포커스가 흔들릴 여지가 있었다. + - `MOVE`, `VERTEX_EDIT`의 상세정보창 오픈을 preview 준비 완료 시점에 의존시켜 선택 직후 오픈이 누락되는 경우가 있었다. +- 이번 라운드 반영 + - 자동 세션 저장은 렌더 기반 effect에서 제거하고, 실제 `input/select` 변경 이벤트 시점에만 draft를 세션에 반영하도록 바꿨다. + - `detailView` wrapper를 안정화해 편집 팝업이 입력 중 불필요하게 remount되지 않도록 조정했다. + - `MOVE`, `VERTEX_EDIT`에서 하수관로를 선택하면 `setTimeout(0)` 기준으로 상세정보창을 즉시 열도록 단순화했다. + - 공통 `GisSelect`에 `canSelectFeature` 가드를 연결해, 하수관로 편집 중에는 비하수관로 피처를 아예 선택 단계에서 차단하도록 확장했다. + - 차단된 경우에는 지도 줌 이동이나 상세정보 전환 없이 상태문구 `하수관로를 선택해주세요.`만 남기도록 정리했다. + ## 5. 기능별 현재 상태 ### 5.1 `ADD` diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index 2fae15fc9..b377d6f90 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -49,6 +49,7 @@ function Gis({ searchView: SearchView, detailView: DetailView, onFeatureSelect, + canSelectFeature, setIsSearchModalOpen, onCheckedLayersChange, searchBar: SearchBar, @@ -354,7 +355,7 @@ function Gis({ )} - +
diff --git a/packages/common/business_components/src/ui/map/GisSelect.js b/packages/common/business_components/src/ui/map/GisSelect.js index c17563724..ac862c2f1 100644 --- a/packages/common/business_components/src/ui/map/GisSelect.js +++ b/packages/common/business_components/src/ui/map/GisSelect.js @@ -3,14 +3,19 @@ import { GisContext } from "./Gis"; import GisApp from "./lib/gis.core"; import GisOlSelect from "./lib/gis.select"; -export default React.memo(function GisSelect({ onFeatureSelect }) { +export default React.memo(function GisSelect({ onFeatureSelect, canSelectFeature }) { const { gisMap } = useContext(GisContext); const onFeatureSelectRef = useRef(onFeatureSelect); + const canSelectFeatureRef = useRef(canSelectFeature); useEffect(() => { onFeatureSelectRef.current = onFeatureSelect; }, [onFeatureSelect]); + useEffect(() => { + canSelectFeatureRef.current = canSelectFeature; + }, [canSelectFeature]); + useEffect(() => { if (!gisMap) return; // 아직 초기화 안 됐으면 아무것도 안 함 @@ -20,6 +25,11 @@ export default React.memo(function GisSelect({ onFeatureSelect }) { // 피처 선택 시 콜백 등록 select.getSelectedInfo(function (features) { if (features?.[0]) { + const isSelectable = canSelectFeatureRef.current?.(features[0]); + if (isSelectable === false) { + select.resetSelectedFeature?.(); + return; + } onFeatureSelectRef.current?.(features[0]); } }); From 17637146bef580a08a323fff308eaa81db573a45 Mon Sep 17 00:00:00 2001 From: mind-one001 Date: Thu, 21 May 2026 15:56:33 +0900 Subject: [PATCH 007/185] =?UTF-8?q?=EC=9C=A0=ED=95=98=EB=B0=A9=ED=96=A5=20?= =?UTF-8?q?=EC=A4=8C=EB=A0=88=EB=B2=A8=20=ED=91=9C=EC=8B=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/(map-layout)/map/page.tsx | 38 +++++++++++++++++++++------ 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 90bfb5cae..51d4776af 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -1408,6 +1408,35 @@ const Map = () => { const [flowDirectionLayerInfo, setFlowDirectionLayerInfo] = useState(null); // 관종 레이어 체크 변경도 유하방향 재조회 조건이므로 별도 버전 state로 effect를 다시 실행한다. const [flowDirectionSelectionVersion, setFlowDirectionSelectionVersion] = useState(0); + // 줌레벨 컨트롤에 현재 체크된 특수 레이어의 최소 표출 기준을 안내한다. + 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]); // moveend 이벤트 핸들러는 최신 state closure를 보장하지 않으므로 현재 체크 여부를 ref로 함께 보관한다. const flowDirectionCheckedRef = useRef(false); // 지도 이동 후 재조회할 때 최신 유하방향 레이어 설정을 참조하기 위한 ref이다. @@ -3568,14 +3597,7 @@ const Map = () => { showZoomScaleInfo={true} showFloatingSearchButton={false} treeTogglePosition="top" - zoomGuide={ - isAgedPipeChecked - ? { - label: "노후관로 기준", - zoom: AGED_PIPE_GUIDE_MIN_ZOOM, - } - : undefined - } + zoomGuide={zoomGuide} defaultZoom={7.5} defaultLonLat={[127.75, 36]} From c7c69ea5a73cfc6c8a37a334d44bec42190bbe41 Mon Sep 17 00:00:00 2001 From: mind-one001 Date: Mon, 1 Jun 2026 15:49:50 +0900 Subject: [PATCH 008/185] =?UTF-8?q?gis=20=ED=8E=B8=EC=A7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../map/components/common/table.ts | 3 + .../gisEdit/GisEditPipeSessionPopup.tsx | 305 ++++-- .../gisEdit/GisEditSessionPanel.tsx | 34 +- .../map/components/gisEdit/GisEditToolbar.tsx | 115 +-- .../map/components/gisEdit/gisEditTypes.ts | 47 +- .../map/components/gisEdit/useGisEdit.ts | 465 +++++++++- .../popupComponent/featureClickEventPopup.tsx | 114 ++- apps/sn/app/(map-layout)/map/page.tsx | 867 +++++++++++++++--- .../oper/_components/SearchDatePicker.tsx | 73 ++ .../(with-base-layout)/oper/floodPre/page.tsx | 15 +- .../(with-base-layout)/oper/oldPp/page.tsx | 15 +- apps/sn/app/components/lib/api/index.ts | 26 + apps/sn/app/components/ui/verticalTable.tsx | 74 +- .../002-qa-SFR-012-003-하수관로편집-통합검증결과.md | 320 ++++++- .../windowPopup/lib/openWindowPopup.ts | 50 +- .../windowPopup/model/popupDefinitions.ts | 7 +- .../src/features/windowPopup/model/types.ts | 1 + .../src/ui/map/lib/gis.layer.js | 5 +- .../src/ui/map/lib/gis.select.js | 4 +- .../src/ui/map/lib/gis.style.js | 3 +- 20 files changed, 2130 insertions(+), 413 deletions(-) create mode 100644 apps/sn/app/(with-base-layout)/oper/_components/SearchDatePicker.tsx diff --git a/apps/sn/app/(map-layout)/map/components/common/table.ts b/apps/sn/app/(map-layout)/map/components/common/table.ts index 81e14a83f..6818b45e4 100644 --- a/apps/sn/app/(map-layout)/map/components/common/table.ts +++ b/apps/sn/app/(map-layout)/map/components/common/table.ts @@ -1,7 +1,9 @@ // 하수관로 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: '도엽번호' }, @@ -33,6 +35,7 @@ export const MsSwagePplCol2 = [ { id: 'rowNumber', title: '일련\n번호' }, { id: 'fcltMngNo', title: '시설\n관리번호' }, { id: 'instMngNo', title: '기관\n관리번호' }, + { id: 'flowDirectionBasis', title: '유하방향\n근거' }, { id: 'ctpvNm', title: '시도' }, { id: 'sggNm', title: '시군구' }, { id: "swtrmt_zone_nm", title: "처리구역" }, diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx index 83bd8564f..01e462338 100644 --- a/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx @@ -6,10 +6,9 @@ import { Button } from "@common/components/ui"; import { getData } from "@/components/lib/api"; import { VerticalTable } from "@/components/ui/verticalTable"; -import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue"; import type { GisEditChange, GisEditPipeBasicInfo } from "./gisEditTypes"; -import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES, MsSwagePplCol } from "../common/table"; +import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from "../common/table"; type Props = { change: GisEditChange; @@ -18,83 +17,146 @@ type Props = { onSave: (changeId: string, draft: GisEditPipeBasicInfo) => void; }; +type MngInstOption = { + mngInstCd: string; + lwrInstNm: string; +}; + +const EMPTY_SELECT_OPTION = { value: "", label: "선택" }; + +const PIPE_BASIC_INFO_FIELDS = [ + { id: "mngInstCd", sourceId: "mng_inst_cd", title: "관리기관", type: "select", required: true }, + { id: "fcltMngNo", sourceId: "fclt_mng_no", title: "시설관리번호", type: "input", required: true }, + { id: "instMngNo", sourceId: "inst_mng_no", title: "기관관리번호", type: "input", required: true }, + { id: "instlYmd", sourceId: "instl_ymd", title: "설치일자", type: "date" }, + { id: "mapshtNo", sourceId: "mapsht_no", title: "도엽번호", type: "input" }, + { id: "pdmtVl", sourceId: "pdmt_vl", title: "관경(mm)", type: "input" }, + { id: "prlgVl", sourceId: "prlg_vl", title: "연장(m)", type: "input" }, + { + id: "usgCd", + sourceId: "usg_cd", + title: "용도", + type: "select", + required: true, + options: [ + EMPTY_SELECT_OPTION, + ...MS_SWAGE_PPL_USE_CODES.map((item) => ({ value: item.val.trim(), label: item.text })), + ], + }, + { + id: "mqltCd", + sourceId: "mqlt_cd", + title: "재질", + type: "select", + options: [ + EMPTY_SELECT_OPTION, + ...MS_SWAGE_PPL_CODES.map((item) => ({ value: item.val.trim(), label: item.text })), + ], + }, + { id: "avgGrdn", sourceId: "avg_grdn", title: "평균구배(m/m)", type: "input" }, + { id: "drtwMltvArea", sourceId: "drtw_mltv_area", title: "오수배수면적(m²)", type: "input" }, + { id: "exclnMltvArea", sourceId: "excln_mltv_area", title: "우수배수면적(m²)", type: "input" }, + { id: "tmptDpth", sourceId: "tmpt_dpth", title: "시점깊이(m)", type: "input" }, + { id: "tmptMhlMngNo", sourceId: "tmpt_mhl_mng_no", title: "시점맨홀관리번호", type: "input" }, + { id: "scendDpth", sourceId: "scend_dpth", title: "종점깊이(m)", type: "input" }, + { id: "scendMhlMngNo", sourceId: "scend_mhl_mng_no", title: "종점맨홀관리번호", type: "input" }, + { id: "trflnCnt", sourceId: "trfln_cnt", title: "차선수", type: "input" }, + { id: "vrtcLen", sourceId: "vrtc_len", title: "세로길이(m)", type: "input" }, + { id: "wdthLen", sourceId: "wdth_len", title: "가로길이(m)", type: "input" }, +]; + const PIPE_INPUT_INFO_FIELDS = [ - { id: "instlMbdyCd", title: "설치주체", type: "select", options: ["", "BTL", "재정사업"] }, + { id: "instlMbdyCd", title: "설치주체", type: "select", options: [EMPTY_SELECT_OPTION, "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: "layCd", title: "매설위치", type: "select", options: ["", "도로", "하상"] }, + { id: "lineCd", title: "지선/간선", type: "select", options: [EMPTY_SELECT_OPTION, "지선", "간선"] }, + { id: "floCd", title: "흐름방식", type: "select", options: [EMPTY_SELECT_OPTION, "자연유하", "압송"] }, + { id: "layCd", title: "매설위치", type: "select", options: [EMPTY_SELECT_OPTION, "도로", "하상"] }, ]; -const PIPE_BASIC_INFO_FIELDS = MsSwagePplCol.map((column) => { - if (column.id === "usg_cd") { - return { - ...column, - type: "select", - options: ["", ...MS_SWAGE_PPL_USE_CODES.map((item) => item.text)], - }; - } +const PIPE_VISIBLE_BASIC_FIELD_IDS = PIPE_BASIC_INFO_FIELDS.map((field) => field.id); +const PIPE_VISIBLE_INPUT_FIELD_IDS = PIPE_INPUT_INFO_FIELDS.map((field) => field.id); - if (column.id === "mqlt_cd") { - return { - ...column, - type: "select", - options: ["", ...MS_SWAGE_PPL_CODES.map((item) => item.text)], - }; - } - - return { - ...column, - type: "input", - }; -}); - -const buildBaseFeatureValues = (featureData: any) => { - const resolveUsageText = (value: unknown) => { - const raw = String(value ?? "").trim(); - const matched = MS_SWAGE_PPL_USE_CODES.find((item) => item.val === raw || item.text === raw); - return matched?.text ?? raw; - }; - - const resolveMaterialText = (value: unknown) => { - const raw = String(value ?? "").trim(); - const matched = MS_SWAGE_PPL_CODES.find((item) => item.val === raw || item.text === raw); - return matched?.text ?? raw; - }; - - if (featureData?.values_) { - const formatted = formatDisplayData(featureData.values_); - return { - ...formatted, - usg_cd: resolveUsageText(featureData.values_?.usg_cd ?? formatted.usg_cd), - mqlt_cd: resolveMaterialText(featureData.values_?.mqlt_cd ?? formatted.mqlt_cd), - }; - } - - if (featureData) { - const formatted = formatDisplayData(featureData); - return { - ...formatted, - usg_cd: resolveUsageText(featureData?.usg_cd ?? formatted.usg_cd), - mqlt_cd: resolveMaterialText(featureData?.mqlt_cd ?? formatted.mqlt_cd), - }; - } - - return {}; +const toEditableText = (value: unknown) => { + const text = String(value ?? "").trim(); + return text === "-" ? "" : text; }; -const buildInitialInputDraft = (change: GisEditChange, baseValues: Record) => ({ - fcltMngNo: String(change.basicInfo?.fcltMngNo ?? change.featureRef?.fcltMngNo ?? baseValues.fclt_mng_no ?? baseValues.fcltMngNo ?? ""), - mngInstCd: String(change.basicInfo?.mngInstCd ?? change.featureRef?.mngInstCd ?? baseValues.mng_inst_cd ?? baseValues.mngInstCd ?? ""), - instlMbdyCd: String(change.basicInfo?.instlMbdyCd ?? ""), - swtrmtZoneNm: String(change.basicInfo?.swtrmtZoneNm ?? ""), - mltvZoneNm: String(change.basicInfo?.mltvZoneNm ?? ""), - mltvDtdvNm: String(change.basicInfo?.mltvDtdvNm ?? ""), - lineCd: String(change.basicInfo?.lineCd ?? ""), - floCd: String(change.basicInfo?.floCd ?? ""), - layCd: String(change.basicInfo?.layCd ?? ""), +const toDateInputValue = (value: unknown) => { + const text = toEditableText(value).replaceAll(".", "").replaceAll("-", ""); + if (!/^\d{8}$/.test(text)) return ""; + return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`; +}; + +const toDbYmdValue = (value: unknown) => toEditableText(value).replaceAll("-", ""); + +const getObjectValue = (values: Record, field: { id: string; sourceId?: string }) => + values[field.id] ?? (field.sourceId ? values[field.sourceId] : undefined); + +const pickFields = (values: Record, fieldIds: string[]) => + fieldIds.reduce>((acc, fieldId) => { + if (Object.prototype.hasOwnProperty.call(values, fieldId)) { + acc[fieldId] = values[fieldId]; + } + return acc; + }, {}); + +const normalizeBasicFormValues = (values: Record) => { + const nextValues = pickFields(values, PIPE_VISIBLE_BASIC_FIELD_IDS); + if (Object.prototype.hasOwnProperty.call(nextValues, "instlYmd")) { + nextValues.instlYmd = toDateInputValue(nextValues.instlYmd); + } + return nextValues; +}; + +const buildBaseFeatureValues = (featureData: any) => { + const resolveUsageCode = (value: unknown) => { + const raw = String(value ?? "").trim(); + const matched = MS_SWAGE_PPL_USE_CODES.find((item) => item.val === raw || item.text === raw); + return matched?.val.trim() ?? raw; + }; + + const resolveMaterialCode = (value: unknown) => { + const raw = String(value ?? "").trim(); + const matched = MS_SWAGE_PPL_CODES.find((item) => item.val === raw || item.text === raw); + return matched?.val.trim() ?? raw; + }; + + const sourceValues = featureData?.values_ ?? featureData ?? {}; + return PIPE_BASIC_INFO_FIELDS.reduce>((acc, field) => { + const rawValue = getObjectValue(sourceValues, field); + if (field.id === "usgCd") { + acc[field.id] = resolveUsageCode(rawValue); + return acc; + } + if (field.id === "mqltCd") { + acc[field.id] = resolveMaterialCode(rawValue); + return acc; + } + if (field.id === "instlYmd") { + acc[field.id] = toDateInputValue(rawValue); + return acc; + } + acc[field.id] = toEditableText(rawValue); + return acc; + }, {}); +}; + +const getPipeUsageLabel = (value: unknown) => { + const raw = String(value ?? "").trim(); + if (!raw) return ""; + return MS_SWAGE_PPL_USE_CODES.find((item) => item.val.trim() === raw || item.text === raw)?.text ?? raw; +}; + +const buildInitialInputDraft = (change: GisEditChange) => ({ + instlMbdyCd: toEditableText(change.basicInfo?.instlMbdyCd ?? ""), + swtrmtZoneNm: toEditableText(change.basicInfo?.swtrmtZoneNm ?? ""), + mltvZoneNm: toEditableText(change.basicInfo?.mltvZoneNm ?? ""), + mltvDtdvNm: toEditableText(change.basicInfo?.mltvDtdvNm ?? ""), + lineCd: toEditableText(change.basicInfo?.lineCd ?? ""), + floCd: toEditableText(change.basicInfo?.floCd ?? ""), + layCd: toEditableText(change.basicInfo?.layCd ?? ""), }); export default function GisEditPipeSessionPopup({ @@ -107,22 +169,67 @@ export default function GisEditPipeSessionPopup({ const baseFeatureValues = useMemo(() => buildBaseFeatureValues(featureData), [featureData]); const [basicInfoTableData, setBasicInfoTableData] = useState>({}); const [inputInfoTableData, setInputInfoTableData] = useState>( - buildInitialInputDraft(change, baseFeatureValues), + buildInitialInputDraft(change), + ); + const [mngInstOptions, setMngInstOptions] = useState([]); + + useEffect(() => { + let isMounted = true; + + const fetchMngInstOptions = async () => { + try { + const result = await getData("v1/fcltppl/mngInsts"); + if (!isMounted) return; + setMngInstOptions(Array.isArray(result) ? result : []); + } catch (error) { + console.error("관리기관 목록 조회 실패:", error); + if (isMounted) setMngInstOptions([]); + } + }; + + fetchMngInstOptions(); + return () => { + isMounted = false; + }; + }, []); + + const mngInstSelectOptions = useMemo( + () => { + const sortedOptions = [...mngInstOptions].sort((a, b) => + String(a.lwrInstNm ?? a.mngInstCd ?? "").localeCompare( + String(b.lwrInstNm ?? b.mngInstCd ?? ""), + "ko", + ), + ); + return [ + EMPTY_SELECT_OPTION, + ...sortedOptions.map((option) => ({ + value: String(option.mngInstCd ?? "").trim(), + label: option.lwrInstNm || String(option.mngInstCd ?? "").trim(), + })), + ]; + }, + [mngInstOptions], + ); + + const basicInfoFields = useMemo( + () => + PIPE_BASIC_INFO_FIELDS.map((field) => + field.id === "mngInstCd" + ? { ...field, options: mngInstSelectOptions } + : field, + ), + [mngInstSelectOptions], ); useEffect(() => { const initialBasicValues: Record = { ...baseFeatureValues, - ...(change.basicInfo ?? {}), + ...normalizeBasicFormValues(change.basicInfo ?? {}), }; - if (isNewPipe) { - delete initialBasicValues.instl_ymd; - delete initialBasicValues.elapsedYears; - } - setBasicInfoTableData(initialBasicValues); - setInputInfoTableData(buildInitialInputDraft(change, baseFeatureValues)); + setInputInfoTableData(buildInitialInputDraft(change)); }, [baseFeatureValues, change.changeId, isNewPipe]); useEffect(() => { @@ -140,10 +247,8 @@ export default function GisEditPipeSessionPopup({ setInputInfoTableData((previous) => ({ ...previous, - ...result, - ...(change.basicInfo ?? {}), - fcltMngNo, - mngInstCd, + ...pickFields(result, PIPE_VISIBLE_INPUT_FIELD_IDS), + ...pickFields(change.basicInfo ?? {}, PIPE_VISIBLE_INPUT_FIELD_IDS), })); } catch (error) { console.error("GIS 편집 관로 입력정보 조회 실패:", error); @@ -156,20 +261,20 @@ export default function GisEditPipeSessionPopup({ }; }, [change.changeId, change.featureRef?.fcltMngNo, change.featureRef?.mngInstCd, isNewPipe]); - const editableBasicInfoFields = useMemo( - () => PIPE_BASIC_INFO_FIELDS.filter((field) => !(isNewPipe && (field.id === "instl_ymd" || field.id === "elapsedYears"))), - [isNewPipe], - ); - const buildMergedDraft = ( nextBasicInfoTableData: Record, nextInputInfoTableData: Record, - ): GisEditPipeBasicInfo => ({ - ...nextBasicInfoTableData, - ...nextInputInfoTableData, - fcltMngNo: String(nextInputInfoTableData.fcltMngNo ?? change.featureRef?.fcltMngNo ?? change.objectId ?? ""), - mngInstCd: String(nextInputInfoTableData.mngInstCd ?? change.featureRef?.mngInstCd ?? ""), - }); + ): GisEditPipeBasicInfo => { + const basicDraft = pickFields(nextBasicInfoTableData, PIPE_VISIBLE_BASIC_FIELD_IDS); + const inputDraft = pickFields(nextInputInfoTableData, PIPE_VISIBLE_INPUT_FIELD_IDS); + if (basicDraft.instlYmd) { + basicDraft.instlYmd = toDbYmdValue(basicDraft.instlYmd); + } + return { + ...basicDraft, + ...inputDraft, + }; + }; const handleBasicInfoChange = (nextFormData: Record) => { setBasicInfoTableData(nextFormData); @@ -181,14 +286,18 @@ export default function GisEditPipeSessionPopup({ onSave(change.changeId, buildMergedDraft(basicInfoTableData, nextFormData)); }; + const basicInfoTableRows = useMemo(() => [basicInfoTableData], [basicInfoTableData]); + const inputInfoTableRows = useMemo(() => [inputInfoTableData], [inputInfoTableData]); + const displayManagementNo = toEditableText(change.featureRef?.fcltMngNo ?? change.objectId ?? ""); + return ( + {isSaving ? "저장 중" : "저장"} + -
- {statusMessage} + +

- {activeTool === "MOVE" ? ( -
- 이동 대상 선택 후 파란 미리보기 객체를 드래그해 위치를 조정하세요. -
- ) : null} - {activeTool === "VERTEX_EDIT" ? ( -
- 편집 대상 선택 후 주황색 꼭지점 handle을 드래그해 형상을 조정하세요. -
- ) : null}
); } diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts b/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts index dd2bfd3ed..4c627942c 100644 --- a/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts @@ -15,16 +15,33 @@ export type GisEditFeatureRef = { }; export type GisEditPipeBasicInfo = { - [key: string]: string | undefined; - fcltMngNo?: string; - mngInstCd?: string; - instlMbdyCd?: string; - swtrmtZoneNm?: string; - mltvZoneNm?: string; - mltvDtdvNm?: string; - lineCd?: string; - floCd?: string; - layCd?: string; + [key: string]: string | null | undefined; + fcltMngNo?: string | null; + mngInstCd?: string | null; + instMngNo?: string | null; + instlYmd?: string | null; + mapshtNo?: string | null; + pdmtVl?: string | null; + prlgVl?: string | null; + usgCd?: string | null; + mqltCd?: string | null; + avgGrdn?: string | null; + drtwMltvArea?: string | null; + exclnMltvArea?: string | null; + tmptDpth?: string | null; + tmptMhlMngNo?: string | null; + scendDpth?: string | null; + scendMhlMngNo?: string | null; + trflnCnt?: string | null; + vrtcLen?: string | null; + wdthLen?: string | null; + instlMbdyCd?: string | null; + swtrmtZoneNm?: string | null; + mltvZoneNm?: string | null; + mltvDtdvNm?: string | null; + lineCd?: string | null; + floCd?: string | null; + layCd?: string | null; }; export type GisEditLayerInfo = { @@ -43,12 +60,16 @@ export type GisEditChange = { layerId: string; layerName: string; objectId?: string; + fid?: string; + baseEditSn?: string; featureRef?: GisEditFeatureRef; objectLabel: string; beforeGeometry?: Record | null; afterGeometry?: Record | null; + featureProperties?: Record | null; basicInfo?: GisEditPipeBasicInfo | null; basicInfoSavedAt?: string | null; + attributeOnly?: boolean; summary: string; createdAt: string; source: "MAP_SELECTION" | "MAP_CENTER" | "MAP_DRAW"; @@ -58,7 +79,7 @@ export type GisEditSavePayload = { screenId: string; layerId: string; layerName: string; - saveTargetType: "VERSION"; + saveTargetType: "VERSION" | "FINAL"; view: { srs: string; bbox: string | null; @@ -68,8 +89,10 @@ export type GisEditSavePayload = { editType: GisEditTool; objectId?: string; clientTempId?: string; + fid?: string; + baseEditSn?: string; featureRef?: GisEditFeatureRef; - basicInfo?: GisEditPipeBasicInfo | null; + attributes?: GisEditPipeBasicInfo | null; beforeGeometry?: Record | null; afterGeometry?: Record | null; geometry?: Record | null; diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts b/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts index 75de19699..47e91c0b9 100644 --- a/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts @@ -19,6 +19,12 @@ type GisEditInteractionRequest = { editType: "MOVE" | "VERTEX_EDIT"; }; +const isActionableChange = (change: GisEditChange) => + !change.attributeOnly || Boolean(change.basicInfoSavedAt); + +const isGeometryChange = (change: GisEditChange) => + !change.attributeOnly && change.editType !== "ADD"; + const buildPreviewFocusMessage = ( change: Pick, ) => { @@ -113,10 +119,57 @@ const EDITABLE_LAYER_RULES: Record< const FLOW_DIRECTION_SRS = "EPSG:5179"; const SCREEN_ID = "SN_MN010301"; const GIS_EDIT_ALLOWED_LAYER_IDS = new Set(["tb_ms_swage_ppl"]); +const GIS_EDIT_PIPE_ATTRIBUTE_FIELD_IDS = [ + "mngInstCd", + "fcltMngNo", + "instMngNo", + "instlYmd", + "mapshtNo", + "pdmtVl", + "prlgVl", + "usgCd", + "mqltCd", + "avgGrdn", + "drtwMltvArea", + "exclnMltvArea", + "tmptDpth", + "tmptMhlMngNo", + "scendDpth", + "scendMhlMngNo", + "trflnCnt", + "vrtcLen", + "wdthLen", + "instlMbdyCd", + "swtrmtZoneNm", + "mltvZoneNm", + "mltvDtdvNm", + "lineCd", + "floCd", + "layCd", +] as const; + +const buildPipeSaveAttributes = (basicInfo: GisEditPipeBasicInfo | null | undefined) => { + if (!basicInfo) return undefined; + + return GIS_EDIT_PIPE_ATTRIBUTE_FIELD_IDS.reduce((acc, fieldId) => { + if (!Object.prototype.hasOwnProperty.call(basicInfo, fieldId)) return acc; + + const value = basicInfo[fieldId]; + const normalizedValue = fieldId === "instlYmd" ? String(value ?? "").replaceAll("-", "") : value; + acc[fieldId] = + typeof normalizedValue === "string" && normalizedValue.trim() === "" + ? null + : normalizedValue; + return acc; + }, {}); +}; export const EMPTY_GIS_EDIT_PIPE_BASIC_INFO: GisEditPipeBasicInfo = { - fcltMngNo: "", mngInstCd: "", + fcltMngNo: "", + instMngNo: "", + instlYmd: "", + usgCd: "", instlMbdyCd: "", swtrmtZoneNm: "", mltvZoneNm: "", @@ -321,15 +374,52 @@ const getFeatureLayerId = (featureData: any) => { return normalizeLayerIdCandidate(featureData?.lyrTypeNm ?? featureData?.lyrTyNm ?? ""); }; +const stripLayerPrefix = (value: unknown) => { + const raw = String(value ?? "").trim(); + if (!raw) return ""; + + const withoutNamespace = raw.includes(":") ? raw.split(":").pop() ?? raw : raw; + if (withoutNamespace.startsWith("tb_ms_swage_ppl.")) { + return withoutNamespace.slice("tb_ms_swage_ppl.".length); + } + return withoutNamespace; +}; + +const getFeatureFid = (featureData: any) => + stripLayerPrefix( + featureData?.get?.("fid") ?? + featureData?.values_?.fid ?? + featureData?.fid ?? + featureData?.getId?.() ?? + featureData?.id_ ?? + "", + ); + const getFeatureObjectId = (featureData: any) => String( - featureData?.get?.("objectId") ?? - featureData?.values_?.fclt_mng_no ?? - featureData?.values_?.swtrmt_fclt_cd ?? + getFeatureFid(featureData) || + String( + featureData?.get?.("objectId") ?? + featureData?.values_?.fclt_mng_no ?? + featureData?.values_?.swtrmt_fclt_cd ?? + featureData?.fcltMngNo ?? + featureData?.fid ?? + "", + ), + ).trim(); + +const getFeatureObjectLabel = (featureData: any) => { + const values = featureData?.values_ ?? {}; + return String( + featureData?.get?.("fclt_mng_no") ?? + values?.fclt_mng_no ?? featureData?.fcltMngNo ?? - featureData?.fid ?? + featureData?.get?.("objectId") ?? + values?.swtrmt_fclt_cd ?? + getFeatureFid(featureData) ?? "", ).trim(); +}; const getFeatureRef = (featureData: any): GisEditFeatureRef | undefined => { const mapValues = featureData?.values_ ?? {}; @@ -346,12 +436,7 @@ const getFeatureRef = (featureData: any): GisEditFeatureRef | undefined => { featureData?.mngInstCd ?? "", ).trim() || undefined, - fid: String( - featureData?.getId?.() ?? - featureData?.id_ ?? - featureData?.fid ?? - "", - ).trim() || undefined, + fid: getFeatureFid(featureData) || undefined, }; if (!featureRef.fcltMngNo && !featureRef.mngInstCd && !featureRef.fid) { @@ -361,6 +446,32 @@ const getFeatureRef = (featureData: any): GisEditFeatureRef | undefined => { return featureRef; }; +// 선택한 원본 관로의 속성 snapshot을 보관해 preview 재선택 시 상세정보가 비지 않게 한다. +const getFeatureProperties = (featureData: any): Record | null => { + const values = featureData?.values_ ?? featureData?.getProperties?.() ?? featureData; + if (!values || typeof values !== "object") return null; + + const { geometry, ...properties } = values; + return properties; +}; + +const normalizePipeGeometry = ( + layerId: string, + geometry: Record | null, +): Record | null => { + if (layerId !== "tb_ms_swage_ppl" || !geometry) return geometry; + if (geometry.type !== "LineString") return geometry; + + const coordinates = geometry.coordinates; + if (!Array.isArray(coordinates)) return geometry; + + return { + ...geometry, + type: "MultiLineString", + coordinates: [coordinates], + }; +}; + const createChangeId = (prefix: string, sequence: number) => { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return `${prefix}-${crypto.randomUUID()}`; @@ -373,15 +484,15 @@ export const useGisEdit = (treeData: any[]) => { // GIS 편집 모드 on/off는 우측 분석 메뉴의 GIS 편집 버튼과 직접 연결된다. const [isEditMode, setIsEditMode] = useState(false); // 현재 선택 레이어를 기준으로 허용 도구와 저장 payload의 layerId를 결정한다. - const [selectedLayerId, setSelectedLayerId] = useState(""); + const [selectedLayerId, setSelectedLayerId] = useState("tb_ms_swage_ppl"); // 1차 구현에서는 activeTool과 지도 선택 이벤트를 조합해 변경분을 로컬 세션에 누적한다. const [activeTool, setActiveTool] = useState(null); - // 저장 전까지의 변경분은 FE 로컬 편집 세션으로만 유지하고 BE API 연결 전 payload 검토 근거로 사용한다. + // 저장 전까지의 변경분은 FE 로컬 편집 세션으로만 유지한다. const [changes, setChanges] = useState([]); const [statusMessage, setStatusMessage] = useState( - "GIS편집 버튼을 켜면 레이어를 선택하고 편집 도구를 사용할 수 있습니다.", + "GIS편집 버튼을 켜면 하수관로 편집 도구를 사용할 수 있습니다.", ); - // 실제 저장 대신 마지막으로 조립한 payload를 세션 패널에서 미리보기로 노출한다. + // 이전 구현 호환용 상태다. 저장 버튼은 payload 미리보기를 노출하지 않고 즉시 API 호출 흐름으로 넘어간다. const [lastSavedPayload, setLastSavedPayload] = useState(null); const [pendingInteraction, setPendingInteraction] = useState(null); const [focusedChangeId, setFocusedChangeId] = useState(null); @@ -394,7 +505,11 @@ export const useGisEdit = (treeData: any[]) => { ); const canUseTools = Boolean(selectedLayer); - const canSave = changes.length > 0 && Boolean(selectedLayer); + const actionableChanges = useMemo( + () => changes.filter(isActionableChange), + [changes], + ); + const canSave = actionableChanges.length > 0 && Boolean(selectedLayer); const appendChange = useCallback((change: GisEditChange) => { setLastSavedPayload(null); @@ -407,11 +522,7 @@ export const useGisEdit = (treeData: any[]) => { if (!targetChange) return; setFocusedChangeId(changeId); - if (targetChange.editType === "MOVE" || targetChange.editType === "VERTEX_EDIT") { - setPendingInteraction({ changeId, editType: targetChange.editType }); - } else { - setPendingInteraction(null); - } + setPendingInteraction(null); setStatusMessage(buildPreviewFocusMessage(targetChange)); }, [changes]); @@ -422,12 +533,26 @@ export const useGisEdit = (treeData: any[]) => { ) => changes.find( (change) => + !change.attributeOnly && change.editType === editType && change.layerId === layerId && String(change.objectId ?? "") === objectId, ) ?? null, [changes]); + // 같은 원본 관로에 대한 삭제/이동/꼭지점편집 변경분은 하나만 유지해 도구 전환 시 세션 상태가 섞이지 않게 한다. + const findExistingGeometryChange = useCallback(( + layerId: string, + objectId: string, + ) => + changes.find( + (change) => + isGeometryChange(change) && + change.layerId === layerId && + String(change.objectId ?? "") === objectId, + ) ?? null, + [changes]); + // 편집 모드를 끄더라도 작업 중 세션을 잃지 않도록 변경분은 유지하고 도구 상태만 정리한다. const toggleEditMode = useCallback(() => { setIsEditMode((previous) => { @@ -438,7 +563,7 @@ export const useGisEdit = (treeData: any[]) => { latestSelectionRef.current = null; setStatusMessage( next - ? "편집 대상 레이어를 선택한 뒤 지도에서 편집 작업을 진행하세요." + ? "하수관로 편집 도구를 선택한 뒤 지도에서 편집 작업을 진행하세요." : "GIS 편집 모드를 종료했습니다. 편집 세션은 유지됩니다.", ); return next; @@ -510,6 +635,13 @@ export const useGisEdit = (treeData: any[]) => { ); }, [appendChange, selectedLayer]); + const deactivateTool = useCallback(() => { + setActiveTool(null); + setPendingInteraction(null); + latestSelectionRef.current = null; + setStatusMessage("편집 도구 선택을 해제했습니다. 추가, 이동, 꼭지점편집, 삭제 모드가 모두 꺼졌습니다."); + }, []); + const registerAddGeometry = useCallback((geometry: Record | null) => { if (!selectedLayer || !geometry) { setStatusMessage("추가 geometry를 확인하지 못했습니다."); @@ -519,6 +651,7 @@ export const useGisEdit = (treeData: any[]) => { changeSequenceRef.current += 1; const clientTempId = `NEW-${changeSequenceRef.current}`; const changeId = createChangeId(`ADD-${selectedLayer.layerId}`, changeSequenceRef.current); + const nextGeometry = normalizePipeGeometry(selectedLayer.layerId, geometry); appendChange({ changeId, @@ -529,12 +662,11 @@ export const useGisEdit = (treeData: any[]) => { featureRef: undefined, objectLabel: clientTempId, beforeGeometry: null, - afterGeometry: geometry, + afterGeometry: nextGeometry, basicInfo: selectedLayer.layerId === "tb_ms_swage_ppl" ? { ...EMPTY_GIS_EDIT_PIPE_BASIC_INFO, - fcltMngNo: clientTempId, } : null, basicInfoSavedAt: null, @@ -557,6 +689,87 @@ export const useGisEdit = (treeData: any[]) => { const previewChange = changes.find((change) => change.changeId === previewChangeId); if (!previewChange) return { handled: true }; + if (previewChange.editType === "ADD" && activeTool === "DELETE") { + setLastSavedPayload(null); + setChanges((previous) => previous.filter((change) => change.changeId !== previewChangeId)); + setFocusedChangeId((current) => (current === previewChangeId ? null : current)); + setPendingInteraction((current) => (current?.changeId === previewChangeId ? null : current)); + latestSelectionRef.current = null; + setStatusMessage(`${previewChange.layerName} 신규 객체를 편집 세션에서 삭제했습니다.`); + return { + handled: true, + isPreviewSelection: true, + editType: "DELETE", + }; + } + + if ( + previewChange.editType !== "ADD" && + previewChange.editType !== "DELETE" && + activeTool === "DELETE" + ) { + setLastSavedPayload(null); + setChanges((previous) => + previous.map((change) => + change.changeId === previewChangeId + ? { + ...change, + editType: "DELETE", + afterGeometry: null, + summary: `${change.layerName} ${change.objectLabel} 객체를 임시 삭제 대상으로 표시했습니다.`, + } + : change, + ), + ); + setFocusedChangeId(previewChangeId); + setPendingInteraction(null); + latestSelectionRef.current = null; + setStatusMessage(`${previewChange.layerName} ${previewChange.objectLabel} 객체를 임시 삭제 대상으로 표시했습니다.`); + return { + handled: true, + isPreviewSelection: true, + changeId: previewChangeId, + editType: "DELETE", + fid: previewChange.fid ?? previewChange.featureRef?.fid ?? previewChange.objectId, + }; + } + + if ( + previewChange.editType === "DELETE" && + (activeTool === "MOVE" || activeTool === "VERTEX_EDIT") + ) { + setFocusedChangeId(previewChangeId); + setPendingInteraction(null); + setStatusMessage("이미 삭제 예정인 관로입니다. 이동 또는 꼭지점편집이 필요하면 마지막 변경 취소 후 다시 선택하세요."); + return { + handled: true, + isPreviewSelection: true, + changeId: previewChangeId, + editType: activeTool, + fid: previewChange.fid ?? previewChange.featureRef?.fid ?? previewChange.objectId, + }; + } + + if ( + previewChange.editType !== "DELETE" && + (activeTool === "MOVE" || activeTool === "VERTEX_EDIT") + ) { + setFocusedChangeId(previewChangeId); + setPendingInteraction({ changeId: previewChangeId, editType: activeTool }); + const targetLabel = previewChange.editType === "ADD" ? "신규 객체" : previewChange.objectLabel; + setStatusMessage( + activeTool === "MOVE" + ? `${previewChange.layerName} ${targetLabel}를 드래그해 위치를 조정하세요.` + : `${previewChange.layerName} ${targetLabel}의 꼭지점을 드래그해 형상을 조정하세요.`, + ); + return { + handled: true, + isPreviewSelection: true, + changeId: previewChangeId, + editType: activeTool, + }; + } + focusChange(previewChangeId); return { handled: true, @@ -585,6 +798,47 @@ export const useGisEdit = (treeData: any[]) => { return { handled: true }; } + const existingGeometryChange = findExistingGeometryChange(selectedLayer.layerId, objectId); + if (existingGeometryChange?.editType === "DELETE" && (activeTool === "MOVE" || activeTool === "VERTEX_EDIT")) { + focusChange(existingGeometryChange.changeId); + setPendingInteraction(null); + setStatusMessage("이미 삭제 예정인 관로입니다. 이동 또는 꼭지점편집이 필요하면 마지막 변경 취소 후 다시 선택하세요."); + return { + handled: true, + isPreviewSelection: false, + changeId: existingGeometryChange.changeId, + editType: "DELETE", + fid: existingGeometryChange.fid ?? existingGeometryChange.featureRef?.fid ?? objectId, + }; + } + + if (existingGeometryChange && existingGeometryChange.editType !== "DELETE" && activeTool === "DELETE") { + setLastSavedPayload(null); + setChanges((previous) => + previous.map((change) => + change.changeId === existingGeometryChange.changeId + ? { + ...change, + editType: "DELETE", + afterGeometry: null, + summary: `${change.layerName} ${change.objectLabel} 객체를 임시 삭제 대상으로 표시했습니다.`, + } + : change, + ), + ); + setFocusedChangeId(existingGeometryChange.changeId); + setPendingInteraction(null); + latestSelectionRef.current = null; + setStatusMessage(`${existingGeometryChange.layerName} ${existingGeometryChange.objectLabel} 객체를 임시 삭제 대상으로 표시했습니다.`); + return { + handled: true, + isPreviewSelection: false, + changeId: existingGeometryChange.changeId, + editType: "DELETE", + fid: existingGeometryChange.fid ?? existingGeometryChange.featureRef?.fid ?? objectId, + }; + } + const selectionSignature = `${activeTool}:${selectedLayer.layerId}:${objectId}`; const now = Date.now(); if ( @@ -598,30 +852,36 @@ export const useGisEdit = (treeData: any[]) => { const existingChange = findExistingObjectChange(activeTool, selectedLayer.layerId, objectId); if (existingChange) { focusChange(existingChange.changeId); + if (activeTool === "MOVE" || activeTool === "VERTEX_EDIT") { + setPendingInteraction({ changeId: existingChange.changeId, editType: activeTool }); + } return { handled: true, isPreviewSelection: false, changeId: existingChange.changeId, editType: existingChange.editType, + fid: existingChange.fid ?? existingChange.featureRef?.fid ?? objectId, }; } const geometryObject = toGeometryObject(featureData); const geometryType = normalizeGeometryType(geometryObject?.type); const featureRef = getFeatureRef(featureData); + const objectLabel = getFeatureObjectLabel(featureData) || objectId; + const normalizedGeometryObject = normalizePipeGeometry(selectedLayer.layerId, geometryObject); const nextGeometry = activeTool === "MOVE" - ? geometryObject + ? normalizedGeometryObject : activeTool === "VERTEX_EDIT" - ? geometryObject + ? normalizedGeometryObject : activeTool === "DELETE" ? null - : geometryObject; + : normalizedGeometryObject; const summaryMap: Record = { - MOVE: `${selectedLayer.layerName} ${objectId} 객체를 선택했습니다. 지도에서 드래그해 위치를 조정하세요.`, - VERTEX_EDIT: `${selectedLayer.layerName} ${objectId} 객체를 선택했습니다. 주황색 실꼭지점을 드래그하거나 흰색 midpoint를 끌어 새 꼭지점을 추가하세요.`, - DELETE: `${selectedLayer.layerName} ${objectId} 객체를 임시 삭제 대상으로 표시했습니다.`, - ADD: `${selectedLayer.layerName} ${objectId} 객체를 추가했습니다.`, + MOVE: `${selectedLayer.layerName} ${objectLabel} 객체를 선택했습니다. 지도에서 드래그해 위치를 조정하세요.`, + VERTEX_EDIT: `${selectedLayer.layerName} ${objectLabel} 객체를 선택했습니다. 주황색 실꼭지점을 드래그하거나 흰색 midpoint를 끌어 새 꼭지점을 추가하세요.`, + DELETE: `${selectedLayer.layerName} ${objectLabel} 객체를 임시 삭제 대상으로 표시했습니다.`, + ADD: `${selectedLayer.layerName} ${objectLabel} 객체를 추가했습니다.`, }; changeSequenceRef.current += 1; const changeId = createChangeId(`${activeTool}-${objectId}`, changeSequenceRef.current); @@ -632,15 +892,17 @@ export const useGisEdit = (treeData: any[]) => { layerId: selectedLayer.layerId, layerName: selectedLayer.layerName, objectId, + fid: featureRef?.fid ?? objectId, featureRef, - objectLabel: objectId, - beforeGeometry: geometryObject, + objectLabel, + beforeGeometry: normalizedGeometryObject, afterGeometry: nextGeometry, + featureProperties: getFeatureProperties(featureData), basicInfo: null, basicInfoSavedAt: null, summary: activeTool === "VERTEX_EDIT" && geometryType === "Point" - ? `${selectedLayer.layerName} ${objectId}는 점형 객체여서 꼭지점편집 대신 geometry 검토 대상으로 기록했습니다.` + ? `${selectedLayer.layerName} ${objectLabel}는 점형 객체여서 꼭지점편집 대신 geometry 검토 대상으로 기록했습니다.` : summaryMap[activeTool], createdAt: new Date().toISOString(), source: "MAP_SELECTION", @@ -656,12 +918,85 @@ export const useGisEdit = (treeData: any[]) => { } else { setPendingInteraction(null); } - return { handled: true, isPreviewSelection: false, changeId, editType: activeTool }; - }, [activeTool, appendChange, findExistingObjectChange, focusChange, isEditMode, selectedLayer]); + return { handled: true, isPreviewSelection: false, changeId, editType: activeTool, fid: featureRef?.fid ?? objectId }; + }, [activeTool, appendChange, findExistingGeometryChange, findExistingObjectChange, focusChange, isEditMode, selectedLayer]); + + const registerPipeDetailSelection = useCallback((featureData: any) => { + if (!isEditMode || !selectedLayer) return { handled: false }; + if (selectedLayer.layerId !== "tb_ms_swage_ppl") return { handled: false }; + + const featureLayerId = getFeatureLayerId(featureData); + if (featureLayerId !== selectedLayer.layerId) { + setStatusMessage("하수관로를 선택해주세요."); + return { handled: true }; + } + + const objectId = getFeatureObjectId(featureData); + if (!objectId) { + setStatusMessage("선택 객체의 관리번호를 찾을 수 없어 상세정보 수정 화면을 열 수 없습니다."); + return { handled: true }; + } + + const existingChange = + changes.find( + (change) => + change.layerId === selectedLayer.layerId && + change.editType !== "DELETE" && + String(change.objectId ?? "") === objectId, + ) ?? null; + if (existingChange) { + focusChange(existingChange.changeId); + setPendingInteraction(null); + setStatusMessage(`${selectedLayer.layerName} ${objectId} 상세정보를 수정할 수 있습니다.`); + return { + handled: true, + isPreviewSelection: false, + changeId: existingChange.changeId, + editType: existingChange.editType as GisEditTool, + fid: existingChange.fid ?? existingChange.featureRef?.fid ?? objectId, + }; + } + + const geometryObject = toGeometryObject(featureData); + const normalizedGeometryObject = normalizePipeGeometry(selectedLayer.layerId, geometryObject); + const featureRef = getFeatureRef(featureData); + const objectLabel = getFeatureObjectLabel(featureData) || objectId; + changeSequenceRef.current += 1; + const changeId = createChangeId(`DETAIL-${objectId}`, changeSequenceRef.current); + + appendChange({ + changeId, + editType: "VERTEX_EDIT" as const, + layerId: selectedLayer.layerId, + layerName: selectedLayer.layerName, + objectId, + fid: featureRef?.fid ?? objectId, + featureRef, + objectLabel, + beforeGeometry: normalizedGeometryObject, + afterGeometry: normalizedGeometryObject, + featureProperties: getFeatureProperties(featureData), + basicInfo: null, + basicInfoSavedAt: null, + attributeOnly: true, + summary: `${selectedLayer.layerName} ${objectLabel} 상세정보 수정 대상으로 선택했습니다.`, + createdAt: new Date().toISOString(), + source: "MAP_SELECTION", + }); + setFocusedChangeId(changeId); + setPendingInteraction(null); + setStatusMessage(`${selectedLayer.layerName} ${objectLabel} 상세정보를 수정할 수 있습니다.`); + return { + handled: true, + isPreviewSelection: false, + changeId, + editType: "VERTEX_EDIT", + fid: featureRef?.fid ?? objectId, + }; + }, [appendChange, changes, focusChange, isEditMode, selectedLayer]); - // 저장 API가 붙기 전까지는 실제 전송 대신 최종 payload를 조립해 검증 패널에 남긴다. const save = useCallback((map: any) => { - if (!selectedLayer || changes.length === 0) { + if (!selectedLayer || actionableChanges.length === 0) { setStatusMessage("저장할 편집 변경분이 없습니다."); return null; } @@ -670,28 +1005,28 @@ export const useGisEdit = (treeData: any[]) => { screenId: SCREEN_ID, layerId: selectedLayer.layerId, layerName: selectedLayer.layerName, - saveTargetType: "VERSION", + saveTargetType: "FINAL", view: { srs: FLOW_DIRECTION_SRS, bbox: buildBbox(map), }, - changes: changes.map((change) => ({ + changes: actionableChanges.map((change) => ({ changeId: change.changeId, editType: change.editType, objectId: change.editType === "ADD" ? undefined : change.objectId, clientTempId: change.editType === "ADD" ? change.objectId : undefined, + fid: change.editType === "ADD" ? undefined : change.fid ?? change.featureRef?.fid ?? change.objectId, + baseEditSn: change.editType === "ADD" ? undefined : change.baseEditSn, featureRef: change.featureRef, - basicInfo: change.basicInfo ?? undefined, + attributes: buildPipeSaveAttributes(change.basicInfo), beforeGeometry: change.beforeGeometry, afterGeometry: change.editType === "DELETE" ? null : change.afterGeometry, geometry: change.editType === "ADD" ? change.afterGeometry : undefined, })), }; - setLastSavedPayload(payload); - setStatusMessage("관로 기본정보와 geometry를 함께 담은 최종 저장 payload를 생성했습니다. 현재는 FE 세션 검토 단계이며, 실제 DB 반영은 백엔드 저장 API가 필요합니다."); return payload; - }, [changes, selectedLayer]); + }, [actionableChanges, selectedLayer]); const removeLastChange = useCallback(() => { setLastSavedPayload(null); @@ -721,10 +1056,22 @@ export const useGisEdit = (treeData: any[]) => { setStatusMessage("편집 세션을 초기화했습니다."); }, []); + const discardSession = useCallback(() => { + setIsEditMode(false); + setLastSavedPayload(null); + setChanges([]); + setActiveTool(null); + setPendingInteraction(null); + setFocusedChangeId(null); + latestSelectionRef.current = null; + setStatusMessage("하수관로 편집을 종료하고 세션 변경분을 초기화했습니다."); + }, []); + const updateChangeGeometry = useCallback(( changeId: string, afterGeometry: Record | null, summary: string, + options?: { keepVertexEditActive?: boolean }, ) => { setLastSavedPayload(null); setChanges((previous) => @@ -739,6 +1086,14 @@ export const useGisEdit = (treeData: any[]) => { ), ); setFocusedChangeId(changeId); + if (options?.keepVertexEditActive) { + setActiveTool("VERTEX_EDIT"); + setPendingInteraction({ changeId, editType: "VERTEX_EDIT" }); + } else { + setActiveTool(null); + setPendingInteraction(null); + } + latestSelectionRef.current = null; setStatusMessage(summary); }, []); @@ -767,6 +1122,20 @@ export const useGisEdit = (treeData: any[]) => { setStatusMessage("관로 기본정보를 편집 세션에 저장했습니다. 최종 GIS 저장 전까지는 DB에 반영되지 않습니다."); }, []); + const updateChangeBaseEditSn = useCallback((changeId: string, baseEditSn: string) => { + setLastSavedPayload(null); + setChanges((previous) => + previous.map((change) => + change.changeId === changeId + ? { + ...change, + baseEditSn, + } + : change, + ), + ); + }, []); + const clearPendingInteraction = useCallback(() => { setPendingInteraction(null); }, []); @@ -789,18 +1158,22 @@ export const useGisEdit = (treeData: any[]) => { pendingInteraction, focusedChangeId, pendingApiMessage: - "현재 구현은 FE 로컬 편집 세션, 관로 기본정보 draft, 저장 payload 미리보기까지 포함합니다. BE 저장 API(`/api/v1/map/gisEditSessions/save`) 연결 전까지는 실제 저장 대신 payload를 검토합니다.", - canUndo: changes.length > 0, + "현재 구현은 FE 로컬 편집 세션, 관로 기본정보 draft, BE 저장 API(`/api/v1/map/gisEditSessions/save`) 최종본 저장까지 포함합니다.", + canUndo: actionableChanges.length > 0, toggleEditMode, selectLayer, activateTool, + deactivateTool, registerAddGeometry, registerFeatureSelection, + registerPipeDetailSelection, save, removeLastChange, clearChanges, + discardSession, updateChangeGeometry, updateChangeBasicInfo, + updateChangeBaseEditSn, clearPendingInteraction, focusChange, announceStatus, diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx index 36cbb46f6..49c0d168c 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx @@ -61,6 +61,11 @@ const linkedFacilityHighlightStyle = new Style({ zIndex: 9999, }); +type MngInstOption = { + mngInstCd: string; + lwrInstNm: string; +}; + const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData: any }) => { const { gisMap } = useGisContext() as unknown as { checkedLayerIds: React.MutableRefObject; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; }; const [layerNm, setLayerNm] = useState(""); // 레이어 이름 상태 @@ -80,6 +85,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const [chmbrYdnts, setChmbrYdnts] = useState(''); // 로딩 상태 const linkedFacilityFeatureRef = useRef(null); const linkedFacilityOriginalStyleRef = useRef(null); + const [mngInstOptions, setMngInstOptions] = useState([]); // 🎯 관로관리번호 추출 const pplMngNo = featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || ""; @@ -87,6 +93,10 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const featureId = typeof featureData.getId === 'function' ? featureData.getId() : featureData.fid; + const selectedLayerTypeName = featureData?.values_ + ? featureData?.id_?.split('.')?.[0] + : featureData?.lyrTypeNm; + const isPipeFeature = selectedLayerTypeName === 'tb_ms_swage_ppl'; // 🎯 데이터 전처리 메모이제이션 const processedData = useMemo(() => { @@ -98,6 +108,81 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, }; }, [featureData?.values_]); + useEffect(() => { + let isMounted = true; + + if (isPipeFeature) { + setMngInstOptions([]); + return () => { + isMounted = false; + }; + } + + const fetchMngInstOptions = async () => { + try { + const result = await getData("v1/fcltppl/mngInsts"); + if (!isMounted) return; + const sortedOptions = Array.isArray(result) + ? [...result].sort((a, b) => + String(a?.lwrInstNm ?? a?.mngInstCd ?? "").localeCompare( + String(b?.lwrInstNm ?? b?.mngInstCd ?? ""), + "ko", + ), + ) + : []; + setMngInstOptions(sortedOptions); + } catch (error) { + console.error("관리기관 목록 조회 실패:", error); + if (isMounted) setMngInstOptions([]); + } + }; + + fetchMngInstOptions(); + return () => { + isMounted = false; + }; + }, [isPipeFeature]); + + const mngInstNameMap = useMemo(() => { + const map = new Map(); + mngInstOptions.forEach((option) => { + const code = String(option.mngInstCd ?? "").trim(); + if (!code) return; + map.set(code, option.lwrInstNm || code); + }); + return map; + }, [mngInstOptions]); + + const withMngInstName = (data: any) => { + if (!data || typeof data !== "object") return data; + const mngInstCd = String(data.mng_inst_cd ?? data.mngInstCd ?? "").trim(); + if (!mngInstCd) return data; + return { + ...data, + mngInstNm: mngInstNameMap.get(mngInstCd) ?? data.mngInstNm ?? data.mng_inst_nm ?? mngInstCd, + }; + }; + + const hasDetailValue = (value: unknown) => { + if (value === null || value === undefined) return false; + if (typeof value === "string") return value.trim() !== ""; + return true; + }; + + const mergeDetailData = (baseData: any, detailData: any) => { + if (!detailData || typeof detailData !== "object") return baseData; + + const meaningfulDetailData = Object.entries(detailData).reduce>((acc, [key, value]) => { + if (hasDetailValue(value)) acc[key] = value; + return acc; + }, {}); + + return { + ...(baseData ?? {}), + ...meaningfulDetailData, + }; + }; + const clearLinkedFacilityHighlight = () => { if (linkedFacilityFeatureRef.current) { linkedFacilityFeatureRef.current.setStyle(linkedFacilityOriginalStyleRef.current ?? undefined); @@ -306,6 +391,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, try { const selectedLayerName = getSelectedLayers(); // 레이어 이름 가져오기 setLayerNm(selectedLayerName); // 상태 업데이트 + const isPipeDetailLayer = selectedLayerName === '하수관로'; let data; if (featureData['ol_uid']) data = featureData.values_; // Gis 레이어 클릭 이벤트일 때 @@ -334,7 +420,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, } // 🎯 기본 데이터 포맷팅 - const formattedData = formatDisplayData(data); + const formattedData = withMngInstName(formatDisplayData(data)); if (selectedLayerName === '하수관로') { const installYmd = data.instl_ymd ?? data.instlYmd; formattedData.elapsedYears = getElapsedYearsDisplayText(installYmd); @@ -344,18 +430,22 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, } } - setFetchData(formattedData); - setTableData(formattedData); + if (!isPipeDetailLayer) { + setFetchData(formattedData); + setTableData(formattedData); + } switch (selectedLayerName) { case '하수관로': setButtonChk(true) // ✅ codeMapper 사용하여 API 결과도 변환 - setTableData(codeMapper({ - layerName: selectedLayerName, - data: { ...formattedData } - })); + if (!isPipeDetailLayer) { + setTableData(withMngInstName(codeMapper({ + layerName: selectedLayerName, + data: { ...formattedData } + }))); + } break; case '하수맨홀': setButtonChk(true) @@ -422,12 +512,14 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const result = await getData(apiEndpoint); if (result) { - const processedApiData = codeMapper({ + const mergedData = isPipeDetailLayer ? result : mergeDetailData(formattedData, result); + const processedApiData = withMngInstName(codeMapper({ layerName: selectedLayerName, - data: { ...result, ...tableData } - }); + data: mergedData + })); setFetchData(processedApiData); + setTableData(processedApiData); } } catch (apiError) { console.error('API 데이터 로딩 중 오류:', apiError); @@ -439,7 +531,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, }; fetchData(); - }, [featureId, processedData]); + }, [featureId, processedData, mngInstNameMap]); useEffect(() => { const layerTypeNm = featureData?.values_ diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 93563fc20..67a3f3497 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -46,7 +46,7 @@ import GisApp from "@common/business_components/ui/map/lib/gis.core"; import { ProcSearchBar } from "@common/business_components/ui/map/searchBar"; import { Button } from "@common/components"; -import { getData, getRawJson } from "../../components/lib/api"; +import { getData, getRawJson, postRawJson } from "../../components/lib/api"; import useSearchModalStore from "@/store/useSearchModalStore"; import useStore from "@/store/useStore"; @@ -286,27 +286,33 @@ const createGisEditPreviewStyle = (feature: any) => { ? "rgba(245, 158, 11, 0.92)" : editType === "MOVE" ? layerPointColor || "rgba(37, 99, 235, 0.92)" - : layerPointColor || "rgba(22, 163, 74, 0.92)"; + : editType === "ADD" + ? "rgba(15, 23, 42, 0.96)" + : layerPointColor || "rgba(22, 163, 74, 0.92)"; const lineColor = editType === "DELETE" ? "rgba(220, 38, 38, 0.9)" : editType === "VERTEX_EDIT" ? "rgba(245, 158, 11, 0.95)" : editType === "MOVE" ? layerLineColor || "rgba(37, 99, 235, 0.95)" - : layerLineColor || "rgba(22, 163, 74, 0.95)"; + : editType === "ADD" + ? "rgba(15, 23, 42, 0.96)" + : layerLineColor || "rgba(22, 163, 74, 0.95)"; const fillColor = editType === "DELETE" ? "rgba(220, 38, 38, 0.18)" : editType === "VERTEX_EDIT" ? "rgba(245, 158, 11, 0.2)" : editType === "MOVE" ? layerFillColor || "rgba(37, 99, 235, 0.18)" - : layerFillColor || "rgba(22, 163, 74, 0.18)"; + : editType === "ADD" + ? "rgba(15, 23, 42, 0.2)" + : layerFillColor || "rgba(22, 163, 74, 0.18)"; const highlightColor = isDragging ? "rgba(14, 116, 144, 0.98)" : isInteractionTarget ? "rgba(15, 23, 42, 0.9)" : lineColor; - const emphasisWidth = isDragging ? 7 : isInteractionTarget ? 6 : 4; + const emphasisWidth = isDragging ? 7 : isInteractionTarget ? 6 : editType === "ADD" ? 6 : 4; const textStyle = previewLabel ? new Text({ text: previewLabel, @@ -365,7 +371,24 @@ const createGisEditPreviewStyle = (feature: any) => { return polygonStyles; } - const lineStyles: Style[] = [ + const lineStyles: Style[] = editType === "ADD" + ? [ + new Style({ + stroke: new Stroke({ + color: "rgba(255, 255, 255, 0.98)", + width: emphasisWidth + 4, + }), + zIndex: 10000, + }), + new Style({ + stroke: new Stroke({ + color: highlightColor, + width: emphasisWidth, + }), + zIndex: 10003, + }), + ] + : [ new Style({ stroke: new Stroke({ color: highlightColor, @@ -540,6 +563,11 @@ const createWaterRiskPipeStyle = (feature: any) => { }; const getSelectedFeatureLayerTypeName = (data: any) => { + const previewLayerId = String(data?.get?.("layerId") ?? "").trim(); + if (String(data?.get?.("changeId") ?? "").trim() && previewLayerId) { + return previewLayerId; + } + if (typeof data?.getId === "function") { return String(data.getId() ?? "").split(".")[0]; } @@ -551,6 +579,85 @@ const getSelectedFeatureLayerTypeName = (data: any) => { return String(data?.lyrTypeNm ?? data?.values_?.lyrTypeNm ?? "").trim(); }; +const normalizeGisEditFid = (value: unknown) => { + const raw = String(value ?? "").trim(); + if (!raw) return ""; + const withoutNamespace = raw.includes(":") ? raw.split(":").pop() ?? raw : raw; + return withoutNamespace.startsWith("tb_ms_swage_ppl.") + ? withoutNamespace.slice("tb_ms_swage_ppl.".length) + : withoutNamespace; +}; + +const getGisEditFeatureFid = (data: any) => + normalizeGisEditFid( + data?.get?.("fid") ?? + data?.values_?.fid ?? + data?.fid ?? + data?.getId?.() ?? + data?.id_ ?? + "", + ); + +const isGisEditPreviewFeatureData = (data: any) => + Boolean(String(data?.get?.("changeId") ?? data?.changeId ?? "").trim()); + +const highlightGisEditSelectedFeature = (featureData: any) => { + if ( + !featureData || + typeof featureData.getGeometry !== "function" || + typeof featureData.setStyle !== "function" + ) { + return; + } + + const select = GisApp.Module.select as any; + select?.selectFeature?.(featureData, true); +}; + +const isVisibleGisEditSessionChange = (change: GisEditSessionSnapshot["changes"][number]) => + !change.attributeOnly || Boolean(change.basicInfoSavedAt); + +const getLatestNumericEditSn = (histories: any[]) => + histories.reduce((latest, history) => { + const editSn = String(history?.editSn ?? "").trim(); + if (!/^\d+$/.test(editSn)) return latest; + const value = Number(editSn); + return latest === null || value > latest ? value : latest; + }, null); + +const getGisEditAttributeText = (attributes: Record | null | undefined, keys: string[]) => { + for (const key of keys) { + const value = String(attributes?.[key] ?? "").trim(); + if (value) return value; + } + return ""; +}; + +const buildGisEditHiddenPipeFilter = (fids: string[]) => { + const normalizedFids = Array.from( + new Set( + fids + .map((fid) => normalizeGisEditFid(fid)) + .filter((fid) => /^\d+$/.test(fid)), + ), + ); + if (normalizedFids.length === 0) return null; + return `fid NOT IN (${normalizedFids.join(",")})`; +}; + +const findInvalidPipeRequiredChange = (changes: Array<{ changeId: string; editType: string; clientTempId?: string; attributes?: Record | null }>) => + changes.find((change) => { + const attributes = change.attributes ?? {}; + const hasAttributePayload = Object.keys(attributes).length > 0; + if (change.editType !== "ADD" && !hasAttributePayload) return false; + + const mngInstCd = getGisEditAttributeText(attributes, ["mngInstCd"]); + const fcltMngNo = getGisEditAttributeText(attributes, ["fcltMngNo"]); + const instMngNo = getGisEditAttributeText(attributes, ["instMngNo"]); + const usgCd = getGisEditAttributeText(attributes, ["usgCd"]); + return !mngInstCd || !fcltMngNo || /^NEW-/i.test(fcltMngNo) || !instMngNo || /^NEW-/i.test(instMngNo) || !usgCd; + }); + const formatWaterRiskPipeField = (value: unknown, suffix: string) => { const normalized = String(value ?? "").trim(); if (!normalized) return "-"; @@ -1424,6 +1531,7 @@ const Map = () => { 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); @@ -1431,6 +1539,7 @@ const Map = () => { const agedPipePopupWindowRef = useRef(null); const pipeAnalysisPopupWindowRef = useRef(null); const gisEditSessionPopupWindowRef = useRef(null); + const gisEditSessionPopupCloseWatcherRef = useRef(null); const [gisEditDetailChangeId, setGisEditDetailChangeId] = useState(null); const gisEditDetailChangeRef = useRef(null); const gisEditDetailFeatureDataRef = useRef(null); @@ -1496,6 +1605,8 @@ const Map = () => { const flowDirectionFailureAlertedRef = useRef(false); const gisEditPreviewLayerRef = useRef(null); const gisEditPreviewSourceRef = useRef(null); + const gisEditOriginalLayerFilterRef = useRef>(new globalThis.Map()); + const gisEditOriginalFeatureStyleRef = useRef>(new globalThis.Map()); const gisEditVertexLayerRef = useRef | null>(null); const gisEditVertexSourceRef = useRef(null); const waterRiskAreaLayerRef = useRef | null>(null); @@ -1504,7 +1615,6 @@ const Map = () => { const waterRiskGridSourceRef = useRef(null); const waterRiskPipeLayerRef = useRef | null>(null); const waterRiskPipeSourceRef = useRef(null); - const gisEditDrawSourceRef = useRef(null); const gisEditDrawRef = useRef(null); const gisEditTranslateRef = useRef(null); const gisEditModifyRef = useRef(null); @@ -1513,22 +1623,26 @@ const Map = () => { ); const [waterRiskInfoAnchor, setWaterRiskInfoAnchor] = useState<[number, number] | null>(null); const [waterRiskInfoCardStyle, setWaterRiskInfoCardStyle] = useState | null>(null); + const gisEditVisibleSessionChanges = useMemo( + () => gisEdit.changes.filter(isVisibleGisEditSessionChange), + [gisEdit.changes], + ); const gisEditSessionSnapshot = useMemo(() => ({ isEditMode: gisEdit.isEditMode, selectedLayerName: gisEdit.selectedLayer?.layerName ?? "", activeTool: gisEdit.activeTool, - changes: gisEdit.changes, + changes: gisEditVisibleSessionChanges, lastSavedPayload: gisEdit.lastSavedPayload, canUndo: gisEdit.canUndo, focusedChangeId: gisEdit.focusedChangeId, }), [ gisEdit.activeTool, gisEdit.canUndo, - gisEdit.changes, gisEdit.focusedChangeId, gisEdit.isEditMode, gisEdit.lastSavedPayload, gisEdit.selectedLayer, + gisEditVisibleSessionChanges, ]); const gisEditDetailChange = useMemo( () => gisEdit.changes.find((change) => change.changeId === gisEditDetailChangeId) ?? null, @@ -1536,33 +1650,32 @@ const Map = () => { ); const gisEditDetailFeatureData = useMemo(() => { if (!gisEditDetailChange) return null; - if (featureData) return featureData; + 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 ?? gisEditDetailChange.objectId ?? "", - mngInstCd: gisEditDetailChange.featureRef?.mngInstCd ?? "", - fclt_mng_no: gisEditDetailChange.featureRef?.fcltMngNo ?? gisEditDetailChange.objectId ?? "", - mng_inst_cd: gisEditDetailChange.featureRef?.mngInstCd ?? "", - inst_mng_no: "", - instl_ymd: "", - mapsht_no: "", - pdmt_vl: "", - prlg_vl: "", - usg_cd: "", - mqlt_cd: "", - avg_grdn: "", - drtw_mltv_area: "", - excln_mltv_area: "", - tmpt_dpth: "", - tmpt_mhl_mng_no: "", - scend_dpth: "", - scend_mhl_mng_no: "", - trfln_cnt: "", - vrtc_len: "", - wdth_len: "", + 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; @@ -1588,6 +1701,26 @@ const Map = () => { ); }, [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; + }, []); + + useEffect(() => () => { + if (gisEditSessionPopupCloseWatcherRef.current) { + window.clearInterval(gisEditSessionPopupCloseWatcherRef.current); + gisEditSessionPopupCloseWatcherRef.current = null; + } + }, []); + const openGisEditPipeDetail = useCallback((changeId: string | null) => { if (!changeId) { setGisEditDetailChangeId(null); @@ -1596,6 +1729,39 @@ const Map = () => { 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 syncWaterRiskPopup = useCallback(() => { const popup = waterRiskPopupWindowRef.current; if (!popup || popup.closed) { @@ -1710,7 +1876,7 @@ const Map = () => { }, []); useEffect(() => { - const shouldKeepPopupOpen = gisEdit.isEditMode || gisEdit.changes.length > 0; + const shouldKeepPopupOpen = gisEdit.isEditMode || gisEditVisibleSessionChanges.length > 0; if (shouldKeepPopupOpen) return; const popup = gisEditSessionPopupWindowRef.current; @@ -1721,7 +1887,7 @@ const Map = () => { popup.close(); gisEditSessionPopupWindowRef.current = null; - }, [gisEdit.changes.length, gisEdit.isEditMode]); + }, [gisEdit.isEditMode, gisEditVisibleSessionChanges.length]); useEffect(() => { return () => { @@ -1748,9 +1914,9 @@ const Map = () => { }, [gisEdit.isEditMode, gisEditDetailChange, gisEditDetailChangeId]); useEffect(() => { - if (!(gisEdit.isEditMode || gisEdit.changes.length > 0)) return; + if (!(gisEdit.isEditMode || gisEditVisibleSessionChanges.length > 0)) return; syncGisEditSessionPopup(); - }, [gisEdit.changes.length, gisEdit.isEditMode, syncGisEditSessionPopup]); + }, [gisEdit.isEditMode, gisEditVisibleSessionChanges.length, syncGisEditSessionPopup]); useEffect(() => { const handleGisEditSessionPopupMessage = (event: MessageEvent) => { @@ -1766,7 +1932,11 @@ const Map = () => { const changeId = String(data.payload?.changeId ?? "").trim(); if (changeId) { gisEdit.focusChange(changeId); + focusGisEditChangeOnMap(changeId); + setFeatureData(null); openGisEditPipeDetail(changeId); + setIsOpen(true); + setIsSearchModalOpen(false); } return; } @@ -1785,7 +1955,7 @@ const Map = () => { return () => { window.removeEventListener("message", handleGisEditSessionPopupMessage); }; - }, [gisEdit, openGisEditPipeDetail, syncGisEditSessionPopup]); + }, [focusGisEditChangeOnMap, gisEdit, openGisEditPipeDetail, syncGisEditSessionPopup]); const fetchData = async () => { try { @@ -1816,6 +1986,13 @@ const Map = () => { return waterRiskAreaSourceRef.current; } + const layers = map.getLayers?.().getArray?.() ?? []; + layers.forEach((existingLayer: any) => { + if (existingLayer?.get?.("id") === GIS_EDIT_PREVIEW_LAYER_ID) { + map.removeLayer(existingLayer); + } + }); + const source = new VectorSource(); const layer = new VectorLayer({ source, @@ -2445,21 +2622,75 @@ const Map = () => { return; } - // 편집 모드 + activeTool 조합일 때는 상세 팝업 대신 편집 세션 누적을 우선한다. - const editSelection = gisEdit.registerFeatureSelection(data); + // 편집 도구가 켜져 있을 때는 상세 팝업보다 도구별 세션 처리를 우선한다. + // 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; + (editSelection.isPreviewSelection !== true || canOpenPreviewPipeDetail); - if (typeof editSelection.changeId === "string" && editSelection.changeId) { - openGisEditPipeDetail(editSelection.changeId); - } if (shouldOpenPipeDetail) { - const select = GisApp.Module.select as any; - select?.resetSelectedFeature?.(); + openGisEditPipeDetail(String(editSelection.changeId)); + loadLatestEditSn(String(editSelection.changeId)); + if (editSelection.isPreviewSelection !== true && !isGeometryEditTargetSelection) { + highlightGisEditSelectedFeature(data); + } setFeatureData(data); window.setTimeout(() => { setGisEditDetailChangeId(String(editSelection.changeId)); @@ -2471,11 +2702,7 @@ const Map = () => { // 편집 대상으로 넘긴 뒤에는 기존 상세 팝업/원본 선택 강조를 정리해 // 실제 편집 대상인 preview feature가 시각적으로 가려지지 않게 한다. - const select = GisApp.Module.select as any; - select?.resetSelectedFeature?.(); - setFeatureData(null); - setIsOpen(false); - setIsSearchModalOpen(false); + closeGisEditDetail(); return; } @@ -2967,6 +3194,18 @@ const Map = () => { 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; + }, 300); window.focus?.(); return popup; }, []); @@ -2976,12 +3215,18 @@ const Map = () => { if (!map) return null; if (gisEditPreviewLayerRef.current && gisEditPreviewSourceRef.current) { + const layers = map.getLayers?.().getArray?.() ?? []; + if (!layers.includes(gisEditPreviewLayerRef.current)) { + map.addLayer(gisEditPreviewLayerRef.current); + } + gisEditPreviewLayerRef.current.setZIndex?.(10001); return gisEditPreviewSourceRef.current; } const source = new VectorSource(); const layer = new VectorLayer({ source, + style: createGisEditPreviewStyle, zIndex: 10001, updateWhileAnimating: true, updateWhileInteracting: true, @@ -2990,12 +3235,10 @@ const Map = () => { layer.set("id", GIS_EDIT_PREVIEW_LAYER_ID); layer.set("lyrNm", "GIS 편집 미리보기"); layer.set("lyrTyNm", GIS_EDIT_PREVIEW_LAYER_ID); - // MOVE/VERTEX_EDIT는 preview 객체를 실제 상호작용 대상으로 써야 하므로 클릭 가능 상태를 유지한다. - // 이전에 N으로 바꿨다가 점/선/면 시설물 전반에서 이동 드래그가 끊기는 회귀가 발생했다. - layer.set("clickPsblYn", "Y"); + // Preview selection is handled by map.forEachFeatureAtPixel; keep it out of the common Select styler. + layer.set("clickPsblYn", "N"); + layer.setVisible?.(true); - const core = GisApp.Module.core as any; - core?.deleteLayer?.(GIS_EDIT_PREVIEW_LAYER_ID); map.addLayer(layer); gisEditPreviewLayerRef.current = layer; gisEditPreviewSourceRef.current = source; @@ -3007,6 +3250,85 @@ const Map = () => { setGisEditPreviewCursor(""); }, [setGisEditPreviewCursor]); + const restoreGisEditOriginalPipeMask = useCallback(() => { + gisEditOriginalLayerFilterRef.current.forEach((originalFilter, layer) => { + const source = layer?.getSource?.(); + const params = { ...(source?.getParams?.() ?? {}) }; + if (originalFilter) { + params.CQL_FILTER = originalFilter; + } else { + delete params.CQL_FILTER; + } + params._t = Date.now(); + source?.updateParams?.(params); + source?.refresh?.(); + source?.changed?.(); + layer?.changed?.(); + }); + gisEditOriginalLayerFilterRef.current.clear(); + + gisEditOriginalFeatureStyleRef.current.forEach((originalStyle, feature) => { + feature?.setStyle?.(originalStyle); + feature?.changed?.(); + }); + gisEditOriginalFeatureStyleRef.current.clear(); + gisMaps.current?.renderSync?.(); + }, []); + + const applyGisEditOriginalPipeMask = useCallback((hiddenFids: string[]) => { + const hiddenFidSet = new Set( + hiddenFids + .map((fid) => normalizeGisEditFid(fid)) + .filter(Boolean), + ); + + if (hiddenFidSet.size === 0) { + restoreGisEditOriginalPipeMask(); + return; + } + + const map = gisMaps.current; + if (!map) return; + + const filter = buildGisEditHiddenPipeFilter(Array.from(hiddenFidSet)); + map.getAllLayers?.().forEach((layer: any) => { + const layerTypeName = String(layer?.get?.("lyrTyNm") ?? "").trim(); + if (layerTypeName !== "tb_ms_swage_ppl") return; + + const source = layer.getSource?.(); + if (source?.updateParams && filter) { + const params = { ...(source.getParams?.() ?? {}) }; + if (!gisEditOriginalLayerFilterRef.current.has(layer)) { + gisEditOriginalLayerFilterRef.current.set(layer, String(params.CQL_FILTER ?? "").trim() || null); + } + const originalFilter = gisEditOriginalLayerFilterRef.current.get(layer); + params.CQL_FILTER = originalFilter ? `(${originalFilter}) AND (${filter})` : filter; + params._t = Date.now(); + source.updateParams(params); + source.refresh?.(); + source.changed?.(); + layer.changed?.(); + } + + source?.getFeatures?.().forEach((feature: any) => { + const fid = getGisEditFeatureFid(feature); + const shouldHide = hiddenFidSet.has(fid); + if (shouldHide && !gisEditOriginalFeatureStyleRef.current.has(feature)) { + gisEditOriginalFeatureStyleRef.current.set(feature, feature.getStyle?.()); + feature.setStyle?.(() => []); + feature.changed?.(); + } + if (!shouldHide && gisEditOriginalFeatureStyleRef.current.has(feature)) { + feature.setStyle?.(gisEditOriginalFeatureStyleRef.current.get(feature)); + gisEditOriginalFeatureStyleRef.current.delete(feature); + feature.changed?.(); + } + }); + }); + + gisMaps.current?.renderSync?.(); + }, [restoreGisEditOriginalPipeMask]); + const ensureGisEditVertexLayer = useCallback(() => { const map = gisMaps.current; if (!map) return null; @@ -3040,15 +3362,6 @@ const Map = () => { gisEditVertexSourceRef.current?.clear(); }, []); - const ensureGisEditDrawSource = useCallback(() => { - if (gisEditDrawSourceRef.current) { - return gisEditDrawSourceRef.current; - } - - gisEditDrawSourceRef.current = new VectorSource(); - return gisEditDrawSourceRef.current; - }, []); - const removeGisEditDrawInteraction = useCallback(() => { const map = gisMaps.current; if (!map) return; @@ -3057,7 +3370,6 @@ const Map = () => { map.removeInteraction(gisEditDrawRef.current); gisEditDrawRef.current = null; } - gisEditDrawSourceRef.current?.clear(); }, []); const removeGisEditInteractions = useCallback(() => { @@ -3083,6 +3395,30 @@ const Map = () => { setGisEditPreviewCursor(""); }, [clearGisEditVertexLayer, setGisEditPreviewCursor]); + 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 편집은 map 화면의 로컬 편집 모드를 토글하면서 세션 상태는 별도 popup 창에 동기화한다. const handleToggleGisEdit = useCallback(() => { if (!gisEdit.isEditMode) { @@ -3090,35 +3426,129 @@ const Map = () => { if (!popup) { alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); } + } else if (!gisEditSessionPopupWindowRef.current || gisEditSessionPopupWindowRef.current.closed) { + const popup = openGisEditSessionPopup(); + if (!popup) { + alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); + } + syncGisEditSessionPopup(); + return; } gisEdit.toggleEditMode(); setIsOpen(false); - }, [gisEdit, openGisEditSessionPopup]); + }, [gisEdit, openGisEditSessionPopup, syncGisEditSessionPopup]); + + const handleOpenGisEditSessionPopup = useCallback(() => { + const popup = openGisEditSessionPopup(); + if (!popup) { + alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); + return; + } + 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]); + }, [gisEdit, removeGisEditDrawInteraction, removeGisEditInteractions]); - // 1차 구현은 저장 API 호출 대신 payload 미리보기만 만든다. - const handleSaveGisEdit = useCallback(() => { - gisEdit.save(gisMaps.current); - }, [gisEdit]); + 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]); + }, [gisEdit.updateChangeBasicInfo]); const canSelectFeatureDuringGisEdit = useCallback((feature: any) => { if (!gisEdit.isEditMode) return true; - if (gisEdit.activeTool !== "MOVE" && gisEdit.activeTool !== "VERTEX_EDIT") return true; - if (gisEdit.selectedLayer?.layerId !== "tb_ms_swage_ppl") return true; + if (!gisEdit.activeTool) return true; - const layerTypeName = getSelectedFeatureLayerTypeName(feature); - if (layerTypeName === "tb_ms_swage_ppl") return true; - - gisEdit.announceStatus("하수관로를 선택해주세요."); + if ( + gisEdit.selectedLayer?.layerId === "tb_ms_swage_ppl" && + getSelectedFeatureLayerTypeName(feature) !== "tb_ms_swage_ppl" + ) { + gisEdit.announceStatus("하수관로를 선택해주세요."); + } return false; }, [gisEdit]); @@ -3157,8 +3587,104 @@ const Map = () => { if (!mapReady || !gisMaps.current || !gisEdit.isEditMode) return; const map = gisMaps.current; + const clickKey = map.on("singleclick", (event: any) => { + const previewFeature = map.forEachFeatureAtPixel( + event.pixel, + (feature: any) => String(feature?.get?.("changeId") ?? "").trim() ? feature : null, + { hitTolerance: 12 }, + ); + // 편집 도구가 켜진 동안에는 공통 Select를 우회하므로 원본 WFS 피처도 여기서 직접 판정한다. + const editableSourceFeature = previewFeature + ? null + : gisEdit.activeTool && gisEdit.activeTool !== "ADD" + ? map.forEachFeatureAtPixel( + event.pixel, + (feature: any) => { + if (String(feature?.get?.("changeId") ?? "").trim()) return null; + return getSelectedFeatureLayerTypeName(feature) === gisEdit.selectedLayer?.layerId + ? feature + : null; + }, + { hitTolerance: 12 }, + ) + : null; + const selectedFeature = previewFeature ?? editableSourceFeature; + if (!selectedFeature) return; - if (gisEdit.activeTool !== "ADD" || !gisEdit.selectedLayer) { + const editSelection = gisEdit.registerFeatureSelection(selectedFeature); + if (!editSelection.handled) return; + + event.preventDefault?.(); + + if (editSelection.editType === "DELETE") { + const select = GisApp.Module.select as any; + select?.resetSelectedFeature?.(); + setGisEditDetailChangeId(null); + setFeatureData(null); + setIsOpen(false); + setIsSearchModalOpen(false); + return; + } + + if (gisEdit.activeTool === "MOVE" || gisEdit.activeTool === "VERTEX_EDIT") { + setGisEditDetailChangeId(null); + setFeatureData(null); + setIsOpen(false); + setIsSearchModalOpen(false); + return; + } + + const selectedChangeId = + typeof editSelection.changeId === "string" ? editSelection.changeId : ""; + const selectedChange = gisEdit.changes.find((change) => change.changeId === selectedChangeId); + const canOpenPipeDetail = + selectedChangeId && + editSelection.editType !== "DELETE"; + + if (canOpenPipeDetail) { + openGisEditPipeDetail(selectedChangeId); + setFeatureData( + selectedChange?.editType === "ADD" + ? { + lyrTypeNm: "tb_ms_swage_ppl", + fcltMngNo: "", + mngInstCd: "", + fclt_mng_no: "", + inst_mng_no: "", + } + : null, + ); + window.setTimeout(() => { + setGisEditDetailChangeId(selectedChangeId); + setIsOpen(true); + }, 0); + setIsSearchModalOpen(false); + } + }); + + return () => { + unByKey(clickKey); + }; + }, [ + gisEdit.activeTool, + gisEdit.changes, + gisEdit.isEditMode, + gisEdit.registerFeatureSelection, + gisEdit.selectedLayer, + mapReady, + openGisEditPipeDetail, + ]); + + const gisEditSelectedLayerId = gisEdit.selectedLayer?.layerId ?? ""; + const gisEditSelectedLayerGeometryType = gisEdit.selectedLayer?.geometryType ?? null; + const gisEditSelectedLayerPreviewColors = gisEdit.selectedLayer?.previewColors ?? null; + + useEffect(() => { + if (!mapReady || !gisMaps.current || !gisEdit.isEditMode) return; + + const map = gisMaps.current; + + if (gisEdit.activeTool !== "ADD" || !gisEditSelectedLayerId || !gisEditSelectedLayerGeometryType) { removeGisEditDrawInteraction(); return; } @@ -3166,11 +3692,12 @@ const Map = () => { removeGisEditInteractions(); removeGisEditDrawInteraction(); - const drawSource = ensureGisEditDrawSource(); + const drawSource = ensureGisEditPreviewLayer(); + if (!drawSource) return; const drawType = - gisEdit.selectedLayer.geometryType === "Point" + gisEditSelectedLayerGeometryType === "Point" ? "Point" - : gisEdit.selectedLayer.geometryType === "Polygon" + : gisEditSelectedLayerGeometryType === "Polygon" ? "Polygon" : "LineString"; @@ -3190,7 +3717,38 @@ const Map = () => { dataProjection: FLOW_DIRECTION_SRS, }) as Record; const changeId = gisEdit.registerAddGeometry(geometryObject); - if (changeId && gisEdit.selectedLayer?.layerId === "tb_ms_swage_ppl") { + if (changeId) { + event.feature.setId(`gis-edit:${changeId}`); + event.feature.set("fid", changeId); + event.feature.set("editType", "ADD"); + event.feature.set("objectId", changeId); + event.feature.set("layerId", gisEditSelectedLayerId); + event.feature.set("changeId", changeId); + event.feature.set("layerPointColor", gisEditSelectedLayerPreviewColors?.pointColor ?? ""); + event.feature.set("layerLineColor", gisEditSelectedLayerPreviewColors?.lineColor ?? ""); + event.feature.set("layerFillColor", gisEditSelectedLayerPreviewColors?.fillColor ?? ""); + event.feature.set("isInteractionTarget", false); + event.feature.set("isDragging", false); + event.feature.set("previewLabel", "신규 추가"); + event.feature.setStyle(createGisEditPreviewStyle(event.feature)); + gisMaps.current?.renderSync?.(); + window.setTimeout(() => { + const source = ensureGisEditPreviewLayer(); + if (!source) return; + if (!source.getFeatureById?.(`gis-edit:${changeId}`)) { + source.addFeature(event.feature); + } + event.feature.changed?.(); + source.changed?.(); + gisEditPreviewLayerRef.current?.changed?.(); + gisMaps.current?.renderSync?.(); + }, 0); + } else { + window.setTimeout(() => { + drawSource.removeFeature(event.feature); + }, 0); + } + if (changeId && gisEditSelectedLayerId === "tb_ms_swage_ppl") { openGisEditPipeDetail(changeId); setFeatureData({ lyrTypeNm: "tb_ms_swage_ppl", @@ -3198,33 +3756,22 @@ const Map = () => { mngInstCd: "", fclt_mng_no: "", inst_mng_no: "", - instl_ymd: "", - mapsht_no: "", - pdmt_vl: "", - prlg_vl: "", - usg_cd: "", - mqlt_cd: "", - avg_grdn: "", - drtw_mltv_area: "", - excln_mltv_area: "", - tmpt_dpth: "", - tmpt_mhl_mng_no: "", - scend_dpth: "", - scend_mhl_mng_no: "", - trfln_cnt: "", - vrtc_len: "", - wdth_len: "", }); window.setTimeout(() => { setGisEditDetailChangeId(changeId); setIsOpen(true); }, 0); + setIsSearchModalOpen(false); + window.setTimeout(() => { + gisEdit.deactivateTool(); + removeGisEditDrawInteraction(); + }, 0); + gisEdit.announceStatus("하수관로 신규 객체를 지도에 추가했습니다. 우측 상세정보를 입력한 뒤 저장하세요."); } } catch (error) { console.error("GIS 편집 ADD geometry 변환 실패:", error); gisEdit.announceStatus("신규 geometry를 추가하지 못했습니다. 다시 시도해 주세요."); } finally { - drawSource.clear(); setGisEditPreviewCursor("crosshair"); } }); @@ -3236,10 +3783,17 @@ const Map = () => { removeGisEditDrawInteraction(); }; }, [ - ensureGisEditDrawSource, - gisEdit, - openGisEditPipeDetail, + ensureGisEditPreviewLayer, + gisEdit.activeTool, + gisEdit.announceStatus, + gisEdit.deactivateTool, + gisEdit.isEditMode, + gisEdit.registerAddGeometry, + gisEditSelectedLayerGeometryType, + gisEditSelectedLayerId, + gisEditSelectedLayerPreviewColors, mapReady, + openGisEditPipeDetail, removeGisEditDrawInteraction, removeGisEditInteractions, setGisEditPreviewCursor, @@ -3301,33 +3855,49 @@ const Map = () => { const nextChangeIdSet = new Set(); gisEdit.changes.forEach((change) => { + if (change.attributeOnly) return; + const geometry = change.editType === "DELETE" ? change.beforeGeometry : change.afterGeometry; if (!geometry) return; + const previewEditType = + gisEdit.pendingInteraction?.changeId === change.changeId + ? gisEdit.pendingInteraction.editType + : change.editType; const previewLabel = - gisEdit.focusedChangeId === change.changeId && change.editType === "MOVE" + gisEdit.focusedChangeId === change.changeId && previewEditType === "MOVE" ? "드래그 이동" - : gisEdit.focusedChangeId === change.changeId && change.editType === "VERTEX_EDIT" + : gisEdit.focusedChangeId === change.changeId && previewEditType === "VERTEX_EDIT" ? "꼭지점 편집" - : change.editType === "DELETE" + : previewEditType === "DELETE" ? "삭제 예정" - : change.editType === "MOVE" + : previewEditType === "MOVE" ? "이동 예정" - : change.editType === "VERTEX_EDIT" + : previewEditType === "VERTEX_EDIT" ? "편집 예정" : "신규 추가"; let feature = existingFeatureMap.get(change.changeId); const previewColors = layerPreviewColorMap.get(change.layerId); + const originalProperties = change.featureProperties ?? {}; + const previewObjectId = change.objectId ?? change.objectLabel; + const previewFid = + originalProperties["fid"] ?? + change.fid ?? + change.featureRef?.fid ?? + previewObjectId; if (!feature) { feature = geoJson.readFeature( { type: "Feature", geometry, properties: { - editType: change.editType, - objectId: change.objectId ?? change.objectLabel, + ...originalProperties, + fid: previewFid, + editType: previewEditType, + objectId: previewObjectId, layerId: change.layerId, + changeId: change.changeId, }, }, { @@ -3346,8 +3916,13 @@ const Map = () => { ); } - feature.set("editType", change.editType); - feature.set("objectId", change.objectId ?? change.objectLabel); + Object.entries(originalProperties).forEach(([key, value]) => { + if (key === "geometry") return; + feature.set(key, value); + }); + feature.set("fid", previewFid); + feature.set("editType", previewEditType); + feature.set("objectId", previewObjectId); feature.set("layerId", change.layerId); feature.set("changeId", change.changeId); feature.set("layerPointColor", previewColors?.pointColor ?? ""); @@ -3365,6 +3940,9 @@ const Map = () => { source.removeFeature(feature); } }); + source.changed?.(); + gisEditPreviewLayerRef.current?.changed?.(); + gisMaps.current?.renderSync?.(); }, [ clearGisEditPreviewLayer, @@ -3373,9 +3951,52 @@ const Map = () => { gisEdit.editableLayers, gisEdit.focusedChangeId, gisEdit.isEditMode, + gisEdit.pendingInteraction, mapReady, ]); + useEffect(() => { + if (!mapReady || !gisEdit.isEditMode || gisEdit.changes.length === 0) return; + ensureGisEditPreviewLayer(); + gisEditPreviewSourceRef.current?.changed?.(); + gisEditPreviewLayerRef.current?.changed?.(); + gisMaps.current?.renderSync?.(); + }, [ + ensureGisEditPreviewLayer, + gisEdit.changes.length, + gisEdit.isEditMode, + mapReady, + ]); + + const gisEditMaskedPipeFidKey = useMemo(() => { + const fids = gisEdit.changes + .filter((change) => change.layerId === "tb_ms_swage_ppl") + .filter((change) => !change.attributeOnly) + .filter((change) => change.editType !== "ADD" && change.editType !== "DELETE") + .map((change) => normalizeGisEditFid(change.fid ?? change.featureRef?.fid ?? change.objectId)) + .filter((fid) => /^\d+$/.test(fid)); + return Array.from(new Set(fids)).sort().join(","); + }, [gisEdit.changes]); + + useEffect(() => { + if (!mapReady || !gisEdit.isEditMode) { + restoreGisEditOriginalPipeMask(); + return; + } + + applyGisEditOriginalPipeMask(gisEditMaskedPipeFidKey ? gisEditMaskedPipeFidKey.split(",") : []); + }, [ + applyGisEditOriginalPipeMask, + gisEdit.isEditMode, + gisEditMaskedPipeFidKey, + mapReady, + restoreGisEditOriginalPipeMask, + ]); + + useEffect(() => () => { + restoreGisEditOriginalPipeMask(); + }, [restoreGisEditOriginalPipeMask]); + useEffect(() => { if (!mapReady || !gisMaps.current || !gisEdit.isEditMode || !gisEdit.pendingInteraction) return; @@ -3583,6 +4204,7 @@ const Map = () => { gisEdit.pendingInteraction!.changeId, nextGeometry, `${layerName} ${objectId} 객체 꼭지점을 지도에서 조정했습니다.`, + { keepVertexEditActive: true }, ); targetFeature.changed?.(); source.changed?.(); @@ -3597,7 +4219,6 @@ const Map = () => { }; } - gisEdit.clearPendingInteraction(); return cleanupPointerMove; }, [ clearGisEditVertexLayer, @@ -3619,6 +4240,19 @@ const Map = () => { } }, [gisEdit.activeTool, gisEdit.isEditMode, removeGisEditInteractions]); + useEffect(() => { + const select = GisApp.Module.select as any; + if (!select?.setActive) return; + + if (gisEdit.isEditMode && gisEdit.activeTool) { + select.resetSelectedFeature?.(); + select.setActive(false); + return; + } + + select.setActive(true); + }, [gisEdit.activeTool, gisEdit.isEditMode, mapReady]); + useEffect(() => { if (!gisEdit.isEditMode || gisEdit.activeTool !== "ADD") { removeGisEditDrawInteraction(); @@ -3760,14 +4394,15 @@ const Map = () => { /> diff --git a/apps/sn/app/(with-base-layout)/oper/_components/SearchDatePicker.tsx b/apps/sn/app/(with-base-layout)/oper/_components/SearchDatePicker.tsx new file mode 100644 index 000000000..f31f52ae7 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/_components/SearchDatePicker.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { CalendarIcon } from "lucide-react"; +import { useState, type ComponentProps } from "react"; + +import { Button, Calendar, Popover, PopoverContent, PopoverTrigger } from "@common/components/ui"; + +type CalendarProps = ComponentProps; + +type SearchDatePickerProps = { + selected?: Date; + onSelect: (date: Date | undefined) => void; + placeholder?: string; + className?: string; + disabled?: CalendarProps["disabled"]; + defaultMonth?: CalendarProps["defaultMonth"]; +}; + +const formatDateLabel = (date: Date) => { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, "0"); + const day = `${date.getDate()}`.padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +export function SearchDatePicker({ + selected, + onSelect, + placeholder = "날짜 선택", + className, + disabled, + defaultMonth, +}: SearchDatePickerProps) { + // 검색조건 달력은 날짜 선택과 동시에 Popover를 닫아야 하므로 open 상태를 화면 전용으로 제어한다. + const [open, setOpen] = useState(false); + + const handleSelect = (date: Date | undefined) => { + onSelect(date); + if (date) { + setOpen(false); + } + }; + + return ( + + + + + + + + + ); +} diff --git a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx index 3df6a1541..464e372d5 100644 --- a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx @@ -23,7 +23,7 @@ import { formatDate, handleDownload } from '@/components/lib/utils' import LoadingOverlay from '@/components/ui/LoadingBar' import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui' import ListDiv from '@common/business_components/ui/list_div' -import { Button, DatePicker, Input, Label } from '@common/components/ui' +import { Button, Input, Label } from '@common/components/ui' import { useEffect, useState } from 'react' import { tableHeadlist } from '..' import { useQuery } from "@tanstack/react-query"; @@ -31,6 +31,7 @@ import { ENDPOINT } from "@common/components/config"; import { getUserInfo } from "@common/components/api"; import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; +import { SearchDatePicker } from '../_components/SearchDatePicker'; const Page = () => { @@ -176,20 +177,16 @@ const Page = () => {
- { if (date) setFromDate(date) }} - modifiers={{ - disabled: { after: new Date() } - }} + disabled={{ after: new Date() }} /> ~ - { if (date) setToDate(date) }} - modifiers={{ - disabled: { after: new Date() } - }} + disabled={{ after: new Date() }} />
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 86d5d4853..6fb88829c 100644 --- a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx @@ -23,7 +23,7 @@ import { formatDate, handleDownload } from '@/components/lib/utils' import LoadingOverlay from '@/components/ui/LoadingBar' import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui' import ListDiv from '@common/business_components/ui/list_div' -import { Button, DatePicker, Input, Label } from '@common/components/ui' +import { Button, Input, Label } from '@common/components/ui' import { useEffect, useState } from 'react' import { tableHeadlistOldPp } from '..' import { useQuery } from "@tanstack/react-query"; @@ -31,6 +31,7 @@ import { ENDPOINT } from "@common/components/config"; import { getUserInfo } from "@common/components/api"; import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; +import { SearchDatePicker } from '../_components/SearchDatePicker'; const Page = () => { @@ -174,19 +175,15 @@ const Page = () => {
- { if (date) setFromDate(date) }} - modifiers={{ - disabled: { after: new Date() } - }} /> + disabled={{ after: new Date() }} /> ~ - { if (date) setToDate(date) }} - modifiers={{ - disabled: { after: new Date() } - }} /> + disabled={{ after: new Date() }} />
=> { } }; +export const postRawJson = async (url: string, data: object): Promise => { + const res = await fetch(resolveApiUrl(url), { + method: "POST", + headers: withSiteIdHeaders({ + "Content-Type": "application/json", + }), + body: JSON.stringify(data), + }); + if (res.status === 401) { + handleUnauthorizedResponse(); + } + + const text = await res.text(); + const body = text && text.trim() ? JSON.parse(text) : null; + if (!res.ok) { + const message = + body?.message ?? + `Failed to post data: ${res.status} ${res.statusText}`; + const error = new Error(message) as Error & { status?: number; body?: any }; + error.status = res.status; + error.body = body; + throw error; + } + return body; +}; + const parseDownloadFileName = (contentDisposition: string | null) => { if (!contentDisposition) return undefined; diff --git a/apps/sn/app/components/ui/verticalTable.tsx b/apps/sn/app/components/ui/verticalTable.tsx index 1ed617243..a03641e1e 100644 --- a/apps/sn/app/components/ui/verticalTable.tsx +++ b/apps/sn/app/components/ui/verticalTable.tsx @@ -45,17 +45,37 @@ type UiTableProps = { onFormDataChange?: (formData: { [key: string]: string | number | boolean }) => void; // 추가 }; +type UiSelectOption = + | string + | { + value?: string | number; + label?: string; + val?: string | number; + text?: string; + hidden?: boolean; + }; + type UiHeadProps = { id: string; title: string; align?: string; type?: string; - options?: string[]; + options?: UiSelectOption[]; placeholder?: string; isNumber?: boolean; length?: number; + required?: boolean; }; +const getSelectOptionValue = (option: UiSelectOption) => + typeof option === "string" ? option : String(option.value ?? option.val ?? ""); + +const getSelectOptionLabel = (option: UiSelectOption) => + typeof option === "string" ? option : String(option.label ?? option.text ?? getSelectOptionValue(option)); + +const isSelectOptionHidden = (option: UiSelectOption) => + typeof option === "string" ? false : Boolean(option.hidden); + export function VerticalTable({ headName, tableName1, @@ -153,6 +173,7 @@ export function VerticalTable({ {/* 컬럼 제목 */} {head.title} + {head.required ? * : null} {/* 데이터 값 또는 입력 필드 */} @@ -160,21 +181,28 @@ export function VerticalTable({ head.type === "select" ? ( - ) : head.type === "input" ? ( + ) : head.type === "input" || head.type === "date" ? (
{ if (head.isNumber) { @@ -241,8 +269,9 @@ export function VerticalTable({ {/* 컬럼 제목 */} - {head.title} - + {head.title} + {head.required ? * : null} + {/* 데이터 값 또는 입력 필드 */} {children && children[head.id] ? children[head.id] : isEditable2 ? ( @@ -253,17 +282,24 @@ export function VerticalTable({ onChange={(e) => handleInputChange(head.id, e.target.value)} className="w-full border border-gray-300 rounded-sm px-2 py-1 text-xl border-radius-0" > - {head.options?.map((option) => ( - - ))} + {head.options?.map((option, optionIndex) => { + const optionValue = getSelectOptionValue(option); + return ( + + ); + })} - ) : head.type === "input" ? ( + ) : head.type === "input" || head.type === "date" ? (
{ if (head.isNumber) { @@ -333,4 +369,4 @@ export function VerticalTable({ ); -} \ No newline at end of file +} diff --git a/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md index 3caacaa1d..90d15c331 100644 --- a/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md @@ -142,6 +142,308 @@ - 비하수관로 클릭 시 줌 이동 완전 차단 - `ADD` 미리보기와 `MOVE`, `VERTEX_EDIT` interaction 회귀 여부 +### 2.11 2026-05-22 최종본/이력 DB 저장 통합 검증 + +- 검증 환경 + - 화면: `http://localhost:3002/map` + - 프론트 프록시 대상: `http://localhost:8883/sewerage-service` + - 백엔드: `sewerage-service` local profile, port `8883` + - DB: `sm_bs_ms.tb_ms_swage_ppl`, `sm_bs_ms.tb_ms_edit_swage_ppl` +- 발견 문제 + - 브라우저에서 `ADD` 저장 요청은 정상 전송됐지만 최초 검증 시 `500 / E999`가 반환됐다. + - DB 직접 확인 결과, 최종본 `tb_ms_swage_ppl`의 다음 fid는 `4132694`였지만 이력 테이블에는 이미 `fid=4132694, edit_sn=0` 이력이 존재했다. + - 기존 BE 채번이 최종본 `MAX(fid)+1`만 사용해 삭제 이력이 남은 fid를 재사용했고, 이력 테이블 복합키 `tb_ms_edit_swage_ppl_pk(fid, edit_sn)`와 충돌했다. +- 조치 내용 + - 신규 fid 채번 기준을 최종본과 이력 테이블을 합친 `MAX(fid::bigint)+1`로 변경했다. + - 브라우저 저장 성공 후 원본 레이어 refresh가 호출되도록 기존 저장 성공 처리 흐름을 유지했다. + - 신규 관로의 시설관리번호와 관로관리번호가 비어 있거나 `NEW-*` 임시값이면 저장 API를 호출하지 않고 상세 입력창을 열도록 FE 검증을 추가했다. +- 브라우저/API 검증 결과 + - `ADD`: 지도에서 신규 관로를 그리고 `저장` 버튼 클릭, 응답 `200`, `editCd=C`, `editSn=0`, 신규 `fid=4132695` + - `MOVE`: 같은 브라우저 세션에서 신규 fid 기준 수정 저장, 응답 `200`, `editCd=U`, `editSn=1` + - `DELETE`: 같은 브라우저 세션에서 신규 fid 기준 삭제 저장, 응답 `200`, `editCd=D`, `editSn=2` + - 번호 미입력 신규 저장: 저장 요청 `0건`, 상태문구 `신규 하수관로 저장 전 시설관리번호와 관로관리번호를 입력해주세요.` 노출 확인 +- DB 검증 결과 + - 삭제 후 `tb_ms_swage_ppl`의 테스트 fid 최종본 건수: `0` + - 삭제 전 검증 시 `tb_ms_edit_swage_ppl`의 테스트 fid 이력: `0:C,1:U,2:D` + - 검증 완료 후 테스트 fid `4132695`의 최종본/이력 흔적은 정리했다. + - 중간 자동화 중 잘못 선택된 기존 관로 `fid=2280012`는 직전 이력 기준으로 geometry를 원복했고, 테스트로 생성된 `edit_sn=1` 이력은 삭제했다. +- 정적 검증 + - BE: `.\gradlew.bat compileJava test --tests iipbs.sewerage.service.map.SwagePplEditServiceTest` 성공 + - FE: `pnpm --filter sn exec tsc --noEmit --pretty false`는 저장소 기존 오류로 실패했으며, 이번 GIS 편집 변경 파일 관련 신규 오류는 확인되지 않았다. +- 남은 확인 사항 + - 자동화 좌표 클릭으로 `MOVE` 대상을 고를 때, 새 관로와 기존 관로가 겹치면 기존 관로가 선택될 수 있다. 실제 QA에서는 신규 관로를 기존 관로와 겹치지 않는 위치에 생성하거나 fid 기반 재선택 보조가 필요하다. + - 저장 전 신규 관로의 시설관리번호/관로관리번호 입력 편의성은 별도 화면 검증에서 추가 확인이 필요하다. + +### 2.12 2026-05-26 하수관망도 GIS 편집 UI/기능 상세 재검증 + +#### 2.12.1 검증 목적 + +- 하수관망도 화면의 `GIS 편집` 기능을 실제 사용자 조작 흐름 기준으로 재검증한다. +- `추가`, `이동`, `꼭지점편집`, `삭제`, `저장 검증`, `편집 세션` 흐름을 함께 확인한다. +- 사용자가 제기한 "기능이 이상한 것 같다"는 관찰을 UI/기능/계약/검증 가능성 관점으로 분해한다. +- OpenLayers, ArcGIS, QGIS의 일반적인 GIS 편집 UX와 비교해 개선 방향을 정리한다. + +#### 2.12.2 검증 환경 + +- 작업 루트: `D:\workspace` +- 프론트엔드 앱: `D:\workspace\IIPBS-FRONTEND\frontend\apps\sn` +- 화면 URL: `http://localhost:3002/map` +- 백엔드 URL: `http://localhost:8883/sewerage-service` +- 기준 화면: 하수관망도 +- 기준 편집 대상 레이어: `tb_ms_swage_ppl` +- 기준 브라우저 검증 방식: 로컬 개발 서버에 로그인 후 실제 지도 화면에서 버튼 클릭, 좌표 클릭, 드래그, 더블클릭 수행 +- 병행 QA: `qa` 서브에이전트로 백엔드 테스트, API 검증, 인증 차단 여부 확인 + +#### 2.12.3 검증 전제와 환경 제약 + +- `sn` Next 개발 서버가 `3002` 포트에서 이미 실행 중이었다. +- `AGENTS.md` 기준에 따라 개발 서버가 실행 중인 상태에서는 `next build`, `pnpm --filter sn run build`, `pnpm turbo run build --filter=sn`을 실행하지 않았다. +- 로컬 브라우저 직접 검증은 개발 로그인 계정으로 진행했다. +- QA 서브에이전트는 별도 세션에서 `/auth/login` 인증 차단에 걸려 화면 직접 검증을 수행하지 못했다. +- 따라서 본 섹션의 UI 직접 조작 결과는 메인 브라우저 검증 결과를 기준으로 하고, 서브에이전트 결과는 API/BE/정적 검증 보조 증적으로 사용한다. + +#### 2.12.4 브라우저 진입 및 초기 로딩 상태 + +- `/map` 직접 접근 시 로그인 페이지로 이동하는 것을 확인했다. +- 개발 로그인 후 하수관망도 화면에 진입했다. +- 지도, 좌측 시설 트리, 우측 분석 메뉴, 하수관망도 레이어 표시는 확인됐다. +- `GIS 편집` 버튼 클릭 시 지도 상단 중앙에 편집 툴바가 표시됐다. +- 툴바 구성은 `추가`, `이동`, `꼭지점편집`, `삭제`, `저장`으로 확인됐다. +- 편집 진입 직후 `저장` 버튼은 비활성 상태였다. +- 상태 문구는 `하수관로 편집 도구를 선택한 뒤 지도에서 편집 작업을 진행하세요.`로 표시됐다. +- 지도 초기 로딩 중 다음 오류가 관찰됐다. + - `BIZ_TY_CD 데이터 로드 실패` + - `BIZ_SE_CD 데이터 로드 실패` + - 레이어 설정 조회 실패, `500 Internal Server Error` + - 메뉴 wrapper 계열 `AxiosError` +- 위 오류는 GIS 편집 단독 결함으로 단정하지 않았지만, 편집 QA 중 화면 신뢰도와 재현성을 낮추는 환경/연동 리스크로 기록한다. + +#### 2.12.5 `추가` 검증 결과 + +| 항목 | 내용 | +| --- | --- | +| 재현 절차 | `GIS 편집` 진입 후 `추가` 선택, 지도 위 여러 지점 클릭, 마지막 지점 더블클릭 | +| 기대 결과 | 신규 하수관로 선형 sketch가 생성되고, 지도 위에서 선택 가능한 preview가 명확히 표시되며, 상세정보 입력 패널과 저장 가능 상태가 연결된다. | +| 실제 결과 | 신규 변경분 `NEW-1`이 생성되고 `저장` 버튼이 활성화됐다. 우측 상세정보 패널이 열렸고 기본정보 입력 영역이 표시됐다. | +| 통과 항목 | 도구 활성화, 선형 입력 완료, 신규 변경분 생성, 상세 패널 오픈, 저장 버튼 활성화 | +| 문제 항목 | 신규 선형이 지도 위에서 충분히 강조되지 않았고, 우측 상세정보 패널이 지도 영역을 크게 가려 생성 위치 확인이 어렵다. | +| 판정 | 부분 통과 | + +- `추가` 도구의 기본 기능은 동작한다. +- 다만 사용자는 "내가 방금 추가한 관로가 어디에 생겼는지"를 지도에서 즉시 확인하기 어렵다. +- 신규 객체는 고대비 선형, 라벨, 선택 테두리, 꼭지점 표시 중 최소 하나 이상의 강한 피드백이 필요하다. +- 상세정보 패널이 우측 지도 영역을 크게 차지하므로 추가 직후 선형 위치와 패널 입력을 동시에 확인하기 어렵다. + +#### 2.12.6 `이동` 검증 결과 + +| 항목 | 내용 | +| --- | --- | +| 재현 절차 | 신규 관로 추가 후 `이동` 선택, 신규 관로가 생성된 것으로 예상되는 위치에서 클릭 및 드래그 | +| 기대 결과 | 이동 대상이 선택되고, 이동용 preview 또는 선택 테두리가 표시되며, 드래그 시 객체 geometry만 이동한다. | +| 실제 결과 | 이동 도구 안내 문구는 표시됐지만, 대상 선택 상태와 드래그 핸들이 명확하지 않았다. 드래그 시 객체 이동보다 지도 pan처럼 보이는 동작이 관찰됐다. | +| 통과 항목 | 도구 활성화, 상태 문구 표시 | +| 문제 항목 | 선택 대상 식별 불명확, 이동 preview 식별 불명확, 드래그 중 지도 이동과 객체 이동이 구분되지 않음 | +| 판정 | 불안정 | + +- `이동`은 GIS 편집에서 사용자가 가장 직관적으로 기대하는 기능 중 하나지만, 현재 화면에서는 "어떤 객체가 선택됐고 무엇을 잡고 움직여야 하는지"가 명확하지 않다. +- 편집 대상이 선택되면 지도 pan보다 객체 이동 interaction이 우선되어야 한다. +- 선택 후 이동 가능한 상태에서는 cursor, 선택 테두리, preview 색상, 상태 문구가 함께 바뀌어야 한다. + +#### 2.12.7 `꼭지점편집` 검증 결과 + +| 항목 | 내용 | +| --- | --- | +| 재현 절차 | `꼭지점편집` 선택 후 신규 관로 또는 기존 관로로 보이는 위치를 클릭하고 vertex handle 표시 및 드래그 가능 여부 확인 | +| 기대 결과 | 실꼭지점은 주황색 handle로, 새 점 삽입 후보 midpoint는 흰색 handle로 안정적으로 표시된다. 꼭지점을 드래그하면 선형 geometry가 즉시 갱신된다. | +| 실제 결과 | 안내 문구는 표시됐지만 실제 주황색 꼭지점과 흰색 midpoint handle을 안정적으로 확인하지 못했다. 꼭지점 선택/드래그 가능 상태도 사용자 관점에서 확정하기 어려웠다. | +| 통과 항목 | 도구 활성화, 상태 문구 표시 | +| 문제 항목 | vertex handle 표시 불명확, midpoint 삽입 후보 확인 불가, 드래그 가능 대상 식별 불가 | +| 판정 | 불량 | + +- `꼭지점편집`은 현재 가장 큰 기능 리스크다. +- 기존 문서에서도 `VERTEX_EDIT`는 QGIS 기준 사용자 경험 재정렬과 실조작 수동 재검증이 필요한 항목으로 남아 있었다. +- 이번 재검증에서도 사용자가 기대하는 "꼭지점이 보이고 잡아서 움직인다"는 핵심 피드백이 충분하지 않았다. +- 선형 편집 도구는 실꼭지점, 선택 꼭지점, hover segment, midpoint 삽입 후보를 시각적으로 구분해야 한다. + +#### 2.12.8 `삭제` 검증 결과 + +| 항목 | 내용 | +| --- | --- | +| 재현 절차 | `삭제` 선택 후 신규 관로 또는 기존 관로로 보이는 위치 클릭 | +| 기대 결과 | 삭제 대상이 선택되고, 즉시 최종 삭제하지 않더라도 pending-delete 스타일이 표시되며, 세션 변경목록에 삭제 변경분이 추가된다. | +| 실제 결과 | 삭제 도구 안내 문구는 표시됐지만, 어떤 객체가 삭제 대상으로 선택됐는지 명확한 지도 피드백을 확인하지 못했다. | +| 통과 항목 | 도구 활성화, 상태 문구 표시 | +| 문제 항목 | 삭제 대상 선택 피드백 부족, pending-delete 스타일 확인 어려움, 되돌리기 흐름 노출 부족 | +| 판정 | 불안정 | + +- 삭제는 저장 전 pending 상태가 반드시 명확해야 한다. +- 현재처럼 선택 피드백이 약하면 사용자는 기존 관로를 잘못 삭제 대상으로 올렸는지 확인하기 어렵다. +- 삭제 대상은 빨간 점선, 반투명 처리, `삭제 예정` 라벨, 세션 목록 강조가 함께 필요하다. + +#### 2.12.9 저장 검증 결과 + +| 항목 | 내용 | +| --- | --- | +| 재현 절차 | 신규 관로 `NEW-1` 생성 후 필수값을 충분히 입력하지 않은 상태에서 `저장` 클릭 | +| 기대 결과 | 저장 API 호출 전 필수값 누락을 차단하고, 누락 필드와 입력 위치를 정확히 안내한다. | +| 실제 결과 | 저장 API 호출 전 차단됐고, 상세정보 패널이 열리며 상태 문구가 표시됐다. | +| 통과 항목 | 신규 관로 필수값 누락 시 저장 API 호출 차단 | +| 문제 항목 | 상태 문구와 화면 필드명이 일치하지 않음 | +| 판정 | 부분 통과 | + +- 표시 문구: `신규 하수관로 저장 전 시설관리번호와 관로관리번호를 입력해주세요.` +- 화면 필드명: `시설관리번호`, `기관관리번호` +- `관로관리번호`와 `기관관리번호`가 서로 다른 의미라면 화면에 두 필드를 모두 분명히 노출해야 한다. +- 같은 의미라면 필드명과 검증 문구를 통일해야 한다. +- 이 불일치는 저장 실패 원인을 사용자가 잘못 이해하게 만들 수 있으므로 UI 문구 결함으로 분류한다. + +#### 2.12.10 편집 세션 및 팝업 검증 결과 + +| 항목 | 내용 | +| --- | --- | +| 기대 결과 | GIS 편집 진입 시 편집 세션 목록, 마지막 변경 취소, 세션 초기화, 항목 focus가 사용자가 확인 가능한 위치에 표시된다. | +| 실제 결과 | 메인 지도 툴바는 표시됐지만, 세션 팝업 또는 변경목록이 사용자에게 명확히 노출되는지 확인이 어려웠다. | +| 판정 | 불안정 | + +- 기존 구현 기록상 GIS 편집 세션은 popup 기반으로 전환되어 있다. +- 실제 사용자는 브라우저 팝업 차단, 포커스 이동, 별도 창 위치 문제로 세션 상태를 놓칠 수 있다. +- 편집 기능은 저장 전 변경목록 확인이 중요하므로, 팝업이 열리지 않거나 가려져도 메인 화면 안에서 최소 변경 건수와 마지막 변경 요약은 확인 가능해야 한다. + +#### 2.12.11 서브에이전트 QA 결과 요약 + +- 코드 변경 없이 읽기, 브라우저, API, 테스트 중심으로 검증했다. +- BE `.\gradlew.bat compileJava`: 통과 +- BE `.\gradlew.bat test`: 통과, `BUILD SUCCESSFUL` +- FE 빌드: 미실행 + - `sn` 개발 서버가 `3002`에서 실행 중이므로 사용자 확인 없이 빌드하지 않았다. +- FE `pnpm --filter sn exec tsc --noEmit --pretty false`: 실패 + - 기존 공통/앱 TypeScript 오류 다수 확인 + - 이번 GIS 편집 단독 신규 오류로 단정하지 않음 +- 서브에이전트 브라우저 검증: `/auth/login` 인증 차단으로 GIS 화면 직접 검증 불가 +- API 검증: + - `GET /sewerage-service/api/v1/map/gisEdit/swagePpl/meta?lyrGrp=SN_NETWORK`: 통과 + - 편집 대상: `tb_ms_swage_ppl` + - 지원 편집: `ADD`, `MOVE`, `VERTEX_EDIT`, `DELETE` + - 저장 테이블: `sm_bs_ms.tb_ms_swage_ppl` + - 이력 테이블: `sm_bs_ms.tb_ms_edit_swage_ppl` + - 빈 변경분 저장 요청: `400 GIS_EDIT_EMPTY_CHANGES` 정상 + - 비관로 레이어 저장 요청: `400 GIS_EDIT_PIPE_ONLY` 정상 + - `GET /gisEdit/swagePpl/histories?fid=1`: `200 OK` +- 서브에이전트 증적: + - `D:\workspace\qa-evidence\sfr-012-003-auth-blocker.png` + - `D:\workspace\qa-evidence\sfr-012-003-session-popup-direct.png` + +#### 2.12.12 외부 GIS 편집 UX 비교 + +| 비교 대상 | 참고 기준 | 현재 화면과의 차이 | +| --- | --- | --- | +| OpenLayers Draw | `Draw` interaction은 sketch 시작, 진행, 완료 이벤트를 별도 interaction으로 관리한다. | 현재 `추가`는 완료 후 신규 sketch의 시각 강조가 약하다. | +| OpenLayers Modify | `Modify` interaction은 vertex 조작을 명확한 handle 기반으로 제공한다. | 현재 `꼭지점편집`은 실꼭지점과 midpoint handle이 안정적으로 보이지 않는다. | +| OpenLayers Translate | `Translate` interaction은 선택 feature 이동을 지도 pan과 분리해 처리한다. | 현재 `이동`은 드래그 시 객체 이동과 지도 pan이 사용자에게 구분되지 않는다. | +| ArcGIS Map Viewer / Web Editor | 편집 대상 선택, vertex 편집, 저장 전 확인 흐름을 사용자에게 명확히 제공한다. | 현재 세션 변경목록과 pending 상태 노출이 약하다. | +| QGIS Vertex Tool | 실꼭지점, segment, midpoint 삽입 후보를 시각적으로 구분한다. | 현재 `VERTEX_EDIT`는 QGIS 수준의 조작 피드백에 도달하지 못했다. | + +참고 링크: + +- OpenLayers Draw: https://openlayers.org/en/latest/apidoc/module-ol_interaction_Draw-Draw.html +- OpenLayers Modify: https://openlayers.org/en/latest/apidoc/module-ol_interaction_Modify-Modify.html +- OpenLayers Translate: https://openlayers.org/en/latest/apidoc/module-ol_interaction_Translate-Translate.html +- ArcGIS Online Map Viewer editing: https://doc.arcgis.com/en/arcgis-online/manage-data/edit-features-mv.htm +- ArcGIS Web Editor vertex editing: https://doc.arcgis.com/en/web-editor/latest/create-and-edit/edit-vertices.htm +- QGIS geometry editing: https://docs.qgis.org/latest/en/docs/user_manual/working_with_vector/editing_geometry_attributes.html + +#### 2.12.13 결함 및 개선 백로그 + +| 우선순위 | 구분 | 내용 | 개선 방향 | +| --- | --- | --- | --- | +| P1 | 기능/UX | `이동`, `꼭지점편집`, `삭제`에서 편집 대상 선택 피드백이 불충분하다. | 선택 feature를 고대비 overlay로 표시하고, 선택/drag 가능 상태를 cursor와 상태 문구로 함께 제공한다. | +| P1 | 기능 | `꼭지점편집`의 vertex handle과 midpoint handle이 안정적으로 확인되지 않는다. | 실꼭지점, 선택 꼭지점, hover segment, midpoint 삽입 후보를 별도 style로 분리한다. | +| P1 | 기능 | `이동` 드래그가 지도 pan과 구분되지 않는다. | 선택 feature drag 중 지도 pan을 일시 제한하고, 이동 preview source를 명확히 분리한다. | +| P1 | 기능/세션 | 세션 변경목록과 pending 상태가 사용자에게 명확하지 않다. | 팝업 실패 fallback 또는 메인 지도 내 축약 세션 패널을 제공한다. | +| P2 | UI | `추가` 후 신규 선형이 지도에서 충분히 강조되지 않는다. | 신규 객체 label, 선택 테두리, vertex 표시, 자동 focus를 보강한다. | +| P2 | UI | 상세정보 패널이 지도 확인을 방해한다. | 패널 폭/위치/접힘 상태를 조정하고, 선택 객체 focus와 겹치지 않게 한다. | +| P2 | 문구 | 저장 검증 문구의 `관로관리번호`와 화면 필드 `기관관리번호`가 불일치한다. | 백엔드 계약 기준으로 필드명을 통일하고 누락 필드를 행 단위로 강조한다. | +| P2 | 연동 | 지도 초기 로딩 중 코드/레이어 설정 조회 오류가 발생한다. | GIS 편집 결함과 분리해 공통 코드/레이어 설정 API 오류를 먼저 정리한다. | +| P3 | QA | QA 서브에이전트 세션에서 인증 차단으로 화면 검증이 막힌다. | QA용 로컬 로그인 절차, 테스트 계정, 토큰 주입 절차를 문서화한다. | + +#### 2.12.14 종합 판정 + +- 백엔드 컴파일, 테스트, 기본 GIS 편집 API 계약은 정상 범위로 확인됐다. +- 프론트엔드 정적 검증은 기존 TypeScript 오류로 실패 상태이며, 이번 검증만으로 GIS 편집 단독 신규 오류라고 단정하지 않는다. +- 실제 사용자 기준의 GIS 편집 UI는 `추가`만 부분 통과이고, `이동`, `꼭지점편집`, `삭제`는 선택 피드백과 조작 피드백이 부족해 개선 필요로 판정한다. +- 특히 `VERTEX_EDIT`는 기존 검증 문서에서 이미 진행 중/리스크로 분류된 항목이며, 이번 재검증에서도 동일하게 핵심 개선 대상으로 확인됐다. +- 다음 개선 순서는 `선택 피드백`, `vertex handle`, `이동 drag와 지도 pan 분리`, `세션 변경목록 노출`, `저장 검증 문구 정리` 순서가 적절하다. + +### 2.13 2026-05-26 Playwright 기반 GIS 편집 실조작 재검증 + +#### 2.13.1 검증 범위와 경로 + +- 검증 방식: Playwright MCP 브라우저 자동화 기반 실제 화면 조작 +- 로그인 경로: `http://localhost:3002/auth/login` +- 로그인 계정: `CLEANWATER` +- 지도 경로: `http://localhost:3002/map` +- 백엔드 경로: `http://localhost:8883/sewerage-service` +- 세션 팝업 경로: `http://localhost:3002/popup/gisEditSessionPopup` +- 테스트 관리번호: + - 1차: `QA-PW-20260526-001`, `clientTempId=NEW-1` + - 2차: `QA-PW-20260526-002`, `clientTempId=NEW-2` +- 최종 정리 상태: 저장 API가 두 차례 모두 `500 E999`로 실패해 `fid/editSn`은 발급되지 않았고, DB 삭제 저장은 수행할 대상이 없었다. 미저장 로컬 세션은 `마지막 변경 취소`로 `총 0건`까지 정리했다. + +#### 2.13.2 기능별 실조작 결과 + +| 기능 | 재현 절차 | 기대 결과 | 실제 결과 | 심각도 | 개선 제안 | +| --- | --- | --- | --- | --- | --- | +| 로그인/진입 | `/auth/login`에서 `CLEANWATER` 로그인 후 `/map` 진입, `GIS 편집` 클릭 | 지도와 편집 툴바, 세션 팝업이 표시된다. | 로그인 성공, `/map` 진입 성공, `GIS 편집` 클릭 시 툴바와 세션 팝업 탭 표시 확인 | 통과 | QA 계정 기반 재현 절차를 검증 문서에 유지한다. | +| 추가 | 빈 위치에 선형 클릭-더블클릭, 상세정보에 `QA-PW-20260526-001/002` 입력, `저장` 클릭 | 신규 관로가 저장되고 응답에서 신규 `fid`, `editSn=0`, `editCd=C`가 확인된다. | preview, 상세 패널, 세션 목록, 저장 버튼 활성화는 확인됐으나 저장 API가 2회 모두 `500 E999`로 실패했다. `fid/editSn` 미발급 | P1 | BE는 `E999/ERROR` 대신 원인 코드와 메시지를 반환해야 한다. FE는 저장 실패 시 사용자가 조치 가능한 필드/원인을 표시해야 한다. | +| 추가 payload | 상세정보의 `시설관리번호`, `기관관리번호`에 테스트 번호 입력 후 세션 payload 확인 | 입력한 관리번호가 저장 payload의 필수 식별 필드에 일관되게 반영된다. | `fclt_mng_no`, `inst_mng_no`에는 값이 들어가지만 `fcltMngNo`, `mngInstCd`는 빈 문자열로 전송됐다. | P1 | 신규 관로 입력값 병합 시 빈 문자열을 유효값으로 우선하지 말고 snake/camel 필드를 동일 값으로 정규화해야 한다. | +| 이동 | 저장 실패 후 남은 `NEW-2` preview 기준 `이동` 선택, 대상 클릭 후 drag | 대상 관로가 선택되고 지도 pan이 아니라 관로 preview가 이동한다. | 상태문구는 이동 모드로 전환됐지만 대상 선택/drag 후 세션 변경이나 화면상 선택 피드백을 확인하지 못했다. | P2 | 미저장 ADD preview도 선택 가능 여부를 명확히 하거나, 지원하지 않는다면 상태문구로 제한을 안내한다. | +| 꼭지점편집 | `꼭지점편집` 선택 후 `NEW-2` preview 클릭 및 vertex drag 시도 | 주황색 실꼭지점과 흰색 midpoint가 보이고 vertex/midpoint 조작이 가능하다. | 상태문구는 vertex 안내로 전환됐지만 주황색/흰색 handle과 실제 drag 반영을 확인하지 못했다. | P2 | 선택 성공/실패 피드백, vertex handle 표시 조건, midpoint 삽입 가능 범위를 화면에서 명확히 표시한다. | +| 삭제 | `삭제` 선택 후 `NEW-2` preview 클릭 | 삭제 예정 스타일, 세션 변경목록, 되돌리기 가능 상태가 표시된다. | 저장된 fid가 없어 최종 삭제 저장은 불가했다. 미저장 preview 클릭 후 삭제 변경 전환은 확인되지 않았고, 세션에는 ADD 1건만 남았다. | P2 | 미저장 ADD 객체 삭제는 세션 목록의 `마지막 변경 취소`와 같은 명시적 UI로 안내하거나 지도 클릭 삭제를 지원한다. | +| 취소/롤백 | 세션 팝업에서 `마지막 변경 취소` 클릭 | 마지막 변경분이 제거되고 저장할 변경분이 없어야 한다. | `총 0건`, `저장할 편집 변경분이 없습니다.` 확인. 지도 상태문구도 `가장 최근 편집 변경분을 세션에서 제거했습니다.`로 표시 | 통과 | 최종 저장 실패 후 사용자가 쉽게 정리할 수 있도록 이 동작을 메인 툴바에도 노출하는 방안을 검토한다. | + +#### 2.13.3 네트워크/API 응답 + +| 순번 | 요청 | 테스트 관리번호 | 응답 | 기록 | +| --- | --- | --- | --- | --- | +| 147 | `POST http://localhost:3002/sewerage-service/api/v1/map/gisEditSessions/save` | `QA-PW-20260526-001` | `500 Internal Server Error`, body `{"message":"ERROR","status":500,"code":"E999"}` | `clientTempId=NEW-1`, `fid/editSn` 미발급 | +| 149 | `POST http://localhost:3002/sewerage-service/api/v1/map/gisEditSessions/save` | `QA-PW-20260526-002` | `500 Internal Server Error`, body `{"message":"ERROR","status":500,"code":"E999"}` | `clientTempId=NEW-2`, `fid/editSn` 미발급 | + +- 2차 저장 요청 주요 payload: + - `layerId`: `tb_ms_swage_ppl` + - `saveTargetType`: `FINAL` + - `view.srs`: `EPSG:5179` + - `editType`: `ADD` + - `geometry.type`: `LineString` + - `coordinates`: `[[1068626.8294507712,1774978.3276935548],[1068741.356641528,1774930.6080307395],[1068855.8838322847,1774978.3276935548]]` + - `attributes.fclt_mng_no`: `QA-PW-20260526-002` + - `attributes.inst_mng_no`: `QA-PW-20260526-002` + - `attributes.fcltMngNo`: 빈 문자열 + - `attributes.mngInstCd`: 빈 문자열 + +#### 2.13.4 콘솔 오류 + +- `GET http://localhost:3002/server/cms-service/api/v1/menu-roles/SN` -> `401 Unauthorized` +- `POST http://localhost:3002/sewerage-service/api/v1/map/gisEditSessions/save` -> `500 Internal Server Error` +- `GIS 편집 저장 실패: Error: ERROR` +- `AxiosError` + +#### 2.13.5 화면 캡처 + +- `D:\workspace\qa-evidence\sfr-012-003-login-map-loaded.png` +- `D:\workspace\qa-evidence\sfr-012-003-add-before-save.png` +- `D:\workspace\qa-evidence\sfr-012-003-session-add-failed-payload.png` +- `D:\workspace\qa-evidence\sfr-012-003-add-retry-before-save.png` +- `D:\workspace\qa-evidence\sfr-012-003-move-unsaved-no-selection.png` +- `D:\workspace\qa-evidence\sfr-012-003-vertex-unsaved-no-handles.png` +- `D:\workspace\qa-evidence\sfr-012-003-session-undo-clean.png` + +#### 2.13.6 현재 판정 + +- `ADD`는 화면 조작, 상세정보 입력, 세션 반영, 저장 버튼 활성화까지는 통과했지만 최종 저장 API가 `500 E999`로 실패해 통합 검증은 실패로 판정한다. +- `MOVE`, `VERTEX_EDIT`, `DELETE`는 저장된 테스트 fid가 없어 최종본 기준 검증을 수행하지 못했다. 미저장 preview 기준으로는 선택/drag/삭제 피드백이 명확하지 않아 보류 및 UX 결함으로 분류한다. +- 테스트 관리번호 `QA-PW-20260526-001/002`는 저장 실패로 `fid/editSn`이 생성되지 않았다. 최종본 삭제 저장 정리 대상은 없었고, FE 로컬 세션은 `총 0건`으로 정리했다. +- `next build`, `pnpm --filter sn run build`, `pnpm turbo run build --filter=sn`은 실행하지 않았다. `sn` dev server가 `3002`에서 실행 중이므로 AGENTS.md 기준에 따라 빌드 검증은 제외했다. + ## 3. 현재 검증 상태 ### 3.1 통과 @@ -150,34 +452,47 @@ - 레이어별 도구 활성 규칙 - 세션 `undo`, `reset` - `MOVE`의 기본 preview drag +- 하수관로 `ADD/MOVE/DELETE` 최종본/이력 DB 저장 ### 3.2 진행 중 - `ADD` geometry family별 직접 캡처 +- 2026-05-26 Playwright 재검증 기준 `ADD` 최종 저장 API `500 E999` 원인 분석 및 재검증 - `VERTEX_EDIT`의 QGIS 기준 사용자 경험 재정렬 - `MOVE`, `VERTEX_EDIT` 실조작 수동 재검증 +- 2026-05-26 재검증에서 확인된 `이동`, `꼭지점편집`, `삭제` 선택 피드백/조작 피드백 개선 ### 3.3 보류 -- 실제 저장 API 연결 이후 통합 저장 검증 -- 원본 보호와 편집본 참조 갱신 검증 +- 저장 성공 후 GeoServer/GWC 캐시 무효화 운영 방식 검증 +- `ADD` 저장 실패로 인한 신규 fid 기반 `MOVE/VERTEX_EDIT/DELETE/삭제 저장 정리` 재검증 +- QA 서브에이전트 또는 독립 검증자가 사용할 수 있는 로컬 로그인/인증 절차 문서화 ## 4. 보류/리스크 +- 2026-05-26 Playwright 재검증에서 신규 관로 저장 API가 `500 E999`로 실패해 최종본 기준 편집 시나리오가 차단됐다. +- 신규 관로 상세정보 저장 payload에서 `fclt_mng_no`/`inst_mng_no`는 입력값이 들어가지만 `fcltMngNo`/`mngInstCd`는 빈 문자열로 남아 필수 식별자 정합성 리스크가 있다. - dev 서버가 켜져 있으면 `next build`를 함께 돌리기 어렵다. - `next lint`는 환경 이슈로 안정적인 회귀 수단이 되지 못하고 있다. - 지도 캔버스 기반 drag/vertex 테스트는 수동 브라우저 검증 의존도가 높다. - `VERTEX_EDIT`는 하수관로 `LineString`/`MultiLineString` 처리와 vertex marker UX를 다시 고정해야 한다. - 현재 저장소의 기존 TypeScript 오류 때문에 변경분만 분리한 정적 통과 여부를 보장하기 어렵다. +- 기존 관로와 겹친 위치에서 자동 클릭 기반 `MOVE/DELETE` 검증을 수행하면 의도하지 않은 기존 관로가 선택될 수 있다. +- `GIS 편집 세션`이 popup에 의존하면 팝업 차단, 포커스 이동, 별도 창 위치 문제로 사용자가 변경목록을 놓칠 수 있다. +- 저장 검증 메시지와 화면 필드명이 불일치하면 신규 관로 저장 실패 원인을 사용자가 잘못 이해할 수 있다. +- 지도 초기 로딩 오류가 남아 있으면 GIS 편집 결함과 공통 지도/코드/레이어 설정 오류를 분리하기 어렵다. ## 4-1. 관측 증적 요약 - 자동으로 확인된 항목 + - `CLEANWATER` 계정 로그인 후 `/map` 진입 - `GIS편집` 모드 진입/종료 + - 신규 관로 `ADD` preview 생성, 상세정보 입력, 세션 목록 반영 - 레이어별 도구 활성 규칙 - 세션 `undo`, `reset` - `MOVE`의 기본 preview drag 준비 구조 - 수동 또는 후속 확인이 필요한 항목 + - `ADD` 저장 API `500 E999` 원인 확인 - 실제 canvas drag 정확도 - `VERTEX_EDIT`의 vertex marker 노출과 꼭지점 직접 이동 - 저장 API 연결 후 응답 처리와 원본 보호 @@ -197,6 +512,7 @@ - `환경 차단` - dev 서버 실행 중 build 병행이 어렵고, lint도 안정적 회귀 수단이 아니다. - `기능 결함 또는 미완료` + - `ADD` 최종 저장이 `500 E999`로 실패해 신규 fid 기반 후속 검증이 차단된다. - `VERTEX_EDIT`는 아직 요구사항 기준으로 다시 고정이 필요하다. ## 5. 흡수한 기존 문서 diff --git a/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts b/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts index dbb90794a..a59c2f28d 100644 --- a/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts +++ b/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts @@ -17,17 +17,51 @@ const buildQueryString = (params: OpenWindowPopupParams = {}) => { return queryString ? `?${queryString}` : ""; }; -const buildPopupFeatures = (width: number, height: number) => { +const clamp = (value: number, min: number, max: number) => { + if (max < min) return min; + return Math.min(Math.max(value, min), max); +}; + +const buildPopupFeatures = ( + width: number, + height: number, + placement: "center" | "browser-left" = "center", +) => { const screenLeft = window.screenX ?? window.screenLeft ?? 0; const screenTop = window.screenY ?? window.screenTop ?? 0; const outerWidth = window.outerWidth || window.screen.width; const outerHeight = window.outerHeight || window.screen.height; - const left = Math.max(0, screenLeft + Math.round((outerWidth - width) / 2)); - const top = Math.max(0, screenTop + Math.round((outerHeight - height) / 2)); + const screen = window.screen as Screen & { + availLeft?: number; + availTop?: number; + }; + const availableLeft = screen.availLeft ?? 0; + const availableTop = screen.availTop ?? 0; + const availableWidth = window.screen.availWidth || outerWidth; + const availableHeight = window.screen.availHeight || outerHeight; + const safeWidth = Math.min(width, Math.max(360, availableWidth - 48)); + const safeHeight = Math.min(height, Math.max(420, availableHeight - 72)); + const minLeft = availableLeft; + const minTop = availableTop + 24; + const maxLeft = availableLeft + availableWidth - safeWidth - 16; + const maxTop = availableTop + availableHeight - safeHeight - 24; + const left = + placement === "browser-left" + ? clamp(screenLeft, minLeft, maxLeft) + : clamp( + screenLeft + Math.round((outerWidth - safeWidth) / 2), + availableLeft + 16, + maxLeft, + ); + const top = clamp( + screenTop + Math.round((outerHeight - safeHeight) / 2), + minTop, + maxTop, + ); return [ - `width=${width}`, - `height=${height}`, + `width=${safeWidth}`, + `height=${safeHeight}`, `left=${left}`, `top=${top}`, "resizable=yes", @@ -48,7 +82,11 @@ export const openWindowPopup = ( const definition = windowPopupDefinitions[popupKey]; const popupUrl = `/popup/${popupKey}${buildQueryString(params)}`; - const popupFeatures = buildPopupFeatures(definition.width, definition.height); + const popupFeatures = buildPopupFeatures( + definition.width, + definition.height, + "placement" in definition ? definition.placement : undefined, + ); const popupWindow = window.open( popupUrl, `sn-popup-${popupKey}`, diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts index ff6c1207e..a354aa7cb 100644 --- a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -105,9 +105,10 @@ export const windowPopupDefinitions = { }, gisEditSessionPopup: { key: "gisEditSessionPopup", - title: "GIS 편집 세션", - width: 520, - height: 820, + title: "편집 세션", + width: 460, + height: 640, + placement: "browser-left", }, gisManagementPopup: { key: "gisManagementPopup", diff --git a/apps/sn/src/features/windowPopup/model/types.ts b/apps/sn/src/features/windowPopup/model/types.ts index 220988c12..904a9bc2f 100644 --- a/apps/sn/src/features/windowPopup/model/types.ts +++ b/apps/sn/src/features/windowPopup/model/types.ts @@ -14,6 +14,7 @@ export type WindowPopupDefinition = { title: string; width: number; height: number; + placement?: "center" | "browser-left"; requiredParams?: string[]; }; diff --git a/packages/common/business_components/src/ui/map/lib/gis.layer.js b/packages/common/business_components/src/ui/map/lib/gis.layer.js index 314b93338..97d3eb6db 100644 --- a/packages/common/business_components/src/ui/map/lib/gis.layer.js +++ b/packages/common/business_components/src/ui/map/lib/gis.layer.js @@ -742,10 +742,13 @@ GisOlLayer.prototype = { const extent = this.combineExtents(geometries.map(g => g?.getExtent())); let map = GisApp.Module.core.map; + const targetZoom = 16; + const currentZoom = Number(map.getView().getZoom()); + const maxZoom = Number.isFinite(currentZoom) && currentZoom > targetZoom ? currentZoom : targetZoom; map.getView().fit(extent, { duration: 300, padding: [50, 50, 50, 50], - maxZoom: 16 + maxZoom: maxZoom }); } } else { diff --git a/packages/common/business_components/src/ui/map/lib/gis.select.js b/packages/common/business_components/src/ui/map/lib/gis.select.js index a1dab024e..e05edb9af 100644 --- a/packages/common/business_components/src/ui/map/lib/gis.select.js +++ b/packages/common/business_components/src/ui/map/lib/gis.select.js @@ -132,13 +132,15 @@ GisOlSelect.prototype = { const selectedLayer = e.target?.getLayer(feature) || feature.getLayer?.(); const clckZoom = Number(selectedLayer?.get('clckZoom')); const targetZoom = Number.isFinite(clckZoom) && clckZoom > 0 ? clckZoom : 16; + const currentZoom = Number(map.getView().getZoom()); + const maxZoom = Number.isFinite(currentZoom) && currentZoom > targetZoom ? currentZoom : targetZoom; const geometry = feature.getGeometry(); if (geometry) { const extent = geometry.getExtent(); map.getView().fit(extent, { duration: 300, padding: [50, 50, 50, 50], - maxZoom: targetZoom + maxZoom: maxZoom }); } } diff --git a/packages/common/business_components/src/ui/map/lib/gis.style.js b/packages/common/business_components/src/ui/map/lib/gis.style.js index 4ad75c386..767244e73 100644 --- a/packages/common/business_components/src/ui/map/lib/gis.style.js +++ b/packages/common/business_components/src/ui/map/lib/gis.style.js @@ -343,12 +343,13 @@ GisOlStyle.prototype = { //레이어 스타일 반환 getLayerStyle: function (layer, options) { let gobjTy = layer.get('gobjTy'); - if (!gobjTy) return; + if (!gobjTy || typeof this[gobjTy] !== 'function') return; let style = this[gobjTy](layer, options); return style; }, getLayerStyleFunction: function (layer, options) { let gobjTy = layer.get('gobjTy'); + if (!gobjTy || typeof this[gobjTy] !== 'function') return; let styleFunction = function (feature, resolution) { return this[gobjTy](layer, options, feature, resolution); }.bind(this); From 1c37591bab95fd090947558db8a00563c0663c21 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Tue, 2 Jun 2026 15:44:36 +0900 Subject: [PATCH 009/185] =?UTF-8?q?=EB=85=B8=ED=9B=84=EA=B4=80=EB=A1=9C=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=BC=EB=85=84=EB=8F=84=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../map/components/popupComponent/featureClickEventPopup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx index 49c0d168c..cf56063d0 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx @@ -512,7 +512,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const result = await getData(apiEndpoint); if (result) { - const mergedData = isPipeDetailLayer ? result : mergeDetailData(formattedData, result); + const mergedData = mergeDetailData(formattedData, result); const processedApiData = withMngInstName(codeMapper({ layerName: selectedLayerName, data: mergedData From 9d6cc405a982e0530ad62d1b702160432b09126e Mon Sep 17 00:00:00 2001 From: kiminchan Date: Tue, 2 Jun 2026 16:29:07 +0900 Subject: [PATCH 010/185] =?UTF-8?q?=ED=95=98=EC=88=98=EA=B4=80=EB=A7=9D?= =?UTF-8?q?=EB=8F=84=20-=20=EC=B2=98=EB=A6=AC=EA=B5=AC=EC=97=AD=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=83=81=EC=84=B8=EC=A0=95=EB=B3=B4=20=EB=A7=A4?= =?UTF-8?q?=EC=84=A4=EB=85=84=EB=8F=84->=20=EA=B2=BD=EA=B3=BC=EB=85=84?= =?UTF-8?q?=EB=8F=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx index b02977a09..139af3f6c 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx @@ -33,9 +33,9 @@ const metricGroups: { title: string; field: keyof SwtrmtPrcsZoneAnalysisMetric; }[] = [ - { title: "평균매설년도(년)", field: "avgInstlYear" }, - { title: "최장매설년도(년)", field: "oldestInstlYear" }, - { title: "최신매설년도(년)", field: "latestInstlYear" }, + { title: "평균경과년도(년)", field: "avgInstlYear" }, + { title: "최장경과년도(년)", field: "oldestInstlYear" }, + { title: "최신경과년도(년)", field: "latestInstlYear" }, ]; const MetricVerticalGrid = ({ From af8db9457f8c3922c6688aadace0c15adadc97a3 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Thu, 4 Jun 2026 15:16:53 +0900 Subject: [PATCH 011/185] =?UTF-8?q?=EB=85=B8=ED=9B=84=EA=B4=80=EB=A1=9C=20?= =?UTF-8?q?=ED=8C=9D=EC=97=85=20=EC=9C=84=EC=B9=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/agedPipe/AgedPipePopupPage.tsx | 92 +++++++++++++++---- .../map/components/agedPipe/agedPipeUtils.ts | 4 +- apps/sn/app/(map-layout)/map/page.tsx | 22 ++++- .../app/html/(map-layout)/aged-pipe/page.tsx | 18 +++- .../001-qa-SFR-011-노후관로관리-통합검증결과.md | 57 ++++++++++++ .../001-fe-SFR-011-노후관로관리-통합구현기록.md | 76 +++++++++++++++ .../windowPopup/lib/openWindowPopup.ts | 32 ++++--- .../windowPopup/model/popupDefinitions.ts | 8 +- .../src/features/windowPopup/model/types.ts | 3 + 9 files changed, 273 insertions(+), 39 deletions(-) create mode 100644 apps/sn/docs/작업검증/SFR-011-노후관로관리/001-qa-SFR-011-노후관로관리-통합검증결과.md create mode 100644 apps/sn/docs/작업내용/SFR-011-노후관로관리/001-fe-SFR-011-노후관로관리-통합구현기록.md diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx index 1b6542816..77eee2aad 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx @@ -196,8 +196,16 @@ export default function AgedPipePopupPage() { window.close(); }; + const getCellClassName = (_row: AgedPipeRow, headId: string) => { + if (headId === "mqltCd") { + return "break-all whitespace-normal"; + } + + return "whitespace-normal"; + }; + return ( -
+

노후관로 현황

@@ -220,9 +228,9 @@ export default function AgedPipePopupPage() {
) : null} -
-
-
diff --git a/apps/sn/docs/작업검증/SFR-010-010-지반침하정보/002-qa-SFR-010-010-지반침하정보-통합검증결과.md b/apps/sn/docs/작업검증/SFR-010-010-지반침하정보/002-qa-SFR-010-010-지반침하정보-통합검증결과.md index 9ccebd00b..45090a3ad 100644 --- a/apps/sn/docs/작업검증/SFR-010-010-지반침하정보/002-qa-SFR-010-010-지반침하정보-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-010-010-지반침하정보/002-qa-SFR-010-010-지반침하정보-통합검증결과.md @@ -90,6 +90,18 @@ - 다운로드 첫 데이터 행 헤더 스타일 오염 제거 - 현재 대화 범위에서는 기능 테스트 성공으로 판정한다. +### 2.13 2026-06-11 목록 총 건수 헤더 표시 확인 + +- 검증 대상 + - `FE_APP_ROOT/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx` +- 확인 내용 + - 지반침하 팝업 목록 테이블이 `total` 상태값을 이미 보유하고 있는지 코드 경로를 다시 확인했다. + - 테이블 헤더를 `지반침하 조회 결과 총 {total.toLocaleString()}건`으로 지정하고 `isHideCount={true}`를 적용해, 노후관로와 같은 문구 형식으로 총 건수가 노출되도록 보강했다. + - 사용자 확인 결과 반경이 정상 적용되었고, 요구한 총 건수 문구가 지반침하 팝업 상단에 표시되는 것을 확인했다. +- 검증 결과 + - 사용자 확인 완료 + - 추가 브라우저 재현 캡처와 빌드 재실행은 이번 라운드에서 수행하지 않음 + ## 3. 미실행 항목 - FE 브라우저 검증 diff --git a/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md b/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md index 4f6bf1597..8d9d5482f 100644 --- a/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md @@ -211,6 +211,12 @@ - `layericon` 폴더의 기존 운영 자산이 대부분 `png` 기반인 점도 고려해, 연도 노드 아이콘 자산을 `png`로 추가하고 `sinkholeTypes.ts` 상수도 `.png`를 바라보도록 바꿨다. - 이렇게 하면 DB 자식 노드와 FE fallback 노드 모두 기존 정적 아이콘 자산 체계와 같은 형식을 사용하므로, 서버 설정 차이 없이 같은 경로 규칙으로 안정적으로 로드할 수 있다. +### 4.28 2026-06-11 목록 총 건수 헤더 보강 + +- 지반침하 목록 팝업은 기존에도 `/api/v1/sinkholes` 응답의 `total` 값을 상태로 보관하고 있었지만, `UiTable`의 `headName`을 비워 둔 상태라 노후관로처럼 상단에 “조회 결과 총 n건” 문구가 노출되지 않았다. +- 이번 유지보수에서는 `SinkholePopupPage.tsx`의 목록 테이블 헤더를 `지반침하 조회 결과 총 {total.toLocaleString()}건`으로 지정하고, `isHideCount={true}`를 함께 적용해 `UiTable` 기본 건수 문구와 중복되지 않도록 정리했다. +- 결과적으로 노후관로 팝업과 같은 위치/형식으로 총 건수가 표시되며, 별도 API 변경 없이 기존 `total` 응답을 그대로 재사용한다. + ## 5. 승인 전 보류 사항 - 목록 팝업을 `window popup`으로 열지 `/map` 내부 중앙 모달로 열지 확정 필요 From 83a3810301c5331584d37b1fad3f612fbb19cb40 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Fri, 12 Jun 2026 13:26:35 +0900 Subject: [PATCH 021/185] =?UTF-8?q?=EC=8B=9C=EC=84=A4=EB=AC=BC=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20ui=20=EB=B3=80=EA=B2=BD=20=EC=9E=91?= =?UTF-8?q?=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agedPipe/AgedPipeLayerSettingPanel.tsx | 46 ++++- .../sinkhole/SinkholeLayerSettingPanel.tsx | 43 ++++- apps/sn/app/(map-layout)/map/page.tsx | 158 ++++++++++++++---- .../map/waterDepth/waterDepth.tsx | 24 ++- .../001-fe-SFR-010-010-지반침하정보-통합구현기록.md | 22 +++ .../001-fe-SFR-011-노후관로관리-통합구현기록.md | 22 +++ .../business_components/src/ui/map/Gis.js | 2 + .../business_components/src/ui/map/GisTree.js | 103 ++++++------ 8 files changed, 327 insertions(+), 93 deletions(-) diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx index 9162074f4..2539f4df3 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx @@ -7,12 +7,14 @@ type Props = { filters: AgedPipeFilters; onFiltersChange: (filters: AgedPipeFilters) => void; onApply: (filters?: AgedPipeFilters) => void; + compact?: boolean; }; export default function AgedPipeLayerSettingPanel({ filters, onFiltersChange, onApply, + compact = false, }: Props) { const [isCollapsed, setIsCollapsed] = useState(false); const [elapsedYearsText, setElapsedYearsText] = useState(String(filters.elapsedYears ?? "")); @@ -30,8 +32,48 @@ export default function AgedPipeLayerSettingPanel({ onApply(nextFilters); }; + if (compact) { + return ( +
+
+
+ + +
+
+ event.currentTarget.select()} + onChange={(event) => { + const digits = event.target.value.replace(/[^0-9]/g, ""); + setElapsedYearsText(digits); + }} + onKeyDown={(event) => { + if (event.key === "Enter") commitElapsedYears(); + }} + /> + +
+
+
+ ); + } + return ( -
+
+
+
+
+ ); + } + return ( -
+
-
침 수 심
+
침수심 범례
- {/* 코드: N330 */} - {/* 코드: N331 */} - {/* 코드: N332 */} - {/* 코드: N333 */} - {/* 코드: N334 */} + {WATER_DEPTH_LEGEND_ITEMS.map((item, index) => ( + + ))}
@@ -95,4 +105,4 @@ function LegendItem({ color, label }: { color: string; label: string }) { {label}
); -} \ No newline at end of file +} diff --git a/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md b/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md index 8d9d5482f..15b25da2c 100644 --- a/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md @@ -217,6 +217,28 @@ - 이번 유지보수에서는 `SinkholePopupPage.tsx`의 목록 테이블 헤더를 `지반침하 조회 결과 총 {total.toLocaleString()}건`으로 지정하고, `isHideCount={true}`를 함께 적용해 `UiTable` 기본 건수 문구와 중복되지 않도록 정리했다. - 결과적으로 노후관로 팝업과 같은 위치/형식으로 총 건수가 표시되며, 별도 API 변경 없이 기존 `total` 응답을 그대로 재사용한다. +### 4.29 2026-06-11 시설물 트리 하단 설정영역 분리 + +- 기존에는 `지반침하정보`의 반경 설정 패널이 트리 노드 바로 아래 인라인으로 붙어 있었는데, 생활안전지도처럼 “레이어를 사용할 때만 하단 설정영역에 따로 모아 보여주는 구조” 요청이 추가됐다. +- 이번 유지보수에서는 패널 디자인과 입력/적용 동작은 그대로 두고, `map/page.tsx`에서 인라인 노출 분기를 제거한 뒤 `Gis`의 `layerSettingView` 하단 영역으로 이동시켰다. +- 따라서 `지반침하정보`를 체크하지 않으면 설정 패널이 보이지 않고, 연도/전체 노드가 활성화된 동안에만 트리 하단 별도 영역에 나타난다. +- 후속 보정으로 하단 영역 자체에 `선택 레이어 설정` 헤더와 선택된 레이어명 표시를 추가하고, 세로 공간이 부족할 때는 내부 스크롤이 생기도록 정리했다. +- `노후관로 설정`과 패널 폭이 달라 보이지 않도록 `지반침하정보` 설정 패널 폭도 하단 영역 기준 `w-full`로 통일했다. +- 추가 보정으로 별도 상단 타이틀 줄은 제거하고, 대신 시설물 레이어 목록 영역 자체를 얇은 직사각형 테두리로 감싸서 위 레이어 영역과 아래 설정영역이 자연스럽게 구분되도록 정리했다. +- 추가 점검으로 `선택 레이어 설정` 노출 기준을 연도 자식 노드 탐지 대신 실제 `isSinkholeChecked` 상태로 바꿔, 지반침하 레이어 체크 시 하단 설정영역 반영이 누락되지 않도록 보정했다. +- 공용 트리 접기 버튼이 세로 스크롤바 위를 덮어 보이는 문제를 줄이기 위해, 버튼을 패널 안쪽이 아니라 우측 바깥 경계 쪽으로 이동시켜 핸들처럼 보이도록 조정했다. +- 후속 조정으로 버튼이 아예 보이지 않는 문제를 해결하기 위해, 접기/펼치기 버튼을 패널 밖 가운데에 붙는 단일 토글 핸들 구조로 재배치했다. +- 추가 수정으로 토글 버튼이 `overflow-hidden` 패널 박스에 같이 들어가 잘리던 구조를 분리해, 버튼을 패널 박스 바깥 형제 위치로 옮겨 실제 화면에서 보이도록 보정했다. +- 최종 보정으로 토글 버튼 시각 스타일은 분석도구 패널에서 사용 중인 `36x20` 핸들 디자인과 동일하게 맞추고, 위치는 패널 바깥 중앙을 유지하도록 정리했다. +- 추가 구조 보정으로 시설물 레이어 직사각형 박스는 화면에 항상 보이는 고정 영역으로 두고, 실제 롤업/스크롤은 그 내부에서만 일어나도록 바꿨다. 토글 핸들도 이제 패널 전체가 아니라 이 직사각형 박스 기준 중앙에 붙는다. +- 후속 위치 보정으로 토글 핸들이 박스 바깥으로 너무 멀리 빠지지 않도록 `left-full` 배치를 제거하고, 레이어 직사각형 박스 오른쪽 경계에 걸치는 `right` 음수 오프셋 기준으로 다시 맞췄다. +- 후속 가시성 보정으로 토글 핸들 크기를 키우고 파란 계열 대비와 그림자를 강화해, 배경 지도와 겹쳐도 버튼이 바로 보이도록 조정했다. +- 추가 구조 보정으로 생활안전지도처럼 `레이어 박스 내부 스크롤`과 `패널 전체 스크롤`을 분리했다. 시설물 레이어 박스는 고정 높이와 고정 테두리를 유지하고, 선택 레이어 설정이 많아질 때는 패널 전체 스크롤로 아래 내용을 보도록 정리했다. +- `침수위험지도(중점관리지역)`, `도시침수지도(정보제공포털)` 선택 시 `침수심 범례`는 토글 제어가 아니라 선택 레이어 설정 안에서 실제 범례 항목을 그대로 보여주는 정보 카드로 정리했다. +- 스크롤바 폭은 줄이되 색상은 브라우저 기본색으로 되돌려, 이전보다 얇지만 과한 커스텀 색은 보이지 않도록 맞췄다. +- 후속 정리로 선택 레이어 설정 안의 침수심 범례 카드는 실제 범례 본문만 남기고, 지도 우측 하단에 별도로 뜨던 기존 범례 박스는 제거했다. +- 추가 구조 보정으로 토글 핸들을 패널 이동 영역과 완전히 분리된 형제로 빼고, 열림 시에는 레이어 박스 옆에, 닫힘 시에는 화면 왼쪽 가장자리에 남도록 바꿔 다시 열기 버튼이 사라지지 않게 정리했다. + ## 5. 승인 전 보류 사항 - 목록 팝업을 `window popup`으로 열지 `/map` 내부 중앙 모달로 열지 확정 필요 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 ee502cdbc..d6f51e189 100644 --- a/apps/sn/docs/작업내용/SFR-011-노후관로관리/001-fe-SFR-011-노후관로관리-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-011-노후관로관리/001-fe-SFR-011-노후관로관리-통합구현기록.md @@ -30,6 +30,28 @@ - `UiTable`에 전달하는 외곽 마진을 제거하고, 중복 총 건수 문구를 숨겨 팝업 내부 가로/세로 스크롤 발생 가능성을 낮췄다. - 표 영역은 현재 팝업 폭과 높이 안에서 보이도록 유지하는 방향으로 정리했다. +### 3.3 2026-06-11 시설물 트리 하단 설정영역 분리 + +- 기존 `노후관로 설정` 패널은 시설물 트리 안에서 `노후관로` 노드 바로 아래에 인라인으로 열렸지만, 지반침하정보와 동일하게 “레이어 사용 시 하단 설정영역에 추가되는 방식”으로 맞춰 달라는 요청이 들어왔다. +- 이번 유지보수에서는 `노후관로 설정` 패널 컴포넌트 자체는 유지하고, `map/page.tsx`에서 `isAgedPipeChecked`일 때만 `Gis` 하단 `layerSettingView` 영역에 렌더되도록 위치만 조정했다. +- 체크 해제 시에는 설정 패널도 같이 사라지고, 목록보기/필터 적용/지도 표시 흐름은 기존과 동일하게 유지한다. +- 후속 보정으로 하단 영역은 `선택 레이어 설정` 묶음으로 감싸고, 선택된 설정이 없으면 섹션 자체가 사라지도록 해 “선택된 게 있을 때만 뜨는 느낌”을 명확히 했다. +- 같은 영역 안에서 `지반침하정보`와 `노후관로 설정` 패널이 서로 다른 폭으로 보이지 않도록 두 패널 모두 하단 영역 기준으로 동일 폭을 쓰게 맞췄다. +- 추가 보정으로 별도 상단 구분 타이틀은 제거하고, 시설물 레이어 목록을 얇은 사각형 프레임으로 감싸 위 레이어 영역과 아래 설정영역의 구획만 남기도록 정리했다. +- 스크롤바가 생길 때 레이어명이 2줄로 꺾이는 현상을 줄이기 위해, 공용 트리 스크롤 영역에 `scrollbar-gutter: stable`을 적용하고 레이어명은 한 줄 `truncate` 기준으로 보정했다. +- 공용 트리 접기 버튼이 스크롤바를 가려 보이는 배치도 함께 조정해, 버튼이 패널 외곽 핸들처럼 보이도록 옮겼다. +- 후속 조정으로 버튼이 잘려 보이지 않는 문제를 해결하기 위해, 접기/펼치기 버튼을 패널 밖 가운데에 붙는 단일 토글 핸들 구조로 재배치했다. +- 추가 수정으로 토글 버튼이 패널의 `overflow-hidden` 범위에 포함돼 실제로는 잘려 보이지 않던 구조를 분리해, 패널 바깥 형제 위치에서 항상 보이도록 보정했다. +- 최종 보정으로 토글 시각 스타일은 분석도구 패널과 같은 `36x20` 핸들 디자인을 재사용하고, 위치는 패널 바깥 중앙으로 유지하도록 정리했다. +- 추가 구조 보정으로 시설물 레이어 박스는 항상 윤곽이 유지되게 하고, 스크롤은 그 박스 내부에서만 동작하도록 바꿨다. 토글 핸들 위치도 박스 기준 중앙으로 다시 정리했다. +- 후속 위치 보정으로 토글 핸들이 박스 오른쪽 경계에 자연스럽게 걸치도록 다시 맞춰, 지도 영역 쪽으로 과하게 밀려나 보이던 배치를 줄였다. +- 후속 가시성 보정으로 토글 핸들 크기, 파란 계열 대비, 그림자를 함께 올려 지도 배경 위에서도 더 눈에 띄도록 정리했다. +- 추가 구조 보정으로 패널은 `바깥 전체 스크롤`, 시설물 레이어 박스는 `고정 높이 + 내부 스크롤`로 분리해 생활안전지도와 비슷한 사용 흐름으로 정리했다. +- `침수심 범례`는 선택 레이어 설정 안에서 별도 토글을 두지 않고, 관련 침수 레이어가 선택된 경우 실제 범례 항목을 그대로 확인하는 정보 카드로 반영했다. +- 스크롤바는 폭만 얇게 유지하고 색상은 기본값으로 되돌려, 생활안전지도처럼 덜 눈에 띄는 톤으로 정리했다. +- 후속 정리로 선택 레이어 설정에는 침수심 범례 본문만 남기고, 지도 우측 하단에 따로 뜨던 기존 범례 박스는 제거했다. +- 추가 구조 보정으로 토글 핸들을 패널 본체와 분리된 고정 핸들로 바꿔, 닫았을 때도 화면 왼쪽 가장자리에 다시 열기 버튼이 남도록 정리했다. + ## 4. 실제 수정 파일 - `FE_APP_ROOT/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx` diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index b377d6f90..adc1f1be9 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -56,6 +56,7 @@ function Gis({ waterDepth: WaterDepth = null, layerSettingView: LayerSettingView = null, inlineLayerSettingView, + treeSectionClassName = "", layerIconVisible, isPlaying = false, setIsPlaying = () => { }, @@ -316,6 +317,7 @@ function Gis({ onProgressChange={handleProgressChange} layerSettingView={LayerSettingView} inlineLayerSettingView={inlineLayerSettingView} + treeSectionClassName={treeSectionClassName} toggleButtonPosition={treeTogglePosition} /> diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js index 0ad2e7ecc..f931b06b7 100644 --- a/packages/common/business_components/src/ui/map/GisTree.js +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -46,6 +46,7 @@ export default React.memo(function GisTree({ layerSettingView: LayerSettingView = null, layerIconVisible, inlineLayerSettingView, + treeSectionClassName = "", toggleButtonPosition = "center", }) { const { gisCore, treeData } = useGisContext(); @@ -513,31 +514,37 @@ export default React.memo(function GisTree({ if (!treeData || treeData.length === 0) return null; const toggleButtonPositionClass = - toggleButtonPosition === "top" ? "top-[5.5rem]" : "top-1/2 -translate-y-1/2"; + toggleButtonPosition === "top" ? "top-4" : "top-1/2 -translate-y-1/2"; + const thinScrollbarClass = + "[scrollbar-width:thin] [&::-webkit-scrollbar]:w-[4px]"; return ( <> {/* 사이드 패널 */}
-
-

시설물

-
- {/* DnD Tree */} - - { +
+
+

시설물

+
+
+
+
+ {/* DnD Tree */} + + { const isVisible = node.data.exprsYn === "Y"; const hasVisibleChildren = nodes.some( (child) => @@ -554,7 +561,7 @@ export default React.memo(function GisTree({ return (
{!isSinkholeRootTreeNode && node.icon && {node.icon}} - + {node.text} {showLayerActionButton && ( @@ -621,35 +631,34 @@ export default React.memo(function GisTree({ )}
); - }} - sort={(a, b) => { - const aOrder = parseInt(a.data?.sortOrdr ?? "0"); - const bOrder = parseInt(b.data?.sortOrdr ?? "0"); - return aOrder - bOrder; - }} - /> - -
- {LayerSettingView && ( -
- + }} + sort={(a, b) => { + const aOrder = parseInt(a.data?.sortOrdr ?? "0"); + const bOrder = parseInt(b.data?.sortOrdr ?? "0"); + return aOrder - bOrder; + }} + /> + +
+
+
+ {LayerSettingView && ( +
+ +
+ )}
- )} - +
- {!visible && ( - - )} + ); }); From e47c00f0b60987714879b930e4a405d62cb59aeb Mon Sep 17 00:00:00 2001 From: kiminchan Date: Mon, 15 Jun 2026 16:28:37 +0900 Subject: [PATCH 022/185] =?UTF-8?q?=EC=B9=A8=EC=88=98=ED=9D=94=EC=A0=81?= =?UTF-8?q?=EB=8F=84=20=EB=AA=A9=EB=A1=9D/=EC=A7=80=EB=8F=84=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8C=9D=EC=97=85=20mock=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EB=B0=8F=20md=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysisMenu/AnalysisPopupMenu.tsx | 2 - .../floodHistory/FloodHistoryDetailPanel.tsx | 142 ++++++ .../floodHistory/FloodHistoryPopupPage.tsx | 408 ++++++++++++++++ .../floodHistory/floodHistoryTypes.ts | 458 ++++++++++++++++++ apps/sn/app/(map-layout)/map/page.tsx | 260 +++++++++- .../001-qa-SFR-011-009-침수발생이력-통합검증계획.md | 79 +++ .../002-qa-SFR-011-009-침수발생이력-통합검증결과.md | 52 ++ .../001-fe-SFR-011-009-침수발생이력-작업카드.md | 117 +++++ .../001-fe-SFR-011-009-침수발생이력-통합구현기록.md | 73 +++ .../windowPopup/model/popupDefinitions.ts | 4 +- apps/sn/src/features/windowPopup/registry.tsx | 11 +- .../ui/FloodHistoryPopupContent.tsx | 5 + .../business_components/src/ui/map/GisTree.js | 8 +- 13 files changed, 1602 insertions(+), 17 deletions(-) create mode 100644 apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryDetailPanel.tsx create mode 100644 apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx create mode 100644 apps/sn/app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts create mode 100644 apps/sn/docs/작업검증/SFR-011-009-침수발생이력/001-qa-SFR-011-009-침수발생이력-통합검증계획.md create mode 100644 apps/sn/docs/작업검증/SFR-011-009-침수발생이력/002-qa-SFR-011-009-침수발생이력-통합검증결과.md create mode 100644 apps/sn/docs/작업계획/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-작업카드.md create mode 100644 apps/sn/docs/작업내용/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-통합구현기록.md create mode 100644 apps/sn/src/features/windowPopup/ui/FloodHistoryPopupContent.tsx diff --git a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx index 902be2916..b01c8b019 100644 --- a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx +++ b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx @@ -5,7 +5,6 @@ import { ChartColumnBig, ChevronLeft, ChevronRight, - History, PanelsTopLeft, PencilRuler, Search, @@ -31,7 +30,6 @@ const ANALYSIS_POPUP_ITEMS: Array<{ { key: "infoSearch", label: "정보검색", icon: Search }, { key: "pipeAnalysisPopup", label: "관로분석", icon: ChartColumnBig }, { key: "floodPredictionMapPopup", label: "침수예측지도", icon: Waves }, - { key: "floodHistoryPopup", label: "침수발생이력", icon: History }, { key: "gisEditPopup", label: "GIS 편집", icon: PencilRuler }, { key: "gisManagementPopup", label: "GIS 관리", icon: Settings2 }, { key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert }, diff --git a/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryDetailPanel.tsx b/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryDetailPanel.tsx new file mode 100644 index 000000000..66ed55078 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryDetailPanel.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { formatFloodArea, formatFloodDepth, type FloodHistoryItem } from "./floodHistoryTypes"; + +type Props = { + detail: FloodHistoryItem; + onClose: () => void; + position: { + left: number; + top: number; + }; +}; + +type DetailTab = "year" | "disaster" | "rainfall"; + +const renderRows = (rows: Array<[string, string]>) => ( + + + + + + + {rows.map(([label, value], index) => ( + + + + + ))} + +
+ {label} + + {value} +
+); + +export default function FloodHistoryDetailPanel({ detail, onClose, position }: Props) { + // 지도에서 침수흔적도 polygon을 클릭했을 때, 상세정보는 업무 의미 기준으로 3개 탭에 나눠 보여준다. + const [activeTab, setActiveTab] = useState("year"); + + const yearRows = useMemo( + () => [ + ["침수심", `${detail.floodDepthM.toFixed(2)} m`], + ["침수면적", formatFloodArea(detail.floodAreaHa)], + ["침수년도", `${detail.floodYear}년`], + ["등급", detail.gradeLabel], + ["침수구역", detail.districtName], + ] as Array<[string, string]>, + [detail], + ); + + const disasterRows = useMemo( + () => [ + ["침수재해명", detail.disasterName], + ["침수면적", formatFloodArea(detail.floodAreaHa)], + ["침수원인상세명", detail.causeDetailName], + ["침수시작년월일", detail.startDate], + ["침수종료년월일", detail.endDate], + ["침수시작시간", detail.startTime], + ] as Array<[string, string]>, + [detail], + ); + + const rainfallRows = useMemo( + () => [ + ["침수시작일자", detail.startDate], + ["침수종료일자", detail.endDate], + ["침수시작시간", detail.startTime], + ["침수종료시간", detail.endTime], + ["침수시간", detail.floodHour], + ["강우시작시간", detail.rainStartTime], + ["강우종료시간", detail.rainEndTime], + ["총강우량(mm)", `${detail.totalRainfallMm.toFixed(0)}`], + ["강우강도(mm/hr)", `${detail.rainfallIntensityMmHr.toFixed(0)}`], + ["침수시간", detail.floodHour], + ["침수원인상세코드", detail.causeDetailCode], + ["피해금액", detail.damageAmount], + ] as Array<[string, string]>, + [detail], + ); + + const currentRows = activeTab === "year" + ? yearRows + : activeTab === "disaster" + ? disasterRows + : rainfallRows; + + return ( +
+
+
상세정보
+ +
+ +
+
+ {[ + ["year", "년도탭"], + ["disaster", "재해명탭"], + ["rainfall", "침수발생강우탭"], + ].map(([tabId, label]) => { + const isActive = activeTab === tabId; + + return ( + + ); + })} +
+
+ +
+
+ {renderRows(currentRows)} +
+
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx new file mode 100644 index 000000000..346ee33ec --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx @@ -0,0 +1,408 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Button, Input, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components"; +import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; +import UiPageNations from "@common/business_components/ui/table/uiPageNations"; +import useStore from "@/store/useStore"; +import { + buildFloodHistoryYearOptions, + buildFloodHistoryTableRows, + FLOOD_HISTORY_DEFAULT_FILTERS, + FLOOD_HISTORY_DOWNLOAD_HEADERS, + FLOOD_HISTORY_ITEMS, + FLOOD_HISTORY_PAGE_SIZE, + FLOOD_HISTORY_ROW_SELECT_MESSAGE, + formatFloodArea, + formatFloodDepth, + normalizeFloodHistoryFilters, + type FloodHistoryFilters, + type FloodHistoryItem, +} from "./floodHistoryTypes"; + +const resolveOptionText = ( + code: string, + options: Array<{ text: string; val: string | number }>, + prefixLength?: number, +) => { + const normalized = String(code ?? "").trim(); + if (!normalized || normalized === "0") return ""; + const lookup = prefixLength ? normalized.slice(0, prefixLength) : normalized; + return options.find((item) => String(item.val).trim() === lookup)?.text ?? ""; +}; + +const GRID_COLUMNS = [ + { id: "rowNo", title: "NO", width: "64px", align: "center" }, + { id: "ctpvNm", title: "시도", width: "90px", align: "center" }, + { id: "sggNm", title: "시군구", width: "100px", align: "center" }, + { id: "startDateDisplay", title: "침수시작일자", width: "118px", align: "center" }, + { id: "endDateDisplay", title: "침수종료일자", width: "118px", align: "center" }, + { id: "startTimeDisplay", title: "침수시작시간", width: "98px", align: "center" }, + { id: "endTimeDisplay", title: "침수종료시간", width: "98px", align: "center" }, + { id: "floodYearDisplay", title: "침수년도", width: "96px", align: "center" }, + { id: "floodHourDisplay", title: "침수시간", width: "100px", align: "center" }, + { id: "disasterName", title: "침수재해명", width: "190px", align: "left" }, + { id: "districtNameDisplay", title: "침수지구명", width: "150px", align: "left" }, + { id: "causeCodeDisplay", title: "침수원인상세코드", width: "132px", align: "center" }, + { id: "causeDetailDisplay", title: "침수원인상세", width: "190px", align: "left" }, + { id: "avgFloodDepthDisplay", title: "평균침수위", width: "104px", align: "right" }, + { id: "floodAreaDisplay", title: "침수면적", width: "104px", align: "right" }, + { id: "exmnYearDisplay", title: "조사년도", width: "96px", align: "center" }, + { id: "damageAmountDisplay", title: "피해금액", width: "132px", align: "right" }, +] as const; + +const buildDownloadRows = (rows: FloodHistoryItem[]) => + rows.map((row, index) => ({ + rowNo: index + 1, + ctpvNm: row.ctpvNm, + sggNm: row.sggNm, + disasterName: row.disasterName, + exmnYearDisplay: `${row.exmnYear}년`, + avgFloodDepthDisplay: formatFloodDepth(row.avgFloodDepthM), + floodAreaDisplay: formatFloodArea(row.floodAreaHa), + causeDetailDisplay: row.causeDetailName, + startDateDisplay: row.startDate, + endDateDisplay: row.endDate, + startTimeDisplay: row.startTime, + endTimeDisplay: row.endTime, + causeCodeDisplay: row.causeDetailCode, + districtNameDisplay: row.districtName, + damageAmountDisplay: row.damageAmount, + floodHourDisplay: row.floodHour, + floodYearDisplay: `${row.floodYear}년`, + })); + +export default function FloodHistoryPopupPage() { + const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); + // 조회조건 draft는 popup 내부에서 사용자가 편집 중인 값이다. + const [filters, setFilters] = useState(FLOOD_HISTORY_DEFAULT_FILTERS); + // 적용된 조회조건과 선택행은 다운로드/페이지 이동에도 같은 기준을 유지하기 위해 분리한다. + const [appliedFilters, setAppliedFilters] = useState(FLOOD_HISTORY_DEFAULT_FILTERS); + const [currentPage, setCurrentPage] = useState(1); + const [selectedHistoryId, setSelectedHistoryId] = useState(""); + + useEffect(() => { + if (ctpv.length === 0) void fetchCtpv(); + if (sgg.length === 0) void fetchSgg(); + }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); + + const floodYearOptions = useMemo( + () => buildFloodHistoryYearOptions(FLOOD_HISTORY_ITEMS), + [], + ); + + const sggOptions = useMemo( + () => + filters.ctpvCd === "0" + ? sgg + : sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))), + [filters.ctpvCd, sgg], + ); + + const filteredRows = useMemo(() => { + const normalized = normalizeFloodHistoryFilters(appliedFilters); + const selectedCtpvName = resolveOptionText(normalized.ctpvCd, ctpv as any, 2); + const selectedSggName = resolveOptionText(normalized.sggCd, sgg as any, 5); + const disasterToken = normalized.disasterName.toLocaleLowerCase("ko-KR"); + + return FLOOD_HISTORY_ITEMS.filter((item) => { + if (selectedCtpvName && item.ctpvNm !== selectedCtpvName) return false; + if (selectedSggName && item.sggNm !== selectedSggName) return false; + if (normalized.floodYear && item.floodYear !== normalized.floodYear) return false; + if ( + disasterToken && + !item.disasterName.toLocaleLowerCase("ko-KR").includes(disasterToken) && + !item.districtName.toLocaleLowerCase("ko-KR").includes(disasterToken) + ) { + return false; + } + return true; + }).sort((left, right) => { + if (left.floodYear !== right.floodYear) { + return Number(right.floodYear) - Number(left.floodYear); + } + return left.disasterName.localeCompare(right.disasterName, "ko"); + }); + }, [appliedFilters, ctpv, sgg]); + + const pagedRows = useMemo(() => { + const start = (currentPage - 1) * FLOOD_HISTORY_PAGE_SIZE; + const end = start + FLOOD_HISTORY_PAGE_SIZE; + return buildFloodHistoryTableRows( + filteredRows.slice(start, end), + currentPage, + FLOOD_HISTORY_PAGE_SIZE, + ); + }, [currentPage, filteredRows]); + + const handleSearch = () => { + const normalized = normalizeFloodHistoryFilters(filters); + + if (normalized.sggCd !== "0" && normalized.ctpvCd === "0") { + alert("시도를 먼저 선택하세요."); + return; + } + if (normalized.floodYear && normalized.floodYear.length !== 4) { + alert("침수발생년도는 4자리 연도로 입력하세요."); + return; + } + + setAppliedFilters(normalized); + setCurrentPage(1); + setSelectedHistoryId(""); + }; + + const handleDownload = async () => { + if (filteredRows.length === 0) { + alert("다운로드할 침수발생이력 데이터가 없습니다."); + return; + } + + await downloadExcelWithKoreanHeaders({ + data: buildDownloadRows(filteredRows) as unknown as JSON[], + fileName: `침수흔적도_${new Date().toISOString().slice(0, 10)}.xlsx`, + sheetNameArray: ["침수흔적도"], + headerRange: "A1:Q1", + headerMapping: FLOOD_HISTORY_DOWNLOAD_HEADERS, + }); + }; + + const handleRowDoubleClick = (row: FloodHistoryItem) => { + window.opener?.postMessage( + { + type: FLOOD_HISTORY_ROW_SELECT_MESSAGE, + row, + openDetail: false, + }, + window.location.origin, + ); + window.close(); + }; + + const handleRowClick = (row: FloodHistoryItem) => { + setSelectedHistoryId(row.historyId); + // popup을 유지한 채 메인 지도에 mock 침수흔적/침수심 preview를 먼저 보여준다. + window.opener?.postMessage( + { + type: FLOOD_HISTORY_ROW_SELECT_MESSAGE, + row, + openDetail: false, + }, + window.location.origin, + ); + }; + + return ( +
+
+

침수흔적도 목록

+
+
+ +
+
+
+ + + + + + + + +
+ + +
+
+
+ +
+
+

+ 침수흔적도 조회 결과 {filteredRows.length.toLocaleString("ko-KR")}건 +

+
+ +
+ + + {GRID_COLUMNS.map((head) => ( + + ))} + + + + NO + 시도 + 시군구 + 강우 분석정보 + 침수재해명 + 침수년도 + 침수시간 + 침수지구명 + 침수원인상세코드 + 침수원인상세 + 평균침수위 + 침수면적 + 조사년도 + 피해금액 + + + 침수시작일자 + 침수종료일자 + 침수시작시간 + 침수종료시간 + + + + {pagedRows.length > 0 ? ( + pagedRows.map((row) => { + const isSelected = selectedHistoryId === row.historyId; + + return ( + handleRowClick(row)} + onDoubleClick={() => handleRowDoubleClick(row)} + > + {row.rowNo} + {row.ctpvNm} + {row.sggNm} + {row.startDateDisplay} + {row.endDateDisplay} + {row.startTimeDisplay} + {row.endTimeDisplay} + {row.disasterName} + {row.floodYearDisplay} + {row.floodHourDisplay} + {row.districtNameDisplay} + {row.causeCodeDisplay} + {row.causeDetailDisplay} + {row.avgFloodDepthDisplay} + {row.floodAreaDisplay} + {row.exmnYearDisplay} + {row.damageAmountDisplay} + + ); + }) + ) : ( + + + 조회된 데이터가 없습니다. + + + )} + +
+
+ + {filteredRows.length > 0 ? ( + + ) : null} + +
+ 행 클릭 시 메인 지도에 미리 표시되고, 더블클릭하면 팝업을 닫습니다. 상세정보는 지도 위 침수흔적 영역을 클릭해 확인합니다. +
+
+
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts b/apps/sn/app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts new file mode 100644 index 000000000..fd6ef484e --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts @@ -0,0 +1,458 @@ +"use client"; + +export const FLOOD_HISTORY_POPUP_KEY = "floodHistoryPopup"; +export const FLOOD_HISTORY_ROW_SELECT_MESSAGE = "SN_FLOOD_HISTORY_ROW_SELECT"; +export const FLOOD_HISTORY_LAYER_TYPE = "tb_sn_flood_trace_mock"; +export const FLOOD_HISTORY_LAYER_NAME = "침수흔적도"; +export const FLOOD_HISTORY_AREA_LAYER_ID = "SN_FLOOD_HISTORY_AREA"; +export const FLOOD_HISTORY_DEPTH_LAYER_ID = "SN_FLOOD_HISTORY_DEPTH"; +export const FLOOD_HISTORY_PAGE_SIZE = 10; + +export type FloodHistoryFilters = { + ctpvCd: string; + sggCd: string; + floodYear: string; + disasterName: string; +}; + +export type FloodHistoryDepthZone = { + zoneId: string; + depthLabel: string; + color: string; + ring: [number, number][]; +}; + +export type FloodHistoryItem = { + historyId: string; + ctpvNm: string; + sggNm: string; + floodYear: string; + exmnYear: string; + disasterName: string; + districtName: string; + floodAreaHa: number; + avgFloodDepthM: number; + floodDepthM: number; + gradeLabel: string; + startDate: string; + endDate: string; + startTime: string; + endTime: string; + floodHour: string; + causeDetailCode: string; + causeDetailName: string; + damageAmount: string; + rainStartTime: string; + rainEndTime: string; + totalRainfallMm: number; + rainfallIntensityMmHr: number; + areaRing: [number, number][]; + depthZones: FloodHistoryDepthZone[]; +}; + +export type FloodHistoryTableRow = FloodHistoryItem & { + rowNo: number; + exmnYearDisplay: string; + avgFloodDepthDisplay: string; + floodAreaDisplay: string; + causeDetailDisplay: string; + startDateDisplay: string; + endDateDisplay: string; + startTimeDisplay: string; + endTimeDisplay: string; + causeCodeDisplay: string; + districtNameDisplay: string; + damageAmountDisplay: string; + floodHourDisplay: string; + floodYearDisplay: string; +}; + +export const FLOOD_HISTORY_DEFAULT_FILTERS: FloodHistoryFilters = { + ctpvCd: "0", + sggCd: "0", + floodYear: "", + disasterName: "", +}; + +export const FLOOD_HISTORY_DOWNLOAD_HEADERS: Record = { + rowNo: "NO", + ctpvNm: "시도", + sggNm: "시군구", + disasterName: "침수재해명", + exmnYearDisplay: "조사년도", + avgFloodDepthDisplay: "평균침수위", + floodAreaDisplay: "침수면적", + causeDetailDisplay: "침수원인상세", + startDateDisplay: "침수시작일자", + endDateDisplay: "침수종료일자", + startTimeDisplay: "침수시작시간", + endTimeDisplay: "침수종료시간", + causeCodeDisplay: "침수원인상세코드", + districtNameDisplay: "침수지구명", + damageAmountDisplay: "피해금액", + floodHourDisplay: "침수시간", + floodYearDisplay: "침수년도", +}; + +export const FLOOD_HISTORY_DEPTH_LEGEND_ITEMS = [ + { color: "#fff27a", label: "0.0~0.5m" }, + { color: "#c2f13d", label: "0.5~1.0m" }, + { color: "#67d8ff", label: "1.0~2.0m" }, + { color: "#8f8bff", label: "2.0~5.0m" }, + { color: "#f2528b", label: "5.0m 이상" }, +] as const; + +const toRadian = (degree: number) => (degree * Math.PI) / 180; + +const createIrregularRing = ({ + centerX, + centerY, + radiusX, + radiusY, + scales, + rotationDeg = 0, +}: { + centerX: number; + centerY: number; + radiusX: number; + radiusY: number; + scales: number[]; + rotationDeg?: number; +}): [number, number][] => { + const rotation = toRadian(rotationDeg); + const points = scales.map((scale, index) => { + const angle = rotation + (Math.PI * 2 * index) / scales.length; + const jitterX = radiusX * scale * Math.cos(angle); + const jitterY = radiusY * scale * Math.sin(angle); + return [centerX + jitterX, centerY + jitterY] as [number, number]; + }); + + return [...points, points[0]]; +}; + +const createDepthZones = ( + historyId: string, + centerX: number, + centerY: number, +): FloodHistoryDepthZone[] => { + return [ + { + zoneId: `${historyId}-depth-1`, + depthLabel: "0.0~0.5m", + color: "#fff27a", + ring: createIrregularRing({ + centerX: centerX - 5200, + centerY: centerY - 2600, + radiusX: 3600, + radiusY: 2900, + rotationDeg: -18, + scales: [1.02, 0.78, 0.9, 1.08, 0.86, 0.72, 0.94, 1.04], + }), + }, + { + zoneId: `${historyId}-depth-2`, + depthLabel: "0.5~1.0m", + color: "#c2f13d", + ring: createIrregularRing({ + centerX: centerX + 2600, + centerY: centerY - 1200, + radiusX: 4300, + radiusY: 3600, + rotationDeg: 12, + scales: [1.04, 0.88, 1.02, 0.8, 0.92, 1.08, 0.84, 0.95], + }), + }, + { + zoneId: `${historyId}-depth-3`, + depthLabel: "1.0~2.0m", + color: "#67d8ff", + ring: createIrregularRing({ + centerX: centerX - 600, + centerY: centerY + 2600, + radiusX: 5600, + radiusY: 4700, + rotationDeg: 8, + scales: [1.12, 0.86, 0.94, 1.06, 0.82, 0.97, 1.04, 0.9, 1.08, 0.84], + }), + }, + { + zoneId: `${historyId}-depth-4`, + depthLabel: "2.0~5.0m", + color: "#8f8bff", + ring: createIrregularRing({ + centerX: centerX + 4200, + centerY: centerY + 5200, + radiusX: 3200, + radiusY: 2500, + rotationDeg: 26, + scales: [1.05, 0.82, 1.08, 0.88, 0.96, 0.78, 1.02, 0.9], + }), + }, + { + zoneId: `${historyId}-depth-5`, + depthLabel: "5.0m 이상", + color: "#f2528b", + ring: createIrregularRing({ + centerX: centerX + 600, + centerY: centerY - 5200, + radiusX: 1500, + radiusY: 1350, + rotationDeg: -10, + scales: [1.0, 0.8, 1.06, 0.84, 0.94, 0.76, 0.98], + }), + }, + ]; +}; + +const createHistory = ( + item: Omit & { + centerX: number; + centerY: number; + width: number; + height: number; + }, +): FloodHistoryItem => { + const { centerX, centerY, width, height, ...detail } = item; + + return { + ...detail, + areaRing: createIrregularRing({ + centerX, + centerY, + radiusX: width / 2, + radiusY: height / 2, + rotationDeg: 9, + scales: [1.08, 0.9, 1.04, 0.82, 0.98, 1.12, 0.88, 0.96, 1.03, 0.86], + }), + depthZones: createDepthZones(detail.historyId, centerX, centerY), + }; +}; + +export const FLOOD_HISTORY_ITEMS: FloodHistoryItem[] = [ + createHistory({ + historyId: "FH-2024-001", + ctpvNm: "부산광역시", + sggNm: "중구", + floodYear: "2024", + exmnYear: "2025", + disasterName: "중앙동 저지대 침수", + districtName: "중앙동 저지대", + floodAreaHa: 1.8, + avgFloodDepthM: 0.62, + floodDepthM: 0.62, + gradeLabel: "1등급", + startDate: "2024-07-18", + endDate: "2024-07-18", + startTime: "14:10", + endTime: "18:20", + floodHour: "4.2시간", + causeDetailCode: "FC-01", + causeDetailName: "집중강우", + damageAmount: "120,000,000원", + rainStartTime: "13:50", + rainEndTime: "18:20", + totalRainfallMm: 91, + rainfallIntensityMmHr: 31, + centerX: 1122000, + centerY: 1705000, + width: 18000, + height: 14000, + }), + createHistory({ + historyId: "FH-2023-002", + ctpvNm: "경상남도", + sggNm: "창원시", + floodYear: "2023", + exmnYear: "2024", + disasterName: "팔용동 공단 배수지연 침수", + districtName: "팔용동 공단", + floodAreaHa: 4.8, + avgFloodDepthM: 1.92, + floodDepthM: 1.92, + gradeLabel: "2등급", + startDate: "2023-08-11", + endDate: "2023-08-11", + startTime: "09:40", + endTime: "15:10", + floodHour: "5.5시간", + causeDetailCode: "FC-04", + causeDetailName: "배수시설 처리능력 초과", + damageAmount: "340,000,000원", + rainStartTime: "08:55", + rainEndTime: "15:05", + totalRainfallMm: 138, + rainfallIntensityMmHr: 42, + centerX: 1081000, + centerY: 1778000, + width: 25000, + height: 17000, + }), + createHistory({ + historyId: "FH-2022-003", + ctpvNm: "울산광역시", + sggNm: "남구", + floodYear: "2022", + exmnYear: "2023", + disasterName: "태화강 제내지 도로 침수", + districtName: "여천동 하류부", + floodAreaHa: 3.1, + avgFloodDepthM: 1.15, + floodDepthM: 1.15, + gradeLabel: "2등급", + startDate: "2022-09-06", + endDate: "2022-09-06", + startTime: "07:20", + endTime: "12:40", + floodHour: "5.3시간", + causeDetailCode: "FC-02", + causeDetailName: "하천 수위 상승", + damageAmount: "210,000,000원", + rainStartTime: "06:30", + rainEndTime: "11:50", + totalRainfallMm: 117, + rainfallIntensityMmHr: 37, + centerX: 1168000, + centerY: 1811000, + width: 22000, + height: 16000, + }), + createHistory({ + historyId: "FH-2021-004", + ctpvNm: "부산광역시", + sggNm: "서구", + floodYear: "2021", + exmnYear: "2022", + disasterName: "서대신동 급경사지 유출 침수", + districtName: "서대신동 일원", + floodAreaHa: 2.4, + avgFloodDepthM: 0.85, + floodDepthM: 0.85, + gradeLabel: "1등급", + startDate: "2021-07-07", + endDate: "2021-07-07", + startTime: "16:00", + endTime: "20:10", + floodHour: "4.1시간", + causeDetailCode: "FC-03", + causeDetailName: "우수관로 월류", + damageAmount: "175,000,000원", + rainStartTime: "15:20", + rainEndTime: "19:50", + totalRainfallMm: 104, + rainfallIntensityMmHr: 29, + centerX: 1111000, + centerY: 1709000, + width: 20000, + height: 14500, + }), + createHistory({ + historyId: "FH-2020-005", + ctpvNm: "부산광역시", + sggNm: "영도구", + floodYear: "2020", + exmnYear: "2021", + disasterName: "청학동 해안도로 월파 침수", + districtName: "청학동 해안부", + floodAreaHa: 1.2, + avgFloodDepthM: 0.48, + floodDepthM: 0.48, + gradeLabel: "1등급", + startDate: "2020-10-22", + endDate: "2020-10-22", + startTime: "03:10", + endTime: "06:30", + floodHour: "3.3시간", + causeDetailCode: "FC-06", + causeDetailName: "해안 월파", + damageAmount: "82,000,000원", + rainStartTime: "02:40", + rainEndTime: "05:50", + totalRainfallMm: 55, + rainfallIntensityMmHr: 18, + centerX: 1129000, + centerY: 1695000, + width: 15000, + height: 12000, + }), + createHistory({ + historyId: "FH-2019-006", + ctpvNm: "경상남도", + sggNm: "김해시", + floodYear: "2019", + exmnYear: "2020", + disasterName: "장유 배수분구 내수 침수", + districtName: "장유 배수분구", + floodAreaHa: 2.9, + avgFloodDepthM: 0.96, + floodDepthM: 0.96, + gradeLabel: "1등급", + startDate: "2019-09-21", + endDate: "2019-09-21", + startTime: "11:30", + endTime: "16:15", + floodHour: "4.8시간", + causeDetailCode: "FC-05", + causeDetailName: "배수펌프 지연 가동", + damageAmount: "143,000,000원", + rainStartTime: "10:45", + rainEndTime: "15:50", + totalRainfallMm: 88, + rainfallIntensityMmHr: 24, + centerX: 1098000, + centerY: 1734000, + width: 21000, + height: 15000, + }), +]; + +export const normalizeFloodHistoryFilters = ( + filters: FloodHistoryFilters, +): FloodHistoryFilters => ({ + ctpvCd: String(filters.ctpvCd ?? "0") || "0", + sggCd: String(filters.sggCd ?? "0") || "0", + floodYear: String(filters.floodYear ?? "").replace(/[^0-9]/g, "").slice(0, 4), + disasterName: String(filters.disasterName ?? "").trim(), +}); + +export const formatFloodArea = (value: number) => `${value.toFixed(1)}ha`; + +export const formatFloodDepth = (value: number) => `${value.toFixed(2)}m`; + +export const buildFloodHistoryTableRows = ( + items: FloodHistoryItem[], + page: number, + pageSize = FLOOD_HISTORY_PAGE_SIZE, +): FloodHistoryTableRow[] => + items.map((item, index) => ({ + ...item, + rowNo: (page - 1) * pageSize + index + 1, + exmnYearDisplay: `${item.exmnYear}년`, + avgFloodDepthDisplay: formatFloodDepth(item.avgFloodDepthM), + floodAreaDisplay: formatFloodArea(item.floodAreaHa), + causeDetailDisplay: item.causeDetailName, + startDateDisplay: item.startDate, + endDateDisplay: item.endDate, + startTimeDisplay: item.startTime, + endTimeDisplay: item.endTime, + causeCodeDisplay: item.causeDetailCode, + districtNameDisplay: item.districtName, + damageAmountDisplay: item.damageAmount, + floodHourDisplay: item.floodHour, + floodYearDisplay: `${item.floodYear}년`, + })); + +export const buildFloodHistoryYearOptions = (items: FloodHistoryItem[]) => + Array.from(new Set(items.map((item) => item.floodYear))) + .sort((left, right) => Number(right) - Number(left)); + +const resolveFloodHistoryNodeName = (node: any) => + String(node?.text ?? node?.data?.lyrNm ?? node?.data?.name ?? node?.lyrNm ?? node?.name ?? "").trim(); + +const resolveFloodHistoryLayerType = (node: any) => + String(node?.data?.lyrTyNm ?? node?.data?.lyrTypeNm ?? node?.lyrTyNm ?? node?.lyrTypeNm ?? "").trim(); + +export const isFloodHistoryNode = (node: any) => { + const name = resolveFloodHistoryNodeName(node); + const layerType = resolveFloodHistoryLayerType(node); + return name === FLOOD_HISTORY_LAYER_NAME || layerType === FLOOD_HISTORY_LAYER_TYPE; +}; diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 7bdddc77f..95f1cbb8a 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -80,6 +80,7 @@ import { import { downloadAgedPipeCsv } from "./components/agedPipe/agedPipeExport"; import SinkholeDetailPanel from "./components/sinkhole/SinkholeDetailPanel"; import SinkholeLayerSettingPanel from "./components/sinkhole/SinkholeLayerSettingPanel"; +import FloodHistoryDetailPanel from "./components/floodHistory/FloodHistoryDetailPanel"; import { applySinkholeNodeFilters, applySinkholeYearFilters, @@ -109,6 +110,15 @@ import { type SinkholeNearbyPipe, type SinkholeSyncSummary, } from "./components/sinkhole/sinkholeTypes"; +import { + FLOOD_HISTORY_AREA_LAYER_ID, + FLOOD_HISTORY_DEPTH_LAYER_ID, + FLOOD_HISTORY_LAYER_TYPE, + FLOOD_HISTORY_ROW_SELECT_MESSAGE, + FLOOD_HISTORY_POPUP_KEY, + isFloodHistoryNode, + type FloodHistoryItem, +} from "./components/floodHistory/floodHistoryTypes"; import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup"; import SearchModal from "./searchModal/searchModal"; import { WATER_DEPTH_LAYER_TYPES, WATER_DEPTH_LEGEND_ITEMS } from "./waterDepth/waterDepth"; @@ -157,6 +167,7 @@ const GIS_EDIT_VERTEX_LAYER_ID = "SN_GIS_EDIT_VERTEX_HANDLES"; const WATER_RISK_AREA_LAYER_ID = "SN_WATER_RISK_AREA"; const WATER_RISK_GRID_LAYER_ID = "SN_WATER_RISK_GRID"; const WATER_RISK_PIPE_LAYER_ID = "SN_WATER_RISK_PIPE"; +const FLOOD_HISTORY_SELECTION_PADDING: [number, number, number, number] = [100, 380, 120, 120]; type FlowDirectionUsageStyle = { color: string; @@ -727,6 +738,44 @@ const getSelectedFeatureLayerTypeName = (data: any) => { return String(data?.lyrTypeNm ?? data?.values_?.lyrTypeNm ?? "").trim(); }; +const createFloodHistoryAreaStyle = (label: string) => + new Style({ + fill: new Fill({ + color: "rgba(30, 136, 229, 0.16)", + }), + stroke: new Stroke({ + color: "rgba(30, 136, 229, 0.95)", + width: 3, + }), + text: new Text({ + text: label, + font: "700 12px sans-serif", + fill: new Fill({ color: "#ffffff" }), + backgroundFill: new Fill({ color: "rgba(15, 23, 42, 0.88)" }), + padding: [4, 7, 4, 7], + overflow: true, + }), + zIndex: 9994, + }); + +const createFloodHistoryDepthStyle = (label: string, color: string) => + new Style({ + fill: new Fill({ + color, + }), + stroke: new Stroke({ + color: "rgba(71, 85, 105, 0.72)", + width: 1.4, + }), + text: new Text({ + text: label, + font: "700 11px sans-serif", + fill: new Fill({ color: "#0f172a" }), + overflow: true, + }), + zIndex: 9993, + }); + const normalizeGisEditFid = (value: unknown) => { const raw = String(value ?? "").trim(); if (!raw) return ""; @@ -2242,6 +2291,7 @@ const Map = () => { const [mapReady, setMapReady] = useState(false); const [isAgedPipeChecked, setIsAgedPipeChecked] = useState(false); const [isSinkholeChecked, setIsSinkholeChecked] = useState(false); + const [isFloodHistoryChecked, setIsFloodHistoryChecked] = useState(false); const [draftAgedPipeFilters, setDraftAgedPipeFilters] = useState(AGED_PIPE_DEFAULT_FILTERS); const [appliedAgedPipeFilters, setAppliedAgedPipeFilters] = @@ -2251,6 +2301,8 @@ const Map = () => { ); const [isSinkholePopupFilterActive, setIsSinkholePopupFilterActive] = useState(false); const [sinkholeRadiusMeters, setSinkholeRadiusMeters] = useState(SINKHOLE_DEFAULT_RADIUS); + const [selectedFloodHistoryDetail, setSelectedFloodHistoryDetail] = useState(null); + const [floodHistoryDetailPopupPosition, setFloodHistoryDetailPopupPosition] = useState<{ left: number; top: number } | null>(null); const [selectedSinkholeDetail, setSelectedSinkholeDetail] = useState(null); const [selectedSinkholeNearbyPipes, setSelectedSinkholeNearbyPipes] = useState([]); const [lastSinkholeSyncSummary, setLastSinkholeSyncSummary] = useState(null); @@ -2269,6 +2321,9 @@ const Map = () => { const shouldFocusAgedPipeOnNextSelectRef = useRef(false); const agedPipePopupWindowRef = useRef(null); const sinkholePopupWindowRef = useRef(null); + // 침수발생이력 overlay layer는 지도 드래그 중에도 유지돼야 하므로 React state 대신 ref로 보관한다. + const floodHistoryAreaLayerRef = useRef | null>(null); + const floodHistoryDepthLayerRef = useRef | null>(null); const sinkholePopupCloseWatcherRef = useRef(null); const pipeAnalysisPopupWindowRef = useRef(null); const gisEditSessionPopupWindowRef = useRef(null); @@ -3857,6 +3912,121 @@ const Map = () => { }); }, []); + const ensureFloodHistoryAreaSource = useCallback(() => { + if (!mapReady || !gisMaps.current) return null; + + if (!floodHistoryAreaLayerRef.current) { + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + zIndex: 9993, + }); + layer.set("id", FLOOD_HISTORY_AREA_LAYER_ID); + layer.set("lyrNm", "침수흔적도"); + layer.set("lyrTyNm", FLOOD_HISTORY_LAYER_TYPE); + layer.set("clickPsblYn", "N"); + gisMaps.current.addLayer(layer); + floodHistoryAreaLayerRef.current = layer; + } + + return floodHistoryAreaLayerRef.current.getSource(); + }, [mapReady]); + + const ensureFloodHistoryDepthSource = useCallback(() => { + if (!mapReady || !gisMaps.current) return null; + + if (!floodHistoryDepthLayerRef.current) { + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + zIndex: 9992, + }); + layer.set("id", FLOOD_HISTORY_DEPTH_LAYER_ID); + layer.set("lyrNm", "침수흔적도"); + layer.set("lyrTyNm", FLOOD_HISTORY_LAYER_TYPE); + layer.set("clickPsblYn", "N"); + gisMaps.current.addLayer(layer); + floodHistoryDepthLayerRef.current = layer; + } + + return floodHistoryDepthLayerRef.current.getSource(); + }, [mapReady]); + + const clearFloodHistoryLayers = useCallback(() => { + floodHistoryAreaLayerRef.current?.getSource?.()?.clear?.(); + floodHistoryDepthLayerRef.current?.getSource?.()?.clear?.(); + }, []); + + const renderSelectedFloodHistory = useCallback( + (detail: FloodHistoryItem | null) => { + const areaSource = ensureFloodHistoryAreaSource(); + const depthSource = ensureFloodHistoryDepthSource(); + if (!areaSource || !depthSource) return; + + areaSource.clear(); + depthSource.clear(); + + if (!detail) return; + + const areaFeature = new Feature({ + geometry: new Polygon([detail.areaRing]), + }); + areaFeature.setStyle(createFloodHistoryAreaStyle(`${detail.floodYear}년 ${detail.districtName}`)); + areaSource.addFeature(areaFeature); + + detail.depthZones.forEach((zone) => { + const zoneFeature = new Feature({ + geometry: new Polygon([zone.ring]), + }); + zoneFeature.setStyle(createFloodHistoryDepthStyle(zone.depthLabel, zone.color)); + depthSource.addFeature(zoneFeature); + }); + }, + [ensureFloodHistoryAreaSource, ensureFloodHistoryDepthSource], + ); + + const focusFloodHistoryOnMap = useCallback((detail: FloodHistoryItem) => { + if (!gisMaps.current) return; + + const geometry = new Polygon([detail.areaRing]); + gisMaps.current.getView?.().fit?.(geometry.getExtent(), { + duration: 350, + padding: FLOOD_HISTORY_SELECTION_PADDING, + maxZoom: 15, + constrainResolution: true, + nearest: true, + }); + }, []); + + const clearFloodHistorySelection = useCallback(() => { + setSelectedFloodHistoryDetail(null); + setFloodHistoryDetailPopupPosition(null); + clearFloodHistoryLayers(); + }, [clearFloodHistoryLayers]); + + const closeFloodHistoryDetail = useCallback(() => { + setFloodHistoryDetailPopupPosition(null); + }, []); + + const handleFloodHistoryRowSelect = useCallback( + (detail: FloodHistoryItem) => { + setSelectedFloodHistoryDetail(detail); + renderSelectedFloodHistory(detail); + focusFloodHistoryOnMap(detail); + setIsSearchModalOpen(false); + }, + [focusFloodHistoryOnMap, renderSelectedFloodHistory, setIsSearchModalOpen], + ); + + const openFloodHistoryPopupWindow = useCallback(() => { + const popup = openWindowPopup(FLOOD_HISTORY_POPUP_KEY as WindowPopupKey); + if (!popup) { + alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); + return null; + } + return popup; + }, []); + const buildSinkholeQueryString = useCallback( (filters: SinkholeFilters, page = 0, size = SINKHOLE_DEFAULT_POINT_LIMIT, checkedNodesData = checkedNodesDataRef.current) => buildSinkholeListQueryString(applySinkholeYearFilters(filters, checkedNodesData), ctpv, sgg, page, size), @@ -4329,6 +4499,18 @@ const Map = () => { return () => window.removeEventListener("message", handleSinkholePopupMessage); }, [loadSinkholeDetail, sinkholeScope]); + useEffect(() => { + const handleFloodHistoryPopupMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== FLOOD_HISTORY_ROW_SELECT_MESSAGE || !event.data?.row) return; + + handleFloodHistoryRowSelect(event.data.row as FloodHistoryItem); + }; + + window.addEventListener("message", handleFloodHistoryPopupMessage); + return () => window.removeEventListener("message", handleFloodHistoryPopupMessage); + }, [handleFloodHistoryRowSelect]); + // WaterDepth 표시 여부를 결정하는 함수 const handleWaterDepthVisibility = useCallback((checkedIds: string[], checkedNodesData: any[]) => { const waterDepthLayers = checkedNodesData.filter(node => @@ -4378,6 +4560,11 @@ const Map = () => { handleWaterDepthVisibility(checkedIds, checkedNodesData); setIsAgedPipeChecked(checkedNodesData.some((node) => isAgedPipeLayerNode(node))); + const floodHistoryChecked = checkedNodesData.some((node) => isFloodHistoryNode(node)); + setIsFloodHistoryChecked(floodHistoryChecked); + if (!floodHistoryChecked) { + clearFloodHistorySelection(); + } const sinkholeChecked = checkedNodesData.some((node) => isSinkholeLayerNode(node)); setIsSinkholeChecked(sinkholeChecked); if (sinkholeChecked) { @@ -4387,7 +4574,7 @@ const Map = () => { removeSinkholeLayer(); closeSinkholeDetail(); } - }, [closeSinkholeDetail, handleWaterDepthVisibility, removeFlowDirectionLayer, removeSinkholeLayer, retainFlowDirectionUsageCodes]); + }, [clearFloodHistorySelection, closeSinkholeDetail, handleWaterDepthVisibility, removeFlowDirectionLayer, removeSinkholeLayer, retainFlowDirectionUsageCodes]); const handleOpenLayerPopup = useCallback( (_layerNm: string, node?: any) => { @@ -4399,13 +4586,17 @@ const Map = () => { openSinkholePopupWindow(); return false; } + if (isFloodHistoryNode(node)) { + openFloodHistoryPopupWindow(); + return false; + } if (isSinkholeYearNode(node)) { return false; } return true; }, - [openAgedPipeList, openSinkholePopupWindow], + [openAgedPipeList, openFloodHistoryPopupWindow, openSinkholePopupWindow], ); // 현재 체크된 특수 레이어만 하단 설정영역에 모아 렌더링하기 위한 패널 목록이다. @@ -5191,9 +5382,11 @@ const Map = () => { ); }, [ closeSinkholeDetail, + closeFloodHistoryDetail, clearSinkholeFocusedPipeLayer, focusSinkholeNearbyPipe, handleApplyGisEditPipeBasicInfo, + selectedFloodHistoryDetail, selectedSinkholeDetail, selectedSinkholeNearbyPipes, setIsSearchModalOpen, @@ -6211,6 +6404,58 @@ const Map = () => { renderSelectedSinkhole(selectedSinkholeDetail, sinkholeRadiusMeters); }, [renderSelectedSinkhole, selectedSinkholeDetail, sinkholeRadiusMeters]); + useEffect(() => { + renderSelectedFloodHistory(selectedFloodHistoryDetail); + }, [renderSelectedFloodHistory, selectedFloodHistoryDetail]); + + useEffect(() => { + if (!mapReady || !gisMaps.current) return; + + const map = gisMaps.current; + const clickKey = map.on("singleclick", (event: any) => { + if (!selectedFloodHistoryDetail || !isFloodHistoryChecked) return; + + let clickedFloodHistory = false; + map.forEachFeatureAtPixel( + event.pixel, + (feature: any, layer: any) => { + if ( + layer === floodHistoryAreaLayerRef.current || + layer === floodHistoryDepthLayerRef.current + ) { + clickedFloodHistory = true; + return feature; + } + return undefined; + }, + { hitTolerance: 4 }, + ); + + if (!clickedFloodHistory) return; + + const size = map.getSize?.() ?? [1200, 800]; + const popupWidth = 220; + const popupHeight = 210; + const nextLeft = Math.min(Math.max(event.pixel[0] + 12, 12), size[0] - popupWidth - 12); + const nextTop = Math.min(Math.max(event.pixel[1] - 12, 12), size[1] - popupHeight - 12); + setFloodHistoryDetailPopupPosition({ + left: nextLeft, + top: nextTop, + }); + setIsSearchModalOpen(false); + }); + + return () => { + unByKey(clickKey); + }; + }, [isFloodHistoryChecked, mapReady, selectedFloodHistoryDetail, setIsSearchModalOpen]); + + useEffect(() => { + return () => { + clearFloodHistoryLayers(); + }; + }, [clearFloodHistoryLayers]); + return ( <> @@ -6337,6 +6582,13 @@ const Map = () => {
) : null} + {selectedFloodHistoryDetail && floodHistoryDetailPopupPosition ? ( + + ) : null} setIsSearchModalOpen(true)} @@ -6397,7 +6649,9 @@ const Map = () => { showFloatingSearchButton={false} treeTogglePosition="center" zoomGuide={zoomGuide} - layerIconVisible={(node: any) => isAgedPipeLayerNode(node) || isSinkholeRootNode(node)} + layerIconVisible={(node: any) => + isAgedPipeLayerNode(node) || isSinkholeRootNode(node) || isFloodHistoryNode(node) + } defaultZoom={7.5} defaultLonLat={[129, 36.3]} diff --git a/apps/sn/docs/작업검증/SFR-011-009-침수발생이력/001-qa-SFR-011-009-침수발생이력-통합검증계획.md b/apps/sn/docs/작업검증/SFR-011-009-침수발생이력/001-qa-SFR-011-009-침수발생이력-통합검증계획.md new file mode 100644 index 000000000..e80066fa9 --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-011-009-침수발생이력/001-qa-SFR-011-009-침수발생이력-통합검증계획.md @@ -0,0 +1,79 @@ +# SFR-011-009 침수발생이력 통합 검증 계획 + +## 1. 문서 목적 + +- `SFR-011-009 침수발생이력`의 FE 검증 계획을 기능당 1개 문서로 누적 관리한다. +- 현재 문서는 docs-first 설계 완료 직후 기준으로, FE-only 범위 검증 항목과 후속 통합 검증 항목을 분리한다. + +## 2. 검증 기준 요약 + +- 레이어 트리 `침수흔적도` 목록 popup이 정상적으로 열린다. +- `침수흔적도` on/off가 지도 표시와 맞게 동작한다. +- 조회조건 유효성 검증이 동작한다. +- `침수발생년도` 선택이 동작한다. +- 목록이 조건에 따라 필터링된다. +- 목록 다운로드가 현재 조회 결과 기준으로 동작한다. +- 행 클릭 시 메인 지도 preview가 뜬다. +- 지도 위 큰 침수흔적 레이어 클릭 시 작은 상세 팝업이 열린다. + +## 3. FE-only 검증 계획 + +### 3.1 popup 진입 + +- 레이어 트리 `침수흔적도` 체크 +- 레이어 트리 `침수흔적도`의 목록 버튼 클릭 +- popup open 여부 +- popup 제목, 조회조건 영역, 목록 그리드 노출 여부 + +### 3.2 조회조건 검증 + +- 시도 미선택 상태에서 시군구 선택 후 조회 시 차단되는지 확인 +- 침수발생년도 선택 시 목록 필터가 반영되는지 확인 +- 침수재해명 키워드 입력 시 목록 필터가 적용되는지 확인 + +### 3.3 목록/다운로드 + +- 조회 결과 총 건수 표시 +- `강우 분석정보` 묶음 헤더와 하위 4개 컬럼 표시 +- 나머지 상세 필드가 가로 스크롤로 모두 노출되는지 확인 +- 10건 초과 시 페이지네이션 노출 +- 다운로드 버튼 클릭 시 현재 필터링 결과 기준 파일이 생성되는지 확인 +- 결과 없음 상태에서 다운로드가 차단되는지 확인 + +### 3.4 지도 연동 + +- 행 클릭 시 지도 preview 표시 여부 +- 행 더블클릭 시 popup close 여부 +- 메인 지도 영역으로 fit 이동되는지 확인 +- `침수흔적도` 체크 시 mock polygon과 침수심 overlay가 함께 표시되는지 확인 +- `침수흔적도`를 끄면 관련 표시가 함께 숨겨지는지 확인 +- 큰 침수흔적 polygon 클릭 시 상세 팝업이 뜨는지 확인 + +### 3.5 지도 상세 팝업 + +- `침수년도탭`, `재해명탭`, `침수발생강우탭` 전환 +- 기술 컬럼 제외 후 한글 업무 항목만 보이는지 확인 +- 각 탭의 테이블 구조와 값 표시 +- 닫기 시 팝업만 닫히는지 확인 + +## 4. BE 연동 후 검증 계획 + +- 실제 목록 조회 API 필드 매핑 +- 실제 geometry 및 지도 위치 정합성 +- 지도 click 상세조회 +- 실제 레이어 트리 `침수흔적도` 토글 연계 +- 다운로드 권한 및 파일 포맷 검증 + +## 5. 차단 사유 분류 기준 + +- `기능 결함` +- `인증 차단` +- `환경 차단` +- `데이터 차단` +- `자동화 한계` + +## 6. 현재 상태 + +- 공통 설계서 작성 완료 +- FE 작업카드 작성 완료 +- 구현은 진행 중이며, 검증은 FE-only 기준으로 먼저 수행한다. diff --git a/apps/sn/docs/작업검증/SFR-011-009-침수발생이력/002-qa-SFR-011-009-침수발생이력-통합검증결과.md b/apps/sn/docs/작업검증/SFR-011-009-침수발생이력/002-qa-SFR-011-009-침수발생이력-통합검증결과.md new file mode 100644 index 000000000..ca7d05fd6 --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-011-009-침수발생이력/002-qa-SFR-011-009-침수발생이력-통합검증결과.md @@ -0,0 +1,52 @@ +# SFR-011-009 침수발생이력 통합 검증 결과 + +## 1. 문서 목적 + +- `SFR-011-009 침수발생이력`의 현재 검증 결과와 남은 수동 확인 항목을 누적 관리한다. +- 현재 문서는 FE draft 구현 직후 기준의 로컬 응답 확인과 자동화 한계 항목을 기록한다. + +## 2. 이번 라운드 결과 요약 + +- 상태: 부분 확인 +- 확인 완료 + - `localhost:3002` 기본 응답 200 확인 + - `localhost:3002/map` 응답 200 확인 + - 공통 설계서, FE 작업카드, FE 검증계획 작성 완료 +- 미완료 + - popup 실제 진입 확인 + - 목록 조회/행 클릭 preview/메인 지도 플로팅 상세팝업 수동 확인 + - 다운로드 파일 수동 확인 + +## 3. 세부 결과 + +### 3.1 환경 확인 + +- `pnpm --filter sn run dev`는 샌드박스 내에서 `spawn EPERM`으로 실패했다. +- 권한 상승 후 로컬 서버를 열어 `http://127.0.0.1:3002`, `http://127.0.0.1:3002/map` 응답 200을 확인했다. + +### 3.2 자동/간접 확인 + +| 항목 | 결과 | 메모 | +| --- | --- | --- | +| 로컬 서버 기본 응답 | 통과 | `/` 200 | +| 지도 메인 경로 응답 | 통과 | `/map` 200 | +| popup 경로 직접 요청 | 보류 | 초기 컴파일 지연 또는 자동 요청 timeout 가능성 | +| 실제 popup 클릭 | 보류 | 사용자가 로컬 수동 검증 예정 | +| 행 클릭 preview | 보류 | 사용자가 로컬 수동 검증 예정 | +| 더블클릭 후 지도 이동 | 보류 | 사용자가 로컬 수동 검증 예정 | +| 지도 클릭 플로팅 팝업 | 보류 | 사용자가 로컬 수동 검증 예정 | + +## 4. 남은 수동 검증 항목 + +1. 레이어 트리 `침수흔적도` 목록 버튼으로 popup이 정상적으로 열리는지 확인 +2. 조회조건 입력과 목록 필터가 기대대로 동작하는지 확인 +3. 목록 행 클릭 시 메인 지도에 테스트 레이어가 보이는지 확인 +4. 목록 행 더블클릭 시 popup이 닫히고 메인 지도 이동이 유지되는지 확인 +5. 큰 침수흔적 polygon 클릭 시 작은 상세 팝업이 뜨는지 확인 +6. `침수흔적도` on/off에 따라 영역/침수심 overlay가 메인 지도에 맞게 보이는지 확인 +7. 상세 팝업 탭과 표 구성이 자연스러운지 확인 + +## 5. 결론 + +- FE draft 구현과 문서 선행 정리는 완료됐다. +- 최종 상태는 사용자 로컬 수동 검증 결과를 반영한 뒤 `검증 통과` 또는 `추가 보완 필요`로 갱신한다. diff --git a/apps/sn/docs/작업계획/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-작업카드.md b/apps/sn/docs/작업계획/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-작업카드.md new file mode 100644 index 000000000..574acdb4d --- /dev/null +++ b/apps/sn/docs/작업계획/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-작업카드.md @@ -0,0 +1,117 @@ +# SFR-011-009 침수발생이력 작업카드 + +## 1. 작업 개요 + +- 작업 ID: `SFR-011-009-FE` +- 작업명: 침수발생이력 프론트엔드 구현 +- 상태: 검증 +- 작성일: 2026-06-15 +- 담당 역할: FE +- 관련 역할: DE, QA +- 담당 범위: `apps/sn` 지도 화면에서 레이어 트리 `침수흔적도` 목록 popup, 목록 조회, 지도 preview, 지도 클릭 플로팅 상세 팝업, mock 침수심 overlay 구현 +- 근거 문서 + - `COMMON_DOCS_ROOT/작업계획/SFR-011-009-침수발생이력/001-de-SFR-011-009-침수발생이력-설계.md` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-009-침수발생이력/0.침수발생이력_요구사항명세서_v2.0.docx` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-009-침수발생이력/5.침수발생이력_화면시나리오_V2.0.md.docx` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-009-침수발생이력/침수발생이력 목록화면설계서.png` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-009-침수발생이력/침수발생이력 지도상세정보화면설계서.png` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-009-침수발생이력/침수발생이력_화면.html` + +## 1-1. 공통 동기화 확인 + +- 공통 작업현황판 확인 여부: 확인 +- 파일점유현황 확인 여부: 확인 +- 결정로그 확인 여부: 확인 +- 기존 진행 중 작업과의 관계: `map/page.tsx`, `windowPopup/**`는 다른 지도형 기능과 최근 수정 이력이 있어 충돌 주의가 필요하다. + +## 2. 작업 목적 + +- 레이어 트리 `침수흔적도` 목록 진입을 placeholder가 아닌 실제 popup UI로 연결한다. +- popup 조회 결과를 메인 지도와 연동해 사용자가 선택한 침수발생이력을 즉시 확인할 수 있게 한다. +- 설계 이미지의 `침수흔적도 on/off + 목록 popup + 지도 강조 + 지도 위 상세 팝업` 흐름을 현재 `sn` 지도형 구조 안에서 자연스럽게 재현한다. + +## 3. 수정 대상 파일 + +| 구분 | 파일 경로 | 수정 여부 | 비고 | +| --- | --- | --- | --- | +| 메인 지도 | `FE_APP_ROOT/app/(map-layout)/map/page.tsx` | 진행 | popup 선택 결과 수신, 지도 overlay, 우측 상세패널 연결 | +| popup 정의 | `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts` | 진행 | tree 목록 진입 popup 키/크기 정의 | +| popup 등록 | `FE_APP_ROOT/src/features/windowPopup/registry.tsx` | 진행 | placeholder에서 실제 popup content로 교체 | +| 분석도구 메뉴 | `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx` | 진행 | 분석도구에서 `침수발생이력` 제거 | +| popup content | `FE_APP_ROOT/src/features/windowPopup/ui/FloodHistoryPopupContent.tsx` | 진행 | popup 본문 연결 | +| 신규 popup 페이지 | `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx` | 진행 | 조회조건/목록/다운로드 | +| 신규 상세패널 | `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/FloodHistoryDetailPanel.tsx` | 진행 | 기본정보/발생일시/원인·피해 탭 | +| 신규 타입/샘플데이터 | `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts` | 진행 | mock 데이터, postMessage 타입, legend 정의 | + +## 4. 구현 범위 + +### 4.1 이번 라운드 포함 + +- 레이어 트리 `침수흔적도` 목록 popup 진입 +- 레이어 트리 `침수흔적도` on/off +- 시도/시군구/침수발생년도 선택/침수재해명 조회조건 +- mock 목록 조회/페이징 +- 목록 다운로드 +- 목록 행 클릭 preview + 더블클릭 후 메인 지도 이동 +- 메인 지도 mock polygon/침수심 overlay +- 지도 클릭 플로팅 상세 팝업 +- `강우 분석정보` 묶음 헤더 유지 + 상세 필드 가로 스크롤 그리드 + +### 4.2 이번 라운드 제외 + +- 실제 백엔드 API 연동 +- 실제 레이어 트리 토글 연계 +- 지도 click 상세조회 +- 실제 기준데이터 기반 GIS layer 렌더링 + +## 5. 화면 흐름 + +1. 사용자가 레이어 트리에서 `침수흔적도`를 체크한다. +2. 사용자가 `침수흔적도` 목록 버튼을 클릭한다. +3. popup이 열린다. +4. 사용자가 조회조건을 입력하고 `조회`를 클릭한다. +5. 목록이 popup 그리드에 표시된다. +6. 사용자가 행을 클릭하면 메인 지도에 테스트 레이어가 먼저 표시된다. +7. 사용자가 행을 더블클릭하면 popup이 닫히고 메인 지도는 해당 침수 이력 영역으로 이동한다. +8. `침수흔적도` 체크 상태에 따라 영역/침수심 표현이 함께 보인다. +9. 메인 지도 위 큰 침수흔적 영역을 클릭하면 작은 상세 팝업이 열린다. + +## 6. 구현 메모 + +- popup은 기존 `windowPopup` 체계를 그대로 사용하되, 분석도구가 아니라 레이어 트리 `침수흔적도` 목록 버튼으로 연다. +- 목록 선택 결과는 `window.opener.postMessage`로 메인 지도에 전달한다. +- popup의 `침수발생년도`는 같은 필터 줄 안의 선택 컴포넌트로 처리한다. +- 메인 지도는 `VectorLayer` 두 개를 사용해 + - 침수발생이력상세 영역 + - 침수심 표현 영역 + 을 임시 렌더링한다. +- 두 overlay는 `침수흔적도` 체크 상태를 따르도록 맞춘다. +- 상세정보는 우측 패널이 아니라 지도 위 플로팅 팝업으로 노출하고, 탭은 `침수년도탭`, `재해명탭`, `침수발생강우탭` 기준으로 보여준다. + +## 7. 검증 방법 + +- 레이어 트리 `침수흔적도` 목록 popup이 열린다. +- `침수흔적도` 체크 on/off에 따라 영역/침수심 overlay가 함께 바뀐다. +- 시도 없이 시군구를 선택하면 조회가 차단된다. +- 침수발생년도 선택이 동작한다. +- 조건 변경 시 목록 필터가 바뀐다. +- 행 클릭 시 지도 preview가 표시된다. +- 지도 위 큰 polygon 클릭 시 작은 상세 팝업이 열린다. +- 상세 팝업 탭 전환이 동작한다. + +## 8. 현재 메모 + +- 문서 우선 원칙에 맞춰 이번 라운드는 설계서/작업카드/검증계획을 먼저 정리한 뒤 구현을 이어간다. +- 현재 코드에는 일부 draft 구현이 이미 들어가 있으므로, 이후 단계에서 이 작업카드 기준으로 정합성을 다시 맞춘다. + +## 9. 롤백 방법 + +- 레이어 트리 목록 진입 연결 해제 +- `floodHistory` 신규 컴포넌트 삭제 +- `map/page.tsx`의 floodHistory overlay/상세패널 분기 제거 + +## 10. 남은 리스크 + +- 실제 API 필드명이 mock 데이터와 다를 수 있다. +- 실제 지도 레이어 기준데이터가 준비되면 현재 mock overlay 방식과 상태 관리가 달라질 수 있다. +- popup 상세정보를 우측 패널로 둘지 지도 내부 floating popup으로 둘지는 실제 사용성 피드백 후 재조정 가능성이 있다. diff --git a/apps/sn/docs/작업내용/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-통합구현기록.md b/apps/sn/docs/작업내용/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-통합구현기록.md new file mode 100644 index 000000000..db4e62129 --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-통합구현기록.md @@ -0,0 +1,73 @@ +# SFR-011-009 침수발생이력 통합 구현 기록 + +## 1. 문서 목적 + +- `SFR-011-009 침수발생이력`의 FE 구현 이력과 남은 리스크를 기능당 1개 문서로 누적 관리한다. +- 현재 문서는 docs-first 재정렬 이후의 1차 FE draft 구현 상태를 기록한다. + +## 2. 입력 근거 + +- `COMMON_DOCS_ROOT/작업계획/SFR-011-009-침수발생이력/001-de-SFR-011-009-침수발생이력-설계.md` +- `COMMON_DOCS_ROOT/작업내용/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-통합공유기록.md` +- `FE_APP_DOCS_ROOT/작업계획/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-작업카드.md` + +## 3. 구현 라운드 요약 + +### 3.1 2026-06-15 docs-first 재정렬 + +- 수행 내용 + - 작업지시 DOCX, 시나리오 DOCX, 설계 PNG, HTML 시안을 먼저 읽고 기능 범위를 재정리했다. + - 공통 설계서, FE 작업카드, FE 통합검증계획을 먼저 작성했다. + - 공통 작업현황판/파일점유현황에 `SFR-011-009-DE-FE-PLAN` 진행 상태를 등록했다. + +### 3.2 2026-06-15 FE draft 구현 + +- 반영 파일 + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` + - `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx` + - `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts` + - `FE_APP_ROOT/src/features/windowPopup/registry.tsx` + - `FE_APP_ROOT/src/features/windowPopup/ui/FloodHistoryPopupContent.tsx` + - `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx` + - `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/FloodHistoryDetailPanel.tsx` + - `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts` +- 구현 내용 + - 분석도구의 `침수발생이력` 항목은 제거하고, 레이어 트리 `침수흔적도` 목록 진입으로 popup을 열도록 바꿨다. + - `침수발생이력` popup placeholder를 실제 popup 페이지로 교체했다. + - 시도/시군구/침수발생년도 선택/침수재해명 조회조건과 mock 목록 조회/다운로드를 연결했다. + - 그리드는 현재 파란 톤을 유지하되, 사용안함 항목을 제외한 상세 필드를 모두 노출하도록 바꾸고 가로 스크롤을 허용했다. `강우 분석정보` 묶음은 유지하면서 뒤쪽 컬럼을 재배치했다. + - 행 클릭 시 popup을 유지한 채 `postMessage`로 메인 지도에 mock preview를 보내고, 더블클릭 시 popup을 닫도록 구성했다. + - 메인 지도는 mock polygon layer와 침수심 표현 layer를 임시 `VectorLayer`로 그리도록 구성했고, 표시 여부는 `침수흔적도` 체크 상태를 따르도록 보정했다. + - 메인 지도 우측 상세패널은 제거하고, 지도 위 큰 침수흔적 영역 클릭 시 작은 플로팅 팝업이 열리도록 바꿨다. + - 상세 팝업 탭은 `침수년도탭`, `재해명탭`, `침수발생강우탭` 구조에 맞춰 재배치했고, 탭별 표 항목도 캡처 기준에 가깝게 맞췄다. + +## 4. 기능별 현재 상태 + +### 4.1 popup 조회 + +- FE-only draft 구현 완료 +- 레이어 트리 `침수흔적도` 목록 진입 기준 +- 시도/시군구/년도/재해명 조건과 목록 필터, 페이지네이션, 다운로드를 포함한다. + +### 4.2 지도 이동 및 강조 + +- FE-only draft 구현 완료 +- 더블클릭 시 popup close 후 메인 지도 fit 이동과 mock polygon/침수심 표현 overlay를 수행한다. +- `침수흔적도` 체크 상태에 따라 영역과 침수심 overlay를 함께 표시/숨김한다. + +### 4.3 지도 상세 팝업 + +- FE-only draft 구현 완료 +- 선택 이력 기준 3탭 플로팅 팝업을 제공한다. + +## 5. 현재 검증 메모 + +- 로컬 `localhost:3002` 응답은 확인했다. +- `next dev`는 샌드박스 내에서 `spawn EPERM`이 발생해 권한 상승 후 로컬 서버를 띄웠다. +- 사용자가 직접 로컬 화면을 수동 검증할 예정이므로, popup 실제 진입/더블클릭/상세패널 체감 확인은 후속 결과를 받아 갱신한다. + +## 6. 남은 리스크 + +- popup 라우트의 초기 컴파일 시간이 길어 자동 요청에서 timeout이 발생할 수 있다. +- `침수흔적도` 기준데이터 이름/구조와 FE 판별식이 다를 수 있다. +- 실제 API 필드가 확정되면 상세패널 항목명/정렬이 일부 바뀔 수 있다. diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts index 5724edd3a..dcba0e396 100644 --- a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -104,8 +104,8 @@ export const windowPopupDefinitions = { floodHistoryPopup: { key: "floodHistoryPopup", title: "침수발생이력", - width: 1080, - height: 760, + width: 1160, + height: 820, }, gisEditPopup: { key: "gisEditPopup", diff --git a/apps/sn/src/features/windowPopup/registry.tsx b/apps/sn/src/features/windowPopup/registry.tsx index aa65d06d2..f63b08a91 100644 --- a/apps/sn/src/features/windowPopup/registry.tsx +++ b/apps/sn/src/features/windowPopup/registry.tsx @@ -14,24 +14,19 @@ import SinkholePopupContent from "./ui/SinkholePopupContent"; import SscDetailPopupContent from "./ui/SscDetailPopupContent"; import type { WindowPopupContentProps } from "./model/types"; import FeaturePlaceholderPopupContent from "./ui/FeaturePlaceholderPopupContent"; +import FloodHistoryPopupContent from "./ui/FloodHistoryPopupContent"; import GisEditSessionPopupContent from "./ui/GisEditSessionPopupContent"; import GisManagementPopupContent from "./ui/GisManagementPopupContent"; import PipeAnalysisPopupContent from "./ui/PipeAnalysisPopupContent"; import PipeAnalysisDetailPopupContent from "./ui/PipeAnalysisDetailPopupContent"; import WaterRiskPopupContent from "./ui/WaterRiskPopupContent"; -const FloodPredictionMapPopupContent = (_props: WindowPopupContentProps) => ( +const FloodPredictionPlaceholderPopupContent = (_props: WindowPopupContentProps) => ( ); -const FloodHistoryPopupContent = (_props: WindowPopupContentProps) => ( - -); - const GisEditPopupContent = (_props: WindowPopupContentProps) => ( ); @@ -95,7 +90,7 @@ export const windowPopupRegistry = { }, floodPredictionMapPopup: { ...windowPopupDefinitions.floodPredictionMapPopup, - component: FloodPredictionMapPopupContent, + component: FloodPredictionPlaceholderPopupContent, }, floodHistoryPopup: { ...windowPopupDefinitions.floodHistoryPopup, diff --git a/apps/sn/src/features/windowPopup/ui/FloodHistoryPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/FloodHistoryPopupContent.tsx new file mode 100644 index 000000000..d363c7ecf --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/FloodHistoryPopupContent.tsx @@ -0,0 +1,5 @@ +import FloodHistoryPopupPage from "@/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage"; + +export default function FloodHistoryPopupContent() { + return ; +} diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js index f931b06b7..6d9da766d 100644 --- a/packages/common/business_components/src/ui/map/GisTree.js +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -550,9 +550,13 @@ export default React.memo(function GisTree({ (child) => child.parent === node.id && child.data.exprsYn === "Y" ); + const nodeLabel = String(node?.data?.lyrNm ?? "").trim(); const isSinkholeRootTreeNode = String(node.id) === "SN_SINKHOLE" || - String(node?.data?.lyrNm ?? "").trim() === "지반침하정보"; + nodeLabel === "지반침하정보"; + const isFloodHistoryRootTreeNode = nodeLabel === "침수흔적도"; + const hideTreeLegendIcon = + isSinkholeRootTreeNode || isFloodHistoryRootTreeNode; const showLayerActionButton = layerIconVisible?.(node); const showLayerActionIcon = depth > 0 && imageVisible; @@ -582,7 +586,7 @@ export default React.memo(function GisTree({ onChange={() => handleCheck(node.id)} className="cursor-pointer" /> - {!isSinkholeRootTreeNode && node.icon && {node.icon}} + {!hideTreeLegendIcon && node.icon && {node.icon}} Date: Mon, 15 Jun 2026 17:34:24 +0900 Subject: [PATCH 023/185] =?UTF-8?q?=ED=86=B5=EC=88=98=EC=9C=84=ED=97=98=20?= =?UTF-8?q?=ED=8C=9D=EC=97=85=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../waterRisk/WaterRiskPopupPage.tsx | 134 +++++++++++------- .../001-fe-SFR-011-007-통수위험분석-통합구현기록.md | 15 ++ 2 files changed, 98 insertions(+), 51 deletions(-) diff --git a/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx index a8671987a..2eecde5a6 100644 --- a/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx @@ -57,21 +57,18 @@ const DEFAULT_SETTING_FILTERS: SettingFilters = { }; const TOOLBAR_INPUT_CLASS_NAME = - "h-[30px] rounded-none border border-[#8f98a3] bg-white px-2 text-[12px] text-[#1f2937] outline-none"; - -const TOOLBAR_BUTTON_CLASS_NAME = - "h-[30px] min-w-[44px] rounded-none border border-[#7c5a20] bg-[#ef8d2f] px-3 text-[12px] font-semibold text-white hover:bg-[#df7f24]"; + "h-10 rounded-md border border-slate-300 bg-white px-3 text-[1.05rem] text-slate-700 outline-none"; const SECONDARY_BUTTON_CLASS_NAME = - "h-[28px] rounded-none border border-[#8f98a3] bg-white px-3 text-[12px] text-[#334155] hover:bg-[#f8fafc]"; + "h-10 rounded-md border border-slate-300 bg-white px-5 text-[1.05rem] text-slate-700 hover:bg-slate-100"; const ACTION_BUTTON_CLASS_NAME = - "h-[28px] rounded-[6px] border border-[#2e77bc] bg-[#3f8fd7] px-3 text-[12px] font-semibold text-white hover:bg-[#337ec1]"; + "h-10 px-5 !text-[1.05rem]"; const WATER_RISK_PAGE_SIZE = 10; const RESULT_TABLE_CONTAINER_CLASS_NAME = - "min-h-0 overflow-hidden rounded-[10px] border border-[#d6e1ee] bg-[linear-gradient(180deg,#ffffff_0%,#f9fbfe_100%)] shadow-[0_6px_16px_rgba(31,59,87,0.06)]"; + "min-h-0 overflow-hidden border border-[#dbe2e6] bg-white"; const SETTING_TABLE_CONTAINER_CLASS_NAME = - "min-h-0 overflow-hidden rounded-[10px] border border-[#d6e1ee] bg-[linear-gradient(180deg,#ffffff_0%,#f9fbfe_100%)] shadow-[0_6px_16px_rgba(31,59,87,0.06)]"; + "min-h-0 overflow-hidden border border-[#dbe2e6] bg-white"; const postMessageToOpener = (type: string, payload?: Record) => { if (typeof window === "undefined" || !window.opener) return; @@ -396,28 +393,28 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { }; return ( -
-
+
+
{activeView === "setting" ? ( ) : null} -

+

{activeView === "result" ? "통수위험분석" : "통수위험분석 설정"}

-
+
{activeView === "result" ? ( -
-
-
-
+
+
+ + + + + +
-
+ +
-
+
+

+ 통수위험 분석 결과 {visibleResultRows.length.toLocaleString("ko-KR")}건 +

+
+
@@ -620,10 +639,10 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { ) : null} ) : ( -
-
-
-
+
+
+ + +
-
+ +
-
+
+

+ 통수위험 설정 현황 {visibleSettingRows.length.toLocaleString("ko-KR")}건 +

+
+
@@ -754,10 +785,10 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { ) : null} {showSettingEditor ? ( -
+
-
-
+
+
설정 편집 대상: {currentSettingAreaName || "선택 전"}
@@ -765,7 +796,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { type="button" size="sm" color="white" - className="h-[28px] rounded-[4px] border border-[#cad7e6] bg-white px-[10px] text-[12px] text-[#43566b] hover:bg-[#f8fbff]" + className="h-9 rounded-md border border-slate-300 bg-white px-4 text-[1.05rem] text-slate-700 hover:bg-slate-100" onClick={handleStopSettingEdit} > 편집 종료 @@ -774,7 +805,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { type="button" size="sm" color="white" - className="h-[28px] rounded-[4px] border border-[#cad7e6] bg-white px-[10px] text-[12px] text-[#43566b] hover:bg-[#f8fbff]" + className="h-9 rounded-md border border-slate-300 bg-white px-4 text-[1.05rem] text-slate-700 hover:bg-slate-100" onClick={handleClearStagedPipes} disabled={snapshot.stagedPipes.length === 0} > @@ -783,6 +814,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
-
+
@@ -832,7 +864,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { type="button" size="sm" color="white" - className="h-[28px] rounded-[4px] border border-[#cad7e6] bg-white px-[10px] text-[12px] text-[#43566b] hover:bg-[#f8fbff]" + className="h-9 rounded-md border border-slate-300 bg-white px-4 text-[1.05rem] text-slate-700 hover:bg-slate-100" onClick={() => handleMoveStagedPipe(pipe.pipeId, "UP")} disabled={index === 0} > @@ -842,7 +874,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { type="button" size="sm" color="white" - className="h-[28px] rounded-[4px] border border-[#cad7e6] bg-white px-[10px] text-[12px] text-[#43566b] hover:bg-[#f8fbff]" + className="h-9 rounded-md border border-slate-300 bg-white px-4 text-[1.05rem] text-slate-700 hover:bg-slate-100" onClick={() => handleMoveStagedPipe(pipe.pipeId, "DOWN")} disabled={index === snapshot.stagedPipes.length - 1} > @@ -852,7 +884,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { type="button" size="sm" color="white" - className="h-[28px] rounded-none border border-[#e7b7b7] bg-white px-3 text-[12px] text-[#c62828] hover:bg-[#fff8f8]" + className="h-9 rounded-md border border-[#e7b7b7] bg-white px-4 text-[1.05rem] text-[#c62828] hover:bg-[#fff8f8]" onClick={() => handleRemoveStagedPipe(pipe.pipeId)} > 삭제 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 272371f54..a485657bb 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 @@ -95,6 +95,21 @@ - 남은 메모 - 실제 운영에서 요구되는 카드 고정 규칙과 사용성은 브라우저 수동 검증에서 한 번 더 확정할 필요가 있다. +### 4.6 2026-06-15 통수위험 팝업 디자인 톤 재정렬 + +- 반영 파일 + - `FE_APP_ROOT/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx` +- 배경 + - 사용자 피드백 기준으로 현재 `통수위험분석` 팝업은 `관로분석`, `노후관로` 계열과 비교해 별도 그라데이션/카드/버튼 톤이 강해 혼자 튀는 상태로 보였다. + - 이번 라운드에서는 기능 흐름은 유지하고, 시각 언어만 기존 지도형 팝업 계열과 같은 방향으로 다시 맞추는 것을 목표로 했다. +- 구현 내용 + - 상단 헤더를 `bg-primary` + 흰색 제목 구조로 바꿔 `관로분석`, `노후관로` 팝업과 같은 첫인상을 맞췄다. + - 본문 배경을 흰색으로 단순화하고, 조회조건 영역을 `slate-50` 기반 필터 바 형태로 바꿨다. + - 결과/설정 화면 모두에 `card-header` 스타일의 제목 줄을 추가해 기존 팝업의 표 제목 리듬과 맞췄다. + - 버튼 높이, 입력 높이, 테이블 외곽선, 편집 영역 상단 바를 기존 팝업 계열과 가까운 톤으로 조정했다. +- 남은 메모 + - 현재는 `노후관로`/`관로분석` 계열에 시각 톤을 맞춘 1차 정리이며, 실제 사용자 체감 기준에서 간격이나 글자 크기 보정이 더 필요하면 후속 유지보수로 미세조정할 수 있다. + ## 5. 기능별 현재 상태 ### 5.1 결과 조회 From 58ca85ce90258c0d374942d31083bee99566a3a5 Mon Sep 17 00:00:00 2001 From: yangsein Date: Tue, 16 Jun 2026 16:43:06 +0900 Subject: [PATCH 024/185] =?UTF-8?q?feat:=20=ED=86=B5=EA=B3=84=EC=9D=B8?= =?UTF-8?q?=EA=B5=AC=EA=B4=80=EB=A6=AC=20=EB=82=B4=EA=B5=AD=EC=9D=B8/?= =?UTF-8?q?=EC=99=B8=EA=B5=AD=EC=9D=B8/=ED=96=89=EC=A0=95=EB=8F=99?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20=ED=83=AD=20=EA=B5=AC=ED=98=84"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StatisticsPopulationManagement.tsx | 695 +++++++++++++++++- .../management/UpperCodeSelectDialog.tsx | 118 +++ .../column/management/AdmdstRenewalColumn.ts | 75 ++ .../management/DomesticPopulationColumn.ts | 145 ++++ .../management/ForeignPopulationColumn.ts | 145 ++++ .../api/statistics-ppltn-service.ts | 161 ++++ ...tatistics-domestic-population-interface.ts | 62 ++ apps/st/src/shared/config/api.ts | 1 + 8 files changed, 1397 insertions(+), 5 deletions(-) create mode 100644 apps/st/src/components/statistics/management/UpperCodeSelectDialog.tsx create mode 100644 apps/st/src/components/util/column/management/AdmdstRenewalColumn.ts create mode 100644 apps/st/src/components/util/column/management/DomesticPopulationColumn.ts create mode 100644 apps/st/src/components/util/column/management/ForeignPopulationColumn.ts create mode 100644 apps/st/src/entities/statistics/api/statistics-ppltn-service.ts create mode 100644 apps/st/src/entities/statistics/model/ppltn/statistics-domestic-population-interface.ts diff --git a/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx b/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx index c0c5e0de1..e1d7ecd94 100644 --- a/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx +++ b/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx @@ -1,11 +1,696 @@ -import SearchForm from "@/components/module/SearchForm"; +import { useRef, useState } from "react"; +import * as XLSX from "xlsx"; +import { useAlertStore } from "@/shared/lib"; +import { Button, LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components"; +import SearchForm from "@/components/module/SearchForm"; +import DataGrid from "@/components/module/DataGrid"; +import type { ColumnInfo } from "@/components/type/DataGrid.type"; +import type { SearchFormValues } from "@/components/type/SearchForm.type"; +import { toGridRows } from "@/components/util/grid/gridFunction"; + +import { + DomesticPopulationLeftColumnConfig, + DomesticPopulationRightColumnConfig, +} from "@/components/util/column/management/DomesticPopulationColumn"; +import { + ForeignPopulationLeftColumnConfig, + ForeignPopulationRightColumnConfig, +} from "@/components/util/column/management/ForeignPopulationColumn"; +import { + AdmdstRenewalReducedColumnConfig, + AdmdstRenewalAddedColumnConfig, +} from "@/components/util/column/management/AdmdstRenewalColumn"; +import { statisticsPpltnApi } from "@/entities/statistics/api/statistics-ppltn-service"; +import { getUserInfo } from "../../../../../../packages/common/components/src/api"; +import type { UpperCodeCandidateItem } from "@/entities/statistics/model/ppltn/statistics-domestic-population-interface"; +import { UpperCodeSelectDialog } from "./UpperCodeSelectDialog"; + +const TAB_LIST = [ + { key: "domestic", label: "행정인구등록" }, + { key: "foreign", label: "외국인인구등록" }, + { key: "renewal", label: "행정동갱신" }, + { key: "status", label: "행정인구현황" }, + { key: "statusViz", label: "행정인구현황 시각화" }, +]; + +/* ── 컬럼 설정 → ColumnInfo 변환 ── */ +const domesticLeftColumnInfo: ColumnInfo = { + columns: DomesticPopulationLeftColumnConfig.columns ?? [], + columnLayout: DomesticPopulationLeftColumnConfig.columnLayout ?? [], +}; +const domesticRightColumnInfo: ColumnInfo = { + columns: DomesticPopulationRightColumnConfig.columns ?? [], + columnLayout: DomesticPopulationRightColumnConfig.columnLayout ?? [], +}; +const foreignLeftColumnInfo: ColumnInfo = { + columns: ForeignPopulationLeftColumnConfig.columns ?? [], + columnLayout: ForeignPopulationLeftColumnConfig.columnLayout ?? [], +}; +const foreignRightColumnInfo: ColumnInfo = { + columns: ForeignPopulationRightColumnConfig.columns ?? [], + columnLayout: ForeignPopulationRightColumnConfig.columnLayout ?? [], +}; +const renewalReducedColumnInfo: ColumnInfo = { + columns: AdmdstRenewalReducedColumnConfig.columns ?? [], + columnLayout: AdmdstRenewalReducedColumnConfig.columnLayout ?? [], +}; +const renewalAddedColumnInfo: ColumnInfo = { + columns: AdmdstRenewalAddedColumnConfig.columns ?? [], + columnLayout: AdmdstRenewalAddedColumnConfig.columnLayout ?? [], +}; + +/* ── 엑셀 헤더 검증 ── */ +const validateExcelHeader = (file: File, requiredKeywords: string[]): Promise => { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = (e) => { + try { + const data = e.target?.result; + const workbook = XLSX.read(data, { type: "binary" }); + const sheetName = workbook.SheetNames[0]; + if (!sheetName) { resolve(false); return; } + const sheet = workbook.Sheets[sheetName]; + if (!sheet) { resolve(false); return; } + const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 }); + const headerRow = (rows[0] as string[]).map((h) => String(h ?? "")); + const isValid = requiredKeywords.every((kw) => + headerRow.some((h) => h.includes(kw)) + ); + resolve(isValid); + } catch { + resolve(false); + } + }; + reader.readAsBinaryString(file); + }); +}; + +/* ══════════════════════════════════════════ + 컴포넌트 +══════════════════════════════════════════ */ const StatisticsPopulationManagement = () => { + const { setMessage: alert } = useAlertStore((state) => state); + + const [activeTab, setActiveTab] = useState("domestic"); + const [isLoading, setIsLoading] = useState(false); + + /* ── 내국인 데이터 ── */ + const [domesticLeftRowData, setDomesticLeftRowData] = useState[]>([]); + const [domesticRightRowData, setDomesticRightRowData] = useState[]>([]); + + /* ── 외국인 데이터 ── */ + const [foreignLeftRowData, setForeignLeftRowData] = useState[]>([]); + const [foreignRightRowData, setForeignRightRowData] = useState[]>([]); + + /* ── 행정동갱신 데이터 ── */ + const [renewalReducedRowData, setRenewalReducedRowData] = useState[]>([]); + const [renewalAddedRowData, setRenewalAddedRowData] = useState[]>([]); + + /* ── 상위 코드 다이얼로그 ── */ + const [dialogOpen, setDialogOpen] = useState(false); + const [candidates, setCandidates] = useState([]); + const selectedRowIndexRef = useRef(null); + const selectedTabRef = useRef<"domestic" | "foreign" | "renewal">("domestic"); + + const lastSearchedValuesRef = useRef(null); + const domesticFileInputRef = useRef(null); + const foreignFileInputRef = useRef(null); + + /* ════════════════ 내국인 ════════════════ */ + const loadDomesticData = async (values: SearchFormValues, reloadRight = true): Promise => { + const yr = values.year ?? ""; + if (!yr) { alert("연도를 선택해주세요."); return false; } + try { + setIsLoading(true); + const leftRes = await statisticsPpltnApi.getDomesticPopulationList(yr, "1"); + if (leftRes.status === "success") setDomesticLeftRowData(toGridRows((leftRes.data as object[]) ?? [])); + if (reloadRight) { + const rightRes = await statisticsPpltnApi.getDomesticPopulationList(yr, "2"); + if (rightRes.status === "success") setDomesticRightRowData(toGridRows((rightRes.data as object[]) ?? [])); + } + return true; + } catch { + alert("서버 연결 중 오류가 발생했습니다."); + return false; + } finally { + setIsLoading(false); + } + }; + + const handleDomesticFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const isValidExt = [".xlsx", ".xls", ".csv"].some((ext) => file.name.toLowerCase().endsWith(ext)); + if (!isValidExt) { alert("xlsx, xls, csv 파일만 업로드 가능합니다."); e.target.value = ""; return; } + const isValidHeader = await validateExcelHeader(file, ["행정기관코드", "행정기관"]); + if (!isValidHeader) { alert("행정인구(내국인) 전용 파일만 업로드 가능합니다.\n올바른 파일을 선택해주세요."); e.target.value = ""; return; } + const yr = lastSearchedValuesRef.current?.year ?? ""; + if (!yr) { alert("연도를 선택해주세요."); e.target.value = ""; return; } + try { + setIsLoading(true); + const res = await statisticsPpltnApi.uploadDomesticPopulationExcel(file, yr); + if (res.status === "success") { + await loadDomesticData(lastSearchedValuesRef.current!, true); + alert("업로드가 완료되었습니다."); + } else { + alert(res.message || "업로드 중 오류가 발생했습니다."); + } + } catch { + alert("업로드 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + e.target.value = ""; + } + }; + + const handleDomesticFinal = async () => { + const values = lastSearchedValuesRef.current; + const yr = values?.year ?? ""; + if (!yr) { alert("먼저 조회를 진행해주세요."); return; } + if (domesticRightRowData.length === 0) { alert("반영할 데이터가 없습니다."); return; } + try { + setIsLoading(true); + const regList = domesticRightRowData + .filter((row) => String(row.mpinCd ?? "").trim() !== "") + .map((row) => ({ + statsYr: yr, + admdstCd: String(row.admdstCd ?? ""), + admdstNm: String(row.admdstNm ?? ""), + subAdmdstCd: String(row.mpinCd ?? ""), + })); + const res = await statisticsPpltnApi.saveDomesticPopulationFinal(yr, regList); + if (res.status === "success") { + const leftRes = await statisticsPpltnApi.getDomesticPopulationList(yr, "1"); + if (leftRes.status === "success") setDomesticLeftRowData(toGridRows((leftRes.data as object[]) ?? [])); + alert("반영이 완료되었습니다."); + } else { + alert(res.message || "반영 중 오류가 발생했습니다."); + } + } catch { + alert("반영 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + /* ════════════════ 외국인 ════════════════ */ + const loadForeignData = async (values: SearchFormValues, reloadRight = true): Promise => { + const yr = values.year ?? ""; + if (!yr) { alert("연도를 선택해주세요."); return false; } + try { + setIsLoading(true); + const leftRes = await statisticsPpltnApi.getForeignPopulationList(yr, "1"); + if (leftRes.status === "success") setForeignLeftRowData(toGridRows((leftRes.data as object[]) ?? [])); + if (reloadRight) { + const rightRes = await statisticsPpltnApi.getForeignPopulationList(yr, "2"); + if (rightRes.status === "success") setForeignRightRowData(toGridRows((rightRes.data as object[]) ?? [])); + } + return true; + } catch { + alert("서버 연결 중 오류가 발생했습니다."); + return false; + } finally { + setIsLoading(false); + } + }; + + const handleForeignFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const isValidExt = [".xlsx", ".xls", ".csv"].some((ext) => file.name.toLowerCase().endsWith(ext)); + if (!isValidExt) { alert("xlsx, xls, csv 파일만 업로드 가능합니다."); e.target.value = ""; return; } + const isValidHeader = await validateExcelHeader(file, ["행정동코드", "행정동명", "외국인수"]); + if (!isValidHeader) { alert("외국인 인구 전용 파일만 업로드 가능합니다.\n올바른 파일을 선택해주세요."); e.target.value = ""; return; } + const yr = lastSearchedValuesRef.current?.year ?? ""; + if (!yr) { alert("연도를 선택해주세요."); e.target.value = ""; return; } + try { + setIsLoading(true); + const res = await statisticsPpltnApi.uploadForeignPopulationExcel(file, yr); + if (res.status === "success") { + await loadForeignData(lastSearchedValuesRef.current!, true); + alert("업로드가 완료되었습니다."); + } else { + alert(res.message || "업로드 중 오류가 발생했습니다."); + } + } catch { + alert("업로드 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + e.target.value = ""; + } + }; + + const handleForeignFinal = async () => { + const values = lastSearchedValuesRef.current; + const yr = values?.year ?? ""; + if (!yr) { alert("먼저 조회를 진행해주세요."); return; } + if (foreignRightRowData.length === 0) { alert("반영할 데이터가 없습니다."); return; } + try { + setIsLoading(true); + const regList = foreignRightRowData + .filter((row) => String(row.mpinCd ?? "").trim() !== "") + .map((row) => ({ + statsYr: yr, + admdstCd: String(row.admdstCd ?? ""), + admdstNm: String(row.admdstNm ?? ""), + subAdmdstCd: String(row.mpinCd ?? ""), + })); + const res = await statisticsPpltnApi.saveForeignPopulationFinal(yr, regList); + if (res.status === "success") { + const leftRes = await statisticsPpltnApi.getForeignPopulationList(yr, "1"); + if (leftRes.status === "success") setForeignLeftRowData(toGridRows((leftRes.data as object[]) ?? [])); + alert("반영이 완료되었습니다."); + } else { + alert(res.message || "반영 중 오류가 발생했습니다."); + } + } catch { + alert("반영 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + /* ════════════════ 행정동갱신 ════════════════ */ + const loadRenewalData = async (values: SearchFormValues): Promise => { + const yr = values.year ?? ""; + if (!yr) { alert("연도를 선택해주세요."); return false; } + try { + setIsLoading(true); + // OPT_1: "1" = 추가된 행정구역, "2" = 감축된 행정구역 + const [addedRes, reducedRes] = await Promise.all([ + statisticsPpltnApi.searchChangedArea(yr, "1"), + statisticsPpltnApi.searchChangedArea(yr, "2"), + ]); + if (addedRes.status === "success") setRenewalAddedRowData(toGridRows((addedRes.data as object[]) ?? [])); + if (reducedRes.status === "success") setRenewalReducedRowData(toGridRows((reducedRes.data as object[]) ?? [])); + return true; + } catch { + alert("서버 연결 중 오류가 발생했습니다."); + return false; + } finally { + setIsLoading(false); + } + }; + + const handleRenewalFinal = async () => { + const values = lastSearchedValuesRef.current; + if (!values?.year) { alert("먼저 조회를 진행해주세요."); return; } + if (renewalReducedRowData.length === 0 && renewalAddedRowData.length === 0) { + alert("반영할 데이터가 없습니다."); + return; + } + try { + setIsLoading(true); + const userInfo = await getUserInfo(); + + // adstDstrNm("시도 구군 동" 형태)을 sido/gugun/adstDong으로 분리 + const addedList = renewalAddedRowData.map((row) => { + const fullNm = String(row.adstDstrNm ?? "").trim(); + const parts = fullNm.split(/\s+/); + const sido = parts[0] ?? ""; + const gugun = parts.length >= 2 ? parts[1] : ""; + const adstDong = parts.length >= 3 ? parts.slice(2).join(" ") : ""; + + return { + adstCd: String(row.adstCd ?? ""), + lawCd: String(row.lawCd ?? ""), + sido, + gugun, + adstDong, + userId: userInfo.userUnqId, + }; + }); + + const reducedList = renewalReducedRowData.map((row) => ({ + adstCd: String(row.adstCd ?? ""), + })); + + const res = await statisticsPpltnApi.saveChangedArea(reducedList, addedList); + if (res.status === "success") { + await loadRenewalData(values); + alert("행정구역 감축 및 추가사항이 성공적으로 반영되었습니다."); + } else { + alert(res.message || "반영 중 오류가 발생했습니다."); + } + } catch { + alert("반영 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + /* ════════════════ 공통 ════════════════ */ + const handleSearch = async (values: SearchFormValues) => { + lastSearchedValuesRef.current = values; + if (activeTab === "domestic") { + const yr = values.year ?? ""; + try { + setIsLoading(true); + const leftRes = await statisticsPpltnApi.getDomesticPopulationList(yr, "1"); + if (leftRes.status === "success") setDomesticLeftRowData(toGridRows((leftRes.data as object[]) ?? [])); + if (domesticRightRowData.length === 0) { + const rightRes = await statisticsPpltnApi.getDomesticPopulationList(yr, "2"); + if (rightRes.status === "success") setDomesticRightRowData(toGridRows((rightRes.data as object[]) ?? [])); + } + } catch { + alert("서버 연결 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + } else if (activeTab === "foreign") { + const yr = values.year ?? ""; + try { + setIsLoading(true); + const leftRes = await statisticsPpltnApi.getForeignPopulationList(yr, "1"); + if (leftRes.status === "success") setForeignLeftRowData(toGridRows((leftRes.data as object[]) ?? [])); + if (foreignRightRowData.length === 0) { + const rightRes = await statisticsPpltnApi.getForeignPopulationList(yr, "2"); + if (rightRes.status === "success") setForeignRightRowData(toGridRows((rightRes.data as object[]) ?? [])); + } + } catch { + alert("서버 연결 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + } else if (activeTab === "renewal") { + await loadRenewalData(values); + } + }; + + const handleReset = () => { + setDomesticLeftRowData([]); + setDomesticRightRowData([]); + setForeignLeftRowData([]); + setForeignRightRowData([]); + setRenewalReducedRowData([]); + setRenewalAddedRowData([]); + setActiveTab("domestic"); + lastSearchedValuesRef.current = null; + }; + + const handleActiveTabChange = async (tabKey: string) => { + setActiveTab(tabKey); + const values = lastSearchedValuesRef.current; + if (!values) return; + if (tabKey === "domestic" && domesticLeftRowData.length === 0) await loadDomesticData(values); + if (tabKey === "foreign" && foreignLeftRowData.length === 0) await loadForeignData(values); + if (tabKey === "renewal" && renewalReducedRowData.length === 0 && renewalAddedRowData.length === 0) await loadRenewalData(values); + }; + + /* ── 우측 행 클릭 → 상위 코드 후보 조회 ── */ + const handleRightRowClick = async ( + rowData: Record, + rowIndex: number, + tab: "domestic" | "foreign", + ) => { + const yr = lastSearchedValuesRef.current?.year ?? ""; + const admdstNm = String(rowData.admdstNm ?? ""); + if (!admdstNm) { alert("행정구역명이 없습니다."); return; } + try { + setIsLoading(true); + const res = await statisticsPpltnApi.getUpperCodesByName(yr, admdstNm); + if (res.status === "success" && Array.isArray(res.data) && res.data.length > 0) { + setCandidates(res.data); + selectedRowIndexRef.current = rowIndex; + selectedTabRef.current = tab; + setDialogOpen(true); + } else { + alert("해당 지역명에 대한 상위 코드 후보가 없습니다."); + } + } catch { + alert("상위 코드 조회 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + /* ── 행정동갱신 우측 행 클릭 → 법정동코드 후보 조회 ── */ + const handleRenewalRowClick = async ( + rowData: Record, + rowIndex: number, + ) => { + const yr = lastSearchedValuesRef.current?.year ?? ""; + const adstDstrNm = String(rowData.adstDstrNm ?? ""); + if (!adstDstrNm) { alert("행정구역명이 없습니다."); return; } + try { + setIsLoading(true); + const res = await statisticsPpltnApi.getRenewalUpperCodesByName(yr, adstDstrNm); + if (res.status === "success" && Array.isArray(res.data) && res.data.length > 0) { + setCandidates(res.data); + selectedRowIndexRef.current = rowIndex; + selectedTabRef.current = "renewal"; + setDialogOpen(true); + } else { + alert("해당 지역명에 대한 법정동코드 후보가 없습니다."); + } + } catch { + alert("법정동코드 조회 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + /* ── 다이얼로그 코드 선택 → 업데이트 ── */ + const handleCodeSelect = (item: UpperCodeCandidateItem) => { + const rowIndex = selectedRowIndexRef.current; + if (rowIndex === null) return; + + if (selectedTabRef.current === "renewal") { + // 행정동갱신: 법정동코드/법정동명 업데이트 + setRenewalAddedRowData((prev) => + prev.map((row, idx) => + idx === rowIndex + ? { ...row, lawCd: item.adstCd, lawNm: item.adstDstrNm } + : row + ) + ); + } else { + // 내국인/외국인: mpinCd 업데이트 + const setter = selectedTabRef.current === "domestic" ? setDomesticRightRowData : setForeignRightRowData; + setter((prev) => + prev.map((row, idx) => + idx === rowIndex ? { ...row, mpinCd: item.adstCd } : row + ) + ); + } + selectedRowIndexRef.current = null; + }; + + /* ══════════════════════ 렌더 ══════════════════════ */ return ( - <> - - +
+ + + + + + {isLoading && ( +
+
+ +
+
+ )} + +
+ void handleActiveTabChange(v)} className="w-full" variant="default"> + + {TAB_LIST.map((tab) => ( + {tab.label} + ))} + + + {/* ── 내국인 탭 ── */} + +
+
+
+ + 엑셀 업로드 자료 (내국인 전체 리스트) + +
+
+ +
+ +
+
+
+ + 미매핑 리스트 (작년 대비) + +
+
+ +
+ { + const rowIndex = domesticRightRowData.findIndex((r) => r.admdstCd === rowData.admdstCd); + void handleRightRowClick(rowData, rowIndex, "domestic"); + }} + /> +
+
+
+ + {/* ── 외국인 탭 ── */} + +
+
+
+ + 엑셀 업로드 자료 (외국인 전체 리스트) + +
+
+ +
+ +
+
+
+ + 미매핑 리스트 (작년 대비) + +
+
+ +
+ { + const rowIndex = foreignRightRowData.findIndex((r) => r.admdstCd === rowData.admdstCd); + void handleRightRowClick(rowData, rowIndex, "foreign"); + }} + /> +
+
+
+ + +
+
+
+ + 감축된 행정구역(범위:동읍면) 자료 + +
+
+ +
+
+
+ + 추가된 행정구역 자료 + +
+
+ +
+ { + const rowIndex = renewalAddedRowData.findIndex((r) => r.adstCd === rowData.adstCd); + void handleRenewalRowClick(rowData, rowIndex); + }} + /> +
+
+ + +
행정인구현황 구현 예정
+
+ +
행정인구현황 시각화 구현 예정
+
+ +
+ + +
); }; -export default StatisticsPopulationManagement; +export default StatisticsPopulationManagement; \ No newline at end of file diff --git a/apps/st/src/components/statistics/management/UpperCodeSelectDialog.tsx b/apps/st/src/components/statistics/management/UpperCodeSelectDialog.tsx new file mode 100644 index 000000000..52355304f --- /dev/null +++ b/apps/st/src/components/statistics/management/UpperCodeSelectDialog.tsx @@ -0,0 +1,118 @@ +import { useState } from "react"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@common/components"; +import type { UpperCodeCandidateItem } from "@/entities/statistics/model/ppltn/statistics-domestic-population-interface"; + +interface UpperCodeSelectDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + candidates: UpperCodeCandidateItem[]; + onSelect: (item: UpperCodeCandidateItem) => void; +} + +export const UpperCodeSelectDialog = ({ + open, + onOpenChange, + candidates, + onSelect, +}: UpperCodeSelectDialogProps) => { + const [selectedCode, setSelectedCode] = useState(null); + + const handleSelect = () => { + const item = candidates.find((c) => c.adstCd === selectedCode); + if (!item) return; + onSelect(item); + onOpenChange(false); + setSelectedCode(null); + }; + + const handleClose = () => { + onOpenChange(false); + setSelectedCode(null); + }; + + return ( + + + + 상위 코드 리스트 + + + {/* 테이블 */} +
+
순번
+ + + + + + +
+ 코드 + + 동명 +
+ {/* 스크롤 영역 분리 */} +
+ + + {candidates.length === 0 ? ( + + + + ) : ( + candidates.map((item) => ( + setSelectedCode(item.adstCd)} + className={`cursor-pointer border-b border-gray-200 transition-colors + ${selectedCode === item.adstCd + ? "bg-blue-100" + : "hover:bg-gray-50" + }`} + > + + + + )) + )} + +
+ 후보 코드가 없습니다. +
+ {item.adstCd} + + {item.adstDstrNm} +
+
+
+ + + + + + + + ); +}; \ No newline at end of file diff --git a/apps/st/src/components/util/column/management/AdmdstRenewalColumn.ts b/apps/st/src/components/util/column/management/AdmdstRenewalColumn.ts new file mode 100644 index 000000000..3e01e91ed --- /dev/null +++ b/apps/st/src/components/util/column/management/AdmdstRenewalColumn.ts @@ -0,0 +1,75 @@ +import type { ColumnInfoList } from "@/components/type/DataGrid.type"; + +/* ══════════════════════════════════════════════════ + 16. 통계인구관리 > 행정동갱신 탭 컬럼 설정 +══════════════════════════════════════════════════ */ + +/** 좌측: 감축된 행정구역(범위:동읍면) 자료 */ +export const AdmdstRenewalReducedColumnConfig: ColumnInfoList = { + tabKey: "RENEWAL_REDUCED", + tabLabel: "감축된 행정구역(범위:동읍면) 자료", + rowStartNum: 1, + fixedColumns: 1, + columns: [ + { + name: "adstDstrNm", + fieldName: "adstDstrNm", + header: { text: "행정동명" }, + width: 200, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "adstCd", + fieldName: "adstCd", + header: { text: "행정동코드" }, + width: 150, + editable: false, + styleName: "rg-cell-readonly", + }, + ], + columnLayout: ["adstDstrNm", "adstCd"], +}; + +/** 우측: 추가된 행정구역 자료 */ +export const AdmdstRenewalAddedColumnConfig: ColumnInfoList = { + tabKey: "RENEWAL_ADDED", + tabLabel: "추가된 행정구역 자료", + rowStartNum: 1, + fixedColumns: 1, + columns: [ + { + name: "adstDstrNm", + fieldName: "adstDstrNm", + header: { text: "행정동명" }, + width: 200, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "adstCd", + fieldName: "adstCd", + header: { text: "행정동코드" }, + width: 150, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "lawCd", + fieldName: "lawCd", + header: { text: "법정동코드" }, + width: 150, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "lawNm", + fieldName: "lawNm", + header: { text: "법정동명" }, + width: 200, + editable: false, + styleName: "rg-cell-readonly", + }, + ], + columnLayout: ["adstDstrNm", "adstCd", "lawCd", "lawNm"], +}; \ No newline at end of file diff --git a/apps/st/src/components/util/column/management/DomesticPopulationColumn.ts b/apps/st/src/components/util/column/management/DomesticPopulationColumn.ts new file mode 100644 index 000000000..00dafba70 --- /dev/null +++ b/apps/st/src/components/util/column/management/DomesticPopulationColumn.ts @@ -0,0 +1,145 @@ +import { ValueType } from "realgrid"; +import type { ColumnInfoList } from "@/components/type/DataGrid.type"; + +/* ══════════════════════════════════════════════════ + 16. 통계인구관리 > 내국인 인구 탭 컬럼 설정 +══════════════════════════════════════════════════ */ + +/** 좌측: 엑셀 업로드 자료 (내국인 전체 리스트) */ +export const DomesticPopulationLeftColumnConfig: ColumnInfoList = { + tabKey: "DOMESTIC_LEFT", + tabLabel: "엑셀 업로드 자료 (내국인 전체 리스트)", + rowStartNum: 1, + fixedColumns: 2, + columns: [ + { + name: "admdstCd", + fieldName: "admdstCd", + header: { text: "코드" }, + width: 120, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "admdstNm", + fieldName: "admdstNm", + header: { text: "동명" }, + width: 200, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "domplWholPpltnCnt", + fieldName: "domplWholPpltnCnt", + header: { text: "합계" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "domplMlPpltnCnt", + fieldName: "domplMlPpltnCnt", + header: { text: "남성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "domplFmlPpltnCnt", + fieldName: "domplFmlPpltnCnt", + header: { text: "여성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + ], + columnLayout: [ + "admdstCd", + "admdstNm", + { + name: "populationGroup", + header: { text: "인구수" }, + items: ["domplWholPpltnCnt", "domplMlPpltnCnt", "domplFmlPpltnCnt"], + }, + ], +}; + +/** 우측: 미매핑 리스트 (작년 대비) */ +export const DomesticPopulationRightColumnConfig: ColumnInfoList = { + tabKey: "DOMESTIC_RIGHT", + tabLabel: "미매핑 리스트 (작년 대비)", + rowStartNum: 1, + fixedColumns: 2, + columns: [ + { + name: "admdstCd", + fieldName: "admdstCd", + header: { text: "업로드코드" }, + width: 130, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "admdstNm", + fieldName: "admdstNm", + header: { text: "동명" }, + width: 200, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "mpinCd", + fieldName: "mpinCd", + header: { text: "맵핑코드" }, + width: 130, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "domplWholPpltnCnt", + fieldName: "domplWholPpltnCnt", + header: { text: "합계" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "domplMlPpltnCnt", + fieldName: "domplMlPpltnCnt", + header: { text: "남성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "domplFmlPpltnCnt", + fieldName: "domplFmlPpltnCnt", + header: { text: "여성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + ], + columnLayout: [ + "admdstCd", + "admdstNm", + "mpinCd", + { + name: "populationGroup", + header: { text: "인구수" }, + items: ["domplWholPpltnCnt", "domplMlPpltnCnt", "domplFmlPpltnCnt"], + }, + ], +}; \ No newline at end of file diff --git a/apps/st/src/components/util/column/management/ForeignPopulationColumn.ts b/apps/st/src/components/util/column/management/ForeignPopulationColumn.ts new file mode 100644 index 000000000..0e924f39b --- /dev/null +++ b/apps/st/src/components/util/column/management/ForeignPopulationColumn.ts @@ -0,0 +1,145 @@ +import { ValueType } from "realgrid"; +import type { ColumnInfoList } from "@/components/type/DataGrid.type"; + +/* ══════════════════════════════════════════════════ + 16. 통계인구관리 > 외국인 인구 탭 컬럼 설정 +══════════════════════════════════════════════════ */ + +/** 좌측: 엑셀 업로드 자료 (외국인 전체 리스트) */ +export const ForeignPopulationLeftColumnConfig: ColumnInfoList = { + tabKey: "FOREIGN_LEFT", + tabLabel: "엑셀 업로드 자료 (외국인 전체 리스트)", + rowStartNum: 1, + fixedColumns: 2, + columns: [ + { + name: "admdstCd", + fieldName: "admdstCd", + header: { text: "코드" }, + width: 120, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "admdstNm", + fieldName: "admdstNm", + header: { text: "동명" }, + width: 200, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "frgnrWholPpltnCnt", + fieldName: "frgnrWholPpltnCnt", + header: { text: "합계" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "frgnrMlPpltnCnt", + fieldName: "frgnrMlPpltnCnt", + header: { text: "남성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "frgnrFmlPpltnCnt", + fieldName: "frgnrFmlPpltnCnt", + header: { text: "여성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + ], + columnLayout: [ + "admdstCd", + "admdstNm", + { + name: "populationGroup", + header: { text: "외국인수" }, + items: ["frgnrWholPpltnCnt", "frgnrMlPpltnCnt", "frgnrFmlPpltnCnt"], + }, + ], +}; + +/** 우측: 미매핑 리스트 (작년 대비) */ +export const ForeignPopulationRightColumnConfig: ColumnInfoList = { + tabKey: "FOREIGN_RIGHT", + tabLabel: "미매핑 리스트 (작년 대비)", + rowStartNum: 1, + fixedColumns: 2, + columns: [ + { + name: "admdstCd", + fieldName: "admdstCd", + header: { text: "업로드코드" }, + width: 130, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "admdstNm", + fieldName: "admdstNm", + header: { text: "동명" }, + width: 200, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "mpinCd", + fieldName: "mpinCd", + header: { text: "맵핑코드" }, + width: 130, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "frgnrWholPpltnCnt", + fieldName: "frgnrWholPpltnCnt", + header: { text: "합계" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "frgnrMlPpltnCnt", + fieldName: "frgnrMlPpltnCnt", + header: { text: "남성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "frgnrFmlPpltnCnt", + fieldName: "frgnrFmlPpltnCnt", + header: { text: "여성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + ], + columnLayout: [ + "admdstCd", + "admdstNm", + "mpinCd", + { + name: "populationGroup", + header: { text: "외국인수" }, + items: ["frgnrWholPpltnCnt", "frgnrMlPpltnCnt", "frgnrFmlPpltnCnt"], + }, + ], +}; \ No newline at end of file diff --git a/apps/st/src/entities/statistics/api/statistics-ppltn-service.ts b/apps/st/src/entities/statistics/api/statistics-ppltn-service.ts new file mode 100644 index 000000000..b4eb5a0aa --- /dev/null +++ b/apps/st/src/entities/statistics/api/statistics-ppltn-service.ts @@ -0,0 +1,161 @@ +import { ENDPOINT } from "@/shared/config"; +import { stClient } from "@/shared/api/client"; + +import type { + DomesticPopulationListResponse, + DomesticPopulationFinalResponse, + UpperCodesByNameResponse, + DomesticPopulationMappingItem, +} from "../model/ppltn/statistics-domestic-population-interface"; + +export class StatisticsPpltnApiService { + + /** 내국인 엑셀 임시 업로드 */ + public async uploadDomesticPopulationExcel( + file: File, + statsYr: string, + ): Promise<{ status: string; message?: string }> { + const formData = new FormData(); + formData.append("file", file); + formData.append("statsYr", statsYr); + + const response = await stClient.post<{ status: string; message?: string }>( + `${ENDPOINT.ST_SERVICE.PPLTN}/domestic-population-excel`, + formData, + { headers: { "Content-Type": "multipart/form-data" } }, + ); + return response.data; + } + + /** 내국인 임시 적재 리스트 조회 + * @param opt1 "1" = 전체 리스트(좌측), "2" = 미매핑 리스트(우측) + */ + public async getDomesticPopulationList( + statsYr: string, + opt1: "1" | "2", + ): Promise { + const response = await stClient.get( + `${ENDPOINT.ST_SERVICE.PPLTN}/domestic-population-list`, + { params: { statsYr, opt1 } }, + ); + return response.data; + } + + /** 내국인 최종 마스터 반영 */ + public async saveDomesticPopulationFinal( + statsYr: string, + regList: DomesticPopulationMappingItem[], + ): Promise { + const response = await stClient.post( + `${ENDPOINT.ST_SERVICE.PPLTN}/domestic-population-final`, + { YR: statsYr, regList }, + ); + return response.data; + } + + /** 미매핑 행 클릭 시 상위 코드 후보 조회 */ + public async getUpperCodesByName( + statsYr: string, + admdstNm: string, + ): Promise { + const response = await stClient.post( + `${ENDPOINT.ST_SERVICE.PPLTN}/upper-codes-by-name`, + { statsYr, admdstNm }, + ); + return response.data; + } + + + /* ════════════════════════════════════ + 외국인 + ════════════════════════════════════ */ + + /** 외국인 엑셀 임시 업로드 */ + public async uploadForeignPopulationExcel( + file: File, + statsYr: string, + ): Promise<{ status: string; message?: string }> { + const formData = new FormData(); + formData.append("file", file); + formData.append("statsYr", statsYr); + const response = await stClient.post<{ status: string; message?: string }>( + `${ENDPOINT.ST_SERVICE.PPLTN}/foreign-population-excel`, + formData, + { headers: { "Content-Type": "multipart/form-data" } }, + ); + return response.data; + } + + /** 외국인 임시 적재 리스트 조회 + * @param opt1 "1" = 전체 리스트(좌측), "2" = 미매핑 리스트(우측) + */ + public async getForeignPopulationList( + statsYr: string, + opt1: "1" | "2", + ): Promise<{ status: string; data: object[]; message?: string }> { + const response = await stClient.get<{ status: string; data: object[]; message?: string }>( + `${ENDPOINT.ST_SERVICE.PPLTN}/foreign-population-list`, + { params: { statsYr, opt1 } }, + ); + return response.data; + } + + /** 외국인 최종 마스터 반영 */ + public async saveForeignPopulationFinal( + statsYr: string, + regList: DomesticPopulationMappingItem[], + ): Promise<{ status: string; message?: string }> { + const response = await stClient.post<{ status: string; message?: string }>( + `${ENDPOINT.ST_SERVICE.PPLTN}/foreign-population-final`, + { statsYr, regList }, + ); + return response.data; + } + + + /* ════════════════════════════════════ + 행정동갱신 + ════════════════════════════════════ */ + + /** 행정동 갱신 — 감축/추가 행정구역 조회 + * @param opt1 "1" = 추가된 행정구역, "2" = 감축된 행정구역 + */ + public async searchChangedArea( + statsYr: string, + opt1: "1" | "2", + ): Promise<{ status: string; data: object[]; message?: string }> { + const response = await stClient.get<{ status: string; data: object[]; message?: string }>( + `${ENDPOINT.ST_SERVICE.PPLTN}/admdst-renewal/search`, + { params: { statsYr, opt1 } }, + ); + return response.data; + } + + /** 행정동 갱신 — 감축/추가 반영 */ + public async saveChangedArea( + reducedList: object[], + addedList: object[], + ): Promise<{ status: string; message?: string }> { + const response = await stClient.post<{ status: string; message?: string }>( + `${ENDPOINT.ST_SERVICE.PPLTN}/admdst-renewal/save`, + { reducedList, addedList }, + ); + return response.data; + } + + /** 행정동 갱신 — 법정동코드 후보 조회 (행정동명 클릭 시) */ + public async getRenewalUpperCodesByName( + statsYr: string, + admdstNm: string, + ): Promise { + const response = await stClient.post( + `${ENDPOINT.ST_SERVICE.PPLTN}/admdst-renewal/upper-codes-by-name`, + { statsYr, admdstNm }, + ); + return response.data; + } +} + + + +export const statisticsPpltnApi = new StatisticsPpltnApiService(); \ No newline at end of file diff --git a/apps/st/src/entities/statistics/model/ppltn/statistics-domestic-population-interface.ts b/apps/st/src/entities/statistics/model/ppltn/statistics-domestic-population-interface.ts new file mode 100644 index 000000000..0325f6cf9 --- /dev/null +++ b/apps/st/src/entities/statistics/model/ppltn/statistics-domestic-population-interface.ts @@ -0,0 +1,62 @@ +import type { ApiResponse } from "@/entities/statistics/model/statistics-interface"; + +/* ══════════════════════════════════════════════════ + 16. 통계인구관리 > 내국인 인구 관련 인터페이스 +══════════════════════════════════════════════════ */ + +/** 내국인 인구 임시 적재 행 (좌측 전체 리스트 / 우측 미매핑 리스트 공통) */ +export interface DomesticPopulationItem { + statsYr: string; // 통계연도 + admdstCd: string; // 행정구역코드 + admdstNm: string; // 행정구역명 + domplWholPpltnCnt: number; // 내국인 합계 인구수 + domplMlPpltnCnt: number; // 내국인 남성 인구수 + domplFmlPpltnCnt: number; // 내국인 여성 인구수 + /** 미매핑 리스트 전용 - 매핑 대상 행정구역코드 (상위코드) */ + mpinCd: string; +} + +/** 엑셀 업로드 요청 파라미터 */ +export interface DomesticPopulationUploadParams { + file: File; + statsYr: string; +} + +/** 임시 적재 리스트 조회 요청 파라미터 */ +export interface DomesticPopulationListParams { + statsYr: string; + /** 1: 전체 리스트(좌측), 2: 미매핑 리스트(우측) */ + opt1: '1' | '2'; +} + +/** 수동 매핑 후 최종 반영 요청 파라미터 */ +export interface DomesticPopulationFinalParams { + YR: string; + /** 수동 매핑된 행 목록 (admdstCd → subAdmdstCd 매핑 정보) */ + regList: DomesticPopulationMappingItem[]; +} + +/** 수동 매핑 단건 아이템 */ +export interface DomesticPopulationMappingItem { + statsYr: string; + admdstCd: string; + admdstNm: string; + subAdmdstCd: string; +} + +/** 상위 코드 후보 조회 요청 */ +export interface UpperCodesByNameParams { + statsYr: string; + admdstNm: string; +} + +/** 상위 코드 후보 단건 아이템 */ +export interface UpperCodeCandidateItem { + adstCd: string; // 행정구역코드 + adstDstrNm: string; // 행정구역명 +} + +/* ── API 응답 타입 ── */ +export type DomesticPopulationListResponse = ApiResponse; +export type DomesticPopulationFinalResponse = ApiResponse; +export type UpperCodesByNameResponse = ApiResponse; \ No newline at end of file diff --git a/apps/st/src/shared/config/api.ts b/apps/st/src/shared/config/api.ts index c7e0afcf6..dfc788394 100644 --- a/apps/st/src/shared/config/api.ts +++ b/apps/st/src/shared/config/api.ts @@ -71,6 +71,7 @@ export const ENDPOINT = { INPUT: "/st-input-service/api/v1/input", REVIEW: "/st-management-service/api/v1/review", STATS: "/st-management-service/api/stats", + PPLTN: "/st-management-service/api/v1/ppltn", INPUTCOMMON: "/st-input-service/api/v1/common", INPUTVERIFY: "/st-input-service/api/v1/verify", TOTAL: "/st-management-service/api/v1/stats", From 2c27a67cc36588003b999540188aca19ad8941d5 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Thu, 18 Jun 2026 11:03:39 +0900 Subject: [PATCH 025/185] =?UTF-8?q?=EC=9C=84=EC=84=B1=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=B9=A8?= =?UTF-8?q?=EC=88=98=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EB=B2=94=EB=A1=80=20?= =?UTF-8?q?=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/(map-layout)/map/page.tsx | 21 +++- .../map/waterDepth/waterDepth.tsx | 113 ++++++++++++++++-- .../src/ui/map/lib/gis.core.js | 21 ++-- 3 files changed, 132 insertions(+), 23 deletions(-) diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 95f1cbb8a..dfb64a1ee 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -121,7 +121,7 @@ import { } from "./components/floodHistory/floodHistoryTypes"; import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup"; import SearchModal from "./searchModal/searchModal"; -import { WATER_DEPTH_LAYER_TYPES, WATER_DEPTH_LEGEND_ITEMS } from "./waterDepth/waterDepth"; +import { WATER_DEPTH_LAYER_TYPES, extractWaterDepthLegendItems } from "./waterDepth/waterDepth"; import { buildWaterRiskStatusLabel, EMPTY_WATER_RISK_SESSION_SNAPSHOT, @@ -2289,6 +2289,7 @@ const Map = () => { const [isWaterDepthLayerActive, setIsWaterDepthLayerActive] = useState(false); // 침수심 레이어 활성 여부 const [isPlaying, setIsPlaying] = useState(false); const [mapReady, setMapReady] = useState(false); + const [checkedLayerLegendVersion, setCheckedLayerLegendVersion] = useState(0); const [isAgedPipeChecked, setIsAgedPipeChecked] = useState(false); const [isSinkholeChecked, setIsSinkholeChecked] = useState(false); const [isFloodHistoryChecked, setIsFloodHistoryChecked] = useState(false); @@ -4524,6 +4525,7 @@ const Map = () => { // 체크된 레이어 변경 처리 함수 const handleCheckedLayersChange = useCallback((checkedIds: string[], checkedNodesData: any[]) => { checkedNodesDataRef.current = checkedNodesData; + setCheckedLayerLegendVersion((version) => version + 1); // 유하방향은 일반 setLayerOnMap 대상이 아니므로 체크 노드 여부를 별도로 추적한다. const flowDirectionNode = checkedNodesData.find((node) => @@ -4576,6 +4578,11 @@ const Map = () => { } }, [clearFloodHistorySelection, closeSinkholeDetail, handleWaterDepthVisibility, removeFlowDirectionLayer, removeSinkholeLayer, retainFlowDirectionUsageCodes]); + const waterDepthLegendItems = useMemo( + () => extractWaterDepthLegendItems(checkedNodesDataRef.current ?? []), + [checkedLayerLegendVersion], + ); + const handleOpenLayerPopup = useCallback( (_layerNm: string, node?: any) => { if (isAgedPipeLayerNode(node)) { @@ -4657,11 +4664,14 @@ const Map = () => { 침수심 범례
- {WATER_DEPTH_LEGEND_ITEMS.map((item, index) => ( -
+ {waterDepthLegendItems.map((item) => ( +
{item.label}
@@ -4687,6 +4697,7 @@ const Map = () => { lastSinkholeSyncSummary, sinkholeRadiusMeters, userInfo?.adminRoleYn, + waterDepthLegendItems, ]); // 선택된 설정이 있을 때만 하단 전용 섹션을 노출하고, 세로 공간이 부족하면 내부 스크롤로 처리한다. diff --git a/apps/sn/app/(map-layout)/map/waterDepth/waterDepth.tsx b/apps/sn/app/(map-layout)/map/waterDepth/waterDepth.tsx index e1a1fcb53..8b03a82e8 100644 --- a/apps/sn/app/(map-layout)/map/waterDepth/waterDepth.tsx +++ b/apps/sn/app/(map-layout)/map/waterDepth/waterDepth.tsx @@ -20,13 +20,101 @@ export const WATER_DEPTH_LAYER_TYPES: string[] = [ 'tb_ms_sbmrg_dngr_map' // 침수위험지도 ]; -export const WATER_DEPTH_LEGEND_ITEMS = [ - { color: "rgb(255, 255, 127)", label: "0.0~0.5m 미만" }, - { color: "rgb(191, 255, 0)", label: "0.5~1.0m 미만" }, - { color: "rgb(0, 255, 255)", label: "1.0~2.0m 미만" }, - { color: "rgb(191, 127, 255)", label: "2.0~5.0m 미만" }, - { color: "rgb(255, 0, 127)", label: "5.0m 이상" }, -] as const; +export type WaterDepthLegendItem = { + key: string; + label: string; + color: string; + borderColor: string; + sortSeq: number; +}; + +const getLayerTypeName = (layerInfo: any) => + String(layerInfo?.lyrTyNm ?? layerInfo?.lyrTypeNm ?? layerInfo?.lyr_type_nm ?? "").trim(); + +const getFiniteSortOrder = (value: unknown) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : Number.MAX_SAFE_INTEGER; +}; + +const getLegendNumericOrder = (label: string) => { + const normalized = String(label).trim(); + const rangeMatch = normalized.match(/^(\d+(?:\.\d+)?)~(\d+(?:\.\d+)?)m/); + if (rangeMatch) { + return { + start: Number(rangeMatch[1]), + end: Number(rangeMatch[2]), + priority: 0, + }; + } + + const overMatch = normalized.match(/^(\d+(?:\.\d+)?)m\s*이상/); + if (overMatch) { + const value = Number(overMatch[1]); + return { + start: value, + end: value, + priority: 1, + }; + } + + return { + start: Number.MAX_SAFE_INTEGER, + end: Number.MAX_SAFE_INTEGER, + priority: 2, + }; +}; + +const getLegendColor = (layerInfo: any) => + String(layerInfo?.polgnColor ?? layerInfo?.spacColor ?? layerInfo?.lnColor ?? layerInfo?.lineColor ?? "").trim(); + +const getLegendBorderColor = (layerInfo: any) => + String(layerInfo?.lnColor ?? layerInfo?.lineColor ?? layerInfo?.polgnColor ?? layerInfo?.spacColor ?? "").trim(); + +const hasLegendStyle = (layerInfo: any) => getLegendColor(layerInfo) !== "" || getLegendBorderColor(layerInfo) !== ""; + +const normalizeLegendLabel = (label: string) => { + const normalized = String(label).trim(); + return normalized.endsWith("미만") ? normalized : `${normalized} 미만`; +}; + +export const extractWaterDepthLegendItems = (checkedNodesData: any[]): WaterDepthLegendItem[] => { + if (!Array.isArray(checkedNodesData) || checkedNodesData.length === 0) return []; + + const items = new Map(); + + checkedNodesData.forEach((node) => { + const layerInfo = node?.data ?? {}; + const layerTypeName = getLayerTypeName(layerInfo); + if (!WATER_DEPTH_LAYER_TYPES.includes(layerTypeName)) return; + if (!hasLegendStyle(layerInfo)) return; + + const itemKey = String(node?.text ?? layerInfo?.lyrNm ?? "").trim(); + const itemColor = getLegendColor(layerInfo); + const itemBorderColor = getLegendBorderColor(layerInfo); + + if (!itemKey || !itemColor) return; + if (!items.has(itemKey)) { + items.set(itemKey, { + key: itemKey, + label: normalizeLegendLabel(itemKey), + color: itemColor, + borderColor: itemBorderColor || itemColor, + sortSeq: getFiniteSortOrder(layerInfo?.sortSeq), + }); + } + }); + + return Array.from(items.values()).sort((left, right) => { + const leftOrder = getLegendNumericOrder(left.key); + const rightOrder = getLegendNumericOrder(right.key); + + if (leftOrder.start !== rightOrder.start) return leftOrder.start - rightOrder.start; + if (leftOrder.priority !== rightOrder.priority) return leftOrder.priority - rightOrder.priority; + if (leftOrder.end !== rightOrder.end) return leftOrder.end - rightOrder.end; + if (left.sortSeq !== right.sortSeq) return left.sortSeq - right.sortSeq; + return left.label.localeCompare(right.label, "ko"); + }); +}; interface WaterDepthProps { onClose?: () => void; @@ -34,6 +122,7 @@ interface WaterDepthProps { setIsPlaying: (isPlaying: boolean) => void; totalSteps?: number; currentStep?: number; + legendItems?: WaterDepthLegendItem[]; } export function WaterDepth({ @@ -42,6 +131,7 @@ export function WaterDepth({ setIsPlaying, totalSteps = 0, currentStep = 0, + legendItems = [], }: WaterDepthProps) { useEffect(() => { @@ -80,10 +170,11 @@ export function WaterDepth({
침수심 범례
- {WATER_DEPTH_LEGEND_ITEMS.map((item, index) => ( + {legendItems.map((item) => ( ))} @@ -95,12 +186,12 @@ export function WaterDepth({ ); } -function LegendItem({ color, label }: { color: string; label: string }) { +function LegendItem({ color, borderColor, label }: { color: string; borderColor: string; label: string }) { return (
{label}
diff --git a/packages/common/business_components/src/ui/map/lib/gis.core.js b/packages/common/business_components/src/ui/map/lib/gis.core.js index b136cdcee..e9e652183 100644 --- a/packages/common/business_components/src/ui/map/lib/gis.core.js +++ b/packages/common/business_components/src/ui/map/lib/gis.core.js @@ -45,7 +45,7 @@ GisApp.GOOGLE_3D_URL = `https://tile.googleapis.com/v1/3dtiles/root.json?key=${G // NGII 정보(배경지도) //GisApp.NGII_WMTS_URL = process.env.NEXT_PUBLIC_NGII_URL; -GisApp.NGII_WMTS_URL = `/ngii-server/openapi/Gettile.do?apikey=${process.env.NEXT_PUBLIC_NGII_PARAM}`; +GisApp.NGII_WMTS_PARAM = process.env.NEXT_PUBLIC_NGII_PARAM ?? ''; GisApp.EMAP_URL = '/emap/'; GisApp.EMAP_SATELLITE_URL = '/emap/satellite/'; @@ -79,6 +79,17 @@ GisApp.setLayerInfos = function (layerInfos) { GisApp.layerInfos = layerInfos; }; +GisApp.getNgiiWmtsUrl = function (layerName, matrix, x, y) { + const layerAwareParams = /(^|&)layer=[^&]*/i.test(GisApp.NGII_WMTS_PARAM) + ? GisApp.NGII_WMTS_PARAM.replace(/(^|&)layer=[^&]*/i, `$1layer=${layerName}`) + : `${GisApp.NGII_WMTS_PARAM}&layer=${layerName}`; + + return `/ngii-server/openapi/Gettile.do?apikey=${layerAwareParams}` + .replace("{z}", matrix) + .replace("{x}", x) + .replace("{y}", y); +}; + /** * GisApp core * @requires gis.layer.js @@ -161,7 +172,7 @@ GisApp.core.prototype = { }); const wmtsSource = new TileWMTS({ - //url: GisApp.NGII_WMTS_URL, + // url은 setTileUrlFunction에서 layer별로 동적으로 만든다. projection: projection, tileGrid: tileGrid, crossOrigin: "anonymous", @@ -175,11 +186,7 @@ GisApp.core.prototype = { wmtsSource.setTileUrlFunction((tileCoord) => { const [z, x, y] = tileCoord; const matrix = matrixIds[z]; - // console.log(GisApp.NGII_WMTS_URL.replace("{z}", matrix).replace("{x}", x).replace("{y}", y)); - return GisApp.NGII_WMTS_URL - .replace("{z}", matrix) - .replace("{x}", x) - .replace("{y}", y); + return GisApp.getNgiiWmtsUrl(_layer, matrix, x, y); }); return new TileLayer({ From 82ab1376cc002e0bf3a917db48c61d5bb596a982 Mon Sep 17 00:00:00 2001 From: yangsein Date: Thu, 18 Jun 2026 15:38:10 +0900 Subject: [PATCH 026/185] =?UTF-8?q?feat:=ED=96=89=EC=A0=95=EB=8F=99=20?= =?UTF-8?q?=ED=83=AD=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../management/PopulationStatusChart.tsx | 106 +++++++++ .../StatisticsPopulationManagement.tsx | 202 ++++++++++++++++-- .../management/PopulationStatusColumn.ts | 121 +++++++++++ .../api/statistics-ppltn-service.ts | 60 ++++++ 4 files changed, 472 insertions(+), 17 deletions(-) create mode 100644 apps/st/src/components/statistics/management/PopulationStatusChart.tsx create mode 100644 apps/st/src/components/util/column/management/PopulationStatusColumn.ts diff --git a/apps/st/src/components/statistics/management/PopulationStatusChart.tsx b/apps/st/src/components/statistics/management/PopulationStatusChart.tsx new file mode 100644 index 000000000..1b7198135 --- /dev/null +++ b/apps/st/src/components/statistics/management/PopulationStatusChart.tsx @@ -0,0 +1,106 @@ +import { Chart } from "react-chartjs-2"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + BarElement, + LineElement, + PointElement, + Title, + Tooltip, + Legend, +} from "chart.js"; +import type { ChartOptions } from "chart.js"; + +ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend); + +// 차트 설정 외부 정의 (Mixed 타입 명시) +const options: ChartOptions<"bar" | "line"> = { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { stacked: true }, + y: { + stacked: true, + beginAtZero: true, + title: { display: true, text: "인구수(명)" }, + }, + y1: { + position: "right", + beginAtZero: true, + grid: { drawOnChartArea: false }, + title: { display: true, text: "총 인구(명)" }, + }, + }, + plugins: { + legend: { position: "bottom" }, + }, +}; + +export type PopulationChartData = { + X_AXIS: string[]; + TOT: number[]; + ORIGIN_MALE: (number | null)[]; + ORIGIN_FEMALE: (number | null)[]; + FOREIGN_MALE: (number | null)[]; + FOREIGN_FEMALE: (number | null)[]; +}; + +type Props = { + chartData: PopulationChartData | null; +}; + +const SERIES_META = [ + { key: "ORIGIN_MALE", name: "내국인 남성", color: "#3B82F6" }, + { key: "ORIGIN_FEMALE", name: "내국인 여성", color: "#EC4899" }, + { key: "FOREIGN_MALE", name: "외국인 남성", color: "#10B981" }, + { key: "FOREIGN_FEMALE", name: "외국인 여성", color: "#F59E0B" }, +] as const; + +const PopulationStatusChart = ({ chartData }: Props) => { + if (!chartData || chartData.X_AXIS.length === 0) { + return ( +
+ 조회된 데이터가 없습니다. 조회 버튼을 눌러주세요. +
+ ); + } + + const data = { + labels: chartData.X_AXIS.map((yr) => `${yr}년`), + datasets: [ + ...SERIES_META + .filter((s) => chartData[s.key]?.some((v) => v !== null)) + .map((s) => ({ + type: "bar" as const, + label: s.name, + data: chartData[s.key].map((v) => v ?? 0), + backgroundColor: s.color, + stack: "stack1", + })), + { + type: "line" as const, + label: "총 인구", + data: chartData.TOT, + borderColor: "#1F2937", + backgroundColor: "#1F2937", + borderDash: [5, 5], + tension: 0.1, + yAxisID: "y1", + }, + ], + }; + + return ( +
+ {/* 제네릭 타입을 통해 Mixed Chart 지원, 부모 컨테이너 크기에 맞춰 반응형으로 리사이즈됨 */} + + type="bar" + data={data} + options={options} + /> +
+ ); +}; + +export default PopulationStatusChart; diff --git a/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx b/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx index e1d7ecd94..5c2f0cd0f 100644 --- a/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx +++ b/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx @@ -21,10 +21,12 @@ import { AdmdstRenewalReducedColumnConfig, AdmdstRenewalAddedColumnConfig, } from "@/components/util/column/management/AdmdstRenewalColumn"; +import { PopulationStatusColumnConfig } from "@/components/util/column/management/PopulationStatusColumn"; import { statisticsPpltnApi } from "@/entities/statistics/api/statistics-ppltn-service"; import { getUserInfo } from "../../../../../../packages/common/components/src/api"; import type { UpperCodeCandidateItem } from "@/entities/statistics/model/ppltn/statistics-domestic-population-interface"; import { UpperCodeSelectDialog } from "./UpperCodeSelectDialog"; +import PopulationStatusChart, { type PopulationChartData } from "./PopulationStatusChart"; const TAB_LIST = [ { key: "domestic", label: "행정인구등록" }, @@ -59,6 +61,10 @@ const renewalAddedColumnInfo: ColumnInfo = { columns: AdmdstRenewalAddedColumnConfig.columns ?? [], columnLayout: AdmdstRenewalAddedColumnConfig.columnLayout ?? [], }; +const populationStatusColumnInfo: ColumnInfo = { + columns: PopulationStatusColumnConfig.columns ?? [], + columnLayout: PopulationStatusColumnConfig.columnLayout ?? [], +}; /* ── 엑셀 헤더 검증 ── */ const validateExcelHeader = (file: File, requiredKeywords: string[]): Promise => { @@ -107,6 +113,21 @@ const StatisticsPopulationManagement = () => { const [renewalReducedRowData, setRenewalReducedRowData] = useState[]>([]); const [renewalAddedRowData, setRenewalAddedRowData] = useState[]>([]); + /* ── 행정인구현황 데이터 ── */ + const [statusRowData, setStatusRowData] = useState[]>([]); + const [statusOMale, setStatusOMale] = useState(true); + const [statusOFemale, setStatusOFemale] = useState(true); + const [statusFMale, setStatusFMale] = useState(true); + const [statusFFemale, setStatusFFemale] = useState(true); + + /* ── 행정인구현황 시각화 데이터 ── */ + const [vizYear, setVizYear] = useState(String(new Date().getFullYear() - 1)); + const [vizOMale, setVizOMale] = useState(true); + const [vizOFemale, setVizOFemale] = useState(true); + const [vizFMale, setVizFMale] = useState(true); + const [vizFFemale, setVizFFemale] = useState(true); + const [chartData, setChartData] = useState(null); + /* ── 상위 코드 다이얼로그 ── */ const [dialogOpen, setDialogOpen] = useState(false); const [candidates, setCandidates] = useState([]); @@ -340,6 +361,38 @@ const StatisticsPopulationManagement = () => { } }; + /* ════════════════ 행정인구현황 시각화 ════════════════ */ + const handleVizSearch = async (yr: string) => { + if (!yr) { alert("연도를 선택해주세요."); return; } + try { + setIsLoading(true); + const res = await statisticsPpltnApi.getChartDatas1(yr, vizOMale, vizOFemale, vizFMale, vizFFemale); + if (res.status === "success") { + setChartData(res.data as PopulationChartData); + } else { + alert(res.message || "조회 중 오류가 발생했습니다."); + } + } catch { + alert("서버 연결 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const handleVizSelectAll = () => { + setVizOMale(true); + setVizOFemale(true); + setVizFMale(true); + setVizFFemale(true); + }; + + const handleVizDeselectAll = () => { + setVizOMale(false); + setVizOFemale(false); + setVizFMale(false); + setVizFFemale(false); + }; + /* ════════════════ 공통 ════════════════ */ const handleSearch = async (values: SearchFormValues) => { lastSearchedValuesRef.current = values; @@ -375,6 +428,32 @@ const StatisticsPopulationManagement = () => { } } else if (activeTab === "renewal") { await loadRenewalData(values); + } else if (activeTab === "status") { + const yr = values.year ?? ""; + if (!yr) { alert("연도를 선택해주세요."); return; } + if (!statusOMale && !statusOFemale && !statusFMale && !statusFFemale) { + alert("체크 박스를 모두 해제하신 상태에서는\n조회가 불가능합니다.\n(최소 1개 이상의 체크 박스 선택 후\n조회를 진행해주세요.)"); + return; + } + try { + setIsLoading(true); + const res = await statisticsPpltnApi.searchPopByEachCondition( + yr, statusOMale, statusOFemale, statusFMale, statusFFemale, + ); + if (res.status === "success") { + setStatusRowData(toGridRows((res.data as object[]) ?? [])); + } else { + alert(res.message || "조회 중 오류가 발생했습니다."); + } + } catch { + alert("서버 연결 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + } else if (activeTab === "statusViz") { + const yr = values.year ?? ""; + setVizYear(yr); + await handleVizSearch(yr); } }; @@ -385,6 +464,17 @@ const StatisticsPopulationManagement = () => { setForeignRightRowData([]); setRenewalReducedRowData([]); setRenewalAddedRowData([]); + setStatusRowData([]); + setStatusOMale(true); + setStatusOFemale(true); + setStatusFMale(true); + setStatusFFemale(true); + setVizYear(String(new Date().getFullYear() - 1)); + setVizOMale(true); + setVizOFemale(true); + setVizFMale(true); + setVizFFemale(true); + setChartData(null); setActiveTab("domestic"); lastSearchedValuesRef.current = null; }; @@ -483,7 +573,57 @@ const StatisticsPopulationManagement = () => { - +
+ + {activeTab === "status" && ( +
+ + + + +
+ )} + {activeTab === "statusViz" && ( +
+ + + + + + +
+ )} +
{isLoading && (
@@ -510,7 +650,7 @@ const StatisticsPopulationManagement = () => { 엑셀 업로드 자료 (내국인 전체 리스트)
-
+
-
+
+ + 엑셀 업로드 자료 (외국인 전체 리스트) + +
+ + {/* pr-8 (오른쪽 패딩)을 추가하여 버튼을 왼쪽으로 밀어냅니다 */} +
+ +
{
-
+
감축된 행정구역(범위:동읍면) 자료
-
+
{
-
행정인구현황 구현 예정
+ {/* 그리드 라벨 */} +
+ + 행정 인구 현황 + +
+ +
+ + {/* ── 행정인구현황 시각화 탭 ── */} -
행정인구현황 시각화 구현 예정
+ + 행정인구현황 시각화 (기준연도 ~ 과거 자료(최대 5년) / *단위: 명) + + +
+ +
diff --git a/apps/st/src/components/util/column/management/PopulationStatusColumn.ts b/apps/st/src/components/util/column/management/PopulationStatusColumn.ts new file mode 100644 index 000000000..3ea57358e --- /dev/null +++ b/apps/st/src/components/util/column/management/PopulationStatusColumn.ts @@ -0,0 +1,121 @@ +import { ValueType } from "realgrid"; +import type { ColumnInfoList } from "@/components/type/DataGrid.type"; + +/* ══════════════════════════════════════════════════ + 16. 통계인구관리 > 행정인구현황 탭 컬럼 설정 +══════════════════════════════════════════════════ */ + +export const PopulationStatusColumnConfig: ColumnInfoList = { + tabKey: "POPULATION_STATUS", + tabLabel: "행정 인구 현황", + rowStartNum: 1, + fixedColumns: 3, + columns: [ + { + name: "statsYr", + fieldName: "statsYr", + header: { text: "년도" }, + width: 90, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "admdstCd", + fieldName: "admdstCd", + header: { text: "행정구역코드" }, + width: 140, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "admdstNm", + fieldName: "admdstNm", + header: { text: "행정구역명" }, + width: 200, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "wholPpltnCnt", + fieldName: "wholPpltnCnt", + header: { text: "총인구" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "domplWholPpltnCnt", + fieldName: "domplWholPpltnCnt", + header: { text: "내국인 소계" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "domplMlPpltnCnt", + fieldName: "domplMlPpltnCnt", + header: { text: "내국인 남성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "domplFmlPpltnCnt", + fieldName: "domplFmlPpltnCnt", + header: { text: "내국인 여성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "frgnrWholPpltnCnt", + fieldName: "frgnrWholPpltnCnt", + header: { text: "외국인 소계" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "frgnrMlPpltnCnt", + fieldName: "frgnrMlPpltnCnt", + header: { text: "외국인 남성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "frgnrFmlPpltnCnt", + fieldName: "frgnrFmlPpltnCnt", + header: { text: "외국인 여성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + ], + columnLayout: [ + "statsYr", + "admdstCd", + "admdstNm", + "wholPpltnCnt", + "domplWholPpltnCnt", + "domplMlPpltnCnt", + "domplFmlPpltnCnt", + "frgnrWholPpltnCnt", + "frgnrMlPpltnCnt", + "frgnrFmlPpltnCnt", + ], +}; \ No newline at end of file diff --git a/apps/st/src/entities/statistics/api/statistics-ppltn-service.ts b/apps/st/src/entities/statistics/api/statistics-ppltn-service.ts index b4eb5a0aa..c4fbec777 100644 --- a/apps/st/src/entities/statistics/api/statistics-ppltn-service.ts +++ b/apps/st/src/entities/statistics/api/statistics-ppltn-service.ts @@ -8,6 +8,20 @@ import type { DomesticPopulationMappingItem, } from "../model/ppltn/statistics-domestic-population-interface"; +// 1. 응답 데이터의 타입을 정의합니다 (이미 정의된 것이 있다면 그것을 사용하세요) +interface ChartDataResponse { + status: string; + data: { + X_AXIS: string[]; + TOT: number[]; + ORIGIN_MALE: (number | null)[]; + ORIGIN_FEMALE: (number | null)[]; + FOREIGN_MALE: (number | null)[]; + FOREIGN_FEMALE: (number | null)[]; + }; + message?: string; +} + export class StatisticsPpltnApiService { /** 내국인 엑셀 임시 업로드 */ @@ -154,6 +168,52 @@ export class StatisticsPpltnApiService { ); return response.data; } + + /* ════════════════════════════════════ + 행정인구현황 + ════════════════════════════════════ */ + + /** 행정인구현황 — 체크박스 조건에 따른 인구 현황 조회 */ + public async searchPopByEachCondition( + statsYr: string, + oMale: boolean, + oFemale: boolean, + fMale: boolean, + fFemale: boolean, + ): Promise<{ status: string; data: object[]; message?: string }> { + const response = await stClient.post<{ status: string; data: object[]; message?: string }>( + `${ENDPOINT.ST_SERVICE.PPLTN}/admdst-renewal/pop-by-condition`, + { + statsYr, + oMale: oMale ? "TRUE" : "FALSE", + oFemale: oFemale ? "TRUE" : "FALSE", + fMale: fMale ? "TRUE" : "FALSE", + fFemale: fFemale ? "TRUE" : "FALSE", + }, + ); + return response.data; + } + +/** 행정인구현황 시각화 — 기준연도 기준 최대 5년치 차트 데이터 조회 */ + public async getChartDatas1( + statsYr: string, + oMale: boolean, + oFemale: boolean, + fMale: boolean, + fFemale: boolean, + ): Promise { + const response = await stClient.post( + `${ENDPOINT.ST_SERVICE.PPLTN}/admdst-renewal/chart-datas`, + { + statsYr, + oMale: oMale ? "TRUE" : "FALSE", + oFemale: oFemale ? "TRUE" : "FALSE", + fMale: fMale ? "TRUE" : "FALSE", + fFemale: fFemale ? "TRUE" : "FALSE", + }, + ); + return response.data; + } } From bdd6c7b804d10f3eadf437058c2f048669b5a06f Mon Sep 17 00:00:00 2001 From: ljpark Date: Fri, 19 Jun 2026 16:44:30 +0900 Subject: [PATCH 027/185] =?UTF-8?q?dm:=20theme=20css=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/wr/global.d.ts | 1 + apps/wr/src/app/index.tsx | 4 + apps/wr/src/app/styles/components-black.css | 85 +++++++++++++++++++++ apps/wr/src/app/styles/components-blue.css | 31 ++++++++ apps/wr/src/app/styles/components-green.css | 31 ++++++++ apps/wr/src/app/styles/components-red.css | 30 ++++++++ apps/wr/src/app/styles/components.css | 65 +--------------- 7 files changed, 183 insertions(+), 64 deletions(-) create mode 100644 apps/wr/global.d.ts create mode 100644 apps/wr/src/app/styles/components-black.css create mode 100644 apps/wr/src/app/styles/components-blue.css create mode 100644 apps/wr/src/app/styles/components-green.css create mode 100644 apps/wr/src/app/styles/components-red.css diff --git a/apps/wr/global.d.ts b/apps/wr/global.d.ts new file mode 100644 index 000000000..cbe652dbe --- /dev/null +++ b/apps/wr/global.d.ts @@ -0,0 +1 @@ +declare module "*.css"; diff --git a/apps/wr/src/app/index.tsx b/apps/wr/src/app/index.tsx index 692b55ef0..4cf2ad5fa 100644 --- a/apps/wr/src/app/index.tsx +++ b/apps/wr/src/app/index.tsx @@ -1,5 +1,9 @@ import "./styles/base.css"; import "./styles/components.css"; +import "./styles/components-black.css"; +import "./styles/components-blue.css"; +import "./styles/components-green.css"; +import "./styles/components-red.css"; import "@fontsource-variable/noto-sans-kr"; import { AppProvider } from "./providers"; diff --git a/apps/wr/src/app/styles/components-black.css b/apps/wr/src/app/styles/components-black.css new file mode 100644 index 000000000..fd651fe6c --- /dev/null +++ b/apps/wr/src/app/styles/components-black.css @@ -0,0 +1,85 @@ +@tailwind components; + +@layer components { + .card { + @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666]; + } + .card-border { + @apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur; + } + .card-header { + @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4; + } + .card-footer { + @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; + } + + [data-theme="black"] { + .card-border { + @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; + } + .card-header { + @apply border-b-[#3f4042]; + } + + .gis-layer-panel { + background: #111214; + border-right-color: #2f3136; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.03); + } + + .gis-layer-panel-title { + color: #f3f4f6; + } + + .gis-layer-panel hr { + border-color: #2f3136; + } + + .gis-layer-row { + color: #e5e7eb; + } + + .gis-layer-row:hover { + background: rgba(255, 255, 255, 0.08); + } + + .gis-layer-row span, + .gis-layer-row label { + color: #e5e7eb !important; + } + + .gis-layer-row input[type="checkbox"] { + accent-color: #c026d3; + } + + .gis-layer-action-button { + background: #1a1c20; + border-color: #3b82f6; + color: #cfe3ff; + } + + .gis-layer-action-button:hover { + background: #23262b; + } + + .gis-layer-setting { + background: #111214; + border-top: 1px solid #2f3136; + } + + .gis-layer-toggle { + background: #111214; + border-color: #2f3136; + color: #f3f4f6; + } + + .gis-layer-toggle:hover { + background: #1a1c20; + } + } + + input[type="file"] { + @apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer; + } +} diff --git a/apps/wr/src/app/styles/components-blue.css b/apps/wr/src/app/styles/components-blue.css new file mode 100644 index 000000000..d83d93ca0 --- /dev/null +++ b/apps/wr/src/app/styles/components-blue.css @@ -0,0 +1,31 @@ +@tailwind components; + +@layer components { + .card { + @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666]; + } + .card-border { + @apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur; + } + .card-header { + @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4; + } + .card-footer { + @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; + } + + [data-theme="blue"] { + .card-border { + @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; + } + .card-header { + @apply border-b-[#3f4042]; + } + + + } + + input[type="file"] { + @apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer; + } +} diff --git a/apps/wr/src/app/styles/components-green.css b/apps/wr/src/app/styles/components-green.css new file mode 100644 index 000000000..b4b4b3154 --- /dev/null +++ b/apps/wr/src/app/styles/components-green.css @@ -0,0 +1,31 @@ +@tailwind components; + +@layer components { + .card { + @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666]; + } + .card-border { + @apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur; + } + .card-header { + @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4; + } + .card-footer { + @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; + } + + [data-theme="green"] { + .card-border { + @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; + } + .card-header { + @apply border-b-[#3f4042]; + } + + + } + + input[type="file"] { + @apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer; + } +} diff --git a/apps/wr/src/app/styles/components-red.css b/apps/wr/src/app/styles/components-red.css new file mode 100644 index 000000000..6d626d44d --- /dev/null +++ b/apps/wr/src/app/styles/components-red.css @@ -0,0 +1,30 @@ +@tailwind components; + +@layer components { + .card { + @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666]; + } + .card-border { + @apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur; + } + .card-header { + @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4; + } + .card-footer { + @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; + } + + [data-theme="red"] { + .card-border { + @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; + } + .card-header { + @apply border-b-[#3f4042]; + } + + } + + input[type="file"] { + @apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer; + } +} diff --git a/apps/wr/src/app/styles/components.css b/apps/wr/src/app/styles/components.css index fd651fe6c..cc6c1a46e 100644 --- a/apps/wr/src/app/styles/components.css +++ b/apps/wr/src/app/styles/components.css @@ -14,70 +14,7 @@ @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; } - [data-theme="black"] { - .card-border { - @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; - } - .card-header { - @apply border-b-[#3f4042]; - } - - .gis-layer-panel { - background: #111214; - border-right-color: #2f3136; - box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.03); - } - - .gis-layer-panel-title { - color: #f3f4f6; - } - - .gis-layer-panel hr { - border-color: #2f3136; - } - - .gis-layer-row { - color: #e5e7eb; - } - - .gis-layer-row:hover { - background: rgba(255, 255, 255, 0.08); - } - - .gis-layer-row span, - .gis-layer-row label { - color: #e5e7eb !important; - } - - .gis-layer-row input[type="checkbox"] { - accent-color: #c026d3; - } - - .gis-layer-action-button { - background: #1a1c20; - border-color: #3b82f6; - color: #cfe3ff; - } - - .gis-layer-action-button:hover { - background: #23262b; - } - - .gis-layer-setting { - background: #111214; - border-top: 1px solid #2f3136; - } - - .gis-layer-toggle { - background: #111214; - border-color: #2f3136; - color: #f3f4f6; - } - - .gis-layer-toggle:hover { - background: #1a1c20; - } - } + input[type="file"] { @apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer; From 3d27e07ccb23c2c4666afa10d584c9b7758c7f49 Mon Sep 17 00:00:00 2001 From: ljpark Date: Fri, 19 Jun 2026 17:27:30 +0900 Subject: [PATCH 028/185] =?UTF-8?q?fo:=20theme=20css=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/fo/src/app/index.tsx | 4 +++ apps/fo/src/app/styles/components-black.css | 29 +++++++++++++++++++++ apps/fo/src/app/styles/components-blue.css | 29 +++++++++++++++++++++ apps/fo/src/app/styles/components-green.css | 29 +++++++++++++++++++++ apps/fo/src/app/styles/components-red.css | 29 +++++++++++++++++++++ apps/fo/src/app/styles/components.css | 9 ------- apps/sn/src/app/index.tsx | 6 +++++ apps/sn/src/app/styles/components-black.css | 29 +++++++++++++++++++++ apps/sn/src/app/styles/components-blue.css | 29 +++++++++++++++++++++ apps/sn/src/app/styles/components-green.css | 29 +++++++++++++++++++++ apps/sn/src/app/styles/components-red.css | 29 +++++++++++++++++++++ apps/sn/src/app/styles/components.css | 9 ------- 12 files changed, 242 insertions(+), 18 deletions(-) create mode 100644 apps/fo/src/app/styles/components-black.css create mode 100644 apps/fo/src/app/styles/components-blue.css create mode 100644 apps/fo/src/app/styles/components-green.css create mode 100644 apps/fo/src/app/styles/components-red.css create mode 100644 apps/sn/src/app/styles/components-black.css create mode 100644 apps/sn/src/app/styles/components-blue.css create mode 100644 apps/sn/src/app/styles/components-green.css create mode 100644 apps/sn/src/app/styles/components-red.css diff --git a/apps/fo/src/app/index.tsx b/apps/fo/src/app/index.tsx index 692b55ef0..4cf2ad5fa 100644 --- a/apps/fo/src/app/index.tsx +++ b/apps/fo/src/app/index.tsx @@ -1,5 +1,9 @@ import "./styles/base.css"; import "./styles/components.css"; +import "./styles/components-black.css"; +import "./styles/components-blue.css"; +import "./styles/components-green.css"; +import "./styles/components-red.css"; import "@fontsource-variable/noto-sans-kr"; import { AppProvider } from "./providers"; diff --git a/apps/fo/src/app/styles/components-black.css b/apps/fo/src/app/styles/components-black.css new file mode 100644 index 000000000..4dc30bafd --- /dev/null +++ b/apps/fo/src/app/styles/components-black.css @@ -0,0 +1,29 @@ +@tailwind components; + +@layer components { + .card { + @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666]; + } + .card-border { + @apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur; + } + .card-header { + @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4; + } + .card-footer { + @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; + } + + [data-theme="black"] { + .card-border { + @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; + } + .card-header { + @apply border-b-[#3f4042]; + } + } + + input[type="file"] { + @apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer; + } +} diff --git a/apps/fo/src/app/styles/components-blue.css b/apps/fo/src/app/styles/components-blue.css new file mode 100644 index 000000000..7c1e9db27 --- /dev/null +++ b/apps/fo/src/app/styles/components-blue.css @@ -0,0 +1,29 @@ +@tailwind components; + +@layer components { + .card { + @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666]; + } + .card-border { + @apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur; + } + .card-header { + @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4; + } + .card-footer { + @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; + } + + [data-theme="blue"] { + .card-border { + @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; + } + .card-header { + @apply border-b-[#3f4042]; + } + } + + input[type="file"] { + @apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer; + } +} diff --git a/apps/fo/src/app/styles/components-green.css b/apps/fo/src/app/styles/components-green.css new file mode 100644 index 000000000..4b4cc770e --- /dev/null +++ b/apps/fo/src/app/styles/components-green.css @@ -0,0 +1,29 @@ +@tailwind components; + +@layer components { + .card { + @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666]; + } + .card-border { + @apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur; + } + .card-header { + @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4; + } + .card-footer { + @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; + } + + [data-theme="green"] { + .card-border { + @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; + } + .card-header { + @apply border-b-[#3f4042]; + } + } + + input[type="file"] { + @apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer; + } +} diff --git a/apps/fo/src/app/styles/components-red.css b/apps/fo/src/app/styles/components-red.css new file mode 100644 index 000000000..cadffac52 --- /dev/null +++ b/apps/fo/src/app/styles/components-red.css @@ -0,0 +1,29 @@ +@tailwind components; + +@layer components { + .card { + @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666]; + } + .card-border { + @apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur; + } + .card-header { + @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4; + } + .card-footer { + @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; + } + + [data-theme="red"] { + .card-border { + @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; + } + .card-header { + @apply border-b-[#3f4042]; + } + } + + input[type="file"] { + @apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer; + } +} diff --git a/apps/fo/src/app/styles/components.css b/apps/fo/src/app/styles/components.css index 4dc30bafd..3aa3c770d 100644 --- a/apps/fo/src/app/styles/components.css +++ b/apps/fo/src/app/styles/components.css @@ -14,15 +14,6 @@ @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; } - [data-theme="black"] { - .card-border { - @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; - } - .card-header { - @apply border-b-[#3f4042]; - } - } - input[type="file"] { @apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer; } diff --git a/apps/sn/src/app/index.tsx b/apps/sn/src/app/index.tsx index 29d73b03f..d70dc3d73 100644 --- a/apps/sn/src/app/index.tsx +++ b/apps/sn/src/app/index.tsx @@ -1,3 +1,9 @@ +import "./styles/base.css"; +import "./styles/components.css"; +import "./styles/components-black.css"; +import "./styles/components-blue.css"; +import "./styles/components-green.css"; +import "./styles/components-red.css"; import { AppProvider } from "./providers"; import { cookies } from "next/headers"; diff --git a/apps/sn/src/app/styles/components-black.css b/apps/sn/src/app/styles/components-black.css new file mode 100644 index 000000000..4dc30bafd --- /dev/null +++ b/apps/sn/src/app/styles/components-black.css @@ -0,0 +1,29 @@ +@tailwind components; + +@layer components { + .card { + @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666]; + } + .card-border { + @apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur; + } + .card-header { + @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4; + } + .card-footer { + @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; + } + + [data-theme="black"] { + .card-border { + @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; + } + .card-header { + @apply border-b-[#3f4042]; + } + } + + input[type="file"] { + @apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer; + } +} diff --git a/apps/sn/src/app/styles/components-blue.css b/apps/sn/src/app/styles/components-blue.css new file mode 100644 index 000000000..7c1e9db27 --- /dev/null +++ b/apps/sn/src/app/styles/components-blue.css @@ -0,0 +1,29 @@ +@tailwind components; + +@layer components { + .card { + @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666]; + } + .card-border { + @apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur; + } + .card-header { + @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4; + } + .card-footer { + @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; + } + + [data-theme="blue"] { + .card-border { + @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; + } + .card-header { + @apply border-b-[#3f4042]; + } + } + + input[type="file"] { + @apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer; + } +} diff --git a/apps/sn/src/app/styles/components-green.css b/apps/sn/src/app/styles/components-green.css new file mode 100644 index 000000000..4b4cc770e --- /dev/null +++ b/apps/sn/src/app/styles/components-green.css @@ -0,0 +1,29 @@ +@tailwind components; + +@layer components { + .card { + @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666]; + } + .card-border { + @apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur; + } + .card-header { + @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4; + } + .card-footer { + @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; + } + + [data-theme="green"] { + .card-border { + @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; + } + .card-header { + @apply border-b-[#3f4042]; + } + } + + input[type="file"] { + @apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer; + } +} diff --git a/apps/sn/src/app/styles/components-red.css b/apps/sn/src/app/styles/components-red.css new file mode 100644 index 000000000..cadffac52 --- /dev/null +++ b/apps/sn/src/app/styles/components-red.css @@ -0,0 +1,29 @@ +@tailwind components; + +@layer components { + .card { + @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666]; + } + .card-border { + @apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur; + } + .card-header { + @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4; + } + .card-footer { + @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; + } + + [data-theme="red"] { + .card-border { + @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; + } + .card-header { + @apply border-b-[#3f4042]; + } + } + + input[type="file"] { + @apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer; + } +} diff --git a/apps/sn/src/app/styles/components.css b/apps/sn/src/app/styles/components.css index 4dc30bafd..3aa3c770d 100644 --- a/apps/sn/src/app/styles/components.css +++ b/apps/sn/src/app/styles/components.css @@ -14,15 +14,6 @@ @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; } - [data-theme="black"] { - .card-border { - @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; - } - .card-header { - @apply border-b-[#3f4042]; - } - } - input[type="file"] { @apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer; } From 4b5ceb0e0a784af2a91d7163382c6e916a7f5c69 Mon Sep 17 00:00:00 2001 From: Leeinseo Date: Mon, 22 Jun 2026 13:06:30 +0900 Subject: [PATCH 029/185] FO dashboard-red theme edit-Leeinseo --- .../pages/(with-layout)/dashboard/facilityInfo/page.tsx | 8 ++++---- apps/fo/app/pages/(with-layout)/dashboardMain/page.tsx | 2 +- apps/fo/src/app/styles/components-red.css | 4 ++-- packages/common/business_components/src/ui/tab/index.tsx | 2 +- .../business_components/src/ui/table/HeadMakeColSpan.tsx | 2 +- .../common/business_components/src/ui/table/index.tsx | 2 +- packages/common/components/src/ui/tabs/tabs.css | 2 ++ 7 files changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/fo/app/pages/(with-layout)/dashboard/facilityInfo/page.tsx b/apps/fo/app/pages/(with-layout)/dashboard/facilityInfo/page.tsx index e65fe3cc8..d4cfb7bab 100644 --- a/apps/fo/app/pages/(with-layout)/dashboard/facilityInfo/page.tsx +++ b/apps/fo/app/pages/(with-layout)/dashboard/facilityInfo/page.tsx @@ -226,7 +226,7 @@ export default function ProcessFacilitySearch({searchYear = '', fcltyList = []}:
- +
{upChangeRePlanFclty} @@ -239,7 +239,7 @@ export default function ProcessFacilitySearch({searchYear = '', fcltyList = []}: :((index === 6 || index === 7 || index === 8) && (head.id === 'now' || head.id === 'stepOne' || head.id === 'stepThree' || head.id === 'stepFour'))? null :((index === 5) && (head.id === 'provisionalApproval' || head.id === 'stepTwo'))? - {el[head.id]} + {el[head.id]} :((index === 5) && (head.id === 'now' || head.id === 'stepOne' || head.id === 'stepThree'|| head.id === 'stepFour'))? null :(index < 5 || index > 5)&& (index!= 4) && (head.id ==='provisionalApproval'||head.id ==='now'||head.id ==='stepOne'||head.id ==='stepTwo'||head.id ==='stepThree'||head.id ==='stepFour') @@ -250,7 +250,7 @@ export default function ProcessFacilitySearch({searchYear = '', fcltyList = []}: ?null :(head.id === 'item' && index != 5)? {el[head.id]} - :{el[head.id]} + :{el[head.id]} ))} ))} @@ -260,7 +260,7 @@ export default function ProcessFacilitySearch({searchYear = '', fcltyList = []}:
-
+
{upChangeHeadListOperationStatus} diff --git a/apps/fo/app/pages/(with-layout)/dashboardMain/page.tsx b/apps/fo/app/pages/(with-layout)/dashboardMain/page.tsx index 67e704570..05d906500 100644 --- a/apps/fo/app/pages/(with-layout)/dashboardMain/page.tsx +++ b/apps/fo/app/pages/(with-layout)/dashboardMain/page.tsx @@ -1,5 +1,5 @@ /** - * 대시보드 - 표로보기 상단구역 페에지 + * 대시보드 - 표로보기 상단구역 페이지 * * - 대시보드 - 표로보기의 3개의 탭 상단에 조회년도, 행정정보, 시설명의 구역을 컴포넌트 */ diff --git a/apps/fo/src/app/styles/components-red.css b/apps/fo/src/app/styles/components-red.css index cadffac52..c78e23183 100644 --- a/apps/fo/src/app/styles/components-red.css +++ b/apps/fo/src/app/styles/components-red.css @@ -15,12 +15,12 @@ } [data-theme="red"] { - .card-border { + /* .card-border { @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; } .card-header { @apply border-b-[#3f4042]; - } + } */ } input[type="file"] { diff --git a/packages/common/business_components/src/ui/tab/index.tsx b/packages/common/business_components/src/ui/tab/index.tsx index 273a4b907..2c69f3f7d 100644 --- a/packages/common/business_components/src/ui/tab/index.tsx +++ b/packages/common/business_components/src/ui/tab/index.tsx @@ -30,7 +30,7 @@ const UITab = React.forwardRef< {tab.label} diff --git a/packages/common/business_components/src/ui/table/HeadMakeColSpan.tsx b/packages/common/business_components/src/ui/table/HeadMakeColSpan.tsx index b3d3fdc40..3ad4c8e23 100644 --- a/packages/common/business_components/src/ui/table/HeadMakeColSpan.tsx +++ b/packages/common/business_components/src/ui/table/HeadMakeColSpan.tsx @@ -29,7 +29,7 @@ const HeadMakeColSpan = ({ upHeadList }: { upHeadList: TableUpperProps[] }) => { colSpan={head.colSpan || 1} rowSpan={head.rowSpan || 1} style={{ whiteSpace: 'pre-line' }} - className="bg-gray-50" + className="" > {head.title} diff --git a/packages/common/business_components/src/ui/table/index.tsx b/packages/common/business_components/src/ui/table/index.tsx index ed05a7f19..15aef7fa9 100644 --- a/packages/common/business_components/src/ui/table/index.tsx +++ b/packages/common/business_components/src/ui/table/index.tsx @@ -152,7 +152,7 @@ export function UiTable>({ <> )}
-
+
{widthFixed ? {leafCols.map(col => ( diff --git a/packages/common/components/src/ui/tabs/tabs.css b/packages/common/components/src/ui/tabs/tabs.css index b1227b0ce..bb097be0e 100644 --- a/packages/common/components/src/ui/tabs/tabs.css +++ b/packages/common/components/src/ui/tabs/tabs.css @@ -22,4 +22,6 @@ .tabs[data-variant="outline"] .tabs-trigger { @apply relative rounded-none rounded-t-sm border-border bg-background text-muted-foreground after:absolute after:bottom-0 after:left-0 after:h-[0.1rem] after:w-full after:bg-border after:content-[''] data-[state=active]:border data-[state=active]:border-b-0 data-[state=active]:border-border data-[state=active]:text-foreground data-[state=active]:after:bg-transparent; } + + } From 4f10864c2cca82b42f37ab6897a2ce3d397d69bb Mon Sep 17 00:00:00 2001 From: LGS Date: Mon, 22 Jun 2026 13:09:20 +0900 Subject: [PATCH 030/185] =?UTF-8?q?[=20wr=20]=20=EB=AC=BC=EC=9E=AC?= =?UTF-8?q?=EC=9D=B4=EC=9A=A9=20=EB=A7=A4=EC=B9=AD=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20Black=20=ED=85=8C=EB=A7=88=20css=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/wr/src/app/styles/components-black.css | 53 +++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/apps/wr/src/app/styles/components-black.css b/apps/wr/src/app/styles/components-black.css index fd651fe6c..d337b1ba1 100644 --- a/apps/wr/src/app/styles/components-black.css +++ b/apps/wr/src/app/styles/components-black.css @@ -15,6 +15,52 @@ } [data-theme="black"] { + .card { + color: #fff; + background-color: #0a0a0a !important; + } + form { + color: rgb(255 255 255); + } + label { + color: rgb(241, 241, 241); + } + input { + height: 3.2rem; + background-color: #1a1a1a; + border: 1px solid #262626; + border-radius: 4px; + } + input.text-gray-600 { + color: #f1f1f1; + } + button { + color: #171717; + } + button[role="combobox"] { + color: #f1f1f1; + background-color: rgb(26, 26, 26); + } + div [role='tablist'] { + border: unset; + gap: 4px; + } + .tabs-trigger[data-state="inactive"] { + color: #a4b7bf; + background: #55595d; + } + .tabs-trigger[data-state="inactive"]:hover { + color: #6bbaff; + background: #4c4f52; + } + .card-border { + box-shadow: 0 0 10px -1px #0000000f; + border: 1px solid #393939; + } + a[aria-current="page"] { + background-color: rgb(32, 148, 250); + color: #000; + } .card-border { @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; } @@ -77,6 +123,13 @@ .gis-layer-toggle:hover { background: #1a1c20; } + + .table-ui.secondary th { + background-color : rgb(98, 104, 109) !important; + } + .table-ui.secondary tr { + background-color : rgb(84, 85, 86) !important; + } } input[type="file"] { From ef331363f737cdd0fff96ece8a1390a1e55ff050 Mon Sep 17 00:00:00 2001 From: LGS Date: Mon, 22 Jun 2026 13:09:53 +0900 Subject: [PATCH 031/185] =?UTF-8?q?Revert=20"[=20wr=20]=20=EB=AC=BC?= =?UTF-8?q?=EC=9E=AC=EC=9D=B4=EC=9A=A9=20=EB=A7=A4=EC=B9=AD=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20Black=20=ED=85=8C=EB=A7=88=20css=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 4f10864c2cca82b42f37ab6897a2ce3d397d69bb. --- apps/wr/src/app/styles/components-black.css | 53 --------------------- 1 file changed, 53 deletions(-) diff --git a/apps/wr/src/app/styles/components-black.css b/apps/wr/src/app/styles/components-black.css index d337b1ba1..fd651fe6c 100644 --- a/apps/wr/src/app/styles/components-black.css +++ b/apps/wr/src/app/styles/components-black.css @@ -15,52 +15,6 @@ } [data-theme="black"] { - .card { - color: #fff; - background-color: #0a0a0a !important; - } - form { - color: rgb(255 255 255); - } - label { - color: rgb(241, 241, 241); - } - input { - height: 3.2rem; - background-color: #1a1a1a; - border: 1px solid #262626; - border-radius: 4px; - } - input.text-gray-600 { - color: #f1f1f1; - } - button { - color: #171717; - } - button[role="combobox"] { - color: #f1f1f1; - background-color: rgb(26, 26, 26); - } - div [role='tablist'] { - border: unset; - gap: 4px; - } - .tabs-trigger[data-state="inactive"] { - color: #a4b7bf; - background: #55595d; - } - .tabs-trigger[data-state="inactive"]:hover { - color: #6bbaff; - background: #4c4f52; - } - .card-border { - box-shadow: 0 0 10px -1px #0000000f; - border: 1px solid #393939; - } - a[aria-current="page"] { - background-color: rgb(32, 148, 250); - color: #000; - } .card-border { @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; } @@ -123,13 +77,6 @@ .gis-layer-toggle:hover { background: #1a1c20; } - - .table-ui.secondary th { - background-color : rgb(98, 104, 109) !important; - } - .table-ui.secondary tr { - background-color : rgb(84, 85, 86) !important; - } } input[type="file"] { From 7b1c12e9526b53124162685c369e9a05e9cdf3c0 Mon Sep 17 00:00:00 2001 From: LEEKYUHYUN Date: Mon, 22 Jun 2026 13:10:50 +0900 Subject: [PATCH 032/185] =?UTF-8?q?=EB=B8=94=EB=A3=A8=20=ED=85=8C=EB=A7=88?= =?UTF-8?q?=20=EC=B9=B4=EB=93=9C=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=5FFO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/fo/src/app/styles/components-blue.css | 70 +++++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/apps/fo/src/app/styles/components-blue.css b/apps/fo/src/app/styles/components-blue.css index 7c1e9db27..b4d49cbcd 100644 --- a/apps/fo/src/app/styles/components-blue.css +++ b/apps/fo/src/app/styles/components-blue.css @@ -16,10 +16,76 @@ [data-theme="blue"] { .card-border { - @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; + @apply border-[#d8e3eb] bg-white shadow-blur; } .card-header { - @apply border-b-[#3f4042]; + @apply border-b-[#d8e3eb]; + } + + .card { + @apply text-[#4b5563]; + } + + .card.card-border { + box-shadow: 0 1px 8px rgba(15, 23, 42, 0.06); + } + + .card.card-border .card.card-border { + @apply border-[#dde6ed] bg-white; + box-shadow: none; + } + + .card.card-border input, + .card.card-border button[role="combobox"] { + @apply border-[#d8e3eb] bg-white text-[#737373]; + } + + .card.card-border label { + @apply text-[#666666]; + } + + .table-ui.secondary { + @apply bg-white; + border-collapse: collapse; + } + + .table-ui.secondary th { + @apply border-[#e5e5e5] bg-[#5987bf] text-[#ffffff]; + font-weight: 500; + } + + .table-ui.secondary td { + @apply border-[#e5e5e5] bg-white text-[#666666]; + } + + .table-ui.secondary tbody tr:hover td { + @apply bg-[#f7fbff]; + } + + nav[aria-label="pagination"] li a { + border-color: transparent !important; + background-color: transparent !important; + color: #0a0a0a !important; + } + + nav[aria-label="pagination"] li a:hover { + background-color: #f6f6f6 !important; + color: #0a0a0a !important; + } + + nav[aria-label="pagination"] li a[aria-current="page"] { + border-color: #2094fa !important; + background-color: #2094fa !important; + color: #fafafa !important; + } + + nav[aria-label="pagination"] li a[aria-label="Go to previous page"], + nav[aria-label="pagination"] li a[aria-label="Go to next page"] { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + border: 1px solid #e5e7eb !important; + color: #666666 !important; } } From 7ac53e56d7e258772ff3c79b0394c42aa1d868ed Mon Sep 17 00:00:00 2001 From: LGS Date: Mon, 22 Jun 2026 13:17:12 +0900 Subject: [PATCH 033/185] WR dashboard-black theme edit-Leeinseo --- apps/wr/src/app/styles/components-black.css | 53 +++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/apps/wr/src/app/styles/components-black.css b/apps/wr/src/app/styles/components-black.css index fd651fe6c..d337b1ba1 100644 --- a/apps/wr/src/app/styles/components-black.css +++ b/apps/wr/src/app/styles/components-black.css @@ -15,6 +15,52 @@ } [data-theme="black"] { + .card { + color: #fff; + background-color: #0a0a0a !important; + } + form { + color: rgb(255 255 255); + } + label { + color: rgb(241, 241, 241); + } + input { + height: 3.2rem; + background-color: #1a1a1a; + border: 1px solid #262626; + border-radius: 4px; + } + input.text-gray-600 { + color: #f1f1f1; + } + button { + color: #171717; + } + button[role="combobox"] { + color: #f1f1f1; + background-color: rgb(26, 26, 26); + } + div [role='tablist'] { + border: unset; + gap: 4px; + } + .tabs-trigger[data-state="inactive"] { + color: #a4b7bf; + background: #55595d; + } + .tabs-trigger[data-state="inactive"]:hover { + color: #6bbaff; + background: #4c4f52; + } + .card-border { + box-shadow: 0 0 10px -1px #0000000f; + border: 1px solid #393939; + } + a[aria-current="page"] { + background-color: rgb(32, 148, 250); + color: #000; + } .card-border { @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; } @@ -77,6 +123,13 @@ .gis-layer-toggle:hover { background: #1a1c20; } + + .table-ui.secondary th { + background-color : rgb(98, 104, 109) !important; + } + .table-ui.secondary tr { + background-color : rgb(84, 85, 86) !important; + } } input[type="file"] { From ea274f8f7dee77a39479a2184041ad3707b512cc Mon Sep 17 00:00:00 2001 From: LEEKYUHYUN Date: Mon, 22 Jun 2026 16:33:16 +0900 Subject: [PATCH 034/185] =?UTF-8?q?=EB=9E=9C=EB=94=A9=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../public/images/landing/intro/rel_note.png | Bin 142023 -> 61459 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/dm/public/images/landing/intro/rel_note.png b/apps/dm/public/images/landing/intro/rel_note.png index 6b26f52489f804a735386277be7f53bee3a4ebce..c5cd23c7b5caaf468798e365be19166c0fce65a8 100644 GIT binary patch literal 61459 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%0DR?bj4%nLM;-UjDt6~a=)r|_~-yzJk4U>dX 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 F{{YKN=-vPT literal 142023 zcmZ_$byQUE_dX5}EiGLVlG5ENAq@&hzUT(&Zj|osMpC+K=!OBLrF-ZWhURyCf4hstfThrt)*bYjEYF@*Xi+Go<-=gjSL_pQZg8GNW2NS4x zwo+c;%Z2oVuYO!eNXVceC><0?45#MzRf6%Ryeo1N_BF?;(Qjm`%j3z=AGXCF5OB5j zD)zBbjuV7gcI{_JiO0dMje{eHV5D4hcm4d}&)7XRaDlWK(Di!XB@w0GfBW+qYbEXl z-so}KOV;5~)|DcHAB6lyAVF;Dc32!fQXX$bb*`_kZ(B9&tpmvl!5a_`?EEo8^%gaI z1O3sZ2SP=i!5w%SA-?K(0UH*>CkvuSB(?@g4=h7E2>0PD>vP57qj_-P?tHt&Jcw>! zslsO!4FY+!&t+kKw?7S#R^q%rcuR>3O%0GqJcd#FJ6_R(ym(x5*@uBe3tRfG-5vvw zsQLHC2Xd=q5k7PXF84kFnv$EPR%av zzm1r*w?YU!0c_6U9u~sf_;3e{rK+!8epB~uIr1zHN^2|GXg6P@P*5CRD4%)jOcW5{ zI3(OtT-XXwA(?WF9!*KeNImrjURP^7*nA903^za6gDGCrF2DxmGHl<(j|72}!+?|L z2CdJ;O)^}*^pCn;^@}r^;&B~Ij`y&#u&})F$ZccXPWp5odeeA179>Oae+%&BolbN{B+`deVDzmvGKIe z$7;8n40C}LxKvbWX$+IboTs<#{Pp%A8!wm0a6)V>gG}}YdbnidncwS7!`{S`B=CXK zJc;xcAbb!rasu@almBqrH)H!msN1(DUWMHICeK9?0fDms0v9%Z{4ym8=-Pga=j)c4 zE_c*OcE%vqKY0=Ky#2bN-EhtQbM-p-B>@ z3?7g96KW>N${e$GAA>YgLKm;j3};6Ago$m2irRMvy%#61ZSzkUL*DZQ%0A45_21$G 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

&GEYm*x{es=4$rkl zhkcjW$Z%iN)Lvsl3_#zKzIQiNd%{5e<|{qH0|O9u5=&gHOXc86%Q>-R`9RZ-&mM~eBIWuCN zfYzd_JnIs^6{pcx0yn)a@hZ=&Q>wo}gXY^k9KVHF_(Ju;LAnmCdeXvFoD_YUWf7*H z``FrS{4J8bFBoggL?2UK0?Ln&xGNR=!kzvTebC zTaA}b26BTY9W!Sk6DzKY1nUzmnDgIS*_ljst2CCX<=vbekIFor?eyXhdF1G7#FG27 z^up4|WUuk@G$0qHlh3x`$$mQ$tzq)wIk~Ua)@A#YsTiU{o9)PEii~u8$Uy%c5I1g- zsix+1t4yv9#y#}CT9zKGYR`QnoY(D99-e2BA}<)X!6D5Q7r*LqC_`MAJ{46AW1?wA zRF7`RG~$%vl#=A2k8f*+8^@wVC((W=JNHI5TqV~GVEwDf;^AR>JG9|_=I+Q?pmg$*D*;6hHhouj*NM@dN3^Y1VS(!%o~jil$+T;g&YJN6m&A38#R&5B z5!#y-U_D(cYHJWIHJRGJbf3B1>#&D0J*qT@5G53KwtBe(p7hLd0je`s>_zdoAq0v9R>HSYb8kCB z-oJ>WRmxj0lSHOcJnwzFLt1SZpBO=`>Fddn(7f!S*kO@6F7oS}jUG96^s}s^d*5by z#9P_JhGnMv6v(@l$;x)UtqdHdE9X3)Fe8y*nPnNag`D1%|!&xhn(`v|JW6+)m zpuot9jLY5zVbS+^gVT7z6>N=Wu1g?kR6>_<3yMAd_)Eu>Oa`|6xhc}fg<-=@J!Q`T z65n{q3S*-?)cKY%Ka+(BICD=*f+&aTzlwZ0J~ ztv8srKlGfDWb`VTfHNYX6I713hY)wa1s~VdTJf$yW`})$7a@@GTU1vKOBqS-^K>}? zUA82=|K%w6T86b@%kcVRS*f65$zi!DCRMcsCzNa8;_w`%<3qk-A=NeTG z;VF}!?}};dp_07{D%T3Qx>md&uClm4Nf%1^L!YuU5pnB0d`>JA!c@YWii4z3b!|8g zSULRcpg*NEL74x-6ltr6^`Vm~If03`S~MXX9ybQILb*W^H6g!umA1T73I^*OX8W6E z9QF7(#2*dRUTW11^ny9!xB>F+{T{@EDqMZQCIFN@krx`A|4694wW8Fo1a_p&NVQK% z*3?gd=W|RVYw?ibhFS9F?(zEK^`r!Cq?#fL4KoYixQMk5=#UUJ+?|P_kFuCpBZKcy zQ&MD=uH~6{>*hdo97r4&%oXv7unMnBsMS4QLiNaRy)8>Fj>N(ffNj{NyBTeq`uTAe z5HAfRoNf-fPJFMbA|Lc>kqpgT2V~KluYmZ3#g-!$MB_(*9{eg4;9kNBi)W~zh_v;S@ zvd~`IJlRn!xj1rw@9JoIUHO53V6PmRg}iE1bYGU>f=4cUb_yrwz=qyI5vnwa|!1(b3WDVH5g9o?D%`1BYf1$yqb>*2piQ?|_4Sv*V_89SU!wZMlNMUOOvQ8Q<6mtYB%AqMLWI-lE=i$X;ylZV8p&ueNAx^XO zusDVQJh!6#7bmqHEl`n)8RJ zBIiNdEApTBm8+W`FiAHdW5-?Yz3T0T3mdMTWKsZ~4A+x~Ws zZJUuzG4{D*OhJ+~UGhc}n68i)Az4Z>P1RKZGyZrw=;gBS)TW!qU8i57yi2@mU(oma z-NP;&X|W7(MYSLcI?M}y#9kDa@M!zi29(u4ttuPv{?Ae$_zirJIG3mUU_&}&Ashxd zehIuEW;q>pAj2ZyZ2k^h$*TBx9_a1Alb-aKk@U1n+98e~;^e;MwmooTyEwnQVZ=F{g=hy~p!(H$2H-TOjsz0pWO z;xq?|Y<9mrM5@k>*-ibPG~9H5k$LJm0IaOi8P?~t-gOtKoJa~fOz-$1*XV8!KjRQN z;qJGn1|N$OE-ol96qY>_*fnc=`OOh=Qjd1spFP+f#ohWK76}*|qc+_JNaj^%kxq3b zIB83*w1c9ihrT`uvEu1J7{fdkHKW~JG~P@TOL^t?-@_|B=|roZfmz*)OO@ z{1-6~x|8)%co&!4;lzHfx2BFHn)3YWb~L@nTPl>0HT_!t7PPFzS4V0ZlQ`3MrPFaD z>6>7#px|!&O>jhqp<&^J3~uKu0J6#u{A@$Be03xysV4(6(of9D7}uR1Su4x_YO?YbJg5z#@AdT z0#oi&CO&urxm~e2hpl=3X=JudQ;-i2fHfLxg*`Dx+N!(rK@zTQkO|CNK&hh^sO#nn zPMuzHMXF0F$w;r>Qk!LL1beWnMq%Q$kKDzm=fQg-czhyF4szNCL1!W{>AOvU&9IYL z0URedmB}qFQl<$9*eHwTF*1ihmKY>BCdQItnYcZ0s)Ow%YHK9k{bpdA0k`WUo2+7? z+8@xI9evFcvpahbAr}KqV1FJ?QV;ZLV4y?XV`bah|d3M$321_U(5foHVu%&a`w<00qf zi-lgCFqb!~jN3k5`UbPG+9#ld-YW3?cBGoWrIFzNP6Sy0v!#L&tR*e;arwY#Z8*t8 z7meCsN#uJ;Nq*T?SDjC`;JZ7y$s1r!0?rYv(5A;;yY#9VLXs|no!v{OP`){+en{i zW{H=J4esWH&)I|R%=A3z@QQS7j5uXv1ta&h+SV3+1bStMf=qh?>sS z9>-s*M9BkU){D_hQCY@eXZQ@@qPSzxM~f1UK4Zb|Co*5^3nYE4OY+`kZ?R9dHB(RzS2}Vzl^)0AW4$7qw6ngaW7l)BlDBez|OPvAjap-T)9lD-ISs}e=2OJ7krGm zz8g|EoX?95Zc4==rZ{I-?l)ii*kNdwe_i0qT|Sp}julq5b-_(`XPvNn&eHtI(nXKzRq zBo*`)WHk9{w%Puax7hM6sc}0F|F%+ZU>s9fc}CSyn(a3gvqx2|)h}Pqzw_|=^x8Df znA!#qWXw{?AmyO$UwQ`*^J09?Ltf2XfKt18cKvHGnL+V+8p+uh4kC;upH4*2IvbBV%3ImB8y;33j zQaSMcsd0^neMzRL2sg)QPTWWDuCgigk3l#Ixg9!)?|M1Ynl>jtEJkOx*g7@gP8P8H zNP-yUgk6FtoMn@;k#823_eH#eMN~E*BN|l{b7djFB+;|adUS4_i+(z9w6mxb+Jk^%=?H@v7iZSIkHlF7D>_R}UtbOS{g;W7p2 z;(BVKrq1i`9+X9f7Ow2=0&MAxLkh7S2+7D@j_WPyeYQ-#A7vco6|l*Su>JtghsP3? z4r^$asW4TChlusi2|8Oce3jFp1Cajzk!nt9eMyC=KD}dTNSE`ZQ{cg2Fn3rwA!3d* zZ}+qEFO`X-_xK}OpS9hekz@OtBi&5YF}mJiK>E{wi1N4md&0tmL|f&Qjx2uE!y zX}5T%M#Zg@pKnLj!l-mNu*F=E%Ro=}$jx4%g6(m1ObFSK75@_RUR%Si>Uek_`Mjdx zFDlprBMmEucpL^;`!DmfoORmKN-eeQ9>ayxhahFVaiL{rOMNneJlxC5*0ZZTq^8xS zW=PE4oc!QBS-!U#{g&Za}<*6fe%?C898H&L}OaX%>_47Q&4n@_cO@W0A34L|dUp-Oo=p^W^of zI-?S2lQqNq0z?aOj%fU=D?JE%hqK*mtud{f^p{JVsi|X@%&o9DCe-Gz1E+|q>YOdd zY8-{SX8S;rc>@1R-tBm?Z0G2@q4p;>M@@{xmzP34r@((Q-6}-S;^nS7@qXgc2^B!; z?puE`gZQRe#1c0cqRF^q5pu+bXs5ft&|O~cE-b%W8JG!6Lc%Q3v(ZEFBQ%Nc_X1{` z!ROG+F7KE7;cG`Id{YQn2ZAxa%ZjQ!WvQ)~Z^EsFgWB^f_s~QyDZce`lYFaQu{gAq z`LRt!I?!7AF${dklC{19gBW(U|OC-+wU5Bte(xD#0sNtk8MAWHYEdo>O=c zuf|Y#d3^22ECm;*oQc!vtEZ%R_WTSFQ@8>yrfTJ1nj`yYGNJqKplFzXU3#=Vu+nD zLGlgM-C07y;_ZE^boU9 zKyoF+H>~jOso)gN>_9RNtp7oP(IH0~pO`lDaTCQ$4rGj*vi_i9{s)4Q28J%~;z#M| zlhOncn7+lRcZPkdPs-g5g(}AYfq5R6DG07W``hywJXJ?>^iCOIVHjm4f2zmhAM)UZ zTNX1>*VeB)yf`Oh5QF`A?=wSSwPFweE?=`Uv9@H@hve<8(iS+=KtVhKAQ)^{f|+Bn z9nYqPxb(#$@yyONGcfXYkYlfHxSgIT?9F!81c}2Ft+hKSRJ7u93aRN3#4L!HbWWt|M9rB^?-* z#7x_j08a`}fD*pSoTK;pNK7@?y}#^@9L2qT`5?@?tkt?$&3v2@b^f_%OE|`5cgmvU zu+ijC8~u=jpKq2ZU~J&^Rs+Rf>6NT!qRZm@Vfdy?Fv9$P+TrcM0}9}=-$9*~xd^J- zLGvPOX8WM++z%j~Cos{qTl@{6m6sH;!Ni^>)2BE`?M&6&FDL`JUc`Yx;Sy+<2hEaa zgro`lIG%Em1z_FW6o{7@gj`);UO`bAmJl@S5NTPeVv--J;6-Bt@^NIRu4_piIM?J0 zOA-l%Ic&9qRDMI*fIIysUs)Lwv+Qa)7v98nntB0ghBbKeLuw^BVn$NB0M9!ZZ zEJN&xbjTfX-Io}g+q@)mc>|(8ES>?qA5t3T_#ct91Ly`qs!)l!&5^?_Mo zmokO>90v6wBMsk8ZdclY%euGw1caG_5{YW)=1G?V+5L9hr%M|?;>Dra7;yAHem}L@ z$Y)q%GF}a}Yx%*W7>h}wxgfrGBQ6DnY%>$-zGYCg$#+erHM#nvZad}Viy25;lhacq zA3B2keE^LOFlKXjFTjX@q|5g~9``y)qsdxZR){XMt05jkbtN&bjc&t%0_$_>Q3;=y z63R|H_c{(o7hCo&9)!I5rO3tSnIao|>3+39yV&OyF-LHR?JS>ufsc;*Wl~3rj|^disT7-ez*9)Zs1l5}=taaZ07bmJxaI(3br^$XyR) z1FN)vz-+HYv}0YMudek!cc6V+fJR>w!a6I&r$nGFP2a~9i;6B*zf#6a{kAvSFB( zMoBapFeJ;1Fj&p>Kscj~qoTOqYSb|Wl8D~}DI6Ejz>huvi)I~)K9R)O=-P1b_rN0l zJdF)s`ry)`1=zyk)S{bQOAtlI*HJF{YI+op1G45oIU5;Ig2D>0uH$zdYwsOFeYL{i z{P&FKd=I_L3;VuB>kL>N#mLmq1eF&g)jbCyOT7Y2r_S^>2G-qP+YRa;G)$Q7m8b(5 zWqNw>dBvf^W_wI$K_)h(6&IDrS2j`kqP87$Lt0Srm2*K{btG!q@wunNh3pA6O;4e+ z^kw0~;Hbq5pZG7B`@EAh`PVeUSiZl_8BVQiqwy~T4i1&SOjw<1VGxdv>U-xYfaY#9 zJ&St(hDY0>Oq+PAzEwZpV3HvBz@{a0L9)QXL^T6;|7+x(6R`bI#5=ke;hk{6Ww&)V zDhsrAXx%K7pP#C>jp`h5g=Lff<=3{>$8S0fiEpGs%JXV2Dd%%{lJ|ojIi@#!gR21f zqnGsu56%Oz^Z(1my^IGg7rOBopgNW4Jn;;Y9EsR}&0q*!O9W_E^e<}{qF)_J_J0K4 zmg(ee{&ZW@g&j*kktBpQ@LyAb*TYuEE~?_F95T#=(_6)oc{=U3y#SK)V2#%Q8#CMN z?vrfvuTcgmHjHuLA7U_s*6Ok#8j5ysl@7U)@z?m5EmWs;J$@qX^0-CN_YXe94jI0} zza%2mbo{5mKVg(rcKUyO&g$(l27~{WtS!m_Mg40XGl=z9uIgPU%KoP_Ci3qFP14Z^ ze;vTsBhL3AV8Hul{=*v2ctZv@tZZM&_z(s;fJU%w_wUl3c>ilYP@Td76QwHDZwC8Q z?Rk@cBDw!_9YZCp?n&CHV&b9te}4vI?j~{nM6HPF+drQE*U`=W^TR7CJas@`?cu-f zU^rNW-rZUFL9sQ7^f(=9!vBKO#covJCH^l5PEsfBU!8o~vs=;fv*R}3;O+-mxkgH` zr;hlgA?(!EGGpT8Hx}`gtNowK&U5CpMXv$zi>=)9{QRG;6fcsvE@k*D%StkRCtp9@ zHCs4<*h*sX7=<0jO)q2APDy#g?~GZqDMm!$jHj(HMhxJsnZK7k1<vXm3m-?69weLqr9+bD7PoCI10Hu2I zB8wQn@m4i}t{cNA4n{84cnH?kx(=aElIOo|X1SksoO8JMo^3zdsF|WLc2@TECFU^N zSzk>+UA#DK=u^EIPO4cFT|9n!)|}yfF{b_S?NZ=z6DV3J#~p&CFd4 zNv7i|c7Oduk+UxY7)@Y1Xe3hsSkaNa3h^wFk&%A0w5@2r4xf!O@_JW8m%UBgS$*uu z&^M`Wzoph4IcNl%ZJ%(uXy!U7_-q*ottfFBbkN@7Uf=CR&5)Dw$c>$mUQWELbA%A$>0yD9yK7>M3kR>g zo>Z}bC7@N@S>Nn!MFPmu28~ zN3dO;R*=UF{ee!Gv`o+z$PhI2w)=KPD13c3_M%y*vVw3-qnv5PS?MWvZyUAv+~Vb? z`+V{p!c8UQT@|hm^0)5rdr6m&*;dz*68C@A2Fb)JmEQ4W7!|5E`|6oPhu-NT zH`2?6sYhRZC=Fd39>*ePSHxW-bsMgXmk8=zsQ+g+JvTq9b+ip(mE{c*ad$Ewr2hUOtK1^p`*T8BK(= z(_&W-m1`FstBOw?+|WNi z>UjgK-mYy*s`~DSXQ6#oJh)1RMY&uhreq{9pE#5i7NaS5L93$7zWY?XzczU@#B4gNAgU(wq@wv46{}+kC{CVDX z(k;3s%Q^92oAyOT>SkD+_aV{W6Ol^vUS`_UMEKL+v(M`dhy1 zWMy45Pt#J0%Q_{4^xMWlp#c`17ZKs{S}Y7Uu=~$d)n=-s@bvp%`V%&VQW6HLGks72 zy^Cj7b*o{NqD!`K^*a{c^q>-{fSd}+LT9-5uak{;e&)jzH!@0MYHdI7a{CQI9%l*cFqmlltOL9dh12`Z^Ys|n)Jbj^%oii?DSFV{QM;Kx0fgQy z?zlPJgp~XfpzMlylO1EVg}a-Lcr#W;;j50jNt+aJoX-6{Cnni)OM@^Mw6tU3#dRB?URAZp+ z;5~D(;$_d`JNY3-ediU7}kJ|&tCZ6V`c8HHy@UX&c3v&w?kTIkK(bBF#$0U=I9(WkZrGB0`T;e2$P z>#g!&9q+xc@5jm_J*MMDTWxzUt!XaD*hCw1>Ci1`9M5;FZyJX2Cay?-GVu535Oe-< zP9Ynzd+#|#L@V~2h+21%jtR5NGGLd^4%395eh%SKH1Bj;SXXg5ztKEQY(e^2r*FMbAQaXLo{&Ug z5WqOA@r0WVq}7_~rUc@US6llEZ&5S0e+a(nqE4#&PF>3PI3gR$$sa|%{;e}gbY;N& zoxYJ|6qXZDX20?{IIcurO1Qo@t9R3b2!2%ehOzGnZ8UrA!!Hy`@lQl`qTH051Z0zP zNN7lNq^D_fgoO{Gk4P;j7cI$F^`>+|?0^WF>AR_!Jo&X7J9d0C1%jjYuQ>TIEqy4@ zuG}OKmwNqsLFAKmVBzgF8^T!`;?MA+Zhq;ujAMeUZq@Yum!e?gu^Z29?7FMfv<)YT5*;}5>>8du9xp}f~~Qe12! z{aCo>><&50;jI}3Rs6ip*8|abl33soto!cd^_0=nOgl?cW99y7in$NA2B7U!igFs4dJo_-^*}kUk z+wb64@c4S?ZqL>>!IduMDn2%^c&&rNaJ1!@Cp|d9Z9)& zLHjjKa0N>~u=#R0eGUa&J6!6X0!T+IKNr!<{kAZRG)&KoQTCC~o19$#X*8NoD%&x{ zToJ%wqjU#ct^kafPUySf&%U?wNoP|wLYm5HPGT?`ABY(?1k804|AvE_MEoinEnaMI z`7~`SAU4gpBR-5n0%+MSH%|NGG~H9#gpcg94T=5Ay*;A)sZ}QFW`pCwkl;WYdr_$M z_Ah7^E$!3X+L;(;AK$z;_ta(h7>b)47G2_7Ho{)?Z$^*rb(0OjnLf0~>VJ_NZBRbo ziJD1p)SFdPfHQ|B;)!33Y@~u*{5M5xr(PC}TeOhsYHRzdhybr^GAn)VkK~yW!G_qR z+5Pqf%*XfFuAJs0Ib5+8$#)a%@(H?ZQV=`-{(aJuW~43F+yC+IcwF|thHbJH{UjOu zt%-|V8LevjJ08d6;gwfLTFR^~w%sb&7wUEHs~csvw?w1W~ybq={)xmYi$+U@MgEwoYa zA9HQQ;^U@1X|22=ugue83>;cVh;g=u;b7i?b&AkACI53q(dl+J zo4BotQkAj1jv2?3y2rW8zs6KfM-f*F3YTVP8tneHq<>_i@JB6f)a5Kx!oT1*5U}q= zJQ|N?av`^x?c%cu=lMbl=Pmh}@lsFI)5bE(M>p@<6{xvssWQO}z*sRsM+ z-~PGIT@~HU6@G$<+U2h>mui(#1(DR)AiD(^0C|M0Np?WWctw42{FayH;mlGnjfsup zWouPmnPJ0(pk5QrM8{*iV3C zZ`67|bwc72BRejkyzjFrq00f23lL0Rs-Df+Q}|ie7?=^1P%rsw*kwFNiL_p+o0}jS z=KhIQ3Y*~|?BPpuIo1NDh1kreVFlc=ttqd+%~GhUHf*V2v+vRhztb|{=+J%c&O!h9 zE|`GA3msl(=h51de19eBrFA-(T7mRMD}upU!5Th!vxGw@RcB&P_2k=Zc783 zFrNB1gTF|)0zYpE!css+SZ>ewHe9?dXScGSMZe9jUro~x*Q_+7a!BhwuF_iaaHeXJ z&=tT!RK?+a7mK5wSpyk&$RF6?((P5C^0q7&Jtat6kB<$*p}jLmBB{Rf*a?6r-BRRO zWy99Upcae>wSCUjFR+kZSo2{au3bodzQ6sm_!a>Jv}^yvEk#KPSPk0HlY z2r3mr3Xe(f{T@Vak+@&e#fy3lM;oW}TYflj^1*&18~+r_m^1u?NqR`5UipYgmU;uP z|8!Vl77Z2A=TMa2nIwXWVlHFB+cEKZcAKy_1+Y+Uf0$`?B%H`Mr0XqRNMk88n2**s zi8(<2Q(cH6bS6&pkbTPhi_Xg#(hzML3R@K(?k9clABLDFaE8oO_MJ$Fj(+aJCu;*= zxmArH3|+*X-HoT3IZD$}`T1uCYvy81{Gl(~T1jJ)rwl#wDH@@FfK-y|qS0FFgx<`T z8Bb$W;0WALy0x9Ex(>f{%w@i&@Nu@{l$t8jei?XqQZIw*{8Lw5pd~s{PXxCECv1%? zybGWP!lO!Hy!t;Fcz(ODM3o~ODIY~MAVU)@sZ7_{LH;2fd6y41DW$dsC?Fv!qV=Yz z*X1&{9u|j!ijJGjfNrXWYLe>@M)vsnzJaYRp$!<9KCY9f#FFMzXi9`@nFGGYqMg2p zD4$5rd)`?lHnxBw!3E>Ri}kxl-3{*2ciXuAL;7~q!T~B!na-g87lZ~=}?UxOd4WO_!A4{9imi_ zJ7nU{U&|VvF5k;k7H{9@7`n+gp5v>3y%xXjC=cpklpWi-V~8p3>|={vD~9I1q&p1; z1$wrU;DdoEn~~;^3{MzzweW&+sG0;AzRI{8c6A2Of8(+-Y`I264VF&X@Ut3xd#{@i z;EGToO_)T9-buU>dp@U;qI@8bNPYW3)uU{jTl7&ENmXcsd4`$76a!_zgpNqC z{b;R6mdoJywU>LP*-AXoIGiZI7D<=1=1x1tvy%x_xB0MjdapS1wXi*V(IWQ&<@j_n z5h6aN-CE8{x=?RxtuV%c0o;b!fg9LgpbC!xsvMimE|p*&E8oW#5(8ae3YF@f(k*=% zm|E2DVa}w%4Y%wGEwnK3;vW$8yLdlEMlTS*wsF09YuQ46O+sezW)Joqi zIvt4v>7_%@OdxZu&lx4hUKv|VxMJF4zK|itG%i{Tnl4+S$CGT3e%@{Uj=h}{=S^j@ zgkG4rg9d6$Z{)n&JKAOxJ3l!M-{k2B6+r)fYHz!fpU5ff`w!iWF(EE;>wwRY^*@N* z01N|2eVnixMLJLAYx>*cpveyTk(DE(#v|a6=&^tt=|5=Uv($1fiGU&l2&*mOioY_fB%F^~oQLBUj0fzh} zsfmeUY2+YXwmmbeOWrcJs7_COn#ZmiV(Sw=0Zdia;tLMFmHLxbrH>1Jt_M;ph?Rx4 zd#eDN?^RMnvTjVClVf`yQ{xM};6#rnfqQ<}aNzC)1 z7@T7x8-G&a)JZKCqJXaaJek6A$R0c-Q!y-?0KPl-MKG&ke~RGUtojQ_n1bD_N@{ z2N9M%sLAz}a?)w(z{K`Tdg~>vN{GT$H2RA8@}T9gc^iL_ClGJ?&q+Crm`64ZpV~L+ z5HGV=w%WVn#@`L3x`P%7M>#lP!U6hG$DEUTBO%A0RAK&5sFOL2kGbdv3Cqs7 zxXXr>ak@H`^L54hLPkgH6P=-_8O6A?Tb8um?{|FMIJ^5_FigfP{wYaJfiiCUCO-gXRk70H5BdqUYC2MP4?bP0 z)$NTBv~Phh8S3z)zfr2L)%tvQ)J>e6N1lp7N~QVj1B*2O0QZRcWa3KcI1Jdn7l?9L~B($2`!YiEMa=ivPxc22$w!B`2=XOxR?@MEpT;S=DS-qXQRVzau6)^%V1F8??*rXls4vY!NxFO{=G5~@J(Fa z5^+WA=zOKM`d15nxl`cyG|N5wuz4W7hbH%0TkKqG#YL%RXWB}%wa3lS@Azk^f2>8h zojoUU!=g_OU6gg%IZ;9XjF`wfuLvaYGM}B+8`gf@_U#S!L|1<5b455>O z|7jdkvG3tVG#-2JCyLOWaKc4YM7QX5$e}B7^5Et#uCBV5B^AtJh?-%c+*_oDj!~ir z`t#-D;iaIRf$}#e!Zf$}iN7B{Vc5h^%^n0J$fo`J**W``{f&!RhmMm*mQ0T>;=)WX z;M*+bPtr~A(?LPh_g0%R%8u(nhq)s4+&Ky4-kAMu69zusv=WEC5hk>PX2JA~X=w!Q zU6PHxibc>cE*tH$w@L`dRmzh~;mt2&_SD9kbyU2TwW93;D5_4Y zcMMQR%uDk7zTo_t#k{H9)8$8!C-MNDc*+wFVIIW|CU2S|%wn?1b4337P%TI28egE~ zSfe5~f{y$n>N)h(-P1)g-RrlM-$$DAw)%OWU)^tFRFwH3Bawu#O7eGHjM2h9a#{;P$!tojOBF%6!8{n{-RWJZ?fELQ+)6t+d=l^Xj@rA;zmbgOD^F*3eOp%5EA?$3UI zBuVkGV-c-F4pIM7y2~PoZlaST>Sfn!a=Lcnk{*sW&$HticvVU|EsW}A92nCeCVM~K zGhR!6xJ8$Zt2oR2Pwm_gbCuoI(2$@KFcEVH704k1%|Wq2wKZq6bnH>;_1A}vL4@j+qK^nKvwS(z(l`TpW&NvhNIWT3 z3+rd%Z;Y&4E^)HcYgHELXD0puB4Y5sJeGA-fj8S<=9avy=vY_)}_y^Jt(W&5S^z(}%LWH`ql^I}q6e&F{YiyZuM(J3QgmECr=N5C! z9MMn{4L-2{7M(2eR+Zy7eXuor4KfnV8znI#r#vKjD7JWp1GkCeC5AGufTWtv+L5=7 zj@}2`Dp5T@nKo-xeZY=X%qHPc6#tZkZ~AWuEh~B)hft;KdV}Dobn^N{VcyLhx-TkF$YYH;~{yc^+*=8 zZfr2K?&%uCsdKB4N_{Cr=?wf_ScN@(UqQi^LRt&6orvfkQ-k2m{5hoDa42{r&=m4k zzfE>WUIQSllAjT%doIXC4ti9`;&g%`o>&V_H3doG@ON%$N&um$INy3|<>i8(uG-F) z_~{=rUoU~}fyAGA7c$1 zJ1^fu;vejW7kX{x!-BiW*iw?J?at|CK;dsJ{APzMgUo%NO?u8I#p56nrcx2q*gt=k zk>q$mSOHsXjmi+p2$|<+#JTBKW@fn*X%!<+3+woAxWw&n*o67@by!J4b{Tj~(AWACby6vrV*P@SdNK8}6@!=`a<-S8D9=ce-7zROLpEIIv>ft&0rSkW)`;D6@d4-OfIL7yD56AD!wL8zo*yz z6q!+^Twh$Y_gET&DFYen1Q|da!6zk*3Spc-Sn4Q|9cGN9Dal_5~YT;t5yHhEFppzh@A|J zyf;X&c>03@{7BvUR38E+CFV}U9#aeFfsp$K<>#OB?nOmHG0JTC_6CjyKS$h%Mw6X^ z(#L8;gW$n*2b2!C0ng+X(^sZG(IjPamItZJ;+Cs;J^6wxNL zUt1omiYm#=5ZiXt|LU7dX6pp?++PY()omwwKjQf#gzsg z*}e`McrnT`mjfOfYKq)s*)7_T=sC~4pshakjLnk{0n8z+EgumI&zi>Q16$;=(!`3m zn;V4qcr+#yn*Zk=_Up>*XD@?(0)YX!ov_`{s z_fvVq8o^I*Bb-Q^kn2ufaUG6sm>{B*%4@X!)-~6MX2+71!PtZQ)cvQ_60p*?6<%q(-u?*%)6V`bgeQvz+-Es%2w~1j2Mn*VwasnW|c)-X~P!`h-{T0 z0Y#X%jKqJcdAL^MaDzJVvT?x?Fz3_>zrCN_dhRp7Z5XA?1#*G=PHUs}3U2ho{<=3`-Un(l7 zNGV9eFqD*ZgTMeYbSNDn-Q97j)X)q=r;LP1hjb`P3pjKmUDDm}$^W{q`+lG8xt}lc zK{p&0=egE8_G90FN3|D+H8s;WRE&UeqYAyW1V@|BB1!4C-X@>-t5gZYHIceeH+kMv z48Lk&h7m57=06q1896eiYLy=y3jbIXZ&x-En<(L+@di9=m-G^~G~|bW{v<=qtwV* z>9NrC*jM2SU+|e|7Z!x%NpDfgNwj}@QtG5-B%2Z=aEOh=98 z@a|eq-o|~NXD*?*5T*EBT|9lo>lI`rY*oI`P-}=`Wou}|H!FSyKN$o7vc9GTp3zSS^Utm#Hm z$VJW^se!*C35oQMa_;PctQ{#)dbq}rge#=YK&_sI>6#hOW0SIXKm`k11776+eQ*!} z#0qqU<4Fm`$~n*vT{wjEzk}Xz;C$?Yj}UdkBXZ3A^cH9o4^@cu?F0u{3rq4??ya=_ zbR>pJgs4}hlSE6dl&$WnMto%FG1fTtEOe)cdqv4VvkUsRV6)QyRiq*%;JILy1=6D< z$I(EapBq6I)>t`_U|sfz|JNE|G|*>>^=8YEEd10cJNCgwHBaUf1oLH~H?qi2dPLa7 z2QFQUy; zb!zIr)C&ZE{-2kH2>D(?qLY_{Cy-#&db+V8u=lo^@tsxOlP zt(9X0_U6Rx7@xK-L{$AEi68mMVnoIlox4cVMG>%TGSJsv&Vd;H*S6{8Q^M+M^bq^V7kW{`wUvWQHc`FgWo<#l}__|=0OC7PrP#9u8anPCU^#u&TRIEid4(%(f z{dJ!heR1nP(=75y71#5>ml?sbw^PlO;=WfuK8G;amBbHCPbX5o{ZMA*R*Yx6+(QzN zFm@`V)qOi@Gv%`VFbT!fV=FGZOPN-K9SgDLsWNUER+ZReO;~spXu?q zAR7Wz?Z`y1|63vTG%cYPAU{B6kdUY1??*bj8 z$NqqrJG4=T4$@D`X`n;u-6`Il_PZ(-trYW$cf4H;z!fUQqbMb_+3OGBvDYB;z$1mb@>yIeQ2y7c(cZCE+@lDTq{@t{QJZDp_3~2AV{5cW zQsyC6ANBDsMCH7dtZ^}I|7&TTaK_0!^Ia}r?S3K&8O;%ewDZ``oCDa{4=_Z^dab^{ zKLrNaFTVAPovl__iCw479!RdpU#mT$6!>MY(=x+x(&u?m8_|4jc#Yn)s0Z6-wGCS% z`i8lDEr*E>Vx-+!IOnc=>B1!%4mwqx5u@P!w7b}Aowa7Mq+u#(^|)58n^^Wn{kbM5 zv#>iKIN$bUV>qo6IGcV!#Lt}?ur77q9nhiK7=MB<^J%Sh^N&zfb#?veO2*2la78(` zw^T!IqFF$0g){!GB512~bfp=L5o%QiRTEHWTsD=f?Ux zj*0H+%<$;W80{=kCEI3DQKm@YI8BOPfZn#V!v9Gtd)ChHolb?A;$4`HhMEUQ)Dzycdmp;q^a(J%fzS(;PK9%^_X}PyFu$nTeU5xwTN4Ey*Y*ot+L~ zYLf2}W-_tZr{i_&y_+(nY|rhiz0ZJ7Ql~VJR8fC1cMCe@% zmjUfYDz&)RYYLNR52m?MA1Nq{DGMyAND) ztkg+vMnA^s;+c-hMBUk3g7(>_vS~o8VAZ`9f>WUqsE#lVTbAFzr8e|&f5XAR6p{%s z5>aGd(~EWT*tv@{Aco&}>pbx}%co6~a|W|rP8L9eB3vbej-6b~ z(P#FiVW^DeLTWsiP->(B;k2LQ z<#_*vt@m2)N|t~1bqb@J=tCD53#fh^?vg&0870cq570tJbi;a{f>N%s(rm7IbTqGjU z-{`25;VM}lYCaP9j0+zp{ zoH2Dcna{go*5XK#t3LiX9uGcK+WY=rHOKml9jf4+Vzuz^40>MG3;_IH?+cSlzhcD3 zKazc^qSwtga2q96FQ^{njHug@mwbM0q=FaYke6e&VzNhktNQ>ZEphNCFu)-0QIzRz zezSQR-&(!-!5oIVWSraB1gIHRP)u%seyUUQ0|y8j9%d2QDk>ZotSPx0wzn4!XCW~b zFl0$nn%{MOOV=;d0?eI-m$2DiGP#k-V@T%LI;_tlpHZi{h`k@(3t9t>ejHD-ZGs@< zIJuXGVd-CSUo8vM=}DqO{`N=W)QNM9Nkof&;8Fg-Y|ph4bNV+q^?>6!Y1mWlTM~J0 zP3&PMxB8|VU;5>JukV-GSGsvmz>5G!`H-dU)LRX%Vj74Bz^&4F?vBgThhf-skAR!i zLO=Z^yZxJD_gNA13`#Wf!DE;dPgxV#yZ9^06K8ElPTHHyh_A~T=e{*nx3mf2;eB}2 zrm?=D?4mToBny@X6=2)ndor~btlwCHud_!5r3?kJ5jomiT){c|n+O|W2oYjM4tirj z-h?pY130FP25#f#Mm7)_`J)ka*qE1zh(#n>f;_bgl2*PKF`4+fUouOCi^pcI6xbY1 zfO@dYP7J1|Uj;6*L4w&iQnF-olENy&&bRbw<0yz?DwR^v5%Z}ea1F+v1)x5*^0 zy{~zTWXHFfh-R}^Z>@a4{S^k@Vv(84H`1w}{b=Gk7<9(hQAR{yz%Ih@w>>4gRKSln z#oF)(hqy?3=xCVSBL?CI$Y#o6M?<*&{heAWK1$gT11pUq?Ajd11m9{sgUZ)-VA(7!sL zO-hO}Xgr<^?J;b&pRSH9I&f;~1yD-nscMmWGE~*r)HSHHZ`CE79MjpF$MQ0scHYWB zN?qX~?^& zc$Y^g+arpVgJ6yOr+h-5iVEr&xN&sh=EIeDv)&oFdbR8I8zNMm==NUL4+9kYI-0aW z>*m_Vo27<|{WO#rl!eD*ap(sGr1aet9SLStvIp%9I+n~&-mgr+&u_oCkGr{HW68X% z53~HyRA2$+L)KZ5(>DTqg|Jq@#4oq1f{a) zanRjV#exxm>50{eKWFfW%)27tu6|6L4UU){#?*xO3mv=Bsw5>Oy2($3ZAPP$kgDQK z!4`hNc~U$DY?tCtVxNLlnJoeTO$J;mG3uS%P)E)8yePER5uJVGf2mqN>}Qo)gwEcF z!`hx!xMtEvksrhI2r)wchbaq2Kkqz-BwA9L&?WrZ$(Q4k_OfbKu}~M`xpg{wrqyuD zZcc@C;G+*KgH{l)E(6MDxiQbuUSwyS@KuNeCe?6HaALRz2Z9l36~%MjX*+YAVZM+zfh3S+ONr}f+?@Y@| zqZrF6N(jDtzl7#npfsp17ltSFCOMPX!RYk|XMs*HJ%&9$$3Dj%0)GIX8_gd0ylBJvee@AV7bJL1SQa zC356@6&ufbTE#T(E}rl9X6Rk);dcp=-L8r{qy6Z@QmKObq&Iat71-v)Y`^F0iC|*q zQ6^2~G(_M4CNm;L5gD5y)B-hKgj~0S3myXGD9OXg;i}t3->yU z5~%p&3#TMJ&S-B!{R@TzVdmg*nd2Wl@rfOZhw;$>1BkHwI02fsz4o!Ga1D~oT^2<; zA9R&N#K%akhq0fFTHuu#(AKR!`otqUvs@~Mz*s=7lW(1nXBKqw*lAyLq6}zp7pgRFIcGay5^R5#XqQkbQmTHieI7qoHuAO0 zJ{njF#kkXEVw=+K@fjS(@84?=MWwCdfO;OL1~ipPABczN=y?iIH>AdIHLkHKxAc7) zIM{$pg0UZb@VU$PxB8fNjHTb&lVtMT%cN}*uvM!62pxEV*h}%sK`CiW)KI?orYLRe zWU6ete7(_9fA{*x(LoAoAW)>gQc)HHh&ju}&};&E{4*wvvOY}qls!xCM^UeK73dZ0 z4G1^y?Y4v=l_J=yR%OkwzXny0DJvkzeGZQsxv~kq}_Yl|7)8_BBA*@C6nkBhExCU=@iJy1@=5~rY zzIy{D!~;0bFfmU&FgRhi&6{|6C^&aTh;pyQWL;wSRVKt2EG%K&E&*}X=hblH3VahY z8>+>{s0h+i(?|p89?HF31!}G_-9bp}Oo6VPJFK@@{q3mZJP1U+-&)MnC+J$HpTp8uspwBnX z#X{cpjH>aLSruQNC{0 zDC_L)5QU)@E1qoLc`R0Tk?NifTBCnU zXCHvUoIMDUbNenGq-20asE~fm)>zIZ;?$n1g`qs|C~cIzSG?I!)+CL{W+>ULj7i43 z*I$UnBhwC8$?ya+lX}uzD2Q|2f%}VQZ%4PRQamDcW0Ylv*!k%kTh^G8?oTd@mdmen zzVRoukNDR#l28TCYa((Fww_9}I&h;qACN)`VWhy>VAd41=hU3+(!tswNxt_Vu$&vn zRh+hz1T!;?M~sc19fyJxZ`=$OIO=h_sJO59=n;zmja+ zx&lofk&_x_emc{&@hf8%<5Js_|qq!KP(RJ;XYZ7*+TU-GkbpJ%d9 zMFOomV!HW%8@&s^V;Iptg*v+11(}DMB6(Stq>^{hE;MobHeN3NK?U1AwAeVcMcfF+C6J48@m9o}u=sVqhsU>qU0pi|ohd>aF%45h|zy z{rX64HtULd`cSuOQ*L(#UG3aSM6nb+qLaZ;^hrc#SD)O&IMUgar7~r{@|2gRnKbJM zza%2rhu;?AWM$G!+f*E7n7c}Z)o&`z?Q!u%y&KhbtG;`Ade)&K4n15$WX(?+8Co2xcnS+4;Uz!!Xpe&|$zRZBt_%hx&xck4C+1B^HrMmT&6>A@@J64<{jW zL7x*#f$6&7halDY-~#d`yHuj(-@h1lnD~S48((<7*kLtLdp%r*+A?LhCrTeK{eQum z52H02?>ID;gTNoNlNY0Bx#;w?>MYx+d-RdFQ~^oKl)$XITQ%2T79v{CUhCU~2QRqp zZXwMl7m)v~I}3|v!*=P`aN_Y)+xa8y#rK~dK#2N$eVQgsehX~nGF)??34g z)i*GjX*Ppn!J8(0<^`+xo6)qWUkTPFCXqn2?gBBg30!*u}NA+$O55KBpS24;cs zLhKKnxW0tghSnQ9I(pAu{(TDgdvhufij!{)a0s(LATngQ(j%uC3aNa&^q$pol58JgJ(n&9LfG{UCr?H&^)qLB$85_qPSe( zw>xnXyw;!HTTqgVo^@6A6l+UlI7xeN|Hdzu?vZ25>GITC9<6gwS^%(!D$(cz#eo(> z=Khq0o7gRvC6jW>fCB~Bnu2r@sH2KBdMsdM9YA%k4s?GFZ}CU31s+eY#Jpzl)w%cM z7nrkDWpRxP5)|3**N)r$}~HqMZiw3z$|`L#yl^XFC# z$hdZT;H`9e(lPB`Goyzte9H$g#OY;^fdNiy=y%TWMDgv$eGz!S(`@RjAFr!87mO#; z-Hp&h_%#Wk6FjT-qgmRxW%RKL>WyvWPxNe})isfyB1G5Cqsno`*vER^W0th5GT|;n zeN*RbGwkTj_jnu)?oF@CVzw-wFVjNN2o|e|bW0YOzOiF(M(3ovqjuCrd|8rt!Yg2I zHZptCe$<5aV!T(VqbSW}87rTJ_BDk9!^--X{r+-~T%_GXK1uKlySs}NeoG?=Ekl|2}T3G(J%^)piX|w0%3%%7)==a2*0F>$Wh=N#k}(0q#1b!4mN>_1q|~3{UlO( zBFl=wzBwD`9zOt#;bn1tRrb?Q5R#R|KH>w0i-EI!$dGVwJCJVXV!(rU>)jPN{)l2O1UJxobHdP#b$xrmv z;QWnxLuf86jcJo@`#S{N2Hq~Unv3uIsZ+u$1GV*LTmfmXc^>PmZr(ko)33}$R z^9p?+ar-~dA$VQC3Cccp;R*VzfMF#Mf0u3mGRaP6!!#H~@WZ@7aobFx`$cdA3&?Nh zickv(U23^*N~`UlDM@4xgKkvJURD1%sVxg9QIIg}4~2lO{kub$_E;3tn^@LJdEm$l z@jQoR5E@37SPnXpFSZkMnkvNx98t3LQLj7c7hlD_lEz^OAbcrN>^*9D>6J4yi@8-0 zl-@nzz_o86kls)D^Tr^CnHI{)!RxNnRCw}&Adq_SrrOTsn@nQ%T!;*V7GzZ3!I+1j z{AK&cZw#6>DXW@6%&fm)1^_7$_$^yV?IkCvOD1uA8Fdi1Fwh=>ShA_R zuvugeubDu>EP|i0BqrvX=sJ(so1MXibysV zBDUqjc{3L%72eGLVWE&@Kps%l~3VwvNy)DI{}P&xOY zZwD~+Tp*gF30@V#|HvB~FI%n`|K{zJx;I~+D@W&<;5!kbc16Nt5;TOs);EJ>vILDk zK>FeDc>E56-jUrr{H~tE?Fgmpk&cie~TmB868SgXowpp+fomWI(_ z7+9%m;AYzCFV5J1<~)AOn3);%nf`WP&*ztJW$D>ZS-zsTBzS! zg)3&}aP|uLH_8ZF_r8ma07l7`?yR*xZ3|GH@|+$nd2LjhQ#|i(N~$UvWJGI^-swlEo7wu1iL9(OrM<0lvd=uH>NvVVp}| zvFnC>{?yGB)XdQ)6Cy;3kF29yTYb_E?t3_}X1^$W!vF<~n#=qD6%#53I};#L)!y|J zOXa$1oOF~I6*z*S4(&mTl*^+ZrYz|C^NE$=mXtUmhXIAU`f8=e5LtVm9+z9y|G0-B zb?u}+5ezBhZ{=+FBm+ zv1DT5&Vya}H?Iy;hIG#6Qi)3eriIw>M+NwAF#AdFVwHbtl|EOHxaKA&D-!D?)>@f_ z2VYLnnIuAM>-kqc!>POb(s@hd%`AHIl*z^ur*+%TE*s3V?It7IyI7EpTDt;FBksv4tLU#ju^>RdC5d=@77vwZa{-)@_8 zZP7hUj8&x&$I~3G!D&hp^avvbc@Gef^6aZmFwD}T>v`bNADclN<=bQl2s>>pdG9Q&M<|*xh zPxMa@TU(!?BZAUeOyFmECzngQ*Z#b@>8^FP@ykYdr0%v+B}G!2ded4npA>+@o6K1d zVa5`OEn2R&>*D&s6toI}?_~NB*i)X753TLFPM+%W4LF!6;$Hg@hua24A-4a~55htzM3C7tduwnLOBf|ck(hq{0K=5IC(0cgh^uIWpU2<(GK78sna7~~^u1IS zbt|-Vd*J}?gn9s8zcvB5to%R_hSxn!vGnwDSIcGbb}UsM1^Q^imZ6-I@YdH~Mw%^d#6SJ5atsOkrO?qT%~ZaKoh9zCfP(K5|x;sd27p zX&%q_)l33sQaJuxszQy2B3ar}u!Q%8kso0y5Y`4_jh@1=dDuLCM!mvwx+f_9Av`|s z4^#}{k(@oc>V`Rs1B;8H+D2>n@_zKsBlv2{A9r+q1i}}|=lfscUy}K1~YAxMc zuX&yaK}|_now?1!Nu7VO7n6It=}1pFYOK4^BnALe33TTJ6vIJ8l_SE9A2Gw&-&b8G`Qc-X;g5F!*kdlGthn*9j))I&3oZ)X0T4rUy}zVN>Op0Ho?Om^%L3M3E>$c% ze?2t$mC_esJ+|;6`>KOW@Ksd!SiLZz;bv9qU+oYbCcDXmr!aevAAd%(@*bX}$3XQb zRr0!`=Sw&ze{7@fo{#zW$9oy?KMyQuP|VY*$;39;ksRj%-mJNg!6g9SLF9fYnUQng zeFu;y1%|mm{RzjMK-$ME7s}t5UH-UW${AVo>!;1^SBv~rDjK|W40Y(jBL5DHbne>@ zD2Yp0{6U!|^4lu#XQ!{TXT!LE(|ifJD=VBW^w;kvOAf^Csn3LSc~= zP+u4iv{T5{fUcT?U;d!~#BTjiEy7g`4`$_JNR-ee5WKdQZXCqg>iAa9&3U4k$z60i zGCfGFtLN+3@P`53H!rihyjtj3MD^odf}Qwnm?21MGiGVutGlg35 z9p%Kg4JNHuk~+sySbNi-E9~~&BJUG6PcwL`(a`XDF;yTt)l(T^&teCT;YxFfC7AXW(aB-84>ehR?XokXN#Bdgl-_J8LpJ#+Kta5HICB=_6sapQY1AdW-1x$gG0sP};E6PfqDuBi zxRJBCo!MP5VkqkAbdQR-)AcH%x^4sidcRjS%0t>MQT@2eP<5MDb9QtPWCk{V6ti20 zYLEWpc{l^?-(`m7+VR>LoD(&jFL)Lf0poO z1qE)1grDi-(Eq;TQT*?CKl-*bIbra7*k%IOU(bJhNy;DuFjxO^i%LJaCwy8_D)Ue5 zK%Mec;C~VPz|%>*FPZ_I(1tvN{kQa*T)j2D&c`0~SseWjeei!J`|-1!b8~QTIDn(E z>b`>Vmd}Ub(&qJ1<6XbfM$qYUpxy#p{_}~!Kh^`IIJWCK^lQMZ=Vdl77)*@RcNsM_ zDhqZg24$m*7yHSkQ=pH_Mv!2Tes9mp%4(-)XCF*FI9vuv-Iy1y9qtht#9VsLm_Y_*h_xr_bepFMigOs$iaV8Lt_Z^HFE z;PNmAI(`7m1R^QN$Hr9DcpNs<9241YH|>DtLk$7hp1z1$*f%{h=pdN0^f1Nv^Q0i5 z%D2mXdRv3TN~6d{M|wqeVM+J)1?11Qu0$tPNTL?rZY+F7vG$l`zQ#JwQJI^ZL;l&@ zCS=GQaX$Pz*lGiAYf&%xAYYf|Z}ap4Crw9tME*Ctge*>0p6#2Pbh`!YFaESc!{Y@S z;6VLYnmw4pSw~rqpHIHR`8C1@@+t)g!ZRIs^D$I_5(jH%0=c)_W$=+;LaZe= zG79fQ-!iT43;fBKN5R;@&bAi=aU#*O7~c1vJHZoYwZ_o^dY&jZP);9*@kWItdMF=Q zWPUXBuC~92LiJ!a?TX&aqVi#dDyd@QO{9o!O3=F3+XF}3bPmpVL6PF$L*@=i`_9`9 zu7Q2ctjqMYitAe{2edwPYn!bzA(P) zyZhHMg+YguGtRR{JHX|(WpB!6d!&j({mvZ=k^Q#r+PyQx^ zO`&TFn_I{1*V7AEE34ef)S3Ftp4ht%KY;qft>3CNbB|AFvy{@W6E}eQEME{@t~$?` z;eYjeU$LKFN|(Vxem?56gbfu>P(>u46%N!LKmVTkyNtW2XL7eI0wB)|WUV)P1FaM> zB1DCq2#w$4N7>jT$SqVTygN)Y&kgQt;a_Chj+Jj<1hJFXzH-5+utHt^c+N^eAwTM;PSA{@&+gNS&HgvWfon`uSjN{)|XD|(m`I`?A+Fwz!ACU zgtPrPJ_v6OO>_A+^lOdw?D%~8%zeXt_W2`=R)tFRmQl~TsG4&AXEp7CbPlZ0gPqq9Ew05x7JPER^znDR2d$t)^0 zvSw7LJ(!WayLqDC?(%{I+*glXY}QhiFg;{vD`Byr0sg>2dU-n#(ZeU@y`fsVKe$5` z$EQ57ADU@=b`K;Xf53gYNF^Q9DY2Mc8vG2=Vhn4Iqlg+bMI~eefBx0Bi^@?*8pz!r z$m0;F+(nwD2Mnp3;~~W18dy}cAiLh;(vo+Q;j(d!#mnYIQ@*TRzqH>M2GF-ZCX#gt zz45$RCu-y;yL|v6^o^{odr%<`YODpuT&RT35vR2Iorzmu_}{Fz^vV*`#QTVdIUYeJZ2p%-V4ZQca&sI`*^d&!xO5N<;KVZ#n?; zhRo^DSb2$_qB*#aU`8%h&$Z)$75!^3K52@D;|WoIk?+q0{9yAZHzTZ;2%duPyepfWn7KQt>wu<2kH=DQ^Cm)*b%U)I8%9*utEQExX3$G0lH@Ik~<;Dz2VCmVw z(6csO%rcDIoNJgh$ZpSLnXmsmzehl|dX$y(4t3Rf%qb@6BT@Hh;Ut1{C(wD;R2ACS z?;0Y@A3{h}chZNp_Ay{m=y`E7h=A6P63L>x5fTM&yogi0*zm@;4e(k@IGucEdPXE) zh2HR?dEnR9I(14EZ=89KCh=vyvt7G|7WbtBO1Qc-LcGq7&tB#T}QG={!? z09Qo)9ugcbC0=M?SupZT?5J|nMu?+!b@IwHSK(_Y{0Cq+_1#|34M~C#EnnuD?y6*D z_3Ga3NNmHz1hI!H=9pxDyg0hJ1~f}Pd4E%{oXGVmCHc$iJDm<2ONqiihdY?6t(OJWhvvUl_hn^Q$&%A>yF<-~UYFYa z!zB@mrloArZ&3==0$=6OTyyO^aww$bT`aQjj+FHwREAT@Do+!tS4rVa5#HGmSan!+ z;84c-$2r;Zmz7~dQwFvCqM+t_#E#>4QShcI3A>hf*@RH|IsXAY7XZQSChI|=@_WzF z4*~CIyHD-i7qrCpt0-HJCBg>jMacKSadpPq=yZUAR`y@=xgIA^b8CWJ;jx=p7cHk8s*o0rRj=hdk2-a%uyiNoQJ<$Hz$3M zJAfI?D_>5~_6yyN9X0YTo}L}#vGI&17p?Zo6mrP^%Q)b^q^X)Kmvj>!6UQT1QI%-^ zg+{&OrTa8g%M=lu%Q-Ppw47fvvzE3J9eVu+8A}I+uu9oE>8|sm+8J%r^GbJtsRjLn~|xy ze;C=A(f8>F;Dc`Xbu93@#lXqrd`WJ8ze?k{d;5AZKLvZAv<|`TgW>w`i6Db|94s_D z3mR3DZvhvV8g5!n;E&%`?(o)OOI5;;xgSUWJ&5g7`oKw6f8**^a|y?u?RA)M{U*gk z>c$1g1#foH^}7h{(TX1hKwbNanc4~Xe~e)3qx|1Su(|9NSe;eVb{pR5UYogeH#Pqoif?5WOfnrGZXS3`qS9Fms^VVR$qno1fot{q%{`HtYB z=?@||I*UkD4}O3FrM2JCe6}F6o=v6uyHE=%|0>Zx-|jqAP6L@&&-A|7>c;1UNdMhX z4M6h+K6?sIT!BjXp~;0`}PG?mj-=)W*O#(TA*+zHU`I8(0{{Ma0m`}&tWD=li z;3zpU+C1_Amo?CRLAO4tb2VaF|N*9b5 z<8YJRzJX#-%NDmWqoNlcs!YBv_}$sr?tpyrw{QOjf#XI>^Ap|N#e91JXDK^NjHl*G zgJVt#aXqvkqTQU^^1|epiB?zU$&v@YjDLKj>Y+-qfW4pI@kHR!rOy1x8Xn3YhVz<- zW;~g%dF_YjDSh9T#D_vt8&B~~TgQ;jHh1($5Ci37jV2Q5^b7@QeF%OAfmko+oIlvPH^q*RV6W5FcMu7cY7_5eg2m4zJK0RMG8 zdty1zUC;4B<3NJFxS>U(G-`+t)l$1Xd+CalWq~kC9*!^AT(#Mte9+)&mGNJ;rJ-A; zrA8U(^I(nJl}u>W5NWpaZ`Y+6IWQJt;V#u2R#b-WMf9tku=}Fen$=RnM#Nf)r?9Rf z94p~65rA$C;ioBOk!2umtE~^`+k^a0w;XS(IX$p16eQ;X$*U_>Xo65mK+;({tI&3G z^7HB`1HF$1IL0UqoM>M{wy*UY{~u6)hG{;hmBUEd_;QKhMZcgV|HPr?j85gmby32* zCtZ>C`I|mI*U2-U;!UT^X#vmpZQ^O`0}D%=CpIhlCANOc5GZFX3$~m_&C=ur-tc*v zF7|pHMv8O;u0VEApFoDsdO?XQakX8H*L5$MK@;JF-!_mJ#VQtKILwX{Gf&oZ6|2el zOD>T!m%EKUy7hL!WbvVl=-E(4vFb?&S4*%bkoN-imQ%OlXM}V>=(NXVZ~u>XFZe*> z2bX&??YI}n%!wbx)7_Af-t%E$Ng6SHe3TD7*P+P{% zibB#WnQ9EaF@izVW6w87(aA{B$6IwPKIO@LTralUqbMiQb4Z0dz~v#DGmPc_KE`)_ zjqD-XFpLpDH_1pZI`z?pN~UX{Fghko_D;R$6nZoa3@Gth2%%5HIB_7BhL#)YB%nOJp0WT{fzB7w4)Q4C*pSY(wqc@p#?(fyTW`2P1J(Ex_0?3lF<$*b~N%i&K?FaWRjwm6S5LC-6wzZ7~XGQt-*9aDSlOi&p| zvdg6n9wnh*>Fs@!%yK_JuTzr5jx`u?>6p z6Zg8lbQrGew|F4kqKd62;s+I|4;$=r0d$UuQ|{XHXDC5>IY^VW^zHhm6qIjQpb>$uqM#3dcsTV zl#%z{!zFz%nTDD-`qn7_r1wqmleo5hEEF8r{cA+7qYU@2YbssUWe38kfMD$Y0b$`O{FNF9 z!y_W}$mQgS_1k|rr!cXOKL!+;?-!neo`qmS(ATQ7I3s=Qi=i@cSS zP1OBE0WxMsK374UA7S>nt6fZ5s_rDI1qR#9x(*A_vbRr)84swtU?zY z7FT2K(_Q{n0>_9Lfd3&UAAL6J=SoOzx)JRB6xZx1X5arCv&7u#Z7~^8AdoL2sg~8;Gnv9Lg(0h~>68F7k*mj) zQ}2co7G)$3Aq!(-?35<4%*u$B>dhB+zWq`DVeNZyBdkHd>iO@s2L7{qzIzgD1v5vv zc+zp7<;|eScw5$_&i1HfEzu<3r==w1eO_0~`3c`X=h%f1Vr*kSM(XTzcup^&(jQ6b z@D$A)G~Wa#3ijjbGoTMt!H{xyi?L@9M+q(*fet?MWg$a@HF>SRS1&(k5CrFWUXy&+ zQv{uYz%LJVZ$ddIdaZCdF(Pwwbz5X5pNSPVG z;$byGEDN%^u}Tv&zqNDsha`j z)_VJ0WQUiYX)>g58!02v7#fGIcVZs)eVC-rXCAjUU$AP;nx?z)MEbx!9Gna3X4lX% z*2LNvN?2}h&S3RTc1UnXZ&Xux(DK_i->zl#EOrVR2Y0K{eWJp;G=NTQSIA&D*s z(Ys~UXwiEo>QSQiE~|G|jou|BMDK0&-igk#d`I5*{muOUGv9n>m|3&CXJ^lIo^#*V zeHGz@dkjq0L=!^_^}nyZi787~lYRmmvF zeX`YER8tN*y;ZGGdu0+LvChktG}1Snexus~qL__DVZkNDLW@rTbK-->{vZ#bO?Bi? zA+}hE-v=yZ;$QT7smX=aC%e_`V!OH|^FTerTLou^fK5SgGT&_7zdmao9p8pvZ_p@z zghfjj!(Y9N6fy({a`u!nJPg2}VXf?GvjlT(I}q}1}!HAb!kVTp87Gl061^bD#k zCvEy@Rya#|BTfKF0luaWQX^thdn`o<(TEYpD!%jTG{&0|4do2;5SGuXWjdsx zX;DZdab-VCs4G?qTFcV-MOh}$yaz~6d6|e>6Y`H~@h{gUH+nd>o_A$G3hJb#imQx$ z`yzWK?PY%z$Iow2mU05h#(fRE0-t^fU*M%-;I1>mk^;luc+3ryKm|DH{coGu6% z_V8MZQ*3-Ft2}0)_5RAaqh=Cmwz)-;{t(>%Z8gq!v|Mv*1{QD=Uq6#DRI5rOeb)V! zR<}*emmS%#|ly^m60x$HWWY97drQmb}6joP~+9O+m_y z^mA8AV9@Fd`?2%Lcj+y>9HN-ex|?0o{WFh!a~`%4d#C6|>#Zd*(SZ<~YB0}Z?{?i4 z9HG#$KMNj9eX?Vt!6s%FOBor?Swe$1lMY=tI+L=Y)s!l@k%>lHe04zo?z{WteQc!M z3z=_RoR&->Qk`^(td;oB-huSEo{_a{&~au9=$4|16HDrB?S2@?qE*X{F8TE~ z)~8(5zUKs=2j&HKEmcmuIl6m}+6@UzT@+NS3`VBDx_$iX(|Pdez+i4v>n|E%6Y;Z9 z5l+i%!2uMyriVrm4WS)dG*Ym-^VB>592&Air1_mqCzNF^ru!tZhK2uy(^W3&ilL+} z6k(~X4#M9XdJG6cK52`yYg?~gS>chIn1`TK zfT1S8pSXmm$>O%Iv6>M!|IuGJe*!? zII&X;^94o>3-3pK$A7WpZNQ;Vu^8H}#FK(aLtYZdsL$_S5Xjs5VH({L=%2GwYD;>ikZ?J_9R}}6Uc5tvZ83HD-Jl%2$)kL8<-(Ht=!yXvyc!k~X(q8j zB~a0su{5(Tp`1US!6~;%O)G!@NI*F_Yu7hG7#UNp{YZ=%@5P&TN&R(a(BtEdnkxX> zpH;tnJ37)5lOv^U`6Vvlfo`rrRJrBlVN`((IlYz^|I1|zbbl+{7lzbCg|=i8dI4#b zy%eDjasMPS4|B4ma0AX}5{5On&$}mo_JQdh?F|sXcGpx*4CQ_IKf8({SP*W!>e0;JxoYK#;?o8ip6b}%Z!vAqG9mDzpq)aT+ zy4h;zj2H1y)QpymvqqOMUHMx6>Om(q2E%p|lL*sQt|*=b8+p+jT#$UNMDs*BDp&y_ zv8yl!iv%Zk7zd4?TLw3y*1utQ_QKI-9AM}F5YOpKak~+*qRwEn{45ucRl**AcgM-9 z>mqPHc>)|5DLBp!_`lPu`_sjtINgTGi;K;aeg359ZnES`=Ji9ONk|39$+2*a z7zy22Z6%(LWz&yWk8rBjI$FpnRrXua4W|TajR26AKT(1eJ9tWv)7 zyTQ+9zCh|q(eGdt*(#n8@+gS-mpR>vCNHeF)rb|_t)T>cqB&aP!S{RBvu^D>Bh)>I zU~B<$VFR;mLY7Yoou)6K%rZNK!BZCVfOUHMs zD*^m}Eo=5|rCaaB?DKfH=(04`*!Jx$Y#Mil{}&l;ZRY#cijHRLPk(K1)gr%sIstCW zZ_Oi2m#NX?R;lG33-P~A7!{DpI}pT&$?9as@%vPGN%|QUj(4eNn}Rd`|cM}p4Eo^4WDmAot{2i=qq#*gy;>g z<~-_=N%=riQS>I1v4+z;G33i>gv;jx!7%-kwEmpquVz9G`fR;FHs?Z3>n%b?9WHf7u%Oc8UE*8E5-P9%j+ z_!UF{T;zB0`$TgtGQL`#ua2TC9*Vs^_5m~2+G>$Clb-S)Xy5TA?u`V2+}#6A{&?!-2u%79t}Y4C(VaERp;di({~a@C>l(r06VgMpJ}KOPBVFKXc|I zh%-*5VK#ntHJv~$AH#CE{_2%|66&n(+i}&x#9z!%O1*5N5QJPYTlZn(W~yUzTt%t| zpk@H~BC?3QQ}x{eDyWB`b?EXAcW0xqD<)R7j7rOBeqGrzjoV|f;84FTJ_sv2bETS` zOktNPy)(}~+t}wBX2I51_|m^6-UqxT43RQo_!=7#%rn4OULG+{%9ZRL;<(nBu_nQi z#~+WyvEbZ<-z$A@DV^joc{|_!t{br4>%*}ejVxR8h_|HP5&XbkGrm9{S~;86O#CQ< z(}uOBAg;EpKm^-#Tr`b%H|{^5QZ%?V{U#@9_WA6>(Dr|7Q6hOC5zh>(7V6n?xJ9C zJrY|S!Y_yMJ$ZaK=oEzMX{}4?T!_Sb?~%1mY>7N1vB*?4dgey4Xp1i+PXD_2LxJ0W z&KQpdh^gh!-Wtf9l4kFd4IvO(x#SvAhiaIo^X~6B+h|TxI^St3CyH^Fy5G1_9VUMti%W1I_=t3AHtIgAwi zk*r%Ti@yvMct-qD&fzEV(%7q+wrR?C?7DI6du~Y&MkH|yjBEbd;&yFW0$MMN zh82Pbzyyfeoh_81lVC`&hGEC~g6Gdi%1kri&nLab4sO28gJx~DS)d$F7TI%r=lf3k z1eT!G6f4ynLnI}7;*qTG5ZxC3x+T=ij`ZhRJF zjCF%TSM>|m>pV9b;m_WEvi&f~o~?G0xAF8pZj&e43W%DYI-rge%)v4_UlFz*``B6- zYI%DSsXcd;KI7bEJty5XrvB}I%ui_szVkg>f%CH&YoJ{F;DIL+r|fiJo1RIW`%m|# zmifcsxKAq1z(8lB>)hhcdvlq-)9~e4$HCLjL|I=pvw~{-U!Wq)=!uAY-9QZZ;!yGZ z;nyY*b0$jikFVZxe{v@y%PbdcWBgAbEh>@Xf%S?vzwyIcfdy)9SQvr$672h?Kh7EX z`d7xPk50A?OMxyq55$(uQ@Vrd(M?nLX5S4805pj2yXV8g62f-(@V;%8C%8~I1$cnO zu>IkMTN=mbBMpSnf4ViROfDIv0`JgiNrj_0bKLv}(Bl#16K$k*)x^^yJ#-wl$?A3Z zKUMGMq^J9dlIyzU?TpT87wP%8u8GFWl^`Y}5n{hT_bB%#ZOxrG4jNjr8N>lescsZ< zGVSW-d?wNvm?ACt<6rbK@k{l*fBIW}Kw@Fd$@u@B z5f9RvQ9PSpnbx!7gDooqUhZj4XL+vI{x@9`xGo-1Lo3l2XppRZ)bT6VTfLhZ!HMey z@7Zrua|2!;cog3J*uXS0rF-@gw>%$hTRZdcj26)OG^)um2#tyw`^D+iWI>r?zIQL-jZtDRSo z0NEzq{1%mG9LBcKBV|IK`OR6ubKW|KJfl1_d?JtKU$uCDbDa(<{GTNFhUad{E4LHd zWwW7w56S#gZ)b2>qbQkOU&r0|X!tlxDo6M|s6?-P(STY{K@BP?(PAXcIwuM227^N! zW7(6(gIvQAj&}sdEUY`G){AG|zb`@iiH*rEfc;TVO-wXr;ylo%{p?X+r&(iXY%uwA zFFWJ3J7sK&ecu}!6V87($cX7-{}=wC8Y6YOYUTlr*ze|+8;V=Upr!7j`CJpar!;a~ zc(=9jwiWrj>YyGuBWcX8^E)oT@63YN-L`TQ4jQ9#bSI0YYgl>jumM!vykKUn%Mr&7 zvR?3=;tpi1bh=`9i+^Ke=Wtq*O02oor4x>DLB&Hb2{%9y`m`9#1uaW#; zi2(i)(|hnnI-Xwmu?3WtiESBzHGF(W5y{p6gbTSM6w#~>TmiB4tyeGq$QiA2sq0SL zkc%ExZl+NBV<}`)tG6NH9=pSmbFc0j0&>aEtp-6ru)%oP$*H2HgyTZ@_uZQ(pkfDsy^Xz$-)0jVq!pRQBnYWLE3KOa9cu>D5(nm<$T-t1T}nNluQ!Vk z*#G(j;e1L7z#OXR6H>=)^4Uc{R;Rjw!5XBJGhg~i)W5uuM)f_Dq}t1;a*qEOr+r5P zIs9=CzdlbkDe*QDjBzQsl&yLJCfvW^atN9=Cyb@R>a-AWIL}i6J$ux_feh}}#2;bW z=LO!&Pri-5R@l65sf{0zN6D-jq?$jZP>t-N2T&=6vJ-2TvLKU!DnrdbcFj8U0i;{; z5)7NZqp7M4M+qE?-9E33b7Oe$`HskVw+P`h@o^smaSs81`aG$X%Oo{Z7m#KRhA)9L zAf{2Vm$bMb`lh>oa;Px-fH0z5Jo~r?V%<&9XniHYowv@1B~MHH%+#5E^uq^$DD7F$ zT7^P3XKBj6ejR*&)v)gF4t(KR)RrP}wBoYhsx!HHI&YS5ly* zl~+2dYr%_+2WSUCq6A2wUU2-Ezh3#^lhXj7E@@=g*LkKGdR7G4!#l<`)wmRKZb;{Q zklYgfVhEu%{Qkj=1if)g;QQcSnI!iAtgJ%4bns^G;_z01k;!$|UXsV{2&{(RfW!3v zT++qwnV)Xllv{W>Nb{d%H!u>|@Qo%VtxHe90kIjqOl%gfQ7 zT%&JCN{bC99x)h|y~EGXpy=muNzzIEZ`F=9obo|N*XwkAe=SMx%wgmM$39TE;Js(G z68lp2njpwLInfxbecJ4+O!`5uI8Zv8xhvtGy(GuAV-;-EQzHek8vnZjLq|yYreOke zcS$tASHW6oHYHH77cUkvnPExDxiLs=Tt~BV zyC47WEi(6R8kWt4LtT|WFu`5MK9a<7D@8^uQv^n59S;h(>2ej>LE5q9Fq8YkG4L|E zuo%(rUiQPMfCD05t3nLvL@6@+sOmpEgPM~hxo>j4SkfLeUw&MTkN)!C`y}Qz`u+Kgsq3W7#HzMPp6DOD z3i6$*k$m+3J@4*mTBjvcznjL#zG{!h!Q)fknJQ#Vjy0zUG5VfmXK=7T2+fG5vCg-fzO zry@^A*nOg!i=2agkVO5{l7EIB0U@ev3%nK_9+KOAD8D>~O}W=l2y&Ju4hvBN*M|W! zV~Y%0x(2cEc}}4C>fu=9pzy8d0)xRUI}KuG3p$9*V1Lona&Jh}Y zDNAHOxUqv2k`)t52O#)`pu1DX+CMK0lT(Yf*{?b-=4R9HUFW=;v|Fvdr8H0K-n@yP zbDY$<*%(8empAt{?ott-&wACp5cY0PxkgMjvRU6rK+ff_-ot<5Uu!8`3EC8-J=e17 zHjHWjLk60Qta}pqOeNCx@?GY=m#o|7AaHgixFT;0E&=Kc3EI~XXU@-<@y{_evkaJ7 zRrS2wgWJVZ^jOEnPE;!jh6Z6{h$HN0T;=K7&M6q8f}(1}`PSz2CjG_6%a>LywTf=U z>49AZD1bw&0JD|9)$Z8?S#x|p=}bpLrP}EB?hccIlfeV8)fnA!oxgePmlc1HSUqQj zk%7ay>X~2m)mxWM+IDmTdbjq+9jwwcd4m5RW94V|YgX!YS|8 zlMFNFF=ewa{ZZbk{0l#AfhK{Ap^rK_LdV^TNC3rumK!%~YtuaZ(A)k)QM8nA*sq@< zx=ly1va=_wL&0DG6-Qgc(gUDu0ux%V6+~IJfZCVIQc-5iw>G<4sRHN`XKds$-<>yk z*{|E5&X!nFRa0fDR@7~RQS~#IM?9@d9ltR_>y?>z9`mXnzBvwD3qdb}N?P=ch2l_# zfa*-}#`tNj^_B+Yq;bCL;AWMfd-5o8+~d%$Hnm0X?HiAbnzo3q^E5iQ{ObIHTf;Tk z=c~25SwDDNThIOs?9favh)ti?a5M=$HJL zN+{rz^+uPqjCmZFhP`%JspN3k|7P0g0%)Y})luGUKk>adG+>V@rk=n|EKZZ__aob+ zY~t%gZoBCBzk@Ku_IY-GEsO#0Q}Kh6Yy#!0bjc;-=5hl}c#8>1#*z>kS}iL^5A63X zV)ONvRX*2+?WeQb5h=4L6hVzSukc(Y_un?#@jJVvj0{B;gLJ@tR-)$Qr>lWdCWH>51v#Ju&_(1P4Tf{cN$Jk zV6>}^UK!I=m+EZ{_r2!BpS=DXB(ypDMy|VE3bVP9`=I4mfeNzIqM~6s86nzwU^*!o z(~QP7n_hc5zspalr(Iq)>QS|`U8`+l5A=DbyWLy{&{UR*Q`C8@Jz^vQbaxuhCaW>y zYku&hl8!DYf6@8V!T)gj)Ea&*7+N;WJo(`wFJQhG^XHZMi8pbYDH>X|qOTU*frFhl zd_&o;&<8Rehb$8LxGW;7Dqgry>QcoPRJ#QOGl!{%*bZh zX~Su&Ji0r^hh0}7qezhgOypQ7f+<#Q{am3uKE6>tZykT?r8_<~Su&FH&7+i^Xxmiq z=ntMX9=}GGC<2XxCr{>YZ;;2m!1c;%rR$u%WI6SyB96EEOJn9tPHbjt0RvuyMG>~R zvQn1M+bBO;s8NXDivTVURV%d;XW%M3%<~vwSg8aNiDcd+W=#XsAWL~Y+}146NBJv5 zE3@wg{`%_ba%K7@pH2&x5YKM%5?7c3#E?k1S5vdlA^s0K;ftR_z6i|e#vV9DjdwuY zL*t(VNLD9EVGf$9`@L}^?8a(59BvZbEmzDY{(}|E1M$#Eb5G$LkwomQWgmX_aL8H? zW)+L&ctKcI^dLZt8IR3FLb}~dk&t$kJa4GO!()6*4#in=k`LT~UX?mJE~YH(_Ap+I zov9`iS<>Wh)ynG}yB%aeKO(*>9GH%0mR$x}?r1;53`T5_h{WXymc;TTt0fE^H~QfV zHM%(OE+vtwoX?bdQ*q!_va4LT8XuRI_cuk}1fruA{sQ1v>2lX?&5XFJn*~3VNaC;w z#&@UNB`}@gtz(THH)LEo1AlJzN(dBtSx#_RaM|QkGu@_*lP)(Tsl2Lh=U`ug`r#k7 z3QBw7WF7fyD1!}#65N`_v4McvBFD@|%_Kr)nl6V%W zZYDVm&z(}Q{BrioKsAF1u5zg3K1Ee@w#>19Tff#+9*4sxr7RN>UFF*l?2nvL@D03B z<_9%+IBiF;mK`spKaRVSLd*G+qAbvErfSs+O~6QX?7g-~`~+76y$@t4Ke|Vhd+%e_ z&jd_&wXJ-+x9Ox+CL4(J7(N4d{stH7kj6!t2g5C)e2L2dnydGEOZJZj84n%9CsmS+ zvTs3_=!KgtxE~c;S`9hbqWnHJwVqJ^?kb6%5}j^7Hdtyp)A7v^^~OBFt}Eu8nRKR5 z-vR>l9)Q%Cfe6aRGpM}zMqkkBV;7Xt#HV@D$!KPQmFrv1p}C0 zHFJMdX_k3b&L#d-IKqNkQ_U72?Ow{5S-lQd2W~ed zZwSje+MjWmrl&u#(B#sqe=Io3Hv;O<+{+>I>L1l7V8h$wEV=UE391Beiu>YGXU9-` zZ5YPf^kZ(ervcvX8feX{hh3=CVKb^|(o8KZYZPZF#$k-|2aCz{S~sI?KgdDUnYj$k zLvLlXGNn?=%?Bhkh{SxR*~ojE05r%#*jer~dj0xj5*pk`^>k--voUJxbaH&42+9g? zXlSuLwgf<-ilORNy`l9SKaWLd8o#Z!%z3VK>t)NPS8E-&1}PfaW-m-G$SV4qYE6b; zogK$>He3JSn{MRKEp&GVmCLuz-Hz8UL#4UU>zpkp-1`&Nqn~nD;XOPlxqMm_QFyU02fWd z)dU#Cud+Z18Mas4&Mpc!#Zr4A?R0N^;)#u7SEe(gs#e1@q!*w{mhWtt(azjoQDm2{ zdhv?X&m7?T5Yf&7z50zZw`kPSTjI)D9EgN2eH*C4keX>zT}ao`-I!FrG$}8JC|>1T zPoYU;Hy(@f;v6MP`05g3ey^(Bk@ON~Cd0kwj)SxESHKFR9L4u~Qx-az9SJ?wSV*?O z%XkV==!+a?mH^bp8ikZ9OebR=?1z}ouvp&1opvuTJIyQdsxeerG}nubhpKBG?~a-6 zEggt^DQ)xDwH5rf##%*3Ba>4-ERdWM&S7SL%Br!*f>vr?2fw#sGGFx5ER?Zutd@9U ztKsxx5rN|9iHB}y))F3!pwxHOq#-rJ@c**AY=!d9T%msX+QgYaw)^EqkKrHI_l{H^PB1vpH;r8g}eK zR@}!annCEs%2m1n@Dy%qi_1k$Y-!h$(wVa?3&*1vzW!#m`vN=7$J_GmBz{bp5JuC3 zrpLRqF8vx@@OHr#D(ty2T&;0GIo)j=MLM|=H2JoGifP;C9v8;KL_I>AkX^UuIbxx z$y}&Q;LbQVidy~W^~7pp4;pui1Rs98tMp#m>4pXC*Mhq)?RgybH1XeWV~kTEQ`#Ar zJcZ>GC>iMMI+{S`1GBRI6kj9=lruvNGDSa@miI8uYXu=2R5#%qN*cVz3D8k4$Ykx;6hix3}W%eo+vAO zO?9)S%&Et#5mv?%VN(iHfJ{8CJ5PjuCHQz(ADU@>>EH77ydG0Q)f7j2N+-zl?`l9$ z+ORKwX@)(Xavw&Xk47qIl>@M#^76-LIgXT+RZv&%-m)cCtm3wS3)nE9^?EM>*i>th z3k6y!rM$=fOMH{2JW}3h-6b)m05$HUvu24_Rf2yQVP=Ca6-6KUXxU3x05y~^DkQDu z!&uIw=NU^UnK{{vpoPyU@#);_1`iId<6a0k6?$}xO_0W*kzMc$0h#Zu&k6T+>V&m< z2Vjy(0L4zS<`EtLS?b$PDR-PkJo zE*5G9Y|)6c733GXSA~1gJi!l*lk5_s0vj~W{20)?h~ulurUvn^X&)Xp|4t1ca$-!o zjm4;1MvSfe)eJWJG3>LYTT436(ZzbEbwC$+U;(yj7%bcH*u%R+0-1TAU$yID{5u!& zZLq4xa$V8`M(ErI`*j5@K7-9~rm}n;JMTd=GLtjudILaa!1epy)tyD*conMa#k4Lf z`|r@VXAtlBLwbLU6mD|uCF!akA4@RtBIw}DFru&i6G_XIm^mcH{0dMleo9O#Y--h| znj)ha3!>s19Iij%u(>&6sR8Z%wHzHN$0`=ce>UNp*`ARkH5(Ja`ph zVG7~Hz{5oWt#g;*wo}Nx6EaM9(u?1~l~E3`VJ?}F4YkPM!RM1QeGTX^8CE;Q$*!09_X~4Gt--Vv` z*Zt)`K8hF*FLJM^Yf0tn?>dj>U&i=Fz==0Zm*QdfEm3aJNQ9~NFW*NkU#XUqiLYE}ivQXc0JLl0vnwf2dEmwYS4$mYFJ#{7nv3}Y`hZ`>=OjIe17pP*3R zG)HJoe`n@A>rYQHGqEIb$h!Dc=U$PPj3F#|Bq4eb1s^ZW+vye+52z_Zt5aKv(!GgOr9#n_S5=2DPiSkc%qt^Ck6o}Wo`lf-a|m@?)#|^!pBeOtY7Q^+~U5w z=TA^lT5D$Gp>|-u6M^yTt=p1Og0Z37y#da43F@a@J7q+wT5%3gF1T%0MUj4$ zCL9g=#2J}!--j*Ar!v4ZBdX@+Wy;}@KR%MM<(Sb)ifdeLA<6Y+t4PiIdV>WW?Zb#*vo}e&aVb3)d_-g`_0V5R;vVVs&l% zR-~bEw@?_@&(|Xv`U5bGbJHtvVl*DDwC9j|a~(XnM`yn!V$^T5ibTF@$@lk$-8e89 z|I95bP_>(53`p%7gMC)#g8O2FfW6Y3KK@|IAkYK18vDzPoJlg)jB`T!vHtM`pAVxQ z^EZ0HX9PX(u9DrFq?*#nk4x@ zAgFn+7maFp>&N1B#5>JUb6rqCY5}!PNx_&6l99k2wDg-}1`T-zFH)6T>f#L*_`B6f zfen<7M%7tA8yCz~Y7VQK`LEj4)I#(V5NBtsvO5$kW4Ur8kZK^om6thCnpGBgjOUie4n5<~!v&s=YjvbXVE?{nl8)RtrF7j<_x6|34_xNA6UZM28l`->CZp%gOalx80LT>epYGbD| z=YdC}JZ)(Yew;o4p(@c2kDOlL4O95Bz!8MLESS+IiyDmLoHEAcAEerM`vC~H+2?qS zR(nzS42V9l{Wql;ayb*I==W}_-=9K+7U(+>FB>>w0j^&ehv7>jpUDk4@RcE)-Fxb;dnY8_M5 zb?!X4Nx%bq=84F^agbRE>U?2AU4r>*yg$BB{p1d;?C()DsLngaK%@RBlwp5ws`2rQ zSzyBEawN~De|y{ptb6v?cY!GYxk80<6ol9BoXyPXfMFULQ48h_b$PjG9__zEY-X(M zN}D4u+b#k;z_7&Ux;C;JN9S!#44~#9aB!=qAl*Th5}1uZ*a+}gO>xxaZF!dI)eS_< z83fI`sTo~;x!ZdUcMgOsJancNDF9o#{f3>_=f95D_DP<+s+V9xq7LSeHI^x@^LrO{ zFSy`KDBYyA8OhtDB=5F1=zKxENtx`-M)P^mrYC1pM(Ie#&VBQlnx)qJXq&9%-lxSV zMa&o-9i7wsS)}=r5&0*KqmymZxnRPMxvyzmd}wI_*gfcrU@?vw^q_M41?ZgmQ(gQx zoGthK-S0iwbbY+moA@$m=GN!l-6Z6YCWDf}Yx#-I5h&A~&c&yQOck6Fz8&x9x$>z{ zW@x@ec`sBq@13tr-{Yn?v1S?Cs z$;5a)g{)@NbOKLRs%(vcuC@Do{15R_mxJE(W1f@Oo03BH|4@5Coum41=Zm=$o|Yo- zbd^!!bheAjL6n1*>vXFn)ye3Y$C0I8N>eIDaij`*LhZAI^8mdIKjE_m)AVmmIuut4 zpwW6Kuqxr96prxKn&0ICMcON*5euE4;Pt1`#j z^OsQr(>)|C*Zh%!Tl73FE;pfxJM+$yNRRB}a%06_K3DfS@va0|{&BwHkWjq!!oWAr zo$Rn{#irLQhU&?@uTzBQ3ogEVEIN*<%g{#HR<^P0n5q<2prPq2DPu9glgZzF{^|h= zf@gE0gk*R_0MYQ}$KWOU{{h`+oqjs%w?*11n3&wI+yzGsdfgdhB(+Wn-^vEj-atLP&tJCnlf(@s+M|0v1tWQSTJ=$PnOc7&&zWDJ=GLb zd=&tHi}@V;$BB|1Hw)I_38JnlTGAj|lp%nhdx279vD#)2p*u6;8D`s%#}Hf+5X0SX zjaZ#zk*)G#&RHY9-Zd4PCB~G&PGl{7f3aulS{2Egt**4y@dH%NQ5CPN7t$jRC}iPV z)v0?KCFK+;108=dKPz;Zh{O*>oxL%-A_E?#!17Vgim;n^vy@mAb){Y{42o9%*8Gu@ z{lVr_09*uT<;e4#L0DR0@^F36HKAfU*Kd-UMtj(s1-H~;7ss|fi!w5)A8@YCkhSzJ z)Y;LRA83m_H!Mh9hF|nC2y2?R(h2|dMe_aeDDQ1|21m+YG^ET?%0vnnXh#G}Ccx{9 zUdR1*)>K@kiZR1(x z!_>E5B#2TAKbd%uF}xC+x4*y~fDLXDN(H<(2sZo~={<-u0U`csPGw?zu=g{FLT!O( zFAQT4bDHn?itIiY_A}>_CGVNT1U^Qb_Z>e@qcq9#U$tP<>plHyVsNaBDQ%ybzdfWH ze3r7Cx@nN$QncOr&=Q&=UVTleOj5CD2}NuOKABOccC{^yeIvd>JAYvJ4Zv?Z@G27~ z#@-R^i$mD9)HRk7yCXbS?roDgtr4gN^u%wrls*5`QxeNA~`j|cN1QOsys?eF04d*#kQaOlI5aMePV)K~TD!9Uy!Qmwlg+m~{ zzxqpn)24;-49iwNs;gvSZh?uny-BP^=XvJG#V}5&G9o;o242mZN(@zDzOP)LV=_is z%k+2HQJ@YSIP1X5E=!9lVIyU-U+H1MXVU$tGm_Q|_zx>hWuUM+lbTaiso1!>-@j^+ zQ}=wAF4C^7xVQY{m9;@`ZJzNmN^O(GMwlcwKqSW&p^FnXuD%{ab*=yQFT~k`9CsZA zP-ony+#x$J^olQ7vkgGIWbE*vnb4jrT=OcNr>((G z8;*yPFV&jLo!`N%VSl%5F`{Cz!tEraY^oZ(qN?3ifuxBvk z>FblLGD&#$h-j&ua8Dm2V4f-Os`?AL;&{DztE75Lil3W( z%#&lzMiWMMBqrC4U-m6KK*l1Ji#NyqL4`>{GgkBZ@ z9p`CjT}#v*Zz8q0TA$?eN+i{s)aS~DYeJcJ&)<09Pb$m*t2DH0{(k^z^h`__>{V6s zDO}7iZvB}gyJ@H21}}OfF@u+U=p{t-D;*&6nTo=52L=5FLi9jsrLsugY1)>5kJW8Q zkF=v>=>ZvAF`5W8+ASe_$@@id=9rL{8K3d_wL-MlMH1%Au&NC=^fhqM{5wbnMDj|Q z^i?$Sqlb{msz07QP=OuHBR;(2o`W})6je$?ZOk=sW~`Jb(+qD5^*z}#M-SH?D>)9P zxt>|IahFHR4gxh9+?i1a0`G8=S7&N`RUpKRTBDya2V(P?9O7j=uqP$+~VH*e;jWmYnw1zRk6O3BShi?#N#f zmv3gv9P>!W{;`J`w``0gohW1Q7_1(2XLm|77cR-JiV<-*daxa1r=?3350jj{x`<`R z2J6U*({C3?hc+FZPs#7#WAwG;EFEv9RN7*0;#{3{L%83;f5OHkj^=bW3Uc1Uzm8JlYVzqi+D$^AJ#-ri~6DtIu=QAVPQEEzg zUXVqyKxKR3AP|7vR2?$jXqNy52#oO~9S_|#^^??%&8l?*KE+vf?R{E!HO~Z(PUW|T z9pH>&F!WR51!teP!z#PRlj(Bc{Ssav4C!S@N-0a$if{@@?p~}Xt+s)ai3QDk2MpDy zVOCV6tp_?li3i{&#zYRbB*GM!}3gcy^@7LuH)^s~-8 zYE0H5PDGwn`ntC(4}^*8Sn}%$w`x&K-vMQ4X{Lkf&H`v@Mc^jn)rregn$5Zmj35nF z?UF29=7PbxGj|>r(d$RBFbNovvNGdBIHvosrm&xux{tuA9i5#o4Y4I>CJwj@Bf%L3 zLl~Sv#s4aq!>$_L6@(XOOodKa+|DjH7$ylHlEqcJF}y@(eWL0!rITYBQ=KZPkT3}M zV~6(r&WeZ6x5t!|U@*(|;wYBz)lTG86fl;IZvHv8ym$P|e^%pIA6R&5OpR+;%Fjvi zBhYH~KxpHoDY0#ij6RML$sf&%d~@txPC;C8=MtBjR_F_wxr{iii_`8W3Y$<&b#DG@ zXb88=3`(sOHWsN2&0oMrzEiz$>5~+OHAUJH(5DHPf|-2}hLr2C?7Dpv`Pb_kzL0lz zdIhB0A9df5Z_UEDfysda{Az#8cecN*_Ua<#Nir=+OLyogSEW!9f#Vfj#xuj0*d6Y| zdZYw(v5$v1g$~p6k>%l6_IY=>78=@AqccdgLhRGcHrW8RcIkAk&(g187-Gt3Uqtsl zws0A%k$su!`{u2mYND~0>RMj9GmS{=U19Q>N%s$R>v-bKf@cpA{n zjOn12huhCk_%Bt80s@FUP#RL}CxBTco|SVp2a_&f<|1iD90~nmHBV1l{y6iCao&Kp zI1}SN9?u?jjoy}kb-Ue z0_)HE1X95&e*WrEq@vWZkYKJ%WG|@faX`}H>Gw=97LDvLi+dVUeiRu zkoJr+S@F3P(A2M{xUPx3Sb3Z#*@EvPKl~8vhye~ala5#C{-8oaTg}SuT&U`&U~O5m z0FzVXoWx^i6&JE~yo`#{tD&+|)cM53^6*PVquaSv-EkCl(0qMcCTP0}Y+d*`bVh!> zs~neJU(34FA2du`O3L@)GjeS^dnrA)L_WI-hxx;)q?M-%hZIr5q~A1O94cgGN+1S+ z7ko23sDVck#JaDe;LmC(OBIRzI-TUO?XL^xror=7arwG!oB8&*qOLiw8NWwhzc47L z$gT9U4qqdO@K@MZ^guuI0J5dcjv{mW%~uIV@H?3DxTeyCdg z(IDMv7ZXmx=y|7=h?`jrD4hA~nsIcxHPFBASNjUvb7y|>gPOtEXgQS>NX5`GLvNm1?O` z(q^Sh^Q_r+zto#){6zcbS~y_;yFe;SpBxwdsM?PwAk6;4)Ig_Pzv`=>V5ti6DpR=X zT9&8;CR458Lf`v0QVMVf<*XdccsKd)HRTg^CcK3T?_hgjknzy%CmeWE?IN63c5xNo z>>adn89510MH*-c?=yeU1SuO0y(Fi?)6Ce^QN54SUrTXrP1t)jO`HH)S)&&SeJ*Sk zlCNz2qD+|aR05}CLLy}_X6=k%?6)-~7<7U*O>3kJo?0TNRQ$R|I~J!VNGn^I)I=A0SgF%H1Q}! z1*M5}kRnP?0O3HSNe2;-8V1D(ihxHzH1r~nP=fRxipmkBN$*W51_BBpn2_WS2hRPT z=iVRp*WG{Tnf1)xvuD;??|R?0%X9>+^;xz)42G+k@TY|@{OYnR>XyKG#Zt56(fO^n zdEUH3w@C$j6(6c`@<{S{-}wOsFK9!w9xAy+jCFS5L~=t;mdS)=nOEC+ z+Run#YU9Nt5~SuOTHH$4Gr4llU)2}gjMoydBAfLp1|+1Eo*3z+VftWR9+_ zS%=!X@Xg{#-t${Uc`%p!2rD^1xQRQD-+Lxk8w~GU-e`+T+!3nTnHZi?n76vu`4Nh| z*9~mvW#PE`5l?}a=dedB)uo4SUn@0$i{6XM{Xku$-uN>7?xp$AO9u&0aUg!e@B1Ct z*U6@_{6Vt=6EYGF7I~eeC4nK= zCnZEHOZ8;`Dnrz~z*t8ezJ4>7?GZmivHK1sj2eihLkvT0w;2<3l%tA~ z<$z|K;%$XwOKZ&e(GcD{sj+SB@W6b%*?9MJerf_Xz$toK;B-iYY4W3G>K`oM3)%ht z6niZYoPQd6biphqc6r>irAmUMcJTrmpSi?4scx9RGkkf)+M>f2s7#X-G?lc*J=KNHMbcb4L=GoewhF|b_xYt4yv|KB!$@^DlC3&IoQ>f{oQkXv z%>whl#KQ7t8<05js>ITCxpmvM_y)ph@@pMN_nK^R7gHOJm3D=HUB(XN3kdWz6{`<9 zM2YW2h@@QF%yt2=p=LuCp>{)wIgx!<){3JVc@*5B#KP5vJeg1Q)tc>*?J5Io<`*na zqhy&N-`1_S=wS&#EoJ3bfhF^0F{I2<4;PkiZyK>T6u7w#3$g3RU(Ru9%m1i|4N#GA z6GHUimxs@Wv20-||U{e8;Q@yoB zGo8t)=-pHnG%21@({{=JcVjAf-D&DVP(c+txGe+O2EaG;4|yBJY)gLjd}KTrVJs*I z{Tvhu&qQce3?kC7X$c+&V zu#^9q?;hMcGWZLiPR#7m-DkK8twnQPPJxt?Y07X-J*Z;n@RpoJ^hrgn&<`MLx4xMX z-z^RDX_YTbG`~@4Jqlzlq1WsFT2{+CC}Q1qm>jS2L~azw5({(VAK=2jONVtA-S1z7 zAH&QVZ=!I~Oz##O)c9jHCxEXy6GvF|Y@^#W)f~ZYfKO-mE)~5r-|S#|$NExoT%;TK zy{dO{9&B!IqW;?%7g8m}3I;^kYu=yln}dr1A4dZ2hxXb1^2^h%M~?LgczlvhC<$xz zc83a*$~4WzGPIRZPy83Z{M1TE#X*1YEW1x9nyA+ad;K^*t_<+&XwGFWasYfBSDkEv z_*HF0>lA-D68tl%X$X2Oc!n|({W^BUeZorOEw&cMfBLf1Tp>KPw4|?UWU+hKI2Z1XISv%F z2kh3fzVV3gW-8Fuf=hYSXV~)Q3J*U~J<4M-=LoV;$K!>e(iTj)sMn!1P$;uH@WGZ) zbr-&Qrx1E~^K90ky|^{2-J0d0R*khtwg?o4@?~;?)1>U5V z*9WBp&1f@O*P$2C`HOj#j~sY@p`!ZT>^)EU(2Q+nlJy?t^@q;)S6>2>j;2&R3ek2L z%3)1(@b_Xx|AeS3W}=>-GLYJ-^d4V#;Fmh})I&(o`sHHH43k&HLD3^Xlr;uQ;W5Yn z=)WV-ZQ4c_yiTg2Rf|?-Zn?o7;K|GnY(@)BWra06^3X;_0#~_IOW5n++BPC7p~Akl zgrh|l*Mj9U<1YZd=X`{2Su;rqC`_V*J80(2qpJVDO;bxVx7+~fv@VX#8`(}~@NBTi zIe(=X27^0}I$u}#J5IRT8x=KYFg~8It;6~TL%+~|yvc&Vt$$K!F6+7?U_bL3q9}7v zkTHGsSX*uWc$qFd@spGY?@_727cAOMuL0;8?@C`HO$ zihGkbdW5)yo76(mC!;>5O5y!|4iWm$*8EZ-!BXzWwU-t7vwFB_qxVLWXKY$?INeTi zh3Bq7mErF(OW5(Fpd{jzhhr(y;t00$OD%Wj#x?V(2{*eDyC6HAE;m8UqhJ8?AG7C& zKGYw_oFBlvlv&mPDhxg39p=aHF~ECceAhA8-xlt4ygPLy$a(aIYF)!{u z;b03eD|w!>({sq7gutp6)0F)wjiZ0*i~DtZy-pq2a%N;J%_u3wSuo zS+W%B6#-|5<{``Vt-Vl+Fy0$`6wgj8RixVE>+!QpoDVEy|Za`4S8 zL?o(#H8ONC%iguf>eLk)H5vC?SA79BO??OxvC{ap8y86 zje`hkn*s#crI{cbEOSS@oBtM6bUqUGWlv|tFVE(E=i?E|z+M%xPwTaeolVFz*u#ED z|1X6iD{A!ZH`n^%fuBcY9&~{{3Q$c5(AZT2)&r^dIn9wmIg|7Spqb?D8zjeApEy&U_RggEetR@$pi06-tHb?z0(Z^t z%tS;((8FFEbA}+JZDg@d?+at4irC|2m)jBVSCAQKtkb0!=6j1hVQCRDvrNWx(k@k* zJoKe3r&&;&5VqncK$!+Ok3&7GBnuLEm&?{%9dPkjuq?+BlWV_@Fm|8n)+|Nb9X{-d zdbU_I79BuqzDL4(y(LltkdgS!>W?JkMJfb;-FAI9(~giyUc)r*S(D|$;Q`Mqs0mT| zfJq>KT0CybqO_J$;A3HBMO*(&)Th`+Y|yFxDc}O>Q}9hHjIwLC+XZyt0XNOx3)0O5 z{Kda=!a&M>amQh@!Ni64)YK+AeZk~!BA+k0H}K1dD-+?(6{o4jb^Eyj6gj^=A()%V zg?ayLJ$e@Ui-#?L>rix&9WNGOy9yN|F{ywjl{dGh2aZc3Qy4e|HM;-sjw&S!Sbm}O ztvO=(1q>xX}0Dl!5gQvzL$Oh{h5qEIdud5>pylXH_2?7}6d8{RCPekuuNFwW8icn$&)d*OgJ~hn(|7<-t6a`oKB(Nr|O?bcd zbct(d-!Zp{=5V(T7t30HkMc7yAY*L)E;PNL2$Tk9L~Jh+lFjvo(4o6KVJXyzxi;@* zon2}I$=)Rf5+;oxmo2z3z>)F|qc`<6b;<;>n|Sv`vKv={VTX1f5aDUWh-r$ z=-U^wj^blvJ*5IZW`&e8Rur0NZgMaOnwa}PFtX5Hjv~)2;rn9N`R1-Xi6RtAhKa|k z6Q5A{6rj4ize=+OpFKVH59y&rbc3`WfqPK#4g6tkQ>N4l$&HGiqF>AbE%Fpu9QRL( zskc#KFKxUrR1?!Gc0;74eewIc+7@HDy>}xDuP`s8O}G3cjYCdg^Hi6a> zmE9-CBDR)8@yQfXT$Xj=tM9m5tpIZ;X&14V}WM2eGy}Wh2!pI|Ysr zf8^|*&q7uuN=fh3_%gC= zMBeoJjxPH&ZJN;Qw!a$G)A^7?Z!fOyMDATXjrK)5Q{)2fSuU) zaU1{jIi2Wx9rCh|{aAyy$;nt}uOzt4Q{LWC;cYQ#HTW0;_b02+KvS;kp`W{=vPU(; z^hiV%UyzkZ>qws!_y{z;W9-MfcOe$|7oi_`HquQ+ROgrK#9RPr*-LI^kbXU=Raw@o znMfzEFmYbLpLnaaO|)-Lh%0M8Bff=7rfUI@wWcu7-CucQz*Ph$NRR zBe>( z1@yz5pI3$(BDdW|O-I-tCP)azElbE!`9PRql^RMt1@b(cv5E~!q%r<$D0!oE89k2QkB%IxI#c~SK@$$ zcyv*H`CltCf)&UIq2#WhUkQB-)h#75u>9&m=r#~3|FgN-^7BQE3B) zOy_Nv);ImRgxm;17Z*IxR^t^~pL}+z26p;R^-MRHb}sdKwbRrM>zPz+&v4{*Ff_8i z`l@Ei7T#SFxWbX#7?!;t%{Ps1g%!o$i# zsw8#?qDl8B)CkM&p*~_c%xXx31WDLnu7=l4C0@YGas1KYZ737g(qUy+;+%wlofQrC zgEV%@Z4vr}$9S5&;lv`9=4ELD#H%?pqhf#Ds{T@GV|CI&_Ki(VVdqtxuc5Lehir;W zh-zJ9i4M!+IkI?zE8r%KWO8aPWLx`DH)6CU<|vLd#dfiCC|i)-r)pL+D(=CnT6?Z@ z9>gxp&=pIZ6mj7;QsF7G85OVbYl_ZN$(d%6O!}EmQU@EalaTH(Ggh}RdW?93z@%CN)A;gJvw8&s6dFYD|x$F$JavuHkCxUUb#H4H;X~RV=o{*jA^&n zSVPdIOs-osrI0JWjPTJr9I84t)69k>Wa<(n)81nJy7rv2nbL7M1~K(pOIHdj{!dOF zsg+fL`{==hfLq$Z-Z7hX%a+VvmB|{M6@c=Qixp;$!cTWs<5`Rfp;*HmEOJcaxK|7u zFl8>uIX`84>w8|k?U5)0+NHIxQ&W9zLu&98HFCL(GdYVEMeV&s82i?9eJv?J^}dw@ z=;&<9$FxEnp~@Co1wD60|6rr+r}dFq&eeKFm)#?ZTSODulsFFO3th`qSL4!%rNn*V zFyxhA&8)bc+^U4-TF&LBx;S``0!eE5P8|BDxrMGfU19pe#$(A(rB>!Uk|*tVua|4USC#cJ{d1NS7UVbypf>M?DN`+@(IzD-?%?bo|?c+-6vB>%DJl(<8(J21{8w!dre zbzq?|QxWG4=8OM~N`UOcLpWI2V_WH>j-rj~Tdi^j{ckLd`f3LB@Tj>MRZN!Z07*WOz)e1ZW z_E#v94eYQlNN%D9ZS=yk9lMt!u>sxIfdgYaCId7ysZ5^4~WB`?VhA z>>Wm9*x)gxpkHn^!20N%&-!~Ep}T{FzpNP6>ptQ0z*G(|gHV9{r7%$!f)gY}s)BX2 zk6@wm;7KFuf4vVRrX3!x#k-1}cy#p#Vk3--fAPrAl%32c?^rgC*xw0Rx;*Fp#D_OE z1P$r69oi}<6pHzDj%E`tsd(E89zqF^^zjqV$oyTQFo~dR)Z)Vy20L7>_qOFtUE>?2 I+IJuS7i<<;4*&oF From bbf3ce39489d6a434a1c85162c4299386333b6cd Mon Sep 17 00:00:00 2001 From: LEEKYUHYUN Date: Tue, 23 Jun 2026 10:38:05 +0900 Subject: [PATCH 036/185] =?UTF-8?q?wr:=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/common/business_components/src/ui/table/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/common/business_components/src/ui/table/index.tsx b/packages/common/business_components/src/ui/table/index.tsx index 15aef7fa9..ed05a7f19 100644 --- a/packages/common/business_components/src/ui/table/index.tsx +++ b/packages/common/business_components/src/ui/table/index.tsx @@ -152,7 +152,7 @@ export function UiTable>({ <> )}

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 From 1333bd040a0a1995a5c276caab07b886f77e75cf Mon Sep 17 00:00:00 2001 From: LEEKYUHYUN Date: Mon, 22 Jun 2026 18:02:57 +0900 Subject: [PATCH 035/185] =?UTF-8?q?=EB=AC=BC=20=EC=9E=AC=EC=9D=B4=EC=9A=A9?= =?UTF-8?q?=20=EC=86=8C=EA=B0=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../public/images/landing/intro/rel_note.png | Bin 61459 -> 61425 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/dm/public/images/landing/intro/rel_note.png b/apps/dm/public/images/landing/intro/rel_note.png index c5cd23c7b5caaf468798e365be19166c0fce65a8..94880c74311f59b2e6dae5c0281447bcdcde14ef 100644 GIT binary patch delta 58748 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 delta 58785 zcmZ7dWmuG5_dbq43K9as07@w_45fs0gTM?hbV-Lui-2@4^X9sf~gI1pDUutaz%{^mv|KdF`Had6xYynCrYF!*V+eo>w{RZ@ZQ z+32{2N85OLij2;$9|V_u_#7<16^NPWak;WLI~F=>I%XI3tdza8e0xxy9fq0Clg6j| z-c=XgK=8dX5Ktc7$?V{DDBiG+x}2Zf+C=&~Py~=jhotd-NVl647&#&yd1=1%iY3m| zTd@Q={eotmanN9{zoDT~N55W#A;O3Tr#V!V&?$==MSPHFME!*%F&i1)D}6~ZLgfO0 z^v;5j10UO&Sp^;DcP1UCS8lR(9!ujfG)LOu5`G~14B)`EdK!_D?(=K|=mWZwz+SVd z{d!l5Jz*uV6gkFcVM>UwBn(0)wYSCJmGOmd4Bc@6OMKxb$4 zqKNxMI1TDhQ|^(QoS%7!=yU2cYKY@cT(LHLXTJBY1lo2VN(>fGS2_RKrU1Vwz(n&V zP!NDw;{;~GtkOVZ#~GE>bhSnJEF}?Ug#kqoU$JnT_i>5c&VL0C+kK#rXz3)P^DucLs^$|4jNHaOF_n?4a^h@%r5x>|JRD z9Nhjf90iKaeri~_y&%{QjIOA7?%1I}pi68R=Ml2w4W*A0rJ!AfbOyV0CxGYo;JHFM zVn=C8OcaLxFR(W&C<~e?Tx<2UJQ8WV5w@KsKi(1CS8JbW4`BIwWJbJ(q96KC8eXFn zYi0hIh^;%Yh>S^V$C;ND{A|tpT?iv4elEbnwY8SRw#^-)9AaZT&tZQ4_a(Kg>imYH z<0voh0px+z2tN}xX70OB2V7u$OvpI4Y%i0IxmX3KmV?q8rHWW8zSU$skA$lf@S|qp18Tg8s_|U=jcCtkdz;j^YaYcQZS{0k3q% zFW9Eg4x`@yL~sh`(Rx=-<<$6mH#WPVnL~o0r6Fi zK#V1BMJ;QKTrFm7(PPXcp}!MHk`K6ibKp^!*kH#0^T+|f@iEvw@zL16n4ctol~II0 zkmZq+vmH!CchGa#RJcnR4qPi<_N4LK-x_;2$rPx%Zvv<32a!`ImjfB-GKZ#jl@^u0 z1Qkupt|F5yx0fC6G?NvVSP`_mJ&_BZR)Q8CD8sr^uM54)-=q0=m#eB0(;V%)ewGZ3Zjv}a-%O~NL!;-M3=GA$YYj_wP+jp zvZ#jt`8<*yn_UWG<_q($Y}{&Dbj`Te`T6@By$}LFxyq(VVDiVdr|*RG@bx0G@a++_ z*m6Lg%)XQ?)N%lRfBMG-y;~3w&E)bq5sg4&Fs;Y;@WCs$Qm?z~>KM}wUDf=qm&69f zGk1(OFFu{7FlT>1w5=W1OZlTbYchmiEkG3D6gO9GQ3!hYGOr$fs#Y2JmYuwDSt9r? ze`sUgsej9oCtxw7Kqc@NdOfrHr$$omN4{6Anbjsgyq|X=>C@V;u8UzP&@!d*|Km

+
{widthFixed ? {leafCols.map(col => ( From 4126bc2234f5cc11d2bc09bde7c28cdd28555b96 Mon Sep 17 00:00:00 2001 From: ljpark Date: Tue, 23 Jun 2026 13:32:25 +0900 Subject: [PATCH 037/185] =?UTF-8?q?wr:=20=EC=9E=91=EC=97=85=EA=B3=84?= =?UTF-8?q?=ED=9A=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/wr/docs/작업계획/작업계획_20260623.md | 162 +++++++++++++++++++++ apps/wr/docs/작업계획/테마적용.md | 11 ++ 2 files changed, 173 insertions(+) create mode 100644 apps/wr/docs/작업계획/작업계획_20260623.md create mode 100644 apps/wr/docs/작업계획/테마적용.md diff --git a/apps/wr/docs/작업계획/작업계획_20260623.md b/apps/wr/docs/작업계획/작업계획_20260623.md new file mode 100644 index 000000000..e0b106b9d --- /dev/null +++ b/apps/wr/docs/작업계획/작업계획_20260623.md @@ -0,0 +1,162 @@ +# apps/wr 테마 적용 작업계획 + +작성일: 2026-06-23 +대상 앱: `D:\iipbs\frontend\apps\wr` + +## 1. 분석 요약 + +- 현재 테마 구조는 이미 `data-theme` 기반으로 잡혀 있다. +- 실제 테마 키는 `default + black + blue + green + red` 기준으로 운영되고 있다. +- `apps/wr`는 `src/app/index.tsx`에서 `site-theme-key` 쿠키값을 읽어 ``에 주입한다. +- `packages/common/components`와 `packages/common/assets`는 CSS 변수와 컴포넌트별 `.css` 파일을 통해 테마를 적용하는 구조다. +- 반면 `packages/common/business_components`는 다수의 UI가 TSX 내부 Tailwind 클래스에 직접 의존하고 있어 테마 일관성이 약하다. +- `apps/wr` 내부에서 `@common/business_components`를 참조하는 파일 수가 많아 영향 범위가 크다. + +## 2. 확인된 현재 구조 + +### 2.1 테마 진입점 + +- `D:\iipbs\frontend\apps\wr\src\app\index.tsx` + - `site-theme-key` 쿠키를 읽어 `data-theme`를 설정한다. +- `D:\iipbs\frontend\packages\common\components\src\ui\header\index.tsx` + - 사이트 정보 조회 후 `site-theme-key` 쿠키와 `document.documentElement[data-theme]`를 갱신한다. + +### 2.2 공통 테마 자산 + +- `D:\iipbs\frontend\packages\common\assets\src\styles\base.css` + - `:root`, `[data-theme="black"]`, `[data-theme="red"]`, `[data-theme="green"]`에서 CSS 변수를 정의한다. +- `D:\iipbs\frontend\packages\common\components\src\ui\*.css` + - `header.css`, `table.css`, `sidebar.css` 등에서 컴포넌트별 테마 오버라이드를 관리한다. +- `D:\iipbs\frontend\apps\wr\src\app\styles\components*.css` + - 앱 레벨의 blue/green/red/black 보정 CSS가 이미 존재한다. + +### 2.3 business_components 현황 + +- `packages/common/business_components/src/ui` 기준 소스 파일 수: 81 +- 같은 경로 기준 CSS 파일 수: 6 +- 이 중 일반 UI용 CSS는 사실상 `date_picker/date-picker.css` 중심이고, 나머지는 지도 legacy CSS 성격이 강하다. +- 하드코딩된 `text-gray-*`, `bg-white`, `border-gray-*`, `hover:bg-gray-*`, `dark:*` 패턴이 확인된 파일 수: 28 + +## 3. 핵심 판단 + +### 3.1 CSS 전환 가능 여부 + +- 가능하다. +- 다만 "모든 Tailwind를 제거"가 아니라 "테마 의존 스타일을 CSS로 추출"하는 방향이 맞다. +- 즉 배경색, 글자색, 보더색, hover/active 색상, 선택 상태, 강조색은 CSS 변수 기반 클래스로 이동한다. +- 반대로 `flex`, `grid`, `gap`, `w-*`, `h-*`, `p-*` 같은 구조/간격 유틸리티까지 전부 CSS로 옮기는 것은 효율이 낮다. + +### 3.2 왜 이 방향이 맞는가 + +- 기존 `components`가 이미 같은 패턴을 사용한다. +- `assets/base.css`의 토큰(`--background`, `--foreground`, `--primary`, `--border`, `--label`, `--container`, `--title` 등)을 재사용할 수 있다. +- `apps/wr`는 `business_components` 사용 범위가 넓어, 테마 속성만 먼저 추출해도 화면 일관성이 크게 개선된다. +- 전부 TSX 내부 유틸리티로 유지하면 5개 테마 차이를 한 번에 통제하기 어렵다. + +## 4. 우선 전환 대상 + +### 4.1 1순위: 색상 하드코딩이 직접 보이는 공통 UI + +- `packages/common/business_components/src/ui/file-upload/index.tsx` + - `text-gray-*`, `outline-slate-500`, `dark:text-gray-400` 사용 +- `packages/common/business_components/src/ui/tab/index.tsx` + - `text-blue-600`, `text-gray-500`, `border-gray-200`, `hover:border-gray-300` 사용 +- `packages/common/business_components/src/ui/search_input/index.tsx` +- `packages/common/business_components/src/ui/search_input_select/index.tsx` +- `packages/common/business_components/src/ui/select/index.tsx` +- `packages/common/business_components/src/ui/form_ui/index.tsx` +- `packages/common/business_components/src/ui/search_icon/index.tsx` + +이 영역은 `apps/wr`의 검색조건, 입력폼, 탭, 업로드 UI 전반에 재사용될 가능성이 높아서 우선순위가 높다. + +### 4.2 2순위: 테이블/검색/모달 계열 + +- `packages/common/business_components/src/ui/table/index.tsx` +- `packages/common/business_components/src/ui/table/HeadMakeColSpan.tsx` +- `packages/common/business_components/src/ui/search_address/SearchAddressModal.tsx` +- `packages/common/business_components/src/ui/map/searchBar/SearchInputText.tsx` +- `packages/common/business_components/src/ui/map/searchBar/searchBar.tsx` +- `packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx` + +이 영역은 화면 체감도가 높고, 회색/흰색/파란색 하드코딩 비중이 높다. + +### 4.3 3순위: 차트 계열 + +- `packages/common/business_components/src/ui/chart/*.tsx` +- `packages/common/business_components/src/ui/chart/lib/chart.tsx` +- `packages/common/business_components/src/ui/chart/lib/card.tsx` + +차트는 레이아웃보다 tooltip, legend, card, axis text 색상이 중요하다. 우선 색상과 surface만 토큰화하고, 차트 팔레트는 기존 CSS 변수(`--chart-*`)를 재사용하는 방식이 적절하다. + +### 4.4 별도 트랙: 지도 legacy 영역 + +- `packages/common/business_components/src/ui/map/**` +- `packages/common/business_components/src/ui/map/lib/css/**` + +지도는 legacy JS/CSS, sprite/image, OpenLayers 연동이 섞여 있어 일반 UI와 분리해서 작업하는 것이 안전하다. + +## 5. 권장 작업 방식 + +### 단계 1. 테마 기준 정리 + +- `components`에서 이미 쓰는 토큰과 클래스 패턴을 기준 문법으로 고정한다. +- `business_components`에서 새로 쓰는 색상 클래스는 직접 색상값을 쓰지 말고 CSS 변수 기반으로 통일한다. +- 예시 기준 + - 텍스트: `text-foreground`, `text-label`, `text-title` + - 배경: `bg-background`, `bg-card`, `bg-container`, `bg-form` + - 보더: `border-border` + - 포커스/선택: `--primary`, `--primary-transparent` + +### 단계 2. 컴포넌트별 CSS 파일 추가 + +- `components`와 동일하게 각 컴포넌트 폴더에 `.css`를 둔다. +- 예시 + - `file-upload/file-upload.css` + - `tab/tab.css` + - `search_input/search-input.css` + - `select/select.css` + - `search_address/search-address.css` +- TSX에서는 색상/상태 관련 className을 의미 있는 클래스명으로 치환한다. + +예시 방향 + +- 변경 전: `text-gray-500`, `bg-white`, `hover:bg-gray-50`, `data-[state=active]:text-blue-600` +- 변경 후: `file-upload__hint`, `tab-trigger`, `search-address__suggestion`, `is-active` + +### 단계 3. 테마별 분기 작성 + +- 기본값은 `:root` 또는 기본 클래스에서 처리한다. +- 테마 차이가 필요한 경우만 `[data-theme="black"]`, `[data-theme="blue"]`, `[data-theme="green"]`, `[data-theme="red"]` 블록을 추가한다. +- 동일한 값은 중복 정의하지 않는다. + +### 단계 4. apps/wr 화면 검증 + +- `apps/wr`에서 `business_components` 사용도가 높은 화면부터 확인한다. +- 우선 검증 화면 + - 통계 화면 + - 재이용시설 관리 등록/현황 화면 + - 자재관리 검색/등록 화면 + - 지도 검색바/상세팝업/레이어팝업 + +## 6. 실제 작업 순서 + +1. `business_components` 내 테마 하드코딩 파일 목록 확정 +2. 공통 토큰 매핑표 작성 +3. 1순위 UI에 CSS 파일 추가 및 TSX class 정리 +4. `apps/wr` 주요 화면에서 5개 테마 수동 점검 +5. 2순위 테이블/검색/모달 영역 확장 +6. 차트 계열 색상 토큰화 +7. 지도 legacy 영역은 별도 보수 계획으로 분리 + +## 7. 검증 기준 + +- 같은 화면에서 5개 테마 전환 시 텍스트, 보더, 배경, hover, 선택상태가 모두 읽혀야 한다. +- black 테마에서 회색/흰색 하드코딩이 남아 흐릿하거나 과도하게 튀는 영역이 없어야 한다. +- blue/green/red 테마에서 활성 탭, 버튼, 선택상태가 `primary` 계열로 일관되게 바뀌어야 한다. +- `apps/wr` 기존 기능 동작은 그대로 유지되어야 한다. + +## 8. 결론 + +- `business_components`의 Tailwind 스타일을 `components`와 같은 방식의 CSS 중심 구조로 전환하는 것은 가능하다. +- 다만 전체 유틸리티를 전부 CSS로 옮기기보다, 테마에 영향을 주는 색상/상태 표현만 우선 추출하는 방식이 가장 현실적이다. +- 우선순위는 `공통 입력 UI -> 테이블/검색/모달 -> 차트 -> 지도 legacy` 순으로 잡는 것이 좋다. diff --git a/apps/wr/docs/작업계획/테마적용.md b/apps/wr/docs/작업계획/테마적용.md new file mode 100644 index 000000000..c5d8cbd9b --- /dev/null +++ b/apps/wr/docs/작업계획/테마적용.md @@ -0,0 +1,11 @@ +#frontend 5가지 테마(theme) css 적용 작업 +## 대상 dir: D:\iipbs\fr\frontend +## branch :iipbs/dm/dev +## 대상앱 : apps/wr +## 전제 : + - 주 component는 packages/common/components, packages/common/assets이다. 5가지 테마가 적용되어 있다. + - 부 component인 packages/common/business_components는 주 component가 바뀌면 따라 바뀌어야 한다. +## 지시 : + - business_components에 css로 안되어 있고 tailwind 코드가 삽입된 경우는 components와 같이 css로 넣어 줄 수 있는지 확인 부탁해. + - 어떤 방향으로 작업이 진행되어야 할지 프로젝트를 분석해 주고 작업계획_[오늘날짜].md 파일 만들어줘. + \ No newline at end of file From 99db9a0b779e94cbb28826e90c13fc617a30a27f Mon Sep 17 00:00:00 2001 From: ljpark Date: Tue, 23 Jun 2026 22:05:19 +0900 Subject: [PATCH 038/185] =?UTF-8?q?wr:=20business=5Fcomponent=20css=20?= =?UTF-8?q?=EC=88=98=EC=A0=951?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/wr/docs/작업계획/작업계획_20260623.md | 55 +++++++++++---- .../src/ui/file-upload/file-upload.css | 67 +++++++++++++++++++ .../src/ui/file-upload/index.tsx | 16 +++-- .../src/ui/file-upload/lib/file_upload.tsx | 24 +++---- .../src/ui/form_ui/form-ui.css | 19 ++++++ .../src/ui/form_ui/index.tsx | 32 +++++---- .../src/ui/search_icon/index.tsx | 6 +- .../src/ui/search_icon/search-icon.css | 7 ++ .../src/ui/search_input/index.tsx | 9 ++- .../src/ui/search_input/search-input.css | 15 +++++ .../src/ui/search_input_select/index.tsx | 13 ++-- .../search-input-select.css | 23 +++++++ .../src/ui/select/index.tsx | 13 ++-- .../src/ui/select/select.css | 15 +++++ .../business_components/src/ui/tab/index.tsx | 65 +++++++++--------- .../business_components/src/ui/tab/tab.css | 27 ++++++++ 16 files changed, 312 insertions(+), 94 deletions(-) create mode 100644 packages/common/business_components/src/ui/file-upload/file-upload.css create mode 100644 packages/common/business_components/src/ui/form_ui/form-ui.css create mode 100644 packages/common/business_components/src/ui/search_icon/search-icon.css create mode 100644 packages/common/business_components/src/ui/search_input/search-input.css create mode 100644 packages/common/business_components/src/ui/search_input_select/search-input-select.css create mode 100644 packages/common/business_components/src/ui/select/select.css create mode 100644 packages/common/business_components/src/ui/tab/tab.css diff --git a/apps/wr/docs/작업계획/작업계획_20260623.md b/apps/wr/docs/작업계획/작업계획_20260623.md index e0b106b9d..cad73ed99 100644 --- a/apps/wr/docs/작업계획/작업계획_20260623.md +++ b/apps/wr/docs/작업계획/작업계획_20260623.md @@ -3,13 +3,17 @@ 작성일: 2026-06-23 대상 앱: `D:\iipbs\frontend\apps\wr` +수정불가 component : `packages/common/components`와 `packages/common/assets` +수정가능 파일 : `packages/common/business_components/*`, `apps/wr/*` + ## 1. 분석 요약 - 현재 테마 구조는 이미 `data-theme` 기반으로 잡혀 있다. - 실제 테마 키는 `default + black + blue + green + red` 기준으로 운영되고 있다. - `apps/wr`는 `src/app/index.tsx`에서 `site-theme-key` 쿠키값을 읽어 ``에 주입한다. - `packages/common/components`와 `packages/common/assets`는 CSS 변수와 컴포넌트별 `.css` 파일을 통해 테마를 적용하는 구조다. -- 반면 `packages/common/business_components`는 다수의 UI가 TSX 내부 Tailwind 클래스에 직접 의존하고 있어 테마 일관성이 약하다. +- 이번 작업의 전제는 `business_components`가 1차로 `assets`를 참조하고, 2차로 관련 `components`의 CSS 변경을 따라가야 한다는 점이다. +- 반면 현재 `packages/common/business_components`는 다수의 UI가 TSX 내부 Tailwind 클래스에 직접 의존하고 있어 위 전제를 충분히 만족하지 못한다. - `apps/wr` 내부에서 `@common/business_components`를 참조하는 파일 수가 많아 영향 범위가 크다. ## 2. 확인된 현재 구조 @@ -42,7 +46,7 @@ ### 3.1 CSS 전환 가능 여부 - 가능하다. -- 다만 "모든 Tailwind를 제거"가 아니라 "테마 의존 스타일을 CSS로 추출"하는 방향이 맞다. +- 다만 "모든 Tailwind를 제거"가 아니라 "테마 의존 스타일을 CSS로 추출하고, `assets` 및 `components` 참조 구조에 편입"하는 방향이 맞다. - 즉 배경색, 글자색, 보더색, hover/active 색상, 선택 상태, 강조색은 CSS 변수 기반 클래스로 이동한다. - 반대로 `flex`, `grid`, `gap`, `w-*`, `h-*`, `p-*` 같은 구조/간격 유틸리티까지 전부 CSS로 옮기는 것은 효율이 낮다. @@ -50,9 +54,19 @@ - 기존 `components`가 이미 같은 패턴을 사용한다. - `assets/base.css`의 토큰(`--background`, `--foreground`, `--primary`, `--border`, `--label`, `--container`, `--title` 등)을 재사용할 수 있다. +- `business_components`는 `주 component가 바뀌면 따라 바뀌어야 한다`는 전제가 있으므로, 자체 색상 하드코딩보다 상위 스타일 자산 참조가 우선되어야 한다. +- 따라서 `business_components` 스타일은 독립 규격이 아니라 `assets -> components -> business_components` 흐름 안에서 정리되어야 한다. - `apps/wr`는 `business_components` 사용 범위가 넓어, 테마 속성만 먼저 추출해도 화면 일관성이 크게 개선된다. - 전부 TSX 내부 유틸리티로 유지하면 5개 테마 차이를 한 번에 통제하기 어렵다. +### 3.3 참조 원칙 + +- 1차 기준: `@common/assets/styles/*` +- 2차 기준: 관련 `@common/components` 하위 CSS +- 3차 보완: 위 두 곳으로 흡수되지 않는 business 전용 표현만 `business_components` 내부 CSS에 작성 + +즉 `business_components`는 가능한 한 직접 색상을 소유하지 않고, 상위 레이어가 정의한 테마 결과를 따라가는 구조로 맞춘다. + ## 4. 우선 전환 대상 ### 4.1 1순위: 색상 하드코딩이 직접 보이는 공통 UI @@ -99,8 +113,12 @@ ### 단계 1. 테마 기준 정리 +- `business_components`의 참조 순서를 먼저 고정한다. + - 1차: `assets` 토큰/공통 스타일 참조 + - 2차: 관련 `components` CSS 참조 + - 3차: business 전용 CSS 최소 추가 - `components`에서 이미 쓰는 토큰과 클래스 패턴을 기준 문법으로 고정한다. -- `business_components`에서 새로 쓰는 색상 클래스는 직접 색상값을 쓰지 말고 CSS 변수 기반으로 통일한다. +- `business_components`에서 새로 쓰는 색상 클래스는 직접 색상값을 쓰지 말고, 우선 상위 참조 자산으로 해결 가능한지 확인한 뒤 CSS 변수 기반으로 통일한다. - 예시 기준 - 텍스트: `text-foreground`, `text-label`, `text-title` - 배경: `bg-background`, `bg-card`, `bg-container`, `bg-form` @@ -109,7 +127,14 @@ ### 단계 2. 컴포넌트별 CSS 파일 추가 -- `components`와 동일하게 각 컴포넌트 폴더에 `.css`를 둔다. +- 먼저 관련 `components`에 이미 존재하는 CSS가 있는지 확인한다. +- 같은 성격의 UI라면 `business_components`가 그 CSS 결과를 따라가도록 구조를 맞춘다. +- 상위 참조만으로 해결되지 않는 경우에만 `components`와 동일하게 각 컴포넌트 폴더에 `.css`를 둔다. +- CSS 파일명은 `packages/common/components`와 동일한 규칙으로 작성한다. +- 즉 CSS 파일명은 컴포넌트 폴더/컴포넌트명 기준의 kebab-case를 유지한다. +- CSS 클래스명은 너무 길게 만들지 않는다. +- 네이밍은 `__` 대신 `_`를 사용한다. +- 컴포넌트명도 필요 이상으로 길게 쓰지 않고 축약형을 허용한다. - 예시 - `file-upload/file-upload.css` - `tab/tab.css` @@ -117,17 +142,19 @@ - `select/select.css` - `search_address/search-address.css` - TSX에서는 색상/상태 관련 className을 의미 있는 클래스명으로 치환한다. +- 이때 business 전용 CSS는 상위 CSS와 충돌하지 않도록 override 범위를 최소화한다. 예시 방향 - 변경 전: `text-gray-500`, `bg-white`, `hover:bg-gray-50`, `data-[state=active]:text-blue-600` -- 변경 후: `file-upload__hint`, `tab-trigger`, `search-address__suggestion`, `is-active` +- 변경 후: `fileup_hint`, `tab_trigger`, `searchaddr_suggestion`, `is_active` ### 단계 3. 테마별 분기 작성 - 기본값은 `:root` 또는 기본 클래스에서 처리한다. - 테마 차이가 필요한 경우만 `[data-theme="black"]`, `[data-theme="blue"]`, `[data-theme="green"]`, `[data-theme="red"]` 블록을 추가한다. - 동일한 값은 중복 정의하지 않는다. +- 가능하면 테마 값은 `assets` 또는 `components`에서 정의하고, `business_components`는 해당 결과를 소비하는 쪽으로 유지한다. ### 단계 4. apps/wr 화면 검증 @@ -141,15 +168,17 @@ ## 6. 실제 작업 순서 1. `business_components` 내 테마 하드코딩 파일 목록 확정 -2. 공통 토큰 매핑표 작성 -3. 1순위 UI에 CSS 파일 추가 및 TSX class 정리 -4. `apps/wr` 주요 화면에서 5개 테마 수동 점검 -5. 2순위 테이블/검색/모달 영역 확장 -6. 차트 계열 색상 토큰화 -7. 지도 legacy 영역은 별도 보수 계획으로 분리 +2. 각 대상 UI별로 `assets` 참조와 `components` 참조 가능 여부를 먼저 판정 +3. 공통 토큰 매핑표 작성 +4. 1순위 UI에 상위 참조 우선 적용 후 부족한 부분만 business 전용 CSS 추가 +5. `apps/wr` 주요 화면에서 5개 테마 수동 점검 +6. 2순위 테이블/검색/모달 영역 확장 +7. 차트 계열 색상 토큰화 +8. 지도 legacy 영역은 별도 보수 계획으로 분리하며 배경지도는 수정하지 않는다. ## 7. 검증 기준 +- `assets` , `components`가 수정되면 안된다. - 같은 화면에서 5개 테마 전환 시 텍스트, 보더, 배경, hover, 선택상태가 모두 읽혀야 한다. - black 테마에서 회색/흰색 하드코딩이 남아 흐릿하거나 과도하게 튀는 영역이 없어야 한다. - blue/green/red 테마에서 활성 탭, 버튼, 선택상태가 `primary` 계열로 일관되게 바뀌어야 한다. @@ -157,6 +186,6 @@ ## 8. 결론 -- `business_components`의 Tailwind 스타일을 `components`와 같은 방식의 CSS 중심 구조로 전환하는 것은 가능하다. -- 다만 전체 유틸리티를 전부 CSS로 옮기기보다, 테마에 영향을 주는 색상/상태 표현만 우선 추출하는 방식이 가장 현실적이다. +- `business_components`의 Tailwind 스타일을 `assets` 1차 참조, `components` 2차 참조 구조에 맞춰 재편하는 것은 가능하다. +- 다만 전체 유틸리티를 전부 CSS로 옮기기보다, 테마에 영향을 주는 색상/상태 표현을 우선 상위 자산 참조 구조로 흡수하는 방식이 가장 현실적이다. - 우선순위는 `공통 입력 UI -> 테이블/검색/모달 -> 차트 -> 지도 legacy` 순으로 잡는 것이 좋다. diff --git a/packages/common/business_components/src/ui/file-upload/file-upload.css b/packages/common/business_components/src/ui/file-upload/file-upload.css new file mode 100644 index 000000000..9a8737063 --- /dev/null +++ b/packages/common/business_components/src/ui/file-upload/file-upload.css @@ -0,0 +1,67 @@ +@tailwind components; + +@layer components { + .fileup_root { + @apply relative rounded-lg bg-background p-2; + } + + .fileup_dropzone { + @apply border border-border outline-dashed outline-1 outline-border; + } + + .fileup_panel { + @apply flex w-full flex-col items-center justify-center p-1; + } + + .fileup_icon { + @apply h-4 w-5 text-label; + } + + .fileup_hint { + @apply mb-1 text-sm text-label; + } + + .fileup_hint_strong { + @apply font-semibold text-foreground; + } + + .fileup_meta { + @apply text-xs text-label; + } + + .fileup_item { + @apply text-foreground; + } + + .fileup_item_active { + @apply bg-muted; + } + + .fileup_item_name { + @apply flex h-full w-4/5 items-center gap-1.5 overflow-hidden font-medium leading-normal tracking-tight; + } + + .fileup_item_remove { + @apply text-label; + } + + .fileup_item_remove_icon { + @apply h-4 w-4 transition-colors duration-200 ease-in-out hover:stroke-destructive; + } + + .fileup_input { + @apply border; + } + + .fileup_input_idle { + @apply border-border; + } + + .fileup_input_accept { + @apply border-primary; + } + + .fileup_input_reject { + @apply border-destructive; + } +} diff --git a/packages/common/business_components/src/ui/file-upload/index.tsx b/packages/common/business_components/src/ui/file-upload/index.tsx index 6836691e2..ae6e8cc55 100644 --- a/packages/common/business_components/src/ui/file-upload/index.tsx +++ b/packages/common/business_components/src/ui/file-upload/index.tsx @@ -1,4 +1,6 @@ "use client"; +import "./file-upload.css"; + import { CloudUpload, Paperclip } from "lucide-react"; import { FileInput, FileUploader, FileUploaderContent, FileUploaderItem } from "./lib/file_upload"; @@ -36,19 +38,19 @@ export const FileUpload = onValueChange={setFiles} dropzoneOptions={dropZoneConfig} acceptName={fileTypes} - className="relative bg-background rounded-lg p-2" + className="fileup_root" > -
- -

- 클릭 후 업로드 +

+ +

+ 클릭 후 업로드   혹은 파일을 드래그 해주세요

-

+

{!fileTypes?"SVG, PNG, JPG, GIF":fileTypes}

diff --git a/packages/common/business_components/src/ui/file-upload/lib/file_upload.tsx b/packages/common/business_components/src/ui/file-upload/lib/file_upload.tsx index 9e47d296e..f0071748e 100644 --- a/packages/common/business_components/src/ui/file-upload/lib/file_upload.tsx +++ b/packages/common/business_components/src/ui/file-upload/lib/file_upload.tsx @@ -344,14 +344,14 @@ export const FileUploaderItem = forwardRef< ref={ref} className={cn( buttonVariants({ variant: "ghost" }), - "h-9 justify-between cursor-pointer relative w-full", + "fileup_item h-9 justify-between cursor-pointer relative w-full", className, - isSelected ? "bg-muted" : "", + isSelected ? "fileup_item_active" : "", )} {...props} > -
{onFileClick?.(index) }}> {children} @@ -359,7 +359,7 @@ export const FileUploaderItem = forwardRef<
); @@ -396,14 +396,12 @@ export const FileInput = forwardRef< >
{ label?: string; @@ -29,17 +31,19 @@ const FormUi = React.forwardRef( }, [onCheckboxChange]); return ( - {label && {label}} + {label && {label}} {label === BZNM_TOT_NO && ( <> -
-
- +
+ - +
{children} @@ -48,14 +52,14 @@ const FormUi = React.forwardRef( {label === CANDIDATE && ( {children} - + )} {label !== BZNM_TOT_NO && label !== CANDIDATE && ( {React.isValidElement(children) && isDisabled - ? React.cloneElement(children, { + ? React.cloneElement(children, { disabled: isDisabled, - ...(children.props as FormElementProps) + ...(children.props as FormElementProps), }) : children} @@ -66,5 +70,5 @@ const FormUi = React.forwardRef( } ); -FormUi.displayName = 'FormUi'; -export { FormUi }; \ No newline at end of file +FormUi.displayName = "FormUi"; +export { FormUi }; diff --git a/packages/common/business_components/src/ui/search_icon/index.tsx b/packages/common/business_components/src/ui/search_icon/index.tsx index 0ef0e17e3..5c608354f 100644 --- a/packages/common/business_components/src/ui/search_icon/index.tsx +++ b/packages/common/business_components/src/ui/search_icon/index.tsx @@ -1,3 +1,5 @@ +import "./search-icon.css"; + export default function SearchIcon() { return ( ); - } \ No newline at end of file + } diff --git a/packages/common/business_components/src/ui/search_icon/search-icon.css b/packages/common/business_components/src/ui/search_icon/search-icon.css new file mode 100644 index 000000000..b0ebb1f0a --- /dev/null +++ b/packages/common/business_components/src/ui/search_icon/search-icon.css @@ -0,0 +1,7 @@ +@tailwind components; + +@layer components { + .searchicon_svg { + @apply h-6 w-6 text-label; + } +} diff --git a/packages/common/business_components/src/ui/search_input/index.tsx b/packages/common/business_components/src/ui/search_input/index.tsx index 503322559..e7be9f75d 100644 --- a/packages/common/business_components/src/ui/search_input/index.tsx +++ b/packages/common/business_components/src/ui/search_input/index.tsx @@ -1,5 +1,8 @@ +import "./search-input.css"; + import { Input, Label } from "@common/components"; import React from "react"; +import { cn } from "../../lib/utils"; const SearchInput = React.forwardRefvoid}>(({className, label, textValue, setText}, ref) => { const changeText=(e:React.ChangeEvent)=>{ @@ -20,9 +23,9 @@ const SearchInput = React.forwardRef - {label && } - +
+ {label && } +
) }); diff --git a/packages/common/business_components/src/ui/search_input/search-input.css b/packages/common/business_components/src/ui/search_input/search-input.css new file mode 100644 index 000000000..f272abc7e --- /dev/null +++ b/packages/common/business_components/src/ui/search_input/search-input.css @@ -0,0 +1,15 @@ +@tailwind components; + +@layer components { + .searchinput_root { + @apply flex items-center; + } + + .searchinput_label { + @apply w-20 text-xl; + } + + .searchinput_field { + @apply h-14 w-3/4 flex-1 rounded-md border border-input text-lg; + } +} diff --git a/packages/common/business_components/src/ui/search_input_select/index.tsx b/packages/common/business_components/src/ui/search_input_select/index.tsx index bf8787356..8632eac78 100644 --- a/packages/common/business_components/src/ui/search_input_select/index.tsx +++ b/packages/common/business_components/src/ui/search_input_select/index.tsx @@ -1,6 +1,9 @@ +import "./search-input-select.css"; + import { Input, Label } from "@common/components"; import React from "react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../select/lib/selectBox"; +import { cn } from "../../lib/utils"; export type SelectDataType = { text: string; val: string; @@ -23,12 +26,12 @@ const SearchInputSelect = React.forwardRef - {label && } -
- +
+ {label && } +
+ { const inputValue = e.target.value.replace(/[^0-9.]/g, ""); // 숫자만 저장 @@ -133,7 +135,7 @@ export function BuseWaterQulity({ readOnly={readonly} maxLength={6} /> - + 이하
@@ -156,7 +158,7 @@ export function BuseWaterQulity({
{ const inputValue = e.target.value.replace(/[^0-9.]/g, ""); // 숫자만 저장 @@ -171,7 +173,7 @@ export function BuseWaterQulity({ maxLength={6} /> {/* TODO: 총대장균군수 확인 필요*/} - + 이하
@@ -191,7 +193,7 @@ export function BuseWaterQulity({
{ const inputValue = e.target.value.replace(/[^0-9.]/g, ""); // 숫자만 저장 @@ -205,7 +207,7 @@ export function BuseWaterQulity({ readOnly={readonly} maxLength={6} /> - + 이상
@@ -225,7 +227,7 @@ export function BuseWaterQulity({
{ const inputValue = e.target.value.replace(/[^0-9.]/g, ""); // 숫자만 저장 @@ -239,7 +241,7 @@ export function BuseWaterQulity({ readOnly={readonly} maxLength={6} /> - + 이상
@@ -298,7 +300,7 @@ export function BuseWaterQulity({
{ const inputValue = e.target.value.replace(/[^0-9.]/g, ""); // 숫자만 저장 @@ -312,7 +314,7 @@ export function BuseWaterQulity({ readOnly={readonly} maxLength={6} /> - + 이하
@@ -332,7 +334,7 @@ export function BuseWaterQulity({
{ const inputValue = e.target.value.replace(/[^0-9.]/g, ""); // 숫자만 저장 @@ -346,7 +348,7 @@ export function BuseWaterQulity({ readOnly={readonly} maxLength={6} /> - + 이하
@@ -367,7 +369,7 @@ export function BuseWaterQulity({
{ const inputValue = e.target.value.replace(/[^0-9.]/g, ""); // 숫자만 저장 @@ -381,7 +383,7 @@ export function BuseWaterQulity({ readOnly={readonly} maxLength={6} /> - + 이하
@@ -401,7 +403,7 @@ export function BuseWaterQulity({
{ const inputValue = e.target.value.replace(/[^0-9.]/g, ""); // 숫자만 저장 @@ -415,7 +417,7 @@ export function BuseWaterQulity({ readOnly={readonly} maxLength={7} /> - + 이하
@@ -436,7 +438,7 @@ export function BuseWaterQulity({
{ const inputValue = e.target.value.replace(/[^0-9.]/g, ""); // 숫자만 저장 @@ -450,7 +452,7 @@ export function BuseWaterQulity({ readOnly={readonly} maxLength={7} /> - + 이하
@@ -463,7 +465,7 @@ export function BuseWaterQulity({
@@ -474,9 +476,9 @@ export function BuseWaterQulity({ readOnly={readonly} maxLength={3} /> - ~ + ~ { diff --git a/apps/wr/components/ui/form/fcltReg/pbnstReg/mngReg/mngRegForm.css b/apps/wr/components/ui/form/fcltReg/pbnstReg/mngReg/mngRegForm.css new file mode 100644 index 000000000..d28e7bcc6 --- /dev/null +++ b/apps/wr/components/ui/form/fcltReg/pbnstReg/mngReg/mngRegForm.css @@ -0,0 +1,7 @@ +@tailwind components; + +@layer components { + .pbnstmng_file_link { + @apply block cursor-pointer text-2xl text-primary hover:underline; + } +} diff --git a/apps/wr/components/ui/form/fcltReg/pbnstReg/mngReg/mngRegForm.tsx b/apps/wr/components/ui/form/fcltReg/pbnstReg/mngReg/mngRegForm.tsx index 85364f0d1..9b592d3bc 100644 --- a/apps/wr/components/ui/form/fcltReg/pbnstReg/mngReg/mngRegForm.tsx +++ b/apps/wr/components/ui/form/fcltReg/pbnstReg/mngReg/mngRegForm.tsx @@ -1,4 +1,5 @@ "use client"; +import "./mngRegForm.css"; import { useWrStore } from "@/app/store"; import { @@ -265,7 +266,7 @@ export function PbnstMngForm({ return ( -
+
{/* */} @@ -332,13 +333,14 @@ export function PbnstMngForm({ > 추가등록 -
@@ -442,7 +444,7 @@ export function PbnstMngForm({ {relationfiles?.map((item, index) => ( fileDownLoadName(item.atflCd, item.file.name)} > {item.file.name} @@ -481,7 +483,7 @@ export function PbnstMngForm({ {planFiles?.map((item, index) => ( fileDownLoadName(item.atflCd, item.file.name)} > {item.file.name} diff --git a/apps/wr/components/ui/form/fcltReg/rainReg/rainRegForm.css b/apps/wr/components/ui/form/fcltReg/rainReg/rainRegForm.css new file mode 100644 index 000000000..cf03d7f7f --- /dev/null +++ b/apps/wr/components/ui/form/fcltReg/rainReg/rainRegForm.css @@ -0,0 +1,23 @@ +@tailwind components; + +@layer components { + .rainreg_header_note { + @apply mt-3 ml-1 text-2xl font-bold text-foreground; + } + + .rainreg_header_title { + @apply col-span-10 text-2xl font-medium text-label; + } + + .rainreg_section_note { + @apply col-span-10 mt-4 pl-3 text-xl text-foreground; + } + + .rainreg_actions { + @apply mt-4 flex justify-end gap-2; + } + + .rainreg_divider { + @apply my-4 border-b border-border; + } +} diff --git a/apps/wr/components/ui/form/fcltReg/rainReg/rainRegForm.tsx b/apps/wr/components/ui/form/fcltReg/rainReg/rainRegForm.tsx index 27033d0e2..3619e95fc 100644 --- a/apps/wr/components/ui/form/fcltReg/rainReg/rainRegForm.tsx +++ b/apps/wr/components/ui/form/fcltReg/rainReg/rainRegForm.tsx @@ -1,5 +1,7 @@ "use client"; +import "./rainRegForm.css"; + import { useWrStore } from "@/app/store"; import { rainZod, @@ -180,19 +182,19 @@ export default function RainRegForm({ }, [rainForm]); return ( -
+
-

+

{readonly && `(시설명 : ${rainForm?.bdstNm})`}

-

+

{isModal ? "건축물 정보" : "빗물이용시설 정보"}

{!readonly && ( -
+
{headList diff --git a/apps/wr/src/app/styles/components-black.css b/apps/wr/src/app/styles/components-black.css index d337b1ba1..8401fa2d5 100644 --- a/apps/wr/src/app/styles/components-black.css +++ b/apps/wr/src/app/styles/components-black.css @@ -68,6 +68,10 @@ @apply border-b-[#3f4042]; } + .form-section-title { + @apply bg-[#5e7b9c] text-[#fff]; + } + .gis-layer-panel { background: #111214; border-right-color: #2f3136; diff --git a/apps/wr/src/app/styles/components-blue.css b/apps/wr/src/app/styles/components-blue.css index d83d93ca0..1e49c377c 100644 --- a/apps/wr/src/app/styles/components-blue.css +++ b/apps/wr/src/app/styles/components-blue.css @@ -22,6 +22,10 @@ @apply border-b-[#3f4042]; } + .form-section-title { + @apply bg-[#eaf3fb] text-[#647784]; + } + } diff --git a/apps/wr/src/app/styles/components-green.css b/apps/wr/src/app/styles/components-green.css index b4b4b3154..8803e7cb2 100644 --- a/apps/wr/src/app/styles/components-green.css +++ b/apps/wr/src/app/styles/components-green.css @@ -22,6 +22,10 @@ @apply border-b-[#3f4042]; } + .form-section-title { + @apply bg-[#ebf7f7] text-[#64908f]; + } + } diff --git a/apps/wr/src/app/styles/components-red.css b/apps/wr/src/app/styles/components-red.css index 6d626d44d..9bfc1b792 100644 --- a/apps/wr/src/app/styles/components-red.css +++ b/apps/wr/src/app/styles/components-red.css @@ -22,6 +22,10 @@ @apply border-b-[#3f4042]; } + .form-section-title { + @apply bg-[#f6f6f9] text-[#808080]; + } + } input[type="file"] { diff --git a/apps/wr/src/app/styles/components.css b/apps/wr/src/app/styles/components.css index cc6c1a46e..60549b226 100644 --- a/apps/wr/src/app/styles/components.css +++ b/apps/wr/src/app/styles/components.css @@ -14,6 +14,10 @@ @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; } + .form-section-title { + @apply mt-4 border border-border bg-[#eaf3fb] p-2 text-center text-xl font-medium text-[#647784]; + } + input[type="file"] { From bba4a8eebae42a8885117e4ba3db0bdbdaf4bca0 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Wed, 24 Jun 2026 15:39:06 +0900 Subject: [PATCH 040/185] =?UTF-8?q?=EC=8B=9C=EC=84=A4=EB=AC=BC=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20=ED=8C=A8=EB=84=90=20=ED=86=A0=EA=B8=80=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/(map-layout)/map/page.tsx | 1 + .../business_components/src/ui/map/Gis.js | 2 + .../business_components/src/ui/map/GisTree.js | 44 ++++++++++++++----- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index dfb64a1ee..30e7f8a4e 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -6650,6 +6650,7 @@ const Map = () => { waterDepth={undefined} layerSettingView={ActiveLayerSettingView} treeSectionClassName="rounded-sm border border-slate-300 px-1 py-2" + detachedTreeToggleButton={true} isPlaying={isPlaying} setIsPlaying={setIsPlaying as any} targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any} diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index adc1f1be9..2eea996df 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -75,6 +75,7 @@ function Gis({ zoomGuide, showFloatingSearchButton = true, treeTogglePosition = "center", + detachedTreeToggleButton = false, showZoomScaleInfo = false, }) { const mapRef = useRef(); @@ -319,6 +320,7 @@ function Gis({ inlineLayerSettingView={inlineLayerSettingView} treeSectionClassName={treeSectionClassName} toggleButtonPosition={treeTogglePosition} + detachedToggleButton={detachedTreeToggleButton} /> {typeof WaterDepth === "function" && ( diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js index 7056bd622..1e6d66edc 100644 --- a/packages/common/business_components/src/ui/map/GisTree.js +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -29,6 +29,7 @@ import GisApp from "./lib/gis.core"; * - onProgressChange(total, current): 애니메이션 진행상태 콜백 * - layerSettingView: (선택) 좌측 레이어 트리 하단에 표시할 설정 영역 * - layerIconVisible: (선택) 레이어 행 우측 액션 아이콘 노출 여부를 결정하는 함수 + * - detachedToggleButton: (선택) 접기/펼치기 버튼을 패널 밖 고정 핸들로 노출할지 여부 */ export default React.memo(function GisTree({ checkedNodes = [], @@ -48,6 +49,8 @@ export default React.memo(function GisTree({ inlineLayerSettingView, treeSectionClassName = "", toggleButtonPosition = "center", + showLayerPanelTitle = true, + detachedToggleButton = false, }) { const { gisCore, treeData } = useGisContext(); @@ -517,6 +520,7 @@ export default React.memo(function GisTree({ toggleButtonPosition === "top" ? "top-4" : "top-1/2 -translate-y-1/2"; const thinScrollbarClass = "[scrollbar-width:thin] [&::-webkit-scrollbar]:w-[4px]"; + const treeSectionClassNames = [treeSectionClassName, thinScrollbarClass].filter(Boolean).join(" "); return ( <> @@ -528,13 +532,18 @@ export default React.memo(function GisTree({ }`} >
-

시설물

-
+ {showLayerPanelTitle && ( + <> +

시설물

+
+ + )} {/* DnD Tree */} - +
+ - + +
{LayerSettingView && (
)} + {!detachedToggleButton && ( + + )} + + {detachedToggleButton && visible && ( - + )} {!visible && ( )} From 27672605051e55c8e25f4ee41419b474a9b572d5 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Wed, 24 Jun 2026 16:13:52 +0900 Subject: [PATCH 041/185] =?UTF-8?q?=EC=A7=80=EB=B0=98=EC=B9=A8=ED=95=98=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=95=84=EC=9D=B4=EC=BD=98=20PNG?= =?UTF-8?q?=20=EC=9E=AC=EC=9D=B8=EC=BD=94=EB=94=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../001-fe-SFR-010-010-지반침하정보-통합구현기록.md | 7 +++++++ .../map/layericon/SN_SINKHOLE_SPOT_BLUE.png | Bin 379 -> 312 bytes .../map/layericon/SN_SINKHOLE_SPOT_ORANGE.png | Bin 376 -> 306 bytes 3 files changed, 7 insertions(+) diff --git a/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md b/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md index 15b25da2c..ab0b1098b 100644 --- a/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md @@ -239,6 +239,13 @@ - 후속 정리로 선택 레이어 설정 안의 침수심 범례 카드는 실제 범례 본문만 남기고, 지도 우측 하단에 별도로 뜨던 기존 범례 박스는 제거했다. - 추가 구조 보정으로 토글 핸들을 패널 이동 영역과 완전히 분리된 형제로 빼고, 열림 시에는 레이어 박스 옆에, 닫힘 시에는 화면 왼쪽 가장자리에 남도록 바꿔 다시 열기 버튼이 사라지지 않게 정리했다. +### 4.30 2026-06-24 개발서버 PNG 아이콘 400 재점검 + +- 개발서버에서 `SN_SINKHOLE_SPOT_ORANGE.png`, `SN_SINKHOLE_SPOT_BLUE.png`만 `400 Bad Request`로 실패하고, 없는 파일은 `404`, 기존 `layericon` PNG는 `200`으로 내려오는 차이를 재확인했다. +- 코드의 `/images/map/layericon/` 경로 조합은 기존 PNG와 같으므로, 아이콘명을 다른 자산으로 바꾸지 않고 두 PNG의 저장 구조를 비교했다. +- 문제 PNG는 `sRGB`, `gAMA`, `pHYs` 부가 청크를 포함하고 있었고, 정상 로드되는 단순 RGBA PNG 중 `WTL_RSRV_PS_ORANGE.png`는 `IHDR`, `IDAT`, `IEND`만 가지고 있었다. +- 이에 `SN_SINKHOLE_SPOT_ORANGE.png`, `SN_SINKHOLE_SPOT_BLUE.png`의 파일명과 픽셀은 유지하고, PNG 컨테이너만 `IHDR + IDAT + IEND` 구조로 재인코딩해 개발서버 정적 파일 처리 차이를 줄였다. + ## 5. 승인 전 보류 사항 - 목록 팝업을 `window popup`으로 열지 `/map` 내부 중앙 모달로 열지 확정 필요 diff --git a/apps/sn/public/images/map/layericon/SN_SINKHOLE_SPOT_BLUE.png b/apps/sn/public/images/map/layericon/SN_SINKHOLE_SPOT_BLUE.png index 2bcac78fae4d1aef27621a8823eebc2e25005b39..073f396c190af993da9b7e7adc687512c12d4461 100644 GIT binary patch delta 285 zcmV+&0pkAq0=NQ@B!B-&L_t(|+GAiC-r)cL{~Z7S|96050a7#y{QLKB{(m&^@87@U z|NsBjAf#F1-@kwNVVch0{r&&MjbF%yeEACtKa=sO@0l= z#<_3)?>_ev?h>4~gB1Mu_3yt!IYKiG>tww7|LVhEm}4BU8GjG}k^wmxT{8^N-}Mb< zKm#@d5>#7@AvGe1X~i z9A~=t_wSzt%nDFUfSla3=F9(smwzHBi3Gf9;orZ19>{3{DGj_Qs2MF82Y>?W-@kv; j|NZ+H0g7g-=N1pG5J^NqR5(xFlA%vRVHCxQMx*}#o58?lF}{ZJMIsx8VkTH9Ha9kk zk){TVRRSYn(!iz^=4NpNhPr_$jC0s|zKf6R7{TV*Zo2n(?|=EedsC_ZEs|R#gE+Q* zeyvmtphhPEEt0#wenljA06ZCV-<|QcGfg&`kj!{)DF7ELe0ZKQdpu;i(2rvOXu$g2 z*V2jSB5Aq`KBr(%`hhL>iUaoE=4K}Fyc&ec$g+nmYBwL&pzL`y$SWhO9<-=lkL?*1 z&#OU3$@eiLyICGqBtA6sd?Wym%APkSzu=S0kxhSDdlvu>Y=kx;%coZ(HrwADh?V!; yiU1rYZ@~oMbt7zw&p6a)0XPGY)0%zVpS1w6se5z!)&(E{0000-B!BryL_t(|+TD|}3qesB#f{3MEbm%9Cbz!nyXSY${qDJez<)22L6VtAg})Jo0W|rv z0E|e+KZ&CNmP&Nnjp=TNI>aHFGy(v0q;Nl@(@XKx2+6YBF@L(NzURbvy4>J8w7UsU z>{Tm5*jr^L;|H)rM_7IlKk7QKDnQ8(ke7f}-w3!&%jy~xKR{A8&qrz351pG4M{{nR5(xFk}+!nQ4oYJ18D+jB)>pXX_wM6<&~EHij-nwZ6jC+ z2|^$s*jQTH1R)@TU@3y22%=5yP%ksGkK5ziLoDnvFwO0LvwwTpPt%nSkgJeiRGH`1^kE?0-HnocH2Q zEaxQCT|*eq?>xtF?Diw!uXLHBFdeQMbGV z Date: Wed, 24 Jun 2026 16:38:53 +0900 Subject: [PATCH 042/185] =?UTF-8?q?wr:=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EB=82=B4=20=EC=83=89=EC=83=81=20=EC=A0=95=EC=9D=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=951?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../matMng/coMat/coMatRvw/page.tsx | 22 ++++++++--------- .../matMng/coMat/ruseCaseMng/page.tsx | 2 +- .../matMng/coMat/ruseRvwHstry/page.tsx | 2 +- .../matMng/coMng/coSttn/page.tsx | 2 +- .../matMng/coMng/drgtSttn/page.tsx | 2 +- .../ruseFcltMng/fcltSttn/pbnstSttn/page.tsx | 2 +- .../prfmncReg/chkgdnReg/TableHead.tsx | 6 ++--- .../ruseFcltMng/prfmncReg/chkgdnReg/page.tsx | 8 +++---- .../prfmncReg/pbnstPrfmncReg/page.tsx | 4 ++-- .../prfmncSttn/chkgdnSttn/page.tsx | 10 ++++---- .../prfmncSttn/pbnstPrfmncSttn/page.tsx | 2 +- apps/wr/app/store/common/code/index.ts | 2 +- apps/wr/components/lib/func/TableData.tsx | 24 +++++++++---------- apps/wr/components/lib/func/common.tsx | 6 ++--- .../ui/form/coMat/conCostForm/conCostForm.tsx | 2 +- .../ui/form/fcltReg/htdflReg/htdflRegForm.tsx | 20 ++++++++-------- .../ui/form/fcltReg/rswtrReg/rswtrRegForm.tsx | 4 ++-- .../LayerPopUp/HasuFacilty/HasuFacility.tsx | 2 +- .../LayerPopUp/RuseFacilty/RuseFacility.tsx | 2 +- .../coSttnDetailPopup/coSttnDetailPopup.tsx | 4 ++-- .../ui/modal/chkgd/chkgdExcel/chkgdExcel.tsx | 2 +- .../ui/modal/chkgd/chkgdModal/chkgdModal.tsx | 2 +- .../pbnPrfExcelModal/pbnPrfExcelModal.tsx | 2 +- .../ui/modal/coMat/constCostFeeTable.tsx | 6 ++--- .../ui/modal/coMat/rateFeeModal.tsx | 8 +++---- .../ruseCaseMng/ruseCaseMngExcelModal.tsx | 2 +- .../coReg/coRegExcel/coRegExcelModal.tsx | 2 +- .../fcltReg/bdstRegModal/bdstRegModal.tsx | 8 +++---- .../fcOrgwtsRegModal/fcOrgwtsRegModal.tsx | 12 +++++----- .../fcltReg/pbnstRegModal/pbnstRegModal.tsx | 2 +- .../pbnstReg/ruseMngTable/ruseMngTable.tsx | 2 +- .../ruseMngTableForm/ruseMngTableForm.tsx | 8 +++---- apps/wr/components/ui/toast/toastFromUi.tsx | 2 +- 33 files changed, 93 insertions(+), 93 deletions(-) diff --git a/apps/wr/app/(with-layout)/matMng/coMat/coMatRvw/page.tsx b/apps/wr/app/(with-layout)/matMng/coMat/coMatRvw/page.tsx index 4996b1cd9..2fba54fb6 100644 --- a/apps/wr/app/(with-layout)/matMng/coMat/coMatRvw/page.tsx +++ b/apps/wr/app/(with-layout)/matMng/coMat/coMatRvw/page.tsx @@ -93,7 +93,7 @@ const EMPTY_DETAIL_FORM: Partial = { }; export default function CoMatRvw() { - const readOnlyInputClass = "bg-slate-50 text-gray-700"; + const readOnlyInputClass = "bg-secondary text-secondary-foreground"; const { auth, code, coRvw, coMatRvwInit, coMatRvwSearch, coMatRvwInqua } = useWrStore((state) => state); const { coMatRvwRes, coMatRvwCount } = coRvw; @@ -305,7 +305,7 @@ export default function CoMatRvw() { />
-
@@ -573,7 +573,7 @@ export default function CoMatRvw() { value={formatNumberWithComma(field.value)} readOnly /> - + 이하
@@ -601,7 +601,7 @@ export default function CoMatRvw() { value={formatNumberWithComma(field.value)} readOnly /> - + 이하 @@ -643,7 +643,7 @@ export default function CoMatRvw() { value={formatNumberWithComma(field.value)} readOnly /> - + 이하 @@ -670,7 +670,7 @@ export default function CoMatRvw() { value={formatNumberWithComma(field.value)} readOnly /> - + 이하 @@ -697,7 +697,7 @@ export default function CoMatRvw() { value={formatNumberWithComma(field.value)} readOnly /> - + 이하 @@ -748,7 +748,7 @@ export default function CoMatRvw() { value={formatNumberWithComma(field.value)} readOnly /> - + 이하 @@ -775,7 +775,7 @@ export default function CoMatRvw() { value={formatNumberWithComma(field.value)} readOnly /> - + 이하 diff --git a/apps/wr/app/(with-layout)/matMng/coMat/ruseCaseMng/page.tsx b/apps/wr/app/(with-layout)/matMng/coMat/ruseCaseMng/page.tsx index 9fc636427..b8ca96ca1 100644 --- a/apps/wr/app/(with-layout)/matMng/coMat/ruseCaseMng/page.tsx +++ b/apps/wr/app/(with-layout)/matMng/coMat/ruseCaseMng/page.tsx @@ -347,7 +347,7 @@ export default function RuseCaseMng() { }} resetPageNo={resetPage} /> - + ☞ 본 사례의 사업비는 재원협의 단계의 정보이므로, 최종확정된 비용이 아닙니다. diff --git a/apps/wr/app/(with-layout)/matMng/coMat/ruseRvwHstry/page.tsx b/apps/wr/app/(with-layout)/matMng/coMat/ruseRvwHstry/page.tsx index b142bff16..83f6fbe69 100644 --- a/apps/wr/app/(with-layout)/matMng/coMat/ruseRvwHstry/page.tsx +++ b/apps/wr/app/(with-layout)/matMng/coMat/ruseRvwHstry/page.tsx @@ -250,7 +250,7 @@ export default function RuseRvwHstry() {
-
+ + + 의견내용 + +