From 2649ce286b2e978a011c910b51a661d3fb6de08a Mon Sep 17 00:00:00 2001 From: mind-one001 Date: Tue, 11 Aug 2026 10:32:35 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=EB=B3=B4=EC=88=98=EC=9D=B4=EB=A0=A5=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../popupComponent/SwtrmtPrcsZoneAnalysis.tsx | 237 +++++++++-- .../swtrmtPrcsZoneAnalysisApi.ts | 106 ++++- .../repairHistory/RepairHistoryPopupPage.tsx | 372 ++++++++++++++++++ .../001-fe-SFR-011-006-보수이력-작업카드.md | 32 ++ .../001-fe-SFR-011-006-보수이력-구현및검증결과.md | 41 ++ .../windowPopup/model/popupDefinitions.ts | 7 + .../src/features/windowPopup/model/types.ts | 3 + apps/sn/src/features/windowPopup/registry.tsx | 5 + .../ui/RepairHistoryPopupContent.tsx | 6 + 9 files changed, 771 insertions(+), 38 deletions(-) create mode 100644 apps/sn/app/(map-layout)/map/components/repairHistory/RepairHistoryPopupPage.tsx create mode 100644 apps/sn/docs/작업계획/SFR-011-006-보수이력/001-fe-SFR-011-006-보수이력-작업카드.md create mode 100644 apps/sn/docs/작업내용/SFR-011-006-보수이력/001-fe-SFR-011-006-보수이력-구현및검증결과.md create mode 100644 apps/sn/src/features/windowPopup/ui/RepairHistoryPopupContent.tsx diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx index 139af3f6c..545fc4163 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx @@ -1,9 +1,26 @@ "use client"; -import React, { useEffect, useMemo, useState } from "react"; -import { Table, TableBody, TableCell, TableRow } from "@common/components"; -import { fetchSwtrmtPrcsZoneAnalysis } from "./swtrmtPrcsZoneAnalysisApi"; -import type { SwtrmtPrcsZoneAnalysisMetric, SwtrmtPrcsZoneAnalysisResponse } from "./swtrmtPrcsZoneAnalysisApi"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Button, Table, TableBody, TableCell, TableRow } from "@common/components"; +import { useGisContext } from "@common/business_components/ui/map/Gis"; +import Feature from "ol/Feature.js"; +import GeoJSON from "ol/format/GeoJSON.js"; +import VectorLayer from "ol/layer/Vector.js"; +import VectorSource from "ol/source/Vector.js"; +import Stroke from "ol/style/Stroke.js"; +import Style from "ol/style/Style.js"; + +import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import { + fetchSwtrmtPrcsZoneAnalysis, + getSwtrmtPrcsZoneId, +} from "./swtrmtPrcsZoneAnalysisApi"; +import type { + RepairHistoryRow, + SwtrmtPrcsZoneAnalysisMetric, + SwtrmtPrcsZoneAnalysisResponse, +} from "./swtrmtPrcsZoneAnalysisApi"; const analysisRows: { key: keyof NonNullable; title: string }[] = [ { key: "total", title: "전체" }, @@ -12,6 +29,16 @@ const analysisRows: { key: keyof NonNullable { if (value === null || value === undefined || value === "") return "데이터 없음"; @@ -26,7 +53,7 @@ const formatValue = (value: unknown, unit = "") => { const getMetric = ( analysis: SwtrmtPrcsZoneAnalysisResponse["analysis"] | undefined, - key: keyof NonNullable + key: keyof NonNullable, ): SwtrmtPrcsZoneAnalysisMetric => analysis?.[key] ?? {}; const metricGroups: { @@ -60,7 +87,9 @@ const MetricVerticalGrid = ({ {row.title} - {isLoading ? "" : formatValue(metric[field])} + + {isLoading ? "조회 중" : formatValue(metric[field])} + ); })} @@ -69,6 +98,7 @@ const MetricVerticalGrid = ({ ); +/** 처리구역 분석 패널과 보수이력 윈도우 팝업의 부모 지도 연계를 담당한다. */ const SwtrmtPrcsZoneAnalysis = ({ featureData, rawFeatureData, @@ -76,9 +106,14 @@ const SwtrmtPrcsZoneAnalysis = ({ featureData: any; rawFeatureData?: any; }) => { + const { gisMap } = useGisContext() as unknown as { gisMap: any }; + // 기존 처리구역 관로분석 정보의 조회 상태다. const [analysisData, setAnalysisData] = useState(null); const [isLoading, setIsLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(""); + // 보수이력 지도 레이어는 React 렌더링 대상이 아니므로 ref로 보관해 팝업 종료·구역 전환 때 제거한다. + const repairLayerRef = useRef | null>(null); + const repairPopupRef = useRef(null); const requestKey = useMemo(() => { const rawValues = rawFeatureData?.values_ ?? rawFeatureData ?? {}; @@ -87,71 +122,199 @@ const SwtrmtPrcsZoneAnalysis = ({ rawFeatureData?.id_, rawFeatureData?.fid, rawValues?.fid, - rawValues?.swtrmt_zone_sn, - rawValues?.swtrmt_fclt_cd, featureData?.fid, - featureData?.swtrmtZoneSn, - featureData?.swtrmt_zone_sn, - featureData?.swtrmtFcltCd, + rawValues?.swtrmt_fclt_cd, + rawValues?.swtrmtFcltCd, featureData?.swtrmt_fclt_cd, + featureData?.swtrmtFcltCd, ].filter(Boolean).join("|"); }, [featureData, rawFeatureData]); + const zoneId = useMemo( + () => getSwtrmtPrcsZoneId(rawFeatureData, featureData), + [featureData, rawFeatureData, requestKey], + ); + + /** 이전 보수이력 조회에서 만든 임시 벡터 레이어를 제거한다. */ + const clearRepairLayer = useCallback(() => { + const layer = repairLayerRef.current; + if (layer && gisMap) gisMap.removeLayer(layer); + layer?.getSource()?.clear(); + repairLayerRef.current = null; + }, [gisMap]); + + /** 윈도우 팝업 조회 결과 전체를 청록색 관로 레이어로 교체한다. */ + const replaceRepairLayer = useCallback((rows: RepairHistoryRow[]) => { + if (!gisMap) return; + clearRepairLayer(); + + const format = new GeoJSON(); + const projection = gisMap.getView()?.getProjection(); + const features = rows.flatMap((row) => { + if (!row.geometryJson) return []; + try { + const geometry = format.readGeometry(JSON.parse(row.geometryJson), { + dataProjection: "EPSG:3857", + featureProjection: projection, + }); + const feature = new Feature({ geometry, repairFid: row.fid }); + feature.setId(`repair-history-${row.fid}`); + return [feature]; + } catch (error) { + console.warn("보수이력 관로 도형을 읽지 못했습니다.", row.fid, error); + return []; + } + }); + + if (features.length === 0) return; + const layer = new VectorLayer({ + source: new VectorSource({ features }), + style: (feature) => feature.get("repairSelected") ? selectedRepairHistoryStyle : repairHistoryStyle, + }); + layer.set("repairHistoryLayer", true); + layer.setZIndex(10020); + gisMap.addLayer(layer); + repairLayerRef.current = layer; + }, [clearRepairLayer, gisMap]); + + /** 팝업에서 클릭한 관로를 붉게 선택하고 부모 지도를 해당 범위로 이동한다. */ + const focusRepairFeature = useCallback((fid: string) => { + const features = repairLayerRef.current?.getSource()?.getFeatures() ?? []; + const selectedFeature = features.find((feature) => String(feature.get("repairFid")) === String(fid)); + if (!selectedFeature || !gisMap) return; + + features.forEach((feature) => feature.set("repairSelected", feature === selectedFeature)); + repairLayerRef.current?.changed(); + const extent = selectedFeature.getGeometry()?.getExtent(); + if (extent) { + gisMap.getView().fit(extent, { + padding: [100, 100, 100, 460], + duration: 350, + maxZoom: 18, + }); + } + }, [gisMap]); + useEffect(() => { let isMounted = true; + setIsLoading(true); + setErrorMessage(""); - const fetchAnalysis = async () => { - setIsLoading(true); - setErrorMessage(""); - setAnalysisData(null); - + const loadAnalysis = async () => { try { const data = await fetchSwtrmtPrcsZoneAnalysis(rawFeatureData, featureData); if (!isMounted) return; - if (!data) { - setErrorMessage("분석정보 조회에 필요한 처리구역 식별값이 없습니다."); + setAnalysisData(null); + setErrorMessage("처리구역 식별값이 없어 관로분석정보를 조회하지 못했습니다."); return; } - setAnalysisData(data); } catch (error) { if (!isMounted) return; - console.error("하수처리구역 분석정보 로딩 중 오류:", error); - setErrorMessage("분석정보를 불러오지 못했습니다."); + setAnalysisData(null); + setErrorMessage(error instanceof Error ? error.message : "관로분석정보를 불러오지 못했습니다."); } finally { if (isMounted) setIsLoading(false); } }; - fetchAnalysis(); - + void loadAnalysis(); return () => { isMounted = false; }; }, [featureData, rawFeatureData, requestKey]); + useEffect(() => { + const handleRepairPopupMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + const data = event.data; + const messageZoneId = String(data?.payload?.zoneId ?? "").trim(); + if (!messageZoneId || messageZoneId !== zoneId) return; + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS) { + replaceRepairLayer(Array.isArray(data.payload?.rows) ? data.payload.rows : []); + return; + } + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SELECT_PIPE) { + focusRepairFeature(String(data.payload?.fid ?? "")); + return; + } + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_CLEAR) { + clearRepairLayer(); + } + }; + + window.addEventListener("message", handleRepairPopupMessage); + return () => window.removeEventListener("message", handleRepairPopupMessage); + }, [clearRepairLayer, focusRepairFeature, replaceRepairLayer, zoneId]); + + useEffect(() => { + clearRepairLayer(); + }, [clearRepairLayer, requestKey]); + + useEffect(() => () => clearRepairLayer(), [clearRepairLayer]); + + useEffect(() => { + const timerId = window.setInterval(() => { + if (repairPopupRef.current?.closed) { + repairPopupRef.current = null; + clearRepairLayer(); + } + }, 500); + + return () => window.clearInterval(timerId); + }, [clearRepairLayer]); + + /** 선택 처리구역을 고정한 기존 공통 윈도우 팝업을 연다. */ + const handleOpenRepairHistory = () => { + if (!zoneId) { + setErrorMessage("처리구역 식별값이 없어 보수이력을 열 수 없습니다."); + return; + } + + clearRepairLayer(); + const popup = openWindowPopup("repairHistoryPopup", { + zoneId, + zoneName: analysisData?.zone?.swtrmtZoneNm ?? featureData?.swtrmt_zone_nm ?? featureData?.swtrmtZoneNm, + }); + if (!popup) { + alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요."); + return; + } + repairPopupRef.current = popup; + }; + return ( <>

처리구역내 관로분석정보

+
- {!isLoading && errorMessage && ( -
{errorMessage}
- )} - {(isLoading || !errorMessage) && ( - metricGroups.map((group) => ( - - )) - )} + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + {metricGroups.map((group) => ( + + ))}
diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/swtrmtPrcsZoneAnalysisApi.ts b/apps/sn/app/(map-layout)/map/components/popupComponent/swtrmtPrcsZoneAnalysisApi.ts index 27eff02de..21b6899dc 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/swtrmtPrcsZoneAnalysisApi.ts +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/swtrmtPrcsZoneAnalysisApi.ts @@ -1,4 +1,4 @@ -import { getData } from "@/components/lib/api"; +import { getBlobDataWithHeaders, getData } from "@/components/lib/api"; export type SwtrmtPrcsZoneAnalysisMetric = { avgInstlYear?: number | string | null; @@ -32,7 +32,43 @@ export type SwtrmtPrcsZoneAnalysisResponse = { type ZoneAnalysisSource = Record | null | undefined; +export type RepairHistoryFilters = { + useCd?: string; + materialCode?: string; + diameterFrom?: string; + diameterTo?: string; +}; + +export type RepairHistoryRow = { + rowNo: number; + fid: string; + lyrTypeNm?: string | null; + fcltMngNo?: string | null; + repairCount: number; + latestRepairYmd?: string | null; + latestRepairYear?: number | null; + elapsedYearsSinceRepair?: number | null; + useCd?: string | null; + useNm?: string | null; + installationYear?: number | null; + burialDepth?: number | null; + materialCode?: string | null; + materialName?: string | null; + pdmtVl?: number | null; + prlgVl?: number | null; + geometryJson?: string | null; +}; + +export type RepairHistoryResponse = { + totalElements: number; + totalPages: number; + page: number; + size: number; + data: RepairHistoryRow[]; +}; + const ANALYSIS_ENDPOINT_PREFIX = "v1/swagePrcsZones"; +const REPAIR_HISTORY_PAGE_SIZE = 200; const firstValue = (...values: unknown[]) => { const value = values.find((item) => { @@ -97,3 +133,71 @@ export const fetchSwtrmtPrcsZoneAnalysis = async ( return (await getData(path)) ?? {}; }; + +const appendRepairHistoryFilters = (params: URLSearchParams, filters: RepairHistoryFilters) => { + Object.entries(filters).forEach(([key, value]) => { + const normalized = String(value ?? "").trim(); + if (normalized) params.append(key, normalized); + }); +}; + +const getRepairHistoryPath = ( + zoneId: string, + filters: RepairHistoryFilters, + suffix = "", + extraParams: Record = {}, +) => { + const params = new URLSearchParams(); + appendRepairHistoryFilters(params, filters); + Object.entries(extraParams).forEach(([key, value]) => { + const normalized = value.trim(); + if (normalized) params.append(key, normalized); + }); + const query = params.toString(); + return `${ANALYSIS_ENDPOINT_PREFIX}/${encodeURIComponent(zoneId)}/repairHistories${suffix}${query ? `?${query}` : ""}`; +}; + +/** 윈도우 팝업과 부모 지도가 같은 전체 결과를 사용하도록 서버 페이지를 끝까지 합친다. */ +export const fetchAllRepairHistoriesByZoneId = async ( + zoneId: string, + filters: RepairHistoryFilters, +): Promise => { + if (!zoneId.trim()) throw new Error("처리구역 식별값이 없습니다."); + + const firstParams = new URLSearchParams(); + appendRepairHistoryFilters(firstParams, filters); + firstParams.set("page", "0"); + firstParams.set("size", String(REPAIR_HISTORY_PAGE_SIZE)); + const first = (await getData( + `${ANALYSIS_ENDPOINT_PREFIX}/${encodeURIComponent(zoneId)}/repairHistories?${firstParams.toString()}`, + )) as RepairHistoryResponse; + + const pages: RepairHistoryResponse[] = [first]; + for (let page = 1; page < (first.totalPages ?? 0); page += 1) { + const params = new URLSearchParams(firstParams); + params.set("page", String(page)); + pages.push((await getData( + `${ANALYSIS_ENDPOINT_PREFIX}/${encodeURIComponent(zoneId)}/repairHistories?${params.toString()}`, + )) as RepairHistoryResponse); + } + + const data = pages.flatMap((page) => page?.data ?? []); + return { + ...first, + totalElements: data.length, + totalPages: 1, + page: 0, + size: data.length, + data, + }; +}; + +/** 현재 조회 조건의 화면 목록 항목 파일을 내려받는다. */ +export const downloadRepairHistoriesByZoneId = async ( + zoneId: string, + filters: RepairHistoryFilters, + zoneName: string, +) => { + if (!zoneId.trim()) throw new Error("처리구역 식별값이 없습니다."); + return getBlobDataWithHeaders(getRepairHistoryPath(zoneId, filters, "/download", { zoneName })); +}; diff --git a/apps/sn/app/(map-layout)/map/components/repairHistory/RepairHistoryPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/repairHistory/RepairHistoryPopupPage.tsx new file mode 100644 index 000000000..7683ed59b --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/repairHistory/RepairHistoryPopupPage.tsx @@ -0,0 +1,372 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@common/components"; +import { UiTable } from "@common/business_components/ui/table"; +import type { UiHeadProps } from "@common/business_components/ui/table"; + +import LoadingOverlay from "@/components/ui/LoadingBar"; +import { getRawJson } from "@/components/lib/api"; +import { + WINDOW_POPUP_MESSAGE_TYPE, + type WindowPopupContentProps, + type WindowPopupMessageType, +} from "@/features/windowPopup/model/types"; +import { MS_SWAGE_PPL_CODES } from "../common/table"; +import { + downloadRepairHistoriesByZoneId, + fetchAllRepairHistoriesByZoneId, + type RepairHistoryFilters, + type RepairHistoryRow, +} from "../popupComponent/swtrmtPrcsZoneAnalysisApi"; + +const REPAIR_HISTORY_PAGE_SIZE = 10; +const MAP_LAYER_SETTING_PATH = "/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK"; + +type PipeUseOption = { + val: string; + text: string; +}; + +type MapLayerSettingRow = { + lyrTyNm?: string | null; + lyrNm?: string | null; + sortOrdr?: string | number | null; + flterSql?: string | null; +}; + +/** 관망도 트리의 하수관로 노드에서 용도코드·명칭·표시순서를 그대로 추출한다. */ +const resolvePipeUseOptions = (rows: MapLayerSettingRow[]): PipeUseOption[] => rows + .filter((row) => row.lyrTyNm === "tb_ms_swage_ppl") + .sort((left, right) => Number(left.sortOrdr ?? 0) - Number(right.sortOrdr ?? 0)) + .flatMap((row) => { + const code = String(row.flterSql ?? "").match(/\busg_cd\s*=\s*['\"]([^'\"]+)['\"]/i)?.[1]?.trim(); + const text = String(row.lyrNm ?? "").trim(); + return code && text ? [{ val: code, text }] : []; + }); + +type RepairHistoryTableRow = { + fid: string; + fcltMngNo: string; + repairCount: string; + latestRepairYear: string; + elapsedYearsSinceRepair: string; + materialName: string; + installationYear: string; + burialDepth: string; +}; + +/** 부모 업무화면과 동일한 공통 UiTable 헤더·열 정렬 규격이다. */ +const REPAIR_HISTORY_HEADLIST: UiHeadProps[] = [ + { id: "fcltMngNo", title: "시설관리번호", align: "center", widthPercent: 22 }, + { id: "repairCount", title: "보수이력(건)", align: "right", widthPercent: 12 }, + { id: "latestRepairYear", title: "최근보수년도", align: "center", widthPercent: 13 }, + { id: "elapsedYearsSinceRepair", title: "보수후 경과년도", align: "right", widthPercent: 14 }, + { id: "materialName", title: "관종", align: "center", widthPercent: 16 }, + { id: "installationYear", title: "설치년도", align: "center", widthPercent: 11 }, + { id: "burialDepth", title: "매설심도(m)", align: "right", widthPercent: 12 }, +]; + +/** 화면 입력조건은 조회 버튼을 누른 시점의 적용조건과 분리해 다운로드 계약을 고정한다. */ +const buildEmptyFilters = (): RepairHistoryFilters => ({ + useCd: "", + materialCode: "", + diameterFrom: "", + diameterTo: "", +}); + +/** Next searchParams의 단일 값을 안전하게 읽는다. */ +const firstSearchParam = (value: string | string[] | undefined) => + Array.isArray(value) ? String(value[0] ?? "").trim() : String(value ?? "").trim(); + +/** 팝업의 조회·선택·정리 이벤트를 동일 출처의 부모 지도에 전달한다. */ +const postRepairMessage = ( + type: WindowPopupMessageType, + payload: Record, +) => { + if (typeof window === "undefined" || !window.opener) return; + window.opener.postMessage({ type, payload }, window.location.origin); +}; + +/** 지정한 파일명으로 Blob을 다운로드한다. */ +const saveBlob = (blob: Blob, fileName: string) => { + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0); +}; + +const buildRepairHistoryFileName = (zoneName: string) => { + const sanitizedZoneName = zoneName.trim().replace(/[\\/:*?"<>|\r\n]+/g, "_"); + return `${sanitizedZoneName || "처리구역"}_보수이력.xlsx`; +}; + +const formatCellValue = (value: unknown) => { + if (value === null || value === undefined || value === "") return "-"; + return typeof value === "number" + ? value.toLocaleString("ko-KR", { maximumFractionDigits: 2 }) + : String(value); +}; + +const formatYear = (value: unknown) => { + if (value === null || value === undefined || value === "") return "-"; + return String(value); +}; + +/** SFR-011-006 관로별 보수이력 조회·다운로드와 부모 지도 연계를 제공한다. */ +export default function RepairHistoryPopupPage({ searchParams }: WindowPopupContentProps) { + const zoneId = useMemo(() => firstSearchParam(searchParams.zoneId), [searchParams.zoneId]); + const zoneName = useMemo(() => firstSearchParam(searchParams.zoneName), [searchParams.zoneName]); + // 입력조건과 마지막 조회조건을 분리해 다운로드가 현재 표와 동일한 범위를 사용한다. + const [filters, setFilters] = useState(buildEmptyFilters); + const [appliedFilters, setAppliedFilters] = useState(buildEmptyFilters); + // 전체 조회 결과는 부모 지도 강조와 팝업 내 10건 페이지 표가 함께 사용한다. + const [rows, setRows] = useState([]); + // 용도 선택지는 별도 하드코딩하지 않고 관망도 트리 설정 API의 sortOrdr를 따른다. + const [pipeUseOptions, setPipeUseOptions] = useState([]); + const [currentPage, setCurrentPage] = useState(1); + const [selectedFid, setSelectedFid] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + /** 조회 결과 전체를 부모 지도 레이어와 팝업 표에 동시에 반영한다. */ + const searchRepairHistories = useCallback(async (nextFilters: RepairHistoryFilters) => { + if (!zoneId) { + setErrorMessage("처리구역 식별값이 없습니다."); + return; + } + + setIsLoading(true); + setErrorMessage(""); + setSelectedFid(""); + try { + const response = await fetchAllRepairHistoriesByZoneId(zoneId, nextFilters); + const nextRows = response.data ?? []; + setRows(nextRows); + setAppliedFilters({ ...nextFilters }); + setCurrentPage(1); + postRepairMessage(WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS, { + zoneId, + rows: nextRows, + }); + } catch (error) { + setRows([]); + postRepairMessage(WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS, { + zoneId, + rows: [], + }); + setErrorMessage(error instanceof Error ? error.message : "보수이력을 불러오지 못했습니다."); + } finally { + setIsLoading(false); + } + }, [zoneId]); + + useEffect(() => { + const initialFilters = buildEmptyFilters(); + setFilters(initialFilters); + setAppliedFilters(initialFilters); + void searchRepairHistories(initialFilters); + }, [searchRepairHistories]); + + useEffect(() => { + let isMounted = true; + + const loadPipeUseOptions = async () => { + try { + const layerSettings = await getRawJson(MAP_LAYER_SETTING_PATH) as MapLayerSettingRow[]; + if (isMounted) setPipeUseOptions(resolvePipeUseOptions(Array.isArray(layerSettings) ? layerSettings : [])); + } catch { + if (isMounted) setPipeUseOptions([]); + } + }; + + void loadPipeUseOptions(); + return () => { + isMounted = false; + }; + }, []); + + useEffect(() => { + const clearParentLayer = () => { + postRepairMessage(WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_CLEAR, { zoneId }); + }; + window.addEventListener("beforeunload", clearParentLayer); + return () => { + window.removeEventListener("beforeunload", clearParentLayer); + clearParentLayer(); + }; + }, [zoneId]); + + /** 화면에서 적용한 조건과 동일한 7개 목록 항목의 XLSX를 내려받는다. */ + const handleDownload = async () => { + if (!zoneId || rows.length === 0) return; + setIsLoading(true); + setErrorMessage(""); + try { + const { blob } = await downloadRepairHistoriesByZoneId(zoneId, appliedFilters, zoneName); + saveBlob(blob, buildRepairHistoryFileName(zoneName)); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : "보수이력 Excel 다운로드에 실패했습니다."); + } finally { + setIsLoading(false); + } + }; + + /** 행을 한 번 클릭하면 부모 지도에서 해당 관로를 선택하고 범위로 이동한다. */ + const handleRowClick = (row: RepairHistoryRow) => { + setSelectedFid(row.fid); + postRepairMessage(WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SELECT_PIPE, { + zoneId, + fid: row.fid, + }); + }; + + const visibleRows = useMemo( + () => rows.slice( + (currentPage - 1) * REPAIR_HISTORY_PAGE_SIZE, + currentPage * REPAIR_HISTORY_PAGE_SIZE, + ), + [currentPage, rows], + ); + // 공통 UiTable에 전달할 표시값은 원본 API 행과 분리해 지도 연계 식별값을 보존한다. + const tableRows = useMemo( + () => visibleRows.map((row) => ({ + fid: row.fid, + fcltMngNo: formatCellValue(row.fcltMngNo), + repairCount: formatCellValue(row.repairCount), + latestRepairYear: formatYear(row.latestRepairYear), + elapsedYearsSinceRepair: formatCellValue(row.elapsedYearsSinceRepair), + materialName: formatCellValue(row.materialName), + installationYear: formatYear(row.installationYear), + burialDepth: formatCellValue(row.burialDepth), + })), + [visibleRows], + ); + const selectedRowIndex = visibleRows.findIndex((row) => row.fid === selectedFid); + + return ( +
+ +
+

보수이력

+
+ +
+
+ + + + + + + + +
+ + +
+
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+ + headName={<>{zoneName || "처리구역"} 총 {rows.length.toLocaleString("ko-KR")}건} + tableData={isLoading ? [] : tableRows} + headlist={REPAIR_HISTORY_HEADLIST} + pageSize={REPAIR_HISTORY_PAGE_SIZE} + total={rows.length} + cellClick={(index) => { + if (index === undefined) return; + const row = visibleRows[index]; + if (row) handleRowClick(row); + }} + onPageChange={setCurrentPage} + curPage={currentPage} + index={selectedRowIndex} + widthFixed={true} + isHideCount={true} + className="!m-0 overflow-x-hidden" + cellClassName={(_, headId) => headId === "fcltMngNo" ? "break-all whitespace-normal" : "whitespace-normal"} + /> +
+ +
행을 클릭하면 부모 지도의 해당 관로로 이동합니다.
+
+
+ ); +} diff --git a/apps/sn/docs/작업계획/SFR-011-006-보수이력/001-fe-SFR-011-006-보수이력-작업카드.md b/apps/sn/docs/작업계획/SFR-011-006-보수이력/001-fe-SFR-011-006-보수이력-작업카드.md new file mode 100644 index 000000000..662e4fd78 --- /dev/null +++ b/apps/sn/docs/작업계획/SFR-011-006-보수이력/001-fe-SFR-011-006-보수이력-작업카드.md @@ -0,0 +1,32 @@ +# SFR-011-006 보수이력 FE 작업카드 + +## 1. 작업 목적 + +- 처리구역 분석 패널에서 보수이력 공통 윈도우 팝업을 열고 조회·다운로드·부모 지도 연계를 제공한다. + +## 2. 구현 범위 + +- `repairHistoryPopup` WindowPopup 정의·레지스트리·콘텐츠 +- 용도, 관재질, 관경 시작·종료 필터와 7개 요구 열 그리드 +- 조회 결과 전체 관로 청록색 강조 +- 목록은 페이지당 10건으로 표시 +- 행 단일 클릭 시 적색 선택과 부모 지도 범위 이동 +- 팝업 종료·처리구역 변경 시 임시 레이어 정리 +- 현재 조건에서 화면 목록과 동일한 7개 열 XLSX 다운로드 +- 파일명은 `<처리구역명>_보수이력.xlsx` 형식으로 생성 + +## 3. UI 기준 + +- 레이어 모달을 사용하지 않고 별도 윈도우 팝업으로 연다. +- 상단에는 처리구역명만 표시하고 내부 피처 ID는 노출하지 않는다. +- 후속 보정으로 상단 제목은 `보수이력`, 목록 제목은 `<처리구역명> 총 N건`으로 표시한다. +- 검색조건 라벨과 입력항목은 부모 업무화면처럼 한 줄로 배치한다. +- 용도 선택지는 관망도 트리 레이어 설정 API의 `sortOrdr`, `lyrNm`, `flterSql`을 그대로 사용한다. +- 목록은 부모 업무화면과 다른 윈도우 팝업이 사용하는 공통 `UiTable`을 재사용한다. +- 부모 화면과 동일한 `Noto Sans KR` 글꼴 및 조회 버튼 스타일을 사용한다. +- 팝업과 부모 지도는 동일 출처 `postMessage`로만 연동한다. + +## 4. 연동 API + +- `GET /api/v1/swagePrcsZones/{zoneId}/repairHistories` +- `GET /api/v1/swagePrcsZones/{zoneId}/repairHistories/download?zoneName={처리구역명}` diff --git a/apps/sn/docs/작업내용/SFR-011-006-보수이력/001-fe-SFR-011-006-보수이력-구현및검증결과.md b/apps/sn/docs/작업내용/SFR-011-006-보수이력/001-fe-SFR-011-006-보수이력-구현및검증결과.md new file mode 100644 index 000000000..b0b5dd94b --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-011-006-보수이력/001-fe-SFR-011-006-보수이력-구현및검증결과.md @@ -0,0 +1,41 @@ +# SFR-011-006 보수이력 FE 구현 및 검증 결과 + +## 1. 구현 결과 + +- 처리구역 분석 패널의 `보수이력` 버튼이 `repairHistoryPopup` 별도 창을 연다. +- 상단 제목은 `보수이력`으로 표시하고 내부 피처 ID는 제거했다. +- 목록 제목은 `<처리구역명> 총 N건` 형식으로 표시한다. +- 용도 선택지는 팝업에 코드·순서를 별도 하드코딩하지 않고 관망도 트리 레이어 설정 API에서 직접 구성한다. +- 검색조건 라벨과 입력항목을 한 줄로 배치하고 부모 화면의 글꼴·입력·버튼 규격을 적용했다. +- 목록은 공통 `UiTable`로 교체하고 페이지당 10건으로 표시한다. +- 초기·조건 조회 결과를 부모 지도에 전달하고 행 단일 클릭 시 선택 관로를 적색 강조한 뒤 이동한다. +- 불필요한 Suspense 경계를 제거해 실제 별도 창의 콘텐츠가 즉시 표시되도록 했고, 부모 창에서 popup `closed` 상태도 감시한다. +- 다운로드 파일명은 화면의 처리구역명을 사용해 `<처리구역명>_보수이력.xlsx`로 만들며, XLSX 항목은 화면 그리드와 동일한 7개 열로 맞췄다. + +## 2. 주요 수정 파일 + +- `app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx` +- `app/(map-layout)/map/components/popupComponent/swtrmtPrcsZoneAnalysisApi.ts` +- `app/(map-layout)/map/components/repairHistory/RepairHistoryPopupPage.tsx` +- `src/features/windowPopup/model/popupDefinitions.ts` +- `src/features/windowPopup/model/types.ts` +- `src/features/windowPopup/registry.tsx` +- `src/features/windowPopup/ui/RepairHistoryPopupContent.tsx` + +## 3. 검증 결과 + +- 실제 `window.open` 별도 창 URL과 크기 정의를 확인했다. +- 굴포처리구역 전체 3건, 우수관·관경 600~600 조건 2건을 확인했다. +- 다운로드 endpoint 호출과 부모 지도 적색 선택·범위 이동 연계 계약을 확인했다. +- 실제 팝업에서 상단 `보수이력`, 목록 `굴포처리구역 총 3건`이 표시되고 피처 ID가 노출되지 않는 것을 확인했다. +- 용도 순서가 관망도 트리와 동일한 `전체 → 합류관 → 차집관 → 우수관 → 오수관 → 기타 → 미분류`임을 확인했다. +- 네 개 검색조건 라벨의 `top` 좌표가 모두 동일하고, 공통 `table-ui secondary` 스타일 및 `Noto Sans KR` 글꼴 적용을 확인했다. +- 목록 행을 한 번 클릭한 뒤 해당 행이 `data-state=selected`로 바뀌는 것을 확인했다. +- 인증 갱신 후 새 팝업 기준 브라우저 콘솔 error/warn은 0건이었다. +- 전체 TypeScript 검사는 기존 선행 오류로 실패했지만 변경 파일명 필터 결과 관련 오류는 0건이었다. +- 실제 팝업에서 다운로드해 `굴포처리구역_보수이력.xlsx` 파일 생성을 확인했고, 파일 내부가 헤더 1행과 데이터 3행 및 화면과 동일한 7개 열로 구성됨을 확인했다. + +## 4. 영향 및 롤백 + +- 영향은 `apps/sn` 처리구역 분석과 WindowPopup 범위에 한정되며 공통 패키지는 수정하지 않았다. +- 롤백은 popup 정의·메시지 타입·콘텐츠를 제거하고 처리구역 분석의 버튼·지도 레이어 연계를 제거한다. diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts index 990573f19..09aaf6752 100644 --- a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -174,6 +174,13 @@ export const windowPopupDefinitions = { width: 1180, height: 760, }, + repairHistoryPopup: { + key: "repairHistoryPopup", + title: "보수이력", + width: 1260, + height: 760, + requiredParams: ["zoneId"], + }, floodPredictionMapPopup: { key: "floodPredictionMapPopup", title: "침수예측지도", diff --git a/apps/sn/src/features/windowPopup/model/types.ts b/apps/sn/src/features/windowPopup/model/types.ts index 4b8dfa7fd..7e1d9b8ba 100644 --- a/apps/sn/src/features/windowPopup/model/types.ts +++ b/apps/sn/src/features/windowPopup/model/types.ts @@ -62,6 +62,9 @@ export const WINDOW_POPUP_MESSAGE_TYPE = { WATER_RISK_READY: "sn:popup:waterRiskReady", WATER_RISK_SYNC: "sn:popup:waterRiskSync", WATER_RISK_SELECT_RESULT: "sn:popup:waterRiskSelectResult", + REPAIR_HISTORY_SHOW_RESULTS: "sn:popup:repairHistoryShowResults", + REPAIR_HISTORY_SELECT_PIPE: "sn:popup:repairHistorySelectPipe", + REPAIR_HISTORY_CLEAR: "sn:popup:repairHistoryClear", } as const; export type WindowPopupMessageType = diff --git a/apps/sn/src/features/windowPopup/registry.tsx b/apps/sn/src/features/windowPopup/registry.tsx index b178aa19f..55def4c06 100644 --- a/apps/sn/src/features/windowPopup/registry.tsx +++ b/apps/sn/src/features/windowPopup/registry.tsx @@ -34,6 +34,7 @@ import GisEditSessionPopupContent from "./ui/GisEditSessionPopupContent"; import GisManagementPopupContent from "./ui/GisManagementPopupContent"; import PipeAnalysisPopupContent from "./ui/PipeAnalysisPopupContent"; import PipeAnalysisDetailPopupContent from "./ui/PipeAnalysisDetailPopupContent"; +import RepairHistoryPopupContent from "./ui/RepairHistoryPopupContent"; import WaterRiskPopupContent from "./ui/WaterRiskPopupContent"; const FloodPredictionPlaceholderPopupContent = ( @@ -151,6 +152,10 @@ export const windowPopupRegistry = { ...windowPopupDefinitions.pipeAnalysisDetailPopup, component: PipeAnalysisDetailPopupContent, }, + repairHistoryPopup: { + ...windowPopupDefinitions.repairHistoryPopup, + component: RepairHistoryPopupContent, + }, floodPredictionMapPopup: { ...windowPopupDefinitions.floodPredictionMapPopup, component: FloodPredictionPlaceholderPopupContent, diff --git a/apps/sn/src/features/windowPopup/ui/RepairHistoryPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/RepairHistoryPopupContent.tsx new file mode 100644 index 000000000..4b0b1c3a0 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/RepairHistoryPopupContent.tsx @@ -0,0 +1,6 @@ +import RepairHistoryPopupPage from "@/app/(map-layout)/map/components/repairHistory/RepairHistoryPopupPage"; +import type { WindowPopupContentProps } from "../model/types"; + +export default function RepairHistoryPopupContent(props: WindowPopupContentProps) { + return ; +} From d8c53444519babef012aeebab29eaf42113f48dc Mon Sep 17 00:00:00 2001 From: mind-one001 Date: Tue, 11 Aug 2026 11:11:09 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=EA=B4=80=EB=A7=9D=EB=8F=84=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=EB=8F=84=EA=B5=AC=20=EC=9C=84=EC=B9=98=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysisMenu/AnalysisPopupMenu.tsx | 166 ++++++++++-------- apps/sn/app/(map-layout)/map/page.tsx | 8 +- .../001-fe-FE-MAP-우측도구배치-구현기록.md | 62 +++++++ 3 files changed, 156 insertions(+), 80 deletions(-) create mode 100644 apps/sn/docs/작업내용/FE-MAP-우측도구배치/001-fe-FE-MAP-우측도구배치-구현기록.md 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 51bd9a5f1..d321dd5a4 100644 --- a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx +++ b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx @@ -3,8 +3,8 @@ import { useEffect, useState, type ComponentType, type ReactNode } from "react"; import { ChartColumnBig, - ChevronLeft, - ChevronRight, + ChevronDown, + ChevronUp, PanelsTopLeft, PencilRuler, Search, @@ -61,11 +61,19 @@ 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: "agedPipeStatusByCtpvPopup", + label: "시도별 노후관로현황", + icon: ChartColumnBig, + }, { key: "gisEditPopup", label: "GIS 편집", icon: PencilRuler }, { key: "gisManagementPopup", label: "GIS 관리", icon: Settings2 }, { key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert }, - { key: "inftwtResultPopup", label: "침입수 분석결과", renderIcon: InftwtMenuIcon }, + { + key: "inftwtResultPopup", + label: "침입수 분석결과", + renderIcon: InftwtMenuIcon, + }, { key: "rdiiResultPopup", label: "RDII 분석결과", icon: ChartColumnBig }, ]; @@ -78,7 +86,9 @@ export default function AnalysisPopupMenu({ initialActiveKey = null, }: Props) { const [isOpen, setIsOpen] = useState(true); - const [activeKey, setActiveKey] = useState(initialActiveKey); + const [activeKey, setActiveKey] = useState< + WindowPopupKey | "infoSearch" | null + >(initialActiveKey); useEffect(() => { if (!isGisEditActive && activeKey === "gisEditPopup") { @@ -98,87 +108,91 @@ export default function AnalysisPopupMenu({ }, [initialActiveKey]); return ( -
-
- {isOpen ? ( -
-
-
- - 분석도구 -
-
+
+
+
+
+ + 분석도구 +
-
- {ANALYSIS_POPUP_ITEMS.map((item) => { - const Icon = item.icon; - const isActive = item.key === "gisEditPopup" + +
+ + {isOpen ? ( +
+ {ANALYSIS_POPUP_ITEMS.map((item) => { + const Icon = item.icon; + const isActive = + item.key === "gisEditPopup" ? isGisEditActive : activeKey === item.key; - return ( - - ); - })} -
+ const popupWindow = onOpenPopup(item.key); + if (!popupWindow) { + setActiveKey(null); + return; + } + + const closeWatcher = window.setInterval(() => { + if (!popupWindow.closed) return; + window.clearInterval(closeWatcher); + setActiveKey((currentKey) => + currentKey === item.key ? null : currentKey, + ); + }, 300); + }} + > + {item.renderIcon ? item.renderIcon({ isActive }) : null} + {Icon ? ( + + ) : null} + {item.label} + + ); + })}
) : null} - -
); diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 380a6b2af..c5963e0ba 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -7850,7 +7850,7 @@ const Map = () => { .sn-map-control-layout .mapbtn { top: auto; right: 15px; - bottom: 451px; + bottom: 439px; } .sn-map-control-layout .custom-toolbar { @@ -7860,12 +7860,12 @@ const Map = () => { .sn-map-control-layout .tool-bar { top: auto; right: 15px; - bottom: 248px; + bottom: 230px; } .sn-map-control-layout .zoom-level-control { - right: 16px; - bottom: 56px; + right: 15px; + bottom: 36px; } .sn-map-control-layout .zoom-level-summary { diff --git a/apps/sn/docs/작업내용/FE-MAP-우측도구배치/001-fe-FE-MAP-우측도구배치-구현기록.md b/apps/sn/docs/작업내용/FE-MAP-우측도구배치/001-fe-FE-MAP-우측도구배치-구현기록.md new file mode 100644 index 000000000..fe6dc5008 --- /dev/null +++ b/apps/sn/docs/작업내용/FE-MAP-우측도구배치/001-fe-FE-MAP-우측도구배치-구현기록.md @@ -0,0 +1,62 @@ +# FE-MAP 우측 도구 배치 개선 구현 기록 + +## 작업 목적 + +- 지도 우측의 분석도구 펼침·숨김 버튼을 분석도구 제목 영역 안으로 이동한다. +- 분석도구, 지도 선택 버튼, 지도 도구 모음, 줌 컨트롤의 우측 기준선을 통일한다. +- 우측 도구를 위아래로 재배치해 도구 사이의 겹침을 제거한다. + +## 원본 요구사항 추적 근거 + +- `D:\workspace\IIPBS\docs\기준문서\화면설계서.pptx` + - 슬라이드 12: `SFR-011-003`, 화면 ID `SN_MN010105`, 관로분석·GIS 편집·GIS 관리·통수위험 등 분석도구 구성 + - 슬라이드 14, 16: 지도 우측 분석도구 배치 패턴 + - 슬라이드 32: `SFR-012-003`, `SFR-012-004`, 화면 ID `SN_MN010301`, GIS 편집 + - 슬라이드 33: `SFR-012-002`, 화면 ID `SN_MN010302`, GIS 관리 +- `D:\workspace\IIPBS\docs\기준문서\요구사항정의서.xlsx` + - 시트 `3. (기능)요구사항정의 및 추적표`, 행 89~92: `SFR-011-003`~`SFR-011-006` + - 같은 시트 행 97~99: `SFR-012-002`~`SFR-012-004` + +이번 변경은 원본에 정의된 분석 기능이나 동작 계약을 변경하지 않고, 해당 도구의 화면 배치와 접기·펼치기 UI만 개선한다. + +## 수정 대상 + +- `app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx` +- `app/(map-layout)/map/page.tsx` + +## 변경 내용 + +- 분석도구 토글을 외부 돌출 버튼에서 제목 우측의 내부 버튼으로 변경했다. +- 토글에 `aria-label`, `aria-expanded`를 적용하고 접힘 상태에서도 제목 바는 유지하도록 했다. +- 분석도구를 지도 영역 우측 15px 기준으로 고정하고 다른 도구의 우측 기준선도 15px로 통일했다. +- 분석도구 항목 높이와 간격을 소폭 조정하고, 하단 지도 도구들의 `bottom` 위치를 재배치했다. + +## 영향 범위 + +- 프론트엔드 `sn` 지도 화면의 우측 도구 배치에만 영향이 있다. +- API, 상태관리 계약, 백엔드, DB 스키마 영향은 없다. + +## 테스트 및 검증 + +- 변경 내용 검사: `git diff --check` 통과 +- 개발 서버의 Turbopack 컴파일 및 실제 화면 반영 확인 +- 실제 브라우저 검증: `http://localhost:3002/map`, 1920×912 뷰포트 + - 분석도구 우측 좌표: 1905px + - 지도 선택 버튼·지도 도구 모음·줌 컨트롤 우측 좌표: 모두 1905px + - 분석도구→지도 선택 버튼 간격: 6px + - 지도 선택 버튼→지도 도구 모음 간격: 4px + - 지도 도구 모음→줌 컨트롤 간격: 4px + - 숨김 시 `aria-expanded=false`, 분석 메뉴 항목 0개, 제목 바 유지 확인 + - 보이기 시 `aria-expanded=true`, 분석 메뉴 항목 9개 확인 +- 검증 화면: `D:\workspace\IIPBS\docs\작업내용\FE-MAP-우측도구배치\검증\20260811-분석도구-우측도구-정렬.png` + +## 롤백 방법 + +- 위 두 소스 파일에서 본 작업의 분석도구 헤더/토글 구조와 우측 도구 위치값 변경을 되돌린다. +- 구현 기록과 검증 이미지를 제거한다. + +## 남은 리스크 + +- 사용자 실행 중인 Next 개발 서버 보호를 위해 별도 프로덕션 빌드는 수행하지 않았다. +- ESLint는 기존 `apps/sn/eslint.config.mjs`의 배열 전개 오류로, TypeScript 전체 검사는 기존 `tsconfig.json`의 `ignoreDeprecations` 설정 오류로 각각 대상 코드 검사 전에 중단됐다. +- 사용자 제보 화면과 동일한 1920×912 크기의 실제 브라우저에서는 정렬·간격·토글 동작을 확인했다. From d582293e5fbf5dbbcaa6918d8f67348e7d630357 Mon Sep 17 00:00:00 2001 From: mind-one001 Date: Tue, 11 Aug 2026 11:28:52 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=EB=B3=B4=EC=88=98=EC=9D=B4=EB=A0=A5=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repairHistory/RepairHistoryPopupPage.tsx | 41 +++++++++++-------- .../001-fe-SFR-011-006-보수이력-작업카드.md | 4 +- .../001-fe-SFR-011-006-보수이력-구현및검증결과.md | 6 ++- 3 files changed, 29 insertions(+), 22 deletions(-) diff --git a/apps/sn/app/(map-layout)/map/components/repairHistory/RepairHistoryPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/repairHistory/RepairHistoryPopupPage.tsx index 7683ed59b..b9d3b8ddb 100644 --- a/apps/sn/app/(map-layout)/map/components/repairHistory/RepairHistoryPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/repairHistory/RepairHistoryPopupPage.tsx @@ -48,23 +48,27 @@ const resolvePipeUseOptions = (rows: MapLayerSettingRow[]): PipeUseOption[] => r type RepairHistoryTableRow = { fid: string; fcltMngNo: string; + useName: string; repairCount: string; latestRepairYear: string; elapsedYearsSinceRepair: string; materialName: string; + diameter: string; installationYear: string; burialDepth: string; }; /** 부모 업무화면과 동일한 공통 UiTable 헤더·열 정렬 규격이다. */ const REPAIR_HISTORY_HEADLIST: UiHeadProps[] = [ - { id: "fcltMngNo", title: "시설관리번호", align: "center", widthPercent: 22 }, - { id: "repairCount", title: "보수이력(건)", align: "right", widthPercent: 12 }, - { id: "latestRepairYear", title: "최근보수년도", align: "center", widthPercent: 13 }, - { id: "elapsedYearsSinceRepair", title: "보수후 경과년도", align: "right", widthPercent: 14 }, - { id: "materialName", title: "관종", align: "center", widthPercent: 16 }, - { id: "installationYear", title: "설치년도", align: "center", widthPercent: 11 }, - { id: "burialDepth", title: "매설심도(m)", align: "right", widthPercent: 12 }, + { id: "fcltMngNo", title: "시설관리번호", align: "center", widthPercent: 17 }, + { id: "useName", title: "용도", align: "center", widthPercent: 10 }, + { id: "repairCount", title: "보수이력(건)", align: "right", widthPercent: 10 }, + { id: "latestRepairYear", title: "최근보수년도", align: "center", widthPercent: 11 }, + { id: "elapsedYearsSinceRepair", title: "보수후 경과년도", align: "right", widthPercent: 12 }, + { id: "materialName", title: "재질", align: "center", widthPercent: 13 }, + { id: "diameter", title: "관경(mm)", align: "right", widthPercent: 9 }, + { id: "installationYear", title: "설치년도", align: "center", widthPercent: 8 }, + { id: "burialDepth", title: "매설심도(m)", align: "right", widthPercent: 10 }, ]; /** 화면 입력조건은 조회 버튼을 누른 시점의 적용조건과 분리해 다운로드 계약을 고정한다. */ @@ -201,7 +205,7 @@ export default function RepairHistoryPopupPage({ searchParams }: WindowPopupCont }; }, [zoneId]); - /** 화면에서 적용한 조건과 동일한 7개 목록 항목의 XLSX를 내려받는다. */ + /** 화면에서 적용한 조건과 동일한 9개 목록 항목의 XLSX를 내려받는다. */ const handleDownload = async () => { if (!zoneId || rows.length === 0) return; setIsLoading(true); @@ -237,10 +241,12 @@ export default function RepairHistoryPopupPage({ searchParams }: WindowPopupCont () => visibleRows.map((row) => ({ fid: row.fid, fcltMngNo: formatCellValue(row.fcltMngNo), + useName: formatCellValue(row.useNm), repairCount: formatCellValue(row.repairCount), latestRepairYear: formatYear(row.latestRepairYear), elapsedYearsSinceRepair: formatCellValue(row.elapsedYearsSinceRepair), materialName: formatCellValue(row.materialName), + diameter: formatCellValue(row.pdmtVl), installationYear: formatYear(row.installationYear), burialDepth: formatCellValue(row.burialDepth), })), @@ -273,7 +279,7 @@ export default function RepairHistoryPopupPage({ searchParams }: WindowPopupCont - - +