Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
+200
-37
@@ -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>
|
||||
</>
|
||||
|
||||
+105
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user