침입수 분석결과 지도연계 및 I/I 분석 메뉴 정리
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
ChartColumnBig,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Droplets,
|
||||
PanelsTopLeft,
|
||||
PencilRuler,
|
||||
Search,
|
||||
@@ -33,6 +34,7 @@ const ANALYSIS_POPUP_ITEMS: Array<{
|
||||
{ key: "gisEditPopup", label: "GIS 편집", icon: PencilRuler },
|
||||
{ key: "gisManagementPopup", label: "GIS 관리", icon: Settings2 },
|
||||
{ key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert },
|
||||
{ key: "inftwtResultPopup", label: "침입수 분석", icon: Droplets },
|
||||
];
|
||||
|
||||
export default function AnalysisPopupMenu({
|
||||
@@ -45,7 +47,6 @@ export default function AnalysisPopupMenu({
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [activeKey, setActiveKey] = useState<WindowPopupKey | "infoSearch" | null>(null);
|
||||
|
||||
// GIS 편집 모드가 꺼지면 메뉴 강조 상태도 함께 정리해 사용자가 현재 모드를 오해하지 않게 한다.
|
||||
useEffect(() => {
|
||||
if (!isGisEditActive && activeKey === "gisEditPopup") {
|
||||
setActiveKey(null);
|
||||
@@ -140,4 +141,4 @@ export default function AnalysisPopupMenu({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { DateMonthPicker, UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { getData } from "@/components/lib/api";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
|
||||
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
|
||||
import useStore from "@/store/useStore";
|
||||
|
||||
import {
|
||||
INFTWT_RESULT_ROW_SELECT_MESSAGE,
|
||||
type InftwtResultRow,
|
||||
} from "./inftwtResultTypes";
|
||||
|
||||
const INFTWT_RESULT_PAGE_SIZE = 10;
|
||||
|
||||
const INFTWT_RESULT_HEADLIST = [
|
||||
{ id: "rowNumber", title: "일련번호" },
|
||||
{ id: "ctpvNm", title: "시도" },
|
||||
{ id: "sggNm", title: "시군구" },
|
||||
{ id: "swtrmtFcltNm", title: "하수처리시설" },
|
||||
{ id: "aplcnYm", title: "적용연월" },
|
||||
{ id: "drtwSwtcRt", title: "오수 전환율" },
|
||||
{ id: "anlsInfo", title: "침입수 분석정보" },
|
||||
{ id: "regDt", title: "분석일자" },
|
||||
{ id: "rgtrNm", title: "분석자" },
|
||||
{ id: "scsYn", title: "분석성공여부" },
|
||||
] as const;
|
||||
|
||||
type InftwtResultFilters = {
|
||||
ctpvCd: string;
|
||||
sggCd: string;
|
||||
sggName: string;
|
||||
swtrmtFcltNm: string;
|
||||
aplcnYmFrom: string;
|
||||
aplcnYmTo: string;
|
||||
};
|
||||
|
||||
const createDefaultAplcnYm = () => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
return `${year}${month}`;
|
||||
};
|
||||
|
||||
const DEFAULT_FILTERS: InftwtResultFilters = {
|
||||
ctpvCd: "0",
|
||||
sggCd: "0",
|
||||
sggName: "",
|
||||
swtrmtFcltNm: "",
|
||||
aplcnYmFrom: `${new Date().getFullYear()}01`,
|
||||
aplcnYmTo: createDefaultAplcnYm(),
|
||||
};
|
||||
|
||||
const postRowSelectMessage = (row: InftwtResultRow) => {
|
||||
if (typeof window === "undefined" || !window.opener) return;
|
||||
window.opener.postMessage(
|
||||
{
|
||||
type: INFTWT_RESULT_ROW_SELECT_MESSAGE,
|
||||
row,
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
};
|
||||
|
||||
export default function InftwtResultPopupPage() {
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
const [filters, setFilters] = useState<InftwtResultFilters>(DEFAULT_FILTERS);
|
||||
const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState("0");
|
||||
const [selectedRow, setSelectedRow] = useState<InftwtResultRow | null>(null);
|
||||
const [rows, setRows] = useState<InftwtResultRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (ctpv.length === 0) void fetchCtpv();
|
||||
if (sgg.length === 0) void fetchSgg();
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
|
||||
const fetchRows = useCallback(
|
||||
async (page: number, nextFilters: InftwtResultFilters) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
page: String(page - 1),
|
||||
size: String(INFTWT_RESULT_PAGE_SIZE),
|
||||
sort: "inftwtAnlsSn,desc",
|
||||
});
|
||||
|
||||
if (nextFilters.aplcnYmFrom) queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom);
|
||||
if (nextFilters.aplcnYmTo) queryParams.set("aplcnYmTo", nextFilters.aplcnYmTo);
|
||||
if (nextFilters.ctpvCd !== "0") queryParams.set("ctpvCd", nextFilters.ctpvCd);
|
||||
if (nextFilters.sggCd !== "0") queryParams.set("sggCd", nextFilters.sggCd);
|
||||
if (nextFilters.swtrmtFcltNm.trim()) queryParams.set("swtrmtFcltNm", nextFilters.swtrmtFcltNm.trim());
|
||||
|
||||
const response = await getData(`v1/inftwtAnls?${queryParams.toString()}`);
|
||||
const content = Array.isArray(response?.content) ? response.content : [];
|
||||
|
||||
const nextRows = content
|
||||
.map((item: Record<string, unknown>, index: number) => {
|
||||
const formattedItem = formatDisplayData(item);
|
||||
const ctpvOption = ctpv.find((code) => code.val.trim() === String(item?.ctpvCd ?? "").trim());
|
||||
const sggOption = sgg.find((code) => code.val === item?.sggCd);
|
||||
|
||||
return {
|
||||
...(formattedItem as Record<string, unknown>),
|
||||
rowNumber: (page - 1) * INFTWT_RESULT_PAGE_SIZE + index + 1,
|
||||
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
|
||||
sggNm: sggOption ? sggOption.text : String(item?.sggCd ?? "-"),
|
||||
scsYn: String(item?.scsYn ?? "N"),
|
||||
} as InftwtResultRow;
|
||||
})
|
||||
.filter((item: InftwtResultRow) => item.scsYn === "Y");
|
||||
|
||||
setRows(nextRows);
|
||||
setTotal(Number(response?.totalElements ?? nextRows.length));
|
||||
setCurrentPage(page);
|
||||
},
|
||||
[ctpv, sgg],
|
||||
);
|
||||
|
||||
const loadRows = useCallback(
|
||||
async (page = 1, nextFilters = filters) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await fetchRows(page, nextFilters);
|
||||
} catch (error) {
|
||||
console.error("침입수 분석결과 목록 조회 중 오류:", error);
|
||||
alert("침입수 분석결과 목록을 불러오지 못했습니다.");
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[fetchRows, filters],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRows(1, DEFAULT_FILTERS);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSearch = async (page = 1) => {
|
||||
setSelectedIndex(-1);
|
||||
setSelectedRow(null);
|
||||
await loadRows(page, filters);
|
||||
};
|
||||
|
||||
const handleRowClick = (index?: number) => {
|
||||
if (typeof index !== "number") return;
|
||||
const row = rows[index];
|
||||
if (!row) return;
|
||||
|
||||
setSelectedIndex(index);
|
||||
setSelectedRow(row);
|
||||
postRowSelectMessage(row);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!selectedRow?.inftwtAnlsSn) {
|
||||
alert("다운로드할 분석결과를 먼저 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await getData(`v1/inftwtAnlsRslt/ppl/${selectedRow.inftwtAnlsSn}`);
|
||||
const excelRows = Array.isArray(response?.content) ? response.content : [];
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: excelRows as JSON[],
|
||||
fileName: `침입수분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.inftwtAnlsSn}`,
|
||||
sheetNameArray: ["침입수 분석결과"],
|
||||
headerRange: "A1:D1",
|
||||
headerMapping: {
|
||||
inftwtAnlsSn: "침입수분석\n일련번호",
|
||||
pplMngNo: "관로\n관리번호",
|
||||
anlsTm: "분석시각",
|
||||
predcOilq: "예측유량\n(m³/h)",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("침입수 분석결과 다운로드 중 오류:", error);
|
||||
alert("침입수 분석결과 다운로드 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex h-screen flex-col overflow-hidden bg-white">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
|
||||
<h1 className="text-[1.45rem] font-semibold text-white">침입수 분석결과</h1>
|
||||
</header>
|
||||
|
||||
<section className="overflow-y-auto p-4">
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.05rem] font-medium text-slate-700">상수사용기간</span>
|
||||
<DateMonthPicker
|
||||
field={{
|
||||
value: filters.aplcnYmFrom,
|
||||
onChange: (value: string) => {
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
aplcnYmFrom: value,
|
||||
}));
|
||||
},
|
||||
}}
|
||||
selectClass="w-[145px]"
|
||||
startYear={1900}
|
||||
/>
|
||||
<span className="text-[1.1rem] font-semibold text-slate-500">~</span>
|
||||
<DateMonthPicker
|
||||
field={{
|
||||
value: filters.aplcnYmTo,
|
||||
onChange: (value: string) => {
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
aplcnYmTo: value,
|
||||
}));
|
||||
},
|
||||
}}
|
||||
selectClass="w-[145px]"
|
||||
startYear={1900}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 overflow-x-auto">
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
sido={filters.ctpvCd}
|
||||
sigungu={filters.sggCd}
|
||||
sggName={filters.sggName}
|
||||
onSidoChange={(value) => {
|
||||
setSelectedSwagePrcsFclt("0");
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
ctpvCd: value,
|
||||
sggCd: "0",
|
||||
sggName: "",
|
||||
swtrmtFcltNm: "",
|
||||
}));
|
||||
}}
|
||||
onSigunguChange={(value) => {
|
||||
setSelectedSwagePrcsFclt("0");
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
sggCd: value,
|
||||
swtrmtFcltNm: "",
|
||||
}));
|
||||
}}
|
||||
setSggName={(value) => {
|
||||
setSelectedSwagePrcsFclt("0");
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
sggName: value ?? "",
|
||||
swtrmtFcltNm: "",
|
||||
}));
|
||||
}}
|
||||
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
|
||||
onSwagePrcsFcltChange={setSelectedSwagePrcsFclt}
|
||||
setSwtrmtFcltNm={(value) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
swtrmtFcltNm: value ?? "",
|
||||
}))
|
||||
}
|
||||
setIsLoading={setIsLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
onClick={() => void handleSearch(1)}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="green"
|
||||
size="sm"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
onClick={() => void handleDownload()}
|
||||
>
|
||||
다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<UiTable<InftwtResultRow>
|
||||
headName={<>{`분석이력 총 ${total.toLocaleString()}건`}</>}
|
||||
tableData={rows}
|
||||
headlist={INFTWT_RESULT_HEADLIST as unknown as { id: string; title: string }[]}
|
||||
pageSize={INFTWT_RESULT_PAGE_SIZE}
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
onPageChange={(page) => void handleSearch(page)}
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedRow ? (
|
||||
<div className="mt-3 rounded-md border border-slate-200 bg-slate-50 px-4 py-3 text-[1rem] text-slate-600">
|
||||
{"선택 분석: "}
|
||||
<span className="font-semibold text-slate-800">{selectedRow.anlsInfo}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
export const INFTWT_RESULT_ROW_SELECT_MESSAGE = "SN_INFTWT_RESULT_ROW_SELECT";
|
||||
export const INFTWT_RESULT_OVERLAY_TYPE = "INFTWT_RESULT";
|
||||
|
||||
export type InftwtResultRow = {
|
||||
rowNumber: number;
|
||||
inftwtAnlsSn: number;
|
||||
ctpvCd?: string;
|
||||
ctpvNm: string;
|
||||
sggCd?: string;
|
||||
sggNm: string;
|
||||
swtrmtFcltNm: string;
|
||||
aplcnYm: string;
|
||||
drtwSwtcRt?: number | string;
|
||||
anlsInfo: string;
|
||||
regDt?: string;
|
||||
rgtrNm?: string;
|
||||
scsYn?: string;
|
||||
};
|
||||
|
||||
export type InftwtResultMapViewPipe = {
|
||||
pplMngNo: string;
|
||||
fcltMngNo: string;
|
||||
hasOilq: boolean;
|
||||
};
|
||||
|
||||
export type InftwtResultMapViewResponse = {
|
||||
analysis: {
|
||||
inftwtAnlsSn: number;
|
||||
aplcnYm: string;
|
||||
ctpvCd?: string;
|
||||
sggCd?: string;
|
||||
swtrmtFcltNm?: string;
|
||||
anlsInfo?: string;
|
||||
};
|
||||
focusTargetType: "ZONE" | "PIPE_EXTENT";
|
||||
focusTargetName: string;
|
||||
focusLayerType?: "tb_ms_swage_prcs_zone" | "tb_ms_swage_ppl";
|
||||
focusFid?: string;
|
||||
focusFallbackApplied?: boolean;
|
||||
zoneGeometry: Record<string, unknown>;
|
||||
pipeGeometry: Record<string, unknown>;
|
||||
pipes: InftwtResultMapViewPipe[];
|
||||
pipeCount: number;
|
||||
oilqPipeCount: number;
|
||||
};
|
||||
@@ -0,0 +1,456 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
|
||||
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 Fill from "ol/style/Fill.js";
|
||||
import Style from "ol/style/Style.js";
|
||||
import { intersects as intersectsExtent } from "ol/extent";
|
||||
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
|
||||
|
||||
import GisApp from "@common/business_components/ui/map/lib/gis.core";
|
||||
import { getSigunguCenter, moveMapToCenter, sidoCenter } from "@common/business_components/ui/map/searchBar/utils";
|
||||
|
||||
import {
|
||||
INFTWT_RESULT_OVERLAY_TYPE,
|
||||
INFTWT_RESULT_ROW_SELECT_MESSAGE,
|
||||
type InftwtResultMapViewResponse,
|
||||
type InftwtResultRow,
|
||||
} from "./inftwtResultTypes";
|
||||
|
||||
type UseInftwtResultMapParams = {
|
||||
gisMaps: MutableRefObject<any | null>;
|
||||
setFeatureData: (value: any) => void;
|
||||
setIsOpen: (value: boolean) => void;
|
||||
setIsSearchModalOpen: (value: boolean) => void;
|
||||
};
|
||||
|
||||
const INFTWT_ZONE_LAYER_ID = "SN_INFTWT_RESULT_ZONE_LAYER";
|
||||
const INFTWT_PIPE_LAYER_ID = "SN_INFTWT_RESULT_PIPE_LAYER";
|
||||
const DEFAULT_RESULT_FIT_PADDING: [number, number, number, number] = [120, 120, 120, 120];
|
||||
const INFTWT_PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH";
|
||||
|
||||
const zoneStyle = new Style({
|
||||
fill: new Fill({
|
||||
color: "rgba(16, 185, 129, 0.06)",
|
||||
}),
|
||||
stroke: new Stroke({
|
||||
color: "rgba(5, 150, 105, 0.72)",
|
||||
width: 2,
|
||||
lineDash: [10, 8],
|
||||
}),
|
||||
zIndex: 19991,
|
||||
});
|
||||
|
||||
const buildPipeStyle = (hasOilq: boolean) => {
|
||||
const innerColor = hasOilq ? "rgba(236, 72, 153, 0.98)" : "rgba(14, 165, 233, 0.98)";
|
||||
const haloWidth = hasOilq ? 11 : 5;
|
||||
const innerWidth = hasOilq ? 6 : 2.5;
|
||||
const dashPattern = hasOilq ? [18, 9] : undefined;
|
||||
|
||||
return [
|
||||
new Style({
|
||||
stroke: new Stroke({
|
||||
color: "rgba(15, 23, 42, 0.58)",
|
||||
width: hasOilq ? haloWidth + 4 : haloWidth + 2,
|
||||
lineCap: "round",
|
||||
lineJoin: "round",
|
||||
}),
|
||||
zIndex: hasOilq ? 20000 : 19999,
|
||||
}),
|
||||
new Style({
|
||||
stroke: new Stroke({
|
||||
color: "rgba(255, 255, 255, 0.98)",
|
||||
width: haloWidth,
|
||||
lineCap: "round",
|
||||
lineJoin: "round",
|
||||
}),
|
||||
zIndex: hasOilq ? 20001 : 20000,
|
||||
}),
|
||||
new Style({
|
||||
stroke: new Stroke({
|
||||
color: innerColor,
|
||||
width: innerWidth,
|
||||
lineCap: "round",
|
||||
lineJoin: "round",
|
||||
lineDash: dashPattern,
|
||||
}),
|
||||
zIndex: hasOilq ? 20002 : 20001,
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
const getFeatureCollection = (payload: any) => {
|
||||
if (payload?.type === "FeatureCollection") return payload;
|
||||
if (payload?.data?.type === "FeatureCollection") return payload.data;
|
||||
return { type: "FeatureCollection", features: [] };
|
||||
};
|
||||
|
||||
const normalizeProjectionName = (value: string) => {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) return "EPSG:4326";
|
||||
if (trimmed.startsWith("EPSG:")) return trimmed;
|
||||
const match = trimmed.match(/EPSG[:/]{1,2}(\d{4,5})/i) || trimmed.match(/(\d{4,5})$/);
|
||||
return match?.[1] ? `EPSG:${match[1]}` : trimmed;
|
||||
};
|
||||
|
||||
const resolveFeatureCollectionProjection = (payload: any) => {
|
||||
const featureCollection = getFeatureCollection(payload);
|
||||
const crsName = String(featureCollection?.crs?.properties?.name ?? "").trim();
|
||||
return normalizeProjectionName(crsName || GisApp.TO_PROJ);
|
||||
};
|
||||
|
||||
const getOverlayFlag = (data: any) =>
|
||||
String(
|
||||
data?.get?.("analysisOverlayType") ??
|
||||
data?.values_?.analysisOverlayType ??
|
||||
data?.analysisOverlayType ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
const getOverlayBasePplMngNo = (data: any) =>
|
||||
String(
|
||||
data?.get?.("fcltMngNo") ??
|
||||
data?.values_?.fcltMngNo ??
|
||||
data?.values_?.fclt_mng_no ??
|
||||
data?.fcltMngNo ??
|
||||
data?.fclt_mng_no ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
const getOverlayAnalysisPplMngNo = (data: any) =>
|
||||
String(
|
||||
data?.get?.("pplMngNo") ??
|
||||
data?.values_?.pplMngNo ??
|
||||
data?.pplMngNo ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
const getOverlayAnalysisSn = (data: any) =>
|
||||
Number(
|
||||
data?.get?.("inftwtAnlsSn") ??
|
||||
data?.values_?.inftwtAnlsSn ??
|
||||
data?.inftwtAnlsSn ??
|
||||
0,
|
||||
);
|
||||
|
||||
const getOverlayAplcnYm = (data: any) =>
|
||||
String(
|
||||
data?.get?.("aplcnYm") ??
|
||||
data?.values_?.aplcnYm ??
|
||||
data?.aplcnYm ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
const getOverlayAnalysisInfo = (data: any) =>
|
||||
String(
|
||||
data?.get?.("analysisInfo") ??
|
||||
data?.values_?.analysisInfo ??
|
||||
data?.analysisInfo ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
const getFirstFeatureId = (payload: any) => {
|
||||
const featureCollection = getFeatureCollection(payload);
|
||||
const firstFeature = Array.isArray(featureCollection?.features) ? featureCollection.features[0] : null;
|
||||
return String(firstFeature?.id ?? "").trim();
|
||||
};
|
||||
|
||||
export const useInftwtResultMap = ({
|
||||
gisMaps,
|
||||
setFeatureData,
|
||||
setIsOpen,
|
||||
setIsSearchModalOpen,
|
||||
}: UseInftwtResultMapParams) => {
|
||||
const activeResultRowRef = useRef<InftwtResultRow | null>(null);
|
||||
const zoneLayerRef = useRef<VectorLayer<any> | null>(null);
|
||||
const zoneSourceRef = useRef<VectorSource | null>(null);
|
||||
const pipeLayerRef = useRef<VectorLayer<any> | null>(null);
|
||||
const pipeSourceRef = useRef<VectorSource | null>(null);
|
||||
const mapViewCacheRef = useRef<Map<number, InftwtResultMapViewResponse>>(new Map());
|
||||
const [activeMapView, setActiveMapView] = useState<InftwtResultMapViewResponse | null>(null);
|
||||
|
||||
const clearLayers = useCallback(() => {
|
||||
zoneSourceRef.current?.clear?.();
|
||||
pipeSourceRef.current?.clear?.();
|
||||
zoneLayerRef.current?.changed?.();
|
||||
pipeLayerRef.current?.changed?.();
|
||||
gisMaps.current?.renderSync?.();
|
||||
}, [gisMaps]);
|
||||
|
||||
const ensureZoneLayer = useCallback(() => {
|
||||
const map = gisMaps.current;
|
||||
if (!map) return null;
|
||||
|
||||
if (zoneLayerRef.current && zoneSourceRef.current) {
|
||||
return zoneSourceRef.current;
|
||||
}
|
||||
|
||||
const source = new VectorSource();
|
||||
const layer = new VectorLayer({
|
||||
source,
|
||||
zIndex: 19991,
|
||||
});
|
||||
|
||||
layer.set("id", INFTWT_ZONE_LAYER_ID);
|
||||
layer.set("lyrNm", "침입수분석-처리구역");
|
||||
layer.set("lyrTyNm", "tb_ms_swage_prcs_zone");
|
||||
layer.set("clickPsblYn", "N");
|
||||
layer.setVisible?.(true);
|
||||
map.addLayer(layer);
|
||||
|
||||
zoneLayerRef.current = layer;
|
||||
zoneSourceRef.current = source;
|
||||
return source;
|
||||
}, [gisMaps]);
|
||||
|
||||
const ensurePipeLayer = useCallback(() => {
|
||||
const map = gisMaps.current;
|
||||
if (!map) return null;
|
||||
|
||||
if (pipeLayerRef.current && pipeSourceRef.current) {
|
||||
return pipeSourceRef.current;
|
||||
}
|
||||
|
||||
const source = new VectorSource();
|
||||
const layer = new VectorLayer({
|
||||
source,
|
||||
zIndex: 19997,
|
||||
});
|
||||
|
||||
layer.set("id", INFTWT_PIPE_LAYER_ID);
|
||||
layer.set("lyrNm", "침입수분석-결과관로");
|
||||
layer.set("lyrTyNm", "tb_ms_swage_ppl");
|
||||
layer.set("clickPsblYn", "Y");
|
||||
layer.setVisible?.(true);
|
||||
map.addLayer(layer);
|
||||
|
||||
pipeLayerRef.current = layer;
|
||||
pipeSourceRef.current = source;
|
||||
return source;
|
||||
}, [gisMaps]);
|
||||
|
||||
const fitToRenderedFeatures = useCallback(
|
||||
(mapView?: InftwtResultMapViewResponse | null) => {
|
||||
const map = gisMaps.current;
|
||||
const zoneExtent = zoneSourceRef.current?.getExtent?.();
|
||||
const pipeExtent = pipeSourceRef.current?.getExtent?.();
|
||||
const pipeFeatures = pipeSourceRef.current?.getFeatures?.() ?? [];
|
||||
const hasPipeFeatures = pipeFeatures.length > 0;
|
||||
const hasZoneFeatures = Boolean(zoneSourceRef.current?.getFeatures?.().length);
|
||||
const preferZone = mapView?.focusTargetType === "ZONE" && hasZoneFeatures;
|
||||
const zoneExtentIsFinite = Array.isArray(zoneExtent) && zoneExtent.every((value: number) => Number.isFinite(value));
|
||||
const pipeExtentIsFinite = Array.isArray(pipeExtent) && pipeExtent.every((value: number) => Number.isFinite(value));
|
||||
const hasPipeInsideZone =
|
||||
preferZone && zoneExtentIsFinite
|
||||
? pipeFeatures.some((feature: any) => {
|
||||
const geometryExtent = feature?.getGeometry?.()?.getExtent?.();
|
||||
return Array.isArray(geometryExtent)
|
||||
&& geometryExtent.every((value: number) => Number.isFinite(value))
|
||||
&& intersectsExtent(zoneExtent as [number, number, number, number], geometryExtent as [number, number, number, number]);
|
||||
})
|
||||
: false;
|
||||
|
||||
const shouldUseZoneExtent = preferZone && (hasPipeInsideZone || !hasPipeFeatures);
|
||||
const extent = shouldUseZoneExtent
|
||||
? zoneExtent
|
||||
: hasPipeFeatures
|
||||
? pipeExtent
|
||||
: hasZoneFeatures
|
||||
? zoneExtent
|
||||
: null;
|
||||
|
||||
if (!map || !extent || extent.some((value: number) => !Number.isFinite(value))) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.getView?.().fit?.(extent, {
|
||||
duration: 350,
|
||||
padding: DEFAULT_RESULT_FIT_PADDING,
|
||||
maxZoom: shouldUseZoneExtent ? 15 : 16,
|
||||
constrainResolution: true,
|
||||
nearest: true,
|
||||
});
|
||||
},
|
||||
[gisMaps],
|
||||
);
|
||||
|
||||
const moveByRegionCodes = useCallback(
|
||||
(ctpvCd?: string, sggCd?: string) => {
|
||||
const normalizedSggCd = String(sggCd ?? "").trim();
|
||||
if (normalizedSggCd && normalizedSggCd !== "0") {
|
||||
const sigunguCenter = getSigunguCenter(normalizedSggCd);
|
||||
if (sigunguCenter) {
|
||||
moveMapToCenter(gisMaps, sigunguCenter, 12);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedCtpvCd = String(ctpvCd ?? "").trim();
|
||||
if (normalizedCtpvCd && normalizedCtpvCd !== "0") {
|
||||
const sidoCenterCoordinate = sidoCenter[normalizedCtpvCd.slice(0, 2)];
|
||||
if (sidoCenterCoordinate) {
|
||||
moveMapToCenter(gisMaps, sidoCenterCoordinate, 11);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[gisMaps],
|
||||
);
|
||||
|
||||
const moveToStableFocus = useCallback(
|
||||
async (mapView: InftwtResultMapViewResponse) => {
|
||||
const activeRow = activeResultRowRef.current;
|
||||
const movedByRegion = moveByRegionCodes(
|
||||
activeRow?.ctpvCd ?? mapView.analysis?.ctpvCd,
|
||||
activeRow?.sggCd ?? mapView.analysis?.sggCd,
|
||||
);
|
||||
if (movedByRegion) return;
|
||||
|
||||
fitToRenderedFeatures(mapView);
|
||||
},
|
||||
[fitToRenderedFeatures, moveByRegionCodes],
|
||||
);
|
||||
|
||||
const renderMapView = useCallback(
|
||||
async (mapView: InftwtResultMapViewResponse) => {
|
||||
const zoneSource = ensureZoneLayer();
|
||||
const pipeSource = ensurePipeLayer();
|
||||
if (!zoneSource || !pipeSource) return;
|
||||
|
||||
clearLayers();
|
||||
|
||||
const zoneDataProjection = resolveFeatureCollectionProjection(mapView.zoneGeometry);
|
||||
const zoneFeatures = new GeoJSON().readFeatures(getFeatureCollection(mapView.zoneGeometry), {
|
||||
dataProjection: zoneDataProjection,
|
||||
featureProjection: GisApp.TO_PROJ,
|
||||
});
|
||||
zoneFeatures.forEach((feature: any) => feature.setStyle(zoneStyle));
|
||||
zoneSource.addFeatures(zoneFeatures);
|
||||
zoneSource.changed?.();
|
||||
zoneLayerRef.current?.setVisible?.(true);
|
||||
zoneLayerRef.current?.changed?.();
|
||||
|
||||
const pipeDataProjection = resolveFeatureCollectionProjection(mapView.pipeGeometry);
|
||||
const pipeFeatures = new GeoJSON().readFeatures(getFeatureCollection(mapView.pipeGeometry), {
|
||||
dataProjection: pipeDataProjection,
|
||||
featureProjection: GisApp.TO_PROJ,
|
||||
});
|
||||
pipeFeatures.forEach((feature: any) => {
|
||||
const hasOilq = Boolean(feature.get?.("hasOilq"));
|
||||
feature.setStyle(buildPipeStyle(hasOilq));
|
||||
});
|
||||
pipeSource.addFeatures(pipeFeatures);
|
||||
pipeSource.changed?.();
|
||||
pipeLayerRef.current?.setVisible?.(true);
|
||||
pipeLayerRef.current?.changed?.();
|
||||
gisMaps.current?.renderSync?.();
|
||||
|
||||
console.info("침입수 FE overlay 렌더 결과", {
|
||||
focusTargetType: mapView.focusTargetType,
|
||||
zoneFeatureCount: zoneFeatures.length,
|
||||
pipeFeatureCount: pipeFeatures.length,
|
||||
});
|
||||
|
||||
await moveToStableFocus(mapView);
|
||||
},
|
||||
[clearLayers, ensurePipeLayer, ensureZoneLayer, gisMaps, moveToStableFocus],
|
||||
);
|
||||
|
||||
const handleRowSelect = useCallback(
|
||||
async (row: InftwtResultRow) => {
|
||||
try {
|
||||
activeResultRowRef.current = row;
|
||||
setFeatureData(null);
|
||||
setIsOpen(false);
|
||||
setIsSearchModalOpen(false);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(INFTWT_PROC_SEARCH_EVENT, {
|
||||
detail: {
|
||||
ctpvCd: row.ctpvCd ?? "0",
|
||||
sggCd: row.sggCd ?? "0",
|
||||
swtrmtFcltNm: row.swtrmtFcltNm ?? "",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const cachedMapView = mapViewCacheRef.current.get(row.inftwtAnlsSn);
|
||||
if (cachedMapView) {
|
||||
setActiveMapView(cachedMapView);
|
||||
void renderMapView(cachedMapView);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await getRawJson(`/sewerage-service/api/v1/inftwtAnlsRslt/mapView/${row.inftwtAnlsSn}`);
|
||||
const mapView = (payload?.data ?? payload) as InftwtResultMapViewResponse;
|
||||
mapViewCacheRef.current.set(row.inftwtAnlsSn, mapView);
|
||||
setActiveMapView(mapView);
|
||||
void renderMapView(mapView);
|
||||
} catch (error) {
|
||||
console.error("침입수 분석결과 지도 연계 중 오류:", error);
|
||||
alert("침입수 분석결과를 지도에 연계하지 못했습니다.");
|
||||
}
|
||||
},
|
||||
[renderMapView, setFeatureData, setIsOpen, setIsSearchModalOpen],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopupMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (event.data?.type !== INFTWT_RESULT_ROW_SELECT_MESSAGE || !event.data?.row) return;
|
||||
void handleRowSelect(event.data.row as InftwtResultRow);
|
||||
};
|
||||
|
||||
window.addEventListener("message", handlePopupMessage);
|
||||
return () => window.removeEventListener("message", handlePopupMessage);
|
||||
}, [handleRowSelect]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearLayers();
|
||||
};
|
||||
}, [clearLayers]);
|
||||
|
||||
const handleOverlayFeatureSelect = useCallback((data: any) => {
|
||||
if (getOverlayFlag(data) !== INFTWT_RESULT_OVERLAY_TYPE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const overlayAnalysisSn = getOverlayAnalysisSn(data);
|
||||
const basePplMngNo = getOverlayBasePplMngNo(data);
|
||||
const analysisPplMngNo = getOverlayAnalysisPplMngNo(data);
|
||||
const currentRow = activeResultRowRef.current;
|
||||
const popupWindow = openWindowPopup("infilChart", {
|
||||
pplMngNo: basePplMngNo || analysisPplMngNo.replace(/^L/, ""),
|
||||
inftwtAnlsSn: overlayAnalysisSn || currentRow?.inftwtAnlsSn || 0,
|
||||
aplcnYm: getOverlayAplcnYm(data) || currentRow?.aplcnYm || activeMapView?.analysis?.aplcnYm || "",
|
||||
analysisInfo:
|
||||
getOverlayAnalysisInfo(data) ||
|
||||
currentRow?.anlsInfo ||
|
||||
activeMapView?.analysis?.anlsInfo ||
|
||||
"",
|
||||
resultOnly: "Y",
|
||||
});
|
||||
|
||||
if (!popupWindow) {
|
||||
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [activeMapView]);
|
||||
|
||||
return {
|
||||
activeMapView,
|
||||
clearLayers,
|
||||
handleOverlayFeatureSelect,
|
||||
};
|
||||
};
|
||||
@@ -11,11 +11,15 @@ import {
|
||||
import { SelectBox } from '@common/business_components/ui';
|
||||
import { Button, Input, Label } from '@common/components';
|
||||
|
||||
import type { TInftwtAnls } from './aplcnYms';
|
||||
import type { TInftwtAnls, TInftwtAnlsRslt, TOilqData } from './aplcnYms';
|
||||
import * as fn from './aplcnYms';
|
||||
|
||||
type ModalOpenProp = {
|
||||
pplMngNo?: string; // 관로관리번호
|
||||
pplMngNo?: string;
|
||||
inftwtAnlsSn?: number;
|
||||
aplcnYm?: string;
|
||||
analysisInfo?: string;
|
||||
resultOnly?: boolean;
|
||||
};
|
||||
|
||||
type DataType = {
|
||||
@@ -25,8 +29,13 @@ type DataType = {
|
||||
|
||||
const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: '선택' }];
|
||||
|
||||
const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
|
||||
|
||||
const InfilChart = ({
|
||||
pplMngNo = '',
|
||||
inftwtAnlsSn = 0,
|
||||
aplcnYm = '',
|
||||
analysisInfo = '',
|
||||
resultOnly = false,
|
||||
}: ModalOpenProp) => {
|
||||
const [selectedAplcnYm, setSelectedAplcnYm] = useState('');
|
||||
const [inftwtAnlss, setInftwtAnlss] = useState<TInftwtAnls[]>([]);
|
||||
const [inftwtAnlsInfos, setInftwtAnlsInfos] = useState<{ text: string; val: number; hidden?: boolean }[]>(
|
||||
@@ -42,6 +51,7 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
|
||||
|
||||
const aplcnYms = fn.useAplcnYms('inftwtAnlsRslt');
|
||||
const normalizedPplMngNo = pplMngNo ? `L${pplMngNo}` : '';
|
||||
const isResultOnlyMode = resultOnly && inftwtAnlsSn > 0 && Boolean(normalizedPplMngNo);
|
||||
|
||||
const chartForm = CHART_FORM.LINE;
|
||||
const chartConfig = {
|
||||
@@ -53,6 +63,28 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
|
||||
return chartData.chartData1.length > 0 || chartData.chartData2.length > 0;
|
||||
}, [chartData]);
|
||||
|
||||
|
||||
const calculateFallbackInftwtQty = (
|
||||
inftwtAnlsRslt: TInftwtAnlsRslt[],
|
||||
oilqRows: TOilqData[],
|
||||
) => {
|
||||
const predcValues = inftwtAnlsRslt
|
||||
.map((item) => Number(item.predcOilq))
|
||||
.filter((value) => Number.isFinite(value));
|
||||
const oilqValues = oilqRows
|
||||
.map((item) => Number(item.oilq))
|
||||
.filter((value) => Number.isFinite(value));
|
||||
|
||||
if (predcValues.length === 0 || oilqValues.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const avgPredc = predcValues.reduce((sum, value) => sum + value, 0) / predcValues.length;
|
||||
const avgOilq = oilqValues.reduce((sum, value) => sum + value, 0) / oilqValues.length;
|
||||
const dailyQty = (avgOilq - avgPredc) * 24;
|
||||
return Math.round(dailyQty / 10) * 10;
|
||||
};
|
||||
|
||||
const resetState = () => {
|
||||
setSelectedAplcnYm('');
|
||||
setInftwtAnlss([]);
|
||||
@@ -63,6 +95,63 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
|
||||
setInftwtQty('0');
|
||||
};
|
||||
|
||||
const loadResultChart = async (targetInftwtAnlsSn: number) => {
|
||||
if (!targetInftwtAnlsSn || !normalizedPplMngNo) {
|
||||
setChartData({ chartData1: [], chartData2: [] });
|
||||
setInftwtQty('0');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [inftwtAnlsRslt, oilq, qty] = await Promise.all([
|
||||
fn.selectInftwtAnlsRslt(targetInftwtAnlsSn, normalizedPplMngNo),
|
||||
fn.selectOilq(targetInftwtAnlsSn, normalizedPplMngNo).catch((err) => {
|
||||
console.error('selectOilq 에러:', err);
|
||||
return [];
|
||||
}),
|
||||
fn.selectPplInftwtQty(targetInftwtAnlsSn, normalizedPplMngNo).catch((err) => {
|
||||
console.error('selectPplInftwtQty 에러:', err);
|
||||
return 0;
|
||||
}),
|
||||
]);
|
||||
|
||||
setChartData({
|
||||
chartData1: fn.getChartDataInftwtAnlsRslt(inftwtAnlsRslt ?? []),
|
||||
chartData2: fn.getChartDataOilq(oilq ?? []),
|
||||
});
|
||||
|
||||
const fallbackQty = calculateFallbackInftwtQty(inftwtAnlsRslt ?? [], oilq ?? []);
|
||||
const normalizedQty = Number(qty ?? Number.NaN);
|
||||
const resolvedQty = Number.isFinite(normalizedQty) && !(normalizedQty === 0 && fallbackQty !== 0)
|
||||
? normalizedQty
|
||||
: fallbackQty;
|
||||
|
||||
if (!Number.isFinite(normalizedQty) || (normalizedQty === 0 && fallbackQty !== 0)) {
|
||||
console.warn('Inftwt qty API returned empty or zero; using chart-data fallback qty.', {
|
||||
apiQty: qty,
|
||||
fallbackQty,
|
||||
resultCount: inftwtAnlsRslt?.length ?? 0,
|
||||
oilqCount: oilq?.length ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
setInftwtQty(resolvedQty.toLocaleString('ko-KR'));
|
||||
} catch (error) {
|
||||
console.error('침입수 분석결과 차트 조회 중 오류:', error);
|
||||
setChartData({ chartData1: [], chartData2: [] });
|
||||
setInftwtQty('0');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResultOnlyMode) return;
|
||||
|
||||
setSelectedAplcnYm(aplcnYm);
|
||||
setSelectedInftwtAnls(inftwtAnlsSn);
|
||||
setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
|
||||
void loadResultChart(inftwtAnlsSn);
|
||||
}, [aplcnYm, inftwtAnlsSn, isResultOnlyMode, normalizedPplMngNo]);
|
||||
|
||||
const handleAplcnYmChange = async (val: string | number) => {
|
||||
const selectedValue = String(val);
|
||||
|
||||
@@ -113,31 +202,7 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [inftwtAnlsRslt, oilq, qty] = await Promise.all([
|
||||
fn.selectInftwtAnlsRslt(selectedInftwtAnls, normalizedPplMngNo),
|
||||
fn.selectOilq(selectedInftwtAnls, normalizedPplMngNo).catch((err) => {
|
||||
console.error('selectOilq 에러:', err);
|
||||
return [];
|
||||
}),
|
||||
fn.selectPplInftwtQty(selectedInftwtAnls, normalizedPplMngNo).catch((err) => {
|
||||
console.error('selectPplInftwtQty 에러:', err);
|
||||
return 0;
|
||||
}),
|
||||
]);
|
||||
|
||||
setChartData({
|
||||
chartData1: fn.getChartDataInftwtAnlsRslt(inftwtAnlsRslt ?? []),
|
||||
chartData2: fn.getChartDataOilq(oilq ?? []),
|
||||
});
|
||||
|
||||
const normalizedQty = Number(qty ?? 0);
|
||||
setInftwtQty(normalizedQty.toLocaleString('ko-KR'));
|
||||
} catch (error) {
|
||||
console.error('infilRsltChart 에러:', error);
|
||||
setChartData({ chartData1: [], chartData2: [] });
|
||||
setInftwtQty('0');
|
||||
}
|
||||
await loadResultChart(selectedInftwtAnls);
|
||||
};
|
||||
|
||||
const renderInfoInput = (label: string, value?: string | number | null) => (
|
||||
@@ -154,14 +219,56 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
|
||||
|
||||
const handleReset = () => {
|
||||
resetState();
|
||||
}
|
||||
};
|
||||
|
||||
const renderBody = () => (
|
||||
<div className="text-black">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="text-[18px] font-semibold text-[#111827]" />
|
||||
const renderResultPanel = () => (
|
||||
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-4">
|
||||
<h3 className="mb-4 text-[20px] font-extrabold tracking-[-0.02em] text-black">침입수 분석 결과</h3>
|
||||
<div className="mb-4 grid grid-cols-2 gap-4">
|
||||
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
|
||||
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
|
||||
<span>상수사용량 적용연월</span>
|
||||
</div>
|
||||
<div className="text-[20px] font-extrabold text-[#222]">
|
||||
{selectedAplcnYm ? moment(selectedAplcnYm, 'YYYYMM').format('YYYY-MM') : '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
|
||||
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
|
||||
<span>침입수량 산정결과</span>
|
||||
</div>
|
||||
<div className="flex items-end gap-3 whitespace-nowrap text-[#222]">
|
||||
<span className="text-[16px] text-[#666]">침입수량</span>
|
||||
<div className="flex items-end justify-start gap-2 whitespace-nowrap">
|
||||
<span className="text-[20px] font-extrabold">{inftwtQty}</span>
|
||||
<span className="pb-[2px] text-[16px]">m³/d</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[10px] bg-white px-2 pb-2 pt-3">
|
||||
{hasChartData ? (
|
||||
<MainChart
|
||||
chartConfig={chartConfig}
|
||||
chartForm={chartForm}
|
||||
chartData={chartData}
|
||||
chartTitle={'침입수량 그래프'}
|
||||
yFieldText="유량(m³/h)"
|
||||
yFieldText2=""
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-[464px] items-center justify-center text-[18px] text-[#2c2f33]">
|
||||
데이터가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
const renderDefaultBody = () => (
|
||||
<div className="text-black">
|
||||
<div className="grid min-h-[68vh] grid-cols-[minmax(0,38%)_minmax(0,62%)] gap-5">
|
||||
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-5">
|
||||
<h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black">
|
||||
@@ -216,60 +323,14 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-5">
|
||||
<h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black">침입수 분석 결과</h3>
|
||||
|
||||
<div className="mb-6 grid grid-cols-2 gap-5">
|
||||
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
|
||||
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
|
||||
{/* <span className="text-[14px] leading-none">●</span> */}
|
||||
<span>상수사용량 적용연월</span>
|
||||
</div>
|
||||
<div className="text-[20px] font-extrabold text-[#222]">
|
||||
{selectedAplcnYm ? moment(selectedAplcnYm, 'YYYYMM').format('YYYY-MM') : '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
|
||||
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
|
||||
{/* <span className="text-[14px] leading-none">●</span> */}
|
||||
<span>침입수량 산정결과</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-7 text-[#222]">
|
||||
<span className="text-[16px] text-[#666]">침입수량</span>
|
||||
<div className='w-[66px] flex items-end justify-start gap-2'>
|
||||
<span className="text-[20px] font-extrabold">{inftwtQty}</span>
|
||||
<span className="pb-[2px] text-[16px]">m³/d</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[10px] bg-white px-2 pb-2 pt-3">
|
||||
{hasChartData ? (
|
||||
<MainChart
|
||||
chartConfig={chartConfig}
|
||||
chartForm={chartForm}
|
||||
chartData={chartData}
|
||||
chartTitle={'침입수량 그래프'}
|
||||
yFieldText="유량(m³/h)"
|
||||
yFieldText2=""
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-[464px] items-center justify-center text-[18px] text-[#2c2f33]">
|
||||
데이터가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{renderResultPanel()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-white p-4">
|
||||
<h1 className="mb-3 text-2xl font-semibold text-gray-900">침입수 분석</h1>
|
||||
{renderBody()}
|
||||
<div className="min-h-dvh bg-white p-3">
|
||||
{isResultOnlyMode ? renderResultPanel() : renderDefaultBody()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -78,6 +78,7 @@ import {
|
||||
validateElapsedYears,
|
||||
} from "./components/agedPipe/agedPipeUtils";
|
||||
import { downloadAgedPipeCsv } from "./components/agedPipe/agedPipeExport";
|
||||
import { useInftwtResultMap } from "./components/inftwtResult/useInftwtResultMap";
|
||||
import SinkholeDetailPanel from "./components/sinkhole/SinkholeDetailPanel";
|
||||
import SinkholeLayerSettingPanel from "./components/sinkhole/SinkholeLayerSettingPanel";
|
||||
import FloodHistoryDetailPanel from "./components/floodHistory/FloodHistoryDetailPanel";
|
||||
@@ -2451,6 +2452,13 @@ const Map = () => {
|
||||
const { ctpv, sgg } = useStore();
|
||||
// GIS 편집은 map/page.tsx 내부 기존 지도 상태를 재사용하되, 세부 상태와 payload 조립은 별도 hook으로 분리한다.
|
||||
const gisEdit = useGisEdit(treeData);
|
||||
// 침입수 분석결과 popup과 지도 highlight 흐름은 별도 hook으로 묶어 기존 지도 로직과 충돌을 줄인다.
|
||||
const inftwtResultMap = useInftwtResultMap({
|
||||
gisMaps,
|
||||
setFeatureData,
|
||||
setIsOpen,
|
||||
setIsSearchModalOpen,
|
||||
});
|
||||
const shouldFocusAgedPipeOnNextSelectRef = useRef(false);
|
||||
const agedPipePopupWindowRef = useRef<Window | null>(null);
|
||||
const sinkholePopupWindowRef = useRef<Window | null>(null);
|
||||
@@ -3574,8 +3582,15 @@ const Map = () => {
|
||||
// SearchModal, GisContent 등에서 호출
|
||||
const onFeatureSelect = (data: any) => {
|
||||
const clickedLayerTypeName = getSelectedFeatureLayerTypeName(data);
|
||||
// 지도에서 새 피처를 직접 선택하면 지반침하 반경 표에서 만든 임시 관로 포커스는 해제한다.
|
||||
// 지형에 있는 피처를 직접 선택하면 지반침하 반경 내에서 만든 임시 관로 강조는 해제한다.
|
||||
clearSinkholeFocusedPipeLayer();
|
||||
if (inftwtResultMap.handleOverlayFeatureSelect(data)) {
|
||||
setFeatureData(null);
|
||||
setIsOpen(false);
|
||||
setIsSearchModalOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
gisEdit.isEditMode &&
|
||||
(gisEdit.activeTool === "MOVE" || gisEdit.activeTool === "VERTEX_EDIT") &&
|
||||
@@ -4084,11 +4099,13 @@ const Map = () => {
|
||||
const layerTypeName = getSelectedFeatureLayerTypeName(data);
|
||||
if (layerTypeName === SINKHOLE_LAYER_TYPE) {
|
||||
clearSinkholeFocusedPipeLayer();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data || typeof data.getGeometry !== "function") {
|
||||
clearSinkholeFocusedPipeLayer();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4108,6 +4125,7 @@ const Map = () => {
|
||||
}
|
||||
|
||||
clearSinkholeFocusedPipeLayer();
|
||||
|
||||
select?.selectFeature?.(data, true);
|
||||
},
|
||||
[clearSinkholeFocusedPipeLayer, ensureSinkholeFocusedPipeLayer],
|
||||
@@ -4285,6 +4303,7 @@ const Map = () => {
|
||||
setSelectedSinkholeNearbyPipes([]);
|
||||
clearSinkholeSelectionLayer();
|
||||
clearSinkholeFocusedPipeLayer();
|
||||
|
||||
restoreSinkholeDetailOnCloseRef.current = false;
|
||||
if (getSelectedFeatureLayerTypeName(featureData) === SINKHOLE_LAYER_TYPE) {
|
||||
setFeatureData(null);
|
||||
@@ -4644,6 +4663,7 @@ const Map = () => {
|
||||
const select = GisApp.Module.select as any;
|
||||
select?.resetSelectedFeature?.();
|
||||
clearSinkholeFocusedPipeLayer();
|
||||
|
||||
}
|
||||
if (closingTab?.kind === "gisEdit") {
|
||||
setGisEditDetailChangeId(null);
|
||||
|
||||
@@ -51,6 +51,7 @@ type BaseLayout = {
|
||||
analysisBtn?: string
|
||||
firstDivBtn?: boolean
|
||||
selectBox?: SelectBox
|
||||
selectionComponent?: ReactNode
|
||||
showDatePicker?: boolean;
|
||||
ctpvCd: string;
|
||||
sggCd: string;
|
||||
@@ -83,6 +84,7 @@ const BaseLayout = ({
|
||||
graphBtn = false,
|
||||
analysisBtn,
|
||||
selectBox,
|
||||
selectionComponent,
|
||||
showDatePicker = false,
|
||||
ctpvCd,
|
||||
sggCd,
|
||||
@@ -191,21 +193,24 @@ const BaseLayout = ({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
sido={ctpvCd}
|
||||
sigungu={sggCd}
|
||||
sggName={sggName}
|
||||
onSidoChange={(sidoId: string) => {
|
||||
setCtpvCd(sidoId);
|
||||
setSggCd('0');
|
||||
}}
|
||||
onSigunguChange={setSggCd}
|
||||
setSggName={setSggName}
|
||||
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
|
||||
onSwagePrcsFcltChange={onSwagePrcsFcltChange}
|
||||
setSwtrmtFcltNm={setSwtrmtFcltNm}
|
||||
setIsLoading={setIsLoading}
|
||||
/>
|
||||
{/* 침입수 분석 실행처럼 선택 구조가 다른 화면은 커스텀 selector를 주입하고, 나머지는 기존 시설 selector를 유지한다. */}
|
||||
{selectionComponent ?? (
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
sido={ctpvCd}
|
||||
sigungu={sggCd}
|
||||
sggName={sggName}
|
||||
onSidoChange={(sidoId: string) => {
|
||||
setCtpvCd(sidoId);
|
||||
setSggCd('0');
|
||||
}}
|
||||
onSigunguChange={setSggCd}
|
||||
setSggName={setSggName}
|
||||
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
|
||||
onSwagePrcsFcltChange={onSwagePrcsFcltChange}
|
||||
setSwtrmtFcltNm={setSwtrmtFcltNm}
|
||||
setIsLoading={setIsLoading}
|
||||
/>
|
||||
)}
|
||||
{selectBox?.searchChk && (
|
||||
<div className='flex flex-1 justify-end mr-6'>
|
||||
<Button size="sm" onClick={() => selectBox.onSearchClick?.()}>조회</Button>
|
||||
|
||||
@@ -62,7 +62,7 @@ const initialFileInfo: UploadedFileInfo = { file: null, atflCd: null, atflId: nu
|
||||
const IrSetting = () => {
|
||||
const [input1, setInput1] = useState('0.8'); // 오수전환율 초기값
|
||||
const [input2, setInput2] = useState('');
|
||||
const [select1, setSelect1] = useState<string>('0'); // 하수처리장코드
|
||||
const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState<string>('0'); // ?????? ?? ??
|
||||
const [formResetKey, setFormResetKey] = useState(0); // FileUploadForm 리셋을 위한 key 상태
|
||||
const [isLoadingResult, setIsLoadingResult] = useState<boolean>(false); // 로딩 상태
|
||||
const [ctpvCd, setCtpvCd] = useState<string>('0'); // 시도 코드 상태
|
||||
@@ -126,7 +126,7 @@ const IrSetting = () => {
|
||||
setSelectedDate(`${year}${month}`);
|
||||
setInput1(''); // 입력 필드 초기화
|
||||
setInput2(''); // 입력 필드 초기화
|
||||
setSelect1('0');
|
||||
setSelectedSwagePrcsFclt('0');
|
||||
setSwtrmtFcltNm('');
|
||||
setFormResetKey(prevKey => prevKey + 1); // FileUploadForm 재마운트를 위해 key 변경
|
||||
};
|
||||
@@ -168,11 +168,12 @@ const IrSetting = () => {
|
||||
setIsLoadingResult(true); // 로딩 상태 시작
|
||||
|
||||
// 기본 선택 항목들 검증 (환경청, 시도, 시군구, 하수처리장)
|
||||
if (!validateBasicSelectionsWithAlert(select1, {
|
||||
if (!validateBasicSelectionsWithAlert(selectedSwagePrcsFclt, {
|
||||
requireEnv: true,
|
||||
requireSido: true,
|
||||
requireSigungu: true,
|
||||
requireTreatmentPlant: true
|
||||
requireTreatmentPlant: true,
|
||||
treatmentPlantLabel: '하수처리시설'
|
||||
})) {
|
||||
setIsLoadingResult(false);
|
||||
return;
|
||||
@@ -302,8 +303,8 @@ const IrSetting = () => {
|
||||
);
|
||||
|
||||
// 각 파싱 결과 접근
|
||||
const matchingResultData = parsedResults.find(r => r && r.fileName === file2.file?.name)?.parsedData;
|
||||
const timePatternData = parsedResults.find(r => r && r.fileName === file3.file?.name)?.parsedData;
|
||||
const matchingResultData = parsedResults.find(r => r && r.fileName === file2.file?.name)?.parsedData ?? [];
|
||||
const timePatternData = parsedResults.find(r => r && r.fileName === file3.file?.name)?.parsedData ?? [];
|
||||
const flowRateData = file4?.file
|
||||
? parsedResults.find(r => r && r.fileName === file4.file?.name)?.parsedData
|
||||
: null;
|
||||
@@ -311,6 +312,23 @@ const IrSetting = () => {
|
||||
? parsedResults.find(r => r && r.fileName === file5.file?.name)?.parsedData
|
||||
: null;
|
||||
|
||||
if (!swtrmtFcltNm || !swtrmtFcltNm.trim()) {
|
||||
setIsLoadingResult(false);
|
||||
alert('하수처리시설명이 비어 있습니다. 시설을 다시 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(matchingResultData) || matchingResultData.length === 0) {
|
||||
setIsLoadingResult(false);
|
||||
alert('수용가-맨홀 매칭결과 파일에서 저장할 데이터를 읽지 못했습니다. 엑셀 헤더를 확인해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(timePatternData) || timePatternData.length === 0) {
|
||||
setIsLoadingResult(false);
|
||||
alert('오수량 시간패턴 파일에서 저장할 데이터를 읽지 못했습니다. 엑셀 헤더를 확인해주세요.');
|
||||
return;
|
||||
}
|
||||
// 새로운 JSON 데이터 생성
|
||||
const jsonData = {
|
||||
ctpvCd: ctpvCd !== '0' ? String(ctpvCd).trim() : null,
|
||||
@@ -364,8 +382,8 @@ const IrSetting = () => {
|
||||
ctpvCd={ctpvCd}
|
||||
sggCd={sggCd}
|
||||
sggName={sggName}
|
||||
selectedSwagePrcsFclt={select1}
|
||||
onSwagePrcsFcltChange={setSelect1}
|
||||
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
|
||||
onSwagePrcsFcltChange={setSelectedSwagePrcsFclt}
|
||||
setCtpvCd={setCtpvCd}
|
||||
setSggCd={setSggCd}
|
||||
setSggName={setSggName}
|
||||
@@ -494,4 +512,4 @@ const IrSetting = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export default IrSetting
|
||||
export default IrSetting
|
||||
|
||||
@@ -100,19 +100,21 @@ export const validateBasicSelections = (
|
||||
requireSido?: boolean;
|
||||
requireSigungu?: boolean;
|
||||
requireTreatmentPlant?: boolean;
|
||||
treatmentPlantLabel?: string;
|
||||
} = {}
|
||||
): SelectionValidationResult => {
|
||||
const {
|
||||
requireEnv = true,
|
||||
requireSido = true,
|
||||
requireSigungu = true,
|
||||
requireTreatmentPlant = true
|
||||
requireTreatmentPlant = true,
|
||||
treatmentPlantLabel = '하수처리장'
|
||||
} = options;
|
||||
|
||||
const validationConfigs: SelectionValidationConfig[] = [
|
||||
{
|
||||
value: select1,
|
||||
description: '하수처리장',
|
||||
description: treatmentPlantLabel,
|
||||
required: requireTreatmentPlant
|
||||
}
|
||||
];
|
||||
@@ -133,6 +135,7 @@ export const validateBasicSelectionsWithAlert = (
|
||||
requireSido?: boolean;
|
||||
requireSigungu?: boolean;
|
||||
requireTreatmentPlant?: boolean;
|
||||
treatmentPlantLabel?: string;
|
||||
} = {}
|
||||
): boolean => {
|
||||
const result = validateBasicSelections(select1, options);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import axios from 'axios';
|
||||
import { AxiosError } from 'axios';
|
||||
import { CLAIM_NAME, SITE_ID } from "@/shared/config";
|
||||
import { tokenManager } from "@/entities/auth/lib/token-manager";
|
||||
|
||||
@@ -212,29 +213,34 @@ export const postBlobData = async (
|
||||
};
|
||||
|
||||
export const postMutiPart = async (url: string, data: FormData): Promise<any> => {
|
||||
const res = await axios.post(`${BASE_URL}/${url}`, data, {
|
||||
headers: DEFAULT_HEADERS,
|
||||
timeout: 600000, // 10분
|
||||
});
|
||||
console.log('postMultiPart response:', res);
|
||||
return res.data.data;
|
||||
};
|
||||
try {
|
||||
const res = await axios.post(`${BASE_URL}/${url}`, data, {
|
||||
headers: DEFAULT_HEADERS,
|
||||
timeout: 600000, // 10분
|
||||
});
|
||||
console.log('postMultiPart response:', res);
|
||||
return res.data.data;
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
const responseBody = error.response?.data;
|
||||
const serverMessage =
|
||||
typeof responseBody?.message === 'string' && responseBody.message.trim()
|
||||
? responseBody.message.trim()
|
||||
: typeof responseBody?.error?.message === 'string' && responseBody.error.message.trim()
|
||||
? responseBody.error.message.trim()
|
||||
: typeof responseBody === 'string' && responseBody.trim()
|
||||
? responseBody.trim()
|
||||
: null;
|
||||
|
||||
export const putData = async (url: string, data: object): Promise<any> => {
|
||||
const options: RequestInit = {
|
||||
method: "PUT",
|
||||
headers: withSiteIdHeaders({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify(data),
|
||||
};
|
||||
const res = await fetch(`${BASE_URL}/${url}`, options);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorizedResponse();
|
||||
const nextError = new Error(
|
||||
serverMessage ?? `Multipart request failed: ${error.response?.status ?? 'UNKNOWN'} ${error.response?.statusText ?? ''}`.trim(),
|
||||
) as Error & { status?: number; body?: unknown };
|
||||
nextError.status = error.response?.status;
|
||||
nextError.body = responseBody;
|
||||
throw nextError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const resData = await res.json();
|
||||
console.log('putData response:', resData);
|
||||
return resData.data;
|
||||
};
|
||||
|
||||
export const deleteData = async (url: string, data?: object): Promise<any> => {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# SN 침입수분석결과 지도연계 유지보수 작업카드
|
||||
|
||||
## 1. 작업 개요
|
||||
|
||||
- 작업 ID: `SN-INFTWT-RESULT-FE`
|
||||
- 작업명: 침입수분석결과 지도형 popup 및 하이라이트 연계
|
||||
- 상태: 검증
|
||||
- 작성일: 2026-06-30
|
||||
- 담당 역할: FE
|
||||
- 관련 역할: DE, BE, QA
|
||||
- 담당 범위: `apps/sn` 하수관망도 `분석도구`, popup registry, 지도 하이라이트, 결과 전용 popup 모드
|
||||
- 근거 문서
|
||||
- `COMMON_DOCS_ROOT/작업계획/SN-침입수분석결과-지도연계유지보수/001-de-SN-침입수분석결과-지도연계유지보수-설계.md`
|
||||
- `COMMON_DOCS_ROOT/기준문서/010-AI개발작업흐름.md`
|
||||
- `FE_APP_DOCS_ROOT/기준문서/010-용어사전.md`
|
||||
- `FE_APP_DOCS_ROOT/기준문서/013-UI레이아웃가이드.md`
|
||||
|
||||
## 1-1. 공통 동기화 확인
|
||||
|
||||
- 공통 작업현황판 확인 여부: 확인
|
||||
- 파일점유현황 확인 여부: 확인
|
||||
- 결정로그 확인 여부: 확인
|
||||
- 기존 진행 중 작업과의 관계
|
||||
- `map/page.tsx`, `AnalysisPopupMenu.tsx`, `windowPopup/**`는 최근 지도형 popup 기능과 겹친다.
|
||||
- 따라서 기존 `노후관로`, `관로분석`, `지반침하` popup 패턴을 그대로 재사용하는 방향을 우선한다.
|
||||
|
||||
## 2. 작업 목적
|
||||
|
||||
- 기존 업무형 `ii/result` 화면의 침입수 분석결과 조회를 지도형 `분석도구` popup으로 옮긴다.
|
||||
- 결과 행 선택 시 지도에서 결과 관로를 강조하고, `실측하수량` 보유 관로를 별도 색으로 구분한다.
|
||||
- 하이라이트 관로 클릭 시 기본 관로 상세가 아니라 `침입수 분석결과` 전용 popup이 열리도록 FE 흐름을 전환한다.
|
||||
|
||||
## 3. 수정 대상 파일
|
||||
|
||||
| 구분 | 파일 경로 | 수정 여부 | 메모 |
|
||||
| --- | --- | --- | --- |
|
||||
| 우측 메뉴 | `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx` | 완료 | `침입수 분석` 메뉴 추가 |
|
||||
| 지도 메인 | `FE_APP_ROOT/app/(map-layout)/map/page.tsx` | 완료 | popup 열기, 결과 overlay, 클릭 분기, popup message 처리 |
|
||||
| popup 정의 | `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts` | 완료 | `inftwtResultPopup` 정의 추가 |
|
||||
| popup 등록 | `FE_APP_ROOT/src/features/windowPopup/registry.tsx` | 완료 | 결과 popup content 연결 |
|
||||
| 기존 결과 popup | `FE_APP_ROOT/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx` | 완료 | `resultOnly` 모드 추가 |
|
||||
| 기존 API helper | `FE_APP_ROOT/app/(map-layout)/map/components/popupComponent/chart/aplcnYms.ts` | 유지 | 기존 helper 재사용, popup content에서 고정 파라미터만 추가 |
|
||||
| 기존 업무형 화면 | `FE_APP_ROOT/app/(with-base-layout)/ii/result/page.tsx` | 유지 | 현재 구현은 popup 전용으로 분리하고 기존 화면은 읽기 기준으로 유지 |
|
||||
| 신규 popup | `FE_APP_ROOT/app/(map-layout)/map/components/inftwtResult/**` | 완료 | 결과 목록 popup 화면 및 지도 연계 hook 구현 |
|
||||
|
||||
## 4. 구현 범위
|
||||
|
||||
### 4.1 포함
|
||||
|
||||
- `분석도구 > 침입수 분석` popup 진입
|
||||
- 기존 `ii/result` 조회조건과 결과 목록의 popup 재구성
|
||||
- 결과 row click -> 부모창 message 전달
|
||||
- map/page 결과 overlay state와 focus 처리
|
||||
- 일반 결과 관로 / `실측하수량` 관로 이중 스타일
|
||||
- overlay 관로 click -> `infilChart` 결과 전용 모드
|
||||
|
||||
### 4.2 제외 또는 후속
|
||||
|
||||
- 실제 분석 산정 로직 수정
|
||||
- 신규 업무형 route 추가
|
||||
- 공통 패키지(`business_components`) 확장
|
||||
- 다른 앱 공통 popup 체계 변경
|
||||
|
||||
## 5. 화면 흐름
|
||||
|
||||
1. 사용자가 하수관망도 우측 `분석도구`에서 `침입수 분석`을 클릭한다.
|
||||
2. 시스템이 `inftwtResultPopup`을 연다.
|
||||
3. 사용자가 기존 침입수 분석 이력 목록을 조회한다.
|
||||
4. 사용자가 특정 분석 row를 클릭한다.
|
||||
5. popup은 부모창으로 `inftwtAnlsSn`과 row 요약을 전달한다.
|
||||
6. `map/page.tsx`가 분석 결과 overlay를 그리고 focus area로 이동한다.
|
||||
7. 사용자가 강조된 관로를 클릭한다.
|
||||
8. 시스템은 `openWindowPopup("infilChart", { pplMngNo, inftwtAnlsSn, resultOnly: "Y" })`를 호출한다.
|
||||
9. popup은 좌측 조건 없이 결과 그래프와 침입수량만 보여준다.
|
||||
|
||||
## 6. FE 구현 메모
|
||||
|
||||
- popup UI는 `AgedPipePopupPage`, `PipeAnalysisPopupPage` 패턴을 우선 따른다.
|
||||
- 새 popup에서 기존 `ii/result/page.tsx` 전체를 그대로 감싸기보다:
|
||||
- 조회조건/테이블 데이터 조회
|
||||
- 다운로드 매핑
|
||||
- row 선택 로직
|
||||
- 이 3가지를 shared util 또는 hook으로 추출하는 쪽이 안전하다.
|
||||
- overlay 관로는 기존 피처 클릭과 분리하기 위해 전용 vector layer를 우선 검토한다.
|
||||
- `실측하수량` 관로 스타일은 `infilChart`의 주황 의미와 맞춘다.
|
||||
|
||||
## 7. API 연동
|
||||
|
||||
| 기능 | Method | URL | 상태 | FE 메모 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 분석이력 조회 | `GET` | `/api/v1/inftwtAnls` | 재사용 | popup 목록 조회 |
|
||||
| 결과 전체 다운로드 | `GET` | `/api/v1/inftwtAnlsRslt/ppl/{inftwtAnlsSn}` | 재사용 | 엑셀 생성 source |
|
||||
| 지도 연계 요약 | `GET` | `/api/v1/inftwtAnlsRslt/mapView/{inftwtAnlsSn}` | 신규 | focus geometry + highlight 대상 |
|
||||
| 관로 결과 그래프 | `GET` | `/api/v1/inftwtAnlsRslt/ppl/{inftwtAnlsSn}/{pplMngNo}` | 재사용 | 결과 전용 popup |
|
||||
| 실측하수량 시계열 | `GET` | `/api/v1/inftwtAnlsRslt/oilqPpl/{inftwtAnlsSn}/{pplMngNo}` | 재사용 | 결과 전용 popup |
|
||||
| 침입수량 집계 | `GET` | `/api/v1/inftwtAnlsRslt/pplInftwtQty/{inftwtAnlsSn}/{pplMngNo}` | 재사용 | 결과 전용 popup |
|
||||
|
||||
## 8. 검증 계획
|
||||
|
||||
- popup 진입/닫기
|
||||
- 기존 `ii/result`와 동일 조건 조회 결과 일치
|
||||
- row click 후 map focus 이동
|
||||
- 결과 관로 / 실측하수량 관로 스타일 구분
|
||||
- overlay click 시 기본 `FeatureClickEventPopup` 대신 결과 전용 popup 노출
|
||||
- `양평` 데이터에서 실측하수량 관로 색상 및 그래프 확인
|
||||
|
||||
## 9. 롤백 방법
|
||||
|
||||
- `AnalysisPopupMenu.tsx`의 `침입수 분석` 메뉴 제거
|
||||
- `page.tsx`의 결과 overlay state 및 message listener 제거
|
||||
- `infilChart.tsx`의 `resultOnly` 모드 제거
|
||||
- 신규 popup registry/definition 제거
|
||||
|
||||
## 10. 남은 리스크
|
||||
|
||||
- `ii/result`와 popup의 조회/다운로드 로직 공용화 범위를 잘못 잡으면 중복 수정이 생길 수 있다.
|
||||
- 결과 관로 수가 많은 경우 overlay 피처 수와 클릭 성능을 점검해야 한다.
|
||||
- 기본 하수관로 상세와 overlay click 분기 우선순위를 잘못 잡으면 사용자 체감이 어색해질 수 있다.
|
||||
|
||||
## 11. 2026-07-01 유지보수 보정
|
||||
|
||||
- row click 후 FE가 기대하는 `mapView` 계약을 `focusLayerType`, `focusFid`, `focusFallbackApplied`, `pipeCount`, `oilqPipeCount` 중심으로 재정리했다.
|
||||
- FE는 백엔드가 내려준 결과 관로 GeoJSON을 그대로 하이라이트하되, 지도 이동은 처리구역 1건의 `focus fid`를 우선 사용하고 실패 시 결과 관로 extent fallback만 수행한다.
|
||||
- 이 보정으로 FE는 더 이상 전체 시계열 결과를 별도로 들고 있지 않고, 결과 popup row 요약 + 경량 mapView 응답만 기준으로 지도 연계를 수행한다.
|
||||
- 브라우저 수동 검증 전까지 상태는 `검증`을 유지한다.
|
||||
|
||||
## 12. 2026-07-01 row click 지도 이동 보정
|
||||
|
||||
- row click 직후 FE는 더 이상 `showSearchResultsFromGeoJSON`로 처리구역/관로를 강제 선택하지 않는다.
|
||||
- `focusTargetType=ZONE`이면 처리구역 overlay extent를 우선 fit 하고, 아니면 결과 관로 extent를 fit 한다.
|
||||
- 이 보정으로 잘못된 기본 관로 상세 패널이 자동으로 열리는 현상을 막고, 지도 이동과 overlay 하이라이트를 분리한다.
|
||||
|
||||
## 13. 2026-07-01 분석 실행 화면 선택구조 유지보수
|
||||
|
||||
- `ii/irsetting`만 기존 시설 우선 selector를 그대로 쓰지 않고, 커스텀 `selectionComponent`로 처리구역 우선 selector를 주입한다.
|
||||
- 새 selector는 시도/시군구 선택 뒤 `GET /api/v1/swagePrcsZones/selectionOptions`를 호출해 `하수처리구역명` 목록을 받고, 선택 시 `하수처리시설명`을 읽기 전용으로 자동 표시한다.
|
||||
- `ii/rdii`, `ii/result`, 지도 검색 모달 등 기존 `CtpvSggPrcsFcltSelectBox` 사용처는 이번 범위에서 변경하지 않는다.
|
||||
- 저장 payload는 기존과 동일하게 `swtrmtFcltNm`만 유지해 회귀 범위를 최소화한다.
|
||||
@@ -0,0 +1,102 @@
|
||||
# SN 침입수분석결과 지도연계 유지보수 FE 통합구현기록
|
||||
|
||||
## 1. 작업 목적
|
||||
|
||||
- 하수관망도 `분석도구`에 `침입수 분석` popup을 추가하고, 기존 `ii/result` 결과 조회 흐름을 지도 중심 popup으로 옮긴다.
|
||||
- 결과 row 선택 시 지도에서 처리구역과 결과 관로를 강조하고, `실측하수량` 보유 관로는 별도 색상으로 구분한다.
|
||||
- 결과 관로 클릭 시 기존 관로 상세 대신 `침입수 분석결과` 전용 popup을 재사용한다.
|
||||
|
||||
## 2. 실제 수정 파일
|
||||
|
||||
- `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx`
|
||||
- `FE_APP_ROOT/app/(map-layout)/map/page.tsx`
|
||||
- `FE_APP_ROOT/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx`
|
||||
- `FE_APP_ROOT/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx`
|
||||
- `FE_APP_ROOT/app/(map-layout)/map/components/inftwtResult/inftwtResultTypes.ts`
|
||||
- `FE_APP_ROOT/app/(map-layout)/map/components/inftwtResult/useInftwtResultMap.ts`
|
||||
- `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts`
|
||||
- `FE_APP_ROOT/src/features/windowPopup/registry.tsx`
|
||||
- `FE_APP_ROOT/src/features/windowPopup/ui/InfilChartPopupContent.tsx`
|
||||
- `FE_APP_ROOT/src/features/windowPopup/ui/InftwtResultPopupContent.tsx`
|
||||
|
||||
## 3. 구현 내용
|
||||
|
||||
- `AnalysisPopupMenu`에 `침입수 분석` 메뉴를 추가했다.
|
||||
- window popup 체계에 `inftwtResultPopup`을 등록했다.
|
||||
- 신규 `InftwtResultPopupPage`를 추가해 기존 침입수 분석이력 조회와 결과 다운로드를 popup 안에서 수행하도록 구성했다.
|
||||
- 결과 row 클릭 시 `SN_INFTWT_RESULT_ROW_SELECT` message를 부모창으로 전달하도록 구현했다.
|
||||
- `useInftwtResultMap` hook을 추가해:
|
||||
- 신규 `mapView` endpoint 호출
|
||||
- 처리구역 geometry overlay
|
||||
- 결과 관로 overlay
|
||||
- `실측하수량` 보유 관로 주황색 강조
|
||||
- overlay 관로 클릭 시 결과 전용 `infilChart` popup 분기
|
||||
- 를 한 곳에서 처리하게 했다.
|
||||
- 기존 `infilChart`는 그대로 두지 않고 `resultOnly` 모드를 추가했다.
|
||||
- 기본 모드: 기존처럼 사용자가 상수사용월/분석정보를 선택
|
||||
- 결과 전용 모드: `inftwtAnlsSn`, `aplcnYm`, `analysisInfo`, `pplMngNo`를 받아 좌측 조건 없이 결과만 표시
|
||||
|
||||
## 4. 지도 연계 기준
|
||||
|
||||
- 현재 구현은 처리분구 geometry 원천이 명확하지 않아 `처리구역` geometry를 우선 focus source로 사용한다.
|
||||
- 처리구역 geometry가 없으면 결과 관로 extent로 fallback 한다.
|
||||
- 일반 결과 관로는 파란색, `실측하수량` 보유 관로는 주황색으로 표시한다.
|
||||
|
||||
- 후속 UI 보정으로 `침입수 분석` 메뉴를 분석도구 최하단으로 이동했고, popup 조회조건의 `상수사용기간`은 기존 `DateMonthPicker` 패턴으로 교체했으며, 하수처리시설은 텍스트 입력 대신 기존 `CtpvSggPrcsFcltSelectBox` 기반 선택으로 바꿨다.
|
||||
- 지도 이동 안정화를 위해 결과 row 선택 후에는 처리구역 첫 feature id 또는 첫 번째 결과 관로 fid를 기존 `showSearchResultsFromGeoJSON` 경로로 우선 사용하고, overlay geometry는 GeoJSON `crs` 값을 읽어 동적으로 projection을 해석하도록 보강했다.
|
||||
|
||||
## 5. 검증'
|
||||
|
||||
- 코드 기준 정적 확인
|
||||
- popup 정의/registry 연결 확인
|
||||
- `map/page.tsx` message listener 및 overlay click 분기 확인
|
||||
- 자동 검증
|
||||
- FE TypeScript 검증은 저장소 `pnpm` lockfile 정책 문제로 수행하지 못했다.
|
||||
- 오류 내용: `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`
|
||||
|
||||
## 6. 남은 이슈
|
||||
|
||||
- 실제 브라우저에서 popup row 선택 후 지도 focus, 색상 구분, overlay 관로 click 동작을 수동으로 확인해야 한다.
|
||||
- `양평` 데이터 기준 `실측하수량` 관로가 기대대로 주황색으로 분리되는지 검증이 필요하다.
|
||||
- 처리분구 geometry 원천이 확정되면 `useInftwtResultMap` 내부 focus 규칙을 확장해야 한다.
|
||||
|
||||
|
||||
## 7. 2026-07-01 속도/안정화 보정
|
||||
|
||||
- `useInftwtResultMap`이 `focusLayerType`, `focusFid`를 우선 사용해 기존 `showSearchResultsFromGeoJSON` 이동 경로를 재사용하도록 보정했다.
|
||||
- 결과 관로 하이라이트는 백엔드가 조합한 dedup `pplMngNo` 기준 GeoJSON만 사용하고, 실패 시에만 관로 extent fallback을 수행한다.
|
||||
- FE 정적 검증은 로컬 `tsc` 실행까지 시도했지만 `apps/sn/tsconfig.json`의 `ignoreDeprecations` 값이 현재 TypeScript 버전과 맞지 않아 미완료였다.
|
||||
|
||||
## 8. 2026-07-01 row click 오동작 보정
|
||||
|
||||
- `useInftwtResultMap`의 지도 이동 기준을 `showSearchResultsFromGeoJSON` 선택 흐름에서 overlay extent fit 기준으로 바꿨다.
|
||||
- `focusTargetType=ZONE`이면 처리구역 extent를 우선 사용하고, 아니면 결과 관로 extent를 사용하도록 수정했다.
|
||||
- 이 보정으로 row click 직후 잘못된 기본 관로 상세가 자동으로 열리는 현상을 줄인다.
|
||||
|
||||
## 9. 2026-07-01 분석 실행 화면 선택구조 보정
|
||||
|
||||
- `ii/BaseLayout.tsx`에 `selectionComponent` 주입 지점을 추가해, 화면별로 기본 시설 selector를 교체할 수 있게 했다.
|
||||
- `ii/irsetting/page.tsx`는 새 `CtpvSggSwtrmtZoneSelectBox`를 사용하도록 바꿨다.
|
||||
- 새 selector는 `하수처리구역명`을 먼저 선택하고, 대응 `하수처리시설명`은 읽기 전용으로 자동 표시한다.
|
||||
- 선택 검증 유틸은 기존 로직을 유지하되 `treatmentPlantLabel` 옵션만 추가해, `irsetting`에서는 안내 문구가 `하수처리구역을 선택해주세요.`로 나오게 맞췄다.
|
||||
- FE 타입 검증은 `tsc --ignoreDeprecations 5.0` 기준 새 변경 파일 오류 없이 통과했고, 남은 오류는 기존 지도/공통 차트 파일 선행 이슈로 분리했다.
|
||||
## 10. 2026-07-01 분석 실행 500 디버깅 보강
|
||||
- postMutiPart는 Axios 500 응답의 본문 메시지를 추출해 상위 화면으로 다시 던지도록 바꿨다.
|
||||
- ii/irsetting은 자동 표시된 시설명, 매칭결과 배열, 시간패턴 배열이 비어 있으면 서버 호출 전에 사용자에게 즉시 알린다.
|
||||
## 11. 2026-07-01 분석도구 popup 조회조건 처리구역 우선 전환
|
||||
- InftwtResultPopupPage의 조회조건 selector도 CtpvSggSwtrmtZoneSelectBox로 교체했다.
|
||||
- 팝업 목록 조회는 저장 구조를 유지하기 위해 여전히 swtrmtFcltNm으로 필터링하되, 사용자가 선택하는 순서는 하수처리구역명 -> 하수처리시설명 자동 표시로 맞췄다.
|
||||
## 12. 2026-07-01 popup 하이라이트 렌더 보강
|
||||
- useInftwtResultMap은 처리구역 focus 시 현재 처리구역 extent 안에 결과 관로가 하나도 없으면 결과 관로 extent로 자동 fallback 하도록 보강했다.
|
||||
- zone/pipe source와 layer에 changed, setVisible(true),
|
||||
enderSync를 호출해 overlay가 바로 그려지도록 보강했다.
|
||||
- 브라우저 콘솔에는 침입수 FE overlay 렌더 결과 로그를 남겨 zone/pipe feature count를 확인할 수 있게 했다.
|
||||
## 13. 2026-07-01 시설명 기준 복귀와 시도/시군구 조회 이동
|
||||
- InftwtResultPopupPage 조회조건은 처리구역 selector를 제거하고 다시 CtpvSggPrcsFcltSelectBox 기반 하수처리시설명 선택 구조로 복귀했다.
|
||||
- 팝업 다운로드 버튼은 상단 헤더가 아니라 조회 버튼 옆으로 이동시켜, 실제 결과 조회와 내려받기 동선을 한 줄에 맞췄다.
|
||||
- row click 직후 지도는 처리구역 extent fit 대신 SN_INFTWT_PROC_SEARCH 이벤트로 메인 상단 검색바의 시도/시군구/시설명 상태를 동기화하고, 검색바와 같은 시도/시군구 중심 이동 규칙을 우선 사용한다.
|
||||
- 처리구역 우선 selector 실험용 CtpvSggSwtrmtZoneSelectBox.tsx는 현재 구현에서 더 이상 참조되지 않아 정리했다.
|
||||
## 14. 2026-07-01 I/I 분석 legacy 결과 탭 임시 숨김
|
||||
- 사용자 요청 기준으로 좌측 `I/I 분석` 메뉴의 `침입수 분석결과` 항목은 현재 화면에서 숨겼다.
|
||||
- 숨김 방식은 공통 LNB 데이터 구조를 다시 건드리지 않고, `FE_APP_ROOT/src/app/styles/components.css`에서 해당 서브메뉴 항목만 표시하지 않도록 제한했다.
|
||||
- 이 보정은 메뉴 노출만 감추는 수준이며, 기존 `ii/result` route와 화면 코드는 유지한다.
|
||||
@@ -18,3 +18,7 @@
|
||||
@apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer;
|
||||
}
|
||||
}
|
||||
/* `I/I 분석 > 침입수 분석결과`는 나중에 다시 사용할 수 있도록 라우트는 유지하고 LNB에서만 임시 숨김 처리한다. */
|
||||
[data-sidebar="menu"] > *:nth-child(2) [data-sidebar="menu-sub"] > li:nth-child(2) {
|
||||
display: none;
|
||||
}
|
||||
@@ -11,4 +11,4 @@ export class MenuApiService implements MenuService {
|
||||
}
|
||||
}
|
||||
|
||||
export const MenuApi = new MenuApiService();
|
||||
export const MenuApi = new MenuApiService();
|
||||
@@ -46,11 +46,21 @@ export const windowPopupDefinitions = {
|
||||
width: 1280,
|
||||
height: 980,
|
||||
},
|
||||
inftwtResultPopup: {
|
||||
key: "inftwtResultPopup",
|
||||
title: "침입수 분석결과",
|
||||
width: 1280,
|
||||
height: 900,
|
||||
},
|
||||
infilChart: {
|
||||
key: "infilChart",
|
||||
title: "침입수 분석",
|
||||
width: 1360,
|
||||
height: 980,
|
||||
width: 940,
|
||||
height: 860,
|
||||
placement: "browser-left",
|
||||
alignTop: true,
|
||||
offsetLeft: 0,
|
||||
offsetTop: 70,
|
||||
requiredParams: ["pplMngNo"],
|
||||
},
|
||||
rdiiChart: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import AgedPipePopupContent from "./ui/AgedPipePopupContent";
|
||||
import FloodPreTablePopupContent from "./ui/FloodPreTablePopupContent";
|
||||
import FloodPreUploadPopupContent from "./ui/FloodPreUploadPopupContent";
|
||||
import InfilChartPopupContent from "./ui/InfilChartPopupContent";
|
||||
import InftwtResultPopupContent from "./ui/InftwtResultPopupContent";
|
||||
import OldPpUploadPopupContent from "./ui/OldPpUploadPopupContent";
|
||||
import OldPpTablePopupContent from "./ui/OldPpTablePopupContent";
|
||||
import RdiiChartPopupContent from "./ui/RdiiChartPopupContent";
|
||||
@@ -60,6 +61,10 @@ export const windowPopupRegistry = {
|
||||
...windowPopupDefinitions.mapSearch,
|
||||
component: SearchModalPopupContent,
|
||||
},
|
||||
inftwtResultPopup: {
|
||||
...windowPopupDefinitions.inftwtResultPopup,
|
||||
component: InftwtResultPopupContent,
|
||||
},
|
||||
infilChart: {
|
||||
...windowPopupDefinitions.infilChart,
|
||||
component: InfilChartPopupContent,
|
||||
@@ -112,4 +117,4 @@ export const windowPopupRegistry = {
|
||||
...windowPopupDefinitions.flowRiskPopup,
|
||||
component: WaterRiskPopupContent,
|
||||
},
|
||||
} as const satisfies Record<WindowPopupKey, WindowPopupRegistryItem>;
|
||||
} as const satisfies Record<WindowPopupKey, WindowPopupRegistryItem>;
|
||||
@@ -8,6 +8,10 @@ export default function InfilChartPopupContent({
|
||||
searchParams,
|
||||
}: WindowPopupContentProps) {
|
||||
const pplMngNo = getPopupParamValue(searchParams.pplMngNo);
|
||||
const resultOnly = getPopupParamValue(searchParams.resultOnly) === "Y";
|
||||
const aplcnYm = getPopupParamValue(searchParams.aplcnYm);
|
||||
const analysisInfo = getPopupParamValue(searchParams.analysisInfo);
|
||||
const inftwtAnlsSn = Number(getPopupParamValue(searchParams.inftwtAnlsSn) || "0");
|
||||
|
||||
if (!pplMngNo) {
|
||||
return (
|
||||
@@ -18,5 +22,13 @@ export default function InfilChartPopupContent({
|
||||
);
|
||||
}
|
||||
|
||||
return <InfilChart pplMngNo={pplMngNo} />;
|
||||
}
|
||||
return (
|
||||
<InfilChart
|
||||
pplMngNo={pplMngNo}
|
||||
inftwtAnlsSn={Number.isFinite(inftwtAnlsSn) ? inftwtAnlsSn : 0}
|
||||
aplcnYm={aplcnYm}
|
||||
analysisInfo={analysisInfo}
|
||||
resultOnly={resultOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import InftwtResultPopupPage from "@/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage";
|
||||
|
||||
export default function InftwtResultPopupContent() {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!isMounted) {
|
||||
return <div className="h-dvh bg-white" suppressHydrationWarning />;
|
||||
}
|
||||
|
||||
return <InftwtResultPopupPage />;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { SelectBox, type SelectDataType } from "../../../ui";
|
||||
import { Button } from "@common/components/ui";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import GisApp from "../lib/gis.core";
|
||||
import { moveMapToCenter, sidoCenter, sigunguCenter } from "./utils";
|
||||
import { getSigunguCenter, moveMapToCenter, sidoCenter, sigunguCenter } from "./utils";
|
||||
// import { getGeometryCenter } from "./utils";
|
||||
|
||||
const geoserverUrl =
|
||||
@@ -13,6 +13,7 @@ const FACILITY_TYPE_URL_MAP: Record<string, string> = {
|
||||
tb_il_swage_prcs_fclt: geoserverUrl,
|
||||
};
|
||||
const DEFAULT_SWAGE_PRCS_FCLT_LABEL = "하수처리시설";
|
||||
const INFTWT_PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH";
|
||||
|
||||
type FacilityLayerFilter = {
|
||||
label: string;
|
||||
@@ -52,6 +53,7 @@ export const ProcSearchBar = ({
|
||||
{ text: facilityLayerFilter?.label ?? DEFAULT_SWAGE_PRCS_FCLT_LABEL, val: "0" },
|
||||
]);
|
||||
const [facilityValue, setFacilityValue] = useState("0");
|
||||
const [pendingExternalFacilityName, setPendingExternalFacilityName] = useState("");
|
||||
|
||||
const [facilityType, setFacilityType] = useState(
|
||||
facilityTypes?.length === 1 && facilityTypes[0] ? facilityTypes[0].val : "0",
|
||||
@@ -67,9 +69,9 @@ export const ProcSearchBar = ({
|
||||
|
||||
const list = (res.features || [])
|
||||
.map((f: any) => ({
|
||||
text: f.properties.mctpv_korn_nm,
|
||||
val: f.properties.mctpv_cd,
|
||||
center: sidoCenter[f.properties.mctpv_cd?.trim()] //getGeometryCenter(f.geometry) ?? undefined,
|
||||
text: String(f.properties.mctpv_korn_nm ?? "").trim(),
|
||||
val: String(f.properties.mctpv_cd ?? "").trim(),
|
||||
center: sidoCenter[String(f.properties.mctpv_cd ?? "").trim()] //getGeometryCenter(f.geometry) ?? undefined,
|
||||
}))
|
||||
.sort((a: any, b: any) => {
|
||||
// 커스텀 순서 정의
|
||||
@@ -118,9 +120,9 @@ export const ProcSearchBar = ({
|
||||
|
||||
const list = (res.features || [])
|
||||
.map((f: any) => ({
|
||||
text: f.properties.sgg_korn_nm,
|
||||
val: f.properties.sgg_cd,
|
||||
center: sigunguCenter[f.properties.sgg_cd?.trim()] //getGeometryCenter(f.geometry) ?? undefined,
|
||||
text: String(f.properties.sgg_korn_nm ?? "").trim(),
|
||||
val: String(f.properties.sgg_cd ?? "").trim(),
|
||||
center: sigunguCenter[String(f.properties.sgg_cd ?? "").trim()] //getGeometryCenter(f.geometry) ?? undefined,
|
||||
}))
|
||||
.sort((a: any, b: any) => a.text.localeCompare(b.text, "ko"));
|
||||
|
||||
@@ -241,6 +243,17 @@ export const ProcSearchBar = ({
|
||||
setFacilityValue("0");
|
||||
}, [defaultFacilityData, facilityLayerFilter?.cqlFilter, facilityLayerFilter?.disabled, facilityType, fetchFacilitiesBySggNm, sidoData, sidoValue, sigunData, sigunValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingExternalFacilityName) return;
|
||||
|
||||
const matchedFacility = facilityData.find(
|
||||
(item) => String(item.text ?? "").trim() === pendingExternalFacilityName,
|
||||
);
|
||||
setFacilityValue(matchedFacility?.val ?? "0");
|
||||
setPendingExternalFacilityName("");
|
||||
}, [facilityData, pendingExternalFacilityName]);
|
||||
|
||||
|
||||
const initializeSigunData = useCallback(
|
||||
(nextSidoCd: string) => {
|
||||
if (nextSidoCd === "0") {
|
||||
@@ -251,7 +264,7 @@ export const ProcSearchBar = ({
|
||||
|
||||
const prefix = nextSidoCd.substring(0, 2);
|
||||
const filtered = sigunData.filter((item) => item.val.startsWith(prefix));
|
||||
setFilteredSigunData([{ text: "시군구", val: "0" }, ...filtered]);
|
||||
setFilteredSigunData([{ text: "시군구", val: "0" }, ...filtered]);
|
||||
setSigunValue("0");
|
||||
},
|
||||
[sigunData],
|
||||
@@ -323,6 +336,50 @@ export const ProcSearchBar = ({
|
||||
moveMapToCenter(gisMaps, selectedSido.center, sidoSearchZoom);
|
||||
}, [gisMaps, sidoValue, sigunValue, sidoData, filteredSigunData, sigunData, sidoSearchZoom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const handleExternalSearch = (rawEvent: Event) => {
|
||||
const event = rawEvent as CustomEvent<{
|
||||
ctpvCd?: string;
|
||||
sggCd?: string;
|
||||
swtrmtFcltNm?: string;
|
||||
}>;
|
||||
const nextCtpvCd = String(event.detail?.ctpvCd ?? "0").trim() || "0";
|
||||
const nextSggCd = String(event.detail?.sggCd ?? "0").trim() || "0";
|
||||
const nextSwtrmtFcltNm = String(event.detail?.swtrmtFcltNm ?? "").trim();
|
||||
const nextSidoCenter = sidoCenter[nextCtpvCd.slice(0, 2)];
|
||||
const nextSigunguCenter = getSigunguCenter(nextSggCd);
|
||||
|
||||
setSidoValue(nextCtpvCd);
|
||||
initializeSigunData(nextCtpvCd);
|
||||
|
||||
if (nextCtpvCd !== "0") {
|
||||
const prefix = nextCtpvCd.substring(0, 2);
|
||||
const filtered = sigunData.filter((item) => item.val.startsWith(prefix));
|
||||
setFilteredSigunData([{ text: "시군구", val: "0" }, ...filtered]);
|
||||
}
|
||||
|
||||
setSigunValue(nextSggCd !== "0" ? nextSggCd : "0");
|
||||
setFacilityValue("0");
|
||||
setPendingExternalFacilityName(nextSwtrmtFcltNm);
|
||||
|
||||
if (nextSggCd !== "0" && nextSigunguCenter) {
|
||||
moveMapToCenter(gisMaps, nextSigunguCenter, 12);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextCtpvCd !== "0" && nextSidoCenter) {
|
||||
moveMapToCenter(gisMaps, nextSidoCenter, sidoSearchZoom);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(INFTWT_PROC_SEARCH_EVENT, handleExternalSearch as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener(INFTWT_PROC_SEARCH_EVENT, handleExternalSearch as EventListener);
|
||||
};
|
||||
}, [gisMaps, initializeSigunData, sigunData, sidoSearchZoom]);
|
||||
|
||||
const handleFeatureFocus = async (data: any) => {
|
||||
const layerNm = String(data?.layerNm ?? "").trim();
|
||||
let featureId = String(data?.fid ?? "").trim();
|
||||
@@ -382,3 +439,4 @@ export const ProcSearchBar = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user