보수이력 기능 개발

This commit is contained in:
2026-08-11 10:32:35 +09:00
parent b99fe8daf1
commit 2649ce286b
9 changed files with 771 additions and 38 deletions
@@ -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<SwtrmtPrcsZoneAnalysisResponse["analysis"]>; title: string }[] = [
{ key: "total", title: "전체" },
@@ -12,6 +29,16 @@ const analysisRows: { key: keyof NonNullable<SwtrmtPrcsZoneAnalysisResponse["ana
{ key: "sewage", title: "오수관" },
];
const repairHistoryStyle = new Style({
stroke: new Stroke({ color: "rgba(0, 229, 255, 0.95)", width: 5 }),
zIndex: 10020,
});
const selectedRepairHistoryStyle = new Style({
stroke: new Stroke({ color: "rgba(255, 82, 82, 1)", width: 8 }),
zIndex: 10021,
});
const formatValue = (value: unknown, unit = "") => {
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<SwtrmtPrcsZoneAnalysisResponse["analysis"]>
key: keyof NonNullable<SwtrmtPrcsZoneAnalysisResponse["analysis"]>,
): SwtrmtPrcsZoneAnalysisMetric => analysis?.[key] ?? {};
const metricGroups: {
@@ -60,7 +87,9 @@ const MetricVerticalGrid = ({
<TableCell className="font-bold bg-blue-100 text-[13px] w-[120px] whitespace-nowrap">
{row.title}
</TableCell>
<TableCell className="text-[13px]">{isLoading ? "" : formatValue(metric[field])}</TableCell>
<TableCell className="text-[13px]">
{isLoading ? "조회 중" : formatValue(metric[field])}
</TableCell>
</TableRow>
);
})}
@@ -69,6 +98,7 @@ const MetricVerticalGrid = ({
</div>
);
/** 처리구역 분석 패널과 보수이력 윈도우 팝업의 부모 지도 연계를 담당한다. */
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<SwtrmtPrcsZoneAnalysisResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
// 보수이력 지도 레이어는 React 렌더링 대상이 아니므로 ref로 보관해 팝업 종료·구역 전환 때 제거한다.
const repairLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const repairPopupRef = useRef<Window | null>(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 (
<>
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl mx-3 font-medium text-label">처리구역내 관로분석정보</h3>
<Button
type="button"
size="sm"
className="mr-3 bg-blue-600 text-white hover:bg-blue-700"
onClick={handleOpenRepairHistory}
data-testid="repair-history-open"
>
보수이력
</Button>
</div>
<div className="card !mx-[1.2rem] !mb-[1.2rem] !mt-1 !p-0">
<div className="px-3 pb-3 pt-1">
{!isLoading && errorMessage && (
<div className="py-6 text-center text-[13px] text-gray-500">{errorMessage}</div>
)}
{(isLoading || !errorMessage) && (
metricGroups.map((group) => (
<MetricVerticalGrid
key={group.field}
title={group.title}
field={group.field}
analysis={analysisData?.analysis}
isLoading={isLoading}
/>
))
)}
{errorMessage ? (
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[13px] text-red-700">
{errorMessage}
</div>
) : null}
{metricGroups.map((group) => (
<MetricVerticalGrid
key={group.title}
title={group.title}
field={group.field}
analysis={analysisData?.analysis}
isLoading={isLoading}
/>
))}
</div>
</div>
</>
@@ -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<string, any> | 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<string, string> = {},
) => {
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<RepairHistoryResponse> => {
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 }));
};
@@ -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<string, unknown>,
) => {
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<RepairHistoryFilters>(buildEmptyFilters);
const [appliedFilters, setAppliedFilters] = useState<RepairHistoryFilters>(buildEmptyFilters);
// 전체 조회 결과는 부모 지도 강조와 팝업 내 10건 페이지 표가 함께 사용한다.
const [rows, setRows] = useState<RepairHistoryRow[]>([]);
// 용도 선택지는 별도 하드코딩하지 않고 관망도 트리 설정 API의 sortOrdr를 따른다.
const [pipeUseOptions, setPipeUseOptions] = useState<PipeUseOption[]>([]);
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<RepairHistoryTableRow[]>(
() => 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 (
<main className="sn-popup-page w-full max-w-full overflow-x-hidden overflow-y-hidden">
<LoadingOverlay isLoading={isLoading} />
<header className="sn-popup-header">
<h1 className="sn-popup-title">보수이력</h1>
</header>
<section className="flex min-h-0 flex-1 flex-col overflow-x-hidden px-4 pb-3 pt-3">
<div className="sn-popup-filter-panel-compact flex w-full flex-wrap items-center gap-x-8 gap-y-3 px-4 py-3">
<label className="flex items-center gap-2 whitespace-nowrap text-xl font-medium text-gray-600">
<span>용도</span>
<select
className="h-[3.2rem] w-[150px] rounded-lg border border-input bg-white px-3 text-base font-normal text-slate-900"
value={filters.useCd}
onChange={(event) => setFilters((current) => ({ ...current, useCd: event.target.value }))}
data-testid="repair-use-filter"
>
<option value="">전체</option>
{pipeUseOptions.map((code) => (
<option key={code.val} value={code.val}>{code.text}</option>
))}
</select>
</label>
<label className="flex items-center gap-2 whitespace-nowrap text-xl font-medium text-gray-600">
<span>관재질</span>
<select
className="h-[3.2rem] w-[180px] rounded-lg border border-input bg-white px-3 text-base font-normal text-slate-900"
value={filters.materialCode}
onChange={(event) => setFilters((current) => ({ ...current, materialCode: event.target.value }))}
data-testid="repair-material-filter"
>
<option value="">전체</option>
{MS_SWAGE_PPL_CODES.map((code) => (
<option key={code.val} value={code.val}>{code.text}</option>
))}
</select>
</label>
<label className="flex items-center gap-2 whitespace-nowrap text-xl font-medium text-gray-600">
<span>관경 시작(mm)</span>
<input
type="number"
min="0"
className="h-[3.2rem] w-[110px] rounded-lg border border-input bg-white px-3 text-base font-normal text-slate-900"
value={filters.diameterFrom}
onChange={(event) => setFilters((current) => ({ ...current, diameterFrom: event.target.value }))}
data-testid="repair-diameter-from"
/>
</label>
<label className="flex items-center gap-2 whitespace-nowrap text-xl font-medium text-gray-600">
<span>관경 종료(mm)</span>
<input
type="number"
min="0"
className="h-[3.2rem] w-[110px] rounded-lg border border-input bg-white px-3 text-base font-normal text-slate-900"
value={filters.diameterTo}
onChange={(event) => setFilters((current) => ({ ...current, diameterTo: event.target.value }))}
data-testid="repair-diameter-to"
/>
</label>
<div className="ml-auto flex shrink-0 items-center gap-2">
<Button
type="button"
size="sm"
color="primary"
className="rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white"
onClick={() => void searchRepairHistories({ ...filters })}
disabled={isLoading}
data-testid="repair-history-search"
>
조회
</Button>
<Button
type="button"
size="sm"
color="primary"
className="rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white"
onClick={() => void handleDownload()}
disabled={isLoading || rows.length === 0}
data-testid="repair-history-download"
>
다운로드
</Button>
</div>
</div>
{errorMessage ? (
<div role="alert" className="mb-2 mt-2 border border-destructive bg-secondary px-4 py-2 text-base text-destructive">
{errorMessage}
</div>
) : null}
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto pt-2" aria-busy={isLoading}>
<UiTable<RepairHistoryTableRow>
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"}
/>
</div>
<div className="sn-popup-help-text">행을 클릭하면 부모 지도의 해당 관로로 이동합니다.</div>
</section>
</main>
);
}
@@ -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={처리구역명}`
@@ -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 정의·메시지 타입·콘텐츠를 제거하고 처리구역 분석의 버튼·지도 레이어 연계를 제거한다.
@@ -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: "침수예측지도",
@@ -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 =
@@ -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,
@@ -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 <RepairHistoryPopupPage {...props} />;
}