diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx
new file mode 100644
index 000000000..cf6db8572
--- /dev/null
+++ b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { Button } from "@common/components";
+import {
+ AGED_PIPE_DETAIL_FIELDS,
+ getElapsedYears,
+ getPipeDisplayValue,
+} from "./agedPipeUtils";
+
+type Props = {
+ featureData: any;
+ onClose: () => void;
+};
+
+export default function AgedPipeDetailPanel({ featureData, onClose }: Props) {
+ const source = featureData?.values_ ?? featureData ?? {};
+ const facilityNo = String(getPipeDisplayValue(source, "fclt_mng_no") ?? "-");
+ const elapsedYears = getElapsedYears(source.instl_ymd ?? source.instlYmd);
+
+ return (
+
+
+
+
노후관로 상세
+
시설관리번호 {facilityNo}
+
+ 경과년도 {elapsedYears ?? "-"}년
+
+
+
+ 닫기
+
+
+
+
+
+ {AGED_PIPE_DETAIL_FIELDS.map((field) => (
+
+
{field.title}
+
+ {String(getPipeDisplayValue(source, field.id) ?? "-")}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx
new file mode 100644
index 000000000..5a94014a2
--- /dev/null
+++ b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { Button, Input } from "@common/components";
+import useStore from "@/store/useStore";
+import type { AgedPipeFilters } from "./agedPipeUtils";
+
+type OverlayProps = {
+ onClose?: () => void;
+ isPlaying?: boolean;
+ setIsPlaying?: (value: boolean) => void;
+ totalSteps?: number;
+ currentStep?: number;
+};
+
+type Props = OverlayProps & {
+ filters: AgedPipeFilters;
+ onFiltersChange: (filters: AgedPipeFilters) => void;
+ onApply: () => void;
+};
+
+export default function AgedPipeFilterPanel({
+ filters,
+ onFiltersChange,
+ onApply,
+}: Props) {
+ const { ctpv, sgg } = useStore();
+ const sggOptions = filters.ctpvCd === "0"
+ ? sgg
+ : sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2)));
+
+ return (
+
+
+
노후관로 기준
+
+ 경과년수 이상 관로만 지도와 목록에 반영합니다.
+
+
+
+
+
+ 경과년수
+ {
+ const next = Number(event.target.value.replace(/[^0-9]/g, "") || "0");
+ onFiltersChange({ ...filters, elapsedYears: next });
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") onApply();
+ }}
+ />
+
+
+
+ 시도
+
+ onFiltersChange({
+ ...filters,
+ ctpvCd: event.target.value,
+ sggCd: "0",
+ })
+ }
+ >
+ 전체
+ {ctpv.map((item) => (
+
+ {item.text}
+
+ ))}
+
+
+
+
+ 시군구
+ onFiltersChange({ ...filters, sggCd: event.target.value })}
+ >
+ 전체
+ {sggOptions.map((item) => (
+
+ {item.text}
+
+ ))}
+
+
+
+
+
+ GIS 반영
+
+
+ );
+}
diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx
new file mode 100644
index 000000000..bf6a8fb02
--- /dev/null
+++ b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx
@@ -0,0 +1,180 @@
+"use client";
+
+import { useRef } from "react";
+import { Button, Input } from "@common/components";
+import { UiTable } from "@common/business_components/ui";
+import LoadingOverlay from "@/components/ui/LoadingBar";
+import useStore from "@/store/useStore";
+import {
+ AGED_PIPE_LIST_HEADLIST,
+ type AgedPipeFilters,
+ type AgedPipeRow,
+} from "./agedPipeUtils";
+
+type Props = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ filters: AgedPipeFilters;
+ onFiltersChange: (filters: AgedPipeFilters) => void;
+ rows: AgedPipeRow[];
+ total: number;
+ currentPage: number;
+ isLoading: boolean;
+ onSearch: (page?: number) => void;
+ onDownload: () => void;
+ onRowDoubleClick: (row: AgedPipeRow) => void;
+};
+
+export default function AgedPipeListModal({
+ open,
+ onOpenChange,
+ filters,
+ onFiltersChange,
+ rows,
+ total,
+ currentPage,
+ isLoading,
+ onSearch,
+ onDownload,
+ onRowDoubleClick,
+}: Props) {
+ const { ctpv, sgg } = useStore();
+ const lastClickRef = useRef<{ index: number; clickedAt: number } | null>(null);
+ const sggOptions = filters.ctpvCd === "0"
+ ? sgg
+ : sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2)));
+
+ const handleRowClick = (index?: number) => {
+ if (typeof index !== "number") return;
+ const row = rows[index];
+ if (!row) return;
+
+ const now = Date.now();
+ const lastClick = lastClickRef.current;
+ if (lastClick && lastClick.index === index && now - lastClick.clickedAt < 300) {
+ lastClickRef.current = null;
+ onRowDoubleClick(row);
+ return;
+ }
+
+ lastClickRef.current = { index, clickedAt: now };
+ };
+
+ if (!open) return null;
+
+ return (
+
+
+
+
+
+
노후관로 목록
+
+ 지도 위에 띄워지는 윈도우형 팝업 미리보기
+
+
+
onOpenChange(false)}
+ >
+ 닫기
+
+
+
+
+
+
+ 경과년수
+ {
+ const next = Number(event.target.value.replace(/[^0-9]/g, "") || "0");
+ onFiltersChange({ ...filters, elapsedYears: next });
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") onSearch(1);
+ }}
+ />
+
+
+
+ 시도
+
+ onFiltersChange({
+ ...filters,
+ ctpvCd: event.target.value,
+ sggCd: "0",
+ })
+ }
+ >
+ 전체
+ {ctpv.map((item) => (
+
+ {item.text}
+
+ ))}
+
+
+
+
+ 시군구
+ onFiltersChange({ ...filters, sggCd: event.target.value })}
+ >
+ 전체
+ {sggOptions.map((item) => (
+
+ {item.text}
+
+ ))}
+
+
+
+
+ onSearch(1)}>
+ 조회
+
+
+ 다운로드
+
+
+
+
+
+
+ headName={<>노후관로 조회 결과>}
+ tableData={rows}
+ headlist={AGED_PIPE_LIST_HEADLIST}
+ pageSize={10}
+ total={total}
+ cellClick={(index) => handleRowClick(index)}
+ onPageChange={(page) => onSearch(page)}
+ curPage={currentPage}
+ widthFixed={true}
+ />
+
+
+
+ 목록 행을 더블클릭하면 지도 이동 후 상세 패널이 열립니다.
+
+
+
+
+ );
+}
diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/agedPipeUtils.ts b/apps/sn/app/html/(map-layout)/aged-pipe/agedPipeUtils.ts
new file mode 100644
index 000000000..baff4b003
--- /dev/null
+++ b/apps/sn/app/html/(map-layout)/aged-pipe/agedPipeUtils.ts
@@ -0,0 +1,221 @@
+"use client";
+
+import type { SelectDataType } from "@common/business_components";
+import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
+import {
+ MS_SWAGE_PPL_CODES,
+ MS_SWAGE_PPL_USE_CODES,
+ MsSwagePplCol,
+} from "../map/components/common/table";
+
+export type AgedPipeFilters = {
+ elapsedYears: number;
+ ctpvCd: string;
+ sggCd: string;
+};
+
+export type AgedPipeRow = {
+ fid: string;
+ lyrTypeNm: string;
+ rowNumber: number;
+ fcltMngNo: string;
+ instlYmd: string;
+ pdmtVl: string | number;
+ prlgVl: string | number;
+ usgCd: string;
+ mqltCd: string;
+ elapsedYears: number | string;
+ ctpvNm?: string;
+ sggNm?: string;
+ [key: string]: unknown;
+};
+
+type PipeSource = Record;
+
+const useMap = new Map(MS_SWAGE_PPL_USE_CODES.map((item) => [item.val.trim(), item.text]));
+const materialMap = new Map(MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text]));
+
+export const AGED_PIPE_DEFAULT_FILTERS: AgedPipeFilters = {
+ elapsedYears: 20,
+ ctpvCd: "0",
+ sggCd: "0",
+};
+
+export const AGED_PIPE_LIST_HEADLIST = [
+ { id: "rowNumber", title: "NO", widthPercent: 6 },
+ { id: "fcltMngNo", title: "시설관리번호", widthPercent: 18 },
+ { id: "instlYmd", title: "설치일자", widthPercent: 12 },
+ { id: "pdmtVl", title: "관경(mm)", widthPercent: 10 },
+ { id: "prlgVl", title: "연장(m)", widthPercent: 10 },
+ { id: "usgCd", title: "용도", widthPercent: 14 },
+ { id: "mqltCd", title: "재질", widthPercent: 18 },
+ { id: "elapsedYears", title: "경과년도", widthPercent: 12, align: "right" },
+];
+
+export const AGED_PIPE_EXCEL_HEADER = {
+ rowNumber: "NO",
+ fcltMngNo: "시설관리번호",
+ instlYmd: "설치일자",
+ pdmtVl: "관경(mm)",
+ prlgVl: "연장(m)",
+ usgCd: "용도",
+ mqltCd: "재질",
+ elapsedYears: "경과년도",
+};
+
+export const AGED_PIPE_DETAIL_FIELDS = MsSwagePplCol;
+
+const getToday = () => new Date();
+
+const pad2 = (value: number) => String(value).padStart(2, "0");
+
+export const getCutoffYmd = (elapsedYears: number) => {
+ const today = getToday();
+ const cutoff = new Date(today.getFullYear() - elapsedYears, today.getMonth(), today.getDate());
+ return `${cutoff.getFullYear()}${pad2(cutoff.getMonth() + 1)}${pad2(cutoff.getDate())}`;
+};
+
+const normalizeYmd = (value: unknown) => String(value ?? "").replace(/[^0-9]/g, "").slice(0, 8);
+
+const getCodePrefix = (source: PipeSource) =>
+ String(source.pbadmsEmdCd ?? source.pbadms_emd_cd ?? "").trim();
+
+const resolveRegionName = (
+ code: string,
+ directName: unknown,
+ options: SelectDataType[],
+ prefixLength: number,
+) => {
+ if (String(directName ?? "").trim()) return String(directName);
+ const prefix = code.slice(0, prefixLength);
+ return options.find((item) => String(item.val).trim() === prefix)?.text ?? "";
+};
+
+export const getElapsedYears = (value: unknown) => {
+ const normalized = normalizeYmd(value);
+ if (normalized.length < 4) return null;
+
+ const year = Number(normalized.slice(0, 4));
+ const month = Number(normalized.slice(4, 6) || "1");
+ const day = Number(normalized.slice(6, 8) || "1");
+ if (!year) return null;
+
+ const today = getToday();
+ let elapsed = today.getFullYear() - year;
+ if (
+ today.getMonth() + 1 < month ||
+ (today.getMonth() + 1 === month && today.getDate() < day)
+ ) {
+ elapsed -= 1;
+ }
+
+ return Math.max(elapsed, 0);
+};
+
+export const buildAgedPipeCqlFilters = (filters: AgedPipeFilters) => {
+ const cqlFilters: string[] = [];
+ cqlFilters.push(`instl_ymd <= '${getCutoffYmd(filters.elapsedYears)}'`);
+
+ if (filters.sggCd !== "0") {
+ cqlFilters.push(`pbadms_emd_cd LIKE '${filters.sggCd}%'`);
+ } else if (filters.ctpvCd !== "0") {
+ cqlFilters.push(`pbadms_emd_cd LIKE '${filters.ctpvCd}%'`);
+ }
+
+ return cqlFilters;
+};
+
+export const matchesAgedPipeFilters = (source: PipeSource, filters: AgedPipeFilters) => {
+ const instlYmd = normalizeYmd(source.instl_ymd ?? source.instlYmd);
+ if (!instlYmd || instlYmd > getCutoffYmd(filters.elapsedYears)) return false;
+
+ const regionCode = getCodePrefix(source);
+ if (filters.sggCd !== "0") return regionCode.startsWith(filters.sggCd);
+ if (filters.ctpvCd !== "0") return regionCode.startsWith(filters.ctpvCd);
+ return true;
+};
+
+export const toDisplayPipeRow = (
+ source: PipeSource,
+ index: number,
+ page: number,
+ ctpvOptions: SelectDataType[],
+ sggOptions: SelectDataType[],
+): AgedPipeRow => {
+ const formatted = formatDisplayData(source) as PipeSource;
+ const codePrefix = getCodePrefix(source);
+ const instlYmd = formatted.instlYmd ?? source.instlYmd ?? formatted.instl_ymd ?? source.instl_ymd;
+ const trimmedUse = String(source.usgCd ?? source.usg_cd ?? "").trim();
+ const trimmedMaterial = String(source.mqltCd ?? source.mqlt_cd ?? "").trim();
+ const pdmtVl = formatted.pdmtVl ?? source.pdmtVl ?? source.pdmt_vl ?? "-";
+ const prlgVl = formatted.prlgVl ?? source.prlgVl ?? source.prlg_vl ?? "-";
+
+ return {
+ ...source,
+ ...formatted,
+ fid: String(source.fid ?? ""),
+ lyrTypeNm: String(source.lyrTypeNm ?? "tb_ms_swage_ppl"),
+ rowNumber: (page - 1) * 10 + index + 1,
+ fcltMngNo: String(formatted.fcltMngNo ?? source.fcltMngNo ?? source.fclt_mng_no ?? "-"),
+ instlYmd: String(instlYmd ?? "-"),
+ pdmtVl: typeof pdmtVl === "number" ? pdmtVl : String(pdmtVl),
+ prlgVl: typeof prlgVl === "number" ? prlgVl : String(prlgVl),
+ usgCd: useMap.get(trimmedUse) ?? String(formatted.usgCd ?? source.usgCd ?? source.usg_cd ?? "-"),
+ mqltCd:
+ materialMap.get(trimmedMaterial) ??
+ String(formatted.mqltCd ?? source.mqltCd ?? source.mqlt_cd ?? "-"),
+ elapsedYears: getElapsedYears(source.instlYmd ?? source.instl_ymd) ?? "-",
+ ctpvNm: resolveRegionName(
+ codePrefix,
+ formatted.ctpvNm ?? source.ctpvNm ?? source.ctpv_nm,
+ ctpvOptions,
+ 2,
+ ),
+ sggNm: resolveRegionName(
+ codePrefix,
+ formatted.sggNm ?? source.sggNm ?? source.sgg_nm,
+ sggOptions,
+ 5,
+ ),
+ };
+};
+
+export const getPipeDisplayValue = (source: PipeSource, key: string) => {
+ const formatted = formatDisplayData(source) as PipeSource;
+ const keyMap: Record = {
+ fclt_mng_no: ["fclt_mng_no", "fcltMngNo"],
+ inst_mng_no: ["inst_mng_no", "instMngNo"],
+ instl_ymd: ["instl_ymd", "instlYmd"],
+ mapsht_no: ["mapsht_no", "mapshtNo"],
+ pdmt_vl: ["pdmt_vl", "pdmtVl"],
+ prlg_vl: ["prlg_vl", "prlgVl"],
+ usg_cd: ["usg_cd", "usgCd"],
+ mqlt_cd: ["mqlt_cd", "mqltCd"],
+ avg_grdn: ["avg_grdn", "avgGrdn"],
+ drtw_mltv_area: ["drtw_mltv_area", "drtwMltvArea"],
+ excln_mltv_area: ["excln_mltv_area", "exclnMltvArea"],
+ tmpt_dpth: ["tmpt_dpth", "tmptDpth"],
+ tmpt_mhl_mng_no: ["tmpt_mhl_mng_no", "tmptMhlMngNo"],
+ scend_dpth: ["scend_dpth", "scendDpth"],
+ scend_mhl_mng_no: ["scend_mhl_mng_no", "scendMhlMngNo"],
+ trfln_cnt: ["trfln_cnt", "trflnCnt"],
+ vrtc_len: ["vrtc_len", "vrtcLen"],
+ wdth_len: ["wdth_len", "wdthLen"],
+ };
+
+ const candidates = keyMap[key] ?? [key];
+ for (const candidate of candidates) {
+ const value = formatted[candidate] ?? source[candidate];
+ if (value !== undefined && value !== null && value !== "") {
+ if (candidate === "usg_cd" || candidate === "usgCd") {
+ return useMap.get(String(source.usg_cd ?? source.usgCd ?? "").trim()) ?? value;
+ }
+ if (candidate === "mqlt_cd" || candidate === "mqltCd") {
+ return materialMap.get(String(source.mqlt_cd ?? source.mqltCd ?? "").trim()) ?? value;
+ }
+ return value;
+ }
+ }
+
+ return "-";
+};
diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/page.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/page.tsx
new file mode 100644
index 000000000..e3ebe4d3a
--- /dev/null
+++ b/apps/sn/app/html/(map-layout)/aged-pipe/page.tsx
@@ -0,0 +1,403 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import Gis from "@common/business_components/ui/map/Gis";
+import GisApp from "@common/business_components/ui/map/lib/gis.core";
+import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
+import Stroke from "ol/style/Stroke.js";
+import Style from "ol/style/Style.js";
+import useStore from "@/store/useStore";
+import { getData } from "@/components/lib/api";
+import AgedPipeFilterPanel from "./AgedPipeFilterPanel";
+import AgedPipeDetailPanel from "./AgedPipeDetailPanel";
+import AgedPipeListModal from "./AgedPipeListModal";
+import {
+ AGED_PIPE_DEFAULT_FILTERS,
+ AGED_PIPE_EXCEL_HEADER,
+ buildAgedPipeCqlFilters,
+ matchesAgedPipeFilters,
+ toDisplayPipeRow,
+ type AgedPipeFilters,
+ type AgedPipeRow,
+} from "./agedPipeUtils";
+
+const agedPipeHighlightStyle = new Style({
+ stroke: new Stroke({
+ color: "rgba(239, 68, 68, 0.95)",
+ width: 5,
+ }),
+ zIndex: 999,
+});
+
+const PIPE_LAYER_TYPE = "tb_ms_swage_ppl";
+const EmptySearchView = () => null;
+const DESIGN_PREVIEW_FEATURE = {
+ fid: "preview-aged-pipe",
+ lyrTypeNm: PIPE_LAYER_TYPE,
+ fcltMngNo: "SN-PPL-000123",
+ instMngNo: "INST-4458",
+ instlYmd: "19981215",
+ mapshtNo: "M-12-34",
+ pdmtVl: 450,
+ prlgVl: 132.5,
+ usgCd: "합류관",
+ mqltCd: "철근콘크리트관(RC)",
+ avgGrdn: 0.0021,
+ drtwMltvArea: 2140,
+ exclnMltvArea: 980,
+ tmptDpth: 2.1,
+ tmptMhlMngNo: "TMH-2201",
+ scendDpth: 2.4,
+ scendMhlMngNo: "SMH-2202",
+ trflnCnt: 2,
+ vrtcLen: 1.2,
+ wdthLen: 1.2,
+};
+
+const Map = () => {
+ const gisMaps = useRef(null);
+ const [mapReady, setMapReady] = useState(false);
+ const [treeData, setTreeData] = useState([]);
+ const [isDetailOpen, setIsDetailOpen] = useState(true);
+ const [featureData, setFeatureData] = useState(DESIGN_PREVIEW_FEATURE);
+ const [draftFilters, setDraftFilters] = useState(AGED_PIPE_DEFAULT_FILTERS);
+ const [appliedFilters, setAppliedFilters] = useState(AGED_PIPE_DEFAULT_FILTERS);
+ const [isListOpen, setIsListOpen] = useState(true);
+ const [listRows, setListRows] = useState([
+ {
+ fid: "preview-aged-pipe",
+ lyrTypeNm: PIPE_LAYER_TYPE,
+ rowNumber: 1,
+ fcltMngNo: "SN-PPL-000123",
+ instlYmd: "1998-12-15",
+ pdmtVl: 450,
+ prlgVl: 132.5,
+ usgCd: "합류관",
+ mqltCd: "철근콘크리트관(RC)",
+ elapsedYears: 27,
+ ctpvNm: "서울특별시",
+ sggNm: "강남구",
+ },
+ {
+ fid: "preview-aged-pipe-2",
+ lyrTypeNm: PIPE_LAYER_TYPE,
+ rowNumber: 2,
+ fcltMngNo: "SN-PPL-000124",
+ instlYmd: "2001-03-10",
+ pdmtVl: 300,
+ prlgVl: 88.4,
+ usgCd: "오수관",
+ mqltCd: "염화비닐관(PVC)",
+ elapsedYears: 25,
+ ctpvNm: "서울특별시",
+ sggNm: "강남구",
+ },
+ ]);
+ const [totalCount, setTotalCount] = useState(2);
+ const [currentPage, setCurrentPage] = useState(1);
+ const [isListLoading, setIsListLoading] = useState(false);
+ const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
+ const { ctpv, sgg } = useStore();
+
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ let data;
+ const response = await fetch("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK", {
+ method: "GET",
+ });
+ if (response.ok) {
+ const contentLength = response.headers.get("content-length");
+ if (contentLength === "0") data = null;
+
+ const text = await response.text();
+ if (!text || text.trim() === "") data = null;
+ else data = JSON.parse(text);
+ }
+
+ setTreeData(data);
+ } catch (error) {
+ console.error("노후관로 레이어 설정 조회 중 오류:", error);
+ throw new Error("Invalid JSON response from server");
+ }
+ };
+
+ fetchData();
+ }, []);
+
+ useEffect(() => {
+ const timer = window.setInterval(() => {
+ if (gisMaps.current) {
+ setMapReady(true);
+ window.clearInterval(timer);
+ }
+ }, 300);
+
+ return () => window.clearInterval(timer);
+ }, []);
+
+ const fetchAgedPipeRows = useCallback(
+ async (page: number, filters: AgedPipeFilters, size: number) => {
+ const queryParams = new URLSearchParams({
+ lyrTypeNm: PIPE_LAYER_TYPE,
+ page: String(page - 1),
+ size: String(size),
+ sort: "string",
+ });
+
+ const cqlFilters = buildAgedPipeCqlFilters(filters);
+ if (cqlFilters.length > 0) {
+ queryParams.append("cqlFiter", cqlFilters.join(" and "));
+ }
+
+ const response = await getData(`v1/fclts?${queryParams.toString()}`);
+ const rows = Array.isArray(response?.data) ? response.data : [];
+
+ return {
+ rows: rows.map((item: Record, index: number) =>
+ toDisplayPipeRow(item, index, page, ctpv, sgg),
+ ),
+ total: Number(response?.count ?? 0),
+ };
+ },
+ [ctpv, sgg],
+ );
+
+ const loadAgedPipeList = useCallback(
+ async (page = 1, filters = appliedFilters) => {
+ setIsListLoading(true);
+ try {
+ const response = await fetchAgedPipeRows(page, filters, 10);
+ setListRows(response.rows);
+ setTotalCount(response.total);
+ setCurrentPage(page);
+ } catch (error) {
+ console.error("노후관로 목록 조회 중 오류:", error);
+ setListRows([]);
+ setTotalCount(0);
+ } finally {
+ setIsListLoading(false);
+ }
+ },
+ [appliedFilters, fetchAgedPipeRows],
+ );
+
+ useEffect(() => {
+ loadAgedPipeList(1, appliedFilters);
+ }, [appliedFilters, loadAgedPipeList]);
+
+ const handleApplyFilters = useCallback(() => {
+ const normalized = {
+ ...draftFilters,
+ elapsedYears: draftFilters.elapsedYears > 0 ? draftFilters.elapsedYears : 20,
+ };
+ setDraftFilters(normalized);
+ setAppliedFilters(normalized);
+
+ if (isListOpen) {
+ loadAgedPipeList(1, normalized);
+ }
+ }, [draftFilters, isListOpen, loadAgedPipeList]);
+
+ const handleFeatureSelect = useCallback((data: any) => {
+ const map = gisMaps.current;
+ if (!map) return;
+
+ const select = GisApp.Module.select as any;
+ if (!select) return;
+ select.resetSelectedFeature();
+
+ let foundFeature = null;
+ if (data && typeof data.getGeometry === "function" && typeof data.setStyle === "function") {
+ foundFeature = data;
+ } else {
+ const featureId = data?.fid ?? data?.ftrIdn ?? data?.id;
+ map
+ .getLayers()
+ .getArray()
+ .forEach((layer: any) => {
+ const source = layer.getSource?.();
+ if (!source?.getFeatureById) return;
+ const feature = source.getFeatureById(String(featureId).trim());
+ if (feature) foundFeature = feature;
+ });
+ }
+
+ if (foundFeature?.getGeometry) {
+ map.getView().fit(foundFeature.getGeometry().getExtent(), {
+ duration: 300,
+ padding: [50, 50, 50, 50],
+ maxZoom: 16,
+ });
+ select.selectFeature(foundFeature, true);
+ }
+
+ setFeatureData(data);
+ setIsDetailOpen(true);
+ }, []);
+
+ const handleOpenPipeList = useCallback(() => {
+ setIsListOpen(true);
+ loadAgedPipeList(1, appliedFilters);
+ }, [appliedFilters, loadAgedPipeList]);
+
+ const handleTreePopup = useCallback(
+ (layerNm: string) => {
+ if (String(layerNm).includes("하수관로")) {
+ handleOpenPipeList();
+ }
+ },
+ [handleOpenPipeList],
+ );
+
+ const handleListRowDoubleClick = useCallback(
+ async (row: AgedPipeRow) => {
+ try {
+ const core = GisApp.Module.layer as any;
+ await core.showSearchResultsFromGeoJSON(PIPE_LAYER_TYPE, row.fid);
+ } catch (error) {
+ console.error("노후관로 위치 이동 중 오류:", error);
+ }
+
+ setFeatureData(row);
+ setIsDetailOpen(true);
+ setIsListOpen(false);
+ },
+ [],
+ );
+
+ const handleDownload = useCallback(async () => {
+ try {
+ setIsListLoading(true);
+ const response = await fetchAgedPipeRows(1, appliedFilters, 10000);
+ await downloadExcelWithKoreanHeaders({
+ data: response.rows as JSON[],
+ fileName: `노후관로_목록_${new Date().toISOString().slice(0, 10)}`,
+ sheetNameArray: ["노후관로 목록"],
+ headerRange: "A1:H1",
+ headerMapping: AGED_PIPE_EXCEL_HEADER,
+ });
+ } catch (error) {
+ console.error("노후관로 목록 다운로드 중 오류:", error);
+ alert("노후관로 목록 다운로드 중 오류가 발생했습니다.");
+ } finally {
+ setIsListLoading(false);
+ }
+ }, [appliedFilters, fetchAgedPipeRows]);
+
+ useEffect(() => {
+ if (!mapReady || !gisMaps.current) return;
+
+ const map = gisMaps.current;
+ const cleanups: Array<() => void> = [];
+ const filterFn = (feature: any) =>
+ matchesAgedPipeFilters(feature?.getProperties?.() ?? feature?.values_ ?? {}, appliedFilters);
+
+ const decorateLayer = (layer: any) => {
+ if (layer?.get?.("lyrTyNm") !== PIPE_LAYER_TYPE) return;
+
+ const originalStyle =
+ layer.get("_agedPipeOriginalStyle") ?? layer.getStyleFunction?.() ?? layer.getStyle?.();
+ if (!layer.get("_agedPipeOriginalStyle")) {
+ layer.set("_agedPipeOriginalStyle", originalStyle);
+ }
+
+ layer.setStyle((feature: any, resolution: number) => {
+ const baseStyle =
+ typeof originalStyle === "function" ? originalStyle(feature, resolution) : originalStyle;
+ const styles = Array.isArray(baseStyle) ? [...baseStyle] : baseStyle ? [baseStyle] : [];
+ if (filterFn(feature)) {
+ styles.push(agedPipeHighlightStyle);
+ }
+ return styles;
+ });
+
+ const source = layer.getSource?.();
+ if (source?.on) {
+ const onLoadEnd = () => layer.changed?.();
+ source.on("featuresloadend", onLoadEnd);
+ cleanups.push(() => source.un?.("featuresloadend", onLoadEnd));
+ }
+
+ layer.changed?.();
+ };
+
+ map.getLayers().getArray().forEach(decorateLayer);
+ const layers = map.getLayers();
+ const onAdd = (event: any) => decorateLayer(event.element);
+ layers.on?.("add", onAdd);
+ cleanups.push(() => layers.un?.("add", onAdd));
+
+ return () => {
+ cleanups.forEach((cleanup) => cleanup());
+ };
+ }, [appliedFilters, mapReady]);
+
+ const FilterOverlay = useMemo(
+ () =>
+ function FilterOverlayComponent(props: any) {
+ return (
+
+ );
+ },
+ [draftFilters, handleApplyFilters],
+ );
+
+ return (
+ <>
+
+ undefined}
+ searchBar={<>>}
+ waterDepth={FilterOverlay}
+ showZoomLevel={true}
+ defaultZoom={7.5}
+ defaultLonLat={[127.75, 36]}
+ />
+
+
+ {
+ const normalized = {
+ ...draftFilters,
+ elapsedYears: draftFilters.elapsedYears > 0 ? draftFilters.elapsedYears : 20,
+ };
+ setDraftFilters(normalized);
+ setAppliedFilters(normalized);
+ loadAgedPipeList(page, normalized);
+ }}
+ onDownload={handleDownload}
+ onRowDoubleClick={handleListRowDoubleClick}
+ />
+ >
+ );
+};
+
+export default Map;
diff --git a/apps/sn/app/html/06. 노후관로관리_시나리오_v1.0.md b/apps/sn/app/html/06. 노후관로관리_시나리오_v1.0.md
index 968fdc849..2e05f8e89 100644
--- a/apps/sn/app/html/06. 노후관로관리_시나리오_v1.0.md
+++ b/apps/sn/app/html/06. 노후관로관리_시나리오_v1.0.md
@@ -4,6 +4,7 @@
## 화면구성
### GIS 기본화면
+-노후관로 조건에 맞지 않는 관로를 숨기지 않고, 기존 시설물 레이어는 원래대로 모두 보이게 두고 조건에 맞는 관로만 빨간 강조선을 추가하도록 한다.
- 좌측 상단 (시설물 레이어 트리 및 목록 아이콘): 시설, 침수위험지도, 지반침하정보(우측에 목록 아이콘), 노후관로(우측에 목록 아이콘)
- 좌측 하단 (레이어별 설정 항목 및 적용 버튼): 경과년도(년) [ 디폴트 30년 ]
- 우측 영역(배경 지도 및 시설레이어 표출): 하수관로 중 경과년도가 30년 이상된 관로들을 시각적으로 강조 표시
@@ -12,7 +13,8 @@
- GIS 화면에서 관로 위치 클릭시 우측 정렬로 세로형 영역으로 관로정보 상세화면 팝업
- 1차년도에서 개발한 관로정보 표출 : 시설관리번호, 기관관리번호, 설치일자, 도엽번호 등 전체 항목 그대로 표출
-### 노후관로정보 목록화면
+### 노후관로정보 목록화면
+- 윈도우형 팝업
- GIS 기본화면 좌측 상단의 레이어 트리에서 노후관로 목록 아이콘 클릭시 윈도우 팝업으로 가운데 영역으로 노후관로정보 목록화면 팝업
- 상단 조회조건( 조회조건 영역 ) : 경과년도, 시도, 시군구, 버튼 2개 (조회, 다운로드)
- 하단 가로형 그리드 (노후관로 현황) : NO, 시설관리번호, 설치일자, 관경(mm), 연장(m), 용도, 재질, 경과년도
diff --git a/apps/sn/app/html/AGENTS.md b/apps/sn/app/html/AGENTS.md
index f1c03522e..348d1c2d1 100644
--- a/apps/sn/app/html/AGENTS.md
+++ b/apps/sn/app/html/AGENTS.md
@@ -3,7 +3,7 @@
- 작업시 html 디렉토리 이외의 수정이 되지 않도록 해야 한다.
- 추가 *.md 파일의 요청대로 만들되 상위의 를 건드리지 않고 apps\sn\app\html 에 추가파일을 만들어서 처리한다.
- 기존의 package/common/component 와 package/common/business_component를 잘 활용한다.
-
+- 의 시설물 layer는 api 호출 결과 값 설정된 대로 zoom level에 따라 모두 보여야 한다.
## Encoding
- UTF-8
- 한글이 깨지지 않도록 주의할 것