From 2652e2cfe04847ac77e0d6ba18f34b06f1bc71dd Mon Sep 17 00:00:00 2001 From: ljpark Date: Mon, 27 Jul 2026 10:29:45 +0900 Subject: [PATCH 1/4] =?UTF-8?q?sn:=20components.css=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/src/app/index.tsx | 4 - apps/sn/src/app/styles/components-black.css | 20 --- apps/sn/src/app/styles/components-blue.css | 20 --- apps/sn/src/app/styles/components-green.css | 20 --- apps/sn/src/app/styles/components-red.css | 20 --- apps/sn/src/app/styles/components.css | 142 ++++++++++++++++++++ 6 files changed, 142 insertions(+), 84 deletions(-) delete mode 100644 apps/sn/src/app/styles/components-black.css delete mode 100644 apps/sn/src/app/styles/components-blue.css delete mode 100644 apps/sn/src/app/styles/components-green.css delete mode 100644 apps/sn/src/app/styles/components-red.css 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에서만 숨긴다. */ From 09b5cc3798ec7045718d528416f90e7f0f533b75 Mon Sep 17 00:00:00 2001 From: ljpark Date: Mon, 27 Jul 2026 17:51:33 +0900 Subject: [PATCH 2/4] =?UTF-8?q?sn:=20=EC=8B=9C=EB=8F=84=EB=B3=84=20?= =?UTF-8?q?=EB=85=B8=ED=9B=84=EA=B4=80=EB=A1=9C=ED=98=84=ED=99=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AgedPipeStatusByCtpvPopupPage.tsx | 209 ++++++++++++++++++ .../agedPipeStatusByCtpvExport.ts | 25 +++ .../agedPipeStatusByCtpvUtils.ts | 149 +++++++++++++ .../analysisMenu/AnalysisPopupMenu.tsx | 1 + .../fldngHstry/FldngHstryPopupPage.tsx | 27 +++ .../popupComponent/featureClickEventPopup.tsx | 30 ++- .../001-qa-SFR-011-001-시도별 노후관로현황-검증기록.md | 10 + .../001-fe-SFR-011-001-시도별 노후관로현황-작업카드.md | 19 ++ .../001-fe-SFR-011-001-시도별 노후관로현황-통합구현기록.md | 22 ++ .../windowPopup/model/popupDefinitions.ts | 6 + apps/sn/src/features/windowPopup/registry.tsx | 5 + .../ui/AgedPipeStatusByCtpvPopupContent.tsx | 10 + 12 files changed, 512 insertions(+), 1 deletion(-) create mode 100644 apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx create mode 100644 apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvExport.ts create mode 100644 apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvUtils.ts create mode 100644 apps/sn/docs/작업검증/SFR-011-001-시도별 노후관로현황/001-qa-SFR-011-001-시도별 노후관로현황-검증기록.md create mode 100644 apps/sn/docs/작업계획/SFR-011-001-시도별 노후관로현황/001-fe-SFR-011-001-시도별 노후관로현황-작업카드.md create mode 100644 apps/sn/docs/작업내용/SFR-011-001-시도별 노후관로현황/001-fe-SFR-011-001-시도별 노후관로현황-통합구현기록.md create mode 100644 apps/sn/src/features/windowPopup/ui/AgedPipeStatusByCtpvPopupContent.tsx 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..40abd0a68 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx @@ -0,0 +1,209 @@ +"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} + +
+
+ + + + + + +
+ + +
+
+ +
+ + headName={<>시도별 노후관로현황 조회 결과 총 {rows.length.toLocaleString()}건} + 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..6deaa1329 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvUtils.ts @@ -0,0 +1,149 @@ +"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: 15 }, (_, 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).toFixed(3); + +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/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/popupComponent/featureClickEventPopup.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx index 3a75126fa..ae9d47898 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx @@ -39,6 +39,7 @@ 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({ @@ -92,6 +93,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 +151,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 +176,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 +471,10 @@ 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 installYmd = data.instl_ymd ?? data.instlYmd; formattedData.elapsedYears = getElapsedYearsDisplayText(installYmd); 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/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts index 56e9a75f3..f39bacb78 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 ( + + + + ); +} From bf61f6f76a4f09e1c13e8575a2ad4808d7f4b09a Mon Sep 17 00:00:00 2001 From: ljpark Date: Tue, 28 Jul 2026 17:51:47 +0900 Subject: [PATCH 3/4] =?UTF-8?q?sn:=20=EC=B9=A8=EC=88=98=EB=B0=9C=EC=83=9D?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=20=ED=8C=9D=EC=97=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AgedPipeStatusByCtpvPopupPage.tsx | 3 +- .../agedPipeStatusByCtpvUtils.ts | 10 +- .../map/components/common/table.ts | 13 +-- .../components/fldngHstry/fldngHstryTypes.ts | 97 ++++++++++--------- .../popupComponent/featureClickEventPopup.tsx | 45 ++++++++- apps/sn/middleware.ts | 1 + 6 files changed, 111 insertions(+), 58 deletions(-) diff --git a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx index 40abd0a68..7b4dcc924 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx @@ -189,7 +189,8 @@ export default function AgedPipeStatusByCtpvPopupPage() {
- headName={<>시도별 노후관로현황 조회 결과 총 {rows.length.toLocaleString()}건} + // 조회 결과 총 {rows.length.toLocaleString()}건 + headName={<>시도별 노후관로현황 } tableData={rows} headlist={headList} pageSize={AGED_PIPE_STATUS_BY_CTPV_PAGE_SIZE} diff --git a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvUtils.ts b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvUtils.ts index 6deaa1329..d41f12d31 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvUtils.ts +++ b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvUtils.ts @@ -51,7 +51,7 @@ export const normalizeAgedPipeStatusByCtpvResponse = ( export const buildAgedPipeStatusYearOptions = () => { const currentYear = new Date().getFullYear(); - return Array.from({ length: 15 }, (_, index) => currentYear - index); + return Array.from({ length: currentYear - 1900 + 1 }, (_, index) => currentYear - index); }; export const normalizeAgedPipeStatusByCtpvFilters = ( @@ -91,7 +91,11 @@ export const validateAgedPipeStatusByCtpvFilters = (filters: AgedPipeStatusByCtp return filters.elapsedYears >= 1 && filters.elapsedYears <= 100; }; -const formatMetric = (value: number) => Number(value ?? 0).toFixed(3); +const formatMetric = (value: number) => + Number(value ?? 0).toLocaleString("ko-KR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); export const toAgedPipeStatusByCtpvRows = ( response: Partial | null | undefined, @@ -115,7 +119,7 @@ export const toAgedPipeStatusByCtpvRows = ( export const buildAgedPipeStatusByCtpvHeadList = (years: number[]): UiHeadProps[] => { const heads: UiHeadProps[] = [ - { id: "rowNumber", title: "NO", widthPercent: 6, sortable: false }, + // { id: "rowNumber", title: "NO", widthPercent: 6, sortable: false }, { id: "ctpvNm", title: "시도", widthPercent: 14, sortable: false }, ]; 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..7f24bc7ef 100644 --- a/apps/sn/app/(map-layout)/map/components/common/table.ts +++ b/apps/sn/app/(map-layout)/map/components/common/table.ts @@ -484,13 +484,12 @@ 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: '조사년도' }, @@ -502,10 +501,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: '침수원인상세코드' }, 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..310bc81dd 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침수위(m)", 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" }, ]; @@ -313,6 +313,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 +368,9 @@ 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"); -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 +399,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 +429,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 ae9d47898..70251c87c 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx @@ -32,7 +32,16 @@ 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'; @@ -68,6 +77,25 @@ const normalizeFldngWtlvMapDisplayId = (value: unknown) => { return normalized.startsWith(prefix) ? normalized.slice(prefix.length) : normalized; }; +// 침수흔적도 침수시작일시, 침수종료일시 한줄로 보이도록 +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; @@ -475,6 +503,21 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, ? 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); 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" //도시침수지도 ) { From 86e00d48dc4f6302ed669577338bb250aa630092 Mon Sep 17 00:00:00 2001 From: ljpark Date: Wed, 29 Jul 2026 11:43:51 +0900 Subject: [PATCH 4/4] =?UTF-8?q?sn:=20=EC=A7=80=EB=8F=84=20=EC=B9=A8?= =?UTF-8?q?=EC=88=98=ED=9D=94=EC=A0=81=EB=8F=84=20=EC=9A=B0=EC=B8=A1=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EC=97=90=20=EA=B0=95=EC=9A=B0=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/(auth)/auth/layout.tsx | 6 +-- .../map/components/common/table.ts | 8 ++++ .../components/fldngHstry/fldngHstryTypes.ts | 8 +++- .../popupComponent/featureClickEventPopup.tsx | 40 +++++++++++++++++++ .../001-fe-SFR-011-009-침수발생이력-통합구현기록.md | 12 ++++++ .../business_components/src/ui/map/GisTree.js | 4 +- 6 files changed, 68 insertions(+), 10 deletions(-) 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/common/table.ts b/apps/sn/app/(map-layout)/map/components/common/table.ts index 7f24bc7ef..e65222ddf 100644 --- a/apps/sn/app/(map-layout)/map/components/common/table.ts +++ b/apps/sn/app/(map-layout)/map/components/common/table.ts @@ -494,6 +494,10 @@ export const FldngWtlvMapCol = [ { 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: '일련번호' }, @@ -510,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/fldngHstryTypes.ts b/apps/sn/app/(map-layout)/map/components/fldngHstry/fldngHstryTypes.ts index 310bc81dd..8940bdb3d 100644 --- a/apps/sn/app/(map-layout)/map/components/fldngHstry/fldngHstryTypes.ts +++ b/apps/sn/app/(map-layout)/map/components/fldngHstry/fldngHstryTypes.ts @@ -132,7 +132,7 @@ export const FLDNG_HSTRY_LIST_HEADLIST: UiHeadProps[] = [ { 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: "평균\n침수위(m)", widthPercent: 7, sortable: true, sortKey: "avgFldngWtlv" }, + { 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(); @@ -368,7 +369,10 @@ export const buildFldngHstryListQueryString = ( return params.toString(); }; -export const formatFloodArea = (value: number) => Number(value ?? 0).toLocaleString("ko-KR"); +export const formatFloodArea = (value: number) => Number(value ?? 0).toLocaleString("ko-KR", { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }); export const formatFloodDepth = (value: number) => value.toFixed(2); 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 70251c87c..fea0e45df 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx @@ -77,6 +77,26 @@ 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(); @@ -627,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-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/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;