diff --git a/apps/sn/app/(auth)/auth/layout.tsx b/apps/sn/app/(auth)/auth/layout.tsx
index 42f63e71a..2b4eb89a8 100644
--- a/apps/sn/app/(auth)/auth/layout.tsx
+++ b/apps/sn/app/(auth)/auth/layout.tsx
@@ -9,9 +9,5 @@ export default function AuthLayout({
}: {
children: React.ReactNode;
}) {
- return (
-
-
{children}
-
- );
+ return children;
}
diff --git a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx
new file mode 100644
index 000000000..7b4dcc924
--- /dev/null
+++ b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx
@@ -0,0 +1,210 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { Button, Input } from "@common/components";
+import { UiTable } from "@common/business_components/ui";
+import { getRawJson } from "@/components/lib/api";
+import LoadingOverlay from "@/components/ui/LoadingBar";
+import {
+ AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
+ AGED_PIPE_STATUS_BY_CTPV_PAGE_SIZE,
+ buildAgedPipeStatusByCtpvHeadList,
+ buildAgedPipeStatusYearOptions,
+ normalizeAgedPipeStatusByCtpvResponse,
+ normalizeAgedPipeStatusByCtpvFilters,
+ toAgedPipeStatusByCtpvRows,
+ validateAgedPipeStatusByCtpvFilters,
+ type AgedPipeStatusByCtpvFilters,
+ type AgedPipeStatusByCtpvResponse,
+ type AgedPipeStatusByCtpvRow,
+} from "./agedPipeStatusByCtpvUtils";
+import { downloadAgedPipeStatusByCtpvCsv } from "./agedPipeStatusByCtpvExport";
+
+export default function AgedPipeStatusByCtpvPopupPage() {
+ // 조회조건 입력 상태다. 기본값 30년, 최근 연도 기준 단년 조회로 시작한다.
+ const [filters, setFilters] = useState(() =>
+ normalizeAgedPipeStatusByCtpvFilters({
+ elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
+ }),
+ );
+ // 서버 응답을 화면 그리드에 맞게 평탄화한 row 목록이다.
+ const [rows, setRows] = useState([]);
+ // 동적 컬럼 생성을 위한 기준년도 목록이다.
+ const [years, setYears] = useState([]);
+ // 조회/다운로드 중 로딩 overlay를 제어한다.
+ const [isLoading, setIsLoading] = useState(false);
+ // 사용자에게 현재 다운로드 상태를 보여주는 진행 문구다.
+ const [downloadProgressText, setDownloadProgressText] = useState("");
+
+ const yearOptions = useMemo(() => buildAgedPipeStatusYearOptions(), []);
+ const headList = useMemo(() => buildAgedPipeStatusByCtpvHeadList(years), [years]);
+
+ const loadRows = useCallback(async (nextFilters: AgedPipeStatusByCtpvFilters) => {
+ setIsLoading(true);
+ try {
+ const normalized = normalizeAgedPipeStatusByCtpvFilters(nextFilters);
+ const queryParams = new URLSearchParams({
+ startYear: String(normalized.startYear),
+ endYear: String(normalized.endYear),
+ elapsedYears: String(normalized.elapsedYears),
+ });
+ const rawResponse = await getRawJson(
+ `v1/swagePpl/aged-status-by-ctpv?${queryParams.toString()}`,
+ );
+ const response = normalizeAgedPipeStatusByCtpvResponse(rawResponse) as AgedPipeStatusByCtpvResponse;
+ setYears(Array.isArray(response?.years) ? response.years : []);
+ setRows(toAgedPipeStatusByCtpvRows(response));
+ } catch (error) {
+ console.error("시도별 노후관로현황 조회 중 오류:", error);
+ setYears([]);
+ setRows([]);
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void loadRows(filters);
+ }, [filters, loadRows]);
+
+ const handleSearch = async () => {
+ const normalized = normalizeAgedPipeStatusByCtpvFilters(filters);
+ if (!validateAgedPipeStatusByCtpvFilters(normalized)) {
+ alert("기준년도는 최대 3년 범위 내에서 선택하고, 경과년도는 1~100 사이로 입력해주세요.");
+ return;
+ }
+ setFilters(normalized);
+ await loadRows(normalized);
+ };
+
+ const handleDownload = async () => {
+ try {
+ setIsLoading(true);
+ setDownloadProgressText("시도별 노후관로현황 CSV를 준비하고 있습니다.");
+ await downloadAgedPipeStatusByCtpvCsv(filters);
+ setDownloadProgressText("");
+ } catch (error) {
+ console.error("시도별 노후관로현황 다운로드 중 오류:", error);
+ setDownloadProgressText("");
+ alert(
+ error instanceof Error
+ ? error.message
+ : "시도별 노후관로현황 CSV 다운로드 중 오류가 발생했습니다.",
+ );
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+ {downloadProgressText ? (
+
+ {downloadProgressText}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ // 조회 결과 총 {rows.length.toLocaleString()}건
+ headName={<>시도별 노후관로현황 >}
+ tableData={rows}
+ headlist={headList}
+ pageSize={AGED_PIPE_STATUS_BY_CTPV_PAGE_SIZE}
+ total={rows.length}
+ cellClick={() => undefined}
+ onPageChange={() => undefined}
+ curPage={1}
+ index={-1}
+ widthFixed={true}
+ isHideCount={true}
+ cellClassName={() => "text-[1.2rem]"}
+ />
+
+
+
+ );
+}
diff --git a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvExport.ts b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvExport.ts
new file mode 100644
index 000000000..df5dbf0d0
--- /dev/null
+++ b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvExport.ts
@@ -0,0 +1,25 @@
+"use client";
+
+import { postBlobData } from "@/components/lib/api";
+import { fileDownload } from "@/components/lib/utils/fileDownload";
+import {
+ normalizeAgedPipeStatusByCtpvFilters,
+ type AgedPipeStatusByCtpvFilters,
+} from "./agedPipeStatusByCtpvUtils";
+
+/** 시도별 노후관로현황 CSV 다운로드를 요청한다. */
+export const downloadAgedPipeStatusByCtpvCsv = async (
+ filters: AgedPipeStatusByCtpvFilters,
+) => {
+ const normalizedFilters = normalizeAgedPipeStatusByCtpvFilters(filters);
+ const { blob, fileName } = await postBlobData("v1/swagePpl/aged-status-by-ctpv/exportJobs/download", {
+ startYear: normalizedFilters.startYear,
+ endYear: normalizedFilters.endYear,
+ elapsedYears: normalizedFilters.elapsedYears,
+ format: "csv",
+ });
+
+ const resolvedFileName = fileName || "시도별_노후관로현황.csv";
+ fileDownload(blob, resolvedFileName);
+ return resolvedFileName;
+};
diff --git a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvUtils.ts b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvUtils.ts
new file mode 100644
index 000000000..d41f12d31
--- /dev/null
+++ b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvUtils.ts
@@ -0,0 +1,153 @@
+"use client";
+
+import type { UiHeadProps } from "@common/business_components/ui";
+
+export const AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS = 30;
+export const AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN = 3;
+export const AGED_PIPE_STATUS_BY_CTPV_PAGE_SIZE = 50;
+
+export type AgedPipeStatusByCtpvFilters = {
+ startYear: number;
+ endYear: number;
+ elapsedYears: number;
+};
+
+export type AgedPipeStatusByCtpvYearStat = {
+ year: number;
+ totalLengthKm: number;
+ agedLengthKm: number;
+ agedRate: number;
+};
+
+export type AgedPipeStatusByCtpvRow = {
+ rowNumber: number;
+ ctpvCd: string;
+ ctpvNm: string;
+ yearStats: AgedPipeStatusByCtpvYearStat[];
+ [key: string]: string | number | AgedPipeStatusByCtpvYearStat[];
+};
+
+export type AgedPipeStatusByCtpvResponse = {
+ startYear: number;
+ endYear: number;
+ elapsedYears: number;
+ years: number[];
+ rows: Array<{
+ ctpvCd: string;
+ ctpvNm: string;
+ yearStats: AgedPipeStatusByCtpvYearStat[];
+ }>;
+};
+
+export const normalizeAgedPipeStatusByCtpvResponse = (
+ response: Partial | null | undefined,
+): AgedPipeStatusByCtpvResponse => ({
+ startYear: Number(response?.startYear ?? 0),
+ endYear: Number(response?.endYear ?? 0),
+ elapsedYears: Number(response?.elapsedYears ?? AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS),
+ years: Array.isArray(response?.years) ? response.years.map((year) => Number(year)) : [],
+ rows: Array.isArray(response?.rows) ? response.rows : [],
+});
+
+export const buildAgedPipeStatusYearOptions = () => {
+ const currentYear = new Date().getFullYear();
+ return Array.from({ length: currentYear - 1900 + 1 }, (_, index) => currentYear - index);
+};
+
+export const normalizeAgedPipeStatusByCtpvFilters = (
+ filters: Partial,
+): AgedPipeStatusByCtpvFilters => {
+ const currentYear = new Date().getFullYear();
+ let startYear = Math.trunc(Number(filters.startYear ?? currentYear));
+ let endYear = Math.trunc(Number(filters.endYear ?? startYear));
+ let elapsedYears = Math.trunc(
+ Number(filters.elapsedYears ?? AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS),
+ );
+
+ if (!Number.isFinite(startYear)) startYear = currentYear;
+ if (!Number.isFinite(endYear)) endYear = startYear;
+ if (!Number.isFinite(elapsedYears) || elapsedYears < 1 || elapsedYears > 100) {
+ elapsedYears = AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS;
+ }
+ if (startYear > endYear) {
+ const swap = startYear;
+ startYear = endYear;
+ endYear = swap;
+ }
+ if (endYear - startYear + 1 > AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN) {
+ endYear = startYear + (AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN - 1);
+ }
+
+ return {
+ startYear,
+ endYear,
+ elapsedYears,
+ };
+};
+
+export const validateAgedPipeStatusByCtpvFilters = (filters: AgedPipeStatusByCtpvFilters) => {
+ if (filters.startYear > filters.endYear) return false;
+ if (filters.endYear - filters.startYear + 1 > AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN) return false;
+ return filters.elapsedYears >= 1 && filters.elapsedYears <= 100;
+};
+
+const formatMetric = (value: number) =>
+ Number(value ?? 0).toLocaleString("ko-KR", {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ });
+
+export const toAgedPipeStatusByCtpvRows = (
+ response: Partial | null | undefined,
+): AgedPipeStatusByCtpvRow[] =>
+ normalizeAgedPipeStatusByCtpvResponse(response).rows.map((row, index) => {
+ const displayRow: AgedPipeStatusByCtpvRow = {
+ rowNumber: index + 1,
+ ctpvCd: String(row.ctpvCd ?? "").trim(),
+ ctpvNm: String(row.ctpvNm ?? "").trim(),
+ yearStats: row.yearStats ?? [],
+ };
+
+ (row.yearStats ?? []).forEach((yearStat) => {
+ displayRow[`year${yearStat.year}TotalLengthKm`] = formatMetric(Number(yearStat.totalLengthKm ?? 0));
+ displayRow[`year${yearStat.year}AgedLengthKm`] = formatMetric(Number(yearStat.agedLengthKm ?? 0));
+ displayRow[`year${yearStat.year}AgedRate`] = formatMetric(Number(yearStat.agedRate ?? 0));
+ });
+
+ return displayRow;
+ });
+
+export const buildAgedPipeStatusByCtpvHeadList = (years: number[]): UiHeadProps[] => {
+ const heads: UiHeadProps[] = [
+ // { id: "rowNumber", title: "NO", widthPercent: 6, sortable: false },
+ { id: "ctpvNm", title: "시도", widthPercent: 14, sortable: false },
+ ];
+
+ years.forEach((year) => {
+ heads.push(
+ {
+ id: `year${year}TotalLengthKm`,
+ title: `${year} 전체관로연장(km)`,
+ widthPercent: 13,
+ sortable: false,
+ align: "right",
+ },
+ {
+ id: `year${year}AgedLengthKm`,
+ title: `${year} 노후관로연장(km)`,
+ widthPercent: 13,
+ sortable: false,
+ align: "right",
+ },
+ {
+ id: `year${year}AgedRate`,
+ title: `${year} 노후화율(%)`,
+ widthPercent: 11,
+ sortable: false,
+ align: "right",
+ },
+ );
+ });
+
+ return heads;
+};
diff --git a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx
index 51aff4d1b..51bd9a5f1 100644
--- a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx
+++ b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx
@@ -61,6 +61,7 @@ const ANALYSIS_POPUP_ITEMS: Array<{
{ key: "infoSearch", label: "정보검색", icon: Search },
{ key: "pipeAnalysisPopup", label: "관로분석", icon: ChartColumnBig },
{ key: "floodPredictionMapPopup", label: "침수예측지도", icon: Waves },
+ { key: "agedPipeStatusByCtpvPopup", label: "시도별 노후관로현황", icon: ChartColumnBig },
{ key: "gisEditPopup", label: "GIS 편집", icon: PencilRuler },
{ key: "gisManagementPopup", label: "GIS 관리", icon: Settings2 },
{ key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert },
diff --git a/apps/sn/app/(map-layout)/map/components/common/table.ts b/apps/sn/app/(map-layout)/map/components/common/table.ts
index 6441b99c6..e65222ddf 100644
--- a/apps/sn/app/(map-layout)/map/components/common/table.ts
+++ b/apps/sn/app/(map-layout)/map/components/common/table.ts
@@ -484,17 +484,20 @@ export const FldngWtlvMapCol = [
{ id: 'fldng_dstr_nm', title: '침수재해명' },
{ id: 'ctpv_nm', title: '시도명' },
{ id: 'sgg_nm', title: '시군구명' },
- { id: 'bgng_ymd', title: '침수시작일자' },
- { id: 'fldng_bgng_hm', title: '침수시작시분' },
- { id: 'end_ymd', title: '침수종료일자' },
- { id: 'fldng_end_hm', title: '침수종료시분' },
+ { id: 'fldng_bgng_dtm', title: '침수시작일시' },
+ { id: 'fldng_end_dtm', title: '침수종료일시' },
{ id: 'fldng_hr', title: '침수시간' },
{ id: 'fldng_dstrct_nm', title: '침수지구명' },
{ id: 'fldng_cs_dtl_cd', title: '침수원인상세코드' },
+ { id: 'dtl_ocrn_cs_cn', title: '침수발생원인' },
{ id: 'avg_fldng_wtlv', title: '평균침수위' },
{ id: 'fldng_area', title: '침수면적' },
{ id: 'exmn_yr', title: '조사년도' },
{ id: 'dam_amt', title: '피해금액' },
+ { id: 'rain_bgng_tm', title: '강우시작일시' },
+ { id: 'rain_end_tm', title: '강우종료일시' },
+ { id: 'rain_tot', title: '총강우량(mm)' },
+ { id: 'rain_str', title: '강우강도(mm/hr)' },
];
export const FldngWtlvMapCol2 = [
{ id: 'rowNumber', title: '일련번호' },
@@ -502,10 +505,8 @@ export const FldngWtlvMapCol2 = [
{ id: 'fldngDstrNm', title: '침수재해명' },
{ id: 'ctpvNm', title: '시도' },
{ id: 'sggNm', title: '시군구' },
- { id: 'bgngYmd', title: '침수시작일자' },
- { id: 'fldngBgngHm', title: '침수시작시분' },
- { id: 'endYmd', title: '침수종료일자' },
- { id: 'fldngEndHm', title: '침수종료시분' },
+ { id: 'fldngBgngDtm', title: '침수시작일시' },
+ { id: 'fldngEndDtm', title: '침수종료일시' },
{ id: 'fldngHr', title: '침수시간' },
{ id: 'fldngDstrctNm', title: '침수지구명' },
{ id: 'fldngCsDtlCd', title: '침수원인상세코드' },
@@ -513,6 +514,10 @@ export const FldngWtlvMapCol2 = [
{ id: 'fldngArea', title: '침수면적' },
{ id: 'exmnYr', title: '조사년도' },
{ id: 'damAmt', title: '피해금액' },
+ { id: 'rainBgngTm', title: '강우시작일시' },
+ { id: 'rainEndTm', title: '강우종료일시' },
+ { id: 'rainTot', title: '총강우량(mm)' },
+ { id: 'rainStr', title: '강우강도(mm/hr)' },
];
export const FldngWtlvMapExcelCol = {
'fldngYr': '침수년도',
diff --git a/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx
index 470a7d3c2..487ce744e 100644
--- a/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx
+++ b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx
@@ -8,9 +8,11 @@ import { Button, Input } from "@common/components";
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import type { UiTableSort } from "@common/business_components/ui";
import { getRawJson } from "@/components/lib/api";
+import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import useStore from "@/store/useStore";
import LoadingOverlay from "@/components/ui/LoadingBar";
import {
+ FLOOD_HISTORY_LAYER_TYPE,
FLDNG_HSTRY_DEFAULT_FILTERS,
FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE,
FLDNG_HSTRY_DOWNLOAD_HEADERS,
@@ -46,6 +48,15 @@ const resolveFldngHstryApiSort = (sort?: UiTableSort) => {
return { key: sort.key, direction: sort.direction } as UiTableSort;
};
+const resolveFldngHstryMapFeatureFid = (row: FldngHstryTableRow) => {
+ const fid = String(row.fid ?? "").trim();
+ if (fid.startsWith(`${FLOOD_HISTORY_LAYER_TYPE}.`)) return fid;
+ if (fid) return `${FLOOD_HISTORY_LAYER_TYPE}.${fid}`;
+
+ const mapFid = String(row.fldngMapFid ?? "").trim();
+ return mapFid || "";
+};
+
const buildDownloadRows = (rows: FldngHstryTableRow[]) =>
rows.map((row) => ({
rowNo: row.rowNo,
@@ -200,6 +211,22 @@ export default function FldngHstryPopupPage() {
if (!row) return;
setSelectedIndex(index);
+
+ const mapFeatureFid = resolveFldngHstryMapFeatureFid(row);
+ if (mapFeatureFid) {
+ window.opener?.postMessage(
+ {
+ type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE,
+ payload: {
+ layerNm: FLOOD_HISTORY_LAYER_TYPE,
+ fid: mapFeatureFid,
+ },
+ },
+ window.location.origin,
+ );
+ return;
+ }
+
window.opener?.postMessage(
{
type: FLDNG_HSTRY_ROW_SELECT_MESSAGE,
diff --git a/apps/sn/app/(map-layout)/map/components/fldngHstry/fldngHstryTypes.ts b/apps/sn/app/(map-layout)/map/components/fldngHstry/fldngHstryTypes.ts
index 4743f83a0..8940bdb3d 100644
--- a/apps/sn/app/(map-layout)/map/components/fldngHstry/fldngHstryTypes.ts
+++ b/apps/sn/app/(map-layout)/map/components/fldngHstry/fldngHstryTypes.ts
@@ -27,36 +27,36 @@ export type FldngHstryDepthZone = {
};
export type FldngHstryApiItem = {
- fid: string;
- fldngMapFid?: string;
- fldngWtlvMapSn?: number | null;
- taskClCd?: string;
- fldngDstrNm?: string;
- exmnYr?: string;
- avgFldngWtlv?: number | null;
- fldngArea?: number | null;
- dtlOcrnCsCn?: string;
- stdgCd?: string;
- ctpvCd?: string;
- sggCd?: string;
- dataSn?: number | null;
- bgngYmd?: string;
- endYmd?: string;
- fldngBgngHm?: string;
- fldngEndHm?: string;
- fldngCsDtlCd?: string;
- fldngDstrctNm?: string;
- damAmt?: number | null;
- fldngHr?: number | null;
- fldngYr?: string;
- cstrnFbctnYn?: string;
- atchFileSn?: number | null;
- confArea?: number | null;
- confLen?: number | null;
- rainBgngTm?: string | null;
- rainEndTm?: string | null;
- rainTot?: number | null;
- rainStr?: number | null;
+ fid: string; // FID
+ fldngMapFid?: string; // 침수흔적도지도 FID
+ fldngWtlvMapSn?: number | null; // 침수흔적도지도 일련번호
+ taskClCd?: string; // 업무 구분 코드
+ fldngDstrNm?: string; // 침수재해명
+ exmnYr?: string; // 조사년도
+ avgFldngWtlv?: number | null; // 평균침수위
+ fldngArea?: number | null; // 침수면적
+ dtlOcrnCsCn?: string; // 상세발생원인내용
+ stdgCd?: string; // 표준지역코드
+ ctpvCd?: string; // 시도코드
+ sggCd?: string; // 시군구코드
+ dataSn?: number | null; // 데이터 일련번호
+ bgngYmd?: string; // 시작일자
+ endYmd?: string; // 종료일자
+ fldngBgngHm?: string; // 침수시작시분
+ fldngEndHm?: string; // 침수종료시분
+ fldngCsDtlCd?: string; // 침수원인상세코드
+ fldngDstrctNm?: string; // 침수지구명
+ damAmt?: number | null; // 피해금액
+ fldngHr?: number | null; // 침수시간
+ fldngYr?: string; // 침수년도
+ cstrnFbctnYn?: string; // 공사여부
+ atchFileSn?: number | null; // 첨부파일 일련번호
+ confArea?: number | null; // 확정면적
+ confLen?: number | null; // 확정연장
+ rainBgngTm?: string | null; // 강우시작일시
+ rainEndTm?: string | null; // 강우종료일시
+ rainTot?: number | null; // 총강우량
+ rainStr?: number | null; // 강우강도
};
export type FldngHstryItem = FldngHstryApiItem & {
@@ -120,20 +120,20 @@ export const FLDNG_HSTRY_DEPTH_LEGEND_ITEMS = [
export const FLDNG_HSTRY_LIST_HEADLIST: UiHeadProps[] = [
{ id: "rowNo", title: "NO", widthPercent: 5, sortable: false },
- { id: "floodYearDisplay", title: "침수년도", widthPercent: 6, sortable: true, sortKey: "fldngYr" },
+ { id: "floodYearDisplay", title: "침수\n년도", widthPercent: 6, sortable: true, sortKey: "fldngYr" },
{ id: "ctpvNm", title: "시도", widthPercent: 7, sortable: true, sortKey: "ctpvCd" },
{ id: "sggNm", title: "시군구", widthPercent: 8, sortable: true, sortKey: "sggCd" },
{ id: "disasterName", title: "침수재해명", widthPercent: 14, sortable: true, sortKey: "fldngDstrNm" },
- { id: "rainBgngTmDisplay", title: "강우시작일시", widthPercent: 10, sortable: true, sortKey: "rainBgngTm" },
- { id: "rainEndTmDisplay", title: "강우종료일시", widthPercent: 10, sortable: true, sortKey: "rainEndTm" },
+ { id: "rainBgngTmDisplay", title: "강우\n시작일시", widthPercent: 10, sortable: true, sortKey: "rainBgngTm" },
+ { id: "rainEndTmDisplay", title: "강우\n종료일시", widthPercent: 10, sortable: true, sortKey: "rainEndTm" },
{ id: "rainTotDisplay", title: "총강우량(mm)", widthPercent: 8, sortable: true, sortKey: "rainTot" },
{ id: "rainStrDisplay", title: "강우강도(mm/hr)", widthPercent: 8, sortable: true, sortKey: "rainStr" },
{ id: "floodHourDisplay", title: "침수시간", widthPercent: 7, sortable: true, sortKey: "fldngHr" },
{ id: "districtName", title: "침수지구명", widthPercent: 10, sortable: true, sortKey: "fldngDstrctNm" },
- { id: "causeDetailCode", title: "침수원인상세코드", widthPercent: 8, sortable: true, sortKey: "fldngCsDtlCd" },
- { id: "causeDetailName", title: "침수원인상세", widthPercent: 12, sortable: false },
- { id: "avgFloodDepthDisplay", title: "평균침수위", widthPercent: 7, sortable: true, sortKey: "avgFldngWtlv" },
- { id: "floodAreaDisplay", title: "침수면적", widthPercent: 7, sortable: true, sortKey: "fldngArea" },
+ //{ id: "causeDetailCode", title: "침수원인상세코드", widthPercent: 8, sortable: true, sortKey: "fldngCsDtlCd" },
+ //{ id: "causeDetailName", title: "침수원인상세", widthPercent: 12, sortable: false },
+ { id: "avgFloodDepthDisplay", title: "평균\n침수위", widthPercent: 7, sortable: true, sortKey: "avgFldngWtlv" },
+ { id: "floodAreaDisplay", title: "침수\n면적\(ha)", widthPercent: 7, sortable: true, sortKey: "fldngArea" },
{ id: "exmnYearDisplay", title: "조사년도", widthPercent: 6, sortable: true, sortKey: "exmnYr" },
{ id: "damageAmountDisplay", title: "피해금액", widthPercent: 9, sortable: true, sortKey: "damAmt" },
];
@@ -291,6 +291,7 @@ const normalizeHm = (value?: string | null) => {
const normalizeDateTime = (value?: string | null) => {
const source = String(value ?? "").trim();
if (!source) return "";
+ if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(source)) return source;
const date = new Date(source);
if (Number.isNaN(date.getTime())) return source.slice(0, 16).replace("T", " ");
const yyyy = date.getFullYear();
@@ -313,6 +314,13 @@ const formatInteger = (value?: number | null, suffix = "") => {
return `${numeric.toLocaleString("ko-KR")}${suffix}`;
};
+const formatOptionalNumber = (value?: number | null, digits = 1, suffix = "") => {
+ if (value === null || value === undefined || value === "") return "-";
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) return "-";
+ return `${numeric.toFixed(digits)}${suffix}`;
+};
+
const buildCauseDetailName = (item: FldngHstryApiItem) => {
const direct = String(item.dtlOcrnCsCn ?? "").trim();
if (direct) return direct;
@@ -361,9 +369,12 @@ export const buildFldngHstryListQueryString = (
return params.toString();
};
-export const formatFloodArea = (value: number) => `${value.toFixed(1)}ha`;
+export const formatFloodArea = (value: number) => Number(value ?? 0).toLocaleString("ko-KR", {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 2,
+ });
-export const formatFloodDepth = (value: number) => `${value.toFixed(2)}m`;
+export const formatFloodDepth = (value: number) => value.toFixed(2);
export const attachFldngHstryGeometry = (item: FldngHstryApiItem): FldngHstryItem => {
const historyId = String(item.fid ?? "");
@@ -392,10 +403,10 @@ export const attachFldngHstryGeometry = (item: FldngHstryApiItem): FldngHstryIte
endDate,
startTime,
endTime,
- floodHour: formatNumber(Number(item.fldngHr ?? 0), 1, "시간"),
+ floodHour: formatNumber(Number(item.fldngHr ?? 0), 1),
causeDetailCode: String(item.fldngCsDtlCd ?? "").trim(),
causeDetailName: buildCauseDetailName(item),
- damageAmount: formatInteger(Number(item.damAmt ?? 0), "원"),
+ damageAmount: formatInteger(Number(item.damAmt ?? 0)),
rainStartDateTime: normalizeDateTime(item.rainBgngTm),
rainEndDateTime: normalizeDateTime(item.rainEndTm),
rainStartTime: normalizeDateTime(item.rainBgngTm).slice(11, 16),
@@ -422,16 +433,16 @@ export const toFldngHstryTableRows = (
items.map((item, index) => ({
...item,
rowNo: (page - 1) * pageSize + index + 1,
- floodYearDisplay: item.floodYear ? `${item.floodYear}년` : "-",
+ floodYearDisplay: item.floodYear || "-",
floodHourDisplay: item.floodHour || "-",
avgFloodDepthDisplay: formatFloodDepth(item.avgFloodDepthM),
floodAreaDisplay: formatFloodArea(item.floodAreaHa),
- exmnYearDisplay: item.exmnYear ? `${item.exmnYear}년` : "-",
+ exmnYearDisplay: item.exmnYear || "-",
damageAmountDisplay: item.damageAmount || "-",
rainBgngTmDisplay: item.rainStartDateTime || "-",
rainEndTmDisplay: item.rainEndDateTime || "-",
- rainTotDisplay: formatNumber(item.totalRainfallMm, 1),
- rainStrDisplay: formatNumber(item.rainfallIntensityMmHr, 1),
+ rainTotDisplay: formatOptionalNumber(item.rainTot, 1),
+ rainStrDisplay: formatOptionalNumber(item.rainStr, 1),
}));
const resolveFldngHstryNodeName = (node: any) =>
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 3a75126fa..fea0e45df 100644
--- a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx
+++ b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx
@@ -32,13 +32,23 @@ import Fill from "ol/style/Fill.js";
import Stroke from "ol/style/Stroke.js";
import CircleStyle from "ol/style/Circle.js";
import { getLayerContentComponent } from "../common";
-import { FldngWtlvMapCol, FldngWtlvMapCol2, SwtrmtFcltCol, SwtrmtFcltCol2, SwtrmtPrcsZoneCol, SwtrmtPrcsZoneCol2, untrtSwgRainCol1, untrtSwgRainCol2, MsSwagePplCol, MsSwagePplCol2, ExclnSewagePumpCol, ExclnSewagePumpCol2, DrtwSewagePumpCol, DrtwSewagePumpCol2, MsSwageMhlCol, MsSwageMhlCol2, stofcbrCol, stofcbrCol2, MS_SWAGE_MHL_CODES, DRTW_SEWAGE_PUMP_USE_CODES } from "../common/table";
+import { FldngWtlvMapCol, FldngWtlvMapCol2
+ , SwtrmtFcltCol, SwtrmtFcltCol2
+ , SwtrmtPrcsZoneCol, SwtrmtPrcsZoneCol2
+ , untrtSwgRainCol1, untrtSwgRainCol2
+ , MsSwagePplCol, MsSwagePplCol2
+ , ExclnSewagePumpCol, ExclnSewagePumpCol2
+ , DrtwSewagePumpCol, DrtwSewagePumpCol2
+ , MsSwageMhlCol, MsSwageMhlCol2
+ , stofcbrCol, stofcbrCol2
+ , MS_SWAGE_MHL_CODES, DRTW_SEWAGE_PUMP_USE_CODES } from "../common/table";
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
import { codeMapper } from "../common/codeMapper";
import LoadingOverlay from '@/components/ui/LoadingBar';
import { searchUpperPpls } from "./smh";
import { getElapsedYearsDisplayText, isInvalidInstallYmd } from "../agedPipe/agedPipeUtils";
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
+import useStore from "@/store/useStore";
const linkedFacilityHighlightStyle = new Style({
image: new CircleStyle({
@@ -67,6 +77,45 @@ const normalizeFldngWtlvMapDisplayId = (value: unknown) => {
return normalized.startsWith(prefix) ? normalized.slice(prefix.length) : normalized;
};
+const normalizeFldngWtlvMapApiFid = (value: unknown) => {
+ const normalized = String(value ?? "").trim();
+ if (!normalized) return "";
+ return normalizeFldngWtlvMapDisplayId(normalized);
+};
+
+const normalizeRainAnalysisPayload = (data: any) => {
+ if (!data || typeof data !== "object") return {};
+ return {
+ rain_bgng_tm: data.rainBgngTm ?? data.rain_bgng_tm ?? null,
+ rainBgngTm: data.rainBgngTm ?? data.rain_bgng_tm ?? null,
+ rain_end_tm: data.rainEndTm ?? data.rain_end_tm ?? null,
+ rainEndTm: data.rainEndTm ?? data.rain_end_tm ?? null,
+ rain_tot: data.rainTot ?? data.rain_tot ?? null,
+ rainTot: data.rainTot ?? data.rain_tot ?? null,
+ rain_str: data.rainStr ?? data.rain_str ?? null,
+ rainStr: data.rainStr ?? data.rain_str ?? null,
+ };
+};
+
+// 침수흔적도 침수시작일시, 침수종료일시 한줄로 보이도록
+const formatFldngWtlvDateTime = (dateValue: unknown, timeValue: unknown) => {
+ const rawDate = String(dateValue ?? "").trim();
+ const rawTime = String(timeValue ?? "").trim();
+
+ const normalizedDate = /^\d{8}$/.test(rawDate)
+ ? `${rawDate.slice(0, 4)}-${rawDate.slice(4, 6)}-${rawDate.slice(6, 8)}`
+ : rawDate;
+
+ const timeDigits = rawTime.replace(/[^0-9]/g, "");
+ const normalizedTime = !timeDigits
+ ? ""
+ : timeDigits.length <= 2
+ ? `${timeDigits.padStart(2, "0")}:00`
+ : `${timeDigits.padStart(4, "0").slice(0, 2)}:${timeDigits.padStart(4, "0").slice(2, 4)}`;
+
+ return [normalizedDate, normalizedTime].filter(Boolean).join(" ");
+};
+
type MngInstOption = {
mngInstCd: string;
lwrInstNm: string;
@@ -92,6 +141,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
const linkedFacilityFeatureRef = useRef(null);
const linkedFacilityOriginalStyleRef = useRef(null);
const [mngInstOptions, setMngInstOptions] = useState([]);
+ const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
// 🎯 관로관리번호 추출
const pplMngNo = featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || "";
@@ -149,6 +199,11 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
};
}, [isPipeFeature]);
+ useEffect(() => {
+ if (ctpv.length === 0) void fetchCtpv();
+ if (sgg.length === 0) void fetchSgg();
+ }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
+
const mngInstNameMap = useMemo(() => {
const map = new Map();
mngInstOptions.forEach((option) => {
@@ -169,6 +224,24 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
};
};
+ const withResolvedRegionNames = (data: any) => {
+ if (!data || typeof data !== "object") return data;
+
+ const stdgCd = String(data.stdg_cd ?? data.stdgCd ?? "").trim();
+ const ctpvCd = String(data.ctpv_cd ?? data.ctpvCd ?? stdgCd.slice(0, 2)).trim();
+ const sggCd = String(data.sgg_cd ?? data.sggCd ?? stdgCd.slice(0, 5)).trim();
+ const resolvedCtpvNm = ctpv.find((item) => String(item.val).trim() === ctpvCd)?.text ?? "";
+ const resolvedSggNm = sgg.find((item) => String(item.val).trim() === sggCd)?.text ?? "";
+
+ return {
+ ...data,
+ ctpv_nm: data.ctpv_nm ?? data.ctpvNm ?? resolvedCtpvNm,
+ ctpvNm: data.ctpvNm ?? data.ctpv_nm ?? resolvedCtpvNm,
+ sgg_nm: data.sgg_nm ?? data.sggNm ?? resolvedSggNm,
+ sggNm: data.sggNm ?? data.sgg_nm ?? resolvedSggNm,
+ };
+ };
+
const hasDetailValue = (value: unknown) => {
if (value === null || value === undefined) return false;
if (typeof value === "string") return value.trim() !== "";
@@ -446,7 +519,25 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
}
// 🎯 기본 데이터 포맷팅
- const formattedData = withMngInstName(formatDisplayData(data));
+ const normalizedRegionData = selectedLayerName === '침수흔적도'
+ ? withResolvedRegionNames(data)
+ : data;
+ const formattedData = withMngInstName(formatDisplayData(normalizedRegionData));
+ if (selectedLayerName === '침수흔적도') {
+ const fldngBgngDtm = formatFldngWtlvDateTime(
+ formattedData.bgngYmd ?? formattedData.bgng_ymd ?? data.bgngYmd ?? data.bgng_ymd,
+ formattedData.fldngBgngHm ?? formattedData.fldng_bgng_hm ?? data.fldngBgngHm ?? data.fldng_bgng_hm,
+ );
+ const fldngEndDtm = formatFldngWtlvDateTime(
+ formattedData.endYmd ?? formattedData.end_ymd ?? data.endYmd ?? data.end_ymd,
+ formattedData.fldngEndHm ?? formattedData.fldng_end_hm ?? data.fldngEndHm ?? data.fldng_end_hm,
+ );
+
+ formattedData.fldng_bgng_dtm = fldngBgngDtm;
+ formattedData.fldngBgngDtm = fldngBgngDtm;
+ formattedData.fldng_end_dtm = fldngEndDtm;
+ formattedData.fldngEndDtm = fldngEndDtm;
+ }
if (selectedLayerName === '하수관로') {
const installYmd = data.instl_ymd ?? data.instlYmd;
formattedData.elapsedYears = getElapsedYearsDisplayText(installYmd);
@@ -556,6 +647,26 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
console.warn("Pipe detail fallback kept initial feature data.");
}
}
+
+ if (selectedLayerName === '침수흔적도' && featureId) {
+ try {
+ const normalizedFloodMapFid = normalizeFldngWtlvMapApiFid(featureId);
+ if (!normalizedFloodMapFid) {
+ throw new Error("침수흔적도 fid가 비어 있습니다.");
+ }
+ const rainAnalysis = await getData(`v1/fldng/hstry/rain-anls/${encodeURIComponent(normalizedFloodMapFid)}`);
+ if (rainAnalysis) {
+ const mergedData = {
+ ...formattedData,
+ ...normalizeRainAnalysisPayload(rainAnalysis),
+ };
+ setFetchData(mergedData);
+ setTableData(mergedData);
+ }
+ } catch (rainError) {
+ console.warn("침수강우분석 조회 실패:", rainError);
+ }
+ }
} catch (error) {
console.error('데이터 로딩 중 오류:', error);
}
diff --git a/apps/sn/docs/작업검증/SFR-011-001-시도별 노후관로현황/001-qa-SFR-011-001-시도별 노후관로현황-검증기록.md b/apps/sn/docs/작업검증/SFR-011-001-시도별 노후관로현황/001-qa-SFR-011-001-시도별 노후관로현황-검증기록.md
new file mode 100644
index 000000000..b95de821c
--- /dev/null
+++ b/apps/sn/docs/작업검증/SFR-011-001-시도별 노후관로현황/001-qa-SFR-011-001-시도별 노후관로현황-검증기록.md
@@ -0,0 +1,10 @@
+# SFR-011-001 시도별 노후관로현황 FE 검증 기록
+
+## 1. 정적 확인
+- 분석도구 메뉴, popup definition, popup registry, popup page 연결을 코드 기준으로 확인했다.
+- 동적 연도 컬럼 생성 로직과 CSV 다운로드 호출 경로를 확인했다.
+
+## 2. 실행 확인
+- `pnpm --filter sn build`
+ - 결과: 시간 제한 내 완료되지 않아 최종 성공/실패를 확정하지 못했다.
+ - 비고: 이 저장소는 기존에도 FE build가 환경 영향으로 지연 또는 `EMFILE` 이슈가 있었으므로 사용자 로컬 실행 확인이 추가로 필요하다.
diff --git a/apps/sn/docs/작업계획/SFR-011-001-시도별 노후관로현황/001-fe-SFR-011-001-시도별 노후관로현황-작업카드.md b/apps/sn/docs/작업계획/SFR-011-001-시도별 노후관로현황/001-fe-SFR-011-001-시도별 노후관로현황-작업카드.md
new file mode 100644
index 000000000..e8cbd094a
--- /dev/null
+++ b/apps/sn/docs/작업계획/SFR-011-001-시도별 노후관로현황/001-fe-SFR-011-001-시도별 노후관로현황-작업카드.md
@@ -0,0 +1,19 @@
+# SFR-011-001 시도별 노후관로현황 FE 작업카드
+
+## 1. 작업 목적
+- 하수관망도 우측 분석도구에서 `시도별 노후관로현황` 팝업을 열고 기준년도 구간별 시도 집계를 조회한다.
+
+## 2. 구현 범위
+- `AnalysisPopupMenu`에 진입 버튼 추가
+- `windowPopup` registry에 새 popup key 추가
+- 전용 popup page 구현
+- 기준년도 시작/종료, 경과년도 기본값 30년, CSV 다운로드 연결
+
+## 3. 구현 기준
+- 기존 `노후관로 현황` popup의 레이아웃 톤과 다운로드 UX를 최대한 재사용한다.
+- 집계형 표이므로 지도 feature 선택 연동은 넣지 않는다.
+- 기준년도 범위는 최대 3년까지만 허용한다.
+
+## 4. 연동 API
+- `GET /api/v1/swagePpl/aged-status-by-ctpv`
+- `POST /api/v1/swagePpl/aged-status-by-ctpv/exportJobs/download`
diff --git a/apps/sn/docs/작업내용/SFR-011-001-시도별 노후관로현황/001-fe-SFR-011-001-시도별 노후관로현황-통합구현기록.md b/apps/sn/docs/작업내용/SFR-011-001-시도별 노후관로현황/001-fe-SFR-011-001-시도별 노후관로현황-통합구현기록.md
new file mode 100644
index 000000000..5b708afa5
--- /dev/null
+++ b/apps/sn/docs/작업내용/SFR-011-001-시도별 노후관로현황/001-fe-SFR-011-001-시도별 노후관로현황-통합구현기록.md
@@ -0,0 +1,22 @@
+# SFR-011-001 시도별 노후관로현황 FE 통합 구현 기록
+
+## 1. 구현 요약
+- 분석도구 메뉴에 `시도별 노후관로현황` 항목을 추가했다.
+- 새 popup key `agedPipeStatusByCtpvPopup`와 registry content를 연결했다.
+- 전용 popup page에서 기준년도 시작/종료, 경과년도(기본 30년), 조회, CSV 다운로드를 구현했다.
+- 응답의 `years + rows.yearStats` 구조를 동적 컬럼 `UiTable` row로 변환하도록 유틸을 추가했다.
+
+## 2. 주요 수정 파일
+- `app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx`
+- `src/features/windowPopup/model/popupDefinitions.ts`
+- `src/features/windowPopup/registry.tsx`
+- `src/features/windowPopup/ui/AgedPipeStatusByCtpvPopupContent.tsx`
+- `app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx`
+- `app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvUtils.ts`
+- `app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvExport.ts`
+
+## 3. 화면 동작
+- popup 제목은 `시도별 노후관로현황`이다.
+- 기준년도 범위는 최대 3년으로 제한한다.
+- 경과년도는 기본 `30년`으로 시작하고, 후속에 사용자가 변경 가능한 입력 구조를 미리 반영했다.
+- CSV 다운로드는 기존 노후관로 popup과 동일하게 `postBlobData + fileDownload` 흐름을 따른다.
diff --git a/apps/sn/docs/작업내용/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-통합구현기록.md b/apps/sn/docs/작업내용/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-통합구현기록.md
index 6067080f1..fa051e08d 100644
--- a/apps/sn/docs/작업내용/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-통합구현기록.md
+++ b/apps/sn/docs/작업내용/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-통합구현기록.md
@@ -71,3 +71,15 @@
- popup 라우트의 초기 컴파일 시간이 길어 자동 요청에서 timeout이 발생할 수 있다.
- `침수흔적도` 기준데이터 이름/구조와 FE 판별식이 다를 수 있다.
- 실제 API 필드가 확정되면 상세패널 항목명/정렬이 일부 바뀔 수 있다.
+
+## 7. 2026-07-28 지도 클릭 강우분석 조회 연동
+
+- 반영 파일
+ - `FE_APP_ROOT/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx`
+ - `FE_APP_ROOT/app/(map-layout)/map/components/common/table.ts`
+- 구현 내용
+ - 지도에서 `침수흔적도(tb_ms_fldng_wtlv_map)`를 클릭해 우측 상세를 열면, 기존 원본 속성값만 보여주던 구조에 `fid` 기준 강우분석 조회를 추가했다.
+ - FE는 `GET /api/v1/fldng/hstry/rain-anls/{fid}`를 추가 호출한 뒤 `tb_sn_fldng_wtlv_rain_anls` 응답을 상세 테이블 데이터에 merge한다.
+ - 침수흔적도 상세 컬럼에 `강우시작일시`, `강우종료일시`, `총강우량(mm)`, `강우강도(mm/hr)`를 추가했다.
+- 영향
+ - 레이어 트리 popup 목록과 별도로, 실제 지도 객체 클릭 상세에서도 강우분석 정보를 동일하게 확인할 수 있다.
diff --git a/apps/sn/middleware.ts b/apps/sn/middleware.ts
index 2eddd4de5..f8a8c42e1 100644
--- a/apps/sn/middleware.ts
+++ b/apps/sn/middleware.ts
@@ -72,6 +72,7 @@ export function middleware(req: NextRequest) {
typename == "hasudo:tb_il_swage_prcs_fclt" //하수처리시설
|| typename == "hasudo:tb_ms_swage_prcs_zone" //하수처리구역
|| typename == "hasudo:tb_ms_fldng_wtlv_map" //침수흔적도
+ || typename == "hasudo:tb_ms_fldng_dpth_map" //침수흔적도-깊이
|| typename == "hasudo:tb_ms_sbmrg_dngr_map" //침수위험지도
|| typename == "hasudo:tb_ms_flood_dngr_map" //도시침수지도
) {
diff --git a/apps/sn/src/app/index.tsx b/apps/sn/src/app/index.tsx
index d70dc3d73..5a73f2fdd 100644
--- a/apps/sn/src/app/index.tsx
+++ b/apps/sn/src/app/index.tsx
@@ -1,9 +1,5 @@
import "./styles/base.css";
import "./styles/components.css";
-import "./styles/components-black.css";
-import "./styles/components-blue.css";
-import "./styles/components-green.css";
-import "./styles/components-red.css";
import { AppProvider } from "./providers";
import { cookies } from "next/headers";
diff --git a/apps/sn/src/app/styles/components-black.css b/apps/sn/src/app/styles/components-black.css
deleted file mode 100644
index d6378dad4..000000000
--- a/apps/sn/src/app/styles/components-black.css
+++ /dev/null
@@ -1,20 +0,0 @@
-@tailwind components;
-
-@layer components {
- .card {
- @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-label;
- }
- .card-border {
- @apply border border-border bg-background shadow-blur;
- }
- .card-header {
- @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-border p-4;
- }
- .card-footer {
- @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-border py-4;
- }
-
- input[type="file"] {
- @apply h-[40px] w-[200px] cursor-pointer rounded-md border border-input bg-field px-3 py-2 text-sm text-foreground;
- }
-}
diff --git a/apps/sn/src/app/styles/components-blue.css b/apps/sn/src/app/styles/components-blue.css
deleted file mode 100644
index d6378dad4..000000000
--- a/apps/sn/src/app/styles/components-blue.css
+++ /dev/null
@@ -1,20 +0,0 @@
-@tailwind components;
-
-@layer components {
- .card {
- @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-label;
- }
- .card-border {
- @apply border border-border bg-background shadow-blur;
- }
- .card-header {
- @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-border p-4;
- }
- .card-footer {
- @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-border py-4;
- }
-
- input[type="file"] {
- @apply h-[40px] w-[200px] cursor-pointer rounded-md border border-input bg-field px-3 py-2 text-sm text-foreground;
- }
-}
diff --git a/apps/sn/src/app/styles/components-green.css b/apps/sn/src/app/styles/components-green.css
deleted file mode 100644
index d6378dad4..000000000
--- a/apps/sn/src/app/styles/components-green.css
+++ /dev/null
@@ -1,20 +0,0 @@
-@tailwind components;
-
-@layer components {
- .card {
- @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-label;
- }
- .card-border {
- @apply border border-border bg-background shadow-blur;
- }
- .card-header {
- @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-border p-4;
- }
- .card-footer {
- @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-border py-4;
- }
-
- input[type="file"] {
- @apply h-[40px] w-[200px] cursor-pointer rounded-md border border-input bg-field px-3 py-2 text-sm text-foreground;
- }
-}
diff --git a/apps/sn/src/app/styles/components-red.css b/apps/sn/src/app/styles/components-red.css
deleted file mode 100644
index d6378dad4..000000000
--- a/apps/sn/src/app/styles/components-red.css
+++ /dev/null
@@ -1,20 +0,0 @@
-@tailwind components;
-
-@layer components {
- .card {
- @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-label;
- }
- .card-border {
- @apply border border-border bg-background shadow-blur;
- }
- .card-header {
- @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-border p-4;
- }
- .card-footer {
- @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-border py-4;
- }
-
- input[type="file"] {
- @apply h-[40px] w-[200px] cursor-pointer rounded-md border border-input bg-field px-3 py-2 text-sm text-foreground;
- }
-}
diff --git a/apps/sn/src/app/styles/components.css b/apps/sn/src/app/styles/components.css
index a92f6ad27..d3bd67271 100644
--- a/apps/sn/src/app/styles/components.css
+++ b/apps/sn/src/app/styles/components.css
@@ -190,6 +190,148 @@
.sn-popup-help-text {
@apply mt-2 rounded-md border border-border bg-secondary px-4 py-3 text-[1.05rem] text-label;
}
+
+ .form-section-title {
+ @apply mt-4 border border-border bg-[#eaf3fb] p-2 text-center text-xl font-medium text-[#647784];
+ }
+
+ [data-theme="black"] {
+ .card {
+ color: #fff;
+ background-color: #0a0a0a !important;
+ }
+ form {
+ color: rgb(255 255 255);
+ }
+ label {
+ color: rgb(241, 241, 241);
+ }
+ input {
+ height: 3.2rem;
+ background-color: #1a1a1a;
+ border: 1px solid #262626;
+ border-radius: 4px;
+ }
+ input.text-gray-600 {
+ color: #f1f1f1;
+ }
+ button[role="combobox"] {
+ color: #f1f1f1;
+ background-color: rgb(26, 26, 26);
+ }
+ div [role="tablist"] {
+ border: unset;
+ gap: 4px;
+ }
+ .tabs-trigger[data-state="inactive"] {
+ color: #a4b7bf;
+ background: #55595d;
+ }
+ .tabs-trigger[data-state="inactive"]:hover {
+ color: #6bbaff;
+ background: #4c4f52;
+ }
+ .card-border {
+ box-shadow: 0 0 10px -1px #0000000f;
+ border: 1px solid #393939;
+ @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)];
+ }
+ a[aria-current="page"] {
+ background-color: rgb(32, 148, 250);
+ color: #000;
+ }
+ .card-header {
+ @apply border-b-[#3f4042];
+ }
+ .form-section-title {
+ @apply bg-[#5e7b9c] text-[#fff];
+ }
+ .gis-layer-panel {
+ background: #111214;
+ border-right-color: #2f3136;
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.03);
+ }
+ .gis-layer-panel-title {
+ color: #f3f4f6;
+ }
+ .gis-layer-panel hr {
+ border-color: #2f3136;
+ }
+ .gis-layer-row {
+ color: #e5e7eb;
+ }
+ .gis-layer-row:hover {
+ background: rgba(255, 255, 255, 0.08);
+ }
+ .gis-layer-row span,
+ .gis-layer-row label {
+ color: #e5e7eb !important;
+ }
+ .gis-layer-row input[type="checkbox"] {
+ accent-color: #c026d3;
+ }
+ .gis-layer-action-button {
+ background: #1a1c20;
+ border-color: #3b82f6;
+ color: #cfe3ff;
+ }
+ .gis-layer-action-button:hover {
+ background: #23262b;
+ }
+ .gis-layer-setting {
+ background: #111214;
+ border-top: 1px solid #2f3136;
+ }
+ .gis-layer-toggle {
+ background: #111214;
+ border-color: #2f3136;
+ color: #f3f4f6;
+ }
+ .gis-layer-toggle:hover {
+ background: #1a1c20;
+ }
+ input[type="file"] {
+ background-color: #1a1a1a;
+ border-color: #262626;
+ color: #f1f1f1;
+ }
+ }
+
+ [data-theme="blue"] {
+ .card-border {
+ @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)];
+ }
+ .card-header {
+ @apply border-b-[#3f4042];
+ }
+ .form-section-title {
+ @apply bg-[#eaf3fb] text-[#647784];
+ }
+ }
+
+ [data-theme="green"] {
+ .card-border {
+ @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)];
+ }
+ .card-header {
+ @apply border-b-[#3f4042];
+ }
+ .form-section-title {
+ @apply bg-[#ebf7f7] text-[#64908f];
+ }
+ }
+
+ [data-theme="red"] {
+ .card-border {
+ @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)];
+ }
+ .card-header {
+ @apply border-b-[#3f4042];
+ }
+ .form-section-title {
+ @apply bg-[#f6f6f9] text-[#808080];
+ }
+ }
}
/* `I/I 분석 > 침입수 분석결과`는 나중에 다시 사용할 수 있도록 라우트는 유지하고 LNB에서만 임시 숨김 처리한다. */
/* `I/I 분석 > 침입수 분석결과`, `RDII 분석결과`는 지도 popup 흐름으로 대체하고 레거시 라우트는 유지한 채 LNB에서만 숨긴다. */
diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts
index 7902af24b..b260e651a 100644
--- a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts
+++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts
@@ -141,6 +141,12 @@ export const windowPopupDefinitions = {
width: 1080,
height: 760,
},
+ agedPipeStatusByCtpvPopup: {
+ key: "agedPipeStatusByCtpvPopup",
+ title: "시도별 노후관로현황",
+ width: 1160,
+ height: 760,
+ },
fldngHstryPopup: {
key: "fldngHstryPopup",
title: "침수발생이력",
diff --git a/apps/sn/src/features/windowPopup/registry.tsx b/apps/sn/src/features/windowPopup/registry.tsx
index 3c7fa98cf..891290881 100644
--- a/apps/sn/src/features/windowPopup/registry.tsx
+++ b/apps/sn/src/features/windowPopup/registry.tsx
@@ -1,6 +1,7 @@
import { windowPopupDefinitions, type WindowPopupKey } from "./model/popupDefinitions";
import type { WindowPopupRegistryItem } from "./model/types";
import AgedPipePopupContent from "./ui/AgedPipePopupContent";
+import AgedPipeStatusByCtpvPopupContent from "./ui/AgedPipeStatusByCtpvPopupContent";
import ExmnBusinessRegisterPopupContent from "./ui/ExmnBusinessRegisterPopupContent";
import ExmnResultRegisterPopupContent from "./ui/ExmnResultRegisterPopupContent";
import ExmnSurveyDetailPopupContent from "./ui/ExmnSurveyDetailPopupContent";
@@ -117,6 +118,10 @@ export const windowPopupRegistry = {
...windowPopupDefinitions.floodPredictionMapPopup,
component: FloodPredictionPlaceholderPopupContent,
},
+ agedPipeStatusByCtpvPopup: {
+ ...windowPopupDefinitions.agedPipeStatusByCtpvPopup,
+ component: AgedPipeStatusByCtpvPopupContent,
+ },
fldngHstryPopup: {
...windowPopupDefinitions.fldngHstryPopup,
component: FldngHstryPopupContent,
diff --git a/apps/sn/src/features/windowPopup/ui/AgedPipeStatusByCtpvPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/AgedPipeStatusByCtpvPopupContent.tsx
new file mode 100644
index 000000000..fe9338af8
--- /dev/null
+++ b/apps/sn/src/features/windowPopup/ui/AgedPipeStatusByCtpvPopupContent.tsx
@@ -0,0 +1,10 @@
+import { Suspense } from "react";
+import AgedPipeStatusByCtpvPopupPage from "@/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage";
+
+export default function AgedPipeStatusByCtpvPopupContent() {
+ return (
+
+
+
+ );
+}
diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js
index e58d66cbf..daae30fe4 100644
--- a/packages/common/business_components/src/ui/map/GisTree.js
+++ b/packages/common/business_components/src/ui/map/GisTree.js
@@ -585,10 +585,8 @@ export default React.memo(function GisTree({
const isSinkholeRootTreeNode =
String(node.id) === "SN_SINKHOLE" ||
nodeLabel === "지반침하정보";
- const isFloodHistoryRootTreeNode = nodeLabel === "침수흔적도";
const hideTreeLegendIcon =
- (isSinkholeRootTreeNode && !sinkholeLegendIconVisible) ||
- isFloodHistoryRootTreeNode;
+ (isSinkholeRootTreeNode && !sinkholeLegendIconVisible);
const showLayerActionButton = layerIconVisible?.(node);
const showLayerActionIcon = depth > 0 && imageVisible;