- ) : 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"
>
+
{notice && notice.type !== "success" && (