From 6ef4603a7cc0466da35322edf1b14097b6c2ba1e Mon Sep 17 00:00:00 2001 From: kiminchan Date: Fri, 2 Oct 2026 16:51:47 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EB=8C=80=EC=83=81=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=9D=B4=EB=8F=99=20=EC=8B=9C=20=EC=A7=80?= =?UTF-8?q?=EC=97=AD=C2=B7=EC=8B=9C=EC=84=A4=20=EA=B2=80=EC=83=89=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=B4=88=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/exmn/use-exmn-result-map.ts | 5 +- .../fldng-hstry/fldng-hstry-feature-click.tsx | 10 ++- .../inftwt-result-popup-page.tsx | 4 + .../inftwt-result/use-inftwt-result-map.ts | 41 +++++----- .../rdii-result/rdii-result-popup-page.tsx | 4 + .../rdii-result/use-rdii-result-map.ts | 43 +++++----- .../water-risk/water-risk-popup-page.tsx | 15 +++- .../map/lib/use-flood-history-selection.ts | 4 +- .../map/lib/use-map-runtime-bridge.ts | 8 +- .../map/lib/use-water-risk-layer.ts | 19 ++++- .../map/search-modal/use-basic-tab.ts | 3 +- .../map/search-modal/use-operation-tab.ts | 3 +- .../map/search-modal/use-search-modal.ts | 5 +- .../(map-layout)/map/ui/map-page-client.tsx | 18 ++++- .../(map-layout)/map/ui/map-search-bar.tsx | 11 ++- .../001-qa-SN-GIS-지도이동검색조건초기화-통합검증계획.md | 46 +++++++++++ .../002-qa-SN-GIS-지도이동검색조건초기화-통합검증결과.md | 51 ++++++++++++ .../001-fe-SN-GIS-지도이동검색조건초기화-통합구현기록.md | 62 ++++++++++++++ .../src/features/facility-detail/ui/smh.tsx | 3 +- .../ui/swtrmt-prcs-zone-analysis.tsx | 3 +- .../lib/use-flood-prediction-map.ts | 13 ++- .../features/map-search/lib/map-navigation.ts | 81 +++++++++++++++++++ .../src/ui/map/lib/gis.layer.js | 14 +++- .../src/ui/map/searchBar/procSearchBar.tsx | 34 +++++++- .../src/ui/map/searchBar/utils.ts | 3 + 25 files changed, 433 insertions(+), 70 deletions(-) create mode 100644 apps/sn/docs/작업검증/SN-GIS-지도이동검색조건초기화/001-qa-SN-GIS-지도이동검색조건초기화-통합검증계획.md create mode 100644 apps/sn/docs/작업검증/SN-GIS-지도이동검색조건초기화/002-qa-SN-GIS-지도이동검색조건초기화-통합검증결과.md create mode 100644 apps/sn/docs/작업내용/SN-GIS-지도이동검색조건초기화/001-fe-SN-GIS-지도이동검색조건초기화-통합구현기록.md create mode 100644 apps/sn/src/features/map-search/lib/map-navigation.ts diff --git a/apps/sn/app/(map-layout)/map/components/exmn/use-exmn-result-map.ts b/apps/sn/app/(map-layout)/map/components/exmn/use-exmn-result-map.ts index c53147b55..9e2e5f5b6 100644 --- a/apps/sn/app/(map-layout)/map/components/exmn/use-exmn-result-map.ts +++ b/apps/sn/app/(map-layout)/map/components/exmn/use-exmn-result-map.ts @@ -1,4 +1,5 @@ "use client"; +import { beginMapNavigation } from "@/features/map-search/lib/map-navigation"; import { useCallback, @@ -91,6 +92,8 @@ export const useExmnResultMap = (options: Options) => { async (job: ExmnMapJobType, filters: ExmnMapFilters) => { const map = mapRef.current; if (!map) return; + const navigation = entry.jobType === job && entry.exmnBizId && entry.trtdstId + ? beginMapNavigation(map) : null; removeLayer(job); const state = layers.current[job]; const sequence = state.sequence; @@ -184,7 +187,7 @@ export const useExmnResultMap = (options: Options) => { const extent = selectedSource.getExtent(); if ( fittedContext.current !== contextKey && - extent.every(Number.isFinite) + extent.every(Number.isFinite) && navigation?.commit(extent) ) { map .getView() diff --git a/apps/sn/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-feature-click.tsx b/apps/sn/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-feature-click.tsx index 00c81bece..88db8bab7 100644 --- a/apps/sn/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-feature-click.tsx +++ b/apps/sn/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-feature-click.tsx @@ -3,6 +3,7 @@ * 우측 정보 화면의 심선·위선 목록, 선택 상태와 화면 생명주기 캐시를 관리한다. */ "use client"; +import { mapSearchNavigationOptions } from "@/features/map-search/lib/map-navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { RefObject } from "react"; @@ -23,6 +24,7 @@ type FldngHstryGisLayer = { showSearchResultsFromGeoJSON: ( layerTypeName: string, featureId: string, + options?: { beforeFocus: (extent: unknown) => boolean }, ) => void | Promise; }; @@ -127,11 +129,14 @@ const displayCachedFldngHstryFeatures = ( gisLayer: FldngHstryGisLayer, layerTypeName: string, features: Feature[], + beforeFocus: (extent: unknown) => boolean, ) => { const matched = Object.values(GisApp.layerCode ?? {}).find( (item: any) => item?.lyrTyNm === layerTypeName, ) as any; if (!matched || features.length === 0) return; + // 캐시를 재사용해도 최신 선택의 유효 도형일 때만 검색조건을 초기화한다. + if (!beforeFocus(features[0]?.getGeometry()?.getExtent())) return; gisLayer.clearSearchLayer(matched); const layer = gisLayer.ensureSearchLayer(matched); @@ -148,17 +153,20 @@ const showFldngHstryFeature = async ( featureId: string, ) => { const cacheKey = `${layerTypeName}:${featureId}`; + /** 캐시 대기와 신규 WFS 조회가 동일한 이동 성공/취소 계약을 사용한다. */ + const navigationOptions = mapSearchNavigationOptions((GisApp.Module.core as any).map); const cachedRequest = cache.get(cacheKey); if (cachedRequest) { displayCachedFldngHstryFeatures( gisLayer, layerTypeName, await cachedRequest, + navigationOptions.beforeFocus, ); return; } - await gisLayer.showSearchResultsFromGeoJSON(layerTypeName, featureId); + await gisLayer.showSearchResultsFromGeoJSON(layerTypeName, featureId, navigationOptions); const source = gisLayer.searchLayer?.getSource?.(); if (!source) return; diff --git a/apps/sn/app/(map-layout)/map/components/inftwt-result/inftwt-result-popup-page.tsx b/apps/sn/app/(map-layout)/map/components/inftwt-result/inftwt-result-popup-page.tsx index 22e511c80..abff14286 100644 --- a/apps/sn/app/(map-layout)/map/components/inftwt-result/inftwt-result-popup-page.tsx +++ b/apps/sn/app/(map-layout)/map/components/inftwt-result/inftwt-result-popup-page.tsx @@ -77,6 +77,8 @@ const DEFAULT_FILTERS: InftwtResultFilters = { }; const postRowSelectMessage = (row: InftwtResultRow) => { + // 같은 클릭의 message/storage 전달을 식별한다. + const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; if (typeof window !== "undefined") { try { window.localStorage.setItem( @@ -84,6 +86,7 @@ const postRowSelectMessage = (row: InftwtResultRow) => { JSON.stringify({ row, timestamp: Date.now(), + requestId, }), ); } catch (error) { @@ -96,6 +99,7 @@ const postRowSelectMessage = (row: InftwtResultRow) => { { type: INFTWT_RESULT_ROW_SELECT_MESSAGE, row, + requestId, }, window.location.origin, ); diff --git a/apps/sn/app/(map-layout)/map/components/inftwt-result/use-inftwt-result-map.ts b/apps/sn/app/(map-layout)/map/components/inftwt-result/use-inftwt-result-map.ts index 0f39fa97c..d6cc00279 100644 --- a/apps/sn/app/(map-layout)/map/components/inftwt-result/use-inftwt-result-map.ts +++ b/apps/sn/app/(map-layout)/map/components/inftwt-result/use-inftwt-result-map.ts @@ -1,4 +1,5 @@ "use client"; +import { beginMapNavigation, fitMapSelection, mapSearchNavigationOptions } from "@/features/map-search/lib/map-navigation"; /** 침입수 분석결과 선택 시 부모 지도에 결과 레이어를 표시하는 훅이다. */ import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react"; @@ -35,7 +36,6 @@ type UseInftwtResultMapParams = { const INFTWT_ZONE_LAYER_ID = "SN_INFTWT_RESULT_ZONE_LAYER"; const INFTWT_PIPE_LAYER_ID = "SN_INFTWT_RESULT_PIPE_LAYER"; const DEFAULT_RESULT_FIT_PADDING: [number, number, number, number] = [120, 120, 120, 120]; -const INFTWT_PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH"; const zoneStyle = new Style({ fill: new Fill({ @@ -302,7 +302,7 @@ export const useInftwtResultMap = ({ return; } - map.getView?.().fit?.(extent, { + fitMapSelection(map, extent, { duration: 350, padding: DEFAULT_RESULT_FIT_PADDING, maxZoom: oilqPipeExtent ? 17 : shouldUseZoneExtent ? 15 : 16, @@ -319,7 +319,7 @@ export const useInftwtResultMap = ({ if (normalizedSggCd && normalizedSggCd !== "0") { const sigunguCenter = getSigunguCenter(normalizedSggCd); if (sigunguCenter) { - moveMapToCenter(gisMaps, sigunguCenter, 12); + moveMapToCenter(gisMaps, sigunguCenter, 12, mapSearchNavigationOptions(gisMaps.current).beforeFocus); return true; } } @@ -328,7 +328,7 @@ export const useInftwtResultMap = ({ if (normalizedCtpvCd && normalizedCtpvCd !== "0") { const sidoCenterCoordinate = sidoCenter[normalizedCtpvCd.slice(0, 2)]; if (sidoCenterCoordinate) { - moveMapToCenter(gisMaps, sidoCenterCoordinate, 11); + moveMapToCenter(gisMaps, sidoCenterCoordinate, 11, mapSearchNavigationOptions(gisMaps.current).beforeFocus); return true; } } @@ -395,8 +395,17 @@ export const useInftwtResultMap = ({ [clearLayers, ensurePipeLayer, ensureZoneLayer, gisMaps, moveToStableFocus], ); + /** message/storage가 전달하는 동일 선택 요청을 한 번만 처리한다. */ + const selectionRequestsRef = useRef(new Set()); const handleRowSelect = useCallback( - async (row: InftwtResultRow) => { + async (row: InftwtResultRow, requestId?: string) => { + if (requestId) { + const requests = selectionRequestsRef.current; + if (requests.has(requestId)) return; + requests.add(requestId); + if (requests.size > 64) requests.delete(requests.values().next().value!); + } + const navigation = beginMapNavigation(gisMaps.current); try { activeResultRowRef.current = row; setFeatureData(null); @@ -412,21 +421,9 @@ export const useInftwtResultMap = ({ mapViewCacheRef.current.set(row.inftwtAnlsSn, mapView); } - if (typeof window !== "undefined") { - window.dispatchEvent( - new CustomEvent(INFTWT_PROC_SEARCH_EVENT, { - detail: { - ctpvCd: row.ctpvCd ?? "0", - sggCd: row.sggCd ?? "0", - swtrmtFcltNm: row.swtrmtFcltNm ?? "", - suppressMapMove: (mapView.oilqPipeCount ?? 0) > 0, - }, - }), - ); - } - + if (!navigation.isCurrent()) return; setActiveMapView(mapView); - void renderMapView(mapView); + await renderMapView(mapView); } catch (error) { console.error("移⑥엯??遺꾩꽍寃곌낵 吏€???곌퀎 以??ㅻ쪟:", error); alert("침입수 분석결과를 지도에 연계하지 못했습니다."); @@ -439,16 +436,16 @@ export const useInftwtResultMap = ({ const handlePopupMessage = (event: MessageEvent) => { if (event.origin !== window.location.origin) return; if (event.data?.type !== INFTWT_RESULT_ROW_SELECT_MESSAGE || !event.data?.row) return; - void handleRowSelect(event.data.row as InftwtResultRow); + void handleRowSelect(event.data.row as InftwtResultRow, event.data.requestId); }; const handleStorageEvent = (event: StorageEvent) => { if (event.key !== INFTWT_RESULT_ROW_SELECT_STORAGE_KEY || !event.newValue) return; try { - const payload = JSON.parse(event.newValue) as { row?: InftwtResultRow }; + const payload = JSON.parse(event.newValue) as { row?: InftwtResultRow; requestId?: string }; if (!payload?.row) return; - void handleRowSelect(payload.row); + void handleRowSelect(payload.row, payload.requestId); } catch (error) { console.warn("移⑥엯??遺꾩꽍寃곌낵 storage ?숆린??媛믪쓣 ?댁꽍?섏? 紐삵뻽?듬땲??", error); } diff --git a/apps/sn/app/(map-layout)/map/components/rdii-result/rdii-result-popup-page.tsx b/apps/sn/app/(map-layout)/map/components/rdii-result/rdii-result-popup-page.tsx index ba4c3aca5..99cb5c777 100644 --- a/apps/sn/app/(map-layout)/map/components/rdii-result/rdii-result-popup-page.tsx +++ b/apps/sn/app/(map-layout)/map/components/rdii-result/rdii-result-popup-page.tsx @@ -79,6 +79,8 @@ const DEFAULT_FILTERS: RdiiResultFilters = { }; const postRowSelectMessage = (row: RdiiResultRow) => { + // 같은 클릭의 message/storage 전달을 식별한다. + const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; if (typeof window !== "undefined") { try { window.localStorage.setItem( @@ -86,6 +88,7 @@ const postRowSelectMessage = (row: RdiiResultRow) => { JSON.stringify({ row, timestamp: Date.now(), + requestId, }), ); } catch (error) { @@ -98,6 +101,7 @@ const postRowSelectMessage = (row: RdiiResultRow) => { { type: RDII_RESULT_ROW_SELECT_MESSAGE, row, + requestId, }, window.location.origin, ); diff --git a/apps/sn/app/(map-layout)/map/components/rdii-result/use-rdii-result-map.ts b/apps/sn/app/(map-layout)/map/components/rdii-result/use-rdii-result-map.ts index 9986ad611..099b10c87 100644 --- a/apps/sn/app/(map-layout)/map/components/rdii-result/use-rdii-result-map.ts +++ b/apps/sn/app/(map-layout)/map/components/rdii-result/use-rdii-result-map.ts @@ -1,5 +1,6 @@ /** RDII 분석결과 선택 시 부모 지도에 결과 레이어를 표시하는 훅이다. */ "use client"; +import { beginMapNavigation, fitMapSelection, mapSearchNavigationOptions } from "@/features/map-search/lib/map-navigation"; import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react"; import GeoJSON from "ol/format/GeoJSON.js"; @@ -32,7 +33,6 @@ type UseRdiiResultMapParams = { const RDII_PIPE_LAYER_ID = "SN_RDII_RESULT_PIPE_LAYER"; const DEFAULT_RESULT_FIT_PADDING: [number, number, number, number] = [120, 120, 120, 120]; -const PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH"; const pipeStyle = [ new Style({ @@ -186,7 +186,7 @@ export const useRdiiResultMap = ({ return; } - map.getView?.().fit?.(pipeExtent, { + fitMapSelection(map, pipeExtent, { duration: 350, padding: DEFAULT_RESULT_FIT_PADDING, maxZoom: 16, @@ -201,7 +201,7 @@ export const useRdiiResultMap = ({ if (normalizedSggCd && normalizedSggCd !== "0") { const sigunguCenter = getSigunguCenter(normalizedSggCd); if (sigunguCenter) { - moveMapToCenter(gisMaps, sigunguCenter, 12); + moveMapToCenter(gisMaps, sigunguCenter, 12, mapSearchNavigationOptions(gisMaps.current).beforeFocus); return true; } } @@ -210,7 +210,7 @@ export const useRdiiResultMap = ({ if (normalizedCtpvCd && normalizedCtpvCd !== "0") { const sidoCenterCoordinate = sidoCenter[normalizedCtpvCd.slice(0, 2)]; if (sidoCenterCoordinate) { - moveMapToCenter(gisMaps, sidoCenterCoordinate, 11); + moveMapToCenter(gisMaps, sidoCenterCoordinate, 11, mapSearchNavigationOptions(gisMaps.current).beforeFocus); return true; } } @@ -260,38 +260,37 @@ export const useRdiiResultMap = ({ [clearLayers, ensurePipeLayer, gisMaps, moveToStableFocus], ); + /** message/storage가 전달하는 동일 선택 요청을 한 번만 처리한다. */ + const selectionRequestsRef = useRef(new Set()); const handleRowSelect = useCallback( - async (row: RdiiResultRow) => { + async (row: RdiiResultRow, requestId?: string) => { + if (requestId) { + const requests = selectionRequestsRef.current; + if (requests.has(requestId)) return; + requests.add(requestId); + if (requests.size > 64) requests.delete(requests.values().next().value!); + } + const navigation = beginMapNavigation(gisMaps.current); try { activeResultRowRef.current = row; setFeatureData(null); setIsOpen(false); setIsSearchModalOpen(false); - if (typeof window !== "undefined") { - window.dispatchEvent( - new CustomEvent(PROC_SEARCH_EVENT, { - detail: { - ctpvCd: row.ctpvCd ?? "0", - sggCd: row.sggCd ?? "0", - swtrmtFcltNm: row.swtrmtFcltNm ?? "", - }, - }), - ); - } - const cachedMapView = mapViewCacheRef.current.get(row.rainInfwtAnlsSn); if (cachedMapView) { + if (!navigation.isCurrent()) return; setActiveMapView(cachedMapView); - void renderMapView(cachedMapView); + await renderMapView(cachedMapView); return; } const payload = await getRawJson(`/sewerage-service/api/v1/rdiiAnlsRslt/mapView/${row.rainInfwtAnlsSn}`); const mapView = (payload?.data ?? payload) as RdiiResultMapViewResponse; mapViewCacheRef.current.set(row.rainInfwtAnlsSn, mapView); + if (!navigation.isCurrent()) return; setActiveMapView(mapView); - void renderMapView(mapView); + await renderMapView(mapView); } catch (error) { console.error("RDII 분석결과 지도 연계 중 오류:", error); alert("RDII 분석결과를 지도에 연계하지 못했습니다."); @@ -304,16 +303,16 @@ export const useRdiiResultMap = ({ const handlePopupMessage = (event: MessageEvent) => { if (event.origin !== window.location.origin) return; if (event.data?.type !== RDII_RESULT_ROW_SELECT_MESSAGE || !event.data?.row) return; - void handleRowSelect(event.data.row as RdiiResultRow); + void handleRowSelect(event.data.row as RdiiResultRow, event.data.requestId); }; const handleStorageEvent = (event: StorageEvent) => { if (event.key !== RDII_RESULT_ROW_SELECT_STORAGE_KEY || !event.newValue) return; try { - const payload = JSON.parse(event.newValue) as { row?: RdiiResultRow }; + const payload = JSON.parse(event.newValue) as { row?: RdiiResultRow; requestId?: string }; if (!payload?.row) return; - void handleRowSelect(payload.row); + void handleRowSelect(payload.row, payload.requestId); } catch (error) { console.warn("RDII 분석결과 storage 동기화 값을 해석하지 못했습니다.", error); } diff --git a/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-popup-page.tsx b/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-popup-page.tsx index a11fac80f..bb8d92730 100644 --- a/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-popup-page.tsx +++ b/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-popup-page.tsx @@ -4,7 +4,7 @@ */ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, useRef } from "react"; import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions"; import { @@ -462,26 +462,37 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { setSelectedResultKey(""); }; + /** 늦게 끝난 이전 행 요청이 최신 선택을 덮지 않도록 식별한다. */ + const mapRequestRef = useRef(""); + /** 선택 행의 실제 GeoJSON을 조회한 뒤 부모 지도 창에 전달한다. */ const handleSelectResultRow = async (row: WaterRiskResultRow) => { + const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + mapRequestRef.current = requestId; + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT, { + phase: "loading", requestId, + }); setSelectedResultKey(buildResultRowKey(row)); setIsMapLoading(true); setErrorMessage(""); try { const mapView = await fetchWaterRiskMapView(row); + if (mapRequestRef.current !== requestId) return; postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT, { + requestId, areaId: row.areaId, resultRow: row, mapView, }); } catch (error) { + if (mapRequestRef.current !== requestId) return; setErrorMessage( error instanceof Error ? error.message : "통수위험 지도 결과를 조회하지 못했습니다.", ); } finally { - setIsMapLoading(false); + if (mapRequestRef.current === requestId) setIsMapLoading(false); } }; diff --git a/apps/sn/app/(map-layout)/map/lib/use-flood-history-selection.ts b/apps/sn/app/(map-layout)/map/lib/use-flood-history-selection.ts index 7c3527389..39fbab50c 100644 --- a/apps/sn/app/(map-layout)/map/lib/use-flood-history-selection.ts +++ b/apps/sn/app/(map-layout)/map/lib/use-flood-history-selection.ts @@ -1,4 +1,6 @@ "use client"; +import { fitMapSelection } from "@/features/map-search/lib/map-navigation"; + import type * as React from "react"; import { useCallback, useEffect, useRef } from "react"; import Feature from "ol/Feature.js"; @@ -122,7 +124,7 @@ export function useFloodHistorySelection({ if (!gisMaps.current) return; const geometry = new Polygon([detail.areaRing]); - gisMaps.current.getView?.().fit?.(geometry.getExtent(), { + fitMapSelection(gisMaps.current, geometry.getExtent(), { duration: 350, padding: FLDNG_HSTRY_SELECTION_PADDING, maxZoom: 15, diff --git a/apps/sn/app/(map-layout)/map/lib/use-map-runtime-bridge.ts b/apps/sn/app/(map-layout)/map/lib/use-map-runtime-bridge.ts index c8882a6e1..78b3e54e4 100644 --- a/apps/sn/app/(map-layout)/map/lib/use-map-runtime-bridge.ts +++ b/apps/sn/app/(map-layout)/map/lib/use-map-runtime-bridge.ts @@ -1,4 +1,6 @@ "use client"; +import { fitMapSelection, mapSearchNavigationOptions } from "@/features/map-search/lib/map-navigation"; + import type { useFloodPredictionMap } from "@/features/fldng-predc-prst/lib/use-flood-prediction-map"; import { useEffect, @@ -46,7 +48,7 @@ export function useMapRuntimeBridge({ const fid = data.payload?.fid; if (!layerNm || !fid || !core?.showSearchResultsFromGeoJSON) return; - await core.showSearchResultsFromGeoJSON(layerNm, fid); + await core.showSearchResultsFromGeoJSON(layerNm, fid, mapSearchNavigationOptions(gisMaps.current)); return; } @@ -64,7 +66,7 @@ export function useMapRuntimeBridge({ return; } - await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids); + await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids, mapSearchNavigationOptions(gisMaps.current)); return; } @@ -80,7 +82,7 @@ export function useMapRuntimeBridge({ ) { return; } - map.getView().fit(extent, { + fitMapSelection(map, extent, { duration: 300, padding: [50, 50, 50, 50], maxZoom: 16, diff --git a/apps/sn/app/(map-layout)/map/lib/use-water-risk-layer.ts b/apps/sn/app/(map-layout)/map/lib/use-water-risk-layer.ts index aa40880f1..4af91499d 100644 --- a/apps/sn/app/(map-layout)/map/lib/use-water-risk-layer.ts +++ b/apps/sn/app/(map-layout)/map/lib/use-water-risk-layer.ts @@ -1,4 +1,6 @@ "use client"; +import { beginMapNavigation } from "@/features/map-search/lib/map-navigation"; + import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Feature from "ol/Feature.js"; import GeoJSON from "ol/format/GeoJSON.js"; @@ -35,6 +37,9 @@ export function useWaterRiskLayer({ mapReady, onSelectResult, }: Options) { + /** 팝업의 조회 시작부터 지도 이동까지 같은 요청 세대를 사용한다. */ + const selectionNavigationRef = useRef | null>(null); + const selectionRequestRef = useRef(undefined); const waterRiskPopupWindowRef = useRef(null); const waterRiskPopupCloseWatcherRef = useRef(null); @@ -247,7 +252,7 @@ export function useWaterRiskLayer({ const renderWaterRiskSelection = useCallback( (areaId: string | null, mapView: WaterRiskMapView | null) => { const map = gisMaps.current; - if (!map) return; + if (!map || !selectionNavigationRef.current?.isCurrent()) return; const areaSource = ensureWaterRiskAreaLayer(); if (!areaSource) return; @@ -318,6 +323,7 @@ export function useWaterRiskLayer({ const extent = focusGeometry.getExtent(); const [, minY = 0, maxX = 0, maxY = 0] = extent; setWaterRiskInfoAnchor([maxX, (minY + maxY) / 2]); + if (!selectionNavigationRef.current?.commit(extent)) return; map.getView().fit(extent, { duration: 300, padding: [120, 120, 120, 120], @@ -389,6 +395,17 @@ export function useWaterRiskLayer({ } if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT) { + if (data.payload?.phase === "loading") { + selectionRequestRef.current = data.payload.requestId; + selectionNavigationRef.current = beginMapNavigation(gisMaps.current); + return; + } + if (data.payload?.requestId) { + if (data.payload.requestId !== selectionRequestRef.current || + !selectionNavigationRef.current?.isCurrent()) return; + } else { + selectionNavigationRef.current = beginMapNavigation(gisMaps.current); + } const areaId = String(data.payload?.areaId ?? "").trim(); if (!areaId) return; const resultRow = data.payload?.resultRow as diff --git a/apps/sn/app/(map-layout)/map/search-modal/use-basic-tab.ts b/apps/sn/app/(map-layout)/map/search-modal/use-basic-tab.ts index ba922734b..54fc2cd7a 100644 --- a/apps/sn/app/(map-layout)/map/search-modal/use-basic-tab.ts +++ b/apps/sn/app/(map-layout)/map/search-modal/use-basic-tab.ts @@ -1,3 +1,4 @@ +import { mapSearchNavigationOptions } from "@/features/map-search/lib/map-navigation"; /** * 📄 useBasicTab Hook * 검색 모달 기본정보 탭 훅 @@ -189,7 +190,7 @@ export default function useBasicTab() { const layerNm = row.lyrTypeNm; const fid = row.fid; const core = GisApp.Module.layer as any; - await core.showSearchResultsFromGeoJSON(layerNm, fid); + await core.showSearchResultsFromGeoJSON(layerNm, fid, mapSearchNavigationOptions((GisApp.Module.core as any).map)); setIsLoading(false); setIsSearchModalOpen(false); // SearchModal 닫기 } diff --git a/apps/sn/app/(map-layout)/map/search-modal/use-operation-tab.ts b/apps/sn/app/(map-layout)/map/search-modal/use-operation-tab.ts index 50ce10c23..bd6352679 100644 --- a/apps/sn/app/(map-layout)/map/search-modal/use-operation-tab.ts +++ b/apps/sn/app/(map-layout)/map/search-modal/use-operation-tab.ts @@ -1,3 +1,4 @@ +import { mapSearchNavigationOptions } from "@/features/map-search/lib/map-navigation"; /** * 📄 useOperationTab Hook * 검색 모달 유지관리 정보 탭 훅 @@ -284,7 +285,7 @@ export default function useOperationTab() { const layerNm = row.lyrTypeNm; const fid = row.fid; const core = GisApp.Module.layer as any; - await core.showSearchResultsFromGeoJSON(layerNm, fid); + await core.showSearchResultsFromGeoJSON(layerNm, fid, mapSearchNavigationOptions((GisApp.Module.core as any).map)); setIsLoading(false); setIsSearchModalOpen(false); // SearchModal 닫기 } diff --git a/apps/sn/app/(map-layout)/map/search-modal/use-search-modal.ts b/apps/sn/app/(map-layout)/map/search-modal/use-search-modal.ts index 518e7913a..7345f8b2b 100644 --- a/apps/sn/app/(map-layout)/map/search-modal/use-search-modal.ts +++ b/apps/sn/app/(map-layout)/map/search-modal/use-search-modal.ts @@ -1,3 +1,4 @@ +import { mapSearchNavigationOptions } from "@/features/map-search/lib/map-navigation"; /** * 📄 useSearchModal Hook * 검색 모달 훅 @@ -198,7 +199,7 @@ export default function useSearchModal() { const layerNm = row.lyrTypeNm; const fids = tableData.map((item) => item.fid); const core = GisApp.Module.layer as any; - await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids); + await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids, mapSearchNavigationOptions((GisApp.Module.core as any).map)); setIsLoading(false); setIsSearchModalOpen(false); } @@ -222,7 +223,7 @@ export default function useSearchModal() { const layerNm = row.lyrTypeNm; const fids = detailTableData.map((item) => item.fid); const core = GisApp.Module.layer as any; - await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids); + await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids, mapSearchNavigationOptions((GisApp.Module.core as any).map)); setIsLoading(false); setIsSearchModalOpen(false); } diff --git a/apps/sn/app/(map-layout)/map/ui/map-page-client.tsx b/apps/sn/app/(map-layout)/map/ui/map-page-client.tsx index 7642a284b..fd2f012ef 100644 --- a/apps/sn/app/(map-layout)/map/ui/map-page-client.tsx +++ b/apps/sn/app/(map-layout)/map/ui/map-page-client.tsx @@ -16,6 +16,7 @@ */ "use client"; +import { fitMapSelection, mapSearchNavigationOptions, beginMapNavigation } from "@/features/map-search/lib/map-navigation"; import { useFloodPredictionMap } from "@/features/fldng-predc-prst/lib/use-flood-prediction-map"; import { FLOOD_DNGR_MAP_LAYER_NAME, @@ -627,7 +628,7 @@ const MapPageClient = () => { const currentZoom = Number(view?.getZoom?.()); const maxZoom = Number.isFinite(currentZoom) && currentZoom > 18 ? currentZoom : 18; - view?.fit?.(extent, { + fitMapSelection(map, extent, { duration: 350, padding: [80, 420, 80, 80], maxZoom, @@ -1292,6 +1293,7 @@ const MapPageClient = () => { void core?.showSearchResultsFromGeoJSON?.( layerTypeName, `${layerTypeName}.${featureIdentifier}`, + mapSearchNavigationOptions(gisMaps.current), ); } @@ -1384,7 +1386,9 @@ const MapPageClient = () => { focusMap = true, fallbackPoint?: { x: number; y: number } | null, fallbackDetail?: Partial | null, + resetSearch = false, ) => { + const navigation = resetSearch ? beginMapNavigation(gisMaps.current) : null; if (!acdntNo) return; try { @@ -1540,6 +1544,7 @@ const MapPageClient = () => { } : detail; + if (navigation && !navigation.isCurrent()) return; setSelectedSinkholeDetail(normalizedDetail); setSelectedSinkholeNearbyPipes(nearbyPipes); renderSelectedSinkhole(normalizedDetail, sinkholeRadiusMeters); @@ -1555,6 +1560,9 @@ const MapPageClient = () => { normalizedDetail.x != null && normalizedDetail.y != null ) { + const x = Number(normalizedDetail.x); + const y = Number(normalizedDetail.y); + if (navigation && !navigation.commit([x, y, x, y])) return; focusSinkholeOnMap(normalizedDetail, sinkholeRadiusMeters); } } catch (error) { @@ -1615,6 +1623,7 @@ const MapPageClient = () => { const focusSinkholeNearbyPipe = useCallback( async (pipe: SinkholeNearbyPipe) => { + const navigation = beginMapNavigation(gisMaps.current); const fid = String(pipe?.fid ?? "").trim(); if (!fid) { alert("표시할 관로 식별값이 없습니다."); @@ -1654,7 +1663,7 @@ const MapPageClient = () => { }); const firstFeature = features[0]; - if (firstFeature) { + if (firstFeature && navigation.commit(firstFeature.getGeometry()?.getExtent())) { syncRightPanelFeatureSelectionOnMap(firstFeature); setFeatureData(firstFeature); setIsOpen(true); @@ -2005,7 +2014,7 @@ const MapPageClient = () => { try { shouldFocusAgedPipeOnNextSelectRef.current = true; const core = GisApp.Module.layer as any; - await core.showSearchResultsFromGeoJSON(AGED_PIPE_LAYER_TYPE, row.fid); + await core.showSearchResultsFromGeoJSON(AGED_PIPE_LAYER_TYPE, row.fid, mapSearchNavigationOptions(gisMaps.current)); } catch (error) { shouldFocusAgedPipeOnNextSelectRef.current = false; console.error("노후관로 위치 이동 중 오류:", error); @@ -2025,7 +2034,7 @@ const MapPageClient = () => { try { shouldFocusAgedPipeOnNextSelectRef.current = true; const core = GisApp.Module.layer as any; - await core.showSearchResultsFromGeoJSON(AGED_PIPE_LAYER_TYPE, row.fid); + await core.showSearchResultsFromGeoJSON(AGED_PIPE_LAYER_TYPE, row.fid, mapSearchNavigationOptions(gisMaps.current)); } catch (error) { shouldFocusAgedPipeOnNextSelectRef.current = false; console.error("관로 위치 이동 중 오류:", error); @@ -2149,6 +2158,7 @@ const MapPageClient = () => { true, fallbackPoint, row, + true, ); return; } diff --git a/apps/sn/app/(map-layout)/map/ui/map-search-bar.tsx b/apps/sn/app/(map-layout)/map/ui/map-search-bar.tsx index d91ce0074..b6595eb3c 100644 --- a/apps/sn/app/(map-layout)/map/ui/map-search-bar.tsx +++ b/apps/sn/app/(map-layout)/map/ui/map-search-bar.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useState, type ComponentProps } from "react"; +import { useCallback, useEffect, useState, useSyncExternalStore, type ComponentProps } from "react"; +import { getMapSearchRevision, invalidateMapNavigation, subscribeMapSearch } from "@/features/map-search/lib/map-navigation"; import { ProcSearchBar } from "@common/business_components/ui/map/searchBar"; /** 지도 검색바의 Radix Select는 브라우저 마운트 이후 생성한다. @@ -8,6 +9,12 @@ import { ProcSearchBar } from "@common/business_components/ui/map/searchBar"; * hydration 불일치를 피한다. 공통 Select 및 다른 앱의 렌더링 방식은 변경하지 않는다. */ export default function MapSearchBar(props: ComponentProps) { + /** 부모의 지도 준비 렌더에 맞춰 해당 인스턴스만 구독한다. */ + const map = props.gisMaps.current; + const subscribe = useCallback((listener: () => void) => subscribeMapSearch(map, listener), [map]); + const getSnapshot = useCallback(() => getMapSearchRevision(map), [map]); + const resetRevision = useSyncExternalStore(subscribe, getSnapshot, () => 0); + useEffect(() => () => invalidateMapNavigation(map), [map]); const [isMounted, setIsMounted] = useState(false); useEffect(() => { @@ -25,5 +32,5 @@ export default function MapSearchBar(props: ComponentProps ); } - return ; + return invalidateMapNavigation(map)} />; } diff --git a/apps/sn/docs/작업검증/SN-GIS-지도이동검색조건초기화/001-qa-SN-GIS-지도이동검색조건초기화-통합검증계획.md b/apps/sn/docs/작업검증/SN-GIS-지도이동검색조건초기화/001-qa-SN-GIS-지도이동검색조건초기화-통합검증계획.md new file mode 100644 index 000000000..7bab9ab5a --- /dev/null +++ b/apps/sn/docs/작업검증/SN-GIS-지도이동검색조건초기화/001-qa-SN-GIS-지도이동검색조건초기화-통합검증계획.md @@ -0,0 +1,46 @@ +# SN GIS 지도 이동 검색조건 초기화 통합검증계획 + +## 2026-10-02 계획 / 실행 전 + +공통 설계의 15개 기능 묶음에 아래 항목을 적용한다. 원본 근거는 PPT 실제 슬라이드 19/SN_MN010201·21/SN_MN010202의 행 선택 지도 이동, 이번 사용자 확정은 상단 검색조건만 초기화이다. + +| 확인 항목 | 기대 결과 | +| --- | --- | +| 서울특별시/강남구/시설 선택 후 통수위험 다른 지역 행 선택 | 기존 기준대로 이동, 상단 3개 선택은 기본값, 위험도·선택 강조·정보 카드 정상 유지 | +| 필수 기능 각각의 행/셀 선택 | 동일 초기화. 새 결과의 범례/레이어/지도 확대 규칙 정상 | +| 침입수 실측관로 있음/없음, RDII 지역 중심/extent fallback | 기존 최종 이동 유지, 지역/시설 자동 재채움 없음 | +| 인라인 정보검색과 별도 창 정보검색 | 양쪽 모두 동일 동작. 팝업 origin 검증 유지 | +| 조회 실패, geometry 없음, 빈 피처, NaN/무한/뒤집힌 extent | 초기화 및 잘못된 이동 없음. 기존 오류 안내 유지 | +| 침수예측 레이어 준비 거부/취소 | 성공 이동이 없으면 상단 조건 유지 | +| 이미 같은 위치인 행 재선택 | 이동할 대상이 유효하면 검색조건 초기화, moveend 발생 유무에 의존하지 않음 | +| 상단 시도/시군구 조회, 상단 시설 선택 | 직접 선택한 검색값 유지. 공통 검색 함수 확장의 역효과 없음 | +| 일반 드래그·줌·지도 피처 클릭 | 본 변경으로 검색조건 초기화하지 않음 | +| 초기화 직전 시설 후보 요청을 느리게 응답 | 예전 시설 후보/선택/대기 시설명이 복원되지 않음 | +| A→B 행을 빠르게 선택, A 응답이 나중 도착 | 마지막 선택만 유효. 이전 이동·검색바 변경 없음 | +| message/storage로 같은 행 중복 도착 | 지도 이동/초기화 중복 처리 방지 | +| 행 선택 후 응답 전에 사용자가 상단 조건 변경 | 오래된 작업이 최신 사용자 의도를 덮지 않음 | +| 지도 미준비/검색바 마운트 전 메시지 | 초기화 의도 유실·다른 지도 영향 없음 | +| 필터가 다른 하수처리시설 기본 라벨 | 현재 레이어 필터의 기본 라벨과 시설 유형 유지 | +| GIS 편집 변경 목록 | 검색조건만 초기화, 미저장 변경/편집 세션 유지 | +| 별도 준설 위치 지도·GIS 업로드 미리보기 | 메인 지도 검색조건에 영향 없음 | +| 다른 ProcSearchBar/GIS 검색 함수 소비자 | optional 인자를 생략하면 이전과 같은 동작 | +| 백엔드 요청 비교 | 선택 행 식별자·ctpvCd/sggCd·분석번호 보존. 검색바 초기값이 API 조건으로 섞이지 않음 | + +## 검증 순서와 현재 결과 + +- 이번에는 정적 경로 추적과 원본 대조만 수행했다. 위 표의 동작 시험은 모두 미실행이다. +- 구현 후 상태 초기화·응답 역전·유효 geometry 성공 callback 검증 → 대표 통수위험 브라우저 재현 → 기능별 회귀 순으로 진행한다. +- 공통 패키지 변경은 SN 외 소비자의 기본값 보존을 함께 확인한다. 사용자 dev 서버와 build 동시 실행 금지. +- 서버와 테스트를 실행한다면 적용 AGENTS.md의 소유 프로세스 종료·임시검증물 정리 규칙을 따른다. +- 신규 버그/호출부 누락이 있으면 공통 설계 목록을 갱신한다. 코드 검색 결과를 실브라우저 통과로 기록하지 않는다. + +## 2026-10-02 구현 후 진행 상태 + +- 자동/모의 실행 검증은 결과 문서 `002-qa-SN-GIS-지도이동검색조건초기화-통합검증결과.md`에 기록했다. +- 실제 지도 로그인 후 기능별 회귀는 대기다. 위 표를 브라우저 통과로 일괄 표시하지 않는다. + +## 2026-10-02 추가 회귀 대상 + +- 침수흔적 상세 심선/위선 연도 선택을 최초 조회와 캐시 재선택으로 나누어 검증한다. 캐시 대기 중 다른 대상을 선택하면 이전 요청이 검색바를 초기화하지 않아야 한다. +- 상단 조건을 다시 선택한 뒤 침수흔적 상세 탭 복귀로 FID 재조회·지도 이동 시 초기화되는지 확인한다. +- 모의 실행 결과는 결과 문서에 기록했고 실제 화면 확인은 미완료다. diff --git a/apps/sn/docs/작업검증/SN-GIS-지도이동검색조건초기화/002-qa-SN-GIS-지도이동검색조건초기화-통합검증결과.md b/apps/sn/docs/작업검증/SN-GIS-지도이동검색조건초기화/002-qa-SN-GIS-지도이동검색조건초기화-통합검증결과.md new file mode 100644 index 000000000..ca8f5694a --- /dev/null +++ b/apps/sn/docs/작업검증/SN-GIS-지도이동검색조건초기화/002-qa-SN-GIS-지도이동검색조건초기화-통합검증결과.md @@ -0,0 +1,51 @@ +# SN GIS 지도 이동 검색조건 초기화 통합검증결과 + +## 2026-10-02 자동검증 완료 / 실브라우저 대기 + +| 검증 | 결과 | +| --- | --- | +| SN 전체 TypeScript (`node node_modules/typescript/bin/tsc --noEmit --incremental false --pretty false`) | 최종 exit 0. 초기 sandbox EPERM 후 승인된 외부 실행, 중간 타입 차이 수정 후 최종 통과 | +| 지도 공통 모듈 실제 함수 실행 | 초기화 1회/지도별 격리/빈·NaN·무한·역전 범위 거부/이전 요청 취소/검색바 조작 우선/동일 위치 재선택/구독 해제 통과 | +| 실제 gis.layer 메서드와 모의 source 이벤트 실행 | 로드 전 초기화 없음/도형 로드 후 callback/취소/옵션 없는 기존 호출/이전 source 무시/다중 선택 성공·취소 통과 | +| 침입수/RDII 실제 송신 함수 및 중심 이동 함수 실행 | message/storage 동일 ID, 같은 행 재클릭 새 ID, crypto 의존 없음, 중심 이동 승인·취소·기존 호출·비정상 좌표 거부 통과 | +| ProcSearchBar 로직 hook 모의 실행 | 상단 직접 조회값 유지, 3개 선택값·후보 초기화, 늦은 시설 응답 무시, 초기화 자체의 지도 이동 없음, 시설 기본 라벨 유지 통과 | +| UTF-8/클라이언트 지시문 순서/diff 공백 | 확인 통과 | +| 실 API/DB | 미수행. BE 코드·계약·DB 변경 없음 | +| 브라우저 | localhost:3002/map 진입 후 /auth/login으로 이동. 로그인 대기. 아직 실제 지도 동작 통과 아님 | +| build | 사용자 dev 서버 실행 중이므로 미수행 | + +- Node 검증은 실제 소스에서 함수/로직을 읽어 모의 지도·네트워크·hook 상태로 실행했다. 실제 React DOM/OpenLayers/GeoServer를 연결한 브라우저 통합시험을 대체하지 않는다. +- 검증용 파일·서버를 만들지 않았다. 사용자 서버 3002/PID 40220 및 8883/PID 2676 보존. +- 원본 근거: 요구사항 기능 시트 93/SFR-011-007, 96/SFR-012-001, PPT 실제 슬라이드 19/SN_MN010201·21/SN_MN010202. +- 남은 검증: 통합검증계획의 기능별 실데이터 회귀와 다른 공통 컴포넌트 소비자의 화면 동작. + +## 2026-10-02 사용자 요청에 따른 적용 경로 재점검 + +- 이전 `15개 기능 적용` 보고는 대표 목록 경로 기준이었다. 재점검에서 침수흔적 상세 내부의 추가 선택 경로가 누락된 것을 발견하여 보완했다. 이전 보고를 모든 하위 동작의 실검증 완료로 해석하지 않는다. +- 보완 1: `components/fldng-hstry/fldng-hstry-feature-click.tsx`의 심선·위선 연도 선택. 신규 WFS 조회는 beforeFocus를 전달하고 캐시 재사용은 유효 도형/최신 요청 확인 후 초기화한다. +- 보완 2: `ui/map-page-client.tsx`의 침수흔적 상세 탭 복귀 시 FID 재조회. 같은 성공 callback을 연결했다. +- 본 라운드 수정 소스 2개. 누적 소스는 기존21+신규1=22개다. API·DB 변경 없음. + +| 대조한 기능 | 초기화 연결 근거 | +| --- | --- | +| 통수위험 현재/과거 | use-water-risk-layer의 selectionNavigationRef.commit → fit | +| 침입수·RDII | 각 결과 hook의 fitMapSelection / 지역중심 beforeMove | +| 노후관로·관로분석 상세 | map-page-client의 두 행 선택 handler → mapSearchNavigationOptions | +| 정보검색 기본/유지관리 결과 | use-basic-tab / use-operation-tab의 직접 검색 및 runtime bridge의 popup 수신 | +| 정보검색 선택/전체 지도보기 | use-search-modal / runtime bridge의 다중 검색 callback | +| 지반침하 목록·주변 관로 | loadSinkholeDetail의 resetSearch=true / focusSinkholeNearbyPipe의 commit | +| 침수발생이력 목록 | runtime bridge의 일반 FID 검색 / use-flood-history-selection의 fitMapSelection | +| 침수위험지도 중점관리지역 | MAP_SEARCH_SHOW_MULTIPLE → runtime bridge | +| 도시침수지도·침수예측현황 | MAP_SEARCH_SHOW_FLOOD_GROUP → use-flood-prediction-map의 commit | +| 처리구역 보수이력 | swtrmt-prcs-zone-analysis의 fitMapSelection | +| GIS 편집 변경 목록 | focusGisEditChangeOnMap의 fitMapSelection | +| 조사결과 지도보기 | use-exmn-result-map의 navigation.commit → fit | +| 맨홀 상류관로 | smh의 fitMapSelection | +| 침수흔적 상세 심선·위선 | 이번 보완: 신규 WFS callback + 캐시 beforeFocus | +| 침수흔적 상세 탭 복귀 재조회 | 이번 보완: FID 검색 callback | + +- `fit`/optional fit/animate/setCenter/지역중심 함수/단일·다중 GIS 검색/피처 선택 및 메시지 경로를 SN TS·TSX·JS·JSX 범위에서 다시 검색했다. 직접 fit 잔여는 상위 commit으로 보호되거나 아래 제외 경로임을 확인했다. +- 의도적 제외: 상단 직접 검색/시설 선택, 일반 지도 클릭·드래그·줌, GIS 편집 저장 후 자동 재초점, 별도 GIS 업로드 미리보기·준설 위치 지도. 원래 위치를 이동하지 않는 상세 탭 선택/업로드 이력 선택도 강제 초기화하지 않는다. +- 검증: SN 전체 TypeScript 최종 exit 0, diff 공백 검사 통과. 실제 침수흔적 helper를 읽어 모의 실행한 캐시 성공/늦은 캐시 응답 거부/신규 조회 callback 전달 시험 통과. 테스트 파일/프로세스 생성 없음. +- 현재 결론은 정적 적용 경로 및 자동검증 기준이다. 로그인 이후 전 기능 실브라우저 검증은 여전히 별도 미완료다. +- 이번 보완 롤백: 위 2개 소스의 beforeFocus 추가만 복원. 전체 기능 롤백은 누적22개 변경을 함께 복원하며 DB 작업은 없다. diff --git a/apps/sn/docs/작업내용/SN-GIS-지도이동검색조건초기화/001-fe-SN-GIS-지도이동검색조건초기화-통합구현기록.md b/apps/sn/docs/작업내용/SN-GIS-지도이동검색조건초기화/001-fe-SN-GIS-지도이동검색조건초기화-통합구현기록.md new file mode 100644 index 000000000..400dcd143 --- /dev/null +++ b/apps/sn/docs/작업내용/SN-GIS-지도이동검색조건초기화/001-fe-SN-GIS-지도이동검색조건초기화-통합구현기록.md @@ -0,0 +1,62 @@ +# SN GIS 지도 이동 검색조건 초기화 통합구현기록 + +## 2026-10-02 유지보수 사전 분석 — 구현 미착수 + +- 사용자 범위: 프론트+백엔드 영향 분석. 실제 구현은 다음 단계. +- 확정 대상: 상단 지역·시설 검색조건. 목록에서 선택한 분석 결과/레이어 상태 유지. +- 원본: 요구사항정의서 기능 시트 93행 SFR-011-007, 96행 SFR-012-001; 화면설계서 실제 슬라이드 19/SN_MN010201, 21/SN_MN010202. +- `ProcSearchBar` 내부 state와 공통 GeoJSON 검색/직접 fit의 분산 구조를 확인했다. 통수위험은 검색바 갱신이 없고 침입수/RDII는 반대로 지역·시설 동기화 이벤트가 있다. +- 필수 대상 15개 기능 묶음과 별도 버튼/지도 진입 경로는 공통 설계의 목록을 기준으로 한다. 기능 목록은 정적 코드 기준이며 브라우저에서 전수 검증한 목록이 아니다. +- 제안: SN 지도별 공통 이동 처리, 검색바 optional reset revision, 공통 GeoJSON 검색의 optional 성공 callback. `moveend` 일괄 초기화 금지. +- 구현 시 핵심 파일: `map/ui/map-search-bar.tsx`, `map/ui/map-page-client.tsx`, `map/lib/use-map-runtime-bridge.ts`, 각 결과 지도 hook, `src/features/facility-detail`의 직접 이동부. 허용 공통 경로는 `packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx` 및 필요한 `lib/gis.layer.js`로 한정한다. +- 비동기 시설 후보 조회의 늦은 응답, message/storage 중복, 실패 시 유지, 검색바 직접 조회의 값 보존을 검증한다. +- 이번 실제 수정은 문서뿐이다. 타입검사·빌드·브라우저·API 시험은 미수행이며 새 테스트 파일/서버를 생성하지 않았다. +- 기존 문서 경로와 파일명은 유지했다. 기존 한글 주석의 일부 손상은 본 작업에서 수정하지 않았다. +- 롤백: 이 기록 및 공통 설계/검증계획의 이번 추가분만 복원. 코드·API·DB 영향 없음. +- 상세 설계: `IIPBS/docs/작업계획/SN-GIS-지도이동검색조건초기화/001-de-SN-GIS-지도이동검색조건초기화-설계.md`. + +## 2026-10-02 구현 및 자동검증 + +- 사용자 후속 구현 지시에 따라 공통 지도 이동/검색조건 초기화를 반영했다. +- 지도별 요청 세대와 reset revision, 검색바 optional props, GIS 비동기 성공 callback, 직접 fit/지역 이동 경로를 연결했다. +- 기존 분석 결과·선택 강조·레이어·시설유형/필터·상단 직접 검색 동작은 유지한다. 실행 경로별 보존은 브라우저 회귀에서 추가 확인한다. +- 침입수/RDII 기존 검색바 채우기 제거, 선택 요청 ID 중복 제거, 통수위험의 조회 시작/결과 식별과 오래된 응답 방어를 구현했다. +- 타입검사 최종 exit 0, 파일 없는 Node 실행 검증 4종 및 diff 공백 검사 통과. 중간의 FitOptions/부분 지도 인터페이스 타입 오류는 수정 후 재검사했다. +- 사용자 개발 서버 가동을 확인하여 build/재시작하지 않았다. 새 프로세스/검증파일 생성 없음. 브라우저 로그인 대기. + +### 실제 소스 변경 파일 (frontend 기준) + +- `apps/sn/app/(map-layout)/map/components/exmn/use-exmn-result-map.ts` +- `apps/sn/app/(map-layout)/map/components/inftwt-result/inftwt-result-popup-page.tsx` +- `apps/sn/app/(map-layout)/map/components/inftwt-result/use-inftwt-result-map.ts` +- `apps/sn/app/(map-layout)/map/components/rdii-result/rdii-result-popup-page.tsx` +- `apps/sn/app/(map-layout)/map/components/rdii-result/use-rdii-result-map.ts` +- `apps/sn/app/(map-layout)/map/components/water-risk/water-risk-popup-page.tsx` +- `apps/sn/app/(map-layout)/map/lib/use-flood-history-selection.ts` +- `apps/sn/app/(map-layout)/map/lib/use-map-runtime-bridge.ts` +- `apps/sn/app/(map-layout)/map/lib/use-water-risk-layer.ts` +- `apps/sn/app/(map-layout)/map/search-modal/use-basic-tab.ts` +- `apps/sn/app/(map-layout)/map/search-modal/use-operation-tab.ts` +- `apps/sn/app/(map-layout)/map/search-modal/use-search-modal.ts` +- `apps/sn/app/(map-layout)/map/ui/map-page-client.tsx` +- `apps/sn/app/(map-layout)/map/ui/map-search-bar.tsx` +- `apps/sn/src/features/facility-detail/ui/smh.tsx` +- `apps/sn/src/features/facility-detail/ui/swtrmt-prcs-zone-analysis.tsx` +- `apps/sn/src/features/fldng-predc-prst/lib/use-flood-prediction-map.ts` +- `packages/common/business_components/src/ui/map/lib/gis.layer.js` +- `packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx` +- `packages/common/business_components/src/ui/map/searchBar/utils.ts` +- `apps/sn/src/features/map-search/lib/map-navigation.ts` + +### 남은 검증과 롤백 + +- 실제 데이터로 15개 기능의 지도 이동/상단 초기화 및 분석 표시 보존 확인은 로그인 후 수행한다. +- 롤백 시 위 소스 변경을 함께 복원한다. BE/DB 롤백 없음. + +## 2026-10-02 적용 누락 재점검 및 보완 + +- 사용자 재확인 요청으로 전체 지도 이동 호출·메시지 경로를 대조했다. 침수흔적 상세 심선/위선 연도 선택(신규 조회·캐시)과 상세 탭 복귀 FID 재조회 경로의 초기화 누락을 발견해 보완했다. +- 수정: `frontend/apps/sn/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-feature-click.tsx`, `frontend/apps/sn/app/(map-layout)/map/ui/map-page-client.tsx`. 누적 소스 변경은22개다. +- 기존15개 기능 묶음 및 추가 경로의 적용표를 공통 설계/통합검증결과에 기록했다. 초기 대표 목록 적용 보고와 하위 상세 경로 누락을 구분했다. +- SN 전체 타입검사 exit0, 캐시 성공·지연 응답 거부·신규 callback 전달 실행검증 및 diff 공백 검사 통과. 임시파일·서버 생성 없음, BE/API/DB 변경 없음. +- 실브라우저 전수 검증은 로그인 대기 상태로 유지한다. 롤백은 위2개 파일의 이번 callback 추가를 복원한다. diff --git a/apps/sn/src/features/facility-detail/ui/smh.tsx b/apps/sn/src/features/facility-detail/ui/smh.tsx index ff7fe9e47..89ef5c156 100644 --- a/apps/sn/src/features/facility-detail/ui/smh.tsx +++ b/apps/sn/src/features/facility-detail/ui/smh.tsx @@ -1,3 +1,4 @@ +import { fitMapSelection } from "@/features/map-search/lib/map-navigation"; /** * 화면명: 하수맨홀 정보 * ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수맨홀) @@ -69,7 +70,7 @@ export async function searchUpperPpls( const geometry = foundFeature?.getGeometry(); if (geometry) { const extent = geometry.getExtent(); - gisMap.getView().fit(extent, { + fitMapSelection(gisMap, extent, { duration: 300, padding: [50, 50, 50, 50], maxZoom: 14, diff --git a/apps/sn/src/features/facility-detail/ui/swtrmt-prcs-zone-analysis.tsx b/apps/sn/src/features/facility-detail/ui/swtrmt-prcs-zone-analysis.tsx index dfa3668a4..6927ba5a0 100644 --- a/apps/sn/src/features/facility-detail/ui/swtrmt-prcs-zone-analysis.tsx +++ b/apps/sn/src/features/facility-detail/ui/swtrmt-prcs-zone-analysis.tsx @@ -3,6 +3,7 @@ * 선택한 하수처리구역의 관로 분석 현황과 보수이력 연계 기능을 제공한다. */ "use client"; +import { fitMapSelection } from "@/features/map-search/lib/map-navigation"; import React, { useCallback, @@ -228,7 +229,7 @@ const SwtrmtPrcsZoneAnalysis = ({ repairLayerRef.current?.changed(); const extent = selectedFeature.getGeometry()?.getExtent(); if (extent) { - gisMap.getView().fit(extent, { + fitMapSelection(gisMap, extent, { padding: [100, 100, 100, 460], duration: 350, maxZoom: 18, diff --git a/apps/sn/src/features/fldng-predc-prst/lib/use-flood-prediction-map.ts b/apps/sn/src/features/fldng-predc-prst/lib/use-flood-prediction-map.ts index 9204b1bb8..e8f0c6596 100644 --- a/apps/sn/src/features/fldng-predc-prst/lib/use-flood-prediction-map.ts +++ b/apps/sn/src/features/fldng-predc-prst/lib/use-flood-prediction-map.ts @@ -1,4 +1,5 @@ "use client"; +import { beginMapNavigation } from "@/features/map-search/lib/map-navigation"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -217,6 +218,8 @@ export const useFloodPredictionMap = ({ floodLayerType, onRequestFloodLayer, }: UseFloodPredictionMapOptions) => { + /** 레이어 준비 대기와 늦은 오버레이 응답도 같은 선택 세대로 제한한다. */ + const navigationRef = useRef | null>(null); // 자동 레이어 선택이 완료된 뒤 다시 적용할 침수예측 행을 보관한다. const [pendingSelection, setPendingSelection] = useState(null); @@ -241,6 +244,7 @@ export const useFloodPredictionMap = ({ /** 선택행의 시군구·분석시각으로 도시침수지도 중앙 오버레이를 표시한다. */ const loadFloodPredictionOverlay = useCallback( async (selection: FloodPredictionSelection) => { + const navigation = navigationRef.current; const { sggCd, analysisDateTime, extent } = selection; const map = mapRef.current; if (!map || !/^\d{12}$/.test(analysisDateTime)) return; @@ -250,6 +254,7 @@ export const useFloodPredictionMap = ({ const response = await getRawJson( `/sewerage-service/api/v1/fldng/predc/prst/gridMap?sggCd=${encodeURIComponent(sggCd)}&analysisDateTime=${encodeURIComponent(analysisDateTime)}`, ); + if (!navigation?.isCurrent()) return; const payload = response?.data ?? response; const row = payload && typeof payload === "object" && !Array.isArray(payload) @@ -284,6 +289,8 @@ export const useFloodPredictionMap = ({ /** 활성화된 도시침수지도에서 선택 행의 시군구·빈도 피처를 선택하고 지도와 오버레이를 이동한다. */ const applyFloodPredictionSelection = useCallback( async (selection: FloodPredictionSelection) => { + const navigation = navigationRef.current; + if (!navigation?.isCurrent()) return true; const map = mapRef.current; const select = GisApp.Module.select as unknown as | FloodSelectionModule @@ -302,10 +309,12 @@ export const useFloodPredictionMap = ({ ); if (floodLayers.length === 0) return false; - await select.resetSelectedFeature?.(); + if (!navigation.commit(selection.extent)) return true; + select.resetSelectedFeature?.(); const selectedFeatureIds = new Set(); /** 현재 로드된 동일 시군구·빈도 피처를 중복 없이 선택한다. */ const selectLoadedGroupFeatures = (source: FloodPredictionSource) => { + if (!navigation.isCurrent()) return; const features = source?.getFeatures?.() ?? []; features.forEach((feature) => { const featureSggCd = String(feature.get?.("sgg_cd") ?? "").trim(); @@ -352,6 +361,8 @@ export const useFloodPredictionMap = ({ async (payload: unknown, popupContext: FloodPredictionPopupContext) => { const selection = parseFloodPredictionSelection(payload); if (!selection) return; + navigationRef.current = beginMapNavigation(mapRef.current); + setPendingSelection(null); if (await applyFloodPredictionSelection(selection)) return; pendingPopupRef.current = popupContext; diff --git a/apps/sn/src/features/map-search/lib/map-navigation.ts b/apps/sn/src/features/map-search/lib/map-navigation.ts new file mode 100644 index 000000000..77ade1429 --- /dev/null +++ b/apps/sn/src/features/map-search/lib/map-navigation.ts @@ -0,0 +1,81 @@ +import type Map from "ol/Map"; +import type { FitOptions } from "ol/View"; + +/** 지도 객체별 이동 요청과 검색바 초기화 revision을 보관한다. 다른 지도와 상태를 공유하지 않는다. */ +type NavigationState = { + generation: number; + revision: number; + listeners: Set<() => void>; +}; +const states = new WeakMap(); +/** 부분 지도 인터페이스를 사용하는 기능도 동일한 인스턴스로 요청을 관리한다. */ +type NavigationTarget = { getView?: () => unknown }; + +/** 지도 생명주기와 함께 회수되는 상태를 반환한다. */ +function stateFor(map: object): NavigationState { + let state = states.get(map); + if (!state) { + state = { generation: 0, revision: 0, listeners: new Set() }; + states.set(map, state); + } + return state; +} + +/** 빈/역전/비수치 범위를 이동 성공으로 취급하지 않는다. */ +export function isNavigationExtent(extent: unknown): extent is number[] { + return Array.isArray(extent) && extent.length === 4 && + extent.every(Number.isFinite) && extent[0] <= extent[2] && extent[1] <= extent[3]; +} + +/** 검색바 조작은 아직 완료되지 않은 목록 이동보다 우선한다. */ +export function invalidateMapNavigation(map: object | null | undefined) { + if (map) stateFor(map).generation += 1; +} + +/** 검색바는 이미 발생한 초기화도 revision으로 읽어 늦은 마운트에서 유실하지 않는다. */ +export function getMapSearchRevision(map: object | null | undefined) { + return map ? stateFor(map).revision : 0; +} + +/** 한 지도 검색바의 초기화만 구독한다. */ +export function subscribeMapSearch(map: object | null | undefined, listener: () => void) { + if (!map) return () => {}; + const state = stateFor(map); + state.listeners.add(listener); + return () => { state.listeners.delete(listener); }; +} + +/** 조회 시작에 발급하고 도형 확보 뒤 commit한다. 최신 요청만 한 번 검색바를 초기화한다. */ +export function beginMapNavigation(map: NavigationTarget | null | undefined) { + const state = map ? stateFor(map) : null; + const generation = state ? ++state.generation : -1; + let committed = false; + const isCurrent = () => Boolean(state && state.generation === generation); + return { + isCurrent, + commit(extent: unknown) { + if (!isCurrent() || !map?.getView?.() || !isNavigationExtent(extent)) return false; + if (!committed && state) { + committed = true; + state.revision += 1; + state.listeners.forEach((listener) => listener()); + } + return true; + }, + }; +} + +/** 도형이 이미 준비된 목록 선택은 기존 fit 옵션을 그대로 사용한다. */ +export function fitMapSelection(map: Pick | null | undefined, extent: unknown, options: FitOptions & { constrainResolution?: boolean }) { + if (!map?.getView?.()?.fit || !isNavigationExtent(extent)) return false; + const navigation = beginMapNavigation(map); + if (!navigation.commit(extent)) return false; + map.getView().fit(extent, options); + return true; +} + +/** 공통 GeoJSON 검색의 비동기 로드 완료 시 호출할 선택적 계약이다. */ +export function mapSearchNavigationOptions(map: NavigationTarget | null | undefined) { + const navigation = beginMapNavigation(map); + return { beforeFocus: (extent: unknown) => navigation.commit(extent) }; +} 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 b30f1fec6..b6935c383 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 @@ -659,7 +659,7 @@ GisOlLayer.prototype = { this.searchLayer = null; } }, - showSearchResultsFromGeoJSON: function (lyrTyNm, featureId) { + showSearchResultsFromGeoJSON: function (lyrTyNm, featureId, options) { //레이어 타입 설정(point, line 등. 이걸 style에서 사용) const layerCode = GisApp.layerCode; const matched = Object.values(layerCode).find(obj => obj.lyrTyNm === lyrTyNm); @@ -674,10 +674,15 @@ GisOlLayer.prototype = { // 1) 로드 완료시 extent 맞추기 const onLoadEnd = () => { + // 이전 검색 source의 지연 응답은 현재 선택을 변경하지 않는다. + if (layer.getSource() !== source || this.searchLayer !== layer) return; if (Object.keys(source.nullGeometryFeatures_).length > 0) showBusinessUserMessage('해당 시설의 위치 정보가 없습니다.'); const feats = source.getFeatures(); if (feats && feats.length > 0) { + const extent = feats[0].getGeometry()?.getExtent(); + // 선택적 소비자만 성공 경계에서 초기화/취소를 결정한다. + if (options?.beforeFocus && options.beforeFocus(extent) === false) return; const select = GisApp.Module.select; select.selectFeature(feats[0], false); } else { @@ -740,7 +745,7 @@ GisOlLayer.prototype = { * @param {string} lyrTyNm 레이어 타입명 * @param {Array|string} featureIds 피처 ID 배열 또는 단일 ID */ - showMultipleSearchResultsFromGeoJSON: function (lyrTyNm, featureIds) { + showMultipleSearchResultsFromGeoJSON: function (lyrTyNm, featureIds, options) { const layerCode = GisApp.layerCode; const matched = Object.values(layerCode).find(obj => obj.lyrTyNm === lyrTyNm); @@ -754,10 +759,13 @@ GisOlLayer.prototype = { const source = this.getMultiFeatureSourceFromGeoserver(lyrTyNm, featureIds); const onLoadEnd = async () => { + if (layer.getSource() !== source || this.searchLayer !== layer) return; const feats = source.getFeatures(); if (feats && feats.length > 0) { + const focusExtent = this.combineExtents(feats.map(f => f.getGeometry()?.getExtent())); + if (options?.beforeFocus && options.beforeFocus(focusExtent) === false) return; const select = GisApp.Module.select; - await select.resetSelectedFeature(); + select.resetSelectedFeature(); for (const feature of feats) select.selectMultiFeature(feature, true); const geometries = feats.map(f => f.getGeometry()); if (geometries.length > 0) { diff --git a/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx b/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx index 3f67ca2c7..64150198a 100644 --- a/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx +++ b/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx @@ -1,7 +1,7 @@ "use client"; import { SelectBox, type SelectDataType } from "../../../ui"; import { Button } from "@common/components/ui"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { showBusinessUserMessage } from "../../../lib/user-message"; import GisApp from "../lib/gis.core"; import { @@ -34,6 +34,10 @@ interface SearchBarProps { facilityLayerFilter?: FacilityLayerFilter; /** 관망도 전용 3개 셀렉트와 조회 버튼을 하나로 연결한 검색바 레이아웃이다. */ integrated?: boolean; + /** 지도 목록 선택이 성공했을 때만 증가하는 선택적 초기화 revision이다. */ + resetRevision?: number; + /** 직접 검색조건 조작이 이전 비동기 지도 선택을 취소할 수 있게 알린다. */ + onSearchInteraction?: () => void; } interface CenterSelectDataType extends SelectDataType { @@ -46,7 +50,13 @@ export const ProcSearchBar = ({ sidoSearchZoom = 11, facilityLayerFilter, integrated = false, + resetRevision, + onSearchInteraction, }: SearchBarProps) => { + /** 초기화·조건 변경 이전 시설 후보 응답이 현재 선택을 되살리지 못하게 한다. */ + const facilityRequestRef = useRef(0); + /** 라벨 변경은 선택을 지우지 않고 새 revision에서만 초기화한다. */ + const appliedResetRevisionRef = useRef(undefined); const [sidoValue, setSidoValue] = useState("0"); const [sigunValue, setSigunValue] = useState("0"); const [sidoData, setSidoData] = useState([ @@ -157,6 +167,7 @@ export const ProcSearchBar = ({ sidoNm: string, currentFacilityType?: string, ) => { + const request = ++facilityRequestRef.current; const type = currentFacilityType ?? facilityType; if (!type || type === "0") return; @@ -240,6 +251,7 @@ export const ProcSearchBar = ({ })); } + if (request !== facilityRequestRef.current) return; const sorted = list .slice() .sort((a, b) => a.text.localeCompare(b.text, "ko")); @@ -248,6 +260,20 @@ export const ProcSearchBar = ({ }, [defaultFacilityData, facilityLayerFilter, facilityType], ); + /** 외부 이동은 검색값만 초기화하고 지도·레이어 및 원천 지역 목록은 유지한다. */ + useLayoutEffect(() => { + if (resetRevision === undefined || appliedResetRevisionRef.current === resetRevision) return; + appliedResetRevisionRef.current = resetRevision; + facilityRequestRef.current += 1; + setSidoValue("0"); + setSigunValue("0"); + setFacilityValue("0"); + setFilteredSigunData([{ text: "시군구", val: "0" }]); + setFacilityData(defaultFacilityData()); + setPendingExternalFacilityName(""); + }, [resetRevision, defaultFacilityData]); + + useEffect(() => () => { facilityRequestRef.current += 1; }, []); useEffect(() => { fetchSido(); fetchSigungu(); @@ -262,6 +288,7 @@ export const ProcSearchBar = ({ return; } + facilityRequestRef.current += 1; setFacilityData(defaultFacilityData()); setFacilityValue("0"); }, [ @@ -317,6 +344,8 @@ export const ProcSearchBar = ({ const onSelectValue = useCallback( (val: string, type: string) => { + facilityRequestRef.current += 1; + onSearchInteraction?.(); if (type === "ctpvCd") { setSidoValue(val); initializeSigunData(val); @@ -368,10 +397,12 @@ export const ProcSearchBar = ({ sidoData, sidoValue, facilityType, + onSearchInteraction, ], ); const handleSearch = useCallback(() => { + onSearchInteraction?.(); if (sidoValue === "0") { showBusinessUserMessage("시도를 선택해 주세요."); return; @@ -400,6 +431,7 @@ export const ProcSearchBar = ({ filteredSigunData, sigunData, sidoSearchZoom, + onSearchInteraction, ]); useEffect(() => { diff --git a/packages/common/business_components/src/ui/map/searchBar/utils.ts b/packages/common/business_components/src/ui/map/searchBar/utils.ts index 84918a3ca..8ac901bed 100644 --- a/packages/common/business_components/src/ui/map/searchBar/utils.ts +++ b/packages/common/business_components/src/ui/map/searchBar/utils.ts @@ -79,6 +79,8 @@ export const moveMapToCenter = ( gisMaps: RefObject, centerCoord?: [number, number], zoom?: number, + /** 유효한 중심을 확보한 뒤 소비자가 이동을 승인/취소하는 선택적 callback이다. */ + beforeMove?: (extent: number[]) => boolean, ) => { if (!centerCoord) return; @@ -99,6 +101,7 @@ export const moveMapToCenter = ( } } + if (beforeMove && !beforeMove([center[0], center[1], center[0], center[1]])) return; view.animate({ center, ...(typeof zoom === "number" ? { zoom } : {}), From 2d086a05b21472f122fc8176809991623f7de4bd Mon Sep 17 00:00:00 2001 From: kiminchan Date: Fri, 2 Oct 2026 17:11:04 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=EC=A1=B0=EC=82=AC=EC=82=AC=EC=97=85=C2=B7?= =?UTF-8?q?=EC=A4=80=EC=84=A4=20=EB=93=B1=EB=A1=9D=20=ED=95=84=EC=88=98=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EB=B0=8F=20=EC=88=AB=EC=9E=90=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EC=A0=9C=ED=95=9C=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../002-qa-SFR-010-004~007-조사사업-통합검증결과.md | 9 +++++++++ .../001-qa-SFR-010-012-준설관리-검증결과.md | 12 ++++++++++++ .../001-fe-SFR-010-004~007-조사사업-통합구현기록.md | 9 +++++++++ .../001-fe-SFR-010-012-준설관리-구현기록.md | 12 ++++++++++++ .../dredging/ui/dredging-registration-body.tsx | 14 +++++++++++--- .../exmn/ui/exmn-business-register-popup-page.tsx | 11 +++++++++-- 6 files changed, 62 insertions(+), 5 deletions(-) diff --git a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md index e0d291237..5024b66fe 100644 --- a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md @@ -669,3 +669,12 @@ - 저장 API는 실제 시트명 등록을 계속 요구하며 미준비면 기존 결과를 변경하기 전에 차단한다. 프론트는 첨부 업로드 전에 최신 상태를 확인하며 서버에서 같은 오류가 반환돼도 alert로 안내한다. - 검증: ExmnTemplateGateTest 7건 통과(Excel/PPL PDF/MNHL 검토 독립, 미준비 commit 무쓰기 차단, 기존 시트명 보존, 준비 완료 commit, 해시 불일치 차단), FE 타입 검사 통과. 테스트는 mock 기반이며 실제 파일 저장·DB 변경 및 브라우저 끝단 저장은 수행하지 않았다. - 임시 테스트 소스·산출물은 요약 후 정리한다. 이전 ‘검토 전 양식 확인’ 기록은 이 지시로 대체한다. + +## 2026-10-02 quick_vibe 등록 입력 필수 표시 보완 + +- 사업대상 처리분구 제목에 기존 폼과 같은 붉은 별표와 접근성용 필수 문구를 추가했다. 기존 1개 이상 선택 검증은 유지한다. +- 정밀조사연장은 숫자·소수점 하나·입력 삭제만 허용한다. 문자/한글/부호/지수/콤마/복수 소수점 입력 및 붙여넣기는 거부한다. km 소수 입력과 기존 저장 검증은 유지한다. +- 원본: 요구사항 기능 시트76행 SFR-010-004, PPT 실제39슬라이드 SN_MN040105 사업정보 등록/처리분구 선택/정밀조사연장(km). +- 검증: SN 전체 타입검사 exit0, 숫자/소수/삭제 및 거부 문자열 Node 검증 통과, git diff --check 통과. 실브라우저 입력 검증은 미수행. +- 수정 소스: `frontend/apps/sn/src/features/exmn/ui/exmn-business-register-popup-page.tsx`. API/백엔드 코드·저장 계약 변경 없음. 기존 GIS 작업은 변경하지 않았다. +- 롤백: 해당 소스의 이번 필수 표시/입력 제한만 복원한다. diff --git a/apps/sn/docs/작업검증/SFR-010-012-준설관리/001-qa-SFR-010-012-준설관리-검증결과.md b/apps/sn/docs/작업검증/SFR-010-012-준설관리/001-qa-SFR-010-012-준설관리-검증결과.md index 0475da26a..56ef7a5bf 100644 --- a/apps/sn/docs/작업검증/SFR-010-012-준설관리/001-qa-SFR-010-012-준설관리-검증결과.md +++ b/apps/sn/docs/작업검증/SFR-010-012-준설관리/001-qa-SFR-010-012-준설관리-검증결과.md @@ -422,3 +422,15 @@ Chrome 확장의 파일 URL 접근 권한이 꺼져 있어 이번 회귀에서 E - `git diff --check`는 줄바꿈 변환 경고 외 whitespace 오류 없이 통과했다. - 사용자 요청에 따라 전체 TypeScript 검사와 Next 빌드는 수행하지 않았다. - 등록·수정 팝업에서 버튼명, 위치출처, 사진상태 표시와 기존 저장 흐름의 수동 검증이 남아 있다. + +## 2026-10-02 quick_vibe 등록 입력 필수 표시 보완 + +- 설정된 SN DB의 information_schema.columns를 psql로 읽기 전용 조회했다(default_transaction_read_only=on). 대상은 sm_bs_sn.tb_sn_drdgn_job, tb_sn_drdgn_ppl, tb_sn_drdgn_job_photo 3개 테이블이다. DB 데이터/DDL 변경 없음. +- 작업 테이블의 drdgn_ymd/reg_se_cd/pstn_src_cd/lot/lat, 관로 테이블의 fid/mng_inst_cd/fclt_mng_no/관로 및 준설 좌표/거리 등 NOT NULL을 Repository 저장값과 대조했다. +- 사용자 확인이 필요한 위치등록·자동 연결 관로에 기존 TableHead data-required 별표를 적용했다. 준설일자의 기존 필수 표시는 유지했다. +- 사진은 DredgingRequestValidator의 PHOTO 방식 1장 이상 규칙에 따라 PHOTO 모드에서만 별표를 표시한다. MANUAL에는 필수 표시하지 않는다. 촬영일시 sht_dt는 nullable이므로 필수로 표시하지 않는다. +- ID/순번/등록시각/사진건수 및 자동 매핑된 관로 속성은 시스템이 채우므로 개별 사용자 입력 필수 항목으로 추가하지 않는다. 등록 방식도 기존 기본 선택/버튼을 유지한다. +- 원본: 요구사항 기능 시트84행 SFR-010-012, PPT 실제53슬라이드 SN_MN040201 준설 결과 업로드. +- 검증: DB 메타데이터 조회 성공, 기존 서버/FE 저장 검증 대조, SN 전체 타입검사 exit0, git diff --check 통과. 실브라우저 표시는 미수행. +- 수정 소스: `frontend/apps/sn/src/features/dredging/ui/dredging-registration-body.tsx`. API/백엔드 코드·저장 계약 변경 없음. 기존 GIS 작업은 변경하지 않았다. +- 롤백: 해당 소스의 이번 필수 표시/입력 제한만 복원한다. diff --git a/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md b/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md index 1567284ac..60950552e 100644 --- a/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md @@ -995,3 +995,12 @@ ### 2026-10-01 조사양식 안내창 폭 조정 - 해당 안내만 56rem 폭과 화면 여백 최대폭을 적용한 모달로 표시한다. 단어 유지와 균형 줄바꿈으로 두 줄 표기를 유도하며 문구와 저장 시점은 유지한다. 다른 공통 알림창은 변경하지 않는다. - git diff --check 통과. 좁은 화면에서는 잘림 방지를 위해 추가 줄바꿈을 허용한다. + +## 2026-10-02 quick_vibe 등록 입력 필수 표시 보완 + +- 사업대상 처리분구 제목에 기존 폼과 같은 붉은 별표와 접근성용 필수 문구를 추가했다. 기존 1개 이상 선택 검증은 유지한다. +- 정밀조사연장은 숫자·소수점 하나·입력 삭제만 허용한다. 문자/한글/부호/지수/콤마/복수 소수점 입력 및 붙여넣기는 거부한다. km 소수 입력과 기존 저장 검증은 유지한다. +- 원본: 요구사항 기능 시트76행 SFR-010-004, PPT 실제39슬라이드 SN_MN040105 사업정보 등록/처리분구 선택/정밀조사연장(km). +- 검증: SN 전체 타입검사 exit0, 숫자/소수/삭제 및 거부 문자열 Node 검증 통과, git diff --check 통과. 실브라우저 입력 검증은 미수행. +- 수정 소스: `frontend/apps/sn/src/features/exmn/ui/exmn-business-register-popup-page.tsx`. API/백엔드 코드·저장 계약 변경 없음. 기존 GIS 작업은 변경하지 않았다. +- 롤백: 해당 소스의 이번 필수 표시/입력 제한만 복원한다. diff --git a/apps/sn/docs/작업내용/SFR-010-012-준설관리/001-fe-SFR-010-012-준설관리-구현기록.md b/apps/sn/docs/작업내용/SFR-010-012-준설관리/001-fe-SFR-010-012-준설관리-구현기록.md index ed3215cf9..c14145252 100644 --- a/apps/sn/docs/작업내용/SFR-010-012-준설관리/001-fe-SFR-010-012-준설관리-구현기록.md +++ b/apps/sn/docs/작업내용/SFR-010-012-준설관리/001-fe-SFR-010-012-준설관리-구현기록.md @@ -400,3 +400,15 @@ - 기존 `SFR-010-012/013` 사진 확인 흐름에서 사용자 요청에 따른 표시 보완이다. 등록 화면, API·DB와 업로드·저장 처리는 변경하지 않았다. - 로컬 HMR 후 실제 사진 4건의 12px/18px/500 적용, 가로 잘림 없음과 밑줄 표시를 확인했다. 사진 선택·미리보기 전환 후 기존 선택으로 복원했다. 신규 사진 업로드는 실행하지 않았다. - `git diff --check` 통과. 롤백은 파일명·촬영일자의 글자 클래스만 기존 값으로 되돌린다. + +## 2026-10-02 quick_vibe 등록 입력 필수 표시 보완 + +- 설정된 SN DB의 information_schema.columns를 psql로 읽기 전용 조회했다(default_transaction_read_only=on). 대상은 sm_bs_sn.tb_sn_drdgn_job, tb_sn_drdgn_ppl, tb_sn_drdgn_job_photo 3개 테이블이다. DB 데이터/DDL 변경 없음. +- 작업 테이블의 drdgn_ymd/reg_se_cd/pstn_src_cd/lot/lat, 관로 테이블의 fid/mng_inst_cd/fclt_mng_no/관로 및 준설 좌표/거리 등 NOT NULL을 Repository 저장값과 대조했다. +- 사용자 확인이 필요한 위치등록·자동 연결 관로에 기존 TableHead data-required 별표를 적용했다. 준설일자의 기존 필수 표시는 유지했다. +- 사진은 DredgingRequestValidator의 PHOTO 방식 1장 이상 규칙에 따라 PHOTO 모드에서만 별표를 표시한다. MANUAL에는 필수 표시하지 않는다. 촬영일시 sht_dt는 nullable이므로 필수로 표시하지 않는다. +- ID/순번/등록시각/사진건수 및 자동 매핑된 관로 속성은 시스템이 채우므로 개별 사용자 입력 필수 항목으로 추가하지 않는다. 등록 방식도 기존 기본 선택/버튼을 유지한다. +- 원본: 요구사항 기능 시트84행 SFR-010-012, PPT 실제53슬라이드 SN_MN040201 준설 결과 업로드. +- 검증: DB 메타데이터 조회 성공, 기존 서버/FE 저장 검증 대조, SN 전체 타입검사 exit0, git diff --check 통과. 실브라우저 표시는 미수행. +- 수정 소스: `frontend/apps/sn/src/features/dredging/ui/dredging-registration-body.tsx`. API/백엔드 코드·저장 계약 변경 없음. 기존 GIS 작업은 변경하지 않았다. +- 롤백: 해당 소스의 이번 필수 표시/입력 제한만 복원한다. diff --git a/apps/sn/src/features/dredging/ui/dredging-registration-body.tsx b/apps/sn/src/features/dredging/ui/dredging-registration-body.tsx index 870be5cd0..4a2c93b2d 100644 --- a/apps/sn/src/features/dredging/ui/dredging-registration-body.tsx +++ b/apps/sn/src/features/dredging/ui/dredging-registration-body.tsx @@ -224,7 +224,15 @@ export const DredgingRegistrationBody = ({ >
-

준설 사진

+

+ {activeGroup.registrationMode === "PHOTO" && ( + + )} + 준설 사진 + {activeGroup.registrationMode === "PHOTO" && ( + (필수) + )} +

촬영일과 GPS 정보를 확인합니다.

@@ -415,7 +423,7 @@ export const DredgingRegistrationBody = ({ - 위치등록 + 위치등록 - 자동 연결 관로 + 자동 연결 관로 {activeGroup.isMatching diff --git a/apps/sn/src/features/exmn/ui/exmn-business-register-popup-page.tsx b/apps/sn/src/features/exmn/ui/exmn-business-register-popup-page.tsx index 00cadc431..1d96a25a9 100644 --- a/apps/sn/src/features/exmn/ui/exmn-business-register-popup-page.tsx +++ b/apps/sn/src/features/exmn/ui/exmn-business-register-popup-page.tsx @@ -536,7 +536,11 @@ export function ExmnBusinessRegisterPopupPage({ maxLength={10} value={prcisExmnPrlg} disabled={formLocked} - onChange={(event) => setPrcisExmnPrlg(event.target.value)} + onChange={(event) => { + // km 단위 소수 입력과 삭제는 허용하고 문자·부호·지수 표기는 거부한다. + const value = event.target.value; + if (/^\d*(?:\.\d*)?$/.test(value)) setPrcisExmnPrlg(value); + }} /> km
@@ -612,7 +616,10 @@ export function ExmnBusinessRegisterPopupPage({
-

사업대상 처리분구

+

+ + 사업대상 처리분구 (필수) +

Date: Fri, 2 Oct 2026 18:07:00 +0900 Subject: [PATCH 3/7] =?UTF-8?q?sn:=20=ED=99=95=EB=A5=A0=EA=B0=95=EC=9A=B0?= =?UTF-8?q?=EB=9F=89=20=EB=AA=A8=EB=8B=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ce-SFR-011-008-침수예측현황-확률강우량-작업계획.md | 41 ++++ ...fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md | 19 ++ .../fldngPredcPrst/ui/PrbbtRnflDialog.tsx | 204 ++++++++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md create mode 100644 apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md create mode 100644 apps/sn/src/features/fldngPredcPrst/ui/PrbbtRnflDialog.tsx diff --git a/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md b/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md new file mode 100644 index 000000000..18497c7c6 --- /dev/null +++ b/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md @@ -0,0 +1,41 @@ +# SFR-011-008 침수예측현황 확률강우량 FE 유지보수 작업계획 + +## 작업 목적 + +침수예측현황의 선택 시군구가 어떤 확률강우량 기준으로 위험도가 산정되는지 확인할 수 있도록 목록 열과 기준표 팝업을 추가한다. + +## 승인 상태 + +- `maintenance` 작업계획 +- 2026-10-02 사용자 승인, 코드 구현 진행 + +## 구현 계획 + +- `FldngPredcPrstPopupContent`의 `침수위험도` 오른쪽에 `확률강우량` 액션을 추가한다. +- 기준표 팝업 컴포넌트 파일은 표준용어를 사용한 `PrbbtRnflDialog.tsx`로 둔다. +- 목록의 확률강우량은 `predc_rain_hr` 기준 `N시간` 형식의 클릭 가능한 지속시간으로 표시한다. 기존 `predictionHour`(`predc_hr`)와 구분한다. +- 선택 행의 `sggCd`로 BE 확률강우량 조회 API를 호출한다. +- API 경로는 사용자 지정 `GET /api/v1/fldng/predc/prst/prbbt-rnfl`을 사용한다. +- 팝업 표는 재현기간을 행, `1시간`, `2시간`, `3시간` 형식의 지속시간을 열로 표시한다. +- 팝업 하단에 확률관측소코드·관측소명·면적비율(%) 비율표를 표시한다. +- 팝업 맨 아래에 `시간은 강우가 지속된 시간입니다` 안내를 표시한다. +- 지속시간 헤더는 기존 팝업 표 헤더 스타일을 유지한다. +- 선택 행의 대표 지속시간에 해당하는 열의 확률강우량 기준값 셀들을 강조 색상으로 표시한다. 표 헤더는 기존 스타일을 유지한다. +- 강수량은 mm 단위로 표시하고 로딩·빈 결과·오류 상태를 제공한다. +- 기존 행 클릭의 도시침수지도 이동과 별도 액션으로 동작시킨다. + +## 검증 계획 + +- 제주·서울 등 서로 다른 시군구 기준표 조회 +- 1~72시간 행과 빈도년도 열 표시 +- API 오류·빈 결과 표시 +- 기존 지도 이동 회귀 +- `pnpm --filter sn run build` + +## 구현 결과 (2026-10-02) + +- 현황 목록에 `확률강우량` 열을 추가하고 대표 지속시간 `predcRainHr`를 `N시간` 링크로 표시한다. +- 링크 클릭은 행 선택 지도 이동을 실행하지 않으며, 시군구별 기준표 팝업을 연다. +- 팝업은 재현기간 행×지속시간 열의 확률강우량, 하단 관측소 면적비율과 API 합계를 표시한다. +- 선택 지속시간 열의 값 셀만 강조하고 헤더는 기존 스타일을 유지한다. +- `pnpm --filter sn run typecheck`, `git diff --check`가 통과했다. 3002 포트 개발 서버 실행 중이므로 build는 실행하지 않았다. diff --git a/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md b/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md new file mode 100644 index 000000000..a7071cccf --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md @@ -0,0 +1,19 @@ +# SFR-011-008 침수예측현황 확률강우량 FE 구현기록 + +## 구현 내용 +- 확률강우량 팝업 컴포넌트는 `PrbbtRnflDialog.tsx`에 둔다. +- 침수위험도 오른쪽에 확률강우량 열을 추가하고 대표 누적강우 지속시간 `predcRainHr`를 `N시간` 링크로 표시했다. +- 링크는 `GET /api/v1/fldng/predc/prst/prbbt-rnfl?sggCd=...`를 호출한다. +- 팝업 본문에 재현기간 행×지속시간 열의 확률강우량표를 표시한다. +- 선택 지속시간 열의 값 셀만 강조하고 표 헤더는 기존 스타일을 유지한다. +- 팝업 하단에 확률관측소코드·관측소명·면적비율 및 API 합계를 표시한다. +- 팝업 맨 아래에 `시간은 강우가 지속된 시간입니다` 안내를 표시한다. +- 링크 클릭 이벤트는 기존 행 선택의 지도 이동으로 전파하지 않는다. + +## 검증 +- `pnpm --filter sn run typecheck` 성공 +- `git diff --check` 성공 +- 개발 서버(3002)가 실행 중이어서 build는 실행하지 않았다. + +## 수동 확인 필요 +- 실제 로그인 화면에서 팝업 크기·가로 스크롤·강조 색상 및 지도 이동 회귀 확인 diff --git a/apps/sn/src/features/fldngPredcPrst/ui/PrbbtRnflDialog.tsx b/apps/sn/src/features/fldngPredcPrst/ui/PrbbtRnflDialog.tsx new file mode 100644 index 000000000..c870d5ab2 --- /dev/null +++ b/apps/sn/src/features/fldngPredcPrst/ui/PrbbtRnflDialog.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useMemo } from "react"; + +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@common/components"; + +/** 시군구별 면적가중 확률강우량 한 셀의 API 응답이다. */ +export type PrbbtRnflCell = { + frequencyYear: number; + durationHour: number; + rainfall: number; +}; + +/** 확률관측소의 시군구 교차면적 비율 API 응답이다. */ +export type PrbbtRnflStation = { + prbbtObsvtrCd: string; + prbbtObsvtrNm: string; + areaRt: number; +}; + +/** 확률강우량 표와 그 계산에 사용한 관측소 비율을 함께 받는다. */ +export type PrbbtRnflResponse = { + sggCd: string; + rainfall: PrbbtRnflCell[]; + stations: PrbbtRnflStation[]; + areaRtTotal: number; +}; + +type PrbbtRnflDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + sggName: string; + selectedDurationHour: number | null; + data: PrbbtRnflResponse | null; + loading: boolean; + error: string | null; +}; + +const formatRainfall = (value: number) => + Number.isFinite(Number(value)) ? Number(value).toFixed(4) : "-"; + +const formatAreaRatio = (value: number) => + Number.isFinite(Number(value)) ? Number(value).toFixed(3) : "-"; + +/** 재현기간을 행, 강우지속시간을 열로 구성하고 목록에서 선택한 지속시간의 기준값을 강조한다. */ +export default function PrbbtRnflDialog({ + open, + onOpenChange, + sggName, + selectedDurationHour, + data, + loading, + error, +}: PrbbtRnflDialogProps) { + const { frequencies, durations, rainfallByKey } = useMemo(() => { + const rainfall = data?.rainfall ?? []; + return { + frequencies: [...new Set(rainfall.map((item) => item.frequencyYear))].sort( + (a, b) => a - b, + ), + durations: [...new Set(rainfall.map((item) => item.durationHour))].sort( + (a, b) => a - b, + ), + rainfallByKey: new Map( + rainfall.map((item) => [ + `${item.frequencyYear}:${item.durationHour}`, + item.rainfall, + ]), + ), + }; + }, [data]); + + return ( + + + + {sggName} 확률강우량 + + + {loading ? ( +

+ 확률강우량을 조회하고 있습니다. +

+ ) : error ? ( +

+ {error} +

+ ) : !frequencies.length || !durations.length ? ( +

+ 해당 시군구의 확률강우량 기준자료가 없습니다. +

+ ) : ( + <> +
+ + + + + + {durations.map((durationHour) => ( + + ))} + + + + {frequencies.map((frequencyYear) => ( + + + {durations.map((durationHour) => { + const rainfall = rainfallByKey.get( + `${frequencyYear}:${durationHour}`, + ); + return ( + + ); + })} + + ))} + +
+ {sggName} 재현기간 및 강우지속시간별 확률강우량(mm) +
+ 재현기간(년) + + {durationHour}시간 +
+ {frequencyYear} + + {rainfall == null ? "-" : formatRainfall(rainfall)} +
+
+ +
+

확률관측소 면적비율

+
+ + + + + + + + + + {(data?.stations ?? []).map((station) => ( + + + + + + ))} + + + + + +
+ 확률관측소코드 + + 관측소명 + + 면적비율(%) +
+ {station.prbbtObsvtrCd} + + {station.prbbtObsvtrNm || "-"} + + {formatAreaRatio(station.areaRt)} +
+ 합계 + + {formatAreaRatio(data?.areaRtTotal ?? Number.NaN)} +
+
+
+ + )} +

+ 시간은 강우가 지속된 시간입니다 +

+
+
+ ); +} From 8ed4f959cbc3c3de4066747841e6fef5c4475a0c Mon Sep 17 00:00:00 2001 From: ljpark Date: Fri, 2 Oct 2026 18:12:03 +0900 Subject: [PATCH 4/7] =?UTF-8?q?sn:=20=ED=99=95=EB=A5=A0=EA=B0=95=EC=9A=B0?= =?UTF-8?q?=EB=9F=89=20=EB=AA=A8=EB=8B=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/dm/AGENTS.md | 13 ++ apps/fo/AGENTS.md | 13 ++ apps/sn/AGENTS.md | 38 +++++- ...ce-SFR-011-008-침수예측현황-확률강우량-작업계획.md | 10 +- ...fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md | 5 +- .../api/cty-fldng-prvtn-biz-api.ts | 6 + .../ui/CtyFldngPrvtnBizFormPage.tsx | 118 +++++++++++++++--- .../ui/CtyFldngPrvtnBizListPage.tsx | 20 +-- .../ui/FldngPredcPrstPopupContent.tsx | 95 +++++++++++++- .../fldngPredcPrst/ui/PrbbtRnflDialog.tsx | 45 +++++-- .../windowPopup/ui/MapPopupDataTable.tsx | 13 +- apps/wr/AGENTS.md | 16 ++- 12 files changed, 347 insertions(+), 45 deletions(-) diff --git a/apps/dm/AGENTS.md b/apps/dm/AGENTS.md index 7eefb1676..ef14f5097 100644 --- a/apps/dm/AGENTS.md +++ b/apps/dm/AGENTS.md @@ -1,6 +1,19 @@ ## Project This repository contains a Next.js web application. +## Portable paths + +Resolve paths from this `apps/dm/AGENTS.md` file, not from a developer-specific drive or the current shell directory. + +- `FE_APP_ROOT`: directory containing this file (`apps/dm`) +- `FE_REPO_ROOT`: `FE_APP_ROOT/../..` (`frontend`) +- `WORKSPACE_ROOT`: `FE_REPO_ROOT/..` +- `COMMON_ROOT_AGENTS`: `WORKSPACE_ROOT/AGENTS.md` +- `BE_REPO_ROOT`: `WORKSPACE_ROOT/IIPBS` (Backend repository root) +- `BE_SERVICE_ROOT`: `BE_REPO_ROOT/sewerage-service` + +Use an explicitly supplied repository path when the repositories are installed under different parent directories. Verify a calculated path exists before using it. + Main technologies: - Next.js - TypeScript diff --git a/apps/fo/AGENTS.md b/apps/fo/AGENTS.md index 7eefb1676..14b50f71b 100644 --- a/apps/fo/AGENTS.md +++ b/apps/fo/AGENTS.md @@ -1,6 +1,19 @@ ## Project This repository contains a Next.js web application. +## Portable paths + +Resolve paths from this `apps/fo/AGENTS.md` file, not from a developer-specific drive or the current shell directory. + +- `FE_APP_ROOT`: directory containing this file (`apps/fo`) +- `FE_REPO_ROOT`: `FE_APP_ROOT/../..` (`frontend`) +- `WORKSPACE_ROOT`: `FE_REPO_ROOT/..` +- `COMMON_ROOT_AGENTS`: `WORKSPACE_ROOT/AGENTS.md` +- `BE_REPO_ROOT`: `WORKSPACE_ROOT/IIPBS` (Backend repository root) +- `BE_SERVICE_ROOT`: `BE_REPO_ROOT/sewerage-service` + +Use an explicitly supplied repository path when the repositories are installed under different parent directories. Verify a calculated path exists before using it. + Main technologies: - Next.js - TypeScript diff --git a/apps/sn/AGENTS.md b/apps/sn/AGENTS.md index f8b84ffa9..76b511b8f 100644 --- a/apps/sn/AGENTS.md +++ b/apps/sn/AGENTS.md @@ -43,14 +43,46 @@ ## 공통 시작 기준 -Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작 기준은 `AGENTS.md`를 따른다. +개발자마다 드라이브와 상위 작업 폴더 위치가 다르므로 특정 드라이브·사용자 디렉터리의 절대경로를 기준으로 사용하지 않는다. + +경로 별칭은 `apps/sn/AGENTS.md`가 있는 디렉터리(`FE_APP_ROOT`)를 기준으로 다음과 같이 해석한다. 상대경로는 현재 셸의 실행 위치가 아니라 이 파일의 위치를 기준으로 계산한다. + +- `FE_APP_ROOT`: 이 파일이 있는 `apps/sn` 디렉터리 +- `FE_REPO_ROOT`: `FE_APP_ROOT/../..` +- `WORKSPACE_ROOT`: `FE_APP_ROOT/../../..` +- `COMMON_ROOT_AGENTS`: `WORKSPACE_ROOT/AGENTS.md` +- `BE_REPO_ROOT`: `WORKSPACE_ROOT/IIPBS` (Backend Git 저장소 루트) +- `BE_SERVICE_ROOT`: `BE_REPO_ROOT/sewerage-service` (하수관로 백엔드 서비스 모듈) +- `COMMON_DOCS_ROOT`: `BE_REPO_ROOT/docs` +- `FE_APP_DOCS_ROOT`: `FE_APP_ROOT/docs` + +경로 결정은 아래 우선순위를 따른다. + +1. 작업 환경이나 사용자가 `WORKSPACE_ROOT`, `COMMON_DOCS_ROOT`, `BE_REPO_ROOT`, `BE_SERVICE_ROOT` 등의 유효한 경로를 명시한 경우 그 값을 우선한다. 저장소를 서로 다른 상위 폴더에 배치한 개발자도 이 방식으로 사용할 수 있다. +2. 명시값이 없으면 위의 `FE_APP_ROOT` 기준 상대경로로 계산한다. 드라이브 문자나 사용자별 상위 디렉터리 이름이 달라도 같은 저장소 배치 구조를 사용할 수 있다. +3. 계산한 경로는 사용 전에 대상 파일이나 디렉터리의 존재 여부를 확인한다. 없거나 후보가 여러 개인 경우 특정 절대경로를 추측하지 말고 현재 저장소에서 확인 가능한 작업만 진행한 뒤 필요한 경로를 사용자에게 확인한다. +4. 작업 문서와 결과 보고에는 특별한 사유가 없으면 개발자별 절대경로 대신 위 경로 별칭이나 저장소 기준 상대경로를 기록한다. + +PowerShell에서 현재 프론트엔드 저장소를 기준으로 확인할 때는 다음처럼 동적으로 계산할 수 있다. 각 단계는 앞 단계에서 찾은 저장소 위치를 기반으로 하므로 개발자별 설치 경로에 의존하지 않는다. + +```powershell +$feRepoRoot = (git rev-parse --show-toplevel).Trim() +$feAppRoot = Join-Path $feRepoRoot 'apps/sn' +$workspaceRoot = Split-Path $feRepoRoot -Parent +$commonRootAgents = Join-Path $workspaceRoot 'AGENTS.md' +$beRepoRoot = Join-Path $workspaceRoot 'IIPBS' +$beServiceRoot = Join-Path $beRepoRoot 'sewerage-service' +$commonDocsRoot = Join-Path $beRepoRoot 'docs' +``` + +공통 시작 기준은 `COMMON_ROOT_AGENTS`를 따른다. 이 파일은 프론트엔드 `sn` 앱 세부 기준이다. 백엔드(`sewerage-service`)와 함께 수정하거나 API/응답/DB/인증/권한 연동 영향이 있는 작업은 아래 파일을 함께 확인한다. - 공통 시작 기준: `AGENTS.md` -- 백엔드 세부 기준: `BE_REPO_ROOT/AGENTS.md` +- 백엔드 세부 기준: `BE_SERVICE_ROOT/AGENTS.md` - 프론트엔드 `sn` 앱 기준: `FE_APP_ROOT/AGENTS.md` --- @@ -230,7 +262,7 @@ VSCode나 별도 터미널에서 `sn` Next 개발 서버가 실행 중이면 `ne ### 프론트엔드 실행 프로세스 관리 -- 모든 세션·역할·문서/검증 섹션에 공통 D:/workspace/AGENTS.md의 테스트 파일 정리 기준을 적용한다. 에이전트가 테스트를 위해 만든 파일은 정식 회귀 테스트·재사용 가능한 소스·fixture·mock·임시 라우트를 포함해 위치와 관계없이 결과를 요약한 뒤 작업 종료 전에 삭제한다(2026-09-23 사용자 지시). 전용 빈 폴더와 끊어진 실행 명령도 정리한다. 명시 보존 요청 파일·최종 산출물·사용자 원본/복구 백업은 유지한다. 기존 타 작업자 테스트 자산은 삭제 권한을 먼저 확인한다. 사용자 dev server의 .next·공유 캐시·node_modules는 포괄 삭제하지 않는다. +- 모든 세션·역할·문서/검증 섹션에 `COMMON_ROOT_AGENTS`의 테스트 파일 정리 기준을 적용한다. 에이전트가 테스트를 위해 만든 파일은 정식 회귀 테스트·재사용 가능한 소스·fixture·mock·임시 라우트를 포함해 위치와 관계없이 결과를 요약한 뒤 작업 종료 전에 삭제한다(2026-09-23 사용자 지시). 전용 빈 폴더와 끊어진 실행 명령도 정리한다. 명시 보존 요청 파일·최종 산출물·사용자 원본/복구 백업은 유지한다. 기존 타 작업자 테스트 자산은 삭제 권한을 먼저 확인한다. 사용자 dev server의 .next·공유 캐시·node_modules는 포괄 삭제하지 않는다. - Codex 에이전트가 작업 또는 검증을 위해 직접 기동한 `next dev`, `dev:sn`, Turborepo dev와 기타 프론트엔드 장시간 실행 프로세스는 작업 종료 전에 반드시 종료한다. - 셸, 백그라운드, 숨김 창 등 VSCode 밖에서 에이전트가 기동한 프로세스도 종료 대상이다. diff --git a/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md b/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md index 18497c7c6..3a0fd344d 100644 --- a/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md +++ b/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md @@ -20,9 +20,12 @@ - 팝업 하단에 확률관측소코드·관측소명·면적비율(%) 비율표를 표시한다. - 팝업 맨 아래에 `시간은 강우가 지속된 시간입니다` 안내를 표시한다. - 지속시간 헤더는 기존 팝업 표 헤더 스타일을 유지한다. -- 선택 행의 대표 지속시간에 해당하는 열의 확률강우량 기준값 셀들을 강조 색상으로 표시한다. 표 헤더는 기존 스타일을 유지한다. +- 선택 행의 대표 지속시간과 초과빈도년도가 교차하는 확률강우량 기준값 한 셀만 강조 색상으로 표시한다. 표 헤더는 기존 스타일을 유지한다. +- 팝업 표가 표시되면 선택한 지속시간 열이 보이도록 표 내부 가로 스크롤을 자동으로 맞춘다. +- 선택 행의 초과빈도년도에 해당하는 재현기간 행은 년도와 강수량 글씨를 굵게 표시한다. - 강수량은 mm 단위로 표시하고 로딩·빈 결과·오류 상태를 제공한다. - 기존 행 클릭의 도시침수지도 이동과 별도 액션으로 동작시킨다. +- 현황 목록의 분석시각·강우시작시각은 분까지 말줄임 없이 표시하며, 좁은 팝업에서는 표의 가로 스크롤을 사용한다. ## 검증 계획 @@ -37,5 +40,8 @@ - 현황 목록에 `확률강우량` 열을 추가하고 대표 지속시간 `predcRainHr`를 `N시간` 링크로 표시한다. - 링크 클릭은 행 선택 지도 이동을 실행하지 않으며, 시군구별 기준표 팝업을 연다. - 팝업은 재현기간 행×지속시간 열의 확률강우량, 하단 관측소 면적비율과 API 합계를 표시한다. -- 선택 지속시간 열의 값 셀만 강조하고 헤더는 기존 스타일을 유지한다. +- 선택 지속시간·초과빈도년도의 교차값 한 셀만 색으로 강조하고 헤더는 기존 스타일을 유지한다. +- 팝업 표의 선택 지속시간 열은 로딩 완료 후 표 내부 가로 스크롤 영역 중앙에 자동으로 나타난다. +- 선택 초과빈도년도의 재현기간 행은 년도와 강수량을 굵게 표시한다. +- 분석시각·강우시작시각 열은 고정 폭을 지정하지 않고 말줄임만 해제하여, 전체 시각을 표시하면서 주변 열과 함께 자동으로 너비를 계산한다. - `pnpm --filter sn run typecheck`, `git diff --check`가 통과했다. 3002 포트 개발 서버 실행 중이므로 build는 실행하지 않았다. diff --git a/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md b/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md index a7071cccf..d80976eb2 100644 --- a/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md +++ b/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md @@ -5,10 +5,13 @@ - 침수위험도 오른쪽에 확률강우량 열을 추가하고 대표 누적강우 지속시간 `predcRainHr`를 `N시간` 링크로 표시했다. - 링크는 `GET /api/v1/fldng/predc/prst/prbbt-rnfl?sggCd=...`를 호출한다. - 팝업 본문에 재현기간 행×지속시간 열의 확률강우량표를 표시한다. -- 선택 지속시간 열의 값 셀만 강조하고 표 헤더는 기존 스타일을 유지한다. +- 선택 지속시간·초과빈도년도의 교차값 한 셀만 색으로 강조하고 표 헤더는 기존 스타일을 유지한다. +- 팝업 표가 로딩되면 목록에서 선택한 지속시간 열이 보이도록 표 내부 가로 스크롤을 자동으로 맞춘다. +- 선택 행의 초과빈도년도에 해당하는 재현기간 행은 년도와 강수량을 굵게 표시한다. - 팝업 하단에 확률관측소코드·관측소명·면적비율 및 API 합계를 표시한다. - 팝업 맨 아래에 `시간은 강우가 지속된 시간입니다` 안내를 표시한다. - 링크 클릭 이벤트는 기존 행 선택의 지도 이동으로 전파하지 않는다. +- 침수예측현황 목록의 분석시각·강우시작시각은 말줄임을 적용하지 않고, 고정 열 폭 없이 표의 자동 너비 계산과 가로 스크롤로 분까지 표시한다. ## 검증 - `pnpm --filter sn run typecheck` 성공 diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/api/cty-fldng-prvtn-biz-api.ts b/apps/sn/src/features/ctyFldngPrvtnBiz/api/cty-fldng-prvtn-biz-api.ts index bdbd5d405..4e79e0b11 100644 --- a/apps/sn/src/features/ctyFldngPrvtnBiz/api/cty-fldng-prvtn-biz-api.ts +++ b/apps/sn/src/features/ctyFldngPrvtnBiz/api/cty-fldng-prvtn-biz-api.ts @@ -45,6 +45,12 @@ export const getAllCtyFldngPrvtnBusinesses = async ( return result.items; }; +/** 등록 화면에 자동으로 넣을 다음 사업순번을 조회한다. */ +export const getNextCtyFldngPrvtnSeqNo = async () => { + const body = await getData(`${API_PATH}/nextSeqNo`); + return Number(unwrapData(body)); +}; + /** 수정 화면의 사업 한 건을 조회한다. */ export const getCtyFldngPrvtnBusiness = (id: string) => getData(`${API_PATH}/${encodeURIComponent(id)}`) as Promise; diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage.tsx b/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage.tsx index f0507195b..26b2ab7e6 100644 --- a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage.tsx +++ b/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage.tsx @@ -22,6 +22,7 @@ import { createCtyFldngPrvtnBusiness, deleteCtyFldngPrvtnBusiness, getCtyFldngPrvtnBusiness, + getNextCtyFldngPrvtnSeqNo, updateCtyFldngPrvtnBusiness, } from "../api/cty-fldng-prvtn-biz-api"; import type { @@ -91,6 +92,35 @@ const DETAIL_FIELDS = [ ["rmrkSrcCn", "비고출처", "text"], ] as const; type BasicKey = (typeof BASIC_FIELDS)[number][0]; +/** 저장 전에 반드시 입력해야 하는 항목이다. 시도는 별도 선택이라 region 키로 관리한다. */ +const REQUIRED_BASIC_KEYS = new Set(["seqNo", "dsgnYmd"]); +type FieldErrorKey = BasicKey | "region" | `soa-${string}`; + +/** 금액·면적 입력의 DB 자릿수 규칙이다. intDigits 는 정수부, scale 은 소수부 한계다. */ +const NUMBER_RULES: Record = { + dsgnArea: { label: "지정면적", intDigits: 10, scale: 9 }, + tpco: { label: "총사업비", intDigits: 20, scale: 2 }, + gvexp: { label: "국비", intDigits: 20, scale: 2 }, + lcexp: { label: "지방비", intDigits: 20, scale: 2 }, +}; +const SETTLEMENT_RULE = { label: "결산금액", intDigits: 20, scale: 2 }; + +/** 숫자 입력이 비었는지, 숫자인지, 0 이상인지, 자릿수를 넘지 않는지 확인한다. */ +const numberError = ( + value: unknown, + rule: { label: string; intDigits: number; scale: number }, +) => { + const text = String(value ?? "").trim(); + if (!text) return undefined; + if (!/^\d*(\.\d+)?$/.test(text)) + return `${rule.label}은(는) 0 이상 숫자로 입력해주세요.`; + const [integerPart = "", decimalPart = ""] = text.split("."); + if (decimalPart.length > rule.scale) + return `${rule.label}은(는) 소수점 ${rule.scale}자리까지 입력할 수 있습니다.`; + if (integerPart.replace(/^0+/, "").length > rule.intDigits) + return `${rule.label}은(는) 정수 ${rule.intDigits}자리를 넘을 수 없습니다.`; + return undefined; +}; type DetailKey = (typeof DETAIL_FIELDS)[number][0]; /** 등록 화면의 빈 기본·결산·4단계 상세 값을 생성한다. */ @@ -294,6 +324,18 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) { const [notice, setNotice] = useState(""); const [ctpvCd, setCtpvCd] = useState("0"); const [sggCd, setSggCd] = useState("0"); + const [fieldErrors, setFieldErrors] = useState< + Partial> + >({}); + + /** 입력을 고치면 해당 항목의 오류 문구를 지운다. */ + const clearFieldError = (key: FieldErrorKey) => + setFieldErrors((current) => { + if (!current[key]) return current; + const next = { ...current }; + delete next[key]; + return next; + }); const listHref = `/oper/ctyFldngPrvtnBiz${searchParams.toString() ? `?${searchParams}` : ""}`; const availableSgg = useMemo( () => @@ -308,6 +350,13 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) { if (!ctpv.length) void fetchCtpv(); if (!sgg.length) void fetchSgg(); }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); + useEffect(() => { + if (id) return; + // 등록 화면은 다음 사업순번을 자동으로 채우고 사용자가 바꾸지 못하게 한다. + getNextCtyFldngPrvtnSeqNo() + .then((seqNo) => setForm((current) => ({ ...current, seqNo }))) + .catch(() => setNotice("다음 사업순번을 조회하지 못했습니다.")); + }, [id]); useEffect(() => { if (!id) return; setLoading(true); @@ -337,14 +386,42 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) { /** 필수값·날짜·숫자를 검사한 뒤 등록 또는 수정한다. */ const handleSave = async () => { - const dates = [ - form.dsgnYmd, - ...form.details.flatMap((d) => [d.bgngYmd, d.cmcnYmd]), - ]; + const nextErrors: Partial> = {}; + if (!String(form.ctpvNm ?? "").trim()) nextErrors.region = "시도는 필수입니다."; + if (form.seqNo === null || form.seqNo === undefined || String(form.seqNo) === "") + nextErrors.seqNo = "사업순번은 필수입니다."; + if (!String(form.dsgnYmd ?? "").trim()) + nextErrors.dsgnYmd = "지정일은 필수입니다."; + else if (!validDate(form.dsgnYmd)) + nextErrors.dsgnYmd = "YYYY-MM-DD 형식의 유효한 날짜로 입력해주세요."; + const requiredMissing = Object.keys(nextErrors).length > 0; + Object.entries(NUMBER_RULES).forEach(([key, rule]) => { + const message = numberError(form[key as BasicKey], rule); + if (message) nextErrors[key as BasicKey] = message; + }); + form.settlements.forEach((settlement) => { + const message = numberError(settlement.soaAmt, { + ...SETTLEMENT_RULE, + label: `${settlement.soaYr}년 결산금액`, + }); + if (message) nextErrors[`soa-${settlement.soaYr}`] = message; + }); + if (Object.keys(nextErrors).length > 0) { + setFieldErrors(nextErrors); + setNotice( + requiredMissing + ? "필수 항목을 입력해주세요." + : "입력값을 확인해주세요.", + ); + return; + } + const dates = form.details.flatMap((d) => [d.bgngYmd, d.cmcnYmd]); if (dates.some((value) => !validDate(value))) { setNotice("일자는 YYYY-MM-DD 형식의 유효한 날짜여야 합니다."); return; } + setFieldErrors({}); + setNotice(""); setLoading(true); try { if (id) await updateCtyFldngPrvtnBusiness(id, form); @@ -393,14 +470,16 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) { suggestionOptions.length > 0 ? `${fieldId}-suggestions` : undefined; const span: 1 | 2 | 3 = key === "excptnMttrCn" ? 3 : key === "bizNm" || key === "pstn" ? 2 : 1; - // 수정 화면에서는 중복 판정 업무키인 사업순번과 단계를 변경할 수 없다. - const isBusinessKeyLocked = Boolean(id) && (key === "seqNo" || key === "stp"); + // 사업순번은 등록 시 자동 번호를 보여주고, 수정 시에는 중복 판정 업무키라 바꿀 수 없다. + const isBusinessKeyLocked = + key === "seqNo" || (Boolean(id) && key === "stp"); return ( <> @@ -408,19 +487,21 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) { id={fieldId} className="w-full !text-[1.3rem]" maxLength={standardMaxLengths[key]} + min={NUMBER_RULES[key] ? 0 : undefined} type={type === "date" ? "date" : type} list={suggestionListId} disabled={isBusinessKeyLocked} value={String(form[key] ?? "")} - onChange={(event) => + onChange={(event) => { + clearFieldError(key); setForm((current) => ({ ...current, [key]: type === "date" ? formatDateInput(event.target.value) : inputValue(event.target.value, type), - })) - } + })); + }} /> {suggestionListId ? ( @@ -455,11 +536,17 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) { )} - + + onChange={(event) => { + clearFieldError(`soa-${year}`); setForm((current) => ({ ...current, settlements: [ @@ -554,8 +644,8 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) { : Number(event.target.value), }, ], - })) - } + })); + }} /> ); diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx b/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx index d86dfbb34..2897621ec 100644 --- a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx +++ b/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx @@ -157,21 +157,11 @@ type Search = { }; /** 브라우저 로컬 날짜를 검색 input이 사용하는 YYYY-MM-DD 형식으로 변환한다. */ -const formatLocalDateInputValue = (date: Date) => { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; -}; - -/** 지정일 기본 검색기간을 최초 사업자료 기준일인 2013-01-01부터 오늘까지로 생성한다. */ -const createDefaultDesignationDateRange = () => { - const to = new Date(); - return { - dsgnYmdFrom: "2013-01-01", - dsgnYmdTo: formatLocalDateInputValue(to), - }; -}; +/** 지정일 검색조건은 처음부터 비워 둔다. 지정일이 없는 사업도 목록에서 보이도록 하기 위함이다. */ +const createDefaultDesignationDateRange = () => ({ + dsgnYmdFrom: "", + dsgnYmdTo: "", +}); type TableRow = Record & { ctyFldngPrvtnBizSn: number; diff --git a/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx b/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx index e13e98b00..306259ac1 100644 --- a/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx +++ b/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx @@ -20,6 +20,9 @@ import useStore from "@/store/useStore"; import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; import { downloadFloodPredictionStatusExcel } from "../lib/fldng-predc-prst-excel"; import { getFloodRiskGradeClassName } from "../lib/flood-risk-grade"; +import PrbbtRnflDialog, { + type PrbbtRnflResponse, +} from "./PrbbtRnflDialog"; type ApiRow = { sggCd: string; @@ -30,6 +33,7 @@ type ApiRow = { analysisDateTime: string; observedAccumulatedRainfall: number | null; predictionHour: number | null; + predcRainHr: number | null; predictedAccumulatedRainfall: number | null; exceededFrequencyYear: number | null; floodRiskGrade: string; @@ -44,6 +48,7 @@ type TableRow = ApiRow & predictionHourDisplay: string; predictedAccumulatedRainfallDisplay: string; exceededFrequencyYearDisplay: string; + probabilityRainfallDisplay: string; }; type Filters = { @@ -109,6 +114,13 @@ const HEADS: UiHeadProps[] = [ align: "right", }, { id: "floodRiskGrade", title: "침수위험도", widthPercent: 10 }, + { id: "probabilityRainfallDisplay", title: "확률강우량", widthPercent: 10 }, +]; + +/** 날짜·시각의 뒷부분이 잘리지 않도록 전체 표시할 목록 열이다. */ +const FULL_TEXT_TIME_HEAD_IDS = [ + "analysisDateTimeDisplay", + "rainBeginningDateTimeDisplay", ]; /** 최초·초기화 시 시각 범위를 제한하지 않고 최신 침수예측 데이터부터 조회한다. */ @@ -135,6 +147,12 @@ const predictionHourText = (value: number | null) => { return hour > 0 ? `${hour}시간 후` : "-"; }; +/** 분석 결과의 대표 누적강우시간을 확률강우량 기준표 링크 문구로 표시한다. */ +const probabilityDurationText = (value: number | null) => { + const hour = Number(value); + return Number.isInteger(hour) && hour > 0 ? `${hour}시간` : "-"; +}; + const formatNumber = (value: number | null, digits: number) => value == null || !Number.isFinite(Number(value)) ? "-" @@ -202,6 +220,14 @@ export default function FldngPredcPrstPopupContent() { const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); const [selectedRowId, setSelectedRowId] = useState(""); + // 확률강우량 기준표는 목록의 선택 행과 별도로 열고 닫는다. + const [probabilityRow, setProbabilityRow] = useState(null); + const [probabilityData, setProbabilityData] = + useState(null); + const [probabilityLoading, setProbabilityLoading] = useState(false); + const [probabilityError, setProbabilityError] = useState(null); + // 연속 클릭 또는 닫기 이후 도착한 이전 응답이 현재 팝업을 덮지 않도록 요청 순서를 보관한다. + const probabilityRequestIdRef = useRef(0); // 개발 StrictMode에서도 최초 목록 API를 한 번만 호출하기 위한 ref다. const didInitialLoadRef = useRef(false); @@ -268,6 +294,9 @@ export default function FldngPredcPrstPopupContent() { item.exceededFrequencyYear == null ? "-" : String(item.exceededFrequencyYear), + probabilityRainfallDisplay: probabilityDurationText( + item.predcRainHr, + ), })), ); setTotal(Number(payload?.totalElements ?? 0)); @@ -373,6 +402,43 @@ export default function FldngPredcPrstPopupContent() { } }; + /** 선택 시군구의 확률강우량과 관측소 비율을 조회한다. 지도 행 클릭은 버튼에서 차단한다. */ + const handleProbabilityClick = async (row: TableRow) => { + if (!row.sggCd || !Number.isInteger(Number(row.predcRainHr)) || Number(row.predcRainHr) <= 0) { + return; + } + const requestId = ++probabilityRequestIdRef.current; + setProbabilityRow(row); + setProbabilityData(null); + setProbabilityError(null); + setProbabilityLoading(true); + try { + const params = new URLSearchParams({ sggCd: row.sggCd }); + const response = await getRawJson( + `/sewerage-service/api/v1/fldng/predc/prst/prbbt-rnfl?${params.toString()}`, + ); + if (requestId !== probabilityRequestIdRef.current) return; + const payload: PrbbtRnflResponse = response?.data ?? response; + setProbabilityData(payload); + } catch (error) { + if (requestId !== probabilityRequestIdRef.current) return; + console.error("시군구 확률강우량 조회 중 오류:", error); + setProbabilityError("확률강우량 기준표를 조회하지 못했습니다."); + } finally { + if (requestId === probabilityRequestIdRef.current) { + setProbabilityLoading(false); + } + } + }; + + /** 닫힌 팝업으로 늦게 도착하는 API 응답을 무시한다. */ + const handleProbabilityOpenChange = (open: boolean) => { + if (open) return; + probabilityRequestIdRef.current += 1; + setProbabilityRow(null); + setProbabilityLoading(false); + }; + const handleDownload = async () => { if (!total) { alert("다운로드할 데이터가 없습니다."); @@ -562,10 +628,27 @@ export default function FldngPredcPrstPopupContent() { > {row.floodRiskGrade || "-"} + ) : headId === "probabilityRainfallDisplay" ? ( + row.probabilityRainfallDisplay === "-" ? ( + "-" + ) : ( + + ) ) : undefined } scrollMode="horizontal" - minTableWidth="100rem" + minTableWidth="112rem" + fullTextHeadIds={FULL_TEXT_TIME_HEAD_IDS} fillAvailableHeight={rows.length === 0} emptyMessage="조회 조건에 해당하는 침수예측현황이 없습니다." actions={ @@ -591,6 +674,16 @@ export default function FldngPredcPrstPopupContent() {

행을 선택하면 해당 시군구·빈도의 도시침수지도로 이동합니다.

+
); diff --git a/apps/sn/src/features/fldngPredcPrst/ui/PrbbtRnflDialog.tsx b/apps/sn/src/features/fldngPredcPrst/ui/PrbbtRnflDialog.tsx index c870d5ab2..02275efd2 100644 --- a/apps/sn/src/features/fldngPredcPrst/ui/PrbbtRnflDialog.tsx +++ b/apps/sn/src/features/fldngPredcPrst/ui/PrbbtRnflDialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useLayoutEffect, useMemo, useRef } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@common/components"; @@ -31,6 +31,7 @@ type PrbbtRnflDialogProps = { onOpenChange: (open: boolean) => void; sggName: string; selectedDurationHour: number | null; + selectedFrequencyYear: number | null; data: PrbbtRnflResponse | null; loading: boolean; error: string | null; @@ -42,16 +43,22 @@ const formatRainfall = (value: number) => const formatAreaRatio = (value: number) => Number.isFinite(Number(value)) ? Number(value).toFixed(3) : "-"; -/** 재현기간을 행, 강우지속시간을 열로 구성하고 목록에서 선택한 지속시간의 기준값을 강조한다. */ +/** 재현기간을 행, 강우지속시간을 열로 구성하고 선택한 두 기준이 교차하는 값만 색으로 강조한다. */ export default function PrbbtRnflDialog({ open, onOpenChange, sggName, selectedDurationHour, + selectedFrequencyYear, data, loading, error, }: PrbbtRnflDialogProps) { + /** 선택한 지속시간 열을 모달 안에서 찾기 위한 표 스크롤 영역이다. */ + const tableScrollRef = useRef(null); + /** 목록에서 클릭한 지속시간에 대응하는 표 헤더이다. */ + const selectedDurationHeaderRef = useRef(null); + const { frequencies, durations, rainfallByKey } = useMemo(() => { const rainfall = data?.rainfall ?? []; return { @@ -70,6 +77,22 @@ export default function PrbbtRnflDialog({ }; }, [data]); + /** 표가 표시되면 선택 지속시간 열을 가로 스크롤 영역의 가운데로 맞춘다. */ + useLayoutEffect(() => { + if (!open || loading || error || selectedDurationHour == null) return; + + const scrollArea = tableScrollRef.current; + const selectedHeader = selectedDurationHeaderRef.current; + if (!scrollArea || !selectedHeader) return; + + const scrollAreaRect = scrollArea.getBoundingClientRect(); + const selectedHeaderRect = selectedHeader.getBoundingClientRect(); + scrollArea.scrollLeft += + selectedHeaderRect.left - + scrollAreaRect.left - + (scrollArea.clientWidth - selectedHeaderRect.width) / 2; + }, [open, loading, error, selectedDurationHour, durations]); + return ( ) : ( <> -
+
{frequencies.map((frequencyYear) => ( - + @@ -135,8 +164,10 @@ export default function PrbbtRnflDialog({
{sggName} 재현기간 및 강우지속시간별 확률강우량(mm) @@ -110,6 +133,7 @@ export default function PrbbtRnflDialog({ {durations.map((durationHour) => (
@@ -120,10 +144,15 @@ export default function PrbbtRnflDialog({
{frequencyYear} diff --git a/apps/sn/src/features/windowPopup/ui/MapPopupDataTable.tsx b/apps/sn/src/features/windowPopup/ui/MapPopupDataTable.tsx index b96b19fab..e371641ea 100644 --- a/apps/sn/src/features/windowPopup/ui/MapPopupDataTable.tsx +++ b/apps/sn/src/features/windowPopup/ui/MapPopupDataTable.tsx @@ -43,6 +43,7 @@ type MapPopupDataTableProps> = { headId: string, rowIndex: number, ) => ReactNode | undefined; + fullTextHeadIds?: string[]; /** 시각처럼 뒤쪽이 잘리면 의미가 달라지는 열은 고정 너비 없이 말줄임만 해제한다. */ sort?: UiTableSort; onSortChange?: (sort: UiTableSort | undefined) => void; }; @@ -77,6 +78,7 @@ export default function MapPopupDataTable>({ onCellClick, clickableHeadIds, renderCell, + fullTextHeadIds, sort, onSortChange, }: MapPopupDataTableProps) { @@ -143,7 +145,15 @@ export default function MapPopupDataTable>({ ? head.align : "center", width: head.widthPercent, - }, + ...(fullTextHeadIds?.includes(head.id) + ? { + "data-sn-cell-wrap": "", + style: { + whiteSpace: "nowrap", + }, + } + : {}), + } as ColumnDef["meta"], })), [ clickableHeadIds, @@ -151,6 +161,7 @@ export default function MapPopupDataTable>({ page, pageSize, renderCell, + fullTextHeadIds, sort, total, visibleHeads, diff --git a/apps/wr/AGENTS.md b/apps/wr/AGENTS.md index 3f05ba328..001e98fa1 100644 --- a/apps/wr/AGENTS.md +++ b/apps/wr/AGENTS.md @@ -1,6 +1,20 @@ ## Project This repository contains a Next.js web application. +## Portable paths + +Resolve paths from this `apps/wr/AGENTS.md` file, not from a developer-specific drive or the current shell directory. + +- `FE_APP_ROOT`: directory containing this file (`apps/wr`) +- `FE_REPO_ROOT`: `FE_APP_ROOT/../..` (`frontend`) +- `WORKSPACE_ROOT`: `FE_REPO_ROOT/..` +- `COMMON_ROOT_AGENTS`: `WORKSPACE_ROOT/AGENTS.md` +- `BE_REPO_ROOT`: `WORKSPACE_ROOT/IIPBS` (Backend repository root) +- `BE_SERVICE_ROOT`: `BE_REPO_ROOT/sewerage-service` +- `WR_BACKEND_ROOT`: `WORKSPACE_ROOT/wr_back_out` when that checkout exists; otherwise use a path explicitly supplied by the developer. + +Verify a calculated path exists before using it. If the repositories are installed under different parent directories, use the explicitly supplied path. + Main technologies: - Next.js - TypeScript @@ -41,4 +55,4 @@ apps/ wr ## local backend src 경로 -D:\iipbs\wr_back_out - http://localhost:8883/reuse-service +`WR_BACKEND_ROOT` - http://localhost:8883/reuse-service From f602b75268f9d2652230af25a0702fa2f9406b35 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Tue, 6 Oct 2026 13:06:01 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EB=B3=84=20?= =?UTF-8?q?=EB=8F=84=EC=9B=80=EB=A7=90=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EB=A9=94=EB=89=B4=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/src/shared/ui/page-layout.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sn/src/shared/ui/page-layout.tsx b/apps/sn/src/shared/ui/page-layout.tsx index 358d3b5db..6b67a3c70 100644 --- a/apps/sn/src/shared/ui/page-layout.tsx +++ b/apps/sn/src/shared/ui/page-layout.tsx @@ -12,6 +12,7 @@ function PageLayout({ children }: { children: React.ReactNode }) {
{children} From 0c77ed9bbab19f8a00f45cb5a307570fa42703eb Mon Sep 17 00:00:00 2001 From: ljpark Date: Tue, 6 Oct 2026 13:15:32 +0900 Subject: [PATCH 6/7] =?UTF-8?q?sn:=20=EB=8F=84=EC=8B=9C=EC=B9=A8=EC=88=98?= =?UTF-8?q?=EC=98=88=EB=B0=A9=EC=82=AC=EC=97=85=20=EC=82=AC=EC=97=85?= =?UTF-8?q?=EB=AA=85=20=ED=95=84=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ce-SFR-011-008-침수예측현황-확률강우량-작업계획.md | 4 ++-- ...fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md | 4 ++-- .../ui/cty-fldng-prvtn-biz-form-page.tsx | 5 +++-- .../ui/fldng-predc-prst-popup-content.tsx | 20 +++++++++---------- .../window-popup/ui/map-popup-data-table.tsx | 20 +++++++++++-------- 5 files changed, 29 insertions(+), 24 deletions(-) diff --git a/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md b/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md index 3a0fd344d..99f8a0097 100644 --- a/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md +++ b/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md @@ -43,5 +43,5 @@ - 선택 지속시간·초과빈도년도의 교차값 한 셀만 색으로 강조하고 헤더는 기존 스타일을 유지한다. - 팝업 표의 선택 지속시간 열은 로딩 완료 후 표 내부 가로 스크롤 영역 중앙에 자동으로 나타난다. - 선택 초과빈도년도의 재현기간 행은 년도와 강수량을 굵게 표시한다. -- 분석시각·강우시작시각 열은 고정 폭을 지정하지 않고 말줄임만 해제하여, 전체 시각을 표시하면서 주변 열과 함께 자동으로 너비를 계산한다. -- `pnpm --filter sn run typecheck`, `git diff --check`가 통과했다. 3002 포트 개발 서버 실행 중이므로 build는 실행하지 않았다. +- 현황 목록은 자동 표 레이아웃을 유지하고 `widthPercent` 합계를 100%로 맞춘다. 분석시각·강우시작시각은 각각 13%를 적용해 분까지 표시하며, 최소 표 너비 112rem과 가로 스크롤을 유지한다. +- `pnpm --filter sn run typecheck`, `git diff --check`가 통과했다. 빌드 중 3002 포트 개발 서버가 확인되어 에이전트가 시작한 빌드만 중단했으며, 빌드 완료와 실화면 검증은 미확인이다. diff --git a/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md b/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md index d80976eb2..302591ae5 100644 --- a/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md +++ b/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md @@ -11,12 +11,12 @@ - 팝업 하단에 확률관측소코드·관측소명·면적비율 및 API 합계를 표시한다. - 팝업 맨 아래에 `시간은 강우가 지속된 시간입니다` 안내를 표시한다. - 링크 클릭 이벤트는 기존 행 선택의 지도 이동으로 전파하지 않는다. -- 침수예측현황 목록의 분석시각·강우시작시각은 말줄임을 적용하지 않고, 고정 열 폭 없이 표의 자동 너비 계산과 가로 스크롤로 분까지 표시한다. +- 침수예측현황 목록은 자동 표 레이아웃에서 `widthPercent` 합계 100%를 CSS 너비로 전달한다. 분석시각·강우시작시각은 각각 13%를 적용해 말줄임 없이 분까지 표시하고, 최소 표 너비 112rem과 가로 스크롤을 유지한다. ## 검증 - `pnpm --filter sn run typecheck` 성공 - `git diff --check` 성공 -- 개발 서버(3002)가 실행 중이어서 build는 실행하지 않았다. +- 빌드 중 개발 서버(3002)가 확인되어 에이전트가 시작한 빌드만 중단했다. 빌드 완료와 실화면 검증은 미확인이다. ## 수동 확인 필요 - 실제 로그인 화면에서 팝업 크기·가로 스크롤·강조 색상 및 지도 이동 회귀 확인 diff --git a/apps/sn/src/features/cty-fldng-prvtn-biz/ui/cty-fldng-prvtn-biz-form-page.tsx b/apps/sn/src/features/cty-fldng-prvtn-biz/ui/cty-fldng-prvtn-biz-form-page.tsx index b08af294f..63f39509f 100644 --- a/apps/sn/src/features/cty-fldng-prvtn-biz/ui/cty-fldng-prvtn-biz-form-page.tsx +++ b/apps/sn/src/features/cty-fldng-prvtn-biz/ui/cty-fldng-prvtn-biz-form-page.tsx @@ -93,7 +93,7 @@ const DETAIL_FIELDS = [ ] as const; type BasicKey = (typeof BASIC_FIELDS)[number][0]; /** 저장 전에 반드시 입력해야 하는 항목이다. 시도는 별도 선택이라 region 키로 관리한다. */ -const REQUIRED_BASIC_KEYS = new Set(["seqNo", "dsgnYmd"]); +const REQUIRED_BASIC_KEYS = new Set(["seqNo", "dsgnYmd", "bizNm"]); type FieldErrorKey = BasicKey | "region" | `soa-${string}`; /** 금액·면적 입력의 DB 자릿수 규칙이다. intDigits 는 정수부, scale 은 소수부 한계다. */ @@ -390,6 +390,7 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) { if (!String(form.ctpvNm ?? "").trim()) nextErrors.region = "시도는 필수입니다."; if (form.seqNo === null || form.seqNo === undefined || String(form.seqNo) === "") nextErrors.seqNo = "사업순번은 필수입니다."; + if (!String(form.bizNm ?? "").trim()) nextErrors.bizNm = "사업명은 필수입니다."; if (!String(form.dsgnYmd ?? "").trim()) nextErrors.dsgnYmd = "지정일은 필수입니다."; else if (!validDate(form.dsgnYmd)) @@ -478,7 +479,7 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) { key={key} id={fieldId} label={label} - required={key === "bizNm" || REQUIRED_BASIC_KEYS.has(key)} + required={REQUIRED_BASIC_KEYS.has(key)} error={fieldErrors[key]} span={span} > diff --git a/apps/sn/src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx b/apps/sn/src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx index b2650cc21..e5d6b98db 100644 --- a/apps/sn/src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx +++ b/apps/sn/src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx @@ -76,9 +76,9 @@ const toFloodMapFrequencyCode = (value: number) => { }; const HEADS: UiHeadProps[] = [ - { id: "rowNo", title: "순번", widthPercent: 6 }, - { id: "ctpvNm", title: "시도", widthPercent: 8 }, - { id: "sggNm", title: "시군구", widthPercent: 9 }, + { id: "rowNo", title: "순번", widthPercent: 5 }, + { id: "ctpvNm", title: "시도", widthPercent: 7 }, + { id: "sggNm", title: "시군구", widthPercent: 8 }, { id: "analysisDateTimeDisplay", title: "분석시각", @@ -87,33 +87,33 @@ const HEADS: UiHeadProps[] = [ { id: "rainBeginningDateTimeDisplay", title: "강우시작시각", - widthPercent: 16, + widthPercent: 13, }, { id: "observedAccumulatedRainfallDisplay", title: "관측 누적강수량(mm)", - widthPercent: 13, + widthPercent: 11, align: "right", }, { id: "predictionHourDisplay", title: "예측시간", - widthPercent: 9, + widthPercent: 7, }, { id: "predictedAccumulatedRainfallDisplay", title: "예측 누적강수량(mm)", - widthPercent: 15, + widthPercent: 12, align: "right", }, { id: "exceededFrequencyYearDisplay", title: "초과 빈도(년)", - widthPercent: 10, + widthPercent: 8, align: "right", }, - { id: "floodRiskGrade", title: "침수위험도", widthPercent: 10 }, - { id: "probabilityRainfallDisplay", title: "확률강우량", widthPercent: 10 }, + { id: "floodRiskGrade", title: "침수위험도", widthPercent: 8 }, + { id: "probabilityRainfallDisplay", title: "확률강우량", widthPercent: 8 }, ]; /** 날짜·시각의 뒷부분이 잘리지 않도록 전체 표시할 목록 열이다. */ diff --git a/apps/sn/src/features/window-popup/ui/map-popup-data-table.tsx b/apps/sn/src/features/window-popup/ui/map-popup-data-table.tsx index e371641ea..019adbaca 100644 --- a/apps/sn/src/features/window-popup/ui/map-popup-data-table.tsx +++ b/apps/sn/src/features/window-popup/ui/map-popup-data-table.tsx @@ -43,7 +43,8 @@ type MapPopupDataTableProps> = { headId: string, rowIndex: number, ) => ReactNode | undefined; - fullTextHeadIds?: string[]; /** 시각처럼 뒤쪽이 잘리면 의미가 달라지는 열은 고정 너비 없이 말줄임만 해제한다. */ + /** 시각처럼 뒤쪽이 잘리면 의미가 달라지는 열의 말줄임을 해제한다. */ + fullTextHeadIds?: string[]; sort?: UiTableSort; onSortChange?: (sort: UiTableSort | undefined) => void; }; @@ -144,15 +145,18 @@ export default function MapPopupDataTable>({ head.align === "left" || head.align === "right" ? head.align : "center", - width: head.widthPercent, ...(fullTextHeadIds?.includes(head.id) - ? { - "data-sn-cell-wrap": "", - style: { - whiteSpace: "nowrap", - }, - } + ? { "data-sn-cell-wrap": "" } : {}), + style: { + width: + head.widthPercent != null + ? `${head.widthPercent}%` + : undefined, + ...(fullTextHeadIds?.includes(head.id) + ? { whiteSpace: "nowrap" } + : {}), + }, } as ColumnDef["meta"], })), [ From 5e43d69d8e8030d444ad6ee09708c50ee4ea3222 Mon Sep 17 00:00:00 2001 From: ljpark Date: Wed, 7 Oct 2026 15:05:40 +0900 Subject: [PATCH 7/7] =?UTF-8?q?sn:=20null=20=EC=9D=B8=EA=B2=BD=EC=9A=B0,?= =?UTF-8?q?=20=EA=B3=B5=EB=B0=B1=EC=9C=BC=EB=A1=9C=20=EB=B3=B4=EC=9D=B4?= =?UTF-8?q?=EB=8F=84=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/ui/vertical-table.tsx | 21 ++++++-------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/apps/sn/app/components/ui/vertical-table.tsx b/apps/sn/app/components/ui/vertical-table.tsx index f2685d98c..1f3615a55 100644 --- a/apps/sn/app/components/ui/vertical-table.tsx +++ b/apps/sn/app/components/ui/vertical-table.tsx @@ -198,11 +198,8 @@ export function VerticalTable({ head.type === "select" ? ( handleInputChange(head.id, e.target.value) } @@ -357,11 +351,8 @@ export function VerticalTable({ ? "number" : "text" } - value={ - formData[head.id] !== undefined - ? String(formData[head.id]) - : "" - } + // DB 값이 null 이면 "null" 글자가 입력칸에 보이므로 빈칸으로 맞춘다. + value={String(formData[head.id] ?? "")} onChange={(e) => { if (head.isNumber) { const newValue = e.target.value;