diff --git a/apps/sn/app/(map-layout)/map/components/common/index.ts b/apps/sn/app/(map-layout)/map/components/common/index.ts index bec20e908..377df2344 100644 --- a/apps/sn/app/(map-layout)/map/components/common/index.ts +++ b/apps/sn/app/(map-layout)/map/components/common/index.ts @@ -17,6 +17,7 @@ type LayerComponentProps = { chmbrYdnts: string; gisObject?: Record; // GIS 객체 추가 rawFeatureData?: any; + surveyResultContent?: React.ReactNode; }; type LayerContentMap = { [key: string]: { diff --git a/apps/sn/app/(map-layout)/map/components/exmn/ExmnLayerSettingPanel.tsx b/apps/sn/app/(map-layout)/map/components/exmn/ExmnLayerSettingPanel.tsx new file mode 100644 index 000000000..a600c9a2f --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/exmn/ExmnLayerSettingPanel.tsx @@ -0,0 +1,81 @@ +"use client"; + +import type { ExmnMapFilters, ExmnMapJobType } from "./exmnMapTypes"; + +type Props = { + jobType: ExmnMapJobType; + filters: ExmnMapFilters; + onFiltersChange: (filters: ExmnMapFilters) => void; +}; + +const GRADE_OPTIONS = ["", "1", "2", "3", "4", "5"]; + +/** 조사결과 레이어의 세 가지 자동 적용 필터다. */ +export default function ExmnLayerSettingPanel({ + jobType, + filters, + onFiltersChange, +}: Props) { + const updateFilter = (key: keyof ExmnMapFilters, value: string) => { + onFiltersChange({ + ...filters, + [key]: value.replace(/[^0-9]/g, ""), + }); + }; + const selectClass = + "h-[24px] w-[54px] rounded-[4px] border border-[#ccd6df] bg-white px-2 text-[1rem] text-[#222] outline-none focus:border-primary"; + const labelClass = "shrink-0 text-[1.05rem] font-medium text-slate-700"; + + return ( +
+
+ + + {jobType === "PPL" ? "관로조사" : "맨홀조사"} + +
+
+ + + +
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/exmn/ExmnSurveyResultSection.tsx b/apps/sn/app/(map-layout)/map/components/exmn/ExmnSurveyResultSection.tsx new file mode 100644 index 000000000..9c0417de2 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/exmn/ExmnSurveyResultSection.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { + Button, + Table, + TableBody, + TableCell, + TableRow, +} from "@common/components"; + +import { getExmnPipeReportAvailability } from "@/features/exmn"; +import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; + +type ExmnSurveyResultSectionProps = { + featureData: any; +}; + +const readRawFeatureProperty = (featureData: any, keys: string[]) => { + for (const key of keys) { + const value = + featureData?.get?.(key) ?? + featureData?.values_?.[key] ?? + featureData?.[key]; + if (value !== undefined && value !== null) return value; + } + return undefined; +}; + +const readFeatureProperty = (featureData: any, keys: string[]) => + String(readRawFeatureProperty(featureData, keys) ?? "").trim(); + +const readBooleanFeatureProperty = (featureData: any, keys: string[]) => { + const value = readRawFeatureProperty(featureData, keys); + return value === true || String(value ?? "").trim().toLowerCase() === "true"; +}; + +const displayValue = (value: string) => value || "-"; + +export default function ExmnSurveyResultSection({ + featureData, +}: ExmnSurveyResultSectionProps) { + const exmnBizId = readFeatureProperty(featureData, ["exmnBizId"]); + const jobType = readFeatureProperty(featureData, ["jobType"]); + const pipeNumber = readFeatureProperty(featureData, [ + "gisManagementNo", + "fclt_mng_no", + "fcltMngNo", + ]); + const reportAvailable = readBooleanFeatureProperty(featureData, [ + "reportAvailable", + ]); + const canOpenPipeReport = jobType === "PPL" && Boolean(exmnBizId && pipeNumber); + + const openPipeReport = async () => { + if (!canOpenPipeReport) return; + if (!reportAvailable) { + alert("등록된 관로 조사보고서가 없습니다."); + return; + } + + try { + const availability = await getExmnPipeReportAvailability( + exmnBizId, + pipeNumber, + ); + if (!availability.available) { + alert( + availability.message?.trim() + || `선택한 관로번호의 조사보고서가 등록된 PDF에 없습니다: ${pipeNumber}`, + ); + return; + } + + const reportWindow = openWindowPopup("exmnPipeReport", { + exmnBizId, + pipeNumber, + reportNo: readFeatureProperty(featureData, ["reportNo"]), + }); + if (!reportWindow) { + alert("팝업이 차단되었습니다. 브라우저의 팝업 허용 설정을 확인해주세요."); + } + } catch (error) { + alert( + error instanceof Error && error.message + ? error.message + : "관로 조사보고서 열람 가능 여부를 확인하지 못했습니다.", + ); + } + }; + + const rows = [ + ["사업명", readFeatureProperty(featureData, ["bizNm"])], + ["사업차수", readFeatureProperty(featureData, ["bizCycl"])], + ["처리분구", readFeatureProperty(featureData, ["trtdstNm"])], + ["조사유형", jobType === "MNHL" ? "맨홀조사" : "관로조사"], + ["보고서번호", readFeatureProperty(featureData, ["reportNo"])], + ["구조적결함 등급", readFeatureProperty(featureData, ["structuralGrade"])], + ["운영적결함 등급", readFeatureProperty(featureData, ["operationalGrade"])], + ]; + + return ( +
+
+

조사 결과

+ {jobType === "PPL" && ( + + )} +
+ +
+ + + {rows.map(([label, value]) => ( + + + {label} + + + {displayValue(value)} + + + ))} + +
+
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/exmn/exmnMapApi.ts b/apps/sn/app/(map-layout)/map/components/exmn/exmnMapApi.ts new file mode 100644 index 000000000..56d444a2d --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/exmn/exmnMapApi.ts @@ -0,0 +1,25 @@ +import { getRawJson } from "../../../../components/lib/api"; +import type { ExmnMapFilters, ExmnMapJobType } from "./exmnMapTypes"; + +type ExmnLayerScope = { + sggCd?: string; + ctpvNm?: string; + sggNm?: string; +}; + +/** 사업차수·구조등급·운영등급 AND 조건으로 조사결과 GeoJSON을 조회한다. */ +export const getExmnMapLayer = async ( + jobType: ExmnMapJobType, + filters: ExmnMapFilters, + scope: ExmnLayerScope, +) => { + const query = new URLSearchParams({ jobType }); + if (scope.sggCd?.trim()) query.set("sggCd", scope.sggCd.trim()); + if (scope.ctpvNm?.trim()) query.set("ctpvNm", scope.ctpvNm.trim()); + if (scope.sggNm?.trim()) query.set("sggNm", scope.sggNm.trim()); + if (filters.bizCycl) query.set("bizCycl", filters.bizCycl); + if (filters.structuralGrade) query.set("structuralGrade", filters.structuralGrade); + if (filters.operationalGrade) query.set("operationalGrade", filters.operationalGrade); + + return getRawJson(`/sewerage-service/api/v1/exmnMap/layers?${query.toString()}`); +}; diff --git a/apps/sn/app/(map-layout)/map/components/exmn/exmnMapTypes.ts b/apps/sn/app/(map-layout)/map/components/exmn/exmnMapTypes.ts new file mode 100644 index 000000000..248c8547b --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/exmn/exmnMapTypes.ts @@ -0,0 +1,298 @@ +export const EXMN_RESULT_ROOT_NODE_ID = "SN_EXMN_RESULT"; +export const EXMN_RESULT_PPL_NODE_ID = "SN_EXMN_RESULT_PPL"; +export const EXMN_RESULT_MNHL_NODE_ID = "SN_EXMN_RESULT_MNHL"; +export const EXMN_RESULT_PPL_LAYER_ID = "SN_EXMN_RESULT_PPL_LAYER"; +export const EXMN_RESULT_MNHL_LAYER_ID = "SN_EXMN_RESULT_MNHL_LAYER"; +export const EXMN_RESULT_ROOT_LAYER_TYPE = "sn_exmn_result_group"; +export const EXMN_RESULT_PPL_LAYER_TYPE = "sn_exmn_result_ppl"; +export const EXMN_RESULT_MNHL_LAYER_TYPE = "sn_exmn_result_mnhl"; +// 레이어 트리에서 조사 관로와 기존 맨홀 분류 심볼을 즉시 구분하기 위한 전용 범례 스타일이다. +export const EXMN_RESULT_PPL_LEGEND_COLOR = "#facc15"; +export const EXMN_RESULT_MNHL_LEGEND_ICON = "SN_EXMN_MANHOLE_PURPLE.png?v=3"; + +export type ExmnMapJobType = "PPL" | "MNHL"; + +export type ExmnMapFilters = { + bizCycl: string; + structuralGrade: string; + operationalGrade: string; +}; + +export type ExmnMapEntryContext = { + exmnBizId: string; + ctpvNm: string; + sggNm: string; + bizCycl: string; + jobType: ExmnMapJobType | null; +}; + +export const EMPTY_EXMN_MAP_FILTERS: ExmnMapFilters = { + bizCycl: "", + structuralGrade: "", + operationalGrade: "", +}; + +const buildControlNode = ( + id: string, + name: string, + upperId: string | number | null, + layerType: string, + initialVisible: boolean, +) => { + const isPipeNode = layerType === EXMN_RESULT_PPL_LAYER_TYPE; + const isManholeNode = layerType === EXMN_RESULT_MNHL_LAYER_TYPE; + + return { + lyrSn: id, + id, + lyrNm: name, + upperLyrSn: upperId, + upId: upperId, + sortOrdr: upperId ? (id === EXMN_RESULT_PPL_NODE_ID ? "1" : "2") : "5", + drawOrdr: null, + flterSql: null, + gobjTy: isPipeNode ? "line" : "", + lyrTyNm: layerType, + useYn: "Y", + minViewSize: null, + iconNm: isManholeNode ? EXMN_RESULT_MNHL_LEGEND_ICON : null, + iconSize: null, + lblAtrbNm: null, + fontNm: null, + fontIntnColor: null, + fontExtnColor: null, + fontExtnWdth: null, + lnWdth: null, + lnColor: isPipeNode ? EXMN_RESULT_PPL_LEGEND_COLOR : null, + polgnColor: null, + initEprssYn: initialVisible ? "Y" : "N", + clickPsblYn: "N", + exprsYn: "Y", + cmncNm: "all", + srvcNm: "api", + actlLyrYn: "N", + clckZoom: null, + children: null, + }; +}; + +const resolveNodeData = (node: any) => node?.data ?? node ?? {}; + +const resolveNodeId = (node: any) => { + const nodeData = resolveNodeData(node); + return nodeData?.lyrSn ?? nodeData?.id ?? ""; +}; + +const resolveNodeParentId = (node: any) => { + const nodeData = resolveNodeData(node); + return nodeData?.upperLyrSn ?? nodeData?.upId ?? ""; +}; + +const resolveNodeLayerType = (node: any) => { + const nodeData = resolveNodeData(node); + return String( + nodeData?.lyrTyNm ?? + nodeData?.lyrTypeNm ?? + nodeData?.lyr_type_nm ?? + "", + ).trim(); +}; + +const resolveNodeName = (node: any) => { + const nodeData = resolveNodeData(node); + return String(nodeData?.lyrNm ?? nodeData?.name ?? "").trim(); +}; + +const sameNodeId = (left: unknown, right: unknown) => + String(left ?? "") === String(right ?? ""); + +const isNodeType = (node: any, layerType: string) => + resolveNodeLayerType(node) === layerType; + +const normalizeControlNode = ( + node: any, + fallbackId: string, + fallbackName: string, + upperId: string | number | null, + layerType: string, + initialVisible: boolean, +) => { + const nodeId = resolveNodeId(node) || fallbackId; + const isPipeNode = layerType === EXMN_RESULT_PPL_LAYER_TYPE; + const isManholeNode = layerType === EXMN_RESULT_MNHL_LAYER_TYPE; + + return { + ...node, + lyrSn: nodeId, + id: nodeId, + // DB에 저장된 과거 명칭과 무관하게 조사관리 표준 화면 명칭으로 표시한다. + lyrNm: fallbackName, + upperLyrSn: upperId, + upId: upperId, + lyrTyNm: layerType, + gobjTy: isPipeNode ? "line" : "", + iconNm: isManholeNode ? EXMN_RESULT_MNHL_LEGEND_ICON : null, + lnColor: isPipeNode ? EXMN_RESULT_PPL_LEGEND_COLOR : null, + initEprssYn: initialVisible ? "Y" : "N", + clickPsblYn: "N", + srvcNm: "api", + actlLyrYn: "N", + }; +}; + +/** + * 서버 레이어 설정의 조사결과 노드를 우선 사용한다. + * DB 설정이 아직 배포되지 않았거나 일부만 등록된 환경에서는 누락 노드만 가상 노드로 보완한다. + */ +export const appendExmnResultControlNodes = ( + data: unknown[], + initialJobType: ExmnMapJobType | null, +) => { + if (!Array.isArray(data)) return data; + + const rootNode = + data.find((item: any) => isNodeType(item, EXMN_RESULT_ROOT_LAYER_TYPE)) ?? + data.find((item: any) => + sameNodeId(resolveNodeId(item), EXMN_RESULT_ROOT_NODE_ID), + ) ?? + data.find( + (item: any) => + resolveNodeName(item) === "조사결과" && !resolveNodeParentId(item), + ); + const rootId = resolveNodeId(rootNode) || EXMN_RESULT_ROOT_NODE_ID; + + const pplNode = + data.find((item: any) => isNodeType(item, EXMN_RESULT_PPL_LAYER_TYPE)) ?? + data.find((item: any) => + sameNodeId(resolveNodeId(item), EXMN_RESULT_PPL_NODE_ID), + ) ?? + data.find( + (item: any) => + ["관로조사", "CCTV조사"].includes(resolveNodeName(item)) && + sameNodeId(resolveNodeParentId(item), rootId), + ); + const mnhlNode = + data.find((item: any) => isNodeType(item, EXMN_RESULT_MNHL_LAYER_TYPE)) ?? + data.find((item: any) => + sameNodeId(resolveNodeId(item), EXMN_RESULT_MNHL_NODE_ID), + ) ?? + data.find( + (item: any) => + resolveNodeName(item) === "맨홀조사" && + sameNodeId(resolveNodeParentId(item), rootId), + ); + + const normalizedPplNode = pplNode + ? normalizeControlNode( + pplNode, + EXMN_RESULT_PPL_NODE_ID, + "관로조사", + rootId, + EXMN_RESULT_PPL_LAYER_TYPE, + initialJobType === "PPL", + ) + : buildControlNode( + EXMN_RESULT_PPL_NODE_ID, + "관로조사", + rootId, + EXMN_RESULT_PPL_LAYER_TYPE, + initialJobType === "PPL", + ); + const normalizedMnhlNode = mnhlNode + ? normalizeControlNode( + mnhlNode, + EXMN_RESULT_MNHL_NODE_ID, + "맨홀조사", + rootId, + EXMN_RESULT_MNHL_LAYER_TYPE, + initialJobType === "MNHL", + ) + : buildControlNode( + EXMN_RESULT_MNHL_NODE_ID, + "맨홀조사", + rootId, + EXMN_RESULT_MNHL_LAYER_TYPE, + initialJobType === "MNHL", + ); + const childIds = [ + resolveNodeId(normalizedPplNode), + resolveNodeId(normalizedMnhlNode), + ]; + const previousChildren = Array.isArray((rootNode as any)?.children) + ? (rootNode as any).children + : []; + const normalizedRootNode = { + ...(rootNode + ? normalizeControlNode( + rootNode, + EXMN_RESULT_ROOT_NODE_ID, + "조사결과", + null, + EXMN_RESULT_ROOT_LAYER_TYPE, + false, + ) + : buildControlNode( + EXMN_RESULT_ROOT_NODE_ID, + "조사결과", + null, + EXMN_RESULT_ROOT_LAYER_TYPE, + false, + )), + children: [...previousChildren, ...childIds].filter( + (childId, index, children) => + childId !== "" && + children.findIndex((candidate) => sameNodeId(candidate, childId)) === + index, + ), + }; + + const recognizedIds = new Set( + [ + EXMN_RESULT_ROOT_NODE_ID, + EXMN_RESULT_PPL_NODE_ID, + EXMN_RESULT_MNHL_NODE_ID, + resolveNodeId(rootNode), + resolveNodeId(pplNode), + resolveNodeId(mnhlNode), + ] + .filter((id) => id !== "") + .map((id) => String(id)), + ); + const filtered = data.filter((item: any) => { + const nodeId = String(resolveNodeId(item)); + return ( + !recognizedIds.has(nodeId) && + !isNodeType(item, EXMN_RESULT_ROOT_LAYER_TYPE) && + !isNodeType(item, EXMN_RESULT_PPL_LAYER_TYPE) && + !isNodeType(item, EXMN_RESULT_MNHL_LAYER_TYPE) + ); + }); + + return [ + ...filtered, + normalizedRootNode, + normalizedPplNode, + normalizedMnhlNode, + ]; +}; + +export const isExmnResultPplNode = (node: any) => + sameNodeId(resolveNodeId(node), EXMN_RESULT_PPL_NODE_ID) || + isNodeType(node, EXMN_RESULT_PPL_LAYER_TYPE); + +export const isExmnResultMnhlNode = (node: any) => + sameNodeId(resolveNodeId(node), EXMN_RESULT_MNHL_NODE_ID) || + isNodeType(node, EXMN_RESULT_MNHL_LAYER_TYPE); + +export const buildExmnMapEntryContext = ( + searchParams: Pick, +): ExmnMapEntryContext => { + const rawJobType = String(searchParams.get("exmnJobType") ?? "").toUpperCase(); + return { + exmnBizId: String(searchParams.get("exmnBizId") ?? "").trim(), + ctpvNm: String(searchParams.get("ctpvNm") ?? "").trim(), + sggNm: String(searchParams.get("sggNm") ?? "").trim(), + bizCycl: String(searchParams.get("bizCycl") ?? "").replace(/[^0-9]/g, ""), + jobType: rawJobType === "PPL" || rawJobType === "MNHL" ? rawJobType : null, + }; +}; diff --git a/apps/sn/app/(map-layout)/map/components/floodDngrMap/FloodDngrMapPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/floodDngrMap/FloodDngrMapPopupPage.tsx new file mode 100644 index 000000000..e60f971e5 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/floodDngrMap/FloodDngrMapPopupPage.tsx @@ -0,0 +1,333 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@common/components"; +import { UiTable } from "@common/business_components/ui"; +import type { UiTableSort } from "@common/business_components/ui"; +import { getData } from "@/components/lib/api"; +import LoadingOverlay from "@/components/ui/LoadingBar"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import useStore from "@/store/useStore"; +import { + FLOOD_DNGR_MAP_DEFAULT_FILTERS, + FLOOD_DNGR_MAP_DEPTH_OPTIONS, + FLOOD_DNGR_MAP_FREQUENCY_OPTIONS, + FLOOD_DNGR_MAP_HEADLIST, + FLOOD_DNGR_MAP_PAGE_SIZE, + buildFloodDngrMapListQueryString, + toFloodDngrMapTableRows, + type FloodDngrMapApiItem, + type FloodDngrMapExtent, + type FloodDngrMapFilters, + type FloodDngrMapTableRow, +} from "./floodDngrMapTypes"; + +const FLOOD_DNGR_MAP_REQUEST_TIMEOUT_MS = 10_000; +const FLOOD_DNGR_MAP_LOADING_WATCHDOG_MS = 12_000; + +/** Abort 처리 지연 여부와 무관하게 제한 시간이 지나면 팝업 요청 Promise를 종료한다. */ +const getFloodDngrMapJson = async (url: string) => { + let timeoutId: number | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = window.setTimeout(() => { + reject(new Error("도시침수지도 조회 시간이 초과되었습니다. 검색조건을 좁혀 다시 조회해주세요.")); + }, FLOOD_DNGR_MAP_REQUEST_TIMEOUT_MS); + }); + try { + return await Promise.race([getData(url), timeoutPromise]); + } finally { + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + } +}; + +/** 시설물 레이어의 정보제공포털 도시침수지도 피처를 조건별 목록으로 제공한다. */ +export default function FloodDngrMapPopupPage() { + const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); + const [filters, setFilters] = useState(FLOOD_DNGR_MAP_DEFAULT_FILTERS); + const [appliedFilters, setAppliedFilters] = useState(FLOOD_DNGR_MAP_DEFAULT_FILTERS); + const [items, setItems] = useState([]); + const [administrativeScopeCodes, setAdministrativeScopeCodes] = useState(null); + const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [sort, setSort] = useState(); + const [appliedSort, setAppliedSort] = useState(); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + const availableCtpvCodes = useMemo( + () => new Set((administrativeScopeCodes ?? []).map((code) => code.slice(0, 2)).filter(Boolean)), + [administrativeScopeCodes], + ); + + const availableSggCodes = useMemo( + () => new Set((administrativeScopeCodes ?? []).map((code) => code.slice(0, 5)).filter(Boolean)), + [administrativeScopeCodes], + ); + + const ctpvOptions = useMemo( + () => + administrativeScopeCodes === null + ? ctpv + : ctpv.filter((item) => availableCtpvCodes.has(String(item.val).trim().slice(0, 2))), + [administrativeScopeCodes, availableCtpvCodes, ctpv], + ); + + const sggOptions = useMemo(() => { + const scopedOptions = + administrativeScopeCodes === null + ? sgg + : sgg.filter((item) => availableSggCodes.has(String(item.val).trim().slice(0, 5))); + return filters.ctpvCd === "0" + ? scopedOptions + : scopedOptions.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))); + }, [administrativeScopeCodes, availableSggCodes, filters.ctpvCd, sgg]); + + const rows = useMemo( + () => + toFloodDngrMapTableRows( + items, + currentPage - 1, + FLOOD_DNGR_MAP_PAGE_SIZE, + ctpv, + sgg, + total, + sort, + ), + [ctpv, currentPage, items, sgg, sort, total], + ); + + useEffect(() => { + if (ctpv.length === 0) void fetchCtpv(); + if (sgg.length === 0) void fetchSgg(); + }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); + + /** 요청 Promise가 예상과 다르게 정리되지 않아도 로딩 오버레이를 최대 12초 안에 해제한다. */ + useEffect(() => { + if (!isLoading) return; + const watchdogId = window.setTimeout(() => { + setIsLoading(false); + setErrorMessage((current) => + current || "도시침수지도 조회가 지연되어 로딩을 종료했습니다. 검색조건을 좁혀 다시 조회해주세요.", + ); + }, FLOOD_DNGR_MAP_LOADING_WATCHDOG_MS); + return () => window.clearTimeout(watchdogId); + }, [isLoading]); + + /** 마지막 적용 조건과 정렬로 서버 페이지를 조회한다. */ + const loadRows = useCallback( + async (page: number, nextFilters: FloodDngrMapFilters, nextSort?: UiTableSort) => { + setIsLoading(true); + setErrorMessage(""); + try { + const queryString = buildFloodDngrMapListQueryString( + nextFilters, + page - 1, + FLOOD_DNGR_MAP_PAGE_SIZE, + nextSort, + ); + const response = await getFloodDngrMapJson( + `/sewerage-service/api/v1/fldng/flood-dngr-map?${queryString}`, + ); + const payload = response as { + items?: FloodDngrMapApiItem[]; + total?: number; + } | null; + if (!payload || !Array.isArray(payload.items)) { + throw new Error("도시침수지도 목록 API 응답 형식이 올바르지 않습니다."); + } + setItems(payload.items); + setTotal(Number(payload?.total ?? 0)); + setCurrentPage(page); + setAppliedFilters(nextFilters); + setSort(nextSort); + setAppliedSort(nextSort); + setSelectedIndex(-1); + } catch (error) { + console.error("도시침수지도 목록 조회 중 오류:", error); + setItems([]); + setTotal(0); + setErrorMessage( + error instanceof Error ? error.message : "도시침수지도 목록 조회 중 오류가 발생했습니다.", + ); + } finally { + setIsLoading(false); + } + }, + [], + ); + + /** 실제 도시침수지도 데이터가 존재하는 행정구역 코드만 검색 선택지로 제한한다. */ + const loadAdministrativeScope = useCallback(async () => { + try { + const response = await getFloodDngrMapJson( + "/sewerage-service/api/v1/fldng/flood-dngr-map/scope", + ); + if (!Array.isArray(response)) { + throw new Error("도시침수지도 행정구역 API 응답 형식이 올바르지 않습니다."); + } + setAdministrativeScopeCodes(response); + } catch (error) { + console.error("도시침수지도 행정구역 범위 조회 중 오류:", error); + setAdministrativeScopeCodes(null); + } + }, []); + + useEffect(() => { + void loadRows(1, FLOOD_DNGR_MAP_DEFAULT_FILTERS); + void loadAdministrativeScope(); + }, [loadAdministrativeScope, loadRows]); + + const handleSearch = () => { + void loadRows(1, filters, sort); + }; + + /** 시군구·침수빈도 그룹의 범위와 선택 조건을 부모 지도에 전달한다. */ + const handleRowClick = async (index?: number) => { + if (typeof index !== "number") return; + const row = rows[index]; + if (!row?.sggCd || !row?.sbmrgFrqExpln) return; + setSelectedIndex(index); + setIsLoading(true); + setErrorMessage(""); + try { + const params = new URLSearchParams({ + sggCd: row.sggCd, + sbmrgFrqExpln: row.sbmrgFrqExpln, + }); + const response = await getFloodDngrMapJson( + `/sewerage-service/api/v1/fldng/flood-dngr-map/group/extent?${params.toString()}`, + ); + const payload = response as FloodDngrMapExtent; + const extent = [payload?.minX, payload?.minY, payload?.maxX, payload?.maxY].map(Number); + if (extent.length !== 4 || extent.some((value) => !Number.isFinite(value))) { + throw new Error("해당 도시침수지도 피처의 위치 정보가 없습니다."); + } + window.opener?.postMessage( + { + type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP, + payload: { + extent, + sggCd: row.sggCd, + sbmrgFrqExpln: row.sbmrgFrqExpln, + }, + }, + window.location.origin, + ); + } catch (error) { + console.error("도시침수지도 위치 이동 중 오류:", error); + const message = + error instanceof Error && error.message.includes("404") + ? "해당 도시침수지도 데이터에 위치 정보가 없어 이동할 수 없습니다." + : error instanceof Error + ? error.message + : "도시침수지도 위치 이동 중 오류가 발생했습니다."; + setErrorMessage(message); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ +
+

도시침수지도 목록

+
+ +
+
+ + + + + + + + +
+ +
+
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+ + headName={ + <> + 도시침수지도 조회 결과 총 {total.toLocaleString()}건 + + } + tableData={rows} + headlist={FLOOD_DNGR_MAP_HEADLIST} + pageSize={FLOOD_DNGR_MAP_PAGE_SIZE} + total={total} + cellClick={(index) => void handleRowClick(index)} + onPageChange={(page) => void loadRows(page, appliedFilters, appliedSort)} + curPage={currentPage} + index={selectedIndex} + widthFixed={true} + isHideCount={true} + sort={sort} + onSortChange={(nextSort) => void loadRows(1, appliedFilters, nextSort)} + /> +
+
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/floodDngrMap/floodDngrMapTypes.ts b/apps/sn/app/(map-layout)/map/components/floodDngrMap/floodDngrMapTypes.ts new file mode 100644 index 000000000..24a4187d7 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/floodDngrMap/floodDngrMapTypes.ts @@ -0,0 +1,172 @@ +"use client"; + +import type { UiHeadProps, UiTableSort } from "@common/business_components/ui"; + +export const FLOOD_DNGR_MAP_POPUP_KEY = "floodDngrMapPopup"; +export const FLOOD_DNGR_MAP_LAYER_TYPE = "tb_ms_flood_dngr_map"; +export const FLOOD_DNGR_MAP_LAYER_NAME = "도시침수지도(정보제공포털)"; +export const FLOOD_DNGR_MAP_PAGE_SIZE = 20; + +export type FloodDngrMapFilters = { + ctpvCd: string; + sggCd: string; + sbmrgDpthlCd: string; + sbmrgFrqExpln: string; +}; + +export type FloodDngrMapApiItem = { + ctpvCd: string; + sggCd: string; + minSbmrgDpthlCd: string; + maxSbmrgDpthlCd: string; + sbmrgFrqExpln: string; +}; + +export type FloodDngrMapExtent = { + minX: number; + minY: number; + maxX: number; + maxY: number; +}; + +export type FloodDngrMapTableRow = FloodDngrMapApiItem & { + rowNo: number; + ctpvNm: string; + sggNm: string; + sbmrgDpthlDisplay: string; + sbmrgFrqDisplay: string; +}; + +type AdministrativeOption = { + val: unknown; + text: unknown; +}; + +export const FLOOD_DNGR_MAP_DEFAULT_FILTERS: FloodDngrMapFilters = { + ctpvCd: "0", + sggCd: "0", + sbmrgDpthlCd: "0", + sbmrgFrqExpln: "0", +}; + +export const FLOOD_DNGR_MAP_FREQUENCY_OPTIONS = ["030", "050", "080", "100"]; + +export const FLOOD_DNGR_MAP_DEPTH_LABELS: Record = { + N330: "0.0~0.5m 미만", + N331: "0.5~1.0m 미만", + N332: "1.0~2.0m 미만", + N333: "2.0~5.0m 미만", + N334: "5.0m 이상", +}; + +export const FLOOD_DNGR_MAP_DEPTH_OPTIONS = Object.entries(FLOOD_DNGR_MAP_DEPTH_LABELS).map( + ([value, label]) => ({ value, label }), +); + +export const FLOOD_DNGR_MAP_HEADLIST: UiHeadProps[] = [ + { id: "rowNo", title: "NO", widthPercent: 8, sortable: true, sortKey: "rowNo" }, + { id: "ctpvNm", title: "시도", widthPercent: 18, sortable: true, sortKey: "ctpvCd" }, + { id: "sggNm", title: "시군구", widthPercent: 20, sortable: true, sortKey: "sggCd" }, + { + id: "sbmrgDpthlDisplay", + title: "침수심도(최소~최대)", + widthPercent: 31, + sortable: true, + sortKey: "sbmrgDpthlCd", + }, + { + id: "sbmrgFrqDisplay", + title: "침수빈도", + widthPercent: 23, + sortable: true, + sortKey: "sbmrgFrqExpln", + }, +]; + +/** API가 허용하는 검색 조건과 페이징·정렬 파라미터만 쿼리 문자열로 만든다. */ +export const buildFloodDngrMapListQueryString = ( + filters: FloodDngrMapFilters, + page: number, + size: number, + sort?: UiTableSort, +) => { + const params = new URLSearchParams({ page: String(page), size: String(size) }); + if (filters.ctpvCd && filters.ctpvCd !== "0") params.set("ctpvCd", filters.ctpvCd.slice(0, 2)); + if (filters.sggCd && filters.sggCd !== "0") params.set("sggCd", filters.sggCd.slice(0, 5)); + if (filters.sbmrgDpthlCd && filters.sbmrgDpthlCd !== "0") { + params.set("sbmrgDpthlCd", filters.sbmrgDpthlCd); + } + if (filters.sbmrgFrqExpln && filters.sbmrgFrqExpln !== "0") { + params.set("sbmrgFrqExpln", filters.sbmrgFrqExpln); + } + if (sort?.key && sort.direction) params.set("sort", `${sort.key},${sort.direction}`); + return params.toString(); +}; + +const resolveAdministrativeName = ( + value: unknown, + options: AdministrativeOption[], + codeLength: number, +) => { + const code = String(value ?? "").trim().slice(0, codeLength); + const matched = options.find( + (option) => String(option.val ?? "").trim().slice(0, codeLength) === code, + ); + return String(matched?.text ?? "").trim() || "-"; +}; + +const toFrequencyDisplay = (value: unknown) => { + const normalized = String(value ?? "").trim(); + if (!normalized) return "-"; + const numericValue = Number(normalized); + return Number.isFinite(numericValue) ? `${numericValue}년` : normalized; +}; + +const FLOOD_DNGR_MAP_DEPTH_BOUNDS: Record = { + N330: { min: "0.0", max: "0.5" }, + N331: { min: "0.5", max: "1.0" }, + N332: { min: "1.0", max: "2.0" }, + N333: { min: "2.0", max: "5.0" }, + N334: { min: "5.0", max: "5.0", openEnded: true }, +}; + +/** 그룹의 최소·최대 침수심도 코드를 하나의 범위 문구로 표시한다. */ +const toDepthRangeDisplay = (minCode: string, maxCode: string) => { + if (!minCode && !maxCode) return "-"; + if (minCode === maxCode) return FLOOD_DNGR_MAP_DEPTH_LABELS[minCode] || minCode || "-"; + const minDepth = FLOOD_DNGR_MAP_DEPTH_BOUNDS[minCode]; + const maxDepth = FLOOD_DNGR_MAP_DEPTH_BOUNDS[maxCode]; + if (!minDepth || !maxDepth) return `${minCode || "-"} ~ ${maxCode || "-"}`; + return maxDepth.openEnded + ? `${minDepth.min}m ~ ${maxDepth.min}m 이상` + : `${minDepth.min}m ~ ${maxDepth.max}m 미만`; +}; + +/** 서버 페이지 번호와 코드 표시값을 화면 행으로 변환한다. */ +export const toFloodDngrMapTableRows = ( + items: FloodDngrMapApiItem[], + page: number, + pageSize = FLOOD_DNGR_MAP_PAGE_SIZE, + ctpvOptions: AdministrativeOption[] = [], + sggOptions: AdministrativeOption[] = [], + total = items.length, + sort?: UiTableSort, +): FloodDngrMapTableRow[] => + items.map((item, index) => ({ + ...item, + rowNo: + sort?.key === "rowNo" && sort.direction === "desc" + ? total - page * pageSize - index + : page * pageSize + index + 1, + ctpvNm: resolveAdministrativeName(item.ctpvCd, ctpvOptions, 2), + sggNm: resolveAdministrativeName(item.sggCd, sggOptions, 5), + sbmrgDpthlDisplay: toDepthRangeDisplay(item.minSbmrgDpthlCd, item.maxSbmrgDpthlCd), + sbmrgFrqDisplay: toFrequencyDisplay(item.sbmrgFrqExpln), + })); + +const resolveNodeName = (node: any) => + String(node?.text ?? node?.data?.lyrNm ?? node?.data?.name ?? node?.lyrNm ?? node?.name ?? "").trim(); + +/** 빈도·침수심 자식 노드가 아니라 도시침수지도 루트만 목록보기 대상으로 판별한다. */ +export const isFloodDngrMapRootNode = (node: any) => + resolveNodeName(node).replace(/\s/g, "") === FLOOD_DNGR_MAP_LAYER_NAME.replace(/\s/g, ""); diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx index 6ddb8fd98..b081f5506 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx @@ -34,7 +34,15 @@ const inputColumn = [ type operationDataType = { year: string; type: string, bizInfoNm: string, bizType: string }; -const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: () => void }) => { +const MsSwagePpl = ({ + featureData, + onSaveClick, + surveyResultContent, +}: { + featureData: any; + onSaveClick?: () => void; + surveyResultContent?: React.ReactNode; +}) => { const [formData, setFormData] = useState({}); const [repairData, setRepairData] = useState([]); const [cleaningData, setCleaningData] = useState([]); @@ -48,6 +56,8 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic const { BIZ_SE_CD, BIZ_TY_CD } = useStore(); const requestMngInstCd = String(featureData?.mng_inst_cd ?? featureData?.mngInstCd ?? "").trim(); const requestFcltMngNo = String(featureData?.fclt_mng_no ?? featureData?.fcltMngNo ?? "").trim(); + // 조사관리 전용 탭은 기본·입력정보와 조사결과만 표시하고 일반 시설 유지관리 이력은 숨긴다. + const showOperationSections = !surveyResultContent; // 마운트 및 업데이트 시 API 호출 @@ -58,7 +68,7 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic }, [featureData]); useEffect(() => { - if (!requestMngInstCd || !requestFcltMngNo) { + if (!showOperationSections || !requestMngInstCd || !requestFcltMngNo) { setInspectionData([]); setCleaningData([]); setRepairData([]); @@ -121,7 +131,7 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic setCleaningData([]); setRepairData([]); }); - }, [BIZ_SE_CD, BIZ_TY_CD, requestFcltMngNo, requestMngInstCd]); + }, [BIZ_SE_CD, BIZ_TY_CD, requestFcltMngNo, requestMngInstCd, showOperationSections]); // 폼 데이터가 변경될 때 호출될 핸들러 const handleFormDataChange = (newFormData: any) => { @@ -154,24 +164,29 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic onSaveClick={handleSaveClick} onFormDataChange={handleFormDataChange} // 폼 데이터 변경 콜백 추가 /> - handleInputChange(section, index, field, value, dataMap)} - /> - handleInputChange(section, index, field, value, dataMap)} - /> - handleInputChange(section, index, field, value, dataMap)} - /> + {surveyResultContent} + {showOperationSections && ( + <> + handleInputChange(section, index, field, value, dataMap)} + /> + handleInputChange(section, index, field, value, dataMap)} + /> + handleInputChange(section, index, field, value, dataMap)} + /> + + )} ) } diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx index fea0e45df..71b256cf3 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx @@ -49,6 +49,7 @@ import { searchUpperPpls } from "./smh"; import { getElapsedYearsDisplayText, isInvalidInstallYmd } from "../agedPipe/agedPipeUtils"; import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; import useStore from "@/store/useStore"; +import ExmnSurveyResultSection from "../exmn/ExmnSurveyResultSection"; const linkedFacilityHighlightStyle = new Style({ image: new CircleStyle({ @@ -121,7 +122,15 @@ type MngInstOption = { lwrInstNm: string; }; -const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData: any }) => { +const FeatureClickEventPopup = ({ + onClose, + featureData, + surveyMode = false, +}: { + onClose: () => void; + featureData: any; + surveyMode?: boolean; +}) => { const { gisMap } = useGisContext() as unknown as { checkedLayerIds: React.MutableRefObject; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; }; const [layerNm, setLayerNm] = useState(""); // 레이어 이름 상태 const [layerSpecificNm, setLayerSpecificNm] = useState(""); // 레이어 이름 상태 @@ -774,7 +783,18 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
-

{layerNm} + {surveyMode && ( +

+ 조사관리
+ 관로번호 : { + featureData?.get?.("gisManagementNo") + ?? featureData?.values_?.gisManagementNo + ?? featureData?.gisManagementNo + ?? layerSpecificNm + } +

+ )} +

{layerNm} {tableData?.usg_cd && ( <> ({tableData.usg_cd}) @@ -872,6 +892,11 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, chmbrYdnts={chmbrYdnts} gisObject={featureData?.values_ ?? featureData} rawFeatureData={featureData} + surveyResultContent={ + surveyMode + ? + : undefined + } /> }

diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/smh.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/smh.tsx index cd59003ef..68e191d1e 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/smh.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/smh.tsx @@ -13,7 +13,7 @@ * - 상류관로 조회: GET /api/v1/fcltmhl/upperPpls/{fcltMngNo} (sewerage-service) */ import { VerticalTable } from '@/components/ui/verticalTable'; -import { useEffect, useState } from 'react'; +import { type ReactNode, useEffect, useState } from 'react'; import GisApp from "@common/business_components/ui/map/lib/gis.core"; import { getData } from '@/components/lib/api'; @@ -80,7 +80,15 @@ const inputColumn = [ { id: "mltvDtdvNm", title: "배수분구", type: "input" }, { id: "layCd", title: "매설위치", type: "select", options: ["도로", "하상"] }, ]; -const Smh = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: () => void }) => { +const Smh = ({ + featureData, + onSaveClick, + surveyResultContent, +}: { + featureData: any; + onSaveClick?: () => void; + surveyResultContent?: ReactNode; +}) => { const [formData, setFormData] = useState({}); // featureData가 변경될 때 formData 초기화 @@ -104,6 +112,7 @@ const Smh = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: () }; return ( + <> + {surveyResultContent} + ) } diff --git a/apps/sn/app/(map-layout)/map/components/sbmrgDngrMap/SbmrgDngrMapPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/sbmrgDngrMap/SbmrgDngrMapPopupPage.tsx new file mode 100644 index 000000000..592a86ba3 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/sbmrgDngrMap/SbmrgDngrMapPopupPage.tsx @@ -0,0 +1,267 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@common/components"; +import { UiTable } from "@common/business_components/ui"; +import type { UiTableSort } from "@common/business_components/ui"; +import { getRawJson } from "@/components/lib/api"; +import LoadingOverlay from "@/components/ui/LoadingBar"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import useStore from "@/store/useStore"; +import { + SBMRG_DNGR_MAP_DEFAULT_FILTERS, + SBMRG_DNGR_MAP_FREQUENCY_OPTIONS, + SBMRG_DNGR_MAP_HEADLIST, + SBMRG_DNGR_MAP_LAYER_TYPE, + SBMRG_DNGR_MAP_PAGE_SIZE, + SBMRG_DNGR_MAP_SCOPE_SIZE, + buildSbmrgDngrMapListQueryString, + extractAdministrativeCodes, + toSbmrgDngrMapTableRows, + type SbmrgDngrMapApiItem, + type SbmrgDngrMapFilters, + type SbmrgDngrMapTableRow, +} from "./sbmrgDngrMapTypes"; + +const unwrapPayload = (response: any): T => (response?.data ?? response) as T; + +/** 시설물 레이어의 중점관리지역 침수위험지도 피처를 조건별 목록으로 제공한다. */ +export default function SbmrgDngrMapPopupPage() { + const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); + // 입력 중인 조건과 실제 조회에 적용된 조건을 분리해 페이지 이동 시 조건이 흔들리지 않게 한다. + const [filters, setFilters] = useState(SBMRG_DNGR_MAP_DEFAULT_FILTERS); + const [appliedFilters, setAppliedFilters] = useState(SBMRG_DNGR_MAP_DEFAULT_FILTERS); + const [items, setItems] = useState([]); + const [administrativeScopeItems, setAdministrativeScopeItems] = useState(null); + const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [sort, setSort] = useState(); + const [appliedSort, setAppliedSort] = useState(); + const [isLoading, setIsLoading] = useState(false); + + const availableCtpvCodes = useMemo( + () => + new Set( + (administrativeScopeItems ?? []).flatMap((item) => + extractAdministrativeCodes(item.ctpvCd, 2), + ), + ), + [administrativeScopeItems], + ); + + const availableSggCodes = useMemo( + () => + new Set( + (administrativeScopeItems ?? []).flatMap((item) => + extractAdministrativeCodes(item.sggCd, 5), + ), + ), + [administrativeScopeItems], + ); + + const ctpvOptions = useMemo( + () => + administrativeScopeItems === null + ? ctpv + : ctpv.filter((item) => availableCtpvCodes.has(String(item.val).trim().slice(0, 2))), + [administrativeScopeItems, availableCtpvCodes, ctpv], + ); + + const sggOptions = useMemo( + () => { + const scopedOptions = + administrativeScopeItems === null + ? sgg + : sgg.filter((item) => availableSggCodes.has(String(item.val).trim().slice(0, 5))); + return filters.ctpvCd === "0" + ? scopedOptions + : scopedOptions.filter((item) => + String(item.val).startsWith(filters.ctpvCd.slice(0, 2)), + ); + }, + [administrativeScopeItems, availableSggCodes, filters.ctpvCd, sgg], + ); + + const rows = useMemo( + () => + toSbmrgDngrMapTableRows( + items, + currentPage - 1, + SBMRG_DNGR_MAP_PAGE_SIZE, + ctpv, + sgg, + total, + sort, + ), + [ctpv, currentPage, items, sgg, sort, total], + ); + + useEffect(() => { + if (ctpv.length === 0) void fetchCtpv(); + if (sgg.length === 0) void fetchSgg(); + }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); + + /** 마지막 적용 조건과 정렬로 서버 페이지를 조회한다. */ + const loadRows = useCallback( + async (page: number, nextFilters: SbmrgDngrMapFilters, nextSort?: UiTableSort) => { + setIsLoading(true); + try { + const queryString = buildSbmrgDngrMapListQueryString( + nextFilters, + page - 1, + SBMRG_DNGR_MAP_PAGE_SIZE, + nextSort, + ); + const response = await getRawJson( + `/sewerage-service/api/v1/fldng/sbmrg-dngr-map?${queryString}`, + ); + const payload = unwrapPayload<{ items?: SbmrgDngrMapApiItem[]; total?: number }>(response); + const items = Array.isArray(payload?.items) ? payload.items : []; + setItems(items); + setTotal(Number(payload?.total ?? 0)); + setCurrentPage(page); + setAppliedFilters(nextFilters); + setSort(nextSort); + setAppliedSort(nextSort); + setSelectedIndex(-1); + } catch (error) { + console.error("침수위험지도 목록 조회 중 오류:", error); + setItems([]); + setTotal(0); + alert(error instanceof Error ? error.message : "침수위험지도 목록 조회 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }, + [], + ); + + /** 전체 50개 그룹에서 실제 존재하는 행정구역 코드만 검색 선택지로 제한한다. */ + const loadAdministrativeScope = useCallback(async () => { + try { + const queryString = buildSbmrgDngrMapListQueryString( + SBMRG_DNGR_MAP_DEFAULT_FILTERS, + 0, + SBMRG_DNGR_MAP_SCOPE_SIZE, + ); + const response = await getRawJson( + `/sewerage-service/api/v1/fldng/sbmrg-dngr-map?${queryString}`, + ); + const payload = unwrapPayload<{ items?: SbmrgDngrMapApiItem[] }>(response); + setAdministrativeScopeItems(Array.isArray(payload?.items) ? payload.items : []); + } catch (error) { + console.error("침수위험지도 행정구역 범위 조회 중 오류:", error); + setAdministrativeScopeItems(null); + } + }, []); + + useEffect(() => { + void loadRows(1, SBMRG_DNGR_MAP_DEFAULT_FILTERS); + void loadAdministrativeScope(); + }, [loadAdministrativeScope, loadRows]); + + /** 현재 입력 조건을 적용하고 첫 페이지부터 다시 조회한다. */ + const handleSearch = () => { + void loadRows(1, filters, sort); + }; + + /** 선택한 지역·빈도 그룹의 전체 fid를 부모 지도에 전달해 전체 범위 이동·강조를 실행한다. */ + const handleRowClick = (index?: number) => { + if (typeof index !== "number") return; + const row = rows[index]; + if (!Array.isArray(row?.fids) || row.fids.length === 0) return; + setSelectedIndex(index); + const featureIds = row.fids.map((fid) => + String(fid).startsWith(`${SBMRG_DNGR_MAP_LAYER_TYPE}.`) + ? String(fid) + : `${SBMRG_DNGR_MAP_LAYER_TYPE}.${fid}`, + ); + window.opener?.postMessage( + { + type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE, + payload: { layerNm: SBMRG_DNGR_MAP_LAYER_TYPE, fids: JSON.stringify(featureIds) }, + }, + window.location.origin, + ); + }; + + return ( +
+ +
+

침수위험지도 목록

+
+ +
+
+ + + + + + +
+ +
+
+ +
+ + headName={<>침수위험지도 조회 결과 총 {total.toLocaleString()}건} + tableData={rows} + headlist={SBMRG_DNGR_MAP_HEADLIST} + pageSize={SBMRG_DNGR_MAP_PAGE_SIZE} + total={total} + cellClick={(index) => handleRowClick(index)} + onPageChange={(page) => void loadRows(page, appliedFilters, appliedSort)} + curPage={currentPage} + index={selectedIndex} + widthFixed={true} + isHideCount={true} + sort={sort} + onSortChange={(nextSort) => void loadRows(1, appliedFilters, nextSort)} + /> +
+
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/sbmrgDngrMap/sbmrgDngrMapTypes.ts b/apps/sn/app/(map-layout)/map/components/sbmrgDngrMap/sbmrgDngrMapTypes.ts new file mode 100644 index 000000000..44e4d71c3 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/sbmrgDngrMap/sbmrgDngrMapTypes.ts @@ -0,0 +1,153 @@ +"use client"; + +import type { UiHeadProps, UiTableSort } from "@common/business_components/ui"; + +export const SBMRG_DNGR_MAP_POPUP_KEY = "sbmrgDngrMapPopup"; +export const SBMRG_DNGR_MAP_LAYER_TYPE = "tb_ms_sbmrg_dngr_map"; +export const SBMRG_DNGR_MAP_LAYER_NAME = "침수위험지도(중점관리지역)"; +export const SBMRG_DNGR_MAP_PAGE_SIZE = 20; +export const SBMRG_DNGR_MAP_SCOPE_SIZE = 2000; + +export type SbmrgDngrMapFilters = { + ctpvCd: string; + sggCd: string; + floodWtlvFrqVl: string; +}; + +export type SbmrgDngrMapApiItem = { + fids: string[]; + ctpvCd: string; + sggCd: string; + emphsMngRgnNm: string; + emphsMngRgnMngNo: string; + floodWtlvFrqVl: number | string | null; + minElvtVl: number | string | null; + maxElvtVl: number | string | null; +}; + +export type SbmrgDngrMapTableRow = SbmrgDngrMapApiItem & { + rowNo: number; + ctpvNm: string; + sggNm: string; + floodWtlvFrqVlDisplay: string; + elvtVlDisplay: string; +}; + +type AdministrativeOption = { + val: unknown; + text: unknown; +}; + +export const SBMRG_DNGR_MAP_DEFAULT_FILTERS: SbmrgDngrMapFilters = { + ctpvCd: "0", + sggCd: "0", + floodWtlvFrqVl: "0", +}; + +export const SBMRG_DNGR_MAP_FREQUENCY_OPTIONS = [30, 50, 80, 100]; + +export const SBMRG_DNGR_MAP_HEADLIST: UiHeadProps[] = [ + { id: "rowNo", title: "NO", widthPercent: 7, sortable: true, sortKey: "rowNo" }, + { id: "ctpvNm", title: "시도", widthPercent: 11, sortable: true, sortKey: "ctpvCd" }, + { id: "sggNm", title: "시군구", widthPercent: 12, sortable: true, sortKey: "sggCd" }, + { id: "emphsMngRgnNm", title: "중점관리지역명", widthPercent: 23, sortable: true }, + { id: "emphsMngRgnMngNo", title: "중점관리지역번호", widthPercent: 15, sortable: true }, + { + id: "floodWtlvFrqVlDisplay", + title: "홍수수위빈도값", + widthPercent: 17, + sortable: true, + sortKey: "floodWtlvFrqVl", + }, + { + id: "elvtVlDisplay", + title: "해발값(최소~최대)", + widthPercent: 15, + sortable: true, + sortKey: "elvtVl", + }, +]; + +/** API가 허용하는 검색 조건과 페이징·정렬 파라미터만 쿼리 문자열로 만든다. */ +export const buildSbmrgDngrMapListQueryString = ( + filters: SbmrgDngrMapFilters, + page: number, + size: number, + sort?: UiTableSort, +) => { + const params = new URLSearchParams({ page: String(page), size: String(size) }); + if (filters.ctpvCd && filters.ctpvCd !== "0") params.set("ctpvCd", filters.ctpvCd.slice(0, 2)); + if (filters.sggCd && filters.sggCd !== "0") params.set("sggCd", filters.sggCd.slice(0, 5)); + if (filters.floodWtlvFrqVl && filters.floodWtlvFrqVl !== "0") { + params.set("floodWtlvFrqVl", filters.floodWtlvFrqVl); + } + if (sort?.key && sort.direction) params.set("sort", `${sort.key},${sort.direction}`); + return params.toString(); +}; + +const toDisplay = (value: unknown) => { + const normalized = String(value ?? "").trim(); + return normalized || "-"; +}; + +const toRangeDisplay = (minValue: unknown, maxValue: unknown) => { + const minDisplay = toDisplay(minValue); + const maxDisplay = toDisplay(maxValue); + if (minDisplay === "-" && maxDisplay === "-") return "-"; + if (minDisplay === maxDisplay) return minDisplay; + return `${minDisplay} ~ ${maxDisplay}`; +}; + +/** 집계 응답에서 쉼표로 묶일 수 있는 행정구역 코드를 정규화한다. */ +export const extractAdministrativeCodes = (value: unknown, codeLength: number) => + String(value ?? "") + .split(",") + .map((code) => code.trim().slice(0, codeLength)) + .filter(Boolean); + +/** 쉼표로 묶인 행정구역 코드도 각각 한글명으로 변환하고 중복 명칭은 제거한다. */ +const resolveAdministrativeNames = ( + value: unknown, + options: AdministrativeOption[], + codeLength: number, +) => { + const names = extractAdministrativeCodes(value, codeLength) + .map((code) => { + const matched = options.find( + (option) => String(option.val ?? "").trim().slice(0, codeLength) === code, + ); + return String(matched?.text ?? "").trim(); + }) + .filter(Boolean); + + return names.length > 0 ? [...new Set(names)].join(", ") : "-"; +}; + +/** 서버 페이지 번호를 화면 NO에 반영하고 숫자 표시값을 보정한다. */ +export const toSbmrgDngrMapTableRows = ( + items: SbmrgDngrMapApiItem[], + page: number, + pageSize = SBMRG_DNGR_MAP_PAGE_SIZE, + ctpvOptions: AdministrativeOption[] = [], + sggOptions: AdministrativeOption[] = [], + total = items.length, + sort?: UiTableSort, +): SbmrgDngrMapTableRow[] => + items.map((item, index) => ({ + ...item, + rowNo: + sort?.key === "rowNo" && sort.direction === "desc" + ? total - page * pageSize - index + : page * pageSize + index + 1, + ctpvNm: resolveAdministrativeNames(item.ctpvCd, ctpvOptions, 2), + sggNm: resolveAdministrativeNames(item.sggCd, sggOptions, 5), + floodWtlvFrqVlDisplay: toDisplay(item.floodWtlvFrqVl), + elvtVlDisplay: toRangeDisplay(item.minElvtVl, item.maxElvtVl), + })); + +const resolveNodeName = (node: any) => + String(node?.text ?? node?.data?.lyrNm ?? node?.data?.name ?? node?.lyrNm ?? node?.name ?? "").trim(); + +/** 빈도·침수심 자식 노드가 아니라 중점관리지역 위험지도 루트만 목록보기 대상으로 판별한다. */ +export const isSbmrgDngrMapRootNode = (node: any) => + resolveNodeName(node).replace(/\s/g, "") === SBMRG_DNGR_MAP_LAYER_NAME.replace(/\s/g, ""); diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 6920d2189..8307d0e5e 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -18,7 +18,16 @@ "use client"; import dynamic from "next/dynamic"; -import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, + type UIEvent, +} from "react"; import { usePathname, useSearchParams } from "next/navigation"; import Collection from "ol/Collection.js"; @@ -29,6 +38,7 @@ import Draw from "ol/interaction/Draw.js"; import Snap from "ol/interaction/Snap.js"; import Translate from "ol/interaction/Translate.js"; import Fill from "ol/style/Fill.js"; +import Icon from "ol/style/Icon.js"; import LineString from "ol/geom/LineString.js"; import Point from "ol/geom/Point.js"; import Polygon from "ol/geom/Polygon.js"; @@ -84,6 +94,19 @@ import { useRdiiResultMap } from "./components/rdiiResult/useRdiiResultMap"; import SinkholeDetailPanel from "./components/sinkhole/SinkholeDetailPanel"; import SinkholeLayerSettingPanel from "./components/sinkhole/SinkholeLayerSettingPanel"; import FldngHstryDetailPanel from "./components/fldngHstry/FldngHstryDetailPanel"; +import ExmnLayerSettingPanel from "./components/exmn/ExmnLayerSettingPanel"; +import { getExmnMapLayer } from "./components/exmn/exmnMapApi"; +import { + EMPTY_EXMN_MAP_FILTERS, + EXMN_RESULT_MNHL_LAYER_ID, + EXMN_RESULT_PPL_LAYER_ID, + appendExmnResultControlNodes, + buildExmnMapEntryContext, + isExmnResultMnhlNode, + isExmnResultPplNode, + type ExmnMapFilters, + type ExmnMapJobType, +} from "./components/exmn/exmnMapTypes"; import { buildSinkholeDefaultYearRange, buildSinkholeFiltersFromYearRange, @@ -120,6 +143,15 @@ import { isFldngHstryNode, type FldngHstryItem, } from "./components/fldngHstry/fldngHstryTypes"; +import { + SBMRG_DNGR_MAP_POPUP_KEY, + isSbmrgDngrMapRootNode, +} from "./components/sbmrgDngrMap/sbmrgDngrMapTypes"; +import { + FLOOD_DNGR_MAP_LAYER_TYPE, + FLOOD_DNGR_MAP_POPUP_KEY, + isFloodDngrMapRootNode, +} from "./components/floodDngrMap/floodDngrMapTypes"; import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup"; import SearchModal from "./searchModal/searchModal"; import { WATER_DEPTH_LAYER_TYPES, extractWaterDepthLegendItems } from "./waterDepth/waterDepth"; @@ -146,6 +178,7 @@ const AGED_PIPE_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT"; const AGED_PIPE_POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE"; const AGED_PIPE_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC"; const SINKHOLE_DEFAULT_POINT_LIMIT = 2000; +const EXMN_PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH"; // 지반침하 지도 포인트와 같은 파란 계열의 기존 레이어 트리 심볼 자산을 재사용한다. const SINKHOLE_TREE_ICON_NAME = "SN_SINKHOLE_SPOT_BLUE.png"; const SOUTH_KOREA_VIEW_EXTENT: [number, number, number, number] = [ @@ -155,6 +188,40 @@ const SOUTH_KOREA_VIEW_EXTENT: [number, number, number, number] = [ 2500000, ]; +const EXMN_GRADE_COLORS: Record = { + 1: "#22c55e", + 2: "#84cc16", + 3: "#facc15", + 4: "#f97316", + 5: "#dc2626", +}; +const EXMN_RESULT_MANHOLE_ICON_SRC = + "/images/map/layericon/SN_EXMN_MANHOLE_PURPLE.png?v=3"; + +const getExmnGradeColor = (feature: any) => { + const grade = Number(feature?.get?.("structuralGrade") ?? 0); + return EXMN_GRADE_COLORS[grade] ?? "#2563eb"; +}; + +const createExmnPipeStyle = (feature: any) => + new Style({ + stroke: new Stroke({ + color: getExmnGradeColor(feature), + width: 4, + }), + }); + +// 레이어 트리 범례와 지도 피처가 동일한 기존 맨홀 형태의 보라색 심볼을 사용한다. +const createExmnManholeStyle = () => + new Style({ + image: new Icon({ + src: EXMN_RESULT_MANHOLE_ICON_SRC, + anchor: [0.5, 0.5], + anchorXUnits: "fraction", + anchorYUnits: "fraction", + }), + }); + // 유하방향은 GeoServer WMS/WFS 레이어가 아니라 BE 보정 GeoJSON API로 그리는 가상 레이어이다. const FLOW_DIRECTION_LAYER_TYPE = "tb_sn_swage_ppl_flow_direction"; const FLOW_DIRECTION_LAYER_NAME = "유하방향"; @@ -179,6 +246,7 @@ type RightPanelTabKind = "feature" | "sinkhole" | "gisEdit"; type RightPanelTabCategory = | "sinkhole" | "pipe" + | "exmnSurvey" | "treatmentFacility" | "treatmentZone" | "defaultFeature" @@ -189,6 +257,7 @@ type RightPanelTab = { kind: RightPanelTabKind; category: RightPanelTabCategory; displayLabel: string; + surveyJobType?: ExmnMapJobType; parentTabId?: string | null; featureData: any; sinkholeDetail?: SinkholeDetail | null; @@ -222,6 +291,10 @@ const RIGHT_PANEL_TAB_STYLE_BY_CATEGORY: Record< active: "border-[#2563eb] bg-[#2563eb] text-white", inactive: "border-[#93c5fd] bg-[#eff6ff] text-[#1e40af] hover:bg-[#dbeafe]", }, + exmnSurvey: { + active: "border-[#0f766e] bg-[#0f766e] text-white", + inactive: "border-[#5eead4] bg-[#f0fdfa] text-[#115e59] hover:bg-[#ccfbf1]", + }, treatmentFacility: { active: "border-[#15803d] bg-[#15803d] text-white", inactive: "border-[#86efac] bg-[#f0fdf4] text-[#166534] hover:bg-[#dcfce7]", @@ -240,6 +313,20 @@ const RIGHT_PANEL_TAB_STYLE_BY_CATEGORY: Record< }, }; +const RIGHT_PANEL_EXMN_TAB_STYLE_BY_JOB_TYPE: Record< + ExmnMapJobType, + RightPanelTabStyle +> = { + PPL: { + active: "border-[#eab308] bg-[#facc15] text-[#713f12]", + inactive: "border-[#fde68a] bg-[#fefce8] text-[#854d0e] hover:bg-[#fef9c3]", + }, + MNHL: { + active: "border-[#8b5cf6] bg-[#8b5cf6] text-white", + inactive: "border-[#c4b5fd] bg-[#f5f3ff] text-[#5b21b6] hover:bg-[#ede9fe]", + }, +}; + // 공용 우측 탭은 유형과 활성 상태에 관계없이 먼저 열린 탭부터 제거한다. const findRightPanelTabOverflowCandidate = ( tabs: RightPanelTab[], @@ -267,7 +354,10 @@ const RightPanelBookmarkTabs = ({
{tabs.map((tab) => { const isActive = tab.id === activeTabId; - const style = RIGHT_PANEL_TAB_STYLE_BY_CATEGORY[tab.category]; + const style = + tab.category === "exmnSurvey" && tab.surveyJobType + ? RIGHT_PANEL_EXMN_TAB_STYLE_BY_JOB_TYPE[tab.surveyJobType] + : RIGHT_PANEL_TAB_STYLE_BY_CATEGORY[tab.category]; const parentTab = tab.parentTabId ? tabs.find((candidate) => candidate.id === tab.parentTabId) : null; @@ -335,6 +425,7 @@ const getLayerTypeName = (layerInfo: any) => layerInfo?.lyr_type_nm ?? "", ).trim(); + const getLayerFilterSql = (layerInfo: any) => String( layerInfo?.filtrSql ?? @@ -923,6 +1014,8 @@ const getRightPanelFeatureProperty = (data: any, keys: string[]) => { }; const getRightPanelTabCategory = (data: any): RightPanelTabCategory => { + const surveyOverlayType = getRightPanelFeatureProperty(data, ["surveyOverlayType"]); + if (surveyOverlayType === "EXMN_RESULT") return "exmnSurvey"; const layerTypeName = getSelectedFeatureLayerTypeName(data); if (layerTypeName === "tb_ms_swage_ppl") return "pipe"; if (layerTypeName === "tb_il_swage_prcs_fclt") return "treatmentFacility"; @@ -931,6 +1024,16 @@ const getRightPanelTabCategory = (data: any): RightPanelTabCategory => { }; const getRightPanelFeatureDisplayLabel = (data: any, category: RightPanelTabCategory) => { + if (category === "exmnSurvey") { + const managementNo = + getRightPanelFeatureProperty(data, ["gisManagementNo", "fclt_mng_no", "fcltMngNo"]) || + "-"; + const jobType = getRightPanelFeatureProperty(data, ["jobType"]); + const surveyLabel = + jobType === "PPL" ? "관로조사" : jobType === "MNHL" ? "맨홀조사" : "조사관리"; + return `${surveyLabel}(${managementNo})`; + } + if (category === "pipe") { const managementNo = getRightPanelFeatureProperty(data, ["fclt_mng_no", "fcltMngNo", "pipeId", "fid"]) || @@ -2465,6 +2568,10 @@ const appendSinkholeControlNode = (data: any[]) => { const Map = () => { const searchParams = useSearchParams(); const pathname = usePathname(); + const exmnMapEntryContext = useMemo( + () => buildExmnMapEntryContext(searchParams), + [searchParams], + ); const initialAnalysisPopupKey = useMemo(() => { const popupKey = searchParams.get("analysisPopupKey"); if (!popupKey || !isWindowPopupKey(popupKey)) return null; @@ -2493,6 +2600,16 @@ const Map = () => { const [isAgedPipeChecked, setIsAgedPipeChecked] = useState(false); const [isSinkholeChecked, setIsSinkholeChecked] = useState(false); const [isFldngHstryChecked, setIsFldngHstryChecked] = useState(false); + const [isExmnPplChecked, setIsExmnPplChecked] = useState(false); + const [isExmnMnhlChecked, setIsExmnMnhlChecked] = useState(false); + const [exmnPplFilters, setExmnPplFilters] = useState(() => ({ + ...EMPTY_EXMN_MAP_FILTERS, + bizCycl: exmnMapEntryContext.bizCycl, + })); + const [exmnMnhlFilters, setExmnMnhlFilters] = useState(() => ({ + ...EMPTY_EXMN_MAP_FILTERS, + bizCycl: exmnMapEntryContext.bizCycl, + })); const [swagePrcsFcltLayerMode, setSwagePrcsFcltLayerMode] = useState("all"); const [activeLayerSettingOrder, setActiveLayerSettingOrder] = useState([]); const [draftAgedPipeFilters, setDraftAgedPipeFilters] = @@ -2522,6 +2639,30 @@ const Map = () => { const [agedPipeDownloadProgressText, setAgedPipeDownloadProgressText] = 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으로 묶어 기존 지도 로직과 충돌을 줄인다. @@ -2564,6 +2705,15 @@ const Map = () => { const sinkholeSelectedSourceRef = useRef(null); const sinkholeFocusedPipeLayerRef = useRef | null>(null); const sinkholeFocusedPipeSourceRef = useRef(null); + 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 exmnInitialRegionAppliedRef = useRef(false); + // 조사관리에서 지도보기로 최초 진입했을 때만 조회된 대표 시설 한 건으로 지도를 맞춘다. + const exmnInitialFeatureFitAppliedRef = useRef(false); // 유하방향 레이어 체크 여부를 React 렌더링 흐름에 반영하기 위한 state이다. const [isFlowDirectionChecked, setIsFlowDirectionChecked] = useState(false); // 체크된 유하방향 트리 노드의 레이어 설정을 보관해 API 조회와 vector layer 스타일에 사용한다. @@ -2874,6 +3024,40 @@ const Map = () => { 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) { @@ -2923,6 +3107,71 @@ const Map = () => { return; } + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FIT_EXTENT) { + const extent = Array.isArray(data.payload?.extent) + ? data.payload.extent.map(Number) + : []; + const map = gisMaps.current; + if (extent.length !== 4 || extent.some((value: number) => !Number.isFinite(value)) || !map?.getView) { + return; + } + map.getView().fit(extent, { + duration: 300, + padding: [50, 50, 50, 50], + maxZoom: 16, + }); + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP) { + const extent = Array.isArray(data.payload?.extent) + ? data.payload.extent.map(Number) + : []; + const sggCd = String(data.payload?.sggCd ?? "").trim(); + const frequency = String(data.payload?.sbmrgFrqExpln ?? "").trim(); + const map = gisMaps.current; + const select = GisApp.Module.select as any; + if ( + extent.length !== 4 + || extent.some((value: number) => !Number.isFinite(value)) + || !/^\d{5}$/.test(sggCd) + || !/^(030|050|080|100)$/.test(frequency) + || !map?.getView + || !select?.selectFocusedBoundaryFeature + ) { + return; + } + + await select.resetSelectedFeature?.(); + const selectedFeatureIds = new Set(); + const selectLoadedGroupFeatures = (source: any) => { + const features = source?.getFeatures?.() ?? []; + features.forEach((feature: any) => { + const featureSggCd = String(feature.get?.("sgg_cd") ?? "").trim(); + const featureFrequency = String(feature.get?.("sbmrg_frq_expln") ?? "").trim(); + if (featureSggCd !== sggCd || featureFrequency !== frequency) return; + const featureId = String(feature.getId?.() ?? feature.get?.("fid") ?? ""); + if (featureId && selectedFeatureIds.has(featureId)) return; + if (featureId) selectedFeatureIds.add(featureId); + select.selectFocusedBoundaryFeature(feature, true); + }); + }; + + map.getLayers().getArray().forEach((layer: any) => { + if (String(layer.get?.("lyrTyNm") ?? "").trim() !== FLOOD_DNGR_MAP_LAYER_TYPE) return; + const source = layer.getSource?.(); + if (!source?.getFeatures) return; + selectLoadedGroupFeatures(source); + source.once?.("featuresloadend", () => selectLoadedGroupFeatures(source)); + }); + map.getView().fit(extent, { + duration: 300, + padding: [50, 50, 50, 50], + maxZoom: 16, + }); + return; + } + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_UPLOAD_COMPLETED) { const rawLayerTypeNames = data.payload?.refreshLayerTypeNames; let layerTypeNames = ["tb_ms_swage_ppl"]; @@ -3053,10 +3302,13 @@ const Map = () => { try { const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK"); setTreeData( - appendAgedPipeControlNode( - appendSinkholeControlNode( - normalizeTreeRowSpacing(normalizeFlowDirectionLayerInfos(data)), + appendExmnResultControlNodes( + appendAgedPipeControlNode( + appendSinkholeControlNode( + normalizeTreeRowSpacing(normalizeFlowDirectionLayerInfos(data)), + ), ), + exmnMapEntryContext.jobType, ), ); setLayerLoadError(null); @@ -3955,6 +4207,138 @@ const Map = () => { openAgedPipePopupWindow(); }, [openAgedPipePopupWindow]); + 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; + }, []); + + 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.ctpvCd, + exmnMapRegionCodes.sggCd, + isExmnMnhlChecked, + isExmnPplChecked, + mapReady, + ], + ); + const removeSinkholeLayer = useCallback(() => { const core = GisApp.Module.core as any; core?.deleteLayer?.(SINKHOLE_LAYER_ID); @@ -4615,17 +4999,25 @@ const Map = () => { } const category = getRightPanelTabCategory(data); + const surveyJobTypeValue = getRightPanelFeatureProperty(data, ["jobType"]); + const surveyJobType = + surveyJobTypeValue === "PPL" || surveyJobTypeValue === "MNHL" + ? surveyJobTypeValue + : undefined; const featureId = getRightPanelFeatureIdentifier(data) || `${layerTypeName || "feature"}:${now}`; // Direct map selections accumulate per pipe; only a pipe with a sinkhole parent replaces the linked slot. const pipeParentTabId = category === "pipe" ? pipeParentTabIdRef.current : null; return { id: - category === "pipe" && pipeParentTabId + category === "exmnSurvey" + ? `exmnSurvey:${getRightPanelFeatureProperty(data, ["exmnFacilityId"])}:${getRightPanelFeatureProperty(data, ["manholePosition"])}` + : category === "pipe" && pipeParentTabId ? RIGHT_PANEL_SINKHOLE_PIPE_TAB_ID : `feature:${layerTypeName || "unknown"}:${featureId}`, kind: "feature", category, displayLabel: getRightPanelFeatureDisplayLabel(data, category), + surveyJobType, parentTabId: pipeParentTabId, featureData: data, lastActiveAt: now, @@ -5041,11 +5433,17 @@ const Map = () => { } const sinkholeChecked = checkedNodesData.some((node) => isSinkholeLayerNode(node)); setIsSinkholeChecked(sinkholeChecked); + const exmnPplChecked = checkedNodesData.some((node) => isExmnResultPplNode(node)); + const exmnMnhlChecked = checkedNodesData.some((node) => isExmnResultMnhlNode(node)); + setIsExmnPplChecked(exmnPplChecked); + setIsExmnMnhlChecked(exmnMnhlChecked); setActiveLayerSettingOrder((currentOrder) => { const activeKeys = [ waterDepthChecked ? "waterDepthLegend" : null, sinkholeChecked ? "sinkhole" : null, agedPipeChecked ? "agedPipe" : null, + exmnPplChecked ? "exmnPpl" : null, + exmnMnhlChecked ? "exmnMnhl" : null, ].filter((key): key is string => Boolean(key)); return [ @@ -5060,7 +5458,13 @@ const Map = () => { removeSinkholeLayer(); closeSinkholeDetail(); } - }, [clearFldngHstrySelection, closeSinkholeDetail, handleWaterDepthVisibility, removeFlowDirectionLayer, removeSinkholeLayer, retainFlowDirectionUsageCodes]); + if (!exmnPplChecked) { + removeExmnResultLayer("PPL"); + } + if (!exmnMnhlChecked) { + removeExmnResultLayer("MNHL"); + } + }, [clearFldngHstrySelection, closeSinkholeDetail, handleWaterDepthVisibility, removeExmnResultLayer, removeFlowDirectionLayer, removeSinkholeLayer, retainFlowDirectionUsageCodes]); const waterDepthLegendItems = useMemo( () => extractWaterDepthLegendItems(checkedNodesDataRef.current ?? []), @@ -5081,6 +5485,24 @@ const Map = () => { [sinkholeScope, sinkholeYearRange], ); + /** 침수위험지도 루트의 목록보기 버튼에서 전용 조회 팝업을 연다. */ + const openSbmrgDngrMapPopupWindow = useCallback(() => { + const popup = openWindowPopup(SBMRG_DNGR_MAP_POPUP_KEY as WindowPopupKey); + if (!popup) { + alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); + } + return popup; + }, []); + + /** 도시침수지도 루트의 목록보기 버튼에서 전용 조회 팝업을 연다. */ + const openFloodDngrMapPopupWindow = useCallback(() => { + const popup = openWindowPopup(FLOOD_DNGR_MAP_POPUP_KEY as WindowPopupKey); + if (!popup) { + alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); + } + return popup; + }, []); + const handleOpenLayerPopup = useCallback( (_layerNm: string, node?: any) => { if (isAgedPipeLayerNode(node)) { @@ -5091,6 +5513,14 @@ const Map = () => { openSinkholePopupWindow(); return false; } + if (isSbmrgDngrMapRootNode(node)) { + openSbmrgDngrMapPopupWindow(); + return false; + } + if (isFloodDngrMapRootNode(node)) { + openFloodDngrMapPopupWindow(); + return false; + } if (isFldngHstryNode(node)) { openFldngHstryPopupWindow(); return false; @@ -5098,7 +5528,13 @@ const Map = () => { return true; }, - [openAgedPipeList, openFldngHstryPopupWindow, openSinkholePopupWindow], + [ + openAgedPipeList, + openFldngHstryPopupWindow, + openFloodDngrMapPopupWindow, + openSbmrgDngrMapPopupWindow, + openSinkholePopupWindow, + ], ); // 현재 체크된 특수 레이어만 하단 설정영역에 모아 렌더링하기 위한 패널 목록이다. @@ -5109,6 +5545,36 @@ const Map = () => { return index >= 0 ? index : fallback; }; + if (isExmnPplChecked) { + nextItems.push({ + key: "exmnPpl", + label: "관로조사", + order: resolveSettingOrder("exmnPpl", 9995), + content: ( + + ), + }); + } + + if (isExmnMnhlChecked) { + nextItems.push({ + key: "exmnMnhl", + label: "맨홀조사", + order: resolveSettingOrder("exmnMnhl", 9996), + content: ( + + ), + }); + } + if (isSinkholeChecked) { nextItems.push({ key: "sinkhole", @@ -5185,8 +5651,12 @@ const Map = () => { applySinkholeRadius, applySinkholeYearRange, draftAgedPipeFilters, + exmnMnhlFilters, + exmnPplFilters, handleApplyAgedPipeFilters, isAgedPipeChecked, + isExmnMnhlChecked, + isExmnPplChecked, isSinkholeChecked, isWaterDepthLayerActive, lastSinkholeSyncSummary, @@ -5196,12 +5666,27 @@ const Map = () => { waterDepthLegendItems, ]); + // 설정값 변경으로 설정 컴포넌트가 다시 만들어져도 사용자가 보던 내부 스크롤 위치를 유지한다. + const activeLayerSettingScrollTopRef = useRef(0); + const handleActiveLayerSettingScroll = useCallback( + (event: UIEvent) => { + activeLayerSettingScrollTopRef.current = event.currentTarget.scrollTop; + }, + [], + ); + const restoreActiveLayerSettingScroll = useCallback((element: HTMLDivElement | null) => { + if (!element) return; + + const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight); + element.scrollTop = Math.min(activeLayerSettingScrollTopRef.current, maxScrollTop); + }, []); + // 선택된 설정이 있을 때만 하단 전용 섹션을 노출하고, 세로 공간이 부족하면 내부 스크롤로 처리한다. const ActiveLayerSettingView = useCallback(() => { if (activeLayerSettingItems.length === 0) return null; return ( -
+
선택 레이어 설정 @@ -5218,7 +5703,11 @@ const Map = () => { ))}
-
+
{activeLayerSettingItems.map((item) => ( {item.content} @@ -5227,7 +5716,11 @@ const Map = () => {
); - }, [activeLayerSettingItems]); + }, [ + activeLayerSettingItems, + handleActiveLayerSettingScroll, + restoreActiveLayerSettingScroll, + ]); useEffect(() => { if (!mapReady || !gisMaps.current) return; @@ -5973,6 +6466,7 @@ const Map = () => { { setGisEditDetailChangeId(null); closeActiveTab(); @@ -6971,6 +7465,34 @@ const Map = () => { } }, [gisEdit.activeTool, gisEdit.isEditMode, removeGisEditDrawInteraction]); + useEffect(() => { + if (!mapReady || !isExmnPplChecked) { + removeExmnResultLayer("PPL"); + return; + } + void loadExmnResultLayer("PPL", exmnPplFilters); + }, [ + exmnPplFilters, + isExmnPplChecked, + loadExmnResultLayer, + mapReady, + removeExmnResultLayer, + ]); + + useEffect(() => { + if (!mapReady || !isExmnMnhlChecked) { + removeExmnResultLayer("MNHL"); + return; + } + void loadExmnResultLayer("MNHL", exmnMnhlFilters); + }, [ + exmnMnhlFilters, + isExmnMnhlChecked, + loadExmnResultLayer, + mapReady, + removeExmnResultLayer, + ]); + useEffect(() => { if (!mapReady || !isSinkholeChecked) { removeSinkholeLayer(); @@ -7267,7 +7789,11 @@ const Map = () => { treeTogglePosition="center" zoomGuide={zoomGuide} layerIconVisible={(node: any) => - isAgedPipeLayerNode(node) || isSinkholeRootNode(node) || isFldngHstryNode(node) + isAgedPipeLayerNode(node) + || isSinkholeRootNode(node) + || isSbmrgDngrMapRootNode(node) + || isFloodDngrMapRootNode(node) + || isFldngHstryNode(node) } defaultZoom={7.5} diff --git a/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/page.tsx b/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/page.tsx new file mode 100644 index 000000000..d1721ebe7 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/page.tsx @@ -0,0 +1,6 @@ +import { ObsltPplListPage } from "@/features/obsltPpl/ui/ObsltPplListPage"; + +/** 노후하수관로 정밀조사·정비사업 목록 경로다. */ +export default function Page() { + return ; +} diff --git a/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/register/[id]/page.tsx b/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/register/[id]/page.tsx new file mode 100644 index 000000000..1f8f5e481 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/register/[id]/page.tsx @@ -0,0 +1,7 @@ +import { ObsltPplFormPage } from "@/features/obsltPpl/ui/ObsltPplFormPage"; + +/** 노후하수관로 사업 수정·삭제 경로다. */ +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ; +} diff --git a/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/register/page.tsx b/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/register/page.tsx new file mode 100644 index 000000000..ab92afe46 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/register/page.tsx @@ -0,0 +1,6 @@ +import { ObsltPplFormPage } from "@/features/obsltPpl/ui/ObsltPplFormPage"; + +/** 노후하수관로 사업 신규 등록 경로다. */ +export default function Page() { + return ; +} diff --git a/apps/sn/app/components/ui/ctpvSggPrcsFcltSelectBox.tsx b/apps/sn/app/components/ui/ctpvSggPrcsFcltSelectBox.tsx index 238cf83ee..ee48a048f 100644 --- a/apps/sn/app/components/ui/ctpvSggPrcsFcltSelectBox.tsx +++ b/apps/sn/app/components/ui/ctpvSggPrcsFcltSelectBox.tsx @@ -39,6 +39,8 @@ interface CtpvSggPrcsFcltSelectBoxProps { setIsLoading?: (isLoading: boolean) => void; /** 좁은 조회·입력 행에서 시도·시군구 선택 폭과 간격을 줄여 표시한다. */ compact?: boolean; + /** 화면별 배경에 맞춰 시도·시군구 선택값의 글자색 등을 직접 지정한다. */ + selectValueClassName?: string; } const defaultOption: ApiSelectOption = { val: '0', text: '전체' }; @@ -62,6 +64,7 @@ export function CtpvSggPrcsFcltSelectBox({ defaultSwtrmtFcltNm, setIsLoading, compact = false, + selectValueClassName = "", }: CtpvSggPrcsFcltSelectBoxProps) { const [prcsFcltOptions, setPrcsFcltOptions] = useState([]); @@ -138,7 +141,7 @@ export function CtpvSggPrcsFcltSelectBox({ 시군구 opt.val.startsWith(sidoPrefix))]} value={sigungu} diff --git a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md index fc7f6fdbc..b691a2ba7 100644 --- a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md +++ b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md @@ -230,3 +230,48 @@ | 다운로드 | 관로·맨홀 다운로드 | 종합집계 1개와 처리분구 상세 N개 시트 생성 | | 과거 URL | `/oper/exmn/register?sample=Y` 진입 | 파라미터를 무시하고 실제 API 데이터 표시 | | 지도보기 | 버튼 클릭 | 보류 안내 유지 | + +## 17. 지도보기 이동·조사결과 레이어 검증 계획 + +| 검증 항목 | 방법 | 기대 결과 | +|---|---|---| +| 지도 진입 | 조사 상세현황에서 관로 또는 맨홀 선택 후 지도보기 | `/map`으로 이동하고 선택 조사유형이 최초 체크됨 | +| 행정구역 초기화 | 경기도·하남시 사업에서 지도 진입 | 지도 검색조건에 시도·시군구가 표시되고 해당 지역으로 이동 | +| 레이어 트리 | 지도 왼쪽 레이어 확인 | `조사결과 > 관로조사 / 맨홀조사` 표시 | +| 자동 필터 | 사업차수·구조·운영 등급 변경 | 적용 버튼 없이 해당 레이어가 다시 조회됨 | +| AND 조건 | 구조·운영 등급을 함께 선택 | 두 조건을 모두 만족하는 시설만 표시 | +| 관로 등급 | 소수점 평균등급 데이터 조회 | 정수부 등급 기준으로 필터링 | +| 동시 레이어 | 관로조사와 맨홀조사를 모두 체크 | 두 레이어가 함께 표시되고 설정값은 독립 유지 | +| 체크 해제 | 각 조사 레이어 체크 해제 | 해당 벡터 레이어가 지도에서 제거 | +| 빠른 조건 변경 | 필터를 연속 변경 | 이전 응답이 마지막 조건 결과를 덮어쓰지 않음 | +| 회귀 | 기존 일반·분석 레이어 선택 | 기존 레이어 설정·선택 동작 유지 | + +## 18. 지도 우측 조사관리 탭 검증 계획 + +| 검증 항목 | 방법 | 기대 결과 | +|---|---|---| +| 전용 탭 | PPL·MNHL 조사 레이어 피처 클릭 | 노란 `관로조사(번호)` 또는 보라 `맨홀조사(번호)` 탭 생성 | +| 기존 정보 | 조사관리 탭 내용 확인 | 기본정보와 입력정보가 일반 시설 상세와 동일하게 표시 | +| 조사 결과 | 입력정보 하단 확인 | 사업·처리분구·보고서번호·구조/운영 등급 표시 | +| 관로 보고서 | PPL의 조사보고서 보기 클릭 | 해당 관로 페이지 범위 PDF가 공통 WindowPopup에 표시 | +| 미등록 보고서 | 보고서 미등록 PPL 클릭 | 오류 안내 후 빈 창 닫힘 | +| 맨홀 정책 | MNHL 조사관리 탭 확인 | PDF 버튼과 별도 보류 안내 모두 미표시 | +| 탭 독립성 | 일반 관로와 조사 관로를 차례로 클릭 | 두 탭이 서로 덮어쓰지 않음 | + +## 19. 2026-07-31 최종 통합 회귀 계획 + +| 검증 영역 | 확인 방법 | 기대 결과 | +|---|---|---| +| 조사관리 기본 기능 | 처리분구 CRUD부터 결과등록·상세·다운로드까지 대표 정상 시나리오 재수행 | 이전 단계에서 확인한 저장·조회·집계 결과 유지 | +| 지도 행정구역 | 경기도 하남시 사업의 지도보기 진입 | 시도·시군구가 모두 선택되고 대상 지역 및 대표 시설로 이동 | +| 레이어 표시 | 관로·맨홀을 각각 및 동시에 ON/OFF | 관로는 노란 선, 맨홀은 보라 맨홀 아이콘으로 표시 | +| 레이어 필터 | 사업차수와 구조·운영 등급을 연속 변경 | 마지막 조건만 반영되고 설정 스크롤 위치 유지 | +| 맨홀 지역 범위 | 타 시군구에 같은 맨홀번호가 존재하는 데이터로 조회 | 요청 시군구 앞 5자리의 맨홀만 반환 | +| 지도 탭 | 관로·맨홀 피처를 차례로 선택 | `관로조사(번호)`·`맨홀조사(번호)` 탭과 조사 결과 표시 | +| 일반 시설 회귀 | 일반 관로·맨홀 피처 선택 | 기존 기본정보·입력정보·유지관리 영역 유지 | +| 관로 보고서 | 페이지 범위 저장 전·후 각각 조회 | 최초 분석·저장 후 부분 PDF 표시, 재조회는 저장 범위 사용 | +| 관로 보고서 오류 | 첨부 없음·번호 판독 실패·선택 관로 미포함 | WindowPopup 본문에 업무 오류 표시 | +| 맨홀 보고서 | 맨홀 조사 탭 확인 | 협의 전까지 보고서 버튼과 보류 안내 모두 미표시 | + +- 레이어 API 단위 테스트는 실제 서비스가 호출하는 Repository 메서드와 동일한 메서드를 모킹해야 한다. +- 전체 빌드가 장시간 걸리는 환경에서는 변경 파일 정적 검사 후 백엔드 대상 테스트와 로그인 브라우저 회귀를 분리한다. 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 2f50b3095..ce2ca8bb5 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 @@ -496,3 +496,87 @@ - 백엔드 재기동 후 관로·맨홀 상세표에서 `우수관`, `흉관(HP)` 명칭을 확인한다. - 배수방식·관종 헤더 정렬이 표시 명칭 순으로 동작하는지 확인한다. - 클라이언트 상세 Excel과 서버 다중 시트 Excel에서 코드 대신 명칭이 출력되는지 확인한다. + +## 31. 2026-07-31 지도보기 이동·조사결과 레이어 정적 검증 + +| 검증 항목 | 결과 | 비고 | +|---|---|---| +| 지도 이동 query | 통과 | 사업·행정구역·조사유형·처리분구 전달 | +| 초기 행정구역 연계 | 통과 | 기존 지도 검색 이벤트 재사용 | +| 조사결과 트리 구성 | 통과 | 루트와 PPL·MNHL 가상 노드 구성 | +| 설정 조건 | 통과 | 사업차수·구조·운영 등급 3개만 제공 | +| 자동 조회 | 통과 | 설정 변경 시 레이어 재조회 | +| 레이어 분리 | 통과 | PPL 선·MNHL 점 소스와 필터 상태 독립 | +| 응답 순서 보호 | 통과 | 조사유형별 요청 순번 적용 | +| 변경 공백 검사 | 통과 | `git diff --check` | +| 대상 ESLint | 환경 제한 | pnpm 비대화형 설치 중단, 직접 eslint 모듈 접근 `EPERM` | +| 브라우저 수동 검증 | 대기 | 서버 재기동 후 실제 지도에서 확인 필요 | + +### 남은 수동 확인 + +- 조사관리에서 진입한 시도·시군구가 지도 검색조건과 중심 위치에 동일하게 반영되는지 확인한다. +- 관로조사·맨홀조사 레이어를 각각 또는 동시에 켜고 3개 조건의 자동 필터 결과를 대조한다. +- 조사시설을 클릭했을 때 기존 선택 모듈이 시설을 인식하는지 확인한다. +- 전용 오른쪽 조사관리 탭은 다음 구현 단계에서 별도로 검증한다. + +## 32. 2026-07-31 지도 우측 조사관리 탭 정적 검증 + +| 검증 항목 | 결과 | 비고 | +|---|---|---| +| 조사관리 탭 분기 | 통과 | `surveyOverlayType=EXMN_RESULT`를 일반 시설보다 우선 판별 | +| 탭 식별자 | 통과 | 조사시설물 ID와 맨홀 위치로 구성 | +| 조사 결과 주입 | 통과 | 관로·맨홀 입력정보 컴포넌트에 선택적 ReactNode 전달 | +| 관로 부분 PDF 호출 | 통과 | 사업 ID와 관로번호를 URL 인코딩하여 Blob 조회 | +| 맨홀 PDF 보류 | 통과 | 버튼과 별도 보류 안내 모두 미표시 | +| 변경 공백 검사 | 통과 | 프론트·백엔드 `git diff --check` | +| 브라우저 수동 검증 | 대기 | 개발 서버에서 실제 지도 선택과 팝업 허용 상태 확인 필요 | + +- 사용자 요청에 따라 전체 프론트 빌드와 장시간 Gradle 작업은 수행하지 않았다. + +## 33. 2026-07-31 조사관리 탭 유지관리 영역·부분 PDF 보정 + +| 검증 항목 | 결과 | 비고 | +|---|---|---| +| 조사관리 전용 표시 | 정적 통과 | 조사결과 아래 일반 유지관리 3개 영역 숨김 | +| 일반 관로 탭 회귀 | 정적 통과 | 조사관리 탭이 아닐 때 기존 유지관리 영역 유지 | +| 부분 관로 PDF | 정적 통과 | PDF에 포함된 활성 관로 쪽 범위만 저장 | +| PDF의 미등록 관로 | 정적 통과 | 활성 조사시설에 없는 번호가 있으면 저장 거부 | +| 선택 관로 PDF 누락 | 정적 통과 | 선택 관로 보고서 없음 메시지 반환 | +| 브라우저·단위 테스트 | 대기 | 백엔드 재기동 후 실제 첨부 PDF로 확인 필요 | + +## 34. 2026-07-31 최종 정적 회귀점검 + +| 검증 항목 | 결과 | 비고 | +|---|---|---| +| 처리분구·사업·결과등록 | 구현 확인 | 실제 API 호출과 저장 후 재조회 흐름 유지 | +| 종합·전체·본단위·결함별 조회 | 구현 확인 | PPL/MNHL 및 `ALL/STRUCTURAL/OPERATIONAL` 계약 유지 | +| 다중 시트 다운로드 | 구현 확인 | 종합 1 + 처리분구 상세 N, 본단위구간 제외 | +| 지도 이동·행정구역 | 구현 확인 | 시도·시군구 query와 공통 검색 이벤트 연결 | +| 지도 조사 레이어 | 구현 확인 | PPL 선·MNHL 점, 사업차수·구조·운영 등급 필터 | +| 조사유형 시각 구분 | 구현 확인 | 관로 노란색, 맨홀 보라색, 탭 명칭에 조사유형 표시 | +| 관로 부분 PDF | 구현 확인 | `exmnPipeReport` WindowPopup과 Blob URL 정리 | +| 일반 시설 회귀 | 정적 확인 | 조사 탭에서만 유지관리 영역을 숨김 | +| 변경 공백 검사 | 통과 | 프론트·백엔드 `git diff --check` | +| 전체 빌드·대상 테스트 | 미실행 | 장시간 실행 이력과 사용자 검증 방침에 따라 생략 | + +### 정적 보완 결과 + +1. `ExmnSurveyResultSection.tsx`의 `!p-0r`를 `!p-0`으로 수정했다. +2. 맨홀 공간조회에도 관로와 동일한 시군구코드 앞 5자리 제한을 적용했다. +3. `ExmnMapLayerServiceTest`의 맨홀 Repository 모킹 대상을 실제 서비스 호출 메서드와 지역 인자에 맞췄다. + +### 최종 수동 확인 + +- 로그인된 지도에서 관로·맨홀 동시 표시, 필터 연속 변경, 설정 스크롤 유지와 우측 탭 시각 구분을 확인한다. +- 실제 관로 PDF를 페이지 범위 미저장·저장 상태에서 각각 열어 WindowPopup 표시와 재사용 여부를 확인한다. +- 동일 맨홀번호가 여러 시군구에 있는 자료로 지역 제한 적용 후 반환 지점을 대조한다. + +## 35. 2026-07-31 관로 보고서 미등록 선분기 점검 + +| 검증 항목 | 결과 | 비고 | +|---|---|---| +| 지도 응답 첨부 여부 | 정적 통과 | `reportAvailable` 속성 전달 | +| 보고서 미등록 | 정적 통과 | WindowPopup 없이 alert 표시 | +| 보고서 등록 | 정적 통과 | 기존 `exmnPipeReport` WindowPopup 호출 | +| 부분 PDF 분석 실패 | 기존 동작 유지 | 팝업 내부 백엔드 오류 표시 | +| 브라우저 수동 검증 | 대기 | 보고서 등록·미등록 사업을 각각 선택해 확인 | diff --git a/apps/sn/docs/작업검증/SN-노후하수관로정비사업/001-qa-SN-노후하수관로정비사업-통합검증계획.md b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/001-qa-SN-노후하수관로정비사업-통합검증계획.md new file mode 100644 index 000000000..80ea08299 --- /dev/null +++ b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/001-qa-SN-노후하수관로정비사업-통합검증계획.md @@ -0,0 +1,20 @@ +# SN-노후하수관로정비사업 FE 통합검증계획 + +## 자동 검증 + +- `pnpm --filter sn run build` +- 신규 세 경로의 Next 산출물 생성 확인 +- 변경 파일 `git diff --check` + +## 브라우저 검증 + +1. 목록 전체 23개 열과 가로 스크롤 확인 +2. 검색·페이지 이동 확인 +3. 새 `_upload.xlsx`의 헤더 오류 없이 FE에서 517건 파싱되고 네트워크 요청이 JSON 배열인지 확인 +4. 엑셀다운로드 후 두 시트 및 재업로드 확인 +5. 등록 저장, 행 클릭 수정, 수정 화면 삭제 확인 +6. 목록에 삭제 버튼이 없고 수정 하단 버튼 순서가 `목록`, `저장`, `삭제`인지 확인 + +## 선행조건 + +로그인 세션, 메뉴 권한, sewerage-service, 생성된 `tb_sn_obslt_swage_ppl_biz`가 필요하다. diff --git a/apps/sn/docs/작업검증/SN-노후하수관로정비사업/002-qa-SN-노후하수관로정비사업-통합검증결과.md b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/002-qa-SN-노후하수관로정비사업-통합검증결과.md new file mode 100644 index 000000000..f97a4bd99 --- /dev/null +++ b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/002-qa-SN-노후하수관로정비사업-통합검증결과.md @@ -0,0 +1,17 @@ +# SN-노후하수관로정비사업 FE 통합검증결과 + +## 2026-08-03 + +- 신규 TS/TSX 파일 대상 ESLint를 실행했고 오류·경고 없이 통과했다. +- 선행 빌드에서 목록·등록·수정 세 경로의 `page.js` 산출을 확인했다. +- 독립 `tsc`는 저장소에 설치된 TypeScript가 `ignoreDeprecations: 6.0`을 지원하지 않아 환경 오류로 종료했다. +- 최종 전체 빌드는 신규 화면이 아닌 공통 `file-uploader`, `slider`, `switch`, `table`, `tabs` CSS 처리 중 Windows `EMFILE: too many open files`로 실패했다. +- 로그인·실 API 브라우저 검증은 실행환경 미기동으로 보류한다. +- 지역 검색 유지보수 후 `pnpm exec eslint apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx`를 실행해 오류·경고 없이 통과했다. + +## 2026-08-04 + +- 새 업로드 양식의 두 시트 B6:V9를 값 검사와 렌더링으로 확인했으며 수정 헤더가 정상 표시됨을 확인했다. +- 실제 FE 파서를 실행해 총 517건, 정밀조사 128건, 정비사업 389건을 변환하고 대표 사업코드·연장 값을 대조했다. +- 변경한 API·Excel 파서·목록 파일 대상 ESLint를 오류·경고 없이 통과했다. +- SN `next dev` 프로세스가 실행 중이어서 앱 규칙에 따라 전체 `next build`는 실행하지 않았다. diff --git a/apps/sn/docs/작업검증/SN-도시침수지도-정보제공포털-목록보기/001-qa-SN-도시침수지도-정보제공포털-목록보기-통합검증계획.md b/apps/sn/docs/작업검증/SN-도시침수지도-정보제공포털-목록보기/001-qa-SN-도시침수지도-정보제공포털-목록보기-통합검증계획.md new file mode 100644 index 000000000..c32b7dea4 --- /dev/null +++ b/apps/sn/docs/작업검증/SN-도시침수지도-정보제공포털-목록보기/001-qa-SN-도시침수지도-정보제공포털-목록보기-통합검증계획.md @@ -0,0 +1,67 @@ +# SN 도시침수지도(정보제공포털) 목록보기 통합 검증 계획 + +## 1. 문서 목적 + +- 도시침수지도 목록보기와 WMS·SLD 일반 표출의 정합성·성능·회귀 여부를 검증한다. + +## 2. 검증 범위 + +- `sm_bs_sn.tb_sn_lyr_stng` 부모 3건과 자식 15건 +- 30·50·80년 레이어 트리 ON/OFF +- TileWMS 요청 파라미터와 SLD 규칙 +- 기존 침수심도 범례 색상·투명도·선 두께 +- 기존 WFS 레이어 회귀 +- 목록 팝업과 행 선택 지도 이동 회귀 + +## 3. 검증 기준 + +| 영역 | 통과 기준 | +|---|---| +| 부모 설정 | 58·59·60이 `wms/Y`, 빈도 필터가 각각 `080/030/050` | +| 자식 설정 | 부모별 5건, 전체 `wms/N`, `N330~N334` 각 1건 | +| 화면 | 30·50·80년 각각 ON/OFF 즉시 반영 | +| WMS | `GetMap`, PNG, 투명 배경, EPSG:5179 | +| 빈도 필터 | 선택 빈도에 맞는 `CQL_FILTER` | +| SLD | 접두사 없는 NamedLayer와 침수심도 Rule 5개 | +| 스타일 | DB의 면색·선색·opacity·선 두께와 일치 | +| 회귀 | 다른 WFS 레이어와 목록 팝업 기능 유지 | +| 성능 | 전국 초기 진입 시 대용량 GeoJSON 파싱·벡터 렌더링 없음 | + +## 4. 검증 절차 + +### 4.1 정적·빌드 검증 + +1. SLD 생성 헬퍼에 자식 5건을 입력한다. +2. Rule 5개, 색상·opacity, NamedLayer, 누락·중복 차단을 확인한다. +3. 변경 파일 ESLint를 실행한다. +4. `@common/business_components`를 빌드한다. + +### 4.2 DB 검증 + +1. 부모 58·59·60의 필터·서비스·실제 레이어 여부를 조회한다. +2. 부모별 자식 수와 `wms/N` 개수를 집계한다. +3. `N330~N334`가 부모별 1건인지 확인한다. + +### 4.3 브라우저 검증 + +1. 30·50·80년을 각각 단독 ON/OFF한다. +2. Network에서 `GetMap`과 `tb_ms_flood_dngr_map`을 필터링한다. +3. `CQL_FILTER`가 각각 `030`, `050`, `080`인지 확인한다. +4. `SLD_BODY`에 `N330~N334`와 기존 색상값이 있는지 확인한다. +5. 응답 상태 200과 `image/png`을 확인한다. +6. 도시침수지도 관련 WFS `GetFeature`가 발생하지 않는지 확인한다. +7. 목록 팝업 검색·행 선택 지도 이동을 회귀 확인한다. + +## 5. 관측 포인트 + +- 논리 WMS 레이어 수와 타일 HTTP 요청 수를 구분한다. +- GeoServer가 SLD 레이어명을 무시하고 기본 검정 스타일을 반환하지 않는지 결과 이미지를 확인한다. +- 자식 체크 상태는 범례 유지용이며 지도 레이어 객체가 생성되면 실패다. + +## 6. 다음 검증 순서 + +- 개발환경 검증 완료 후 운영 프론트 배포 → 운영 DB DML → 운영 Network·체감 성능 순으로 확인한다. + +## 7. 흡수한 기존 문서 + +- 별도 SN 검증 계획 문서는 없었다. diff --git a/apps/sn/docs/작업검증/SN-도시침수지도-정보제공포털-목록보기/002-qa-SN-도시침수지도-정보제공포털-목록보기-통합검증결과.md b/apps/sn/docs/작업검증/SN-도시침수지도-정보제공포털-목록보기/002-qa-SN-도시침수지도-정보제공포털-목록보기-통합검증결과.md new file mode 100644 index 000000000..dd1fd7ccb --- /dev/null +++ b/apps/sn/docs/작업검증/SN-도시침수지도-정보제공포털-목록보기/002-qa-SN-도시침수지도-정보제공포털-목록보기-통합검증결과.md @@ -0,0 +1,69 @@ +# SN 도시침수지도(정보제공포털) 목록보기 통합 검증 결과 + +## 1. 문서 목적 + +- 2026-08-05 WMS·SLD 전환 구현의 실제 수행 검증과 현재 완료 상태를 기록한다. + +## 2. 수행한 검증 + +### 2.1 코드 검증 + +| 항목 | 결과 | 비고 | +|---|---|---| +| SLD Rule 생성 | 통과 | `N330~N334` 5개 | +| RGBA 변환 | 통과 | 색상과 fill/stroke opacity 분리 | +| 설정 누락·중복 차단 | 통과 | 정확히 5건이 아니면 생성 중단 | +| ESLint | 통과 | 오류 0건, 기존 import 정렬 경고만 존재 | +| `@common/business_components` 빌드 | 통과 | 의존 작업 포함 4/4 성공 | +| `git diff --check` | 통과 | whitespace 오류 없음 | + +### 2.2 DB 검증 + +| 부모 | 빈도 필터 | 부모 상태 | 자식 수 | 자식 WMS 수 | 자식 메타데이터 수 | 결과 | +|---:|---|---|---:|---:|---:|---| +| 58 | `080` | `wms/Y` | 5 | 5 | 5 | 정상 | +| 59 | `030` | `wms/Y` | 5 | 5 | 5 | 정상 | +| 60 | `050` | `wms/Y` | 5 | 5 | 5 | 정상 | + +- 각 부모의 자식은 `N330~N334`가 1건씩 존재했다. +- 자식의 `exprs_yn=N`, `actl_lyr_yn=N`, 기존 필터·색상·선 두께가 유지됐다. +- 100년 빈도는 전환 대상에 포함되지 않았다. + +### 2.3 화면·네트워크 검증 + +- 사용자가 30·50·80년 레이어 ON/OFF 정상 동작을 확인했다. +- 30년 실제 요청에서 다음 값을 확인했다. + - `SERVICE=WMS` + - `VERSION=1.1.1` + - `REQUEST=GetMap` + - `LAYERS=hasudo:tb_ms_flood_dngr_map` + - `SRS=EPSG:5179` + - `FORMAT=image/png` + - `TRANSPARENT=true` + - `CQL_FILTER=sbmrg_frq_expln='030'` + - SLD `NamedLayer/Name=tb_ms_flood_dngr_map` + - SLD Rule `N330~N334` 5개 + - DB와 동일한 채움색·경계선색·opacity·선 두께 + +## 3. 현재 검증 상태 + +- 개발 DB 전환, 화면 ON/OFF, 30년 WMS·SLD 요청, 공통 패키지 빌드까지 정상이다. +- 일반 표출의 최종 구조는 부모 3개 TileWMS와 자식 15개 SLD·범례 메타데이터로 확정한다. +- 기능 상태는 `완료`로 기록하고 운영 반영 확인은 배포 절차 점검으로 관리한다. + +## 4. 미수행·운영 확인 항목 + +- 50년·80년의 개별 Network 파라미터 캡처는 남아 있다. +- 응답 헤더의 `Content-Type=image/png` 별도 캡처는 남아 있다. +- 전체 SN Next 빌드는 실행 중인 개발 서버 보호를 위해 수행하지 않았다. +- 운영 환경의 WFS 미발생과 최초 화면 체감 성능은 운영 적용 직후 확인한다. + +## 5. 롤백 기준 + +- DB는 부모를 `wfs/N` 및 빈 필터로, 자식을 `wfs/Y`로 복원한다. +- 프론트는 `gis.wms-sld.js`와 WMS 연동 변경을 되돌리면 기존 WFS 구조로 복귀한다. +- 자식 필터·범례 색상값은 롤백 시에도 보존한다. + +## 6. 흡수한 기존 문서 + +- 별도 SN 검증 결과 문서는 없었다. 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 a734dac40..39b63f5f3 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 @@ -280,3 +280,59 @@ - `ExmnSurveyDetailPopupPage`의 데이터행과 정렬값은 `drainageTypeName || drainageType`, `pipeTypeName || pipeType`으로 구성한다. - 미등록 코드도 빈칸으로 숨기지 않고 코드 원문을 표시한다. - 완료 게이트는 관로·맨홀 전체·구조·운영 상세 화면에서 코드가 한글 명칭으로 표시되고, 명칭 정렬과 서버 XLSX가 동일한 값을 사용하는 것이다. + +## 22. 2026-07-30 관로 상세보기 식별자·등급 열 보정 + +- 상세보기 조회 조건의 `관리번호`, `관리번호 입력` 문구를 `관로번호`, `관로번호 입력`으로 통일한다. +- 관로 전체 상세표의 고정 열에 `구조적 내부결함`, `운영적 내부결함` 등급을 다시 포함한다. +- 화면은 상세 API의 `structuralGrade`, `operationalGrade`를 사용하며 프론트에서 등급 산정 공식을 중복 구현하지 않는다. +- 관로·맨홀의 데이터행, 소계·합계와 클라이언트 상세 Excel에 두 등급 열을 동일하게 구성한다. + +## 23. 2026-07-31 지도보기 진입·조사결과 레이어 연계 + +| 작업 단위 | 상태 | 범위 | +|---|---|---| +| 조사 상세현황 지도보기 이동 | 완료 | 선택 사업·조사유형·행정구역·처리분구 정보를 `/map` query로 전달 | +| 초기 지도 위치 동기화 | 완료 | 전달받은 시도·시군구 명칭을 기존 검색 조건 코드로 변환하고 기존 지도 검색 이벤트로 이동 | +| 조사결과 레이어 트리 | 완료 | `조사결과 > 관로조사 / 맨홀조사` 가상 제어 노드 추가 | +| 조사결과 설정 UI | 완료 | 사업차수·구조적 상태등급·운영적 상태등급 3개 조건을 적용 버튼 없이 자동 반영 | +| 조사결과 지도 레이어 | 완료 | PPL 선·MNHL 점 GeoJSON을 별도 벡터 레이어로 조회하고 체크 해제 시 제거 | +| 오른쪽 조사관리 탭 | 대기 | 선택 관로·맨홀의 조사결과 및 조사보고서 연결은 다음 단계 | + +- 구조적 상태등급과 운영적 상태등급을 함께 입력하면 백엔드 API의 `AND` 조건을 그대로 사용한다. +- 관로 평균등급의 소수점 절사는 백엔드 계약을 사용하며 프론트에서 중복 계산하지 않는다. +- 관로조사와 맨홀조사는 동시에 켤 수 있고 각 레이어의 필터 상태를 독립적으로 유지한다. +- 빠른 조건 변경 시 이전 응답이 최신 레이어를 덮어쓰지 않도록 요청 순서를 보호한다. + +## 24. 2026-07-31 지도 우측 조사관리 탭 + +| 작업 단위 | 상태 | 범위 | +|---|---|---| +| 조사관리 전용 탭 | 완료 | 조사결과 레이어 선택 시 `관로조사(번호)`·`맨홀조사(번호)` 탭 생성 | +| 기존 시설정보 재사용 | 완료 | 관로·맨홀 기본정보와 입력정보를 기존 상세 컴포넌트로 표시 | +| 조사 결과 표시 | 완료 | 사업명·사업차수·처리분구·조사유형·보고서번호·구조/운영 등급 표시 | +| 관로 조사보고서 열람 | 완료 | 선택 관로의 저장 페이지 범위를 PDF로 조회해 공통 WindowPopup에 표시 | +| 맨홀 조사보고서 | 보류 | 보고서 판별 기준 협의 후 연결 | + +- 조사 레이어 GeoJSON에는 기존 시설 상세 API 호출에 필요한 `fclt_mng_no`, `mng_inst_cd`가 포함되어야 한다. +- 조사관리 탭 식별자는 조사시설물 ID와 맨홀 위치 구분을 사용해 일반 관로 탭과 충돌하지 않도록 한다. + +## 25. 2026-07-31 프론트 최종 구현 상태 + +| 기능 단위 | 상태 | 최종 범위 | +|---|---|---| +| 처리분구 관리 | 완료 | 최초 조회, 그리드 직접 추가·수정, 저장·삭제 후 목록 동기화 | +| 조사사업 관리 | 완료 | 조회·정렬·행 선택, 등록·수정 WindowPopup, 조사·보고서 양식 다운로드 | +| 결과등록 | 완료 | 조사결과 Excel·관로/맨홀 PDF 검토 상태, 5종 파일 선택, 최종 저장 | +| 조사 상세현황 | 완료 | 관로·맨홀 종합집계, 처리분구 행 선택, 전체·구조·운영 상세 | +| 관로 본단위구간 | 완료 | 처리분구 전체 및 관로번호 검색, 단위구간별 행 표시 | +| 통합 다운로드 | 완료 | 종합집계 1개 + 처리분구 상세 N개 시트, 본단위구간 제외 | +| 지도 이동·레이어 | 구현 완료·통합 검증 필요 | 행정구역 이동, 관로·맨홀 레이어, 사업차수·등급 필터 | +| 지도 조사 탭 | 구현 완료·통합 검증 필요 | `관로조사(번호)`·`맨홀조사(번호)`, 기존 기본/입력정보와 조사결과 | +| 관로 부분 PDF | 구현 완료·통합 검증 필요 | 첨부 존재 시 공통 WindowPopup 표시, 미등록 시 팝업 없이 alert 안내 | +| 맨홀 부분 PDF | 보류 | 보고서 내 맨홀 판별·쪽 구간 기준 협의 필요 | + +- 지도 조사유형 표시는 관로 노란색, 맨홀 보라색을 사용한다. +- 지도 피처 선택 강조는 조사유형 색과 분리하고 기존 공통 지도 선택 효과를 유지한다. +- 관로 PDF는 새 브라우저 탭이 아니라 `exmnPipeReport` WindowPopup 안에서 표시한다. +- 최종 완료 판정 전에는 지도 관로·맨홀 동시 ON/OFF, 필터 변경, 시설 선택, 관로 PDF 성공·실패 흐름의 로그인 브라우저 회귀가 필요하다. diff --git a/apps/sn/docs/작업계획/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-작업카드.md b/apps/sn/docs/작업계획/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-작업카드.md new file mode 100644 index 000000000..00dee4cb5 --- /dev/null +++ b/apps/sn/docs/작업계획/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-작업카드.md @@ -0,0 +1,29 @@ +# SN-노후하수관로정비사업 FE 작업카드 + +## 목표 + +`/oper/obsltPplBiz`에 원본 Excel 전체 항목 목록과 검색·등록·업로드·엑셀다운로드 기능을 제공하고 등록·수정 화면을 분리한다. + +## 구현 계약 + +- 목록은 23개 컬럼을 모두 노출하고 `overflow-x-auto`로 가로 스크롤한다. +- 목록 행 클릭은 `/oper/obsltPplBiz/register/{id}`로 이동한다. +- 목록에는 삭제 버튼을 두지 않는다. +- 등록 화면 하단은 `목록`, `저장`; 수정 화면은 `목록`, `저장`, `삭제` 순서다. +- `apps/wr /matMng/coMat/ruseCaseMng`의 브라우저 Excel 파싱·JSON 저장 흐름을 참고하되 SN 전용 두 시트·병합 헤더 계약으로 구현한다. +- `apps/cs/banner/register`의 표형 폼과 별도 등록/수정 경로를 시각 참고한다. +- 목록 검색의 시도·시군구는 SN 앱 공통 `useStore`의 지역 목록을 사용하는 연계 `SelectBox`로 제공하고, 시도 변경 시 시군구를 초기화한다. + +## API 및 관측 포인트 + +- 목록 로딩·오류·업로드 결과 건수는 화면에서 문구로 관측 가능해야 한다. +- 업로드 input은 `.xlsx`만 받는다. +- FE는 `노후하수관로 정밀조사 및 정비사업 현황_upload.xlsx`의 두 시트와 B6:V7 핵심 헤더를 검증하고 B8:V 마지막 행을 JSON 저장 요청으로 변환한다. +- 다운로드 버튼명은 `엑셀다운로드`로 고정한다. +- 브라우저 검증 URL: `/oper/obsltPplBiz`, `/oper/obsltPplBiz/register`, `/oper/obsltPplBiz/register/{id}` + +## 제외 + +- 기존 `/oper/oldPp` 변경 +- `apps/cs` 또는 공통 컴포넌트 변경 +- 메뉴/권한 운영 데이터 변경 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 f8f759e3f..ca0327abe 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 @@ -687,3 +687,149 @@ - `src/features/exmn/model/exmn-detail-table.ts` - `src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx` + +## 35. 2026-07-31 지도보기 이동과 조사결과 레이어·필터 연결 + +### 구현 내용 + +- 조사 상세현황의 `지도보기` 버튼은 별도 안내 대신 `/map`으로 이동한다. +- 이동 query에는 조사사업 ID, 사업차수, 시도·시군구, 조사유형, 선택 처리분구 ID·명칭을 포함한다. +- 지도 진입 시 시도·시군구 명칭을 공통 행정구역 코드로 해석하고 기존 `SN_INFTWT_PROC_SEARCH` 이벤트를 사용해 검색조건 표시와 지도 위치를 함께 맞춘다. +- 기존 CMS 레이어 응답 뒤에 화면 제어용 `조사결과` 루트와 `CCTV조사`, `맨홀조사` 하위 노드를 추가했다. +- 조사관리에서 진입한 조사유형은 최초 체크 상태로 표시한다. +- 레이어 설정은 사업차수, 구조적 상태등급, 운영적 상태등급만 제공하며 값 변경 즉시 조회한다. +- PPL과 MNHL은 독립된 EPSG:5179 GeoJSON 벡터 소스·레이어를 사용한다. +- 시도·시군구 명칭을 공통 코드 목록으로 해석하고 시군구코드 앞 5자리 `sggCd`를 레이어 API에 전달해 전국 중복 관로번호를 구분한다. +- 행정구역 코드 목록이 아직 준비되지 않았으면 전국 범위 요청을 보내지 않고 코드 상태 갱신 후 다시 조회한다. +- 필터 변경 중 응답 순서가 뒤바뀌어도 최신 요청만 반영하도록 조사유형별 요청 순번을 관리한다. +- 체크 해제 시 해당 조사 레이어와 벡터 소스를 제거한다. + +### 화면·API 계약 + +| 구분 | 계약 | +|---|---| +| 지도 이동 | `/map?exmnBizId=...&ctpvNm=...&sggNm=...&exmnJobType=PPL|MNHL&bizCycl=...` | +| 레이어 조회 | `/sewerage-service/api/v1/exmnMap/layers` | +| 필터 | `jobType`, `sggCd`(앞 5자리), 행정구역명, 사업차수, 구조적 등급, 운영적 등급 | + +### 2026-07-31 지도보기 초기 시군구 선택·대표 관로 확대 보완 + +- 조사관리의 지도보기 진입 시 시도 코드만 먼저 적용하지 않고, 전달된 시군구명에 대응하는 시군구코드까지 준비된 뒤 공통 지도 검색바에 전달한다. +- 공통 지도 검색바는 외부 선택 이벤트가 시군구 원천 목록 조회보다 먼저 도착해도 목록 로딩 후 현재 시도에 맞는 시군구 후보를 다시 구성한다. +- 최초 조사결과 레이어 조회 성공 시 진입한 조사유형의 첫 시설 한 건을 기준으로 최대 줌 18까지 한 번만 확대한다. +- 이후 사용자의 지도 이동이나 필터 재조회에서는 초기 자동 확대를 반복하지 않는다. +| 필터 결합 | 구조·운영 등급 동시 입력 시 `AND` | +| 좌표계 | GeoJSON `EPSG:5179` | +| 레이어 선택 | 기존 지도 선택 모듈에 PPL 선·MNHL 점 레이어 등록 | + +### 수정 파일 + +- `src/features/exmn/ui/ExmnStatusDetailPanel.tsx` +- `app/(map-layout)/map/page.tsx` +- `app/(map-layout)/map/components/exmn/ExmnLayerSettingPanel.tsx` +- `app/(map-layout)/map/components/exmn/exmnMapApi.ts` +- `app/(map-layout)/map/components/exmn/exmnMapTypes.ts` + +### 남은 작업 + +- 지도에서 조사 관로·맨홀을 선택했을 때 기존 시설정보와 분리된 오른쪽 `조사관리` 탭을 연다. +- 조사관리 탭에 구조적·운영적 상태등급과 조사보고서 링크를 표시한다. +- 조사보고서 링크는 백엔드의 저장된 페이지 범위 및 부분 PDF 조회 API와 연결한다. + +## 2026-07-31 지도 우측 조사관리 탭·관로 보고서 연결 + +- 조사결과 GeoJSON 피처를 선택하면 기존 일반 시설 탭이 아닌 `조사관리(관로번호)` 전용 탭을 생성한다. +- 조사관리 탭은 기존 `FeatureClickEventPopup`과 관로·맨홀 입력정보 컴포넌트를 재사용한다. +- 기존 입력정보 바로 아래에 조사 결과 영역을 주입해 사업명, 사업차수, 처리분구, 조사유형, 보고서번호, 구조적·운영적 결함 등급을 표시한다. +- 조사관리 전용 탭에서는 일반 시설용 보수 및 갱신·청소 및 준설·점검 및 진단 영역을 숨긴다. +- PPL 조사결과에는 `조사보고서 보기` 버튼을 표시하고 관로별 부분 PDF API를 호출한다. +- PDF 요청 전에 새 창을 먼저 열어 브라우저 팝업 차단을 최소화하고 응답 Blob URL을 새 창에 연결한다. +- 관로 보고서 조회 실패 시 공통 Blob 오류 문자열 대신 백엔드의 사용자 안내 메시지만 표시한다. +- MNHL 조사보고서는 판별 기준 협의 전까지 버튼을 제공하지 않으며, 별도의 보류 안내 문구도 화면에 표시하지 않는다. + +### 수정 파일 + +- `app/(map-layout)/map/page.tsx` +- `app/(map-layout)/map/components/exmn/ExmnSurveyResultSection.tsx` +- `app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx` +- `app/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx` +- `app/(map-layout)/map/components/popupComponent/smh.tsx` +- `app/(map-layout)/map/components/common/index.ts` +- `src/features/exmn/api/exmn-business-api.ts` + +### 2026-07-31 지도 조사결과 패널 시각 통일 + +- 지도 우측 조사관리 탭의 `조사 결과` 제목, 여백, 표 라벨 셀, 글꼴과 글자 크기를 기존 `입력정보`의 `VerticalTable` 규칙과 동일하게 적용했다. +- 조사보고서 버튼은 기존 입력정보 헤더의 버튼 높이와 글꼴 규칙을 따르면서 다운로드 계열의 초록색을 유지했다. +- PPL 조사유형과 레이어의 사용자 표시 명칭을 `CCTV조사`에서 `관로조사`로 통일했다. +- 기존 DB 레이어명이 `CCTV조사`인 환경도 계속 탐색할 수 있도록 이전 명칭을 호환 키로 유지하고, 화면에는 `관로조사`로 정규화한다. +- 조사결과 레이어 트리의 관로 심볼은 노란색 선(`#facc15`)으로 표시한다. +- 맨홀 심볼은 기존 맨홀 분류의 파랑·빨강·초록·갈색·주황·회색과 겹치지 않는 보라색을 사용하되, 기존 `SWL_MANH_PS_BLUE.png`의 맨홀 형태와 음영을 그대로 유지하는 색상 변형 아이콘으로 통일했다. +- 선택 레이어 설정 카드의 조사유형 표식도 관로조사는 노란색(`#facc15`), 맨홀조사는 보라색(`#8b5cf6`)으로 표시해 레이어 범례·지도 피처와 색상 의미를 통일했다. +- 지도 우측 상세 패널의 다중 선택 탭은 `관로조사(관로번호)`와 `맨홀조사(관로번호)` 형식으로 표시하고, 관로 탭은 노란색 계열, 맨홀 탭은 보라색 계열로 구분한다. +- 선택 레이어 설정 카드의 사업차수·상태등급 입력 폭은 `54px`로 조정해 카드 안의 정보 밀도와 우측 여백을 보정했다. +- 선택 레이어 설정의 사업차수·구조적 상태등급·운영적 상태등급을 변경할 때 설정 컴포넌트가 다시 렌더링되어도 내부 세로 스크롤 위치를 저장·복원해 사용자가 보던 설정 카드 위치를 유지한다. +- 맨홀조사 지도 피처도 등급색 단순 원형 대신 레이어 트리와 동일한 보라색 맨홀 아이콘을 사용한다. +- 지도 피처의 선택 영역은 조사유형 탭·범례 색상과 별개로 기존 지도 공통 선택 효과를 그대로 사용한다. +- 레이어 ON/OFF로 설정 카드 수가 바뀌어도 선택 레이어 설정 영역은 고정 높이와 항상 확보된 세로 스크롤 공간을 유지해 인접 스크롤바의 표시·영역 크기가 흔들리지 않도록 했다. + +#### 수정 파일 + +- `app/(map-layout)/map/page.tsx` +- `app/(map-layout)/map/components/exmn/ExmnLayerSettingPanel.tsx` +- `app/(map-layout)/map/components/exmn/ExmnSurveyResultSection.tsx` +- `app/(map-layout)/map/components/exmn/exmnMapTypes.ts` +- `public/images/map/layericon/SN_EXMN_MANHOLE_PURPLE.png` + +### 2026-07-31 관로 조사보고서 WindowPopup 뷰어 전환 + +- 지도 우측 조사관리 탭의 `조사보고서 보기`는 PDF Blob URL을 브라우저 원본 탭에 직접 여는 대신 공통 WindowPopup `exmnPipeReport`를 연다. +- 전용 팝업은 기존 관로별 부분 PDF API를 호출하고 Blob URL을 내부 `iframe`에 표시한다. +- 팝업을 닫거나 조회 대상을 변경하면 생성한 Blob URL을 해제해 브라우저 메모리가 누적되지 않도록 했다. +- 조회 실패 시 새 탭을 닫고 `alert`를 띄우던 방식 대신 WindowPopup 본문에 백엔드 사용자 안내 메시지를 표시한다. +- 백엔드 API와 DB 계약은 변경하지 않았다. + +#### 수정 파일 + +- `app/(map-layout)/map/components/exmn/ExmnSurveyResultSection.tsx` +- `src/features/windowPopup/model/popupDefinitions.ts` +- `src/features/windowPopup/registry.tsx` +- `src/features/windowPopup/ui/ExmnPipeReportPopupContent.tsx` + +### 2026-07-31 결과등록 검토 오류 중복 안내 제거 + +- 파일 데이터 검토 실패 사유는 해당 파일 행의 실패 상태와 하단 `데이터 검토 내역`에만 표시한다. +- 같은 검토 오류를 팝업 상단 전역 빨간 알림으로 다시 표시하던 중복 안내를 제거했다. +- 파일 미선택, 사업정보 조회, 최종 저장처럼 하단 검토 내역으로 대신할 수 없는 오류는 기존 상단 안내를 유지한다. +- `데이터 검토 내역` 헤더 우측의 보조 문구를 제거해 검토 결과에 시선이 집중되도록 정리했다. +- 결과등록 WindowPopup 기본 높이를 `500px`에서 `650px`로 확대해 최초 진입 시 하단 검토 내역이 잘리지 않도록 보정했다. + +## 36. 2026-07-31 최종 기능 점검과 문서 정합성 + +### 점검 결과 + +- 처리분구 CRUD, 조사사업 조회·등록·수정, 조사·보고서 양식 다운로드, 결과등록, PPL/MNHL 종합집계와 상세조회, 본단위구간, 구조·운영 결함별 조회, 다중 시트 다운로드는 실제 API 기준으로 연결돼 있다. +- 조사 상세현황 지도보기는 사업·행정구역·조사유형 정보를 `/map`으로 전달하고, 지도에서 관로·맨홀 레이어와 독립 필터를 사용한다. +- 지도 우측 탭은 `관로조사(번호)`·`맨홀조사(번호)`로 구분하며 관로는 노란색 계열, 맨홀은 보라색 계열로 표시한다. +- 관로 조사보고서는 공통 WindowPopup 내부 PDF 뷰어로 표시하고, 맨홀 보고서는 판별 기준 확정 전까지 버튼을 표시하지 않는다. +- 지도 피처 선택 강조는 조사유형 범례 색을 덮어쓰지 않고 기존 공통 지도 선택 효과를 사용한다. + +### 정적 점검 보완 결과 + +- `ExmnSurveyResultSection.tsx`의 무효 Tailwind 유틸리티 `!p-0r`를 `!p-0`으로 바로잡아 조사결과 카드 내부 여백 의도가 적용되도록 했다. +- 백엔드 맨홀 공간조회는 관로와 동일하게 시군구코드 앞 5자리를 사용하도록 보완했다. +- `ExmnMapLayerServiceTest`의 맨홀 Repository 모킹을 실제 서비스 호출 메서드와 `sggCd=41450` 조건에 맞췄다. + +### 검증 범위 + +- 프론트·백엔드 `git diff --check`는 공백 오류 없이 통과했다. +- 긴 프론트 전체 빌드와 Gradle 컴파일·테스트는 사용자 요청과 기존 장시간 실행 이력에 따라 수행하지 않았다. +- 코드 정적 보완은 완료했으며 로그인 브라우저에서 지도 관로·맨홀 선택, 필터, 탭, 관로 부분 PDF를 한 번에 회귀 확인해야 한다. + +## 37. 2026-07-31 관로 조사보고서 팝업 선분기 + +- 기존에는 `조사보고서 보기` 클릭 즉시 WindowPopup을 열고 팝업 내부에서 부분 PDF API를 호출했다. +- 지도 GeoJSON의 `reportAvailable` 속성을 먼저 확인하도록 변경했다. +- 관로 조사보고서 첨부파일이 없으면 WindowPopup을 만들지 않고 `등록된 관로 조사보고서가 없습니다.` alert만 표시한다. +- 첨부파일이 있으면 기존 `exmnPipeReport` WindowPopup을 열어 부분 PDF를 조회한다. +- 첨부는 있지만 선택 관로의 페이지 분석·추출이 실패하는 경우에는 기존처럼 팝업 내부에 백엔드 오류를 표시한다. diff --git a/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md b/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md new file mode 100644 index 000000000..88c913db3 --- /dev/null +++ b/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md @@ -0,0 +1,35 @@ +# SN-노후하수관로정비사업 FE 통합구현기록 + +## 입력 근거 + +사용자 작업지시 Excel, 공통 통합설계, `/oper/oldPp`, 조사관리 업로드, `apps/cs/banner/register`를 확인했다. + +## 2026-08-03 구현 + +- `src/features/obsltPpl`에 API, 타입, 목록, 표형 등록·수정 화면을 추가했다. +- `app/(with-base-layout)/oper/obsltPplBiz`에 목록·등록·동적 수정 경로를 추가했다. +- 목록에서 Excel 전체 항목과 사업구분·시군구코드를 표시하고 직접 구성 Table을 가로 스크롤 영역에 배치했다. +- 목록 액션을 `조회`, `등록`, `업로드`, `엑셀다운로드`로 구성하고 삭제는 수정 화면으로 이동했다. +- 업로드 성공 시 전체·정밀조사·정비사업 저장 건수를 표시하고 목록을 재조회한다. +- 기존 공통 또는 타 앱 소스는 변경하지 않았다. + +## BE 공유 계약 + +기준 API는 `/api/v1/obsltPplBiz`이며 목록은 Spring Page, 업로드는 FE 파싱 결과 JSON 배열, 다운로드는 XLSX Blob이다. + +## 남은 리스크 + +로그인·메뉴 권한이 구성된 통합환경 브라우저 검증이 필요하다. + +## 2026-08-03 지역 검색 유지보수 + +- 목록의 시도·시군 입력을 공통 `SelectBox`로 교체했다. +- SN 앱 공통 Zustand store의 `ctpv`, `sgg`, `fetchCtpv`, `fetchSgg`를 사용하고 선택 시 기존 검색 API 계약에 맞는 한글명을 설정한다. +- 선택한 시도의 코드 앞 두 자리를 기준으로 시군구 후보를 제한하고 시도 변경 시 기존 시군구 선택을 초기화한다. + +## 2026-08-04 Excel 업로드 FE 파싱 전환 + +- `apps/wr /matMng/coMat/ruseCaseMng` 흐름을 참고하여 `.xlsx` 읽기와 값 파싱 책임을 FE로 이동했다. +- `lib/obslt-ppl-excel.ts`에서 두 필수 시트, B6:V7 핵심 헤더, 필수 사업명, 순번·연장 숫자를 검증한다. +- `노후하수관로 정밀조사 및 정비사업 현황_upload.xlsx`의 517개 행을 저장 요청 JSON으로 변환한 뒤 `/excel/upload`에 `application/json`으로 전송한다. +- 기존 multipart `FormData` 전송과 `postMutiPart` 의존을 제거했다. diff --git a/apps/sn/docs/작업내용/SN-도시침수지도-정보제공포털-목록보기/001-fe-SN-도시침수지도-정보제공포털-목록보기-통합구현기록.md b/apps/sn/docs/작업내용/SN-도시침수지도-정보제공포털-목록보기/001-fe-SN-도시침수지도-정보제공포털-목록보기-통합구현기록.md new file mode 100644 index 000000000..b89b7604e --- /dev/null +++ b/apps/sn/docs/작업내용/SN-도시침수지도-정보제공포털-목록보기/001-fe-SN-도시침수지도-정보제공포털-목록보기-통합구현기록.md @@ -0,0 +1,83 @@ +# SN 도시침수지도(정보제공포털) 목록보기 통합 구현 기록 + +## 1. 문서 목적 + +- 도시침수지도 목록보기와 일반 지도 표출의 현재 구현 상태를 기능 단위로 통합 기록한다. +- 본 문서에는 2026-08-05 maintenance에서 확정된 WMS·SLD 전환을 최종 일반 표출 기준으로 기록한다. + +## 2. 입력 근거 + +- 공통 설계: `IIPBS/docs/작업계획/SN-도시침수지도-정보제공포털-목록보기/001-de-SN-도시침수지도-정보제공포털-목록보기-설계.md` +- WMS·SLD 매뉴얼: `IIPBS/docs/작업계획/공통-GeoServer-WMS-SLD적용/001-공통-GeoServer-WMS-SLD적용-매뉴얼.md` +- 레이어 설정: `sm_bs_sn.tb_sn_lyr_stng` +- 대상 레이어: `tb_ms_flood_dngr_map` + +## 3. 공통 계약 + +- 화면 노출 빈도는 30년, 50년, 80년이며 100년은 추가하지 않는다. +- 부모 59·60·58은 각각 빈도 `030`, `050`, `080`의 실제 WMS 레이어다. +- 자식 15건은 `actlLyrYn='N'`으로 유지하며 침수심도 SLD와 범례의 메타데이터로 사용한다. +- 백엔드는 기존 레이어 설정 응답 계약을 유지한다. + +## 4. 구현 라운드 요약 + +### 4.1 2026-08-04 목록보기 구현 + +- 도시침수지도 목록 팝업, 검색·페이징·정렬, 그룹 범위 이동을 구현했다. +- 목록 데이터와 범위 조회는 대용량 원본 GeoJSON을 직접 반환하지 않는 구조로 정리했다. +- 이 라운드의 일반 지도 WFS 복원 기록은 2026-08-05 WMS 전환으로 대체한다. + +### 4.2 2026-08-05 일반 지도 WMS·SLD 전환 + +- `gis.wms-sld.js` + - 부모 번호로 자식 레이어 설정 5건을 선별하고 순서를 정렬한다. + - 자식 필터에서 `N330~N334`를 추출해 SLD `Rule` 5개를 만든다. + - RGBA를 16진 색상과 opacity로 변환하고 XML 특수문자를 이스케이프한다. + - 정확히 5건의 유효 설정이 아닐 때 `null`을 반환해 잘못된 스타일 표출을 차단한다. +- `gis.layer.js` + - TileWMS 요청에 `CQL_FILTER`, `SLD_BODY`, PNG 투명 배경을 적용한다. + - SLD 내부 레이어명은 접두사 없는 `tb_ms_flood_dngr_map`을 사용한다. + - WMS 레이어의 `drawOrdr`, `minViewSize`, 문자열 ID를 정상 반영한다. +- `gis.core.js` + - SLD 검증 실패로 WMS 레이어가 생성되지 않은 경우 지도 추가를 안전하게 중단한다. +- `GisTree.js` + - 실제 WMS 부모를 자동 재생 대상에 포함한다. + - 숨겨진 자식은 범례 메타데이터로 유지하되 실제 레이어와 자동 재생 대상에서는 제외한다. + +## 5. 기능별 현재 상태 + +| 기능 | 현재 상태 | +|---|---| +| 목록 팝업 | 기존 구현 유지 | +| 행 선택 지도 이동 | 기존 그룹 범위 이동 유지 | +| 일반 지도 통신 | 빈도 부모 3개 TileWMS | +| 빈도 제한 | 부모 `flterSql`을 WMS `CQL_FILTER`로 전달 | +| 침수심도 스타일 | 자식 5건 기반 동적 SLD | +| 범례 | 숨겨진 자식 설정의 기존 색상·명칭 유지 | +| 100년 빈도 | 화면 및 WMS 전환 대상에서 제외 | +| 기존 다른 레이어 | 기존 WFS 경로 유지 | + +## 6. BE 공유 사항 + +- 신규 API나 응답 필드는 필요하지 않다. +- 레이어 설정 DTO의 `flterSql`, `upperLyrSn`, `lnColor`, `polgnColor`, `lnWdth`를 계속 제공해야 한다. +- DB 전환 DML은 공통 `IIPBS/docs/DB작업.sql`에서 관리한다. + +## 7. 검증 요약 + +- SLD 단위검사 통과 +- ESLint 오류 0건 +- `@common/business_components` 빌드 성공 +- 개발 DB 부모 3건·자식 15건 정합성 정상 +- 30·50·80년 ON/OFF 정상 +- 30년 WMS 요청과 SLD 규칙 5개 정상 + +## 8. 남은 리스크 + +- TileWMS는 논리 레이어 1개당 화면 타일 수만큼 `GetMap`을 요청하므로 HTTP 요청 개수를 정확히 3개로 판단하면 안 된다. +- 운영 프록시의 URL 길이 제한과 GeoServer `SLD_BODY` 허용 설정은 운영 반영 시 재확인한다. +- 전체 SN Next 빌드는 개발 서버 종료 후 별도 수행한다. + +## 9. 흡수한 기존 문서 + +- 별도 SN 기능 문서는 없었다. 공통 2026-08-04 바이브코딩 기록의 목록보기 핵심 상태를 본 문서에 요약했다. diff --git a/apps/sn/public/images/map/layericon/SN_EXMN_MANHOLE_PURPLE.png b/apps/sn/public/images/map/layericon/SN_EXMN_MANHOLE_PURPLE.png new file mode 100644 index 000000000..deb36d084 Binary files /dev/null and b/apps/sn/public/images/map/layericon/SN_EXMN_MANHOLE_PURPLE.png differ diff --git a/apps/sn/src/features/exmn/api/exmn-business-api.ts b/apps/sn/src/features/exmn/api/exmn-business-api.ts index 29b429ed9..e1b3827d8 100644 --- a/apps/sn/src/features/exmn/api/exmn-business-api.ts +++ b/apps/sn/src/features/exmn/api/exmn-business-api.ts @@ -9,6 +9,7 @@ import type { ExmnImportValidationResult, ExmnJobType, ExmnPage, + ExmnPipeReportAvailability, ExmnSectionSummaryRow, ExmnTemplateResult, PplFacility, @@ -285,7 +286,25 @@ export const downloadExmnReportTemplate = async (): Promise => export const downloadExmnBusinessReport = async (attachmentId: string): Promise => getBlobData(`/attach-service/api/v1/download/${encodeURIComponent(attachmentId)}`); +/** 저장된 페이지 범위를 이용해 선택 관로의 조사보고서 구간만 PDF로 조회한다. */ +export const downloadExmnPipeReport = async ( + exmnBizId: string, + pipeNumber: string, +): Promise => + getBlobData( + `${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/pipeReports/${encodeURIComponent(pipeNumber)}`, + ); + /** 사업 파일별 첨부서비스 키 쌍을 조사사업에 연결한다. */ +/** 팝업 생성 전에 선택 관로번호가 등록된 관로 PDF에 포함되는지 확인한다. */ +export const getExmnPipeReportAvailability = async ( + exmnBizId: string, + pipeNumber: string, +): Promise => + getData( + `${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/pipeReports/${encodeURIComponent(pipeNumber)}/availability`, + ) as Promise; + export const updateExmnAttachments = async ( exmnBizId: string, attachments: Partial>, 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 442d5485c..fee3a4a76 100644 --- a/apps/sn/src/features/exmn/lib/exmn-business-validation.ts +++ b/apps/sn/src/features/exmn/lib/exmn-business-validation.ts @@ -8,7 +8,7 @@ export const EXMN_MAX_FILE_SIZE = 200 * 1024 * 1024; /** 조사사업 파일별 화면 표시명과 허용 확장자다. */ export const EXMN_FILE_RULES: Record = { - EXMN_EXCEL: { label: "조사 결과", extensions: ["xlsx", "xlsm"] }, + EXMN_EXCEL: { label: "조사 결과", extensions: ["xlsx"] }, PPL_EXMN_PDF: { label: "CCTV 조사 결과(관로)", extensions: ["pdf"] }, MNHL_EXMN_PDF: { label: "CCTV 조사 결과(맨홀)", extensions: ["pdf"] }, BIZ_REPORT: { label: "사업보고서", extensions: ["pdf"] }, diff --git a/apps/sn/src/features/exmn/model/exmn-business-types.ts b/apps/sn/src/features/exmn/model/exmn-business-types.ts index 3047ef80a..b5e3cbdda 100644 --- a/apps/sn/src/features/exmn/model/exmn-business-types.ts +++ b/apps/sn/src/features/exmn/model/exmn-business-types.ts @@ -115,6 +115,12 @@ export interface ExmnImportCommitResult { summaryCount: number; } +/** 선택 관로의 조사보고서 팝업을 열 수 있는지 확인한 결과다. */ +export interface ExmnPipeReportAvailability { + available: boolean; + message?: string | null; +} + /** 처리분구별 PPL 조사 집계 행이다. */ export interface PplSummaryRow { trtdstId: string; diff --git a/apps/sn/src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx b/apps/sn/src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx index 63a2374f6..b722efb90 100644 --- a/apps/sn/src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx @@ -27,6 +27,10 @@ import type { TrtdstSettingDto } from "../model/trtdst-types"; const DOWNLOAD_BUTTON_CLASS = "rounded-sm px-4 py-2 text-sm font-medium !text-white disabled:!text-white"; +const FORM_LABEL_CLASS = + "shrink-0 whitespace-nowrap text-xl font-medium text-gray-600"; +const FORM_INPUT_CLASS = + "h-[3.2rem] text-base text-label"; type Notice = { type: "success" | "error" | "info"; text: string } | null; @@ -334,7 +338,7 @@ export function ExmnBusinessRegisterPopupPage({ diff --git a/apps/sn/src/features/exmn/ui/ExmnResultRegisterPopupPage.tsx b/apps/sn/src/features/exmn/ui/ExmnResultRegisterPopupPage.tsx index da45702ab..d0b99d0cb 100644 --- a/apps/sn/src/features/exmn/ui/ExmnResultRegisterPopupPage.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnResultRegisterPopupPage.tsx @@ -26,8 +26,12 @@ import type { ExmnImportValidationResult, } from "../model/exmn-business-types"; -const ACTION_BUTTON_CLASS = - "rounded-sm bg-blue-600 px-3 py-1.5 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white"; +const POPUP_BUTTON_SIZE_CLASS = + "h-10 w-[9.6rem] rounded-sm px-4 !text-[1.2rem] font-medium"; +const POPUP_ACTION_BUTTON_CLASS = + `${POPUP_BUTTON_SIZE_CLASS} !text-white disabled:!text-white`; +const FIND_BUTTON_CLASS = + "inline-flex h-10 cursor-pointer items-center justify-center rounded-sm bg-blue-600 px-3 !text-[1.2rem] font-medium !text-white hover:bg-blue-700"; const FILE_TYPES: ExmnFileType[] = [ "EXMN_EXCEL", @@ -283,7 +287,7 @@ export function ExmnResultRegisterPopupPage({ ...current, [fileType]: [validationMessage], })); - setNotice({ type: "error", text: validationMessage }); + setNotice(null); return; } @@ -303,16 +307,7 @@ export function ExmnResultRegisterPopupPage({ ...current, [fileType]: result.errors.map((item) => item.message), })); - const visibleErrors = result.errors.slice(0, 5).map((item) => item.message); - if (result.errors.length > visibleErrors.length) { - visibleErrors.push(`외 ${result.errors.length - visibleErrors.length}건`); - } - setNotice(result.valid - ? null - : { - type: "error", - text: `검토 오류 ${result.errors.length}건\n${visibleErrors.join("\n")}`, - }); + setNotice(null); } catch (error) { const message = error instanceof Error ? error.message : "조사파일 검토 중 오류가 발생했습니다."; setValidationResults((current) => { @@ -325,10 +320,7 @@ export function ExmnResultRegisterPopupPage({ ...current, [fileType]: [message], })); - setNotice({ - type: "error", - text: message, - }); + setNotice(null); } finally { setIsLoading(false); } @@ -375,8 +367,8 @@ export function ExmnResultRegisterPopupPage({ + ) : -}
@@ -506,9 +498,8 @@ export function ExmnResultRegisterPopupPage({ {visibleValidationTypes.length > 0 && (
-
-

데이터 검토 내역

- 파일별로 실제 확인한 항목입니다. +
+

데이터 검토 내역

{visibleValidationTypes.map((fileType) => { const status = fileValidationStatuses[fileType]!; @@ -523,10 +514,10 @@ export function ExmnResultRegisterPopupPage({ return (
- + {EXMN_FILE_RULES[fileType].label}
{status === "success" && countText && ( -

{countText}

+

{countText}

)} -

+

{guide?.checkedItems.join(" · ")}

{guide?.pendingNote && ( @@ -560,7 +551,7 @@ export function ExmnResultRegisterPopupPage({ )} {uploadStatus && ( -

{uploadStatus}

+

{uploadStatus}

)}
diff --git a/apps/sn/src/features/exmn/ui/ExmnStatusDetailPanel.tsx b/apps/sn/src/features/exmn/ui/ExmnStatusDetailPanel.tsx index 9f2c24be3..1948e57ce 100644 --- a/apps/sn/src/features/exmn/ui/ExmnStatusDetailPanel.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnStatusDetailPanel.tsx @@ -241,8 +241,21 @@ export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps) )); }; - const showMapPendingNotice = () => { - setNotice({ type: "info", text: "지도보기는 현재 보류 기능이며 버튼 진입점만 표시합니다." }); + /** 선택 사업의 행정구역·사업차수·현재 조사유형을 하수관망도 초기 조회 계약으로 전달한다. */ + const openExmnMap = () => { + if (!business) return; + const query = new URLSearchParams({ + exmnBizId: business.exmnBizId, + ctpvNm: business.ctpvNm, + sggNm: business.sggNm, + exmnJobType: jobType, + }); + if (business.bizCycl != null) query.set("bizCycl", String(business.bizCycl)); + if (selectedSummaryRow) { + query.set("trtdstId", selectedSummaryRow.trtdstId); + query.set("trtdstNm", selectedSummaryRow.trtdstNm); + } + window.location.assign(`/map?${query.toString()}`); }; /** 현재 조사유형의 종합집계와 처리분구별 전체 상세 시트를 서버 XLSX로 내려받는다. */ @@ -359,7 +372,13 @@ export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps) > 상세보기 - +