diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx index 2539f4df3..c4c3db13c 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx @@ -1,12 +1,14 @@ "use client"; import { useEffect, useState } from "react"; -import type { AgedPipeFilters } from "./agedPipeUtils"; +import { validateElapsedYears, type AgedPipeFilters } from "./agedPipeUtils"; + +type MaybePromise = T | Promise; type Props = { filters: AgedPipeFilters; onFiltersChange: (filters: AgedPipeFilters) => void; - onApply: (filters?: AgedPipeFilters) => void; + onApply: (filters?: AgedPipeFilters) => MaybePromise; compact?: boolean; }; @@ -23,51 +25,68 @@ export default function AgedPipeLayerSettingPanel({ setElapsedYearsText(String(filters.elapsedYears ?? "")); }, [filters.elapsedYears]); - const commitElapsedYears = () => { + const commitElapsedYears = async () => { + const elapsedYears = elapsedYearsText === "" ? NaN : Number(elapsedYearsText); + if (!validateElapsedYears(elapsedYears)) { + alert("경과년도는 1년~100년 범위 내에서 입력해주세요."); + return; + } + const nextFilters = { ...filters, - elapsedYears: elapsedYearsText === "" ? ("" as unknown as number) : Number(elapsedYearsText), + elapsedYears, }; onFiltersChange(nextFilters); - onApply(nextFilters); + await onApply(nextFilters); + alert(`노후관로 경과년도 ${elapsedYears}년으로 반영되었습니다.`); }; + const inputClass = "h-[22px] border border-[#ccd6df] px-[2px] text-center text-[1rem] leading-none text-[#222]"; + const numberInputClass = inputClass + " w-[40px]"; + const applyButtonClass = "grid h-[20px] w-[20px] shrink-0 place-items-center rounded-full bg-[#31a83b] text-[0.95rem] font-bold leading-none text-white"; + const indicator = ; + + const settingRow = ( +
+
+ {indicator} + +
+
+ event.currentTarget.select()} + onChange={(event) => { + const digits = event.target.value.replace(/[^0-9]/g, ""); + setElapsedYearsText(digits); + }} + onKeyDown={(event) => { + if (event.key === "Enter") void commitElapsedYears(); + }} + /> + +
+
+ ); + if (compact) { return ( -
-
-
- - -
-
- event.currentTarget.select()} - onChange={(event) => { - const digits = event.target.value.replace(/[^0-9]/g, ""); - setElapsedYearsText(digits); - }} - onKeyDown={(event) => { - if (event.key === "Enter") commitElapsedYears(); - }} - /> - -
-
+
+ {settingRow}
); } @@ -81,41 +100,14 @@ export default function AgedPipeLayerSettingPanel({ onClick={() => setIsCollapsed((value) => !value)} title={isCollapsed ? "펼치기" : "접기"} > - {isCollapsed ? "›" : "⌄"} + {isCollapsed ? "+" : "-"} 노후관로 설정
{!isCollapsed && (
-
- - event.currentTarget.select()} - onChange={(event) => { - const digits = event.target.value.replace(/[^0-9]/g, ""); - setElapsedYearsText(digits); - }} - onKeyDown={(event) => { - if (event.key === "Enter") commitElapsedYears(); - }} - /> - -
+ {settingRow}
)} diff --git a/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholeLayerSettingPanel.tsx b/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholeLayerSettingPanel.tsx index bd345b79e..0c60d535a 100644 --- a/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholeLayerSettingPanel.tsx +++ b/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholeLayerSettingPanel.tsx @@ -1,11 +1,20 @@ "use client"; import { useEffect, useState } from "react"; -import type { SinkholeSyncSummary } from "./sinkholeTypes"; +import { + normalizeSinkholeYearRange, + validateSinkholeYearRange, + type SinkholeSyncSummary, + type SinkholeYearRange, +} from "./sinkholeTypes"; + +type MaybePromise = T | Promise; type Props = { radiusMeters: number; - onApplyRadius: (radiusMeters: number) => void; + onApplyRadius: (radiusMeters: number) => MaybePromise; + yearRange: SinkholeYearRange; + onApplyYearRange: (yearRange: SinkholeYearRange) => MaybePromise; lastSyncSummary?: SinkholeSyncSummary | null; isAdmin?: boolean; compact?: boolean; @@ -14,59 +23,158 @@ type Props = { export default function SinkholeLayerSettingPanel({ radiusMeters, onApplyRadius, + yearRange, + onApplyYearRange, lastSyncSummary, isAdmin = false, compact = false, }: Props) { const [isCollapsed, setIsCollapsed] = useState(false); const [radiusText, setRadiusText] = useState(String(radiusMeters)); + const [yearRangeDraft, setYearRangeDraft] = useState(() => + normalizeSinkholeYearRange(yearRange), + ); useEffect(() => { setRadiusText(String(radiusMeters)); }, [radiusMeters]); - const commitRadius = () => { + useEffect(() => { + setYearRangeDraft(normalizeSinkholeYearRange(yearRange)); + }, [yearRange]); + + const commitRadius = async () => { const nextValue = Number(radiusText.replace(/[^0-9]/g, "")); if (!Number.isFinite(nextValue) || nextValue <= 0) { alert("반경은 1m 이상 입력해야 합니다."); return; } - onApplyRadius(nextValue); + + try { + await onApplyRadius(nextValue); + alert(`지반침하반경은 ${nextValue}m로 반영되었습니다.`); + } catch { + // 적용 함수 내부에서 실패 사유를 안내한다. + } }; + const commitYearRange = async () => { + const normalized = normalizeSinkholeYearRange(yearRangeDraft, yearRange); + const validationMessage = validateSinkholeYearRange(normalized); + if (validationMessage) { + alert(validationMessage); + return; + } + + try { + await onApplyYearRange(normalized); + alert(`시작년도 ${normalized.startYear} ~ 종료년도 ${normalized.endYear} 반영되었습니다.`); + } catch { + alert("반영 중 오류가 발생했습니다."); + } + }; + + const updateYearRangeDraft = (key: keyof SinkholeYearRange, value: string) => { + setYearRangeDraft((current) => ({ + ...current, + [key]: value.replace(/[^0-9]/g, "").slice(0, 4), + })); + }; + + const inputClass = "h-[22px] border border-[#ccd6df] px-[2px] text-center text-[1rem] leading-none text-[#222]"; + const numberInputClass = inputClass + " w-[40px]"; + const applyButtonClass = "grid h-[20px] w-[20px] shrink-0 place-items-center rounded-full bg-[#31a83b] text-[0.95rem] font-bold leading-none text-white"; + const indicator = ; + + const settingRows = ( +
+
+
+ {indicator} + +
+
+ event.currentTarget.select()} + onChange={(event) => setRadiusText(event.target.value.replace(/[^0-9]/g, ""))} + onKeyDown={(event) => { + if (event.key === "Enter") void commitRadius(); + }} + /> + +
+
+
+
+ {indicator} + + event.currentTarget.select()} + onChange={(event) => updateYearRangeDraft("startYear", event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") void commitYearRange(); + }} + /> + ~ +
+
+ + event.currentTarget.select()} + onChange={(event) => updateYearRangeDraft("endYear", event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") void commitYearRange(); + }} + /> + +
+
+
+ ); + if (compact) { return ( -
-
-
- - -
-
- event.currentTarget.select()} - onChange={(event) => setRadiusText(event.target.value.replace(/[^0-9]/g, ""))} - onKeyDown={(event) => { - if (event.key === "Enter") commitRadius(); - }} - /> - -
-
+
+
{settingRows}
); } @@ -80,38 +188,14 @@ export default function SinkholeLayerSettingPanel({ onClick={() => setIsCollapsed((value) => !value)} title={isCollapsed ? "펼치기" : "접기"} > - {isCollapsed ? "›" : "⌄"} + {isCollapsed ? "+" : "-"} 지반침하정보
{!isCollapsed && (
-
- - event.currentTarget.select()} - onChange={(event) => setRadiusText(event.target.value.replace(/[^0-9]/g, ""))} - onKeyDown={(event) => { - if (event.key === "Enter") commitRadius(); - }} - /> - -
+
{settingRows}
{isAdmin && lastSyncSummary ? (
diff --git a/apps/sn/app/(map-layout)/map/components/sinkhole/sinkholeTypes.ts b/apps/sn/app/(map-layout)/map/components/sinkhole/sinkholeTypes.ts index 8a344d4cd..eecd5606c 100644 --- a/apps/sn/app/(map-layout)/map/components/sinkhole/sinkholeTypes.ts +++ b/apps/sn/app/(map-layout)/map/components/sinkhole/sinkholeTypes.ts @@ -6,8 +6,6 @@ import type { IUserInfo } from "@/shared/store"; export const SINKHOLE_CONTROL_NODE_ID = "SN_SINKHOLE"; export const SINKHOLE_LAYER_TYPE = "tb_sn_sinkhole_info"; export const SINKHOLE_LAYER_NAME = "지반침하정보"; -export const SINKHOLE_ALL_YEAR_NODE_ID = "SN_SINKHOLE_YEAR_ALL"; -export const SINKHOLE_YEAR_NODE_PREFIX = "SN_SINKHOLE_YEAR_"; export const SINKHOLE_POPUP_KEY = "sinkholePopup"; export const SINKHOLE_ROW_SELECT_MESSAGE = "SN_SINKHOLE_ROW_SELECT"; export const SINKHOLE_FILTER_SYNC_MESSAGE = "SN_SINKHOLE_FILTER_SYNC"; @@ -16,8 +14,7 @@ export const SINKHOLE_DEFAULT_RADIUS = 100; export const SINKHOLE_LAYER_ID = "SN_SINKHOLE_LAYER"; export const SINKHOLE_SELECTED_LAYER_ID = "SN_SINKHOLE_SELECTED_LAYER"; export const SINKHOLE_MIN_SEARCH_YMD = "20160101"; -export const SINKHOLE_YEAR_ICON_ORANGE = "SN_SINKHOLE_SPOT_ORANGE.png"; -export const SINKHOLE_YEAR_ICON_BLUE = "SN_SINKHOLE_SPOT_BLUE.png"; +export const SINKHOLE_MIN_SEARCH_YEAR = Number(SINKHOLE_MIN_SEARCH_YMD.slice(0, 4)); export type SinkholeFilters = { startYmd: string; @@ -88,9 +85,68 @@ export type SinkholeScope = { sggLocked: boolean; }; +export type SinkholeYearRange = { + startYear: string; + endYear: string; +}; + const toYmd = (date: Date) => `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, "0")}${String(date.getDate()).padStart(2, "0")}`; +export const buildSinkholeDefaultYearRange = (baseDate = new Date()): SinkholeYearRange => { + const currentYear = baseDate.getFullYear(); + return { + startYear: String(currentYear - 1), + endYear: String(currentYear), + }; +}; + +export const normalizeSinkholeYearRange = ( + range?: Partial | null, + fallback: SinkholeYearRange = buildSinkholeDefaultYearRange(), +): SinkholeYearRange => ({ + startYear: String(range?.startYear ?? fallback.startYear).replace(/[^0-9]/g, "").slice(0, 4), + endYear: String(range?.endYear ?? fallback.endYear).replace(/[^0-9]/g, "").slice(0, 4), +}); + +export const validateSinkholeYearRange = ( + range: SinkholeYearRange, + currentYear = new Date().getFullYear(), +) => { + const startYear = Number(range.startYear); + const endYear = Number(range.endYear); + + if (!/^\d{4}$/.test(range.startYear) || !/^\d{4}$/.test(range.endYear)) { + return "시작년도와 종료년도는 4자리 숫자로 입력해야 합니다."; + } + if (startYear < SINKHOLE_MIN_SEARCH_YEAR || endYear < SINKHOLE_MIN_SEARCH_YEAR) { + return `${SINKHOLE_MIN_SEARCH_YEAR}년 이후 범위로 입력해야 합니다.`; + } + if (startYear > endYear) { + return "시작년도는 종료년도보다 클 수 없습니다."; + } + if (endYear > currentYear) { + return `종료년도는 현재년도(${currentYear})를 초과할 수 없습니다.`; + } + + return null; +}; + +export const buildSinkholeFiltersFromYearRange = ( + range: SinkholeYearRange, + scope?: SinkholeScope, +): SinkholeFilters => { + const normalized = normalizeSinkholeYearRange(range); + return { + startYmd: `${normalized.startYear}0101`, + endYmd: `${normalized.endYear}1231`, + ctpvCd: scope?.ctpvCd || "0", + sggCd: scope?.sggCd || "0", + restoreStatus: "0", + coordStatus: "0", + }; +}; + export const buildSinkholeDefaultFilters = (scope?: SinkholeScope): SinkholeFilters => { const today = new Date(); const oneYearAgo = new Date(today); @@ -106,15 +162,6 @@ export const buildSinkholeDefaultFilters = (scope?: SinkholeScope): SinkholeFilt }; }; -export const buildSinkholeTreeBaseFilters = (scope?: SinkholeScope): SinkholeFilters => ({ - startYmd: "", - endYmd: "", - ctpvCd: scope?.ctpvCd || "0", - sggCd: scope?.sggCd || "0", - restoreStatus: "0", - coordStatus: "0", -}); - export const buildSinkholeScope = (userInfo: IUserInfo | null): SinkholeScope => { const admin = userInfo?.adminRoleYn === "Y"; if (admin) { @@ -239,8 +286,6 @@ export const isSinkholeLayerNode = (node: any) => { return ( nodeId === SINKHOLE_CONTROL_NODE_ID || - nodeId === SINKHOLE_ALL_YEAR_NODE_ID || - nodeId.startsWith(SINKHOLE_YEAR_NODE_PREFIX) || layerName === SINKHOLE_LAYER_NAME || layerTypeName === SINKHOLE_LAYER_TYPE ); @@ -253,192 +298,6 @@ export const isSinkholeRootNode = (node: any) => { return nodeId === SINKHOLE_CONTROL_NODE_ID || layerName === SINKHOLE_LAYER_NAME; }; -export const isSinkholeYearNode = (node: any) => { - const data = node?.data ?? node; - const nodeId = String(node?.id ?? data?.lyrSn ?? ""); - const nodeText = String(node?.text ?? data?.lyrNm ?? data?.name ?? "").trim(); - return ( - nodeId === SINKHOLE_ALL_YEAR_NODE_ID || - nodeId.startsWith(SINKHOLE_YEAR_NODE_PREFIX) || - nodeText === "전체" || - /^\d{4}년$/.test(nodeText) - ); -}; - -const resolveSinkholeYearToken = (node: any) => { - const data = node?.data ?? node; - const explicitYear = String(data?.sinkholeYear ?? "").trim(); - if (explicitYear) { - return explicitYear === "ALL" || /^\d{4}$/.test(explicitYear) ? explicitYear : ""; - } - - const nodeText = String(node?.text ?? data?.lyrNm ?? data?.name ?? "").trim(); - if (nodeText === "전체") { - return "ALL"; - } - - const yearMatch = nodeText.match(/^(\d{4})년$/); - return yearMatch?.[1] ?? ""; -}; - -export const resolveSinkholeLegendIconName = (node: any) => { - const sinkholeYear = resolveSinkholeYearToken(node); - if (sinkholeYear === "ALL") { - return SINKHOLE_YEAR_ICON_BLUE; - } - if (/^\d{4}$/.test(sinkholeYear)) { - return SINKHOLE_YEAR_ICON_ORANGE; - } - return null; -}; - -export const buildSinkholeYearNodes = (currentYear = new Date().getFullYear()) => { - return [ - { - lyrSn: `${SINKHOLE_YEAR_NODE_PREFIX}${currentYear}`, - id: `${SINKHOLE_YEAR_NODE_PREFIX}${currentYear}`, - lyrNm: `${currentYear}년`, - upperLyrSn: SINKHOLE_CONTROL_NODE_ID, - upId: SINKHOLE_CONTROL_NODE_ID, - sortOrdr: "1", - drawOrdr: null, - flterSql: null, - gobjTy: "", - lyrTyNm: SINKHOLE_LAYER_TYPE, - sinkholeYear: String(currentYear), - iconNm: SINKHOLE_YEAR_ICON_ORANGE, - useYn: "Y", - minViewSize: null, - iconSize: null, - lblAtrbNm: null, - fontNm: null, - fontIntnColor: null, - fontExtnColor: null, - fontExtnWdth: null, - lnWdth: null, - lnColor: null, - polgnColor: null, - initEprssYn: "Y", - clickPsblYn: "N", - exprsYn: "Y", - cmncNm: "all", - srvcNm: "api", - actlLyrYn: "N", - clckZoom: null, - children: null, - }, - { - lyrSn: SINKHOLE_ALL_YEAR_NODE_ID, - id: SINKHOLE_ALL_YEAR_NODE_ID, - lyrNm: "전체", - upperLyrSn: SINKHOLE_CONTROL_NODE_ID, - upId: SINKHOLE_CONTROL_NODE_ID, - sortOrdr: "99", - drawOrdr: null, - flterSql: null, - gobjTy: "", - lyrTyNm: SINKHOLE_LAYER_TYPE, - sinkholeYear: "ALL", - iconNm: SINKHOLE_YEAR_ICON_BLUE, - useYn: "Y", - minViewSize: null, - iconSize: null, - lblAtrbNm: null, - fontNm: null, - fontIntnColor: null, - fontExtnColor: null, - fontExtnWdth: null, - lnWdth: null, - lnColor: null, - polgnColor: null, - initEprssYn: "Y", - clickPsblYn: "N", - exprsYn: "Y", - cmncNm: "all", - srvcNm: "api", - actlLyrYn: "N", - clckZoom: null, - children: null, - }, - ]; -}; - -export const applySinkholeNodeFilters = (filters: SinkholeFilters, node: any) => { - const data = node?.data ?? node; - const sinkholeYear = resolveSinkholeYearToken(node); - const filterSql = String(data?.flterSql ?? data?.filterSql ?? "").trim(); - - if (sinkholeYear === "ALL") { - return { - ...filters, - startYmd: "", - endYmd: "", - }; - } - - const rangeMatch = filterSql.match( - /ocrn_ymd\s*>=\s*'(\d{8})'.*ocrn_ymd\s*<=\s*'(\d{8})'/i, - ); - if (rangeMatch) { - return { - ...filters, - startYmd: rangeMatch[1] ?? "", - endYmd: rangeMatch[2] ?? "", - }; - } - - if (/^\d{4}$/.test(sinkholeYear)) { - return { - ...filters, - startYmd: `${sinkholeYear}0101`, - endYmd: `${sinkholeYear}1231`, - }; - } - - return filters; -}; - -export const applySinkholeYearFilters = (filters: SinkholeFilters, checkedNodesData: any[]) => { - const selectedYears = checkedNodesData - .map((node) => resolveSinkholeYearToken(node)) - .filter(Boolean); - - if (selectedYears.includes("ALL")) { - return filters; - } - - if (selectedYears.length === 0) { - return filters; - } - - const numericYears = selectedYears - .map((year) => Number(year)) - .filter((year) => Number.isFinite(year)) - .sort((left, right) => left - right); - - if (numericYears.length === 0) { - return filters; - } - - const yearStart = `${numericYears[0]}0101`; - const yearEnd = `${numericYears[numericYears.length - 1]}1231`; - const nextStart = filters.startYmd ? (filters.startYmd > yearStart ? filters.startYmd : yearStart) : yearStart; - const nextEnd = filters.endYmd ? (filters.endYmd < yearEnd ? filters.endYmd : yearEnd) : yearEnd; - - if (nextStart > nextEnd) { - return { - ...filters, - startYmd: "99991231", - endYmd: "99991231", - }; - } - - return { - ...filters, - startYmd: nextStart, - endYmd: nextEnd, - }; -}; export const SINKHOLE_RESTORE_STATUS_OPTIONS = [ { value: "0", label: "전체" }, diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index c7d3bd35c..5a8f1feee 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -82,18 +82,14 @@ import SinkholeDetailPanel from "./components/sinkhole/SinkholeDetailPanel"; import SinkholeLayerSettingPanel from "./components/sinkhole/SinkholeLayerSettingPanel"; import FloodHistoryDetailPanel from "./components/floodHistory/FloodHistoryDetailPanel"; import { - applySinkholeNodeFilters, - applySinkholeYearFilters, - buildSinkholeYearNodes, - buildSinkholeDefaultFilters, - buildSinkholeTreeBaseFilters, - resolveSinkholeLegendIconName, + buildSinkholeDefaultYearRange, + buildSinkholeFiltersFromYearRange, buildSinkholeListQueryString, buildSinkholeScope, isSinkholeLayerNode, isSinkholeRootNode, - isSinkholeYearNode, normalizeSinkholeFilters, + normalizeSinkholeYearRange, SINKHOLE_CONTROL_NODE_ID, SINKHOLE_DEFAULT_RADIUS, SINKHOLE_FILTER_SYNC_MESSAGE, @@ -109,6 +105,7 @@ import { type SinkholeListItem, type SinkholeNearbyPipe, type SinkholeSyncSummary, + type SinkholeYearRange, } from "./components/sinkhole/sinkholeTypes"; import { FLOOD_HISTORY_AREA_LAYER_ID, @@ -305,42 +302,7 @@ const SINKHOLE_POINT_PALETTE = { type SinkholePointVariant = keyof typeof SINKHOLE_POINT_PALETTE; -const getSinkholeYearValue = (ocrnYmd?: string | null) => String(ocrnYmd ?? "").trim().slice(0, 4); - -const resolveSinkholeActiveYearTokens = (checkedNodesData: any[]) => - checkedNodesData - .map((node) => node?.data ?? node) - .map((data) => { - const explicit = String(data?.sinkholeYear ?? "").trim(); - if (/^\d{4}$/.test(explicit)) return explicit; - const nodeText = String(data?.lyrNm ?? data?.name ?? "").trim(); - const match = nodeText.match(/^(\d{4})년$/); - return match?.[1] ?? ""; - }) - .filter(Boolean); - -const resolveSinkholePointVariant = ( - ocrnYmd?: string | null, - checkedNodesData: any[] = [], -): SinkholePointVariant => { - const year = getSinkholeYearValue(ocrnYmd); - const selectedTokens = checkedNodesData - .map((node) => node?.data ?? node) - .map((data) => String(data?.sinkholeYear ?? data?.lyrNm ?? data?.name ?? "").trim()); - - if (selectedTokens.includes("ALL") || selectedTokens.includes("전체")) { - const activeYears = resolveSinkholeActiveYearTokens(checkedNodesData); - const highlightYear = activeYears[0] ?? String(new Date().getFullYear()); - return year === highlightYear ? "orange" : "blue"; - } - - const activeYears = resolveSinkholeActiveYearTokens(checkedNodesData); - if (activeYears.length > 0) { - return activeYears.includes(year) ? "orange" : "blue"; - } - - return year === String(new Date().getFullYear()) ? "orange" : "blue"; -}; +const resolveSinkholePointVariant = (_ocrnYmd?: string | null): SinkholePointVariant => "blue"; // 현재 지도 화면 BBOX 주변을 함께 조회해 이동 직후 가장자리 유하방향이 비어 보이는 현상을 줄인다. const expandFlowDirectionExtent = (extent: number[]) => { @@ -855,27 +817,51 @@ const findInvalidPipeRequiredChange = (changes: Array<{ changeId: string; editTy return !mngInstCd || !fcltMngNo || /^NEW-/i.test(fcltMngNo) || !instMngNo || /^NEW-/i.test(instMngNo) || !usgCd; }); +const getSinkholeSelectionValue = (data: any, key: string) => + data?.[key] ?? data?.values_?.[key] ?? data?.get?.(key) ?? data?.properties?.[key]; + const getSinkholeAccidentId = (data: any) => - String( - data?.acdntNo ?? - data?.values_?.acdntNo ?? - data?.get?.("acdntNo") ?? - data?.properties?.acdntNo ?? - "", - ).trim(); + String(getSinkholeSelectionValue(data, "acdntNo") ?? "").trim(); + +const getSinkholeFallbackDetailFromSelection = (data: any): Partial | null => { + const acdntNo = getSinkholeAccidentId(data); + if (!acdntNo) return null; + + const point = getSinkholePointFromSelection(data); + return { + acdntNo, + ctpvNm: String(getSinkholeSelectionValue(data, "ctpvNm") ?? ""), + sggNm: String(getSinkholeSelectionValue(data, "sggNm") ?? ""), + emdNm: String(getSinkholeSelectionValue(data, "emdNm") ?? ""), + daddr: String(getSinkholeSelectionValue(data, "daddr") ?? ""), + fullAddress: String(getSinkholeSelectionValue(data, "fullAddress") ?? getSinkholeSelectionValue(data, "daddr") ?? ""), + ocrnYmd: String(getSinkholeSelectionValue(data, "ocrnYmd") ?? ""), + restoreStatus: String(getSinkholeSelectionValue(data, "restoreStatus") ?? ""), + restoreCompletedYmd: String(getSinkholeSelectionValue(data, "restoreCompletedYmd") ?? ""), + coordStatus: String(getSinkholeSelectionValue(data, "coordStatus") ?? getSinkholeSelectionValue(data, "geocodeStatus") ?? ""), + geocodeStatus: String(getSinkholeSelectionValue(data, "geocodeStatus") ?? getSinkholeSelectionValue(data, "coordStatus") ?? ""), + geocodeSource: String(getSinkholeSelectionValue(data, "geocodeSource") ?? ""), + geocodeErrorMessage: String(getSinkholeSelectionValue(data, "geocodeErrorMessage") ?? ""), + latitude: getSinkholeSelectionValue(data, "latitude") ?? null, + longitude: getSinkholeSelectionValue(data, "longitude") ?? null, + x: point?.x ?? getSinkholeSelectionValue(data, "x") ?? null, + y: point?.y ?? getSinkholeSelectionValue(data, "y") ?? null, + sizeWidth: getSinkholeSelectionValue(data, "sizeWidth") ?? null, + sizeLength: getSinkholeSelectionValue(data, "sizeLength") ?? null, + sizeDepth: getSinkholeSelectionValue(data, "sizeDepth") ?? null, + detailCause: getSinkholeSelectionValue(data, "detailCause") ?? null, + deathCount: getSinkholeSelectionValue(data, "deathCount") ?? null, + injuryCount: getSinkholeSelectionValue(data, "injuryCount") ?? null, + vehicleDamageCount: getSinkholeSelectionValue(data, "vehicleDamageCount") ?? null, + }; +}; const getSinkholePointFromSelection = (data: any): { x: number; y: number } | null => { const directX = Number( - data?.x ?? - data?.values_?.x ?? - data?.get?.("x") ?? - data?.properties?.x, + getSinkholeSelectionValue(data, "x"), ); const directY = Number( - data?.y ?? - data?.values_?.y ?? - data?.get?.("y") ?? - data?.properties?.y, + getSinkholeSelectionValue(data, "y"), ); if (Number.isFinite(directX) && Number.isFinite(directY)) { return { x: directX, y: directY }; @@ -895,7 +881,7 @@ const getSinkholePointFromSelection = (data: any): { x: number; y: number } | nu }; const createSinkholePointStyle = ( - variant: SinkholePointVariant = "orange", + variant: SinkholePointVariant = "blue", selected = false, ) => new Style({ @@ -916,7 +902,7 @@ const createSinkholePointStyle = ( zIndex: selected ? 9997 : 9994, }); -const createSinkholeRadiusStyle = (variant: SinkholePointVariant = "orange") => +const createSinkholeRadiusStyle = (variant: SinkholePointVariant = "blue") => new Style({ stroke: new Stroke({ color: SINKHOLE_POINT_PALETTE[variant].radiusStroke, @@ -2173,44 +2159,14 @@ const appendSinkholeControlNode = (data: any[]) => { }); const existingRootId = existingRoot?.lyrSn ?? existingRoot?.id ?? SINKHOLE_CONTROL_NODE_ID; - const existingChildren = data.filter((item) => { - const upperId = item?.upperLyrSn ?? item?.upId ?? null; - return upperId === existingRootId; - }); - - if (existingRoot && existingChildren.length > 0) { - return data.map((item) => { - const nodeId = String(item?.lyrSn ?? item?.id ?? ""); - if (nodeId === existingRootId) { - return { - ...item, - gobjTy: "", - iconNm: null, - clickPsblYn: "N", - actlLyrYn: "N", - }; - } - const upperId = item?.upperLyrSn ?? item?.upId ?? null; - if (upperId === existingRootId && isSinkholeYearNode(item)) { - return { - ...item, - gobjTy: "", - iconNm: resolveSinkholeLegendIconName(item) ?? item?.iconNm ?? null, - initEprssYn: "Y", - }; - } - return item; - }); - } - const filtered = data.filter((item) => { const nodeId = String(item?.lyrSn ?? item?.id ?? ""); const upperId = String(item?.upperLyrSn ?? item?.upId ?? ""); const layerName = String(item?.lyrNm ?? item?.name ?? "").trim(); const layerTypeName = String(item?.lyrTyNm ?? item?.lyrTypeNm ?? item?.lyr_type_nm ?? "").trim(); - if (String(nodeId) === String(existingRootId)) return false; - if (String(upperId) === String(existingRootId)) return false; + if (nodeId === String(existingRootId)) return false; + if (upperId === String(existingRootId)) return false; if (!existingRoot && nodeId === SINKHOLE_CONTROL_NODE_ID) return false; if (!existingRoot && upperId === SINKHOLE_CONTROL_NODE_ID) return false; if (!existingRoot && layerName === SINKHOLE_LAYER_NAME) return false; @@ -2227,6 +2183,7 @@ const appendSinkholeControlNode = (data: any[]) => { iconNm: null, clickPsblYn: "N", actlLyrYn: "N", + children: null, } : { lyrSn: SINKHOLE_CONTROL_NODE_ID, @@ -2261,17 +2218,7 @@ const appendSinkholeControlNode = (data: any[]) => { children: null, }; - const yearNodes = buildSinkholeYearNodes().map((item) => ({ - ...item, - upperLyrSn: existingRootId, - upId: existingRootId, - })); - - return [ - ...filtered, - rootNode, - ...yearNodes, - ]; + return [...filtered, rootNode]; }; const Map = () => { @@ -2293,12 +2240,16 @@ const Map = () => { const [isAgedPipeChecked, setIsAgedPipeChecked] = useState(false); const [isSinkholeChecked, setIsSinkholeChecked] = useState(false); const [isFloodHistoryChecked, setIsFloodHistoryChecked] = useState(false); + const [activeLayerSettingOrder, setActiveLayerSettingOrder] = useState([]); const [draftAgedPipeFilters, setDraftAgedPipeFilters] = useState(AGED_PIPE_DEFAULT_FILTERS); const [appliedAgedPipeFilters, setAppliedAgedPipeFilters] = useState(AGED_PIPE_DEFAULT_FILTERS); + const [sinkholeYearRange, setSinkholeYearRange] = useState(() => + buildSinkholeDefaultYearRange(), + ); const [appliedSinkholeFilters, setAppliedSinkholeFilters] = useState(() => - buildSinkholeDefaultFilters(sinkholeScope), + normalizeSinkholeFilters(buildSinkholeFiltersFromYearRange(buildSinkholeDefaultYearRange(), sinkholeScope), sinkholeScope), ); const [isSinkholePopupFilterActive, setIsSinkholePopupFilterActive] = useState(false); const [sinkholeRadiusMeters, setSinkholeRadiusMeters] = useState(SINKHOLE_DEFAULT_RADIUS); @@ -2637,9 +2588,13 @@ const Map = () => { }, []); useEffect(() => { - const scopedFilters = normalizeSinkholeFilters(appliedSinkholeFilters, sinkholeScope); - setAppliedSinkholeFilters(scopedFilters); - }, [sinkholeScope]); + setAppliedSinkholeFilters((currentFilters) => { + if (isSinkholePopupFilterActive) { + return normalizeSinkholeFilters(currentFilters, sinkholeScope); + } + return normalizeSinkholeFilters(buildSinkholeFiltersFromYearRange(sinkholeYearRange, sinkholeScope), sinkholeScope); + }); + }, [isSinkholePopupFilterActive, sinkholeScope, sinkholeYearRange]); useEffect(() => { const timer = window.setInterval(() => { @@ -3445,7 +3400,7 @@ const Map = () => { restoreSinkholeDetailOnCloseRef.current = false; const acdntNo = getSinkholeAccidentId(data); if (acdntNo) { - void loadSinkholeDetail(acdntNo, false, getSinkholePointFromSelection(data)); + void loadSinkholeDetail(acdntNo, false, getSinkholePointFromSelection(data), getSinkholeFallbackDetailFromSelection(data)); } return; } @@ -3887,7 +3842,7 @@ const Map = () => { const pointFeature = new Feature({ geometry: new Point([detail.x, detail.y]), }); - const pointVariant = resolveSinkholePointVariant(detail.ocrnYmd, checkedNodesDataRef.current); + const pointVariant = resolveSinkholePointVariant(detail.ocrnYmd); pointFeature.setStyle(createSinkholePointStyle(pointVariant, true)); const radiusFeature = new Feature({ @@ -4029,8 +3984,8 @@ const Map = () => { }, []); const buildSinkholeQueryString = useCallback( - (filters: SinkholeFilters, page = 0, size = SINKHOLE_DEFAULT_POINT_LIMIT, checkedNodesData = checkedNodesDataRef.current) => - buildSinkholeListQueryString(applySinkholeYearFilters(filters, checkedNodesData), ctpv, sgg, page, size), + (filters: SinkholeFilters, page = 0, size = SINKHOLE_DEFAULT_POINT_LIMIT) => + buildSinkholeListQueryString(filters, ctpv, sgg, page, size), [ctpv, sgg], ); @@ -4041,11 +3996,7 @@ const Map = () => { const source = ensureSinkholeLayer(); if (!source) return; - const popupFilters = normalizeSinkholeFilters(filters, sinkholeScope); - const treeBaseFilters = normalizeSinkholeFilters(buildSinkholeTreeBaseFilters(sinkholeScope), sinkholeScope); - const effectiveFilters = isSinkholePopupFilterActive - ? applySinkholeYearFilters(popupFilters, checkedNodesDataRef.current) - : applySinkholeYearFilters(treeBaseFilters, checkedNodesDataRef.current); + const effectiveFilters = normalizeSinkholeFilters(filters, sinkholeScope); try { const params = new URLSearchParams(buildSinkholeQueryString(effectiveFilters, 0, SINKHOLE_DEFAULT_POINT_LIMIT)); const rawResponse = await getRawJson( @@ -4072,7 +4023,6 @@ const Map = () => { features.forEach((feature: any) => { const variant = resolveSinkholePointVariant( String(feature?.get?.("ocrnYmd") ?? ""), - checkedNodesDataRef.current, ); feature.setStyle(createSinkholePointStyle(variant, false)); }); @@ -4082,7 +4032,7 @@ const Map = () => { source.clear(); } }, - [appliedSinkholeFilters, buildSinkholeQueryString, ensureSinkholeLayer, isSinkholeChecked, isSinkholePopupFilterActive, mapReady, sinkholeScope], + [appliedSinkholeFilters, buildSinkholeQueryString, ensureSinkholeLayer, isSinkholeChecked, mapReady, sinkholeScope], ); const closeSinkholeDetail = useCallback(() => { @@ -4258,24 +4208,32 @@ const Map = () => { async (radiusMeters: number) => { setSinkholeRadiusMeters(radiusMeters); if (!selectedSinkholeDetail?.acdntNo) { - alert("반경을 적용할 지반침하 위치가 선택되지 않았습니다."); return; } + const selectedPoint = + selectedSinkholeDetail.x != null && selectedSinkholeDetail.y != null + ? { x: Number(selectedSinkholeDetail.x), y: Number(selectedSinkholeDetail.y) } + : null; + try { - const nearbyRawResponse = await getRawJson( - `/sewerage-service/api/v1/sinkholes/${encodeURIComponent(selectedSinkholeDetail.acdntNo)}/nearbyPipes?radiusMeters=${radiusMeters}`, - ); + const nearbyRawResponse = selectedPoint && Number.isFinite(selectedPoint.x) && Number.isFinite(selectedPoint.y) + ? await getRawJson( + `/sewerage-service/api/v1/sinkholes/nearbyPipesByPoint?x=${selectedPoint.x}&y=${selectedPoint.y}&radiusMeters=${radiusMeters}`, + ) + : await getRawJson( + `/sewerage-service/api/v1/sinkholes/${encodeURIComponent(selectedSinkholeDetail.acdntNo)}/nearbyPipes?radiusMeters=${radiusMeters}`, + ); const nearbyResponse = nearbyRawResponse?.data ?? nearbyRawResponse; setSelectedSinkholeNearbyPipes( Array.isArray(nearbyResponse?.items) ? (nearbyResponse.items as SinkholeNearbyPipe[]) : [], ); renderSelectedSinkhole(selectedSinkholeDetail, radiusMeters); focusSinkholeOnMap(selectedSinkholeDetail, radiusMeters); - alert("분석 반경이 적용되었습니다."); } catch (error) { console.error("지반침하 반경 적용 중 오류:", error); alert("반경 내 하수관로 정보를 다시 조회하지 못했습니다."); + throw error; } }, [focusSinkholeOnMap, renderSelectedSinkhole, selectedSinkholeDetail], @@ -4326,8 +4284,7 @@ const Map = () => { }, [ensureSinkholeFocusedPipeLayer, setIsSearchModalOpen]); const openSinkholePopupWindow = useCallback(() => { - const normalized = normalizeSinkholeFilters(appliedSinkholeFilters, sinkholeScope); - const effectiveFilters = applySinkholeYearFilters(normalized, checkedNodesDataRef.current); + const effectiveFilters = normalizeSinkholeFilters(appliedSinkholeFilters, sinkholeScope); const popup = openWindowPopup(SINKHOLE_POPUP_KEY as WindowPopupKey, effectiveFilters as Record); if (!popup) { alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); @@ -4351,12 +4308,11 @@ const Map = () => { } sinkholePopupWindowRef.current = null; setIsSinkholePopupFilterActive(false); - const defaultFilters = normalizeSinkholeFilters(buildSinkholeDefaultFilters(sinkholeScope), sinkholeScope); - setAppliedSinkholeFilters(defaultFilters); + setAppliedSinkholeFilters(normalizeSinkholeFilters(buildSinkholeFiltersFromYearRange(sinkholeYearRange, sinkholeScope), sinkholeScope)); setSinkholeSelectionVersion((version) => version + 1); }, 300); return popup; - }, [appliedSinkholeFilters, sinkholeScope]); + }, [appliedSinkholeFilters, sinkholeScope, sinkholeYearRange]); useEffect(() => () => { if (sinkholePopupCloseWatcherRef.current) { @@ -4561,7 +4517,11 @@ const Map = () => { } handleWaterDepthVisibility(checkedIds, checkedNodesData); - setIsAgedPipeChecked(checkedNodesData.some((node) => isAgedPipeLayerNode(node))); + const agedPipeChecked = checkedNodesData.some((node) => isAgedPipeLayerNode(node)); + const waterDepthChecked = checkedNodesData.some((node) => + WATER_DEPTH_LAYER_TYPES.includes(String(node?.data?.lyrTyNm ?? "")), + ); + setIsAgedPipeChecked(agedPipeChecked); const floodHistoryChecked = checkedNodesData.some((node) => isFloodHistoryNode(node)); setIsFloodHistoryChecked(floodHistoryChecked); if (!floodHistoryChecked) { @@ -4569,6 +4529,18 @@ const Map = () => { } const sinkholeChecked = checkedNodesData.some((node) => isSinkholeLayerNode(node)); setIsSinkholeChecked(sinkholeChecked); + setActiveLayerSettingOrder((currentOrder) => { + const activeKeys = [ + waterDepthChecked ? "waterDepthLegend" : null, + sinkholeChecked ? "sinkhole" : null, + agedPipeChecked ? "agedPipe" : null, + ].filter((key): key is string => Boolean(key)); + + return [ + ...currentOrder.filter((key) => activeKeys.includes(key)), + ...activeKeys.filter((key) => !currentOrder.includes(key)), + ]; + }); if (sinkholeChecked) { setSinkholeSelectionVersion((version) => version + 1); } @@ -4583,6 +4555,20 @@ const Map = () => { [checkedLayerLegendVersion], ); + + const applySinkholeYearRange = useCallback( + (nextRange: SinkholeYearRange) => { + const normalizedRange = normalizeSinkholeYearRange(nextRange, sinkholeYearRange); + setSinkholeYearRange(normalizedRange); + setIsSinkholePopupFilterActive(false); + setAppliedSinkholeFilters( + normalizeSinkholeFilters(buildSinkholeFiltersFromYearRange(normalizedRange, sinkholeScope), sinkholeScope), + ); + setSinkholeSelectionVersion((version) => version + 1); + }, + [sinkholeScope, sinkholeYearRange], + ); + const handleOpenLayerPopup = useCallback( (_layerNm: string, node?: any) => { if (isAgedPipeLayerNode(node)) { @@ -4597,9 +4583,6 @@ const Map = () => { openFloodHistoryPopupWindow(); return false; } - if (isSinkholeYearNode(node)) { - return false; - } return true; }, @@ -4609,9 +4592,8 @@ const Map = () => { // 현재 체크된 특수 레이어만 하단 설정영역에 모아 렌더링하기 위한 패널 목록이다. const activeLayerSettingItems = useMemo<{ key: string; label: string; content: ReactNode }[]>(() => { const nextItems: { key: string; label: string; content: ReactNode; order: number }[] = []; - const checkedSettingNodes = checkedNodesDataRef.current ?? []; - const resolveSettingOrder = (predicate: (node: any) => boolean, fallback: number) => { - const index = checkedSettingNodes.findIndex((node) => predicate(node)); + const resolveSettingOrder = (key: string, fallback: number) => { + const index = activeLayerSettingOrder.indexOf(key); return index >= 0 ? index : fallback; }; @@ -4619,11 +4601,13 @@ const Map = () => { nextItems.push({ key: "sinkhole", label: "지반침하정보", - order: resolveSettingOrder((node) => isSinkholeLayerNode(node), 9998), + order: resolveSettingOrder("sinkhole", 9998), content: ( { nextItems.push({ key: "agedPipe", label: "노후관로", - order: resolveSettingOrder((node) => isAgedPipeLayerNode(node), 9999), + order: resolveSettingOrder("agedPipe", 9999), content: ( { nextItems.push({ key: "waterDepthLegend", label: "침수심 범례", - order: resolveSettingOrder( - (node) => WATER_DEPTH_LAYER_TYPES.includes(String(node?.data?.lyrTyNm ?? "")), - 9997, - ), + order: resolveSettingOrder("waterDepthLegend", 9997), content: (
-
+
침수심 범례 @@ -4688,7 +4669,9 @@ const Map = () => { .sort((a, b) => a.order - b.order) .map(({ key, label, content }) => ({ key, label, content })); }, [ + activeLayerSettingOrder, applySinkholeRadius, + applySinkholeYearRange, draftAgedPipeFilters, handleApplyAgedPipeFilters, isAgedPipeChecked, @@ -4696,6 +4679,7 @@ const Map = () => { isWaterDepthLayerActive, lastSinkholeSyncSummary, sinkholeRadiusMeters, + sinkholeYearRange, userInfo?.adminRoleYn, waterDepthLegendItems, ]); @@ -6401,8 +6385,7 @@ const Map = () => { return; } - const normalized = normalizeSinkholeFilters(appliedSinkholeFilters, sinkholeScope); - const effectiveFilters = applySinkholeYearFilters(normalized, checkedNodesDataRef.current); + const effectiveFilters = normalizeSinkholeFilters(appliedSinkholeFilters, sinkholeScope); popup.postMessage( { type: SINKHOLE_FILTER_SYNC_MESSAGE, diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts index f5da328c2..5efc9bb09 100644 --- a/apps/sn/app/components/lib/api/index.ts +++ b/apps/sn/app/components/lib/api/index.ts @@ -29,6 +29,13 @@ const withSiteIdHeaders = (headers: HeadersInit = {}) => ({ const resolveApiUrl = (url: string) => url.startsWith('/') ? `${url}` : `${BASE_URL}/${url}`; +const buildInvalidJsonResponseError = (url: string, text: string, contentType?: string | null) => { + const preview = text.trim().slice(0, 200); + const responseType = contentType ? ` (${contentType})` : ""; + return new Error( + `JSON 응답이 아닌 데이터를 받았습니다${responseType}: ${url}${preview ? ` - ${preview}` : ""}`, + ); +}; const buildRequestError = ( fallbackMessage: string, status?: number, @@ -274,11 +281,15 @@ export const getRawJson = async (url: string, init: RequestInit = {}): Promise 종료년도, 2016년 이전, 현재년도 초과)이 차단되는지 확인한다. diff --git a/apps/sn/docs/작업검증/SFR-010-010-지반침하정보/002-qa-SFR-010-010-지반침하정보-통합검증결과.md b/apps/sn/docs/작업검증/SFR-010-010-지반침하정보/002-qa-SFR-010-010-지반침하정보-통합검증결과.md index 610054de0..c99e223ad 100644 --- a/apps/sn/docs/작업검증/SFR-010-010-지반침하정보/002-qa-SFR-010-010-지반침하정보-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-010-010-지반침하정보/002-qa-SFR-010-010-지반침하정보-통합검증결과.md @@ -123,6 +123,34 @@ - FE 컬럼 추가와 타입 추가, BE 응답 필드 추가만 남긴 상태에서 `git diff --check`를 다시 수행한다. - 실제 브라우저에서 사고번호 `20260026`처럼 기존에 24건이 나오던 케이스가 그대로 유지되는지 재확인이 필요하다. +### 2.16 2026-06-26 레이어 연도범위 설정 전환 사전 분석 결과 + +- 이번 턴은 구현 전 분석 단계이므로 코드 수정, 브라우저 실행, API 호출 검증은 수행하지 않았다. +- 기존 문서와 코드 경로를 대조한 결과, 현재 지반침하 레이어 조회는 `2026년`/`전체` 자식 노드와 `applySinkholeYearFilters`에 의존하는 구조가 남아 있음을 확인했다. +- `SinkholeLayerSettingPanel`에는 현재 반경 입력만 있고 시작/종료년도 입력이 없어, 다음 구현에서 패널 props와 상태를 확장해야 한다. +- BE `SinkholeListSearchDto`와 `SinkholeQueryService`는 `startYmd`, `endYmd`, 최소일 검증을 이미 제공하므로 신규 BE 계약은 필요하지 않은 것으로 판정했다. +- `COMMON_DOCS_ROOT/DB작업.sql`에는 `2026년`, `전체` 자식 레이어 생성/아이콘 보정 DML이 남아 있어, 다음 구현에서 기준 데이터 삭제/비활성화 DML을 함께 정리해야 한다. +- 다음 검증은 구현 후 `지반침하정보` 루트 on/off, 설정패널 연도 범위 API 파라미터, 팝업 초기조건 동기화, 기존 반경/상세 회귀 순서로 수행한다. + + +### 2.17 2026-06-26 레이어 연도범위 설정 전환 구현 검증 결과 + +- 코드 검색 결과 `2026년`/`전체` 합성 연도 노드 의존 심볼(`buildSinkholeYearNodes`, `isSinkholeYearNode`, `applySinkholeYearFilters`)은 지도 화면 코드에서 제거됐다. +- 변경 TS/TSX 3개 파일(`page.tsx`, `SinkholeLayerSettingPanel.tsx`, `sinkholeTypes.ts`)은 로컬 TypeScript 파서 기준 diagnostics 0건을 확인했다. +- 기존 dev 서버에서 `http://localhost:3002/map` 요청이 200으로 응답해 페이지 컴파일 진입이 가능함을 확인했다. +- `git diff --check`는 FE/공통 변경 파일 기준 공백 오류 없음으로 통과했다. Git 줄바꿈 정책 경고는 일부 파일에서만 출력됐다. +- `next lint`와 `tsc --noEmit`는 현재 프로젝트 도구 설정 문제로 완료하지 못했다. 실제 로그인 화면에서의 레이어 트리/Network query string 검증은 후속 수동 검증으로 남긴다. + + + +### 2.18 2026-06-26 상세 fallback 및 설정 UI 보정 검증 결과 + +- `page.tsx`와 `SinkholeLayerSettingPanel.tsx` 변경분을 TypeScript 파서로 확인했고 diagnostics 0건이었다. +- 기존 dev 서버에서 `http://localhost:3002/map` 요청이 200으로 응답했다. +- `git diff --check`는 변경 FE 파일 기준 공백 오류 없이 통과했다. Git 줄바꿈 정책상 LF→CRLF 경고만 출력됐다. +- 상세 API 500 상황에서 fallback이 실제 화면에서 패널을 유지하는지, 반경 적용 후 Network 요청이 좌표 기반으로 나가는지는 로그인 세션이 있는 브라우저에서 후속 수동 확인이 필요하다. + + ## 3. 미실행 항목 - FE 브라우저 검증 diff --git a/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md b/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md index 7bfe60340..0174c1b70 100644 --- a/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md @@ -258,6 +258,53 @@ - 기존 반경 내 관로 조회 기능은 정상 동작하던 기능이므로, `map/page.tsx`에 추가했던 좌표/사고번호 재조회 fallback 변경은 제거했다. - FE 변경 범위는 `SinkholeDetailPanel.tsx`의 표 컬럼 추가와 `sinkholeTypes.ts`의 `elapsedYears` 타입 추가로 제한한다. +### 4.33 2026-06-26 레이어 연도범위 설정 전환 사전 분석 + +- 이번 요청은 구현 전 분석 단계로 한정하며, 실제 FE/BE 코드는 수정하지 않았다. +- 요구사항은 기존 `2026년`, `전체` 자식 레이어를 화면에서 제거하고, `지반침하정보` 루트 레이어만 on/off 하도록 단순화하는 것이다. +- 현재 하단 `선택 레이어 설정`의 `SinkholeLayerSettingPanel`에는 `지반침하반경(m)`만 있으므로, 다음 구현에서는 같은 패널에 `시작년도`, `종료년도` 입력과 적용 버튼을 추가한다. +- 기본 연도 범위는 현재 연도 기준 최근 1년으로 해석한다. 현재 연도가 2026년이면 화면 입력값은 `2025 ~ 2026`이고, API 호출값은 `startYmd=20250101`, `endYmd=20261231`로 변환한다. +- 현재 코드에서 정리해야 할 지점은 아래와 같다. + - `sinkholeTypes.ts`: `buildSinkholeYearNodes`, `isSinkholeYearNode`, `applySinkholeYearFilters`, 연도 노드 아이콘 상수의 사용 범위 축소 또는 제거 + - `page.tsx`: `appendSinkholeControlNode`의 연도 fallback 추가 제거, `applySinkholeYearFilters` 호출 제거, 팝업 close 시 기본 필터 복귀 기준 재정의 + - `SinkholeLayerSettingPanel.tsx`: 연도 범위 입력 상태와 검증, 적용 callback 추가 + - `DB작업.sql`: `2026년`, `전체` 자식 레이어 추가/보정 DML 제거 또는 기존 행 삭제 DML로 전환 +- BE는 이미 `startYmd/endYmd` 목록 조회 파라미터와 최소일 검증을 제공하므로, 이번 요구는 현재 분석 기준으로 FE/DB 기준정보 변경만으로 처리 가능하다. +- 팝업 조회조건과 설정패널 연도 범위의 관계는 다음 구현에서 명확히 고정해야 한다. 우선안은 설정패널 연도 범위를 지도 레이어 기본 조건으로 두고, 팝업은 그 조건을 초기값으로 받아 목록 조회를 시작하는 방식이다. + + +### 4.34 2026-06-26 레이어 연도범위 설정 전환 구현 + +- `sinkholeTypes.ts`에서 `2026년`/`전체` 합성 노드와 연도 노드 기반 필터 결합 로직을 제거하고, `SinkholeYearRange` 및 시작/종료년도 기반 필터 변환 유틸을 추가했다. +- `page.tsx`는 지반침하정보 트리 노출을 루트 on/off로 단순화하고, 지도 조회/팝업 동기화는 선택 레이어 설정의 연도 범위에서 만든 `startYmd/endYmd`를 사용하도록 변경했다. +- `SinkholeLayerSettingPanel.tsx`에 시작년도/종료년도 입력을 추가하고 적용 시 연도 유효성 검증 후 지도 레이어를 다시 조회하도록 연결했다. +- 기본 연도 범위는 현재년도 기준 최근 1년이다. 2026년 기준 화면 기본값은 `2025 ~ 2026`이며 조회 파라미터는 `20250101 ~ 20261231`로 변환된다. +- 기존 반경 설정, 상세 패널 반경 내 관로 조회, 목록 팝업 row 선택 흐름은 유지했다. + + + +### 4.35 2026-06-26 상세 500 fallback 및 설정 패널 UI 보정 + +- 지도 포인트 선택 시 `loadSinkholeDetail`에 feature/list 응답 기반 fallback 상세정보를 함께 전달하도록 변경했다. 상세 API가 500을 반환해도 목록 응답에 있는 주소, 발생일자, 좌표, 복구상태 등으로 상세 패널을 계속 열 수 있다. +- 지반침하반경 설정은 선택 지점이 없어도 전역 값으로 저장되며, 선택 지점이 있을 때만 반경 내 관로를 즉시 재조회한다. +- 선택 지점 좌표가 있는 경우 반경 내 관로 재조회는 `nearbyPipesByPoint`를 우선 사용해 사고번호 기반 주변 관로 API 실패 영향을 줄였다. +- 선택 레이어 설정 패널의 시작/종료년도 줄은 기존 반경 입력 줄과 같은 버튼 크기와 입력 높이, 간격으로 정렬했다. + + + +### 4.36 2026-06-26 지반침하 포인트 색상 단일화 + +- 지반침하 포인트가 발생연도 기준으로 현재년도는 주황색, 그 외 연도는 파란색으로 표시되던 분기 로직을 제거했다. +- `resolveSinkholePointVariant`는 발생일자를 받아도 항상 `blue`를 반환하도록 변경해, 2026년 데이터와 그 외 데이터가 모두 동일한 파란색 원형 포인트로 표시된다. +- 선택 지점 포인트와 반경 표시 기본 스타일도 파란색 기준으로 맞췄다. + +### 4.37 2026-06-26 선택 레이어 설정 안내문구 및 범례 높이 미세조정 + +- 지반침하 반경 적용 alert는 적용된 반경 값을 포함해 `지반침하반경은 {값}m로 반영되었습니다.`로 표시한다. +- 지반침하 시작/종료년도 적용 alert는 적용된 연도 범위를 포함해 `시작년도 {시작년도} ~ 종료년도 {종료년도} 반영되었습니다.`로 표시한다. +- 노후관로 경과년도 적용 alert는 적용된 경과년 값을 포함해 `노후관로 경과년도 {값}년으로 반영되었습니다.`로 표시한다. +- 침수심 범례는 내부 디자인은 유지하고 외곽 세로 여백만 줄여 선택 레이어 설정 영역의 높이를 소폭 낮췄다. + ## 5. 승인 전 보류 사항 - 목록 팝업을 `window popup`으로 열지 `/map` 내부 중앙 모달로 열지 확정 필요 diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js index 1e6d66edc..cdf67af25 100644 --- a/packages/common/business_components/src/ui/map/GisTree.js +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -570,9 +570,10 @@ export default React.memo(function GisTree({ return (
{hasVisibleChildren ? ( @@ -583,7 +584,7 @@ export default React.memo(function GisTree({ title={isOpen ? "닫기" : "열기"} /> ) : ( - (isSinkholeRootTreeNode ? null : ) + )} handleLayerImgClick( node.data.lyrTyNm,