diff --git a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx index b01c8b019..6de94b71b 100644 --- a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx +++ b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx @@ -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(null); - // GIS 편집 모드가 꺼지면 메뉴 강조 상태도 함께 정리해 사용자가 현재 모드를 오해하지 않게 한다. useEffect(() => { if (!isGisEditActive && activeKey === "gisEditPopup") { setActiveKey(null); @@ -140,4 +141,4 @@ export default function AnalysisPopupMenu({ ); -} +} \ No newline at end of file diff --git a/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx new file mode 100644 index 000000000..9de542912 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx @@ -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(DEFAULT_FILTERS); + const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState("0"); + const [selectedRow, setSelectedRow] = useState(null); + const [rows, setRows] = useState([]); + 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, 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), + 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 ( +
+ +
+

침입수 분석결과

+
+ +
+
+
+
+ 상수사용기간 + { + setFilters((current) => ({ + ...current, + aplcnYmFrom: value, + })); + }, + }} + selectClass="w-[145px]" + startYear={1900} + /> + ~ + { + setFilters((current) => ({ + ...current, + aplcnYmTo: value, + })); + }, + }} + selectClass="w-[145px]" + startYear={1900} + /> +
+ +
+ { + 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} + /> +
+ +
+ + +
+
+
+ +
+ + 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} + /> +
+ + {selectedRow ? ( +
+ {"선택 분석: "} + {selectedRow.anlsInfo} +
+ ) : null} +
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/inftwtResult/inftwtResultTypes.ts b/apps/sn/app/(map-layout)/map/components/inftwtResult/inftwtResultTypes.ts new file mode 100644 index 000000000..10917e591 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/inftwtResult/inftwtResultTypes.ts @@ -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; + pipeGeometry: Record; + pipes: InftwtResultMapViewPipe[]; + pipeCount: number; + oilqPipeCount: number; +}; diff --git a/apps/sn/app/(map-layout)/map/components/inftwtResult/useInftwtResultMap.ts b/apps/sn/app/(map-layout)/map/components/inftwtResult/useInftwtResultMap.ts new file mode 100644 index 000000000..fa3aba0c1 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/inftwtResult/useInftwtResultMap.ts @@ -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; + 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(null); + const zoneLayerRef = useRef | null>(null); + const zoneSourceRef = useRef(null); + const pipeLayerRef = useRef | null>(null); + const pipeSourceRef = useRef(null); + const mapViewCacheRef = useRef>(new Map()); + const [activeMapView, setActiveMapView] = useState(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, + }; +}; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx index 91495f412..0cbd1e36c 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx @@ -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([]); 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 = () => ( -
-
-
+ const renderResultPanel = () => ( +
+

침입수 분석 결과

+
+
+
+ 상수사용량 적용연월 +
+
+ {selectedAplcnYm ? moment(selectedAplcnYm, 'YYYYMM').format('YYYY-MM') : '-'} +
+
+ +
+
+ 침입수량 산정결과 +
+
+ 침입수량 +
+ {inftwtQty} + m³/d +
+
+
+
+ {hasChartData ? ( + + ) : ( +
+ 데이터가 없습니다. +
+ )} +
+
+ ); + + const renderDefaultBody = () => ( +

@@ -216,60 +323,14 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {

-
-

침입수 분석 결과

- -
-
-
- {/* ● */} - 상수사용량 적용연월 -
-
- {selectedAplcnYm ? moment(selectedAplcnYm, 'YYYYMM').format('YYYY-MM') : '-'} -
-
- -
-
- {/* ● */} - 침입수량 산정결과 -
-
- 침입수량 -
- {inftwtQty} - m³/d -
-
-
-
- -
- {hasChartData ? ( - - ) : ( -
- 데이터가 없습니다. -
- )} -
-
+ {renderResultPanel()}
); return ( -
-

침입수 분석

- {renderBody()} +
+ {isResultOnlyMode ? renderResultPanel() : renderDefaultBody()}
); }; diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 2a32b5ee8..ed4044ca1 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -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(null); const sinkholePopupWindowRef = useRef(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); diff --git a/apps/sn/app/(with-base-layout)/ii/BaseLayout.tsx b/apps/sn/app/(with-base-layout)/ii/BaseLayout.tsx index 43648114c..7109141b7 100644 --- a/apps/sn/app/(with-base-layout)/ii/BaseLayout.tsx +++ b/apps/sn/app/(with-base-layout)/ii/BaseLayout.tsx @@ -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 = ({ /> )} - { - setCtpvCd(sidoId); - setSggCd('0'); - }} - onSigunguChange={setSggCd} - setSggName={setSggName} - selectedSwagePrcsFclt={selectedSwagePrcsFclt} - onSwagePrcsFcltChange={onSwagePrcsFcltChange} - setSwtrmtFcltNm={setSwtrmtFcltNm} - setIsLoading={setIsLoading} - /> + {/* 침입수 분석 실행처럼 선택 구조가 다른 화면은 커스텀 selector를 주입하고, 나머지는 기존 시설 selector를 유지한다. */} + {selectionComponent ?? ( + { + setCtpvCd(sidoId); + setSggCd('0'); + }} + onSigunguChange={setSggCd} + setSggName={setSggName} + selectedSwagePrcsFclt={selectedSwagePrcsFclt} + onSwagePrcsFcltChange={onSwagePrcsFcltChange} + setSwtrmtFcltNm={setSwtrmtFcltNm} + setIsLoading={setIsLoading} + /> + )} {selectBox?.searchChk && (
diff --git a/apps/sn/app/(with-base-layout)/ii/irsetting/page.tsx b/apps/sn/app/(with-base-layout)/ii/irsetting/page.tsx index e2a23759b..7cbbff083 100644 --- a/apps/sn/app/(with-base-layout)/ii/irsetting/page.tsx +++ b/apps/sn/app/(with-base-layout)/ii/irsetting/page.tsx @@ -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('0'); // 하수처리장코드 + const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState('0'); // ?????? ?? ?? const [formResetKey, setFormResetKey] = useState(0); // FileUploadForm 리셋을 위한 key 상태 const [isLoadingResult, setIsLoadingResult] = useState(false); // 로딩 상태 const [ctpvCd, setCtpvCd] = useState('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 \ No newline at end of file +export default IrSetting diff --git a/apps/sn/app/(with-base-layout)/selectionValidation.tsx b/apps/sn/app/(with-base-layout)/selectionValidation.tsx index ce80bdd67..6beb4c11a 100644 --- a/apps/sn/app/(with-base-layout)/selectionValidation.tsx +++ b/apps/sn/app/(with-base-layout)/selectionValidation.tsx @@ -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); diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts index 5efc9bb09..0730cdc21 100644 --- a/apps/sn/app/components/lib/api/index.ts +++ b/apps/sn/app/components/lib/api/index.ts @@ -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 => { - 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 => { - 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 => { diff --git a/apps/sn/docs/작업계획/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-작업카드.md b/apps/sn/docs/작업계획/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-작업카드.md new file mode 100644 index 000000000..68a99e880 --- /dev/null +++ b/apps/sn/docs/작업계획/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-작업카드.md @@ -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`만 유지해 회귀 범위를 최소화한다. \ No newline at end of file diff --git a/apps/sn/docs/작업내용/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-통합구현기록.md b/apps/sn/docs/작업내용/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-통합구현기록.md new file mode 100644 index 000000000..c1deaceba --- /dev/null +++ b/apps/sn/docs/작업내용/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-통합구현기록.md @@ -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와 화면 코드는 유지한다. +- 따라서 추후 필요 시 스타일 규칙만 제거하면 기존 업무형 결과 화면 진입선을 다시 살릴 수 있다. \ No newline at end of file diff --git a/apps/sn/src/app/styles/components.css b/apps/sn/src/app/styles/components.css index 3aa3c770d..679feb8f2 100644 --- a/apps/sn/src/app/styles/components.css +++ b/apps/sn/src/app/styles/components.css @@ -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; +} \ No newline at end of file diff --git a/apps/sn/src/entities/menu/api/menu-service.ts b/apps/sn/src/entities/menu/api/menu-service.ts index 4424f8d37..02a2d1718 100644 --- a/apps/sn/src/entities/menu/api/menu-service.ts +++ b/apps/sn/src/entities/menu/api/menu-service.ts @@ -11,4 +11,4 @@ export class MenuApiService implements MenuService { } } -export const MenuApi = new MenuApiService(); +export const MenuApi = new MenuApiService(); \ No newline at end of file diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts index dcba0e396..69fc8d4d1 100644 --- a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -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: { diff --git a/apps/sn/src/features/windowPopup/registry.tsx b/apps/sn/src/features/windowPopup/registry.tsx index f63b08a91..29d767747 100644 --- a/apps/sn/src/features/windowPopup/registry.tsx +++ b/apps/sn/src/features/windowPopup/registry.tsx @@ -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; +} as const satisfies Record; \ No newline at end of file diff --git a/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx index f6a739f53..daed9f2d1 100644 --- a/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx +++ b/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx @@ -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 ; -} + return ( + + ); +} \ No newline at end of file diff --git a/apps/sn/src/features/windowPopup/ui/InftwtResultPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/InftwtResultPopupContent.tsx new file mode 100644 index 000000000..66bb296e2 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/InftwtResultPopupContent.tsx @@ -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
; + } + + return ; +} \ No newline at end of file diff --git a/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx b/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx index 676bd3315..84016e5a8 100644 --- a/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx +++ b/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx @@ -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 = { 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 = ({
); }; +