diff --git a/apps/sn/app/(map-layout)/map/components/exmn/exmn-layer-setting-panel.tsx b/apps/sn/app/(map-layout)/map/components/exmn/exmn-layer-setting-panel.tsx index d3581a30e..f02827016 100644 --- a/apps/sn/app/(map-layout)/map/components/exmn/exmn-layer-setting-panel.tsx +++ b/apps/sn/app/(map-layout)/map/components/exmn/exmn-layer-setting-panel.tsx @@ -14,6 +14,7 @@ type Props = { enabled?: boolean; onEnabledChange?: (enabled: boolean) => void; onClose?: () => void; + statusMessage?: string; }; const GRADE_OPTIONS = ["", "1", "2", "3", "4", "5"]; @@ -26,6 +27,7 @@ export default function ExmnLayerSettingPanel({ enabled = true, onEnabledChange = () => {}, onClose = () => {}, + statusMessage = "", }: Props) { const updateFilter = (key: keyof ExmnMapFilters, value: string) => { onFiltersChange({ @@ -46,6 +48,7 @@ export default function ExmnLayerSettingPanel({ onClose={onClose} >
+ {statusMessage ?

{statusMessage}

: null}
+ {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + } /> - {errorMessage ? ( -
- {errorMessage} -
- ) : null} ); diff --git a/apps/sn/app/(map-layout)/map/lib/use-exmn-result-layers.ts b/apps/sn/app/(map-layout)/map/lib/use-exmn-result-layers.ts deleted file mode 100644 index 84dfd0dc9..000000000 --- a/apps/sn/app/(map-layout)/map/lib/use-exmn-result-layers.ts +++ /dev/null @@ -1,257 +0,0 @@ -"use client"; -import type { ExmnMapEntryContext } from "../components/exmn/exmn-map-types"; -import type * as React from "react"; -import { useCallback, useEffect, useRef } from "react"; -import GeoJSON from "ol/format/GeoJSON.js"; -import VectorLayer from "ol/layer/Vector.js"; -import VectorSource from "ol/source/Vector.js"; -import GisApp from "@common/business_components/ui/map/lib/gis.core"; -import { getExmnMapLayer } from "../components/exmn/exmn-map-api"; -import { - EMPTY_EXMN_MAP_FILTERS, - EXMN_RESULT_MNHL_LAYER_ID, - EXMN_RESULT_PPL_LAYER_ID, - type ExmnMapFilters, - type ExmnMapJobType, -} from "../components/exmn/exmn-map-types"; -import { - createExmnPipeStyle, - createExmnManholeStyle, -} from "./analysis-layer-styles"; - -/** 관로·맨홀 조사 결과 도형 조회와 지도 레이어 수명을 소유한다. */ -type Options = { - gisMaps: React.RefObject; - isExmnPplChecked: boolean; - isExmnMnhlChecked: boolean; - mapReady: boolean; - exmnMapRegionCodes: { ctpvCd: string; sggCd: string }; - exmnMapEntryContext: ExmnMapEntryContext; - isExmnPplSettingEnabled: boolean; - exmnPplFilters: ExmnMapFilters; - isExmnMnhlSettingEnabled: boolean; - exmnMnhlFilters: ExmnMapFilters; -}; - -/** 관로·맨홀 조사 결과 도형 조회와 지도 레이어 수명을 소유한다. */ -export function useExmnResultLayers({ - gisMaps, - isExmnPplChecked, - isExmnMnhlChecked, - mapReady, - exmnMapRegionCodes, - exmnMapEntryContext, - isExmnPplSettingEnabled, - exmnPplFilters, - isExmnMnhlSettingEnabled, - exmnMnhlFilters, -}: Options) { - const exmnPplLayerRef = useRef | null>(null); - - const exmnPplSourceRef = useRef(null); - - const exmnMnhlLayerRef = useRef | null>(null); - - const exmnMnhlSourceRef = useRef(null); - - const exmnPplRequestSeqRef = useRef(0); - - const exmnMnhlRequestSeqRef = useRef(0); - - // 조사관리에서 지도보기로 최초 진입했을 때만 조회된 대표 시설 한 건으로 지도를 맞춘다. - const exmnInitialFeatureFitAppliedRef = useRef(false); - - const removeExmnResultLayer = useCallback((jobType: ExmnMapJobType) => { - const layerId = - jobType === "PPL" ? EXMN_RESULT_PPL_LAYER_ID : EXMN_RESULT_MNHL_LAYER_ID; - const layerRef = jobType === "PPL" ? exmnPplLayerRef : exmnMnhlLayerRef; - const sourceRef = jobType === "PPL" ? exmnPplSourceRef : exmnMnhlSourceRef; - const requestSeqRef = - jobType === "PPL" ? exmnPplRequestSeqRef : exmnMnhlRequestSeqRef; - - requestSeqRef.current += 1; - if (layerRef.current) { - (GisApp.Module.select as any)?.removeLayer?.(layerRef.current); - } - (GisApp.Module.core as any)?.deleteLayer?.(layerId); - layerRef.current = null; - sourceRef.current = null; - }, []); - - const ensureExmnResultLayer = useCallback( - (jobType: ExmnMapJobType) => { - const map = gisMaps.current; - if (!map) return null; - - const layerRef = jobType === "PPL" ? exmnPplLayerRef : exmnMnhlLayerRef; - const sourceRef = - jobType === "PPL" ? exmnPplSourceRef : exmnMnhlSourceRef; - if (layerRef.current && sourceRef.current) return sourceRef.current; - - const layerId = - jobType === "PPL" - ? EXMN_RESULT_PPL_LAYER_ID - : EXMN_RESULT_MNHL_LAYER_ID; - const source = new VectorSource(); - const layer = new VectorLayer({ - source, - zIndex: jobType === "PPL" ? 9988 : 9989, - style: jobType === "PPL" ? createExmnPipeStyle : createExmnManholeStyle, - updateWhileAnimating: false, - updateWhileInteracting: false, - }); - layer.set("id", layerId); - layer.set("lyrNm", jobType === "PPL" ? "관로조사" : "맨홀조사"); - layer.set( - "lyrTyNm", - jobType === "PPL" ? "tb_ms_swage_ppl" : "tb_ms_swage_mhl", - ); - layer.set("clickPsblYn", "Y"); - layer.set("chcYn", "Y"); - - (GisApp.Module.core as any)?.deleteLayer?.(layerId); - map.addLayer(layer); - (GisApp.Module.select as any)?.addLayer?.(layer); - layerRef.current = layer; - sourceRef.current = source; - return source; - }, - [gisMaps], - ); - - const loadExmnResultLayer = useCallback( - async (jobType: ExmnMapJobType, filters: ExmnMapFilters) => { - const isChecked = - jobType === "PPL" ? isExmnPplChecked : isExmnMnhlChecked; - if (!mapReady || !gisMaps.current || !isChecked) return; - - const source = ensureExmnResultLayer(jobType); - if (!source) return; - const sggCodePrefix = exmnMapRegionCodes.sggCd - .replace(/[^0-9]/g, "") - .slice(0, 5); - // 공통 행정구역 코드가 준비되기 전에는 전국 중복 관로를 조회하지 않고 다음 상태 갱신을 기다린다. - if (sggCodePrefix.length !== 5) { - source.clear(); - return; - } - const requestSeqRef = - jobType === "PPL" ? exmnPplRequestSeqRef : exmnMnhlRequestSeqRef; - const requestSeq = requestSeqRef.current + 1; - requestSeqRef.current = requestSeq; - - try { - const response = await getExmnMapLayer(jobType, filters, { - sggCd: sggCodePrefix, - ctpvNm: exmnMapEntryContext.ctpvNm, - sggNm: exmnMapEntryContext.sggNm, - }); - if (requestSeqRef.current !== requestSeq) return; - const featureCollection = - response?.data?.type === "FeatureCollection" - ? response.data - : response; - const features = new GeoJSON().readFeatures(featureCollection, { - dataProjection: "EPSG:5179", - featureProjection: GisApp.TO_PROJ, - }); - source.clear(); - source.addFeatures(features); - - // 표본 데이터가 적은 현재 단계에서는 최초 조사유형의 첫 시설을 기준으로 한 번만 확대한다. - if ( - !exmnInitialFeatureFitAppliedRef.current && - exmnMapEntryContext.jobType === jobType && - features.length > 0 - ) { - const geometry = features[0]?.getGeometry?.(); - const extent = geometry?.getExtent?.(); - if ( - extent && - extent.length === 4 && - extent.every((coordinate: number) => Number.isFinite(coordinate)) - ) { - gisMaps.current?.getView?.().fit?.(extent, { - duration: 300, - padding: [80, 420, 80, 80], - maxZoom: 18, - constrainResolution: true, - nearest: true, - }); - exmnInitialFeatureFitAppliedRef.current = true; - } - } - } catch (error) { - if (requestSeqRef.current !== requestSeq) return; - source.clear(); - console.error( - `${jobType === "PPL" ? "관로조사" : "맨홀조사"} 레이어 조회 실패:`, - error, - ); - } - }, - [ - ensureExmnResultLayer, - exmnMapEntryContext.ctpvNm, - exmnMapEntryContext.jobType, - exmnMapEntryContext.sggNm, - exmnMapRegionCodes.sggCd, - gisMaps, - isExmnMnhlChecked, - isExmnPplChecked, - mapReady, - ], - ); - - useEffect(() => { - if (!mapReady || !isExmnPplChecked) { - removeExmnResultLayer("PPL"); - return; - } - void loadExmnResultLayer( - "PPL", - isExmnPplSettingEnabled ? exmnPplFilters : EMPTY_EXMN_MAP_FILTERS, - ); - }, [ - exmnPplFilters, - isExmnPplChecked, - isExmnPplSettingEnabled, - loadExmnResultLayer, - mapReady, - removeExmnResultLayer, - ]); - - useEffect(() => { - if (!mapReady || !isExmnMnhlChecked) { - removeExmnResultLayer("MNHL"); - return; - } - void loadExmnResultLayer( - "MNHL", - isExmnMnhlSettingEnabled ? exmnMnhlFilters : EMPTY_EXMN_MAP_FILTERS, - ); - }, [ - exmnMnhlFilters, - isExmnMnhlChecked, - isExmnMnhlSettingEnabled, - loadExmnResultLayer, - mapReady, - removeExmnResultLayer, - ]); - // 화면 이탈 시 두 요청을 무효화하고 자신이 등록한 도형 레이어만 제거한다. - useEffect( - () => () => { - exmnPplRequestSeqRef.current += 1; - exmnMnhlRequestSeqRef.current += 1; - [exmnPplLayerRef, exmnMnhlLayerRef].forEach((ref) => { - if (ref.current) { - (GisApp.Module.select as any)?.removeLayer?.(ref.current); - gisMaps.current?.removeLayer?.(ref.current); - } - ref.current = null; - }); - }, - [gisMaps], - ); - return { removeExmnResultLayer }; -} 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 8c2c93076..7642a284b 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 @@ -31,7 +31,11 @@ import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-cod import { useFloodHistoryVisibility } from "../lib/use-flood-history-visibility"; import { useFloodHistorySelection } from "../lib/use-flood-history-selection"; import { useSinkholeLayers } from "../lib/use-sinkhole-layers"; -import { useExmnResultLayers } from "../lib/use-exmn-result-layers"; +import { useExmnResultMap } from "../components/exmn/use-exmn-result-map"; +import { + createExmnPipeStyle, + createExmnManholeStyle, +} from "../lib/analysis-layer-styles"; import { useGisEditSelection } from "../lib/use-gis-edit-selection"; import { useGisEditDraw } from "../lib/use-gis-edit-draw"; import { useGisEditTransform } from "../lib/use-gis-edit-transform"; @@ -341,16 +345,12 @@ const MapPageClient = () => { const waterDepthLayerCheckedRef = useRef(false); const [isExmnPplChecked, setIsExmnPplChecked] = useState(false); const [isExmnMnhlChecked, setIsExmnMnhlChecked] = useState(false); - const [exmnPplFilters, setExmnPplFilters] = useState(() => ({ + const [exmnPplFilters, setExmnPplFilters] = useState({ ...EMPTY_EXMN_MAP_FILTERS, - bizCycl: exmnMapEntryContext.bizCycl, - })); - const [exmnMnhlFilters, setExmnMnhlFilters] = useState( - () => ({ - ...EMPTY_EXMN_MAP_FILTERS, - bizCycl: exmnMapEntryContext.bizCycl, - }), - ); + }); + const [exmnMnhlFilters, setExmnMnhlFilters] = useState({ + ...EMPTY_EXMN_MAP_FILTERS, + }); const [swagePrcsFcltLayerMode, setSwagePrcsFcltLayerMode] = useState("all"); const [activeLayerSettingOrder, setActiveLayerSettingOrder] = useState< @@ -399,26 +399,6 @@ const MapPageClient = () => { useState(""); const [isGisEditSaving, setIsGisEditSaving] = useState(false); const { ctpv, sgg } = useStore(); - // 조사시설 관로번호는 전국에서 중복될 수 있어 시군구코드 앞 5자리와 함께 GIS 원천을 조회한다. - const exmnMapRegionCodes = useMemo(() => { - const ctpvOption = ctpv.find( - (option) => - String(option.text ?? "").trim() === exmnMapEntryContext.ctpvNm, - ); - const ctpvCd = String(ctpvOption?.val ?? "0").trim() || "0"; - const sggOption = sgg.find((option) => { - const code = String(option.val ?? "").trim(); - return ( - String(option.text ?? "").trim() === exmnMapEntryContext.sggNm && - (ctpvCd === "0" || code.startsWith(ctpvCd.slice(0, 2))) - ); - }); - - return { - ctpvCd, - sggCd: String(sggOption?.val ?? "0").trim() || "0", - }; - }, [ctpv, exmnMapEntryContext.ctpvNm, exmnMapEntryContext.sggNm, sgg]); // GIS 편집은 map/page.tsx 내부 기존 지도 상태를 재사용하되, 세부 상태와 payload 조립은 별도 hook으로 분리한다. const gisEdit = useGisEdit(treeData); // 침입수 분석결과 popup과 지도 highlight 흐름은 별도 hook으로 묶어 기존 지도 로직과 충돌을 줄인다. @@ -454,7 +434,6 @@ const MapPageClient = () => { changeId: string; meta: VertexHandleMeta | null; } | null>(null); - const exmnInitialRegionAppliedRef = useRef(false); // 줌레벨 컨트롤에 현재 체크된 특수 레이어의 최소 표출 기준을 안내한다. // 유하방향 API의 관종 필터를 만들기 위해 현재 체크된 트리 노드 목록을 ref로 보관한다. const checkedNodesDataRef = useRef([]); @@ -695,40 +674,6 @@ const MapPageClient = () => { fetchData(); }, []); - useEffect(() => { - if ( - !mapReady || - exmnInitialRegionAppliedRef.current || - (!exmnMapEntryContext.ctpvNm && !exmnMapEntryContext.sggNm) - ) { - return; - } - - const { ctpvCd, sggCd } = exmnMapRegionCodes; - - // 시군구가 전달된 진입에서는 시도만 먼저 적용해 버리지 않고 두 코드가 모두 준비될 때까지 기다린다. - if ( - (exmnMapEntryContext.ctpvNm && ctpvCd === "0") || - (exmnMapEntryContext.sggNm && sggCd === "0") - ) { - return; - } - exmnInitialRegionAppliedRef.current = true; - window.dispatchEvent( - new CustomEvent(EXMN_PROC_SEARCH_EVENT, { - detail: { - ctpvCd, - sggCd, - }, - }), - ); - }, [ - exmnMapEntryContext.ctpvNm, - exmnMapEntryContext.sggNm, - exmnMapRegionCodes, - mapReady, - ]); - useEffect(() => { setAppliedSinkholeFilters((currentFilters) => { if (isSinkholePopupFilterActive) { @@ -1280,18 +1225,22 @@ const MapPageClient = () => { const openAgedPipeList = useCallback(() => { openAgedPipePopupWindow(); }, [openAgedPipePopupWindow]); - const { removeExmnResultLayer } = useExmnResultLayers({ - gisMaps, - isExmnPplChecked, - isExmnMnhlChecked, - mapReady, - exmnMapRegionCodes, - exmnMapEntryContext, - isExmnPplSettingEnabled, - exmnPplFilters, - isExmnMnhlSettingEnabled, - exmnMnhlFilters, - }); + const { removeLayer: removeExmnResultLayer, messages: exmnLayerMessages } = + useExmnResultMap({ + gisApp: GisApp, + mapRef: gisMaps, + ready: mapReady, + treeData, + entry: exmnMapEntryContext, + pipeChecked: isExmnPplChecked, + manholeChecked: isExmnMnhlChecked, + pipeEnabled: isExmnPplSettingEnabled, + manholeEnabled: isExmnMnhlSettingEnabled, + pipeFilters: exmnPplFilters, + manholeFilters: exmnMnhlFilters, + pipeStyle: createExmnPipeStyle, + manholeStyle: createExmnManholeStyle, + }); const { removeSinkholeLayer, clearSinkholeSelectionLayer, @@ -2459,6 +2408,7 @@ const MapPageClient = () => { content: ( { content: ( { applySinkholeRadius, applySinkholeYearRange, draftAgedPipeFilters, + exmnLayerMessages, exmnMnhlFilters, exmnPplFilters, handleApplyAgedPipeFilters, diff --git a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md index 88cc194e3..e0d291237 100644 --- a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md @@ -639,3 +639,33 @@ - `git diff --check`는 줄바꿈 변환 경고 외 whitespace 오류 없이 통과했다. - 사용자 요청에 따라 전체 TypeScript 검사와 Next 빌드는 수행하지 않았다. - 조사 종합집계 필터, 조사 상세표·Excel, 본단위등급표와 시설 상세의 코드명 수동 검증이 남아 있다. + +## 2026-10-01 조사 레이어 검색 독립 표출 검증 +- BE compileJava --offline 통과. 임시 ExmnMapLayerScopeTest 10건 통과(실패 0/오류 0): 다지역 동일 번호 분리, 지역 미확정, 중복 도형 배제, 번호/도형 누락 분류, 빈 결과, 선택 사업/분구 전달·연결 검증, 모순 지역 조건, 상류맨홀만 반환, 501건 배치 분할. 처음 2건의 Mockito 중첩 stubbing 오류는 테스트 fixture를 수정한 후 모두 통과했다. +- FE tsc --noEmit --incremental false --pretty false 통과. Git 공백 검사 통과. +- 실제 8883 API: 지역 없는 PPL 40/40/누락0, MNHL 40/18/누락22(도형 없음22), 제보 테스트 사업·덕풍 PPL 3/3/누락0, 동일 MNHL 3/0/도형없음3. 순서는 matched/returned/missing이다. +- 등급6과 사업 없는 처리분구 요청은 HTTP400 확인. 정상 조회는 HTTP200 확인. +- 실제 3002 Chrome: 검색 없는 /map에서 조사결과 체크 후 관로40·맨홀18 안내 확인, 해제 후 설정 카드 제거 확인. 상세현황 URL로 덕풍 관로 강조와 줌18 이동 육안 확인, 관로 체크 해제 시 강조선 제거 및 재체크 후 40개 조회 복구 확인. +- 현재 전체 PPL 단일 측정: UTF-8 약36,262 bytes, 약856ms. 전국 대량 자료 부하검증을 의미하지 않는다. bbox는 계획대로 후속 성능 개선 단계다. +- 맨홀 미연결은 상류번호 누락이 아니라 해당 지역 GIS에서 도형을 찾지 못한 결과다. 덕풍3건 및 전체22건 원천 정합성 보정은 별도 확인이 필요하며 자동으로 다른 위치에 표시하지 않는다. +- 필터 변경/취소의 모든 경쟁조건, 보고서 PDF, 전국 대량 렌더링 전체 회귀는 미실행이다. 기존 테스트 자산은 수정하지 않았고 이번 임시 검증파일은 결과 요약 후 정리한다. + +### 2026-10-01 후속 사용자 확인 +- 사용자 확인으로 조사 레이어 ON/OFF 정상 표출을 확인했다. +- 사용자 요청에 따라 별도 파란 선택 강조를 제거하고 기본 조사 색상 및 선택 영역 위치 이동을 유지했다. 앞선 강조선 검증은 제거 전 기록이다. + +## 2026-10-01 조사결과 업로드 양식 미준비 안내 +- 사용자 요청은 양식 생성 절차를 생략하는 수정이 아니라 미생성 상태에 ‘양식 받기’ 선행 안내를 제공하는 것이다. +- 실제 다운로드 이력 대신 활성 처리분구의 pplExmnSheetNm/mnhlExmnSheetNm 존재 여부를 기존 사업 상세 API로 확인한다. +- 팝업 진입, 데이터 검토 전, 저장 확인·첨부 업로드 전에 확인한다. 검토 후 사업 수정으로 시트 연결이 초기화됐으면 이전 검토 상태를 폐기하고 안내한다. +- 문구: “조사 입력양식이 준비되지 않았습니다. 사업정보에서 ‘양식 받기’를 먼저 실행한 후 조사결과를 등록해 주세요. 사업정보를 수정한 경우에도 양식을 다시 받아야 합니다.” +- FE ExmnResultRegisterPopupPage.tsx와 exmn-business-validation.ts에 안내/사전 확인을 추가하고 BE ExmnSurveyResultParser의 SHEET_NAME_NOT_ASSIGNED 메시지도 같은 행동 안내로 보완했다. 자동 양식 생성·DB 변경·기존 저장 필수조건 변경은 하지 않았다. +- 검증: 미생성/부분생성/공백/정상/처리분구 없음 5건 통과, FE 타입 검사와 BE compileJava --offline 및 diff --check 통과. 임시 파일 생성 없음. 실제 파일 업로드/저장 및 브라우저 회귀는 미실행이다. +- 롤백은 이번 안내·사전 확인과 parser 오류문구만 복원한다. 실제 다운로드 성공 여부를 추적하는 기능은 아니다. + +### 2026-10-01 후속 지시: 저장 시에만 조사양식 안내 +- 앞선 초기 진입/데이터 검토 단계의 양식 준비 안내와 프론트 사전 차단을 제거했다. 저장을 누른 경우에만 공통 alert로 “조사관리에서 해당 사업 행 선택 → 수정 → 조사양식 받기 후 결과등록 재진행”을 안내한다. 검토 결과는 보존한다. +- 검토 API는 양식 생성과 같은 명명 규칙으로 미등록 시트명만 메모리에서 보완한다. 저장된 시트명은 우선 사용하고 검토 중 시트명/DB를 저장하지 않는다. 실제 파일의 시트·필수값·중복·PDF 검증은 유지한다. +- 저장 API는 실제 시트명 등록을 계속 요구하며 미준비면 기존 결과를 변경하기 전에 차단한다. 프론트는 첨부 업로드 전에 최신 상태를 확인하며 서버에서 같은 오류가 반환돼도 alert로 안내한다. +- 검증: ExmnTemplateGateTest 7건 통과(Excel/PPL PDF/MNHL 검토 독립, 미준비 commit 무쓰기 차단, 기존 시트명 보존, 준비 완료 commit, 해시 불일치 차단), FE 타입 검사 통과. 테스트는 mock 기반이며 실제 파일 저장·DB 변경 및 브라우저 끝단 저장은 수행하지 않았다. +- 임시 테스트 소스·산출물은 요약 후 정리한다. 이전 ‘검토 전 양식 확인’ 기록은 이 지시로 대체한다. diff --git a/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md b/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md index 67fd8bfba..d220683ff 100644 --- a/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md @@ -230,3 +230,10 @@ - 과거 날짜 직접 변경·조회와 최신순 정렬을 유지한다. 백엔드·API 형식·DB 변경 없음. - 검증: git diff --check 통과 및 최초 initialFilters와 초기화 buildDefaultResultFilters 분리 및 초기화 후 재조회 흐름 확인. 브라우저 확인 미실행, ESLint는 앞선 작업에서 확인한 eslint-plugin-react-hooks 누락으로 재실행하지 않았다. 개발 서버와 빌드는 조작하지 않았다. - 롤백: 이 파일의 기본기간 및 조회기간 분리 변경만 복원한다. + +## 2026-10-01 강우 발생기간 조회 제한 +- 메모리 내 날짜 유틸 검증 12건 통과: 제보 587일 차단, 366일 허용, 367일 차단, 윤년 경계, 잘못된 날짜, 빈값, 역전, 미래 날짜, 과거 종료일 보정, 기간 증가 보정, 정상 시작일 유지, 오늘 기준 기본기간. +- git diff --check 통과. 테스트 파일·원시 결과 파일은 생성하지 않았다. +- 3002 포트에서 실행 중인 개발 서버가 확인되지 않아 브라우저 달력·배치 및 실API 회귀는 미실행. 서버를 새로 실행하거나 전체 빌드하지 않았다. +- 후속 브라우저 확인: 최초/초기화 최근 1년, 종료일 이동 시 시작일 보정, 기간 초과 직접 입력 조회 차단, 검색조건 아래 오류 표시, 정상 조회 및 페이지 이동. +- TypeScript 전체 검사 통과: tsc --noEmit --incremental false --pretty false (exit 0). diff --git a/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md b/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md index b61216ca4..1567284ac 100644 --- a/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md @@ -960,3 +960,38 @@ - 조사 코드값, 상세표 열 순서, API 파라미터와 백엔드 Excel 파서·분류 목록은 변경하지 않았다. - CMS 조회 실패 또는 미등록 코드에는 기존 화면 라벨과 서버 제공값을 fallback으로 유지한다. - 백엔드 API와 DB는 변경하지 않았다. + +## 2026-10-01 조사 레이어 검색 독립 표출 구현 결과 +- 일반 /map에서 상단 검색/URL 지역 없이 PPL·MNHL 체크만으로 전체 활성 조사결과를 조회한다. 기존 레이어 색상·사업차수·구조/운영 AND 필터, 설정 토글 OFF의 추가 필터 해제 계약을 유지한다. +- ExmnMapController의 sggCd를 선택값으로 변경하고 exmnBizId/trtdstId 선택 조건을 추가했다. 처리분구 단독 요청·잘못된 사업 연결·범위 밖 등급은 400으로 처리한다. +- ExmnMapLayerService는 기존 AdministrativeDistrictRepository의 시도+시군구 기준정보로 자료별 지역을 해석하고 지역별 500건 배치를 사용한다. 같은 번호의 다른 지역은 분리하고 동일 지역의 다중 도형은 임의 선택하지 않는다. +- missingGeometryCount와 diagnostics(regionUnresolvedCount/identifierMissingCount/geometryNotFoundCount/ambiguousGeometryCount)를 반환한다. MNHL은 기존 상류맨홀만 사용하며 GIS 원천을 임의 보정하지 않는다. +- FE 전용 useExmnResultMap hook으로 기본 레이어·선택 강조·유형별 요청 취소/순번 무효화·0건/실패/누락 안내를 분리했다. 지도보기는 URL의 사업/분구를 별도 조회해 전체 extent로 최초 1회 이동한다. 일반 레이어를 선택 사업으로 제한하거나 첫 피처로 대신 이동하지 않는다. +- 수정 파일: BE ExmnMapController.java, ExmnFacilityRepository.java, ExmnMapLayerService.java; FE map/page.tsx, exmnMapApi.ts, exmnMapTypes.ts, ExmnLayerSettingPanel.tsx, 신규 useExmnResultMap.ts. +- DB DDL/DML·상단 공통 검색바·권한 설정 변경 없음. 사용자 서버를 재시작/종료하지 않았고, 신규 서버/전체 FE build는 실행하지 않았다. +- 롤백은 FE를 기존 지역 포함 호출로 복원한 뒤 BE 변경을 복원한다. 순차 배포는 BE를 먼저 반영한다. + +### 2026-10-01 사용자 확인 및 지도보기 강조 제거 +- 사용자 확인: 조사 레이어 ON/OFF에 따라 시설이 정상 표시됨. +- 후속 지시로 지도보기의 별도 파란 강조 레이어를 제거했다. 선택 사업·처리분구 조회와 전체 extent 위치 이동은 유지하고 기존 조사 레이어의 색상/스타일로 표시한다. +- 앞선 설계·구현 기록의 별도 선택 강조 생성 내용은 이 결정으로 대체한다. 백엔드·DB 추가 변경 없음. + +## 2026-10-01 조사결과 업로드 양식 미준비 안내 +- 사용자 요청은 양식 생성 절차를 생략하는 수정이 아니라 미생성 상태에 ‘양식 받기’ 선행 안내를 제공하는 것이다. +- 실제 다운로드 이력 대신 활성 처리분구의 pplExmnSheetNm/mnhlExmnSheetNm 존재 여부를 기존 사업 상세 API로 확인한다. +- 팝업 진입, 데이터 검토 전, 저장 확인·첨부 업로드 전에 확인한다. 검토 후 사업 수정으로 시트 연결이 초기화됐으면 이전 검토 상태를 폐기하고 안내한다. +- 문구: “조사 입력양식이 준비되지 않았습니다. 사업정보에서 ‘양식 받기’를 먼저 실행한 후 조사결과를 등록해 주세요. 사업정보를 수정한 경우에도 양식을 다시 받아야 합니다.” +- FE ExmnResultRegisterPopupPage.tsx와 exmn-business-validation.ts에 안내/사전 확인을 추가하고 BE ExmnSurveyResultParser의 SHEET_NAME_NOT_ASSIGNED 메시지도 같은 행동 안내로 보완했다. 자동 양식 생성·DB 변경·기존 저장 필수조건 변경은 하지 않았다. +- 검증: 미생성/부분생성/공백/정상/처리분구 없음 5건 통과, FE 타입 검사와 BE compileJava --offline 및 diff --check 통과. 임시 파일 생성 없음. 실제 파일 업로드/저장 및 브라우저 회귀는 미실행이다. +- 롤백은 이번 안내·사전 확인과 parser 오류문구만 복원한다. 실제 다운로드 성공 여부를 추적하는 기능은 아니다. + +### 2026-10-01 후속 지시: 저장 시에만 조사양식 안내 +- 앞선 초기 진입/데이터 검토 단계의 양식 준비 안내와 프론트 사전 차단을 제거했다. 저장을 누른 경우에만 공통 alert로 “조사관리에서 해당 사업 행 선택 → 수정 → 조사양식 받기 후 결과등록 재진행”을 안내한다. 검토 결과는 보존한다. +- 검토 API는 양식 생성과 같은 명명 규칙으로 미등록 시트명만 메모리에서 보완한다. 저장된 시트명은 우선 사용하고 검토 중 시트명/DB를 저장하지 않는다. 실제 파일의 시트·필수값·중복·PDF 검증은 유지한다. +- 저장 API는 실제 시트명 등록을 계속 요구하며 미준비면 기존 결과를 변경하기 전에 차단한다. 프론트는 첨부 업로드 전에 최신 상태를 확인하며 서버에서 같은 오류가 반환돼도 alert로 안내한다. +- 검증: ExmnTemplateGateTest 7건 통과(Excel/PPL PDF/MNHL 검토 독립, 미준비 commit 무쓰기 차단, 기존 시트명 보존, 준비 완료 commit, 해시 불일치 차단), FE 타입 검사 통과. 테스트는 mock 기반이며 실제 파일 저장·DB 변경 및 브라우저 끝단 저장은 수행하지 않았다. +- 임시 테스트 소스·산출물은 요약 후 정리한다. 이전 ‘검토 전 양식 확인’ 기록은 이 지시로 대체한다. + +### 2026-10-01 조사양식 안내창 폭 조정 +- 해당 안내만 56rem 폭과 화면 여백 최대폭을 적용한 모달로 표시한다. 단어 유지와 균형 줄바꿈으로 두 줄 표기를 유도하며 문구와 저장 시점은 유지한다. 다른 공통 알림창은 변경하지 않는다. +- git diff --check 통과. 좁은 화면에서는 잘림 방지를 위해 추가 줄바꿈을 허용한다. diff --git a/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md b/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md index 839b425e3..717993ddc 100644 --- a/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md @@ -275,3 +275,9 @@ - 과거 날짜 직접 변경·조회와 최신순 정렬을 유지한다. 백엔드·API 형식·DB 변경 없음. - 검증: git diff --check 통과 및 최초 initialFilters와 초기화 buildDefaultResultFilters 분리 및 초기화 후 재조회 흐름 확인. 브라우저 확인 미실행, ESLint는 앞선 작업에서 확인한 eslint-plugin-react-hooks 누락으로 재실행하지 않았다. 개발 서버와 빌드는 조작하지 않았다. - 롤백: 이 파일의 기본기간 및 조회기간 분리 변경만 복원한다. + +## 2026-10-01 강우 발생기간 조회 제한 +- WaterRiskPopupPage.tsx: 고정 초기기간을 최근 1년으로 변경, 시작일 min/max 및 종료일 max 적용, 종료일 기준 시작일 보정, 조회 전 유효성 검사, 초기화 시 오류 제거·재조회, 오류 메시지를 목록 위로 이동했다. +- waterRiskDateRange.ts: UTC 날짜 계산으로 포함 366일 경계·윤년을 처리하고 잘못된 날짜/미래/역전/기간 초과를 검증한다. +- 참고 화면: 침수이력의 min/max 입력과 준설의 최근 1년 기본기간. 공통 컴포넌트·BE·DB는 변경하지 않았다. +- 롤백: 위 두 소스와 이번 문서 절만 복원한다. DB 작업 없음. diff --git a/apps/sn/src/features/exmn/lib/exmn-business-validation.ts b/apps/sn/src/features/exmn/lib/exmn-business-validation.ts index fee3a4a76..9e423bdc8 100644 --- a/apps/sn/src/features/exmn/lib/exmn-business-validation.ts +++ b/apps/sn/src/features/exmn/lib/exmn-business-validation.ts @@ -1,8 +1,24 @@ import type { ExmnBusinessSaveRequest, + ExmnBusinessDetail, ExmnFileType, } from "../model/exmn-business-types"; +/** 다운로드 이력이 아니라 실제 처리분구별 시트 연결 여부로 양식 준비 상태를 확인한다. */ +export const getExmnTemplatePrerequisiteMessage = ( + detail: Pick, +): string | null => { + if (!detail.districts?.length) { + return "조사관리에서 해당 사업 행을 선택하고 ‘수정’을 눌러 처리분구를 등록한 뒤 ‘조사양식 받기’를 실행해 주세요. 이후 결과등록을 다시 진행해 주세요."; + } + if (detail.districts.some(district => + !district.pplExmnSheetNm?.trim() || !district.mnhlExmnSheetNm?.trim(), + )) { + return "조사관리에서 해당 사업 행 선택 → ‘수정’ → ‘조사양식 받기’를 먼저 실행한 후 결과등록을 다시 진행해 주세요."; + } + return null; +}; + /** 브라우저 1차 검사에서 허용하는 조사파일 최대 크기(200MB)다. */ export const EXMN_MAX_FILE_SIZE = 200 * 1024 * 1024; diff --git a/apps/sn/src/features/exmn/ui/exmn-result-register-popup-page.tsx b/apps/sn/src/features/exmn/ui/exmn-result-register-popup-page.tsx index 1a77d1301..06c4ddd76 100644 --- a/apps/sn/src/features/exmn/ui/exmn-result-register-popup-page.tsx +++ b/apps/sn/src/features/exmn/ui/exmn-result-register-popup-page.tsx @@ -9,7 +9,7 @@ import { StandardFormSection, StandardPopupLayout, } from "@common/business_components"; -import { Button } from "@common/components"; +import { Button, Dialog, DialogContent, DialogFooter, DialogClose } from "@common/components"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; import type { HttpResponse } from "tus-js-client"; import { useEffect, useState } from "react"; @@ -21,6 +21,7 @@ import { } from "../api/exmn-business-api"; import { EXMN_FILE_RULES, + getExmnTemplatePrerequisiteMessage, validateExmnFile, validateExmnFiles, } from "../lib/exmn-business-validation"; @@ -161,6 +162,8 @@ export function ExmnResultRegisterPopupPage({ businessSummary, }: ExmnResultRegisterPopupPageProps) { const { confirm } = useConfirm(); + // 양식 준비 안내만 넓은 모달로 표시해 다른 공통 알림창의 크기는 유지한다. + const [templateNotice, setTemplateNotice] = useState(""); const [detail, setDetail] = useState(null); const [files, setFiles] = useState({}); /** 파일별 서버 검토 결과를 보관해 다른 파일 검토 후에도 결과와 건수를 유지한다. */ @@ -224,6 +227,19 @@ export function ExmnResultRegisterPopupPage({ }; }, [exmnBizId]); + /** 검토 후 사업이 수정된 경우도 업로드 전에 발견하도록 서버의 최신 양식 연결 상태를 확인한다. */ + const ensureTemplateReady = async () => { + const latestDetail = await getExmnBusiness(exmnBizId); + setDetail(latestDetail); + const message = getExmnTemplatePrerequisiteMessage(latestDetail); + if (message) { + setIsLoading(false); + setTemplateNotice(message); + return false; + } + return true; + }; + /** 파일 변경 시 이전 검토결과를 폐기하고 확장자·크기 규칙을 즉시 확인한다. */ const handleFileChange = ( fileType: ExmnFileType, @@ -363,10 +379,14 @@ export function ExmnResultRegisterPopupPage({ fileValidationStatuses.PPL_EXMN_PDF !== "success" ) return; - if (!(await confirm("조사결과를 저장하시겠습니까?"))) return; setIsLoading(true); setNotice(null); try { + if (!(await ensureTemplateReady())) return; + // 로딩 화면이 저장 확인창을 가리지 않도록 사전 확인과 업로드 상태를 나눈다. + setIsLoading(false); + if (!(await confirm("조사결과를 저장하시겠습니까?"))) return; + setIsLoading(true); await uploadAndLinkAttachments(); await commitExmnImport( exmnBizId, @@ -379,13 +399,15 @@ export function ExmnResultRegisterPopupPage({ }, ); } catch (error) { - setNotice({ - type: "error", - text: - error instanceof Error - ? error.message - : "조사결과 최종 저장 중 오류가 발생했습니다.", - }); + const message = error instanceof Error + ? error.message : "조사결과 최종 저장 중 오류가 발생했습니다."; + // 사전 확인 이후 다른 창에서 사업이 수정돼 서버가 차단한 경우도 동일한 알림으로 안내한다. + if (message.includes("조사양식 받기")) { + setIsLoading(false); + setTemplateNotice(message); + } else { + setNotice({ type: "error", text: message }); + } } finally { setIsLoading(false); } @@ -429,6 +451,18 @@ export function ExmnResultRegisterPopupPage({ bodyClassName="space-y-3 overflow-x-hidden" > + setTemplateNotice("")}> + +
+

+ {templateNotice} +

+
+ + + +
+
{notice && notice.type !== "success" && (