Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev

This commit is contained in:
2026-08-11 14:33:20 +09:00
12 changed files with 934 additions and 118 deletions
@@ -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<WindowPopupKey | "infoSearch" | null>(initialActiveKey);
const [activeKey, setActiveKey] = useState<
WindowPopupKey | "infoSearch" | null
>(initialActiveKey);
useEffect(() => {
if (!isGisEditActive && activeKey === "gisEditPopup") {
@@ -98,87 +108,91 @@ export default function AnalysisPopupMenu({
}, [initialActiveKey]);
return (
<div className="absolute right-[0.55rem] top-[20px] z-[45]">
<div className="relative">
{isOpen ? (
<div className="absolute right-[28px] w-[154px] overflow-hidden rounded-[6px] border border-border bg-background shadow-[0_8px_18px_rgba(15,23,42,0.14)]">
<div className="flex h-[36px] items-center gap-2 border-b border-border bg-secondary px-[10px] text-[13px] font-bold text-foreground">
<div className="flex items-center gap-2">
<PanelsTopLeft className="h-4 w-4 text-primary" />
<span>분석도구</span>
</div>
</div>
<div className="absolute right-[15px] top-[8px] z-[45]">
<div className="flex w-[154px] flex-col overflow-hidden rounded-[6px] border border-border bg-background shadow-[0_8px_18px_rgba(15,23,42,0.14)]">
<div className="flex h-[36px] shrink-0 items-center justify-between gap-2 border-b border-border bg-secondary pl-[10px] pr-[4px] text-[13px] font-bold text-foreground">
<div className="flex items-center gap-2">
<PanelsTopLeft className="h-4 w-4 text-primary" />
<span>분석도구</span>
</div>
<div className="flex flex-col gap-1 p-[6px]">
{ANALYSIS_POPUP_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = item.key === "gisEditPopup"
<button
type="button"
className="flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-[4px] text-label transition hover:bg-accent hover:text-primary"
onClick={() => setIsOpen((value) => !value)}
title={isOpen ? "분석 메뉴 숨기기" : "분석 메뉴 보이기"}
aria-label={isOpen ? "분석 메뉴 숨기기" : "분석 메뉴 보이기"}
aria-expanded={isOpen}
>
{isOpen ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
</div>
{isOpen ? (
<div className="flex flex-col gap-[3px] p-[6px]">
{ANALYSIS_POPUP_ITEMS.map((item) => {
const Icon = item.icon;
const isActive =
item.key === "gisEditPopup"
? isGisEditActive
: activeKey === item.key;
return (
<button
key={item.key}
type="button"
className={`relative flex h-[34px] items-center gap-[7px] rounded-[5px] border px-[8px] pl-[10px] text-left text-[12px] font-semibold transition ${
isActive
? "border-primary bg-secondary font-bold text-primary before:absolute before:left-0 before:top-[7px] before:bottom-[7px] before:w-[3px] before:rounded-r-[2px] before:bg-primary"
: "border-transparent bg-background text-foreground hover:border-border hover:bg-accent hover:text-primary"
}`}
onClick={() => {
if (item.key === "gisEditPopup" && onToggleGisEdit) {
setActiveKey(item.key);
onToggleGisEdit();
return;
}
return (
<button
key={item.key}
type="button"
className={`relative flex h-[33px] items-center gap-[7px] rounded-[5px] border px-[8px] pl-[10px] text-left text-[12px] font-semibold transition ${
isActive
? "border-primary bg-secondary font-bold text-primary before:absolute before:bottom-[7px] before:left-0 before:top-[7px] before:w-[3px] before:rounded-r-[2px] before:bg-primary"
: "border-transparent bg-background text-foreground hover:border-border hover:bg-accent hover:text-primary"
}`}
onClick={() => {
if (item.key === "gisEditPopup" && onToggleGisEdit) {
setActiveKey(item.key);
onToggleGisEdit();
return;
}
if (item.key === "infoSearch") {
onOpenInfoSearch();
return;
}
setActiveKey(item.key);
const popupWindow = onOpenPopup(item.key);
if (!popupWindow) {
setActiveKey(null);
return;
}
if (item.key === "infoSearch") {
onOpenInfoSearch();
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 ? (
<Icon
className={`h-[15px] w-[15px] shrink-0 ${
isActive ? "text-primary" : "text-label"
}`}
/>
) : null}
<span className="whitespace-nowrap">{item.label}</span>
</button>
);
})}
</div>
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 ? (
<Icon
className={`h-[15px] w-[15px] shrink-0 ${
isActive ? "text-primary" : "text-label"
}`}
/>
) : null}
<span className="whitespace-nowrap">{item.label}</span>
</button>
);
})}
</div>
) : null}
<button
type="button"
className="absolute right-0 top-0 flex h-[36px] w-[20px] items-center justify-center rounded-[4px] border border-border bg-background text-label shadow-[0_8px_18px_rgba(15,23,42,0.14)] transition hover:bg-accent hover:text-primary"
onClick={() => setIsOpen((value) => !value)}
title={isOpen ? "분석 메뉴 접기" : "분석 메뉴 펼치기"}
aria-label={isOpen ? "분석 메뉴 접기" : "분석 메뉴 펼치기"}
>
{isOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
</button>
</div>
</div>
);
@@ -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,377 @@
"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;
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: 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 },
];
/** 화면 입력조건은 조회 버튼을 누른 시점의 적용조건과 분리해 다운로드 계약을 고정한다. */
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]);
/** 화면에서 적용한 조건과 동일한 9개 목록 항목의 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),
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),
})),
[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>
<div className="flex items-center gap-2 whitespace-nowrap text-xl font-medium text-gray-600">
<span>관경(mm)</span>
<input
type="number"
min="0"
aria-label="최소 관경(mm)"
className="h-[3.2rem] w-[100px] 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"
/>
<span aria-hidden="true">~</span>
<input
type="number"
min="0"
aria-label="최대 관경(mm)"
className="h-[3.2rem] w-[100px] 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"
/>
</div>
<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>
);
}
+4 -4
View File
@@ -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 {
@@ -0,0 +1,32 @@
# SFR-011-006 보수이력 FE 작업카드
## 1. 작업 목적
- 처리구역 분석 패널에서 보수이력 공통 윈도우 팝업을 열고 조회·다운로드·부모 지도 연계를 제공한다.
## 2. 구현 범위
- `repairHistoryPopup` WindowPopup 정의·레지스트리·콘텐츠
- 용도, 재질, `관경(mm) [최소] ~ [최대]` 필터와 용도·관경을 포함한 9개 열 그리드
- 조회 결과 전체 관로 청록색 강조
- 목록은 페이지당 10건으로 표시
- 행 단일 클릭 시 적색 선택과 부모 지도 범위 이동
- 팝업 종료·처리구역 변경 시 임시 레이어 정리
- 현재 조건에서 화면 목록과 동일한 9개 열 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,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 크기의 실제 브라우저에서는 정렬·간격·토글 동작을 확인했다.
@@ -0,0 +1,43 @@
# SFR-011-006 보수이력 FE 구현 및 검증 결과
## 1. 구현 결과
- 처리구역 분석 패널의 `보수이력` 버튼이 `repairHistoryPopup` 별도 창을 연다.
- 상단 제목은 `보수이력`으로 표시하고 내부 피처 ID는 제거했다.
- 목록 제목은 `<처리구역명> 총 N건` 형식으로 표시한다.
- 용도 선택지는 팝업에 코드·순서를 별도 하드코딩하지 않고 관망도 트리 레이어 설정 API에서 직접 구성한다.
- 검색조건 라벨과 입력항목을 한 줄로 배치하고 부모 화면의 글꼴·입력·버튼 규격을 적용했다.
- 검색조건과 목록의 `관재질`·`관종` 표현은 `재질`로 통일하고, 관경 범위는 `관경(mm) [최소] ~ [최대]` 한 항목으로 묶었다.
- 목록은 공통 `UiTable`로 교체하고 페이지당 10건으로 표시한다.
- 목록에 `용도`, `관경(mm)`을 추가해 화면 항목을 9개 열로 구성했다.
- 초기·조건 조회 결과를 부모 지도에 전달하고 행 단일 클릭 시 선택 관로를 적색 강조한 뒤 이동한다.
- 불필요한 Suspense 경계를 제거해 실제 별도 창의 콘텐츠가 즉시 표시되도록 했고, 부모 창에서 popup `closed` 상태도 감시한다.
- 다운로드 파일명은 화면의 처리구역명을 사용해 `<처리구역명>_보수이력.xlsx`로 만들며, XLSX 항목은 화면 그리드와 동일한 9개 열로 맞췄다.
## 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행 및 화면과 동일한 9개 열로 구성됨을 확인했다.
## 4. 영향 및 롤백
- 영향은 `apps/sn` 처리구역 분석과 WindowPopup 범위에 한정되며 공통 패키지는 수정하지 않았다.
- 롤백은 popup 정의·메시지 타입·콘텐츠를 제거하고 처리구역 분석의 버튼·지도 레이어 연계를 제거한다.
@@ -181,6 +181,13 @@ export const windowPopupDefinitions = {
width: 1180,
height: 760,
},
repairHistoryPopup: {
key: "repairHistoryPopup",
title: "보수이력",
width: 1260,
height: 760,
requiredParams: ["zoneId"],
},
floodPredictionMapPopup: {
key: "floodPredictionMapPopup",
title: "침수예측지도",
@@ -63,6 +63,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";
import CfmtnBgtPopupContent from "../cfmtnBgt/ui/CfmtnBgtPopupContent";
@@ -156,6 +157,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} />;
}