diff --git a/apps/sn/AGENTS.md b/apps/sn/AGENTS.md index 291eb61d6..3c369a72d 100644 --- a/apps/sn/AGENTS.md +++ b/apps/sn/AGENTS.md @@ -101,7 +101,7 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작 ## 작업 원칙 -1. 모든 변경은 2차 요구사항, 화면설계, 기능 명세 또는 승인된 작업 문서를 근거로 한다. +1. 모든 변경은 원본 요구사항정의서와 화면설계서, 2차 요구사항, 기능 명세 또는 승인된 작업 문서를 근거로 한다. 2. `apps/sn`과 `packages/common/business_components` 범위를 넘는 수정은 하지 않는다. 3. 공통 패키지 수정은 `sn` 요구사항을 충족하기 위한 최소 범위로 제한한다. 4. 다른 앱 전용 로직을 `business_components`에 넣지 않는다. @@ -117,6 +117,18 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작 14. `BaseLayout`, `NoFooterLayout`, `PageLayout`, `SearchDiv`, `SearchFormLeft/Right`, `ListDiv`, `UiTable`, `UiModal`, `Gis` 조합을 우선 재사용한다. 15. 새 UI 구조가 꼭 필요하면 기존 패턴으로 해결되지 않는 이유를 먼저 설명한다. +## 원본 요구사항 사전 확인 + +- 모든 `sn` 화면 설계, 구현, 수정, 검증과 문서 작업은 시작 전에 아래 원본 파일에서 관련 범위를 직접 확인한다. + - `COMMON_DOCS_ROOT/기준문서/화면설계서.pptx` + - `COMMON_DOCS_ROOT/기준문서/요구사항정의서.xlsx` +- 화면설계서의 관련 슬라이드, 화면 ID, 입력항목, 버튼, 표 컬럼과 사용자 흐름을 확인한다. +- 요구사항정의서의 관련 시트, 요구사항 ID, 기능 목적과 완료 조건을 함께 대조한다. +- `de`나 메인 에이전트의 요약, 기존 작업계획 또는 현재 화면만으로 원본 확인을 대체하지 않는다. +- 확인한 요구사항 ID, 화면 ID와 슬라이드를 작업계획 또는 결과 보고에 남긴다. +- 원본에 없는 화면 동작이나 데이터 항목은 임의로 추가하지 않고 `설계 제안` 또는 `확인 필요`로 구분한다. +- 상세 절차는 `COMMON_DOCS_ROOT/기준문서/012-원본요구사항사전확인.md`를 따른다. + --- ## 허용 작업 @@ -181,6 +193,15 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작 VSCode나 별도 터미널에서 `sn` Next 개발 서버가 실행 중이면 `next build`, `pnpm --filter sn run build`, `pnpm turbo run build --filter=sn` 같은 빌드 명령을 실행하지 않는다. 개발 서버와 빌드가 동시에 `.next` 산출물을 갱신하면 개발 서버가 종료되거나 꼬일 수 있으므로, 빌드 검증은 사용자에게 확인한 뒤 개발 서버를 끄고 수행한다. +### 프론트엔드 실행 프로세스 관리 + +- Codex 에이전트가 작업 또는 검증을 위해 직접 기동한 `next dev`, `dev:sn`, Turborepo dev와 기타 프론트엔드 장시간 실행 프로세스는 작업 종료 전에 반드시 종료한다. +- 셸, 백그라운드, 숨김 창 등 VSCode 밖에서 에이전트가 기동한 프로세스도 종료 대상이다. +- 사용자가 VSCode 또는 별도 터미널에서 직접 실행한 프론트엔드 개발 서버는 임의로 종료·재시작·교체하지 않는다. 종료가 필요하면 먼저 사용자에게 확인한다. +- 기동 시 PID와 포트를 기록하고, 종료 시에는 에이전트가 기동한 정확한 PID와 하위 프로세스 트리만 종료한다. +- 사용자가 계속 실행해 두라고 명시한 경우에는 종료하지 않고 최종 보고에 PID, 포트와 종료 방법을 남긴다. +- 최종 보고 전에 에이전트가 사용한 프론트엔드 포트 해제와 임시 로그 파일 핸들 종료를 확인한다. + 이 저장소는 `main` 브랜치 push 전에 `.husky/pre-push`에서 아래 빌드를 수행한다. ```sh diff --git a/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx index 487ce744e..1b3159186 100644 --- a/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx @@ -5,9 +5,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Button, Input } from "@common/components"; -import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; -import type { UiTableSort } from "@common/business_components/ui"; +import { UiTable } from "@common/business_components/ui/table"; +import type { UiTableSort } from "@common/business_components/ui/table"; import { getRawJson } from "@/components/lib/api"; +import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; import useStore from "@/store/useStore"; import LoadingOverlay from "@/components/ui/LoadingBar"; diff --git a/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx index 6c9b78c23..5f24e9516 100644 --- a/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx @@ -75,6 +75,8 @@ const FACILITY_TYPE_CODE = "PPL"; const LAYER_ID = "tb_ms_swage_ppl"; const GIS_UPLOAD_PAGE_SIZE = 10; const SELECT_MNG_INST_VALUE = ""; +// A current/history download is an idempotent GET, so one retry is safe after a transient connection drop. +const DOWNLOAD_NETWORK_RETRY_DELAY_MS = 500; const DEFAULT_FACILITY: UploadFacilityType = { facilityTypeCode: FACILITY_TYPE_CODE, facilityTypeName: "하수관로", @@ -102,6 +104,26 @@ const buildManagementLabel = (mngInstCd?: string | null, mngInstNm?: string | nu return mngInstNm || mngInstCd; }; +// Chrome reports an interrupted fetch as TypeError without an HTTP response. +const isDownloadNetworkError = (error: unknown) => { + if (!(error instanceof TypeError)) return false; + const message = error.message.toLowerCase(); + return message.includes("failed to fetch") || message.includes("networkerror"); +}; + +// Retry only transport failures; HTTP validation and server errors must be shown immediately. +const downloadGeoPackage = async (url: string) => { + try { + return await getBlobDataWithHeaders(url); + } catch (error) { + if (!isDownloadNetworkError(error)) throw error; + console.warn("GIS 다운로드 연결이 중단되어 1회 재시도합니다.", error); + await new Promise((resolve) => window.setTimeout(resolve, DOWNLOAD_NETWORK_RETRY_DELAY_MS)); + return getBlobDataWithHeaders(url); + } +}; + +// Keep the object URL alive until Chrome has accepted the synthetic download click. const saveBlob = (blob: Blob, fileName: string) => { const url = window.URL.createObjectURL(blob); const anchor = document.createElement("a"); @@ -110,7 +132,7 @@ const saveBlob = (blob: Blob, fileName: string) => { document.body.appendChild(anchor); anchor.click(); anchor.remove(); - window.URL.revokeObjectURL(url); + window.setTimeout(() => window.URL.revokeObjectURL(url), 1_000); }; const getErrorMessage = (error: unknown, fallback: string) => { @@ -333,7 +355,7 @@ export default function GisManagementPopupPage() { setIsLoading(true); setNotice(""); try { - const response = await getBlobDataWithHeaders( + const response = await downloadGeoPackage( `v1/map/gisUploads/swagePpl/download?${params.toString()}`, ); const downloadId = response.headers.get("X-Gis-Download-Id") ?? ""; @@ -378,7 +400,7 @@ export default function GisManagementPopupPage() { setIsLoading(true); setNotice(""); try { - const response = (await postMutiPart("v1/map/gisUploads", formData)) as GisUploadResponse; + const response = (await postMutiPart("/api/gis-upload", formData)) as GisUploadResponse; if (response.status === "VALIDATION_FAILED") { setValidationErrors(Array.isArray(response.validationErrors) ? response.validationErrors : []); setViewMode("validationErrors"); @@ -409,7 +431,7 @@ export default function GisManagementPopupPage() { setIsLoading(true); setNotice(""); try { - const response = await getBlobDataWithHeaders( + const response = await downloadGeoPackage( `v1/map/gisUploads/${encodeURIComponent(selectedHistory.uploadId)}/download`, ); saveBlob(response.blob, response.fileName ?? `${selectedHistory.uploadId}.gpkg`); diff --git a/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx index 54592fd8e..1c3fb1deb 100644 --- a/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx @@ -1,8 +1,13 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { DateMonthPicker, UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; -import type { UiHeadProps, UiTableSort } from "@common/business_components/ui"; +import { DateMonthPicker } from "@common/business_components/ui/date_month_picker"; +import { UiTable } from "@common/business_components/ui/table"; +import type { + UiHeadProps, + UiTableSort, +} from "@common/business_components/ui/table"; +import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; import { Button } from "@common/components"; import { getData } from "@/components/lib/api"; diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx index 34f058e8a..c57dde03a 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx @@ -3,8 +3,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "next/navigation"; import { Button, Input } from "@common/components"; -import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; -import type { UiTableSort } from "@common/business_components/ui"; +import { UiTable } from "@common/business_components/ui/table"; +import type { UiTableSort } from "@common/business_components/ui/table"; +import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; import { getData } from "@/components/lib/api"; import LoadingOverlay from "@/components/ui/LoadingBar"; import useStore from "@/store/useStore"; diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx index 0c3b86188..0d47ba116 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx @@ -2,9 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button, Table, TableBody, TableCell, TableHeader, TableHead, TableRow } from "@common/components"; -import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; -import type { UiTableSort } from "@common/business_components/ui"; +import { UiTable } from "@common/business_components/ui/table"; +import type { UiTableSort } from "@common/business_components/ui/table"; import UiPageNations from "@common/business_components/ui/table/uiPageNations"; +import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; import { getData } from "@/components/lib/api"; import LoadingOverlay from "@/components/ui/LoadingBar"; import useStore from "@/store/useStore"; diff --git a/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx index e7add3575..76d55fd28 100644 --- a/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx @@ -1,11 +1,16 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { DateMonthPicker, UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; -import type { UiHeadProps, UiTableSort } from "@common/business_components/ui"; +import { DateMonthPicker } from "@common/business_components/ui/date_month_picker"; +import { UiTable } from "@common/business_components/ui/table"; +import type { + UiHeadProps, + UiTableSort, +} from "@common/business_components/ui/table"; import { Button } from "@common/components"; import { getData } from "@/components/lib/api"; +import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; import LoadingOverlay from "@/components/ui/LoadingBar"; import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox"; import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue"; diff --git a/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx b/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx index 06387e1d3..e24e3a26a 100644 --- a/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx @@ -3,9 +3,13 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import { Button, Input } from "@common/components"; -import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; -import type { UiHeadProps, UiTableSort } from "@common/business_components/ui"; +import { UiTable } from "@common/business_components/ui/table"; +import type { + UiHeadProps, + UiTableSort, +} from "@common/business_components/ui/table"; import { getRawJson } from "@/components/lib/api"; +import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; import useStore from "@/store/useStore"; import LoadingOverlay from "@/components/ui/LoadingBar"; import type { SelectDataType } from "@common/business_components"; diff --git a/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx index fc3f7a585..108a3a549 100644 --- a/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx @@ -10,62 +10,61 @@ import { type WindowPopupContentProps, } from "@/features/windowPopup/model/types"; import { - buildWaterRiskSettingAreaName, + fetchWaterRiskAreas, + fetchWaterRiskMapView, + fetchWaterRiskResults, +} from "./waterRiskClient"; +import { buildWaterRiskStatusLabel, EMPTY_WATER_RISK_SESSION_SNAPSHOT, - WATER_RISK_RESULT_ROWS, - WATER_RISK_SETTING_ROWS, - WATER_RISK_SGG_OPTIONS_BY_SIDO, - WATER_RISK_SIDO_OPTIONS, + type WaterRiskAreaOption, + type WaterRiskResultPage, type WaterRiskResultRow, type WaterRiskSessionSnapshot, - type WaterRiskStagedPipe, } from "./waterRiskTypes"; -type WaterRiskView = "result" | "setting"; type WaterRiskResultMode = "current" | "history"; type ResultFilters = { startDate: string; endDate: string; - sidoName: string; - sggName: string; + ctpvCd: string; + sggCd: string; areaId: string; riskStatus: "ALL" | "DANGER" | "NORMAL"; }; -type SettingFilters = { - sidoName: string; - sggName: string; - areaId: string; -}; - -const DEFAULT_RESULT_FILTERS: ResultFilters = { - startDate: "2026-05-18", - endDate: "2026-05-18", - sidoName: "전체", - sggName: "전체", - areaId: "ALL", - riskStatus: "ALL", -}; - -const DEFAULT_SETTING_FILTERS: SettingFilters = { - sidoName: "전체", - sggName: "전체", - areaId: "ALL", -}; - -const TOOLBAR_INPUT_CLASS_NAME = - "h-[32px] rounded-[4px] border border-[#b9c7d6] bg-white px-2 text-[12px] text-[#1f2937] outline-none focus:border-[#00aeb3] focus:ring-1 focus:ring-[#00aeb3]"; - -const ACTION_BUTTON_CLASS_NAME = - "h-[28px] rounded-[6px] border border-[#2e77bc] bg-[#3f8fd7] px-3 text-[12px] font-semibold text-white hover:bg-[#337ec1]"; const WATER_RISK_PAGE_SIZE = 10; -const RESULT_TABLE_CONTAINER_CLASS_NAME = - "min-h-0 overflow-hidden border border-[#cbd9e6] bg-white"; -const SETTING_TABLE_CONTAINER_CLASS_NAME = - "min-h-0 overflow-hidden rounded-[10px] border border-[#d6e1ee] bg-[linear-gradient(180deg,#ffffff_0%,#f9fbfe_100%)] shadow-[0_6px_16px_rgba(31,59,87,0.06)]"; +const EMPTY_RESULT_PAGE: WaterRiskResultPage = { + items: [], + total: 0, + page: 0, + size: WATER_RISK_PAGE_SIZE, + totalPages: 0, +}; +/** 브라우저 로컬 날짜를 API의 yyyy-MM-dd 형식으로 변환한다. */ +const formatLocalDate = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +/** 화면을 연 날짜를 현재 위험 기본 조회일로 사용한다. */ +const buildDefaultResultFilters = (): ResultFilters => { + const today = formatLocalDate(new Date()); + return { + startDate: today, + endDate: today, + ctpvCd: "ALL", + sggCd: "ALL", + areaId: "ALL", + riskStatus: "ALL", + }; +}; + +/** 부모 지도 창으로 통수위험 선택 메시지를 전달한다. */ const postMessageToOpener = ( type: string, payload?: Record, @@ -74,994 +73,533 @@ const postMessageToOpener = ( window.opener.postMessage({ type, payload }, window.location.origin); }; -const buildPreviewTimestamp = () => new Date().toISOString(); - -const buildSavePreview = ( - snapshot: WaterRiskSessionSnapshot, - stagedPipes: WaterRiskStagedPipe[], -) => ({ - endpoint: "/api/v1/map/wraOutletPipeSettings/save", - method: "POST", - generatedAt: buildPreviewTimestamp(), - body: { - focusAreaId: snapshot.selectedSettingAreaId, - focusAreaName: snapshot.selectedSettingAreaName, - pipes: stagedPipes.map((pipe, index) => ({ - pipeId: pipe.pipeId, - setSeq: index + 1, - })), - }, -}); - +/** 위험 여부에 맞는 기존 팝업 badge 스타일을 반환한다. */ const getRiskBadgeClassName = (riskStatus: "DANGER" | "NORMAL") => - riskStatus === "DANGER" - ? "bg-[#ff1c15] text-white" - : "bg-white text-[#111827]"; + riskStatus === "DANGER" ? "sn-popup-badge-danger" : "sn-popup-badge-normal"; +/** 날짜까지 포함해 과거 이력에서도 결과 행 선택을 유일하게 식별한다. */ +const buildResultRowKey = (row: WaterRiskResultRow) => + `${row.analysisDate ?? ""}:${row.ctpvCd ?? ""}:${row.sggCd ?? ""}:${row.areaId}`; + +/** 실제 통수위험 API를 조회하고 선택 결과를 부모 지도에 전달하는 팝업이다. */ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { - const [activeView, setActiveView] = useState("result"); - // 최신 화면설계서의 현재 결과/과거 이력 분리 요구를 팝업 내부 탭 상태로 관리한다. + const initialFilters = useMemo(() => buildDefaultResultFilters(), []); const [resultMode, setResultMode] = useState("current"); - const [snapshot, setSnapshot] = useState( - EMPTY_WATER_RISK_SESSION_SNAPSHOT, - ); - const [draftResultFilters, setDraftResultFilters] = useState( - DEFAULT_RESULT_FILTERS, - ); + const [draftResultFilters, setDraftResultFilters] = + useState(initialFilters); const [appliedResultFilters, setAppliedResultFilters] = - useState(DEFAULT_RESULT_FILTERS); - const [draftSettingFilters, setDraftSettingFilters] = - useState(DEFAULT_SETTING_FILTERS); - const [appliedSettingFilters, setAppliedSettingFilters] = - useState(DEFAULT_SETTING_FILTERS); - const [selectedResultAreaId, setSelectedResultAreaId] = useState( - WATER_RISK_RESULT_ROWS[0]?.areaId ?? "", - ); - const [selectedSettingAreaId, setSelectedSettingAreaId] = - useState(""); + useState(initialFilters); + const [areaOptions, setAreaOptions] = useState([]); + const [resultPage, setResultPage] = + useState(EMPTY_RESULT_PAGE); + const [selectedResultKey, setSelectedResultKey] = useState(""); const [resultCurrentPage, setResultCurrentPage] = useState(1); - const [settingCurrentPage, setSettingCurrentPage] = useState(1); + const [reloadVersion, setReloadVersion] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [isMapLoading, setIsMapLoading] = useState(false); const [noticeMessage, setNoticeMessage] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + + useEffect(() => { + let active = true; + fetchWaterRiskAreas() + .then((options) => { + if (active) setAreaOptions(options); + }) + .catch((error) => { + if (!active) return; + const message = + error instanceof Error + ? error.message + : "중점관리지역 목록을 조회하지 못했습니다."; + setErrorMessage(message); + }); + return () => { + active = false; + }; + }, []); + + useEffect(() => { + let active = true; + setIsLoading(true); + setErrorMessage(""); + fetchWaterRiskResults({ + startDate: appliedResultFilters.startDate, + endDate: appliedResultFilters.endDate, + ctpvCd: + appliedResultFilters.ctpvCd === "ALL" + ? undefined + : appliedResultFilters.ctpvCd, + sggCd: + appliedResultFilters.sggCd === "ALL" + ? undefined + : appliedResultFilters.sggCd, + areaId: + appliedResultFilters.areaId === "ALL" + ? undefined + : appliedResultFilters.areaId, + riskStatus: + appliedResultFilters.riskStatus === "ALL" + ? undefined + : appliedResultFilters.riskStatus, + page: resultCurrentPage - 1, + size: WATER_RISK_PAGE_SIZE, + }) + .then((page) => { + if (!active) return; + setResultPage(page); + if (page.items.length > 0) { + setSelectedResultKey((current) => current || buildResultRowKey(page.items[0]!)); + } + }) + .catch((error) => { + if (!active) return; + setResultPage(EMPTY_RESULT_PAGE); + setErrorMessage( + error instanceof Error + ? error.message + : "통수위험 분석 결과를 조회하지 못했습니다.", + ); + }) + .finally(() => { + if (active) setIsLoading(false); + }); + return () => { + active = false; + }; + }, [appliedResultFilters, reloadVersion, resultCurrentPage]); useEffect(() => { const handleMessage = (event: MessageEvent) => { if (event.origin !== window.location.origin) return; - - if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SYNC) - return; + if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SYNC) return; const nextSnapshot = (event.data.payload ?? EMPTY_WATER_RISK_SESSION_SNAPSHOT) as WaterRiskSessionSnapshot; - setSnapshot(nextSnapshot); if (nextSnapshot.selectedAreaId) { - setSelectedResultAreaId(nextSnapshot.selectedAreaId); - } - if (nextSnapshot.selectedSettingAreaId) { - setSelectedSettingAreaId(nextSnapshot.selectedSettingAreaId); + const matchingRow = resultPage.items.find( + (row) => row.areaId === nextSnapshot.selectedAreaId, + ); + if (matchingRow) setSelectedResultKey(buildResultRowKey(matchingRow)); } }; window.addEventListener("message", handleMessage); postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_READY); - return () => { - postMessageToOpener( - WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_STOP_SETTING_EDIT, - ); window.removeEventListener("message", handleMessage); }; - }, []); - - const visibleResultRows = useMemo(() => { - return WATER_RISK_RESULT_ROWS.filter((row) => { - if ( - appliedResultFilters.sidoName !== "전체" && - row.sidoName !== appliedResultFilters.sidoName - ) { - return false; - } - - if ( - appliedResultFilters.sggName !== "전체" && - row.sggName !== appliedResultFilters.sggName - ) { - return false; - } - - if ( - appliedResultFilters.areaId !== "ALL" && - row.areaId !== appliedResultFilters.areaId - ) { - return false; - } - - if ( - appliedResultFilters.riskStatus !== "ALL" && - row.riskStatus !== appliedResultFilters.riskStatus - ) { - return false; - } - - return true; - }); - }, [appliedResultFilters]); - - const visibleSettingRows = useMemo(() => { - return WATER_RISK_SETTING_ROWS.filter((row) => { - if ( - appliedSettingFilters.sidoName !== "전체" && - row.sidoName !== appliedSettingFilters.sidoName - ) { - return false; - } - - if ( - appliedSettingFilters.sggName !== "전체" && - row.sggName !== appliedSettingFilters.sggName - ) { - return false; - } - - if ( - appliedSettingFilters.areaId !== "ALL" && - row.areaId !== appliedSettingFilters.areaId - ) { - return false; - } - - return true; - }); - }, [appliedSettingFilters]); - - const resultAreaOptions = useMemo( - () => - WATER_RISK_RESULT_ROWS.map((row) => ({ - areaId: row.areaId, - areaName: row.areaName, - })), - [], - ); - - const settingAreaOptions = useMemo(() => { - const areaMap = new Map(); - WATER_RISK_SETTING_ROWS.forEach((row) => { - areaMap.set(row.areaId, { - areaId: row.areaId, - areaName: row.areaName, - }); - }); - return Array.from(areaMap.values()); - }, []); - - const resultSggOptions = - WATER_RISK_SGG_OPTIONS_BY_SIDO[draftResultFilters.sidoName] ?? - WATER_RISK_SGG_OPTIONS_BY_SIDO["전체"] ?? - []; - const settingSggOptions = - WATER_RISK_SGG_OPTIONS_BY_SIDO[draftSettingFilters.sidoName] ?? - WATER_RISK_SGG_OPTIONS_BY_SIDO["전체"] ?? - []; - - const currentSettingAreaName = - snapshot.selectedSettingAreaName || - buildWaterRiskSettingAreaName( - selectedSettingAreaId || snapshot.selectedSettingAreaId, - ); - - const pagedResultRows = useMemo(() => { - const startIndex = (resultCurrentPage - 1) * WATER_RISK_PAGE_SIZE; - return visibleResultRows.slice( - startIndex, - startIndex + WATER_RISK_PAGE_SIZE, - ); - }, [resultCurrentPage, visibleResultRows]); - - const pagedSettingRows = useMemo(() => { - const startIndex = (settingCurrentPage - 1) * WATER_RISK_PAGE_SIZE; - return visibleSettingRows.slice( - startIndex, - startIndex + WATER_RISK_PAGE_SIZE, - ); - }, [settingCurrentPage, visibleSettingRows]); - - const showSettingEditor = - snapshot.isSettingEditMode || snapshot.stagedPipes.length > 0; - const resultTableMinWidth = resultMode === "current" ? 1140 : 1060; - const settingTableMinWidth = 900; + }, [resultPage.items]); useEffect(() => { if (typeof window === "undefined" || !window.resizeTo) return; + window.resizeTo(1180, 720); + }, []); - const popupWidth = - activeView === "result" ? 1180 : showSettingEditor ? 1180 : 980; - const popupHeight = - activeView === "setting" && showSettingEditor ? 900 : 720; + const ctpvOptions = useMemo(() => { + const unique = new Map(); + areaOptions.forEach((option) => { + if (option.ctpvCd) unique.set(option.ctpvCd, option.ctpvName || option.ctpvCd); + }); + return Array.from(unique, ([code, name]) => ({ code, name })).sort((a, b) => + a.name.localeCompare(b.name, "ko"), + ); + }, [areaOptions]); - window.resizeTo(popupWidth, popupHeight); - }, [activeView, showSettingEditor]); + const sggOptions = useMemo(() => { + const unique = new Map(); + areaOptions + .filter( + (option) => + draftResultFilters.ctpvCd === "ALL" || + option.ctpvCd === draftResultFilters.ctpvCd, + ) + .forEach((option) => { + if (option.sggCd) unique.set(option.sggCd, option.sggName || option.sggCd); + }); + return Array.from(unique, ([code, name]) => ({ code, name })).sort((a, b) => + a.name.localeCompare(b.name, "ko"), + ); + }, [areaOptions, draftResultFilters.ctpvCd]); + const filteredAreaOptions = useMemo( + () => + areaOptions.filter( + (option) => + (draftResultFilters.ctpvCd === "ALL" || + option.ctpvCd === draftResultFilters.ctpvCd) && + (draftResultFilters.sggCd === "ALL" || + option.sggCd === draftResultFilters.sggCd), + ), + [areaOptions, draftResultFilters.ctpvCd, draftResultFilters.sggCd], + ); + + const resultTableMinWidth = resultMode === "current" ? 1140 : 1060; + + /** 검증된 검색조건을 적용하고 첫 페이지를 새로 조회한다. */ const handleApplyResultFilters = () => { if (!draftResultFilters.startDate || !draftResultFilters.endDate) { setNoticeMessage("조회 시작일과 종료일을 입력해야 합니다."); return; } - if (draftResultFilters.startDate > draftResultFilters.endDate) { setNoticeMessage("조회 시작일은 종료일보다 늦을 수 없습니다."); return; } setResultCurrentPage(1); - setAppliedResultFilters(draftResultFilters); - setNoticeMessage("통수위험 분석 결과를 갱신했습니다."); + setAppliedResultFilters({ ...draftResultFilters }); + setReloadVersion((version) => version + 1); + setNoticeMessage("통수위험 분석 결과를 조회했습니다."); }; + /** 현재 위험과 과거 이력 표시열만 전환하고 같은 API 검색조건은 유지한다. */ const handleChangeResultMode = (mode: WaterRiskResultMode) => { setResultMode(mode); setResultCurrentPage(1); setNoticeMessage(""); }; - const handleOpenSettingView = () => { - setActiveView("setting"); - setNoticeMessage(""); - }; - - const handleReturnToResultView = () => { - setActiveView("result"); - setNoticeMessage(""); - }; - - const handleSelectResultRow = (row: WaterRiskResultRow) => { - setSelectedResultAreaId(row.areaId); - postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT, { - areaId: row.areaId, - }); - setNoticeMessage(`${row.areaName}을 지도에서 강조했습니다.`); - }; - - const handleApplySettingFilters = () => { - setSettingCurrentPage(1); - setAppliedSettingFilters(draftSettingFilters); - setNoticeMessage("설정 현황 목록을 갱신했습니다."); - }; - - const handleStopSettingEdit = () => { - postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_STOP_SETTING_EDIT); - setNoticeMessage("통수위험 설정 편집 모드를 종료했습니다."); - }; - - const handleRemoveStagedPipe = (pipeId: string) => { - postMessageToOpener( - WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_REMOVE_STAGED_PIPE, - { - pipeId, - }, - ); - }; - - const handleMoveStagedPipe = (pipeId: string, direction: "UP" | "DOWN") => { - postMessageToOpener( - WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_REORDER_STAGED_PIPE, - { - pipeId, - direction, - }, - ); - }; - - const handleClearStagedPipes = () => { - postMessageToOpener( - WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_CLEAR_STAGED_PIPES, - ); - }; - - const handlePreviewSave = () => { - if (!snapshot.selectedSettingAreaId || snapshot.stagedPipes.length === 0) { - setNoticeMessage("저장할 유출관로가 없습니다."); - return; + /** 선택 행의 실제 GeoJSON을 조회한 뒤 부모 지도 창에 전달한다. */ + const handleSelectResultRow = async (row: WaterRiskResultRow) => { + setSelectedResultKey(buildResultRowKey(row)); + setIsMapLoading(true); + setErrorMessage(""); + try { + const mapView = await fetchWaterRiskMapView(row); + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT, { + areaId: row.areaId, + resultRow: row, + mapView, + }); + setNoticeMessage(`${row.areaName}을 지도에서 강조했습니다.`); + } catch (error) { + setErrorMessage( + error instanceof Error + ? error.message + : "통수위험 지도 결과를 조회하지 못했습니다.", + ); + } finally { + setIsMapLoading(false); } - - const preview = buildSavePreview(snapshot, snapshot.stagedPipes); - console.info("WaterRisk save preview", preview); - setNoticeMessage( - "설정 저장 payload preview를 생성했습니다. 콘솔을 확인하세요.", - ); - }; - - const handleSelectSettingArea = (areaId: string) => { - setSelectedSettingAreaId(areaId); - - const targetAreaName = - settingAreaOptions.find((item) => item.areaId === areaId)?.areaName ?? - buildWaterRiskSettingAreaName(areaId); - - postMessageToOpener( - WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_START_SETTING_EDIT, - { - areaId, - areaName: targetAreaName, - }, - ); - setNoticeMessage(`${targetAreaName} 설정 편집 대상을 선택했습니다.`); }; return ( -
-
- {activeView === "setting" ? ( - - ) : null} -

- {activeView === "result" - ? "통수위험분석 결과조회" - : "통수위험분석 설정"} -

+
+
+

통수위험분석 결과조회

-
- {activeView === "result" ? ( -
-
- - -
+
+
+
+ + +
-
-
-
- )} + {resultPage.total > WATER_RISK_PAGE_SIZE ? ( +
+ +
+ ) : null} +
+ {errorMessage ? ( + {errorMessage} + ) : isMapLoading ? ( + "선택한 결과의 지도 도형을 조회하고 있습니다." + ) : ( + noticeMessage + )} +
+
); diff --git a/apps/sn/app/(map-layout)/map/components/waterRisk/waterRiskClient.ts b/apps/sn/app/(map-layout)/map/components/waterRisk/waterRiskClient.ts new file mode 100644 index 000000000..46ce30840 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/waterRisk/waterRiskClient.ts @@ -0,0 +1,82 @@ +import { getData } from "@/app/components/lib/api"; + +import type { + WaterRiskAreaOption, + WaterRiskMapView, + WaterRiskResultPage, + WaterRiskResultRow, + WaterRiskStatus, +} from "./waterRiskTypes"; + +export type WaterRiskResultQuery = { + startDate: string; + endDate: string; + ctpvCd?: string; + sggCd?: string; + areaId?: string; + riskStatus?: WaterRiskStatus; + page: number; + size: number; +}; + +type WaterRiskApiResultRow = Omit & { + ctpvName: string; +}; + +/** 통수위험 결과 API 응답을 기존 화면 표시 모델로 정규화한다. */ +const normalizeResultRow = (row: WaterRiskApiResultRow): WaterRiskResultRow => ({ + ...row, + sidoName: row.ctpvName, + currentAccumRainQty: row.currentAccumRainQty ?? null, + pipeSummaries: Array.isArray(row.pipeSummaries) ? row.pipeSummaries : [], +}); + +/** 등록된 중점관리지역 검색조건을 조회한다. */ +export const fetchWaterRiskAreas = async (): Promise => { + const response = await getData("v1/waterRisk/areas"); + return Array.isArray(response) ? response : []; +}; + +/** 기간·행정구역·지역·위험상태에 맞는 결과 페이지를 조회한다. */ +export const fetchWaterRiskResults = async ( + query: WaterRiskResultQuery, +): Promise => { + const params = new URLSearchParams({ + startDate: query.startDate, + endDate: query.endDate, + page: String(query.page), + size: String(query.size), + }); + if (query.ctpvCd) params.set("ctpvCd", query.ctpvCd); + if (query.sggCd) params.set("sggCd", query.sggCd); + if (query.areaId) params.set("areaId", query.areaId); + if (query.riskStatus) params.set("riskStatus", query.riskStatus); + + const response = await getData(`v1/waterRisk/results?${params.toString()}`); + const items = Array.isArray(response?.items) + ? response.items.map((row: WaterRiskApiResultRow) => normalizeResultRow(row)) + : []; + return { + items, + total: Number(response?.total ?? 0), + page: Number(response?.page ?? query.page), + size: Number(response?.size ?? query.size), + totalPages: Number(response?.totalPages ?? 0), + }; +}; + +/** 결과 행과 연결된 중점관리지역·유출관로 도형을 조회한다. */ +export const fetchWaterRiskMapView = async ( + row: WaterRiskResultRow, +): Promise => { + if (!row.analysisDate || !row.ctpvCd || !row.sggCd) { + throw new Error("지도 조회에 필요한 분석일·행정구역 정보가 없습니다."); + } + const params = new URLSearchParams({ + ctpvCd: row.ctpvCd, + sggCd: row.sggCd, + }); + return getData( + `v1/waterRisk/results/${encodeURIComponent(row.analysisDate)}/areas/${encodeURIComponent(row.areaId)}/mapView?${params.toString()}`, + ); +}; diff --git a/apps/sn/app/(map-layout)/map/components/waterRisk/waterRiskTypes.ts b/apps/sn/app/(map-layout)/map/components/waterRisk/waterRiskTypes.ts index adeac297a..e666778f0 100644 --- a/apps/sn/app/(map-layout)/map/components/waterRisk/waterRiskTypes.ts +++ b/apps/sn/app/(map-layout)/map/components/waterRisk/waterRiskTypes.ts @@ -1,52 +1,33 @@ "use client"; export type WaterRiskStatus = "DANGER" | "NORMAL"; -export type WaterRiskCalculationMethod = "METHOD_1" | "METHOD_2"; -export type WaterRiskTab = "result" | "setting"; +export type WaterRiskCalculationMethod = "METHOD_1" | "METHOD_2" | "RATIONAL_MANNING"; export type WaterRiskPipeSummary = { pipeId: string; runoffFlowQty: number; conveyQty: number; + riskStatus?: WaterRiskStatus; }; export type WaterRiskResultRow = { + analysisDate?: string; areaId: string; areaName: string; + ctpvCd?: string; sidoName: string; + sggCd?: string; sggName: string; - // 화면설계서 v1.12 결과 그리드용 표시값이며 실제 값은 후속 BE API 응답으로 치환한다. - currentAccumRainQty: number; - forecastRainQty: number; - maxRainIntensity: number; + // 기존 물리 테이블에 누적강우량이 없으므로 값이 없을 때는 화면에서 '-'로 표시한다. + currentAccumRainQty: number | null; + forecastRainQty: number | null; + maxRainIntensity: number | null; maxFloodingAt: string; riskStatus: WaterRiskStatus; calculationMethod: WaterRiskCalculationMethod; pipeSummaries: WaterRiskPipeSummary[]; }; -export type WaterRiskSettingRow = { - areaId: string; - areaName: string; - sidoName: string; - sggName: string; - pipeId: string; - pipeDia: string; - pipeLen: string; - pipeMaterial: string; - setSeq: number; - setCompleteYn: "Y" | "N"; -}; - -export type WaterRiskStagedPipe = { - pipeId: string; - pipeDia: string; - pipeLen: string; - pipeMaterial: string; - setSeq: number; - source: "MAP" | "MOCK"; -}; - export type WaterRiskGridCell = { gridId: string; rainQty: number; @@ -69,20 +50,10 @@ export type WaterRiskMapPipe = { export type WaterRiskSessionSnapshot = { selectedAreaId: string | null; - selectedSettingAreaId: string | null; - selectedSettingAreaName: string; - isSettingEditMode: boolean; - stagedPipes: WaterRiskStagedPipe[]; - statusMessage: string; }; export const EMPTY_WATER_RISK_SESSION_SNAPSHOT: WaterRiskSessionSnapshot = { selectedAreaId: null, - selectedSettingAreaId: null, - selectedSettingAreaName: "", - isSettingEditMode: false, - stagedPipes: [], - statusMessage: "", }; const buildRectanglePolygon = ( @@ -456,273 +427,6 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [ }, ]; -export const WATER_RISK_SETTING_ROWS: WaterRiskSettingRow[] = [ - { - areaId: "AREA-001", - areaName: "A 중점관리지역", - sidoName: "서울특별시", - sggName: "강남구", - pipeId: "PIPE-10284", - pipeDia: "900 mm", - pipeLen: "186 m", - pipeMaterial: "콘크리트", - setSeq: 1, - setCompleteYn: "Y", - }, - { - areaId: "AREA-001", - areaName: "A 중점관리지역", - sidoName: "서울특별시", - sggName: "강남구", - pipeId: "PIPE-10291", - pipeDia: "700 mm", - pipeLen: "142 m", - pipeMaterial: "흄관", - setSeq: 2, - setCompleteYn: "Y", - }, - { - areaId: "AREA-002", - areaName: "B 중점관리지역", - sidoName: "서울특별시", - sggName: "강남구", - pipeId: "PIPE-45103", - pipeDia: "600 mm", - pipeLen: "98 m", - pipeMaterial: "PE", - setSeq: 1, - setCompleteYn: "Y", - }, - { - areaId: "AREA-003", - areaName: "C 중점관리지역", - sidoName: "부산광역시", - sggName: "해운대구", - pipeId: "PIPE-60014", - pipeDia: "800 mm", - pipeLen: "154 m", - pipeMaterial: "강관", - setSeq: 1, - setCompleteYn: "Y", - }, - { - areaId: "AREA-003", - areaName: "C 중점관리지역", - sidoName: "부산광역시", - sggName: "해운대구", - pipeId: "PIPE-60018", - pipeDia: "700 mm", - pipeLen: "119 m", - pipeMaterial: "흄관", - setSeq: 2, - setCompleteYn: "Y", - }, - { - areaId: "AREA-004", - areaName: "D 중점관리지역", - sidoName: "부산광역시", - sggName: "해운대구", - pipeId: "PIPE-72010", - pipeDia: "500 mm", - pipeLen: "101 m", - pipeMaterial: "PVC", - setSeq: 1, - setCompleteYn: "N", - }, - { - areaId: "AREA-005", - areaName: "E 중점관리지역", - sidoName: "서울특별시", - sggName: "강남구", - pipeId: "PIPE-83014", - pipeDia: "650 mm", - pipeLen: "133 m", - pipeMaterial: "PE", - setSeq: 1, - setCompleteYn: "Y", - }, - { - areaId: "AREA-006", - areaName: "F 중점관리지역", - sidoName: "서울특별시", - sggName: "강남구", - pipeId: "PIPE-83018", - pipeDia: "900 mm", - pipeLen: "176 m", - pipeMaterial: "흄관", - setSeq: 1, - setCompleteYn: "Y", - }, - { - areaId: "AREA-006", - areaName: "F 중점관리지역", - sidoName: "서울특별시", - sggName: "강남구", - pipeId: "PIPE-83019", - pipeDia: "800 mm", - pipeLen: "167 m", - pipeMaterial: "콘크리트", - setSeq: 2, - setCompleteYn: "Y", - }, - { - areaId: "AREA-007", - areaName: "G 중점관리지역", - sidoName: "부산광역시", - sggName: "해운대구", - pipeId: "PIPE-91004", - pipeDia: "700 mm", - pipeLen: "111 m", - pipeMaterial: "PVC", - setSeq: 1, - setCompleteYn: "Y", - }, - { - areaId: "AREA-008", - areaName: "H 중점관리지역", - sidoName: "부산광역시", - sggName: "해운대구", - pipeId: "PIPE-91010", - pipeDia: "1000 mm", - pipeLen: "201 m", - pipeMaterial: "강관", - setSeq: 1, - setCompleteYn: "Y", - }, - { - areaId: "AREA-008", - areaName: "H 중점관리지역", - sidoName: "부산광역시", - sggName: "해운대구", - pipeId: "PIPE-91012", - pipeDia: "900 mm", - pipeLen: "184 m", - pipeMaterial: "흄관", - setSeq: 2, - setCompleteYn: "Y", - }, - { - areaId: "AREA-008", - areaName: "H 중점관리지역", - sidoName: "부산광역시", - sggName: "해운대구", - pipeId: "PIPE-91014", - pipeDia: "800 mm", - pipeLen: "159 m", - pipeMaterial: "콘크리트", - setSeq: 3, - setCompleteYn: "Y", - }, - { - areaId: "AREA-009", - areaName: "I 중점관리지역", - sidoName: "서울특별시", - sggName: "강남구", - pipeId: "PIPE-99001", - pipeDia: "600 mm", - pipeLen: "94 m", - pipeMaterial: "PE", - setSeq: 1, - setCompleteYn: "N", - }, - { - areaId: "AREA-010", - areaName: "J 중점관리지역", - sidoName: "부산광역시", - sggName: "해운대구", - pipeId: "PIPE-99007", - pipeDia: "850 mm", - pipeLen: "143 m", - pipeMaterial: "흄관", - setSeq: 1, - setCompleteYn: "Y", - }, - { - areaId: "AREA-010", - areaName: "J 중점관리지역", - sidoName: "부산광역시", - sggName: "해운대구", - pipeId: "PIPE-99009", - pipeDia: "750 mm", - pipeLen: "128 m", - pipeMaterial: "강관", - setSeq: 2, - setCompleteYn: "Y", - }, - { - areaId: "AREA-011", - areaName: "K 중점관리지역", - sidoName: "서울특별시", - sggName: "강남구", - pipeId: "PIPE-11001", - pipeDia: "700 mm", - pipeLen: "121 m", - pipeMaterial: "콘크리트", - setSeq: 1, - setCompleteYn: "Y", - }, - { - areaId: "AREA-012", - areaName: "L 중점관리지역", - sidoName: "부산광역시", - sggName: "해운대구", - pipeId: "PIPE-12001", - pipeDia: "820 mm", - pipeLen: "147 m", - pipeMaterial: "강관", - setSeq: 1, - setCompleteYn: "N", - }, - { - areaId: "AREA-013", - areaName: "M 중점관리지역", - sidoName: "서울특별시", - sggName: "강남구", - pipeId: "PIPE-13001", - pipeDia: "900 mm", - pipeLen: "163 m", - pipeMaterial: "흄관", - setSeq: 1, - setCompleteYn: "Y", - }, - { - areaId: "AREA-013", - areaName: "M 중점관리지역", - sidoName: "서울특별시", - sggName: "강남구", - pipeId: "PIPE-13002", - pipeDia: "800 mm", - pipeLen: "152 m", - pipeMaterial: "콘크리트", - setSeq: 2, - setCompleteYn: "Y", - }, - { - areaId: "AREA-014", - areaName: "N 중점관리지역", - sidoName: "부산광역시", - sggName: "해운대구", - pipeId: "PIPE-14001", - pipeDia: "500 mm", - pipeLen: "88 m", - pipeMaterial: "PVC", - setSeq: 1, - setCompleteYn: "Y", - }, - { - areaId: "AREA-015", - areaName: "O 중점관리지역", - sidoName: "서울특별시", - sggName: "강남구", - pipeId: "PIPE-15001", - pipeDia: "980 mm", - pipeLen: "194 m", - pipeMaterial: "강관", - setSeq: 1, - setCompleteYn: "Y", - }, -]; - export const WATER_RISK_MAP_AREAS: WaterRiskAreaMapData[] = [ { areaId: "AREA-001", @@ -979,8 +683,6 @@ export const WATER_RISK_SGG_OPTIONS_BY_SIDO: Record = { 부산광역시: ["전체", "해운대구"], }; -export const WATER_RISK_PIPE_LAYER_TYPE = "tb_ms_swage_ppl"; - export const WATER_RISK_COLOR_LABELS: Record = { LEVEL_1: "0.0~20.0 미만", LEVEL_2: "20.0~30.0 미만", @@ -992,9 +694,51 @@ export const buildWaterRiskStatusLabel = (status: WaterRiskStatus) => status === "DANGER" ? "위험" : "정상"; export const buildWaterRiskMethodLabel = (method: WaterRiskCalculationMethod) => - method === "METHOD_1" ? "방법 1" : "방법 2"; + method === "RATIONAL_MANNING" + ? "합리식·Manning" + : method === "METHOD_1" + ? "방법 1" + : "방법 2"; -export const buildWaterRiskSettingAreaName = (areaId: string | null) => { - if (!areaId) return ""; - return WATER_RISK_RESULT_ROWS.find((row) => row.areaId === areaId)?.areaName ?? ""; +export type WaterRiskAreaOption = { + areaId: string; + areaName: string; + ctpvCd: string; + ctpvName: string; + sggCd: string; + sggName: string; +}; + +export type WaterRiskResultPage = { + items: WaterRiskResultRow[]; + total: number; + page: number; + size: number; + totalPages: number; +}; + +export type WaterRiskGeoJsonGeometry = { + type: string; + coordinates: unknown; +}; + +export type WaterRiskMapView = { + analysisDate: string; + areaId: string; + areaName: string; + ctpvCd: string; + ctpvName: string; + sggCd: string; + sggName: string; + riskStatus: WaterRiskStatus; + projection: "EPSG:5179" | string; + areaGeometry: WaterRiskGeoJsonGeometry | null; + pipes: Array<{ + pipeId: string; + displayLabel: string; + geometry: WaterRiskGeoJsonGeometry | null; + runoffFlowQty: number; + conveyQty: number; + riskStatus: WaterRiskStatus; + }>; }; diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 7c8d985c1..6920d2189 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -128,11 +128,10 @@ import { EMPTY_WATER_RISK_SESSION_SNAPSHOT, WATER_RISK_MAP_AREAS, WATER_RISK_MAP_PIPES_BY_AREA, - WATER_RISK_PIPE_LAYER_TYPE, WATER_RISK_RESULT_ROWS, - WATER_RISK_SETTING_ROWS, + type WaterRiskMapView, + type WaterRiskResultRow, type WaterRiskSessionSnapshot, - type WaterRiskStagedPipe, } from "./components/waterRisk/waterRiskTypes"; const AnalysisPopupMenu = dynamic( @@ -763,19 +762,6 @@ const buildWaterRiskInfoAnchor = (areaId: string | null) => { return [maxX, (minY + maxY) / 2] as [number, number]; }; -const buildWaterRiskSeedPipes = (areaId: string) => - WATER_RISK_SETTING_ROWS - .filter((row) => row.areaId === areaId) - .sort((a, b) => a.setSeq - b.setSeq) - .map((row) => ({ - pipeId: row.pipeId, - pipeDia: row.pipeDia, - pipeLen: row.pipeLen, - pipeMaterial: row.pipeMaterial, - setSeq: row.setSeq, - source: "MOCK", - })); - const buildWaterRiskStatusFillColor = (riskStatus: "DANGER" | "NORMAL") => riskStatus === "DANGER" ? "rgba(198, 40, 40, 0.24)" : "rgba(21, 101, 192, 0.2)"; @@ -1242,48 +1228,6 @@ const createSinkholeFocusedPipeStyle = () => [ }), ]; -const formatWaterRiskPipeField = (value: unknown, suffix: string) => { - const normalized = String(value ?? "").trim(); - if (!normalized) return "-"; - if (normalized.endsWith(suffix) || suffix === "") return normalized; - return `${normalized} ${suffix}`.trim(); -}; - -const buildWaterRiskPipeFromFeature = (data: any): WaterRiskStagedPipe | null => { - const values = data?.values_ ?? data; - const pipeId = String( - values?.fclt_mng_no ?? - values?.fcltMngNo ?? - values?.pipeId ?? - values?.id ?? - data?.fid ?? - "", - ).trim(); - - if (!pipeId) return null; - - return { - pipeId, - pipeDia: formatWaterRiskPipeField( - values?.pipe_diameter ?? values?.pdmt_vl ?? values?.pdmtVl, - "mm", - ), - pipeLen: formatWaterRiskPipeField( - values?.pipe_lt ?? values?.pipeLt ?? values?.extn_lt ?? values?.extnLt, - "m", - ), - pipeMaterial: String( - values?.pipe_material ?? - values?.pipeMaterial ?? - values?.mqlt_cd ?? - values?.mqltCd ?? - "-", - ).trim() || "-", - setSeq: 0, - source: "MAP", - }; -}; - type VertexHandleMeta = { kind: "vertex" | "midpoint"; geometryType: string; @@ -2701,6 +2645,11 @@ const Map = () => { const [waterRiskSession, setWaterRiskSession] = useState( EMPTY_WATER_RISK_SESSION_SNAPSHOT, ); + // 팝업에서 실제 API로 조회한 결과 행과 GeoJSON을 지도·정보카드의 단일 원본으로 보관한다. + const [selectedWaterRiskApiResult, setSelectedWaterRiskApiResult] = + useState(null); + const [selectedWaterRiskMapView, setSelectedWaterRiskMapView] = + useState(null); const [waterRiskInfoAnchor, setWaterRiskInfoAnchor] = useState<[number, number] | null>(null); const [waterRiskInfoCardStyle, setWaterRiskInfoCardStyle] = useState | null>(null); const gisEditVisibleSessionChanges = useMemo( @@ -2876,8 +2825,8 @@ const Map = () => { }, [waterRiskSessionSnapshot]); const selectedWaterRiskResultRow = useMemo( - () => getWaterRiskResultRow(waterRiskSession.selectedAreaId), - [waterRiskSession.selectedAreaId], + () => selectedWaterRiskApiResult ?? getWaterRiskResultRow(waterRiskSession.selectedAreaId), + [selectedWaterRiskApiResult, waterRiskSession.selectedAreaId], ); const syncWaterRiskInfoCardPosition = useCallback(() => { @@ -3209,7 +3158,10 @@ const Map = () => { waterRiskPipeSourceRef.current?.clear(); }, []); - const renderWaterRiskSelection = useCallback((areaId: string | null) => { + const renderWaterRiskSelection = useCallback(( + areaId: string | null, + mapView: WaterRiskMapView | null, + ) => { const map = gisMaps.current; if (!map) return; @@ -3219,9 +3171,62 @@ const Map = () => { areaSource.clear(); gridSource.clear(); + const pipeSource = ensureWaterRiskPipeLayer(); + pipeSource?.clear(); if (!areaId) return; + if (mapView && mapView.areaId === areaId) { + const geoJson = new GeoJSON(); + const projection = mapView.projection || "EPSG:5179"; + let focusGeometry: any | null = null; + + if (mapView.areaGeometry) { + const geometry = geoJson.readGeometry(mapView.areaGeometry as any, { + dataProjection: projection, + featureProjection: "EPSG:5179", + }); + const areaFeature = new Feature({ + geometry, + areaId: mapView.areaId, + areaName: mapView.areaName, + riskStatus: mapView.riskStatus, + }); + areaFeature.setStyle(createWaterRiskAreaStyle(areaFeature)); + areaSource.addFeature(areaFeature); + focusGeometry = geometry; + } + + mapView.pipes.forEach((pipe) => { + if (!pipeSource || !pipe.geometry) return; + const geometry = geoJson.readGeometry(pipe.geometry as any, { + dataProjection: projection, + featureProjection: "EPSG:5179", + }); + const pipeFeature = new Feature({ + geometry, + pipeId: pipe.pipeId, + displayLabel: pipe.displayLabel || pipe.pipeId, + }); + pipeFeature.setStyle(createWaterRiskPipeStyle(pipeFeature)); + pipeSource.addFeature(pipeFeature); + if (!focusGeometry) focusGeometry = geometry; + }); + + if (!focusGeometry) return; + const extent = focusGeometry.getExtent(); + const [, minY = 0, maxX = 0, maxY = 0] = extent; + setWaterRiskInfoAnchor([maxX, (minY + maxY) / 2]); + map.getView().fit(extent, { + duration: 300, + padding: [120, 120, 120, 120], + maxZoom: 15, + constrainResolution: true, + nearest: true, + }); + return; + } + const areaData = getWaterRiskAreaMapData(areaId); const resultRow = getWaterRiskResultRow(areaId); if (!areaData || !resultRow) return; @@ -3246,9 +3251,7 @@ const Map = () => { gridSource.addFeature(cellFeature); }); - const pipeSource = ensureWaterRiskPipeLayer(); const pipes = WATER_RISK_MAP_PIPES_BY_AREA[areaId] ?? []; - pipeSource?.clear(); pipes.forEach((pipe) => { if (!pipeSource) return; const pipeFeature = new Feature({ @@ -3272,90 +3275,6 @@ const Map = () => { }); }, [ensureWaterRiskAreaLayer, ensureWaterRiskGridLayer, ensureWaterRiskPipeLayer]); - const setWaterRiskStatusMessage = useCallback((statusMessage: string) => { - setWaterRiskSession((current) => ({ - ...current, - statusMessage, - })); - }, []); - - const startWaterRiskSettingEdit = useCallback((areaId: string, areaName: string) => { - setWaterRiskSession((current) => { - const shouldSeedPipes = - current.selectedSettingAreaId !== areaId || current.stagedPipes.length === 0; - - return { - ...current, - selectedAreaId: areaId, - selectedSettingAreaId: areaId, - selectedSettingAreaName: areaName, - isSettingEditMode: true, - stagedPipes: shouldSeedPipes ? buildWaterRiskSeedPipes(areaId) : current.stagedPipes, - statusMessage: `${areaName} 설정 편집 모드입니다. 지도에서 하수관로를 클릭하세요.`, - }; - }); - setIsOpen(false); - setFeatureData(null); - }, []); - - const stopWaterRiskSettingEdit = useCallback(() => { - setWaterRiskSession((current) => ({ - ...current, - isSettingEditMode: false, - statusMessage: "", - })); - }, []); - - const removeWaterRiskStagedPipe = useCallback((pipeId: string) => { - setWaterRiskSession((current) => ({ - ...current, - stagedPipes: current.stagedPipes - .filter((pipe) => pipe.pipeId !== pipeId) - .map((pipe, index) => ({ - ...pipe, - setSeq: index + 1, - })), - statusMessage: `${pipeId} 관로를 staged 목록에서 제거했습니다.`, - })); - }, []); - - const reorderWaterRiskStagedPipe = useCallback((pipeId: string, direction: "UP" | "DOWN") => { - setWaterRiskSession((current) => { - const currentIndex = current.stagedPipes.findIndex((pipe) => pipe.pipeId === pipeId); - if (currentIndex < 0) return current; - - const nextIndex = - direction === "UP" ? currentIndex - 1 : currentIndex + 1; - if (nextIndex < 0 || nextIndex >= current.stagedPipes.length) { - return current; - } - - const reordered = [...current.stagedPipes]; - const [targetPipe] = reordered.splice(currentIndex, 1); - if (!targetPipe) { - return current; - } - reordered.splice(nextIndex, 0, targetPipe); - - return { - ...current, - stagedPipes: reordered.map((pipe, index) => ({ - ...pipe, - setSeq: index + 1, - })), - statusMessage: `${pipeId} 관로 순번을 조정했습니다.`, - }; - }); - }, []); - - const clearWaterRiskStagedPipes = useCallback(() => { - setWaterRiskSession((current) => ({ - ...current, - stagedPipes: [], - statusMessage: "staged 유출관로 목록을 초기화했습니다.", - })); - }, []); - useEffect(() => { if (!mapReady || !gisMaps.current) return; if (!waterRiskSession.selectedAreaId) { @@ -3365,17 +3284,19 @@ const Map = () => { return; } - renderWaterRiskSelection(waterRiskSession.selectedAreaId); + renderWaterRiskSelection(waterRiskSession.selectedAreaId, selectedWaterRiskMapView); }, [ clearWaterRiskLayers, mapReady, renderWaterRiskSelection, + selectedWaterRiskMapView, waterRiskSession.selectedAreaId, ]); useEffect(() => { + if (selectedWaterRiskMapView) return; setWaterRiskInfoAnchor(buildWaterRiskInfoAnchor(waterRiskSession.selectedAreaId)); - }, [waterRiskSession.selectedAreaId]); + }, [selectedWaterRiskMapView, waterRiskSession.selectedAreaId]); useEffect(() => { const popup = waterRiskPopupWindowRef.current; @@ -3414,64 +3335,27 @@ const Map = () => { if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT) { const areaId = String(data.payload?.areaId ?? "").trim(); if (!areaId) return; - setWaterRiskInfoAnchor(buildWaterRiskInfoAnchor(areaId)); + const resultRow = data.payload?.resultRow as WaterRiskResultRow | undefined; + const mapView = data.payload?.mapView as WaterRiskMapView | undefined; + if (!resultRow || !mapView || mapView.areaId !== areaId) return; + setSelectedWaterRiskApiResult(resultRow); + setSelectedWaterRiskMapView(mapView); setWaterRiskSession((current) => ({ ...current, selectedAreaId: areaId, - statusMessage: `${getWaterRiskResultRow(areaId)?.areaName ?? areaId} 지도 강조를 갱신했습니다.`, })); setIsOpen(false); setFeatureData(null); return; } - if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_START_SETTING_EDIT) { - const areaId = String(data.payload?.areaId ?? "").trim(); - const areaName = String(data.payload?.areaName ?? "").trim(); - if (!areaId) return; - startWaterRiskSettingEdit(areaId, areaName || getWaterRiskResultRow(areaId)?.areaName || areaId); - return; - } - - if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_STOP_SETTING_EDIT) { - stopWaterRiskSettingEdit(); - return; - } - - if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_REMOVE_STAGED_PIPE) { - const pipeId = String(data.payload?.pipeId ?? "").trim(); - if (pipeId) { - removeWaterRiskStagedPipe(pipeId); - } - return; - } - - if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_REORDER_STAGED_PIPE) { - const pipeId = String(data.payload?.pipeId ?? "").trim(); - const direction = data.payload?.direction === "UP" ? "UP" : "DOWN"; - if (pipeId) { - reorderWaterRiskStagedPipe(pipeId, direction); - } - return; - } - - if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_CLEAR_STAGED_PIPES) { - clearWaterRiskStagedPipes(); - } }; window.addEventListener("message", handleWaterRiskPopupMessage); return () => { window.removeEventListener("message", handleWaterRiskPopupMessage); }; - }, [ - clearWaterRiskStagedPipes, - removeWaterRiskStagedPipe, - reorderWaterRiskStagedPipe, - startWaterRiskSettingEdit, - stopWaterRiskSettingEdit, - syncWaterRiskPopup, - ]); + }, [syncWaterRiskPopup]); useEffect(() => { return () => { @@ -3882,59 +3766,6 @@ const Map = () => { return; } - if (waterRiskSession.isSettingEditMode) { - const layerTypeName = getSelectedFeatureLayerTypeName(data); - if (layerTypeName !== WATER_RISK_PIPE_LAYER_TYPE) { - setWaterRiskStatusMessage("통수위험 설정 모드에서는 하수관로만 선택할 수 있습니다."); - setFeatureData(null); - setIsOpen(false); - setIsSearchModalOpen(false); - return; - } - - const nextPipe = buildWaterRiskPipeFromFeature(data); - if (!nextPipe) { - setWaterRiskStatusMessage("선택한 하수관로 정보를 staged 목록으로 옮기지 못했습니다."); - setFeatureData(null); - setIsOpen(false); - setIsSearchModalOpen(false); - return; - } - - setWaterRiskSession((current) => { - if (current.stagedPipes.some((pipe) => pipe.pipeId === nextPipe.pipeId)) { - return { - ...current, - statusMessage: `${nextPipe.pipeId} 관로는 이미 staged 목록에 있습니다.`, - }; - } - - if (current.stagedPipes.length >= 3) { - return { - ...current, - statusMessage: "유출관로는 최대 3건까지만 staged 목록에 담을 수 있습니다.", - }; - } - - return { - ...current, - stagedPipes: [ - ...current.stagedPipes, - { - ...nextPipe, - setSeq: current.stagedPipes.length + 1, - }, - ], - statusMessage: `${nextPipe.pipeId} 관로를 staged 목록에 추가했습니다.`, - }; - }); - - setFeatureData(null); - setIsOpen(false); - setIsSearchModalOpen(false); - return; - } - let map = gisMaps.current; if (!map) { console.warn("지도가 초기화되지 않았습니다."); @@ -5496,12 +5327,11 @@ const Map = () => { if (!popup.closed) return; clearWaterRiskPopupCloseWatcher(); waterRiskPopupWindowRef.current = null; - stopWaterRiskSettingEdit(); }, 300); } return popup; - }, [clearWaterRiskPopupCloseWatcher, stopWaterRiskSettingEdit]); + }, [clearWaterRiskPopupCloseWatcher]); const openGisEditSessionPopup = useCallback(() => { const existingPopup = gisEditSessionPopupWindowRef.current; @@ -7244,16 +7074,6 @@ const Map = () => { {agedPipeDownloadProgressText} ) : null} - {waterRiskSession.isSettingEditMode ? ( -
-
- 통수위험 설정 모드: {waterRiskSession.selectedSettingAreaName || "중점관리지역 선택 전"} -
-
- 지도에서 하수관로를 클릭하면 staged 목록에 추가됩니다. 최대 3건까지 선택할 수 있습니다. -
-
- ) : null} {selectedWaterRiskResultRow && waterRiskInfoCardStyle ? (
{ 예측강우량(mm/hr) - {selectedWaterRiskResultRow.forecastRainQty.toFixed(1)} + {selectedWaterRiskResultRow.forecastRainQty == null + ? "-" + : selectedWaterRiskResultRow.forecastRainQty.toFixed(1)} @@ -7331,7 +7153,7 @@ const Map = () => { 통수량
- (㎥/hr) + (㎥/s) {pipe.conveyQty.toFixed(1)} @@ -7341,7 +7163,7 @@ const Map = () => { 유출유량
- (㎥/hr) + (㎥/s) {pipe.runoffFlowQty.toFixed(1)} @@ -7433,6 +7255,7 @@ const Map = () => { layerSettingView={ActiveLayerSettingView} treeSectionClassName="rounded-sm border border-border bg-background px-1 py-2 text-foreground" detachedTreeToggleButton={true} + showLayerPanelTitle={false} isPlaying={isPlaying} setIsPlaying={setIsPlaying as any} targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any} diff --git a/apps/sn/app/(map-layout)/map/searchModal/basicTab.tsx b/apps/sn/app/(map-layout)/map/searchModal/basicTab.tsx index f15cce8b0..625edcc6f 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/basicTab.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/basicTab.tsx @@ -9,7 +9,7 @@ * - useSearchModalStore를 통해 상태 관리 및 사용자 상호작용 처리 */ import React from 'react'; -import { SelectBox } from '@common/business_components/ui'; +import { SelectBox } from "@common/business_components/ui/select"; import { Label } from '@common/components'; import SwagePpl from './swagePpl'; import SwagePrcsFclt from './swagePrcsFclt'; diff --git a/apps/sn/app/(map-layout)/map/searchModal/operationTab.tsx b/apps/sn/app/(map-layout)/map/searchModal/operationTab.tsx index 8ef37730e..0855fcaee 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/operationTab.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/operationTab.tsx @@ -13,7 +13,7 @@ */ import React from 'react'; import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; -import { SelectBox } from '@common/business_components/ui'; +import { SelectBox } from "@common/business_components/ui/select"; import { Button, Input, Label } from '@common/components'; import useStore from '@/store/useStore'; import useSearchModalStore from '@/store/useSearchModalStore'; diff --git a/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx b/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx index 04f69f885..48fff794c 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx @@ -16,14 +16,15 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Button, Label } from '@common/components'; import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; -import { SelectBox, UiTable } from '@common/business_components/ui'; +import { SelectBox } from "@common/business_components/ui/select"; +import { UiTable } from "@common/business_components/ui/table"; import LoadingOverlay from '@/components/ui/LoadingBar'; import useReportTab from './useReportTab'; import useStore from '@/store/useStore'; import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox'; import useSearchModalStore from '@/store/useSearchModalStore'; import { useReportTabStore } from '@/store/useReportTabStore'; -import type { UiTableSort } from '@common/business_components/ui'; +import type { UiTableSort } from "@common/business_components/ui/table"; import type { FlattenedFolder, ReportDataFile } from './useReportTab'; // 하수도정비 기본계획 보고서 diff --git a/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx b/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx index f1c0b8249..1d96fd937 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx @@ -13,7 +13,8 @@ */ 'use client'; import LoadingOverlay from '@/components/ui/LoadingBar'; -import { UITab, UiTable } from '@common/business_components/ui'; +import { UITab } from "@common/business_components/ui/tab"; +import { UiTable } from "@common/business_components/ui/table"; import { Button } from '@common/components/ui'; import React, { useEffect, useMemo, useRef } from 'react'; import { Toaster } from 'react-hot-toast'; @@ -26,7 +27,10 @@ import useSearchModal from './useSearchModal'; import useBasicTab from './useBasicTab'; import useOperationTab from './useOperationTab'; import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; -import type { UiHeadProps, UiTableSort } from '@common/business_components/ui'; +import type { + UiHeadProps, + UiTableSort, +} from "@common/business_components/ui/table"; type SearchModalProps = { renderMode?: 'launcher' | 'popup'; diff --git a/apps/sn/app/(map-layout)/map/searchModal/swageMhl.tsx b/apps/sn/app/(map-layout)/map/searchModal/swageMhl.tsx index c176dda2a..6856d0086 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/swageMhl.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/swageMhl.tsx @@ -15,7 +15,7 @@ import React from 'react'; import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; import { Button, Input, Label } from '@common/components'; import { MS_SWAGE_MHL_USE_CODES } from '../components/common/table'; -import { SelectBox } from '@common/business_components/ui'; +import { SelectBox } from "@common/business_components/ui/select"; import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox'; import useSearchModalStore from '../../../store/useSearchModalStore'; import useSearchModal from './useSearchModal'; diff --git a/apps/sn/app/(map-layout)/map/searchModal/swagePpl.tsx b/apps/sn/app/(map-layout)/map/searchModal/swagePpl.tsx index 4615a7c60..4dabee0e3 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/swagePpl.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/swagePpl.tsx @@ -12,7 +12,7 @@ * - Enter 키 입력 시 자동 조회 지원 */ import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; -import { SelectBox } from '@common/business_components/ui'; +import { SelectBox } from "@common/business_components/ui/select"; import { Button, Input, Label } from '@common/components'; import React from 'react'; import { MS_SWAGE_PPL_USE_CODES } from '../components/common/table'; diff --git a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts b/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts index a40c1cee9..6846b92ba 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts @@ -12,7 +12,7 @@ * 🔗 사용 API * - 관망도 시설물 목록 조회: GET /api/v1/fclts (sewerage-service) */ -import { downloadExcelWithKoreanHeaders } from '@common/business_components/ui'; +import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; import { MS_SWAGE_PPL_USE_CODES, MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_SHAPE, MS_SWAGE_MHL_CODES, MS_SWAGE_MHL_USE_CODES, FldngWtlvMapExcelCol, MsSwagePplExcelCol, SwtrmtFcltExcelCol, SwtrmtPrcsZoneExcelCol, MsSwageMhlExcelCol, ExclnSewagePumpExcelCol, stofcbrExcelCol @@ -34,7 +34,7 @@ import { swagePmstnCqlFilters } from './swagePmstn'; import { stofcbrCqlFilters } from './stofcbr'; import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage'; import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; -import type { UiTableSort } from '@common/business_components/ui'; +import type { UiTableSort } from "@common/business_components/ui/table"; const isPopupWindow = () => typeof window !== "undefined" && diff --git a/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts b/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts index e6f3e9974..d92cd3efd 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts @@ -14,7 +14,7 @@ * - 유지관리 정보 목록 조회: GET /api/v1/bizInfo (sewerage-service) * - 유지관리 정보(사업정보) 관련 시설 목록 조회: GET /api/v1/bizInfo/fclts (sewerage-service) */ -import { downloadExcelWithKoreanHeaders } from '@common/business_components/ui'; +import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from '../components/common/table'; import { getData } from "@/components/lib/api"; import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue'; @@ -25,7 +25,7 @@ import useSearchModalStore from '@/store/useSearchModalStore'; import useStore from '@/store/useStore'; import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage'; import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; -import type { UiTableSort } from '@common/business_components/ui'; +import type { UiTableSort } from "@common/business_components/ui/table"; const isPopupWindow = () => typeof window !== "undefined" && diff --git a/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts b/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts index 796064bd5..b9ba6e73d 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts @@ -21,7 +21,7 @@ import useBasicTab from './useBasicTab'; import useOperationTab from './useOperationTab'; import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage'; import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; -import type { UiTableSort } from '@common/business_components/ui'; +import type { UiTableSort } from "@common/business_components/ui/table"; const isPopupWindow = () => typeof window !== 'undefined' && diff --git a/apps/sn/app/(with-base-layout)/ii/rdiiResult/page.tsx b/apps/sn/app/(with-base-layout)/ii/rdiiResult/page.tsx index 835584e75..7ad88bec1 100644 --- a/apps/sn/app/(with-base-layout)/ii/rdiiResult/page.tsx +++ b/apps/sn/app/(with-base-layout)/ii/rdiiResult/page.tsx @@ -19,7 +19,8 @@ */ 'use client' import { deleteData, getData } from '@/components/lib/api' -import { UiTable, downloadExcelWithKoreanHeaders } from '@common/business_components/ui' +import { UiTable } from '@common/business_components/ui/table' +import { downloadExcelWithKoreanHeaders } from '@/shared/lib/download-excel' import { zodResolver } from '@hookform/resolvers/zod' import { useState, useEffect } from 'react' import { useForm } from 'react-hook-form' @@ -325,4 +326,4 @@ const Result = () => { ) } -export default Result \ No newline at end of file +export default Result diff --git a/apps/sn/app/(with-base-layout)/ii/result/page.tsx b/apps/sn/app/(with-base-layout)/ii/result/page.tsx index eb9f679b5..f1cbc8364 100644 --- a/apps/sn/app/(with-base-layout)/ii/result/page.tsx +++ b/apps/sn/app/(with-base-layout)/ii/result/page.tsx @@ -19,7 +19,8 @@ */ 'use client' import { deleteData, getData } from '@/components/lib/api' -import { UiTable, downloadExcelWithKoreanHeaders } from '@common/business_components/ui' +import { UiTable } from '@common/business_components/ui/table' +import { downloadExcelWithKoreanHeaders } from '@/shared/lib/download-excel' import { zodResolver } from '@hookform/resolvers/zod' import { useState, useEffect } from 'react' import { useForm } from 'react-hook-form' @@ -317,4 +318,4 @@ const Result = () => { ) } -export default Result \ No newline at end of file +export default Result diff --git a/apps/sn/app/(with-base-layout)/layout.tsx b/apps/sn/app/(with-base-layout)/layout.tsx index 35a09cf33..8a87ed076 100644 --- a/apps/sn/app/(with-base-layout)/layout.tsx +++ b/apps/sn/app/(with-base-layout)/layout.tsx @@ -13,15 +13,16 @@ */ "use client"; -import React, { useEffect } from 'react'; +import React, { useEffect } from "react"; import { usePathname, useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; -import { BaseLayout } from "@common/components"; +import { BaseLayout } from "@common/components/ui/layout/base"; import type { MenuItem } from "@/entities/menu"; import { useMenuStore } from "@/shared/lib"; import { PageLayout } from "@/shared/ui/page-layout"; -import useStore from '../store/useStore'; +import useStore from "../store/useStore"; import { authService } from "@/entities/auth"; +import { GlobalAnalysisCompletionWatcher } from "./ii/useAnalysisCompletionWatcher"; export default function WithBaseLayout({ children, @@ -55,6 +56,7 @@ export default function WithBaseLayout({ currentPath={pathname ?? ""} handleMenuClick={handleMenuClick} > + {children} ); diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/_components/DredgingLocationMap.tsx b/apps/sn/app/(with-base-layout)/oper/dredging/_components/DredgingLocationMap.tsx index 595031f0f..73ad6e878 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/_components/DredgingLocationMap.tsx +++ b/apps/sn/app/(with-base-layout)/oper/dredging/_components/DredgingLocationMap.tsx @@ -1,9 +1,12 @@ "use client"; import Feature from "ol/Feature.js"; +import type { FeatureLike } from "ol/Feature.js"; +import GeoJSON from "ol/format/GeoJSON.js"; import Point from "ol/geom/Point.js"; import type OlMap from "ol/Map.js"; import { unByKey } from "ol/Observable.js"; +import { bbox as bboxStrategy } from "ol/loadingstrategy.js"; import { transform } from "ol/proj.js"; import VectorLayer from "ol/layer/Vector.js"; import VectorSource from "ol/source/Vector.js"; @@ -13,23 +16,24 @@ import Stroke from "ol/style/Stroke.js"; import Style from "ol/style/Style.js"; import { useEffect, useMemo, useRef, useState } from "react"; -import { Button, Input } from "@common/components/ui"; import Gis, { useGisContext } from "@common/business_components/ui/map/Gis"; import GisApp from "@common/business_components/ui/map/lib/gis.core"; -import type { GeoPoint } from "../_lib/types"; +import type { GeoPoint, PipeMatch } from "../_lib/types"; type DredgingLocationMapProps = { value: GeoPoint | null; onChange?: (point: GeoPoint) => void; interactive?: boolean; + statusText?: string; + matchedPipe?: PipeMatch | null; }; type GisContextValue = { gisMap: OlMap | null; }; -const markerStyle = new Style({ +const confirmedMarkerStyle = new Style({ image: new CircleStyle({ radius: 9, fill: new Fill({ color: "rgba(220, 38, 38, 0.9)" }), @@ -37,35 +41,128 @@ const markerStyle = new Style({ }), }); +const draftMarkerStyle = new Style({ + image: new CircleStyle({ + radius: 9, + fill: new Fill({ color: "rgba(37, 99, 235, 0.9)" }), + stroke: new Stroke({ color: "#ffffff", width: 3 }), + }), +}); + +const nearbyPipeStyle = [ + new Style({ + stroke: new Stroke({ color: "rgba(255, 255, 255, 0.95)", width: 5 }), + zIndex: 10, + }), + new Style({ + stroke: new Stroke({ color: "#0f6cbd", width: 3 }), + zIndex: 11, + }), +]; + +const matchedPipeStyle = [ + new Style({ + stroke: new Stroke({ color: "rgba(255, 255, 255, 0.98)", width: 8 }), + zIndex: 20, + }), + new Style({ + stroke: new Stroke({ color: "#f97316", width: 5 }), + zIndex: 21, + }), +]; + +const PIPE_LAYER_MIN_ZOOM = 13; + +const normalizePipeFid = (fid: unknown) => { + const value = String(fid ?? "").trim(); + const separatorIndex = value.lastIndexOf("."); + return separatorIndex >= 0 ? value.slice(separatorIndex + 1) : value; +}; + +const getPipeFeatureStyle = (matchedPipeFid?: string) => (feature: FeatureLike) => + normalizePipeFid(feature.getId()) === normalizePipeFid(matchedPipeFid) + ? matchedPipeStyle + : nearbyPipeStyle; + /** - * 공통 Gis 내부 지도 객체에 클릭 interaction과 위치 marker만 추가한다. + * 공통 Gis 내부 지도 객체에 관로 layer, 클릭 interaction과 위치 marker를 추가한다. * 공통 지도 코드는 수정하지 않고 이 컴포넌트가 추가한 layer/event만 정리한다. */ const LocationInteraction = ({ value, onChange, interactive, + matchedPipe, }: Required> & Omit) => { const { gisMap } = useGisContext() as GisContextValue; - const layerRef = useRef | null>(null); - const [coordinateInput, setCoordinateInput] = useState(""); - - useEffect(() => { - if (!value) return; - setCoordinateInput( - `${value.latitude.toFixed(7)}, ${value.longitude.toFixed(7)}`, - ); - }, [value]); + const markerLayerRef = useRef | null>(null); + const pipeLayerRef = useRef | null>(null); + const [pipeLoadState, setPipeLoadState] = useState< + "idle" | "loading" | "loaded" | "error" + >("idle"); + const [visiblePipeCount, setVisiblePipeCount] = useState(0); useEffect(() => { if (!gisMap) return; - const source = new VectorSource(); - const layer = new VectorLayer({ source, style: markerStyle, zIndex: 999 }); - layer.set("dredgingLocationLayer", true); - gisMap.addLayer(layer); - layerRef.current = layer; + const pipeSource = new VectorSource({ + format: new GeoJSON(), + strategy: bboxStrategy, + url: (extent) => { + const [minX, minY, maxX, maxY] = extent; + const layerModule = GisApp.Module.layer as unknown as { + storageNm?: string; + } | null; + const storageName = String(layerModule?.storageNm ?? "hasudo:"); + const params = new URLSearchParams({ + SERVICE: "WFS", + VERSION: "1.1.0", + REQUEST: "GetFeature", + TYPENAME: `${storageName}tb_ms_swage_ppl`, + SRSNAME: GisApp.TO_PROJ, + OUTPUTFORMAT: "application/json", + MAXFEATURES: "2000", + CQL_FILTER: `BBOX(geom, ${minX}, ${minY}, ${maxX}, ${maxY}, '${GisApp.TO_PROJ}')`, + }); + return `${GisApp.GEOSERVER_URL}?${params.toString()}`; + }, + }); + const pipeLayer = new VectorLayer({ + source: pipeSource, + minZoom: PIPE_LAYER_MIN_ZOOM, + style: getPipeFeatureStyle(matchedPipe?.fid), + updateWhileAnimating: false, + updateWhileInteracting: false, + zIndex: 700, + }); + pipeLayer.set("dredgingPipeLayer", true); + pipeLayer.set("lyrTyNm", "tb_ms_swage_ppl"); + pipeLayer.set("clickPsblYn", "N"); + gisMap.addLayer(pipeLayer); + pipeLayerRef.current = pipeLayer; + + const sourceEventKeys = [ + pipeSource.on("featuresloadstart", () => setPipeLoadState("loading")), + pipeSource.on("featuresloadend", () => { + setVisiblePipeCount(pipeSource.getFeatures().length); + setPipeLoadState("loaded"); + }), + pipeSource.on("featuresloaderror", () => { + setVisiblePipeCount(0); + setPipeLoadState("error"); + }), + ]; + + const markerSource = new VectorSource(); + const markerLayer = new VectorLayer({ + source: markerSource, + style: interactive ? draftMarkerStyle : confirmedMarkerStyle, + zIndex: 999, + }); + markerLayer.set("dredgingLocationLayer", true); + gisMap.addLayer(markerLayer); + markerLayerRef.current = markerLayer; const clickKey = interactive ? gisMap.on("singleclick", (event) => { @@ -88,14 +185,17 @@ const LocationInteraction = ({ return () => { if (clickKey) unByKey(clickKey); - gisMap.removeLayer(layer); - layerRef.current = null; + unByKey(sourceEventKeys); + gisMap.removeLayer(markerLayer); + gisMap.removeLayer(pipeLayer); + markerLayerRef.current = null; + pipeLayerRef.current = null; }; }, [gisMap, interactive, onChange]); useEffect(() => { - if (!gisMap || !layerRef.current) return; - const source = layerRef.current.getSource(); + if (!gisMap || !markerLayerRef.current) return; + const source = markerLayerRef.current.getSource(); source?.clear(); if (!value) return; @@ -106,59 +206,51 @@ const LocationInteraction = ({ ); source?.addFeature(new Feature({ geometry: new Point(coordinate) })); gisMap.getView().animate({ center: coordinate, zoom: 17, duration: 250 }); - }, [gisMap, value]); + }, [gisMap, interactive, value]); - const applyCoordinateInput = () => { - const [latitudeText, longitudeText] = coordinateInput - .split(",") - .map((item) => item.trim()); - const latitude = Number(latitudeText); - const longitude = Number(longitudeText); - if ( - !Number.isFinite(latitude) || - !Number.isFinite(longitude) || - latitude < -90 || - latitude > 90 || - longitude < -180 || - longitude > 180 - ) { - alert("좌표는 '위도, 경도' 형식으로 입력해주세요."); - return; + useEffect(() => { + if (!pipeLayerRef.current) return; + pipeLayerRef.current.setStyle(getPipeFeatureStyle(matchedPipe?.fid)); + pipeLayerRef.current.changed(); + }, [matchedPipe?.fid]); + + const pipeStatusText = (() => { + if (!value) return "위치를 등록하면 주변 관로가 표시됩니다."; + if (pipeLoadState === "loading") return "주변 관로를 불러오는 중입니다."; + if (pipeLoadState === "error") return "주변 관로를 불러오지 못했습니다."; + if (pipeLoadState === "loaded") { + return visiblePipeCount > 0 + ? `주변 관로 ${visiblePipeCount.toLocaleString()}건 표시` + : "현재 지도 범위에 표시할 관로가 없습니다."; } - onChange?.({ latitude, longitude }); - }; + return "지도를 확대하면 주변 관로가 표시됩니다."; + })(); return ( -
- {interactive ? ( - <> - setCoordinateInput(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - applyCoordinateInput(); - } - }} - placeholder="위도, 경도 입력 또는 지도 클릭" - className="!text-base" - /> - - - ) : ( - - 등록 위치와 자동 매핑 관로를 확인합니다. +
+

+ {interactive + ? "지도를 클릭해 준설위치를 선택하세요." + : "등록 위치와 주변 관로를 함께 확인합니다."} +

+
+ + + 주변 관로 - )} + + + 자동 연결 관로 + + + {pipeStatusText} + +
); }; @@ -167,6 +259,8 @@ const DredgingLocationMap = ({ value, onChange, interactive = true, + statusText, + matchedPipe, }: DredgingLocationMapProps) => { const gisMaps = useRef(null); const defaultLonLat = useMemo<[number, number]>( @@ -192,29 +286,26 @@ const DredgingLocationMap = ({ value={value} onChange={onChange} interactive={interactive} + matchedPipe={matchedPipe} /> } defaultZoom={value ? 17 : 7.5} defaultLonLat={defaultLonLat} - height="h-[420px]" + height="h-[540px]" showZoomLevel={true} showFloatingSearchButton={false} treeTogglePosition="top" />
- - {interactive - ? "지도 클릭 또는 위도·경도 입력으로 준설위치를 지정하세요." - : "등록된 준설위치"} - - - {value - ? `${value.latitude.toFixed(7)}, ${value.longitude.toFixed(7)}` - : "위치 미선택"} - + {statusText ?? + (interactive + ? "지도에서 준설위치를 선택해주세요." + : value + ? "등록된 준설위치가 표시되었습니다." + : "위치가 등록되지 않았습니다.")}
); diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/_components/DredgingRegisterModal.tsx b/apps/sn/app/(with-base-layout)/oper/dredging/_components/DredgingRegisterModal.tsx index ec57f9c79..d9e2ad666 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/_components/DredgingRegisterModal.tsx +++ b/apps/sn/app/(with-base-layout)/oper/dredging/_components/DredgingRegisterModal.tsx @@ -1,13 +1,18 @@ "use client"; -import { FileUpload, UiModal } from "@common/business_components/ui"; -import { Button, Input, Label } from "@common/components/ui"; +import dynamic from "next/dynamic"; +import { FileUpload } from "@common/business_components/ui/file-upload"; +import { UiModal } from "@common/business_components/ui/dialog_ui"; +import { Button } from "@common/components/ui/button"; +import { Input } from "@common/components/ui/input"; +import { Label } from "@common/components/ui/label"; import { useEffect, useRef, useState } from "react"; import { findNearestPipeMatches, isDredgingBackendEnabled, saveDredgingRegistrations, + type AttachmentReference, uploadDredgingPhotos, } from "../_lib/client"; import { readPhotoMetadata } from "../_lib/exif"; @@ -18,14 +23,26 @@ import { type DredgingSaveGroup, type GeoPoint, } from "../_lib/types"; -import DredgingLocationMap from "./DredgingLocationMap"; + +// 무거운 OpenLayers 지도 모듈은 준설 등록 본문과 별도 chunk로 지연 로드한다. +const DredgingLocationMap = dynamic(() => import("./DredgingLocationMap"), { + ssr: false, +}); type DredgingRegisterModalProps = { open: boolean; onOpenChange: (open: boolean) => void; onSaved: (groups: DredgingSaveGroup[]) => void; + presentation?: "modal" | "popup"; }; +const POPUP_PRIMARY_BUTTON_CLASS = + "min-w-[6.4rem] border !border-blue-600 !bg-blue-600 px-4 font-medium !text-white hover:!bg-blue-700"; +const POPUP_SECONDARY_BUTTON_CLASS = + "min-w-[6.4rem] border !border-blue-300 !bg-white px-4 font-medium !text-blue-700 hover:!bg-blue-50"; +const POPUP_DANGER_BUTTON_CLASS = + "min-w-[6.4rem] border !border-rose-300 !bg-white px-4 font-medium !text-rose-600 hover:!bg-rose-50"; + const createGroup = (): DredgingDraftGroup => ({ id: crypto.randomUUID(), registrationMode: "PHOTO", @@ -101,13 +118,20 @@ const DredgingRegisterModal = ({ open, onOpenChange, onSaved, + presentation = "modal", }: DredgingRegisterModalProps) => { const [groups, setGroups] = useState([]); const groupsRef = useRef([]); + const [activeGroupId, setActiveGroupId] = useState(null); const [editingLocationGroupId, setEditingLocationGroupId] = useState< string | null >(null); + // 지도 클릭 좌표는 적용 전까지 원본 EXIF 또는 확정 수동 위치를 덮어쓰지 않는 임시 상태다. + const [locationDraft, setLocationDraft] = useState(null); const [isSaving, setIsSaving] = useState(false); + const uploadedAttachmentsRef = useRef( + new Map(), + ); const [statusMessage, setStatusMessage] = useState(""); const commitGroups = ( @@ -123,7 +147,9 @@ const DredgingRegisterModal = ({ const initial = [createGroup()]; groupsRef.current = initial; setGroups(initial); + setActiveGroupId(initial[0]?.id ?? null); setEditingLocationGroupId(null); + setLocationDraft(null); setStatusMessage(""); }, [open]); @@ -244,6 +270,9 @@ const DredgingRegisterModal = ({ alert("관로별 사진은 최대 5장까지 선택할 수 있습니다."); return; } + if (editingLocationGroupId === groupId) { + cancelLocationEditing(); + } const checkingPhotos: DredgingPhotoDraft[] = files.map((file) => ({ key: crypto.randomUUID(), @@ -307,6 +336,25 @@ const DredgingRegisterModal = ({ groupId: string, registrationMode: DredgingDraftGroup["registrationMode"], ) => { + const currentGroup = groupsRef.current.find( + (group) => group.id === groupId, + ); + if (!currentGroup || currentGroup.registrationMode === registrationMode) { + return; + } + const hasEnteredData = + currentGroup.photos.length > 0 || + currentGroup.manualLocation !== null || + currentGroup.manualDate !== ""; + if ( + hasEnteredData && + !window.confirm( + "등록 방식을 변경하면 현재 사진과 입력한 위치·일자가 초기화됩니다. 변경하시겠습니까?", + ) + ) { + return; + } + commitGroups((current) => current.map((group) => group.id === groupId @@ -323,9 +371,9 @@ const DredgingRegisterModal = ({ : group, ), ); - if (registrationMode === "MANUAL") { - setEditingLocationGroupId(groupId); - } + setActiveGroupId(groupId); + setLocationDraft(null); + setEditingLocationGroupId(null); }; const updateManualDate = (groupId: string, manualDate: string) => { @@ -358,6 +406,37 @@ const DredgingRegisterModal = ({ await resolvePipeMatch(nextGroup); }; + // 본문 하단 지도 편집은 현재 확정 위치를 복사한 임시 마커에서 시작한다. + const startLocationEditing = (group: DredgingDraftGroup) => { + setActiveGroupId(group.id); + setEditingLocationGroupId(group.id); + setLocationDraft(getResolvedLocation(group)); + setStatusMessage(""); + }; + + // 취소 시 적용하지 않은 지도 클릭 위치를 폐기하고 기존 위치를 유지한다. + const cancelLocationEditing = () => { + setEditingLocationGroupId(null); + setLocationDraft(null); + }; + + // 새 지도 좌표를 선택하면 이전 위치에서 발생한 저장 오류 안내를 즉시 비운다. + const changeLocationDraft = (point: GeoPoint) => { + setLocationDraft(point); + setStatusMessage(""); + }; + + // 사용자가 명시적으로 적용한 위치만 확정 상태와 최근접 관로 조회에 반영한다. + const applyLocationDraft = async () => { + if (!editingLocationGroupId || !locationDraft) { + alert("지도에서 준설위치를 선택해주세요."); + return; + } + setStatusMessage(""); + await updateManualLocation(editingLocationGroupId, locationDraft); + cancelLocationEditing(); + }; + const validateAndBuildSaveGroups = () => { const saveGroups: DredgingSaveGroup[] = []; for (let index = 0; index < groups.length; index += 1) { @@ -390,7 +469,9 @@ const DredgingRegisterModal = ({ throw new Error(`${label}: 지도에서 준설위치를 등록해주세요.`); } if (!group.matchedPipe) { - throw new Error(`${label}: 최근접 관로 자동 매핑을 완료해주세요.`); + throw new Error( + `${label}: ${group.matchError ?? "최근접 관로 자동 매핑을 완료해주세요."}`, + ); } if ( group.matchedPipe.confidence === "LOW" && @@ -423,13 +504,18 @@ const DredgingRegisterModal = ({ : "등록 내용을 확인하고 있습니다.", ); - const attachments = await uploadDredgingPhotos(saveGroups); + const attachments = await uploadDredgingPhotos( + saveGroups, + uploadedAttachmentsRef.current, + ); + uploadedAttachmentsRef.current = attachments; setStatusMessage( isDredgingBackendEnabled ? "준설 결과와 관로 매핑을 저장하고 있습니다." : "준설 결과 등록을 완료했습니다.", ); await saveDredgingRegistrations(saveGroups, attachments); + uploadedAttachmentsRef.current.clear(); onSaved(saveGroups); alert("준설 결과를 등록했습니다."); onOpenChange(false); @@ -445,250 +531,434 @@ const DredgingRegisterModal = ({ } }; - const editingGroup = - groups.find((group) => group.id === editingLocationGroupId) ?? null; + const activeGroup = + groups.find((group) => group.id === activeGroupId) ?? groups[0] ?? null; + const activeResolvedDate = activeGroup ? getResolvedDate(activeGroup) : null; + const activeResolvedLocation = activeGroup + ? getResolvedLocation(activeGroup) + : null; + const activeManualDateRequired = activeGroup + ? needsManualDate(activeGroup) + : false; + const isEditingActiveLocation = + activeGroup !== null && + editingLocationGroupId === activeGroup.id; + // 지도에서 새로 찍은 좌표가 아직 확정 위치와 다른지 구분해 이전 좌표의 관로 오류를 노출하지 않는다. + const hasUnappliedLocationDraft = Boolean( + isEditingActiveLocation && + locationDraft && + (!activeResolvedLocation || + locationDraft.longitude !== activeResolvedLocation.longitude || + locationDraft.latitude !== activeResolvedLocation.latitude), + ); + + // 새 관로 등록 건을 만든 뒤 즉시 해당 건을 편집 대상으로 전환한다. + const addGroup = () => { + const nextGroup = createGroup(); + commitGroups((current) => [...current, nextGroup]); + setActiveGroupId(nextGroup.id); + cancelLocationEditing(); + }; + + // 현재 등록 건을 삭제하고 남은 첫 번째 건으로 안전하게 이동한다. + const removeActiveGroup = () => { + if (!activeGroup || groupsRef.current.length <= 1) return; + const remainingGroups = groupsRef.current.filter( + (group) => group.id !== activeGroup.id, + ); + commitGroups(() => remainingGroups); + setActiveGroupId(remainingGroups[0]?.id ?? null); + cancelLocationEditing(); + }; + + // 등록 건을 바꾸면 적용하지 않은 지도 임시 위치는 폐기한다. + const selectGroup = (groupId: string) => { + setActiveGroupId(groupId); + cancelLocationEditing(); + }; const modalBody = ( -
-
- 준설결과 등록 시 주의사항 -
    -
  1. 관로별 사진은 최대 5장까지 등록할 수 있습니다.
  2. -
  3. 사진의 촬영일자가 준설일자가 됩니다.
  4. -
  5. GPS 또는 촬영일자가 없으면 지도 위치와 준설일자를 보완합니다.
  6. -
  7. 한 그룹의 사진은 같은 관로와 같은 준설일자여야 합니다.
  8. -
+
+
+ 등록 안내 + 관로별 사진은 최대 5장까지 등록할 수 있습니다. + 촬영일자가 준설일자가 되며, 누락된 정보만 직접 보완합니다.
-
- {groups.map((group, groupIndex) => { - const resolvedDate = getResolvedDate(group); - const resolvedLocation = getResolvedLocation(group); - const manualDateRequired = needsManualDate(group); - const manualLocationRequired = needsManualLocation(group); - - return ( -
+
+ {groups.map((group, groupIndex) => ( + + ))} +
+ +
+ + {activeGroup ? ( +
+
group.id === activeGroup.id) + 1 + }`} + > +
+
+

+ 사진 및 준설정보 +

+

+ 사진 메타정보를 먼저 확인하고 누락된 항목만 보완합니다. +

+
+
+
+ + +
+ {groups.length > 1 ? ( + + ) : null} +
+
+ + {activeGroup.registrationMode === "PHOTO" ? ( + <> + photo.file)} + setFiles={(files) => { + void handleFilesChange(activeGroup.id, files); + }} + fileTypes=".jpg,.jpeg,.png" + maxFiles={5} + /> + + {activeGroup.photos.length > 0 ? ( +
+ + + + + + + + + + + + {activeGroup.photos.map((photo) => ( + + + + + + + + ))} + +
사진촬영일시준설일자위치정보상태
+ {photo.file.name} + + {photo.metadata.capturedAt ?? "-"} + + {photo.metadata.dredgingDate ?? "직접 입력 필요"} + + {photo.metadata.location ? ( + + GPS 확인됨 + + ) : ( + + 지도 지정 필요 + + )} + + + {statusLabel(photo.metadata.status)} + +
+
+ ) : ( +
+ 준설사진을 선택하면 촬영일과 위치정보를 자동으로 확인합니다. +
+ )} + + ) : ( +
+ +

+ 사진 없이 준설일자와 지도 위치를 직접 등록합니다. +

+

+ 아래 위치등록 버튼으로 지도 레이어를 열어 위치를 선택해주세요. +

+
+ )} + +
+
+ + {activeManualDateRequired ? ( + + updateManualDate(activeGroup.id, event.target.value) + } + className="w-full" + /> + ) : ( + + {activeResolvedDate ?? "촬영일자 확인 필요"} + {activeResolvedDate ? ( + + 사진 촬영일 + + ) : null} + + )} +
+
+ + + {activeGroup.manualLocation + ? "지도에서 지정한 위치" + : activeResolvedLocation + ? "사진 GPS 위치" + : "지도에서 위치 지정 필요"} + +
+
+
+

- 관로 {groupIndex + 1} + 위치 및 관로 확인

- 사진 묶음 또는 수동 위치 한 건이 하나의 관로·준설일자로 - 저장됩니다. + 아래 지도에서 위치를 확인하고, 위치 적용 후 최근접 관로를 + 자동으로 연결합니다.

-
- - - {groups.length > 1 ? ( +
+ {isEditingActiveLocation ? ( + <> + + + + ) : ( - ) : null} + )}
- {group.registrationMode === "PHOTO" ? ( - <> - photo.file)} - setFiles={(files) => { - void handleFilesChange(group.id, files); - }} - fileTypes=".jpg,.jpeg,.png" - maxFiles={5} - /> - - {group.photos.length > 0 ? ( -
- - - - - - - - - - - - {group.photos.map((photo) => ( - - - - - - - - ))} - -
사진촬영일시준설일자GPS상태
- {photo.file.name} - - {photo.metadata.capturedAt ?? "-"} - - {photo.metadata.dredgingDate ?? - "수동 입력 필요"} - - {photo.metadata.location - ? `${photo.metadata.location.latitude.toFixed(6)}, ${photo.metadata.location.longitude.toFixed(6)}` - : "수동 입력 필요"} - - - {statusLabel(photo.metadata.status)} - -
-
- ) : null} - - ) : ( -
- -

- 사진 없이 지도에서 준설위치를 지정합니다. +

+
+
+ + + {activeGroup.manualLocation + ? "수동 지정" + : activeResolvedLocation + ? "사진 GPS" + : "위치 미지정"} + +
+

+ {activeResolvedLocation + ? "위치가 등록되었습니다. 아래 지도에서 실제 위치를 확인할 수 있습니다." + : "사진에 GPS가 없거나 직접 등록하는 경우 지도에서 위치를 선택해주세요."}

- )} -
-
-
- - {manualDateRequired ? ( - - updateManualDate(group.id, event.target.value) - } - className="w-[180px]" - /> - ) : ( - {resolvedDate ?? "-"} - )} -
-
- - - {resolvedLocation - ? `${resolvedLocation.latitude.toFixed(6)}, ${resolvedLocation.longitude.toFixed(6)}` - : "지도 등록 필요"} - -
- -
- -
-
- - {group.isMatching ? ( +
+
+ + {activeGroup.isMatching ? ( 탐색 중... ) : null}
- {group.matchedPipe ? ( + {hasUnappliedLocationDraft ? ( +

+ 새 위치를 선택했습니다. 상단의 ‘이 위치로 관로 다시 찾기’를 + 눌러 새 좌표를 적용해주세요. +

+ ) : activeGroup.matchedPipe ? (
관로번호
- {group.matchedPipe.fcltMngNo} + {activeGroup.matchedPipe.fcltMngNo}
관리기관
-
{group.matchedPipe.mngInstCd}
+
{activeGroup.matchedPipe.mngInstCd}
최근접 거리
-
{group.matchedPipe.distanceMeters.toFixed(2)}m
+
+ {activeGroup.matchedPipe.distanceMeters.toFixed(2)}m +
신뢰도
-
{confidenceLabel[group.matchedPipe.confidence]}
+
+ {confidenceLabel[activeGroup.matchedPipe.confidence]} +
) : (

- 사진 GPS 또는 수동 위치가 입력되면 최근접 관로를 자동으로 - 찾습니다. + 위치를 확정하면 최근접 관로를 자동으로 찾습니다.

)} - {group.matchError ? ( + {!hasUnappliedLocationDraft && activeGroup.matchError ? (

- {group.matchError} + {activeGroup.matchError}

) : null} - {group.matchedPipe?.confidence === "LOW" ? ( + {activeGroup.matchedPipe?.confidence === "LOW" ? ( ) : null}
-
- ); - })} -
- - - {editingGroup ? ( -
-
-
-

준설위치 등록

-

- 지도 클릭 후 최근접 관로가 자동으로 갱신됩니다. -

+
+ +
- -
- photo.metadata.location) - ?.metadata.location ?? - null - } - onChange={(point) => { - void updateManualLocation(editingGroup.id, point); - }} - /> -
+ +
) : null}
- {statusMessage} + {statusMessage || "사진·준설일자·위치와 자동 연결 관로를 확인해주세요."}
); + if (presentation === "popup") { + return ( + <> +
+
+

준설결과 등록

+ +
+
+ {modalBody} +
+
+ + ); + } + return ( - { - if (!isSaving) onOpenChange(nextOpen); - }} - bodyClass="!max-w-[1200px] w-[92vw]" - headerTitle="준설결과 업로드" - headerTitleClass="text-2xl font-semibold text-slate-900" - body={modalBody} - footerContent={ -
- - -
- } - /> + <> + { + if (!isSaving) onOpenChange(nextOpen); + }} + bodyClass="!max-w-[1200px] w-[92vw]" + headerTitle="준설결과 업로드" + headerTitleClass="text-2xl font-semibold text-slate-900" + body={modalBody} + footerContent={ +
+ + +
+ } + /> + ); }; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/_components/DredgingRegisterPopupPage.tsx b/apps/sn/app/(with-base-layout)/oper/dredging/_components/DredgingRegisterPopupPage.tsx new file mode 100644 index 000000000..c409cc596 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/dredging/_components/DredgingRegisterPopupPage.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { postPopupMessage } from "@/features/windowPopup/lib/popupMessage"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; + +import DredgingRegisterModal from "./DredgingRegisterModal"; + +/** 기존 준설 등록 흐름을 공통 WindowPopup 화면 규격으로 표시한다. */ +const DredgingRegisterPopupPage = () => ( + { + postPopupMessage(WINDOW_POPUP_MESSAGE_TYPE.DREDGING_REGISTERED, { + groups: JSON.stringify(groups), + }); + }} + onOpenChange={(open) => { + if (!open) window.close(); + }} + /> +); + +export default DredgingRegisterPopupPage; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/_lib/client.ts b/apps/sn/app/(with-base-layout)/oper/dredging/_lib/client.ts index 08b91b8f4..84fd86f61 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/_lib/client.ts +++ b/apps/sn/app/(with-base-layout)/oper/dredging/_lib/client.ts @@ -1,9 +1,10 @@ -import { postRawJson } from "@/components/lib/api"; +import { getRawJson, postRawJson } from "@/components/lib/api"; import { handleUpload } from "@/components/lib/utils"; import type { HttpResponse } from "tus-js-client"; import type { DredgingSaveGroup, + DredgingRecord, GeoPoint, MatchConfidence, PipeMatch, @@ -13,13 +14,20 @@ type LocationRequest = GeoPoint & { locationKey: string; }; -type AttachmentReference = { +export type AttachmentReference = { atflCd: string; atflId: string; }; type NearestPipeResponseItem = { locationKey: string; + failureCode?: + | "PIPE_NOT_FOUND" + | "PIPE_ASSET_NOT_MAPPED" + | "PIPE_ASSET_AMBIGUOUS" + | string + | null; + failureMessage?: string | null; selected?: { fid: string; mngInstCd: string; @@ -35,8 +43,68 @@ type NearestPipeResponseItem = { } | null; }; +const nearestPipeFailureMessage = (item: NearestPipeResponseItem) => { + switch (item.failureCode) { + case "PIPE_NOT_FOUND": + return "50m 이내에 관로가 없습니다. 지도에서 준설위치를 다시 확인해주세요."; + case "PIPE_ASSET_NOT_MAPPED": + return "가장 가까운 관로는 찾았지만 자산 매핑 정보가 없습니다. 관로 자산 매핑 데이터를 확인해주세요."; + case "PIPE_ASSET_AMBIGUOUS": + return "가장 가까운 관로의 자산 매핑이 둘 이상입니다. 관로 자산 매핑 데이터를 확인해주세요."; + default: + return ( + item.failureMessage?.trim() ?? + "최근접 관로를 자동으로 연결하지 못했습니다." + ); + } +}; + export const isDredgingBackendEnabled = - process.env.NEXT_PUBLIC_DREDGING_BACKEND_ENABLED === "true"; + process.env.NEXT_PUBLIC_DREDGING_BACKEND_ENABLED !== "false"; + +const toDredgingApiError = (error: unknown, fallbackMessage: string) => { + if (!(error instanceof Error)) return new Error(fallbackMessage); + + // 프록시/서버 오류가 HTML 또는 plain text로 반환되면 공통 JSON 파서의 + // SyntaxError를 그대로 노출하지 않고 사용자가 이해할 수 있는 문구로 바꾼다. + if ( + error instanceof SyntaxError || + /Unexpected token|Internal Server Error|Failed to (fetch|post) data/i.test( + error.message, + ) + ) { + return new Error(fallbackMessage); + } + + return error; +}; + +type DredgingRecordListResponse = { + items: DredgingRecord[]; + total: number; + page: number; + size: number; +}; + +/** 실제 준설관리 API에서 기간 내 등록 기록을 조회한다. */ +export const fetchDredgingRecords = async (params: { + fromDate: string; + toDate: string; + page?: number; + size?: number; +}): Promise => { + if (!isDredgingBackendEnabled) { + return { items: [], total: 0, page: 0, size: params.size ?? 2000 }; + } + const search = new URLSearchParams({ + fromDate: params.fromDate, + toDate: params.toDate, + page: String(params.page ?? 0), + size: String(params.size ?? 2000), + }); + const body = await getRawJson(`v1/dredging/records?${search.toString()}`); + return (body?.data ?? body) as DredgingRecordListResponse; +}; const mockPipeForLocations = (locations: LocationRequest[]): PipeMatch => { const first = locations[0]; @@ -79,12 +147,24 @@ export const findNearestPipeMatches = async ( ); } - const body = await postRawJson("v1/dredging/nearestPipes", { locations }); + let body: any; + try { + body = await postRawJson("v1/dredging/nearestPipes", { locations }); + } catch (error) { + throw toDredgingApiError( + error, + "최근접 관로를 조회하지 못했습니다. 잠시 후 다시 시도해주세요.", + ); + } const items = (body?.data ?? body) as NearestPipeResponseItem[]; const matches = new Map(); + const failureMessages: string[] = []; for (const item of items ?? []) { - if (!item.selected) continue; + if (!item.selected) { + failureMessages.push(nearestPipeFailureMessage(item)); + continue; + } matches.set(item.locationKey, { fid: item.selected.fid, mngInstCd: item.selected.mngInstCd, @@ -101,6 +181,10 @@ export const findNearestPipeMatches = async ( }); } + if (failureMessages.length > 0) { + throw new Error(Array.from(new Set(failureMessages)).join(" ")); + } + return matches; }; @@ -142,12 +226,17 @@ const uploadOnePhoto = (file: File): Promise => * 공통 attach-service TUS 업로드를 순차 실행한다. * 등록 API가 아직 없는 FE 선행 모드에서는 고아 첨부 생성을 막기 위해 전송하지 않는다. */ -export const uploadDredgingPhotos = async (groups: DredgingSaveGroup[]) => { - const references = new Map(); +export const uploadDredgingPhotos = async ( + groups: DredgingSaveGroup[], + existingReferences: Map = new Map(), +) => { + const references = new Map(existingReferences); if (!isDredgingBackendEnabled) return references; for (const group of groups) { for (const photo of group.photos) { + // DB 저장만 실패한 재시도에서는 이미 성공한 공통 첨부 업로드를 재사용한다. + if (references.has(photo.key)) continue; references.set(photo.key, await uploadOnePhoto(photo.file)); } } diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx b/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx index c10539141..aa0a80ae5 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx @@ -7,28 +7,38 @@ */ "use client"; -import { - SearchDiv, - SearchFormLeft, - SearchFormRight, - UiModal, - UiTable, -} from "@common/business_components/ui"; +import dynamic from "next/dynamic"; +import { SearchDiv } from "@common/business_components/ui/search_form"; +import { UiModal } from "@common/business_components/ui/dialog_ui"; +import { UiTable } from "@common/business_components/ui/table"; import ListDiv from "@common/business_components/ui/list_div"; -import { Button, Input, Label } from "@common/components/ui"; -import { useMemo, useState } from "react"; +import { Button } from "@common/components/ui/button"; +import { Input } from "@common/components/ui/input"; +import { Label } from "@common/components/ui/label"; +import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import { useCallback, useEffect, useMemo, useState } from "react"; -import DredgingLocationMap from "./_components/DredgingLocationMap"; -import DredgingRegisterModal from "./_components/DredgingRegisterModal"; -import { isDredgingBackendEnabled } from "./_lib/client"; +import { + fetchDredgingRecords, + isDredgingBackendEnabled, +} from "./_lib/client"; import type { DredgingRecord, DredgingSaveGroup, DredgingViewType, } from "./_lib/types"; +// OpenLayers 지도는 사용자가 지도 팝업을 열 때만 컴파일해 목록의 최초 로딩을 가볍게 유지한다. +const DredgingLocationMap = dynamic( + () => import("./_components/DredgingLocationMap"), + { ssr: false }, +); + const PAGE_SIZE = 10; const currentYear = new Date().getFullYear(); +const ACTION_BUTTON_CLASS = + "rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white"; const formatLocalDate = (date: Date) => { const year = date.getFullYear(); @@ -215,13 +225,51 @@ const Page = () => { const [ctpv, setCtpv] = useState("전체"); const [sgg, setSgg] = useState("전체"); const [viewType, setViewType] = useState("MUNICIPALITY"); - const [records, setRecords] = useState(initialRecords); - const [isRegisterOpen, setIsRegisterOpen] = useState(false); + const [records, setRecords] = useState( + isDredgingBackendEnabled ? [] : initialRecords, + ); + const [isLoading, setIsLoading] = useState(isDredgingBackendEnabled); + const [loadError, setLoadError] = useState(null); const [selectedRecordId, setSelectedRecordId] = useState(null); const [isMapOpen, setIsMapOpen] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [searchVersion, setSearchVersion] = useState(0); + // 조회 버튼을 누른 시점의 기간으로 실제 준설관리 API를 호출한다. + useEffect(() => { + if (!isDredgingBackendEnabled) return; + let cancelled = false; + const load = async () => { + setIsLoading(true); + setLoadError(null); + try { + const response = await fetchDredgingRecords({ + fromDate, + toDate, + size: 2000, + }); + if (!cancelled) setRecords(response.items ?? []); + } catch (error) { + if (!cancelled) { + setRecords([]); + setLoadError( + error instanceof Error + ? error.message + : "준설현황을 조회하지 못했습니다.", + ); + } + } finally { + if (!cancelled) setIsLoading(false); + } + }; + void load(); + return () => { + cancelled = true; + }; + // 조회 버튼과 등록 완료 이벤트에서 searchVersion을 증가시켜 명시적으로 재조회한다. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchVersion]); + const filteredRecords = useMemo( () => records.filter( @@ -333,123 +381,162 @@ const Page = () => { } }; - const handleSaved = (savedGroups: DredgingSaveGroup[]) => { - const registeredDate = formatLocalDate(new Date()); - const addedRecords = savedGroups.map((group, index) => ({ - id: `local-${crypto.randomUUID()}`, - ctpv: ctpv === "전체" ? "경기도" : ctpv, - sgg: sgg === "전체" ? "양평군" : sgg, - treatmentZone: "양평처리구역", - fcltMngNo: group.pipe.fcltMngNo, - pipeDiameterMm: group.pipe.pipeDiameterMm, - pipeLengthM: group.pipe.pipeLengthM, - pipeMaterial: group.pipe.pipeMaterial, - installationYear: group.pipe.installationYear, - dredgingDate: group.dredgingDate, - photoCount: group.photos.length, - registeredDate, - longitude: group.location.longitude, - latitude: group.location.latitude, - registrationSource: group.locationSource, - matchDistanceMeters: group.pipe.distanceMeters, - matchConfidence: group.pipe.confidence, - isMock: group.pipe.isMock || !isDredgingBackendEnabled, - })); - setRecords((current) => [...addedRecords, ...current]); - setCurrentPage(1); + const handleSaved = useCallback( + (savedGroups: DredgingSaveGroup[]) => { + if (isDredgingBackendEnabled) { + setCurrentPage(1); + setSelectedRecordId(null); + setSearchVersion((version) => version + 1); + return; + } + const registeredDate = formatLocalDate(new Date()); + const addedRecords = savedGroups.map((group) => ({ + id: `local-${crypto.randomUUID()}`, + ctpv: ctpv === "전체" ? "경기도" : ctpv, + sgg: sgg === "전체" ? "양평군" : sgg, + treatmentZone: "양평처리구역", + fcltMngNo: group.pipe.fcltMngNo, + pipeDiameterMm: group.pipe.pipeDiameterMm, + pipeLengthM: group.pipe.pipeLengthM, + pipeMaterial: group.pipe.pipeMaterial, + installationYear: group.pipe.installationYear, + dredgingDate: group.dredgingDate, + photoCount: group.photos.length, + registeredDate, + longitude: group.location.longitude, + latitude: group.location.latitude, + registrationSource: group.locationSource, + matchDistanceMeters: group.pipe.distanceMeters, + matchConfidence: group.pipe.confidence, + isMock: group.pipe.isMock || !isDredgingBackendEnabled, + })); + setRecords((current) => [...addedRecords, ...current]); + setCurrentPage(1); + }, + [ctpv, sgg], + ); + + /** 공통 WindowPopup 체계로 준설결과 등록 화면을 연다. */ + const openDredgingRegister = () => { + const popup = openWindowPopup( + "dredgingRegister", + { sourcePath: window.location.pathname }, + { + width: window.screen.availWidth, + height: window.screen.availHeight, + }, + ); + if (!popup) { + alert( + "준설결과 등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.", + ); + } }; + /** 등록 팝업의 저장 완료 메시지를 받아 목록에 신규 준설결과를 반영한다. */ + useEffect(() => { + const handlePopupMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.DREDGING_REGISTERED) { + return; + } + + try { + const savedGroups = JSON.parse( + event.data?.payload?.groups ?? "[]", + ) as DredgingSaveGroup[]; + if (Array.isArray(savedGroups) && savedGroups.length > 0) { + handleSaved(savedGroups); + } + } catch (error) { + console.error("준설결과 등록 팝업 응답 처리 실패", error); + } + }; + + window.addEventListener("message", handlePopupMessage); + return () => window.removeEventListener("message", handlePopupMessage); + }, [handleSaved]); + return ( <> -
-

하수관로 준설

-
- - -
-
- - setFromDate(event.target.value)} - className="w-[155px]" - /> - ~ - setToDate(event.target.value)} - className="w-[155px]" - /> -
- - - - - - +
+
+ + setFromDate(event.target.value)} + className="h-[3.2rem] w-[155px] text-base" + /> + ~ + setToDate(event.target.value)} + className="h-[3.2rem] w-[155px] text-base" + />
- - -
+ + + + + + +
- +
+ {loadError ? ( +
+ 준설현황 조회 실패: {loadError} +
+ ) : null} + { /> - - requestHeaders.delete(headerName)); + requestHeaders.delete("host"); + // Next가 라우트 본문을 Web Stream으로 다시 노출하므로 원 요청 길이를 전달하면 undici가 불일치로 중단할 수 있다. + requestHeaders.delete("content-length"); + // undici fetch는 curl 등이 대용량 요청에 붙이는 Expect: 100-continue 전달을 지원하지 않는다. + requestHeaders.delete("expect"); + + // middleware rewrite와 동일하게 로그인 쿠키의 인증 값을 백엔드 요청 헤더에 주입한다. + const token = request.cookies.get(CLAIM_NAME)?.value; + const userId = request.cookies.get(AUTH_USER_ID)?.value; + if (token) requestHeaders.set(CLAIM_NAME, token); + if (userId) requestHeaders.set(AUTH_USER_ID, userId); + + const targetUrl = `${sewerageApiUrl.replace(/\/$/, "")}${GIS_UPLOAD_PATH}`; + + try { + const upstreamResponse = await fetch(targetUrl, { + method: "POST", + headers: requestHeaders, + body: request.body, + cache: "no-store", + signal: request.signal, + // Node fetch는 ReadableStream 요청 본문을 전송할 때 duplex 지정이 필요하다. + duplex: "half", + } as RequestInit & { duplex: "half" }); + + const responseHeaders = new Headers(upstreamResponse.headers); + HOP_BY_HOP_HEADERS.forEach((headerName) => + responseHeaders.delete(headerName), + ); + + return new Response(upstreamResponse.body, { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, + headers: responseHeaders, + }); + } catch (error) { + console.error("[GIS_UPLOAD_PROXY] 백엔드 업로드 요청 실패", error); + return Response.json( + { + status: 502, + message: + "GIS 업로드 서버 연결이 중단되었습니다. 잠시 후 다시 시도하세요.", + code: "GIS_UPLOAD_PROXY_FAILED", + }, + { status: 502 }, + ); + } +} diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts index b4b766f25..d19be5150 100644 --- a/apps/sn/app/components/lib/api/index.ts +++ b/apps/sn/app/components/lib/api/index.ts @@ -253,7 +253,7 @@ export const postBlobData = async ( export const postMutiPart = async (url: string, data: FormData): Promise => { try { - const res = await axios.post(`${BASE_URL}/${url}`, data, { + const res = await axios.post(resolveApiUrl(url), data, { headers: DEFAULT_HEADERS, timeout: 600000, // 10분 }); diff --git a/apps/sn/docs/작업검증/SFR-010-012-준설관리/001-qa-SFR-010-012-준설관리-검증결과.md b/apps/sn/docs/작업검증/SFR-010-012-준설관리/001-qa-SFR-010-012-준설관리-검증결과.md index ea552859c..769cfcc42 100644 --- a/apps/sn/docs/작업검증/SFR-010-012-준설관리/001-qa-SFR-010-012-준설관리-검증결과.md +++ b/apps/sn/docs/작업검증/SFR-010-012-준설관리/001-qa-SFR-010-012-준설관리-검증결과.md @@ -1,4 +1,131 @@ -# SFR-010-012 준설관리 FE 선행 검증결과 +# SFR-010-012 준설관리 FE 누적 검증결과 + +## 0.6 2026-08-05 사진 위치 보정 이전 오류 제거 회귀검증 + +| 항목 | 결과 | 확인 내용 | +| --- | --- | --- | +| 테스트 사진 EXIF | 통과 | 바탕화면 `KakaoTalk_20260731_133751629.jpg`, 촬영일시 `2026-06-20T15:47:45`, GPS 약 `126.2024635, 36.8887116` 확인 | +| 사진 원위치 API | 통과 | 원본 EXIF 위치는 `PIPE_NOT_FOUND` 응답 확인 | +| 사용자 이동 위치 API | 통과 | `126.2827913451679, 36.87256459006985`는 최근접 관로를 찾고 `PIPE_ASSET_NOT_MAPPED` 응답 확인 | +| 적용 전 상태 표시 | 통과 | 새 지도 위치를 찍으면 사진 원위치의 `50m 이내 관로 없음` 오류를 숨기고 `이 위치로 관로 다시 찾기` 안내 표시 | +| 적용 후 재조회 | 통과 | 버튼 적용 후 편집 상태를 종료하고 실제 새 좌표 응답인 자산 매핑 누락 메시지로 전환 | +| 취소 회귀 | 통과 | 새 임시 좌표를 취소하면 직전 확정 위치와 관로 결과 유지 | +| 상태 메시지 초기화 | 통과 | 위치 편집 시작·좌표 변경·적용 시 이전 저장 오류 문구 제거 | +| 백엔드 대상 테스트 | 통과 | `DredgingServiceTest`, `DredgingNearestPipeRepositoryTest` Gradle 대상 테스트 성공 | +| TypeScript 검사 | 변경 파일 통과/전체 기존 오류 | TypeScript 5.9.3과 `--ignoreDeprecations 5.0` 기준 준설 오류 0건, 준설 외 기존 오류 34건 | +| 전체 Next build | 미수행 | 사용자 소유 3002 개발 서버 보호 기준에 따라 생략 | + +이번 문제는 최근접 관로 계산 오류가 아니라 적용 전 임시 위치와 직전 확정 위치의 오류 메시지를 동시에 보여준 프론트 상태 표시 문제였다. 새 위치 적용 후에는 해당 좌표로 서버를 다시 조회하며, 대상 관로의 SN 자산 매핑이 없으면 임의 매핑하지 않고 `PIPE_ASSET_NOT_MAPPED`를 표시한다. + +## 0.5 2026-08-04 최근접 관로 표시·자산 매핑 오류 회귀검증 + +| 항목 | 결과 | 확인 내용 | +| --- | --- | --- | +| 사용자 선택 위치 재현 | 통과 | 수동 위치 `126.2827913451679, 36.87256459006985`를 동일하게 적용해 `PIPE_ASSET_NOT_MAPPED` 응답 재현 | +| MS 최근접 관로 | 통과 | FID `2413388`, 관리기관 `4620000`, 시설관리번호 `4401598401`, 거리 `0.941m`로 관로 자체는 정상 탐색 | +| SN 기준 데이터 대조 | 확인 필요 | `tb_sn_swage_ppl` 대상 관로 0건, `tb_sn_swage_ppl_ast_mtchn` 자산 매핑 0건으로 필수 `ast_id` 확정 불가 | +| 실패 사유 화면 표시 | 통과 | 프론트가 `PIPE_NOT_FOUND`, `PIPE_ASSET_NOT_MAPPED`, `PIPE_ASSET_AMBIGUOUS`를 구분하고 자산 매핑 누락 안내를 인라인 표시 | +| 저장 검증 메시지 | 통과 | 매핑이 없는 그룹 저장 시 일반 `자동 매핑 완료` 문구 대신 해당 그룹의 실제 매핑 실패 사유 사용 | +| 정상 매핑 API 회귀 | 통과 | 좌표 `129.0463665928, 35.4406778683`에서 관로 `2400429`, 거리 `0.000m`, HIGH 응답 유지 | +| 목록·통계 API 회귀 | 통과 | 현재일 `2026-08-04` 기준 목록 및 `MUNICIPALITY`, `DIAMETER`, `ELAPSED_YEAR` HTTP 200, 현재 데이터 0건 | +| 백엔드 대상 테스트 | 통과 | `DredgingServiceTest` 7건, `DredgingNearestPipeRepositoryTest` 1건, 총 8건 성공 | +| TypeScript 검사 | 변경 파일 통과/전체 기존 오류 | TypeScript 5.9.3으로 전체 검사했으며 준설 변경 파일 오류는 0건. 지도관리·조사관리 등 기존 오류 34건은 별도 범위 | +| 전체 Next build | 미수행 | 사용자 소유 3002 개발 서버 보호 기준에 따라 생략 | + +파란 선은 현재 지도 범위의 주변 관로이며, 주황색 자동 연결 관로는 최근접 관로에 단일 자산 ID까지 확정된 경우에만 표시한다. 이번 위치는 관로까지 0.941m로 가깝지만 SN 자산 기준 데이터가 없으므로 임의 `ast_id`를 생성하지 않고 등록을 차단하는 것이 DB 설계 계약과 일치한다. + +## 0.4 2026-08-04 미완료 보완 및 회귀검증 + +| 항목 | 결과 | 확인 내용 | +| --- | --- | --- | +| 원본 요구사항 재확인 | 통과 | 화면설계서 51~55쪽 `SN_MN040201~0203`, 요구사항정의서 84~85행 `SFR-010-012/013` 직접 확인 | +| 최근접 관로 실 API | 통과 | EXIF 기준 좌표 `129.0463665928, 35.4406778683`에서 FID `3538280`, 관로 `2400429`, 거리 `0.000m`, HIGH 응답 | +| 관로 WFS 식별자 연계 | 통과 | 같은 FID의 WFS feature ID가 `tb_ms_swage_ppl.3538280`, 시설관리번호가 `2400429`임을 실 GeoServer로 확인하여 자동 연결 관로 강조 조건과 일치 | +| 프록시/서버 오류 표시 | 통과 | JSON이 아닌 500 응답의 `Unexpected token` 원문 대신 `최근접 관로를 조회하지 못했습니다...` 사용자 문구로 변환 | +| 주변 관로 표시 | 통과 | 수동 위치 선택 후 지도 줌 17, `주변 관로 128건` 및 `154건` 표시를 실제 화면에서 확인 | +| 개발 DB 등록 | 통과 | 수동 등록 1건 생성(jobSn `81`), 상세·목록 조회 후 논리 삭제 및 잔여 0건 확인 | +| 멱등 등록 | 통과 | 같은 `clientRequestId` 재호출 시 등록 0건·기등록 1건 반환 | +| 통계 API 회귀 | 통과 | `MUNICIPALITY`, `DIAMETER`, `ELAPSED_YEAR` 모두 응답 1행 확인 | +| 목록 화면 회귀 | 통과 | 지자체별·관경별·경과년도별 제목/컬럼 전환, 최종 목록 0건, 콘솔 error/warn 0건 | +| 백엔드 대상 테스트 | 통과 | `DredgingServiceTest`, `DredgingNearestPipeRepositoryTest` 8건 통과 | +| 정적 검사 | 부분 확인 | 변경 파일 `git diff --check` 통과. 전체 `tsc --noEmit`은 60초 초과로 종료하고 해당 프로세스 트리를 정리 | +| 전체 Next build | 미수행 | 사용자 소유 3002 개발 서버 보호 기준에 따라 생략 | + +추가 증적: `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/014-준설관리-회귀검증-경과년도별.png` + +Chrome 확장의 파일 URL 접근 권한이 꺼져 있어 이번 회귀에서 EXIF 사진을 다시 선택해 주황색 관로를 화면 캡처하지는 못했다. 다만 이전 실제 EXIF·최근접 관로·공통 첨부·DB 저장 검증과 이번 WFS feature ID 대조로 데이터 연계 조건을 재확인했다. + +## 0.3 2026-08-04 준설 지도 관로 표시 검증 + +| 항목 | 결과 | 확인 내용 | +| --- | --- | --- | +| 원본 요구사항 재확인 | 통과 | 화면설계서 51~52쪽 `SN_MN040201`, 요구사항정의서 84~85행 `SFR-010-012/013` 직접 확인 | +| 기존 관망도 계약 재사용 | 통과 | `/geoserver/hasudo/ows`의 `hasudo:tb_ms_swage_ppl` WFS가 EPSG:5179 `MultiLineString`을 반환함을 확인 | +| 상세 축척 제한 | 통과 | 줌 13 미만에서는 관로 조회를 시작하지 않고, BBOX·`MAXFEATURES=2000`으로 조회 범위를 제한 | +| 주변 관로 실표시 | 통과 | 수동 위치 선택 후 브라우저 상태 `주변 관로 169건 표시`, 파란 관로 선형 렌더링 확인 | +| 위치 마커 중첩 | 통과 | 파란 관로 선형 위에 선택 위치 마커가 별도 레이어로 표시됨을 확인 | +| 자동 연결 관로 스타일 | 정적 통과 | 최근접 관로 FID와 WFS feature ID를 정규화해 일치 관로에 주황색 굵은 스타일 적용 | +| 자동 연결 관로 실행 | 백엔드 차단 | `선택 위치 적용` 시 현재 `nearestPipes`가 HTTP 500 문자열을 반환해 `Unexpected token 'I', "Internal S"...` 오류로 종료되어 주황색 강조 실행은 확인하지 못함 | +| 전체 TypeScript | 기존 오류로 실패 | 최초 전체 검사에서 변경 파일 형변환 1건을 확인·수정했으며, 나머지는 기존 지도관리·조사관리 오류. 제한 재검사는 로컬 부하로 90초 초과 | +| 전체 Next build | 미수행 | 사용자 소유 3002 개발 서버 보호 기준에 따라 생략 | + +증적: `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/013-준설등록-주변관로-표시.png` + +## 0.2 2026-08-04 풀사이즈·통합 지도 UI 검증 + +| 항목 | 결과 | 확인 내용 | +| --- | --- | --- | +| 원본 요구사항 재확인 | 통과 | 화면설계서 50~55쪽 `SN_MN040201~0203`, 요구사항정의서 84~85행 `SFR-010-012/013` 직접 확인 | +| 호출별 팝업 크기 | 통과 | `openWindowPopup(key, params, { width, height })` 선택 옵션과 준설 호출의 `screen.availWidth/availHeight` 전달 확인 | +| 정보영역 통합 | 통과 | 사진·준설일자·위치·자동 연결 관로가 하나의 등록 카드 안에 렌더링 | +| 지도 레이어 제거 | 통과 | 등록창에 추가 `UiModal` 없이 하단 공통 `Gis` 상시 렌더링 | +| 상단 밀도 축소 | 통과 | 안내·탭·빈 상태·요약 카드 간격 축소, 사진 목록 최대 `180px` | +| 지도 확대 | 통과 | 지정 높이 `540px`, 브라우저 실측 상태바 포함 약 `566px` | +| 버튼 통일 | 통과 | 주요 등록 버튼 높이 25px, primary/secondary/danger 규칙 적용 | +| 위치 편집 전환 | 통과 | `위치등록` 클릭 시 `취소`·비활성 `선택 위치 적용` 표시 및 지도 클릭 안내 전환 | +| 취소 회귀 | 통과 | 취소 후 `위치등록` 복원, 미지정 상태와 기존 위치 유지 문구 확인 | +| 개발 서버 컴파일 | 통과 | 사용자 실행 중인 `localhost:3002`에서 변경 경로가 Build/Runtime Error 없이 렌더링 | +| 전체 TypeScript | 기존 오류로 실패 | 변경 파일 오류는 출력되지 않았고, 지도관리·조사관리 등 기존 범위 오류가 남아 있음 | +| ESLint | 환경 보류 | 설치된 의존성에서 `eslint-plugin-react-hooks`를 해석하지 못함 | +| 전체 Next build | 미수행 | 사용자 소유 3002 개발 서버 보호 기준에 따라 생략 | + +증적: `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/012-준설등록-풀사이즈-통합지도.png` + +## 0.1 2026-08-04 백엔드·DB 실연동 검증 + +| 항목 | 결과 | 확인 내용 | +| --- | --- | --- | +| 실 API 목록 | 통과 | `/oper/dredging` 총 0건 → 등록 후 총 1건 → 정리 후 0건 | +| EXIF 자동 등록 | 통과 | 촬영일 `2026-08-03`, GPS, 관로 `2400429`, 거리 0.00m, HIGH | +| 공통 TUS 업로드 | 통과 | 실제 공통 첨부 코드와 36자 파일 ID를 DB에 저장 | +| 등록 후 재조회 | 통과 | 사진건수 1, 준설일자·등록일자 목록 반영 | +| 관경별 통계 | 통과 | 경상남도 0.043km, 450 이하 0.043km | +| 경과년도별 통계 | 통과 | 경상남도 0.043km, 10년 미만 0.043km | +| 목록 지도보기 | 통과 | 선택 행 위치·관로를 레이어 dialog로 표시 | +| 브라우저 콘솔 | 통과 | error/warn 0건 | +| 최신 Next 개발 컴파일 | 통과 | 목록과 등록 팝업에 Build/Runtime Error 없음 | +| XLSX 다운로드 이벤트 | 미확인 | 클릭 시 콘솔 오류는 없었으나 자동화 이벤트 포착 시간 초과 | + +추가 증적: `007`~`011` PNG를 `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/`에 저장하였다. + +## 0. 2026-07-31 조사관리 UI 정렬 변경 검증 + +| 항목 | 결과 | 근거 | +| --- | --- | --- | +| 원본 요구사항 선확인 | 통과 | 화면설계서 50~55쪽 `SN_MN040201~0203`, 요구사항정의서 84~85행 `SFR-010-012/013` 확인 | +| 조사관리 검색영역 규칙 반영 | 정적 통과 | 단일 가로 검색행, 동일 라벨·입력 높이·우측 파란 버튼군 적용 | +| 중복 화면 제목 제거 | 정적 통과 | 화면 내부 `하수관로 준설` 제목 제거 | +| 등록 WindowPopup 전환 | 정적 통과 | `dredgingRegister` 정의·레지스트리·팝업 페이지·완료 메시지 연결 | +| 등록창 지도 레이어 분리 | 정적 통과 | 등록 WindowPopup의 상시 지도 제거, `지도보기` 조회 레이어와 `위치등록/수정` 선택 레이어 분리 | +| 위치 적용 상태 분리 | 정적 통과 | 지도 클릭 위치는 임시 상태로 유지하고 `선택 위치 적용` 이후에만 최근접 관로 조회 | +| 변경 파일 제한 타입 검사 | 통과 | 변경 대상 파일 오류 0건 | +| 전체 TypeScript 검사 | 기존 오류로 실패 | 변경 대상 오류 0건, 그 외 기존 오류 34건 | +| ESLint | 환경 보류 | 설치된 의존성에서 `eslint-plugin-react-hooks`를 해석하지 못해 실행 불가 | +| 팝업 경로 응답 | 통과 | `/popup/dredgingRegister?sourcePath=%2Foper%2Fdredging` HTTP 200, 빌드 오류 문자열 없음 | +| 최신 브라우저 화면·팝업 재검증 | 차단 | Codex 인앱 브라우저가 `localhost:3002` 접근을 URL 보안 정책으로 거부 | +| 전체 Next build | 미수행 | 포트 3002 개발 서버 실행 중 보호 기준에 따라 생략 | + +최신 UI 변경은 코드, 타입 검사와 실행 중인 개발 서버의 경로 응답으로 확인했으나 지도 레이어의 실제 크기·포커스·지도 클릭·취소·적용 흐름은 브라우저 재검증이 필요하다. 기존 2026-07-30 기능 흐름 검증 결과는 아래에 보존한다. ## 1. 검증 환경 @@ -45,6 +172,11 @@ ## 5. 후속 검증 +- 조사관리와 준설관리의 검색영역 높이·입력 정렬·버튼 위치를 같은 해상도에서 비교한다. +- 등록 버튼 클릭 시 별도 WindowPopup이 열리고 부모 화면에는 모달 오버레이가 생기지 않는지 확인한다. +- 등록 WindowPopup 하단 지도가 상시 표시되고 `위치등록/수정` 중에만 클릭 편집되는지 확인한다. +- 본문 지도 편집 취소 시 기존 위치가 유지되고 적용 시에만 최근접 관로가 갱신되는지 확인한다. +- 팝업 저장 완료 메시지 후 부모 목록 건수와 신규 행이 갱신되는지 확인한다. - 백엔드 구현 후 공통 첨부 TUS 응답과 준설 등록 트랜잭션을 실제로 확인한다. - 실제 MS 관로 geometry로 거리·스냅 좌표·동률 처리·탐색 반경을 검증한다. - 서버 원본 EXIF 재분석 결과와 브라우저 분석 결과 불일치 케이스를 검증한다. diff --git a/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/002-qa-SFR-012-002-하수관망도 업로드-통합검증결과.md b/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/002-qa-SFR-012-002-하수관망도 업로드-통합검증결과.md index 9003fb241..5a497aa91 100644 --- a/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/002-qa-SFR-012-002-하수관망도 업로드-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/002-qa-SFR-012-002-하수관망도 업로드-통합검증결과.md @@ -61,3 +61,122 @@ - 실제 파일 선택, 업로드 버튼 클릭, 행 선택 상호작용은 브라우저 수동 확인이 한 번 더 필요하다. - 저장소 기존 타입 오류 때문에 이번 기능만 분리한 정적 통과 여부를 보장하기 어렵다. - 시설구분 목록은 아직 실제 시설물 레이어 조회 결과와 자동 동기화되지 않는다. + +## 5. 2026-07-31 가평군 GPKG 다운로드 오류 회귀 검증 + +### 5.1 요구사항 추적 + +- 요구사항정의서: `SFR-012-002 하수관로자료 업로드/다운로드` +- 화면설계서: 슬라이드 33, 화면 ID `SN_MN010302` +- 검증 범위: 관리기관 `가평군(4160000)` 최신 다운로드 및 이력 선택 다운로드 + +### 5.2 오류 원인 + +- Chrome 콘솔에서 최신 다운로드 요청이 간헐적으로 `TypeError: Failed to fetch`로 종료된 기록을 확인했다. +- 동일한 관리기관 요청을 다시 실행하면 정상 GPKG가 생성됐고 파일 내부 데이터도 정상이었다. +- 따라서 가평군 데이터 생성 실패가 아니라 브라우저와 Next 프록시 사이의 일시적 전송 단절을 재시도 없이 사용자 오류로 처리한 것이 직접 원인이었다. + +### 5.3 수정 내용 + +- 현재 및 이력 다운로드의 멱등 GET 요청에 한해 네트워크 단절 시 500ms 후 1회 재시도한다. +- HTTP 4xx/5xx와 업무 검증 오류는 재시도하지 않고 기존 오류 처리로 전달한다. +- Chrome이 다운로드 클릭을 인계할 시간을 확보하도록 Blob object URL을 1초 뒤 해제한다. + +### 5.4 실제 브라우저 검증 결과 + +- 관리기관 선택: `가평군` +- 업로드 이력 조회: 9건 표출 +- 최신 다운로드: 성공 메시지 및 파일 생성 확인 +- 첫 번째 업로드 이력 선택 다운로드: 성공 메시지 및 파일 생성 확인 +- 검증 종료 시 Chrome `error`, `warn` 로그: 0건 + +### 5.5 생성 GPKG 검증 결과 + +- 파일명: `하수관로_가평군_20260731 (1).gpkg`, `하수관로_가평군_20260731 (2).gpkg` +- 파일 크기: 각 11,169,792 bytes +- SQLite `PRAGMA integrity_check`: `ok` +- 레이어: `tb_ms_swage_ppl` +- 피처 수: 16,670건 +- 좌표계: EPSG:5179 +- FID: `fid INTEGER PRIMARY KEY`, `fid_1` 없음 +- 공간 인덱스: `rtree_tb_ms_swage_ppl_geom` 존재 + +### 5.6 정적 검증 참고 + +- `git diff --check`: 통과 +- 실행 중인 Next 개발 서버에서 수정 화면 컴파일 및 실제 브라우저 동작: 통과 +- Prettier: 저장소에 실행 파일이 없어 미수행 +- TypeScript 단독 검사: 기존 `tsconfig.json`의 `ignoreDeprecations` 값과 로컬 실행 설정 오류로 시작되지 못함 + +## 6. 2026-08-04 업로드 오류 수정 및 전체 회귀 검증 + +### 6.1 요구사항 추적 + +- 요구사항정의서: `SFR-012-002 하수관로자료 업로드/다운로드`, `SFR-012-003 하수관로 편집` +- 화면설계서: 화면 ID `SN_MN010302`(GIS 관리), `SN_MN010301`(GIS 편집) +- 검증 시나리오: `최신 다운로드 → GeoPackage 신규·수정·삭제 → 브라우저 업로드 → 이력·미리보기·오류·재다운로드 확인` + +### 6.2 재현 및 원인 + +- 동일한 11,169,792 byte GeoPackage를 백엔드에 직접 업로드하면 약 9.6초에 `HTTP 200`으로 완료됐다. +- 기존 `/sewerage-service/**` middleware rewrite 경로에서는 multipart 본문 전달이 60초 이상 지연됐고, 재현 요청 1건은 120.576초 후 `HTTP 500 / E999 / ERROR`로 종료됐다. +- 실패 요청은 업로드 이력과 GIS 데이터가 모두 롤백됐다. +- 원인은 대용량 multipart 본문이 일반 middleware rewrite의 요청 복제·프록시 경로를 통과한 데 있었다. + +### 6.3 수정 내용 + +- `app/api/gis-upload/route.ts`에 Node runtime 기반 GIS 업로드 전용 스트리밍 프록시를 추가했다. +- multipart 본문을 메모리에 다시 적재하지 않고 `ReadableStream`으로 백엔드에 전달한다. +- middleware와 동일하게 로그인 쿠키 값을 인증 헤더로 전달하고, hop-by-hop 헤더와 `content-length`, `Expect`는 제거한다. +- `postMutiPart`가 `/`로 시작하는 앱 내부 경로를 `resolveApiUrl`로 처리하게 하고 GIS 관리 popup만 `/api/gis-upload`를 사용하도록 전환했다. +- 백엔드 연결 실패 시 `GIS_UPLOAD_PROXY_FAILED`와 사용자용 502 메시지를 반환한다. + +### 6.4 전용 경로 검증 + +- 기존 middleware 경로 재현: `HTTP 500`, 120.576초, `E999 / ERROR` +- 전용 스트리밍 경로 무변경 파일: `HTTP 200`, 15.591초, 변경 0건 +- 실행 중인 Next 개발 서버의 HMR 상태에서 실제 브라우저 업로드: 성공 + +### 6.5 신규·수정·삭제 회귀 결과 + +- 관리기관: `가평군(4160000)` +- 기준 피처 수: 16,670건 +- 삭제: `fid=1891743`, 최종본에서 미존재 확인 +- 수정: `fid=1891744`, `fclt_mng_no=QA_BROWSER_UPDATE_20260804` 확인 +- 신규: `fid=4132722`, 최종본 1건 존재 확인 +- 업로드 ID: `GUP-20260804165133-057BE473` +- 업로드 상태: `COMPLETED` +- 신규/수정/삭제/변경/오류: `1 / 1 / 1 / 3 / 0` +- 미리보기: 변경 관로 표출, `삭제 미표시 1건` 확인 +- 오류 확인: `표시할 오류가 없습니다.` 확인 +- 선택 이력 다운로드: 성공 메시지와 `HTTP 200` 확인 +- 현재본/선택 이력본: 각각 16,670건, SQLite `integrity_check=ok`, 대상 FID 반영 일치 + +### 6.6 회귀 테스트 데이터 복원 + +- 회귀 검증 완료 후 테스트 직전 기준본을 동일한 전용 스트리밍 경로로 다시 업로드했다. +- 복원 업로드 ID: `GUP-20260804170432-06A6347B`, 상태 `COMPLETED`, 신규/수정/삭제/오류 `1 / 1 / 1 / 0` +- 복원 후 현재본은 16,670건, 스키마 일치, SQLite `integrity_check=ok`이며 자동 생성 FID를 제외한 업무 데이터 전체가 기준본과 일치한다. +- 테스트 신규 `fid=4132722`는 제거됐고 `fid=1891744` 수정값은 정확히 복원됐다. +- 삭제 후 재생성된 원본 업무 피처는 DB 자동 채번 정책에 따라 `fid=1891743` 대신 `fid=4132723`으로 복원됐다. 업무 속성·도형은 기준본과 동일하다. + +### 6.7 정적 검증 및 제한 + +- 신규 route 파일 Prettier 검사: 통과 +- 브라우저 console error: 0건 +- 미리보기에서 오류 화면으로 전환할 때 숨겨진 OpenLayers 컨테이너 크기 0 경고 1건이 있으나 기능 실패는 없었다. +- ESLint는 기존 `eslint.config.mjs:15` 설정 예외로 실행되지 않았다. +- 기본 TypeScript 검사는 기존 `tsconfig.json`의 `ignoreDeprecations=6.0` 값 때문에 시작되지 않았고, `--ignoreDeprecations 5.0`으로 재실행하면 기존 파일 오류가 남았다. 이번 수정 파일에서 신규 타입 오류는 출력되지 않았다. +- 사용자가 실행한 Next 개발 서버를 보호하기 위해 `next build`는 수행하지 않았다. + +### 6.8 증빙 + +- 공통 증빙 폴더: `COMMON_DOCS_ROOT/작업내용/SFR-012-002-하수관망도 업로드/검증/20260804/` +- 완료 화면: `17-regression-upload-completed.png` +- 미리보기: `19-regression-preview.png` +- 오류 0건: `20-regression-no-validation-errors.png` +- API 이력: `18-regression-history.json` +- 데이터 대조: `23-regression-data-verification.txt` +- 복원 응답: `28-regression-rollback-completed.json` +- 복원 후 현재본·이력: `29-current-after-rollback.gpkg`, `30-history-after-rollback.json` +- 복원 데이터 대조: `32-regression-rollback-semantic-verification.txt` diff --git a/apps/sn/docs/작업계획/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-작업카드.md b/apps/sn/docs/작업계획/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-작업카드.md index ca84bc821..650ca7444 100644 --- a/apps/sn/docs/작업계획/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-작업카드.md +++ b/apps/sn/docs/작업계획/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-작업카드.md @@ -8,7 +8,7 @@ - 작성일: 2026-05-18 - 담당 역할: FE - 관련 역할: DE, BE, QA -- 담당 범위: `apps/sn` 지도 화면에서 통수위험 진입, 결과 조회 popup, 지도보기, 설정 현황, staged 유출관로 선택, 저장 payload 준비까지 +- 담당 범위: `apps/sn` 지도 화면에서 통수위험 진입과 현재 위험/과거 이력 결과 조회 popup UI까지 - 근거 문서 - `COMMON_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/001-de-SFR-011-007-통수위험분석-설계.md` - `COMMON_DOCS_ROOT/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합공유기록.md` @@ -27,9 +27,9 @@ ## 2. 작업 목적 -- 기존 하수관망도 지도 화면에 `통수위험` 기능을 추가해 사용자가 결과 조회, 지도보기, 설정 현황 조회, 유출관로 설정 편집을 한 흐름 안에서 수행할 수 있게 한다. -- 프론트는 백엔드 연결 전 단계에서 popup UI, 동적 관로 컬럼, mock 지도 강조, staged 설정 목록, 저장 payload preview까지 먼저 구현한다. -- 실제 산정 로직, geometry 데이터, 다운로드 파일, 저장 API는 BE 범위로 분리하고 FE는 그 계약을 맞출 준비 상태까지 수행한다. +- 기존 하수관망도 지도 화면에 `통수위험` 기능을 추가해 사용자가 현재 위험과 과거 이력 결과를 한 popup에서 조회할 수 있게 한다. +- 프론트는 백엔드 연결 전 단계에서 결과 조회 popup UI와 mock 결과 데이터를 먼저 구현한다. +- 실제 통수량 산정 로직과 조회 API는 후속 BE/FE 공동 범위로 분리한다. ## 2-1. 최근 진행 갱신 @@ -173,3 +173,38 @@ - staged 설정 목록과 저장 payload preview가 준비된다. - FE-only 프로토타입 기준 구현이 완료되고, BE 연동 및 실제 통합 검증 후속 범위가 문서로 정리된다. - 지도 상세정보 카드 위치 규칙은 현재 프로토타입 기준 동작으로 마감하고, 운영 기준 확정은 후속 검증으로 넘긴다. + +## 13. 2026-07-31 UI 범위 정정 + +이 절은 사용자 최신 지시에 따라 위의 과거 설정 기능 관련 기술을 대체한다. + +### 원본 추적 근거 + +- 화면설계서: `REQUIREMENTS_ROOT/화면설계서.pptx` 19쪽 + - 화면 ID: `SN_MN010201` + - 요구사항 ID: `SFR-011-007` + - 화면명: `통수위험분석결과 조회` +- 요구사항정의서: `REQUIREMENTS_ROOT/요구사항정의서.xlsx` + - 시트: `3. (기능)요구사항정의 및 추적표` + - 행: 93 + - 요구사항 ID/명: `SFR-011-007` / `통수량 산정` + +### 최신 결정 + +- 화면설계서 19쪽에는 `설정` 버튼이 있으나, 사용자 명시 지시에 따라 설정 버튼·설정 화면·설정 편집 흐름을 결과 popup에서 제거한다. +- popup은 `현재 위험`과 `과거 이력` 결과 조회만 제공한다. +- 검색조건은 다른 지도 popup과 같은 회색 검색 패널, 동일한 라벨/필드 정렬, 단일 `조회` 주 버튼 구조로 통일한다. +- 부모 지도 검색 컨트롤과 popup 검색 컨트롤은 모두 `Noto Sans KR`, `12.5px`, 높이 `35px`를 사용한다. +- 실제 API와 산정 기능은 이번 범위에 포함하지 않고 후속 BE/FE 공동 구현으로 남긴다. + +### 검증 결과 + +- `http://localhost:3002/popup/flowRiskPopup` +- 설정 버튼 부재, 현재 위험/과거 이력 탭 전환, 위험상태 조회 20건 → 10건 필터링을 브라우저에서 확인했다. +- 부모 지도 `.selectbox_trigger`와 popup 날짜·선택·조회 컨트롤의 계산 스타일이 모두 `Noto Sans KR`, `12.5px`, `35px`로 일치함을 확인했다. +- 로컬 인증 API가 응답하지 않아 개발 검증용 인증 우회를 일시 적용했으며, 검증 직후 원상복구했다. + +### 후속 확인 필요 + +- 요구사항정의서 93행은 합리식 `Q=CIA`와 Manning 공식을 요구하지만, 화면설계서 20쪽은 SWMM5 연계를 제시한다. 실제 산정 기능 설계 전에 적용 기준을 확정해야 한다. +- 2026-08-04 실제 API 연동 시 `map/page.tsx`, 세션 타입, popup 메시지 타입에 남아 있던 설정 편집·staged 관로 호환 코드도 제거해 결과 조회와 지도보기 계약만 유지했다. 상세 구현·검증은 `002-fe-SFR-011-007-통수위험분석-실API연동및검증.md`를 따른다. diff --git a/apps/sn/docs/작업계획/SFR-011-007-통수위험분석/002-fe-SFR-011-007-통수위험분석-실API연동및검증.md b/apps/sn/docs/작업계획/SFR-011-007-통수위험분석/002-fe-SFR-011-007-통수위험분석-실API연동및검증.md new file mode 100644 index 000000000..3bb2cc26b --- /dev/null +++ b/apps/sn/docs/작업계획/SFR-011-007-통수위험분석/002-fe-SFR-011-007-통수위험분석-실API연동및검증.md @@ -0,0 +1,43 @@ +# SFR-011-007 통수위험분석 FE 실 API 연동 및 검증 + +## 1. 목적 + +- 기존 FE mock 결과를 `/api/v1/waterRisk/**` 실제 응답으로 교체한다. +- 부모 지도와 popup의 기존 `sn` 글꼴·조회조건 디자인을 유지한다. +- 사용자 결정에 따라 설정 버튼·설정 화면·설정 API는 제공하지 않는다. + +## 2. 구현 내용 + +- `WaterRiskPopupPage.tsx` + - 현재 위험/과거 이력 탭, 날짜·시도·시군구·중점관리지역·위험상태 조회 + - 서버 페이지네이션, 로딩·오류·빈 결과 처리 + - 현재누적강우량 원천이 없으면 `-` 표기 + - 현재 탭은 예측강우량, 과거 탭은 최대강우강도 컬럼 사용 + - 결과 행 선택 시 mapView 조회 후 부모창에 결과와 geometry 전달 +- `waterRiskClient.ts` + - 중점관리지역, 결과 목록, mapView 실제 API 호출 +- `waterRiskTypes.ts` + - 실제 API 계약, nullable 강우 필드, `RATIONAL_MANNING`, 관로별 위험상태 정의 +- `map/page.tsx` + - EPSG:5179 MultiPolygon/MultiLineString 강조 + - 선택 지역 fit/정보 카드 표시 + - 유출유량·통수량 단위를 `㎥/s`로 통일 + +## 3. 검증 결과 + +- URL: `http://localhost:3002/popup/flowRiskPopup` +- 실제 API로 위험/정상 행, 현재/과거 탭, 위험상태 필터, 시도→시군구→중점관리지역 cascade 확인 +- 결과 행 클릭 후 mapView 요청 성공과 부모 지도 강조 메시지 확인 +- popup 콘솔 오류 없음 +- 화면 증적: `COMMON_DOCS_ROOT/작업내용/SFR-011-007-통수위험분석/검증/001-통수위험-현재위험-실API.png` +- 앱 TypeScript 전체 검사는 통수위험 변경 파일 오류가 없었으나, 기존 타 기능의 타입 오류 때문에 전체 명령은 실패했다. + - 실행: `apps/sn/node_modules/.bin/tsc.CMD -p apps/sn/tsconfig.json --noEmit --pretty false --incremental false --ignoreDeprecations 5.0` + - 잔여 오류: `fldngHstry`, GIS 관리, `exmn`, 메뉴 alias, `map/page.tsx`의 기존 `storageNm` 등 이번 범위 밖 baseline +- 사용자 소유 Next 개발 서버는 중단하거나 빌드하지 않았다. + +## 4. 영향·롤백·잔여 위험 + +- 영향: `components/waterRisk/**`, 부모 지도에서 통수위험 결과 메시지와 geometry 표시 부분 +- 롤백: `waterRiskClient.ts`를 제거하고 popup의 실제 조회/부모 메시지 변경 및 `map/page.tsx` 통수위험 mapView 처리를 되돌린다. +- 자동화 도구가 새 창 popup을 부모 탭과 동시에 제어하는 데 제한이 있어, popup의 실제 mapView 요청과 부모 메시지 처리 코드를 각각 확인했다. 최종 사용자 회귀 시 부모 지도에서 popup을 열고 한 행을 선택하는 시나리오를 한 번 더 수행할 수 있다. +- 현재누적강우량은 백엔드 원천 미확보로 `-`이며, 예측강우 격자 overlay는 이번 합리식 결과 범위에 포함하지 않는다. diff --git a/apps/sn/docs/작업내용/SFR-010-012-준설관리/001-fe-SFR-010-012-준설관리-구현기록.md b/apps/sn/docs/작업내용/SFR-010-012-준설관리/001-fe-SFR-010-012-준설관리-구현기록.md index 76725d818..0fd0e54a0 100644 --- a/apps/sn/docs/작업내용/SFR-010-012-준설관리/001-fe-SFR-010-012-준설관리-구현기록.md +++ b/apps/sn/docs/작업내용/SFR-010-012-준설관리/001-fe-SFR-010-012-준설관리-구현기록.md @@ -1,21 +1,90 @@ # SFR-010-012 준설관리 프론트엔드 구현기록 +## 0.6 2026-08-05 사진 위치 보정 시 이전 관로 오류 제거 + +- 사진 EXIF 원위치에서 발생한 `PIPE_NOT_FOUND` 결과가 지도에서 새 위치를 선택한 뒤에도 남아 새 좌표의 조회 결과처럼 보이던 상태 표시 오류를 수정하였다. +- 확정 위치와 다른 지도 임시 좌표를 `hasUnappliedLocationDraft`로 구분하고, 적용 전에는 이전 관로·오류를 숨긴 뒤 `이 위치로 관로 다시 찾기` 안내만 표시한다. +- 위치 편집 시작, 지도 좌표 변경, 선택 위치 적용 시 이전 저장 상태 메시지를 비워 서로 다른 좌표의 결과가 섞이지 않게 하였다. +- 위치 확정과 최근접 관로 API 호출은 기존과 같이 사용자가 `이 위치로 관로 다시 찾기`를 눌렀을 때만 수행하며, 취소 시 기존 확정 위치와 매핑 결과를 유지한다. +- 테스트 사진 `KakaoTalk_20260731_133751629.jpg`의 EXIF 원위치는 `PIPE_NOT_FOUND`, 사용자가 이동한 위치는 `PIPE_ASSET_NOT_MAPPED`로 응답해 새 좌표가 실제 API에 전달됨을 확인하였다. + +## 0.5 2026-08-04 자동 매핑 실패 사유 표시 보완 + +- 최근접 관로 API가 `selected: null`과 함께 반환하는 `failureCode`, `failureMessage`를 버리지 않고 화면 오류로 전달한다. +- `PIPE_NOT_FOUND`는 위치 재확인, `PIPE_ASSET_NOT_MAPPED`와 `PIPE_ASSET_AMBIGUOUS`는 관로 자산 매핑 데이터 확인이 필요하다는 안내로 구분한다. +- 저장 시 `최근접 관로 자동 매핑을 완료해주세요`만 반복하지 않고, 이미 확인된 그룹별 실제 실패 사유를 경고창에 표시한다. +- 사용자 선택 위치의 관로 FID `2413388`은 0.941m 거리로 탐색됐지만 SN 관로 원장과 자산 매핑이 모두 0건임을 개발 DB에서 확인했다. 필수 `ast_id`를 임의 생성하지 않고 등록을 차단한다. + +## 0.4 2026-08-04 미완료 보완 + +- 최근접 관로 API 또는 프록시가 JSON이 아닌 오류 본문을 반환해도 공통 JSON 파서의 `Unexpected token` 문구를 화면에 노출하지 않고 준설관리용 재시도 안내로 변환한다. +- 개발 DB의 최근접 관로 FID `3538280`과 GeoServer WFS feature ID `tb_ms_swage_ppl.3538280`을 대조해 자동 연결 관로 주황색 강조의 식별자 정규화 계약을 확인했다. +- 실제 API로 수동 등록·멱등 재등록·상세·목록·3개 통계·삭제를 순차 회귀검증하고 생성한 jobSn `81`을 정리했다. +- 실제 화면에서 주변 관로 최대 154건 표시와 지자체별·관경별·경과년도별 화면 전환을 재검증했다. + +## 0.3 2026-08-04 준설 지도 관로 선형 표시 + +- 준설 등록 하단 지도에 기존 관망도와 같은 GeoServer `tb_ms_swage_ppl` WFS 관로 선형을 추가했다. +- 현재 지도 범위의 관로만 BBOX로 조회하며, 대량 전국 조회를 막기 위해 줌 13 이상에서 최대 2,000건만 불러온다. +- 일반 주변 관로는 파란색, `POST /api/v1/dredging/nearestPipes`로 자동 연결된 관로는 FID를 대조해 주황색·굵은 선으로 강조한다. +- 위치 마커는 관로보다 높은 레이어에 유지해 준설 위치와 관로 관계를 동시에 확인할 수 있다. +- 로딩 중·오류·표시 건수를 지도 안내 영역에 표시한다. +- 공통 `Gis`와 공통 지도 패키지는 수정하지 않고 준설 전용 `DredgingLocationMap`이 추가한 WFS/마커 레이어와 이벤트만 정리한다. +- 브라우저 수동 위치 선택에서 실제 `MultiLineString` 관로 169건 표시를 확인했다. + +## 0.2 2026-08-04 풀사이즈 등록 팝업·본문 통합 지도 + +- `openWindowPopup()`에 호출별 `width`, `height` 선택 옵션을 추가했다. URL 쿼리가 아니라 `window.open` feature로 반영한다. +- 준설 등록 호출은 `window.screen.availWidth`, `window.screen.availHeight`를 전달하고 공통 안전 여백 안에서 최대 크기로 연다. +- 사진·준설일자와 위치·자동 연결 관로를 하나의 등록 카드로 합쳤다. +- 등록창 위 지도 레이어를 제거하고 공통 `Gis` 지도를 본문 하단에 상시 표시한다. +- 평상시 지도는 조회 전용이고 `위치등록/위치 수정` 중에만 클릭 선택을 활성화한다. `취소`는 임시 위치를 버리고 `선택 위치 적용`만 최근접 관로 조회를 실행한다. +- 상단 안내·탭·사진 빈 상태·요약 카드의 패딩과 간격을 축소하고 사진 목록 최대 높이를 `180px`로 제한했다. +- 지도 높이는 `420px`에서 `540px`로 확대했다. +- 저장·관로추가·등록방식·위치·취소·삭제 버튼은 동일한 `sm` 높이와 primary/secondary/danger 규칙으로 통일했다. + +## 0. 2026-08-04 실제 연동 보완 + +- 목록 초기값을 로컬 모의 데이터가 아닌 `GET /api/v1/dredging/records` 응답으로 전환하였다. +- 등록 완료 메시지 수신 후 부모 목록을 서버에서 다시 조회하도록 변경하였다. +- `NEXT_PUBLIC_DREDGING_BACKEND_ENABLED`는 명시적으로 `false`인 경우만 선행 모드로 동작한다. +- 실제 EXIF 사진으로 공통 TUS 업로드, 최근접 관로, 준설 등록, DB 목록 반영을 확인하였다. +- 공통 첨부는 실제 36자 ID를 발급하므로 백엔드/DB V80 호환 보정과 함께 검증하였다. +- DB 저장 실패 재시도 시 이미 성공한 사진 업로드 참조를 `photo.key`별로 재사용하여 같은 파일을 다시 업로드하지 않도록 보완하였다. +- 지자체 목록, 관경별·경과년도별 집계, 목록 지도보기 레이어를 실제 API 데이터로 확인하였다. + ## 1. 구현 결과 - 신규 직접 진입 경로: `/oper/dredging` -- 기본 모드: `NEXT_PUBLIC_DREDGING_BACKEND_ENABLED`가 `true`가 아니면 FE 선행 모의 모드 -- 기존 지도 메인, 공통 API 모듈, 공통 컴포넌트는 수정하지 않았다. +- 등록 진입: 공통 WindowPopup 키 `dredgingRegister` +- 기본 모드: 실제 백엔드 연동이며, `NEXT_PUBLIC_DREDGING_BACKEND_ENABLED=false`를 명시한 경우만 FE 선행 모의 모드 +- 조사관리 화면의 검색영역 밀도, 입력 높이, 라벨 크기, 우측 버튼군을 준설관리에도 동일하게 적용했다. +- 화면 안의 중복 제목 `하수관로 준설`을 제거하고 공통 페이지 제목 `준설관리`만 사용한다. +- 기존 지도 메인, 공통 API 모듈과 타 앱은 수정하지 않았다. + +### 1.1 원본 요구사항 근거 + +- `COMMON_DOCS_ROOT/기준문서/화면설계서.pptx` 슬라이드 50~55 + - `SN_MN040201`: 준설 결과 업로드·위치 등록 + - `SN_MN040202`: 지자체별 준설 현황 + - `SN_MN040203`: 관경별·경과년도별 준설 현황 +- `COMMON_DOCS_ROOT/기준문서/요구사항정의서.xlsx` + - 시트 `3. (기능)요구사항정의 및 추적표` 84행 `SFR-010-012` + - 같은 시트 85행 `SFR-010-013` +- 준설결과 등록 본체는 다른 업무화면과 동일한 WindowPopup으로 구성하고, 사용자의 최신 지시에 따라 풀사이즈 팝업 하단에 지도를 통합했다. ## 2. 구현 파일 | 파일 | 책임 | | --- | --- | | `page.tsx` | 지자체별·관경별·경과년도별 조회, 검색, 엑셀 다운로드, 등록/지도보기 진입 | -| `_components/DredgingRegisterModal.tsx` | 관로 그룹, 사진/수동 등록, 검증, 첨부 업로드 및 저장 순서 | -| `_components/DredgingLocationMap.tsx` | 공통 `Gis`에 로컬 위치 마커와 지도 클릭 interaction 연결 | +| `_components/DredgingRegisterModal.tsx` | 관로 그룹, 사진/수동 등록, 검증, 첨부 업로드 및 저장 순서와 팝업 표시 모드 | +| `_components/DredgingRegisterPopupPage.tsx` | 등록 완료 메시지 전송과 팝업 창 닫기 | +| `_components/DredgingLocationMap.tsx` | 공통 `Gis`에 주변 관로 WFS, 자동 연결 관로 강조, 위치 마커와 지도 클릭 interaction 연결 | | `_lib/exif.ts` | JPEG APP1/TIFF EXIF 촬영일·GPS 분석 | -| `_lib/client.ts` | 최근접 관로, 공통 TUS 첨부 업로드, 준설 등록 API 어댑터와 FE 모의 응답 | +| `_lib/client.ts` | 최근접 관로, 공통 TUS 첨부 업로드, 준설 등록 API 어댑터, API 오류 정규화와 FE 모의 응답 | | `_lib/types.ts` | 화면 draft, 사진 메타정보, 관로 매핑, 저장 payload 타입 | +| `src/features/windowPopup/**` | `dredgingRegister` 정의·레지스트리·완료 메시지 계약 | ## 3. 주요 처리 @@ -31,11 +100,13 @@ ### 3.2 수동 등록 -1. `지도 수동 등록`을 선택한다. +1. `사진 없이 직접 등록`을 선택한다. 2. 준설일자를 입력한다. -3. 공통 지도 클릭 또는 `위도, 경도` 입력으로 위치를 지정한다. -4. 좌표 변경 즉시 최근접 관로와 거리·신뢰도를 갱신한다. -5. 관로 매핑이 완료된 건만 저장 payload로 만든다. +3. 등록창 하단의 `위치등록`을 눌러 같은 화면의 지도를 편집 상태로 전환한다. +4. 사용자는 좌표값을 직접 입력하지 않고 지도에서 위치를 클릭한다. +5. `선택 위치 적용`을 눌렀을 때만 선택 위치를 확정하고 최근접 관로와 거리·신뢰도를 갱신한다. +6. 편집을 취소하면 적용하지 않은 위치는 폐기하고 기존 위치를 유지한다. +7. 관로 매핑이 완료된 건만 저장 payload로 만든다. ### 3.3 FE 선행 모드 @@ -44,6 +115,31 @@ - 사용자 화면에는 개발단계, 선행 구현, 모의 매핑 안내 문구를 노출하지 않는다. - 등록 결과는 현재 탭의 목록 상태에만 반영한다. +### 3.4 조사관리 UI 정렬 및 WindowPopup + +1. 검색조건을 조사관리와 같은 단일 가로 flex 행으로 배치한다. +2. 라벨은 `text-xl`, 입력 높이는 `3.2rem`, 선택상자는 흰색 `rounded-lg` 규칙을 사용한다. +3. 조회·다운로드·등록·지도보기 버튼을 조사관리와 같은 파란색 액션 버튼으로 통일하고 장식 아이콘을 제거한다. +4. 등록 버튼은 `/popup/dredgingRegister` 창에 화면 가용 크기를 호출 옵션으로 전달해 풀사이즈에 가깝게 연다. +5. 저장 성공 시 팝업이 같은 출처의 `sn:popup:dredgingRegistered` 메시지를 부모 화면에 전달한다. +6. 부모 화면은 메시지의 등록 그룹을 기존 목록 상태에 반영하고 첫 페이지로 이동한다. + +### 3.5 본문 통합 지도 + +1. 준설결과 등록 WindowPopup의 사진·준설정보와 위치·관로 확인을 하나의 카드로 구성한다. +2. 공통 `Gis` 지도는 위치·관로 요약 바로 아래에 상시 표시한다. +3. 확정 위치가 있으면 `위치 수정`, 위치가 없으면 `위치등록`을 제공한다. +4. 위치 편집 중 지도 클릭 위치는 임시 상태로 유지하고 `선택 위치 적용` 이후에만 등록 데이터와 최근접 관로 조회에 반영한다. +5. 목록 화면의 `지도보기`는 기존 `UiModal` 레이어 팝업 방식을 유지한다. + +### 3.6 관로 선형 표시 + +1. 위치가 없을 때는 전국 관로를 조회하지 않고 위치 등록 안내만 표시한다. +2. 위치가 등록되거나 지도에서 임시 위치를 선택해 줌 17로 이동하면 현재 화면 BBOX의 `tb_ms_swage_ppl` WFS를 조회한다. +3. WFS의 `LineString/MultiLineString`은 파란색 관로 선형으로 표시한다. +4. 최근접 관로 응답의 숫자 FID와 WFS feature ID(`tb_ms_swage_ppl.{fid}`)를 정규화해 같은 관로를 주황색으로 강조한다. +5. 위치 마커는 관로 위에 표시하고, 관로 layer는 클릭 대상에서 제외해 위치 선택 흐름을 방해하지 않게 한다. + ## 4. 연동 전환 - 환경변수: `NEXT_PUBLIC_DREDGING_BACKEND_ENABLED=true` @@ -55,5 +151,8 @@ ## 5. 롤백 -- `apps/sn/app/(with-base-layout)/oper/dredging/**`를 제거한다. +- `page.tsx`의 호출별 가용 화면 크기 옵션을 제거하면 팝업 정의의 기본 크기로 되돌릴 수 있다. +- `DredgingRegisterModal.tsx`의 본문 하단 `DredgingLocationMap`을 제거하면 통합 지도 구성을 롤백할 수 있다. +- `DredgingRegisterModal.tsx`의 `presentation="popup"` 분기와 `DredgingRegisterPopupPage.tsx`를 제거한다. +- `src/features/windowPopup`의 `dredgingRegister` 정의·레지스트리·메시지 타입을 제거한다. - 신규 전용 경로이므로 기존 지도·운영정보 화면의 코드 롤백은 필요하지 않다. diff --git a/apps/sn/docs/작업내용/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md b/apps/sn/docs/작업내용/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md index 8f9d81d59..a5918dc76 100644 --- a/apps/sn/docs/작업내용/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md @@ -114,3 +114,34 @@ - 실제 upload history 스키마가 설계 초안과 달라질 수 있다. - 현재 업로드/다운로드는 요청 미리보기와 mock 목록 갱신만 수행하므로, 실제 파일 바이너리 흐름은 백엔드 구현 후 재검증이 필요하다. - 시설구분 목록은 현재 `UPLOAD_FACILITY_TYPES` mock 상수에 고정돼 있어 실제 좌측 `시설물 레이어`와 다를 수 있다. + +## 7. 2026-07-31 가평군 다운로드 안정화 + +### 7.1 작업 배경 + +- 실제 FE-BE 연동이 완료된 GIS 관리 화면에서 가평군 최신 GPKG 다운로드가 간헐적으로 실패했다. +- Chrome 콘솔의 실패 기록은 HTTP 업무 오류가 아닌 `TypeError: Failed to fetch`였다. +- 같은 요청의 다음 실행은 정상 파일을 반환해 대용량 응답 전송 구간의 일시적 연결 단절로 판단했다. + +### 7.2 구현 내용 + +- `GisManagementPopupPage.tsx`에 GIS 다운로드 전용 호출 함수를 추가했다. +- 네트워크 단절로 식별되는 `TypeError`만 500ms 후 한 번 재시도한다. +- 현재 다운로드와 업로드 이력 선택 다운로드가 같은 안정화 로직을 사용한다. +- HTTP 오류와 서버 업무 오류는 재시도 대상에서 제외한다. +- Blob object URL은 다운로드 클릭 직후가 아닌 1초 뒤 해제한다. + +### 7.3 영향 범위 + +- 변경 파일: `apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx` +- 변경 API 계약: 없음 +- 백엔드 및 DB 변경: 없음 +- 다른 다운로드 화면과 공통 API 유틸리티 영향: 없음 + +### 7.4 검증 요약 + +- 가평군 최신 GPKG 다운로드 성공 +- 가평군 업로드 이력 선택 다운로드 성공 +- 두 파일 모두 16,670건, EPSG:5179, SQLite 무결성 `ok` +- `fid INTEGER PRIMARY KEY` 단일 컬럼과 공간 인덱스 확인 +- 최종 브라우저 콘솔 오류 및 경고 없음 diff --git a/apps/sn/package.json b/apps/sn/package.json index 8cfd172aa..1203cd1e1 100644 --- a/apps/sn/package.json +++ b/apps/sn/package.json @@ -3,7 +3,8 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev -p 3002", + "dev": "next dev --turbopack -p 3002", + "dev:webpack": "next dev -p 3002", "build": "next build", "start:sn": "next start -p 3002", "lint": "next lint" diff --git a/apps/sn/src/app/providers/app-provider.tsx b/apps/sn/src/app/providers/app-provider.tsx index 654acb9c4..2169a8e47 100644 --- a/apps/sn/src/app/providers/app-provider.tsx +++ b/apps/sn/src/app/providers/app-provider.tsx @@ -3,15 +3,23 @@ import { DndProvider } from "react-dnd"; import { MultiBackend, getBackendOptions } from "@minoru/react-dnd-treeview"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ConfirmProvider } from "@frontend-opensource/use-react-hooks"; +import dynamic from "next/dynamic"; import { AuthWrapper } from "@/entities/auth"; import { MenuWrapper } from "@/entities/menu"; import { PromptProvider, StoreProvider } from "@/shared/providers"; import { AlertDialog } from "@/shared/ui/alert-dialog"; import { PromptDialog } from "@/shared/ui/prompt-dialog"; import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; -import { GlobalAnalysisCompletionWatcher } from "../../../app/(with-base-layout)/ii/useAnalysisCompletionWatcher"; + +// 개발 도구는 실제 로컬 개발 환경에서만 별도 청크로 불러와 최초 페이지 컴파일 범위를 줄인다. +const LazyReactQueryDevtools = dynamic( + () => + import("@tanstack/react-query-devtools").then( + ({ ReactQueryDevtools }) => ReactQueryDevtools, + ), + { ssr: false }, +); const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { const queryClient = new QueryClient(); @@ -29,7 +37,6 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { {children} - @@ -37,11 +44,13 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { -
- -
+ {process.env.NODE_ENV === "development" ? ( +
+ +
+ ) : null} ); }; -export { AppProvider }; \ No newline at end of file +export { AppProvider }; diff --git a/apps/sn/src/app/styles/components.css b/apps/sn/src/app/styles/components.css index d3bd67271..707983373 100644 --- a/apps/sn/src/app/styles/components.css +++ b/apps/sn/src/app/styles/components.css @@ -64,6 +64,30 @@ @apply flex min-h-0 flex-1 flex-col overflow-hidden p-4; } + /* 부모 업무화면의 10px rem 기준과 동일한 가독성을 준설 등록 팝업에도 적용한다. */ + .sn-dredging-register { + font-size: 1.3rem; + line-height: 1.5; + } + + .sn-dredging-register .text-xs { + font-size: 1.1rem; + } + + .sn-dredging-register .text-sm, + .sn-dredging-register button, + .sn-dredging-register input { + font-size: 1.3rem; + } + + .sn-dredging-register .text-base { + font-size: 1.4rem; + } + + .sn-dredging-register .text-xl { + font-size: 1.8rem; + } + .sn-popup-filter-panel { @apply grid grid-cols-1 gap-3 border-b border-border bg-secondary pb-4; } diff --git a/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx b/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx index cf6d79bbd..b9d1412f9 100644 --- a/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx @@ -3,11 +3,9 @@ import type { UIEvent } from "react"; import { useEffect, useMemo, useState } from "react"; -import { - downloadExcelWithKoreanHeaders, - type UiTableSort, -} from "@common/business_components/ui"; +import type { UiTableSort } from "@common/business_components/ui/table"; import { Button, Input } from "@common/components/ui"; +import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; import { getExmnFacilityDetails, diff --git a/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts b/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts index 892d065d4..95fafb96f 100644 --- a/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts +++ b/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts @@ -3,7 +3,11 @@ import { windowPopupDefinitions, type WindowPopupKey, } from "../model/popupDefinitions"; -import type { OpenWindowPopupParams, WindowPopupDefinition } from "../model/types"; +import type { + OpenWindowPopupOptions, + OpenWindowPopupParams, + WindowPopupDefinition, +} from "../model/types"; const buildQueryString = (params: OpenWindowPopupParams = {}) => { const searchParams = new URLSearchParams(); @@ -84,13 +88,18 @@ const buildPopupFeatures = ({ export const openWindowPopup = ( popupKey: WindowPopupKey, params?: OpenWindowPopupParams, + options?: OpenWindowPopupOptions, ) => { if (typeof window === "undefined") return null; if (!isWindowPopupKey(popupKey)) return null; const definition = windowPopupDefinitions[popupKey]; const popupUrl = `/popup/${popupKey}${buildQueryString(params)}`; - const popupFeatures = buildPopupFeatures(definition); + const popupFeatures = buildPopupFeatures({ + ...definition, + width: options?.width ?? definition.width, + height: options?.height ?? definition.height, + }); const popupWindow = window.open( popupUrl, `sn-popup-${popupKey}`, diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts index b260e651a..d6f03760d 100644 --- a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -21,6 +21,12 @@ export const windowPopupDefinitions = { height: 720, requiredParams: ["exmnBizId", "trtdstId", "trtdstNm", "jobType"], }, + dredgingRegister: { + key: "dredgingRegister", + title: "준설결과 등록", + width: 1280, + height: 900, + }, oldPpUpload: { key: "oldPpUpload", title: "노후 하수관로 정비사업 등록", @@ -175,8 +181,8 @@ export const windowPopupDefinitions = { flowRiskPopup: { key: "flowRiskPopup", title: "통수 위험", - width: 920, - height: 760, + width: 1180, + height: 820, }, } as const satisfies Record; diff --git a/apps/sn/src/features/windowPopup/model/types.ts b/apps/sn/src/features/windowPopup/model/types.ts index 2f3049327..99a08c3fd 100644 --- a/apps/sn/src/features/windowPopup/model/types.ts +++ b/apps/sn/src/features/windowPopup/model/types.ts @@ -30,10 +30,16 @@ export type OpenWindowPopupParams = Record< string | number | boolean | null | undefined >; +/** 팝업별 기본 정의를 유지하면서 호출 화면에서 필요한 크기만 재정의한다. */ +export type OpenWindowPopupOptions = Partial< + Pick +>; + export const WINDOW_POPUP_MESSAGE_TYPE = { EXMN_BUSINESS_CREATED: "sn:popup:exmnBusinessCreated", EXMN_BUSINESS_UPDATED: "sn:popup:exmnBusinessUpdated", EXMN_RESULT_COMMITTED: "sn:popup:exmnResultCommitted", + DREDGING_REGISTERED: "sn:popup:dredgingRegistered", OLD_PP_UPLOAD_COMPLETED: "sn:popup:oldPpUploadCompleted", FLOOD_PRE_UPLOAD_COMPLETED: "sn:popup:floodPreUploadCompleted", MAP_SEARCH_SELECT_FEATURE: "sn:popup:mapSearchSelectFeature", @@ -49,11 +55,6 @@ export const WINDOW_POPUP_MESSAGE_TYPE = { WATER_RISK_READY: "sn:popup:waterRiskReady", WATER_RISK_SYNC: "sn:popup:waterRiskSync", WATER_RISK_SELECT_RESULT: "sn:popup:waterRiskSelectResult", - WATER_RISK_START_SETTING_EDIT: "sn:popup:waterRiskStartSettingEdit", - WATER_RISK_STOP_SETTING_EDIT: "sn:popup:waterRiskStopSettingEdit", - WATER_RISK_REMOVE_STAGED_PIPE: "sn:popup:waterRiskRemoveStagedPipe", - WATER_RISK_REORDER_STAGED_PIPE: "sn:popup:waterRiskReorderStagedPipe", - WATER_RISK_CLEAR_STAGED_PIPES: "sn:popup:waterRiskClearStagedPipes", } as const; export type WindowPopupMessageType = diff --git a/apps/sn/src/features/windowPopup/registry.tsx b/apps/sn/src/features/windowPopup/registry.tsx index 891290881..addcff148 100644 --- a/apps/sn/src/features/windowPopup/registry.tsx +++ b/apps/sn/src/features/windowPopup/registry.tsx @@ -2,6 +2,7 @@ import { windowPopupDefinitions, type WindowPopupKey } from "./model/popupDefini import type { WindowPopupRegistryItem } from "./model/types"; import AgedPipePopupContent from "./ui/AgedPipePopupContent"; import AgedPipeStatusByCtpvPopupContent from "./ui/AgedPipeStatusByCtpvPopupContent"; +import DredgingRegisterPopupContent from "./ui/DredgingRegisterPopupContent"; import ExmnBusinessRegisterPopupContent from "./ui/ExmnBusinessRegisterPopupContent"; import ExmnResultRegisterPopupContent from "./ui/ExmnResultRegisterPopupContent"; import ExmnSurveyDetailPopupContent from "./ui/ExmnSurveyDetailPopupContent"; @@ -50,6 +51,10 @@ export const windowPopupRegistry = { ...windowPopupDefinitions.exmnSurveyDetail, component: ExmnSurveyDetailPopupContent, }, + dredgingRegister: { + ...windowPopupDefinitions.dredgingRegister, + component: DredgingRegisterPopupContent, + }, oldPpUpload: { ...windowPopupDefinitions.oldPpUpload, component: OldPpUploadPopupContent, diff --git a/apps/sn/src/features/windowPopup/ui/DredgingRegisterPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/DredgingRegisterPopupContent.tsx new file mode 100644 index 000000000..f75d79b0a --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/DredgingRegisterPopupContent.tsx @@ -0,0 +1,19 @@ +"use client"; + +import DredgingRegisterPopupPage from "@/app/(with-base-layout)/oper/dredging/_components/DredgingRegisterPopupPage"; +import { useEffect, useState } from "react"; + +/** 준설결과 등록 화면을 공통 WindowPopup 레이아웃에 연결한다. */ +export default function DredgingRegisterPopupContent() { + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + if (!isMounted) { + return
; + } + + return ; +} diff --git a/apps/sn/src/shared/lib/download-excel.ts b/apps/sn/src/shared/lib/download-excel.ts new file mode 100644 index 000000000..659bc06d4 --- /dev/null +++ b/apps/sn/src/shared/lib/download-excel.ts @@ -0,0 +1,125 @@ +type ExcelDownloadWithHeaderProps = { + data: JSON | JSON[]; + fileName: string; + sheetNameArray: string[]; + headerRange?: string; + headerMapping: Record; + mergeRanges?: string[]; +}; + +type ExcelRow = Record; + +// 엑셀 시트명 제한 문자와 최대 길이를 정리해 다운로드 시 런타임 오류를 방지한다. +const normalizeSheetName = (sheetName: string | undefined) => { + const normalized = (sheetName || "Sheet1").replace(/[\\/?*[\]:]/g, "_"); + return normalized.slice(0, 31) || "Sheet1"; +}; + +// 한글을 두 글자 폭으로 계산해 내려받은 파일의 기본 열 너비를 읽기 좋게 맞춘다. +const getDisplayWidth = (value: unknown) => + Array.from(String(value ?? "")).reduce( + (width, character) => width + (character.charCodeAt(0) > 255 ? 2 : 1), + 0, + ); + +/** + * Turbopack이 지원하지 않는 xlsx-populate의 동적 require를 피하면서 + * 기존 한글 헤더, 병합 범위와 XLSX 다운로드 계약을 유지한다. + */ +export const downloadExcelWithKoreanHeaders = async ({ + data, + fileName, + sheetNameArray, + headerRange = "A1:V1", + headerMapping, + mergeRanges = [], +}: ExcelDownloadWithHeaderProps) => { + if (typeof window === "undefined") { + throw new Error("엑셀 다운로드는 브라우저에서만 사용할 수 있습니다."); + } + + const dataRows = (Array.isArray(data) ? data : [data]) as unknown as ExcelRow[]; + if (dataRows.length === 0) { + throw new Error("데이터가 없습니다."); + } + + // XLSX 모듈도 다운로드 버튼을 누를 때만 불러와 지도 화면의 최초 컴파일을 줄인다. + const XLSX = await import("xlsx"); + const englishHeaders = Object.keys(dataRows[0] ?? {}); + const koreanHeaders = englishHeaders.map( + (header) => headerMapping[header] || header, + ); + const worksheetRows = [ + koreanHeaders, + ...dataRows.map((row) => englishHeaders.map((header) => row[header])), + ]; + const worksheet = XLSX.utils.aoa_to_sheet(worksheetRows); + + // SheetJS가 지원하는 범위에서는 기존 공통 다운로드와 유사한 헤더 스타일을 적용한다. + const decodedHeaderRange = XLSX.utils.decode_range(headerRange); + for ( + let rowIndex = decodedHeaderRange.s.r; + rowIndex <= decodedHeaderRange.e.r; + rowIndex += 1 + ) { + for ( + let columnIndex = decodedHeaderRange.s.c; + columnIndex <= decodedHeaderRange.e.c; + columnIndex += 1 + ) { + const cellAddress = XLSX.utils.encode_cell({ + r: rowIndex, + c: columnIndex, + }); + const headerCell = worksheet[cellAddress] as + | (typeof worksheet[string] & { s?: Record }) + | undefined; + if (headerCell) { + headerCell.s = { + fill: { patternType: "solid", fgColor: { rgb: "9BC1E7" } }, + font: { bold: true, sz: 13 }, + alignment: { + horizontal: "center", + vertical: "center", + wrapText: true, + }, + border: { + top: { style: "thin", color: { rgb: "FFFFFF" } }, + bottom: { style: "thin", color: { rgb: "FFFFFF" } }, + left: { style: "thin", color: { rgb: "FFFFFF" } }, + right: { style: "thin", color: { rgb: "FFFFFF" } }, + }, + }; + } + } + } + + worksheet["!autofilter"] = { ref: headerRange }; + worksheet["!merges"] = mergeRanges.map((range) => + XLSX.utils.decode_range(range), + ); + worksheet["!cols"] = englishHeaders.map((header, columnIndex) => { + const sampledValues = dataRows + .slice(0, 200) + .map((row) => row[header]) + .concat(koreanHeaders[columnIndex]); + const width = Math.max(...sampledValues.map(getDisplayWidth), 10); + return { wch: Math.min(width + 2, 40) }; + }); + + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet( + workbook, + worksheet, + normalizeSheetName(sheetNameArray[0]), + ); + + const normalizedFileName = fileName.toLowerCase().endsWith(".xlsx") + ? fileName + : `${fileName}.xlsx`; + XLSX.writeFile(workbook, normalizedFileName, { + bookType: "xlsx", + compression: true, + cellStyles: true, + }); +}; diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index 04bf3a504..1c154da7e 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -78,6 +78,7 @@ function Gis({ showFloatingSearchButton = true, treeTogglePosition = "center", detachedTreeToggleButton = false, + showLayerPanelTitle = true, showZoomScaleInfo = false, }) { const mapRef = useRef(); @@ -324,6 +325,7 @@ function Gis({ treeSectionClassName={treeSectionClassName} toggleButtonPosition={treeTogglePosition} detachedToggleButton={detachedTreeToggleButton} + showLayerPanelTitle={showLayerPanelTitle} /> {typeof WaterDepth === "function" && (