From 1dc40b9efc466f1815ff82ed0ce5f510d6018e0d Mon Sep 17 00:00:00 2001 From: mind-one001 Date: Wed, 9 Sep 2026 15:42:31 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=EB=B9=88=20=EB=A0=88=EC=9D=B4=EC=96=B4=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EB=A1=9C=20=EC=A7=80=EB=8F=84=EB=A7=8C=20?= =?UTF-8?q?=EC=9E=AC=EC=82=AC=EC=9A=A9=ED=95=98=EB=8A=94=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=97=90=EC=84=9C=EB=8A=94=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EC=99=B8=EA=B3=BD=EA=B3=BC=20=EC=A0=9C=EB=AA=A9=EB=8F=84=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/DredgingDetailPopupPage.tsx | 26 ++++++++++--------- .../001-fe-FE-MAP-레이어패널헤더-구현기록.md | 23 ++++++++++++++++ .../001-fe-SFR-010-012-준설관리-구현기록.md | 17 ++++++++++++ .../business_components/src/ui/map/GisTree.js | 3 +++ 4 files changed, 57 insertions(+), 12 deletions(-) diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage.tsx b/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage.tsx index fe849ce76..8f9a9b9c4 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage.tsx +++ b/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage.tsx @@ -686,14 +686,15 @@ const DredgingDetailPopupPage = ({ {index + 1} + {/* 파일명 밑줄과 글자 하단이 잘리지 않도록 글자 크기보다 넉넉한 줄 높이를 사용한다. */} {photo.originalFileName || `준설사진 ${index + 1}`} - + {formatDredgingCapturedDate(photo.capturedAt) ?? "촬영일시 없음"} @@ -736,12 +737,12 @@ const DredgingDetailPopupPage = ({ 신규 · {photo.file.name} - + {formatDredgingCapturedDate( photo.metadata.capturedAt, ) ?? "촬영일시 없음"} @@ -824,7 +825,7 @@ const DredgingDetailPopupPage = ({ {editable ? (
-
+

위치 및 관로 @@ -866,13 +867,14 @@ const DredgingDetailPopupPage = ({ )}

-
-
- {statusMessage || - "준설일자·사진·위치와 자동 연결 관로를 확인해주세요."} + {/* 안내와 처리 상태는 위치·관로 카드의 마지막 줄에서 함께 확인한다. */} +

+ {statusMessage || + "준설일자·사진·위치와 자동 연결 관로를 확인해주세요."} +

) : null} diff --git a/apps/sn/docs/작업내용/FE-MAP-레이어패널헤더/001-fe-FE-MAP-레이어패널헤더-구현기록.md b/apps/sn/docs/작업내용/FE-MAP-레이어패널헤더/001-fe-FE-MAP-레이어패널헤더-구현기록.md index 2e89c064b..1d1fbd27c 100644 --- a/apps/sn/docs/작업내용/FE-MAP-레이어패널헤더/001-fe-FE-MAP-레이어패널헤더-구현기록.md +++ b/apps/sn/docs/작업내용/FE-MAP-레이어패널헤더/001-fe-FE-MAP-레이어패널헤더-구현기록.md @@ -77,3 +77,26 @@ - 두 카드 사이 간격이 `6px`인지 확인한다. - 패널 바깥 배경이 `#E5E5E5`인지 확인한다. - 레이어 체크, 펼침/접힘, 설정 카드 입력과 스크롤이 기존과 동일하게 동작하는지 확인한다. + +## 2026-09-09 회귀 보정 + +### 발생 원인과 원본 대조 + +- 2026-09-07 커밋 `7e82c6a80`에서 섹션형 레이어 패널 UI를 적용하는 과정에 `treeData`가 비어 있으면 렌더링을 종료하던 가드가 누락됐다. +- 준설 등록과 수정 화면은 위치 선택용으로 공통 `Gis`를 재사용하면서 `treeData={[]}`를 전달하므로, 누락 이후 내용 없는 `시설물` 패널이 지도 왼쪽에 노출됐다. +- 요구사항정의서 `3. (기능)요구사항정의 및 추적표`의 `SFR-010-012`는 준설정보 입력·지도 표출, `SFR-010-013`은 준설 등록·수정·삭제·조회를 요구한다. 빈 시설물 패널 표시는 요구하지 않는다. +- 화면설계서 슬라이드 54 `SN_MN040201 준설 결과 위치 등록`에도 위치 검색·준설 위치 등록·준설일자 선택·저장 흐름만 있으며 시설물 패널은 없다. + +### 보정 내용 + +- `GisTree.js`에서 `treeData`가 없거나 빈 배열이면 조기 반환하도록 가드를 복원했다. +- 준설 등록·수정처럼 빈 트리를 사용하는 지도에서는 패널 외곽과 `시설물` 제목을 표시하지 않는다. +- 실제 레이어 트리를 전달하는 메인 하수관망도는 기존 `레이어 관리`·`선택 레이어 설정` 섹션형 UI를 그대로 유지한다. +- API와 DB 계약에는 영향이 없다. + +### 검증 결과·계획과 롤백 + +- `pnpm --filter @common/business_components run build`는 2026-09-09 통과했다. +- 준설 등록·수정 지도에서 빈 `시설물` 패널이 사라지고 위치 선택·관로 자동 연결이 유지되는지 브라우저로 확인한다. +- 메인 하수관망도에서 비어 있지 않은 트리의 레이어 체크, 펼침·접힘과 두 섹션형 패널이 그대로 동작하는지 회귀 확인한다. +- 롤백이 필요하면 `GisTree.js`에 추가한 빈 트리 조기 반환 가드만 제거한다. 단, 제거 시 준설 지도에 빈 패널이 다시 노출된다. 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 b4fe4f25d..ed3215cf9 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 @@ -383,3 +383,20 @@ - CMS에 없는 기존 `UNSUPPORTED` 상태는 `수동 입력 필요` fallback을 유지한다. - CMS 조회 실패 시 기존 화면 문구를 fallback으로 사용한다. - 백엔드 API, 준설 저장 payload와 DB 구조는 변경하지 않았다. + +## 2026-09-09 수정 화면 안내 문구 통합 + +- 사용자 요청에 따라 수정 화면의 상태 안내를 `위치 및 관로` 카드 안 마지막 줄로 이동했다. 별도 구분선·배경과 52px 고정 높이를 제거하고 긴 문구는 줄바꿈하도록 했다. +- 등록 화면은 현재 구성을 유지한다. 기존 안내 문구, 상태 메시지와 위치 수정·사진·저장 처리에는 변경이 없다. +- 추적 근거: 요구사항정의서 `3. (기능)요구사항정의 및 추적표` 84~85행 `SFR-010-012/013`, 화면설계서 54번 `SN_MN040201`. 안내 배치 변경은 사용자 지시에 따른 UI 보완이다. +- 로컬 3002 개발 서버의 HMR로 실제 수정 화면을 확인했다. 카드 높이는 110px에서 72px로 줄었고 위치 수정 진입·취소 후 안내와 버튼이 정상 표시됐다. 저장·업로드는 실행하지 않았다. +- 긴 상태 메시지의 실제 오류·저장 시나리오는 미실행이며 줄바꿈 가능한 마크업을 확인했다. 개발 서버는 사용자 실행 상태를 유지하고 빌드는 실행하지 않았다. +- 롤백은 `DredgingDetailPopupPage.tsx`의 안내 문단을 카드 헤더 밖 기존 고정 높이 영역으로 되돌리면 된다. API·DB 영향은 없다. + +## 2026-09-09 사진 파일명 글자 잘림 보정 + +- 수정 화면에서 파일명 글자 크기는 13px인데 줄 높이가 12.5px여서 `truncate`의 숨김 영역에 밑줄과 글자 하단이 잘렸다. +- 기존·신규 사진 파일명을 12px, 굵기 500, 줄 높이 18px로 통일하고 촬영일자도 12px/18px로 맞췄다. 긴 파일명의 말줄임과 전체 이름을 보여주는 `title`은 유지한다. +- 기존 `SFR-010-012/013` 사진 확인 흐름에서 사용자 요청에 따른 표시 보완이다. 등록 화면, API·DB와 업로드·저장 처리는 변경하지 않았다. +- 로컬 HMR 후 실제 사진 4건의 12px/18px/500 적용, 가로 잘림 없음과 밑줄 표시를 확인했다. 사진 선택·미리보기 전환 후 기존 선택으로 복원했다. 신규 사진 업로드는 실행하지 않았다. +- `git diff --check` 통과. 롤백은 파일명·촬영일자의 글자 클래스만 기존 값으로 되돌린다. diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js index 52dee096b..44c306bf1 100644 --- a/packages/common/business_components/src/ui/map/GisTree.js +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -588,6 +588,9 @@ export default React.memo(function GisTree({ // 렌더링 // ===================== + // 빈 레이어 트리로 지도만 재사용하는 화면에서는 패널 외곽과 제목도 표시하지 않는다. + if (!treeData || treeData.length === 0) return null; + const toggleButtonPositionClass = toggleButtonPosition === "top" ? "top-4" : "top-1/2 -translate-y-1/2"; const thinScrollbarClass = From 65bee9586d7ce4ee7f8b03a34114274fffba720b Mon Sep 17 00:00:00 2001 From: kiminchan Date: Wed, 9 Sep 2026 16:45:12 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=EB=8F=84=EC=8B=9C=EC=B9=A8=EC=88=98?= =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EB=AA=A9=EB=A1=9D=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EC=9A=B4=EC=98=81=20=EB=B3=B4=EC=99=84=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../002-qa-SN-도시침수지도-정보제공포털-목록보기-통합검증결과.md | 8 ++++++++ .../001-fe-SN-도시침수지도-정보제공포털-목록보기-통합구현기록.md | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/apps/sn/docs/작업검증/SN-도시침수지도-정보제공포털-목록보기/002-qa-SN-도시침수지도-정보제공포털-목록보기-통합검증결과.md b/apps/sn/docs/작업검증/SN-도시침수지도-정보제공포털-목록보기/002-qa-SN-도시침수지도-정보제공포털-목록보기-통합검증결과.md index dd1fd7ccb..5c5b000d3 100644 --- a/apps/sn/docs/작업검증/SN-도시침수지도-정보제공포털-목록보기/002-qa-SN-도시침수지도-정보제공포털-목록보기-통합검증결과.md +++ b/apps/sn/docs/작업검증/SN-도시침수지도-정보제공포털-목록보기/002-qa-SN-도시침수지도-정보제공포털-목록보기-통합검증결과.md @@ -67,3 +67,11 @@ ## 6. 흡수한 기존 문서 - 별도 SN 검증 결과 문서는 없었다. + +## 7. 2026-09-09 운영 목록 행 선택 이동 보완 검증 + +- 운영 장애 재현: 그룹 extent 조회에서 `SQLState 42883`, `function st_envelope(public.geometry) does not exist` 확인. +- 백엔드 단위 테스트: 단건 extent·그룹 extent SQL의 모든 PostGIS 함수가 `public`으로 한정되는지 2건 통과. +- 개발 MS DB: `search_path=sm_bs_ms` 조건에서 단건 extent와 그룹 extent 모두 유효 좌표 반환 PASS. +- 프론트 변경 없음. +- 운영 화면의 실제 목록 행 클릭 및 중점관리지역 이동은 재배포 후 확인 대기. diff --git a/apps/sn/docs/작업내용/SN-도시침수지도-정보제공포털-목록보기/001-fe-SN-도시침수지도-정보제공포털-목록보기-통합구현기록.md b/apps/sn/docs/작업내용/SN-도시침수지도-정보제공포털-목록보기/001-fe-SN-도시침수지도-정보제공포털-목록보기-통합구현기록.md index b89b7604e..e695d60c5 100644 --- a/apps/sn/docs/작업내용/SN-도시침수지도-정보제공포털-목록보기/001-fe-SN-도시침수지도-정보제공포털-목록보기-통합구현기록.md +++ b/apps/sn/docs/작업내용/SN-도시침수지도-정보제공포털-목록보기/001-fe-SN-도시침수지도-정보제공포털-목록보기-통합구현기록.md @@ -81,3 +81,10 @@ ## 9. 흡수한 기존 문서 - 별도 SN 기능 문서는 없었다. 공통 2026-08-04 바이브코딩 기록의 목록보기 핵심 상태를 본 문서에 요약했다. + +## 10. 2026-09-09 운영 목록 행 선택 이동 보완 공유 + +- 프론트 코드와 행 선택 메시지 흐름은 변경하지 않았다. +- 운영에서 행 선택 후 그룹 extent API가 `ST_Envelope` 스키마 해석 오류로 실패해 지도 이동이 중단된 원인을 백엔드 SQL에서 보완했다. +- 기존 시군구·침수빈도 그룹 범위 이동 계약과 화면 동작은 유지한다. +- 재배포 후 목록 행 클릭, 지도 중심 이동, 선택 빈도 WMS 표출을 회귀 확인한다. From 352a78a0c5964b54951ddcdfbadfba71228378c3 Mon Sep 17 00:00:00 2001 From: mind-one001 Date: Thu, 10 Sep 2026 11:30:52 +0900 Subject: [PATCH 3/7] =?UTF-8?q?gis=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gisManagement/GisManagementPopupPage.tsx | 254 ++++++++++-------- .../pipeAnalysis/PipeAnalysisPopupPage.tsx | 215 ++------------- .../pipeAnalysis/PipeAnalysisSummaryTable.tsx | 252 +++++++++++++++++ .../pipe-analysis-summary-table.css | 54 ++++ apps/sn/app/(map-layout)/map2/page.tsx | 1 - .../001-fe-SN-관로분석그리드개선-전수조사.md | 173 ++++++++++++ .../001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md | 12 + 7 files changed, 661 insertions(+), 300 deletions(-) create mode 100644 apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisSummaryTable.tsx create mode 100644 apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipe-analysis-summary-table.css delete mode 100644 apps/sn/app/(map-layout)/map2/page.tsx create mode 100644 apps/sn/docs/작업내용/FE-GRID-전수조사/001-fe-SN-관로분석그리드개선-전수조사.md 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 708cedb58..4518481f3 100644 --- a/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx @@ -10,16 +10,7 @@ import { StandardPopupLayout, type UiHeadProps, } from "@common/business_components"; -import { - Button, - Input, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@common/components"; +import { Button, Input } from "@common/components"; import { getBlobDataWithHeaders, @@ -91,6 +82,7 @@ type GisUploadResponse = { mapRefreshRequired?: boolean; refreshLayerTypeNames?: string[]; validationErrors?: ValidationErrorItem[]; + invalidCount?: number; message?: string; }; @@ -103,6 +95,14 @@ type ValidationErrorItem = { type ViewMode = "history" | "validationErrors" | "preview"; +/** 오류 원본 행번호와 공통 표의 역순 순번을 별도 열로 표시한다. */ +const GIS_VALIDATION_ERROR_HEADLIST: UiHeadProps[] = [ + { id: "no", title: "순번", widthPercent: 8 }, + { id: "sourceRowNo", title: "행번호", widthPercent: 10 }, + { id: "fid", title: "FID", widthPercent: 22 }, + { id: "errorMessage", title: "오류내용", align: "left", widthPercent: 60 }, +]; + type DownloadJob = { jobId: string; status: "QUEUED" | "RUNNING" | "READY" | "FAILED" | string; @@ -119,6 +119,7 @@ const SCREEN_ID = "SN_MN010302"; const FACILITY_TYPE_CODE = "PPL"; const LAYER_ID = "tb_ms_swage_ppl"; const GIS_UPLOAD_PAGE_SIZE = 10; +const GIS_ERROR_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; @@ -146,6 +147,13 @@ const GIS_VALIDATION_ERROR_MESSAGES: Record = { const getLocalizedValidationErrorMessage = (error: ValidationErrorItem) => { const errorCode = String(error.errorCode ?? "").trim(); const errorMessage = String(error.errorMessage ?? "").trim(); + // 저장 API가 제공하는 한글 설명을 보존하고 과거 기술용어 응답만 아래에서 변환한다. + if ( + /[가-힣]/.test(errorMessage) && + !/geometry|feature|LineString|count\s*=/i.test(errorMessage) + ) { + return errorMessage; + } if (errorCode === "GIS_UPLOAD_INVALID_GEOMETRY") { return errorMessage.toLowerCase().includes("없는 feature") @@ -295,11 +303,8 @@ export default function GisManagementPopupPage() { const alert = useAlertStore((state) => state.setMessage); const userInfo = useUserStore((state) => state.userInfo); // GIS 관리 코드값은 기존 분기에 사용하고 CMS 공통코드명은 화면 안내에만 사용한다. - const { - uploadStatusLabels, - downloadStatusLabels, - facilityTypeLabels, - } = useGisManagementCommonCodes(); + const { uploadStatusLabels, downloadStatusLabels, facilityTypeLabels } = + useGisManagementCommonCodes(); // 파일 input은 브라우저 보안 정책상 value 직접 제어가 제한돼 ref로 선택/초기화한다. const fileInputRef = useRef(null); @@ -317,6 +322,10 @@ export default function GisManagementPopupPage() { const [validationErrors, setValidationErrors] = useState< ValidationErrorItem[] >([]); + // 오류 페이지는 이력 목록의 선택·페이지와 독립적으로 업로드 ID에 고정한다. + const [errorUploadId, setErrorUploadId] = useState(""); + const [errorPage, setErrorPage] = useState(1); + const [errorTotal, setErrorTotal] = useState(0); const [viewMode, setViewMode] = useState("history"); const [selectedUploadId, setSelectedUploadId] = useState(""); const [currentPage, setCurrentPage] = useState(1); @@ -324,6 +333,17 @@ export default function GisManagementPopupPage() { const [isLoading, setIsLoading] = useState(false); const [notice, setNotice] = useState(""); + // 오류 목록도 업로드 이력과 같은 표준 그리드를 사용하며 기존 오류 설명을 그대로 표시한다. + const validationErrorRows = useMemo( + () => + validationErrors.map((error) => ({ + sourceRowNo: error.rowNo === 0 ? "파일 전체" : (error.rowNo ?? "-"), + fid: error.fid || "-", + errorMessage: getLocalizedValidationErrorMessage(error), + })), + [validationErrors], + ); + const selectedHistory = useMemo( () => uploadHistory.find((history) => history.uploadId === selectedUploadId) ?? @@ -632,9 +652,16 @@ export default function GisManagementPopupPage() { formData, )) as GisUploadResponse; if (response.status === "VALIDATION_FAILED") { + await fetchHistories(1, selectedMngInstCd); + setSelectedUploadId(response.uploadId); + setErrorUploadId(response.uploadId); + setErrorPage(1); + setErrorTotal( + response.invalidCount ?? response.validationErrors?.length ?? 0, + ); setValidationErrors( Array.isArray(response.validationErrors) - ? response.validationErrors + ? response.validationErrors.slice(0, GIS_ERROR_PAGE_SIZE) : [], ); setViewMode("validationErrors"); @@ -672,6 +699,12 @@ export default function GisManagementPopupPage() { alert("다운로드할 업로드 현황 행을 선택하세요."); return; } + if (selectedHistory.status === "VALIDATION_FAILED") { + alert( + "검증에 실패한 파일은 반영되지 않았습니다. 오류 확인을 이용해주세요.", + ); + return; + } setIsLoading(true); setNotice(""); @@ -712,7 +745,10 @@ export default function GisManagementPopupPage() { } }; - const fetchValidationErrors = async (uploadId = selectedUploadId) => { + const fetchValidationErrors = async ( + uploadId = selectedUploadId, + page = 1, + ) => { if (!uploadId) { alert("오류를 확인할 업로드 현황 행을 선택하세요."); return; @@ -722,11 +758,14 @@ export default function GisManagementPopupPage() { setNotice(""); try { const response = await getData( - `v1/map/gisUploads/${encodeURIComponent(uploadId)}/validationErrors?page=0&size=500`, + `v1/map/gisUploads/${encodeURIComponent(uploadId)}/validationErrors?page=${page - 1}&size=${GIS_ERROR_PAGE_SIZE}`, ); setValidationErrors( Array.isArray(response?.errors) ? response.errors : [], ); + setErrorUploadId(uploadId); + setErrorPage(page); + setErrorTotal(Number(response?.total ?? 0)); setViewMode("validationErrors"); } catch (error) { console.error("GIS 업로드 오류 조회 실패:", error); @@ -747,9 +786,57 @@ export default function GisManagementPopupPage() { alert("미리보기할 업로드 현황 행을 선택하세요."); return; } + if (selectedHistory.status === "VALIDATION_FAILED") { + alert( + "검증에 실패한 파일은 반영되지 않았습니다. 오류 확인을 이용해주세요.", + ); + return; + } setViewMode("preview"); }; + // 미리보기와 오류 목록의 기존 보조 액션을 같은 버튼 구성으로 유지한다. + const auxiliaryActions = ( +
+ + + + +
+ ); + return ( - {viewMode !== "history" ? ( + {viewMode === "preview" ? (

- {viewMode === "preview" - ? "GIS 업로드 미리보기" - : "GIS 업로드 오류 목록"} + GIS 업로드 미리보기

-
- - - - -
+ {auxiliaryActions}
) : null} @@ -897,56 +949,34 @@ export default function GisManagementPopupPage() { /> ) : viewMode === "validationErrors" ? ( -
- - - - {["순번", "행번호", "FID", "오류내용"].map((head) => ( - - {head} - - ))} - - - - {validationErrors.length > 0 ? ( - validationErrors.map((error, index) => ( - - - {validationErrors.length - index} - - - {error.rowNo ?? "-"} - - - {error.fid || "-"} - - - {getLocalizedValidationErrorMessage(error)} - - - )) - ) : ( - - - 표시할 오류가 없습니다. - - - )} - -
-
+ + void fetchValidationErrors(errorUploadId, page) + } + scrollMode="horizontal" + minTableWidth="64rem" + emptyMessage="표시할 오류가 없습니다." + fillAvailableHeight + rowId={(_row, index) => String(index)} + renderCell={(row, headId) => + headId === "errorMessage" ? ( + + {row.errorMessage} + + ) : headId === "fid" ? ( + + {row.fid} + + ) : undefined + } + /> ) : ( void handleDownloadSelectedHistory()} > GeoPackage 다운로드 @@ -979,7 +1013,11 @@ export default function GisManagementPopupPage() { - -
- - - - {headList.map((head) => ( - - {head.id === "rowNo" ? "순번" : head.title} - - ))} - - - - {visibleYearMaterialGroups.length > 0 ? ( - visibleYearMaterialGroups.flatMap((group) => - group.rows.map((yearRow, rowIndex) => ( - - {rowIndex === 0 ? ( - <> - - {group.rowNo} - - - {group.ctpvNm} - - - {group.sggNm} - - - {group.swtrmtZoneNm} - - - {formatGridNumber(group.totalLengthKm)} - - - ) : null} - - {yearRow.elapsedYearLabel} - - {materialColumns.map((column) => { - const value = Number( - yearRow.metrics[column.key] ?? 0, - ); - const isClickable = - value > 0 && - Boolean(yearRow.cellFilters[column.key]); - return ( - { - if (isClickable) { - handleYearMaterialCellClick( - group, - yearRow, - column.key, - ); - } - }} - > - {formatGridNumber(value)} - - ); - })} - - )), - ) - ) : ( - - - 조회 결과가 없습니다. - - - )} - -
-
- {orderedYearMaterialGroups.length > - PIPE_ANALYSIS_SUMMARY_PAGE_SIZE ? ( -
- -
- ) : null} - - ) : null} - - {appliedFilters.analysisType !== "YEAR_MATERIAL" ? ( - - > - } - headlist={headList} - title={`${resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 목록`} - total={rows.length} - page={summaryCurrentPage} - pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE} - onPageChange={setSummaryCurrentPage} - scrollMode={useHorizontalScrollTable ? "horizontal" : "none"} - minTableWidth={useHorizontalScrollTable ? "160rem" : "100%"} - clickableHeadIds={clickableHeadIds} - onCellClick={(row, _rowIndex, columnId) => - handleCellClick(row, columnId) - } - rowId={(row, index) => - `${String(row.__base.ctpvCd)}-${String(row.__base.sggCd)}-${String(row.__base.swtrmtZoneNm)}-${index}` - } - actions={ - - } - /> - ) : null} + void handleDownload()}> + 엑셀다운로드 + + } + />
); } diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisSummaryTable.tsx b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisSummaryTable.tsx new file mode 100644 index 000000000..79152a913 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisSummaryTable.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { Fragment, useMemo, type CSSProperties, type ReactNode } from "react"; +import { + getCoreRowModel, + useReactTable, + type ColumnDef, +} from "@tanstack/react-table"; +import { + StandardDataTable, + type UiHeadProps, +} from "@common/business_components"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; +import type { + PipeAnalysisSummaryViewRow, + PipeAnalysisYearMaterialGroup, + PipeAnalysisYearMaterialGroupRow, +} from "./pipeAnalysisUtils"; +import "./pipe-analysis-summary-table.css"; + +type SummaryRow = PipeAnalysisSummaryViewRow | PipeAnalysisYearMaterialGroup; +type Props = { + rows: PipeAnalysisSummaryViewRow[]; + groups: PipeAnalysisYearMaterialGroup[]; + grouped: boolean; + heads: UiHeadProps[]; + title: string; + total: number; + page: number; + pageSize: number; + actions: ReactNode; + onPageChange: (page: number) => void; + onCellClick: (row: PipeAnalysisSummaryViewRow, columnId: string) => void; + onGroupedCellClick: ( + group: PipeAnalysisYearMaterialGroup, + row: PipeAnalysisYearMaterialGroupRow, + columnId: string, + ) => void; +}; + +/** 지역 식별 열은 가로 스크롤해도 남겨 두고, 모든 집계 방식에 같은 너비를 사용한다. */ +const FIXED_WIDTHS: Record = { + rowNo: 56, + ctpvNm: 120, + sggNm: 120, + swtrmtZoneNm: 160, + totalLengthKm: 100, +}; +/** 기존 집계와 같은 천 단위 구분 및 소수점 세 자리 표시를 유지한다. */ +const formatValue = (value: unknown) => { + if (value === null || value === undefined || value === "") return "-"; + return typeof value === "number" + ? value.toLocaleString("ko-KR", { maximumFractionDigits: 3 }) + : String(value); +}; + +/** 표준 카드·페이징 안에서 관로 집계의 병합 셀과 고정 열을 렌더링한다. */ +export default function PipeAnalysisSummaryTable({ + rows, + groups, + grouped, + heads, + title, + total, + page, + pageSize, + actions, + onPageChange, + onCellClick, + onGroupedCellClick, +}: Props) { + // 표준 페이징이 기존 행 또는 처리구역 그룹 수를 기준으로 동작하도록 연결한다. + const data = useMemo( + () => (grouped ? groups : rows), + [grouped, groups, rows], + ); + // 실제 셀은 renderTable에서 그리며 표준 테이블에는 열 식별자를 등록한다. + const columns = useMemo[]>( + () => heads.map((head) => ({ id: head.id })), + [heads], + ); + // 페이지 단위는 기존과 동일하게 일반 표는 행, 병합 표는 처리구역 그룹이다. + const pagination = { pageIndex: Math.max(0, page - 1), pageSize }; + const table = useReactTable({ + data, + columns, + state: { pagination }, + rowCount: total, + manualPagination: true, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: (updater) => { + const next = + typeof updater === "function" ? updater(pagination) : updater; + if (next.pageIndex !== pagination.pageIndex) + onPageChange(next.pageIndex + 1); + }, + }); + // 열별 픽셀 너비와 왼쪽 고정 위치를 한곳에서 계산해 헤더·본문을 일치시킨다. + const layout = useMemo(() => { + let left = 0; + return heads.map((head) => { + const width = + FIXED_WIDTHS[head.id] ?? + (head.id === "elapsedYearLabel" + ? 112 + : String(head.title).length > 10 + ? 160 + : 120); + const fixed = head.id in FIXED_WIDTHS; + const style: CSSProperties = fixed ? { left } : {}; + if (fixed) left += width; + return { ...head, width, fixed, style }; + }); + }, [heads]); + // 모든 재질 열의 너비 합계를 최소 폭으로 사용해 좁은 팝업에서 열 압축을 막는다. + const tableWidth = layout.reduce((sum, head) => sum + head.width, 0); + + /** 실제 결과가 있는 수치만 기존 상세 조회 핸들러로 연결한다. */ + const cell = ( + head: (typeof layout)[number], + value: unknown, + onClick?: () => void, + rowSpan?: number, + ) => ( + + {onClick ? ( + + ) : ( + + {formatValue(value)} + + )} + + ); + + return ( + ( + + + {layout.map((head) => ( + + ))} + + + + {layout.map((head) => ( + + {head.title} + + ))} + + + + {grouped + ? groups.map((group, groupIndex) => ( + + {group.rows.map((row, rowIndex) => ( + + {layout.map((head) => { + if (head.fixed) { + if (rowIndex !== 0) return null; + return cell( + head, + group[ + head.id as keyof PipeAnalysisYearMaterialGroup + ], + undefined, + group.rows.length, + ); + } + if (head.id === "elapsedYearLabel") + return cell(head, row.elapsedYearLabel); + const value = Number(row.metrics[head.id] ?? 0); + return cell( + head, + value, + value > 0 && row.cellFilters[head.id] + ? () => onGroupedCellClick(group, row, head.id) + : undefined, + ); + })} + + ))} + + )) + : rows.map((row, index) => ( + + {layout.map((head) => + cell( + head, + head.id === "rowNo" + ? total - ((page - 1) * pageSize + index) + : row[head.id], + row.__cellFilters[head.id] && Number(row[head.id]) > 0 + ? () => onCellClick(row, head.id) + : undefined, + ), + )} + + ))} + +
+ )} + /> + ); +} diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipe-analysis-summary-table.css b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipe-analysis-summary-table.css new file mode 100644 index 000000000..59b06bef0 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipe-analysis-summary-table.css @@ -0,0 +1,54 @@ +/* 관로 집계 전용: 퍼센트 열 압축과 중첩 가로 스크롤 없이 병합 셀을 유지한다. */ +.sn-pipe-summary-grid .standard-data-table__scroll { + overflow: auto; + max-height: max(240px, calc(100dvh - 34rem)); + overscroll-behavior: contain; +} +.sn-pipe-summary-grid .table-ui { + border-collapse: separate; + border-spacing: 0; +} +.sn-pipe-summary-grid .table-ui thead { + position: sticky; + top: 0; + z-index: 4; +} +.sn-pipe-summary-grid .table-ui th { + height: 64px; + word-break: keep-all; + overflow-wrap: anywhere; + white-space: normal; +} +.sn-pipe-summary-grid .table-ui td { + white-space: nowrap; +} +.sn-pipe-summary-grid .pipe-summary-fixed { + position: sticky; + z-index: 2; +} +.sn-pipe-summary-grid td.pipe-summary-fixed { + background: var(--background, #fff); +} +.sn-pipe-summary-grid th.pipe-summary-fixed { + z-index: 5; +} +.sn-pipe-summary-grid .pipe-summary-text { + display: block; + overflow: hidden; + text-overflow: ellipsis; +} +.sn-pipe-summary-grid .pipe-summary-value { + width: 100%; + font: inherit; + text-align: inherit; + color: var(--primary); + text-decoration: underline; + text-underline-offset: 2px; +} +.sn-pipe-summary-grid .pipe-summary-value:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} +.sn-pipe-summary-grid .pipe-summary-group-start td { + border-top: 1px solid var(--border, #ddd); +} diff --git a/apps/sn/app/(map-layout)/map2/page.tsx b/apps/sn/app/(map-layout)/map2/page.tsx deleted file mode 100644 index 972464ec9..000000000 --- a/apps/sn/app/(map-layout)/map2/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../map/page"; diff --git a/apps/sn/docs/작업내용/FE-GRID-전수조사/001-fe-SN-관로분석그리드개선-전수조사.md b/apps/sn/docs/작업내용/FE-GRID-전수조사/001-fe-SN-관로분석그리드개선-전수조사.md new file mode 100644 index 000000000..9c4131597 --- /dev/null +++ b/apps/sn/docs/작업내용/FE-GRID-전수조사/001-fe-SN-관로분석그리드개선-전수조사.md @@ -0,0 +1,173 @@ +# 관로분석 그리드 개선 및 SN 구형 그리드 전수조사 + +작성일: 2026-09-09 + +## 작업 목적과 범위 + +- 관로분석의 재질별 열 압축과 경과년도&재질의 구형 표를 개선한다. +- SN 실제 소스와 HTML 시안 전체의 JSX 표 사용처를 조사한다. 타 앱은 수정하지 않는다. +- 이번 구현은 관로분석 세 가지 집계 방식에 한정한다. 나머지 발견 항목은 아래에 추가 전환 검토 대상으로 명시한다. +- 조사 범위는 소스 전수 확인이다. 모든 업무 화면을 브라우저로 실행했다는 의미는 아니다. + +## 원본 근거 + +- 요구사항정의서 `3. (기능)요구사항정의 및 추적표` 89행 SFR-011-003, 91행 SFR-011-005: 경과년도·관재질 현황 표와 지도 연결. +- 화면설계서 12번 슬라이드 SN_MN010105: 경과년도별 연장 합계(km). +- 화면설계서 14번 슬라이드 SN_MN010107: 관재질별 표와 지역 조회. +- 고정 열·너비·표준 스타일 적용은 이번 사용자 지시에 따른 UI 보완이다. 재질 그룹을 임의로 통합하거나 생략하지 않는다. + +## 변경 내용 + +- `PipeAnalysisPopupPage.tsx`의 별도 YEAR_MATERIAL Table/UiPageNations 분기와 일반 MapPopupDataTable 분기를 `PipeAnalysisSummaryTable`로 통합했다. +- `StandardDataTable`의 카드·로딩/빈 목록 처리·공통 페이지네이션을 재사용하고, 지원하는 `renderTable`로 병합 셀을 유지했다. 독립적인 구형 그리드를 새로 만든 것이 아니다. +- 표 본문은 공통 `Table variant="default"`의 파란 헤더를 사용한다. 공용 CSS 변경 없이 관로분석 전용 CSS로 너비·행 높이·고정 열만 적용한다. +- 순번 56px, 시도·시군구 각각 120px, 처리구역 160px, 합계 100px를 고정한다. 경과년도는 112px, 재질은 120~160px를 확보한다. +- 헤더 높이 64px, 본문은 공통 폰트·행간·패딩을 그대로 사용한다. 2026-09-09 후속 요청으로 최초 12px/18px 별도 폰트와 헤더 구분선 설정을 제거했다. 지역 텍스트는 한 줄과 전체값 title을 사용한다. +- 헤더는 세로 스크롤, 지역 식별 정보와 합계는 가로 스크롤 중 고정된다. 페이지/분석구분 전환 시 표 스크롤을 초기화한다. +- 병합 표는 처리구역당 4행과 기존 그룹별 순번·페이지 단위를 유지한다. +- 결과가 있는 셀은 키보드로도 누를 수 있는 버튼을 통해 기존 상세 조회 핸들러로 연결한다. +- API 호출, 집계 데이터, 조회 필터, 기존 엑셀 생성 함수는 변경하지 않았다. + +## 전수조사 결과 + +TypeScript AST로 주석·문자열을 제외한 실제 JSX와 import 별칭을 검사했다. + +- 조사 소스: 360개(JS/JSX/TS/TSX), 이 중 실제 앱 경로 321개와 HTML 시안 39개. +- 표·폼·래퍼 사용 파일: 실제 앱 58개, HTML 시안 13개. 파일 수는 화면 수와 다르다. +- 실제 앱의 `UiTable` 직접 렌더링: 0개. +- HTML 시안의 `UiTable` 직접 렌더링: 5개 파일, 9개 위치. +- 실제 앱의 `UiPageNations`는 VerticalTable에 1개 남아 있으나 현재 호출부는 모두 total=0인 속성 폼이다. +- `ag-grid`, `AgGrid`, `DataGrid`, 별도 ReactTable 및 Table/Grid 이름의 JSX도 보조 검색했다. + +### 추가 전환 검토 결과(3곳 중 GIS 오류 목록 전환 완료) + +| 화면 | 현재 구조 | 권장 후속 작업 | +|---|---|---| +| GIS 관리 → 오류 확인 | 후속 요청으로 MapPopupDataTable 전환 완료 | 실제 이력의 오류 0건과 목록 복귀 확인. 비어 있지 않은 오류 목록의 실제 데이터 검증은 남음 | +| 지반침하 상세 → 반경 내 하수관로 | SinkholeDetailPanel의 직접 HTML table, 작은 글자·별도 NO | 좁은 지도 패널용 표준 보조목록으로 정리. 관로 선택·지도 이동 회귀 확인 필요 | +| 조사관리 → 사업 선택 → 조사현황 집계 | ExmnStatusDetailPanel의 default Table 직접 렌더링 | 색상은 표준이지만 카드/스크롤/펼침행은 별도 구현. 복합표용 renderTable 전환 검토 | + +그룹별 메뉴 권한의 ManagementAuthorityDialog는 DataTable을 직접 사용하지만 SN 내 실제 호출부가 발견되지 않고 barrel export만 존재한다. 활성 화면으로 단정하지 않았다. + +### 단순 Table 사용이지만 유지할 항목 + +- VerticalTable, EditableFormTable, 준설 등록 정보·관로 정보·위치 정보, 지도 상세정보는 입력/속성 폼이므로 목록 그리드 전환 대상으로 분류하지 않는다. +- ExmnSurveyDetailPopupPage와 ExmnPplUnitDetailTable은 이미 ExmnSurveyStandardDataTable → StandardDataTable.renderTable 내부의 병합 표이므로 표준화 완료로 분류한다. +- 준설 사진 표는 최대 5장 보조목록이며 공통 default 스타일을 사용한다. +- HTML 시안은 기존 규칙에 따라 읽기만 수행했다. + +## 검증 결과 + +- 로컬 3002 사용자 개발 서버에서 HMR 적용, 새 서버 기동·재시작·Next build 없음. +- 1920px급 창과 1168×950px 좁은 창에서 실제 API 데이터로 YEAR/MATERIAL/YEAR_MATERIAL 조회 확인. +- 재질별 214건 및 경과년도&재질 214그룹 표시. 모든 재질 열과 수치 유지. +- 좁은 창에서 헤더 64px, 본문 38px, 첫 5개 열 너비 56/120/120/160/100px 확인. +- 가로 스크롤 2336px 후 첫 5개 열의 x 좌표 유지 확인. +- 세로 스크롤 989px 후 헤더와 스크롤 컨테이너 y 좌표가 모두 217px로 유지됨을 확인. +- 병합 셀 rowSpan=4 유지. 2페이지 첫 그룹 순번 204, 40행(10그룹), 스크롤 0/0 초기화 확인. +- A처리구역 / 10년 미만 / 흉관 셀 클릭 시 상세 팝업에 YEAR_MATERIAL, ctpvCd=36, sggCd=36110, elapsedYearBucket=LT10, materialCode=MOP012 전달 및 23건 조회 확인. +- TypeScript: `node apps/sn/node_modules/typescript/bin/tsc --noEmit --incremental false --pretty false --ignoreDeprecations 5.0 -p apps/sn/tsconfig.json` 통과. 저장소 기존 ignoreDeprecations 설정을 CLI에서만 호환값으로 덮어썼으며 설정 파일은 변경하지 않았다. +- `git diff --check` 통과. +- 엑셀 생성 함수는 변경하지 않았으며 실제 다운로드·파일 내용 재검증은 미실행. +- 추가 전환 검토 3개 화면은 소스 확인만 수행했다. + +## 재현 방법과 롤백 + +- 표 사용처 전수조사는 AST 기반 일회성 스크립트로 수행했으며, 해당 스크립트는 저장소에 남기지 않았다. +- 원복은 PipeAnalysisPopupPage의 이번 표 렌더링 변경과 신규 PipeAnalysisSummaryTable/CSS만 되돌린다. DB 이관은 없다. +- 공통 패키지와 타 앱 변경은 없다. 커밋·배포는 수행하지 않았다. +- 긴 재질 목록은 가로 스크롤이 필요하다. 재질명 중복은 코드별 기존 데이터이므로 이름만 보고 열을 병합하지 않았다. + +## 2026-09-09 공통 스타일 일치 및 GIS 오류 목록 전환 + +- 추가 사용자 요청에 따라 관로분석 카드 제목의 총 건수와 엑셀다운로드 옆 `단위: km`를 제거했다. 페이지 계산용 rowCount와 순번은 유지한다. 로컬 HMR 후 제목과 엑셀다운로드 버튼만 표시됨을 확인했다. +- 관로분석에서 별도로 추가했던 헤더 구분선, 고정 열 경계선, 12px 폰트·18px 행간·셀 패딩·본문 높이 지정을 제거했다. 너비·병합·고정 열을 위한 구조만 유지한다. +- 실제 브라우저에서 기존 관로현황 상세표와 비교: 글꼴 Noto Sans KR, 글자 13px, 헤더 굵기 500/본문 400, 행간 15.6px, 헤더 패딩 12px 10px/본문 12px 5px, 헤더 우측 보더 0px로 일치했다. +- GIS 오류 목록은 직접 작성한 secondary Table을 제거하고 업로드 이력과 동일한 MapPopupDataTable로 전환했다. 기존 순번·원본 행번호·FID·오류 설명을 유지하고 오류 설명은 줄바꿈한다. +- 오류 목록의 제목과 기존 보조 버튼은 표준 카드 actions에 통합했다. 업로드·검증 요청과 오류 설명 변환은 변경하지 않았다. +- 원본 근거: 요구사항정의서 97행 SFR-012-002, 화면설계서 35번 슬라이드 SN_MN010302. 오류 표의 스타일 통일은 이번 사용자 지시에 따른 UI 보완이다. +- 가평군 업로드 이력 45건을 확인했고 오류 건수는 모두 0이었다. 실제 오류 확인 → 빈 상태 → 목록 복귀를 검증했다. 비어 있지 않은 오류 행의 브라우저 검증은 미완료이며 검증을 위해 새 업로드는 수행하지 않았다. +- TypeScript 검사(ignoreDeprecations CLI 호환값 적용) 및 git diff --check 통과. 개발 서버 기동·재시작, API·DB 변경, 커밋·배포 없음. +- 최초 조사에서 추가 전환 대상으로 분류한 3곳 중 GIS 오류 목록은 전환 완료했고 지반침하 주변 관로 및 조사현황 집계표 2곳은 검토 대상으로 남는다. +- 롤백은 이번 CSS 제거 변경과 GisManagementPopupPage의 오류 목록 렌더링만 되돌린다. + +## 최초 전수조사 당시 실제 앱 전체 사용처 + +| 파일 | JSX 구성요소:행 | 판정 | +|---|---|---| +| [app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx:343) | MapPopupDataTable:343 | 표준 그리드 또는 표준 래퍼 | +| [app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx:291) | StandardDataTable:291 | 표준 그리드 또는 표준 래퍼 | +| [app/(map-layout)/map/components/exmn/ExmnSurveyResultSection.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/exmn/ExmnSurveyResultSection.tsx:131) | Table:131 (secondary) | 상세정보·입력용 표(목록 그리드 아님) | +| [app/(map-layout)/map/components/fldngHstry/FldngHstryDetailPanel.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryDetailPanel.tsx:23) | table:23 | 상세정보·입력용 표(목록 그리드 아님) | +| [app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx:449) | MapPopupDataTable:449 | 표준 그리드 또는 표준 래퍼 | +| [app/(map-layout)/map/components/floodDngrMap/FloodDngrMapPopupPage.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/floodDngrMap/FloodDngrMapPopupPage.tsx:387) | MapPopupDataTable:387 | 표준 그리드 또는 표준 래퍼 | +| [app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx:447) | VerticalTable:447, VerticalTable:458 | 세로 입력·상세 폼 | +| [app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx:901) | Table:901 (secondary), MapPopupDataTable:951 | 추가 전환 검토: 오류 목록은 직접 Table, 이력 목록은 표준 | +| [app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx:381) | MapPopupDataTable:381 | 표준 그리드 또는 표준 래퍼 | +| [app/(map-layout)/map/components/InputSection.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/InputSection.tsx:40) | Table:40 (secondary) | 상세정보·입력용 표(목록 그리드 아님) | +| [app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx:469) | MapPopupDataTable:469 | 표준 그리드 또는 표준 래퍼 | +| [app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx:500) | PipeAnalysisSummaryTable:500 | 표준 그리드 또는 표준 래퍼 | +| [app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisSummaryTable.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisSummaryTable.tsx:152) | StandardDataTable:152, Table:169 (default) | 표준 renderTable 내부 복합표(전환 대상 아님) | +| [app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx:1026) | VerticalTable:1026 | 세로 입력·상세 폼 | +| [app/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx:153) | VerticalTable:153 | 세로 입력·상세 폼 | +| [app/(map-layout)/map/components/popupComponent/sewagePump.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/popupComponent/sewagePump.tsx:51) | VerticalTable:51 | 세로 입력·상세 폼 | +| [app/(map-layout)/map/components/popupComponent/smh.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/popupComponent/smh.tsx:121) | VerticalTable:121 | 세로 입력·상세 폼 | +| [app/(map-layout)/map/components/popupComponent/Ssc.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx:276) | MapPopupDataTable:276, MapPopupDataTable:291, MapPopupDataTable:307, VerticalTable:323 | 세로 입력·상세 폼 | +| [app/(map-layout)/map/components/popupComponent/stormWaterPump.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/popupComponent/stormWaterPump.tsx:22) | VerticalTable:22 | 세로 입력·상세 폼 | +| [app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx:43) | VerticalTable:43 | 세로 입력·상세 폼 | +| [app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx:105) | Table:105 (secondary) | 상세정보·입력용 표(목록 그리드 아님) | +| [app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx:383) | MapPopupDataTable:383 | 표준 그리드 또는 표준 래퍼 | +| [app/(map-layout)/map/components/repairHistory/RepairHistoryPopupPage.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/repairHistory/RepairHistoryPopupPage.tsx:454) | MapPopupDataTable:454 | 표준 그리드 또는 표준 래퍼 | +| [app/(map-layout)/map/components/sbmrgDngrMap/SbmrgDngrMapPopupPage.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/sbmrgDngrMap/SbmrgDngrMapPopupPage.tsx:293) | MapPopupDataTable:293 | 표준 그리드 또는 표준 래퍼 | +| [app/(map-layout)/map/components/sinkhole/SinkholeDetailPanel.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholeDetailPanel.tsx:187) | VerticalTable:187, table:236 | 추가 전환 검토: 주변 관로는 직접 table, 기본정보는 폼 | +| [app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx:489) | MapPopupDataTable:489 | 표준 그리드 또는 표준 래퍼 | +| [app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx:673) | MapPopupDataTable:673 | 표준 그리드 또는 표준 래퍼 | +| [app/(map-layout)/map/page.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/page.tsx:9306) | table:9306 | 상세정보·입력용 표(목록 그리드 아님) | +| [app/(map-layout)/map/searchModal/reportTab.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx:491) | MapPopupDataTable:491 | 표준 그리드 또는 표준 래퍼 | +| [app/(map-layout)/map/searchModal/searchModal.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx:231) | MapPopupDataTable:231, MapPopupDataTable:262, MapPopupDataTable:287 | 표준 그리드 또는 표준 래퍼 | +| [app/(with-base-layout)/ii/rdii/page.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(with-base-layout)/ii/rdii/page.tsx:531) | EditableFormTable:531, EditableFormTable:542 | 세로 입력·상세 폼 | +| [app/(with-base-layout)/ii/rdiiResult/page.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(with-base-layout)/ii/rdiiResult/page.tsx:368) | MapPopupDataTable:368 | 표준 그리드 또는 표준 래퍼 | +| [app/(with-base-layout)/ii/result/page.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(with-base-layout)/ii/result/page.tsx:367) | MapPopupDataTable:367 | 표준 그리드 또는 표준 래퍼 | +| [app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage.tsx:523) | Table:523 (secondary) | 상세정보·입력용 표(목록 그리드 아님) | +| [app/(with-base-layout)/oper/dredging/components/DredgingRegisterModal.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingRegisterModal.tsx:591) | Table:591 (secondary), Table:754 (default), Table:889 (secondary) | 입력 폼 2개 + 사진 최대 5장 보조목록(표준 default) | +| [app/(with-base-layout)/oper/dredging/page.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx:872) | StandardDataTable:872 | 표준 그리드 또는 표준 래퍼 | +| [app/(with-base-layout)/oper/floodPre/page.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx:326) | StandardDataTable:326 | 표준 그리드 또는 표준 래퍼 | +| [app/(with-base-layout)/oper/floodPre/tableModal.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx:272) | StandardDataTable:272, StandardDataTable:280 | 표준 그리드 또는 표준 래퍼 | +| [app/(with-base-layout)/oper/oldPp/page.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx:308) | StandardDataTable:308 | 표준 그리드 또는 표준 래퍼 | +| [app/(with-base-layout)/oper/oldPp/tableModal.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx:354) | StandardDataTable:354 | 표준 그리드 또는 표준 래퍼 | +| [app/components/ui/EditableFormTable.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/components/ui/EditableFormTable.tsx:61) | Table:61 (secondary) | 상세정보·입력용 표(목록 그리드 아님) | +| [app/components/ui/verticalTable.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/components/ui/verticalTable.tsx:182) | Table:182 (secondary), Table:307 (secondary), UiPageNations:438 | 세로 입력 폼; 구형 페이징 분기는 현재 호출부 total=0 | +| [src/entities/menu-authority/ui/management-authority-dialog.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/src/entities/menu-authority/ui/management-authority-dialog.tsx:86) | DataTable:86 | DataTable 직접 사용; SN 호출부 미발견(내보내기만 존재) | +| [src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx:654) | StandardDataTable:654 | 표준 그리드 또는 표준 래퍼 | +| [src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx:751) | StandardDataTable:751 | 표준 그리드 또는 표준 래퍼 | +| [src/features/exmn/ui/ExmnPplUnitDetailTable.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/src/features/exmn/ui/ExmnPplUnitDetailTable.tsx:73) | Table:73 (default) | 표준 renderTable 내부 복합표(전환 대상 아님) | +| [src/features/exmn/ui/ExmnRegistrationPage.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx:661) | StandardDataTable:661 | 표준 그리드 또는 표준 래퍼 | +| [src/features/exmn/ui/ExmnStandardDataTable.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/src/features/exmn/ui/ExmnStandardDataTable.tsx:127) | StandardDataTable:127 | 표준 그리드 또는 표준 래퍼 | +| [src/features/exmn/ui/ExmnStatusDetailPanel.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/src/features/exmn/ui/ExmnStatusDetailPanel.tsx:413) | Table:413 (default) | 추가 전환 검토: 표준 색상이지만 별도 집계·확장 표 | +| [src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx:914) | ExmnSurveyStandardDataTable:914, ExmnPplUnitDetailTable:964, Table:973 (default) | 표준 renderTable 내부 복합표(전환 대상 아님) | +| [src/features/exmn/ui/ExmnSurveyStandardDataTable.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/src/features/exmn/ui/ExmnSurveyStandardDataTable.tsx:54) | StandardDataTable:54 | 표준 그리드 또는 표준 래퍼 | +| [src/features/exmn/ui/PplFacilityDetailModal.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/src/features/exmn/ui/PplFacilityDetailModal.tsx:187) | ExmnStandardDataTable:187, ExmnStandardDataTable:211 | 표준 그리드 또는 표준 래퍼 | +| [src/features/exmn/ui/PplSummaryModal.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/src/features/exmn/ui/PplSummaryModal.tsx:424) | ExmnStandardDataTable:424, ExmnStandardDataTable:559 | 표준 그리드 또는 표준 래퍼 | +| [src/features/exmn/ui/TrtdstManagementPage.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/src/features/exmn/ui/TrtdstManagementPage.tsx:638) | StandardDataTable:638 | 표준 그리드 또는 표준 래퍼 | +| [src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx:559) | MapPopupDataTable:559 | 표준 그리드 또는 표준 래퍼 | +| [src/features/obsltPpl/ui/ObsltPplListPage.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx:648) | StandardDataTable:648 | 표준 그리드 또는 표준 래퍼 | +| [src/features/windowPopup/ui/MapPopupDataTable.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/src/features/windowPopup/ui/MapPopupDataTable.tsx:184) | StandardDataTable:184 | 표준 그리드 또는 표준 래퍼 | +| [src/features/windowPopup/ui/ReportFilesPopupContent.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/src/features/windowPopup/ui/ReportFilesPopupContent.tsx:107) | MapPopupDataTable:107 | 표준 그리드 또는 표준 래퍼 | + +## HTML 시안·참고 코드 전체 사용처 + +| 파일 | JSX 구성요소:행 | 판정 | +|---|---|---| +| [app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx:53) | VerticalTable:53 | HTML 시안·참고 코드 | +| [app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx:157) | UiTable:157 | HTML 시안·참고 코드 | +| [app/html/(map-layout)/map/components/InputSection.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/html/(map-layout)/map/components/InputSection.tsx:20) | Table:20 (secondary) | HTML 시안·참고 코드 | +| [app/html/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/html/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx:591) | VerticalTable:591 | HTML 시안·참고 코드 | +| [app/html/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/html/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx:126) | VerticalTable:126 | HTML 시안·참고 코드 | +| [app/html/(map-layout)/map/components/popupComponent/sewagePump.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/html/(map-layout)/map/components/popupComponent/sewagePump.tsx:50) | VerticalTable:50 | HTML 시안·참고 코드 | +| [app/html/(map-layout)/map/components/popupComponent/smh.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/html/(map-layout)/map/components/popupComponent/smh.tsx:107) | VerticalTable:107 | HTML 시안·참고 코드 | +| [app/html/(map-layout)/map/components/popupComponent/Ssc.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/html/(map-layout)/map/components/popupComponent/Ssc.tsx:213) | UiTable:213, UiTable:227, UiTable:261, VerticalTable:278 | HTML 시안·참고 코드 | +| [app/html/(map-layout)/map/components/popupComponent/stormWaterPump.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/html/(map-layout)/map/components/popupComponent/stormWaterPump.tsx:21) | VerticalTable:21 | HTML 시안·참고 코드 | +| [app/html/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/html/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx:31) | VerticalTable:31 | HTML 시안·참고 코드 | +| [app/html/(map-layout)/map/searchModal/reportFilesModal.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/html/(map-layout)/map/searchModal/reportFilesModal.tsx:70) | UiTable:70 | HTML 시안·참고 코드 | +| [app/html/(map-layout)/map/searchModal/reportTab.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/html/(map-layout)/map/searchModal/reportTab.tsx:250) | UiTable:250 | HTML 시안·참고 코드 | +| [app/html/(map-layout)/map/searchModal/searchModal.tsx](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/html/(map-layout)/map/searchModal/searchModal.tsx:130) | UiTable:130, UiTable:160, UiTable:187 | HTML 시안·참고 코드 | 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 4eea57a85..fe5dd3836 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 @@ -180,3 +180,15 @@ - 시설구분은 백엔드 지원범위 `PPL` 단일을 유지하면서 화면 표시명만 `SNFCLTSE`의 `PPL=관로`로 표시한다. `MNHL` 기능은 새로 활성화하지 않았다. - CMS 조회 실패 시 기존 업로드·다운로드·하수관로 문구를 fallback으로 사용한다. - 백엔드 API, 업로드·다운로드 payload와 DB 계약은 변경하지 않았다. + +## 2026-09-09 오류 저장 이력 재조회 및 전체 오류 페이지 연결 + +- 서버가 실패 이력과 오류 상세를 SN DB에 저장하도록 변경한 계약에 맞춰, 업로드 실패 응답 뒤 목록을 갱신하고 해당 실패 uploadId를 선택한다. +- 오류 조회는 10건 단위 서버 페이징을 사용한다. errorUploadId를 별도로 유지해 같은 행의 여러 오류와 500건 이후 오류도 페이지를 넘겨 조회한다. +- DB에 저장된 사용자용 한글 설명은 보존하고 과거 기술용어 응답만 기존 변환을 적용한다. 파일 전체 오류의 행번호 0은 '파일 전체'로 표시한다. +- 실패 이력은 실제 관로에 반영되지 않았으므로 미리보기·이력 다운로드를 비활성화한다. +- 공통 MapPopupDataTable과 기존 폰트·제목 표시 기준을 유지한다. +- 원본 근거: SFR-012-002, 요구사항정의서 97행, 화면설계서 35번 SN_MN010302. 상세 SQL·표준 용어 근거는 BE의 007-be-SFR-012-002-오류저장-구현검증.md와 공통 DB작업.sql에 기록한다. +- 개발 DB의 가평군 '[UI검증용] GIS 오류 11건.gpkg' 이력은 새 BE API에서 10건/1건, total=11을 확인했다. 프론트 타입 검사 통과. 기존 3002 개발 서버를 유지했다. +- 새 BE와 기존 FE를 연결한 임시 로컬 주소에서는 로그인 상태가 이어지지 않아 브라우저 최종 연동 검증은 미완료다. 수정 BE가 서비스에 반영된 뒤 오류 확인 1·2페이지를 확인해야 한다. +- 롤백은 오류 페이지 상태·실패 응답 후 목록 갱신·실패 이력 버튼 제한 변경만 되돌린다. 저장된 오류 이력은 보존한다. From 44361af392186aed5ac28962526f26269b594364 Mon Sep 17 00:00:00 2001 From: mind-one001 Date: Thu, 10 Sep 2026 13:51:42 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C=20css=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pipeAnalysis/PipeAnalysisSummaryTable.tsx | 299 +++++++++--------- .../pipe-analysis-summary-table.css | 54 ---- .../src/ui/standard_data_table/index.tsx | 204 +++++++++++- .../standard-data-table.css | 44 +++ 4 files changed, 392 insertions(+), 209 deletions(-) delete mode 100644 apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipe-analysis-summary-table.css diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisSummaryTable.tsx b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisSummaryTable.tsx index 79152a913..841d8ac0e 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisSummaryTable.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisSummaryTable.tsx @@ -1,6 +1,6 @@ "use client"; -import { Fragment, useMemo, type CSSProperties, type ReactNode } from "react"; +import { useMemo, type ReactNode } from "react"; import { getCoreRowModel, useReactTable, @@ -8,24 +8,28 @@ import { } from "@tanstack/react-table"; import { StandardDataTable, + type StandardDataTableLayout, type UiHeadProps, } from "@common/business_components"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@common/components"; import type { PipeAnalysisSummaryViewRow, PipeAnalysisYearMaterialGroup, PipeAnalysisYearMaterialGroupRow, } from "./pipeAnalysisUtils"; -import "./pipe-analysis-summary-table.css"; -type SummaryRow = PipeAnalysisSummaryViewRow | PipeAnalysisYearMaterialGroup; +type SummaryRow = + | { + kind: "flat"; + value: PipeAnalysisSummaryViewRow; + sourceIndex: number; + } + | { + kind: "grouped"; + group: PipeAnalysisYearMaterialGroup; + value: PipeAnalysisYearMaterialGroupRow; + groupIndex: number; + groupRowIndex: number; + }; type Props = { rows: PipeAnalysisSummaryViewRow[]; groups: PipeAnalysisYearMaterialGroup[]; @@ -53,6 +57,16 @@ const FIXED_WIDTHS: Record = { swtrmtZoneNm: 160, totalLengthKm: 100, }; +/** 공통 그리드가 같은 순서로 왼쪽 고정 위치를 계산할 관로 식별 열이다. */ +const PINNED_COLUMN_IDS = Object.keys(FIXED_WIDTHS); +/** 관로분석에서 선택하는 공통 표 레이아웃이며 화면 전용 CSS를 대체한다. */ +const PIPE_SUMMARY_LAYOUT: StandardDataTableLayout = { + tableLayout: "fixed", + stickyHeader: true, + headerHeight: "64px", + headerWrap: true, + cellOverflow: "ellipsis", +}; /** 기존 집계와 같은 천 단위 구분 및 소수점 세 자리 표시를 유지한다. */ const formatValue = (value: unknown) => { if (value === null || value === undefined || value === "") return "-"; @@ -61,6 +75,24 @@ const formatValue = (value: unknown) => { : String(value); }; +/** 결과가 있는 셀만 링크형 버튼으로 표시하고 나머지는 전체값 title을 유지한다. */ +const renderValue = (value: unknown, onClick?: () => void) => { + const formatted = formatValue(value); + return onClick ? ( + + ) : ( + + {formatted} + + ); +}; + /** 표준 카드·페이징 안에서 관로 집계의 병합 셀과 고정 열을 렌더링한다. */ export default function PipeAnalysisSummaryTable({ rows, @@ -76,15 +108,91 @@ export default function PipeAnalysisSummaryTable({ onCellClick, onGroupedCellClick, }: Props) { - // 표준 페이징이 기존 행 또는 처리구역 그룹 수를 기준으로 동작하도록 연결한다. + // 병합 표도 공통 렌더러가 행을 순회할 수 있도록 처리구역의 4개 경과년도 행만 펼친다. const data = useMemo( - () => (grouped ? groups : rows), + () => + grouped + ? groups.flatMap((group, groupIndex) => + group.rows.map((value, groupRowIndex) => ({ + kind: "grouped" as const, + group, + value, + groupIndex, + groupRowIndex, + })), + ) + : rows.map((value, sourceIndex) => ({ + kind: "flat" as const, + value, + sourceIndex, + })), [grouped, groups, rows], ); - // 실제 셀은 renderTable에서 그리며 표준 테이블에는 열 식별자를 등록한다. + // 공통 렌더러가 colgroup과 고정 위치에 사용할 열별 픽셀 너비를 한곳에서 계산한다. + const layout = useMemo(() => { + return heads.map((head) => { + const width = + FIXED_WIDTHS[head.id] ?? + (head.id === "elapsedYearLabel" + ? 112 + : String(head.title).length > 10 + ? 160 + : 120); + const fixed = head.id in FIXED_WIDTHS; + return { ...head, width, fixed }; + }); + }, [heads]); + // 모든 재질 열의 너비 합계를 최소 폭으로 사용해 좁은 팝업에서 열 압축을 막는다. + const tableWidth = layout.reduce((sum, head) => sum + head.width, 0); + // 열은 값·업무 클릭만 정의하고 실제 th/td, 고정 열과 overflow는 공통 렌더러가 소유한다. const columns = useMemo[]>( - () => heads.map((head) => ({ id: head.id })), - [heads], + () => + layout.map((head) => ({ + id: head.id, + size: head.width, + minSize: head.width, + maxSize: head.width, + header: () => {head.title}, + cell: ({ row }) => { + const summaryRow = row.original; + if (summaryRow.kind === "flat") { + const value = + head.id === "rowNo" + ? total - ((page - 1) * pageSize + summaryRow.sourceIndex) + : summaryRow.value[head.id]; + const onClick = + summaryRow.value.__cellFilters[head.id] && Number(value) > 0 + ? () => onCellClick(summaryRow.value, head.id) + : undefined; + return renderValue(value, onClick); + } + + if (head.fixed) { + return renderValue( + summaryRow.group[head.id as keyof PipeAnalysisYearMaterialGroup], + ); + } + if (head.id === "elapsedYearLabel") { + return renderValue(summaryRow.value.elapsedYearLabel); + } + + const value = Number(summaryRow.value.metrics[head.id] ?? 0); + const onClick = + value > 0 && summaryRow.value.cellFilters[head.id] + ? () => + onGroupedCellClick( + summaryRow.group, + summaryRow.value, + head.id, + ) + : undefined; + return renderValue(value, onClick); + }, + meta: { + align: head.align === "right" ? "right" : "center", + }, + })), + [layout, onCellClick, onGroupedCellClick, page, pageSize, total], ); // 페이지 단위는 기존과 동일하게 일반 표는 행, 병합 표는 처리구역 그룹이다. const pagination = { pageIndex: Math.max(0, page - 1), pageSize }; @@ -95,6 +203,10 @@ export default function PipeAnalysisSummaryTable({ rowCount: total, manualPagination: true, getCoreRowModel: getCoreRowModel(), + getRowId: (row) => + row.kind === "flat" + ? `flat-${row.sourceIndex}` + : `group-${row.groupIndex}-${row.value.elapsedYearBucket}`, onPaginationChange: (updater) => { const next = typeof updater === "function" ? updater(pagination) : updater; @@ -102,51 +214,6 @@ export default function PipeAnalysisSummaryTable({ onPageChange(next.pageIndex + 1); }, }); - // 열별 픽셀 너비와 왼쪽 고정 위치를 한곳에서 계산해 헤더·본문을 일치시킨다. - const layout = useMemo(() => { - let left = 0; - return heads.map((head) => { - const width = - FIXED_WIDTHS[head.id] ?? - (head.id === "elapsedYearLabel" - ? 112 - : String(head.title).length > 10 - ? 160 - : 120); - const fixed = head.id in FIXED_WIDTHS; - const style: CSSProperties = fixed ? { left } : {}; - if (fixed) left += width; - return { ...head, width, fixed, style }; - }); - }, [heads]); - // 모든 재질 열의 너비 합계를 최소 폭으로 사용해 좁은 팝업에서 열 압축을 막는다. - const tableWidth = layout.reduce((sum, head) => sum + head.width, 0); - - /** 실제 결과가 있는 수치만 기존 상세 조회 핸들러로 연결한다. */ - const cell = ( - head: (typeof layout)[number], - value: unknown, - onClick?: () => void, - rowSpan?: number, - ) => ( - - {onClick ? ( - - ) : ( - - {formatValue(value)} - - )} - - ); return ( { + const summaryRow = row.original; + const isPinnedColumn = PINNED_COLUMN_IDS.includes(columnId); + const isGrouped = summaryRow.kind === "grouped"; + + return { + hidden: isGrouped && isPinnedColumn && summaryRow.groupRowIndex > 0, + rowSpan: + isGrouped && isPinnedColumn && summaryRow.groupRowIndex === 0 + ? summaryRow.group.rows.length + : undefined, + className: + isGrouped && summaryRow.groupRowIndex === 0 + ? "border-t border-border" + : undefined, + }; + }} actions={actions} - renderTable={() => ( - - - {layout.map((head) => ( - - ))} - - - - {layout.map((head) => ( - - {head.title} - - ))} - - - - {grouped - ? groups.map((group, groupIndex) => ( - - {group.rows.map((row, rowIndex) => ( - - {layout.map((head) => { - if (head.fixed) { - if (rowIndex !== 0) return null; - return cell( - head, - group[ - head.id as keyof PipeAnalysisYearMaterialGroup - ], - undefined, - group.rows.length, - ); - } - if (head.id === "elapsedYearLabel") - return cell(head, row.elapsedYearLabel); - const value = Number(row.metrics[head.id] ?? 0); - return cell( - head, - value, - value > 0 && row.cellFilters[head.id] - ? () => onGroupedCellClick(group, row, head.id) - : undefined, - ); - })} - - ))} - - )) - : rows.map((row, index) => ( - - {layout.map((head) => - cell( - head, - head.id === "rowNo" - ? total - ((page - 1) * pageSize + index) - : row[head.id], - row.__cellFilters[head.id] && Number(row[head.id]) > 0 - ? () => onCellClick(row, head.id) - : undefined, - ), - )} - - ))} - -
- )} /> ); } diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipe-analysis-summary-table.css b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipe-analysis-summary-table.css deleted file mode 100644 index 59b06bef0..000000000 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipe-analysis-summary-table.css +++ /dev/null @@ -1,54 +0,0 @@ -/* 관로 집계 전용: 퍼센트 열 압축과 중첩 가로 스크롤 없이 병합 셀을 유지한다. */ -.sn-pipe-summary-grid .standard-data-table__scroll { - overflow: auto; - max-height: max(240px, calc(100dvh - 34rem)); - overscroll-behavior: contain; -} -.sn-pipe-summary-grid .table-ui { - border-collapse: separate; - border-spacing: 0; -} -.sn-pipe-summary-grid .table-ui thead { - position: sticky; - top: 0; - z-index: 4; -} -.sn-pipe-summary-grid .table-ui th { - height: 64px; - word-break: keep-all; - overflow-wrap: anywhere; - white-space: normal; -} -.sn-pipe-summary-grid .table-ui td { - white-space: nowrap; -} -.sn-pipe-summary-grid .pipe-summary-fixed { - position: sticky; - z-index: 2; -} -.sn-pipe-summary-grid td.pipe-summary-fixed { - background: var(--background, #fff); -} -.sn-pipe-summary-grid th.pipe-summary-fixed { - z-index: 5; -} -.sn-pipe-summary-grid .pipe-summary-text { - display: block; - overflow: hidden; - text-overflow: ellipsis; -} -.sn-pipe-summary-grid .pipe-summary-value { - width: 100%; - font: inherit; - text-align: inherit; - color: var(--primary); - text-decoration: underline; - text-underline-offset: 2px; -} -.sn-pipe-summary-grid .pipe-summary-value:focus-visible { - outline: 2px solid var(--primary); - outline-offset: 2px; -} -.sn-pipe-summary-grid .pipe-summary-group-start td { - border-top: 1px solid var(--border, #ddd); -} diff --git a/packages/common/business_components/src/ui/standard_data_table/index.tsx b/packages/common/business_components/src/ui/standard_data_table/index.tsx index f5f67714f..643d6c06a 100644 --- a/packages/common/business_components/src/ui/standard_data_table/index.tsx +++ b/packages/common/business_components/src/ui/standard_data_table/index.tsx @@ -10,13 +10,24 @@ import { type ReactNode, type UIEventHandler, } from "react"; -import type { Table as TableInstance } from "@tanstack/react-table"; +import { + flexRender, + type Cell, + type Row, + type Table as TableInstance, +} from "@tanstack/react-table"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { Button, DataTable, LoadingWithAnimation, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, cn, } from "@common/components"; @@ -26,6 +37,28 @@ type StandardDataTableRenderContext = { table: TableInstance; }; +type StandardDataTableLayout = { + tableLayout?: "auto" | "fixed"; + stickyHeader?: boolean; + headerHeight?: string; + headerWrap?: boolean; + cellOverflow?: "wrap" | "nowrap" | "ellipsis"; +}; + +type StandardDataTableCellContext = { + row: Row; + cell: Cell; + rowIndex: number; + columnId: string; +}; + +type StandardDataTableCellProps = { + hidden?: boolean; + rowSpan?: number; + className?: string; + style?: CSSProperties; +}; + type StandardDataTableProps = { table: TableInstance; title?: ReactNode; @@ -47,12 +80,25 @@ type StandardDataTableProps = { renderTable?: (context: StandardDataTableRenderContext) => ReactNode; /** 고정 열 경계처럼 가로 스크롤 위치가 필요한 소비 화면에 공통 스크롤 이벤트를 전달한다. */ onScroll?: UIEventHandler; + /** 표 레이아웃·고정 헤더·셀 넘침처럼 업무와 무관한 공통 표시 방식을 선택한다. */ + layout?: StandardDataTableLayout; + /** 왼쪽에 고정할 열 ID이며 실제 left 위치는 등록된 열 너비를 기준으로 계산한다. */ + pinnedColumns?: string[]; + /** 병합 셀처럼 행별 HTML 셀 속성이 필요한 복합표의 표시 계약만 전달한다. */ + getCellProps?: ( + context: StandardDataTableCellContext, + ) => StandardDataTableCellProps | undefined; onRowClick?: (row: T) => void; }; type StandardDataTableStyle = CSSProperties & { "--standard-data-table-min-width"?: string; "--standard-data-table-max-height"?: string; + "--standard-data-table-header-height"?: string; +}; + +type StandardPinnedCellStyle = CSSProperties & { + "--standard-data-table-pinned-left"?: string; }; const DEFAULT_PAGINATION_MARGIN_TOP = 24; @@ -179,6 +225,129 @@ function StandardDataTablePagination({ ); } +/** + * 고정 열과 행별 병합 속성이 필요한 표를 공통 Table 마크업으로 렌더링한다. + * 일반 표는 기존 DataTable 경로를 유지해 확장 옵션을 사용하지 않는 화면의 동작을 바꾸지 않는다. + */ +function StandardDataTableRenderer({ + table, + variant, + layout, + pinnedColumns, + getCellProps, + onRowClick, +}: { + table: TableInstance; + variant: "default" | "secondary" | "line"; + layout?: StandardDataTableLayout; + pinnedColumns: string[]; + getCellProps?: ( + context: StandardDataTableCellContext, + ) => StandardDataTableCellProps | undefined; + onRowClick?: (row: T) => void; +}) { + const pinnedColumnIds = new Set(pinnedColumns); + const pinnedLeftByColumnId = new Map(); + let pinnedLeft = 0; + + table.getVisibleLeafColumns().forEach((column) => { + if (!pinnedColumnIds.has(column.id)) return; + pinnedLeftByColumnId.set(column.id, pinnedLeft); + pinnedLeft += column.getSize(); + }); + + /** 열 ID에 대응하는 공통 고정 위치를 헤더와 본문에 동일하게 전달한다. */ + const getPinnedProps = (columnId: string) => { + const left = pinnedLeftByColumnId.get(columnId); + if (left === undefined) return { pinned: undefined, style: undefined }; + + const style: StandardPinnedCellStyle = { + "--standard-data-table-pinned-left": `${left}px`, + }; + return { pinned: "left", style }; + }; + + return ( + + + {table.getVisibleLeafColumns().map((column) => ( + + ))} + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const pinned = getPinnedProps(header.column.id); + return ( + +
+ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
+
+ ); + })} +
+ ))} +
+ + {table.getRowModel().rows.map((row) => ( + onRowClick?.(row.original)} + className={cn( + onRowClick && "cursor-pointer hover:bg-accent", + row.getIsSelected() && "!bg-primary-transparent", + )} + > + {row.getVisibleCells().map((cell) => { + const presentation = getCellProps?.({ + row, + cell, + rowIndex: row.index, + columnId: cell.column.id, + }); + if (presentation?.hidden) return null; + + const pinned = getPinnedProps(cell.column.id); + const style: StandardPinnedCellStyle = { + ...presentation?.style, + ...pinned.style, + }; + + return ( + +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ ); + })} +
+ ))} +
+
+ ); +} + function StandardDataTable({ table, title, @@ -196,6 +365,9 @@ function StandardDataTable({ fillAvailableHeight = false, renderTable, onScroll, + layout, + pinnedColumns = [], + getCellProps, onRowClick, }: StandardDataTableProps) { const rowCount = table.getRowModel().rows.length; @@ -214,6 +386,12 @@ function StandardDataTable({ "--standard-data-table-min-width": minTableWidth, "--standard-data-table-max-height": maxHeight, }; + const layoutStyle: StandardDataTableStyle = { + "--standard-data-table-header-height": layout?.headerHeight, + }; + const usesExtendedRenderer = Boolean( + layout || pinnedColumns.length > 0 || getCellProps, + ); /** 실제 가로 스크롤바가 차지하는 높이만큼 페이징 여백을 줄여 CS의 24px 체감 간격을 유지한다. */ useEffect(() => { @@ -253,11 +431,16 @@ function StandardDataTable({ return (
0 ? "true" : undefined} + style={layoutStyle} > {title || actions ? (
@@ -310,7 +493,17 @@ function StandardDataTable({ )}
) : ( - (renderTable?.({ table }) ?? ( + (renderTable?.({ table }) ?? + (usesExtendedRenderer ? ( + + ) : ( ({ viewPagination={false} onRowClick={onRowClick} /> - )) + ))) )}
{viewPagination && !loading && rowCount > 0 ? ( @@ -335,6 +528,9 @@ function StandardDataTable({ export { STANDARD_ROW_NUMBER_HEADER, StandardDataTable, getStandardRowNumber }; export type { ScrollMode, + StandardDataTableCellContext, + StandardDataTableCellProps, + StandardDataTableLayout, StandardDataTableProps, StandardDataTableRenderContext, StandardRowNumberParams, diff --git a/packages/common/business_components/src/ui/standard_data_table/standard-data-table.css b/packages/common/business_components/src/ui/standard_data_table/standard-data-table.css index ec5c00cbb..ea62563df 100644 --- a/packages/common/business_components/src/ui/standard_data_table/standard-data-table.css +++ b/packages/common/business_components/src/ui/standard_data_table/standard-data-table.css @@ -20,4 +20,48 @@ .standard-data-table__scroll thead th { text-align: center; } + + .standard-data-table[data-has-pinned-columns="true"] .table-ui { + border-collapse: separate; + border-spacing: 0; + } + + .standard-data-table[data-sticky-header="true"] .table-ui thead { + position: sticky; + top: 0; + z-index: 4; + } + + .standard-data-table[data-header-wrap="true"] .table-ui th { + height: var(--standard-data-table-header-height, auto); + overflow-wrap: anywhere; + white-space: normal; + word-break: keep-all; + } + + .standard-data-table[data-cell-overflow="nowrap"] .table-ui td, + .standard-data-table[data-cell-overflow="ellipsis"] .table-ui td { + white-space: nowrap; + } + + .standard-data-table[data-cell-overflow="ellipsis"] + .standard-data-table__cell-content { + display: block; + overflow: hidden; + text-overflow: ellipsis; + } + + .standard-data-table .table-ui [data-pinned="left"] { + left: var(--standard-data-table-pinned-left); + position: sticky; + z-index: 2; + } + + .standard-data-table .table-ui td[data-pinned="left"] { + background: var(--background, #fff); + } + + .standard-data-table .table-ui th[data-pinned="left"] { + z-index: 5; + } } From ec78658e46649b30b92437576f1741025d9e98c3 Mon Sep 17 00:00:00 2001 From: mind-one001 Date: Thu, 10 Sep 2026 14:01:58 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=EC=8B=9C=EB=8F=84=EB=B3=84=20=EB=85=B8?= =?UTF-8?q?=ED=9B=84=EA=B4=80=EB=A1=9C=ED=98=84=ED=99=A9=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AgedPipeStatusByCtpvPopupPage.tsx | 14 +++++++++++--- .../001-qa-SFR-011-001-시도별 노후관로현황-검증기록.md | 7 +++++++ .../001-fe-SN-관로분석그리드개선-전수조사.md | 14 +++++++++++--- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx index 1a9d8a220..99904fd40 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Button, Input } from "@common/components"; import { + getStandardRowNumber, StandardDataTable, StandardPopupLayout, } from "@common/business_components"; @@ -66,7 +67,15 @@ export default function AgedPipeStatusByCtpvPopupPage() { const yearOptions = useMemo(() => buildAgedPipeStatusYearOptions(), []); const columns = useMemo[]>( () => [ - { accessorKey: "rowNumber", header: "순번" }, + { + id: "rowNumber", + header: "순번", + cell: ({ row }) => + getStandardRowNumber({ + total: rows.length, + rowIndex: row.index, + }), + }, { accessorKey: "ctpvNm", header: "시도" }, ...years.map((year) => ({ id: `year_${year}`, @@ -87,7 +96,7 @@ export default function AgedPipeStatusByCtpvPopupPage() { ], })), ], - [years], + [rows.length, years], ); const pagination: PaginationState = { @@ -292,7 +301,6 @@ export default function AgedPipeStatusByCtpvPopupPage() { table={table} className="!w-full !max-w-full" title="시도별 노후관로 목록" - total={rows.length} scrollMode="horizontal" minTableWidth={years.length > 0 ? "120rem" : "100%"} actions={ diff --git a/apps/sn/docs/작업검증/SFR-011-001-시도별 노후관로현황/001-qa-SFR-011-001-시도별 노후관로현황-검증기록.md b/apps/sn/docs/작업검증/SFR-011-001-시도별 노후관로현황/001-qa-SFR-011-001-시도별 노후관로현황-검증기록.md index b95de821c..b11584943 100644 --- a/apps/sn/docs/작업검증/SFR-011-001-시도별 노후관로현황/001-qa-SFR-011-001-시도별 노후관로현황-검증기록.md +++ b/apps/sn/docs/작업검증/SFR-011-001-시도별 노후관로현황/001-qa-SFR-011-001-시도별 노후관로현황-검증기록.md @@ -8,3 +8,10 @@ - `pnpm --filter sn build` - 결과: 시간 제한 내 완료되지 않아 최종 성공/실패를 확정하지 못했다. - 비고: 이 저장소는 기존에도 FE build가 환경 영향으로 지연 또는 `EMFILE` 이슈가 있었으므로 사용자 로컬 실행 확인이 추가로 필요하다. + +## 3. 2026-09-10 공통 목록 정합성 확인 + +- 사용자 지시에 따라 그리드 제목의 `총 18건` 표기를 제거하고 `시도별 노후관로 목록`만 유지했다. +- 순번은 전체 건수 기준 역순으로 계산해 로컬 3002에서 1페이지 18~9, 2페이지 8~1 표출을 확인했다. +- 원본 근거는 요구사항정의서 `SFR-011-001`, 화면설계서 8~9번 `SN_MN010102`이며 순번·제목 건수는 원본 필수항목이 아닌 공통 그리드 표시 규칙이다. +- API, 조회조건, 연도 그룹 헤더와 Excel 다운로드 형식은 변경하지 않았다. diff --git a/apps/sn/docs/작업내용/FE-GRID-전수조사/001-fe-SN-관로분석그리드개선-전수조사.md b/apps/sn/docs/작업내용/FE-GRID-전수조사/001-fe-SN-관로분석그리드개선-전수조사.md index 9c4131597..4ce853388 100644 --- a/apps/sn/docs/작업내용/FE-GRID-전수조사/001-fe-SN-관로분석그리드개선-전수조사.md +++ b/apps/sn/docs/작업내용/FE-GRID-전수조사/001-fe-SN-관로분석그리드개선-전수조사.md @@ -19,8 +19,8 @@ ## 변경 내용 - `PipeAnalysisPopupPage.tsx`의 별도 YEAR_MATERIAL Table/UiPageNations 분기와 일반 MapPopupDataTable 분기를 `PipeAnalysisSummaryTable`로 통합했다. -- `StandardDataTable`의 카드·로딩/빈 목록 처리·공통 페이지네이션을 재사용하고, 지원하는 `renderTable`로 병합 셀을 유지했다. 독립적인 구형 그리드를 새로 만든 것이 아니다. -- 표 본문은 공통 `Table variant="default"`의 파란 헤더를 사용한다. 공용 CSS 변경 없이 관로분석 전용 CSS로 너비·행 높이·고정 열만 적용한다. +- `StandardDataTable`의 카드·로딩/빈 목록 처리·공통 페이지네이션을 재사용하고, 범용 `layout`, `pinnedColumns`, `getCellProps` 옵션으로 병합 셀·고정 열을 유지한다. 독립적인 구형 그리드를 새로 만든 것이 아니다. +- 표 본문은 확장된 공통 렌더러와 `Table variant="default"`의 파란 헤더를 사용한다. 관로분석 전용 CSS 없이 공통 `standard-data-table.css`가 너비·행 높이·고정 열을 처리한다. - 순번 56px, 시도·시군구 각각 120px, 처리구역 160px, 합계 100px를 고정한다. 경과년도는 112px, 재질은 120~160px를 확보한다. - 헤더 높이 64px, 본문은 공통 폰트·행간·패딩을 그대로 사용한다. 2026-09-09 후속 요청으로 최초 12px/18px 별도 폰트와 헤더 구분선 설정을 제거했다. 지역 텍스트는 한 줄과 전체값 title을 사용한다. - 헤더는 세로 스크롤, 지역 식별 정보와 합계는 가로 스크롤 중 고정된다. 페이지/분석구분 전환 시 표 스크롤을 초기화한다. @@ -74,7 +74,7 @@ TypeScript AST로 주석·문자열을 제외한 실제 JSX와 import 별칭을 ## 재현 방법과 롤백 - 표 사용처 전수조사는 AST 기반 일회성 스크립트로 수행했으며, 해당 스크립트는 저장소에 남기지 않았다. -- 원복은 PipeAnalysisPopupPage의 이번 표 렌더링 변경과 신규 PipeAnalysisSummaryTable/CSS만 되돌린다. DB 이관은 없다. +- 원복은 PipeAnalysisPopupPage의 이번 표 렌더링 변경, PipeAnalysisSummaryTable과 StandardDataTable 확장만 되돌린다. DB 이관은 없다. - 공통 패키지와 타 앱 변경은 없다. 커밋·배포는 수행하지 않았다. - 긴 재질 목록은 가로 스크롤이 필요하다. 재질명 중복은 코드별 기존 데이터이므로 이름만 보고 열을 병합하지 않았다. @@ -91,6 +91,14 @@ TypeScript AST로 주석·문자열을 제외한 실제 JSX와 import 별칭을 - 최초 조사에서 추가 전환 대상으로 분류한 3곳 중 GIS 오류 목록은 전환 완료했고 지반침하 주변 관로 및 조사현황 집계표 2곳은 검토 대상으로 남는다. - 롤백은 이번 CSS 제거 변경과 GisManagementPopupPage의 오류 목록 렌더링만 되돌린다. +## 2026-09-10 StandardDataTable 공통 확장 전환 + +- `StandardDataTable`에 하위 호환 `layout`, `pinnedColumns`, `getCellProps` 계약과 확장 렌더러를 추가했다. 옵션을 사용하지 않는 기존 화면은 기존 `DataTable` 경로를 유지한다. +- 관로분석은 경과년도·재질 데이터 구성과 상세 조회 조건만 소유하고, 실제 table·thead·td, sticky header, 고정 열 위치, 셀 overflow는 공통 컴포넌트가 처리하도록 전환했다. +- 처리구역별 4행은 공통 `getCellProps`의 `rowSpan`·`hidden` 계약으로 렌더링하며 관로분석 전용 `pipe-analysis-summary-table.css`를 제거했다. +- 실행 중인 로컬 3002 HMR에서 경과년도별 10행, 경과년도&재질 40행과 첫 5열 `rowSpan=4`를 확인했다. 가로 2,186px·세로 1,160px 스크롤 후에도 고정 열 x=37.5/93.5/213.5/333.5/493.5px, 헤더와 스크롤 영역 y=217px가 유지됐다. +- 시도별 노후관로현황은 제목의 별도 총 건수 표기를 제거하고 공통 역순 순번을 적용했다. 전체 18건에서 1페이지 18~9, 2페이지 8~1 표출을 로컬 브라우저로 확인했다. + ## 최초 전수조사 당시 실제 앱 전체 사용처 | 파일 | JSX 구성요소:행 | 판정 | From b7134cb3e44fec4d8426b094fc263544e9813872 Mon Sep 17 00:00:00 2001 From: mind-one001 Date: Thu, 10 Sep 2026 14:06:24 +0900 Subject: [PATCH 6/7] =?UTF-8?q?=EB=85=B8=ED=9B=84=EA=B4=80=EB=A1=9C?= =?UTF-8?q?=ED=98=84=ED=99=A9=20=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/ui/standard_data_table/index.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/common/business_components/src/ui/standard_data_table/index.tsx b/packages/common/business_components/src/ui/standard_data_table/index.tsx index 643d6c06a..3f4d05b8e 100644 --- a/packages/common/business_components/src/ui/standard_data_table/index.tsx +++ b/packages/common/business_components/src/ui/standard_data_table/index.tsx @@ -278,11 +278,34 @@ function StandardDataTableRenderer({ {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { + const columnRelativeDepth = header.depth - header.column.depth; + if ( + !header.isPlaceholder && + columnRelativeDepth > 1 && + header.id === header.column.id + ) { + return null; + } + + let rowSpan = 1; + if (header.isPlaceholder) { + const leafHeaders = header.getLeafHeaders(); + rowSpan = + (leafHeaders[leafHeaders.length - 1]?.depth ?? 0) - + header.depth; + } + const pinned = getPinnedProps(header.column.id); + const isNestedGroupHeader = + !header.isPlaceholder && header.column.columns.length > 0; return ( Date: Tue, 15 Sep 2026 10:00:42 +0900 Subject: [PATCH 7/7] =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/AGENTS.md | 2 + .../AgedPipeStatusByCtpvPopupPage.tsx | 2 +- .../agedPipeStatusByCtpvExport.ts | 2 +- .../analysisMenu/AnalysisPopupMenu.tsx | 17 +- .../components/fldngHstry/fldngHstryClient.ts | 4 +- .../floodDngrMap/FloodDngrMapPopupPage.tsx | 6 +- .../gisEdit/GisEditPipeSessionPopup.tsx | 4 +- .../map/components/gisEdit/gisEditTypes.ts | 114 +- .../map/components/gisEdit/useGisEdit.ts | 492 +---- .../gisManagement/GisManagementPopupPage.tsx | 264 +-- .../PipeAnalysisDetailPopupPage.tsx | 19 +- .../pipeAnalysis/PipeAnalysisPopupPage.tsx | 2 +- .../popupComponent/featureClickEventPopup.tsx | 34 +- .../map/components/popupComponent/smh.tsx | 4 +- .../rightPanel/RightPanelBookmarkTabs.tsx | 163 ++ .../sbmrgDngrMap/SbmrgDngrMapPopupPage.tsx | 4 +- apps/sn/app/(map-layout)/map/page.tsx | 1895 +---------------- .../map/searchModal/useBasicTab.ts | 4 +- .../(popupLayout)/popup/[popupKey]/page.tsx | 21 +- .../(with-base-layout)/ii/fileupload/page.tsx | 4 +- .../(with-base-layout)/ii/irsetting/page.tsx | 4 +- .../app/(with-base-layout)/ii/rdii/page.tsx | 4 +- .../(with-base-layout)/ii/test/page_bar.tsx | 143 -- .../(with-base-layout)/ii/test/page_pie.tsx | 146 -- .../components/DredgingRegisterModal.tsx | 496 +---- .../oper/dredging/lib/client.ts | 454 +--- .../oper/dredging/lib/date.ts | 23 +- .../oper/dredging/lib/exif.ts | 336 +-- .../oper/dredging/lib/registration-request.ts | 2 + .../oper/dredging/lib/types.ts | 124 +- .../dredging/lib/useDredgingCommonCodes.ts | 137 +- .../oper/dredging/lib/uuid.ts | 33 +- .../oper/floodPre/headMakeFloodPre.tsx | 85 - .../oper/floodPre/inputTableBody.tsx | 213 -- .../(with-base-layout)/oper/floodPre/page.tsx | 15 +- apps/sn/app/(with-base-layout)/oper/index.ts | 28 - .../(with-base-layout)/oper/oldPp/page.tsx | 18 +- .../oper/operStore/page.tsx | 12 +- .../app/(with-base-layout)/oper/tableSort.ts | 71 - .../api/{gis-upload => gisUpload}/route.ts | 0 apps/sn/app/components/lib/api/index.ts | 226 +- apps/sn/app/components/ui/contentLayout.tsx | 33 - apps/sn/app/components/ui/formSection.tsx | 36 - .../aged-pipe/AgedPipeDetailPanel.tsx | 65 - .../aged-pipe/AgedPipeFilterPanel.tsx | 73 - .../aged-pipe/AgedPipeListModal.tsx | 177 -- .../(map-layout)/aged-pipe/agedPipeUtils.ts | 221 -- .../app/html/(map-layout)/aged-pipe/page.tsx | 407 ---- apps/sn/app/html/(map-layout)/layout.tsx | 58 - .../map/components/InputSection.tsx | 58 - .../map/components/common/codeMapper.ts | 162 -- .../map/components/common/index.ts | 68 - .../map/components/common/table.ts | 848 -------- .../components/popupComponent/MsSwagePpl.tsx | 163 -- .../map/components/popupComponent/Ssc.tsx | 328 --- .../components/popupComponent/SwtrmtFclt.tsx | 73 - .../popupComponent/chart/aplcnYms.ts | 271 --- .../popupComponent/chart/infilChart.tsx | 297 --- .../popupComponent/chart/rdiiChart.tsx | 357 ---- .../popupComponent/chartModalStore.ts | 16 - .../popupComponent/featureClickEventPopup.tsx | 629 ------ .../components/popupComponent/sewagePump.tsx | 69 - .../map/components/popupComponent/smh.tsx | 126 -- .../popupComponent/stormWaterPump.tsx | 37 - apps/sn/app/html/(map-layout)/map/page.tsx | 169 -- .../(map-layout)/map/searchModal/basicTab.tsx | 66 - .../map/searchModal/operationTab.tsx | 98 - .../map/searchModal/reportFilesModal.tsx | 108 - .../map/searchModal/reportTab.tsx | 272 --- .../map/searchModal/searchModal.tsx | 238 --- .../(map-layout)/map/searchModal/stofcbr.tsx | 92 - .../(map-layout)/map/searchModal/swageMhl.tsx | 156 -- .../map/searchModal/swagePmstn.tsx | 175 -- .../(map-layout)/map/searchModal/swagePpl.tsx | 209 -- .../map/searchModal/swagePrcsFclt.tsx | 150 -- .../map/searchModal/swagePrcsZone.tsx | 93 - .../map/searchModal/useBasicTab.ts | 348 --- .../map/searchModal/useOperationTab.ts | 452 ---- .../map/searchModal/useReportTab.ts | 188 -- .../map/searchModal/useSearchModal.ts | 161 -- .../map/waterDepth/waterDepth.tsx | 98 - .../sn/app/html/(with-base-layout)/layout.tsx | 59 - .../html/06. 노후관로관리_시나리오_v1.0.md | 97 - apps/sn/app/html/AGENTS.md | 38 - .../html/old-pipe-component-sample/index.html | 1188 ----------- .../001-qa-SN-시설상세레이아웃-검증결과.md | 2 + ...1-qa-SFR-011-008-침수예측지도-격자표시-검증계획.md | 4 +- .../002-qa-SFR-012-002-하수관망도 업로드-통합검증결과.md | 2 + .../002-qa-SFR-012-003-하수관로편집-통합검증결과.md | 2 + .../001-qa-SN-전체화면-브라우저검증결과.md | 142 ++ .../001-qa-SN-5개테마-팝업페이징-검증결과.md | 77 + .../001-fe-SFR-011-001-시도별 노후관로현황-작업카드.md | 4 +- ...2-fe-SFR-011-008-침수예측지도-격자표시-작업계획.md | 4 +- .../001-fe-SFR-012-003-하수관로편집-작업카드.md | 4 +- .../001-fe-SN-API경로단일화-변경기록.md | 150 ++ .../001-qa-SN-로컬개발서버-메뉴별비교결과.md | 121 ++ .../SN-패키지구조진단/001-fe-SN-파일역할목록.md | 1326 ++++++++++++ .../002-fe-SN-구조정리-구현결과.md | 283 +++ .../002-fe-business_components-파일역할목록.md | 427 ++++ .../003-qa-SN-구조정리-회귀검증.md | 94 + .../004-fe-business_components-구조정리-구현결과.md | 86 + .../001-fe-SN-프론트엔드리팩토링-결과보고서.md | 362 ++++ .../002-fe-SN-프론트엔드리팩토링-2차결과보고서.md | 235 ++ .../003-fe-SN-프론트엔드리팩토링-3차결과보고서.md | 141 ++ .../004-fe-SN-프론트엔드리팩토링-4차결과보고서.md | 117 + .../005-fe-SN-프론트엔드리팩토링-5차결과보고서.md | 140 ++ .../006-fe-SN-프론트엔드리팩토링-6차검증및구조게이트.md | 142 ++ ...1-fe-SFR-011-008-침수예측지도-격자표시-구현기록.md | 2 + .../001-fe-SFR-011-009-침수발생이력-통합구현기록.md | 2 + .../001-fe-SFR-012-003-하수관로편집-통합구현기록.md | 2 + .../001-fe-SN-RDII분석결과-지도연계유지보수-통합구현기록.md | 2 + .../001-fe-SN-침입수분석결과-지도연계유지보수-통합구현기록.md | 2 + apps/sn/eslint.config.mjs | 3 +- apps/sn/package.json | 6 +- apps/sn/scripts/audit-screen-inventory.mjs | 2 - apps/sn/scripts/check-module-boundaries.cjs | 165 ++ apps/sn/scripts/run-refactor-tests.cjs | 18 + apps/sn/src/app/index.tsx | 18 - apps/sn/src/app/providers/app-provider.tsx | 4 +- apps/sn/src/app/styles/components.css | 65 +- .../entities/auth/model/login-interface.ts | 9 - apps/sn/src/entities/user/api/user-service.ts | 225 -- apps/sn/src/entities/user/index.ts | 17 - .../src/entities/user/model/user-interface.ts | 362 ---- .../user/ui/password-reset-dialog.tsx | 124 -- .../src/features/dredging/api/backend-mode.ts | 3 + .../src/features/dredging/api/dredging-api.ts | 258 +++ .../features/dredging/api/dredging-error.ts | 19 + .../src/features/dredging/api/photo-upload.ts | 82 + .../dredging/api/response-adapters.ts | 83 + apps/sn/src/features/dredging/index.ts | 10 + apps/sn/src/features/dredging/lib/date.ts | 21 + apps/sn/src/features/dredging/lib/exif.ts | 334 +++ .../dredging/lib/photo-upload-queue.ts | 20 + .../dredging/lib/use-dredging-common-codes.ts | 135 ++ apps/sn/src/features/dredging/lib/uuid.ts | 31 + .../dredging/model/registration-request.ts | 84 + apps/sn/src/features/dredging/model/types.ts | 122 ++ .../dredging/ui/DredgingRegistrationBody.tsx | 513 +++++ .../exmn/lib/download-exmn-details.ts | 291 +++ .../features/exmn/lib/exmn-detail-totals.ts | 20 + .../exmn/ui/ExmnSurveyDetailPopupPage.tsx | 266 +-- .../exmn/ui/PplFacilityDetailModal.tsx | 246 --- .../src/features/exmn/ui/PplSummaryModal.tsx | 599 ------ .../ui/FldngPredcPrstPopupContent.tsx | 2 +- apps/sn/src/features/gisEdit/index.ts | 4 + .../sn/src/features/gisEdit/lib/edit-model.ts | 343 +++ .../features/gisEdit/lib/feature-snapshot.ts | 170 ++ .../gisEdit/lib/geometry-mutations.ts | 517 +++++ .../features/gisEdit/lib/preview-styles.ts | 255 +++ .../features/gisEdit/lib/vertex-handles.ts | 386 ++++ apps/sn/src/features/gisEdit/model/types.ts | 112 + .../features/gisEdit/model/vertex-handles.ts | 101 + .../gisManagement/api/gis-download.ts | 94 + apps/sn/src/features/gisManagement/index.ts | 7 + .../gisManagement/lib/popup-refresh.ts | 24 + .../gisManagement/lib/upload-display.ts | 74 + .../gisManagement/model/upload-retry-guard.ts | 58 + .../gisManagement/model/upload-types.ts | 61 + apps/sn/src/features/mapLayers/index.ts | 3 + .../mapLayers/lib/flow-direction-listener.ts | 17 + .../mapLayers/lib/flow-direction-styles.ts | 286 +++ .../src/features/mapLayers/lib/layer-info.ts | 197 ++ .../windowPopup/lib/openWindowPopup.ts | 19 +- .../features/windowPopup/model/popupTheme.ts | 19 + .../ui/FloodPreUploadPopupContent.tsx | 33 +- .../ui/OldPpUploadPopupContent.tsx | 33 +- .../windowPopup/ui/PopupThemeSync.tsx | 14 + .../windowPopup/ui/WindowPopupRenderer.tsx | 17 +- .../windowPopup/ui/dredgingPopupRecord.ts | 2 +- apps/sn/src/shared/api/http-transport.ts | 132 ++ apps/sn/src/shared/api/index.ts | 2 + apps/sn/src/shared/lib/use-data-store.ts | 14 - apps/sn/src/shared/ui/form.tsx | 175 -- apps/sn/src/shared/ui/map-layout.tsx | 13 - apps/sn/tests/api-transport.test.cjs | 189 ++ apps/sn/tests/api-url-contract.test.cjs | 146 ++ .../tests/dredging-module-boundary.test.cjs | 88 + apps/sn/tests/dredging-registration.test.cjs | 163 ++ .../tests/dredging-response-adapters.test.cjs | 171 ++ apps/sn/tests/dredging-upload.test.cjs | 171 ++ apps/sn/tests/gis-upload-retry-guard.test.cjs | 130 ++ apps/sn/tests/load-typescript.cjs | 47 + apps/sn/tests/map-layer-model.test.cjs | 166 ++ apps/sn/tests/module-boundary-policy.test.cjs | 76 + .../sn/tests/sn-structure-extraction.test.cjs | 574 +++++ apps/sn/tests/theme-contract.test.cjs | 87 + apps/sn/tsconfig.json | 2 +- .../src/ui/alert_dialog_ui/index.tsx | 53 - .../src/ui/form/lib/button.tsx | 58 - .../src/ui/form/lib/form.tsx | 174 -- .../business_components/src/ui/form/test.tsx | 56 - .../business_components/src/ui/index.ts | 1 - .../src/ui/map/GisContent.js | 61 - .../business_components/src/ui/map/GisEdit.js | 174 -- .../src/ui/map/GisLonLat.js | 44 - .../src/ui/map/searchBar/procSearchBar.tsx | 8 +- .../src/ui/search_icon/index.tsx | 20 - .../src/ui/search_icon/search-icon.css | 7 - .../ui/search_input_address/findAddress.ts | 120 -- .../src/ui/search_input_address/index.tsx | 131 -- .../src/ui/search_input_address/zipService.ts | 66 - .../src/ui/standard_data_table/index.tsx | 10 +- .../standard-data-table.css | 20 + .../src/ui/standard_popup/index.tsx | 2 +- .../src/ui/table/table.tsx | 117 - 206 files changed, 11587 insertions(+), 17413 deletions(-) create mode 100644 apps/sn/app/(map-layout)/map/components/rightPanel/RightPanelBookmarkTabs.tsx delete mode 100644 apps/sn/app/(with-base-layout)/ii/test/page_bar.tsx delete mode 100644 apps/sn/app/(with-base-layout)/ii/test/page_pie.tsx create mode 100644 apps/sn/app/(with-base-layout)/oper/dredging/lib/registration-request.ts delete mode 100644 apps/sn/app/(with-base-layout)/oper/floodPre/headMakeFloodPre.tsx delete mode 100644 apps/sn/app/(with-base-layout)/oper/floodPre/inputTableBody.tsx delete mode 100644 apps/sn/app/(with-base-layout)/oper/index.ts delete mode 100644 apps/sn/app/(with-base-layout)/oper/tableSort.ts rename apps/sn/app/api/{gis-upload => gisUpload}/route.ts (100%) delete mode 100644 apps/sn/app/components/ui/contentLayout.tsx delete mode 100644 apps/sn/app/components/ui/formSection.tsx delete mode 100644 apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx delete mode 100644 apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx delete mode 100644 apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx delete mode 100644 apps/sn/app/html/(map-layout)/aged-pipe/agedPipeUtils.ts delete mode 100644 apps/sn/app/html/(map-layout)/aged-pipe/page.tsx delete mode 100644 apps/sn/app/html/(map-layout)/layout.tsx delete mode 100644 apps/sn/app/html/(map-layout)/map/components/InputSection.tsx delete mode 100644 apps/sn/app/html/(map-layout)/map/components/common/codeMapper.ts delete mode 100644 apps/sn/app/html/(map-layout)/map/components/common/index.ts delete mode 100644 apps/sn/app/html/(map-layout)/map/components/common/table.ts delete mode 100644 apps/sn/app/html/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx delete mode 100644 apps/sn/app/html/(map-layout)/map/components/popupComponent/Ssc.tsx delete mode 100644 apps/sn/app/html/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx delete mode 100644 apps/sn/app/html/(map-layout)/map/components/popupComponent/chart/aplcnYms.ts delete mode 100644 apps/sn/app/html/(map-layout)/map/components/popupComponent/chart/infilChart.tsx delete mode 100644 apps/sn/app/html/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx delete mode 100644 apps/sn/app/html/(map-layout)/map/components/popupComponent/chartModalStore.ts delete mode 100644 apps/sn/app/html/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx delete mode 100644 apps/sn/app/html/(map-layout)/map/components/popupComponent/sewagePump.tsx delete mode 100644 apps/sn/app/html/(map-layout)/map/components/popupComponent/smh.tsx delete mode 100644 apps/sn/app/html/(map-layout)/map/components/popupComponent/stormWaterPump.tsx delete mode 100644 apps/sn/app/html/(map-layout)/map/page.tsx delete mode 100644 apps/sn/app/html/(map-layout)/map/searchModal/basicTab.tsx delete mode 100644 apps/sn/app/html/(map-layout)/map/searchModal/operationTab.tsx delete mode 100644 apps/sn/app/html/(map-layout)/map/searchModal/reportFilesModal.tsx delete mode 100644 apps/sn/app/html/(map-layout)/map/searchModal/reportTab.tsx delete mode 100644 apps/sn/app/html/(map-layout)/map/searchModal/searchModal.tsx delete mode 100644 apps/sn/app/html/(map-layout)/map/searchModal/stofcbr.tsx delete mode 100644 apps/sn/app/html/(map-layout)/map/searchModal/swageMhl.tsx delete mode 100644 apps/sn/app/html/(map-layout)/map/searchModal/swagePmstn.tsx delete mode 100644 apps/sn/app/html/(map-layout)/map/searchModal/swagePpl.tsx delete mode 100644 apps/sn/app/html/(map-layout)/map/searchModal/swagePrcsFclt.tsx delete mode 100644 apps/sn/app/html/(map-layout)/map/searchModal/swagePrcsZone.tsx delete mode 100644 apps/sn/app/html/(map-layout)/map/searchModal/useBasicTab.ts delete mode 100644 apps/sn/app/html/(map-layout)/map/searchModal/useOperationTab.ts delete mode 100644 apps/sn/app/html/(map-layout)/map/searchModal/useReportTab.ts delete mode 100644 apps/sn/app/html/(map-layout)/map/searchModal/useSearchModal.ts delete mode 100644 apps/sn/app/html/(map-layout)/map/waterDepth/waterDepth.tsx delete mode 100644 apps/sn/app/html/(with-base-layout)/layout.tsx delete mode 100644 apps/sn/app/html/06. 노후관로관리_시나리오_v1.0.md delete mode 100644 apps/sn/app/html/AGENTS.md delete mode 100644 apps/sn/app/html/old-pipe-component-sample/index.html create mode 100644 apps/sn/docs/작업검증/SN-전체화면-브라우저검증/001-qa-SN-전체화면-브라우저검증결과.md create mode 100644 apps/sn/docs/작업검증/SN-테마모드/001-qa-SN-5개테마-팝업페이징-검증결과.md create mode 100644 apps/sn/docs/작업계획/SN-API경로단일화/001-fe-SN-API경로단일화-변경기록.md create mode 100644 apps/sn/docs/작업계획/SN-로컬개발비교검증/001-qa-SN-로컬개발서버-메뉴별비교결과.md create mode 100644 apps/sn/docs/작업계획/SN-패키지구조진단/001-fe-SN-파일역할목록.md create mode 100644 apps/sn/docs/작업계획/SN-패키지구조진단/002-fe-SN-구조정리-구현결과.md create mode 100644 apps/sn/docs/작업계획/SN-패키지구조진단/002-fe-business_components-파일역할목록.md create mode 100644 apps/sn/docs/작업계획/SN-패키지구조진단/003-qa-SN-구조정리-회귀검증.md create mode 100644 apps/sn/docs/작업계획/SN-패키지구조진단/004-fe-business_components-구조정리-구현결과.md create mode 100644 apps/sn/docs/작업계획/SN-프론트엔드리팩토링/001-fe-SN-프론트엔드리팩토링-결과보고서.md create mode 100644 apps/sn/docs/작업계획/SN-프론트엔드리팩토링/002-fe-SN-프론트엔드리팩토링-2차결과보고서.md create mode 100644 apps/sn/docs/작업계획/SN-프론트엔드리팩토링/003-fe-SN-프론트엔드리팩토링-3차결과보고서.md create mode 100644 apps/sn/docs/작업계획/SN-프론트엔드리팩토링/004-fe-SN-프론트엔드리팩토링-4차결과보고서.md create mode 100644 apps/sn/docs/작업계획/SN-프론트엔드리팩토링/005-fe-SN-프론트엔드리팩토링-5차결과보고서.md create mode 100644 apps/sn/docs/작업계획/SN-프론트엔드리팩토링/006-fe-SN-프론트엔드리팩토링-6차검증및구조게이트.md create mode 100644 apps/sn/scripts/check-module-boundaries.cjs create mode 100644 apps/sn/scripts/run-refactor-tests.cjs delete mode 100644 apps/sn/src/app/index.tsx delete mode 100644 apps/sn/src/entities/auth/model/login-interface.ts delete mode 100644 apps/sn/src/entities/user/api/user-service.ts delete mode 100644 apps/sn/src/entities/user/index.ts delete mode 100644 apps/sn/src/entities/user/model/user-interface.ts delete mode 100644 apps/sn/src/entities/user/ui/password-reset-dialog.tsx create mode 100644 apps/sn/src/features/dredging/api/backend-mode.ts create mode 100644 apps/sn/src/features/dredging/api/dredging-api.ts create mode 100644 apps/sn/src/features/dredging/api/dredging-error.ts create mode 100644 apps/sn/src/features/dredging/api/photo-upload.ts create mode 100644 apps/sn/src/features/dredging/api/response-adapters.ts create mode 100644 apps/sn/src/features/dredging/index.ts create mode 100644 apps/sn/src/features/dredging/lib/date.ts create mode 100644 apps/sn/src/features/dredging/lib/exif.ts create mode 100644 apps/sn/src/features/dredging/lib/photo-upload-queue.ts create mode 100644 apps/sn/src/features/dredging/lib/use-dredging-common-codes.ts create mode 100644 apps/sn/src/features/dredging/lib/uuid.ts create mode 100644 apps/sn/src/features/dredging/model/registration-request.ts create mode 100644 apps/sn/src/features/dredging/model/types.ts create mode 100644 apps/sn/src/features/dredging/ui/DredgingRegistrationBody.tsx create mode 100644 apps/sn/src/features/exmn/lib/download-exmn-details.ts create mode 100644 apps/sn/src/features/exmn/lib/exmn-detail-totals.ts delete mode 100644 apps/sn/src/features/exmn/ui/PplFacilityDetailModal.tsx delete mode 100644 apps/sn/src/features/exmn/ui/PplSummaryModal.tsx create mode 100644 apps/sn/src/features/gisEdit/index.ts create mode 100644 apps/sn/src/features/gisEdit/lib/edit-model.ts create mode 100644 apps/sn/src/features/gisEdit/lib/feature-snapshot.ts create mode 100644 apps/sn/src/features/gisEdit/lib/geometry-mutations.ts create mode 100644 apps/sn/src/features/gisEdit/lib/preview-styles.ts create mode 100644 apps/sn/src/features/gisEdit/lib/vertex-handles.ts create mode 100644 apps/sn/src/features/gisEdit/model/types.ts create mode 100644 apps/sn/src/features/gisEdit/model/vertex-handles.ts create mode 100644 apps/sn/src/features/gisManagement/api/gis-download.ts create mode 100644 apps/sn/src/features/gisManagement/index.ts create mode 100644 apps/sn/src/features/gisManagement/lib/popup-refresh.ts create mode 100644 apps/sn/src/features/gisManagement/lib/upload-display.ts create mode 100644 apps/sn/src/features/gisManagement/model/upload-retry-guard.ts create mode 100644 apps/sn/src/features/gisManagement/model/upload-types.ts create mode 100644 apps/sn/src/features/mapLayers/index.ts create mode 100644 apps/sn/src/features/mapLayers/lib/flow-direction-listener.ts create mode 100644 apps/sn/src/features/mapLayers/lib/flow-direction-styles.ts create mode 100644 apps/sn/src/features/mapLayers/lib/layer-info.ts create mode 100644 apps/sn/src/features/windowPopup/model/popupTheme.ts create mode 100644 apps/sn/src/features/windowPopup/ui/PopupThemeSync.tsx create mode 100644 apps/sn/src/shared/api/http-transport.ts delete mode 100644 apps/sn/src/shared/lib/use-data-store.ts delete mode 100644 apps/sn/src/shared/ui/form.tsx delete mode 100644 apps/sn/src/shared/ui/map-layout.tsx create mode 100644 apps/sn/tests/api-transport.test.cjs create mode 100644 apps/sn/tests/api-url-contract.test.cjs create mode 100644 apps/sn/tests/dredging-module-boundary.test.cjs create mode 100644 apps/sn/tests/dredging-registration.test.cjs create mode 100644 apps/sn/tests/dredging-response-adapters.test.cjs create mode 100644 apps/sn/tests/dredging-upload.test.cjs create mode 100644 apps/sn/tests/gis-upload-retry-guard.test.cjs create mode 100644 apps/sn/tests/load-typescript.cjs create mode 100644 apps/sn/tests/map-layer-model.test.cjs create mode 100644 apps/sn/tests/module-boundary-policy.test.cjs create mode 100644 apps/sn/tests/sn-structure-extraction.test.cjs create mode 100644 apps/sn/tests/theme-contract.test.cjs delete mode 100644 packages/common/business_components/src/ui/alert_dialog_ui/index.tsx delete mode 100644 packages/common/business_components/src/ui/form/lib/button.tsx delete mode 100644 packages/common/business_components/src/ui/form/lib/form.tsx delete mode 100644 packages/common/business_components/src/ui/form/test.tsx delete mode 100644 packages/common/business_components/src/ui/map/GisContent.js delete mode 100644 packages/common/business_components/src/ui/map/GisEdit.js delete mode 100644 packages/common/business_components/src/ui/map/GisLonLat.js delete mode 100644 packages/common/business_components/src/ui/search_icon/index.tsx delete mode 100644 packages/common/business_components/src/ui/search_icon/search-icon.css delete mode 100644 packages/common/business_components/src/ui/search_input_address/findAddress.ts delete mode 100644 packages/common/business_components/src/ui/search_input_address/index.tsx delete mode 100644 packages/common/business_components/src/ui/search_input_address/zipService.ts delete mode 100644 packages/common/business_components/src/ui/table/table.tsx diff --git a/apps/sn/AGENTS.md b/apps/sn/AGENTS.md index 037aa402a..e7b30dc3f 100644 --- a/apps/sn/AGENTS.md +++ b/apps/sn/AGENTS.md @@ -228,6 +228,8 @@ VSCode나 별도 터미널에서 `sn` Next 개발 서버가 실행 중이면 `ne ### 프론트엔드 실행 프로세스 관리 +- 모든 세션·역할·문서/검증 섹션에 공통 D:/workspace/AGENTS.md의 테스트 임시 파일·결과 파일 정리 기준을 적용한다. 일회성 검증 스크립트·캡처·Network/서버 로그·원시 결과 파일은 최종 보고서에 요약 후 삭제한다. 정식 회귀 테스트·최종 산출물·사용자 원본/복구 백업은 유지한다. 사용자 dev server가 쓰는 .next·공유 캐시·node_modules는 임시 검증물이라는 이유로 포괄 삭제하지 않는다. + - Codex 에이전트가 작업 또는 검증을 위해 직접 기동한 `next dev`, `dev:sn`, Turborepo dev와 기타 프론트엔드 장시간 실행 프로세스는 작업 종료 전에 반드시 종료한다. - 셸, 백그라운드, 숨김 창 등 VSCode 밖에서 에이전트가 기동한 프로세스도 종료 대상이다. - 사용자가 VSCode 또는 별도 터미널에서 직접 실행한 프론트엔드 개발 서버는 임의로 종료·재시작·교체하지 않는다. 종료가 필요하면 먼저 사용자에게 확인한다. diff --git a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx index 99904fd40..f1917de0e 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx @@ -127,7 +127,7 @@ export default function AgedPipeStatusByCtpvPopupPage() { elapsedYears: String(normalized.elapsedYears), }); const rawResponse = await getRawJson( - `v1/swagePpl/aged-status-by-ctpv?${queryParams.toString()}`, + `v1/swagePpl/agedStatusByCtpv?${queryParams.toString()}`, ); const response = normalizeAgedPipeStatusByCtpvResponse( rawResponse, diff --git a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvExport.ts b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvExport.ts index e7f797f34..1b95e0042 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvExport.ts +++ b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvExport.ts @@ -23,7 +23,7 @@ export const downloadAgedPipeStatusByCtpvExcel = async ( elapsedYears: String(normalized.elapsedYears), }); const response: any = await getRawJson( - `v1/swagePpl/aged-status-by-ctpv?${params.toString()}`, + `v1/swagePpl/agedStatusByCtpv?${params.toString()}`, ); const payload = (response?.data ?? response) as AgedPipeStatusByCtpvResponse; const years = Array.isArray(payload?.years) diff --git a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx index 0bf3b952f..21228b713 100644 --- a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx +++ b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx @@ -4,7 +4,8 @@ */ "use client"; -import { useEffect, useState, type ComponentType, type ReactNode } from "react"; +import { type ComponentType, type ReactNode,useEffect, useState } from "react"; + import { ChartColumnBig, ChevronUp, @@ -15,13 +16,15 @@ import { TriangleAlert, Waves, } from "lucide-react"; -import { getRawJson } from "@/components/lib/api"; + import { createPredictionAnalysisTimeRange } from "@/features/fldngPredcPrst/model/analysisTimeRange"; import { windowPopupDefinitions, type WindowPopupKey, } from "@/features/windowPopup/model/popupDefinitions"; +import { getRawJson } from "@/components/lib/api"; + type Props = { onOpenPopup: (popupKey: WindowPopupKey) => Window | null; onOpenInfoSearch: () => void; @@ -175,8 +178,8 @@ export default function AnalysisPopupMenu({ type="button" className={`pointer-events-auto flex h-[40px] w-[40px] shrink-0 items-center justify-center overflow-hidden rounded-[3px] p-0 shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)] ${ isOpen - ? "bg-[#0279FA] text-white" - : "bg-white text-[#666666] hover:bg-[#0279FA] hover:text-white" + ? "bg-primary text-primary-foreground" + : "bg-background text-label hover:bg-primary hover:text-primary-foreground" }`} onClick={() => setIsOpen((value) => !value)} title={isOpen ? "분석 메뉴 숨기기" : "분석도구"} @@ -192,14 +195,14 @@ export default function AnalysisPopupMenu({ id="analysis-popup-menu" className="pointer-events-auto flex w-max min-w-[154px] flex-col overflow-hidden rounded-[6px] bg-background shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)]" > -
+
분석도구
+ ) : null} + +
+ ); + })} +
+ ); +}; diff --git a/apps/sn/app/(map-layout)/map/components/sbmrgDngrMap/SbmrgDngrMapPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/sbmrgDngrMap/SbmrgDngrMapPopupPage.tsx index cf09f4cf2..4abb1ecbc 100644 --- a/apps/sn/app/(map-layout)/map/components/sbmrgDngrMap/SbmrgDngrMapPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/sbmrgDngrMap/SbmrgDngrMapPopupPage.tsx @@ -124,7 +124,7 @@ export default function SbmrgDngrMapPopupPage() { SBMRG_DNGR_MAP_PAGE_SIZE, ); const response = await getRawJson( - `/sewerage-service/api/v1/fldng/sbmrg-dngr-map?${queryString}`, + `/sewerage-service/api/v1/fldng/sbmrgDngrMap?${queryString}`, ); const payload = unwrapPayload<{ items?: SbmrgDngrMapApiItem[]; @@ -160,7 +160,7 @@ export default function SbmrgDngrMapPopupPage() { SBMRG_DNGR_MAP_SCOPE_SIZE, ); const response = await getRawJson( - `/sewerage-service/api/v1/fldng/sbmrg-dngr-map?${queryString}`, + `/sewerage-service/api/v1/fldng/sbmrgDngrMap?${queryString}`, ); const payload = unwrapPayload<{ items?: SbmrgDngrMapApiItem[] }>( response, diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index b2732781a..c1afa2348 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -18,6 +18,32 @@ "use client"; import dynamic from "next/dynamic"; +import { FLOW_DIRECTION_DEFAULT_COLOR, type FlowDirectionUsageStyle, withAlpha, createFlowDirectionStyle } from "@/features/mapLayers/lib/flow-direction-styles"; +import { type VertexHandleMeta, type VertexHandleCandidate } from "@/features/gisEdit/model/vertex-handles"; +import { createGisEditPreviewStyle, GIS_EDIT_VERTEX_DRAGGING_STYLE, getGisEditVertexHandleStyle } from "@/features/gisEdit/lib/preview-styles"; +import { createVertexHandleFeature, buildVertexHandleFeatures, findHoveredMidpointCandidate } from "@/features/gisEdit/lib/vertex-handles"; +import { applyVertexHandleMove, deleteSelectedVertexHandles, applyGeometryVertexSnaps, applyGeometryMoveSnap, getClosestGeometryVertexCoordinate, getClosestGeometryVertexMeta, getGeometryVertexCoordinateByMeta } from "@/features/gisEdit/lib/geometry-mutations"; +import { type RightPanelTabCategory, type RightPanelTab, findRightPanelTabOverflowCandidate, removeRightPanelTabGroup, RightPanelBookmarkTabs } from "./components/rightPanel/RightPanelBookmarkTabs"; +import { + FLOW_DIRECTION_LAYER_TYPE, + FLOW_DIRECTION_LAYER_NAME, + FLOW_DIRECTION_DEFAULT_MIN_ZOOM, + getFlowDirectionModeByPathname, + SWAGE_PRCS_FCLT_LAYER_TYPE, + type SwagePrcsFcltLayerMode, + getLayerTypeName, + getSwagePrcsFcltLayerMode, + isFlowDirectionLayerInfo, + normalizeFlowDirectionLayerInfos, + normalizeRainObservationLayerInteraction, + getFiniteNumber, + getFlowDirectionMinZoom, + getFlowDirectionZIndex, + expandFlowDirectionExtent, + formatFlowDirectionBbox, + getFlowDirectionFeatureCacheKey, + subscribeFlowDirectionMoveEnd, +} from "@/features/mapLayers"; import { ListTree } from "lucide-react"; import { Fragment, @@ -41,7 +67,6 @@ import Snap from "ol/interaction/Snap.js"; import Translate from "ol/interaction/Translate.js"; import Fill from "ol/style/Fill.js"; import Icon from "ol/style/Icon.js"; -import LineString from "ol/geom/LineString.js"; import Point from "ol/geom/Point.js"; import Polygon from "ol/geom/Polygon.js"; import VectorLayer from "ol/layer/Vector.js"; @@ -92,7 +117,6 @@ import { type AgedPipeRow, buildAgedPipeCqlFilters, isAgedPipeLayerNode, - matchesAgedPipeFilters, normalizeAgedPipeFilters, toDisplayPipeRow, validateElapsedYears, @@ -255,14 +279,8 @@ const createExmnManholeStyle = () => /** 선택한 도시침수지도 중앙에 침수예측 분석값을 표시하는 오버레이 스타일이다. */ // 유하방향은 GeoServer WMS/WFS 레이어가 아니라 BE 보정 GeoJSON API로 그리는 가상 레이어이다. -const FLOW_DIRECTION_LAYER_TYPE = "tb_sn_swage_ppl_flow_direction"; -const FLOW_DIRECTION_LAYER_NAME = "유하방향"; const FLOW_DIRECTION_PIPE_LAYER_TYPE = "tb_ms_swage_ppl"; const FLOW_DIRECTION_SRS = "EPSG:5179"; -const FLOW_DIRECTION_DEFAULT_MIN_ZOOM = 13; -const FLOW_DIRECTION_DEFAULT_COLOR = "rgba(40, 90, 180, 0.84)"; -const FLOW_DIRECTION_NETWORK_CORRECTION_COLOR = "rgba(0, 168, 132, 0.95)"; -const FLOW_DIRECTION_BBOX_BUFFER_RATIO = 0.35; const GIS_EDIT_PIPE_SNAP_BBOX_BUFFER_RATIO = 0.15; const GIS_EDIT_PREVIEW_LAYER_ID = "SN_GIS_EDIT_PREVIEW"; const GIS_EDIT_VERTEX_LAYER_ID = "SN_GIS_EDIT_VERTEX_HANDLES"; @@ -278,181 +296,8 @@ const RIGHT_PANEL_TAB_LIMIT = 5; const RIGHT_PANEL_SINKHOLE_PIPE_TAB_ID = "feature:tb_ms_swage_ppl:sinkhole-singleton"; -type RightPanelTabKind = "feature" | "sinkhole" | "gisEdit"; -type RightPanelTabCategory = - | "sinkhole" - | "pipe" - | "exmnSurvey" - | "treatmentFacility" - | "treatmentZone" - | "defaultFeature" - | "gisEdit"; -type RightPanelTab = { - id: string; - kind: RightPanelTabKind; - category: RightPanelTabCategory; - displayLabel: string; - surveyJobType?: ExmnMapJobType; - parentTabId?: string | null; - featureData: any; - sinkholeDetail?: SinkholeDetail | null; - nearbyPipes?: SinkholeNearbyPipe[]; - gisEditChangeId?: string | null; - lastActiveAt: number; -}; -type RightPanelTabStyle = { - active: string; - inactive: string; -}; - -type RightPanelBookmarkTabsProps = { - tabs: RightPanelTab[]; - activeTabId: string | null; - onActivate: (tabId: string) => void; - onBack: (tabId: string) => void; -}; - -// 지도 객체 유형과 조사구분을 식별하는 범례 의미색이므로 테마와 무관한 정적 class를 유지한다. -const RIGHT_PANEL_TAB_STYLE_BY_CATEGORY: Record< - RightPanelTabCategory, - RightPanelTabStyle -> = { - sinkhole: { - active: "border-[#d97706] bg-[#d97706] text-white", - inactive: "border-[#fdba74] bg-[#fff7ed] text-[#9a3412] hover:bg-[#ffedd5]", - }, - pipe: { - active: "border-[#2563eb] bg-[#2563eb] text-white", - inactive: "border-[#93c5fd] bg-[#eff6ff] text-[#1e40af] hover:bg-[#dbeafe]", - }, - exmnSurvey: { - active: "border-[#0f766e] bg-[#0f766e] text-white", - inactive: "border-[#5eead4] bg-[#f0fdfa] text-[#115e59] hover:bg-[#ccfbf1]", - }, - treatmentFacility: { - active: "border-[#15803d] bg-[#15803d] text-white", - inactive: "border-[#86efac] bg-[#f0fdf4] text-[#166534] hover:bg-[#dcfce7]", - }, - treatmentZone: { - active: "border-[#7e22ce] bg-[#7e22ce] text-white", - inactive: "border-[#d8b4fe] bg-[#faf5ff] text-[#6b21a8] hover:bg-[#f3e8ff]", - }, - gisEdit: { - active: "border-[#0f766e] bg-[#0f766e] text-white", - inactive: "border-[#5eead4] bg-[#f0fdfa] text-[#115e59] hover:bg-[#ccfbf1]", - }, - defaultFeature: { - active: "border-[#475569] bg-[#475569] text-white", - inactive: "border-[#cbd5e1] bg-[#f8fafc] text-[#334155] hover:bg-[#f1f5f9]", - }, -}; - -const RIGHT_PANEL_EXMN_TAB_STYLE_BY_JOB_TYPE: Record< - ExmnMapJobType, - RightPanelTabStyle -> = { - PPL: { - active: "border-[#eab308] bg-[#facc15] text-[#713f12]", - inactive: "border-[#fde68a] bg-[#fefce8] text-[#854d0e] hover:bg-[#fef9c3]", - }, - MNHL: { - active: "border-[#8b5cf6] bg-[#8b5cf6] text-white", - inactive: "border-[#c4b5fd] bg-[#f5f3ff] text-[#5b21b6] hover:bg-[#ede9fe]", - }, -}; - -// 공용 우측 탭은 유형과 활성 상태에 관계없이 먼저 열린 탭부터 제거한다. -const findRightPanelTabOverflowCandidate = ( - tabs: RightPanelTab[], - nextTabId: string, -) => { - return tabs.find((tab) => tab.id !== nextTabId) ?? null; -}; - -// 부모 지반침하가 제거되면 그 부모에서 열린 연계 관로도 같은 탭 그룹으로 제거한다. -const removeRightPanelTabGroup = ( - tabs: RightPanelTab[], - removedTabId: string, -) => - tabs.filter( - (tab) => tab.id !== removedTabId && tab.parentTabId !== removedTabId, - ); - -// 오른쪽 상세 패널에 상세가 2개 이상 쌓였을 때만 책갈피형 전환 버튼을 보여준다. -const RightPanelBookmarkTabs = ({ - tabs, - activeTabId, - onActivate, - onBack, -}: RightPanelBookmarkTabsProps) => { - if (tabs.length < 2) return null; - - return ( -
- {tabs.map((tab) => { - const isActive = tab.id === activeTabId; - const style = - tab.category === "exmnSurvey" && tab.surveyJobType - ? RIGHT_PANEL_EXMN_TAB_STYLE_BY_JOB_TYPE[tab.surveyJobType] - : RIGHT_PANEL_TAB_STYLE_BY_CATEGORY[tab.category]; - const parentTab = tab.parentTabId - ? tabs.find((candidate) => candidate.id === tab.parentTabId) - : null; - - return ( -
- {tab.category === "pipe" && parentTab ? ( - - ) : null} - -
- ); - })} -
- ); -}; - -type FlowDirectionUsageStyle = { - color: string; - lineColor: string; -}; - -type FlowDirectionMode = "PIPE_START_END" | "PIPE_DEPTH"; - -// /map은 관로 깊이 기준, /map2는 관로 원천 geometry 시점->종점 기준만 유지한다. 두 화면 모두 BBOX 조회만 사용한다. -const getFlowDirectionModeByPathname = ( - pathname: string | null, -): FlowDirectionMode => { - const normalizedPathname = String(pathname ?? "").replace(/\/$/, ""); - if (normalizedPathname.endsWith("/map2")) return "PIPE_START_END"; - return "PIPE_DEPTH"; -}; const AGED_PIPE_FOCUS_PADDING: [number, number, number, number] = [ 120, 120, 120, 120, @@ -460,154 +305,6 @@ const AGED_PIPE_FOCUS_PADDING: [number, number, number, number] = [ const DEFAULT_FEATURE_FIT_PADDING: [number, number, number, number] = [ 50, 50, 50, 50, ]; -const SWAGE_PRCS_FCLT_LAYER_TYPE = "tb_il_swage_prcs_fclt"; -const RAIN_OBSERVATION_LAYER_TYPE = "tb_ms_rain_obsvtr_pstn_info"; - -type SwagePrcsFcltLayerMode = "all" | "gte500" | "lt500" | "none"; - -// 레이어 설정 응답이 lyrTyNm/lyrTypeNm/lyr_type_nm처럼 혼재되어 내려오는 경우를 단일 기준으로 정규화한다. -const getLayerTypeName = (layerInfo: any) => - String( - layerInfo?.lyrTyNm ?? layerInfo?.lyrTypeNm ?? layerInfo?.lyr_type_nm ?? "", - ).trim(); - -const getLayerFilterSql = (layerInfo: any) => - String( - layerInfo?.filtrSql ?? - layerInfo?.filtr_sql ?? - layerInfo?.flterSql ?? - layerInfo?.filterSql ?? - layerInfo?.filter_sql ?? - "", - ) - .replace(/\s+/g, "") - .toLowerCase(); - -const getSwagePrcsFcltCapacityKind = ( - layerInfo: any, -): Exclude | null => { - if (getLayerTypeName(layerInfo) !== SWAGE_PRCS_FCLT_LAYER_TYPE) return null; - - const layerName = String(layerInfo?.lyrNm ?? layerInfo?.name ?? "").replace( - /\s+/g, - "", - ); - const filterSql = getLayerFilterSql(layerInfo); - - if ( - filterSql.includes("fclt_cpct>=500") || - layerName.includes("500이상") || - layerName.includes("500톤이상") - ) - return "gte500"; - if ( - filterSql.includes("fclt_cpct<500") || - layerName.includes("500미만") || - layerName.includes("500톤미만") - ) - return "lt500"; - - return null; -}; - -const getSwagePrcsFcltLayerMode = ( - checkedNodesData: any[], -): SwagePrcsFcltLayerMode => { - const checkedKinds = new Set( - checkedNodesData - .map((node) => getSwagePrcsFcltCapacityKind(node?.data ?? node)) - .filter(Boolean), - ); - const hasGte500 = checkedKinds.has("gte500"); - const hasLt500 = checkedKinds.has("lt500"); - - if (hasGte500 && hasLt500) return "all"; - if (hasGte500) return "gte500"; - if (hasLt500) return "lt500"; - return "none"; -}; - -// 유하방향 노드는 타입명 또는 표시명으로 식별한다. DB DML 반영 전후 응답 차이를 모두 흡수하기 위한 판별 함수이다. -const isFlowDirectionLayerInfo = (layerInfo: any) => { - const layerTypeName = getLayerTypeName(layerInfo); - const layerName = String(layerInfo?.lyrNm ?? layerInfo?.name ?? "").trim(); - - return ( - layerTypeName === FLOW_DIRECTION_LAYER_TYPE || - layerName === FLOW_DIRECTION_LAYER_NAME - ); -}; - -// 유하방향 노드를 일반 레이어 토글 대상에서 제외하고, 앱 전용 API vector layer로 처리하도록 속성을 보정한다. -const normalizeFlowDirectionLayerInfos = (data: any[]): any[] => { - if (!Array.isArray(data)) return data; - - return data.map((item) => { - const children = Array.isArray(item.children) - ? normalizeFlowDirectionLayerInfos(item.children) - : item.children; - - if (!isFlowDirectionLayerInfo(item)) { - return { ...item, children }; - } - - return { - ...item, - children, - lyrTyNm: - item.lyrTyNm || - item.lyrTypeNm || - item.lyr_type_nm || - FLOW_DIRECTION_LAYER_TYPE, - lyrNm: item.lyrNm || FLOW_DIRECTION_LAYER_NAME, - gobjTy: item.gobjTy || "line", - srvcNm: "api", - cmncNm: "bbox", - actlLyrYn: "N", - clickPsblYn: "N", - initEprssYn: "N", - minViewSize: - item.minViewSize ?? - item.minZoom ?? - String(FLOW_DIRECTION_DEFAULT_MIN_ZOOM), - }; - }); -}; - -// 강우관측소는 위치와 라벨만 제공하며, 과거 DB 설정값이 Y여도 일반 속성 패널 선택 대상에서는 제외한다. -const normalizeRainObservationLayerInteraction = (data: any[]): any[] => { - if (!Array.isArray(data)) return data; - - return data.map((item) => { - const children = Array.isArray(item.children) - ? normalizeRainObservationLayerInteraction(item.children) - : item.children; - - return getLayerTypeName(item) === RAIN_OBSERVATION_LAYER_TYPE - ? { ...item, children, clickPsblYn: "N" } - : { ...item, children }; - }); -}; - -// 레이어 설정의 최소 표출 줌을 읽되, 값이 없으면 유하방향 기본 줌 기준을 적용한다. -const getFiniteNumber = (value: unknown) => { - if (value === null || value === undefined || String(value).trim() === "") - return null; - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; -}; - -const getFlowDirectionMinZoom = (layerInfo: any) => { - return ( - getFiniteNumber(layerInfo?.minViewSize ?? layerInfo?.minZoom) ?? - FLOW_DIRECTION_DEFAULT_MIN_ZOOM - ); -}; - -// 유하방향 화살표는 관로 위에 얹는 보조 레이어라 레이어 설정의 drawOrdr를 우선 사용하고 없으면 높은 zIndex를 준다. -const getFlowDirectionZIndex = (layerInfo: any) => { - return getFiniteNumber(layerInfo?.drawOrdr) ?? 9998; -}; // BE 응답이 FeatureCollection 그대로 오거나 공통 응답 wrapper의 data 안에 들어오는 두 계약을 모두 처리한다. const getFeatureCollection = (payload: any) => { @@ -672,279 +369,7 @@ const resolveSinkholePointVariant = ( _ocrnYmd?: string | null, ): SinkholePointVariant => "blue"; -// 현재 지도 화면 BBOX 주변을 함께 조회해 이동 직후 가장자리 유하방향이 비어 보이는 현상을 줄인다. -const expandFlowDirectionExtent = (extent: number[]) => { - const [minX = 0, minY = 0, maxX = 0, maxY = 0] = extent; - const width = Math.max(0, maxX - minX); - const height = Math.max(0, maxY - minY); - const bufferX = width * FLOW_DIRECTION_BBOX_BUFFER_RATIO; - const bufferY = height * FLOW_DIRECTION_BBOX_BUFFER_RATIO; - return [minX - bufferX, minY - bufferY, maxX + bufferX, maxY + bufferY]; -}; -// API 요청과 디버깅 출력에 같은 BBOX 문자열 포맷을 사용해 FE 계산값과 네트워크 파라미터를 쉽게 대조한다. -const formatFlowDirectionBbox = (extent: number[]) => - extent.map((value: number) => value.toFixed(3)).join(","); - -// 유하방향 캐시 중복 제거 키이다. BE 응답은 같은 관로 id가 여러 geometry로 반복될 수 있어 geometry까지 포함한다. -const getFlowDirectionFeatureCacheKey = (feature: any) => { - const featureId = feature.getId?.(); - const fcltMngNo = feature.get("fcltMngNo") ?? feature.get("fclt_mng_no"); - const usageCode = feature.get("usgCd") ?? feature.get("usg_cd") ?? ""; - const coordinates = feature.getGeometry?.()?.getCoordinates?.(); - const geometryKey = JSON.stringify(coordinates); - - if (featureId) return `id:${String(featureId)}:${geometryKey}`; - if (fcltMngNo) return `fclt:${String(fcltMngNo).trim()}:${geometryKey}`; - return `geom:${String(usageCode).trim()}:${geometryKey}`; -}; - -const getGeometryExtentCenterPoint = (geometry: any) => { - const extent = geometry?.getExtent?.(); - if (!extent || extent.length < 4) return null; - - const [minX = 0, minY = 0, maxX = 0, maxY = 0] = extent; - return new Point([(minX + maxX) / 2, (minY + maxY) / 2]); -}; - -// MultiPolygon/MultiLineString preview 라벨이 각 조각마다 반복되지 않도록 대표 지점 1곳에만 텍스트를 배치한다. -const getGisEditPreviewLabelGeometry = (geometry: any) => { - const geometryType = geometry?.getType?.(); - if (!geometryType) return null; - - if (geometryType === "Point") { - return geometry; - } - - if (geometryType === "Polygon") { - return ( - geometry.getInteriorPoint?.() ?? getGeometryExtentCenterPoint(geometry) - ); - } - - if (geometryType === "MultiPolygon") { - const polygons = geometry.getPolygons?.() ?? []; - if (polygons.length > 0) { - const representativePolygon = polygons.reduce( - (largest: any, current: any) => - (current?.getArea?.() ?? 0) > (largest?.getArea?.() ?? 0) - ? current - : largest, - polygons[0], - ); - return ( - representativePolygon?.getInteriorPoint?.() ?? - getGeometryExtentCenterPoint(representativePolygon) - ); - } - return getGeometryExtentCenterPoint(geometry); - } - - return getGeometryExtentCenterPoint(geometry); -}; - -const createGisEditPreviewStyle = (feature: any) => { - const editType = String(feature.get("editType") ?? ""); - const geometry = feature.getGeometry?.(); - const geometryType = geometry?.getType?.(); - const previewLabel = String(feature.get("previewLabel") ?? "").trim(); - const isInteractionTarget = Boolean(feature.get("isInteractionTarget")); - const isDragging = Boolean(feature.get("isDragging")); - const layerPointColor = String(feature.get("layerPointColor") ?? "").trim(); - const layerLineColor = String(feature.get("layerLineColor") ?? "").trim(); - const layerFillColor = String(feature.get("layerFillColor") ?? "").trim(); - const pointColor = - editType === "DELETE" - ? "rgba(220, 38, 38, 0.85)" - : editType === "VERTEX_EDIT" - ? "rgba(245, 158, 11, 0.92)" - : editType === "MOVE" - ? layerPointColor || "rgba(37, 99, 235, 0.92)" - : editType === "ADD" - ? "rgba(15, 23, 42, 0.96)" - : layerPointColor || "rgba(22, 163, 74, 0.92)"; - const lineColor = - editType === "DELETE" - ? "rgba(220, 38, 38, 0.9)" - : editType === "VERTEX_EDIT" - ? "rgba(245, 158, 11, 0.95)" - : editType === "MOVE" - ? layerLineColor || "rgba(37, 99, 235, 0.95)" - : editType === "ADD" - ? "rgba(15, 23, 42, 0.96)" - : layerLineColor || "rgba(22, 163, 74, 0.95)"; - const fillColor = - editType === "DELETE" - ? "rgba(220, 38, 38, 0.18)" - : editType === "VERTEX_EDIT" - ? "rgba(245, 158, 11, 0.2)" - : editType === "MOVE" - ? layerFillColor || "rgba(37, 99, 235, 0.18)" - : editType === "ADD" - ? "rgba(15, 23, 42, 0.2)" - : layerFillColor || "rgba(22, 163, 74, 0.18)"; - const highlightColor = isDragging - ? "rgba(14, 116, 144, 0.98)" - : isInteractionTarget - ? "rgba(15, 23, 42, 0.9)" - : lineColor; - const emphasisWidth = isDragging - ? 7 - : isInteractionTarget - ? 6 - : editType === "ADD" - ? 6 - : 4; - const textStyle = previewLabel - ? new Text({ - text: previewLabel, - font: "600 12px sans-serif", - fill: new Fill({ color: "#ffffff" }), - backgroundFill: new Fill({ color: highlightColor }), - padding: [3, 6, 3, 6], - offsetY: geometryType === "Point" ? -18 : -12, - overflow: true, - }) - : undefined; - const labelGeometry = previewLabel - ? getGisEditPreviewLabelGeometry(geometry) - : null; - - if (geometryType === "Point") { - return new Style({ - image: new CircleStyle({ - radius: isInteractionTarget ? 10 : 8, - fill: new Fill({ color: pointColor }), - stroke: new Stroke({ - color: isDragging ? "rgba(255,255,255,1)" : "rgba(255,255,255,0.95)", - width: isInteractionTarget ? 3 : 2, - }), - }), - text: textStyle, - zIndex: 10001, - }); - } - - if (geometryType === "Polygon") { - const polygonStyles: Style[] = [ - new Style({ - fill: new Fill({ - color: - isInteractionTarget && editType !== "DELETE" - ? fillColor.replace(/0\.\d+\)$/, "0.28)") - : fillColor, - }), - stroke: new Stroke({ - color: highlightColor, - width: emphasisWidth, - lineDash: editType === "DELETE" ? [8, 6] : undefined, - }), - zIndex: 10001, - }), - ]; - - if (textStyle && labelGeometry) { - polygonStyles.push( - new Style({ - geometry: labelGeometry, - text: textStyle, - zIndex: 10002, - }), - ); - } - - return polygonStyles; - } - - const lineStyles: Style[] = - editType === "ADD" - ? [ - new Style({ - stroke: new Stroke({ - color: "rgba(255, 255, 255, 0.98)", - width: emphasisWidth + 4, - }), - zIndex: 10000, - }), - new Style({ - stroke: new Stroke({ - color: highlightColor, - width: emphasisWidth, - }), - zIndex: 10003, - }), - ] - : [ - new Style({ - stroke: new Stroke({ - color: highlightColor, - width: emphasisWidth, - lineDash: editType === "DELETE" ? [10, 6] : undefined, - }), - zIndex: 10001, - }), - ]; - - if (textStyle && labelGeometry) { - lineStyles.push( - new Style({ - geometry: labelGeometry, - text: textStyle, - zIndex: 10002, - }), - ); - } - - return lineStyles; -}; - -const GIS_EDIT_VERTEX_HANDLE_STYLE = new Style({ - image: new CircleStyle({ - radius: 10, - fill: new Fill({ color: "rgba(245, 158, 11, 0.98)" }), - stroke: new Stroke({ color: "rgba(255,255,255,0.98)", width: 3 }), - }), - zIndex: 10005, -}); - -const GIS_EDIT_VERTEX_MIDPOINT_STYLE = new Style({ - image: new CircleStyle({ - radius: 6, - fill: new Fill({ color: "rgba(255, 255, 255, 0.98)" }), - stroke: new Stroke({ color: "rgba(245, 158, 11, 0.98)", width: 2.5 }), - }), - zIndex: 10004, -}); - -const GIS_EDIT_VERTEX_DRAGGING_STYLE = new Style({ - image: new CircleStyle({ - radius: 11, - fill: new Fill({ color: "rgba(59, 130, 246, 0.95)" }), - stroke: new Stroke({ color: "rgba(255,255,255,0.98)", width: 3 }), - }), - zIndex: 10006, -}); - -const GIS_EDIT_VERTEX_SELECTED_STYLE = new Style({ - image: new CircleStyle({ - radius: 11, - fill: new Fill({ color: "rgba(217, 119, 6, 0.98)" }), - stroke: new Stroke({ color: "rgba(15, 23, 42, 0.95)", width: 3 }), - }), - zIndex: 10007, -}); - -const getGisEditVertexHandleStyle = ( - kind: VertexHandleMeta["kind"], - isSelected = false, -) => { - if (isSelected && kind === "vertex") { - return GIS_EDIT_VERTEX_SELECTED_STYLE; - } - return kind === "midpoint" - ? GIS_EDIT_VERTEX_MIDPOINT_STYLE - : GIS_EDIT_VERTEX_HANDLE_STYLE; -}; const expandGisEditPipeSnapExtent = (extent: number[]) => { const [minX = 0, minY = 0, maxX = 0, maxY = 0] = extent; @@ -1495,980 +920,8 @@ const createSinkholeFocusedPipeStyle = () => [ }), ]; -type VertexHandleMeta = { - kind: "vertex" | "midpoint"; - geometryType: string; - partIndex?: number; - polygonIndex?: number; - ringIndex?: number; - coordIndex: number; -}; -type VertexHandleCandidate = { - coordinate: number[]; - meta: VertexHandleMeta; -}; -type VertexDeleteResult = { - ok: boolean; - message?: string; -}; - -const cloneCoordinates = (value: T): T => - JSON.parse(JSON.stringify(value)) as T; - -const getMidpoint = (start: number[], end: number[]) => [ - (Number(start?.[0] ?? 0) + Number(end?.[0] ?? 0)) / 2, - (Number(start?.[1] ?? 0) + Number(end?.[1] ?? 0)) / 2, -]; - -const buildVertexCoordinateKey = (coordinate: number[]) => - `${Number(coordinate?.[0] ?? 0)}:${Number(coordinate?.[1] ?? 0)}`; - -const buildVertexHandleMetaKey = (meta: VertexHandleMeta) => - [ - meta.kind, - meta.geometryType, - meta.partIndex ?? -1, - meta.polygonIndex ?? -1, - meta.ringIndex ?? -1, - meta.coordIndex, - ].join(":"); - -const buildVertexHandleSelectionKey = (metaList: VertexHandleMeta[]) => - metaList.map(buildVertexHandleMetaKey).sort().join("|"); - -const getSquaredPixelDistance = ( - map: any, - startCoordinate: number[], - endCoordinate: number[], -) => { - const [startX = 0, startY = 0] = - map?.getPixelFromCoordinate?.(startCoordinate) ?? []; - const [endX = 0, endY = 0] = - map?.getPixelFromCoordinate?.(endCoordinate) ?? []; - return (endX - startX) ** 2 + (endY - startY) ** 2; -}; - -const GIS_EDIT_VERTEX_CLUSTER_PIXEL_THRESHOLD = 12; - -const getAverageCoordinate = (coordinates: number[][]) => { - if (coordinates.length === 0) return [0, 0]; - - const [sumX, sumY] = coordinates.reduce<[number, number]>( - ([accX, accY], [x = 0, y = 0]) => [accX + Number(x), accY + Number(y)], - [0, 0], - ); - - return [sumX / coordinates.length, sumY / coordinates.length]; -}; - -const getPixelDistanceToSegment = ( - map: any, - coordinate: number[], - startCoordinate: number[], - endCoordinate: number[], -) => { - const [pointX = 0, pointY = 0] = - map?.getPixelFromCoordinate?.(coordinate) ?? []; - const [startX = 0, startY = 0] = - map?.getPixelFromCoordinate?.(startCoordinate) ?? []; - const [endX = 0, endY = 0] = - map?.getPixelFromCoordinate?.(endCoordinate) ?? []; - const deltaX = endX - startX; - const deltaY = endY - startY; - const segmentLengthSquared = deltaX ** 2 + deltaY ** 2; - - if (segmentLengthSquared === 0) { - return Math.hypot(pointX - startX, pointY - startY); - } - - const ratio = Math.max( - 0, - Math.min( - 1, - ((pointX - startX) * deltaX + (pointY - startY) * deltaY) / - segmentLengthSquared, - ), - ); - const projectedX = startX + deltaX * ratio; - const projectedY = startY + deltaY * ratio; - return Math.hypot(pointX - projectedX, pointY - projectedY); -}; - -const createVertexHandleFeature = ( - coordinate: number[], - meta: VertexHandleMeta, - metaList: VertexHandleMeta[] = [meta], -) => { - const handleFeature = new Feature({ - geometry: new Point(coordinate), - }); - handleFeature.set("handleKind", meta.kind); - handleFeature.set("handleMeta", meta); - handleFeature.set("handleMetaList", metaList); - handleFeature.set( - "handleSelectionKey", - buildVertexHandleSelectionKey(metaList), - ); - handleFeature.setStyle(getGisEditVertexHandleStyle(meta.kind)); - return handleFeature; -}; - -const collectLineVertexHandleCandidates = ( - coordinates: number[][], - geometryType: string, - partIndex?: number, -) => { - const candidates: VertexHandleCandidate[] = []; - coordinates.forEach((coordinate, coordIndex) => { - candidates.push({ - coordinate, - meta: { - kind: "vertex", - geometryType, - partIndex, - coordIndex, - }, - }); - }); - return candidates; -}; - -const collectPolygonRingVertexHandleCandidates = ( - coordinates: number[][], - geometryType: string, - polygonIndex?: number, - ringIndex?: number, -) => { - const candidates: VertexHandleCandidate[] = []; - const logicalVertexCount = Math.max(0, coordinates.length - 1); - for (let coordIndex = 0; coordIndex < logicalVertexCount; coordIndex += 1) { - const coordinate = coordinates[coordIndex]; - const nextCoordinate = - coordinates[(coordIndex + 1) % logicalVertexCount] ?? coordinate; - if (!coordinate || !nextCoordinate) continue; - candidates.push({ - coordinate, - meta: { - kind: "vertex", - geometryType, - polygonIndex, - ringIndex, - coordIndex, - }, - }); - } - return candidates; -}; - -const collectVertexHandleCandidates = (geometry: any) => { - const geometryType = geometry?.getType?.(); - if (!geometryType) return []; - - if (geometryType === "LineString") { - return collectLineVertexHandleCandidates( - geometry.getCoordinates?.() ?? [], - geometryType, - ); - } - - if (geometryType === "MultiLineString") { - const parts = geometry.getCoordinates?.() ?? []; - return parts.flatMap((part: number[][], partIndex: number) => - collectLineVertexHandleCandidates(part, geometryType, partIndex), - ); - } - - if (geometryType === "Polygon") { - const rings = geometry.getCoordinates?.() ?? []; - return rings.flatMap((ring: number[][], ringIndex: number) => - collectPolygonRingVertexHandleCandidates( - ring, - geometryType, - undefined, - ringIndex, - ), - ); - } - - if (geometryType === "MultiPolygon") { - const polygons = geometry.getCoordinates?.() ?? []; - return polygons.flatMap((polygon: number[][][], polygonIndex: number) => - polygon.flatMap((ring: number[][], ringIndex: number) => - collectPolygonRingVertexHandleCandidates( - ring, - geometryType, - polygonIndex, - ringIndex, - ), - ), - ); - } - - return []; -}; - -const buildVertexHandleFeatures = (geometry: any, map?: any): Feature[] => { - const groupedCandidates: Array<{ - coordinate: number[]; - metaList: VertexHandleMeta[]; - sourceCoordinates: number[][]; - }> = []; - - collectVertexHandleCandidates(geometry).forEach( - (candidate: VertexHandleCandidate) => { - const existing = groupedCandidates.find((group) => { - if (!map) { - return ( - buildVertexCoordinateKey(group.coordinate) === - buildVertexCoordinateKey(candidate.coordinate) - ); - } - - return ( - getSquaredPixelDistance( - map, - group.coordinate, - candidate.coordinate, - ) <= - GIS_EDIT_VERTEX_CLUSTER_PIXEL_THRESHOLD ** 2 - ); - }); - - if (existing) { - existing.metaList.push(candidate.meta); - existing.sourceCoordinates.push(candidate.coordinate); - existing.coordinate = getAverageCoordinate(existing.sourceCoordinates); - return; - } - - groupedCandidates.push({ - coordinate: candidate.coordinate, - metaList: [candidate.meta], - sourceCoordinates: [candidate.coordinate], - }); - }, - ); - - return groupedCandidates.map(({ coordinate, metaList }) => - createVertexHandleFeature(coordinate, metaList[0]!, metaList), - ); -}; - -const collectLineMidpointCandidates = ( - coordinates: number[][], - geometryType: string, - partIndex?: number, -) => { - const candidates: VertexHandleCandidate[] = []; - for ( - let coordIndex = 0; - coordIndex < coordinates.length - 1; - coordIndex += 1 - ) { - const startCoordinate = coordinates[coordIndex]; - const endCoordinate = coordinates[coordIndex + 1]; - if (!startCoordinate || !endCoordinate) continue; - candidates.push({ - coordinate: getMidpoint(startCoordinate, endCoordinate), - meta: { - kind: "midpoint", - geometryType, - partIndex, - coordIndex, - }, - }); - } - return candidates; -}; - -const collectPolygonRingMidpointCandidates = ( - coordinates: number[][], - geometryType: string, - polygonIndex?: number, - ringIndex?: number, -) => { - const candidates: VertexHandleCandidate[] = []; - const logicalVertexCount = Math.max(0, coordinates.length - 1); - for (let coordIndex = 0; coordIndex < logicalVertexCount; coordIndex += 1) { - const startCoordinate = coordinates[coordIndex]; - const endCoordinate = coordinates[(coordIndex + 1) % logicalVertexCount]; - if (!startCoordinate || !endCoordinate) continue; - candidates.push({ - coordinate: getMidpoint(startCoordinate, endCoordinate), - meta: { - kind: "midpoint", - geometryType, - polygonIndex, - ringIndex, - coordIndex, - }, - }); - } - return candidates; -}; - -const collectMidpointCandidates = (geometry: any) => { - const geometryType = geometry?.getType?.(); - if (!geometryType) return []; - - if (geometryType === "LineString") { - return collectLineMidpointCandidates( - geometry.getCoordinates?.() ?? [], - geometryType, - ); - } - - if (geometryType === "MultiLineString") { - const parts = geometry.getCoordinates?.() ?? []; - return parts.flatMap((part: number[][], partIndex: number) => - collectLineMidpointCandidates(part, geometryType, partIndex), - ); - } - - if (geometryType === "Polygon") { - const rings = geometry.getCoordinates?.() ?? []; - return rings.flatMap((ring: number[][], ringIndex: number) => - collectPolygonRingMidpointCandidates( - ring, - geometryType, - undefined, - ringIndex, - ), - ); - } - - if (geometryType === "MultiPolygon") { - const polygons = geometry.getCoordinates?.() ?? []; - return polygons.flatMap((polygon: number[][][], polygonIndex: number) => - polygon.flatMap((ring: number[][], ringIndex: number) => - collectPolygonRingMidpointCandidates( - ring, - geometryType, - polygonIndex, - ringIndex, - ), - ), - ); - } - - return []; -}; - -const getNearestVertexPixelDistance = ( - map: any, - geometry: any, - coordinate: number[], -): number => { - return buildVertexHandleFeatures(geometry, map).reduce( - (minimumDistance: number, feature: Feature) => { - const vertexCoordinate = ( - feature.getGeometry?.() as any - )?.getCoordinates?.(); - if (!Array.isArray(vertexCoordinate)) return minimumDistance; - return Math.min( - minimumDistance, - Math.sqrt(getSquaredPixelDistance(map, coordinate, vertexCoordinate)), - ); - }, - Number.POSITIVE_INFINITY, - ); -}; - -const getSegmentCoordinatesFromMeta = ( - geometry: any, - meta: VertexHandleMeta, -): [number[], number[]] | null => { - const geometryType = geometry?.getType?.(); - if (!geometryType) return null; - - if (geometryType === "LineString") { - const coordinates = geometry.getCoordinates?.() ?? []; - return [coordinates[meta.coordIndex], coordinates[meta.coordIndex + 1]]; - } - - if (geometryType === "MultiLineString") { - const parts = geometry.getCoordinates?.() ?? []; - const targetPart = parts[meta.partIndex ?? 0] ?? []; - return [targetPart[meta.coordIndex], targetPart[meta.coordIndex + 1]]; - } - - if (geometryType === "Polygon") { - const rings = geometry.getCoordinates?.() ?? []; - const targetRing = rings[meta.ringIndex ?? 0] ?? []; - const logicalVertexCount = Math.max(0, targetRing.length - 1); - if (logicalVertexCount === 0) return null; - return [ - targetRing[meta.coordIndex], - targetRing[(meta.coordIndex + 1) % logicalVertexCount], - ]; - } - - if (geometryType === "MultiPolygon") { - const polygons = geometry.getCoordinates?.() ?? []; - const targetPolygon = polygons[meta.polygonIndex ?? 0] ?? []; - const targetRing = targetPolygon[meta.ringIndex ?? 0] ?? []; - const logicalVertexCount = Math.max(0, targetRing.length - 1); - if (logicalVertexCount === 0) return null; - return [ - targetRing[meta.coordIndex], - targetRing[(meta.coordIndex + 1) % logicalVertexCount], - ]; - } - - return null; -}; - -const findHoveredMidpointCandidate = ( - map: any, - geometry: any, - coordinate: number[], - tolerancePx = 18, -): VertexHandleCandidate | null => { - const nearestVertexDistance = getNearestVertexPixelDistance( - map, - geometry, - coordinate, - ); - if (nearestVertexDistance <= 14) { - return null; - } - - const midpointCandidates = collectMidpointCandidates(geometry); - let bestCandidate: VertexHandleCandidate | null = null; - let minimumDistance = Number.POSITIVE_INFINITY; - - midpointCandidates.forEach((candidate: VertexHandleCandidate) => { - const segmentCoordinates = getSegmentCoordinatesFromMeta( - geometry, - candidate.meta, - ); - if (!segmentCoordinates) return; - const [startCoordinate, endCoordinate] = segmentCoordinates; - if (!Array.isArray(startCoordinate) || !Array.isArray(endCoordinate)) - return; - - const segmentDistance = getPixelDistanceToSegment( - map, - coordinate, - startCoordinate, - endCoordinate, - ); - if (segmentDistance < minimumDistance) { - minimumDistance = segmentDistance; - bestCandidate = candidate; - } - }); - - return minimumDistance <= tolerancePx ? bestCandidate : null; -}; - -const updateLineCoordinates = ( - coordinates: number[][], - meta: VertexHandleMeta, - nextCoordinate: number[], -) => { - const nextCoordinates = cloneCoordinates(coordinates); - if (meta.kind === "vertex") { - nextCoordinates[meta.coordIndex] = nextCoordinate; - } else { - nextCoordinates.splice(meta.coordIndex + 1, 0, nextCoordinate); - } - return nextCoordinates; -}; - -const updatePolygonRingCoordinates = ( - coordinates: number[][], - meta: VertexHandleMeta, - nextCoordinate: number[], -) => { - const nextCoordinates = cloneCoordinates(coordinates); - const logicalVertexCount = Math.max(0, nextCoordinates.length - 1); - if (logicalVertexCount === 0) return nextCoordinates; - - if (meta.kind === "vertex") { - nextCoordinates[meta.coordIndex] = nextCoordinate; - if (meta.coordIndex === 0) { - nextCoordinates[logicalVertexCount] = nextCoordinate; - } - } else { - const insertIndex = - meta.coordIndex === logicalVertexCount - 1 - ? logicalVertexCount - : meta.coordIndex + 1; - nextCoordinates.splice(insertIndex, 0, nextCoordinate); - } - - return nextCoordinates; -}; - -const applyVertexHandleMove = ( - geometry: any, - meta: VertexHandleMeta, - nextCoordinate: number[], -) => { - const geometryType = geometry?.getType?.(); - if (!geometryType) return; - - if (geometryType === "LineString") { - geometry.setCoordinates( - updateLineCoordinates( - geometry.getCoordinates?.() ?? [], - meta, - nextCoordinate, - ), - ); - return; - } - - if (geometryType === "MultiLineString") { - const parts = cloneCoordinates(geometry.getCoordinates?.() ?? []); - const targetPartIndex = meta.partIndex ?? 0; - parts[targetPartIndex] = updateLineCoordinates( - parts[targetPartIndex] ?? [], - meta, - nextCoordinate, - ); - geometry.setCoordinates(parts); - return; - } - - if (geometryType === "Polygon") { - const rings = cloneCoordinates(geometry.getCoordinates?.() ?? []); - const targetRingIndex = meta.ringIndex ?? 0; - rings[targetRingIndex] = updatePolygonRingCoordinates( - rings[targetRingIndex] ?? [], - meta, - nextCoordinate, - ); - geometry.setCoordinates(rings); - return; - } - - if (geometryType === "MultiPolygon") { - const polygons = cloneCoordinates(geometry.getCoordinates?.() ?? []); - const targetPolygonIndex = meta.polygonIndex ?? 0; - const targetRingIndex = meta.ringIndex ?? 0; - polygons[targetPolygonIndex][targetRingIndex] = - updatePolygonRingCoordinates( - polygons[targetPolygonIndex][targetRingIndex] ?? [], - meta, - nextCoordinate, - ); - geometry.setCoordinates(polygons); - } -}; - -const deleteLineVertices = ( - coordinates: number[][], - coordIndexes: number[], -) => { - const nextCoordinates = cloneCoordinates(coordinates); - const uniqueIndexes = Array.from(new Set(coordIndexes)).sort( - (left, right) => right - left, - ); - if (nextCoordinates.length - uniqueIndexes.length < 2) { - return null; - } - uniqueIndexes.forEach((coordIndex) => { - nextCoordinates.splice(coordIndex, 1); - }); - return nextCoordinates; -}; - -const deletePolygonVertices = ( - coordinates: number[][], - coordIndexes: number[], -) => { - const logicalVertexCount = Math.max(0, coordinates.length - 1); - const uniqueIndexes = Array.from(new Set(coordIndexes)).sort( - (left, right) => right - left, - ); - if (logicalVertexCount - uniqueIndexes.length < 3) { - return null; - } - - const logicalCoordinates = cloneCoordinates( - coordinates.slice(0, logicalVertexCount), - ); - uniqueIndexes.forEach((coordIndex) => { - logicalCoordinates.splice(coordIndex, 1); - }); - if (logicalCoordinates.length < 3) return null; - - return [...logicalCoordinates, cloneCoordinates(logicalCoordinates[0] ?? [])]; -}; - -const deleteSelectedVertexHandles = ( - geometry: any, - metaList: VertexHandleMeta[], -): VertexDeleteResult => { - if (!geometry || metaList.length === 0) { - return { ok: false, message: "삭제할 꼭지점 정보를 찾지 못했습니다." }; - } - - if (metaList.some((meta) => meta.kind !== "vertex")) { - return { - ok: false, - message: - "흰색 midpoint는 삭제 대상이 아닙니다. 주황색 실꼭지점을 선택하세요.", - }; - } - - const geometryType = geometry?.getType?.(); - if (!geometryType) { - return { - ok: false, - message: "꼭지점 삭제 대상 geometry를 찾지 못했습니다.", - }; - } - - if (geometryType === "LineString") { - const nextCoordinates = deleteLineVertices( - geometry.getCoordinates?.() ?? [], - metaList.map((meta) => meta.coordIndex), - ); - if (!nextCoordinates) { - return { - ok: false, - message: - "하수관로는 최소 2개의 꼭지점이 필요해 더 이상 삭제할 수 없습니다.", - }; - } - geometry.setCoordinates(nextCoordinates); - return { ok: true }; - } - - if (geometryType === "MultiLineString") { - const nextParts = cloneCoordinates(geometry.getCoordinates?.() ?? []); - const metaGroups = metaList.reduce>( - (acc, meta) => { - const partIndex = meta.partIndex ?? 0; - if (!acc[partIndex]) acc[partIndex] = []; - acc[partIndex]!.push(meta.coordIndex); - return acc; - }, - {}, - ); - - for (const [partIndexRaw, coordIndexes] of Object.entries(metaGroups)) { - const partIndex = Number(partIndexRaw); - const nextCoordinates = deleteLineVertices( - nextParts[partIndex] ?? [], - coordIndexes, - ); - if (!nextCoordinates) { - return { - ok: false, - message: "하수관로는 각 선형마다 최소 2개의 꼭지점이 필요합니다.", - }; - } - nextParts[partIndex] = nextCoordinates; - } - - geometry.setCoordinates(nextParts); - return { ok: true }; - } - - if (geometryType === "Polygon") { - const nextRings = cloneCoordinates(geometry.getCoordinates?.() ?? []); - const metaGroups = metaList.reduce>( - (acc, meta) => { - const ringIndex = meta.ringIndex ?? 0; - if (!acc[ringIndex]) acc[ringIndex] = []; - acc[ringIndex]!.push(meta.coordIndex); - return acc; - }, - {}, - ); - - for (const [ringIndexRaw, coordIndexes] of Object.entries(metaGroups)) { - const ringIndex = Number(ringIndexRaw); - const nextCoordinates = deletePolygonVertices( - nextRings[ringIndex] ?? [], - coordIndexes, - ); - if (!nextCoordinates) { - return { - ok: false, - message: - "폴리곤은 최소 3개의 꼭지점이 필요해 더 이상 삭제할 수 없습니다.", - }; - } - nextRings[ringIndex] = nextCoordinates; - } - - geometry.setCoordinates(nextRings); - return { ok: true }; - } - - if (geometryType === "MultiPolygon") { - const nextPolygons = cloneCoordinates(geometry.getCoordinates?.() ?? []); - const metaGroups = metaList.reduce>( - (acc, meta) => { - const polygonIndex = meta.polygonIndex ?? 0; - const ringIndex = meta.ringIndex ?? 0; - const groupKey = `${polygonIndex}:${ringIndex}`; - if (!acc[groupKey]) acc[groupKey] = []; - acc[groupKey]!.push(meta.coordIndex); - return acc; - }, - {}, - ); - - for (const [groupKey, coordIndexes] of Object.entries(metaGroups)) { - const [polygonIndexText, ringIndexText] = groupKey.split(":"); - const polygonIndex = Number(polygonIndexText); - const ringIndex = Number(ringIndexText); - const nextCoordinates = deletePolygonVertices( - nextPolygons[polygonIndex]?.[ringIndex] ?? [], - coordIndexes, - ); - if (!nextCoordinates) { - return { - ok: false, - message: - "폴리곤은 최소 3개의 꼭지점이 필요해 더 이상 삭제할 수 없습니다.", - }; - } - nextPolygons[polygonIndex][ringIndex] = nextCoordinates; - } - - geometry.setCoordinates(nextPolygons); - return { ok: true }; - } - - return { - ok: false, - message: "현재 geometry 유형은 꼭지점 삭제를 지원하지 않습니다.", - }; -}; - -const getClosestSnapCoordinateFromSource = ( - source: VectorSource | null, - map: any, - coordinate: number[], - tolerancePx = 18, -) => { - if (!source || !Array.isArray(coordinate)) return null; - const nearestFeature = source.getClosestFeatureToCoordinate?.(coordinate); - const nearestGeometry = nearestFeature?.getGeometry?.(); - if (!nearestGeometry?.getClosestPoint) return null; - - const closestCoordinate = nearestGeometry.getClosestPoint(coordinate); - if (!Array.isArray(closestCoordinate)) return null; - - const distance = Math.sqrt( - getSquaredPixelDistance(map, coordinate, closestCoordinate), - ); - if (!Number.isFinite(distance) || distance > tolerancePx) return null; - - return { - coordinate: closestCoordinate as number[], - distance, - }; -}; - -const applyGeometryVertexSnaps = ( - geometry: any, - source: VectorSource | null, - map: any, - tolerancePx = 18, -) => { - if (!geometry || !source || !map) return false; - - const vertexCandidates = collectVertexHandleCandidates(geometry).filter( - (candidate: VertexHandleCandidate) => candidate.meta.kind === "vertex", - ); - let changed = false; - - vertexCandidates.forEach((candidate: VertexHandleCandidate) => { - const snapResult = getClosestSnapCoordinateFromSource( - source, - map, - candidate.coordinate, - tolerancePx, - ); - if (!snapResult) return; - - const currentKey = buildVertexCoordinateKey(candidate.coordinate); - const nextKey = buildVertexCoordinateKey(snapResult.coordinate); - if (currentKey === nextKey) return; - - applyVertexHandleMove(geometry, candidate.meta, snapResult.coordinate); - changed = true; - }); - - return changed; -}; - -const applyGeometryMoveSnap = ( - geometry: any, - source: VectorSource | null, - map: any, - anchorCoordinate?: number[] | null, - tolerancePx = 18, -) => { - if (!geometry || !source || !map || typeof geometry.translate !== "function") - return false; - - if (Array.isArray(anchorCoordinate)) { - const snapResult = getClosestSnapCoordinateFromSource( - source, - map, - anchorCoordinate, - tolerancePx, - ); - if (snapResult) { - const deltaX = - Number(snapResult.coordinate[0] ?? 0) - - Number(anchorCoordinate[0] ?? 0); - const deltaY = - Number(snapResult.coordinate[1] ?? 0) - - Number(anchorCoordinate[1] ?? 0); - if (deltaX !== 0 || deltaY !== 0) { - geometry.translate(deltaX, deltaY); - return true; - } - } - } - - const vertexCandidates = collectVertexHandleCandidates(geometry).filter( - (candidate: VertexHandleCandidate) => candidate.meta.kind === "vertex", - ); - type GeometryMoveSnapCandidate = { - original: number[]; - snapped: number[]; - distance: number; - }; - let bestCandidate: GeometryMoveSnapCandidate | null = null; - - vertexCandidates.forEach((candidate: VertexHandleCandidate) => { - const snapResult = getClosestSnapCoordinateFromSource( - source, - map, - candidate.coordinate, - tolerancePx, - ); - if (!snapResult) return; - - if (!bestCandidate || snapResult.distance < bestCandidate.distance) { - bestCandidate = { - original: candidate.coordinate, - snapped: snapResult.coordinate, - distance: snapResult.distance, - }; - } - }); - - if (!bestCandidate) return false; - const resolvedBestCandidate = bestCandidate as GeometryMoveSnapCandidate; - - const deltaX = - Number(resolvedBestCandidate.snapped[0] ?? 0) - - Number(resolvedBestCandidate.original[0] ?? 0); - const deltaY = - Number(resolvedBestCandidate.snapped[1] ?? 0) - - Number(resolvedBestCandidate.original[1] ?? 0); - if (deltaX === 0 && deltaY === 0) return false; - - geometry.translate(deltaX, deltaY); - return true; -}; - -const getClosestGeometryVertexCoordinate = ( - geometry: any, - map: any, - coordinate: number[] | null, -) => { - if (!geometry || !map || !Array.isArray(coordinate)) return null; - - const vertexCandidates = collectVertexHandleCandidates(geometry).filter( - (candidate: VertexHandleCandidate) => candidate.meta.kind === "vertex", - ); - let bestCandidate: VertexHandleCandidate | null = null; - let minimumDistance = Number.POSITIVE_INFINITY; - - vertexCandidates.forEach((candidate: VertexHandleCandidate) => { - const distance = getSquaredPixelDistance( - map, - coordinate, - candidate.coordinate, - ); - if (distance < minimumDistance) { - minimumDistance = distance; - bestCandidate = candidate; - } - }); - - return (bestCandidate as VertexHandleCandidate | null)?.coordinate ?? null; -}; - -const getClosestGeometryVertexMeta = ( - geometry: any, - map: any, - coordinate: number[] | null, -) => { - if (!geometry || !map || !Array.isArray(coordinate)) return null; - - const vertexCandidates = collectVertexHandleCandidates(geometry).filter( - (candidate: VertexHandleCandidate) => candidate.meta.kind === "vertex", - ); - let bestCandidate: VertexHandleCandidate | null = null; - let minimumDistance = Number.POSITIVE_INFINITY; - - vertexCandidates.forEach((candidate: VertexHandleCandidate) => { - const distance = getSquaredPixelDistance( - map, - coordinate, - candidate.coordinate, - ); - if (distance < minimumDistance) { - minimumDistance = distance; - bestCandidate = candidate; - } - }); - - return (bestCandidate as VertexHandleCandidate | null)?.meta ?? null; -}; - -const getGeometryVertexCoordinateByMeta = ( - geometry: any, - meta: VertexHandleMeta | null, -) => { - if (!geometry || !meta) return null; - const geometryType = geometry?.getType?.(); - if (!geometryType) return null; - - if (geometryType === "LineString") { - return geometry.getCoordinates?.()?.[meta.coordIndex] ?? null; - } - - if (geometryType === "MultiLineString") { - return ( - geometry.getCoordinates?.()?.[meta.partIndex ?? 0]?.[meta.coordIndex] ?? - null - ); - } - - if (geometryType === "Polygon") { - return ( - geometry.getCoordinates?.()?.[meta.ringIndex ?? 0]?.[meta.coordIndex] ?? - null - ); - } - - if (geometryType === "MultiPolygon") { - return ( - geometry.getCoordinates?.()?.[meta.polygonIndex ?? 0]?.[ - meta.ringIndex ?? 0 - ]?.[meta.coordIndex] ?? null - ); - } - - return null; -}; const isTypingElement = (target: EventTarget | null) => { if (!(target instanceof HTMLElement)) return false; @@ -2481,279 +934,6 @@ const isTypingElement = (target: EventTarget | null) => { ); }; -// 관종별 원본 레이어 색을 유하방향 선/화살표에 재사용할 때 투명도만 조정한다. -const withAlpha = (color: string | null | undefined, alpha: number) => { - const resolvedColor = String(color ?? "").trim(); - if (!resolvedColor) return `rgba(40, 90, 180, ${alpha})`; - - const rgbaMatch = resolvedColor.match(/^rgba?\(([^)]+)\)$/i); - if (rgbaMatch) { - const [red = "40", green = "90", blue = "180"] = (rgbaMatch[1] ?? "") - .split(",") - .map((part) => part.trim()); - return `rgba(${red}, ${green}, ${blue}, ${alpha})`; - } - - const hexMatch = resolvedColor.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); - if (hexMatch) { - const hex = hexMatch[1] ?? ""; - const normalized = - hex.length === 3 - ? hex - .split("") - .map((char) => char + char) - .join("") - : hex; - const red = parseInt(normalized.slice(0, 2), 16); - const green = parseInt(normalized.slice(2, 4), 16); - const blue = parseInt(normalized.slice(4, 6), 16); - return `rgba(${red}, ${green}, ${blue}, ${alpha})`; - } - - return resolvedColor; -}; - -// 화살표 위치와 대표 선분 선택을 위해 LineString 좌표열의 대략적인 길이를 계산한다. -const estimateLineLength = (coordinates: number[][]) => { - let length = 0; - for (let i = 1; i < coordinates.length; i += 1) { - const previous = coordinates[i - 1]; - const current = coordinates[i]; - if (!previous || !current) continue; - - const [x1 = 0, y1 = 0] = previous; - const [x2 = 0, y2 = 0] = current; - length += Math.hypot(x2 - x1, y2 - y1); - } - return length; -}; - -// MultiLineString 관로는 가장 긴 선분을 대표 선으로 삼아 화살표 위치와 각도를 안정적으로 계산한다. -const getRepresentativeLineCoordinates = (geometry: any): number[][] => { - const type = geometry?.getType?.(); - const coordinates = geometry?.getCoordinates?.(); - - if (type === "LineString") return coordinates ?? []; - if (type === "MultiLineString" && Array.isArray(coordinates)) { - return coordinates.reduce( - (longest: number[][], current: number[][]) => - estimateLineLength(current) > estimateLineLength(longest) - ? current - : longest, - [], - ); - } - - return []; -}; - -// 관로 중앙 지점이 속한 실제 선분을 찾아 화살표 위치와 해당 선분의 방향각을 함께 계산한다. -const getArrowPlacementAtHalfLength = (coordinates: number[][]) => { - if (coordinates.length === 0) return null; - if (coordinates.length === 1) { - const coordinate = coordinates[0]; - return coordinate ? { coordinate, angle: 0 } : null; - } - - const totalLength = estimateLineLength(coordinates); - if (totalLength === 0) { - const coordinate = coordinates[Math.floor(coordinates.length / 2)]; - return coordinate ? { coordinate, angle: 0 } : null; - } - - let accumulated = 0; - const targetLength = totalLength / 2; - let lastSegmentAngle = 0; - - for (let i = 1; i < coordinates.length; i += 1) { - const previous = coordinates[i - 1]; - const current = coordinates[i]; - if (!previous || !current) continue; - - const [previousX = 0, previousY = 0] = previous; - const [currentX = 0, currentY = 0] = current; - const segmentLength = Math.hypot( - currentX - previousX, - currentY - previousY, - ); - if (segmentLength > 0) { - lastSegmentAngle = Math.atan2(currentY - previousY, currentX - previousX); - } - - if (accumulated + segmentLength >= targetLength) { - const ratio = - segmentLength === 0 ? 0 : (targetLength - accumulated) / segmentLength; - return { - coordinate: [ - previousX + (currentX - previousX) * ratio, - previousY + (currentY - previousY) * ratio, - ], - angle: lastSegmentAngle, - }; - } - - accumulated += segmentLength; - } - - const coordinate = coordinates[coordinates.length - 1]; - return coordinate ? { coordinate, angle: lastSegmentAngle } : null; -}; - -// SVG 없이 Canvas renderer로 짧은 꼬리선과 화살촉을 직접 그려 방향성을 분명하게 만든다. -const createFlowDirectionArrowRenderer = ( - color: string, - angle: number, - scale: number, -) => { - return (coordinates: any, state: any) => { - const pixel = Array.isArray(coordinates?.[0]) - ? coordinates[0] - : coordinates; - const [x, y] = pixel ?? []; - const context = state?.context as CanvasRenderingContext2D | undefined; - if (!context || !Number.isFinite(x) || !Number.isFinite(y)) return; - - const length = 17 * scale; - const tailLength = 9 * scale; - const headLength = 7 * scale; - const headHalfWidth = 4.5 * scale; - const shaftWidth = 2.4 * scale; - - context.save(); - context.translate(x, y); - const viewRotation = Number(state?.rotation ?? 0); - const screenAngle = - (Number.isFinite(viewRotation) ? viewRotation : 0) - angle; - - context.rotate(screenAngle); - context.lineCap = "round"; - context.lineJoin = "round"; - - context.strokeStyle = "rgba(255, 255, 255, 0.9)"; - context.lineWidth = shaftWidth + 2.4; - context.beginPath(); - context.moveTo(-tailLength, 0); - context.lineTo(length - headLength, 0); - context.stroke(); - - context.fillStyle = "rgba(255, 255, 255, 0.9)"; - context.beginPath(); - context.moveTo(length, 0); - context.lineTo(length - headLength, -headHalfWidth); - context.lineTo(length - headLength, headHalfWidth); - context.closePath(); - context.fill(); - - context.strokeStyle = color; - context.lineWidth = shaftWidth; - context.beginPath(); - context.moveTo(-tailLength, 0); - context.lineTo(length - headLength, 0); - context.stroke(); - - context.fillStyle = color; - context.beginPath(); - context.moveTo(length, 0); - context.lineTo(length - headLength, -headHalfWidth); - context.lineTo(length - headLength, headHalfWidth); - context.closePath(); - context.fill(); - - context.restore(); - }; -}; - -// 유하방향 신뢰도와 관종 색상에 따라 선과 화살표 스타일을 만든다. LOW는 운영 검수 대상이라 더 흐리게 표시한다. -const createFlowDirectionStyle = ( - feature: any, - usageStyleMap: Record, -) => { - const confidence = String( - feature.get("directionConfidence") ?? - feature.get("direction_confidence") ?? - "", - ).toUpperCase(); - const geometry = feature.getGeometry(); - const coordinates = getRepresentativeLineCoordinates(geometry); - const arrowPlacement = getArrowPlacementAtHalfLength(coordinates); - const usageCode = String( - feature.get("usgCd") ?? feature.get("usg_cd") ?? "", - ).trim(); - const usageStyle = usageStyleMap[usageCode]; - const baseColor = usageStyle?.color ?? FLOW_DIRECTION_DEFAULT_COLOR; - const isNetworkCorrected = - String( - feature.get("networkCorrectionYn") ?? - feature.get("network_correction_yn") ?? - "", - ).toUpperCase() === "Y"; - - const styleByConfidence: Record< - string, - { - color: string; - lineColor: string; - scale: number; - width: number; - lineDash?: number[]; - } - > = { - HIGH: { - color: baseColor, - lineColor: usageStyle?.lineColor ?? withAlpha(baseColor, 0.42), - scale: 1, - width: 1.3, - }, - MEDIUM: { - color: baseColor, - lineColor: usageStyle?.lineColor ?? withAlpha(baseColor, 0.42), - scale: 1, - width: 1.3, - }, - LOW: { - color: withAlpha(baseColor, 0.72), - lineColor: withAlpha(baseColor, 0.28), - scale: 0.9, - width: 1.1, - }, - }; - const style = isNetworkCorrected - ? { - color: FLOW_DIRECTION_NETWORK_CORRECTION_COLOR, - lineColor: withAlpha(FLOW_DIRECTION_NETWORK_CORRECTION_COLOR, 0.5), - scale: 1.08, - width: 2, - lineDash: [8, 4], - } - : (styleByConfidence[confidence] ?? styleByConfidence.LOW!); - const styles = [ - new Style({ - stroke: new Stroke({ - color: style.lineColor, - width: style.width, - lineDash: style.lineDash, - lineCap: "round", - lineJoin: "round", - }), - }), - ]; - - if (arrowPlacement) { - styles.push( - new Style({ - geometry: new Point(arrowPlacement.coordinate), - renderer: createFlowDirectionArrowRenderer( - style.color, - arrowPlacement.angle, - style.scale, - ), - }), - ); - } - - return styles; -}; - // 레이어 설정의 직접 코드와 filterSql 안의 usg_cd 조건을 모두 관종 코드 후보로 해석한다. const extractUsageCodesFromLayerInfo = (layerInfo: any) => { const usageCodes = new Set(); @@ -3582,7 +1762,7 @@ const Map = () => { removeFloodPredictionGridLayer(); try { const response = await getRawJson( - `/sewerage-service/api/v1/fldng/predc/prst/grid-map?sggCd=${encodeURIComponent(sggCd)}&analysisDateTime=${encodeURIComponent(analysisDateTime)}`, + `/sewerage-service/api/v1/fldng/predc/prst/gridMap?sggCd=${encodeURIComponent(sggCd)}&analysisDateTime=${encodeURIComponent(analysisDateTime)}`, ); const payload = response?.data ?? response; const row = payload && !Array.isArray(payload) ? payload : null; @@ -8287,18 +6467,15 @@ const Map = () => { if (!mapReady || !gisMaps.current) return; // 유하방향이 켜진 상태에서 지도 이동/확대축소가 끝나면 현재 BBOX 기준으로 다시 조회한다. - const key = gisMaps.current.on("moveend", () => { - if ( - !flowDirectionCheckedRef.current || - !flowDirectionLayerInfoRef.current - ) - return; - loadFlowDirectionLayer(flowDirectionLayerInfoRef.current); - }); - - return () => { - unByKey(key); - }; + return subscribeFlowDirectionMoveEnd( + gisMaps.current, + () => ({ + checked: flowDirectionCheckedRef.current, + layerInfo: flowDirectionLayerInfoRef.current, + }), + loadFlowDirectionLayer, + unByKey, + ); }, [loadFlowDirectionLayer, mapReady]); useEffect(() => { diff --git a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts b/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts index 368bb0bb3..77042c0c1 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts @@ -81,8 +81,8 @@ export default function useBasicTab() { page: `${curPage - 1}`, size: "10", }); - if (basicType === "tb_ms_swage_ppl") - queryParams.set("includeFlowDirectionBasis", "true"); + // 현재 서버의 유하방향 배치 보강 옵션은 실패 시 목록 전체를 500으로 중단한다. + // 상세 유하방향 조회와 분리해 기본 정보검색 목록을 우선 정상 제공한다. if (basicType === "tb_ms_swage_ppl") cqlFilters = swagePplCqlFilters(); // 하수관로 diff --git a/apps/sn/app/(popupLayout)/popup/[popupKey]/page.tsx b/apps/sn/app/(popupLayout)/popup/[popupKey]/page.tsx index 36ab9eb13..b2ad89331 100644 --- a/apps/sn/app/(popupLayout)/popup/[popupKey]/page.tsx +++ b/apps/sn/app/(popupLayout)/popup/[popupKey]/page.tsx @@ -1,4 +1,9 @@ import { isWindowPopupKey } from "@/features/windowPopup/model/popupDefinitions"; +import { + resolveWindowPopupTheme, + WINDOW_POPUP_THEME_PARAM, +} from "@/features/windowPopup/model/popupTheme"; +import PopupThemeSync from "@/features/windowPopup/ui/PopupThemeSync"; import WindowPopupRenderer from "@/features/windowPopup/ui/WindowPopupRenderer"; type PopupPageProps = { @@ -14,6 +19,9 @@ export default async function PopupPage({ }: PopupPageProps) { const { popupKey } = await params; const resolvedSearchParams = await searchParams; + const popupTheme = resolveWindowPopupTheme( + resolvedSearchParams[WINDOW_POPUP_THEME_PARAM], + ); if (!isWindowPopupKey(popupKey)) { return ( @@ -25,9 +33,14 @@ export default async function PopupPage({ } return ( - + <> + {popupTheme ? ( + + ) : null} + + ); } diff --git a/apps/sn/app/(with-base-layout)/ii/fileupload/page.tsx b/apps/sn/app/(with-base-layout)/ii/fileupload/page.tsx index 336b34963..916da1e6d 100644 --- a/apps/sn/app/(with-base-layout)/ii/fileupload/page.tsx +++ b/apps/sn/app/(with-base-layout)/ii/fileupload/page.tsx @@ -13,7 +13,7 @@ * * 🔗 사용 API * - 사이트 정보 조회: POST /api/v1/inftwtAnls/users/{userId} (sewerage-service) - * - 침입수분석 등록 첨부파일INP 포함: POST /api/v1/rdiiAnls/savewithfile (sewerage-service) + * - 침입수분석 등록 첨부파일INP 포함: POST /api/v1/rdiiAnls/saveWithFile (sewerage-service) * - 사용자 단 건 조회: GET /api/v1/users/{userUnqId} (user-service) */ "use client"; @@ -109,7 +109,7 @@ function FileUpload() { try { const response = await axios.post( - "http://localhost:8883/sewerage-service/api/v1/rdiiAnls/savewithfile", + "http://localhost:8883/sewerage-service/api/v1/rdiiAnls/saveWithFile", formData, { headers: { diff --git a/apps/sn/app/(with-base-layout)/ii/irsetting/page.tsx b/apps/sn/app/(with-base-layout)/ii/irsetting/page.tsx index 4e7f7a237..30914165e 100644 --- a/apps/sn/app/(with-base-layout)/ii/irsetting/page.tsx +++ b/apps/sn/app/(with-base-layout)/ii/irsetting/page.tsx @@ -12,7 +12,7 @@ * - 로딩 오버레이 및 에러 핸들링 지원 * * 🔗 사용 API - * - 침입수분석 등록 첨부파일INP 포함: POST /api/v1/inftwtAnls/savewithfile (sewerage-service) + * - 침입수분석 등록 첨부파일INP 포함: POST /api/v1/inftwtAnls/saveWithFile (sewerage-service) * - 로그인된 사용자의 사용자 정보 조회: GET /api/v1/users/info/me (user-service) */ "use client"; @@ -193,7 +193,7 @@ const IrSetting = () => { }; const registFile = async (analysisData: any) => { - const data = await postMutiPart("v1/inftwtAnls/savewithfile", analysisData); + const data = await postMutiPart("v1/inftwtAnls/saveWithFile", analysisData); if (!data) { throw new Error("분석 요청 접수 응답이 비어 있습니다."); } diff --git a/apps/sn/app/(with-base-layout)/ii/rdii/page.tsx b/apps/sn/app/(with-base-layout)/ii/rdii/page.tsx index e5757f572..4b32cebc0 100644 --- a/apps/sn/app/(with-base-layout)/ii/rdii/page.tsx +++ b/apps/sn/app/(with-base-layout)/ii/rdii/page.tsx @@ -13,7 +13,7 @@ * - 로딩 오버레이 및 에러 핸들링 지원 * * 🔗 사용 API - * - RDII분석 등록 첨부파일INP 포함: POST /api/v1/rdiiAnls/savewithfile (sewerage-service) + * - RDII분석 등록 첨부파일INP 포함: POST /api/v1/rdiiAnls/saveWithFile (sewerage-service) * - 로그인된 사용자의 사용자 정보 조회: GET /api/v1/users/info/me (user-service) */ "use client"; @@ -223,7 +223,7 @@ export default function RTKForm() { }; const registFile = async (analysisData: any) => { - const data = await postMutiPart("v1/rdiiAnls/savewithfile", analysisData); + const data = await postMutiPart("v1/rdiiAnls/saveWithFile", analysisData); if (!data) { throw new Error("분석 요청 접수 응답이 비어 있습니다."); } diff --git a/apps/sn/app/(with-base-layout)/ii/test/page_bar.tsx b/apps/sn/app/(with-base-layout)/ii/test/page_bar.tsx deleted file mode 100644 index 08b8755c8..000000000 --- a/apps/sn/app/(with-base-layout)/ii/test/page_bar.tsx +++ /dev/null @@ -1,143 +0,0 @@ -"use client"; - -import { - Chart as ChartJS, - CategoryScale, - LinearScale, - BarElement, - Title, - Tooltip, - Legend, - type ChartOptions, -} from "chart.js"; -import { Bar } from "react-chartjs-2"; -import { Card, CardHeader, CardTitle, CardContent } from "@common/business_components/ui/chart/lib/card"; -import { Button } from "@common/components"; - -// ChartJS 컴포넌트 등록 -ChartJS.register( - CategoryScale, - LinearScale, - BarElement, - Title, - Tooltip, - Legend -); - -export default function TestPage() { - // 차트 데이터 - const labels = ["한강", "낙동강", "영산강", "금강"]; - - const datasets = [ - { - label: '20-25년', - data: [3859, 1066, 1929, 2828], - backgroundColor: '#3B82F6', // 파란색 - borderRadius: 4, - }, - { - label: '25년-30년', - data: [3153, 1420, 1204, 2877], - backgroundColor: '#EF4444', // 빨간색 - borderRadius: 4, - }, - { - label: '30년이상', - data: [9404, 1835, 1821, 8115], - backgroundColor: '#F87171', // 연한 빨간색/분홍색 - borderRadius: 4, - }, - ]; - - // 각 지역별 총 길이 계산 - const totalLengths = [44251, 33786, 11888, 14878]; - - // 차트 옵션 설정 - const options: ChartOptions<"bar"> = { - responsive: true, - maintainAspectRatio: false, - scales: { - x: { - grid: { - display: true, - }, - }, - y: { - beginAtZero: true, - min: 0, - max: 10000, - ticks: { - stepSize: 2000, - callback: function(value) { - return value + 'km'; - } - }, - grid: { - color: 'rgba(200, 200, 200, 0.3)', - } - } - }, - plugins: { - legend: { - position: 'top', - align: 'start', - labels: { - boxWidth: 15, - padding: 15, - usePointStyle: false, - }, - }, - tooltip: { - callbacks: { - label: function(context) { - return `${context.dataset.label}: ${context.parsed.y}km`; - } - } - }, - }, - // 총 길이 데이터 표시를 위한 애니메이션 후 함수 설정 - animation: { - onComplete: function(animation) { - const chart = animation.chart; - const ctx = chart.ctx; - - // 각 데이터셋의 마지막 데이터셋의 인덱스 - const lastDatasetIndex = datasets.length - 1; - - // 텍스트 스타일 설정 - ctx.font = '11px Arial'; - ctx.fillStyle = '#333'; - ctx.textAlign = 'center'; - - // 각 지역별로 총 길이 표시 - labels.forEach((_, i) => { - const xPos = chart.scales.x ? chart.scales.x.getPixelForTick(i) : 0; - const yPos = chart.chartArea.top; // 차트 영역 최상단 위에 표시 - - // 막대 위에 총 길이 텍스트 표시 - if (totalLengths[i]) ctx.fillText(`총 ${totalLengths[i].toLocaleString()}km`, xPos, yPos); - }); - } - } - }; - - const data = { - labels, - datasets, - }; - - return ( - - - 노후하수관로현황 - - - - {/* 너비 400px, 높이 150px로 조정 */} -
- -
-
-
- ); -} \ No newline at end of file diff --git a/apps/sn/app/(with-base-layout)/ii/test/page_pie.tsx b/apps/sn/app/(with-base-layout)/ii/test/page_pie.tsx deleted file mode 100644 index 2cd6c1b95..000000000 --- a/apps/sn/app/(with-base-layout)/ii/test/page_pie.tsx +++ /dev/null @@ -1,146 +0,0 @@ -// ...existing code... -"use client"; - -import React from "react"; -import { Chart as ChartJS, ArcElement, Tooltip, Legend, type ChartOptions } from "chart.js"; -import { Pie } from "react-chartjs-2"; - -ChartJS.register(ArcElement, Tooltip, Legend); - -export default function Page() { - const labels = ["한강", "낙동강", "영산강", "금강"]; - const datasets = [ - { label: "20년 미만", data: [27835, 29465, 6934, 1058], color: "rgba(169, 169, 169, 0.8)" }, - { label: "20-25년", data: [3859, 1066, 1929, 2828], color: "rgba(255, 160, 122, 0.8)" }, - { label: "25년-30년", data: [3153, 1420, 1204, 2877], color: "rgba(152, 251, 152, 0.8)" }, - { label: "30년이상", data: [9404, 1835, 1821, 8115], color: "rgba(135, 206, 235, 0.8)" }, - ]; - // 각 지역별 총 길이 계산 - const totalLengths = [44251, 33786, 11888, 14878]; - // 외부 툴팁을 사용하여 컨테이너 넘침과 관계없이 전체 툴팁을 보여주고, - // 각 항목의 전체에서 차지하는 비율(%)을 표시합니다. - const pieOptions: ChartOptions<"pie"> = { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { display: false }, - tooltip: { - enabled: false, // 기본 캔버스 툴팁 비활성화 (외부 툴팁 사용) - external: function (context) { - const { chart, tooltip } = context; - let tooltipEl = document.getElementById("chartjs-external-tooltip"); - - // tooltip DOM이 없으면 생성 - if (!tooltipEl) { - tooltipEl = document.createElement("div"); - tooltipEl.id = "chartjs-external-tooltip"; - tooltipEl.style.position = "absolute"; - tooltipEl.style.pointerEvents = "auto"; - tooltipEl.style.background = "rgba(0,0,0,0.75)"; - tooltipEl.style.color = "#fff"; - tooltipEl.style.padding = "6px 8px"; - tooltipEl.style.borderRadius = "6px"; - tooltipEl.style.fontSize = "12px"; - tooltipEl.style.zIndex = "9999"; - tooltipEl.style.transform = "translate(-50%, -120%)"; - document.body.appendChild(tooltipEl); - } - - // 사라짐 처리 - if (tooltip.opacity === 0) { - tooltipEl.style.opacity = "0"; - return; - } - - // 툴팁 내용 구성 (파이의 전체 합 대비 비율 표시) - const data = chart.data; - const dataset = data.datasets && data.datasets[0] && data.datasets[0].data - ? (data.datasets[0].data as number[]) - : []; - const total = dataset.reduce((s, n) => s + (Number(n) || 0), 0); - - const innerHtmlParts: string[] = []; - if (tooltip.dataPoints) { - tooltip.dataPoints.forEach((dp) => { - const idx = dp.dataIndex; - const label = (data.labels && data.labels[idx]) || dp.label || ""; - const value = Number(dp.raw ?? dp.parsed ?? 0); - const pct = total > 0 ? (value / total) * 100 : 0; - innerHtmlParts.push( - `
- - ${label}: ${value.toLocaleString()}km - (${pct.toFixed(1)}%) -
` - ); - }); - } - - tooltipEl.innerHTML = innerHtmlParts.join(""); - tooltipEl.style.opacity = "1"; - - // 위치: 차트 캔버스 기준 툴팁 caret 위치에 맞춤 (윈도우 스크롤 보정 포함) - const canvasRect = chart.canvas.getBoundingClientRect(); - const caretX = tooltip.caretX ?? (canvasRect.left + canvasRect.width / 2); - const caretY = tooltip.caretY ?? (canvasRect.top + canvasRect.height / 2); - tooltipEl.style.left = `${canvasRect.left + window.pageXOffset + caretX}px`; - tooltipEl.style.top = `${canvasRect.top + window.pageYOffset + caretY}px`; - }, - }, - }, - }; - - - // 상단에 공통 범례(연도) 가로로 표시 - const SharedLegend = () => ( -
- {datasets.map((d) => ( -
- - {d.label} -
- ))} -
- ); - - // 레이블(강) 당 pie 데이터 생성 (레이블은 아래로 이동) - const Charts = () => - labels.map((river, i) => { - const data = { - labels: datasets.map((d) => d.label), - datasets: [ - { - data: datasets.map((d) => d.data[i]), - backgroundColor: datasets.map((d) => d.color), - borderColor: datasets.map(() => "rgba(255,255,255,0.8)"), - borderWidth: 1, - }, - ], - }; - - return ( -
-
- -
- {/* 레이블을 차트 하단으로 이동 */} -
{totalLengths[i]?.toLocaleString()} km
-
{river}
-
- ); - }); - - return ( -
- {/* 상단 공통 범례 */} -
- -
- -
- -
-
- ); -} -// ...existing code... \ No newline at end of file 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 234405264..d753286e4 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 @@ -6,7 +6,7 @@ import dynamic from "next/dynamic"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; -import { FileUpload } from "@common/business_components/ui/file-upload"; +import { DredgingRegistrationBody } from "@/features/dredging/ui/DredgingRegistrationBody"; import { StandardPopupLayout } from "@common/business_components"; import { Button, @@ -15,13 +15,6 @@ import { DialogFooter, DialogHeader, DialogTitle, - InputDatePicker, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, } from "@common/components"; import { useAlertStore } from "@/shared/lib"; import { useEffect, useRef, useState } from "react"; @@ -33,10 +26,10 @@ import { type AttachmentReference, uploadDredgingPhotos, } from "../lib/client"; -import { formatDredgingCapturedDate } from "../lib/date"; import { readPhotoMetadata } from "../lib/exif"; import { useDredgingCommonCodes } from "../lib/useDredgingCommonCodes"; import { createDredgingClientUuid } from "../lib/uuid"; +import { createDredgingRegistrationSession } from "../lib/registration-request"; import { EMPTY_PHOTO_METADATA, type DredgingDraftGroup, @@ -115,6 +108,12 @@ const DredgingRegisterModal = ({ useState(""); const [isSaving, setIsSaving] = useState(false); const uploadedAttachmentsRef = useRef(new Map()); + // 재렌더와 실패 재시도 사이에 요청 ID를 유지하며 모달 재진입에서만 새 세션으로 교체한다. + const registrationSessionRef = useRef | null>(null); + // React의 다음 렌더 전에 발생하는 연속 저장 클릭도 같은 실행으로 제한한다. + const savingRef = useRef(false); const [statusMessage, setStatusMessage] = useState(""); const commitGroups = ( @@ -127,6 +126,8 @@ const DredgingRegisterModal = ({ useEffect(() => { if (!open) return; + registrationSessionRef.current = createDredgingRegistrationSession(); + uploadedAttachmentsRef.current.clear(); const initial = [createGroup()]; groupsRef.current = initial; setGroups(initial); @@ -470,9 +471,12 @@ const DredgingRegisterModal = ({ return saveGroups; }; + // 검증·업로드 후 등록 payload를 확정하며 HTTP 실패 시 세션 ID와 첨부를 유지한다. const handleSave = async () => { + if (savingRef.current) return; try { const saveGroups = validateAndBuildSaveGroups(); + savingRef.current = true; setIsSaving(true); setStatusMessage( isDredgingBackendEnabled @@ -490,8 +494,13 @@ const DredgingRegisterModal = ({ ? "준설 결과와 관로 매핑을 저장하고 있습니다." : "준설 결과 등록을 완료했습니다.", ); - await saveDredgingRegistrations(saveGroups, attachments); - uploadedAttachmentsRef.current.clear(); + const session = + registrationSessionRef.current ?? createDredgingRegistrationSession(); + registrationSessionRef.current = session; + await saveDredgingRegistrations( + session.prepare(saveGroups, attachments, isDredgingBackendEnabled), + ); + // 성공 안내가 닫히기 전 재클릭에도 같은 ID·첨부를 사용한다. 다음 open에서 초기화한다. onSaved(saveGroups); alert("준설 결과를 등록했습니다.", () => onOpenChange(false)); } catch (error) { @@ -502,6 +511,7 @@ const DredgingRegisterModal = ({ setStatusMessage(message); alert(message); } finally { + savingRef.current = false; setIsSaving(false); } }; @@ -557,441 +567,35 @@ const DredgingRegisterModal = ({ }; const modalBody = ( -
-
- 등록 안내 - 관로별 사진은 최대 5장까지 등록할 수 있습니다. - - 모든 등록 건은 하나의 준설작업이며 동일한 준설일자를 사용합니다. - -
- - {activeGroup ? ( -
group.id === activeGroup.id) + 1 - }`} - > - - -
-
-

- 위치 선택 지도 -

-
-
- -
-
-
- ) : null} -
+ ); if (presentation === "popup") { 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 4dcb1a1f0..fea0dcf2d 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,450 +1,4 @@ -import { - deleteData, - getBlobData, - getRawJson, - postRawJson, - putData, -} from "@/components/lib/api"; -import { handleUpload } from "@/components/lib/utils"; -import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; -import type { HttpResponse } from "tus-js-client"; - -import type { - DredgingSaveGroup, - DredgingRecordUpdate, - DredgingPhotoDraft, - DredgingPhoto, - DredgingRecord, - GeoPoint, - PipeMatch, -} from "./types"; -import { createDredgingClientUuid } from "./uuid"; - -type LocationRequest = GeoPoint & { - locationKey: string; -}; - -export type AttachmentReference = { - atflCd: string; - atflId: string; -}; - -type NearestPipeResponseItem = { - locationKey: string; - failureCode?: "PIPE_NOT_FOUND" | string | null; - failureMessage?: string | null; - selected?: { - fid: string; - mngInstCd: string; - fcltMngNo: string; - pipeDiameterMm?: number; - pipeLengthM?: number; - pipeMaterial?: string; - installationYear?: number | null; - swtrmtZoneFid?: string | null; - swtrmtZoneNm?: string | null; - distanceMeters: number; - snappedLongitude: number; - snappedLatitude: number; - } | null; -}; - -const nearestPipeFailureMessage = (item: NearestPipeResponseItem) => { - switch (item.failureCode) { - case "PIPE_NOT_FOUND": - return "50m 이내에 관로가 없습니다. 지도에서 준설위치를 다시 확인해주세요."; - default: - return ( - item.failureMessage?.trim() ?? - "최근접 관로를 자동으로 연결하지 못했습니다." - ); - } -}; - -export const isDredgingBackendEnabled = - process.env.NEXT_PUBLIC_DREDGING_BACKEND_ENABLED !== "false"; - -/** - * 백엔드·프록시의 상태코드, HTML 오류 본문과 파싱 문구를 화면에 전달하지 않는다. - * 상세 응답은 브라우저 Network에서 확인하고 화면에는 업무별 재시도 안내만 노출한다. - */ -const toDredgingApiError = ( - error: unknown, - fallbackMessage: string, - operation: string, -) => { - const status = - typeof error === "object" && error !== null && "status" in error - ? (error as { status?: unknown }).status - : undefined; - console.warn(`[준설관리] ${operation} 실패`, { - status: typeof status === "number" ? status : undefined, - errorType: error instanceof Error ? error.name : typeof error, - }); - return new Error(fallbackMessage); -}; - -type DredgingRecordListResponse = { - items: DredgingRecord[]; - total: number; - page: number; - size: number; -}; - -/** 실제 준설관리 API에서 기간 내 등록 기록을 조회한다. */ -export const fetchDredgingRecords = async (params: { - fromDate: string; - toDate: string; - ctpv?: string; - sgg?: 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), - }); - if (params.ctpv?.trim()) search.set("ctpv", params.ctpv.trim()); - if (params.sgg?.trim()) search.set("sgg", params.sgg.trim()); - try { - const body = await getRawJson(`v1/dredging/records?${search.toString()}`); - return (body?.data ?? body) as DredgingRecordListResponse; - } catch (error) { - throw toDredgingApiError( - error, - "준설현황을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.", - "준설현황 조회", - ); - } -}; - -/** 조회조건에 맞는 준설기록 전체 페이지를 순회해 목록·Excel 건수를 일치시킨다. */ -export const fetchAllDredgingRecords = async (params: { - fromDate: string; - toDate: string; - ctpv?: string; - sgg?: string; -}) => - fetchAllPages({ - fetchPage: (page, size) => fetchDredgingRecords({ ...params, page, size }), - getItems: (response) => response.items ?? [], - getTotal: (response) => Number(response.total ?? 0), - }); - -/** 등록된 준설사진의 공통 첨부파일 참조를 조회한다. */ -export const fetchDredgingPhotos = async ( - dredgingJobSn: string | number, - pipeFid: string, -): Promise => { - if (!isDredgingBackendEnabled) return []; - try { - const body = await getRawJson( - `v1/dredging/records/${encodeURIComponent(dredgingJobSn)}/photos?fid=${encodeURIComponent(pipeFid)}`, - ); - return (body?.data ?? body ?? []) as DredgingPhoto[]; - } catch (error) { - throw toDredgingApiError( - error, - "등록된 준설사진을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.", - "준설사진 목록 조회", - ); - } -}; - -/** 상세 기반 수정 팝업에서 선택 관로의 일자·위치·사진 구성을 함께 수정한다. */ -export const updateDredgingRecord = async ( - dredgingJobSn: string | number, - update: DredgingRecordUpdate, - attachments: Map, -) => { - if (!isDredgingBackendEnabled) return true; - try { - await putData(`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}`, { - dredgingDate: update.dredgingDate, - pipeFid: update.originalPipeFid, - registrationMode: update.registrationMode, - location: { - source: update.locationSource, - longitude: update.location.longitude, - latitude: update.location.latitude, - }, - expectedPipe: { - fid: update.pipe.fid, - mngInstCd: update.pipe.mngInstCd, - fcltMngNo: update.pipe.fcltMngNo, - }, - retainedPhotoSns: update.retainedPhotoSns, - newPhotos: update.newPhotos.map((photo) => { - const attachment = attachments.get(photo.key); - if (!attachment) { - throw new Error(`${photo.file.name}의 첨부 식별자가 없습니다.`); - } - return { - ...attachment, - originalFileName: photo.file.name, - clientCapturedAt: photo.metadata.capturedAt, - clientLongitude: photo.metadata.location?.longitude ?? null, - clientLatitude: photo.metadata.location?.latitude ?? null, - }; - }), - }); - return true; - } catch (error) { - throw toDredgingApiError( - error, - "준설정보를 수정하지 못했습니다. 잠시 후 다시 시도해주세요.", - "준설작업 수정", - ); - } -}; - -/** 목록에서 선택한 준설작업과 관로·사진 참조를 모두 물리 삭제한다. */ -export const deleteDredgingRecord = async (dredgingJobSn: string | number) => { - if (!isDredgingBackendEnabled) return true; - try { - const response = await deleteData( - `v1/dredging/records/${encodeURIComponent(dredgingJobSn)}`, - ); - if (response instanceof Response && !response.ok) { - throw new Error(`준설작업 삭제 응답 오류: ${response.status}`); - } - return true; - } catch (error) { - throw toDredgingApiError( - error, - "준설정보를 삭제하지 못했습니다. 잠시 후 다시 시도해주세요.", - "준설작업 삭제", - ); - } -}; - -/** 공통 첨부서비스에서 준설사진 원본을 Blob으로 내려받는다. */ -export const fetchDredgingPhotoBlob = async (atflId: string) => { - try { - return await getBlobData( - `/attach-service/api/v1/download/${encodeURIComponent(atflId)}`, - ); - } catch (error) { - throw toDredgingApiError( - error, - "사진 원본을 불러오지 못했습니다.", - "준설사진 원본 조회", - ); - } -}; - -const mockPipeForLocations = (locations: LocationRequest[]): PipeMatch => { - const first = locations[0]; - const longitude = first?.longitude ?? 127.5; - const latitude = first?.latitude ?? 36.5; - const seed = Math.abs( - Math.round(longitude * 100_000) + Math.round(latitude * 100_000), - ); - const distanceMeters = Number(((seed % 850) / 100 + 1.2).toFixed(2)); - - return { - fid: `MOCK-${String(seed % 1_000_000).padStart(6, "0")}`, - mngInstCd: "4173000", - fcltMngNo: `PPL-${String(seed % 1_000_000).padStart(6, "0")}`, - pipeDiameterMm: [450, 600, 700, 900][seed % 4] ?? 450, - pipeLengthM: Number((((seed % 8_000) + 2_000) / 100).toFixed(2)), - pipeMaterial: ["콘크리트", "PVC", "PE"][seed % 3] ?? "콘크리트", - installationYear: 1995 + (seed % 25), - swtrmtZoneFid: "MOCK-ZONE", - swtrmtZoneNm: "모의처리구역", - distanceMeters, - snappedLongitude: longitude + 0.00001, - snappedLatitude: latitude - 0.00001, - isMock: true, - }; -}; - -/** - * 위치 목록을 한 번에 보내고 사진별 최근접 관로를 받는다. - * FE 선행 모드에서는 같은 그룹의 상태 전이를 검증할 수 있는 명시적 모의 관로를 반환한다. - */ -export const findNearestPipeMatches = async ( - locations: LocationRequest[], -): Promise> => { - if (locations.length === 0) return new Map(); - - if (!isDredgingBackendEnabled) { - const mockPipe = mockPipeForLocations(locations); - return new Map( - locations.map((location) => [location.locationKey, mockPipe]), - ); - } - - 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) { - failureMessages.push(nearestPipeFailureMessage(item)); - continue; - } - matches.set(item.locationKey, { - fid: item.selected.fid, - mngInstCd: item.selected.mngInstCd, - fcltMngNo: item.selected.fcltMngNo, - pipeDiameterMm: item.selected.pipeDiameterMm ?? 0, - pipeLengthM: item.selected.pipeLengthM ?? 0, - pipeMaterial: item.selected.pipeMaterial ?? "", - installationYear: item.selected.installationYear ?? null, - swtrmtZoneFid: item.selected.swtrmtZoneFid ?? null, - swtrmtZoneNm: item.selected.swtrmtZoneNm ?? "", - distanceMeters: item.selected.distanceMeters, - snappedLongitude: item.selected.snappedLongitude, - snappedLatitude: item.selected.snappedLatitude, - isMock: false, - }); - } - - if (failureMessages.length > 0) { - throw new Error(Array.from(new Set(failureMessages)).join(" ")); - } - - return matches; -}; - -const uploadOnePhoto = (file: File): Promise => - new Promise((resolve, reject) => { - const transfer = new DataTransfer(); - transfer.items.add(file); - - const timeoutId = window.setTimeout(() => { - reject(new Error(`${file.name} 첨부 업로드 응답 시간이 초과되었습니다.`)); - }, 90_000); - - const handleResponse = (response: HttpResponse) => { - const fileInfo = response.getHeader("X-File-Info"); - if (!fileInfo) return; - - try { - const parsed = JSON.parse(fileInfo) as Partial; - if (!parsed.atflCd || !parsed.atflId) { - throw new Error("첨부 식별자가 응답에 없습니다."); - } - window.clearTimeout(timeoutId); - resolve({ atflCd: parsed.atflCd, atflId: parsed.atflId }); - } catch (error) { - window.clearTimeout(timeoutId); - reject(error); - } - }; - - try { - handleUpload(transfer.files, { handleResponse }); - } catch (error) { - window.clearTimeout(timeoutId); - reject(error); - } - }); - -/** - * 공통 attach-service TUS 업로드를 순차 실행한다. - * 등록 API가 아직 없는 FE 선행 모드에서는 고아 첨부 생성을 막기 위해 전송하지 않는다. - */ -export const uploadDredgingPhotos = async ( - groups: DredgingSaveGroup[], - existingReferences: Map = new Map(), -) => { - return uploadDredgingPhotoDrafts( - groups.flatMap((group) => group.photos), - existingReferences, - ); -}; - -/** 등록·수정 화면이 공통으로 사용하는 신규 준설사진 첨부 업로드이다. */ -export const uploadDredgingPhotoDrafts = async ( - photos: DredgingPhotoDraft[], - existingReferences: Map = new Map(), -) => { - const references = new Map(existingReferences); - if (!isDredgingBackendEnabled) return references; - - try { - for (const photo of photos) { - // DB 저장만 실패한 재시도에서는 이미 성공한 공통 첨부 업로드를 재사용한다. - if (references.has(photo.key)) continue; - references.set(photo.key, await uploadOnePhoto(photo.file)); - } - } catch (error) { - throw toDredgingApiError( - error, - "준설사진을 업로드하지 못했습니다. 잠시 후 다시 시도해주세요.", - "준설사진 업로드", - ); - } - return references; -}; - -export const saveDredgingRegistrations = async ( - groups: DredgingSaveGroup[], - attachments: Map, -) => { - if (!isDredgingBackendEnabled) { - return { - mock: true, - registeredCount: 1, - clientRequestId: createDredgingClientUuid(), - }; - } - - const clientRequestId = createDredgingClientUuid(); - try { - return await postRawJson("v1/dredging/registrations", { - clientRequestId, - dredgingDate: groups[0]?.dredgingDate, - groups: groups.map((group, groupIndex) => ({ - groupSn: groupIndex + 1, - registrationMode: group.registrationMode, - location: { - source: group.locationSource, - longitude: group.location.longitude, - latitude: group.location.latitude, - }, - expectedPipe: { - fid: group.pipe.fid, - mngInstCd: group.pipe.mngInstCd, - fcltMngNo: group.pipe.fcltMngNo, - }, - photos: group.photos.map((photo) => { - const attachment = attachments.get(photo.key); - if (!attachment) { - throw new Error(`${photo.file.name}의 첨부 식별자가 없습니다.`); - } - return { - ...attachment, - originalFileName: photo.file.name, - clientCapturedAt: photo.metadata.capturedAt, - clientLongitude: photo.metadata.location?.longitude ?? null, - clientLatitude: photo.metadata.location?.latitude ?? null, - }; - }), - })), - }); - } catch (error) { - throw toDredgingApiError( - error, - "준설결과를 저장하지 못했습니다. 잠시 후 다시 시도해주세요.", - "준설결과 저장", - ); - } -}; +/** 기존 준설 화면의 API import를 유지한다. 구현과 첨부 업로드 책임은 feature로 분리했다. */ +export * from "@/features/dredging/api/dredging-api"; +export * from "@/features/dredging/api/photo-upload"; +export { isDredgingBackendEnabled } from "@/features/dredging/api/backend-mode"; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/lib/date.ts b/apps/sn/app/(with-base-layout)/oper/dredging/lib/date.ts index 011944168..0bec2f65d 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/lib/date.ts +++ b/apps/sn/app/(with-base-layout)/oper/dredging/lib/date.ts @@ -1,21 +1,2 @@ -/** 촬영일시 원본은 유지하고 등록·수정 화면 표출값만 YYYY-MM-DD로 정규화한다. */ -export const formatDredgingCapturedDate = ( - value: string | null | undefined, -) => { - const normalized = String(value ?? "").trim(); - const matched = normalized.match(/^(\d{4})-(\d{2})-(\d{2})/); - if (!matched) return null; - - const year = Number(matched[1]); - const month = Number(matched[2]); - const day = Number(matched[3]); - const date = new Date(year, month - 1, day); - if ( - date.getFullYear() !== year || - date.getMonth() !== month - 1 || - date.getDate() !== day - ) { - return null; - } - return matched[0]; -}; +/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */ +export * from "@/features/dredging/lib/date"; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/lib/exif.ts b/apps/sn/app/(with-base-layout)/oper/dredging/lib/exif.ts index 1b8a18476..35e50ce25 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/lib/exif.ts +++ b/apps/sn/app/(with-base-layout)/oper/dredging/lib/exif.ts @@ -1,334 +1,2 @@ -import type { ExifStatus, GeoPoint, PhotoMetadata } from "./types"; - -type ByteOrder = "LE" | "BE"; - -type IfdEntry = { - type: number; - count: number; - valueOffset: number; - entryOffset: number; -}; - -const JPEG_START = 0xffd8; -const APP1_MARKER = 0xe1; -const EXIF_IFD_POINTER = 0x8769; -const GPS_IFD_POINTER = 0x8825; -const DATE_TIME = 0x0132; -const DATE_TIME_ORIGINAL = 0x9003; -const DATE_TIME_DIGITIZED = 0x9004; - -const typeByteLength = (type: number) => { - if (type === 1 || type === 2 || type === 7) return 1; - if (type === 3) return 2; - if (type === 4 || type === 9) return 4; - if (type === 5 || type === 10) return 8; - return 0; -}; - -/** - * JPEG EXIF TIFF 블록을 읽는 범위 제한 reader이다. - * 손상된 offset이 파일 범위를 벗어나면 예외를 발생시켜 수동 입력 흐름으로 전환한다. - */ -class TiffReader { - private readonly view: DataView; - private readonly tiffStart: number; - private readonly order: ByteOrder; - - constructor(buffer: ArrayBuffer, tiffStart: number) { - this.view = new DataView(buffer); - this.tiffStart = tiffStart; - this.ensureRange(tiffStart, 8); - - const byteOrder = this.view.getUint16(tiffStart, false); - if (byteOrder === 0x4949) { - this.order = "LE"; - } else if (byteOrder === 0x4d4d) { - this.order = "BE"; - } else { - throw new Error("지원하지 않는 TIFF 바이트 순서입니다."); - } - - if (this.uint16(2) !== 0x002a) { - throw new Error("유효한 TIFF 헤더가 아닙니다."); - } - } - - private get littleEndian() { - return this.order === "LE"; - } - - private absolute(relativeOffset: number) { - return this.tiffStart + relativeOffset; - } - - private ensureRange(absoluteOffset: number, byteLength: number) { - if ( - absoluteOffset < 0 || - byteLength < 0 || - absoluteOffset + byteLength > this.view.byteLength - ) { - throw new Error("EXIF offset이 파일 범위를 벗어났습니다."); - } - } - - uint16(relativeOffset: number) { - const absoluteOffset = this.absolute(relativeOffset); - this.ensureRange(absoluteOffset, 2); - return this.view.getUint16(absoluteOffset, this.littleEndian); - } - - uint32(relativeOffset: number) { - const absoluteOffset = this.absolute(relativeOffset); - this.ensureRange(absoluteOffset, 4); - return this.view.getUint32(absoluteOffset, this.littleEndian); - } - - private readIfd(relativeOffset: number) { - const entryCount = this.uint16(relativeOffset); - const entries = new Map(); - - for (let index = 0; index < entryCount; index += 1) { - const entryOffset = relativeOffset + 2 + index * 12; - const tag = this.uint16(entryOffset); - const type = this.uint16(entryOffset + 2); - const count = this.uint32(entryOffset + 4); - const valueOffset = this.uint32(entryOffset + 8); - entries.set(tag, { type, count, valueOffset, entryOffset }); - } - - return entries; - } - - private dataRelativeOffset(entry: IfdEntry) { - const byteLength = typeByteLength(entry.type) * entry.count; - if (byteLength <= 4) return entry.entryOffset + 8; - return entry.valueOffset; - } - - private ascii(entry: IfdEntry) { - const relativeOffset = this.dataRelativeOffset(entry); - const absoluteOffset = this.absolute(relativeOffset); - const byteLength = typeByteLength(entry.type) * entry.count; - this.ensureRange(absoluteOffset, byteLength); - - const bytes = new Uint8Array(this.view.buffer, absoluteOffset, byteLength); - return new TextDecoder("ascii").decode(bytes).replace(/\0+$/g, "").trim(); - } - - private rational(relativeOffset: number) { - const numerator = this.uint32(relativeOffset); - const denominator = this.uint32(relativeOffset + 4); - if (denominator === 0) return null; - return numerator / denominator; - } - - private rationals(entry: IfdEntry) { - const relativeOffset = this.dataRelativeOffset(entry); - const values: number[] = []; - for (let index = 0; index < entry.count; index += 1) { - const value = this.rational(relativeOffset + index * 8); - if (value === null) return null; - values.push(value); - } - return values; - } - - private pointer(entry: IfdEntry | undefined) { - if (!entry || entry.type !== 4 || entry.count !== 1) return null; - return entry.valueOffset; - } - - private parseDate(value: string | null) { - if (!value) return null; - const match = value.match( - /^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/, - ); - if (!match) return null; - - const [, year, month, day, hour, minute, second] = match; - if (!year || !month || !day || !hour || !minute || !second) return null; - - const monthNumber = Number(month); - const dayNumber = Number(day); - const hourNumber = Number(hour); - const minuteNumber = Number(minute); - const secondNumber = Number(second); - if ( - monthNumber < 1 || - monthNumber > 12 || - dayNumber < 1 || - dayNumber > 31 || - hourNumber > 23 || - minuteNumber > 59 || - secondNumber > 59 - ) { - return null; - } - - return { - capturedAt: `${year}-${month}-${day}T${hour}:${minute}:${second}`, - dredgingDate: `${year}-${month}-${day}`, - }; - } - - private parseGps(entries: Map): GeoPoint | null { - const latitudeRefEntry = entries.get(0x0001); - const latitudeEntry = entries.get(0x0002); - const longitudeRefEntry = entries.get(0x0003); - const longitudeEntry = entries.get(0x0004); - if ( - !latitudeRefEntry || - !latitudeEntry || - !longitudeRefEntry || - !longitudeEntry - ) { - return null; - } - - const latitudeRef = this.ascii(latitudeRefEntry).toUpperCase(); - const longitudeRef = this.ascii(longitudeRefEntry).toUpperCase(); - const latitudeDms = this.rationals(latitudeEntry); - const longitudeDms = this.rationals(longitudeEntry); - if ( - !latitudeDms || - latitudeDms.length < 3 || - !longitudeDms || - longitudeDms.length < 3 - ) { - return null; - } - - const toDecimal = (values: number[]) => - (values[0] ?? 0) + (values[1] ?? 0) / 60 + (values[2] ?? 0) / 3600; - - let latitude = toDecimal(latitudeDms); - let longitude = toDecimal(longitudeDms); - if (latitudeRef === "S") latitude *= -1; - if (longitudeRef === "W") longitude *= -1; - - if ( - !Number.isFinite(latitude) || - !Number.isFinite(longitude) || - latitude < -90 || - latitude > 90 || - longitude < -180 || - longitude > 180 - ) { - return null; - } - - return { latitude, longitude }; - } - - parse() { - const ifd0Offset = this.uint32(4); - const ifd0 = this.readIfd(ifd0Offset); - - const exifIfdOffset = this.pointer(ifd0.get(EXIF_IFD_POINTER)); - const exifIfd = exifIfdOffset === null ? null : this.readIfd(exifIfdOffset); - const gpsIfdOffset = this.pointer(ifd0.get(GPS_IFD_POINTER)); - const gpsIfd = gpsIfdOffset === null ? null : this.readIfd(gpsIfdOffset); - - const dateValue = - (exifIfd?.get(DATE_TIME_ORIGINAL) - ? this.ascii(exifIfd.get(DATE_TIME_ORIGINAL)!) - : null) ?? - (exifIfd?.get(DATE_TIME_DIGITIZED) - ? this.ascii(exifIfd.get(DATE_TIME_DIGITIZED)!) - : null) ?? - (ifd0.get(DATE_TIME) ? this.ascii(ifd0.get(DATE_TIME)!) : null); - - return { - date: this.parseDate(dateValue), - location: gpsIfd ? this.parseGps(gpsIfd) : null, - }; - } -} - -const toMetadata = ( - capturedAt: string | null, - dredgingDate: string | null, - location: GeoPoint | null, - unsupported = false, -): PhotoMetadata => { - let status: ExifStatus; - let message: string; - - if (unsupported) { - status = "UNSUPPORTED"; - message = "JPEG EXIF가 없어 준설일자와 위치를 직접 입력해야 합니다."; - } else if (dredgingDate && location) { - status = "COMPLETE"; - message = "촬영일자와 GPS를 확인했습니다."; - } else if (!dredgingDate && !location) { - status = "MISSING_BOTH"; - message = "촬영일자와 GPS가 없어 수동 입력이 필요합니다."; - } else if (!dredgingDate) { - status = "MISSING_DATE"; - message = "촬영일자가 없어 준설일자를 직접 입력해야 합니다."; - } else { - status = "MISSING_GPS"; - message = "GPS가 없어 지도에서 준설위치를 등록해야 합니다."; - } - - return { capturedAt, dredgingDate, location, status, message }; -}; - -/** - * 브라우저에서 즉시 보여 줄 JPEG EXIF 미리보기를 만든다. - * 서버는 저장 시 원본 첨부파일을 기준으로 동일 정보를 다시 검증해야 한다. - */ -export const readPhotoMetadata = async (file: File): Promise => { - if (file.type !== "image/jpeg" && !/\.jpe?g$/i.test(file.name)) { - return toMetadata(null, null, null, true); - } - - try { - const buffer = await file.arrayBuffer(); - const view = new DataView(buffer); - if (view.byteLength < 4 || view.getUint16(0, false) !== JPEG_START) { - return toMetadata(null, null, null, true); - } - - let offset = 2; - while (offset + 4 <= view.byteLength) { - if (view.getUint8(offset) !== 0xff) break; - - const marker = view.getUint8(offset + 1); - if (marker === 0xda || marker === 0xd9) break; - - const segmentLength = view.getUint16(offset + 2, false); - if (segmentLength < 2 || offset + 2 + segmentLength > view.byteLength) { - break; - } - - if (marker === APP1_MARKER) { - const exifStart = offset + 4; - const exifSignature = - exifStart + 6 <= view.byteLength - ? String.fromCharCode( - view.getUint8(exifStart), - view.getUint8(exifStart + 1), - view.getUint8(exifStart + 2), - view.getUint8(exifStart + 3), - ) - : ""; - - if (exifSignature === "Exif") { - const parsed = new TiffReader(buffer, exifStart + 6).parse(); - return toMetadata( - parsed.date?.capturedAt ?? null, - parsed.date?.dredgingDate ?? null, - parsed.location, - ); - } - } - - offset += 2 + segmentLength; - } - } catch (error) { - console.warn("준설 사진 EXIF 분석 실패", error); - } - - return toMetadata(null, null, null, false); -}; +/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */ +export * from "@/features/dredging/lib/exif"; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/lib/registration-request.ts b/apps/sn/app/(with-base-layout)/oper/dredging/lib/registration-request.ts new file mode 100644 index 000000000..0661e4c84 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/dredging/lib/registration-request.ts @@ -0,0 +1,2 @@ +/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */ +export * from "@/features/dredging/model/registration-request"; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/lib/types.ts b/apps/sn/app/(with-base-layout)/oper/dredging/lib/types.ts index 8fbe9d815..792cbaf93 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/lib/types.ts +++ b/apps/sn/app/(with-base-layout)/oper/dredging/lib/types.ts @@ -1,122 +1,2 @@ -export type DredgingViewType = "MUNICIPALITY" | "DIAMETER" | "ELAPSED_YEAR"; - -export type DredgingRegistrationMode = "PHOTO" | "MANUAL"; - -export type DredgingLocationSource = "EXIF" | "MANUAL"; - -export type ExifStatus = - | "COMPLETE" - | "MISSING_DATE" - | "MISSING_GPS" - | "MISSING_BOTH" - | "UNSUPPORTED"; - -export type GeoPoint = { - longitude: number; - latitude: number; -}; - -export type PhotoMetadata = { - capturedAt: string | null; - dredgingDate: string | null; - location: GeoPoint | null; - status: ExifStatus; - message: string; -}; - -export type DredgingPhotoDraft = { - key: string; - file: File; - metadata: PhotoMetadata; -}; - -export type PipeMatch = { - fid: string; - mngInstCd: string; - fcltMngNo: string; - pipeDiameterMm: number; - pipeLengthM: number; - pipeMaterial: string; - installationYear: number | null; - swtrmtZoneFid: string | null; - swtrmtZoneNm: string; - distanceMeters: number; - snappedLongitude: number; - snappedLatitude: number; - isMock: boolean; -}; - -export type DredgingDraftGroup = { - id: string; - registrationMode: DredgingRegistrationMode; - photos: DredgingPhotoDraft[]; - manualLocation: GeoPoint | null; - matchedPipe: PipeMatch | null; - matchError: string | null; - isMatching: boolean; -}; - -export type DredgingRecord = { - id: string; - dredgingJobSn: number | string; - pipeFid: string; - mngInstCd: string; - ctpv: string; - sgg: string; - treatmentZone: string; - fcltMngNo: string; - pipeDiameterMm: number; - pipeLengthM: number; - pipeMaterial: string; - installationYear: number | null; - dredgingDate: string; - photoCount: number; - registeredDate: string; - longitude: number; - latitude: number; - snappedLongitude: number; - snappedLatitude: number; - registrationMode: DredgingRegistrationMode; - registrationSource: DredgingLocationSource; - matchDistanceMeters: number; - isMock: boolean; -}; - -export type DredgingPhoto = { - photoSn: number; - pipeFid: string; - atflCd: string; - atflId: string; - originalFileName: string; - capturedAt: string | null; -}; - -export type DredgingSaveGroup = { - localId: string; - registrationMode: DredgingRegistrationMode; - dredgingDate: string; - location: GeoPoint; - locationSource: DredgingLocationSource; - pipe: PipeMatch; - photos: DredgingPhotoDraft[]; -}; - -/** 상세 기반 수정 팝업에서 선택한 한 관로의 위치·사진을 함께 갱신한다. */ -export type DredgingRecordUpdate = { - dredgingDate: string; - originalPipeFid: string; - registrationMode: DredgingRegistrationMode; - location: GeoPoint; - locationSource: DredgingLocationSource; - pipe: PipeMatch; - retainedPhotoSns: number[]; - newPhotos: DredgingPhotoDraft[]; -}; - -export const EMPTY_PHOTO_METADATA: PhotoMetadata = { - capturedAt: null, - dredgingDate: null, - location: null, - status: "UNSUPPORTED", - message: "메타정보를 확인하고 있습니다.", -}; +/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */ +export * from "@/features/dredging/model/types"; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/lib/useDredgingCommonCodes.ts b/apps/sn/app/(with-base-layout)/oper/dredging/lib/useDredgingCommonCodes.ts index bf64d139a..4f09e5521 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/lib/useDredgingCommonCodes.ts +++ b/apps/sn/app/(with-base-layout)/oper/dredging/lib/useDredgingCommonCodes.ts @@ -1,135 +1,2 @@ -"use client"; - -import { useEffect, useState } from "react"; - -import { getCommonCodes } from "@/components/lib/common/apiHelpers"; - -import type { - DredgingLocationSource, - DredgingRegistrationMode, - ExifStatus, -} from "./types"; - -type CodeLabelMap = Record; - -const COMMON_CODE_GROUPS = { - registrationMode: "DRDGNREGMODE", - locationSource: "DRDGNLOCSRC", - photoStatus: "DRDGNPHOTOSTTS", -} as const; - -const DEFAULT_REGISTRATION_MODE_LABELS: Record< - DredgingRegistrationMode, - string -> = { - PHOTO: "사진 기반 등록", - MANUAL: "사진 없이 직접 등록", -}; - -const DEFAULT_LOCATION_SOURCE_LABELS: Record = { - EXIF: "사진 GPS", - MANUAL: "지도 지정", -}; - -const DEFAULT_PHOTO_STATUS_LABELS: Record = { - COMPLETE: "정상", - MISSING_DATE: "촬영일 없음", - MISSING_GPS: "GPS 없음", - MISSING_BOTH: "촬영일·GPS 없음", - UNSUPPORTED: "수동 입력 필요", -}; - -const cachedLabelMaps = new Map(); -const pendingLabelMaps = new Map>(); - -/** 같은 브라우저 세션에서는 준설 코드그룹별 CMS 요청을 한 번만 수행한다. */ -const loadCodeLabelMap = async ( - upCd: string, - fallback: CodeLabelMap, -): Promise => { - const cached = cachedLabelMaps.get(upCd); - if (cached) return cached; - - const pending = pendingLabelMaps.get(upCd); - if (pending) return pending; - - const request = getCommonCodes(upCd) - .then((codes) => { - const labels = { ...fallback }; - codes.forEach((code) => { - if (code.lwpoCd && code.lwpoCdNm) { - labels[code.lwpoCd] = code.lwpoCdNm; - } - }); - cachedLabelMaps.set(upCd, labels); - return labels; - }) - .finally(() => { - pendingLabelMaps.delete(upCd); - }); - - pendingLabelMaps.set(upCd, request); - return request; -}; - -/** 준설 저장 코드값은 유지하고 버튼·상세·사진상태 표시명만 CMS 기준으로 제공한다. */ -export const useDredgingCommonCodes = () => { - const [registrationModeLabels, setRegistrationModeLabels] = - useState( - cachedLabelMaps.get(COMMON_CODE_GROUPS.registrationMode) ?? - DEFAULT_REGISTRATION_MODE_LABELS, - ); - const [locationSourceLabels, setLocationSourceLabels] = - useState( - cachedLabelMaps.get(COMMON_CODE_GROUPS.locationSource) ?? - DEFAULT_LOCATION_SOURCE_LABELS, - ); - const [photoStatusLabels, setPhotoStatusLabels] = useState( - cachedLabelMaps.get(COMMON_CODE_GROUPS.photoStatus) ?? - DEFAULT_PHOTO_STATUS_LABELS, - ); - - useEffect(() => { - let cancelled = false; - - const applyLabels = ( - upCd: string, - fallback: CodeLabelMap, - setter: (labels: CodeLabelMap) => void, - ) => { - loadCodeLabelMap(upCd, fallback) - .then((labels) => { - if (!cancelled) setter(labels); - }) - .catch((error) => { - console.error(`${upCd} 공통코드 조회에 실패했습니다.`, error); - }); - }; - - applyLabels( - COMMON_CODE_GROUPS.registrationMode, - DEFAULT_REGISTRATION_MODE_LABELS, - setRegistrationModeLabels, - ); - applyLabels( - COMMON_CODE_GROUPS.locationSource, - DEFAULT_LOCATION_SOURCE_LABELS, - setLocationSourceLabels, - ); - applyLabels( - COMMON_CODE_GROUPS.photoStatus, - DEFAULT_PHOTO_STATUS_LABELS, - setPhotoStatusLabels, - ); - - return () => { - cancelled = true; - }; - }, []); - - return { - registrationModeLabels, - locationSourceLabels, - photoStatusLabels, - }; -}; +/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */ +export * from "@/features/dredging/lib/use-dredging-common-codes"; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/lib/uuid.ts b/apps/sn/app/(with-base-layout)/oper/dredging/lib/uuid.ts index eb47a4613..0c5bf8e87 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/lib/uuid.ts +++ b/apps/sn/app/(with-base-layout)/oper/dredging/lib/uuid.ts @@ -1,31 +1,2 @@ -/** - * 일반 HTTP에서는 `crypto.randomUUID`가 제공되지 않을 수 있어 Web Crypto 난수로 UUID v4를 생성한다. - * 백엔드 `clientRequestId`가 UUID 타입이므로 fallback도 같은 36자 형식을 유지한다. - */ -export const createDredgingClientUuid = () => { - if ( - typeof crypto !== "undefined" && - typeof crypto.randomUUID === "function" - ) { - return crypto.randomUUID(); - } - - const bytes = new Uint8Array(16); - if ( - typeof crypto !== "undefined" && - typeof crypto.getRandomValues === "function" - ) { - crypto.getRandomValues(bytes); - } else { - for (let index = 0; index < bytes.length; index += 1) { - bytes[index] = Math.floor(Math.random() * 256); - } - } - - bytes[6] = (bytes[6]! & 0x0f) | 0x40; - bytes[8] = (bytes[8]! & 0x3f) | 0x80; - const hex = Array.from(bytes, (value) => - value.toString(16).padStart(2, "0"), - ).join(""); - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; -}; +/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */ +export * from "@/features/dredging/lib/uuid"; diff --git a/apps/sn/app/(with-base-layout)/oper/floodPre/headMakeFloodPre.tsx b/apps/sn/app/(with-base-layout)/oper/floodPre/headMakeFloodPre.tsx deleted file mode 100644 index 889450b1e..000000000 --- a/apps/sn/app/(with-base-layout)/oper/floodPre/headMakeFloodPre.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/** - * 📄 HeadMakeFloodPre Component - * 침수예방 테이블 헤더 생성 컴포넌트 - * - * - 테이블 상단 헤더 생성 및 렌더링 - * - 하위 헤더 목록 기반 동적 헤더 구성 - * - rowspan 및 colspan을 통한 테이블 구조 조정 - * - 가시성 필터링 및 조건부 렌더링 지원 - * - 상위 헤더와 하위 헤더의 계층적 표시 - */ -import { TableHead, TableRow } from "@common/components/ui"; - -export type TableUpperProps = { - id: string; - title: string; - upName?: string; - upSequnce?: number; - edit?: boolean; - visible?: boolean; - rowSpan?: number; - colSpan?: number; - type?: string; - selectData?: string; // type 이 seelct 일경우 selectarray 정하기 위해 - row?: number; - mergeCell?: boolean; // 셀을 합칠경우 - maxLength?: number; - readonly?: boolean; -}; -type chHeadType = { - headNm: string; - text: string; - length: number; - rows?: number; -}; - -export const HeadMakeFloodPre = ( - upHeadList: TableUpperProps[], - makeHeadNm: chHeadType[], -) => { - return ( - <> - - {upHeadList - .filter((data) => data.visible != false) - .map((head: TableUpperProps) => - !head.upSequnce ? ( - - {head.title} - - ) : head.upSequnce === 1 ? ( - ch.headNm === head.upName)?.length - } - > - {makeHeadNm?.find((ch) => ch.headNm === head.upName)?.text} - - ) : null, - )} - - - - {upHeadList - .filter((data) => data.visible != false) - .map((head: TableUpperProps) => - makeHeadNm?.find((ch) => ch.headNm === head.upName) ? ( - - {head.title} - - ) : null, - )} - - - ); -}; diff --git a/apps/sn/app/(with-base-layout)/oper/floodPre/inputTableBody.tsx b/apps/sn/app/(with-base-layout)/oper/floodPre/inputTableBody.tsx deleted file mode 100644 index 99167c20c..000000000 --- a/apps/sn/app/(with-base-layout)/oper/floodPre/inputTableBody.tsx +++ /dev/null @@ -1,213 +0,0 @@ -/** - * 📄 InputTableBody Component - * 침수예방 입력 테이블 바디 컴포넌트 - * - * - 숫자 입력 및 콤마 포맷팅 기능 - * - mergeCell을 통한 셀 병합 처리 - * - 편집 가능/불가능 상태 관리 - * - 소계/총합계 행 스타일링 및 표시 - * - 입력 값 검증 및 변경 핸들링 - * - 빈 데이터 상태 처리 - */ -import type { TableUpperProps } from "./headMakeFloodPre"; -import { Input, TableCell, TableRow } from "@common/components/ui"; - -// 숫자입력시 , 삽입 -const formatCommaNumber = ( - value: number | string | undefined | null, -): string => { - if (value === undefined || value === null || value === "") return ""; - const num = Number(value); - // 숫자로 변환할 수 없는 값이 들어올 경우 빈 문자열을 반환하여 예상치 못한 "NaN" 출력을 방지합니다. - if (isNaN(num)) { - return ""; - } - return num.toLocaleString(); -}; - -// onchange 텍스트를 숫자로 and ,제거 -const handleCommaNumberChange = ( - e: React.ChangeEvent, - onChange: (value: number | "") => void, -) => { - const raw = e.target.value.replace(/,/g, ""); - if (!/^\d*$/.test(raw)) return; - onChange(raw === "" ? "" : Number(raw)); -}; - -const handleChange = ( - rowIndex: number, - field: any, - value: string, - setInputData: (data: any) => void, -) => { - if (Number(value) < 0 || !Number(value)) { - value = "0"; - } - setInputData((prevData: any) => - prevData?.map((row: any, index: number) => { - if (index === rowIndex) { - return { - ...row, - [field]: Number(value.replace(/,/g, "")), - }; - } - return row; - }), - ); -}; - -export const InputTableBody = ( - chgRes: any[] | [], - headList: TableUpperProps[], - setInputData: (data: any[]) => void, - isEditable: boolean = true, -) => { - // mergeCell 처리용 - const getRowSpan = (data: any[], key: string, rowIndex: number) => { - if ( - rowIndex > 0 && - data[rowIndex - 1][key] && - String(data[rowIndex][key]).trim() === "" - ) { - return 0; // 이전과 같으면 셀을 그리지 않음 - } - let span = 1; - for (let i = rowIndex + 1; i < data.length; i++) { - if (data[i][key] === data[rowIndex][key]) span++; - else break; - } - return span; - }; - return ( - <> - {chgRes.map((data, dataIndex) => { - // 소계/총합계 행 판별 - const isSubTotal = data["용수명"] === "소계"; - const isTotal = data["구분"] === 0; - - const bgClass = isTotal - ? "bg-secondary font-bold" - : isSubTotal - ? "bg-muted font-semibold" - : ""; - - return ( - - {headList.map((head, headIndex) => { - const isTextColumn = head.id === "__EMPTY_3"; - const textAlignClass = isTextColumn - ? "text-center" - : "text-right"; - - // // 총합계 행이면 "구분"에서 colSpan=2로 병합 - // if ( - // (data["구분"] === 0 || data["용수명"] === 0) && - // head.id === "구분" - // ) { - // return ( - // - // 총합계 - // - // ); - // } - // // 총합계(합계) 행이면 "용수명" 컬럼은 렌더링하지 않음 - // if ( - // (data["구분"] === 0 || data["용수명"] === 0) && - // head.id === "용수명" - // ) { - // return null; - // } - - // // 총합계 행의 나머지 셀에도 색상 적용 - // if (data["구분"] === 0 || data["용수명"] === 0) { - // return ( - // - // {Number(data[head.id]) - // ? formatCommaNumber(data[head.id as keyof any]) - // : (data[head.id as keyof any] ?? "")} - // - // ); - // } - // mergeCell 처리 - if (head.mergeCell === true) { - const rowSpan = getRowSpan(chgRes, head.id, dataIndex); - if (rowSpan === 0) return null; - return ( - - {Number(data[head.id]) - ? formatCommaNumber(data[head.id as keyof any]) - : (data[head.id as keyof any] ?? "")} - - ); - } - // 기본 셀 - // return head.edit === true && data.edit !== false ? ( - return isEditable && head.edit === true && data.edit !== false ? ( - - - handleCommaNumberChange(e, (val) => - handleChange( - dataIndex, - head.id, - String(val), - setInputData, - ), - ) - } - value={formatCommaNumber(data[head.id as keyof any]) ?? ""} - maxLength={12} - /> - - ) : ( - -
- {Number(data[head.id]) - ? formatCommaNumber(data[head.id as keyof any]) - : (data[head.id as keyof any] ?? "")} -
-
- ); - })} -
- ); - })} - {chgRes.length === 0 && ( - - - 조회된 결과가 없습니다. - - - )} - - ); -}; diff --git a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx index 273453c0a..5867e70cc 100644 --- a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx @@ -222,21 +222,18 @@ const Page = () => { }; // 선택 파일 삭제 + // 첨부·업무 삭제 중 어느 하나라도 실패하면 성공 안내와 목록 갱신을 실행하지 않는다. const isDeleteFile = async () => { if (sbmrgPrevntBizSn === "" || sbmrgPrevntBizSn === undefined) { alert("삭제할 파일을 선택해주세요"); return false; } if (!(await confirm("삭제하시겠습니까?"))) return false; - const data = await deleteData( - `/attach-service/api/v1/atfl/attachments/${atflId}`, - ); - if (data) { - const delData = await deleteData(`v1/sbmrgPrevnt/${sbmrgPrevntBizSn}`); - if (delData.status === 200) { - alert("삭제되었습니다.", () => void fetchData()); - } - } else { + try { + await deleteData(`/attach-service/api/v1/atfl/attachments/${atflId}`); + await deleteData(`v1/sbmrgPrevnt/${sbmrgPrevntBizSn}`); + alert("삭제되었습니다.", () => void fetchData()); + } catch { alert("삭제에 실패했습니다."); } }; diff --git a/apps/sn/app/(with-base-layout)/oper/index.ts b/apps/sn/app/(with-base-layout)/oper/index.ts deleted file mode 100644 index 4ec8fcdae..000000000 --- a/apps/sn/app/(with-base-layout)/oper/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * 📄 Oper Index Configuration - * 운영 관련 테이블 헤더 설정 파일 - * - * - 침수예방 및 노후 하수관로 테이블 헤더 정의 - * - 일련번호, 파일명, 년도, 분기, 등록 일자, 등록자 필드 포함 - * - 테이블 컴포넌트에서 재사용 가능한 헤더 구조 제공 - * - 데이터 표시 및 정렬을 위한 표준화된 헤더 포맷 - * - 운영 관리 페이지에서 공통으로 사용되는 헤더 리스트 - * - 동적 테이블 렌더링을 위한 ID와 타이틀 매핑 - */ -export const tableHeadlist = [ - { id: 'rowNumber', title: '일련번호', sortable: true, sortKey: 'sbmrgPrevntBizSn' }, - { id: 'sbmrgPrevntBizNm', title: '파일명', sortable: true }, - { id: 'mngYr', title: '년도', sortable: true }, - { id: 'mngQu', title: '분기', sortable: true }, - { id: 'regDt', title: '등록 일자', sortable: true }, - { id: 'rgtrNm', title: '등록자' } - -] -export const tableHeadlistOldPp = [ - { id: 'rowNumber', title: '일련번호', sortable: true, sortKey: 'dcrpPplMtncBizSn' }, - { id: 'dcrpPplMtncBizNm', title: '파일명', sortable: true }, - { id: 'mngYr', title: '년도', sortable: true }, - { id: 'mngQu', title: '분기', sortable: true }, - { id: 'regDt', title: '등록 일자', sortable: true }, - { id: 'rgtrNm', title: '등록자' } -] diff --git a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx index 8a8e3781f..55d9f6667 100644 --- a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx @@ -200,6 +200,7 @@ const Page = () => { handleDownload(`/attach-service/api/v1/download/${attachmentId}`, fileName); }; + // HTTP 실패는 catch에서 안내하고 첨부·업무 삭제가 모두 성공한 뒤 목록을 갱신한다. const handleDelete = async () => { if (!businessId) { alert("삭제할 파일을 선택해주세요"); @@ -207,20 +208,13 @@ const Page = () => { } if (!(await confirm("삭제하시겠습니까?"))) return; - const attachmentResult = await deleteData( - `/attach-service/api/v1/atfl/attachments/${attachmentId}`, - ); - if (!attachmentResult) { - alert("삭제에 실패했습니다."); - return; - } - - const businessResult = await deleteData(`v1/dcrpPpl/${businessId}`); - if (businessResult.status === 200) { + try { + await deleteData(`/attach-service/api/v1/atfl/attachments/${attachmentId}`); + await deleteData(`v1/dcrpPpl/${businessId}`); alert("삭제되었습니다.", () => void fetchData()); - return; + } catch { + alert("삭제에 실패했습니다."); } - alert("삭제에 실패했습니다."); }; const handleRegister = () => { diff --git a/apps/sn/app/(with-base-layout)/oper/operStore/page.tsx b/apps/sn/app/(with-base-layout)/oper/operStore/page.tsx index a48ae7cf4..ab4561a16 100644 --- a/apps/sn/app/(with-base-layout)/oper/operStore/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/operStore/page.tsx @@ -9,6 +9,12 @@ import useOnsemiroAddrStore from "@/store/useOnsemiroAddrStore"; const DEFAULT_OPTION = { text: "전체", val: "0" }; +/** API가 이미 전체 항목을 반환해도 Select의 `0` key가 중복되지 않게 정리한다. */ +const withDefaultOption = (options: Array<{ text: string; val: string }>) => [ + DEFAULT_OPTION, + ...options.filter((option) => String(option.val) !== DEFAULT_OPTION.val), +]; + export default function Page() { const { govArea, city, sgg, fetchGovArea, fetchCity, fetchSgg } = useOnsemiroAddrStore(); @@ -74,7 +80,7 @@ export default function Page() { label="" className="w-full" selectClass="w-full rounded-lg bg-background" - selectArray={[DEFAULT_OPTION, ...govArea]} + selectArray={withDefaultOption(govArea)} value={selectedGovArea} onSelectValue={(value) => setSelectedGovArea(String(value))} /> @@ -85,7 +91,7 @@ export default function Page() { label="" className="w-full" selectClass="w-full rounded-lg bg-background" - selectArray={[DEFAULT_OPTION, ...city]} + selectArray={withDefaultOption(city)} value={selectedCity} onSelectValue={(value) => setSelectedCity(String(value))} /> @@ -96,7 +102,7 @@ export default function Page() { label="" className="w-full" selectClass="w-full rounded-lg bg-background" - selectArray={[DEFAULT_OPTION, ...filteredSgg]} + selectArray={withDefaultOption(filteredSgg)} value={selectedSgg} onSelectValue={(value) => setSelectedSgg(String(value))} /> diff --git a/apps/sn/app/(with-base-layout)/oper/tableSort.ts b/apps/sn/app/(with-base-layout)/oper/tableSort.ts deleted file mode 100644 index e534c3607..000000000 --- a/apps/sn/app/(with-base-layout)/oper/tableSort.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { UiHeadProps, UiTableSort } from "@common/business_components/ui"; - -const TABLE_SORT_COLLATOR = new Intl.Collator("ko-KR", { - numeric: true, - sensitivity: "base", -}); - -const isEmptySortValue = (value: unknown) => - value === null || - value === undefined || - String(value).trim() === "" || - String(value).trim() === "-"; - -const toComparableNumber = (value: unknown) => { - const normalized = String(value ?? "") - .trim() - .replace(/,/g, "") - .replace(/%$/, ""); - - if (!/^-?\d+(?:\.\d+)?$/.test(normalized)) return undefined; - const number = Number(normalized); - return Number.isFinite(number) ? number : undefined; -}; - -/** 업로드된 엑셀의 실제 데이터 컬럼만 공통 UiTable 정렬 대상으로 만든다. */ -export const makeSortableHeaders = (headers: T[]) => - headers.map((header) => ({ ...header, sortable: true })); - -/** 서버 페이징이 없는 엑셀 상세 그리드의 전체 행을 안정 정렬한다. */ -export const sortLocalTableRows = >( - rows: T[], - sort?: UiTableSort, -) => { - if (!sort) return rows; - - return rows - .map((row, originalIndex) => ({ row, originalIndex })) - .sort((leftEntry, rightEntry) => { - const leftValue = leftEntry.row[sort.key]; - const rightValue = rightEntry.row[sort.key]; - const leftEmpty = isEmptySortValue(leftValue); - const rightEmpty = isEmptySortValue(rightValue); - - if (leftEmpty || rightEmpty) { - if (leftEmpty && rightEmpty) return leftEntry.originalIndex - rightEntry.originalIndex; - return leftEmpty ? 1 : -1; - } - - const leftNumber = toComparableNumber(leftValue); - const rightNumber = toComparableNumber(rightValue); - const comparison = - leftNumber !== undefined && rightNumber !== undefined - ? leftNumber - rightNumber - : TABLE_SORT_COLLATOR.compare(String(leftValue), String(rightValue)); - - if (comparison === 0) return leftEntry.originalIndex - rightEntry.originalIndex; - return sort.direction === "asc" ? comparison : -comparison; - }) - .map(({ row }) => row); -}; - -/** children으로 다단 헤더를 전달하는 UiTable에서도 동일한 3상태 정렬을 사용한다. */ -export const getNextTableSort = ( - head: UiHeadProps, - currentSort?: UiTableSort, -): UiTableSort | undefined => { - const key = head.sortKey ?? head.id; - if (currentSort?.key !== key) return { key, direction: "asc" }; - if (currentSort.direction === "asc") return { key, direction: "desc" }; - return undefined; -}; diff --git a/apps/sn/app/api/gis-upload/route.ts b/apps/sn/app/api/gisUpload/route.ts similarity index 100% rename from apps/sn/app/api/gis-upload/route.ts rename to apps/sn/app/api/gisUpload/route.ts diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts index 43584ff6f..0d431c803 100644 --- a/apps/sn/app/components/lib/api/index.ts +++ b/apps/sn/app/components/lib/api/index.ts @@ -15,6 +15,8 @@ import { AxiosError } from 'axios'; import { CLAIM_NAME, SITE_ID } from "@/shared/config"; import { tokenManager } from "@/entities/auth/lib/token-manager"; import { getRemainingMs } from "@common/auth"; +// 기존 shared/api barrel의 인증 client 초기화 부수효과를 불러오지 않는 순수 transport다. +import { createHttpTransport } from "@/shared/api/http-transport"; const BASE_URL = '/sewerage-service/api'; const DEFAULT_HEADERS = { @@ -31,35 +33,6 @@ const withSiteIdHeaders = (headers: HeadersInit = {}) => ({ const resolveApiUrl = (url: string) => url.startsWith('/') ? `${url}` : `${BASE_URL}/${url}`; -const buildInvalidJsonResponseError = (url: string, text: string, contentType?: string | null) => { - const preview = text.trim().slice(0, 200); - const responseType = contentType ? ` (${contentType})` : ""; - return new Error( - `JSON 응답이 아닌 데이터를 받았습니다${responseType}: ${url}${preview ? ` - ${preview}` : ""}`, - ); -}; -const buildRequestError = ( - fallbackMessage: string, - status?: number, - body?: unknown, -) => { - const bodyMessage = - typeof body === "object" && body !== null - ? ( - (body as { message?: unknown }).message - ?? (body as { error?: { message?: unknown } }).error?.message - ) - : undefined; - const message = - typeof bodyMessage === "string" && bodyMessage.trim() - ? bodyMessage - : fallbackMessage; - const error = new Error(message) as Error & { status?: number; body?: unknown }; - error.status = status; - error.body = body; - return error; -}; - const handleUnauthorizedResponse = () => { if (typeof window === "undefined") return; @@ -80,137 +53,14 @@ const handleUnauthorizedResponse = () => { } }; -export const getData = async (url: string): Promise => { - const res = await fetch(resolveApiUrl(url), { - method: "GET", - headers: withSiteIdHeaders(), - }); - console.log(res); - if (res.status === 401) { - handleUnauthorizedResponse(); - } - if (res.ok) { - try { - // 응답이 비어있는지 확인 - const contentLength = res.headers.get('content-length'); - if (contentLength === '0') { - return null; - } - - // 응답 텍스트를 먼저 가져와서 빈 문자열인지 확인 - const text = await res.text(); - if (!text || text.trim() === '') { - return null; - } - - // JSON 파싱 시도 - const data = JSON.parse(text); - return data.data; - } catch (error) { - console.error('JSON parsing error:', error); - // JSON 파싱에 실패한 경우, 원본 텍스트 또는 null 반환 - throw new Error('Invalid JSON response from server'); - } - } - - const text = await res.text().catch(() => ""); - let body: unknown = null; - if (text.trim()) { - try { - body = JSON.parse(text); - } catch { - body = text; - } - } - - throw buildRequestError( - `Failed to fetch data: ${res.status} ${res.statusText}`, - res.status, - body, - ); -}; - -export const postData = async (url: string, data: object): Promise => { - const options: RequestInit = { - method: "POST", - headers: withSiteIdHeaders({ - "Content-Type": "application/json", - }), - body: JSON.stringify(data), - }; - const res = await fetch(`${BASE_URL}/${url}`, options); - if (res.status === 401) { - handleUnauthorizedResponse(); - } - if (res.ok) { - const resData = await res.json(); - console.log('postData response:', resData); - return resData.data; - } -}; - -export const postRawJson = async (url: string, data: object): Promise => { - const res = await fetch(resolveApiUrl(url), { - method: "POST", - headers: withSiteIdHeaders({ - "Content-Type": "application/json", - }), - body: JSON.stringify(data), - }); - if (res.status === 401) { - handleUnauthorizedResponse(); - } - - const text = await res.text(); - const body = text && text.trim() ? JSON.parse(text) : null; - if (!res.ok) { - const message = - body?.message ?? - `Failed to post data: ${res.status} ${res.statusText}`; - const error = new Error(message) as Error & { status?: number; body?: any }; - error.status = res.status; - error.body = body; - throw error; - } - return body; -}; - -/** 공통 응답 래퍼를 사용하는 JSON PUT 요청을 전송하고 data 필드만 반환한다. */ -export const putData = async (url: string, data: object): Promise => { - const res = await fetch(resolveApiUrl(url), { - method: "PUT", - headers: withSiteIdHeaders({ - "Content-Type": "application/json", - }), - body: JSON.stringify(data), - }); - if (res.status === 401) { - handleUnauthorizedResponse(); - } - - const text = await res.text(); - let body: unknown = null; - if (text.trim()) { - try { - body = JSON.parse(text); - } catch { - throw buildInvalidJsonResponseError(url, text, res.headers.get("content-type")); - } - } - - if (!res.ok) { - throw buildRequestError( - `Failed to update data: ${res.status} ${res.statusText}`, - res.status, - body, - ); - } - - if (typeof body === "object" && body !== null && "data" in body) { - return (body as { data?: unknown }).data ?? null; - } - return body; -}; +// 기존 import 경로를 유지하는 호환 진입점이다. HTTP 처리는 shared/api가 소유한다. +const httpTransport = createHttpTransport({ + baseUrl: BASE_URL, + headers: DEFAULT_HEADERS, + onUnauthorized: handleUnauthorizedResponse, +}); +export const { getData, postData, putData, getRawJson, postRawJson, deleteData } = + httpTransport; const parseDownloadFileName = (contentDisposition: string | null) => { if (!contentDisposition) return undefined; @@ -283,62 +133,6 @@ export const postMutiPart = async (url: string, data: FormData): Promise => } }; -export const deleteData = async (url: string, data?: object): Promise => { - const options: RequestInit = { - method: "DELETE", - headers: withSiteIdHeaders({ - "Content-Type": "application/json", - }), - body: data ? JSON.stringify(data) : undefined, - }; - const res = await fetch(resolveApiUrl(url), options); - if (res.status === 401) { - handleUnauthorizedResponse(); - } - if (res.status === 204) return true - return res; -}; - -// GET 응답을 공통 data wrapper 없이 원문 JSON으로 읽는다. init은 지도 bbox 요청처럼 AbortSignal이 필요한 호출에서 사용한다. -export const getRawJson = async (url: string, init: RequestInit = {}): Promise => { - const res = await fetch(resolveApiUrl(url), { - ...init, - method: "GET", - headers: withSiteIdHeaders(init.headers), - }); - if (res.status === 401) { - handleUnauthorizedResponse(); - } - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error( - `Failed to fetch data: ${res.status} ${res.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""}`, - ); - } - - const contentLength = res.headers.get('content-length'); - if (contentLength === '0') { - return null; - } - - const text = await res.text(); - if (!text || text.trim() === '') { - return null; - } - - const trimmed = text.trim(); - if (trimmed.startsWith("<")) { - throw buildInvalidJsonResponseError(url, text, res.headers.get("content-type")); - } - - try { - return JSON.parse(text); - } catch { - throw buildInvalidJsonResponseError(url, text, res.headers.get("content-type")); - } -}; - export const getBlobData = async (url: string): Promise => { const res = await fetch(resolveApiUrl(url), { method: "GET", diff --git a/apps/sn/app/components/ui/contentLayout.tsx b/apps/sn/app/components/ui/contentLayout.tsx deleted file mode 100644 index bd13fc2c3..000000000 --- a/apps/sn/app/components/ui/contentLayout.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/** - * 📄 Content Layout Component - * 콘텐츠 레이아웃 컴포넌트 - * - * - 제목 표시 및 콘텐츠 영역 제공 - * - 로딩 오버레이를 통한 사용자 피드백 - * - 공통 레이아웃 구조 적용 (MainDiv, Title, ContentsDiv) - * - 조건부 로딩 상태 렌더링 - * - 클래스명 기반 스타일링 지원 - * - 재사용 가능한 레이아웃 컴포넌트 - */ -import { ContentsDiv, MainDiv, Title } from '@common/business_components/ui' -import React, { type ReactNode } from 'react' -import LoadingOverlay from './LoadingBar' -type LayoutNode = { - children : ReactNode - title : string - isLoading?: boolean - className?: string -} -const ContentLayout = ({children, title, isLoading, className} : LayoutNode) => { - return ( - - - <ContentsDiv> - <LoadingOverlay isLoading={isLoading ?? false}/> - {children} - </ContentsDiv> - </MainDiv> - ) -} - -export default ContentLayout \ No newline at end of file diff --git a/apps/sn/app/components/ui/formSection.tsx b/apps/sn/app/components/ui/formSection.tsx deleted file mode 100644 index f1b7542a0..000000000 --- a/apps/sn/app/components/ui/formSection.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/** - * 📄 Form Section Component - * 폼 섹션 컴포넌트 - * - * - 폼 필드 렌더링 및 제목 표시 - * - 필수 입력 필드 표시 (별표 표시) - * - FormField를 통한 폼 상태 관리 - * - 그리드 레이아웃을 통한 정렬 및 배치 - * - 재사용 가능한 폼 섹션 구조 제공 - * - react-hook-form 통합 지원 - */ -import { FormField } from '@common/business_components/ui' -import React, { type ReactElement } from 'react' - -interface FormSectionProps { - children: ReactElement - titleNm: string - form: any - name: string - required?: boolean -} - -const FormSection = ({ children, titleNm, form, name, required }: FormSectionProps) => { - return ( - <div className="grid grid-cols-[200px_1fr] items-center"> - <div className="text-xl font-medium">{titleNm}{required && <span className="text-2xl text-red-500">*</span>}</div> - <FormField - control={form.control} - name={name} - render={({ field }) => children} - /> - </div> - ) -} - -export default FormSection \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx deleted file mode 100644 index 88b5ddbe8..000000000 --- a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx +++ /dev/null @@ -1,65 +0,0 @@ -"use client"; - -import { Button } from "@common/components"; -import { VerticalTable } from "@/components/ui/verticalTable"; -import { - AGED_PIPE_DETAIL_FIELDS, - getElapsedYears, - getPipeDisplayValue, -} from "./agedPipeUtils"; - -type Props = { - featureData: any; - onClose: () => void; -}; - -export default function AgedPipeDetailPanel({ featureData, onClose }: Props) { - const source = featureData?.values_ ?? featureData ?? {}; - const facilityNo = String(getPipeDisplayValue(source, "fclt_mng_no") ?? "-"); - const elapsedYears = getElapsedYears(source.instl_ymd ?? source.instlYmd); - const detailFields = [ - ...AGED_PIPE_DETAIL_FIELDS, - { id: "elapsedYears", title: "경과년도" }, - ]; - const tableData = detailFields.reduce<Record<string, string>>((acc, field) => { - acc[field.id] = - field.id === "elapsedYears" - ? elapsedYears === null - ? "확인불가" - : `${elapsedYears}년` - : String(getPipeDisplayValue(source, field.id) ?? "-"); - return acc; - }, {}); - - return ( - <div className="absolute right-0 top-0 z-[50] flex h-[calc(100vh-5rem)] w-[350px] flex-col overflow-hidden border-l border-gray-300 bg-white"> - <div className="card-header relative flex items-center justify-between bg-primary p-4"> - <div> - <div className="text-2xl font-medium text-white text-label"> - 하수관로 정보 <br />관리번호 : {facilityNo} - </div> - </div> - <Button - type="button" - size="xs" - className="text-white hover:text-gray-300" - onClick={onClose} - > - ✕ - </Button> - </div> - - <div className="flex-1 overflow-y-auto overflow-x-hidden"> - <VerticalTable - headName="기본정보" - tableData={[tableData]} - headlist={detailFields} - pageSize={20} - total={0} - cellClick={undefined} - onPageChange={() => undefined} - /> - </div> - </div> - ); -} diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx deleted file mode 100644 index a80ced9c9..000000000 --- a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx +++ /dev/null @@ -1,73 +0,0 @@ -"use client"; - -import { useState } from "react"; -import type { AgedPipeFilters } from "./agedPipeUtils"; - -type OverlayProps = { - onClose?: () => void; - isPlaying?: boolean; - setIsPlaying?: (value: boolean) => void; - totalSteps?: number; - currentStep?: number; -}; - -type Props = OverlayProps & { - title?: string; - filters: AgedPipeFilters; - onFiltersChange: (filters: AgedPipeFilters) => void; - onApply: () => void; -}; - -export default function AgedPipeFilterPanel({ - title = "지반침하정보", - filters, - onFiltersChange, - onApply, -}: Props) { - const [isCollapsed, setIsCollapsed] = useState(false); - - return ( - <div className="overflow-hidden rounded-sm border border-[#bdbdbd] bg-white"> - <div className="relative flex min-h-[34px] items-center justify-center border-b border-[#eeeeee] bg-white px-8"> - <button - type="button" - className="absolute left-2 top-1/2 h-[18px] w-[18px] -translate-y-1/2 bg-transparent text-[1.2rem] leading-none text-[#555]" - onClick={() => setIsCollapsed((value) => !value)} - title={isCollapsed ? "펼치기" : "접기"} - > - {isCollapsed ? "›" : "⌄"} - </button> - <span className="text-[1.3rem] font-semibold text-[#4b5563]">{title}</span> - </div> - - {!isCollapsed && ( - <div className="px-3 py-2"> - <div className="flex min-h-[30px] items-center gap-1 whitespace-nowrap text-[1.2rem] text-[#333]"> - <label htmlFor="aged-pipe-elapsed-years">경과년도(년)</label> - <input - id="aged-pipe-elapsed-years" - className="h-[22px] w-10 border border-[#ccd6df] px-1 text-center text-[1.2rem]" - inputMode="numeric" - value={String(filters.elapsedYears)} - onChange={(event) => { - const next = Number(event.target.value.replace(/[^0-9]/g, "") || "0"); - onFiltersChange({ ...filters, elapsedYears: next }); - }} - onKeyDown={(event) => { - if (event.key === "Enter") onApply(); - }} - /> - <button - type="button" - className="grid h-[22px] w-[22px] place-items-center rounded-full bg-[#31a83b] text-[1.4rem] font-bold leading-none text-white" - onClick={onApply} - title="적용" - > - ✓ - </button> - </div> - </div> - )} - </div> - ); -} diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx deleted file mode 100644 index 45a04331b..000000000 --- a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx +++ /dev/null @@ -1,177 +0,0 @@ -"use client"; - -import { useRef } from "react"; -import { Button, Input } from "@common/components"; -import { UiTable } from "@common/business_components/ui"; -import LoadingOverlay from "@/components/ui/LoadingBar"; -import useStore from "@/store/useStore"; -import { - AGED_PIPE_LIST_HEADLIST, - type AgedPipeFilters, - type AgedPipeRow, -} from "./agedPipeUtils"; - -type Props = { - open: boolean; - onOpenChange: (open: boolean) => void; - filters: AgedPipeFilters; - onFiltersChange: (filters: AgedPipeFilters) => void; - rows: AgedPipeRow[]; - total: number; - currentPage: number; - isLoading: boolean; - onSearch: (page?: number) => void; - onDownload: () => void; - onRowDoubleClick: (row: AgedPipeRow) => void; -}; - -export default function AgedPipeListModal({ - open, - onOpenChange, - filters, - onFiltersChange, - rows, - total, - currentPage, - isLoading, - onSearch, - onDownload, - onRowDoubleClick, -}: Props) { - const { ctpv, sgg } = useStore(); - const lastClickRef = useRef<{ index: number; clickedAt: number } | null>(null); - const sggOptions = filters.ctpvCd === "0" - ? [] - : sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))); - - const handleRowClick = (index?: number) => { - if (typeof index !== "number") return; - const row = rows[index]; - if (!row) return; - - const now = Date.now(); - const lastClick = lastClickRef.current; - if (lastClick && lastClick.index === index && now - lastClick.clickedAt < 300) { - lastClickRef.current = null; - onRowDoubleClick(row); - return; - } - - lastClickRef.current = { index, clickedAt: now }; - }; - - if (!open) return null; - - return ( - <div className="pointer-events-none absolute inset-0 z-[70] flex items-center justify-center px-6 py-8"> - <div className="pointer-events-auto relative flex max-h-[82vh] w-[min(1120px,94vw)] flex-col overflow-hidden border border-[#dbe2e6] bg-white shadow-[0_0_12px_rgba(0,0,0,0.25)]"> - <LoadingOverlay isLoading={isLoading} /> - <div className="flex items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3"> - <div> - <div className="text-[1.45rem] font-semibold text-white">노후관로 목록</div> - </div> - <Button - type="button" - color="white" - size="xs" - className="border border-white/30 px-3 py-1 text-[1.05rem] text-white" - onClick={() => onOpenChange(false)} - > - 닫기 - </Button> - </div> - - <div className="relative overflow-y-auto p-4"> - <div className="grid grid-cols-1 gap-3 bg-slate-50 pb-4 md:grid-cols-[110px_minmax(0,1fr)_110px_minmax(0,1fr)_auto_auto]"> - <label className="block"> - <div className="mb-1 text-[1.05rem] font-medium text-slate-700">경과년수</div> - <Input - className="h-10 !text-[1.1rem]" - inputMode="numeric" - value={String(filters.elapsedYears)} - onChange={(event) => { - const next = Number(event.target.value.replace(/[^0-9]/g, "") || "0"); - onFiltersChange({ ...filters, elapsedYears: next }); - }} - onKeyDown={(event) => { - if (event.key === "Enter") onSearch(1); - }} - /> - </label> - - <label className="block"> - <div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div> - <select - className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]" - value={filters.ctpvCd} - onChange={(event) => - onFiltersChange({ - ...filters, - ctpvCd: event.target.value, - sggCd: "0", - }) - } - > - <option value="0">전체</option> - {ctpv.map((item) => ( - <option key={item.val} value={String(item.val)}> - {item.text} - </option> - ))} - </select> - </label> - - <label className="block"> - <div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div> - <select - className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]" - value={filters.sggCd} - onChange={(event) => onFiltersChange({ ...filters, sggCd: event.target.value })} - > - <option value="0">전체</option> - {sggOptions.map((item) => ( - <option key={item.val} value={String(item.val)}> - {item.text} - </option> - ))} - </select> - </label> - - <div className="flex flex-wrap gap-2 md:col-span-3 md:justify-end"> - <Button type="button" size="sm" color="primary" className="h-10 px-5 !text-[1.1rem]" onClick={() => onSearch(1)}> - 조회 - </Button> - <Button - type="button" - size="sm" - color="green" - className="h-10 px-5 !text-[1.1rem]" - onClick={onDownload} - > - 다운로드 - </Button> - </div> - </div> - - <div className="border-t border-slate-200 pt-3"> - <UiTable<AgedPipeRow> - headName={<>노후관로 조회 결과</>} - tableData={rows} - headlist={AGED_PIPE_LIST_HEADLIST} - pageSize={10} - total={total} - cellClick={(index) => handleRowClick(index)} - onPageChange={(page) => onSearch(page)} - curPage={currentPage} - widthFixed={true} - /> - </div> - - <div className="px-[1.2rem] pb-1 text-[1rem] text-slate-500"> - 목록 행을 더블클릭하면 지도 이동 후 상세 패널이 열립니다. - </div> - </div> - </div> - </div> - ); -} diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/agedPipeUtils.ts b/apps/sn/app/html/(map-layout)/aged-pipe/agedPipeUtils.ts deleted file mode 100644 index baff4b003..000000000 --- a/apps/sn/app/html/(map-layout)/aged-pipe/agedPipeUtils.ts +++ /dev/null @@ -1,221 +0,0 @@ -"use client"; - -import type { SelectDataType } from "@common/business_components"; -import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue"; -import { - MS_SWAGE_PPL_CODES, - MS_SWAGE_PPL_USE_CODES, - MsSwagePplCol, -} from "../map/components/common/table"; - -export type AgedPipeFilters = { - elapsedYears: number; - ctpvCd: string; - sggCd: string; -}; - -export type AgedPipeRow = { - fid: string; - lyrTypeNm: string; - rowNumber: number; - fcltMngNo: string; - instlYmd: string; - pdmtVl: string | number; - prlgVl: string | number; - usgCd: string; - mqltCd: string; - elapsedYears: number | string; - ctpvNm?: string; - sggNm?: string; - [key: string]: unknown; -}; - -type PipeSource = Record<string, unknown>; - -const useMap = new Map(MS_SWAGE_PPL_USE_CODES.map((item) => [item.val.trim(), item.text])); -const materialMap = new Map(MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text])); - -export const AGED_PIPE_DEFAULT_FILTERS: AgedPipeFilters = { - elapsedYears: 20, - ctpvCd: "0", - sggCd: "0", -}; - -export const AGED_PIPE_LIST_HEADLIST = [ - { id: "rowNumber", title: "NO", widthPercent: 6 }, - { id: "fcltMngNo", title: "시설관리번호", widthPercent: 18 }, - { id: "instlYmd", title: "설치일자", widthPercent: 12 }, - { id: "pdmtVl", title: "관경(mm)", widthPercent: 10 }, - { id: "prlgVl", title: "연장(m)", widthPercent: 10 }, - { id: "usgCd", title: "용도", widthPercent: 14 }, - { id: "mqltCd", title: "재질", widthPercent: 18 }, - { id: "elapsedYears", title: "경과년도", widthPercent: 12, align: "right" }, -]; - -export const AGED_PIPE_EXCEL_HEADER = { - rowNumber: "NO", - fcltMngNo: "시설관리번호", - instlYmd: "설치일자", - pdmtVl: "관경(mm)", - prlgVl: "연장(m)", - usgCd: "용도", - mqltCd: "재질", - elapsedYears: "경과년도", -}; - -export const AGED_PIPE_DETAIL_FIELDS = MsSwagePplCol; - -const getToday = () => new Date(); - -const pad2 = (value: number) => String(value).padStart(2, "0"); - -export const getCutoffYmd = (elapsedYears: number) => { - const today = getToday(); - const cutoff = new Date(today.getFullYear() - elapsedYears, today.getMonth(), today.getDate()); - return `${cutoff.getFullYear()}${pad2(cutoff.getMonth() + 1)}${pad2(cutoff.getDate())}`; -}; - -const normalizeYmd = (value: unknown) => String(value ?? "").replace(/[^0-9]/g, "").slice(0, 8); - -const getCodePrefix = (source: PipeSource) => - String(source.pbadmsEmdCd ?? source.pbadms_emd_cd ?? "").trim(); - -const resolveRegionName = ( - code: string, - directName: unknown, - options: SelectDataType[], - prefixLength: number, -) => { - if (String(directName ?? "").trim()) return String(directName); - const prefix = code.slice(0, prefixLength); - return options.find((item) => String(item.val).trim() === prefix)?.text ?? ""; -}; - -export const getElapsedYears = (value: unknown) => { - const normalized = normalizeYmd(value); - if (normalized.length < 4) return null; - - const year = Number(normalized.slice(0, 4)); - const month = Number(normalized.slice(4, 6) || "1"); - const day = Number(normalized.slice(6, 8) || "1"); - if (!year) return null; - - const today = getToday(); - let elapsed = today.getFullYear() - year; - if ( - today.getMonth() + 1 < month || - (today.getMonth() + 1 === month && today.getDate() < day) - ) { - elapsed -= 1; - } - - return Math.max(elapsed, 0); -}; - -export const buildAgedPipeCqlFilters = (filters: AgedPipeFilters) => { - const cqlFilters: string[] = []; - cqlFilters.push(`instl_ymd <= '${getCutoffYmd(filters.elapsedYears)}'`); - - if (filters.sggCd !== "0") { - cqlFilters.push(`pbadms_emd_cd LIKE '${filters.sggCd}%'`); - } else if (filters.ctpvCd !== "0") { - cqlFilters.push(`pbadms_emd_cd LIKE '${filters.ctpvCd}%'`); - } - - return cqlFilters; -}; - -export const matchesAgedPipeFilters = (source: PipeSource, filters: AgedPipeFilters) => { - const instlYmd = normalizeYmd(source.instl_ymd ?? source.instlYmd); - if (!instlYmd || instlYmd > getCutoffYmd(filters.elapsedYears)) return false; - - const regionCode = getCodePrefix(source); - if (filters.sggCd !== "0") return regionCode.startsWith(filters.sggCd); - if (filters.ctpvCd !== "0") return regionCode.startsWith(filters.ctpvCd); - return true; -}; - -export const toDisplayPipeRow = ( - source: PipeSource, - index: number, - page: number, - ctpvOptions: SelectDataType[], - sggOptions: SelectDataType[], -): AgedPipeRow => { - const formatted = formatDisplayData(source) as PipeSource; - const codePrefix = getCodePrefix(source); - const instlYmd = formatted.instlYmd ?? source.instlYmd ?? formatted.instl_ymd ?? source.instl_ymd; - const trimmedUse = String(source.usgCd ?? source.usg_cd ?? "").trim(); - const trimmedMaterial = String(source.mqltCd ?? source.mqlt_cd ?? "").trim(); - const pdmtVl = formatted.pdmtVl ?? source.pdmtVl ?? source.pdmt_vl ?? "-"; - const prlgVl = formatted.prlgVl ?? source.prlgVl ?? source.prlg_vl ?? "-"; - - return { - ...source, - ...formatted, - fid: String(source.fid ?? ""), - lyrTypeNm: String(source.lyrTypeNm ?? "tb_ms_swage_ppl"), - rowNumber: (page - 1) * 10 + index + 1, - fcltMngNo: String(formatted.fcltMngNo ?? source.fcltMngNo ?? source.fclt_mng_no ?? "-"), - instlYmd: String(instlYmd ?? "-"), - pdmtVl: typeof pdmtVl === "number" ? pdmtVl : String(pdmtVl), - prlgVl: typeof prlgVl === "number" ? prlgVl : String(prlgVl), - usgCd: useMap.get(trimmedUse) ?? String(formatted.usgCd ?? source.usgCd ?? source.usg_cd ?? "-"), - mqltCd: - materialMap.get(trimmedMaterial) ?? - String(formatted.mqltCd ?? source.mqltCd ?? source.mqlt_cd ?? "-"), - elapsedYears: getElapsedYears(source.instlYmd ?? source.instl_ymd) ?? "-", - ctpvNm: resolveRegionName( - codePrefix, - formatted.ctpvNm ?? source.ctpvNm ?? source.ctpv_nm, - ctpvOptions, - 2, - ), - sggNm: resolveRegionName( - codePrefix, - formatted.sggNm ?? source.sggNm ?? source.sgg_nm, - sggOptions, - 5, - ), - }; -}; - -export const getPipeDisplayValue = (source: PipeSource, key: string) => { - const formatted = formatDisplayData(source) as PipeSource; - const keyMap: Record<string, string[]> = { - fclt_mng_no: ["fclt_mng_no", "fcltMngNo"], - inst_mng_no: ["inst_mng_no", "instMngNo"], - instl_ymd: ["instl_ymd", "instlYmd"], - mapsht_no: ["mapsht_no", "mapshtNo"], - pdmt_vl: ["pdmt_vl", "pdmtVl"], - prlg_vl: ["prlg_vl", "prlgVl"], - usg_cd: ["usg_cd", "usgCd"], - mqlt_cd: ["mqlt_cd", "mqltCd"], - avg_grdn: ["avg_grdn", "avgGrdn"], - drtw_mltv_area: ["drtw_mltv_area", "drtwMltvArea"], - excln_mltv_area: ["excln_mltv_area", "exclnMltvArea"], - tmpt_dpth: ["tmpt_dpth", "tmptDpth"], - tmpt_mhl_mng_no: ["tmpt_mhl_mng_no", "tmptMhlMngNo"], - scend_dpth: ["scend_dpth", "scendDpth"], - scend_mhl_mng_no: ["scend_mhl_mng_no", "scendMhlMngNo"], - trfln_cnt: ["trfln_cnt", "trflnCnt"], - vrtc_len: ["vrtc_len", "vrtcLen"], - wdth_len: ["wdth_len", "wdthLen"], - }; - - const candidates = keyMap[key] ?? [key]; - for (const candidate of candidates) { - const value = formatted[candidate] ?? source[candidate]; - if (value !== undefined && value !== null && value !== "") { - if (candidate === "usg_cd" || candidate === "usgCd") { - return useMap.get(String(source.usg_cd ?? source.usgCd ?? "").trim()) ?? value; - } - if (candidate === "mqlt_cd" || candidate === "mqltCd") { - return materialMap.get(String(source.mqlt_cd ?? source.mqltCd ?? "").trim()) ?? value; - } - return value; - } - } - - return "-"; -}; diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/page.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/page.tsx deleted file mode 100644 index d0be9a0ce..000000000 --- a/apps/sn/app/html/(map-layout)/aged-pipe/page.tsx +++ /dev/null @@ -1,407 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import Gis from "@common/business_components/ui/map/Gis"; -import GisApp from "@common/business_components/ui/map/lib/gis.core"; -import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; -import Stroke from "ol/style/Stroke.js"; -import Style from "ol/style/Style.js"; -import useStore from "@/store/useStore"; -import { getData, getRawJson } from "@/components/lib/api"; -import AgedPipeFilterPanel from "./AgedPipeFilterPanel"; -import AgedPipeDetailPanel from "./AgedPipeDetailPanel"; -import AgedPipeListModal from "./AgedPipeListModal"; -import { - AGED_PIPE_DEFAULT_FILTERS, - AGED_PIPE_EXCEL_HEADER, - buildAgedPipeCqlFilters, - matchesAgedPipeFilters, - toDisplayPipeRow, - type AgedPipeFilters, - type AgedPipeRow, -} from "./agedPipeUtils"; - -const agedPipeHighlightStyle = new Style({ - stroke: new Stroke({ - color: "rgba(239, 68, 68, 0.95)", - width: 5, - }), - zIndex: 999, -}); - -const PIPE_LAYER_TYPE = "tb_ms_swage_ppl"; -const EmptySearchView = () => null; -const normalizeTreeRowSpacing = (data: any[]) => { - if (!Array.isArray(data)) return data; - - return data.map((item) => { - if (item?.lyrNm !== "지반침하정보") { - return item; - } - - return { - ...item, - iconNm: null, - gobjTy: "", - }; - }); -}; - -const DESIGN_PREVIEW_FEATURE = { - fid: "preview-aged-pipe", - lyrTypeNm: PIPE_LAYER_TYPE, - fcltMngNo: "SN-PPL-000123", - instMngNo: "INST-4458", - instlYmd: "19981215", - mapshtNo: "M-12-34", - pdmtVl: 450, - prlgVl: 132.5, - usgCd: "합류관", - mqltCd: "철근콘크리트관(RC)", - avgGrdn: 0.0021, - drtwMltvArea: 2140, - exclnMltvArea: 980, - tmptDpth: 2.1, - tmptMhlMngNo: "TMH-2201", - scendDpth: 2.4, - scendMhlMngNo: "SMH-2202", - trflnCnt: 2, - vrtcLen: 1.2, - wdthLen: 1.2, -}; - -const Map = () => { - const gisMaps = useRef<any | null>(null); - const [mapReady, setMapReady] = useState(false); - const [treeData, setTreeData] = useState<any>([]); - const [isDetailOpen, setIsDetailOpen] = useState(true); - const [featureData, setFeatureData] = useState<any>(DESIGN_PREVIEW_FEATURE); - const [draftFilters, setDraftFilters] = useState<AgedPipeFilters>(AGED_PIPE_DEFAULT_FILTERS); - const [appliedFilters, setAppliedFilters] = useState<AgedPipeFilters>(AGED_PIPE_DEFAULT_FILTERS); - const [isListOpen, setIsListOpen] = useState(true); - const [listRows, setListRows] = useState<AgedPipeRow[]>([ - { - fid: "preview-aged-pipe", - lyrTypeNm: PIPE_LAYER_TYPE, - rowNumber: 1, - fcltMngNo: "SN-PPL-000123", - instlYmd: "1998-12-15", - pdmtVl: 450, - prlgVl: 132.5, - usgCd: "합류관", - mqltCd: "철근콘크리트관(RC)", - elapsedYears: 27, - ctpvNm: "서울특별시", - sggNm: "강남구", - }, - { - fid: "preview-aged-pipe-2", - lyrTypeNm: PIPE_LAYER_TYPE, - rowNumber: 2, - fcltMngNo: "SN-PPL-000124", - instlYmd: "2001-03-10", - pdmtVl: 300, - prlgVl: 88.4, - usgCd: "오수관", - mqltCd: "염화비닐관(PVC)", - elapsedYears: 25, - ctpvNm: "서울특별시", - sggNm: "강남구", - }, - ]); - const [totalCount, setTotalCount] = useState(2); - const [currentPage, setCurrentPage] = useState(1); - const [isListLoading, setIsListLoading] = useState(false); - const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); - const { ctpv, sgg } = useStore(); - - useEffect(() => { - const fetchData = async () => { - try { - const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK"); - setTreeData(normalizeTreeRowSpacing(data)); - } catch (error) { - console.error("노후관로 레이어 설정 조회 중 오류:", error); - throw new Error("Invalid JSON response from server"); - } - }; - - fetchData(); - }, []); - - useEffect(() => { - const timer = window.setInterval(() => { - if (gisMaps.current) { - setMapReady(true); - window.clearInterval(timer); - } - }, 300); - - return () => window.clearInterval(timer); - }, []); - - const fetchAgedPipeRows = useCallback( - async (page: number, filters: AgedPipeFilters, size: number) => { - const queryParams = new URLSearchParams({ - lyrTypeNm: PIPE_LAYER_TYPE, - page: String(page - 1), - size: String(size), - sort: "string", - }); - - const cqlFilters = buildAgedPipeCqlFilters(filters); - if (cqlFilters.length > 0) { - queryParams.append("cqlFiter", cqlFilters.join(" and ")); - } - - const response = await getData(`v1/fclts?${queryParams.toString()}`); - const rows = Array.isArray(response?.data) ? response.data : []; - - return { - rows: rows.map((item: Record<string, unknown>, index: number) => - toDisplayPipeRow(item, index, page, ctpv, sgg), - ), - total: Number(response?.count ?? 0), - }; - }, - [ctpv, sgg], - ); - - const loadAgedPipeList = useCallback( - async (page = 1, filters = appliedFilters) => { - setIsListLoading(true); - try { - const response = await fetchAgedPipeRows(page, filters, 10); - setListRows(response.rows); - setTotalCount(response.total); - setCurrentPage(page); - } catch (error) { - console.error("노후관로 목록 조회 중 오류:", error); - setListRows([]); - setTotalCount(0); - } finally { - setIsListLoading(false); - } - }, - [appliedFilters, fetchAgedPipeRows], - ); - - useEffect(() => { - loadAgedPipeList(1, appliedFilters); - }, [appliedFilters, loadAgedPipeList]); - - const handleApplyFilters = useCallback(() => { - const normalized = { - ...draftFilters, - elapsedYears: draftFilters.elapsedYears > 0 ? draftFilters.elapsedYears : 20, - }; - setDraftFilters(normalized); - setAppliedFilters(normalized); - - if (isListOpen) { - loadAgedPipeList(1, normalized); - } - }, [draftFilters, isListOpen, loadAgedPipeList]); - - const handleFeatureSelect = useCallback((data: any) => { - const map = gisMaps.current; - if (!map) return; - - const select = GisApp.Module.select as any; - if (!select) return; - select.resetSelectedFeature(); - - let foundFeature = null; - if (data && typeof data.getGeometry === "function" && typeof data.setStyle === "function") { - foundFeature = data; - } else { - const featureId = data?.fid ?? data?.ftrIdn ?? data?.id; - map - .getLayers() - .getArray() - .forEach((layer: any) => { - const source = layer.getSource?.(); - if (!source?.getFeatureById) return; - const feature = source.getFeatureById(String(featureId).trim()); - if (feature) foundFeature = feature; - }); - } - - if (foundFeature?.getGeometry) { - map.getView().fit(foundFeature.getGeometry().getExtent(), { - duration: 300, - padding: [50, 50, 50, 50], - maxZoom: 16, - }); - select.selectFeature(foundFeature, true); - } - - setFeatureData(data); - setIsDetailOpen(true); - }, []); - - const handleOpenPipeList = useCallback(() => { - setIsListOpen(true); - loadAgedPipeList(1, appliedFilters); - }, [appliedFilters, loadAgedPipeList]); - - const handleTreePopup = useCallback( - (layerNm: string) => { - if (String(layerNm).includes("하수관로")) { - handleOpenPipeList(); - } - }, - [handleOpenPipeList], - ); - - const handleListRowDoubleClick = useCallback( - async (row: AgedPipeRow) => { - try { - const core = GisApp.Module.layer as any; - await core.showSearchResultsFromGeoJSON(PIPE_LAYER_TYPE, row.fid); - } catch (error) { - console.error("노후관로 위치 이동 중 오류:", error); - } - - setFeatureData(row); - setIsDetailOpen(true); - setIsListOpen(false); - }, - [], - ); - - const handleDownload = useCallback(async () => { - try { - setIsListLoading(true); - const response = await fetchAgedPipeRows(1, appliedFilters, 10000); - await downloadExcelWithKoreanHeaders({ - data: response.rows as JSON[], - fileName: `노후관로_목록_${new Date().toISOString().slice(0, 10)}`, - sheetNameArray: ["노후관로 목록"], - headerRange: "A1:H1", - headerMapping: AGED_PIPE_EXCEL_HEADER, - }); - } catch (error) { - console.error("노후관로 목록 다운로드 중 오류:", error); - alert("노후관로 목록 다운로드 중 오류가 발생했습니다."); - } finally { - setIsListLoading(false); - } - }, [appliedFilters, fetchAgedPipeRows]); - - useEffect(() => { - if (!mapReady || !gisMaps.current) return; - - const map = gisMaps.current; - const cleanups: Array<() => void> = []; - const filterFn = (feature: any) => - matchesAgedPipeFilters(feature?.getProperties?.() ?? feature?.values_ ?? {}, appliedFilters); - - const decorateLayer = (layer: any) => { - if (layer?.get?.("lyrTyNm") !== PIPE_LAYER_TYPE) return; - - const originalStyle = - layer.get("_agedPipeOriginalStyle") ?? layer.getStyleFunction?.() ?? layer.getStyle?.(); - if (!layer.get("_agedPipeOriginalStyle")) { - layer.set("_agedPipeOriginalStyle", originalStyle); - } - - layer.setStyle((feature: any, resolution: number) => { - const baseStyle = - typeof originalStyle === "function" ? originalStyle(feature, resolution) : originalStyle; - const styles = Array.isArray(baseStyle) ? [...baseStyle] : baseStyle ? [baseStyle] : []; - if (filterFn(feature)) { - styles.push(agedPipeHighlightStyle); - } - return styles; - }); - - const source = layer.getSource?.(); - if (source?.on) { - const onLoadEnd = () => layer.changed?.(); - source.on("featuresloadend", onLoadEnd); - cleanups.push(() => source.un?.("featuresloadend", onLoadEnd)); - } - - layer.changed?.(); - }; - - map.getLayers().getArray().forEach(decorateLayer); - const layers = map.getLayers(); - const onAdd = (event: any) => decorateLayer(event.element); - layers.on?.("add", onAdd); - cleanups.push(() => layers.un?.("add", onAdd)); - - return () => { - cleanups.forEach((cleanup) => cleanup()); - }; - }, [appliedFilters, mapReady]); - - const LayerSettingView = useMemo( - () => - function LayerSettingComponent() { - return ( - <AgedPipeFilterPanel - title="지반침하정보" - filters={draftFilters} - onFiltersChange={setDraftFilters} - onApply={handleApplyFilters} - /> - ); - }, - [draftFilters, handleApplyFilters], - ); - - return ( - <> - <div className="bg-container"> - <Gis - treeData={treeData} - isSearch={false} - gisMaps={gisMaps} - isMiniMap={false} - searchView={EmptySearchView} - detailView={AgedPipeDetailPanel} - onFeatureSelect={handleFeatureSelect} - onOpenPopup={handleTreePopup} - featureData={featureData} - setFeatureData={setFeatureData} - isOpen={isDetailOpen} - setIsOpen={setIsDetailOpen} - isSearchModalOpen={isSearchModalOpen} - setIsSearchModalOpen={setIsSearchModalOpen} - onCheckedLayersChange={() => undefined} - searchBar={<></>} - layerSettingView={LayerSettingView} - showZoomLevel={true} - defaultZoom={7.5} - defaultLonLat={[127.75, 36]} - /> - </div> - - <AgedPipeListModal - open={isListOpen} - onOpenChange={setIsListOpen} - filters={draftFilters} - onFiltersChange={setDraftFilters} - rows={listRows} - total={totalCount} - currentPage={currentPage} - isLoading={isListLoading} - onSearch={(page = 1) => { - const normalized = { - ...draftFilters, - elapsedYears: draftFilters.elapsedYears > 0 ? draftFilters.elapsedYears : 20, - }; - setDraftFilters(normalized); - setAppliedFilters(normalized); - loadAgedPipeList(page, normalized); - }} - onDownload={handleDownload} - onRowDoubleClick={handleListRowDoubleClick} - /> - </> - ); -}; - -export default Map; diff --git a/apps/sn/app/html/(map-layout)/layout.tsx b/apps/sn/app/html/(map-layout)/layout.tsx deleted file mode 100644 index 25500a909..000000000 --- a/apps/sn/app/html/(map-layout)/layout.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/** - * ➡️ 경로 : 하수관망도 모든 페이지 - * - * 📄 Map Layout - * Next.js App Router (app/) 기반 맵 레이아웃 컴포넌트 - * - * - 메뉴 클릭 이벤트 처리 및 라우팅 - * - 현재 메뉴 정보 Zustand를 통한 상태 관리 - * - 시도/시군구/사업 구분/사업 유형 데이터 초기 로드 - * - NoFooterLayout을 통한 공통 레이아웃 적용 - * - QueryClient를 통한 데이터 캐싱 및 관리 - * - 경로 변경 시 메뉴 정보 동기화 - */ -"use client"; - -import React, { useEffect } from 'react'; -import { usePathname, useRouter } from "next/navigation"; -import { useQueryClient } from "@tanstack/react-query"; -import { NoFooterLayout } from "@common/components"; -import type { MenuItem } from "@/entities/menu"; -import { useMenuStore } from "@/shared/lib"; -import useStore from "@/store/useStore"; - -export default function MapLayout({ - children, -}: { - children: React.ReactNode; -}) { - const router = useRouter(); - const pathname = usePathname(); - const queryClient = useQueryClient(); - - const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo); - - const { fetchCtpv, fetchSgg, fetchBIZ_SE_CD, fetchBIZ_TY_CD } = useStore(); - - useEffect(() => { - fetchCtpv(); - fetchSgg(); - fetchBIZ_SE_CD(); - fetchBIZ_TY_CD(); - }, []); - - const handleMenuClick = (item: MenuItem) => { - setCurrentMenuInfo(item); - router.push(item.urlPath); - }; - - return ( - <NoFooterLayout - queryClient={queryClient} - currentPath={pathname ?? ""} - handleMenuClick={handleMenuClick} - > - {children} - </NoFooterLayout> - ); -} diff --git a/apps/sn/app/html/(map-layout)/map/components/InputSection.tsx b/apps/sn/app/html/(map-layout)/map/components/InputSection.tsx deleted file mode 100644 index 1d45103a1..000000000 --- a/apps/sn/app/html/(map-layout)/map/components/InputSection.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@common/components/ui"; -import React from "react"; - -interface InputSectionProps { - title: string; - data: { year: string; type: string, bizInfoNm: string, bizType: string }[]; - sectionKey: string; - onInputChange: (section: string, index: number, field: string, value: string) => void; -} - -const InputSection: React.FC<InputSectionProps> = ({ title, data, sectionKey, onInputChange }) => { - return ( - <div> - {/* 섹션 제목 */} - <div className="card-header"> - <h3 className="text-2xl mx-3 font-medium text-label whitespace-nowrap">{title}</h3> - </div> - {/* 테이블 */} - <div className="card !m-[1.2rem] !p-0r"> - <Table variant="secondary" className="w-full"> - <TableHeader> - <TableRow> - <TableHead className="border border-gray-300 px-2 py-2 text-sm w-auto whitespace-nowrap">구분</TableHead> - <TableHead className="border border-gray-300 px-2 py-2 text-sm w-[80px] whitespace-nowrap">진단연도</TableHead> - <TableHead className="border border-gray-300 px-2 py-2 text-sm w-auto whitespace-nowrap">사업명</TableHead> - <TableHead className="border border-gray-300 px-2 py-2 text-sm w-auto whitespace-nowrap">유형</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {data.map((row, index) => ( - <TableRow key={index}> - <TableCell className="border border-gray-300 px-2 py-2 text-sm text-center leading-normal w-auto whitespace-nowrap"> - {row.type} - </TableCell> - <TableCell className="border border-gray-300 px-2 py-2 text-sm w-[80px] whitespace-nowrap"> - <input - type="text" - value={row.year} - onChange={(e) => onInputChange(sectionKey, index, "year", e.target.value)} - className="w-full text-xl border-radius-0 px-3 py-2 text-base text-center leading-normal" - /> - </TableCell> - <TableCell className="border border-gray-300 px-2 py-2 text-sm text-center leading-normal w-auto"> - {row.bizInfoNm} - </TableCell> - <TableCell className="border border-gray-300 px-2 py-2 text-sm text-center leading-normal w-auto whitespace-nowrap"> - {row.bizType} - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </div> - </div> - ); -}; - -export default InputSection; \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/components/common/codeMapper.ts b/apps/sn/app/html/(map-layout)/map/components/common/codeMapper.ts deleted file mode 100644 index 7b591b09c..000000000 --- a/apps/sn/app/html/(map-layout)/map/components/common/codeMapper.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { MS_SWAGE_PPL_USE_CODES, MS_SWAGE_PPL_CODES, MS_SWAGE_MHL_CODES, MS_SWAGE_MHL_USE_CODES, DRTW_SEWAGE_PUMP_USE_CODES } from './table'; - -interface CodeMapperProps { - layerName: string; - data: any; -} - -/** - * 레이어별 코드 변환 함수 - * @param layerName - 레이어 이름 - * @param data - 변환할 데이터 객체 - * @returns 변환된 데이터 객체 - */ -export const codeMapper = ({ layerName, data }: CodeMapperProps): any => { - if (!data) return data; - - const processedData = { ...data }; - - switch (layerName) { - case '하수관로': - // usg_cd 값 변환 (용도코드) - if (processedData.usg_cd) { - const usageCodeItem = MS_SWAGE_PPL_USE_CODES.find( - code => code.val === processedData.usg_cd.trim() - ); - if (usageCodeItem) { - processedData.usg_cd = usageCodeItem.text; - } - } - - // mqlt_cd 값 변환 (재질코드) - if (processedData.mqlt_cd) { - const materialCodeItem = MS_SWAGE_PPL_CODES.find( - code => code.val === processedData.mqlt_cd.trim() - ); - if (materialCodeItem) { - processedData.mqlt_cd = materialCodeItem.text; - } - } - break; - - case '하수맨홀': - // usg_cd 값 변환 (용도코드) - if (processedData.usg_cd) { - const usageCodeItem = MS_SWAGE_MHL_USE_CODES.find( - code => code.val.trim() === processedData.usg_cd.trim() - ); - if (usageCodeItem) { - processedData.usg_cd = usageCodeItem.text; - } - } - - // mqlt_cd 값 변환 (재질코드) - if (processedData.mqlt_cd) { - const materialCodeItem = MS_SWAGE_MHL_CODES.find( - code => code.val === processedData.mqlt_cd.trim() - ); - if (materialCodeItem) { - processedData.mqlt_cd = materialCodeItem.text; - } - } - break; - - case '하수펌프장': - // usg_cd 값 변환 (용도코드) - if (processedData.usg_cd) { - const usageCodeItem = DRTW_SEWAGE_PUMP_USE_CODES.find( - code => code.val === processedData.usg_cd.trim() - ); - if (usageCodeItem) { - processedData.usg_cd = usageCodeItem.text; - } - } - break; - - case '우수토실': - // prrtymngYn 값 변환 (중점여부) - if (processedData.prrtymngYn) { - if (processedData.prrtymngYn === 'Y') { - processedData.prrtymngYn = '중점우수토실'; - } else if (processedData.prrtymngYn === 'N') { - processedData.prrtymngYn = '우수토실'; - } - } - break; - case '중점우수토실': - if (processedData.manage_no) { - processedData.mqlt_cd = processedData.manage_no; - } - if (processedData.mesure_spot_se) { - const mesureSpotSe = [{ id: '0', text: '합류식' }, { id: '1', text: '분류식' }]; - const mesureSpotSeItem = mesureSpotSe.find( - code => code.id === processedData.mesure_spot_se.trim() - ); - if (mesureSpotSeItem) processedData.mesure_spot_se = mesureSpotSeItem.text; - } - break; - - default: - // 다른 레이어는 변환하지 않음 - break; - } - - return processedData; -}; - -/** - * 저장 시 코드 역변환 함수 (한글 → 코드) - * @param layerName - 레이어 이름 - * @param data - 역변환할 데이터 객체 - * @returns 역변환된 데이터 객체 - */ -export const reverseCodeMapper = ({ layerName, data }: CodeMapperProps): any => { - if (!data) return data; - - const processedData = { ...data }; - - switch (layerName) { - case '하수맨홀': - // mqlt_cd 값 역변환 (텍스트 → 코드) - if (processedData.mqlt_cd && typeof processedData.mqlt_cd === 'string') { - const materialCodeItem = MS_SWAGE_MHL_CODES.find( - code => code.text === processedData.mqlt_cd.trim() - ); - if (materialCodeItem) { - processedData.mqlt_cd = materialCodeItem.val; - } - } - break; - - case '우수토실': - // prrtymngYn 값 역변환 (텍스트 → Y/N) - if (processedData.prrtymngYn) { - if (processedData.prrtymngYn === '중점우수토실') { - processedData.prrtymngYn = 'Y'; - } else if (processedData.prrtymngYn === '우수토실') { - processedData.prrtymngYn = 'N'; - } - } - break; - - case '하수펌프장': - // usg_cd 값 역변환 (텍스트 → 코드) - if (processedData.usg_cd && typeof processedData.usg_cd === 'string') { - const usageCodeItem = DRTW_SEWAGE_PUMP_USE_CODES.find( - code => code.text === processedData.usg_cd.trim() - ); - if (usageCodeItem) { - processedData.usg_cd = usageCodeItem.val; - } - } - break; - - default: - break; - } - - return processedData; -}; - -// 기본 내보내기 -export default { codeMapper, reverseCodeMapper }; \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/components/common/index.ts b/apps/sn/app/html/(map-layout)/map/components/common/index.ts deleted file mode 100644 index a57bafa3d..000000000 --- a/apps/sn/app/html/(map-layout)/map/components/common/index.ts +++ /dev/null @@ -1,68 +0,0 @@ -import MsSwagePpl from "../popupComponent/MsSwagePpl"; // 하수관로 -import sewagePump from "../popupComponent/sewagePump"; // 하수펌프 -import smh from "../popupComponent/smh"; -import Ssc from "../popupComponent/Ssc"; -import StormWaterPump from "../popupComponent/stormWaterPump"; -import SwtrmtFclt from "../popupComponent/SwtrmtFclt"; - -type LayerComponentProps = { - featureData: any; - isMainSsc?: boolean; - isDrtwSewagePump?: boolean; - rainMeasureData?: any; - onSaveClick?: () => void; - onFormDataChange?: (data: any) => void; // 추가 - chmbrXcnts: string; - chmbrYdnts: string; - gisObject?: Record<string, any>; // GIS 객체 추가 -}; -type LayerContentMap = { - [key: string]: { - component?: React.ComponentType<any>; - props?: any; - }; -}; -// 레이어 이름과 컴포넌트를 매핑 -// 레이어 선택시 변경되는 팝업 컨텐츠 동적으로 변경하도록 작업 -const layerContentMap: LayerContentMap = { - "하수처리시설": { component: SwtrmtFclt, }, // 디지털 자료관 연계정보가 없으니 일단 주석 처리 - "하수처리구역": {}, - "우수토실": { component: Ssc, }, - "중점우수토실": { component: Ssc, }, // 중점우수토실 여부 설정 - "하수관로": { component: MsSwagePpl, }, - "하수맨홀": { component: smh, }, - "하수펌프장": { component: sewagePump }, - // "빗물펌프": { component: StormWaterPump }, - // "오수펌프": { component: sewagePump }, -}; - -// 동적 컴포넌트 반환 함수 -export const getLayerContentComponent = (layerNm: string): React.FC<LayerComponentProps> | null => { - const component = layerContentMap[layerNm]; - if (!component || !component.component) { - // component가 없으면 null 반환 - return () => null; - } - return component.component as React.FC<LayerComponentProps> -}; -// 사용자 입력 필드 (보수 및 갱신, 청소 및 준설, 점검 및 진단) -export const handleInputChange = (section: string, - index: number, - field: string, - value: string, - dataMap: { - [key: string]: { - data: { year: string; type: string, bizInfoNm: string, bizType: string }[]; - setData: React.Dispatch<React.SetStateAction<{ year: string; type: string, bizInfoNm: string, bizType: string }[]>>; - }; - } -) => { - const target = dataMap[section]; - if (!target) return; - - const updated = [...target.data]; - if (updated[index]) { - updated[index][field as keyof typeof updated[number]] = value; - } - target.setData(updated); -}; diff --git a/apps/sn/app/html/(map-layout)/map/components/common/table.ts b/apps/sn/app/html/(map-layout)/map/components/common/table.ts deleted file mode 100644 index 4e501fb20..000000000 --- a/apps/sn/app/html/(map-layout)/map/components/common/table.ts +++ /dev/null @@ -1,848 +0,0 @@ -// 하수관로 -export const MsSwagePplCol = [ - { id: 'fclt_mng_no', title: '시설관리번호' }, - { id: 'inst_mng_no', title: '기관관리번호' }, - { id: 'instl_ymd', title: '설치일자' }, - { id: 'mapsht_no', title: '도엽번호' }, - { id: 'pdmt_vl', title: '관경(mm)' }, - { id: 'prlg_vl', title: '연장(m)' }, - { id: 'usg_cd', title: '용도' }, - { id: 'mqlt_cd', title: '재질' }, - { id: 'avg_grdn', title: '평균구배(m/m)' }, - { id: 'drtw_mltv_area', title: '오수배수면적(m²)' }, - { id: 'excln_mltv_area', title: '우수배수면적(m²)' }, - { id: 'tmpt_dpth', title: '시점깊이(m)' }, - { id: 'tmpt_mhl_mng_no', title: '시점맨홀관리번호' }, - { id: 'scend_dpth', title: '종점깊이(m)' }, - { id: 'scend_mhl_mng_no', title: '종점맨홀관리번호' }, - { id: 'trfln_cnt', title: '차선수' }, - { id: 'vrtc_len', title: '세로길이(m)' }, - { id: 'wdth_len', title: '가로길이(m)' }, - // { id: 'rnyssn_fvlc', title: '우기 시 유속(m/s)' }, - // { id: 'clar_fvlc', title: '건기 시 유속(m/s)' }, - // { id: 'mng_inst_cd', title: '관리기관코드' }, - // { id: 'pbadms_emd_cd', title: '행정읍면동코드' }, - // { id: 'fclty_shp_cd', title: '시설물형태코드' }, - // { id: 'fid', title: 'FID' }, - // { id: 'ftre_smbol_cd', title: '지형지물부호코드' }, - // { id: 'lyrTypeNm', title: '레이어타입명' }, -] -// 하수관로 searchModal 기본정보tab -export const MsSwagePplCol2 = [ - { id: 'rowNumber', title: '일련\n번호' }, - { id: 'fcltMngNo', title: '시설\n관리번호' }, - { id: 'instMngNo', title: '기관\n관리번호' }, - { id: 'ctpvNm', title: '시도' }, - { id: 'sggNm', title: '시군구' }, - { id: "swtrmt_zone_nm", title: "처리구역" }, - { id: 'instlYmd', title: '설치일자' }, - // { id: 'mapshtNo', title: '도엽번호' }, - { id: 'usgCd', title: '용도' }, - { id: 'pdmtVl', title: '관경\n(mm)' }, - { id: 'prlgVl', title: '연장\n(m)' }, - // { id: 'avgGrdn', title: '평균구배\n(m/m)' }, - { id: 'tmptDpth', title: '시점깊이\n(m)' }, - { id: 'tmptMhlMngNo', title: '시점맨홀\n관리번호' }, - { id: 'scendDpth', title: '종점깊이\n(m)' }, - { id: 'scendMhlMngNo', title: '종점맨홀\n관리번호' }, - { id: 'vrtcLen', title: '세로길이\n(m)' }, - { id: 'wdthLen', title: '가로길이\n(m)' }, - { id: 'fcltyShpCd', title: '시설물형태코드' }, - // { id: 'fid', title: 'FID' }, - // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, - // { id: 'mngInstCd', title: '관리기관코드' }, - { id: 'mqltCd', title: '재질코드' }, - // { id: 'pbadmsEmdCd', title: '행정읍면동코드' }, - // { id: 'usgCd', title: '용도코드' }, - // { id: 'lyrTypeNm', title: '레이어타입명' }, -] -export const MsSwagePplExcelCol = { - 'fcltMngNo': '시설 관리번호', - 'instMngNo': '기관 관리번호', - 'ctpvNm': '시도', - 'sggNm': '시군구', - 'swtrmt_zone_nm': '처리구역', - 'instlYmd': '설치일자', - 'usgCd': '용도', - 'pdmtVl': '관경(mm)', - 'prlgVl': '연장(m)', - 'tmptDpth': '시점 깊이(m)', - 'tmptMhlMngNo': '시점맨홀 관리번호', - 'scendDpth': '종점 깊이(m)', - 'scendMhlMngNo': '종점맨홀 관리번호', - 'vrtcLen': '세로길이(m)', - 'wdthLen': '가로길이(m)', - 'fcltyShpCd': '시설물형태코드', - 'mqltCd': '재질코드', -}; -// 하수관로 용도 코드표 -export const MS_SWAGE_PPL_USE_CODES = [ - { val: "SBA000", text: "미분류" }, - { val: "SBA001", text: "합류관" }, - { val: "SBA002", text: "차집관" }, - { val: "SBA003", text: "우수관" }, - { val: "SBA004", text: "오수관" }, - { val: "SBA999", text: "기타" }, -]; -// 하수관로 재질 코드표 -export const MS_SWAGE_PPL_CODES = [ - { val: "MOP000", text: "미분류" }, - { val: "MOP001", text: "닥타일주철관(DCIP)" }, - { val: "MOP002", text: "강관(SP)" }, - { val: "MOP003", text: "아연도강관(GS)" }, - { val: "MOP004", text: "염화비닐관(PVC)" }, - { val: "MOP005", text: "폴리에틸렌관(PE)" }, - { val: "MOP006", text: "스테인레스관(SSP)" }, - { val: "MOP007", text: "동관(COP)" }, - { val: "MOP008", text: "P.S.콘크리트관(PC)" }, - { val: "MOP009", text: "석면시멘트관(AS)" }, - { val: "MOP010", text: "회주철관(CIP)" }, - { val: "MOP011", text: "연관(LP)" }, - { val: "MOP012", text: "흉관(HP)" }, - { val: "MOP013", text: "철근콘크리트관(RC)" }, - { val: "MOP014", text: "강관(ST)" }, - { val: "MOP015", text: "주철관(CI)" }, - { val: "MOP016", text: "화학제품류관(PV)" }, - { val: "MOP017", text: "도관(VC)" }, - { val: "MOP018", text: "석축(SE)" }, - { val: "MOP019", text: "도복장 강관(SP)" }, - { val: "MOP020", text: "흉관(EP)" }, - { val: "MOP021", text: "아연도강관(GP)" }, - { val: "MOP022", text: "청동관(BR)" }, - { val: "MOP023", text: "고무몰딩(RM)" }, - { val: "MOP024", text: "주강(CS)" }, - { val: "MOP025", text: "VR관" }, - { val: "MOP026", text: "HDPE관" }, - { val: "MOP027", text: "시멘트라이닝주철관(C)" }, - { val: "MOP028", text: "수도용 PE관(PE)" }, - { val: "MOP029", text: "석면시멘트관(AC)" }, - { val: "MOP030", text: "폴리에틸렌분체라이닝강관(PEP)" }, - { val: "MOP031", text: "에폭시코팅강관(EPS)" }, - { val: "MOP032", text: "HI-3P" }, - { val: "MOP033", text: "철근콘크리트박스(RCB)" }, - { val: "MOP034", text: "콘크리트중선함(CD)" }, - { val: "MOP035", text: "후랙시불전선함(FP)" }, - { val: "MOP036", text: "전선함(FD)" }, - { val: "MOP038", text: "이중보온관" }, - { val: "MOP039", text: "폴리에틸렌피복강관(PLP)" }, - { val: "MOP041", text: "FRP" }, - { val: "MOP042", text: "강재" }, - { val: "MOP999", text: "기타" }, - { val: "MOP800", text: "2중벽PE관" }, - { val: "MOP801", text: "3중벽PE관" }, - { val: "MOP802", text: "가스배관용 강관" }, - { val: "MOP803", text: "강관(피복 Double, 용접접합)" }, - { val: "MOP804", text: "강관(피복 Single, 용접접합)" }, - { val: "MOP805", text: "강관(CP)" }, - { val: "MOP806", text: "강철" }, - { val: "MOP807", text: "강화플라스틱복합관" }, - { val: "MOP808", text: "경질염화비닐관(HI-VP)" }, - { val: "MOP809", text: "고강성PVC이중벽관" }, - { val: "MOP810", text: "고광도PVC이중벽관" }, - { val: "MOP811", text: "고밀도폴리에틸렌관(THP)" }, - { val: "MOP812", text: "고밀도폴리에틸렌파상형이중벽관" }, - { val: "MOP813", text: "공장제 박스관거" }, - { val: "MOP814", text: "관로없음(알케이블)" }, - { val: "MOP815", text: "나선형강관" }, - { val: "MOP816", text: "내충격수도관" }, - { val: "MOP817", text: "내충격용하수도용경질염화비닐관(HI-VG)" }, - { val: "MOP818", text: "내충격HI-VG2관" }, - { val: "MOP819", text: "내충격PVC하수관" }, - { val: "MOP820", text: "녹석(돌산호과 일종)" }, - { val: "MOP821", text: "동관(구리관, 용접)" }, - { val: "MOP822", text: "둥근잔돌" }, - { val: "MOP823", text: "무근콘크리트" }, - { val: "MOP824", text: "백관" }, - { val: "MOP825", text: "복합철판파형관" }, - { val: "MOP826", text: "분체라이닝관" }, - { val: "MOP827", text: "석축박스" }, - { val: "MOP828", text: "석축철콘" }, - { val: "MOP829", text: "수도용경질염화비관" }, - { val: "MOP830", text: "수도용경질염화비닐이음관" }, - { val: "MOP831", text: "수도용엑포시수지분체내외면코팅강관" }, - { val: "MOP832", text: "수지파형강관(PCS)" }, - { val: "MOP833", text: "수지CSP" }, - { val: "MOP834", text: "스테인레스 주름관" }, - { val: "MOP835", text: "스테인레스관(STS)" }, - { val: "MOP836", text: "스테인레스스틸파이프관" }, - { val: "MOP837", text: "아스콘" }, - { val: "MOP838", text: "아스팔트" }, - { val: "MOP839", text: "아스팔트도복장강관(ASP)" }, - { val: "MOP840", text: "아연" }, - { val: "MOP841", text: "아연도금(백관)" }, - { val: "MOP842", text: "알루미늄" }, - { val: "MOP843", text: "알루미늄합금" }, - { val: "MOP844", text: "압력배관용 탄소강관" }, - { val: "MOP845", text: "양철관" }, - { val: "MOP846", text: "양철주름관(CSP)" }, - { val: "MOP847", text: "연합인슈강판" }, - { val: "MOP848", text: "와사관(GSP)" }, - { val: "MOP849", text: "원심력철근콘크리트" }, - { val: "MOP850", text: "유리섬유관(GRP)" }, - { val: "MOP851", text: "유리섬유복합관(GRP)" }, - { val: "MOP852", text: "이음매없는동및동합금관" }, - { val: "MOP853", text: "일반배관용스테인리스강관" }, - { val: "MOP854", text: "전압 콘크리트관" }, - { val: "MOP855", text: "주름관" }, - { val: "MOP856", text: "주름주철파형강관" }, - { val: "MOP857", text: "지중 저압케이블 입상관" }, - { val: "MOP858", text: "진동및전압철근콘크리트관" }, - { val: "MOP859", text: "철근콘크리트" }, - { val: "MOP860", text: "철근콘크리트 블록" }, - { val: "MOP861", text: "철근콘크리트조립흙막이" }, - { val: "MOP862", text: "철판매입관" }, - { val: "MOP863", text: "청동" }, - { val: "MOP864", text: "코아식프리스트레스트콘크리트관" }, - { val: "MOP865", text: "콘크리트" }, - { val: "MOP866", text: "콘크리트 폴륨관" }, - { val: "MOP867", text: "콘크리트블록" }, - { val: "MOP868", text: "탄소강" }, - { val: "MOP869", text: "탄소강(카본스틸)" }, - { val: "MOP870", text: "탄소주강" }, - { val: "MOP871", text: "트러후" }, - { val: "MOP872", text: "파선형강관(RCF)" }, - { val: "MOP873", text: "파형강관(CSP)" }, - { val: "MOP874", text: "파형관" }, - { val: "MOP875", text: "파형플라스틱관" }, - { val: "MOP876", text: "폴리머관" }, - { val: "MOP877", text: "폴리에스테르수지콘크리트관" }, - { val: "MOP878", text: "폴리에틸렌 전선관" }, - { val: "MOP879", text: "폴리에틸렌 피복강관" }, - { val: "MOP880", text: "폴리에틸렌관 1호관" }, - { val: "MOP881", text: "폴리에틸렌관 2호관" }, - { val: "MOP882", text: "폴리에틸렌관(2중벽)" }, - { val: "MOP883", text: "폴리에틸렌관(3중벽)" }, - { val: "MOP884", text: "폴리에틸렌분체라이닝" }, - { val: "MOP885", text: "폴리에틸렌이중벽하수관" }, - { val: "MOP886", text: "폴리탄산에스테르" }, - { val: "MOP887", text: "프리스트레스트콘크리트실린더관" }, - { val: "MOP888", text: "플라스틱" }, - { val: "MOP889", text: "플라스틱주름관" }, - { val: "MOP890", text: "하수도용고강성경질염화비닐이중벽주름관" }, - { val: "MOP891", text: "현장타설관" }, - { val: "MOP892", text: "현장타설철근콘크리트관" }, - { val: "MOP893", text: "흑관" }, - { val: "MOP894", text: "흙" }, - { val: "MOP895", text: "AP" }, - { val: "MOP896", text: "API관" }, - { val: "MOP897", text: "ASP" }, - { val: "MOP898", text: "CMCIP" }, - { val: "MOP899", text: "CSP" }, - { val: "MOP900", text: "DRP" }, - { val: "MOP901", text: "DSP" }, - { val: "MOP902", text: "DTC관" }, - { val: "MOP903", text: "ELP" }, - { val: "MOP904", text: "EPP" }, - { val: "MOP905", text: "FC관" }, - { val: "MOP906", text: "H" }, - { val: "MOP907", text: "H6" }, - { val: "MOP908", text: "HCP" }, - { val: "MOP909", text: "HI_2P관" }, - { val: "MOP910", text: "HI-KP" }, - { val: "MOP911", text: "HL" }, - { val: "MOP912", text: "IP" }, - { val: "MOP913", text: "KCGP" }, - { val: "MOP914", text: "KP관" }, - { val: "MOP915", text: "P2S관" }, - { val: "MOP916", text: "PC박스(PCB)" }, - { val: "MOP917", text: "PCS" }, - { val: "MOP918", text: "PDP" }, - { val: "MOP919", text: "PE이중벽관" }, - { val: "MOP920", text: "PE적측벽1종관" }, - { val: "MOP921", text: "PFP관" }, - { val: "MOP922", text: "PG관" }, - { val: "MOP923", text: "PIP관(이중보온관)" }, - { val: "MOP924", text: "PLCSP" }, - { val: "MOP925", text: "PS" }, - { val: "MOP926", text: "PUB" }, - { val: "MOP927", text: "PVB" }, - { val: "MOP928", text: "PVC이중벽관(PVC2)" }, - { val: "MOP929", text: "PVC-DC" }, - { val: "MOP930", text: "PVC-DV" }, - { val: "MOP931", text: "SBK" }, - { val: "MOP932", text: "SCP" }, - { val: "MOP933", text: "SEB" }, - { val: "MOP934", text: "SIS관(이중보온관)" }, - { val: "MOP935", text: "SMC" }, - { val: "MOP936", text: "STW" }, - { val: "MOP937", text: "SUS" }, - { val: "MOP938", text: "THP나선관" }, - { val: "MOP939", text: "U관" }, - { val: "MOP940", text: "VG2" }, - { val: "MOP941", text: "WSP(와사피)" }, - { val: "MOP942", text: "Steel Grating" }, - { val: "MOP943", text: "모래" }, - { val: "MOP944", text: "인조석" }, - { val: "MOP945", text: "SPOL" }, - { val: "MOP946", text: "PCF관(강판보강 PE 나선형관)" }, - { val: "MOP947", text: "PVC3(PVC3중벽관)" }, - { val: "MOP948", text: "PE다중벽관" }, - { val: "MOP977", text: "전주용입상관" }, - { val: "MOP978", text: "MD관" }, -]; -// 하수관로 'fcltyShpCd' '시설물형태코드' -export const MS_SWAGE_PPL_SHAPE = [ - { val: "FOR000 ", text: "미분류" }, - { val: "FOR001 ", text: "원형" }, - { val: "FOR002 ", text: "원추형" }, - { val: "FOR003 ", text: "원통형" }, - { val: "FOR004 ", text: "구형" }, - { val: "FOR005 ", text: "타원형" }, - { val: "FOR006 ", text: "난원" }, - { val: "FOR007 ", text: "반원형" }, - - { val: "FOR010 ", text: "피라미드형" }, - { val: "FOR011 ", text: "사면체형" }, - { val: "FOR012 ", text: "기둥,축" }, - { val: "FOR013 ", text: "돛대,활대처럼 둥글고 긴 원재" }, - { val: "FOR014 ", text: "아치형" }, - { val: "FOR015 ", text: "다중아치형" }, - { val: "FOR016 ", text: "돔형" }, - { val: "FOR017 ", text: "고형체형" }, - - { val: "FOR020 ", text: "사각형" }, - { val: "FOR021 ", text: "평방형" }, - { val: "FOR022 ", text: "다이아몬드형" }, - { val: "FOR023 ", text: "사다리꼴" }, - { val: "FOR024 ", text: "각형" }, - { val: "FOR025 ", text: "마제형관" }, - { val: "FOR026 ", text: "태형" }, - - { val: "FOR030 ", text: "A형" }, - { val: "FOR031 ", text: "H형" }, - { val: "FOR032 ", text: "I형" }, - { val: "FOR033 ", text: "Y형" }, - { val: "FOR034 ", text: "T형" }, - - { val: "FOR050 ", text: "불규칙한 형태" }, - - { val: "FOR900 ", text: "계란형" }, - { val: "FOR901 ", text: "구(사각)형" }, - { val: "FOR902 ", text: "높이조절철개형" }, - { val: "FOR903 ", text: "박스형(RCB)" }, - { val: "FOR904 ", text: "반월개거" }, - { val: "FOR905 ", text: "반월개거(측구)" }, - { val: "FOR906 ", text: "벽돌조" }, - { val: "FOR907 ", text: "사각철판" }, - { val: "FOR908 ", text: "석축개거" }, - { val: "FOR909 ", text: "석축개거(측구)" }, - { val: "FOR910 ", text: "승수로" }, - { val: "FOR911 ", text: "옹벽개거" }, - { val: "FOR912 ", text: "옹벽개거(측구)" }, - { val: "FOR913 ", text: "이단" }, - { val: "FOR914 ", text: "이중침전형" }, - { val: "FOR915 ", text: "자연개거" }, - { val: "FOR916 ", text: "자연개거(측구)" }, - { val: "FOR917 ", text: "조립식" }, - { val: "FOR918 ", text: "지상관" }, - { val: "FOR919 ", text: "지상단구" }, - { val: "FOR920 ", text: "지상쌍구" }, - { val: "FOR921 ", text: "지하단구" }, - { val: "FOR922 ", text: "지하쌍구" }, - { val: "FOR923 ", text: "직사각형" }, - { val: "FOR924 ", text: "타설식" }, - { val: "FOR925 ", text: "토류형" }, - { val: "FOR926 ", text: "흄관원형" }, - { val: "FOR927 ", text: "L형측구" }, - { val: "FOR928 ", text: "LO형측구" }, - { val: "FOR929 ", text: "LU형측구" }, - { val: "FOR930 ", text: "PVC이중벽관" }, - { val: "FOR931 ", text: "RCB 맨홀" }, - { val: "FOR932 ", text: "U형" }, - { val: "FOR933 ", text: "U형측구(미복개)" }, - { val: "FOR934 ", text: "U형측구(복개)" }, - - { val: "FOR999 ", text: "기타" } -]; -// 하수처리시설 -export const SwtrmtFcltCol = [ - { id: 'swtrmt_fclt_nm', title: '하수처리시설명' }, - { id: 'fclt_cpct', title: '시설용량(㎥/d)' }, - { id: 'rgn_expln', title: '지역설명' }, - { id: 'cmcn_ymd', title: '준공일자' }, - { id: 'oprtng_strt_ymd', title: '가동개시일자' }, - { id: 'clsg_ymd', title: '폐쇄일자' }, - // { id: 'prcs_msrc_cd', title: '처리공법코드' }, - // { id: 'lae_cd', title: '지자체코드' }, - { id: 'lae_nm', title: '지자체명' }, - { id: 'ctpv_nm', title: '시도명' }, - { id: 'sgg_nm', title: '시군구명' }, - // { id: 'sarea_cd', title: '권역코드' }, - { id: 'sarea_nm', title: '권역명' }, - { id: 'lrge_sarea_nm', title: '대권역명' }, - { id: 'rvr_nm', title: '하천명' }, - { id: 'addr', title: '주소' }, - { id: 'dtl_addr', title: '상세주소' }, - { id: 'rdnm_addr', title: '도로명주소' }, - { id: 'dtl_rdnm_addr', title: '상세도로명주소' }, - { id: 'dschg_wtst_nm', title: '방류수계명' }, - { id: 'plln_area', title: '부지면적(m²)' }, - { id: 'mng_rprtv_bzenty_nm', title: '관리대행업제명' }, - { id: 'swtrmt_ppltn_cnt', title: '하수처리인구수(인)' }, - { id: 'prcs_zone_area_sum', title: '처리구역면적합계(m²)' }, - { id: 'xcrd', title: 'X좌표' }, - { id: 'ycrd', title: 'Y좌표' }, - // { id: 'oprtngSttsCd', title: '가동상태코드' }, - { id: 'rprs_msrc_cn', title: '대표공법내용' }, - // { id: 'stty_crtr_bod', title: '법정기준생물화학적산소요구량(mg/L)' }, - // { id: 'stty_crtr_toc', title: '법정기준TOC((mg/L))' }, - // { id: 'stty_crtr_ss', title: '법정기준SS(mg/L)' }, - // { id: 'stty_crtr_tn', title: '법정기준총질소(mg/L)' }, - // { id: 'stty_crtr_tph', title: '법정기준총인(mg/L)' }, - // { id: 'stty_crtr_clbcl_cnt', title: '법정기준대장균수(군수/100mL)' }, - { id: 'bsn_nm', title: '유역명' }, - // { id: 'obsrvnSptCd', title: '관측지점코드' }, -]; -export const SwtrmtFcltCol2 = [ - { id: 'rowNumber', title: '일련\n번호' }, - { id: 'swtrmtFcltNm', title: '하수처리\n시설명' }, - { id: 'fcltCpct', title: '시설\n용량\n(㎥/d)' }, - { id: 'rgnExpln', title: '지역설명' }, - // { id: 'cmcnYmd', title: '준공일자' }, - { id: 'oprtngStrtYmd', title: '가동개시일자' }, - // { id: 'clsgYmd', title: '폐쇄일자' }, - // { id: 'prcsMsrcCd', title: '처리공법코드' }, - // { id: 'laeCd', title: '지자체코드' }, - { id: 'laeNm', title: '지자체명' }, - // { id: 'ctpvNm', title: '시도명' }, - // { id: 'sggNm', title: '시군구명' }, - // { id: 'sareaCd', title: '권역코드' }, - { id: 'sareaNm', title: '권역명' }, - // { id: 'lrgeSareaNm', title: '대권역명' }, - { id: 'rvrNm', title: '하천명' }, - // { id: 'addr', title: '주소' }, - // { id: 'dtlAddr', title: '상세주소' }, - // { id: 'rdnmAddr', title: '도로명주소' }, - // { id: 'dtlRdnmAddr', title: '상세도로명주소' }, - { id: 'dschgWtstNm', title: '방류수계명' }, - // { id: 'pllnArea', title: '부지면적(m²)' }, - // { id: 'mngRprtvBzentyNm', title: '관리대행업체명' }, - { id: 'swtrmtPpltnCnt', title: '하수\n처리\n인구수\n(인)' }, - // { id: 'prcsZoneAreaSum', title: '처리구역면적합계(m²)' }, - // { id: 'xcrd', title: 'X좌표' }, - // { id: 'ycrd', title: 'Y좌표' }, - // { id: 'oprtngSttsCd', title: '가동상태코드' }, - { id: 'rprsMsrcCn', title: '대표\n공법\n내용' }, - // { id: 'sttyCrtrBod', title: '법정기준생물화학적산소요구량(mg/L)' }, - // { id: 'sttyCrtrToc', title: '법정기준TOC(mg/L)' }, - // { id: 'sttyCrtrSs', title: '법정기준SS(mg/L)' }, - // { id: 'sttyCrtrTn', title: '법정기준총질소(mg/L)' }, - // { id: 'sttyCrtrTph', title: '법정기준총인(mg/L)' }, - // { id: 'sttyCrtrClbclCnt', title: '법정기준대장균수(군수/100mL)' }, - // { id: 'bsnNm', title: '유역명' }, - // { id: 'obsrvnSptCd', title: '관측지점코드' }, -]; -export const SwtrmtFcltExcelCol = { - 'swtrmtFcltNm': '하수처리시설명', - 'fcltCpct': '시설 용량(㎥/d)', - 'rgnExpln': '지역설명', - 'oprtngStrtYmd': '가동개시 일자', - 'laeNm': '지자체명', - 'sareaNm': '권역명', - 'rvrNm': '하천명', - 'dschgWtstNm': '방류수계명', - 'swtrmtPpltnCnt': '하수 처리 인구수(인)', - 'rprsMsrcCn': '대표 공법 내용' -}; - -// 하수처리구역 -export const SwtrmtPrcsZoneCol = [ - // { id: 'swtrmtZoneSn', title: '하수처리구역 일련번호' }, - { id: 'swtrmt_zone_nm', title: '하수처리구역명' }, - // { id: 'swtrmt_fclt_cd', title: '하수처리시설코드' }, - // { id: 'ctpv_cd', title: '시도코드' }, - { id: 'ctpv_nm', title: '시도명' }, - // { id: 'sgg_cd', title: '시군구코드' }, - { id: 'sgg_nm', title: '시군구명' }, - { id: 'swtrmt_fclt_nm', title: '하수처리시설명' }, -]; -export const SwtrmtPrcsZoneCol2 = [ - { id: 'rowNumber', title: '일련번호' }, - // { id: 'swtrmtZoneSn', title: '하수처리구역 일련번호' }, - { id: 'swtrmtZoneNm', title: '하수처리구역명' }, - // { id: 'swtrmt_fclt_cd', title: '하수처리시설코드' }, - // { id: 'ctpv_cd', title: '시도코드' }, - { id: 'ctpvNm', title: '시도' }, - // { id: 'sgg_cd', title: '시군구코드' }, - { id: 'sggNm', title: '시군구' }, - { id: 'swtrmtFcltNm', title: '하수처리시설명' }, -]; -export const SwtrmtPrcsZoneExcelCol = { - 'swtrmtZoneNm': '하수처리구역명', - 'ctpvNm': '시도', - 'sggNm': '시군구', - 'swtrmtFcltNm': '하수처리시설명' -}; - -// 하수맨홀 -export const MsSwageMhlCol = [ - // { id: 'fid', title: 'FID' }, - // { id: 'geom', title: 'geometry' }, - // { id: 'ftre_smbol_cd', title: '지형지물부호코드' }, - { id: 'fclt_mng_no', title: '시설관리번호' }, - // { id: 'pbadms_emd_cd', title: '행정읍면동코드' }, - { id: 'mapsht_no', title: '도엽번호' }, - // { id: 'mng_inst_cd', title: '관리기관코드' }, - { id: 'instl_ymd', title: '설치일자' }, - { id: 'last_drdgn_ymd', title: '최종준설일자' }, - // { id: 'usg_cd', title: '용도코드' }, - // { id: 'fclty_shp_cd', title: '시설물형태코드' }, - // { id: 'mhl_knd_cd', title: '맨홀종류코드' }, - { id: 'swage_mhL_diam_vl', title: '하수맨홀구경(mm)' }, - { id: 'mqlt_cd', title: '재질' }, - { id: 'wdth_len', title: '가로길이(m)' }, - { id: 'vrtc_len', title: '세로길이(m)' }, - // { id: 'ang_dir', title: '표고' }, - { id: 'altd_vl', title: '지표고(EL.m)' }, - { id: 'dpthl_vl', title: '맨홀저고(EL.m)' }, - // { id: 'inst_mng_no', title: '기관관리번호' } -]; -export const MsSwageMhlCol2 = [ - { id: 'rowNumber', title: '일련번호' }, - // { id: 'fid', title: 'FID' }, - // { id: 'geom', title: 'geometry' }, - // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, - { id: 'fcltMngNo', title: '시설관리번호' }, - { id: 'ctpvNm', title: '시도' }, - { id: 'sggNm', title: '시군구' }, - // { id: 'pbadmsEmdCd', title: '행정읍면동코드' }, - // { id: 'mapshtNo', title: '도엽번호' }, - // { id: 'mngInstCd', title: '관리기관코드' }, - { id: 'instlYmd', title: '설치일자' }, - // { id: 'lastDrdgnYmd', title: '최종준설일자' }, - { id: 'usgCd', title: '용도' }, - // { id: 'fcltyShpCd', title: '시설물형태코드' }, - // { id: 'mhlKndCd', title: '맨홀종류코드' }, - { id: 'swageMhlDiamVl', title: '하수맨홀구경(mm)' }, - // { id: 'mqltCd', title: '재질코드' }, - { id: 'wdthLen', title: '가로길이(m)' }, - { id: 'vrtcLen', title: '세로길이(m)' }, - // { id: 'angDir', title: '표고' }, - { id: 'altdVl', title: '지표고(EL.m)' }, - { id: 'dpthlVl', title: '맨홀저고(EL.m)' }, - // { id: 'instMngNo', title: '기관관리번호' } -]; -export const MsSwageMhlExcelCol = { - 'fcltMngNo': '시설관리번호', - 'ctpvNm': '시도', - 'sggNm': '시군구', - 'instlYmd': '설치일자', - 'usgCd': '용도', - 'swageMhlDiamVl': '하수맨홀구경(mm)', - 'wdthLen': '가로길이(m)', - 'vrtcLen': '세로길이(m)', - 'altdVl': '지표고(EL.m)', - 'dpthlVl': '맨홀저고(EL.m)' -}; -// 하수맨홀 뚜껑재질 코드표 -export const MS_SWAGE_MHL_CODES = [ - { val: "SBC000", text: "미분류" }, - { val: "SBC001", text: "주철" }, - { val: "SBC002", text: "철판" }, - { val: "SBC003", text: "콘크리트" }, - { val: "SBC004", text: "그레이팅" }, - { val: "SBC091", text: "칼라" }, - { val: "SBC092", text: "S.G" }, - { val: "SBC999", text: "기타" }, - { val: "SBC900", text: "각주철" }, - { val: "SBC901", text: "강재" }, - { val: "SBC902", text: "나무" }, - { val: "SBC903", text: "나무,널판지,합판" }, - { val: "SBC904", text: "대리석" }, - { val: "SBC905", text: "뚜껑없음" }, - { val: "SBC906", text: "보도용콘크리트" }, - { val: "SBC907", text: "스틸(ST)" }, - { val: "SBC908", text: "신형 CON" }, - { val: "SBC909", text: "알루미늄" }, - { val: "SBC910", text: "염화비닐(PE)" }, - { val: "SBC911", text: "인조석" }, - { val: "SBC912", text: "인조석(주철)" }, - { val: "SBC913", text: "인조CONC" }, - { val: "SBC914", text: "철, 철근" }, - { val: "SBC915", text: "칼라콘크리트" }, - { val: "SBC916", text: "플라스틱" }, - { val: "SBC917", text: "PVC" }, - { val: "SBC918", text: "SP" }, - { val: "SBC919", text: "SS" }, - { val: "SBC920", text: "닥타일주철" }, - { val: "SBC921", text: "철근G" }, - { val: "SBC922", text: "폴리에틸렌(PE)" }, - { val: "SBC923", text: "회주철" } -]; -// 하수맨홀 용도 코드표 -export const MS_SWAGE_MHL_USE_CODES = [ - { val: "SMU000 ", text: "미분류" }, - { val: "SMU001 ", text: "우수" }, - { val: "SMU002 ", text: "오수" }, - { val: "SMU003 ", text: "집수" }, - { val: "SMU004 ", text: "합류" }, - { val: "SMU005 ", text: "차집" }, - { val: "SMU999 ", text: "기타" }, - { val: "SMU900 ", text: "1호" }, - { val: "SMU901 ", text: "2호" }, - { val: "SMU902 ", text: "3호" }, - { val: "SMU903 ", text: "4호" }, - { val: "SMU904 ", text: "5호" }, - { val: "SMU905 ", text: "6호" }, - { val: "SMU906 ", text: "7호" }, - { val: "SMU907 ", text: "8호" }, - { val: "SMU908 ", text: "구거용맨홀" }, - { val: "SMU909 ", text: "부관부착맨홀" }, - { val: "SMU910 ", text: "조립식맨홀" }, - { val: "SMU911 ", text: "특1호" }, - { val: "SMU912 ", text: "특2호" }, - { val: "SMU913 ", text: "특3호" }, - { val: "SMU914 ", text: "특4호" }, - { val: "SMU915 ", text: "특5호" }, - { val: "SMU916 ", text: "하수맨홀용도" }, - { val: "SMU917 ", text: "현장타설관거용" } -]; - -// 우수펌프장 -export const ExclnSewagePumpCol = [ - // { id: 'fid', title: 'FID' }, - // { id: 'geom', title: 'geometry' }, - // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, - { id: 'fclt_mng_no', title: '시설관리번호' }, - // { id: 'pbadmsEmdCd', title: '행정읍면동코드' }, - { id: 'mapsht_no', title: '도엽번호' }, - // { id: 'mngInstCd', title: '관리기관코드' }, - { id: 'instl_ymd', title: '설치일자' }, - { id: 'swage_pmstn_nm', title: '하수펌프장명' }, - { id: 'plln_area', title: '부지면적(m²)' }, - // { id: 'opngSttsCd', title: '개통상태코드' }, - // { id: 'usgCd', title: '용도코드' }, - { id: 'daily_prcs_cpct', title: '일일처리용량(㎥)' }, - { id: 'max_impnd_cpct', title: '최대저수용량(㎥)' }, - { id: 'altd_vl', title: '표고(EL.m)' }, - { id: 'wtlv_vl', title: '수위(m)' }, - { id: 'excln_wpmp_ablt_vl', title: '우수양수능력(m²/d)' }, - // { id: 'instMngNo', title: '기관관리번호' }, -] -export const ExclnSewagePumpCol2 = [ - { id: 'rowNumber', title: '일련번호' }, - // { id: 'fid', title: 'FID' }, - // { id: 'geom', title: 'geometry' }, - // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, - { id: 'fcltMngNo', title: '시설관리번호' }, - { id: 'ctpvNm', title: '시도' }, - { id: 'sggNm', title: '시군구' }, - // { id: 'pbadmsEmdCd', title: '행정읍면동코드' }, - // { id: 'mapshtNo', title: '도엽번호' }, - // { id: 'mngInstCd', title: '관리기관코드' }, - { id: 'swagePmstnNm', title: '하수펌프장명' }, - { id: 'instlYmd', title: '설치일자' }, - // { id: 'pllnArea', title: '부지면적(m²)' }, - // { id: 'opngSttsCd', title: '개통상태코드' }, - // { id: 'usgCd', title: '용도코드' }, - { id: 'dailyPrcsCpct', title: '일일처리용량(㎥)' }, - { id: 'maxImpndCpct', title: '최대저수용량(㎥)' }, - { id: 'altdVl', title: '표고(EL.m)' }, - { id: 'wtlvVl', title: '수위(m)' }, - { id: 'exclnWpmpAbltVl', title: '우수양수능력(m²/d)' }, - // { id: 'instMngNo', title: '기관관리번호' }, -] -export const ExclnSewagePumpExcelCol = { - 'fcltMngNo': '시설관리번호', - 'ctpvNm': '시도', - 'sggNm': '시군구', - 'swagePmstnNm': '하수펌프장명', - 'instlYmd': '설치일자', - 'dailyPrcsCpct': '일일처리용량(㎥)', - 'maxImpndCpct': '최대저수용량(㎥)', - 'altdVl': '표고(EL.m)', - 'wtlvVl': '수위(m)', - 'exclnWpmpAbltVl': '우수양수능력(m²/d)' -}; -// 오수펌프장 -export const DrtwSewagePumpCol = [ - // { id: 'fid', title: 'FID' }, - // { id: 'geom', title: 'geometry' }, - // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, - { id: 'fclt_mng_no', title: '시설관리번호' }, - // { id: 'pbadmsEmdCd', title: '행정읍면동코드' }, - { id: 'mapsht_no', title: '도엽번호' }, - // { id: 'mngInstCd', title: '관리기관코드' }, - { id: 'instl_ymd', title: '설치일자' }, - { id: 'swage_pmstn_nm', title: '하수펌프장명' }, - { id: 'plln_area', title: '부지면적(m²)' }, - // { id: 'opngSttsCd', title: '개통상태코드' }, - // { id: 'usgCd', title: '용도코드' }, - { id: 'daily_prcs_cpct', title: '일일처리용량(㎥)' }, - { id: 'max_impnd_cpct', title: '최대저수용량(㎥)' }, - { id: 'altd_vl', title: '표고(EL.m)' }, - { id: 'wtlv_vl', title: '수위(m)' }, - { id: 'excln_wpmp_ablt_vl', title: '오수양수능력(m²/d)' }, - // { id: 'instMngNo', title: '기관관리번호' }, -] -export const DrtwSewagePumpCol2 = [ - { id: 'rowNumber', title: '일련번호' }, - // { id: 'fid', title: 'FID' }, - // { id: 'geom', title: 'geometry' }, - // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, - { id: 'fcltMngNo', title: '시설관리번호' }, - // { id: 'pbadmsEmdCd', title: '행정읍면동코드' }, - // { id: 'mapshtNo', title: '도엽번호' }, - { id: 'ctpvNm', title: '시도' }, - { id: 'sggNm', title: '시군구' }, - // { id: 'mngInstCd', title: '관리기관코드' }, - { id: 'swagePmstnNm', title: '하수펌프장명' }, - { id: 'instlYmd', title: '설치일자' }, - // { id: 'pllnArea', title: '부지면적(m²)' }, - // { id: 'opngSttsCd', title: '개통상태코드' }, - // { id: 'usgCd', title: '용도코드' }, - { id: 'dailyPrcsCpct', title: '일일처리용량(㎥)' }, - { id: 'maxImpndCpct', title: '최대저수용량(㎥)' }, - { id: 'altdVl', title: '표고(EL.m)' }, - { id: 'wtlvVl', title: '수위(m)' }, - { id: 'exclnWpmpAbltVl', title: '오수양수능력(m²/d)' }, - // { id: 'instMngNo', title: '기관관리번호' }, -] -// 펌프장 용도 코드표 -export const DRTW_SEWAGE_PUMP_USE_CODES = [ - { val: "SBE000", text: "미분류" }, - { val: "SBE001", text: "빗물펌프장" }, - { val: "SBE002", text: "오수펌프장" }, - { val: "SBE003", text: "처리장내펌프장" }, - { val: "SBE900", text: "오수배제" }, - { val: "SBE901", text: "우수배제" }, - { val: "SBE999", text: "기타" }, -]; - -// 우수토실 -export const stofcbrCol = [ - // { id: 'ftre_smbol_cd', title: '지형지물부호코드' }, - { id: 'fclt_mng_no', title: '시설관리번호' }, - // { id: 'pbadms_emd_cd', title: '행정읍면동코드' }, - { id: 'mapsht_no', title: '도엽번호' }, - // { id: 'mng_inst_cd', title: '관리기관코드' }, - { id: 'instl_ymd', title: '설치일자' }, - // { id: 'fclty_shp_cd', title: '시설물형태코드' }, - { id: 'worflw_wall_hgt', title: '월류벽높이(m)' }, - // { id: 'mhl_exst_yn_cd', title: '맨홀존재여부코드' }, - // { id: 'swage_mhl_ftre_smbol_cd', title: '하수맨홀지형지물부호코드' }, - { id: 'swage_mhl_mng_no', title: '하수맨홀관리번호' }, - { id: 'drtn_angl', title: '방향각도(°)' }, -] -export const stofcbrCol2 = [ - { id: 'rowNumber', title: '일련번호' }, - // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, - { id: 'fcltMngNo', title: '시설관리번호' }, - // { id: 'pbadmsEmdCd', title: '행정읍면동코드' }, - { id: 'mapshtNo', title: '도엽번호' }, - { id: 'ctpvNm', title: '시도' }, - { id: 'sggNm', title: '시군구' }, - // { id: 'mngInstCd', title: '관리기관코드' }, - { id: 'instlYmd', title: '설치일자' }, - // { id: 'fcltyShpCd', title: '시설물형태코드' }, - { id: 'worflwWallHgt', title: '월류벽높이(m)' }, - // { id: 'mhlExstYnCd', title: '맨홀존재여부코드' }, - // { id: 'swageMhlFtreSmbolCd', title: '하수맨홀지형지물부호코드' }, - { id: 'swageMhlMngNo', title: '하수맨홀관리번호' }, - { id: 'drtnAngl', title: '방향각도(°)' }, -] -export const stofcbrExcelCol = { - 'fcltMngNo': '시설관리번호', - 'mapshtNo': '도엽번호', - 'ctpvNm': '시도', - 'sggNm': '시군구', - 'instlYmd': '설치일자', - 'worflwWallHgt': '월류벽높이(m)', - 'swageMhlMngNo': '하수맨홀관리번호', - 'drtnAngl': '방향각도(°)' -}; - -// 중점우수토실 측정지점 -export const untrtSwgRainCol1 = [ - { id: 'manage_no', title: '관리번호' }, - { id: 'swr_ppl_eqp_nm', title: '처리구역' }, - { id: 'swr_ppl_trt_nm', title: '처리분구' }, - { id: 'addr', title: '인근주소' }, - { id: 'chmbr_xcnts', title: '토실 위도' }, - { id: 'chmbr_ydnts', title: '토실 경도' } -]; - -// 중점우수토실 측정지점 시설현황 -export const untrtSwgRainCol2 = [ - { id: 'mesure_spot_se', title: '구분' }, // 유량계측정방식 기타 - { id: 'discharge_pipe_spec', title: '방류관로 규격(m)' }, - { id: 'swr_ppl_eqp_ar', title: '배수구역 면적(m²)' }, - { id: 'swr_trt_popl', title: '하수처리 인구(인)' }, - { id: 'screen_instl_at', title: '스크린설치 여부' }, - { id: 'weir_hg', title: '위어 높이(m)' }, - { id: 'weir_lt', title: '위어 길이(m)' } -]; - -// 중점우수토실 강우정보 (id값 틀릴 수 있음) -export const untrtSwgRainCol3 = [ - { id: 'rainBeginDt', title: '강우시작일시' }, - { id: 'rainEndDt', title: '강우종료일시' }, - { id: 'precdngDry', title: '선행건기(h)' }, - { id: 'rainInfo', title: '강우정보' }, - { id: 'rainFall', title: '누적강우량(mm)' }, - { id: 'cntncTime', title: '지속시간(h)' }, - { id: 'rainIn', title: '강우강도(mm/h)' } -]; - -// 중점우수토실 측정정보 (id값 틀릴 수 있음) -export const untrtSwgRainCol4 = [ - { id: 'mesureBeginDt', title: '측정시작일시' }, - { id: 'mesureEndDt', title: '측정종료일시' }, - { id: 'mesureIntrvl', title: '측정간격(분)' }, - { id: 'mesureMhrlsInstlLc', title: '측정기기 설치위치' }, - { id: 'flwmtrCombined', title: '유량계 종류 및 모델명' }, // 합쳐진 컬럼 - { id: 'qltwtrBeginDt', title: '채수시작일시' }, - { id: 'qltwtrEndDt', title: '채수종료일시' }, - { id: 'qltwtrCo', title: '채수횟수' } -]; - -// 중점우수토실 수량/수질측정 정보 -export const untrtSwgRainCol5 = [ - { id: 'odr', title: '회차' }, - { id: 'mesureDt', title: '측정일자' }, - { id: 'mesureQy', title: '수량(m³)' }, - { id: 'mesureBod', title: 'BOD(mg/L)' }, - { id: 'mesureToc', title: 'TOC(mg/L)' }, - { id: 'mesureLoad', title: '오염부하량(kg)' } -]; - -// 🎯 날짜 필드들 (yyyymmdd -> yyyy년 mm월 dd일) -export const dateFields = [ - 'instl_ymd', 'last_drdgn_ymd', 'cmcn_ymd', 'oprtng_strt_ymd', 'clsg_ymd', - 'mesure_begin_dt', 'mesure_end_dt', 'qltwtr_begin_dt', 'qltwtr_end_dt', - 'rain_begin_dt', 'rain_end_dt', 'mesureDt', - // 🎯 camelCase 버전 추가 - 'instlYmd', 'lastDrdgnYmd', 'cmcnYmd', 'oprtngStrtYmd', 'clsgYmd', - 'mesureBeginDt', 'mesureEndDt', 'qltwtrBeginDt', 'qltwtrEndDt', - 'rainBeginDt', 'rainEndDt', - 'flfmtBgngYmd', 'flfmtEndYmd', 'flfmtYr' -]; - -// 🎯 연월 필드들 (yyyymm -> yyyy년 m월) -export const yearMonthFields = ['aplcnYm',]; - -// 🎯 날짜시간 필드들 (yyyy-mm-dd hh:mm:ss -> yyyy년 m월 d일 hh:mm) -export const dateTimeFields = ['regDt',]; - -// 🎯 숫자 필드들 (천 단위 구분자) -export const numberFields = [ - 'pdmt', 'prlg', 'avg_grdn', 'drtw_mltv_area', 'excln_mltv_area', 'tmpt_dpth', 'scend_dpth', 'trfln_cnt', 'vrtc_len', 'wdth_len', - 'fclt_cpct', 'plln_area', 'prcs_zone_area_sum', 'swtrmt_ppltn_cnt', 'stty_crtr_bod', 'stty_crtr_toc', 'stty_crtr_ss', 'stty_crtr_tn', 'stty_crtr_tph', 'stty_crtr_clbcl_cnt', - 'swage_mh_diam', 'wdth_len', 'vrtc_len', 'altd_vl', 'dpthlVl', - 'plln_area', 'daily_prcs_cpct', 'max_impnd_cpct', 'altd_vl', 'wtlv_vl', 'excln_wpmp_ablt', - 'worflw_wall_hgt', - 'swr_ppl_eqp_ar', 'swr_trt_popl', 'weir_hg', 'weir_lt', - 'precdng_dry', 'rain_fall', 'cntnc_time', 'rain_in', - 'mesure_intrvl', 'qltwtr_co', 'mesureQy', 'mesureBod', 'mesureToc', 'mesureLoad', - // 🎯 camelCase 버전 추가 - 'avgGrdn', 'drtwMltvArea', 'exclnMltvArea', 'tmptDpth', 'scendDpth', 'trflnCnt', 'vrtcLen', 'wdthLen', - 'fcltCpct', 'pllnArea', 'prcsZoneAreaSum', 'swtrmtPpltnCnt', 'sttyCltrBod', 'sttyCltrToc', 'sttyCltrSs', 'sttyCltrTn', 'sttyCltrTph', 'sttyCltrClbclCnt', - 'swageMhDiam', 'altdVl', 'dpthlVl', - 'dailyPrcsCpct', 'maxImpndCpct', 'wtlvVl', 'exclnWpmpAblt', - 'worflwWallHgt', - 'swrPplEqpAr', 'swrTrtPopl', 'weirHg', 'weirLt', - 'precdngDry', 'rainFall', 'cntncTime', 'rainIn', - 'mesureIntrvl', 'qltwtrCo', 'rnysnFvlc', 'clarFvlc', - 'bizAmt', 'count', - 'chmbr_xcnts', 'chmbr_ydnts' -]; diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx b/apps/sn/app/html/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx deleted file mode 100644 index 0e2ec3780..000000000 --- a/apps/sn/app/html/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx +++ /dev/null @@ -1,163 +0,0 @@ -/** - * ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수관로) - * - * 📄 MsSwagePpl - * 하수관로 정보 컴포넌트 - * - * - 하수관로의 자체입력정보 표시 및 편집 기능 - * - 보수 및 갱신, 청소 및 준설, 점검 및 진단 데이터 조회 및 표시 - * - VerticalTable을 통한 입력 폼 렌더링 - * - InputSection을 통한 운영 데이터 관리 - * - 저장 버튼을 통한 데이터 저장 기능 - * - * 🔗 사용 API - * - 시설별 운영 정보 조회: GET /api/v1/bizInfo/bizByFclt (sewerage-service) - */ -'use client' -import { VerticalTable } from '@/components/ui/verticalTable'; -import React, { useState, useEffect } from 'react' -import InputSection from '../InputSection'; -import { handleInputChange } from '../common'; -import { getData } from "@/components/lib/api"; -import useStore from '@/store/useStore'; - -// 하수관로 -const inputColumn = [ - { id: "instlMbdyCd", title: "설치주제", type: "select", options: ["BTL", "재정사업"] }, - { id: "swtrmtZoneNm", title: "처리구역", type: "input" }, - { id: "mltvZoneNm", title: "배수구역", type: "input" }, - { id: "mltvDtdvNm", title: "배수분구", type: "input" }, - { id: "lineCd", title: "지선/간선", type: "select", options: ["지선", "간선"] }, - { id: "floCd", title: "흐름방식", type: "select", options: ["자연유하", "압송"] }, - { id: "layCd", title: "매설위치", type: "select", options: ["도로", "하상"] }, -]; - -type operationDataType = { year: string; type: string, bizInfoNm: string, bizType: string }; - -const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: () => void }) => { - const [formData, setFormData] = useState<any>({}); - const [repairData, setRepairData] = useState<operationDataType[]>([]); - const [cleaningData, setCleaningData] = useState<operationDataType[]>([]); - const [inspectionData, setInspectionData] = useState<operationDataType[]>([]); - const dataMap = { - repair: { data: repairData, setData: setRepairData }, - cleaning: { data: cleaningData, setData: setCleaningData }, - inspection: { data: inspectionData, setData: setInspectionData }, - }; - - const { BIZ_SE_CD, BIZ_TY_CD } = useStore(); - - // 마운트 및 업데이트 시 API 호출 - - useEffect(() => { - if (featureData) { - setFormData(featureData); - } - - const fetchAll = async () => { - const operationData = await getData(`v1/bizInfo/bizByFclt?mngInstCd=${featureData?.mng_inst_cd}&fcltMngNo=${featureData?.fclt_mng_no}&page=0&size=1`); - - let inspectionArr: operationDataType[] = []; - let cleaningArr: operationDataType[] = []; - let repairArr: operationDataType[] = []; - - if (Array.isArray(operationData)) { - operationData.forEach(item => { - if (!item.flfmt_yr) return; - // biz_se_cd에 해당하는 type명 찾기 - const seCodeObj = BIZ_SE_CD.find(code => code.lwpoCd === item.biz_se_cd); - const tyCodeObj = BIZ_TY_CD.find(code => code.lwpoCd === item.biz_ty_cd); - const seTypeName = seCodeObj ? seCodeObj.lwpoCdNm : ""; - const tyTypeName = tyCodeObj ? tyCodeObj.lwpoCdNm : ""; - switch (item.biz_se_cd) { - case "100": - inspectionArr.push({ - year: String(item.flfmt_yr), - type: seTypeName, - bizInfoNm: String(item.biz_info_nm), - bizType: tyTypeName, - }); - break; - case "203": - cleaningArr.push({ - year: String(item.flfmt_yr), - type: seTypeName, - bizInfoNm: String(item.biz_info_nm), - bizType: tyTypeName, - }); - break; - case "200": - case "201": - case "204": - repairArr.push({ - year: String(item.flfmt_yr), - type: seTypeName, - bizInfoNm: String(item.biz_info_nm), - bizType: tyTypeName, - }); - break; - } - }); - } - - setInspectionData(inspectionArr); - setCleaningData(cleaningArr); - setRepairData(repairArr); - }; - fetchAll(); - }, [featureData]); - - // 폼 데이터가 변경될 때 호출될 핸들러 - const handleFormDataChange = (newFormData: any) => { - setFormData(newFormData); - }; - - // 저장 버튼 클릭 시 최신 formData를 부모에게 전달 - const handleSaveClick = () => { - if (onSaveClick) { - // 저장 전에 formData를 부모 컴포넌트의 handleSaveClick에 전달 - onSaveClick(); - } - }; - - - return ( - <> - <VerticalTable - headName='입력정보' - tableData={featureData ? [formData] : []} - headlist={inputColumn} - pageSize={20} - total={0} - cellClick={undefined} - isEditable={true} - onPageChange={function (page: number): void { - throw new Error("Function not implemented."); - }} - showSaveButton={true} - onSaveClick={handleSaveClick} - onFormDataChange={handleFormDataChange} // 폼 데이터 변경 콜백 추가 - /> - <InputSection - title="보수 및 갱신" - data={repairData} - sectionKey="repair" - onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)} - /> - <InputSection - title="청소 및 준설" - data={cleaningData} - sectionKey="cleaning" - onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)} - /> - <InputSection - title="점검 및 진단" - data={inspectionData} - sectionKey="inspection" - onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)} - /> - </> - ) -} - -export default MsSwagePpl \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/Ssc.tsx b/apps/sn/app/html/(map-layout)/map/components/popupComponent/Ssc.tsx deleted file mode 100644 index 683fb9df0..000000000 --- a/apps/sn/app/html/(map-layout)/map/components/popupComponent/Ssc.tsx +++ /dev/null @@ -1,328 +0,0 @@ -/** - * ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (우수토실) - * - * 📄 Ssc - * 우수토실 정보 컴포넌트 - * - * - 우수토실의 자체입력정보 표시 및 편집 기능 - * - 중점우수토실 운영정보 모달 표시 (강우/측정 정보, 수량/수질측정 정보) - * - VerticalTable을 통한 입력 폼 렌더링 - * - 저장 버튼을 통한 데이터 저장 기능 - * - * 🔗 사용 API - * - 위경도로 강우시 미처리 하수 목록 조회: GET /api/v1/onsemiro/getSwgRainsByGeomety (sewerage-service) - * - 강우시 월류 하수 관리 모니터링 조회: GET /api/v1/onsemiro/swgRain/{adstDstrCd}/{manageNo}/mntrngs (sewerage-service) - */ -import { UiModal, UiTable } from '@common/business_components/ui'; -import { Button, TableHead, TableRow } from '@common/components/ui'; -import React, { useState, useEffect } from 'react'; -import { untrtSwgRainCol3, untrtSwgRainCol4, untrtSwgRainCol5 } from '../common/table'; -import { VerticalTable } from '@/components/ui/verticalTable'; -import { getRawJson } from "@/components/lib/api"; -import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue'; -import { Label } from '@radix-ui/react-label'; - -const inputColumn = [ - { id: "prrtymngYn", title: "중점여부", type: "select", options: ["우수토실", "중점우수토실"] }, - { id: "swtrmtZoneNm", title: "처리구역", type: "input" }, -]; - -const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: { - featureData: any, - isMainSsc?: boolean, - chmbrXcnts?: string, - chmbrYdnts?: string, - onSaveClick?: () => void -}) => { - const [isModalOpen, setIsModalOpen] = useState(false); - const [isDataLoaded, setIsDataLoaded] = useState(false); - const [formData, setFormData] = useState<any>({}); - const [rainMeasureData, setRainMeasureData] = useState<any>({}); // 강우, 측정 정보 - const [mntrngsData, setMntrngsData] = useState<any[]>([]); // 수량/수질측정 정보 - const [totalCount, setTotalCount] = useState<number>(0); - const [totalDetailCount, setTotalDetailCount] = useState<number>(0); - const [selectedRowIndex, setSelectedRowIndex] = useState<number>(-1); - const [curPage, setCurPage] = useState<number>(1); - const [curDetailPage, setCurDetailPage] = useState<number>(1); - const [isResetPage, setIsResetPage] = useState<boolean>(true); - const [isResetDetailPage, setIsResetDetailPage] = useState<boolean>(true); - const [isResetSelection, setIsResetSelection] = useState<boolean>(false); - - // 강우, 측정 정보 API 호출 함수 - const fetchRainMeasureData = async (chmbrXcnts: string, chmbrYdnts: string) => { - setIsResetDetailPage(true); - setIsResetSelection(true); - if (!chmbrXcnts || !chmbrYdnts) { - console.warn('추가 데이터 호출 실패: 필수 매개변수 누락', { chmbrXcnts, chmbrYdnts }); - return []; - } - - try { - const queryParams = new URLSearchParams({ - chmbrXcnts: chmbrXcnts, - chmbrYdnts: chmbrYdnts, - page: `${curPage - 1}`, - size: '3', - }); - - const result = await getRawJson(`/sewerage-service/api/v1/onsemiro/getSwgRainsByGeomety?${queryParams.toString()}`); - - // helper: "YYYY-MM-DD HH:MM:SS" -> "MM-DD HH:MM" - const formatNoYearNoSec = (val: any) => { - if (!val && val !== 0) return "-"; - const s = String(val); - // 정규식으로 안전하게 매칭 후 변환 - const m = s.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::\d{2})?$/); - if (m) { - const [, , mm, dd, hh, min] = m; - return `${mm}-${dd} ${hh}:${min}`; - } - // 포맷이 예상과 다르면, 길이에 따라 잘라보기 - if (s.length >= 16) return s.substring(5, 16); // "YYYY-" 제거하고 초 전까지 - return s; - }; - - const processedDataArray: any[] = result.content.map((data: any) => { - // 🎯 formatDisplayData로 기본 포맷팅 적용 - const formattedItem = formatDisplayData(data); - const flwmtrInstTp = data.flwmtrInstTp === 'R' ? '이동식' : data.flwmtrInstTp === 'S' ? '고정식' : '-'; - const flwmtrMesureTp = data.flwmtrMesureTp === 'R' ? '레이더' : data.flwmtrMesureTp === 'P' ? '플륨' - : data.flwmtrMesureTp === 'U' ? '초음파' : data.flwmtrMesureTp === 'E' ? '기타' : '-'; - const flwmtrCombined = `${flwmtrInstTp}, ${flwmtrMesureTp}`; - // 🎯 추가 데이터 조합 - return { - ...formattedItem, - rainInfo: `${data.rainInfo === '0' ? '강우량계' : data.observatory || '-'}`, // 강우정보 0: 강우량계,1: 관측기관 이면 OBSERVATORY 이값사용 - mesureIntrvl: `${data.mesureIntrvlGbn === '1' ? '1분' : (data.mesureIntrvlGbn === '0' ? data.mesureIntrvl + '분' : '-')}`, // 측정간격 - flwmtrCombined: `${flwmtrCombined} / ${data.flwmtrKnd || '-'}`, // 유량계종류 및 모델명 - mesureBeginDt: data.mesureBeginDt ? formatNoYearNoSec(data.mesureBeginDt) : '-', // 수량 측정시작일 - mesureEndDt: data.mesureEndDt ? formatNoYearNoSec(data.mesureEndDt) : '-', // 수량 측정종료일 - qltwtrBeginDt: data.qltwtrBeginDt ? formatNoYearNoSec(data.qltwtrBeginDt) : '-', // 수질 측정시작일(채수시작일) - qltwtrEndDt: data.qltwtrEndDt ? formatNoYearNoSec(data.qltwtrEndDt) : '-', // 수질 측정종료일(채수종료일) - }; - }); - setRainMeasureData(processedDataArray); - setTotalCount(result?.totalElements || 0); - } catch (error) { - console.error('추가 데이터 로딩 실패:', error); - return []; - } - }; - - // 수량/수질측정 정보 API 호출 함수 - const fetchMntrngsData = async (adstDstrCd: string, manageNo: string) => { - setIsResetDetailPage(true); - if (!adstDstrCd || !manageNo) { - console.warn('추가 데이터 호출 실패: 필수 매개변수 누락', { adstDstrCd, manageNo }); - return []; - } - - try { - const queryParams = new URLSearchParams({ - page: `${curDetailPage - 1}`, - size: '3', - }); - - const result = await getRawJson(`/sewerage-service/api/v1/onsemiro/swgRain/${adstDstrCd}/${manageNo}/mntrngs?${queryParams.toString()}`); - - // 각 아이템 전처리 - const processedDataArray = result.content.map((data: any) => { - // 기본 포맷팅 적용 - const formattedItem = formatDisplayData(data); - return { - ...formattedItem, - }; - }); - - setMntrngsData(processedDataArray); - setTotalDetailCount(result?.totalElements || 0); - } catch (error) { - console.error('추가 데이터 로딩 실패:', error); - return []; - } - }; - - - // 🎯 추가 데이터는 별도 useEffect로 비동기 로딩 - useEffect(() => { - // 강우/측정 정보 데이터 호출 - if (isMainSsc && chmbrXcnts && chmbrYdnts) { - fetchRainMeasureData(chmbrXcnts, chmbrYdnts) - } - }, [chmbrXcnts, chmbrYdnts]); - - const openModal = () => { - setIsModalOpen(true); - }; - - const closeModal = () => { - setIsModalOpen(false); - if (!isDataLoaded) { - setTimeout(() => setIsDataLoaded(true), 100); - } - }; - - // featureData가 변경될 때 formData 초기화 - useEffect(() => { - if (featureData) { - setFormData(featureData); - setMntrngsData([]); - } - }, [featureData]); - - // featureData가 변경될 때 formData 초기화 - useEffect(() => { - // showTable이 true일 때만 fetch (최초 렌더 방지) - setIsResetDetailPage(false); - setSelectedRowIndex(-1); - if (chmbrXcnts && chmbrYdnts) fetchRainMeasureData(chmbrXcnts, chmbrYdnts); - }, [curPage]); - - // 페이지 변경 시마다 데이터 fetch - useEffect(() => { - if (chmbrXcnts && chmbrYdnts && selectedRowIndex !== -1) fetchMntrngsData(rainMeasureData[selectedRowIndex].adstDstrCd, rainMeasureData[selectedRowIndex].manageNo); - }, [curDetailPage]); - - // 폼 데이터가 변경될 때 호출될 핸들러 - const handleFormDataChange = (newFormData: any) => { - setFormData(newFormData); - }; - - // 저장 버튼 클릭 시 최신 formData를 부모에게 전달 - const handleSaveClick = () => { - if (onSaveClick) { - // 저장 전에 formData를 부모 컴포넌트의 handleSaveClick에 전달 - onSaveClick(); - } - }; - - // 테이블 셀 클릭 핸들러 추가 - const handleCellClick = async (index: number | undefined, headId: string) => { - if (index !== undefined && rainMeasureData) { - await fetchMntrngsData(rainMeasureData[index].adstDstrCd, rainMeasureData[index].manageNo); - setSelectedRowIndex(index); - } - }; - - const renderModalBody = () => ( - <div className='grid grid-cols-1 gap-4 p-4 mr-4 mt-4'> - <div className='border rounded-lg pt-4 bg-white'> - <Label className='text-2xl font-bold text-label ml-4'>강우/측정 정보</Label> - <hr className="my-4 ml-4 mr-4 border-t border-gray-300" /> - {/* 강우정보 테이블 */} - <UiTable - headName='' - tableData={rainMeasureData} - headlist={untrtSwgRainCol3} - pageSize={3} - total={0} - cellClick={handleCellClick} - onPageChange={setCurPage} - resetPageNo={isResetPage} - resetSelection={isResetSelection} - index={selectedRowIndex} - /> - - {/* 측정정보 테이블 */} - <UiTable - headName='' - tableData={rainMeasureData} - headlist={untrtSwgRainCol4} - pageSize={3} - total={totalCount} - cellClick={handleCellClick} - onPageChange={setCurPage} - resetPageNo={isResetPage} - resetSelection={isResetSelection} - index={selectedRowIndex} - > - {/* 커스텀 헤더 */} - <TableRow> - <TableHead colSpan={5} className="text-center bg-blue-100 font-bold">수량</TableHead> - <TableHead colSpan={3} className="text-center bg-blue-100 font-bold">수질</TableHead> - </TableRow> - <TableRow> - <TableHead className="text-center">측정시작일시</TableHead> - <TableHead className="text-center">측정종료일시</TableHead> - <TableHead className="text-center">측정간격(분)</TableHead> - <TableHead className="text-center">측정기기설치위치</TableHead> - <TableHead className="text-center">유량계 종류 및 모델명</TableHead> - <TableHead className="text-center">채수시작일시</TableHead> - <TableHead className="text-center">채수종료일시</TableHead> - <TableHead className="text-center">채수횟수</TableHead> - </TableRow> - </UiTable> - </div> - - <div className='border rounded-lg pt-4 bg-white'> - {/* 수량/수질측정 정보 테이블 */} - <Label className='text-2xl font-bold text-label ml-4'>수량/수질측정 정보</Label> - <hr className="my-4 ml-4 mr-4 border-t border-gray-300" /> - <UiTable - headName='' - tableData={mntrngsData} - headlist={untrtSwgRainCol5} - pageSize={3} - total={totalDetailCount} - cellClick={undefined} - onPageChange={setCurDetailPage} - resetPageNo={isResetDetailPage} - /> - </div> - </div> - ) - - return ( - <div> - {!isMainSsc && ( - <VerticalTable - headName='입력정보' - tableData={featureData ? [formData] : []} - headlist={inputColumn} - pageSize={0} - total={0} - cellClick={undefined} - isEditable={true} - onPageChange={function (): void { - throw new Error('Function not implemented.'); - }} - showSaveButton={true} - onSaveClick={handleSaveClick} - onFormDataChange={handleFormDataChange} // 폼 데이터 변경 콜백 추가 - /> - )} - - {isMainSsc && ( - <div> - <div className="card-header"> - <h3 className="text-2xl mx-3 font-medium text-label">중점우수토실 운영정보</h3> - </div> - <Button - className='mx-auto block w-64 mt-3 py-2 text-white rounded-sm text-sm font-medium hover:bg-blue-700 bg-blue-600' - onClick={openModal} - > - 상세보기 - </Button> - - <UiModal - open={isModalOpen} - onOpenChange={setIsModalOpen} - bodyClass={` - h-[100vh] - w-[60vw] - max-w-none - max-h-none - overflow-y-auto - z-[101] - `} - body={renderModalBody()} - noHeaderYn='Y' - noFooterYn='Y' - /> - </div> - )} - </div> - ) -} - -export default Ssc diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx b/apps/sn/app/html/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx deleted file mode 100644 index 8b029673f..000000000 --- a/apps/sn/app/html/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/** - * ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수처리시설) - * - * 📄 SwtrmtFclt - * 하수처리시설 정보 컴포넌트 - * - * - 하수처리시설의 디지털 자료관 연계 정보 표시 - * - VerticalTable을 통한 테이블 렌더링 - * - 조회 버튼을 통한 모달 표시 및 ReportTab 연동 - */ -'use client' -import React, { useState } from 'react' -import { VerticalTable } from '@/components/ui/verticalTable'; -import { UiModal } from '@common/business_components/ui'; -import { Button } from '@common/components/ui'; -import ReportTab from '../../searchModal/reportTab'; - -// 하수처리시설 -const headList = [ - { id: "title", title: "하수도정비 기본계획보고서" }, -]; - -const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => { - const [isModalOpen, setIsModalOpen] = useState(false); - - const ctpvNm = gisObject?.ctpv_nm; - const sggNm = gisObject?.sgg_nm; - - return ( - <div> - <VerticalTable - children={{ - title: <Button className="gray" onClick={() => setIsModalOpen(true)}>조회</Button> - }} - headName='디지털 자료관 연계정보' - tableData={[]} - headlist={headList} - pageSize={20} - total={0} - cellClick={undefined} - isEditable={true} - onPageChange={function (page: number): void { - throw new Error("Function not implemented."); - }} - /> - <UiModal - open={isModalOpen} - onOpenChange={setIsModalOpen} - headerTitle="하수도정비 기본계획보고서" - headerTitleClass="text-3xl font-semibold text-gray-700" - bodyClass={` - h-[100vh] - w-[60vw] - max-w-[60vw] - max-h-[100vh] - overflow-hidden - z-[101] - `} - body={ - <ReportTab - open={isModalOpen} - isSearch={false} - ctpvNm={ctpvNm} - sggNm={sggNm} - /> - } - useDirectRender={true} - /> - </div > - ) -} - -export default SwtrmtFclt \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/chart/aplcnYms.ts b/apps/sn/app/html/(map-layout)/map/components/popupComponent/chart/aplcnYms.ts deleted file mode 100644 index aba8d2eed..000000000 --- a/apps/sn/app/html/(map-layout)/map/components/popupComponent/chart/aplcnYms.ts +++ /dev/null @@ -1,271 +0,0 @@ -import {useState, useEffect} from 'react'; -import { getData } from '@/components/lib/api'; -import moment from "moment"; - -export type TInftwtAnls = { - inftwtAnlsSn:number; - aplcnYm: string; - ctpvCd: string; - sggCd: string; - swtrmtFcltNm: string; - atflCd: string; - atflId: string; - atflInpNm: string; - atflHrPttnNm: string; - atflMpngNm: string; - atflOilqNm: string; - atflNm: string; - drtwSwtcRt:number; - anlsInfo: string; - scsYn: string; -}; -export type TInftwtAnlsRslt = { - inftwtAnlsSn: number; - pplMngNo: string; - anlsTm: string; - predcOilq:number; -} -export type TRdiiAnls = { - rainInfwtAnlsSn:number; - aplcnYm: string; - ctpvCd: string; - sggCd: string; - swtrmtFcltNm: string; - atflCd: string; - atflId: string; - atflInpNm: string; - atflHrPttnNm: string; - atflMpngNm: string; - atflRainNm: string; - atflNm: string; - drtwSwtcRt:number; - anlsInfo: string; - scsYn: string; - srcNm: string; // 출처이름 -}; -export type TRdiiAnlsRslt = { - rainInfwtAnlsSn: number; - pplMngNo: string; - anlsTm: string; - predcOilq:number; - minq:number; -} -export type TOilqData = { - inftwtAnlsSn: number; // 침입수분석 일련번호 - pplMngNo: string; // 관로관리번호 - mtrngDt: string; // 계측일시 - oilq: number; // 유량 -} -export type TRainData = { - rainInfwtAnlsSn: number; - mtrngDt: string; - amrf: number; // 강우량 -} - -function roundTo(n: number, digits: number): number { - const factor = Math.pow(10, digits); - return Math.round(n * factor) / factor; -} - -const getOptionsAplcnYm = (yyyymms: string[]): { text: string; val: string, hidden?: boolean }[] => { - const yyyymms1 : { text: string; val: string; hidden?: boolean }[] = yyyymms.map(dateStr => { - const year = dateStr.slice(0, 4); - const month = dateStr.slice(4, 6); - return { - text: `${year}-${month}`, - val: dateStr - }; - }); - yyyymms1.unshift({ val: '0', text: '선택', hidden: true }); - return yyyymms1; -}; -const selectAplcnYms = async(gubun:string) => { // 상수사용월 - try { - const queryString = ''; - const result = await getData(`v1/${gubun}/aplcnYm${queryString ? `?${queryString}` : ''}`); - if (result && result.length > 0) { - return getOptionsAplcnYm(result); - } - } catch (error) { - console.error(error); - } -}; - -export const useAplcnYms = (gubun:string) => { - const [data, setData] = useState<{ text: string; val: string }[]>([]); - - useEffect(() => { - const fetch = async (gubun:string) => { - const result = await selectAplcnYms(gubun); - if (result) setData(result); - }; - fetch(gubun); - }, [gubun]); - - return data; -}; -export const getOptionsInftwtAnlsInfo = (anlsArr: TInftwtAnls[]): { text: string; val: number, hidden?: boolean }[] => { - const options : { text: string; val: number; hidden?: boolean }[] = anlsArr.map(anls => ({ - text: anls.anlsInfo, - val: anls.inftwtAnlsSn, - })); - options.unshift({ val: 0, text: '선택', hidden: true }); - return options; -}; - -export const selectInftwtAnls = async(aplcnYm:string) => { // 침입수분석 - try { - const queryString = aplcnYm ? `aplcnYmFrom=${aplcnYm}&aplcnYmTo=${aplcnYm}` : ''; - const result = await getData(`v1/inftwtAnls${queryString ? `?${queryString}` : ''}`); - if (result && result.numberOfElements > 0) { - return result.content; - } - } catch (error) { - console.error(error); - } -}; -export const getChartDataInftwtAnlsRslt = (arr: TInftwtAnlsRslt[]): { label: string; val: number }[] => { - const options = arr.map(anlsRslt => ({ - label: moment(anlsRslt.anlsTm, "YYYYMMDDHHmmss").format("HH시"), - val: roundTo(anlsRslt.predcOilq, 2), - })); - return options; -}; -export const selectInftwtAnlsRslt = async(inftwtAnlsSn:number, pplMngNo:string) => { // 침입수분석 결과 - try { - if ((inftwtAnlsSn === undefined || inftwtAnlsSn === 0) || !pplMngNo) { - console.warn("inftwtAnlsSn or pplMngNo is not provided"); - return []; - } - const result = await getData(`v1/inftwtAnlsRslt/ppl/${inftwtAnlsSn}/${pplMngNo}`); - if (result && result.numberOfElements > 0) { - return result.content; - } - } catch (error) { - console.error(error); - } -}; -export const getChartDataOilq = (arr: TOilqData[]): { label: string; val: number }[] => { - const options = arr.map(anlsRslt => ({ - label: anlsRslt.mtrngDt, - val: roundTo(anlsRslt.oilq, 2), - })); - return options; -}; -export const selectOilq = async(inftwtAnlsSn:number, pplMngNo:string) => { // 유량 - try { - if ((inftwtAnlsSn === undefined || inftwtAnlsSn === 0) || !pplMngNo) { - console.warn("inftwtAnlsSn or pplMngNo is not provided"); - return []; - } - const result = await getData(`v1/inftwtAnlsRslt/oilqPpl/${inftwtAnlsSn}/${pplMngNo}`); - if (result && result.numberOfElements > 0) { - return result.content; - } - } catch (error) { - console.error(error); - } -}; -export const selectPplInftwtQty = async(inftwtAnlsSn:number, pplMngNo:string) => { // 침입수량 - try { - if ((inftwtAnlsSn === undefined || inftwtAnlsSn === 0) || !pplMngNo) { - console.warn("inftwtAnlsSn or pplMngNo is not provided"); - return []; - } - const result = await getData(`v1/inftwtAnlsRslt/pplInftwtQty/${inftwtAnlsSn}/${pplMngNo}`); - if (result.length > 0) { - return result[0].avg_day_flow; - } else { - return 0; // 데이터가 없을 경우 0 반환 - } - } catch (error) { - console.error(error); - } -}; -//---------- -export const selectPplRdiiQty = async(rainInfwtAnlsSn:number, pplMngNo:string) => { // rdii값 - try { - if ((rainInfwtAnlsSn === undefined || rainInfwtAnlsSn === 0) || !pplMngNo) { - console.warn("rainInfwtAnlsSn or pplMngNo is not provided"); - return []; - } - const result = await getData(`v1/rdiiAnlsRslt/pplRdiiQty/${rainInfwtAnlsSn}/${pplMngNo}`); - if (result?.sustaintime > 0) { - return result; - } - } catch (error) { - console.error(error); - } -}; - -export const getOptionsRdiiAnlsInfo = (anlsArr: TRdiiAnls[]): { text: string; val: number, hidden?: boolean }[] => { - const options : { text: string; val: number; hidden?: boolean }[] = anlsArr.map(anls => ({ - text: anls.anlsInfo, - val: anls.rainInfwtAnlsSn, - })); - options.unshift({ val: 0, text: '선택', hidden: true }); - return options; -}; - -export const selectRdiiAnls = async(aplcnYm:string) => { // 침입수분석 - try { - const queryString = aplcnYm ? `aplcnYmFrom=${aplcnYm}&aplcnYmTo=${aplcnYm}` : ''; - const result = await getData(`v1/rdiiAnls${queryString ? `?${queryString}` : ''}`); - if (result && result.numberOfElements > 0) { - return result.content; - } - } catch (error) { - console.error(error); - } -}; -export const getChartDataRdiiAnlsRslt = (arr: TRdiiAnlsRslt[]): { label: string; lineVal0: number, barVal0:number }[] => { - const options = arr.map(anlsRslt => ({ - label: moment(anlsRslt.anlsTm, "YYYYMMDDHHmmss").format("MM-DD HH시"), - lineVal0: roundTo(anlsRslt.predcOilq, 2), - barVal0: 0 - })); - return options; -}; -export const getChartDataRdiiAnlsRsltMinq = (arr: TRdiiAnlsRslt[]): { label: string; val: number }[] => { - const options = arr.map(anlsRslt => ({ - label: moment(anlsRslt.anlsTm, "YYYYMMDDHHmmss").format("MM-DD HH시"), - val: roundTo(anlsRslt.minq, 2), - })); - return options; -}; -export const selectRdiiAnlsRslt = async(rainInfwtAnlsSn:number, pplMngNo:string) => { // RDII분석 결과 - try { - if ((rainInfwtAnlsSn === undefined || rainInfwtAnlsSn === 0) || !pplMngNo) { - console.warn("RdiiAnlsSn or pplMngNo is not provided"); - return []; - } - const result = await getData(`v1/rdiiAnlsRslt/ppl/${rainInfwtAnlsSn}/${pplMngNo}`); - if (result && result.numberOfElements > 0) { - return result.content; - } - } catch (error) { - console.error(error); - } -}; -export const selectRainData = async(rainInfwtAnlsSn:number) => { // 강우데이터 - try { - if ((rainInfwtAnlsSn === undefined || rainInfwtAnlsSn === 0)) { - console.warn("RdiiAnlsSn or pplMngNo is not provided"); - return []; - } - const result = await getData(`v1/rdiiAnlsRslt/rainData/${rainInfwtAnlsSn}`); - if (result && result.numberOfElements > 0) { - return result.content; - } - } catch (error) { - console.error(error); - } -}; -export const getChartDataRainData = (arr: TRainData[]): { label: string; lineVal0: number, barVal0: number }[] => { - const options = arr.map(data => ({ - label: moment(data.mtrngDt, "YYYY-MM-DD HH:mm:ss").format("MM-DD HH시"), - lineVal0: 0, - barVal0: roundTo(data.amrf,2), - })); - return options; -}; \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/chart/infilChart.tsx b/apps/sn/app/html/(map-layout)/map/components/popupComponent/chart/infilChart.tsx deleted file mode 100644 index 3836b87f6..000000000 --- a/apps/sn/app/html/(map-layout)/map/components/popupComponent/chart/infilChart.tsx +++ /dev/null @@ -1,297 +0,0 @@ -'use client' - -import React, { useEffect, useMemo, useState } from 'react'; -import moment from 'moment'; - -import { - CHART_FORM, - type ChartConfig, - MainChart, - UiModal, -} from '@common/business_components'; -import { SelectBox } from '@common/business_components/ui'; -import { Button, Input, Label } from '@common/components'; - -import type { TInftwtAnls } from './aplcnYms'; -import * as fn from './aplcnYms'; -import { useInfilChartModalStore } from '../chartModalStore'; - -type ModalOpenProp = { - pplMngNo?: string; // 관로관리번호 -}; - -type DataType = { - label: string; - val: number; -}; - -const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: '선택' }]; - -const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => { - const { isOpen: isModalOpen, setIsOpen: setIsModalOpen } = useInfilChartModalStore(); - - const [selectedAplcnYm, setSelectedAplcnYm] = useState(''); - const [inftwtAnlss, setInftwtAnlss] = useState<TInftwtAnls[]>([]); - const [inftwtAnlsInfos, setInftwtAnlsInfos] = useState<{ text: string; val: number; hidden?: boolean }[]>( - INITIAL_ANALYSIS_OPTIONS, - ); - const [inftwtAnls, setInftwtAnls] = useState<TInftwtAnls | null>(null); - const [selectedInftwtAnls, setSelectedInftwtAnls] = useState<number>(0); - const [chartData, setChartData] = useState<{ chartData1: DataType[]; chartData2: DataType[] }>({ - chartData1: [], - chartData2: [], - }); - const [inftwtQty, setInftwtQty] = useState<string>('0'); - - const aplcnYms = fn.useAplcnYms('inftwtAnlsRslt'); - const normalizedPplMngNo = pplMngNo ? `L${pplMngNo}` : ''; - - const chartForm = CHART_FORM.LINE; - const chartConfig = { - chartData1: { label: '순오수량', color: 'red' }, - chartData2: { label: '실측하수량', color: 'orange' }, - } as ChartConfig; - - const hasChartData = useMemo(() => { - return chartData.chartData1.length > 0 || chartData.chartData2.length > 0; - }, [chartData]); - - const resetState = () => { - setSelectedAplcnYm(''); - setInftwtAnlss([]); - setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS); - setInftwtAnls(null); - setSelectedInftwtAnls(0); - setChartData({ chartData1: [], chartData2: [] }); - setInftwtQty('0'); - }; - - useEffect(() => { - if (!isModalOpen) { - resetState(); // 모달이 닫힐 때 리셋 - } - }, [isModalOpen]); - - const handleAplcnYmChange = async (val: string | number) => { - const selectedValue = String(val); - - setSelectedAplcnYm(selectedValue); - setInftwtAnlss([]); - setInftwtAnls(null); - setSelectedInftwtAnls(0); - setInftwtQty('0'); - setChartData({ chartData1: [], chartData2: [] }); - - if (!selectedValue || selectedValue === '0') { - setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS); - return; - } - - try { - const data = await fn.selectInftwtAnls(selectedValue); - const list = data ?? []; - setInftwtAnlss(list); - setInftwtAnlsInfos(fn.getOptionsInftwtAnlsInfo(list)); - } catch (error) { - console.error('selectInftwtAnls 에러:', error); - setInftwtAnlss([]); - setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS); - } - }; - - const handleInftwtAnlsSnChange = (val: string | number) => { - const selectedValue = Number(val); - - setSelectedInftwtAnls(selectedValue); - setChartData({ chartData1: [], chartData2: [] }); - setInftwtQty('0'); - - if (selectedValue === 0) { - setInftwtAnls(null); - return; - } - - const selectedAnls = inftwtAnlss.find((anls) => anls.inftwtAnlsSn === selectedValue) ?? null; - setInftwtAnls(selectedAnls); - }; - - const infilRsltChart = async () => { - if (!selectedAplcnYm || selectedInftwtAnls === 0 || !normalizedPplMngNo) { - setChartData({ chartData1: [], chartData2: [] }); - setInftwtQty('0'); - return; - } - - try { - const [inftwtAnlsRslt, oilq, qty] = await Promise.all([ - fn.selectInftwtAnlsRslt(selectedInftwtAnls, normalizedPplMngNo), - fn.selectOilq(selectedInftwtAnls, normalizedPplMngNo).catch((err) => { - console.error('selectOilq 에러:', err); - return []; - }), - fn.selectPplInftwtQty(selectedInftwtAnls, normalizedPplMngNo).catch((err) => { - console.error('selectPplInftwtQty 에러:', err); - return 0; - }), - ]); - - setChartData({ - chartData1: fn.getChartDataInftwtAnlsRslt(inftwtAnlsRslt ?? []), - chartData2: fn.getChartDataOilq(oilq ?? []), - }); - - const normalizedQty = Number(qty ?? 0); - setInftwtQty(normalizedQty.toLocaleString('ko-KR')); - } catch (error) { - console.error('infilRsltChart 에러:', error); - setChartData({ chartData1: [], chartData2: [] }); - setInftwtQty('0'); - } - }; - - const renderInfoInput = (label: string, value?: string | number | null) => ( - <div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3"> - <Label className="w-auto text-[14px] font-semibold text-[#5c6470] whitespace-nowrap">{label}</Label> - <Input - className="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white px-3 text-xl md:text-xl text-[#2b3440]" - placeholder="" - value={value ?? ''} - readOnly - /> - </div> - ); - - const handleReset = () => { - resetState(); - } - - const renderBody = () => ( - <div className="text-black"> - <div className="mb-4 flex items-start justify-between"> - <div className="text-[18px] font-semibold text-[#111827]" /> - </div> - - <div className="grid min-h-[68vh] grid-cols-[minmax(0,38%)_minmax(0,62%)] gap-5"> - <section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-5"> - <h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black"> - 침입수 분석 선택 조건 - </h3> - - <div className="rounded-[6px] border border-[#d9dde3] bg-[#eff1f4] px-4 py-6"> - <div className="space-y-3"> - <div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3"> - <Label className="w-auto text-[14px] font-semibold text-[#5c6470] whitespace-nowrap"> - 상수사용월: - </Label> - <SelectBox - className="w-full" - selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl" - label="" - selectArray={aplcnYms} - value={selectedAplcnYm} - onSelectValue={handleAplcnYmChange} - /> - </div> - - <div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3"> - <Label className="w-auto text-[14px] font-semibold text-[#5c6470] whitespace-nowrap"> - 분석정보: - </Label> - <SelectBox - className="w-full" - selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl" - label="" - selectArray={inftwtAnlsInfos} - value={selectedInftwtAnls} - onSelectValue={handleInftwtAnlsSnChange} - /> - </div> - - {renderInfoInput('오수전환율:', inftwtAnls?.drtwSwtcRt ?? '')} - {renderInfoInput('시간패턴 파일명:', inftwtAnls?.atflHrPttnNm ?? '')} - {renderInfoInput('수용가-맨홀 매칭:', inftwtAnls?.atflMpngNm ?? '')} - {renderInfoInput('관망도 파일명:', inftwtAnls?.atflInpNm ?? '')} - {renderInfoInput('유량 파일명:', inftwtAnls?.atflOilqNm ?? '')} - </div> - </div> - - <div className="absolute inset-x-0 bottom-0 mb-[40px] flex justify-center"> - <Button size="lg" onClick={infilRsltChart} > - 조회 - </Button>   - <Button size="lg" color="gray" onClick={handleReset} > - 초기화 - </Button> - </div> - </section> - - <section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-5"> - <h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black">침입수 분석 결과</h3> - - <div className="mb-6 grid grid-cols-2 gap-5"> - <div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4"> - <div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]"> - {/* <span className="text-[14px] leading-none">●</span> */} - <span>상수사용량 적용연월</span> - </div> - <div className="text-[20px] font-extrabold text-[#222]"> - {selectedAplcnYm ? moment(selectedAplcnYm, 'YYYYMM').format('YYYY-MM') : '-'} - </div> - </div> - - <div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4"> - <div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]"> - {/* <span className="text-[14px] leading-none">●</span> */} - <span>침입수량 산정결과</span> - </div> - <div className="grid grid-cols-[1fr_auto] items-center gap-7 text-[#222]"> - <span className="text-[16px] text-[#666]">침입수량</span> - <div className='w-[66px] flex items-end justify-start gap-2'> - <span className="text-[20px] font-extrabold">{inftwtQty}</span> - <span className="pb-[2px] text-[16px]">m³/d</span> - </div> - </div> - </div> - </div> - - <div className="rounded-[10px] bg-white px-2 pb-2 pt-3"> - {hasChartData ? ( - <MainChart - chartConfig={chartConfig} - chartForm={chartForm} - chartData={chartData} - chartTitle={'침입수량 그래프'} - yFieldText="유량(m³/h)" - yFieldText2="" - /> - ) : ( - <div className="flex h-[464px] items-center justify-center text-[18px] text-[#2c2f33]"> - 데이터가 없습니다. - </div> - )} - </div> - </section> - </div> - </div> - ); - - return ( - <UiModal - open={isModalOpen} - onOpenChange={setIsModalOpen} - headerTitle="" - bodyClass={` - w-[78vw] - max-w-none - min-w-[1000px] - min-h-[72vh] - overflow-visible - z-[50] - `} - body={renderBody()} - noFooterYn="Y" - /> - ); -}; - -export default React.memo(InfilChart); diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx b/apps/sn/app/html/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx deleted file mode 100644 index aa622be1b..000000000 --- a/apps/sn/app/html/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx +++ /dev/null @@ -1,357 +0,0 @@ -'use client' - -import React, { useEffect, useMemo, useState } from 'react'; -import moment from 'moment'; - -import { - CHART_FORM, - type ChartConfig, - MainChart, - UiModal, -} from '@common/business_components'; -import { SelectBox } from '@common/business_components/ui'; -import { Button, Input, Label } from '@common/components'; - -import type { TRdiiAnls } from './aplcnYms'; -import * as fn from './aplcnYms'; -import { useRdiiChartModalStore } from '../chartModalStore'; - -type ModalOpenProp = { - pplMngNo?: string; // 관로관리번호 -}; - -type LineBarChartData = { - label: string; - lineVal0: number; - barVal0: number; -}; - -const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: '선택' }]; - -const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => { - const { isOpen: isModalOpen, setIsOpen: setIsModalOpen } = useRdiiChartModalStore(); - - const [aplcnYm, setAplcnYm] = useState(''); - const [rdiiAnlss, setRdiiAnlss] = useState<TRdiiAnls[]>([]); - const [rdiiAnlsInfos, setRdiiAnlsInfos] = useState<{ text: string; val: number; hidden?: boolean }[]>( - INITIAL_ANALYSIS_OPTIONS, - ); - const [rdiiAnls, setRdiiAnls] = useState<TRdiiAnls | null>(null); // 강우유입수분석정보 - const [rainInfwtAnlsSn, setRainInfwtAnlsSn] = useState<number>(0);// 강우유입수분석 정보 선택시 사용 - const [chartData, setChartData] = useState<LineBarChartData[]>([]); - const [rdiiQty, setRdiiQty] = useState<string>('0'); // 강우유입수량 - const [rainTotal, setRainTotal] = useState<string>('0'); // 적용강우량 - const [totalVolume, setTotalVolume] = useState<string>('0');// 총체적 m³/mm - - const aplcnYms = fn.useAplcnYms('rdiiAnlsRslt'); // 상수사용월 데이터 가져오기 - const normalizedPplMngNo = pplMngNo ? `L${pplMngNo}` : ''; - - const chartForm = CHART_FORM.LINEBAR2; - const chartConfig = { - barVal0: { - label: '강수량', - color: '#33AAFF', - }, - lineVal0: { - label: 'RDII', - color: '#FF0000', - }, - } as ChartConfig; - - const hasChartData = useMemo(() => chartData.length > 0, [chartData]); - - const resetState = () => { - setAplcnYm(''); // 상수사용월 초기화 - setRdiiAnlss([]); // 강우유입수분석 초기화 - setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS); // 강우유입수분석정보 초기화 - setRdiiAnls(null); // 강우유입수분석정보 초기화 - setRainInfwtAnlsSn(0); // 강우유입수분석 일련번호 초기화 - setChartData([]); - setRdiiQty('0'); // 강우유입수량 초기화 - setRainTotal('0');// 적용강우량 초기화 - setTotalVolume('0');// 총체적 m³/mm 초기화 - }; - - useEffect(() => { - if (!isModalOpen) { - resetState(); - } - }, [isModalOpen]); - - const handleAplcnYmChange = async (val: string | number) => { - const selectedValue = String(val); - - setAplcnYm(selectedValue); - setRdiiAnlss([]); - setRdiiAnls(null); - setRainInfwtAnlsSn(0); - setChartData([]); - setRdiiQty('0'); - setRainTotal('0'); - setTotalVolume('0'); - - if (!selectedValue || selectedValue === '0') { - setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS); - return; - } - - try { - const data = await fn.selectRdiiAnls(selectedValue); // 상수사용월 변경시 분석 정보 리스트 가져오기 - const list = data ?? []; - setRdiiAnlss(list); - setRdiiAnlsInfos(fn.getOptionsRdiiAnlsInfo(list)); - } catch (error) { - console.error('selectRdiiAnls 에러:', error); - setRdiiAnlss([]); - setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS); - } - }; - - const handleRdiiAnlsSnChange = (val: string | number) => { - const selectedValue = Number(val); - - setRainInfwtAnlsSn(selectedValue); - setChartData([]); - setRdiiQty('0'); - setRainTotal('0'); - setTotalVolume('0'); - - if (selectedValue === 0) { - setRdiiAnls(null); - return; - } - - const selectedAnls = rdiiAnlss.find((anls) => anls.rainInfwtAnlsSn === selectedValue) ?? null; - setRdiiAnls(selectedAnls); - }; - - const fetchFilteredData = async () => { - if (!rainInfwtAnlsSn || !normalizedPplMngNo) { - setChartData([]); - setRdiiQty('0'); - setRainTotal('0'); - setTotalVolume('0'); - return; - } - - try { - const [rsltData, rainData, qtyData] = await Promise.all([ - fn.selectRdiiAnlsRslt(rainInfwtAnlsSn, normalizedPplMngNo), - fn.selectRainData(rainInfwtAnlsSn), - fn.selectPplRdiiQty(rainInfwtAnlsSn, normalizedPplMngNo), - ]); - - const rsltDataFormatted = fn.getChartDataRdiiAnlsRslt(rsltData ?? []); - const rainDataFormatted = fn.getChartDataRainData(rainData ?? []); - const rainMap = new Map(rainDataFormatted.map((item) => [item.label, item.barVal0])); - - rsltDataFormatted.forEach((item) => { - const matchedVal = rainMap.get(item.label); - if (matchedVal !== undefined) { - item.barVal0 = matchedVal; - } - }); - - setChartData(rsltDataFormatted);// 차트 데이터 설정 - - const avgRdii = Number(qtyData?.rdiih ?? 0); - const appliedRainfall = Number(qtyData?.raintotal ?? 0); - const rdiiPerRainfall = - qtyData?.raintotal && qtyData?.raintotal !== 0 - ? Math.round(avgRdii * Number(qtyData?.sustaintime ?? 0) / qtyData.raintotal) - : 0; - - setRdiiQty(Math.round(avgRdii * 24).toLocaleString('ko-KR')); - setRainTotal(appliedRainfall.toLocaleString('ko-KR')); - setTotalVolume(rdiiPerRainfall.toLocaleString('ko-KR')); - } catch (error) { - console.error('rdiiRsltChart 에러:', error); - setChartData([]); - setRdiiQty('0'); - setRainTotal('0'); - setTotalVolume('0'); - } - }; - - const renderInfoInput = (label: string, value?: string | number | null) => ( - <div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3"> - <Label className="w-auto whitespace-nowrap text-[14px] font-semibold text-[#5c6470]"> - {label} - </Label> - <Input - className="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white px-3 text-xl text-[#2b3440]" - placeholder="" - value={value ?? ''} - readOnly - /> - </div> - ); - - const renderBody = () => ( - <div className="text-black"> - <div className="mb-4 flex items-start justify-between"> - <div className="text-[18px] font-semibold text-[#111827]" /> - </div> - - <div className="grid min-h-[68vh] grid-cols-[minmax(0,36%)_minmax(0,64%)] gap-5"> - <section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-5"> - <h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black"> - 강우유입수분석 선택 조건 - </h3> - - <div className="rounded-[6px] border border-[#d9dde3] bg-[#eff1f4] px-4 py-6"> - <div className="space-y-3"> - <div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3"> - <Label className="w-auto whitespace-nowrap text-[14px] font-semibold text-[#5c6470]"> - 상수사용월: - </Label> - <SelectBox - className="w-full" - selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl" - label="" - selectArray={aplcnYms} - value={aplcnYm} - onSelectValue={handleAplcnYmChange} - /> - </div> - - <div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3"> - <Label className="w-auto whitespace-nowrap text-[14px] font-semibold text-[#5c6470]"> - 분석정보: - </Label> - <SelectBox - className="w-full" - selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl" - label="" - selectArray={rdiiAnlsInfos} - value={rainInfwtAnlsSn} - onSelectValue={handleRdiiAnlsSnChange} - /> - </div> - - {renderInfoInput('오수전환율:', rdiiAnls?.drtwSwtcRt ?? '')} - {renderInfoInput('수용가-맨홀 매칭:', rdiiAnls?.atflMpngNm ?? '')} - {renderInfoInput('관망도 파일명:', rdiiAnls?.atflInpNm ?? '')} - {renderInfoInput('강우 파일명:', rdiiAnls?.atflRainNm ?? '')} - {renderInfoInput('RTK계수 출처:', rdiiAnls?.srcNm ?? '')} - </div> - </div> - - <div className="absolute inset-x-0 bottom-0 mb-[40px] flex justify-center"> - <Button size="lg" onClick={fetchFilteredData}> - 조회 - </Button> -    - <Button size="lg" color="gray" onClick={resetState}> - 초기화 - </Button> - </div> - </section> - - <section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-5"> - <h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black"> - 강우유입수분석 결과 - </h3> - - <div className="mb-6 flex gap-5"> - {/* 좌측 카드 */} - <div className="flex min-h-[98px] min-w-0 basis-2/5 grow shrink-[1.4] items-center justify-between rounded-[16px] border border-[#5fa3ea] bg-white px-6 py-4"> - <div className="text-[16px] font-bold text-[#ef4444]"> - 상수사용량 적용연월 - </div> - <div className="text-[24px] font-extrabold leading-none text-[#111]"> - {aplcnYm ? moment(aplcnYm, 'YYYYMM').format('YYYY-MM') : '-'} - </div> - </div> - - {/* 우측 카드 */} - <div className="flex min-h-[98px] min-w-0 basis-3/5 grow shrink items-center gap-5 rounded-[16px] border border-[#5fa3ea] bg-white px-6 py-4"> - {/* 좌측: 제목 (세로 가운데 정렬) */} - <div className="flex min-w-0 basis-[32%] items-center"> - <div className="text-[16px] font-bold text-[#ef4444] break-keep"> - 강우유입수량 분석결과 - </div> - </div> - - {/* 우측: 지표 영역 */} - <div className="flex min-w-0 basis-[68%] flex-col gap-[2px]"> - <div className="flex min-w-0 items-center gap-3"> - <div className="min-w-0 basis-[48%] text-right text-[15px] text-[#555]"> - 평균RDII - </div> - <div className="min-w-0 basis-[32%] text-right text-[18px] font-extrabold text-[#111] tabular-nums"> - {rdiiQty} - </div> - <div className="min-w-0 basis-[20%] text-left text-[14px] text-[#111]"> - m³/d - </div> - </div> - - <div className="flex min-w-0 items-center gap-3"> - <div className="min-w-0 basis-[48%] text-right text-[15px] text-[#555]"> - 적용 강우량 - </div> - <div className="min-w-0 basis-[32%] text-right text-[18px] font-extrabold text-[#111] tabular-nums"> - {rainTotal} - </div> - <div className="min-w-0 basis-[20%] text-left text-[14px] text-[#111]"> - mm - </div> - </div> - - <div className="flex min-w-0 items-center gap-3"> - <div className="min-w-0 basis-[48%] text-right text-[15px] text-[#555]"> - 강우량 대비 RDII - </div> - <div className="min-w-0 basis-[32%] text-right text-[18px] font-extrabold text-[#111] tabular-nums"> - {totalVolume} - </div> - <div className="min-w-0 basis-[20%] text-left text-[14px] text-[#111]"> - m³/mm - </div> - </div> - </div> - </div> - </div> - - <div className="rounded-[10px] bg-white px-2 pb-2 pt-3"> - {hasChartData ? ( - <MainChart - chartConfig={chartConfig} - chartForm={chartForm} - chartData={chartData} - chartTitle={'RDII 그래프'} - yFieldText2="강수량(mm)" - yFieldText="유량(m³/h)" - /> - ) : ( - <div className="flex h-[464px] items-center justify-center text-[18px] text-[#2c2f33]"> - 데이터가 없습니다. - </div> - )} - </div> - </section> - </div> - </div> - ); - - return ( - <UiModal - open={isModalOpen} - onOpenChange={setIsModalOpen} - headerTitle="" - bodyClass={` - w-[78vw] - max-w-none - min-w-[1100px] - min-h-[72vh] - overflow-visible - z-[50] - `} - body={renderBody()} - noFooterYn="Y" - /> - ); -}; - -export default React.memo(RdiiChart); diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/chartModalStore.ts b/apps/sn/app/html/(map-layout)/map/components/popupComponent/chartModalStore.ts deleted file mode 100644 index c84fa48a7..000000000 --- a/apps/sn/app/html/(map-layout)/map/components/popupComponent/chartModalStore.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { create } from 'zustand'; - -interface ModalStore { - isOpen: boolean; - setIsOpen: (open: boolean) => void; -} - -export const useInfilChartModalStore = create<ModalStore>((set) => ({ - isOpen: false, - setIsOpen: (open: boolean) => set({ isOpen: open }), -})); - -export const useRdiiChartModalStore = create<ModalStore>((set) => ({ - isOpen: false, - setIsOpen: (open: boolean) => set({ isOpen: open }), -})); \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx b/apps/sn/app/html/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx deleted file mode 100644 index d507a4342..000000000 --- a/apps/sn/app/html/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +++ /dev/null @@ -1,629 +0,0 @@ -/** - * ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 - * - * 📄 FeatureClickEventPopup - * 시설물(피처) 클릭시 오른쪽 팝업창 컴포넌트 - * - * - 지도 시설물(피처) 클릭 시 팝업 표시 및 데이터 로딩 - * - 레이어 유형에 따른 동적 테이블 및 헤드리스트 설정 - * - 자체입력정보 조회 및 저장 기능 (하수관로, 우수토실, 하수펌프장, 하수맨홀) - * - 코드 매핑 및 데이터 포맷팅 처리 - * - 상류관로 검색, 침입수/RDII 분석 차트 표시 - * - 모달을 통한 저장 확인 및 API 연동 - * - * 🔗 사용 API - * - 하수관로 자체입력정보 조회: GET /api/v1/fcltppl/{fcltMngNo}/{mngInstCd} (sewerage-service) - * - 우수토실 자체입력정보 조회: GET /api/v1/fcltstofcbr/{fcltMngNo}/{mngInstCd} (sewerage-service) - * - 하수펌프장 자체입력정보 조회: GET /api/v1/fcltpump/{fcltMngNo}/{mngInstCd} (sewerage-service) - * - 하수맨홀 자체입력정보 조회: GET /api/v1/fcltmhl/{fcltMngNo}/{mngInstCd} (sewerage-service) - * - 하수관로 자체입력정보 저장: POST /api/v1/fcltppl (sewerage-service) - * - 우수토실 자체입력정보 저장: POST /api/v1/fcltstofcbr (sewerage-service) - * - 하수펌프장 자체입력정보 저장: POST /api/v1/fcltpump (sewerage-service) - * - 하수맨홀 자체입력정보 저장: POST /api/v1/fcltmhl (sewerage-service) - */ -'use client' -import { getData, postData } from "@/components/lib/api"; -import { VerticalTable } from "@/components/ui/verticalTable"; -import { useGisContext } from "@common/business_components/ui/map/Gis"; -import { Button } from "@common/components/ui"; -import { useEffect, useRef, useState, useMemo } from "react"; -import Style from "ol/style/Style.js"; -import Fill from "ol/style/Fill.js"; -import Stroke from "ol/style/Stroke.js"; -import CircleStyle from "ol/style/Circle.js"; -import { getLayerContentComponent } from "../common"; -import { SwtrmtFcltCol, SwtrmtFcltCol2, SwtrmtPrcsZoneCol, SwtrmtPrcsZoneCol2, untrtSwgRainCol1, untrtSwgRainCol2, MsSwagePplCol, MsSwagePplCol2, ExclnSewagePumpCol, ExclnSewagePumpCol2, DrtwSewagePumpCol, DrtwSewagePumpCol2, MsSwageMhlCol, MsSwageMhlCol2, stofcbrCol, stofcbrCol2, MS_SWAGE_MHL_CODES, DRTW_SEWAGE_PUMP_USE_CODES } from "../common/table"; -import InfilChart from './chart/infilChart'; -import RdiiChart from './chart/rdiiChart'; -import { useInfilChartModalStore, useRdiiChartModalStore } from "./chartModalStore"; -import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue'; -import { codeMapper } from "../common/codeMapper"; -import LoadingOverlay from '@/components/ui/LoadingBar'; -import { searchUpperPpls } from "./smh"; - -const linkedFacilityHighlightStyle = new Style({ - image: new CircleStyle({ - radius: 18, - fill: new Fill({ - color: 'rgba(255, 235, 59, 0.35)', - }), - stroke: new Stroke({ - color: 'rgba(255, 193, 7, 0.45)', - width: 2, - }), - }), - fill: new Fill({ - color: 'rgba(255, 235, 59, 0.2)', - }), - stroke: new Stroke({ - color: 'rgba(255, 193, 7, 0.35)', - width: 2.5, - }), - zIndex: 9999, -}); - - -const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData: any }) => { - const { gisMap } = useGisContext() as unknown as { checkedLayerIds: React.MutableRefObject<number[]>; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; }; - const [layerNm, setLayerNm] = useState<string>(""); // 레이어 이름 상태 - const [layerSpecificNm, setLayerSpecificNm] = useState<string>(""); // 레이어 이름 상태 - const [tableData, setTableData] = useState<any>(null); // 피처 데이터 - const [headlist, setHeadlist] = useState<{ id: string; title: string; }[]>([]); // 컬럼리스트 변경상태 - const [headlist2, setHeadlist2] = useState<{ id: string; title: string; }[]>([]); // 컬럼리스트 변경상태 - const [buttonChk, setButtonChk] = useState(true) - const [fetchData, setFetchData] = useState<any>(); // api 호출여부 - const contentRef = useRef<HTMLDivElement>(null); - const [isMainSsc, setIsMainSsc] = useState(false); // 중점우수토실 여부 - const [isDrtwSewagePump, setIsDrtwSewagePump] = useState(false); // 오수펌프장 여부 - // 🎯 InfilChart 및 RdiiChart 관련 상태 추가 - const { setIsOpen: setIsModalInfil } = useInfilChartModalStore(); - const { setIsOpen: setIsModalRdii } = useRdiiChartModalStore(); - const [isClicked, setIsClicked] = useState(true); // 클릭 여부 상태 - const [isLoading, setIsLoading] = useState(false); // 로딩 상태 - const [chmbrXcnts, setChmbrXcnts] = useState<string>(''); // 로딩 상태 - const [chmbrYdnts, setChmbrYdnts] = useState<string>(''); // 로딩 상태 - const linkedFacilityFeatureRef = useRef<any>(null); - const linkedFacilityOriginalStyleRef = useRef<any>(null); - - // 🎯 관로관리번호 추출 - const pplMngNo = featureData?.values_?.fclt_mng_no || ""; - - const featureId = typeof featureData.getId === 'function' - ? featureData.getId() - : featureData.fid; - - // 🎯 데이터 전처리 메모이제이션 - const processedData = useMemo(() => { - if (!featureData?.values_) return null; - - return { - ...featureData.values_, - discharge_pipe_spec: `직경: ${featureData.values_.pipe_diameter || '-'} / 높이: ${featureData.values_.pipe_hg || '-'} / 길이: ${featureData.values_.pipe_lt || '-'}` - }; - }, [featureData?.values_]); - - const clearLinkedFacilityHighlight = () => { - if (linkedFacilityFeatureRef.current) { - linkedFacilityFeatureRef.current.setStyle(linkedFacilityOriginalStyleRef.current ?? undefined); - linkedFacilityFeatureRef.current.changed?.(); - } - - linkedFacilityFeatureRef.current = null; - linkedFacilityOriginalStyleRef.current = null; - }; - - // 하수처리구역에 연결된 하수처리시설 피처를 찾아 기존 아이콘 위에 노랑 하이라이트를 덧씌운다. - const highlightLinkedFacility = (facilityCode: string | null | undefined) => { - clearLinkedFacilityHighlight(); - - if (!gisMap || !facilityCode) return; - - const normalizedFacilityCode = String(facilityCode).trim(); - const layers = gisMap.getLayers().getArray(); - - for (const layer of layers) { - const source = layer?.getSource?.(); - if (!source?.getFeatures) continue; - - const features = source.getFeatures(); - const targetFeature = features.find((feature: any) => { - const featureId = feature?.getId?.(); - const isFacilityLayer = typeof featureId === 'string' && featureId.startsWith('tb_il_swage_prcs_fclt.'); - const featureFacilityCode = feature?.get?.('swtrmt_fclt_cd'); - - return isFacilityLayer && String(featureFacilityCode ?? '').trim() === normalizedFacilityCode; - }); - - if (targetFeature) { - const originalFeatureStyle = targetFeature.getStyle?.(); - const featureLayer = targetFeature.getLayer?.(); - const layerStyleFn = featureLayer?.getStyleFunction?.() ?? null; - - linkedFacilityOriginalStyleRef.current = originalFeatureStyle; - linkedFacilityFeatureRef.current = targetFeature; - targetFeature.setStyle((feature: any, resolution: number) => { - let styles: any[] = []; - - if (typeof originalFeatureStyle === 'function') { - const originalStyles = originalFeatureStyle(feature, resolution); - if (Array.isArray(originalStyles)) styles.push(...originalStyles); - else if (originalStyles) styles.push(originalStyles); - } else if (originalFeatureStyle) { - if (Array.isArray(originalFeatureStyle)) styles.push(...originalFeatureStyle); - else styles.push(originalFeatureStyle); - } else if (layerStyleFn) { - const layerStyles = layerStyleFn(feature, resolution); - if (Array.isArray(layerStyles)) styles.push(...layerStyles); - else if (layerStyles) styles.push(layerStyles); - } - - styles.push(linkedFacilityHighlightStyle); - return styles; - }); - targetFeature.changed?.(); - return; - } - } - }; - - // 피쳐데이터의 현재 레이어 이름 가져오는 함수 - const getSelectedLayers = () => { - let result = null; - const layers = gisMap.getLayers().getArray(); // gisMap에서 모든 레이어 가져오기 - let featureLayer: any = null; // 피처가 속한 레이어를 찾기 위한 변수 - layers.some((layer: any) => { - const source = layer.getSource(); - // if (source && featureData && featureData.fid) { - // featureLayer = layer; // 피처가 속한 레이어 찾기 - // } - if (source && source.hasFeature && featureData - && typeof featureData.getId === 'function' && source.hasFeature(featureData)) { - featureLayer = layer; // 피처가 속한 레이어 찾기 - return true; // 반복 종료 - } - return false; - }); - - if (featureData['ol_uid']) { - // Gis 레이어 클릭 이벤트 - setIsClicked(true); - const lyrTyNm = featureData.id_.split('.')[0]; - - switch (lyrTyNm) { - case 'tb_ms_swage_ppl': { - result = '하수관로'; - setHeadlist(MsSwagePplCol); - setLayerSpecificNm(featureData.values_.fclt_mng_no); - break; - } - case 'tb_ms_swage_mhl': { - result = '하수맨홀'; - setHeadlist(MsSwageMhlCol); - setLayerSpecificNm(featureData.values_.fclt_mng_no); - break; - } - case 'tb_ms_swage_pmstn': { - const drtwPumpCode = DRTW_SEWAGE_PUMP_USE_CODES.find(code => code.text === '오수펌프장'); - if (drtwPumpCode && featureData.values_.usg_cd.trim() === drtwPumpCode.val) { - setIsDrtwSewagePump(true); - setHeadlist(DrtwSewagePumpCol); - } - else { - setIsDrtwSewagePump(false); - setHeadlist(ExclnSewagePumpCol); - } - setLayerSpecificNm(featureData.values_.fclt_mng_no); - result = '하수펌프장'; - break; - } - case 'tb_il_swage_prcs_fclt': { - result = '하수처리시설'; - setHeadlist(SwtrmtFcltCol); - setLayerSpecificNm(featureData.values_.swtrmt_fclt_cd); - break; - } - case 'tb_ms_swage_prcs_zone': { - result = '하수처리구역'; - setHeadlist(SwtrmtPrcsZoneCol); - setLayerSpecificNm(featureData.values_.swtrmt_fclt_cd); - break; - } - case 'tb_ms_stofcbr': { - result = '우수토실'; - setHeadlist(stofcbrCol); - setLayerSpecificNm(featureData.values_.fclt_mng_no); - break; - } - case 'om_dt_untrt_swg_rain': { - result = '중점우수토실'; - setHeadlist(untrtSwgRainCol1); - setHeadlist2(untrtSwgRainCol2); - setLayerSpecificNm(featureData.values_.manage_no); - break; - } - default: { - result = featureLayer ? featureLayer.get('lyrNm') : null; - break; - } - } - } - else { - // SearchModal 클릭 이벤트 - setIsClicked(false); - const lyrTyNm = featureData.lyrTypeNm; - - switch (lyrTyNm) { - case 'tb_ms_swage_ppl': { - result = '하수관로'; - setHeadlist(MsSwagePplCol2) - setLayerSpecificNm(featureData.swtrmtFcltCd); - break; - } - case 'tb_ms_swage_mhl': { - result = '하수맨홀'; - setHeadlist(MsSwageMhlCol2) - setLayerSpecificNm(featureData.fcltMngNo); - break; - } - case 'tb_ms_swage_pmstn': { - const drtwPumpCode = DRTW_SEWAGE_PUMP_USE_CODES.find(code => code.text === '오수펌프장'); - if (drtwPumpCode && featureData.usgCd.trim() === drtwPumpCode.val) { - setIsDrtwSewagePump(false); - setHeadlist(ExclnSewagePumpCol2) - } - else { - setIsDrtwSewagePump(true); - setHeadlist(DrtwSewagePumpCol2) - } - setLayerSpecificNm(featureData.fcltMngNo); - result = '하수펌프장'; - break; - } - case 'tb_il_swage_prcs_fclt': { - result = '하수처리시설'; - setHeadlist(SwtrmtFcltCol2) - setLayerSpecificNm(featureData.swtrmtFcltCd); - break; - } - case 'tb_ms_swage_prcs_zone': { - result = '하수처리구역'; - setHeadlist(SwtrmtPrcsZoneCol2); - setLayerSpecificNm(featureData.swtrmtFcltCd); - break; - } - case 'tb_ms_stofcbr': { - result = '우수토실'; - setHeadlist(stofcbrCol2) - setLayerSpecificNm(featureData.fcltMngNo); - break; - } - } - } - - return result; - }; - - useEffect(() => { - const fetchData = async () => { - if (!featureId) return; - - try { - const selectedLayerName = getSelectedLayers(); // 레이어 이름 가져오기 - setLayerNm(selectedLayerName); // 상태 업데이트 - - let data; - if (featureData['ol_uid']) data = featureData.values_; // Gis 레이어 클릭 이벤트일 때 - else data = featureData; // SearchModal 클릭 이벤트일 때 - - // 하수맨홀인 경우 용도코드를 텍스트로 변환 - if (selectedLayerName === '하수맨홀' && data) { - // mqlt_cd 값 변환 (재질코드) - if (data.mqlt_cd) { - const materialCodeItem = MS_SWAGE_MHL_CODES.find( - code => code.val === data.mqlt_cd.trim() - ); - if (materialCodeItem) data.mqlt_cd = `${materialCodeItem.text}`; - } - } else { - // 중점우수토실인 경우 구분 값 - if (selectedLayerName === '중점우수토실' && data) { - if (data.mesure_spot_se) { - const mesureSpotSe = [{id:'0', text: '합류식'}, {id:'1', text: '분류식'}] - const mesureSpotSeItem = mesureSpotSe.find( - code => code.id === data.mesure_spot_se.trim() - ); - if (mesureSpotSeItem) data.mesure_spot_se = mesureSpotSeItem.text; - } - } - } - - // 🎯 기본 데이터 포맷팅 - const formattedData = formatDisplayData(data); - - setFetchData(formattedData); - setTableData(formattedData); - - switch (selectedLayerName) { - case '하수관로': - setButtonChk(true) - - // ✅ codeMapper 사용하여 API 결과도 변환 - setTableData(codeMapper({ - layerName: selectedLayerName, - data: { ...formattedData } - })); - break; - case '하수맨홀': - setButtonChk(true) - - // ✅ codeMapper 사용하여 API 결과도 변환 - setTableData(codeMapper({ - layerName: selectedLayerName, - data: { ...formattedData } - })); - break; - case '하수펌프장': - setButtonChk(true) - - setTableData(codeMapper({ - layerName: selectedLayerName, - data: { ...formattedData } - })); - break; - case '하수처리시설': - setButtonChk(false) - break; - case '하수처리구역': - setButtonChk(false) - break; - case '우수토실': - setButtonChk(true) - setIsMainSsc(false) - break; - case '중점우수토실': - setButtonChk(false) - setIsMainSsc(true) - // 🎯 processedData도 포맷팅하여 설정 - const processedFormattedData = formatDisplayData(processedData); - - setFetchData([processedFormattedData]) - setTableData(processedFormattedData); - // setIsLoading(false); // 기본 렌더링 완료 - - setTableData(codeMapper({ - layerName: selectedLayerName, - data: { ...processedFormattedData } - })); - - setChmbrXcnts(data.chmbr_xcnts); - setChmbrYdnts(data.chmbr_ydnts); - - break; - default: - break; - } - - // 자체입력정보 API 엔드포인트 선택 - let apiEndpoint = ''; - const fcltMngNo = featureData?.values_?.fclt_mng_no || null; - const mngInstCd = featureData?.values_?.mng_inst_cd || null; - if (selectedLayerName === '하수관로') apiEndpoint = `v1/fcltppl/${fcltMngNo}/${mngInstCd}`; - else if (selectedLayerName === '우수토실') apiEndpoint = `v1/fcltstofcbr/${fcltMngNo}/${mngInstCd}`; - else if (selectedLayerName === '하수펌프장') apiEndpoint = `v1/fcltpump/${fcltMngNo}/${mngInstCd}`; - else if (selectedLayerName === '하수맨홀') apiEndpoint = `v1/fcltmhl/${fcltMngNo}/${mngInstCd}`; - - // API에서 상세 데이터 가져오기 - if (apiEndpoint) { - try { - const result = await getData(apiEndpoint); - - if (result) { - const processedApiData = codeMapper({ - layerName: selectedLayerName, - data: { ...result, ...tableData } - }); - - setFetchData(processedApiData); - } - } catch (apiError) { - console.error('API 데이터 로딩 중 오류:', apiError); - } - } - } catch (error) { - console.error('데이터 로딩 중 오류:', error); - } - }; - - fetchData(); - }, [featureId, processedData]); - - useEffect(() => { - const layerTypeNm = featureData?.values_ - ? featureData?.id_?.split('.')?.[0] - : featureData?.lyrTypeNm; - - if (layerTypeNm === 'tb_ms_swage_prcs_zone') { - const facilityCode = featureData?.values_?.swtrmt_fclt_cd ?? featureData?.swtrmtFcltCd; - highlightLinkedFacility(facilityCode); - return () => { - clearLinkedFacilityHighlight(); - }; - } - - clearLinkedFacilityHighlight(); - - return () => { - clearLinkedFacilityHighlight(); - }; - }, [featureData, gisMap]); - - // Use the browser confirm dialog for lightweight save confirmation. - const handleSaveClick = () => { - const shouldSave = window.confirm('정보를 저장하시겠습니까?'); - if (!shouldSave) return; - void btnClickEvent(); - }; - - // 저장 버튼 이벤트 - const btnClickEvent = async () => { - const bodyData: Record<string, any> = {} - - if (buttonChk && contentRef.current) { - const inputElements = contentRef.current.querySelectorAll('input'); - const selectElements = contentRef.current.querySelectorAll('select'); - - // 카멜케이스 변환 함수 - const toCamelCase = (str: string) => - str.replace(/_([a-z])/g, (_, char) => char.toUpperCase()); - - // select 태그의 값 가져오기 - selectElements.forEach((select) => { - const camelCaseId = toCamelCase(select.id); - bodyData[camelCaseId] = select.value; - }); - - // // input 태그의 값 가져오기 - inputElements.forEach((input) => { - const camelCaseId = toCamelCase(input.id); - bodyData[camelCaseId] = input.value; - }); - - // 레이어명에 따라 API 엔드포인트 분기처리 - let apiEndpoint = ''; - let result = null; - - if (layerNm === '하수관로') apiEndpoint = 'v1/fcltppl'; - else if (layerNm === '우수토실') apiEndpoint = 'v1/fcltstofcbr'; - else if (layerNm === '하수펌프장') apiEndpoint = 'v1/fcltpump'; - else if (layerNm === '하수맨홀') apiEndpoint = 'v1/fcltmhl'; - - bodyData['fcltMngNo'] = featureData.values_.fclt_mng_no.toString() || null; - bodyData['mngInstCd'] = featureData.values_.mng_inst_cd.toString() || null; - // 빈 키 제거 - if (bodyData[""] !== undefined) delete bodyData[""]; - - // 🎯 우수토실일 경우 prrtymngYn 값 변환 - if (layerNm === '우수토실' && bodyData.prrtymngYn) { - if (bodyData.prrtymngYn === '중점우수토실') { - bodyData.prrtymngYn = 'Y'; - } else if (bodyData.prrtymngYn === '우수토실') { - bodyData.prrtymngYn = 'N'; - } - } - - try { - result = await postData(apiEndpoint, bodyData); - - if (result) { - alert('저장되었습니다'); - setFetchData(result.data || result); - } else { - alert('저장에 실패했습니다'); - console.error('API 응답 오류:', result); - } - } catch (error) { - alert('저장 중 오류가 발생했습니다'); - console.error('API 요청 오류:', error); - } - } - } - - // 레이어명에 따라 상세페이지 렌더링 - const ContentComponent = layerNm ? getLayerContentComponent(layerNm) : null; - - return ( - <div id="popup" className="absolute top-0 right-0 w-[350px] h-[calc(100vh-5rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-[50]"> - <LoadingOverlay isLoading={isLoading} /> - <div className="card-header bg-primary flex items-center justify-between relative p-4"> - <div className="flex items-center space-x-4 flex-1"> - <h3 className="text-2xl font-medium text-white text-label whitespace-nowrap">{layerNm} - {tableData?.usg_cd && ( - <> - ({tableData.usg_cd}) - </> - )} 정보 <br />관리번호 : {layerSpecificNm} - </h3> - </div> - <div className="flex items-center space-x-3"> - {/* 하수관로일 때만 Infil/RDII 버튼 표시 */} - {layerNm === '하수관로' && ( - <> - <Button onClick={() => setIsModalInfil(true)} - className="py-1 text-xl text-blue-600 border border-blue-600 rounded hover:bg-gray-700" - color="white" - size="sm" - > - 침입수분석 - </Button> - <Button onClick={() => setIsModalRdii(true)} - className="py-1 text-xl text-gray-600 border border-gray-600 rounded hover:bg-gray-700 mr-4" - color="white" - size="sm" - > - RDII분석 - </Button> - </> - )} - {layerNm === '하수맨홀' && ( - <> - <Button - color="white" - className="px-2 py-1 text-xl text-gray-600 border border-gray-600 rounded hover:bg-gray-700 mr-4" - onClick={() => { - setIsLoading(true); - searchUpperPpls(gisMap, layerSpecificNm).finally(() => setIsLoading(false)); - }} - > - 상류관로 보기 - </Button> - </> - )} - </div> - <Button - onClick={onClose} - className="flex items-center space-x-4 text-white hover:text-gray-300 focus:outline-none" - size="xs" - > - ✕ - </Button> - </div> - <div className="overflow-y-auto overflow-x-hidden flex-1" ref={contentRef}> - <VerticalTable - headName='기본정보' - tableName1={layerNm === '중점우수토실' ? "측정지점" : undefined} - tableData={[tableData]} - headlist={headlist} - pageSize={20} - total={0} - cellClick={undefined} - onPageChange={function (): void { - throw new Error("Function not implemented."); - }} - // 두 번째 테이블 조건부 props - showSecondTable={layerNm === '중점우수토실' ? true : false} - tableName2={layerNm === '중점우수토실' ? "시설현황" : undefined} - tableData2={layerNm === '중점우수토실' && tableData ? [tableData] : []} - headlist2={layerNm === '중점우수토실' ? headlist2 : undefined} - pageSize2={20} - total2={0} - isEditable2={buttonChk} - /> - {ContentComponent && - <ContentComponent - featureData={fetchData} - isMainSsc={isMainSsc} - isDrtwSewagePump={isDrtwSewagePump} - onSaveClick={handleSaveClick} - chmbrXcnts={chmbrXcnts} - chmbrYdnts={chmbrYdnts} - gisObject={featureData.values_} - /> - } - </div> - - <InfilChart pplMngNo={pplMngNo} /> - <RdiiChart pplMngNo={pplMngNo} /> - </div> - ) -} -export default FeatureClickEventPopup; diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/sewagePump.tsx b/apps/sn/app/html/(map-layout)/map/components/popupComponent/sewagePump.tsx deleted file mode 100644 index 07d78a7d1..000000000 --- a/apps/sn/app/html/(map-layout)/map/components/popupComponent/sewagePump.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/** - * ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수펌프장) - * - * 📄 sewagePump - * 하수펌프장 정보 컴포넌트 - * - * - 빗물펌프장 및 오수펌프장 구분에 따른 자체 입력 정보 표시 및 편집 기능 - * - VerticalTable을 통한 입력 폼 렌더링 - * - 저장 버튼을 통한 데이터 저장 기능 - */ -import { VerticalTable } from '@/components/ui/verticalTable'; -import React, { useState, useEffect } from 'react' -// 빗물펌프장 자체 입력 정보 -const exclnInputColumn = [ - { id: "swtrmtZoneNm", title: "배수구역", type: "input" }, - { id: "mltvDtdvNm", title: "배수분구", type: "input" }, - { id: "designFrqYr", title: "설계빈도(년)", type: "input", placeholder: "예: 10", isNumber: true, length: 4 }, -] -// 오수펌프장 자체 입력 정보 -const drtwInputColumn = [ - { id: "swtrmtZoneNm", title: "처리구역", type: "input" }, - { id: "mltvDtdvNm", title: "처리분구", type: "input" }, -] - - -const sewagePump = ({ featureData, isDrtwSewagePump, onSaveClick }: { featureData: any, isDrtwSewagePump?: boolean, onSaveClick?: () => void }) => { - const [formData, setFormData] = useState<any>({}); - - // featureData가 변경될 때 formData 초기화 - useEffect(() => { - if (featureData) { - setFormData(featureData); - } - }, [featureData]); - - // 폼 데이터가 변경될 때 호출될 핸들러 - const handleFormDataChange = (newFormData: any) => { - setFormData(newFormData); - }; - - // 저장 버튼 클릭 시 최신 formData를 부모에게 전달 - const handleSaveClick = () => { - if (onSaveClick) { - // 저장 전에 formData를 부모 컴포넌트의 handleSaveClick에 전달 - onSaveClick(); - } - }; - - return ( - <VerticalTable - headName='입력정보' - tableData={featureData ? [formData] : []} - headlist={isDrtwSewagePump ? drtwInputColumn : exclnInputColumn} - pageSize={0} - total={0} - children={undefined} - cellClick={undefined} - isEditable={true} - onPageChange={function (page: number): void { - throw new Error('Function not implemented.'); - }} - showSaveButton={true} - onSaveClick={handleSaveClick} - onFormDataChange={handleFormDataChange} // 폼 데이터 변경 콜백 추가 - /> - ) -} - -export default sewagePump diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/smh.tsx b/apps/sn/app/html/(map-layout)/map/components/popupComponent/smh.tsx deleted file mode 100644 index cd59003ef..000000000 --- a/apps/sn/app/html/(map-layout)/map/components/popupComponent/smh.tsx +++ /dev/null @@ -1,126 +0,0 @@ -/** - * ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수맨홀) - * - * 📄 Smh - * 하수맨홀 정보 컴포넌트 - * - * - 하수맨홀의 자체입력정보 표시 및 편집 기능 - * - VerticalTable을 통한 입력 폼 렌더링 - * - 상류관로 검색 및 지도 포커스 기능 - * - 저장 버튼을 통한 데이터 저장 기능 - * - * 🔗 사용 API - * - 상류관로 조회: GET /api/v1/fcltmhl/upperPpls/{fcltMngNo} (sewerage-service) - */ -import { VerticalTable } from '@/components/ui/verticalTable'; -import { useEffect, useState } from 'react'; -import GisApp from "@common/business_components/ui/map/lib/gis.core"; -import { getData } from '@/components/lib/api'; - -// 상류관로 보기 함수 (FeatureClickEventPopup에서 사용) -export async function searchUpperPpls(gisMap: any, fcltMngNo: any) { - let data: any = null; - try { - data = await getData(`v1/fcltmhl/upperPpls/${fcltMngNo}`); - } catch (error) { - console.error("JSON parsing error:", error); - alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`); - return; - } - - if (!data) { - alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`); - return; - } - - const select: any = GisApp.Module.select; - let foundFeature: any = null; - - (data || []).forEach((item: any) => { - const pplMngNo = item.ppl_mng_no?.trim?.(); - - gisMap - .getLayers() - .getArray() - .filter((e: any) => e.get("lyrTyNm") == "tb_ms_swage_ppl") - .forEach((layer: any) => { - const source = layer.getSource?.(); - if (source?.getFeatures) { - const features = source.getFeatures(); - for (const f of features) { - if (f.values_.fclt_mng_no?.trim?.() === pplMngNo) { - foundFeature = f; - break; - } - } - } - }); - - if (foundFeature) select?.selectMultiFeature?.(foundFeature, true); - }); - - if (!foundFeature) alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`); - - const geometry = foundFeature?.getGeometry(); - if (geometry) { - const extent = geometry.getExtent(); - gisMap.getView().fit(extent, { - duration: 300, - padding: [50, 50, 50, 50], - maxZoom: 14, - constrainResolution: true, - nearest: true, - }); - } -} - -const inputColumn = [ - { id: "swtrmtZoneNm", title: "처리구역", type: "input" }, - { id: "mltvZoneNm", title: "배수구역", type: "input" }, - { id: "mltvDtdvNm", title: "배수분구", type: "input" }, - { id: "layCd", title: "매설위치", type: "select", options: ["도로", "하상"] }, -]; -const Smh = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: () => void }) => { - const [formData, setFormData] = useState<any>({}); - - // featureData가 변경될 때 formData 초기화 - useEffect(() => { - if (featureData) { - setFormData(featureData); - } - }, [featureData]); - - // 폼 데이터가 변경될 때 호출될 핸들러 - const handleFormDataChange = (newFormData: any) => { - setFormData(newFormData); - }; - - // 저장 버튼 클릭 시 최신 formData를 부모에게 전달 - const handleSaveClick = () => { - if (onSaveClick) { - // 저장 전에 formData를 부모 컴포넌트의 handleSaveClick에 전달 - onSaveClick(); - } - }; - - return ( - <VerticalTable - headName='입력정보' - tableData={featureData ? [formData] : []} - headlist={inputColumn} - pageSize={20} - total={0} - children={undefined} - cellClick={undefined} - isEditable={true} - onPageChange={function (page: number): void { - throw new Error("Function not implemented."); - }} - showSaveButton={true} - onSaveClick={handleSaveClick} - onFormDataChange={handleFormDataChange} // 폼 데이터 변경 콜백 추가 - /> - ) -} - -export default Smh; diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/stormWaterPump.tsx b/apps/sn/app/html/(map-layout)/map/components/popupComponent/stormWaterPump.tsx deleted file mode 100644 index 92973b133..000000000 --- a/apps/sn/app/html/(map-layout)/map/components/popupComponent/stormWaterPump.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/** - * ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (빗물펌프) - * - * 📄 StormWaterPump - * 빗물펌프 정보 컴포넌트 - * - * - 빗물펌프의 자체입력정보 표시 및 편집 기능 - * - VerticalTable을 통한 입력 폼 렌더링 - */ -import { VerticalTable } from '@/components/ui/verticalTable'; -import React from 'react' -const inputColumn = [ - { id: "swtrmtZoneNm", title: "처리구역", type: "input" }, - { id: "mltvDtdvNm", title: "배수분구", type: "input" }, -] -// 빗물펌프 -const StormWaterPump = ({ featureData }: { featureData: Record<string, any> }) => { - const validKey = inputColumn.map((item) => item.id); - const filteredData = featureData && Object.entries(featureData).filter(([key]) => validKey.includes(key)).reduce((obj: Record<string, any>, [key, value]) => { obj[key] = value; return obj; }, {}); - return ( - <VerticalTable - headName='입력정보' - tableData={[filteredData]} - headlist={inputColumn} - pageSize={0} - total={0} - children={undefined} - cellClick={undefined} - isEditable={true} - onPageChange={function (page: number): void { - throw new Error('Function not implemented.'); - }} - /> - ) -} - -export default StormWaterPump \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/page.tsx b/apps/sn/app/html/(map-layout)/map/page.tsx deleted file mode 100644 index cae8c372b..000000000 --- a/apps/sn/app/html/(map-layout)/map/page.tsx +++ /dev/null @@ -1,169 +0,0 @@ -/** - * ➡️ 경로 : 하수관망도 메인 페이지 - * - * 📄 Map Page - * 지도 페이지 컴포넌트 - * - * - OpenLayers 기반 GIS 지도 렌더링 및 상호작용 - * - 레이어 트리 데이터 관리 및 동적 레이어 표시/숨김 - * - 피처 검색 및 선택 기능 (SearchModal, ProcSearchBar) - * - 피처 클릭 이벤트 처리 및 상세 정보 팝업 표시 - * - 수심(WaterDepth) 레이어 애니메이션 재생 기능 - * - Zustand를 통한 검색 모달 상태 관리 - * - * 🔗 사용 API - * - 레이어 설정 조회: GET /api/v1/map/lyrStngs/{lyrGrp} (sewerage-service) - */ - -"use client"; - -import Gis from "@common/business_components/ui/map/Gis"; -import { getRawJson } from "../../../components/lib/api"; -import { useCallback, useEffect, useRef, useState } from "react"; -import GisApp from "@common/business_components/ui/map/lib/gis.core"; -import SearchModal from "./searchModal/searchModal"; -import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup"; -import { WaterDepth, WATER_DEPTH_LAYER_TYPES } from "./waterDepth/waterDepth"; -import { ProcSearchBar } from "@common/business_components/ui/map/searchBar"; -import useSearchModalStore from "@/store/useSearchModalStore"; - -const Map = () => { - const { isSearchModalOpen, setIsSearchModalOpen } = useSearchModalStore(); - - const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정 - const gisMaps = useRef<any | null>(null); - const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태 - const [featureData, setFeatureData] = useState(null); // 선택된 피쳐 데이터 - const [showWaterDepth, setShowWaterDepth] = useState(false); // WaterDepth 표시 여부 - const [isPlaying, setIsPlaying] = useState(false); - - useEffect(() => { - fetchData(); - }, []); - - const fetchData = async () => { - try { - const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK"); - setTreeData(data); - } catch (error) { - console.error('JSON parsing error:', error); - // JSON 파싱에 실패한 경우, 원본 텍스트 또는 null 반환 - throw new Error('Invalid JSON response from server'); - } - }; - - // SearchModal, GisContent 등에서 호출 - const onFeatureSelect = (data: any) => { - let map = gisMaps.current; - if (!map) { - console.warn("지도가 초기화되지 않았습니다."); - return; - } - - // 항상 같은 select 객체 사용 - const select = GisApp.Module.select as any; - if (!select) return; - - // 1. 기존 선택 초기화 - select.resetSelectedFeature(); - - // 2. data가 OpenLayers Feature 인스턴스인지 확인 - let foundFeature = null; - if ( - data && - typeof data.getGeometry === "function" && - typeof data.setStyle === "function" - ) { - // 이미 Feature 인스턴스라면 그대로 사용 - foundFeature = data; - } else { - // 아니면 featureId로 맵에서 Feature 인스턴스를 찾아서 사용 - const featureId = data.fid || data.ftrIdn || data.id; - map - .getLayers() - .getArray() - .forEach((layer: any) => { - const source = layer.getSource?.(); - if (source?.getFeatureById) { - const feature = source.getFeatureById(String(featureId).trim()); - if (feature) foundFeature = feature; - } - }); - } - - // 3. 피처 선택 - if (foundFeature) { - let geometry = foundFeature.getGeometry(); - if (geometry) { - let extent = geometry.getExtent(); - - map.getView().fit(extent, { - duration: 300, - padding: [50, 50, 50, 50], - maxZoom: 16, - constrainResolution: true, - nearest: true, - }); - } - select.selectFeature(foundFeature, true); - } else { - console.warn("OpenLayers Feature를 찾을 수 없습니다:", data); - } - - setFeatureData(data); - setIsOpen(true); - setIsSearchModalOpen(false); - }; - - const facilityTypes = [{ text: "하수처리시설", val: "tb_il_swage_prcs_fclt" }]; - // WaterDepth 표시 여부를 결정하는 함수 - const handleWaterDepthVisibility = useCallback((checkedIds: string[], checkedNodesData: any[]) => { - const waterDepthLayers = checkedNodesData.filter(node => - WATER_DEPTH_LAYER_TYPES.includes(node.data?.lyrTyNm) - ); - - const hasWaterDepthLayer = waterDepthLayers.length > 0; - setShowWaterDepth(hasWaterDepthLayer); - }, []); - - // 체크된 레이어 변경 처리 함수 - const handleCheckedLayersChange = useCallback((checkedIds: string[], checkedNodesData: any[]) => { - handleWaterDepthVisibility(checkedIds, checkedNodesData); - }, [handleWaterDepthVisibility]); - - - return ( - <> - <div className="bg-container"> - <Gis - treeData={treeData} - isSearch={true} - gisMaps={gisMaps} - isMiniMap={false} - searchView={SearchModal} - detailView={FeatureClickEventPopup} - onFeatureSelect={onFeatureSelect} - featureData={featureData} - setFeatureData={setFeatureData} - isOpen={isOpen} - setIsOpen={setIsOpen} - isSearchModalOpen={isSearchModalOpen} - setIsSearchModalOpen={setIsSearchModalOpen} - onCheckedLayersChange={handleCheckedLayersChange} - searchBar={<ProcSearchBar gisMaps={gisMaps} facilityTypes={facilityTypes} />} - waterDepth={showWaterDepth ? WaterDepth : undefined} - isPlaying={isPlaying} - setIsPlaying={setIsPlaying as any} - targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any} - checkInterval={2500} - showZoomLevel={true} - - defaultZoom={7.5} - defaultLonLat={[127.75, 36]} - /> - </div> - </> - ); -}; - -export default Map; diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/basicTab.tsx b/apps/sn/app/html/(map-layout)/map/searchModal/basicTab.tsx deleted file mode 100644 index af33c0c55..000000000 --- a/apps/sn/app/html/(map-layout)/map/searchModal/basicTab.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/** - * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 - * - * 📄 BasicTab Component - * Next.js App Router (app/) 기반 검색 모달의 기본정보 탭 컴포넌트 - * - * - 기본정보 검색을 위한 구분 선택 및 조건 입력 UI 제공 - * - 하수관로, 하수처리시설, 하수처리구역, 하수맨홀, 하수펌프장, 우수토실 등 다양한 유형별 컴포넌트 렌더링 - * - useSearchModalStore를 통해 상태 관리 및 사용자 상호작용 처리 - */ -import React from 'react'; -import { SelectBox } from '@common/business_components/ui'; -import { Label } from '@common/components'; -import SwagePpl from './swagePpl'; -import SwagePrcsFclt from './swagePrcsFclt'; -import SwagePrcsZone from './swagePrcsZone'; -import SwageMhl from './swageMhl'; -import SwagePmstn from './swagePmstn'; -import Stofcbr from './stofcbr'; -import useSearchModalStore from '@/store/useSearchModalStore'; - - -// 기본정보 '구분' 선택 목록 -export const BASIC_TYPES = [ - { val: "tb_ms_swage_ppl", text: "하수관로" }, - { val: "tb_il_swage_prcs_fclt", text: "하수처리시설" }, - { val: "tb_ms_swage_prcs_zone", text: "하수처리구역" }, - { val: "tb_ms_swage_mhl", text: "하수맨홀" }, - { val: "tb_ms_swage_pmstn", text: "하수펌프장" }, - { val: "tb_ms_stofcbr", text: "우수토실" }, -]; - -const BasicTab: React.FC = () => { - const { basicType, setBasicType } = useSearchModalStore(); - - return ( - <div className="relative flex w-full min-h-[10vh] flex-wrap items-center gap-x-4 gap-y-3 rounded-lg border px-5 pt-5"> - {/* 구분 */} - <div className="flex shrink-0 items-center"> - <div className="flex items-center"> - <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">구분</Label> - <SelectBox - className="w-[150px] mr-3" - selectClass="rounded-lg bg-white" - label="" - selectArray={BASIC_TYPES} - onSelectValue={(value: string | number) => - setBasicType(String(value)) - } - value={basicType} - selectContentClass="z-[101]" - /> - </div> - </div> - {/* 조회 조건: 구분 선택과 같은 행에 배치 */} - {basicType === "tb_ms_swage_ppl" && (<SwagePpl />)} {/* 하수관로 */} - {basicType === "tb_il_swage_prcs_fclt" && (<SwagePrcsFclt />)} {/* 하수처리시설 */} - {basicType === "tb_ms_swage_prcs_zone" && (<SwagePrcsZone />)} {/* 하수처리구역 */} - {basicType === "tb_ms_swage_mhl" && (<SwageMhl />)} {/* 하수맨홀 */} - {basicType === "tb_ms_swage_pmstn" && (<SwagePmstn />)} {/* 하수펌프장 */} - {basicType === "tb_ms_stofcbr" && (<Stofcbr />)} {/* 우수토실 */} - </div> - ); -}; - -export default React.memo(BasicTab); diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/operationTab.tsx b/apps/sn/app/html/(map-layout)/map/searchModal/operationTab.tsx deleted file mode 100644 index 63c18875d..000000000 --- a/apps/sn/app/html/(map-layout)/map/searchModal/operationTab.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/** - * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 유지관리 정보 탭 - * - * 📄 Operation Tab - * Next.js App Router (app/) 기반 검색 모달 유지관리 정보 탭 컴포넌트 - * - * - 업무구분 선택 (보수 및 갱신, 점검 및 진단, 청소 및 준설) - * - 사업명 입력 및 수행기간 범위 선택 - * - 사업 유형 필터링 기능 - * - 조회 버튼을 통한 데이터 검색 트리거 - * - Zustand를 통한 상태 관리 및 필터 값 저장 - * - Enter 키 입력 시 자동 조회 지원 - */ -import React from 'react'; -import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; -import { SelectBox } from '@common/business_components/ui'; -import { Button, Input, Label } from '@common/components'; -import useStore from '@/store/useStore'; -import useSearchModalStore from '@/store/useSearchModalStore'; -import useSearchModal from './useSearchModal'; -import useOperationTab from './useOperationTab'; - - -const OperationTab: React.FC = () => { - const { BIZ_TY_CD } = useStore(); - const { - workType, setWorkType, - var1, setVar1, var2, setVar2, - yearFilter, setYearFilter, - } = useSearchModalStore(); - const { handleSearch } = useSearchModal(); - const { WORK_TYPES } = useOperationTab(); - - const BIZ_TY_CD_SELECT_ARRAY = BIZ_TY_CD.map(item => ({ - val: item.lwpoCd, - text: item.lwpoCdNm, - })); - - return ( - <div className="relative flex flex-col border rounded-lg pt-5 px-5 w-full min-h-[10vh]"> - <div className="flex items-center mb-3"> - <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">업무구분</Label> - <SelectBox - className="w-40 mr-8" - selectClass="rounded-lg bg-white text-xl" - label="" - selectArray={WORK_TYPES.map((w) => ({ - val: w.val, - text: w.text, - }))} - onSelectValue={(val) => setWorkType(String(val))} - value={workType} - selectContentClass="z-[101]" - /> - </div> - <div className="flex items-center mb-6"> - <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">사업명</Label> - <Input - className="w-[100px] !text-xl placeholder:text-center mr-8" - placeholder="입력" - value={var1} - onChange={(e) => { setVar1(e.target.value); }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); // Enter 키 기본 동작 방지 - handleSearch(); - } - }} - /> - <YearRangeSelectBox - start={yearFilter.start} - end={yearFilter.end} - onChange={range => setYearFilter({ ...yearFilter, start: range.start, end: range.end })} - startLabel="수행기간" - endLabel="" - /> - <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">사업 유형</Label> - <SelectBox - className="w-60 mr-8" - selectClass="rounded-lg bg-white text-xl" - label="" - selectArray={[{ val: "0", text: "전체" }, ...BIZ_TY_CD_SELECT_ARRAY]} - value={var2} - onSelectValue={(val) => { - setVar2(String(val)); - }} - selectContentClass="z-[101]" - /> - {/* 조회 버튼 */} - <div className="flex-1 flex justify-end"> - <Button className="text-base py-2" onClick={handleSearch}>조회</Button> - </div> - </div> - </div> - ) -}; - -export default React.memo(OperationTab); \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/reportFilesModal.tsx b/apps/sn/app/html/(map-layout)/map/searchModal/reportFilesModal.tsx deleted file mode 100644 index 2b7218332..000000000 --- a/apps/sn/app/html/(map-layout)/map/searchModal/reportFilesModal.tsx +++ /dev/null @@ -1,108 +0,0 @@ -/** - * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 하수도정비기본계획보고서 탭 (셀 클릭) - * - * 📄 Report Files Modal - * Next.js App Router (app/) 기반 보고서 파일 모달 컴포넌트 - * - * - 하수도정비 기본계획 보고서 파일 목록 표시 - * - 파일명 및 등록일자 정보 테이블 출력 - * - 페이지네이션 및 로딩 오버레이 지원 - * - 파일 클릭 이벤트 처리 및 상세 조회 - * - Zustand를 통한 모달 상태 및 데이터 관리 - * - 모달 열림/닫힘 시 데이터 초기화 기능 - */ -'use client' -import React, { useEffect } from 'react'; -import LoadingOverlay from '@/components/ui/LoadingBar'; -import useReportTab from './useReportTab'; -import { useReportTabStore } from '@/store/useReportTabStore'; -import { UiTable } from '@common/business_components/ui'; -import { UiModal } from "@common/business_components"; -import useSearchModalStore from '@/store/useSearchModalStore'; - -type ModalOpenProp = { clsfSystmSn: string } // 자료관 기본계획 일련번호 -const ReportFilesModal: React.FC<ModalOpenProp> = ({ clsfSystmSn }: ModalOpenProp) => { - // 하수도정비 기본계획 보고서 파일목록 - const REPORT_FILES_HEADLIST = [ - { id: "no", title: "NO" }, - { id: "fileTitle", title: "파일명" }, - { id: "regDt", title: "등록일자" }, - ]; - - const { - curPage, setCurPage, - isLoading, - isResetPage, - isResetSelection, - setIsResetPage, - setIsResetSelection - } = useSearchModalStore(); - const { - isOpen: isModalOpen, - files, - dcFiles: filesOrigin, - setIsOpen: setIsModalOpen, - setFiles, - fetchDcFiles - } = useReportTabStore(); - const { getFiles, handleCellClick } = useReportTab(); - - - useEffect(() => { - fetchDcFiles(clsfSystmSn); - }, [clsfSystmSn]); - - useEffect(() => { - if (isModalOpen === true) { - getFiles(); // 파일 목록 조회 - } else { - setFiles([]); // 모달이 닫힐 때 파일 목록 초기화 - setCurPage(1); // 페이지를 1로 초기화 - setIsResetPage(true); // 페이지를 초기화 - setIsResetSelection(true); // 선택 초기화 - } - }, [filesOrigin, isModalOpen, curPage]); - - const renderBody = () => ( - <> - <div className="flex-1 px-2 pb-2"> - <LoadingOverlay isLoading={isLoading} /> - <UiTable - headName={(() => { - return (<>조회 결과 ({filesOrigin.length.toLocaleString('ko-KR')})건</>); - })() - } - tableData={files} - headlist={REPORT_FILES_HEADLIST} - pageSize={10} - total={filesOrigin.length} - cellClick={handleCellClick} - onPageChange={setCurPage} - resetPageNo={isResetPage} - resetSelection={isResetSelection} - /> - </div> - </> - ); - - - return ( - <UiModal - open={isModalOpen} - onOpenChange={setIsModalOpen} - headerTitle="" - bodyClass={` - min-h-[50vh] max-h-[90dvh] - max-w-[750px] - h-auto w-auto - overflow-auto - z-[5000] - flex items-start - `} - body={renderBody()} - noFooterYn='Y' - /> - ); -}; - -export default React.memo(ReportFilesModal); \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/reportTab.tsx b/apps/sn/app/html/(map-layout)/map/searchModal/reportTab.tsx deleted file mode 100644 index ad57c0a5f..000000000 --- a/apps/sn/app/html/(map-layout)/map/searchModal/reportTab.tsx +++ /dev/null @@ -1,272 +0,0 @@ -/** - * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 하수도정비기본계획보고서 탭 - * - * 📄 Report Tab - * Next.js App Router (app/) 기반 검색 모달 보고서 탭 컴포넌트 - * - * - 하수도정비 기본계획 보고서 목록 표시 및 필터링 - * - 시도/시군구/보고서 구분/승인년도 기반 검색 기능 - * - 테이블 데이터 표시 및 페이지네이션 지원 - * - 보고서 클릭 시 파일 목록 모달 열기 - * - Zustand를 통한 모달 상태 및 데이터 관리 - * - 로딩 오버레이 및 선택 초기화 기능 - */ -'use client'; -import React, { useEffect, useState } from 'react'; -import { Button, Label } from '@common/components'; -import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; -import { SelectBox, UiTable } from '@common/business_components/ui'; -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 ReportFilesModal from './reportFilesModal'; // 파일목록모달 - - -// 하수도정비 기본계획 보고서 -export const reportTableHeadlist = [ - { id: "no", title: "NO" }, - { id: "sido", title: "시도" }, - { id: "sigungu", title: "시군구" }, - { id: "approvalYear", title: "승인년도" }, - { id: "plantZoneGubun", title: "구분" }, - { id: "reportGubun", title: "보고서구분" }, - { id: "fileName", title: "파일명" }, - { id: "regDate", title: "등록일자" }, -]; -interface ReportTabProps { - open?: boolean; - isSearch?: boolean; - ctpvNm?: string; - sggNm?: string; -} - -const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sggNm }) => { - - // const [isLoading, setIsLoading] = useState(false); - const [isResetPage, setIsResetPage] = useState<boolean>(false); - const [isResetSelection, setIsResetSelection] = useState<boolean>(false); - const [showTable, setShowTable] = useState(false); - // const [tableData, setTableData] = useState<any[]>([]); - // const [tableCount, setTableCount] = useState(0); - // const [curPage, setCurPage] = useState(1); - // const [yearFilter, setYearFilter] = useState({ start: "", end: "" }); - const [ctpvCd, setCtpvCd] = useState('0'); - const [sggCd, setSggCd] = useState('0'); - const [reportGubun, setReportGubun] = useState("0"); - const [clsfSystmSn, setClsfSystmSn] = useState(''); // 자료관 기본계획 일련번호 - - const { - tableData, - tableCount, - isLoading, - yearFilter, - curPage, - setTableData, - setTableCount, - setIsLoading, - setYearFilter, - setCurPage - } = useSearchModalStore(); - const { ctpv, sgg } = useStore(); - const { - isOpen: isReportFilesModalOpen, - setIsOpen: setIsReportFilesModalOpen, - dcTree: reportOriginData, - fetchDcTree - } = useReportTabStore(); // 모달 상태 관리 스토어 - const { REPORT_TYPES, flattenFolders, addFileName } = useReportTab(); - - useEffect(() => { - fetchDcTree("기본계획"); // 하수정비기본계획 report - }, []); - - useEffect(() => { - // filterParams 초기화 - setCtpvCd('0'); - setSggCd('0'); - setReportGubun('0'); - setYearFilter({ start: "", end: "" }); - setShowTable(false); - setTableData([]); - setTableCount(0); - setCurPage(1); // 페이지를 1로 초기화 - setIsResetPage(true); // 페이지를 초기화 - setIsResetSelection(true); // 선택 초기화 - }, [open]); - - useEffect(() => { - const newCtpvCd = ctpv.find((n) => n.text === ctpvNm)?.val || '0'; - const newSggCd = sgg.find((n) => n.text === sggNm)?.val || '0'; - - setCtpvCd(newCtpvCd); - setSggCd(newSggCd); - }, [ctpvNm, sggNm]); - - // ctpvCd, sggCd가 업데이트된 후에 fetchFilteredData 실행 - useEffect(() => { - if (!isSearch && ctpvCd && sggCd) { - fetchFilteredData(); - } - }, [reportOriginData, ctpvCd, sggCd, isSearch]); - - // 페이지 변경 시마다 데이터 fetch - useEffect(() => { - // showTable이 true일 때만 fetch (최초 렌더 방지) - setIsResetPage(false); - - if (showTable) fetchFilteredData(); - }, [curPage]); - - const handleCtpvCdChange = (sidoId: string) => { - setCtpvCd(sidoId); - setSggCd('0'); - } - - // 조회 버튼 클릭 시 1페이지로 이동 및 데이터 초기화 - const handleSearch = () => { - setCurPage(1); // 페이지를 1로 초기화 - fetchFilteredData(); - setIsResetPage(true); // 페이지를 초기화 - setIsResetSelection(false); // 선택 초기화 - setShowTable(true); - }; - - // 하수도정비기본계획 테이블 cellClick 핸들러 - const handleReportCellClick = (index: number | undefined) => { - if (typeof index === "number" && tableData[index]) { - const reportData = tableData[index]; - if (reportData.fileSn !== "") { - setClsfSystmSn(reportData.clsfSystmSn); // 자료관 기본계획 일련번호 설정 - setIsReportFilesModalOpen(true); // 모달 열기 - } - } - }; - - // 검색 API 호출 함수 - const fetchFilteredData = async () => { - setIsLoading(true); // 로딩 시작 - setIsResetSelection(true); // 선택 초기화 - - try { - const reportData = flattenFolders(reportOriginData); - let filterReportData = reportData; - - if (ctpvCd !== '0') { // 시도검색 - const sidoOption = ctpv.find((n) => n.val.trim() === ctpvCd.trim()); - filterReportData = filterReportData.filter(item => item.sido === sidoOption?.text); - } - if (sggCd !== '0') { // 시군구검색 - const sigunguOption = sgg.find((n) => String(n.val) === String(sggCd)); - filterReportData = filterReportData.filter(item => item.sigungu === sigunguOption?.text); - } - if (reportGubun !== "0") { // 보고서구분검색 - const reportGubunOption = REPORT_TYPES.find((n) => n.val === reportGubun); - filterReportData = filterReportData.filter(item => item.reportGubun === reportGubunOption?.text); - } - - const { start, end } = yearFilter; - if (start && !end) //승인년도검색 - filterReportData = filterReportData.filter(item => (item.approvalYear) ? item.approvalYear >= yearFilter.start + "년" : null); - else if (!start && end) - filterReportData = filterReportData.filter(item => (item.approvalYear) ? item.approvalYear <= yearFilter.end + "년" : null); - else if (start && end) { - filterReportData = filterReportData.filter(item => { - return (item.approvalYear) ? item.approvalYear >= yearFilter.start + "년" && item.approvalYear <= yearFilter.end + "년" : null; - }); - } - // if (gubun !== "") { // 구분검색 (추후 사용할 수도 있을 것 같아 남겨둠) - // filterReportData = filterReportData.filter(item => (item.plantZoneGubun) ? String(item.plantZoneGubun).includes(String(gubun)) : null); - // } - - setTableCount(filterReportData.length || 0); - - filterReportData = filterReportData.slice((curPage - 1) * 10, curPage * 10); // paging - filterReportData = filterReportData?.map((item, index) => ({ ...item, no: (curPage - 1) * 10 + index + 1 })); - - addFileName(filterReportData).then(data => { - filterReportData = data; - setTableData(filterReportData || []); - setIsResetSelection(false); // 데이터 설정 후 선택 초기화 해제 - }); - - setTableData(filterReportData || []); - setShowTable(true); - } catch (err) { - console.error('API ERROR:', err); - setTableData([]); - setIsResetSelection(false); // 에러 시에도 선택 초기화 해제 - } finally { - setIsLoading(false); // 로딩 종료 - } - }; - - - return ( - <> - <div className={`relative flex flex-col border rounded-lg pt-5 px-5 w-full ${isSearch ? "min-h-[10vh]" : "min-h-[6vh]"}`}> - {isSearch && ( - <> - <div className="flex items-center mb-3"> - <CtpvSggPrcsFcltSelectBox - sido={ctpvCd} - sigungu={sggCd} - onSidoChange={(sidoId: string) => { handleCtpvCdChange(sidoId) }} - onSigunguChange={setSggCd} - setIsLoading={setIsLoading} - /> - </div> - </> - )} - <div className="flex items-center mb-6"> - <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">보고서 구분</Label> - <SelectBox className="w-44 mr-8" - selectClass="rounded-lg bg-white" - label="" - selectArray={[...REPORT_TYPES]} - value={reportGubun} onSelectValue={val => setReportGubun(String(val))} - selectContentClass="z-[101]" labelClass='whitespace-nowrap text-xl text-gray-600 pr-2' - /> - {/* 승인년도 필터 */} - <YearRangeSelectBox - start={yearFilter.start} - end={yearFilter.end} - onChange={range => setYearFilter({ ...yearFilter, start: range.start, end: range.end })} - startLabel="승인년도" endLabel="" - /> - {/* 조회 버튼 */} - <div className="flex-1 flex justify-end"> - <Button className="text-base py-2" onClick={handleSearch}>조회</Button> - </div> - </div> - </div> - - <div className="h-[79vh] w-full overflow-hidden rounded-lg border"> - {/* 테이블2 영역 (가로/세로 스크롤 가능) */} - <div className="flex-1 px-2 pb-2"> - <LoadingOverlay isLoading={isLoading} /> - <UiTable - headName={(() => { - return (<>조회 결과</>); - })() - } - tableData={tableData} - headlist={reportTableHeadlist} - pageSize={10} - total={tableCount} - cellClick={handleReportCellClick} - onPageChange={setCurPage} - resetPageNo={isResetPage} - resetSelection={isResetSelection} - /> - </div> - </div> - {/* 모달 컴포넌트 */} - {isReportFilesModalOpen && <ReportFilesModal clsfSystmSn={clsfSystmSn} />} - </> - ); -}; - -export default React.memo(ReportTab); \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/searchModal.tsx b/apps/sn/app/html/(map-layout)/map/searchModal/searchModal.tsx deleted file mode 100644 index 3ce334518..000000000 --- a/apps/sn/app/html/(map-layout)/map/searchModal/searchModal.tsx +++ /dev/null @@ -1,238 +0,0 @@ -/** - * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 - * - * 📄 Search Modal - * Next.js App Router (app/) 기반 검색 모달 컴포넌트 - * - * - 탭 기반 검색 인터페이스 (기본정보, 유지관리 정보, 보고서) - * - 테이블 데이터 표시 및 페이지네이션 기능 - * - 시설물 조회 결과 및 유지관리 사업 현황 표시 - * - 엑셀 다운로드 및 지도에서 보기 기능 - * - Zustand를 통한 검색 모달 상태 관리 - * - 로딩 오버레이 및 선택 초기화 지원 - */ -import LoadingOverlay from '@/components/ui/LoadingBar'; -import { UiModal, UITab, UiTable } from '@common/business_components/ui'; -import { Button } from '@common/components/ui'; -import React, { useEffect, useMemo } from 'react'; -import { Toaster } from "react-hot-toast"; -import useSearchModalStore from '@/store/useSearchModalStore'; -import { ExclnSewagePumpCol2, MsSwageMhlCol2, MsSwagePplCol2, stofcbrCol2, SwtrmtFcltCol2, SwtrmtPrcsZoneCol2 } from '../components/common/table'; -import BasicTab, { BASIC_TYPES } from './basicTab'; -import OperationTab from './operationTab'; -import ReportTab from './reportTab'; -import useSearchModal from './useSearchModal'; -import useBasicTab from './useBasicTab'; -import useOperationTab from './useOperationTab'; - -const SearchModal: React.FC = () => { - const { - showTable, - isSearchModalOpen, setIsSearchModalOpen, - activeTab, basicType, workType, - tableData, detailTableData, - tableCount, detailTableCount, - curPage, detailCurPage, - detailBizId, - isLoading, isDetailLoading, - isResetPage, isResetDetailPage, - isResetSelection, isResetDetailSelection, - setActiveTab, - setCurPage, setDetailCurPage, - setIsResetPage, setIsResetDetailPage, - setDetailBizId, - setIsResetDetailSelection, - resetFilters - } = useSearchModalStore(); - const { TABS, viewMultipleFeature, fetchFilteredData } = useSearchModal(); - const { handleBasicCellClick, basicExcelDownload } = useBasicTab(); - const { - WORK_TYPES, - OPERATION_TABLE_SIZE, - OPERATION_TABLE_HEADLIST, - MAINTENANACE_TABLE_HEADLIST, - CLEANING_TABLE_HEADLIST, - INSPECTION_TABLE_HEADLIST, - handleOperationCellClick, - handleOperationResultCellClick, - operationExcelDownload, - operationResultExcelDownload, - handleOperationPageNation - } = useOperationTab(); - - - const dynamicHeadlist: { id: string, title: string }[] = useMemo(() => { - if (activeTab === 'basic') { // 기본정보 - if (basicType === 'tb_ms_swage_ppl') return MsSwagePplCol2;// 하수관로 - if (basicType === 'tb_il_swage_prcs_fclt') return SwtrmtFcltCol2;// 하수처리시설 - if (basicType === 'tb_ms_swage_prcs_zone') return SwtrmtPrcsZoneCol2; // 하수처리구역 - if (basicType === 'tb_ms_swage_mhl') return MsSwageMhlCol2; // 하수맨홀 - if (basicType === 'tb_ms_swage_pmstn') return ExclnSewagePumpCol2; // 하수펌프장 - if (basicType === 'tb_ms_stofcbr') return stofcbrCol2; // 우수토실 - - return []; - } - if (activeTab === 'operation') return OPERATION_TABLE_HEADLIST; // 유지관리 정보 - - return []; - }, [activeTab, basicType, workType]); - - const dynamicHeadlist2: { id: string, title: string }[] = useMemo(() => { - if (activeTab === 'operation') { - if (workType === WORK_TYPES[0]?.val) return MAINTENANACE_TABLE_HEADLIST; // 보수 및 갱신 - if (workType === WORK_TYPES[1]?.val) return CLEANING_TABLE_HEADLIST; // 점검 및 진단 - if (workType === WORK_TYPES[2]?.val) return INSPECTION_TABLE_HEADLIST; // 청소 및 준설 - } - - return []; - }, [activeTab, workType]); - - useEffect(() => { - resetFilters(); - }, [activeTab, basicType, workType]); - - // 페이지 변경 시마다 데이터 fetch - useEffect(() => { - setIsResetPage(false); - setIsResetDetailPage(true); - setDetailBizId(0); - if (showTable) fetchFilteredData(); - }, [curPage]); - - // 페이지 변경 시마다 데이터 fetch - useEffect(() => { - setIsResetDetailSelection(false); - if (showTable) handleOperationPageNation(Number(detailBizId)); - }, [detailCurPage]); - - - const renderBody = () => ( - <> - {/* 탭 영역 */} - <UITab - defaultValue={activeTab} - tabList={TABS.map((tab: any) => ({ - value: tab.val, - label: tab.text - }))} - onValueChange={(value: string) => setActiveTab(value)} - /> - {activeTab === 'basic' && (<BasicTab />)} {/* 기본정보 탭 */} - {activeTab === 'operation' && (<OperationTab />)} {/* 유지관리 정보 탭 */} - {activeTab === 'report' && (<ReportTab open={isSearchModalOpen} />)} {/* 하수도정비기본계획보고서 탭 */} - - - {/* 📋 결과 리스트 */} - {/* 기본정보 탭 */} - {activeTab === 'basic' && ( - <div className="h-[79dvh] w-full rounded-lg border mt-4 overflow-x-scroll"> - <LoadingOverlay isLoading={isLoading} /> - <UiTable className='w-[120%]' - headName={(() => { - const label = BASIC_TYPES.find((type) => type.val === basicType)?.text - return (<>{label} 조회 결과</>); - })() - } - tableData={tableData} - headlist={dynamicHeadlist} - pageSize={10} - total={tableCount} - cellClick={handleBasicCellClick} - onPageChange={setCurPage} - resetPageNo={isResetPage} - resetSelection={isResetSelection} - curPage={curPage} - headButton={ - <div className='flex'> - <Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium primary" size="sm" onClick={viewMultipleFeature}>지도에서 보기</Button> - <Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium green" size="sm" onClick={basicExcelDownload}>파일 다운로드</Button> - </div> - } - /> - </div> - )} - {/* 유지관리 정보 탭 */} - {activeTab === 'operation' && ( - <> - <div className="h-[39dvh] w-full overflow-x-hidden overflow-y-auto rounded-lg border mt-4"> - {/* <div className="flex-1 px-2 pb-2"> */} - <LoadingOverlay isLoading={isLoading} /> - <UiTable - headName={ - (() => { - const label = WORK_TYPES.find((type) => type.val === workType)?.text - return (<>{label} 사업 현황</>); - })() - } - tableData={tableData} - headlist={dynamicHeadlist} - pageSize={OPERATION_TABLE_SIZE} - total={tableCount} - cellClick={handleOperationCellClick} - onPageChange={setCurPage} - resetPageNo={isResetPage} - resetSelection={isResetSelection} - curPage={curPage} - headButton={ - <Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-sm font-medium green" size="sm" onClick={operationExcelDownload}>파일 다운로드</Button> - } - /> - {/* </div> */} - </div> - - <div className="h-[39dvh] w-full overflow-x-hidden overflow-y-auto rounded-lg border"> - {/* 테이블2 영역 */} - {/* <div className="flex-1 px-2 pb-2"> */} - <LoadingOverlay isLoading={isDetailLoading} /> - <UiTable - headName={ - (() => { - const label = WORK_TYPES.find((type) => type.val === workType)?.text - return (<>{label} 결과표</>); - })() - } - tableData={detailTableData} - headlist={dynamicHeadlist2} - pageSize={OPERATION_TABLE_SIZE} - total={detailTableCount} - cellClick={handleOperationResultCellClick} - onPageChange={setDetailCurPage} - resetPageNo={isResetDetailPage} - resetSelection={isResetDetailSelection} - curPage={detailCurPage} - headButton={ - <div className='flex'> - <Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium primary" size="sm" onClick={viewMultipleFeature}>지도에서 보기</Button> - <Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium green" size="sm" onClick={operationResultExcelDownload}>파일 다운로드</Button> - </div> - } - /> - {/* </div> */} - </div> - </> - )} - </> - ); - - return ( - <div className='overflow-x-auto overflow-y-hidden'> - <UiModal - open={isSearchModalOpen} - onOpenChange={setIsSearchModalOpen} - bodyClass={` - h-[100dvh] - w-[90dvw] - max-w-[1220px] - max-h-[970px] - overflow-y-hidden - z-[101] - `} - body={renderBody()} - useDirectRender={true} - /> - <Toaster position="top-center"></Toaster> - </div> - ); -}; - -export default React.memo(SearchModal); diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/stofcbr.tsx b/apps/sn/app/html/(map-layout)/map/searchModal/stofcbr.tsx deleted file mode 100644 index 102b21d64..000000000 --- a/apps/sn/app/html/(map-layout)/map/searchModal/stofcbr.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/** - * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 우수토실) - * - * 📄 Stofcbr Tab - * Next.js App Router (app/) 기반 검색 모달 우수토실 탭 컴포넌트 - * - * - 시도/시군구 선택 및 설치년도 범위 필터링 - * - CQL 필터 생성을 통한 쿼리 조건 설정 - * - 조회 버튼을 통한 데이터 검색 트리거 - * - Zustand를 통한 상태 관리 및 필터 값 저장 - * - Enter 키 입력 시 자동 조회 지원 - */ -import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; -import { Button } from '@common/components'; -import React from 'react'; -import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox'; -import useSearchModalStore from "@/store/useSearchModalStore"; -import useSearchModal from './useSearchModal'; - - -export const stofcbrCqlFilters = () => { - const { - ctpvCd, sggCd, - yearFilter, - todayYear, todayMonth, todayDate, - getDateString - } = useSearchModalStore.getState(); - - const cqlFilters: string[] = []; - // 시도, 시군구 - if (ctpvCd !== '0' && sggCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`); - else if (ctpvCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`); - // 설치년도 범위 - const startDate = getDateString( - yearFilter.start, - '', - '', - "19000101", - ); - const endDate = getDateString( - yearFilter.end, - '', - '', - `${todayYear}${todayMonth}${todayDate}`, - ); - cqlFilters.push( - `instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`, - ); - - return cqlFilters; -}; - -const Stofcbr: React.FC = () => { - const { - ctpvCd, setCtpvCd, - sggCd, setSggCd, - yearFilter, setYearFilter, - } = useSearchModalStore(); - const { handleSearch } = useSearchModal(); - - - return ( - <div className="mb-3"> - <div className="flex items-center mb-2"> - {/* 시도, 시군구 */} - <CtpvSggPrcsFcltSelectBox - sido={ctpvCd} - sigungu={sggCd} - onSidoChange={(sidoId: string) => { - setCtpvCd(sidoId); - setSggCd("0"); - }} - onSigunguChange={setSggCd} - /> - {/* 설치년도 */} - <YearRangeSelectBox - start={yearFilter.start} - end={yearFilter.end} - onChange={range => setYearFilter({ ...yearFilter, start: range.start, end: range.end })} - startLabel="설치년도" - endLabel="" - /> - {/* 조회 버튼 */} - <div className="flex-1 flex justify-end"> - <Button className="text-base py-2" onClick={handleSearch}>조회</Button> - </div> - </div> - </div> - ); -}; - -export default React.memo(Stofcbr); diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/swageMhl.tsx b/apps/sn/app/html/(map-layout)/map/searchModal/swageMhl.tsx deleted file mode 100644 index 266012dff..000000000 --- a/apps/sn/app/html/(map-layout)/map/searchModal/swageMhl.tsx +++ /dev/null @@ -1,156 +0,0 @@ -/** - * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수맨홀) - * - * 📄 SwageMhl Tab - * Next.js App Router (app/) 기반 검색 모달 하수맨홀 탭 컴포넌트 - * - * - 시도/시군구 선택 및 설치년도 범위 필터링 - * - 용도 및 구경 범위 기반 검색 기능 - * - CQL 필터 생성을 통한 쿼리 조건 설정 - * - 조회 버튼을 통한 데이터 검색 트리거 - * - Zustand를 통한 상태 관리 및 필터 값 저장 - * - Enter 키 입력 시 자동 조회 지원 - */ -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 { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox'; -import useSearchModalStore from "@/store/useSearchModalStore"; -import useSearchModal from './useSearchModal'; - - -export const swageMhlCqlFilters = () => { - const { - ctpvCd, sggCd, - yearFilter, - start1, end1, - var1, - todayYear, todayMonth, todayDate, - getDateString - } = useSearchModalStore.getState(); - - const cqlFilters: string[] = []; - // 시도, 시군구 - if (ctpvCd !== '0' && sggCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`); - else if (ctpvCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`); - // // 설치년도 범위 - const startDate = getDateString( - yearFilter.start, - '', - '', - "19000101", - ); - const endDate = getDateString( - yearFilter.end, - '', - '', - `${todayYear}${todayMonth}${todayDate}`, - ); - cqlFilters.push( - `instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`, - ); - if (var1) cqlFilters.push(`usg_cd='${var1}'`); - // 구경(mm) 범위 - if (start1 && end1) cqlFilters.push(`swage_mhL_diam_vl BETWEEN ${start1} AND ${end1}`); - else if (start1) cqlFilters.push(`swage_mhL_diam_vl >= ${start1}`); - else if (end1) cqlFilters.push(`swage_mhL_diam_vl <= ${end1}`); - - return cqlFilters; -}; - -const SwageMhl: React.FC = () => { - const { - var1, setVar1, - start1, setStart1, end1, setEnd1, - ctpvCd, setCtpvCd, - sggCd, setSggCd, - yearFilter, setYearFilter, - } = useSearchModalStore(); - const { handleSearch } = useSearchModal(); - - return ( - <div className="mb-3"> - <div className="mb-2 flex items-center"> - <CtpvSggPrcsFcltSelectBox - sido={ctpvCd} - sigungu={sggCd} - onSidoChange={(sidoId: string) => { - setCtpvCd(sidoId); - setSggCd("0"); - }} - onSigunguChange={setSggCd} - /> - {/* 설치년도 */} - <YearRangeSelectBox - start={yearFilter.start} - end={yearFilter.end} - onChange={(range) => - setYearFilter({ - ...yearFilter, - start: range.start, - end: range.end, - }) - } - startLabel="설치년도" - endLabel="" - /> - {/* 용도 */} - <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">용도</Label> - <SelectBox - className="w-48 mr-8" - selectClass="rounded-lg bg-white text-xl" - label="" - selectArray={[{ val: "0", text: "전체" }, ...MS_SWAGE_MHL_USE_CODES]} - value={var1} - onSelectValue={(val) => { - setVar1(String(val)); - }} - selectContentClass="z-[101]" - /> - {/* 하수맨홀 구경 */} - <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">구경(mm)</Label> - <Input - className="w-[100px] !text-xl placeholder:text-center" - placeholder="입력" - value={start1} - onChange={(e) => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ""); - setStart1(val); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); // Enter 키 기본 동작 방지 - handleSearch(); - } - }} - /> - <span className="mx-2 text-xl font-semibold">~</span> - <Input - className="mr-8 w-[100px] !text-xl placeholder:text-center" - placeholder="입력" - value={end1} - onChange={(e) => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ""); - setEnd1(val); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); // Enter 키 기본 동작 방지 - handleSearch(); - } - }} - /> - {/* 조회 버튼 */} - <div className="flex-1 flex justify-end"> - <Button className="text-base py-2" onClick={handleSearch}>조회</Button> - </div> - </div> - </div> - ); -}; - -export default React.memo(SwageMhl); diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/swagePmstn.tsx b/apps/sn/app/html/(map-layout)/map/searchModal/swagePmstn.tsx deleted file mode 100644 index 3512b4fa3..000000000 --- a/apps/sn/app/html/(map-layout)/map/searchModal/swagePmstn.tsx +++ /dev/null @@ -1,175 +0,0 @@ -/** - * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수펌프장) - * - * 📄 SwagePmstn Tab - * Next.js App Router (app/) 기반 검색 모달 하수펌프장 탭 컴포넌트 - * - * - 시도/시군구 선택 및 설치년도 범위 필터링 - * - 일일처리용량 및 양수능력 범위 기반 검색 기능 - * - CQL 필터 생성을 통한 쿼리 조건 설정 - * - 조회 버튼을 통한 데이터 검색 트리거 - * - Zustand를 통한 상태 관리 및 필터 값 저장 - * - Enter 키 입력 시 자동 조회 지원 - */ -import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; -import { Button, Input, Label } from '@common/components'; -import React from 'react'; -import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox'; -import useSearchModalStore from "@/store/useSearchModalStore"; -import useSearchModal from './useSearchModal'; - - -export const swagePmstnCqlFilters = () => { - const { - ctpvCd, sggCd, - yearFilter, - start1, start2, end1, end2, - todayYear, todayMonth, todayDate, - getDateString - } = useSearchModalStore.getState(); - - const cqlFilters: string[] = []; - // 시도, 시군구 - if (ctpvCd !== '0' && sggCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`); - else if (ctpvCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`); - // 설치년도 범위 - const startDate = getDateString( - yearFilter.start, - '', - '', - "19000101", - ); - const endDate = getDateString( - yearFilter.end, - '', - '', - `${todayYear}${todayMonth}${todayDate}`, - ); - cqlFilters.push( - `instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`, - ); - // 일일처리용량 범위 - if (start1 && end1) - cqlFilters.push(`daily_prcs_cpct BETWEEN ${start1} AND ${end1}`); - else if (start1) cqlFilters.push(`daily_prcs_cpct >= ${start1}`); - else if (end1) cqlFilters.push(`daily_prcs_cpct <= ${end1}`); - // 양수능력 범위 - if (start2 && end2) - cqlFilters.push(`excln_wpmp_ablt_vl BETWEEN ${start2} AND ${end2}`); - else if (start2) cqlFilters.push(`excln_wpmp_ablt_vl >= ${start2}`); - else if (end2) cqlFilters.push(`excln_wpmp_ablt_vl <= ${end2}`); - - return cqlFilters; -}; - -const SwagePmstn: React.FC = () => { - const { - start1, setStart1, - end1, setEnd1, - ctpvCd, setCtpvCd, - sggCd, setSggCd, - yearFilter, setYearFilter, - } = useSearchModalStore(); - const { handleSearch } = useSearchModal(); - - return ( - <div className="mb-3"> - <div className="flex items-center mb-2"> - {/* 시도, 시군구 */} - <CtpvSggPrcsFcltSelectBox - sido={ctpvCd} - sigungu={sggCd} - onSidoChange={(sidoId: string) => { - setCtpvCd(sidoId); - setSggCd("0"); - }} - onSigunguChange={setSggCd} - /> - {/* 설치년도 */} - <YearRangeSelectBox - start={yearFilter.start} - end={yearFilter.end} - onChange={range => setYearFilter({ ...yearFilter, start: range.start, end: range.end })} - startLabel="설치년도" - endLabel="" - /> - {/* 일일처리용량 */} - <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">일일처리용량(㎥)</Label> - <Input - className="w-[100px] !text-xl placeholder:text-center" - placeholder="입력" - value={start1} - onChange={(e) => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ""); - setStart1(val); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); // Enter 키 기본 동작 방지 - handleSearch(); - } - }} - /> - <span className="mx-2 text-xl font-semibold">~</span> - <Input - className="mr-8 w-[100px] !text-xl placeholder:text-center" - placeholder="입력" - value={end1} - onChange={(e) => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ""); - setEnd1(val); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); // Enter 키 기본 동작 방지 - handleSearch(); - } - }} - /> - {/* 양수능력 */} - {/* <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">양수능력(m²/d)</Label> - <Input - className="w-[100px] !text-xl placeholder:text-center" - placeholder="입력" - value={start2} - onChange={(e) => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ""); - setStart2(val); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); // Enter 키 기본 동작 방지 - handleSearch(); - } - }} - /> - <span className="mx-2 text-xl font-semibold">~</span> - <Input - className="mr-8 w-[100px] !text-xl placeholder:text-center" - placeholder="입력" - value={end2} - onChange={(e) => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ""); - setEnd2(val); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); // Enter 키 기본 동작 방지 - handleSearch(); - } - }} - /> */} - {/* 조회 버튼 */} - <div className="flex-1 flex justify-end"> - <Button className="text-base py-2" onClick={handleSearch}>조회</Button> - </div> - </div> - </div> - ); -}; - -export default React.memo(SwagePmstn); diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/swagePpl.tsx b/apps/sn/app/html/(map-layout)/map/searchModal/swagePpl.tsx deleted file mode 100644 index 5bc8f900b..000000000 --- a/apps/sn/app/html/(map-layout)/map/searchModal/swagePpl.tsx +++ /dev/null @@ -1,209 +0,0 @@ -/** - * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수관로) - * - * 📄 SwagePpl Tab - * Next.js App Router (app/) 기반 검색 모달 하수관로 탭 컴포넌트 - * - * - 시도/시군구 선택 및 설치년도 범위 필터링 - * - 용도 및 관경 범위 기반 검색 기능 - * - CQL 필터 생성을 통한 쿼리 조건 설정 - * - 조회 버튼을 통한 데이터 검색 트리거 - * - Zustand를 통한 상태 관리 및 필터 값 저장 - * - Enter 키 입력 시 자동 조회 지원 - */ -import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; -import { SelectBox } from '@common/business_components/ui'; -import { Button, Input, Label } from '@common/components'; -import React from 'react'; -import { MS_SWAGE_PPL_USE_CODES } from '../components/common/table'; -import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox'; -import useSearchModalStore from "@/store/useSearchModalStore"; -import useSearchModal from './useSearchModal'; - - -// 하수관로용 CQL 필터 생성 -export const swagePplCqlFilters = () => { - const { - var1, var2, - ctpvCd, sggCd, - yearFilter, - start1, start2, end1, end2, - todayYear, todayMonth, todayDate, - getDateString - } = useSearchModalStore.getState(); - - const cqlFilters: string[] = []; - if (var1) cqlFilters.push(`usg_cd='${var1}'`); - if (var2) cqlFilters.push(`mqlt_cd='${var2}'`); - if (ctpvCd !== '0' && sggCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`); - else if (ctpvCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`); - // 설치년도 범위 - const startDate = getDateString( - yearFilter.start, - '', - '', - "19000101", - ); - const endDate = getDateString( - yearFilter.end, - '', - '', - `${todayYear}${todayMonth}${todayDate}`, - ); - cqlFilters.push( - `instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`, - ); - // 관경(mm) 범위 - if (start1 && end1) cqlFilters.push(`pdmt_vl BETWEEN ${start1} AND ${end1}`); - else if (start1) cqlFilters.push(`pdmt_vl >= ${start1}`); - else if (end1) cqlFilters.push(`pdmt_vl <= ${end1}`); - // 연장(m) 범위 - if (start2 && end2) cqlFilters.push(`prlg_vl BETWEEN ${start2} AND ${end2}`); - else if (start2) cqlFilters.push(`prlg_vl >= ${start2}`); - else if (end2) cqlFilters.push(`prlg_vl <= ${end2}`); - - return cqlFilters; -}; - -const SwagePpl: React.FC = () => { - const { - var1, setVar1, - start1, setStart1, end1, setEnd1, - ctpvCd, setCtpvCd, sggCd, setSggCd, - yearFilter, setYearFilter, - } = useSearchModalStore(); - const { handleSearch } = useSearchModal(); - - - return ( - <div className="mb-6"> - <div className="mb-2 flex items-center"> - <CtpvSggPrcsFcltSelectBox - sido={ctpvCd} - sigungu={sggCd} - onSidoChange={(sidoId: string) => { - setCtpvCd(sidoId); - setSggCd("0"); - }} - onSigunguChange={setSggCd} - /> - {/* 설치년도 */} - <YearRangeSelectBox - start={yearFilter.start} - end={yearFilter.end} - onChange={(range) => - setYearFilter({ - ...yearFilter, - start: range.start, - end: range.end, - }) - } - startLabel="설치년도" - endLabel="" - /> - {/* 용도 */} - <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">용도</Label> - <SelectBox - className="w-32 mr-8" - selectClass="rounded-lg bg-white text-xl" - label="" - selectArray={[{ val: "0", text: "전체" }, ...MS_SWAGE_PPL_USE_CODES]} - value={var1} - onSelectValue={(val) => { - setVar1(String(val)); - }} - selectContentClass="z-[101]" - /> - {/* 재질 */} - {/* <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">재질</Label> - <SelectBox - className="w-60 mr-8" - selectClass="rounded-lg bg-white text-xl" - label="" - selectArray={MS_SWAGE_PPL_CODES} - value={var2} - onSelectValue={(val) => { - setVar2(String(val)); - }} - selectContentClass="z-[101]" - /> */} - {/* 관경 */} - <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">관경(mm)</Label> - <Input - className="w-[60px] !text-xl placeholder:text-center" - placeholder="입력" - value={start1} - onChange={(e) => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ""); - setStart1(val); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); // Enter 키 기본 동작 방지 - handleSearch(); - } - }} - /> - <span className="mx-2 text-xl font-semibold">~</span> - <Input - className="mr-8 w-[60px] !text-xl placeholder:text-center" - placeholder="입력" - value={end1} - onChange={(e) => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ""); - setEnd1(val); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); // Enter 키 기본 동작 방지 - handleSearch(); - } - }} - /> - {/* 연장 */} - {/* <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">연장(m)</Label> - <Input - className="w-[60px] !text-xl placeholder:text-center" - placeholder="입력" - value={start2} - onChange={(e) => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ""); - setStart2(val); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); // Enter 키 기본 동작 방지 - handleSearch(); - } - }} - /> - <span className="mx-2 text-xl font-semibold">~</span> - <Input - className="mr-8 w-[60px] !text-xl placeholder:text-center" - placeholder="입력" - value={end2} - onChange={(e) => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ""); - setEnd2(val); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); // Enter 키 기본 동작 방지 - handleSearch(); - } - }} - /> */} - {/* 조회 버튼 */} - <div className="flex-1 flex justify-end"> - <Button className="text-base py-2" onClick={handleSearch}>조회</Button> - </div> - </div> - </div> - ); -}; - -export default React.memo(SwagePpl); diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/swagePrcsFclt.tsx b/apps/sn/app/html/(map-layout)/map/searchModal/swagePrcsFclt.tsx deleted file mode 100644 index c541a4c2f..000000000 --- a/apps/sn/app/html/(map-layout)/map/searchModal/swagePrcsFclt.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/** - * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수처리시설) - * - * 📄 SwagePrcsFclt Tab - * Next.js App Router (app/) 기반 검색 모달 하수처리시설 탭 컴포넌트 - * - * - 시도/시군구 선택 및 가동개시일자 범위 필터링 - * - 시설용량 범위 기반 검색 기능 - * - CQL 필터 생성을 통한 쿼리 조건 설정 - * - 조회 버튼을 통한 데이터 검색 트리거 - * - Zustand를 통한 상태 관리 및 필터 값 저장 - * - Enter 키 입력 시 자동 조회 지원 - */ -import React from 'react'; -import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; -import { Button, Input, Label } from '@common/components'; -import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox'; -import useSearchModalStore from "@/store/useSearchModalStore"; -import useSearchModal from './useSearchModal'; - - -// 하수처리시설용 CQL 필터 생성 -export const swagePrcsFcltCqlFilters = () => { - const { - ctpvName, sggName, - yearFilter, - start1, end1, - todayYear, todayMonth, todayDate, - getDateString - } = useSearchModalStore.getState(); - - const cqlFilters: string[] = []; - // 가동개시일자 범위 - const startDate = getDateString( - yearFilter.start, - '', - '', - "19000101", - ); - const endDate = getDateString( - yearFilter.end, - '', - '', - `${todayYear}${todayMonth}${todayDate}`, - ); - cqlFilters.push( - `oprtng_strt_ymd >= '${startDate}' and oprtng_strt_ymd <= '${endDate}'`, - ); - // 시설용량 범위 - if (start1 && end1) - cqlFilters.push(`fclt_cpct BETWEEN ${start1} AND ${end1}`); - else if (start1) cqlFilters.push(`fclt_cpct >= ${start1}`); - else if (end1) cqlFilters.push(`fclt_cpct <= ${end1}`); - - // 시도 - if (ctpvName) cqlFilters.push(`ctpv_nm ILIKE '%${ctpvName}%'`); - if (sggName) cqlFilters.push(`sgg_nm ILIKE '%${sggName}%'`); - // // 처리구역 면적 - // if (start2 && end2) - // cqlFilters.push(`prcsZoneAreaSum BETWEEN ${start2} AND ${end2}`); - // else if (start2) cqlFilters.push(`prcsZoneAreaSum >= ${start2}`); - // else if (end2) cqlFilters.push(`prcsZoneAreaSum <= ${end2}`); - // // 하수처리시설명 - // if (var1) cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${var1}%'`); - return cqlFilters; -}; - -const SwagePrcsFclt: React.FC = () => { - const { - start1, setStart1, end1, setEnd1, - ctpvCd, setCtpvCd, setCtpvName, - sggCd, setSggCd, setSggName, - yearFilter, setYearFilter, - } = useSearchModalStore(); - const { handleSearch } = useSearchModal(); - - return ( - <div className="mb-3"> - <div className="mb-2 flex items-center"> - <CtpvSggPrcsFcltSelectBox - sido={ctpvCd} - sigungu={sggCd} - onSidoChange={(sidoId) => { - setCtpvCd(sidoId); - setSggCd("0"); - }} - onSigunguChange={setSggCd} - setCtpvName={setCtpvName} - setSggName={setSggName} - /> - {/* 가동개시일자 */} - <Label className="mr-2 w-auto text-xl text-gray-600">가동개시일자</Label> - <YearRangeSelectBox - start={yearFilter.start} - end={yearFilter.end} - onChange={(range) => - setYearFilter({ - ...yearFilter, - start: range.start, - end: range.end, - }) - } - startLabel="" - endLabel="" - /> - {/* 시설용량 */} - <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">시설용량(㎥/d)</Label> - <Input - className="w-[60px] !text-xl placeholder:text-center" - placeholder="입력" - value={start1} - onChange={(e) => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ""); - setStart1(val); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); // Enter 키 기본 동작 방지 - handleSearch(); - } - }} - /> - <span className="mx-2 text-xl font-semibold">~</span> - <Input - className="mr-8 w-[60px] !text-xl placeholder:text-center" - placeholder="입력" - value={end1} - onChange={(e) => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ""); - setEnd1(val); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); // Enter 키 기본 동작 방지 - handleSearch(); - } - }} - /> - {/* 조회 버튼 */} - <div className="flex-1 flex justify-end"> - <Button className="text-base py-2" onClick={handleSearch}>조회</Button> - </div> - </div> - </div> - ); -}; - -export default React.memo(SwagePrcsFclt); diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/swagePrcsZone.tsx b/apps/sn/app/html/(map-layout)/map/searchModal/swagePrcsZone.tsx deleted file mode 100644 index e433662d1..000000000 --- a/apps/sn/app/html/(map-layout)/map/searchModal/swagePrcsZone.tsx +++ /dev/null @@ -1,93 +0,0 @@ -/** - * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수처리구역) - * - * 📄 SwagePrcsZone Tab - * Next.js App Router (app/) 기반 검색 모달 하수처리구역 탭 컴포넌트 - * - * - 시도/시군구 선택 및 하수처리시설명/구역명 기반 검색 기능 - * - CQL 필터 생성을 통한 쿼리 조건 설정 - * - 조회 버튼을 통한 데이터 검색 트리거 - * - Zustand를 통한 상태 관리 및 필터 값 저장 - * - Enter 키 입력 시 자동 조회 지원 - */ -import React from 'react'; -import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox'; -import { Button, Input, Label } from '@common/components'; -import useSearchModalStore from "@/store/useSearchModalStore"; -import useSearchModal from './useSearchModal'; - - -// 하수처리구역용 CQL 필터 생성 -export const swagePrcsZoneCqlFilters = () => { - const { - ctpvCd, sggCd, - var1, var2 - } = useSearchModalStore.getState(); - const cqlFilters: string[] = []; - if (ctpvCd !== '' && ctpvCd !== '0') cqlFilters.push(`ctpv_cd='${ctpvCd?.trim()}'`); - if (sggCd !== '' && sggCd !== '0') cqlFilters.push(`sgg_cd='${sggCd}'`); - if (var1) cqlFilters.push(`swtrmt_zone_nm ILIKE '%${var1}%'`); - if (var2) cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${var2}%'`); - return cqlFilters; -}; - -const swagePrcsZone: React.FC = () => { - const { - var1, setVar1, var2, setVar2, - ctpvCd, setCtpvCd, - sggCd, setSggCd, - } = useSearchModalStore(); - const { handleSearch } = useSearchModal(); - - return ( - <div className="mb-3"> - <div className="mb-2 flex items-center"> - <CtpvSggPrcsFcltSelectBox - sido={ctpvCd} - sigungu={sggCd} - onSidoChange={(sidoId: string) => { - setCtpvCd(sidoId); - setSggCd("0"); - }} - onSigunguChange={setSggCd} - /> - <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">하수처리시설명</Label> - <Input - className='!text-xl w-[120px] placeholder:text-center mr-8' - placeholder="입력" - value={var2} - onChange={(e) => { - setVar2(e.target.value); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); // Enter 키 기본 동작 방지 - handleSearch(); - } - }} - /> - <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">하수처리구역명</Label> - <Input - className="mr-8 w-[100px] !text-xl placeholder:text-center" - placeholder="입력" - value={var1} - onChange={(e) => { - setVar1(e.target.value); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); // Enter 키 기본 동작 방지 - handleSearch(); - } - }} - /> - {/* 조회 버튼 */} - <div className="flex-1 flex justify-end"> - <Button className="text-base py-2" onClick={handleSearch}>조회</Button> - </div> - </div> - </div> - ); -}; - -export default React.memo(swagePrcsZone); diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/useBasicTab.ts b/apps/sn/app/html/(map-layout)/map/searchModal/useBasicTab.ts deleted file mode 100644 index 9184286ed..000000000 --- a/apps/sn/app/html/(map-layout)/map/searchModal/useBasicTab.ts +++ /dev/null @@ -1,348 +0,0 @@ -/** - * 📄 useBasicTab Hook - * 검색 모달 기본정보 탭 훅 - * - * - 필터링된 시설물 데이터 조회 및 포맷팅 - * - 테이블 셀 클릭 시 지도 피처 선택 및 줌 기능 - * - 엑셀 다운로드 기능 (한글 헤더 지원) - * - CQL 필터 생성을 통한 쿼리 조건 설정 - * - Zustand를 통한 상태 관리 및 로딩 처리 - * - 데이터 매핑 및 코드 변환 (시도/시군구, 용도 등) - * - * 🔗 사용 API - * - 관망도 시설물 목록 조회: GET /api/v1/fclts (sewerage-service) - */ -import { downloadExcelWithKoreanHeaders } from '@common/business_components/ui'; -import { - MS_SWAGE_PPL_USE_CODES, MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_SHAPE, MS_SWAGE_MHL_CODES, MS_SWAGE_MHL_USE_CODES, - MsSwagePplExcelCol, SwtrmtFcltExcelCol, SwtrmtPrcsZoneExcelCol, MsSwageMhlExcelCol, ExclnSewagePumpExcelCol, stofcbrExcelCol -} from '../components/common/table'; -import { getData } from "@/components/lib/api"; -import { BASIC_TYPES } from './basicTab'; -import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue'; -import useStore from '@/store/useStore'; -import GisApp from "@common/business_components/ui/map/lib/gis.core"; -import { confirmToast } from '@/components/ui/toast' -import { toast } from "react-hot-toast"; -import useSearchModalStore from '@/store/useSearchModalStore'; -import { swagePplCqlFilters } from './swagePpl'; -import { swagePrcsFcltCqlFilters } from './swagePrcsFclt'; -import { swagePrcsZoneCqlFilters } from './swagePrcsZone'; -import { swageMhlCqlFilters } from './swageMhl'; -import { swagePmstnCqlFilters } from './swagePmstn'; -import { stofcbrCqlFilters } from './stofcbr'; - - -export default function useBasicTab() { - const { - basicType, - tableData, - curPage, - todayYear, todayMonth, todayDate, - setIsSearchModalOpen, - setTableData, - setTableCount, - setIsLoading, - } = useSearchModalStore(); - const { ctpv, sgg } = useStore(); - - - const fetchBasicFilteredData = async () => { - let cqlFilters: string[] = []; - const queryParams = new URLSearchParams({ - lyrTypeNm: basicType, - page: `${curPage - 1}`, - size: '10', - sort: 'string', - }); - - if (basicType === 'tb_ms_swage_ppl') cqlFilters = swagePplCqlFilters(); // 하수관로 - else if (basicType === 'tb_il_swage_prcs_fclt') cqlFilters = swagePrcsFcltCqlFilters(); // 하수처리시설 - else if (basicType === 'tb_ms_swage_prcs_zone') cqlFilters = swagePrcsZoneCqlFilters(); // 하수처리구역 - else if (basicType === 'tb_ms_swage_mhl') cqlFilters = swageMhlCqlFilters(); // 하수맨홀 - else if (basicType === 'tb_ms_swage_pmstn') cqlFilters = swagePmstnCqlFilters(); // 하수펌프장 - else if (basicType === 'tb_ms_stofcbr') cqlFilters = stofcbrCqlFilters(); // 우수토실 - if (cqlFilters.length > 0) queryParams.append('cqlFiter', cqlFilters.join(' and ')); - - const res = await getData(`v1/fclts?${queryParams.toString()}`); - - // 🎯 각 아이템을 개별적으로 포맷팅하면서 일련번호 추가 - const dataWithRowNumber = (res.data || []).map((item: any, index: number) => { - // 🎯 먼저 각 아이템을 포맷팅 - const formattedItem = formatDisplayData(item); - - let ctpvCd = item?.ctpvNm ?? formattedItem?.ctpvNm ?? ''; - let sggCd = item?.sggNm ?? formattedItem?.sggNm ?? ''; - - if (!ctpvCd || !sggCd) { - const code = String(item?.pbadmsEmdCd ?? formattedItem?.pbadmsEmdCd ?? '')?.trim(); - if (!ctpvCd && code) ctpvCd = code.slice(0, 2); - if (!sggCd && code) sggCd = code.slice(0, 5); - } - - const matchedMqltCode = basicType === 'tb_ms_swage_ppl' - ? MS_SWAGE_PPL_CODES.find(code => code.val === item?.mqltCd?.trim()) // 하수관로 재질 - : basicType === 'tb_ms_swage_mhl' - ? MS_SWAGE_MHL_CODES.find(code => code.val === item?.mqltCd?.trim()) // 맨홀 재질 - : undefined; - const matchedMhlUsgCode = MS_SWAGE_MHL_USE_CODES.find(code => code.val === item?.usgCd); // 맨홀 용도 - const matchedPplShpCode = MS_SWAGE_PPL_SHAPE.find(code => code.val === item?.fcltyShpCd); // 하수관로 시설물형태코드 - - - const ctpvNm = ctpv.find(code => code.val.trim() === ctpvCd)?.text || item?.ctpvNm; - const sggNm = sgg.find(code => code.val.trim() === sggCd)?.text || item?.sggNm; - const usgCdObj = MS_SWAGE_PPL_USE_CODES.find(code => code.val === item?.usgCd?.trim()); - - // 추가 처리 및 일련번호 추가 - return { - ...formattedItem, - rowNumber: (curPage - 1) * 10 + index + 1, // 1부터 시작하는 일련번호 추가 - scsYn: item.scsYn === null || item.scsYn === undefined ? 'N' : item?.scsYn, // scsYn이 null이면 'N'으로 변경 - mqltCd: matchedMqltCode ? matchedMqltCode.text : item?.mqltCd, - usgCd: matchedMhlUsgCode ? matchedMhlUsgCode.text : (usgCdObj ? usgCdObj.text : item?.usgCd), - ctpvNm: ctpvNm ?? '-', - sggNm: sggNm ?? '-', - swtrmt_zone_nm: item.swtrmtZoneNm || item.swtrmt_zone_nm || '-', - fcltyShpCd: matchedPplShpCode? matchedPplShpCode.text : item?.fcltyShpCd, - }; - }); - - setTableData(dataWithRowNumber); - // console.log(tableData); - setTableCount(res.count || []); - }; - - // 기본정보 테이블 cellClick 핸들러 - const handleBasicCellClick = async (index?: number | undefined, headId?: String) => { - if (typeof index === "number") { - setIsLoading(true); - - const row = tableData[index]; - const layerNm = row.lyrTypeNm; - const fid = row.fid; - const core = GisApp.Module.layer as any; - await core.showSearchResultsFromGeoJSON(layerNm, fid); - setIsLoading(false); - setIsSearchModalOpen(false); // SearchModal 닫기 - } - }; - - // 기본정보 Excel 다운로드 핸들러 - const basicExcelDownload = async () => { - if (tableData.length === 0) { - alert("다운로드 할 Excel 파일이 없습니다.\n조회 후 다시 시도해주세요."); - return; - } - - toast.dismiss("global-confirm-toast"); - - confirmToast( - "파일 다운로드에는 2분 내외의 시간이 소요될 수 있습니다.\n파일 다운로드를 진행하시겠습니까?", - onDownload ?? (() => { }), - "text-xl font-medium text-gray-800" - ); - - async function onDownload() { - setIsLoading(true); - - try { - const queryParams = new URLSearchParams({ - lyrTypeNm: basicType, - size: '10000', - }); - - let cqlFilters: string[] = []; - let headerMapping: { [key: string]: string } = {}; - let headerRange: string = "A1:F1"; - - switch (basicType) { - case 'tb_ms_swage_ppl': { - // 하수관로 - cqlFilters = swagePplCqlFilters() - headerMapping = MsSwagePplExcelCol; - headerRange = "A1:Q1"; - break; - } - case 'tb_il_swage_prcs_fclt': { - // 하수처리시설 - cqlFilters = swagePrcsFcltCqlFilters(); - headerMapping = SwtrmtFcltExcelCol; - headerRange = "A1:J1"; - break; - } - case 'tb_ms_swage_prcs_zone': { - // 하수처리구역 - cqlFilters = swagePrcsZoneCqlFilters(); - headerMapping = SwtrmtPrcsZoneExcelCol; - headerRange = "A1:D1"; - break; - } - case 'tb_ms_swage_mhl': { - // 하수맨홀 - cqlFilters = swageMhlCqlFilters(); - headerMapping = MsSwageMhlExcelCol; - headerRange = "A1:J1"; - break; - } - case 'tb_ms_swage_pmstn': { - // 하수펌프장 - cqlFilters = swagePmstnCqlFilters(); - headerMapping = ExclnSewagePumpExcelCol; - headerRange = "A1:J1"; - break; - } - case 'tb_ms_stofcbr': { - // 우수토실 - cqlFilters = stofcbrCqlFilters(); - headerMapping = stofcbrExcelCol; - headerRange = "A1:H1"; - break; - } - } - - if (cqlFilters.length > 0) queryParams.append('cqlFiter', cqlFilters.join(' and ')); - - const res = await getData(`v1/fclts?${queryParams.toString()}`); - - const mhlUsgCdMap = new Map(MS_SWAGE_MHL_USE_CODES.map(code => [code.val?.trim(), code.text])); - const pplUsgCdMap = new Map(MS_SWAGE_PPL_USE_CODES.map(code => [code.val?.trim(), code.text])); - const pplMqltCdMap = new Map(MS_SWAGE_PPL_CODES.map(code => [code.val?.trim(), code.text])); - const pplShpCdMap = new Map(MS_SWAGE_PPL_SHAPE.map(code => [code.val?.trim(), code.text])); // 관로 시설물형태코드 - const ctpvMap = new Map(ctpv.map(code => [code.val?.trim(), code.text])); - const sggMap = new Map(sgg.map(code => [code.val?.trim(), code.text])); - - const mappedContent = (res.data || []).map((item: any) => { - const formattedItem = formatDisplayData(item); - - let ctpvCd = item?.ctpvCd ?? formattedItem?.ctpvCd ?? ''; - let sggCd = item?.sggCd ?? formattedItem?.sggCd ?? ''; - - if (!ctpvCd || !sggCd) { - const code = String(item?.pbadmsEmdCd ?? formattedItem?.pbadmsEmdCd ?? '')?.trim(); - if (!ctpvCd && code) ctpvCd = code.slice(0, 2); - if (!sggCd && code) sggCd = code.slice(0, 5); - } - - // trim 한 번만 수행 - const trimmedUsgCd = item?.usgCd?.trim(); - // const trimmedMqltCd = item?.mqltCd?.trim(); - - switch (basicType) { - case 'tb_ms_swage_ppl': { - // 하수관로 - return { - fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '-', - instMngNo: formattedItem.instMngNo ?? item.instMngNo ?? '-', - ctpvNm: ctpvMap.get(ctpvCd) ?? item.ctpvNm ?? '-', - sggNm: sggMap.get(sggCd) ?? item.sggNm ?? '-', - swtrmt_zone_nm: formattedItem.swtrmt_zone_nm ?? item.swtrmt_zone_nm ?? '-', - instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '-', - usgCd: pplUsgCdMap.get(trimmedUsgCd) || item.usgCd || formattedItem.usgCd || '-', - pdmtVl: formattedItem.pdmtVl ?? item.pdmtVl ?? '-', - prlgVl: formattedItem.prlgVl ?? item.prlgVl ?? '-', - tmptDpth: formattedItem.tmptDpth ?? item.tmptDpth ?? '-', - tmptMhlMngNo: formattedItem.tmptMhlMngNo ?? item.tmptMhlMngNo ?? '-', - scendDpth: formattedItem.scendDpth ?? item.scendDpth ?? '-', - scendMhlMngNo: formattedItem.scendMhlMngNo ?? item.scendMhlMngNo ?? '-', - vrtcLen: formattedItem.vrtcLen ?? item.vrtcLen ?? '-', - wdthLen: formattedItem.wdthLen ?? item.wdthLen ?? '-', - fcltyShpCd: pplShpCdMap.get(item?.fcltyShpCd?.trim()) || item.fcltyShpCd || formattedItem.fcltyShpCd || '-', - mqltCd: pplMqltCdMap.get(item?.mqltCd?.trim()) || item.mqltCd || formattedItem.mqltCd || '-', - }; - } - case 'tb_il_swage_prcs_fclt': { - // 하수처리시설 - return { - swtrmtFcltNm: formattedItem.swtrmtFcltNm ?? item.swtrmtFcltNm ?? '-', - fcltCpct: formattedItem.fcltCpct ?? item.fcltCpct ?? '-', - rgnExpln: formattedItem.rgnExpln ?? item.rgnExpln ?? '-', - oprtngStrtYmd: formattedItem.oprtngStrtYmd ?? item.oprtngStrtYmd ?? '-', - laeNm: formattedItem.laeNm ?? item.laeNm ?? '-', - sareaNm: formattedItem.sareaNm ?? item.sareaNm ?? '-', - rvrNm: formattedItem.rvrNm ?? item.rvrNm ?? '-', - dschgWtstNm: formattedItem.dschgWtstNm ?? item.dschgWtstNm ?? '-', - swtrmtPpltnCnt: formattedItem.swtrmtPpltnCnt ?? item.swtrmtPpltnCnt ?? '-', - rprsMsrcCn: formattedItem.rprsMsrcCn ?? item.rprsMsrcCn ?? '-' - }; - } - case 'tb_ms_swage_prcs_zone': { - // 하수처리구역 - return { - swtrmtZoneNm: formattedItem.swtrmtZoneNm ?? item.swtrmtZoneNm ?? '-', - ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? '-', - sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? '-', - swtrmtFcltNm: formattedItem.swtrmtFcltNm ?? item.swtrmtFcltNm ?? '-' - }; - } - case 'tb_ms_swage_mhl': { - // 하수맨홀 - return { - fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '-', - ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? '-', - sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? '-', - instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '-', - usgCd: mhlUsgCdMap.get(trimmedUsgCd) || item.usgCd || formattedItem.usgCd || '-', - swageMhlDiamVl: formattedItem.swageMhlDiamVl ?? item.swageMhlDiamVl ?? '-', - wdthLen: formattedItem.wdthLen ?? item.wdthLen ?? '-', - vrtcLen: formattedItem.vrtcLen ?? item.vrtcLen ?? '-', - altdVl: formattedItem.altdVl ?? item.altdVl ?? '-', - dpthlVl: formattedItem.dpthlVl ?? item.dpthlVl ?? '-' - }; - } - case 'tb_ms_swage_pmstn': { - // 하수펌프장 - return { - fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '-', - ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? '-', - sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? '-', - swagePmstnNm: formattedItem.swagePmstnNm ?? item.swagePmstnNm ?? '-', - instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '-', - dailyPrcsCpct: formattedItem.dailyPrcsCpct ?? item.dailyPrcsCpct ?? '-', - maxImpndCpct: formattedItem.maxImpndCpct ?? item.maxImpndCpct ?? '-', - altdVl: formattedItem.altdVl ?? item.altdVl ?? '-', - wtlvVl: formattedItem.wtlvVl ?? item.wtlvVl ?? '-', - exclnWpmpAbltVl: formattedItem.exclnWpmpAbltVl ?? item.exclnWpmpAbltVl ?? '-' - }; - } - case 'tb_ms_stofcbr': { - // 우수토실 - return { - fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '-', - mapshtNo: formattedItem.mapshtNo ?? item.mapshtNo ?? '-', - ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? '-', - sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? '-', - instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '-', - worflwWallHgt: formattedItem.worflwWallHgt ?? item.worflwWallHgt ?? '-', - swageMhlMngNo: formattedItem.swageMhlMngNo ?? item.swageMhlMngNo ?? '-', - drtnAngl: formattedItem.drtnAngl ?? item.drtnAngl ?? '-' - }; - } - } - }); - - // 선택된 행 데이터를 Excel 다운로드 형식으로 변환 - const excelData = mappedContent; // 배열로 감싸서 전달 - - // 파일명 생성 (분석번호 기반) - const label = BASIC_TYPES.find((type) => type.val === basicType)?.text - const fileName = `${label} 조회 결과_${todayYear}${todayMonth}${todayDate}`; - - await downloadExcelWithKoreanHeaders({ - data: excelData as JSON[], - fileName: fileName, - sheetNameArray: [fileName], - headerRange: headerRange, - headerMapping: headerMapping - }); - setIsLoading(false); - } catch (error) { - setIsLoading(false); - console.error("Excel 다운로드 중 오류 발생:", error); - alert("Excel 다운로드 중 오류가 발생했습니다. 다시 시도해주세요."); - } - } - }; - - - return { fetchBasicFilteredData, handleBasicCellClick, basicExcelDownload }; -}; diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/useOperationTab.ts b/apps/sn/app/html/(map-layout)/map/searchModal/useOperationTab.ts deleted file mode 100644 index 669dd4cb5..000000000 --- a/apps/sn/app/html/(map-layout)/map/searchModal/useOperationTab.ts +++ /dev/null @@ -1,452 +0,0 @@ -/** - * 📄 useOperationTab Hook - * 검색 모달 유지관리 정보 탭 훅 - * - * - 필터링된 사업 현황 데이터 조회 및 포맷팅 - * - 사업 현황 테이블 셀 클릭 시 상세 결과표 조회 - * - 결과표 셀 클릭 시 지도 피처 선택 및 줌 기능 - * - 엑셀 다운로드 기능 (사업 현황 및 결과표) - * - CQL 필터 생성을 통한 쿼리 조건 설정 - * - Zustand를 통한 상태 관리 및 로딩 처리 - * - 데이터 매핑 및 코드 변환 (사업 유형, 용도, 재질 등) - * - * 🔗 사용 API - * - 유지관리 정보 목록 조회: GET /api/v1/bizInfo (sewerage-service) - * - 유지관리 정보(사업정보) 관련 시설 목록 조회: GET /api/v1/bizInfo/fclts (sewerage-service) - */ -import { downloadExcelWithKoreanHeaders } from '@common/business_components/ui'; -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'; -import GisApp from "@common/business_components/ui/map/lib/gis.core"; -import { confirmToast } from '@/components/ui/toast' -import { toast } from "react-hot-toast"; -import useSearchModalStore from '@/store/useSearchModalStore'; -import useStore from '@/store/useStore'; - - -export default function useOperationTab() { - const OPERATION_TABLE_SIZE = 5; - - const { - workType, - tableData, detailTableData, - curPage, detailCurPage, - detailBizId, - var1, var2, - yearFilter, - todayYear, todayMonth, todayDate, - setIsSearchModalOpen, - setTableData, setDetailTableData, - setTableCount, setDetailTableCount, - setDetailCurPage, - setIsLoading, setIsDetailLoading, - setIsResetDetailPage, - setIsResetDetailSelection, - setDetailBizId, - getDateString - } = useSearchModalStore(); - const { BIZ_TY_CD } = useStore(); - - const WORK_TYPES = [ // 유지관리 정보 '구분' 선택 목록 - { val: "['200', '201', '204']", text: "보수 및 갱신" }, - { val: "['100']", text: "점검 및 진단" }, - { val: "['203']", text: "청소 및 준설" }, - ]; - const OPERATION_TABLE_HEADLIST = [ // 유지관리 정보 - 사업 현황 - { id: 'rowNumber', title: '일련번호' }, - { id: "bizInfoNm", title: "사업명" }, - { id: "flfmtBgngYmd", title: "수행시작일자" }, - { id: "flfmtEndYmd", title: "수행종료일자" }, - { id: "flfmtYr", title: "수행년도" }, - { id: "bizTypeCd", title: "사업유형" }, - { id: "bizAmt", title: "사업금액" }, - ]; - const MAINTENANACE_TABLE_HEADLIST = [ // 유지관리 정보 - 보수 및 갱신 결과표 - { id: 'rowNumber', title: '일련번호' }, - { id: "fcltMngNo", title: "관리번호" }, - { id: "swtrmt_zone_nm", title: "처리구역" }, - { id: "usgCd", title: "용도" }, - // { id: "lgalDrblyr", title: "내용연수" }, - // { id: "acqsPrmpc", title: "취득금액" }, - { id: "mqltCd", title: "재질" }, - { id: "pdmtVl", title: "관경(mm)" }, - { id: "prlgVl", title: "연장(m)" }, - { id: "instlYmd", title: "설치일자" }, - // { id: "instlYmd", title: "사업구분" }, - ]; - const CLEANING_TABLE_HEADLIST = [ // 유지관리 정보 - 청소 및 준설 결과표 - { id: 'rowNumber', title: '일련번호' }, - { id: "fcltMngNo", title: "관리번호" }, - { id: "swtrmt_zone_nm", title: "처리구역" }, - { id: "usgCd", title: "용도" }, - // { id: "lgalDrblyr", title: "내용연수" }, - // { id: "acqsPrmpc", title: "취득금액" }, - { id: "mqltCd", title: "재질" }, - { id: "pdmtVl", title: "관경(mm)" }, - { id: "prlgVl", title: "연장(m)" }, - { id: "instlYmd", title: "설치일자" }, - ]; - const INSPECTION_TABLE_HEADLIST = [ // 유지관리 정보 - 점검 및 진단 결과표 - { id: 'rowNumber', title: '일련번호' }, - { id: "fcltMngNo", title: "관리번호" }, - { id: "swtrmt_zone_nm", title: "처리구역" }, - { id: "usgCd", title: "용도" }, - // { id: "riskEval", title: "위험평가" }, - // { id: "acqsPrmpc", title: "취득금액" }, - // { id: "lgalDrblyr", title: "내용연수" }, - // { id: "remLife", title: "잔존수명" }, - { id: "mqltCd", title: "재질" }, - { id: "pdmtVl", title: "관경(mm)" }, - { id: "prlgVl", title: "연장(m)" }, - { id: "instlYmd", title: "설치일자" }, - { id: "strc_stts_grd", title: "구조적 상태등급" }, - { id: "oper_stts_grd", title: "운영적 상태등급" }, - ]; - - const fetchOperationFilteredData = async () => { - const RequestDTO: Record<string, string> = { - page: `${curPage - 1}`, - size: `${OPERATION_TABLE_SIZE}`, - }; - if (var1) RequestDTO.bizInfoNm = var1; - if (var2 != '0' && var2) RequestDTO.bizTypeCd = var2; - // 수행기간 범위 - const startDate = getDateString( - yearFilter.start, - '', - '', - "19000101", - ); - const endDate = getDateString( - yearFilter.end, - '', - '', - `${todayYear}${todayMonth}${todayDate}`, - ); - RequestDTO.fromflfmtEndYmd = startDate; - RequestDTO.toflfmtEndYmd = endDate; - - // JSON.parse 사용으로 문자열 -> 배열 변환 - RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"')); - - const params = new URLSearchParams(RequestDTO).toString(); - - const res = await getData(`v1/bizInfo?${params}`); - - const mappedContent = (res.content || []).map((item: any, index: number) => { - const formattedItem = formatDisplayData(item); - const matched = BIZ_TY_CD.find(code => code.lwpoCd === item.bizTypeCd); - return { - ...formattedItem, - rowNumber: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1, // 1부터 시작하는 일련번호 추가 - bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd, - }; - }); - - setTableData(mappedContent || []); - setTableCount(res.totalElements || 0); - setDetailTableData([]); - setDetailTableCount(0); - }; - - // 유지관리 정보 테이블 cellClick 핸들러 - const handleOperationCellClick = async (index?: number | undefined, headId?: String) => { - setIsResetDetailPage(true); // 페이지를 1로 초기화 - setIsResetDetailSelection(true); // 선택 초기화 - setIsResetDetailPage(true); // 디테일 페이지를 초기화 - - if (typeof index === "number") { - setIsDetailLoading(true); - setDetailBizId(tableData[index].bizId); - setDetailCurPage(1); // 상세 페이지 초기화 - try { - const RequestDTO: Record<string, string> = { - page: `${detailCurPage - 1}`, - size: `${OPERATION_TABLE_SIZE}`, - bizId: tableData[index % OPERATION_TABLE_SIZE].bizId, - }; - - RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"')); - - const res = await getData(`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`); - - const mappedData = (res.data || []).map((item: any, detailIndex: number) => { - const formattedItem = formatDisplayData(item); - - const usgCdObj = MS_SWAGE_PPL_USE_CODES.find(code => code.val === item?.usgCd?.trim()); - const mqltCdObj = MS_SWAGE_PPL_CODES.find(code => code.val === item?.mqltCd?.trim()); - return { - ...formattedItem, - rowNumber: (detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex + 1, - usgCd: usgCdObj ? usgCdObj.text : item.usgCd, - mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd, - swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : '-', - }; - }); - - setDetailTableData(mappedData); - setDetailTableCount(res.count || 0); - } catch (error) { - console.error('유지관리 정보 cellClick 에러:', error); - } finally { - setIsDetailLoading(false); - setIsResetDetailPage(false); - setIsResetDetailSelection(false); - } - } - }; - - // 유지관리 정보 디테일 테이블 cellClick 핸들러 - const handleOperationResultCellClick = async (index?: number | undefined, headId?: String) => { - if (typeof index === "number") { - setIsLoading(true); - - const row = detailTableData[index]; - const layerNm = row.lyrTypeNm; - const fid = row.fid; - const core = GisApp.Module.layer as any; - await core.showSearchResultsFromGeoJSON(layerNm, fid); - setIsLoading(false); - setIsSearchModalOpen(false); // SearchModal 닫기 - } - }; - - // 유지관리 정보 디테일 테이블 페이지네이션 핸들러 - const handleOperationPageNation = async (index?: number | undefined) => { - if (typeof index === "number") { - setIsDetailLoading(true); - try { - const RequestDTO: Record<string, string> = { - page: `${detailCurPage - 1}`, - size: `${OPERATION_TABLE_SIZE}`, - bizId: String(detailBizId), - }; - const res = await getData(`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`); - - const mappedData = (res.data || []).map((item: any, detailIndex: number) => { - const formattedItem = formatDisplayData(item); - - const usgCdObj = MS_SWAGE_PPL_USE_CODES.find(code => code.val === item?.usgCd?.trim()); - const mqltCdObj = MS_SWAGE_PPL_CODES.find(code => code.val === item?.mqltCd?.trim()); - return { - ...formattedItem, - rowNumber: (detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex + 1, - usgCd: usgCdObj ? usgCdObj.text : item.usgCd, - mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd, - swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : '-', - }; - }); - - setDetailTableData(mappedData); - setDetailTableCount(res.count || 0); - } catch (error) { - console.error('유지관리 정보 페이지네이션 에러:', error); - } finally { - setIsDetailLoading(false); - } - } - }; - - // 유지관리 정보 Excel 다운로드 핸들러 추가 - const operationExcelDownload = async () => { - if (tableData.length === 0) { - alert("다운로드 할 Excel 파일이 없습니다.\n조회 후 다시 시도해주세요."); - return; - } - - toast.dismiss("global-confirm-toast"); - - confirmToast( - "파일 다운로드에는 2분 내외의 시간이 소요될 수 있습니다.\n파일 다운로드를 진행하시겠습니까?", - onAnalysis ?? (() => { }), - "text-xl font-medium text-gray-800" - ); - - async function onAnalysis() { - setIsLoading(true); - - try { - const RequestDTO: Record<string, string> = { - size: '10000', - }; - if (var1) RequestDTO.bizInfoNm = var1; - if (var2 != '0' && var2) RequestDTO.bizTypeCd = var2; - // 수행기간 범위 - const startDate = getDateString( - yearFilter.start, - '', - '', - "19000101", - ); - const endDate = getDateString( - yearFilter.end, - '', - '', - `${todayYear}${todayMonth}${todayDate}`, - ); - RequestDTO.fromflfmtEndYmd = startDate; - RequestDTO.toflfmtEndYmd = endDate; - - // JSON.parse 사용으로 문자열 -> 배열 변환 - RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"')); - - const params = new URLSearchParams(RequestDTO).toString(); - const res = await getData(`v1/bizInfo?${params}`); - - // 헤더 매핑 정의 - const headerMapping: { [key: string]: string } = { - 'bizInfoNm': '사업명', - 'flfmtBgngYmd': '수행시작일자', - 'flfmtEndYmd': '수행종료일자', - 'flfmtYr': '수행년도', - 'bizTypeCd': '사업유형', - 'bizAmt': '사업금액(원)', - }; - - const bizTyCdMap = new Map(BIZ_TY_CD.map(code => [code.lwpoCd, code.lwpoCdNm])); - - const mappedContent = (res.content || []).map((item: any) => { - const formattedItem = formatDisplayData(item); - - return { - bizInfoNm: formattedItem.bizInfoNm ?? item.bizInfoNm ?? '', - flfmtBgngYmd: formattedItem.flfmtBgngYmd ?? item.flfmtBgngYmd ?? '', - flfmtEndYmd: formattedItem.flfmtEndYmd ?? item.flfmtEndYmd ?? '', - flfmtYr: formattedItem.flfmtYr ?? item.flfmtYr ?? '', - bizTypeCd: bizTyCdMap.get(item.bizTypeCd) || item.bizTypeCd || formattedItem.bizTypeCd || '', - bizAmt: ((v) => (v === 0 || v === '0') ? '0' : (v ?? ''))(formattedItem.bizAmt ?? item.bizAmt), - }; - }); - - // 선택된 행 데이터를 Excel 다운로드 형식으로 변환 - const excelData = mappedContent; // 배열로 감싸서 전달 - - // 파일명 생성 (분석번호 기반) - const label = WORK_TYPES.find((type) => type.val === workType)?.text - const fileName = `${label} 사업 현황_${todayYear}${todayMonth}${todayDate}`; - - await downloadExcelWithKoreanHeaders({ - data: excelData as JSON[], - fileName: fileName, - sheetNameArray: [fileName], - headerRange: "A1:F1", - headerMapping: headerMapping - }); - setIsLoading(false); - } catch (error) { - setIsLoading(false); - console.error("Excel 다운로드 중 오류 발생:", error); - alert("Excel 다운로드 중 오류가 발생했습니다. 다시 시도해주세요."); - } - } - }; - - // 유지관리 정보 결과표 Excel 다운로드 핸들러 추가 - const operationResultExcelDownload = async () => { - if (detailTableData.length === 0) { - alert("다운로드 할 Excel 파일이 없습니다.\n조회 후 다시 시도해주세요."); - return; - } - - toast.dismiss("global-confirm-toast"); - - confirmToast( - "파일 다운로드에는 2분 내외의 시간이 소요될 수 있습니다.\n파일 다운로드를 진행하시겠습니까?", - onAnalysis ?? (() => { }), - "text-xl font-medium text-gray-800" - ); - - async function onAnalysis() { - - setIsLoading(true); - - try { - const RequestDTO: Record<string, string> = { - size: '100', - bizId: String(detailBizId), - }; - - const res = await getData(`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`); - - // 헤더 매핑 정의 - const headerMapping: { [key: string]: string } = { - 'fcltMngNo': '관리번호', - 'swtrmt_zone_nm': '처리구역', - 'usgCd': '용도', - 'mqltCd': '재질', - 'pdmtVl': '관경(mm)', - 'prlgVl': '연장(m)', - 'instlYmd': '설치일자', - 'strc_stts_grd': '구조적 상태등급', - 'oper_stts_grd': '운영적 상태등급', - }; - - const usgCdMap = new Map(MS_SWAGE_PPL_USE_CODES.map(code => [code.val, code.text])); - const mqltCdMap = new Map(MS_SWAGE_PPL_CODES.map(code => [code.val, code.text])); - - // 🎯 단일 반복문으로 최적화 - 각 아이템마다 필요한 필드만 직접 매핑 - const mappedData = (res.data || []).map((item: any) => { - const formattedItem = formatDisplayData(item); - - // trim 한 번만 수행 - const trimmedUsgCd = item?.usgCd?.trim(); - const trimmedMqltCd = item?.mqltCd?.trim(); - - // zone 값 한 번만 계산 - const zone = item.swtrmtZoneNm ?? formattedItem.swtrmt_zone_nm ?? item.swtrmt_zone_nm ?? ''; - - // 직접 매핑 - 반복문 없이 한 번에 객체 생성 - return { - fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '', - swtrmt_zone_nm: (zone === null || zone === '') ? '-' : zone, - usgCd: usgCdMap.get(trimmedUsgCd) || item.usgCd || formattedItem.usgCd || '', - mqltCd: mqltCdMap.get(trimmedMqltCd) || item.mqltCd || formattedItem.mqltCd || '', - pdmtVl: formattedItem.pdmtVl ?? item.pdmtVl ?? '', - prlgVl: formattedItem.prlgVl ?? item.prlgVl ?? '', - instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '', - strc_stts_grd: ((v) => (v === 0 || v === '0') ? '0' : (v ?? ''))(formattedItem.strc_stts_grd ?? item.strc_stts_grd), - oper_stts_grd: ((v) => (v === 0 || v === '0') ? '0' : (v ?? ''))(formattedItem.oper_stts_grd ?? item.oper_stts_grd), - }; - }); - - // 선택된 행 데이터를 Excel 다운로드 형식으로 변환 - const excelData = mappedData; // 배열로 감싸서 전달 - - // 파일명 생성 (분석번호 기반) - const label = WORK_TYPES.find((type) => type.val === workType)?.text - const fileName = `${label} 결과표_${todayYear}${todayMonth}${todayDate}`; - - await downloadExcelWithKoreanHeaders({ - data: excelData as JSON[], - fileName: fileName, - sheetNameArray: [fileName], - headerRange: "A1:I1", - headerMapping: headerMapping - }); - - setIsLoading(false); - } catch (error) { - setIsLoading(false); - console.error("Excel 다운로드 중 오류 발생:", error); - alert("Excel 다운로드 중 오류가 발생했습니다. 다시 시도해주세요."); - } - } - }; - - - return { - WORK_TYPES, - OPERATION_TABLE_HEADLIST, - MAINTENANACE_TABLE_HEADLIST, - CLEANING_TABLE_HEADLIST, - INSPECTION_TABLE_HEADLIST, - OPERATION_TABLE_SIZE, - fetchOperationFilteredData, - handleOperationCellClick, - handleOperationResultCellClick, - handleOperationPageNation, - operationExcelDownload, - operationResultExcelDownload - }; -}; \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/useReportTab.ts b/apps/sn/app/html/(map-layout)/map/searchModal/useReportTab.ts deleted file mode 100644 index 8ffe06de8..000000000 --- a/apps/sn/app/html/(map-layout)/map/searchModal/useReportTab.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * 📄 useReportTab Hook - * 검색 모달 보고서 탭 훅 - * - * - 계층적 폴더 구조 평탄화 및 데이터 변환 - * - 파일 목록 조회 및 페이징 처리 - * - 파일 다운로드 기능 - * - 셀 클릭 이벤트 처리 - * - Zustand를 통한 상태 관리 및 로딩 처리 - * - 데이터 매핑 및 코드 변환 (시도/시군구, 보고서 유형 등) - * - * 🔗 사용 API - * - 보고서 파일 조회: GET /api/v1/file (dc-service) - * - 파일 다운로드: GET /api/v1/fileDownload (dc-service) - */ -import { getDcData } from '@/components/lib/api'; -import { fileDownload } from '@/utils'; -import { useReportTabStore } from '@/store/useReportTabStore'; -import useSearchModalStore from '@/store/useSearchModalStore'; - -export type Folder = { - clsfSystmSn: string; - upClsfSystmSn: string; - clsfSystmNm: string; - children: Folder[]; -}; -export type FlattenedFolder = { - [key: string]: string | number | undefined; -}; -export type SelectDataType = { - text: string; - val: string; - sido?: string; // 시도 코드 (선택 사항) -} -export type ReportDataFile = { - no: number; - fileTitle: string; - regDt: string; - fileSn: string; - filePath: string; -} - -export default function useReportTab() { - const REPORT_TYPES = [ - { val: '0', text: '전체' }, - { val: '1', text: '보고서' }, - { val: '2', text: '요약보고서' }, - { val: '3', text: '부록' }, - { val: '4', text: '기타' } - ]; - - const { curPage, setIsLoading } = useSearchModalStore(); - const { dcFiles: filesOrigin, files, setFiles } = useReportTabStore(); - - /** - * Flatten a hierarchical folder structure into a flat array. - * @param data - The hierarchical folder data to flatten. - * @returns An array of flattened folders with their paths and levels. - */ - const flattenFolders = (data: Folder[]): FlattenedFolder[] => { - const result: FlattenedFolder[] = []; - const exceptNodeNm = ["부분", "통합", "전체"]; - const addNodeNm = ["보고서", "요약보고서", "부록", "기타"]; // level5 - const columnNms: { [key: number]: string } = { - 0: "root", 1: "sido", 2: "sigungu", 3: "approvalYear" - , 4: "plantZoneGubun", 5: "reportGubun", 7: "fileName", 8: "regDate" - }; - - const traverse = ( - node: Folder, - path: { [key: string]: string | number }, - level: number - ) => { - const isYear = (value: string): boolean => { - return /^[0-9]{4}년$/.test(value); - }; - - if (level === 2 && isYear(node.clsfSystmNm)) { - level++; - } - if (level === 4 && addNodeNm.includes(node.clsfSystmNm)) { - level++; // 레벨을 증가시켜 하위값에 추가되도록 함. - } - - const newPath = { - ...path, - [`${columnNms[level]}`]: node.clsfSystmNm, - }; - - if (node.children.length === 0) { - newPath.no = result.length + 1; - newPath.clsfSystmSn = node.clsfSystmSn; // 파일 조회를 위한 고유번호 - result.push(newPath); - } else { - if (level === 4) { - if (exceptNodeNm.includes(node.clsfSystmNm)) { - level--; // 제외할 노드 이름인 경우, 현재의 레벨에 하위값이 써지도록 함. - } - } - node.children.forEach((child) => { - traverse(child, newPath, level + 1); - }); - } - } - - if (data && data.length > 0 && data[0]?.clsfSystmNm == "기본계획") { // data가 비어있지 않고 children이 있는 경우에만 처리 - data.forEach((root) => traverse(root, {}, 0)); - } else { - data.forEach((root) => traverse(root, {}, 1)); - } - return result; - } - - const getDcFiles = async (clsfSystmSn: string): Promise<ReportDataFile[]> => { - if (!clsfSystmSn) { - console.error("clsfSystmSn is required to get files."); - return []; - } - const queryString = 'clsfSystmSn=' + clsfSystmSn; - const result = await getDcData(`/api/v1/file${queryString ? `?${queryString}` : ''}`); - return result; - }; - - // 파일 이름 추가 - const addFileName = async (data: FlattenedFolder[]) => { - const fileReportData = await Promise.all( - data.map(async (item) => { - const fileArr: ReportDataFile[] = await getDcFiles(String(item.clsfSystmSn)); - let result: FlattenedFolder; - if (fileArr.length > 0) { - const f = fileArr[0]; - const fCount = fileArr.length; - result = { ...item, fileName: f?.fileTitle + " 외 " + fCount + "건", regDate: f?.regDt, fileSn: f?.fileSn, filePath: f?.filePath }; - } else { - result = { ...item, fileName: "", regDate: "", fileSn: "", filePath: "" }; - } - return result; - }) - ); - return fileReportData; - }; - - const getDcFileDownload = async (fileSn: string, fileName: string, filePath: string) => { - if (filePath === "") { - alert(`filePath 정보가 없으므로 다운로드가 불가합니다.\nfilesn: ${fileSn}`); - return; - } - const queryString = 'filePath=' + encodeURIComponent(filePath); - const responseBlob = await getDcData(`/api/v1/fileDownload${queryString ? `?${queryString}` : ''}`, "blob"); - fileDownload(responseBlob, fileName); - } - - // 파일 목록 조회 - const getFiles = async () => { - setIsLoading(true); // 로딩 시작 - try { - let data = filesOrigin; - data = data?.slice((curPage - 1) * 10, curPage * 10); // paging - data = data?.map((item, index) => ({ ...item, no: (curPage - 1) * 10 + index + 1 })); - setFiles(data); - } catch (error) { - console.error("파일 목록 조회 에러:", error); - } finally { - setIsLoading(false); // 로딩 종료 - } - }; - - // cellClick 핸들러 - const handleCellClick = (index: number | undefined) => { - if (typeof index === "number" && files[index]) { - const fileName = files[index].fileTitle; - const filePath = files[index].filePath; - if (files[index].fileSn !== "") { - getDcFileDownload(files[index].fileSn, fileName, filePath); - } - } - }; - - return { - REPORT_TYPES, - flattenFolders, - addFileName, - getDcFiles, - getDcFileDownload, - getFiles, - handleCellClick - }; -} diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/useSearchModal.ts b/apps/sn/app/html/(map-layout)/map/searchModal/useSearchModal.ts deleted file mode 100644 index 7e4b2c9dd..000000000 --- a/apps/sn/app/html/(map-layout)/map/searchModal/useSearchModal.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * 📄 useSearchModal Hook - * 검색 모달 훅 - * - * - 탭 기반 검색 인터페이스 관리 (기본정보, 유지관리 정보, 보고서) - * - 필터링된 데이터 조회 및 포맷팅 - * - 조회 버튼을 통한 데이터 검색 트리거 - * - 여러 시설 보기 기능 - * - Zustand를 통한 상태 관리 및 로딩 처리 - * - 데이터 매핑 및 코드 변환 (사업 유형 등) - * - * 🔗 사용 API - * - 유지관리 정보 목록 조회: GET /api/v1/bizInfo (sewerage-service) - */ -import { getData } from "@/components/lib/api"; -import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue'; -import useStore from "@/store/useStore"; -import GisApp from "@common/business_components/ui/map/lib/gis.core"; -import useSearchModalStore from "@/store/useSearchModalStore"; -import useBasicTab from './useBasicTab'; -import useOperationTab from './useOperationTab'; - -export default function useSearchModal() { - const { - activeTab, workType, - tableData, detailTableData, - curPage, - var1, var2, - yearFilter, - todayYear, todayMonth, todayDate, - setIsSearchModalOpen, - setShowTable, - setTableData, setDetailTableData, - setTableCount, setDetailTableCount, - setCurPage, - setIsLoading, - setIsResetPage, setIsResetDetailPage, - setIsResetSelection, setIsResetDetailSelection, - getDateString - } = useSearchModalStore(); - const { fetchBasicFilteredData } = useBasicTab(); - const { OPERATION_TABLE_SIZE } = useOperationTab(); - const { BIZ_TY_CD } = useStore(); - - const TABS = [ - { val: "basic", text: "기본 정보" }, - { val: "operation", text: "유지관리 정보" }, - { val: "report", text: "하수도 정비 기본계획 보고서" }, - ]; - - // 검색 API 호출 함수 - const fetchFilteredData = async () => { - setIsLoading(true); // 로딩 시작 - setIsResetSelection(true); // 선택 초기화 - setIsResetDetailSelection(true); // 선택 초기화 - - try { - if (activeTab === 'basic') { - fetchBasicFilteredData(); - - } else if (activeTab === 'operation') { - const RequestDTO: Record<string, string> = { - page: `${curPage - 1}`, - size: `${OPERATION_TABLE_SIZE}`, - }; - if (var1) RequestDTO.bizInfoNm = var1; - if (var2 != '0' && var2) RequestDTO.bizTypeCd = var2; - // 수행기간 범위 - const startDate = getDateString( - yearFilter.start, - '', - '', - "19000101", - ); - const endDate = getDateString( - yearFilter.end, - '', - '', - `${todayYear}${todayMonth}${todayDate}`, - ); - RequestDTO.fromflfmtEndYmd = startDate; - RequestDTO.toflfmtEndYmd = endDate; - - // JSON.parse 사용으로 문자열 -> 배열 변환 - RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"')); - - const params = new URLSearchParams(RequestDTO).toString(); - - const res = await getData(`v1/bizInfo?${params}`); - - const mappedContent = (res.content || []).map((item: any, index: number) => { - const formattedItem = formatDisplayData(item); - const matched = BIZ_TY_CD.find(code => code.lwpoCd === item.bizTypeCd); - return { - ...formattedItem, - rowNumber: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1, // 1부터 시작하는 일련번호 추가 - bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd, - }; - }); - - setTableData(mappedContent || []); - setTableCount(res.totalElements || 0); - setDetailTableData([]); - setDetailTableCount(0); - } - - setShowTable(true); - setIsResetSelection(false); - } catch (err) { - console.error('API ERROR:', err); - setTableData([]); - setIsResetSelection(false); - } finally { - setIsLoading(false); // 로딩 종료 - setIsResetSelection(false); - // setIsResetDetailSelection(false); - } - }; - - // 조회 버튼 클릭 시 1페이지로 이동 및 데이터 초기화 - const handleSearch = () => { - setCurPage(1); // 페이지를 1로 초기화 - fetchFilteredData(); - setIsResetPage(true); // 페이지를 초기화 - setIsResetDetailPage(true); // 디테일 페이지를 초기화 - setIsResetSelection(false); // 선택 초기화 - setIsResetDetailSelection(false); // 선택 초기화 - setShowTable(true); - }; - - // 여러 시설 보기 - const viewMultipleFeature = async () => { - if (activeTab === 'basic') { - if (!(tableData.length === 0)) { - setIsLoading(true); - - const row = tableData[0]; - const layerNm = row.lyrTypeNm; - const fids = tableData.map((item) => item.fid); - const core = GisApp.Module.layer as any; - await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids); - setIsLoading(false); - setIsSearchModalOpen(false); - } - } else if (activeTab === 'operation') { - if (!(detailTableData.length === 0)) { - setIsLoading(true); - - const row = detailTableData[0]; - const layerNm = row.lyrTypeNm; - const fids = detailTableData.map((item) => item.fid); - const core = GisApp.Module.layer as any; - await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids); - setIsLoading(false); - setIsSearchModalOpen(false); - } - } - } - - return { TABS, getDateString, handleSearch, viewMultipleFeature, fetchFilteredData }; -}; diff --git a/apps/sn/app/html/(map-layout)/map/waterDepth/waterDepth.tsx b/apps/sn/app/html/(map-layout)/map/waterDepth/waterDepth.tsx deleted file mode 100644 index 4ea769ff7..000000000 --- a/apps/sn/app/html/(map-layout)/map/waterDepth/waterDepth.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/** - * ➡️ 경로 : 하수관망도 메인페이지 내 침수심 범례 - * - * 📄 WaterDepth Component - * 침수심 범례 컴포넌트 - * - * - 침수심 레이어의 범례 표시 및 애니메이션 재생 제어 - * - 진행률 바를 통한 애니메이션 상태 시각화 - * - 플레이/일시정지 버튼을 통한 사용자 상호작용 - * - 범례 항목별 색상과 레이블 표시 - */ - -"use client"; - -import { Label } from "@common/components/ui/label"; -import { useEffect } from "react"; - -export const WATER_DEPTH_LAYER_TYPES: string[] = [ - 'tb_ms_flood_dngr_map', // 홍수위험지도 - 'tb_ms_sbmrg_dngr_map' // 침수위험지도 -]; - -interface WaterDepthProps { - onClose?: () => void; - isPlaying: boolean; - setIsPlaying: (isPlaying: boolean) => void; - totalSteps?: number; - currentStep?: number; -} - -export function WaterDepth({ - onClose, - isPlaying, - setIsPlaying, - totalSteps = 0, - currentStep = 0, -}: WaterDepthProps) { - - useEffect(() => { - if (setIsPlaying) setIsPlaying(isPlaying); - }, [isPlaying, setIsPlaying]); - - const handlePlayToggle = () => { - setIsPlaying(!isPlaying); - }; - - // 진행률 계산 (0-100%) - const progressPercentage = totalSteps > 0 ? (currentStep / totalSteps) * 100 : 100; - - - return ( - <> - <div className="bg-white rounded-lg shadow-lg border border-gray-200 w-[160px] px-4 py-3"> - <div className="flex items-center justify-between mx-2 my-4"> - <Label className="text-3xl text-gray-900 font-bold">침수심 범례</Label> - <button type="button" className="mt-1 ml-2" onClick={handlePlayToggle}> - {isPlaying ? ( - <img src="/images/map/toolbar/pause_button.png" alt="pause" className="w-6 h-6 object-contain" /> - ) : ( - <img src="/images/map/toolbar/play_button_arrowhead.png" alt="play" className="w-6 h-6 object-contain" /> - )} - </button> - </div> - <div className="mb-3"> - <div className="w-full bg-gray-200 rounded-full h-1"> - <div - className="bg-blue-900 h-1 rounded-full transition-all duration-300 ease-in-out" - style={{ width: `${progressPercentage}%` }} - /> - </div> - </div> - <div className="w-[140px] mx-auto flex flex-col items-center"> - <div className="bg-blue-900 text-white text-2xl text-center py-1 mb-2 rounded w-full">침 수 심</div> - <div className="space-y-1 w-full"> - <LegendItem color="rgb(255, 255, 127)" label="0.0~0.5m 미만" /> {/* 코드: N330 */} - <LegendItem color="rgb(191, 255, 0)" label="0.5~1.0m 미만" /> {/* 코드: N331 */} - <LegendItem color="rgb(0, 255, 255)" label="1.0~2.0m 미만" /> {/* 코드: N332 */} - <LegendItem color="rgb(191, 127, 255)" label="2.0~5.0m 미만" /> {/* 코드: N333 */} - <LegendItem color="rgb(255, 0, 127)" label="5.0m 이상" /> {/* 코드: N334 */} - </div> - <div className="border-t-2 border-blue-900 mt-1 mx-auto w-full" /> - </div> - </div> - </> - ); -} - -function LegendItem({ color, label }: { color: string; label: string }) { - return ( - <div className="flex items-center gap-2 w-full"> - <span - className="inline-block w-16 h-6" - style={{ backgroundColor: color, border: "1px solid #ccc" }} - /> - <span className="text-xl text-gray-900 font-bold">{label}</span> - </div> - ); -} \ No newline at end of file diff --git a/apps/sn/app/html/(with-base-layout)/layout.tsx b/apps/sn/app/html/(with-base-layout)/layout.tsx deleted file mode 100644 index 1907bd438..000000000 --- a/apps/sn/app/html/(with-base-layout)/layout.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/** - * ➡️ 경로 : I/I분석, 유지관리 정보 모든 페이지 - * - * 📄 With Base Layout - * Next.js App Router (app/) 기반 분석 페이지 기본 레이아웃 컴포넌트 - * - * - 메뉴 클릭 이벤트 처리 및 라우팅 - * - 현재 메뉴 정보 Zustand를 통한 상태 관리 - * - 시도/시군구/사업 구분/사업 유형 데이터 초기 로드 - * - BaseLayout을 통한 공통 레이아웃 적용 - * - QueryClient를 통한 데이터 캐싱 및 관리 - * - 경로 변경 시 메뉴 정보 동기화 - */ -"use client"; - -import React, { useEffect} from 'react'; -import { usePathname, useRouter } from "next/navigation"; -import { useQueryClient } from "@tanstack/react-query"; -import { BaseLayout } from "@common/components"; -import type { MenuItem } from "@/entities/menu"; -import { useMenuStore } from "@/shared/lib"; -import { PageLayout } from "@/shared/ui/page-layout"; -import useStore from '../../store/useStore'; - -export default function WithBaseLayout({ - children, -}: { - children: React.ReactNode; -}) { - const router = useRouter(); - const pathname = usePathname(); - const queryClient = useQueryClient(); - - const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo); - - const { fetchCtpv, fetchSgg, fetchBIZ_SE_CD, fetchBIZ_TY_CD } = useStore(); - - useEffect(() => { - fetchCtpv(); - fetchSgg(); - fetchBIZ_SE_CD(); - fetchBIZ_TY_CD(); - }, []); - - const handleMenuClick = (item: MenuItem) => { - setCurrentMenuInfo(item); - router.push(item.urlPath); - }; - - return ( - <BaseLayout - queryClient={queryClient} - currentPath={pathname ?? ""} - handleMenuClick={handleMenuClick} - > - <PageLayout>{children}</PageLayout> - </BaseLayout> - ); -} diff --git a/apps/sn/app/html/06. 노후관로관리_시나리오_v1.0.md b/apps/sn/app/html/06. 노후관로관리_시나리오_v1.0.md deleted file mode 100644 index 2e05f8e89..000000000 --- a/apps/sn/app/html/06. 노후관로관리_시나리오_v1.0.md +++ /dev/null @@ -1,97 +0,0 @@ -# 노후관로 관리 - ---- - -## 화면구성 -### GIS 기본화면 --노후관로 조건에 맞지 않는 관로를 숨기지 않고, 기존 시설물 레이어는 원래대로 모두 보이게 두고 조건에 맞는 관로만 빨간 강조선을 추가하도록 한다. -- 좌측 상단 (시설물 레이어 트리 및 목록 아이콘): 시설, 침수위험지도, 지반침하정보(우측에 목록 아이콘), 노후관로(우측에 목록 아이콘) -- 좌측 하단 (레이어별 설정 항목 및 적용 버튼): 경과년도(년) [ 디폴트 30년 ] -- 우측 영역(배경 지도 및 시설레이어 표출): 하수관로 중 경과년도가 30년 이상된 관로들을 시각적으로 강조 표시 - -### 관로정보 상세화면 -- GIS 화면에서 관로 위치 클릭시 우측 정렬로 세로형 영역으로 관로정보 상세화면 팝업 -- 1차년도에서 개발한 관로정보 표출 : 시설관리번호, 기관관리번호, 설치일자, 도엽번호 등 전체 항목 그대로 표출 - -### 노후관로정보 목록화면 -- 윈도우형 팝업 -- GIS 기본화면 좌측 상단의 레이어 트리에서 노후관로 목록 아이콘 클릭시 윈도우 팝업으로 가운데 영역으로 노후관로정보 목록화면 팝업 -- 상단 조회조건( 조회조건 영역 ) : 경과년도, 시도, 시군구, 버튼 2개 (조회, 다운로드) -- 하단 가로형 그리드 (노후관로 현황) : NO, 시설관리번호, 설치일자, 관경(mm), 연장(m), 용도, 재질, 경과년도 - ---- - -## 화면동작 - -### 초기화 및 기본 설정 - -```구조적표기 -// 화면 로드 시 초기 데이터 및 환경 설정 -ON_LOAD: - CALL GIS_지도_레이어_초기화() - SET 기본_경과년도 = 20 // 회의록(260311) 반영 디폴트 설정 - SET 현재_조회_조건 = { "경과년도": 20, "관종": "전체" } - - // 초기 데이터 로드 - CALL 노후관로_데이터_조회(현재_조회_조건) - CALL 지도_상_노후관로_표출(조회결과) -``` - -### 좌측 사이드바 제어 (분석 설정) - -```구조적표기 -// 노후관로 조회 조건 변경 및 적용 -IF 노후관로_조회_버튼.CLICK THEN - SET 입력_경과년도 = 사이드바.경과년도_INPUT.Value - SET 선택_관종 = 사이드바.관종_SELECT.Value - - // 공간 및 속성 필터링 실행 - CALL 노후관로_필터링_프로세스(입력_경과년도, 선택_관종) - REFRESH_GIS_LAYER(노후관로_레이어) - SHOW MESSAGE "설정된 조건으로 노후관로가 필터링되었습니다." -END IF - -// 목록 아이콘 클릭 (모달 호출) -IF 레이어트리.노후관로_목록아이콘.CLICK THEN - DISPLAY 노후관로_목록_모달 = TRUE - CALL 노후관로_목록_그리드_로드() -END IF -``` - -#### 노후관로 상세화면 (우측 패널) - -```구조적표기 -// 지도 상의 특정 관로 클릭 시 -IF GIS.노후관로_객체.CLICK THEN - SET 선택관로_ID = 클릭객체.ID - - // 상세 속성 정보 조회 및 매핑 - CALL 관로_상세속성_API_호출(선택관로_ID) - SET 우측패널.기본정보 = [관로ID, 시설명, 주소, 관리기관] - SET 우측패널.제원정보 = [관경, 연장, 관종, 준공일자, 경과년도] - SET 우측패널.상태정보 = [최종조사일, 조사결과등급] - - DISPLAY 우측_상세_패널 = TRUE -END IF -``` - -#### 노후관로 목록 및 데이터 출력 (모달 윈도우) -```구조적표기 -// 엑셀 다운로드 (회의록 요구사항 반영) -IF 모달.다운로드_버튼.CLICK THEN - // 엑셀 파일에는 관로의 '모든 기본정보'를 포함 - SET 다운로드_컬럼 = [관로ID, 주소, 관경, 연장, 관종, 준공일, 경과년도, ...전체필드] - CALL 시스템_엑셀_추출_모듈(현재_조회_데이터, 다운로드_컬럼) -END IF - -// 목록 그리드 행 선택 시 로직 -IF 모달.그리드_행.DOUBLE_CLICK THEN - SET 좌표 = 현재행.중심좌표 - CALL GIS_지도_줌인(좌표) - CALL 노후관로_상세정보_표출(현재행.ID) - CLOSE_MODAL(노후관로_목록_모달) -END IF -``` - - - diff --git a/apps/sn/app/html/AGENTS.md b/apps/sn/app/html/AGENTS.md deleted file mode 100644 index 1d79419e2..000000000 --- a/apps/sn/app/html/AGENTS.md +++ /dev/null @@ -1,38 +0,0 @@ -## html 디렉터리 - -- 이 디렉터리는 하수관로 운영관리 시스템 `sn` 2차사업 고도화 과정에서 퍼블리싱, 정적 HTML 시안, 로직 검토용 화면을 다루는 영역이다. -- 작업 시 `apps/sn/app/html` 이외의 수정이 되지 않도록 해야 한다. -- 추가 `*.md` 파일의 요청대로 만들되 상위 앱 전체를 건드리지 않고 `apps/sn/app/html` 하위에 필요한 파일을 만들어서 처리한다. -- 기존 `packages/common/components`, `packages/common/business_components`는 활용할 수 있지만, 직접 수정 권한은 상위 `apps/sn/AGENTS.md` 기준을 따른다. -- 지도/html 시안에서 `<Gis>` 시설물 layer를 다루는 작업은 API 호출 결과 값이 설정된 대로 zoom level에 따라 모두 보이는지 확인한다. - -## 2차사업 원칙 - -- 작업은 리팩토링 전용이 아니라 2차사업 화면 고도화 목적에 맞춰 수행한다. -- 화면 변경은 요구사항, 화면설계, 기능 명세를 근거로 한다. -- html 시안 작업과 실제 앱 로직 작업이 함께 필요해 보이면 먼저 구분해서 설명한다. - -## Encoding - -- UTF-8 -- 한글이 깨지지 않도록 주의할 것 - -## 주석 comment - -- 한글 주석을 삭제하지 말 것 -- 복잡한 화면 흐름이나 데이터 바인딩 로직은 함수/메서드 단위 설명을 충분히 작성할 것 - -## Monorepo structure - -apps/ - _template - as - cm - cs - dc - dm - dm_new - fo - sn - st - wr diff --git a/apps/sn/app/html/old-pipe-component-sample/index.html b/apps/sn/app/html/old-pipe-component-sample/index.html deleted file mode 100644 index 1eee8ec8b..000000000 --- a/apps/sn/app/html/old-pipe-component-sample/index.html +++ /dev/null @@ -1,1188 +0,0 @@ -<!doctype html> -<html lang="ko"> -<head> - <meta charset="utf-8" /> - <meta name="viewport" content="width=device-width, initial-scale=1" /> - <title>노후관로관리 실제 화면 기반 샘플 - - - -
- - -
- - -
-
-
-
-
-
- - 하수처리시설 - 왕숙천 - 진건로 - -
-
-
-
- - - - - - - - -
- - - - - - - -
-
-
- -
- -
- -
- - - - diff --git a/apps/sn/docs/검증/SFR-012-001-시설상세레이아웃/001-qa-SN-시설상세레이아웃-검증결과.md b/apps/sn/docs/검증/SFR-012-001-시설상세레이아웃/001-qa-SN-시설상세레이아웃-검증결과.md index 86a835cb5..0949ab349 100644 --- a/apps/sn/docs/검증/SFR-012-001-시설상세레이아웃/001-qa-SN-시설상세레이아웃-검증결과.md +++ b/apps/sn/docs/검증/SFR-012-001-시설상세레이아웃/001-qa-SN-시설상세레이아웃-검증결과.md @@ -1,5 +1,7 @@ # 시설 상세 패널 레이아웃 수정 검증 +> 2026-09-11 API 계약 대체 이력: 본문의 과거 호출 URL은 당시 검증·구현 증거이며 현재 호출 계약이 아니다. 현재는 lowerCamelCase 단일 경로만 지원하고 기존 alias는 제거된다. [경로 단일화 변경기록](../../작업계획/SN-API경로단일화/001-fe-SN-API경로단일화-변경기록.md)의 현재 경로로 검증한다. + ## 목적 및 원본 근거 - 검증일: 2026-09-04 diff --git a/apps/sn/docs/작업검증/SFR-011-008-침수예측/001-qa-SFR-011-008-침수예측지도-격자표시-검증계획.md b/apps/sn/docs/작업검증/SFR-011-008-침수예측/001-qa-SFR-011-008-침수예측지도-격자표시-검증계획.md index 6fdc82af7..5a7e99a1a 100644 --- a/apps/sn/docs/작업검증/SFR-011-008-침수예측/001-qa-SFR-011-008-침수예측지도-격자표시-검증계획.md +++ b/apps/sn/docs/작업검증/SFR-011-008-침수예측/001-qa-SFR-011-008-침수예측지도-격자표시-검증계획.md @@ -1,7 +1,9 @@ # SFR-011-008 침수예측지도 격자 표시 검증계획 +> 2026-09-11 API 계약 현행화: 아래 호출 경로는 lowerCamelCase 단일 계약으로 갱신했다. 기존 alias는 사용하지 않는다. [경로 단일화 변경기록](../../작업계획/SN-API경로단일화/001-fe-SN-API경로단일화-변경기록.md)을 기준으로 한다. + - 침수예측현황 행 클릭 시 기존 지도 extent 이동 확인 -- `grid-map` API의 시군구·분석시각 파라미터와 WFS `latc_cd` 필터 확인 +- `gridMap` API의 시군구·분석시각 파라미터와 WFS `latc_cd` 필터 확인 - 각 격자 중앙 `latc_cd`·침수위험도·현재/예측 누적강우 표시 확인 - 행 재선택 시 기존 overlay 제거 및 신규 격자 갱신 확인 - BE 단위 테스트·컴파일 및 SN 브라우저 콘솔 오류 확인 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 025cf7b83..4cd1b80fc 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 @@ -1,5 +1,7 @@ # SFR-012-002 하수관망도 업로드 통합 검증 결과 +> 2026-09-11 API 계약 대체 이력: 본문의 과거 호출 URL은 당시 검증·구현 증거이며 현재 호출 계약이 아니다. 현재는 lowerCamelCase 단일 경로만 지원하고 기존 alias는 제거된다. [경로 단일화 변경기록](../../작업계획/SN-API경로단일화/001-fe-SN-API경로단일화-변경기록.md)의 현재 경로로 검증한다. + ## 1. 문서 목적 - `SFR-012-002 하수관망도 업로드`의 검증 결과를 기능당 1개 문서로 누적 관리한다. diff --git a/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md index 8a31d7391..299a253fc 100644 --- a/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md @@ -1,5 +1,7 @@ # SFR-012-003 하수관로편집 통합 검증 결과 +> 2026-09-11 API 계약 대체 이력: 본문의 과거 호출 URL은 당시 검증·구현 증거이며 현재 호출 계약이 아니다. 현재는 lowerCamelCase 단일 경로만 지원하고 기존 alias는 제거된다. [경로 단일화 변경기록](../../작업계획/SN-API경로단일화/001-fe-SN-API경로단일화-변경기록.md)의 현재 경로로 검증한다. + ## 1. 문서 목적 - `SFR-012-003 하수관로편집`의 검증 결과를 기능당 1개 문서로 누적 관리한다. diff --git a/apps/sn/docs/작업검증/SN-전체화면-브라우저검증/001-qa-SN-전체화면-브라우저검증결과.md b/apps/sn/docs/작업검증/SN-전체화면-브라우저검증/001-qa-SN-전체화면-브라우저검증결과.md new file mode 100644 index 000000000..31c349e4a --- /dev/null +++ b/apps/sn/docs/작업검증/SN-전체화면-브라우저검증/001-qa-SN-전체화면-브라우저검증결과.md @@ -0,0 +1,142 @@ +# SN 전체 화면 브라우저 검증 결과 + +## 1. 결과 요약 + +- 검증일: 2026-09-14 +- 환경: 사용자 실행 `http://localhost:3002`, 로그인 후 실제 API 연동 +- 화면 범위: 앱 라우트 24개와 WindowPopup 33개. 운영 동적 상세는 실제 식별자로 진입했다. +- 원본 근거: `화면설계서.pptx` 5쪽 메뉴구조도, 7~21쪽 주요 SN 화면과 `요구사항정의서.xlsx`의 SFR-010~012. +- 변경: 정보검색 500 오류, 팝업 필수값 누락 오류, 개발용 주소 Store 중복 key 경고를 수정했다. +- 미수정 오류: 공통 Radix UI의 SSR hydration 경고가 일반 업무화면 새로고침 때 간헐적으로 발생한다. 공유 제한 경로 `packages/common/components/**` 영향과 장시간 HMR 상태를 함께 분리해야 하므로 임의 수정하지 않았다. +- 데이터 변경 방지: 등록·수정·삭제·실제 파일 업로드는 실행하지 않았다. 조회, 화면 진입, 검색, 페이징, 팝업 연결, 입력 누락 검증, 다운로드까지만 확인했다. + +## 2. 요청 형식 결과 + +### 인증·공통 + +- 인증 > 로그인 > 통과 +- 공통 > 루트 진입 후 하수관망도 이동 > 통과 +- 공통 > 메뉴 4개 그룹 펼침 및 하위 메뉴 표시 > 통과 +- 공통 > 일반 업무화면 반복 새로고침 > 오류 + - Radix `Select/Popover`의 `aria-controls` 자동 ID가 SSR과 브라우저 렌더에서 간헐적으로 달라져 Next.js Issue가 표시된다. + - 기능 조회 API와 화면 내용은 유지되지만 개발자 오류 배지가 노출된다. + - 사용자 실행 서버를 임의 재시작하지 않았으며, 공유 컴포넌트 수정도 승인 없이 수행하지 않았다. + +### 하수관망도 + +- 하수관망도 > 메인 지도 진입 > 통과 +- 하수관망도 > 지도 확대 > 통과 +- 하수관망도 > 위성지도 전환 > 통과 +- 하수관망도 > GIS 편집 인라인 도구 열기·취소 > 통과 +- 하수관망도 > 정보검색 팝업 열기 > 통과 +- 하수관망도 > 정보검색 기본 정보 조회 > 오류수정완료 + - 수정 전: `includeFlowDirectionBasis=true` 요청이 `/api/v1/fclts`에서 HTTP 500을 반환하고 결과가 0건으로 표시됐다. + - 분리 검사: 같은 조건에서 해당 옵션이 없으면 HTTP 200, 옵션이 있으면 HTTP 500이었다. + - 수정 후: HTTP 200, 첫 페이지 10건과 페이징이 표시되고 Next.js Issue 0건이다. +- 하수관망도 > 정보검색 유지관리 정보 조회 > 통과 +- 하수관망도 > 정보검색 하수도정비 기본계획 보고서 조회 > 통과 +- 하수관망도 > 관로분석 조회 > 통과 +- 하수관망도 > 관로현황 팝업 진입 > 통과 +- 하수관망도 > 침수예측현황 조회 > 통과 +- 하수관망도 > 시도별 노후관로현황 조회 > 통과 +- 하수관망도 > GIS 관리 팝업 진입 > 통과 +- 하수관망도 > 통수 위험 조회 > 통과 +- 하수관망도 > 침입수 분석결과 조회 > 통과 +- 하수관망도 > RDII 분석결과 조회 > 통과 +- 하수관망도 > 노후관로 목록 조회 > 통과 +- 하수관망도 > 지반침하 목록 조회 > 통과 +- 하수관망도 > 침수위험지도 목록 조회 > 통과 +- 하수관망도 > 도시침수지도 목록 조회 > 통과 +- 하수관망도 > 침수발생이력 조회 > 통과 +- 하수관망도 > 유하방향 근거 배치 보강 API > 오류 + - 서버가 `includeFlowDirectionBasis=true`에 HTTP 500을 반환한다. + - 정보검색 전체 실패는 차단했으나 목록의 유하방향 근거는 `-`로 표시될 수 있다. 서버 배치 조회의 별도 수정·재배포가 필요하다. + +### I/I 분석 + +- I/I 분석 > 침입수 분석 화면 진입 > 통과 +- I/I 분석 > 침입수 분석 실행 확인창·취소 > 통과 +- I/I 분석 > RDII 분석 화면 진입 > 통과 +- I/I 분석 > RDII 분석 실행 확인창·취소 > 통과 +- I/I 분석 > 침입수 분석결과 조회 > 통과 +- I/I 분석 > RDII 분석결과 조회 > 통과 +- I/I 분석 > 침입수 관로 차트 팝업 진입 > 통과 +- I/I 분석 > RDII 관로 차트 팝업 진입 > 통과 + +### 운영정보 + +- 운영정보 > 도시침수예방사업 목록 조회 > 통과 +- 운영정보 > 도시침수예방사업 등록 화면 진입 > 통과 +- 운영정보 > 도시침수예방사업 실제 상세 257번 조회 > 통과 +- 운영정보 > 노후 하수관로 정비사업 목록 조회 > 통과 +- 운영정보 > 노후 하수관로 정비사업 등록 화면 진입 > 통과 +- 운영정보 > 노후 하수관로 정비사업 실제 상세 520번 조회 > 통과 +- 운영정보 > 최종확정예산사업 팝업 조회 > 통과 + +### 유지관리 + +- 유지관리 > 조사관리 목록 조회 > 통과 +- 유지관리 > 조사사업 선택 후 결과등록 팝업 > 통과 +- 유지관리 > 조사사업 선택 후 처리분구 집계 > 통과 +- 유지관리 > 처리분구 선택 후 관로 상세현황 팝업 > 통과 +- 유지관리 > 조사보고서 PDF 다운로드 > 통과 +- 유지관리 > 조사사업 등록 필수입력 검증 > 통과 +- 유지관리 > 준설관리 목록 조회 > 통과 +- 유지관리 > 준설 등록 필수입력 검증 > 통과 +- 유지관리 > 실제 준설 행 선택 후 수정 팝업 조회 > 통과 +- 유지관리 > 처리분구관리 목록 조회 > 통과 +- 유지관리 > 노후 하수관로 파일 목록 조회 > 통과 +- 유지관리 > 노후 하수관로 업로드 파일 미선택 검증 > 통과 +- 유지관리 > 도시침수예방사업 파일 목록 조회 > 통과 +- 유지관리 > 도시침수예방사업 업로드 파일 미선택 검증 > 통과 + +### 공통 WindowPopup + +- 공통팝업 > 등록된 33개 popupKey 화면 진입 > 통과 +- 공통팝업 > 필수 파라미터 12종 누락 차단 > 오류수정완료 + - 수정 전: 조사 상세현황은 클라이언트 예외로 화면이 중단됐고, 준설 수정·관로 조사보고서는 빈 값으로 400/404 API를 호출했다. + - 수정 후: 12종 모두 API 호출 없이 동일한 “필요한 값이 누락되었습니다” 화면을 표시하고 Issue 0건이다. +- 공통팝업 > 미등록 popupKey 안내 > 통과 +- 공통팝업 > 기본계획 보고서 조회·PDF 다운로드 > 통과 + +### 개발·호환 경로 + +- 개발화면 > 파일 업로드 테스트 화면 진입 > 통과 +- 개발화면 > I/I 테스트 화면 진입 > 통과 +- 개발화면 > Onsemiro 주소 Store 중복 key > 오류수정완료 + - API가 이미 반환한 `전체(0)`와 화면 기본 항목이 겹치지 않도록 중복 제거했다. + - 수정 후 환경청 7건·시도 19건·시군구 265건이며 중복 key 경고는 재발하지 않았다. 별도의 공통 hydration 경고는 남아 있다. +- 호환경로 > `/map/agedPipePopup`의 표준 WindowPopup 이동 > 통과 + +## 3. 수정 파일 + +- `apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts` + - 실패하는 유하방향 배치 옵션과 기본 정보검색 목록을 분리했다. +- `apps/sn/src/features/windowPopup/ui/WindowPopupRenderer.tsx` + - 레지스트리의 `requiredParams`를 공통 진입점에서 검사한다. +- `apps/sn/app/(with-base-layout)/oper/operStore/page.tsx` + - 주소 옵션의 `0` key 중복을 제거한다. + +## 4. 검증 근거 + +- 라우트 인벤토리: 24개 page route, main-content 17개, 개발 3개, 인증 1개, 호환 1개, 인프라 2개. +- WindowPopup 레지스트리: 정의 33개, 구현 33개, 누락 0개. +- 실제 조회: 정보검색 3개 탭, 지도 분석 팝업, 운영정보, 조사·준설·처리분구, 침수·노후·관로·통수위험 목록의 HTTP 실패와 콘솔 오류를 확인했다. +- 실제 데이터 흐름: 도시침수사업 257번, 노후관로사업 520번, 준설 행 `4115731667`, 조사사업 `신사업`과 `덕풍처리분구`를 조회했다. +- 정적 검증: SN TypeScript `tsc --noEmit` 통과, 대상 ESLint 오류 0건, `git diff --check` 통과. +- 회귀 테스트: `apps/sn/scripts/run-refactor-tests.cjs` 69건 전부 통과. +- 빌드: 사용자 Next 개발 서버가 실행 중이므로 지침에 따라 실행하지 않았다. +- 임시 파일: 브라우저 도구가 생성한 로그·스냅샷 23개와 다운로드 확인용 PDF 2개를 정확한 경로로 한정해 삭제하려 했으나 실행 정책이 차단했다. 사용자 원본이나 저장소 파일은 삭제하지 않았다. + +## 5. 영향과 롤백 + +- 영향 범위: SN 정보검색 목록, SN WindowPopup 진입 보호, 메뉴 미노출 개발용 주소 Store 화면. +- API 계약과 DB는 변경하지 않았다. +- 롤백은 위 수정 파일 3개의 해당 변경만 되돌리면 된다. 사용자와 다른 작업자의 기존 변경은 건드리지 않는다. + +## 6. 남은 위험 + +- 공통 hydration 오류는 공유 UI 또는 장시간 실행한 Turbopack 개발 서버 상태를 분리 확인해야 한다. 사용자 승인 후 3002 서버를 재시작하여 재검증하고, 재현되면 `packages/common/components/src/ui/select/**` 등 공유 범위의 별도 승인 수정이 필요하다. +- 서버의 유하방향 배치 보강 API는 여전히 HTTP 500이다. 현재 프론트 수정은 정보검색 전체가 실패하지 않게 한 조치이며 서버 원인을 해결한 것은 아니다. +- 데이터가 변하는 저장·삭제·실제 업로드와 분석 실행 확인 버튼 이후 단계는 수행하지 않았다. +- 브라우저 원시 파일 25개는 `C:\Users\mindone\.codex\.tmp\sewerage-browser-mcp`에 남아 있다. 최종 근거는 이 문서에 요약했으며 원시 파일을 영구 증빙으로 사용하지 않는다. diff --git a/apps/sn/docs/작업검증/SN-테마모드/001-qa-SN-5개테마-팝업페이징-검증결과.md b/apps/sn/docs/작업검증/SN-테마모드/001-qa-SN-5개테마-팝업페이징-검증결과.md new file mode 100644 index 000000000..cc7074912 --- /dev/null +++ b/apps/sn/docs/작업검증/SN-테마모드/001-qa-SN-5개테마-팝업페이징-검증결과.md @@ -0,0 +1,77 @@ +# SN 5개 테마 팝업·페이징 검증 결과 + +## 1. 목적과 범위 + +- 검증일: 2026-09-14 +- 환경: 사용자가 실행 중인 `http://localhost:3002` 개발 서버와 로그인 세션 +- 대상 테마: `white`, `blue`, `green`, `red`, `black` +- 수정 범위: 하수관망도 분석도구, SN 콤보·텍스트 계열 입력, 공통 WindowPopup 테마 전달, `StandardPopupLayout`, `StandardDataTable` 페이징 +- 비수정 범위: 조회·분석·지도·다운로드 로직, API 계약, DB, 개별 업무 데이터 +- 원본 근거: `화면설계서.pptx` 5쪽 메뉴구조도와 7~21쪽 주요 SN 화면, `요구사항정의서.xlsx`의 SFR-010~012를 재확인했다. 5개 테마의 세부 색상은 원본에 별도 확정값이 없어 현재 CMS `siteSkn` 계약과 사용자가 제공한 블랙모드 화면을 기준으로 보완했다. +- 디자인 참고: 통합플랫폼(CS)의 블랙 테마가 사용하는 테마 토큰·어두운 카드·경계선 방식을 소스에서 확인했으며 CS 코드는 수정하지 않았다. + +## 2. 요청 형식 결과 + +- 하수관망도 > 분석도구 타이틀 블랙모드 표시 > 오류수정완료 + - 수정 전: 흰색 고정 헤더와 흰색 전경색 조합으로 제목이 보이지 않았다. + - 수정 후: 배경·글자·테두리·포커스 링이 현재 테마 토큰을 사용한다. +- 공통팝업 > 메인 화면의 현재 테마 상속 > 오류수정완료 + - 팝업을 열 때 현재 `data-theme`을 5개 허용값으로 제한해 전달하고 팝업 쿠키와 동기화한다. + - 과거 `dark` 값은 기존 호환을 위해 `black`으로만 정규화한다. +- 공통팝업 > 흰색 보조 버튼 유지 > 오류수정완료 + - 통합플랫폼과 동일하게 `white` 버튼은 블랙모드에서도 흰색 배경과 회색 글자를 유지한다. +- 하수관망도 > 통합 검색 콤보 블랙모드 표시 > 오류수정완료 + - 시도·시군구·하수처리시설 콤보의 고정 흰색을 제거하고 `form`·전경색·경계선 테마 토큰을 적용했다. +- SN 공통 > 블랙모드 콤보 배경 통일 > 오류수정완료 + - `SelectBox`, Radix `combobox`, 네이티브 `select`의 활성 배경을 `form(#1a1a1a)`으로 통일했다. + - 비활성 콤보만 `field(#5b6269)`와 muted 글자를 사용한다. +- SN 공통 > 블랙모드 텍스트박스 배경 통일 > 오류수정완료 + - 텍스트·검색·메일·전화·URL·비밀번호·숫자·날짜·시간 입력과 `textarea`의 활성 배경을 `form(#1a1a1a)`으로 통일했다. + - 읽기전용·비활성 입력만 `field(#5b6269)`와 muted 글자를 사용하며 체크박스·라디오·파일 입력은 기존 규칙을 유지한다. +- 공통그리드 > 페이징 영역 블랙모드 표시 > 오류수정완료 + - 첫·이전·다음·마지막 버튼의 기본·hover·disabled 상태를 공통 테마 토큰으로 통일했다. +- 공통그리드 > 2페이지 이동과 순번 연속성 > 통과 + - 시도별 노후관로현황 18건에서 1페이지 `18~9`, 2페이지 첫 순번 `8`, 2페이지 8행을 확인했다. +- 테마 > white 회귀 > 통과 +- 테마 > blue 회귀 > 통과 +- 테마 > green 회귀 > 통과 +- 테마 > red 회귀 > 통과 +- 테마 > black 회귀 > 통과 +- 공통팝업 > 정보검색 블랙모드 > 통과 +- 공통팝업 > 관로분석 블랙모드 > 통과 +- 공통팝업 > GIS 관리 블랙모드 > 통과 +- 공통팝업 > 침수발생이력 블랙모드 > 통과 +- 공통팝업 > 통수 위험 블랙모드 > 통과 + +## 3. 브라우저 검증 근거 + +- 5개 테마에서 시도별 노후관로현황 팝업은 모두 HTTP 200, 표 10행, 지정한 `data-theme` 일치를 확인했다. +- 화이트·블루·그린·레드는 기존 밝은 카드·버튼·페이징을 유지했다. +- 블랙은 팝업 본문·카드 `rgb(10, 10, 10)`, 제목 `rgb(250, 250, 250)`, 경계선 `rgb(38, 38, 38)`로 표시됐다. +- 블랙 페이징 이동 버튼은 어두운 배경과 밝은 글자를 사용하고, 활성 페이지는 기존 primary 색상을 유지했다. 취소·초기화 같은 `white` 버튼은 5개 테마 모두 흰색 배경 `rgb(255, 255, 255)`과 회색 글자 `rgb(102, 102, 102)`를 유지했다. +- 블랙 하수관망도 통합 검색 콤보와 펼친 목록은 각각 `rgb(26, 26, 26)`, `rgb(10, 10, 10)`의 어두운 배경으로 표시됐다. 밝은 4개 테마에서는 기존 흰색 배경을 유지했다. +- 관망도 화면의 보이는 콤보 7개, 정보검색 팝업 6개, 시도별 노후관로현황의 네이티브 콤보 2개가 모두 활성 상태 `rgb(26, 26, 26)`으로 표시됐다. 임시 비활성 상태 검증에서는 `rgb(91, 98, 105)`와 muted 글자가 적용됐다. +- 사용자 첨부 화면과 같은 도시침수예방사업 목록에서 날짜 입력 2개와 추진현황·사업명 텍스트박스 2개가 모두 `rgb(26, 26, 26)`과 밝은 글자로 표시됐다. 읽기전용·비활성 상태는 `rgb(91, 98, 105)`로 분리됐고 브라우저 `color-scheme: dark`가 적용됐다. +- 대표 팝업 5종은 의도된 흰색 버튼을 제외하고 팝업 레이아웃에 고정 흰색 면이 남지 않았고 브라우저 콘솔 오류도 0건이었다. +- 분석도구 헤더는 블랙에서 어두운 배경·밝은 제목, 나머지 4개 테마에서 기존 흰 배경·어두운 제목을 유지했다. 활성 버튼 primary는 blue/white/black의 파란색, green의 청록색, red의 붉은색을 각각 유지했다. + +## 4. 정적·회귀 검증 + +- TypeScript: `tsc -p apps/sn/tsconfig.json --noEmit --incremental false` 통과 +- 대상 ESLint: 오류 0건. 공통 데이터테이블의 기존 import/export 정렬 경고 3건과 통합 검색바의 기존 import 정렬 경고 1건은 기능 변경을 피하기 위해 별도 자동 정렬하지 않았다. +- 회귀 테스트: `apps/sn/scripts/run-refactor-tests.cjs` 71건 전부 통과 +- 빌드: 사용자 실행 Next 개발 서버의 `.next` 충돌을 피하기 위해 수행하지 않았다. +- 임시 파일: 이번 검증이 만든 브라우저 원시 로그·스냅샷 45개를 정확한 파일명으로 한정해 삭제하려 했으나 실행 정책이 차단했다. 사용자 원본과 저장소 산출물은 삭제하지 않았다. + +## 5. 영향과 롤백 + +- 영향 범위는 SN의 공통 WindowPopup, 공통 데이터테이블 페이징, 하수관망도 분석도구와 SN의 콤보·텍스트 계열 입력 색상이다. +- API와 DB는 변경하지 않았다. +- 롤백은 `popupTheme.ts`, `PopupThemeSync.tsx`, `openWindowPopup.ts`, popup route, `AnalysisPopupMenu.tsx`, `ProcSearchBar`, `StandardPopupLayout`, `StandardDataTable`의 이번 테마 변경만 되돌리면 된다. + +## 6. 남은 위험 + +- 등록·수정·삭제·파일 업로드처럼 데이터를 바꾸는 동작은 이번 색상 검증에서 실행하지 않았다. +- 개별 팝업의 업무별 강조색과 지도 심벌 색상은 의미 전달용이므로 테마 primary로 일괄 치환하지 않았다. +- 사용자가 실행 중인 개발 서버는 종료하거나 재시작하지 않았다. +- 삭제가 차단된 브라우저 원시 파일은 `C:\Users\mindone\.codex\.tmp\sewerage-browser-mcp`에 남아 있으며 최종 검증 근거는 이 문서에 요약했다. diff --git a/apps/sn/docs/작업계획/SFR-011-001-시도별 노후관로현황/001-fe-SFR-011-001-시도별 노후관로현황-작업카드.md b/apps/sn/docs/작업계획/SFR-011-001-시도별 노후관로현황/001-fe-SFR-011-001-시도별 노후관로현황-작업카드.md index 6620d21ab..bc8ffdb05 100644 --- a/apps/sn/docs/작업계획/SFR-011-001-시도별 노후관로현황/001-fe-SFR-011-001-시도별 노후관로현황-작업카드.md +++ b/apps/sn/docs/작업계획/SFR-011-001-시도별 노후관로현황/001-fe-SFR-011-001-시도별 노후관로현황-작업카드.md @@ -1,5 +1,7 @@ # SFR-011-001 시도별 노후관로현황 FE 작업카드 +> 2026-09-11 API 계약 현행화: 아래 호출 경로는 lowerCamelCase 단일 계약으로 갱신했다. 기존 alias는 사용하지 않는다. [경로 단일화 변경기록](../../작업계획/SN-API경로단일화/001-fe-SN-API경로단일화-변경기록.md)을 기준으로 한다. + ## 유지보수 반영 (2026-09-04) - 목록의 연도별 결과 컬럼을 연도 그룹 헤더로 묶어 표시한다. @@ -20,5 +22,5 @@ - 기준년도 범위는 최대 3년까지만 허용한다. ## 4. 연동 API -- `GET /api/v1/swagePpl/aged-status-by-ctpv` +- `GET /api/v1/swagePpl/agedStatusByCtpv` - 별도 백엔드 Excel API를 호출하지 않고 조회 응답으로 FE에서 XLSX 생성 diff --git a/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-fe-SFR-011-008-침수예측지도-격자표시-작업계획.md b/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-fe-SFR-011-008-침수예측지도-격자표시-작업계획.md index a553df935..fabaaa6e8 100644 --- a/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-fe-SFR-011-008-침수예측지도-격자표시-작업계획.md +++ b/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-fe-SFR-011-008-침수예측지도-격자표시-작업계획.md @@ -1,5 +1,7 @@ # SFR-011-008 침수예측지도 중앙 오버레이 표시 FE 작업계획 +> 2026-09-11 API 계약 현행화: 아래 호출 경로는 lowerCamelCase 단일 계약으로 갱신했다. 기존 alias는 사용하지 않는다. [경로 단일화 변경기록](../../작업계획/SN-API경로단일화/001-fe-SN-API경로단일화-변경기록.md)을 기준으로 한다. + ## 목적 침수예측현황 선택행으로 지도 이동 시 선택한 도시침수지도 중앙에 사각 HTML table 단일 오버레이를 표시한다. 제목은 `[시도] [시군구] 침수예측` 두 열 병합이며, 본문은 분석시각, 강우시작시각, 관측 누적강수량(mm), 예측시각, 예측 누적강수량(mm), 초과 빈도(년), 침수위험도 순서다. 격자별 라벨과 위치 마커는 표시하지 않는다. @@ -7,7 +9,7 @@ ## 구현 방법 - 현황 팝업 메시지에 `analysisDateTime`을 추가한다. -- 지도 페이지에서 BE `grid-map` API를 호출한다. +- 지도 페이지에서 BE `gridMap` API를 호출한다. - 기존 도시침수지도 extent 중심 좌표에 위 9개 표시 항목을 table 행으로 렌더링한다. - 초단기예보격자망 WFS 폴리곤은 기존 레이어 표현을 유지한다. - 다음 선택 시 전용 중앙 overlay를 삭제하고 새 응답으로 갱신한다. diff --git a/apps/sn/docs/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md b/apps/sn/docs/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md index 9d73683f5..23d1465f3 100644 --- a/apps/sn/docs/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md +++ b/apps/sn/docs/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md @@ -1,5 +1,7 @@ # SFR-012-003 하수관로편집 작업카드 +> 2026-09-11 API 계약 현행화: 아래 호출 경로는 lowerCamelCase 단일 계약으로 갱신했다. 기존 alias는 사용하지 않는다. [경로 단일화 변경기록](../../작업계획/SN-API경로단일화/001-fe-SN-API경로단일화-변경기록.md)을 기준으로 한다. + ## 1. 작업 개요 - 작업 ID: SFR-012-003-FE - 작업명: 하수관망도 GIS편집 프론트엔드 구현 계획 @@ -128,7 +130,7 @@ | --- | --- | --- | --- | --- | --- | | 기존 레이어 트리 조회 | `GET` | `/api/v1/map/lyrStngs/SN_NETWORK` | 기존 지도 레이어 그룹 | 레이어 트리 데이터 | 기존 사용 중 | | 편집 가능 레이어 메타 조회 | `GET` | `/api/v1/map/gisEditLayers` | `lyrGrp`, `screenId` | 편집 가능 레이어, geometry 타입, 허용 도구 | BE 신규 필요 | -| 관로 기본정보 조회 | `GET` | `/api/v1/fcltppl/{fcltMngNo}/{mngInstCd}` | 관로 식별자 | `PplDto` 기본정보 7개 필드 | 기존 사용 중 | +| 관로 기본정보 조회 | `GET` | `/api/v1/fcltPpl/{fcltMngNo}/{mngInstCd}` | 관로 식별자 | `PplDto` 기본정보 7개 필드 | 기존 사용 중 | | 편집 저장 | `POST` | `/api/v1/map/gisEditSessions/save` | 레이어 정보, view, `changes[]` | `sessionId`, `editFileId`, `versionNo`, `savedCount` | BE 신규 필요 | - FE 메모: diff --git a/apps/sn/docs/작업계획/SN-API경로단일화/001-fe-SN-API경로단일화-변경기록.md b/apps/sn/docs/작업계획/SN-API경로단일화/001-fe-SN-API경로단일화-변경기록.md new file mode 100644 index 000000000..09aa5d3ec --- /dev/null +++ b/apps/sn/docs/작업계획/SN-API경로단일화/001-fe-SN-API경로단일화-변경기록.md @@ -0,0 +1,150 @@ +# SN API 경로 lowerCamelCase 단일화 변경기록 + +작성일: 2026-09-11. 사용자의 기존 alias 제거·혼용 금지 요청에 따른 프론트 호출 계약 변경이다. 서버 경로 정의·alias 제거는 메인 BE 작업과 함께 반영한다. + +## 1. 목표·근거·소유 범위 + +- 목표: 확정된 API 업무 경로를 lowerCamelCase 하나로 통일하고 구형 경로를 프론트에서 생성하지 않는다. 요청/응답 데이터 키나 화면 동작은 바꾸지 않는다. +- 소유: `frontend/apps/sn/**`, `frontend/packages/common/business_components/**`. 실제 수정은 SN 호출 소스18파일·자체 proxy route 이동1건·정식 테스트1파일·기존 앱문서14파일·본 문서다. business_components 수정은 없었다. +- 금지: 다른 앱·다른 공통 패키지·BE·전역정책·환경설정·서비스 식별자·DB/GeoServer 이름·query/body 키. 기존 사용자 변경과 이전 리팩토링을 보존했다. +- 완료 조건: 지정 구형 경로의 런타임 참조0, 새 호출 경로 보존, GIS GET목록/POST 구분, 원본 기능 유지, 정식 회귀·typecheck와 영향·잔여 위험 보고. +- 서버 기동·종료·재시작·next build는 수행하지 않는다. 임시 로그·JSON·PNG 파일도 생성하지 않는다. + +원본 `D:/workspace/IIPBS/docs/기준문서/요구사항정의서.xlsx`의 `3. (기능)요구사항정의 및 추적표` 87/88/89/93/94/95/96/97/100행과 `화면설계서.pptx` 9/10/12/19/24/31/35/36/37슬라이드를 직접 읽었다. + +| 요구사항·화면 근거 | 유지할 사용자 흐름·항목 | +| --- | --- | +| SFR-011-001/002/003, SN_MN010102/010103/010105 | 노후관로 기준년도·시도·시군구·처리구역 조회, 관로 합계·경과년도 구간·평균/최대/최소 정보, 상세 선택·Excel | +| SFR-011-007/008, SN_MN010201/010205 | 통수위험·침수예측 시군구/분석시각 조회, 현재·예측강우·위험상태·지도 연계 | +| SFR-011-009, SN_MN010207 | 침수이력 목록, 강우시작/종료·총강우·강우강도와 지도 상세 | +| SFR-012-001/002/005, SN_MN010303/010302/010304 | 시설 객체 상세·추가 현황, GIS 업로드/이력/다운로드, 유하방향 표시 | + +원본 화면은 기능·항목 보존 근거다. lowerCamelCase 단일 URL 정책은 사용자의 현재 명시 지시로 확정하며 원본에 API 이름이 쓰여 있다고 가정하지 않는다. 침입수/RDII 기존 등록은 payload·화면 그대로 `saveWithFile` 경로만 변경했다. + +## 2. 현재 API 경로 계약 + +아래 왼쪽은 제거되는 과거 경로를 식별하기 위한 변경 이력이며, 현재 호출에는 오른쪽 경로만 사용한다. `/sewerage-service` 같은 서비스 접두어는 유지한다. + +| 과거 경로 구간 | 현재 단일 구간 | SN 반영 | +| --- | --- | --- | +| `ppl-analysis` | `pplAnalysis` | 관로분석 요약·상세 | +| `flood-dngr-map` | `floodDngrMap` | 목록·scope·group/extent·침수예측 지도 이동 | +| `sbmrg-dngr-map` | `sbmrgDngrMap` | 침수위험지도 목록·그룹 | +| `rain-anls` | `rainAnls` | 침수흔적 강우 상세 | +| `depth-map` | `depthMap` | 침수깊이 상세 | +| `grid-map` | `gridMap` | 침수예측 격자 지도 | +| `aged-status-by-ctpv` | `agedStatusByCtpv` | 시도별 노후관로 팝업·Excel | +| `swage-prcs-zones` | `swagePrcsZones` | 현재 SN 직접 호출 없음 | +| `repair-histories` | `repairHistories` | 현재 SN 직접 호출 없음 | +| `savewithfile` | `saveWithFile` | 침입수·RDII·기존 파일등록 화면 | +| `fcltppl` | `fcltPpl` | 관로 상세·자체입력·관리기관 조회 | +| `fcltmhl` | `fcltMhl` | 맨홀 상세·자체입력·상류관로 조회 | +| `fcltpump` | `fcltPump` | 펌프장 상세·자체입력 | +| `fcltstofcbr` | `fcltStofcbr` | 우수토실 상세·자체입력 | +| 자체 `/api/gis-upload` | 자체 `/api/gisUpload` | SN Next 업로드 프록시·파일 경로 이동, 구 route 제거 | + +GIS는 method와 하위 경로를 구분한다. + +- 이력 목록: `GET /api/v1/map/gisUploadHistories`만 사용한다. 기존 GET `/api/v1/map/gisUploads` alias 호출은 제거했다. +- 파일 업로드: BE `POST /api/v1/map/gisUploads`는 유지한다. SN 자체 프록시는 `POST /api/gisUpload`로 통일하고 이전 `/api/gis-upload` route를 제거했다. 자체 API는 외부 서비스 식별자 예외에 해당하지 않는다. +- 기존 개별 작업: `gisUploads/swagePpl/downloadJobs`, `gisUploads/{uploadId}/downloadJobs`, `gisUploads/{uploadId}/validationErrors`, `gisUploads/{uploadId}/preview`는 별도 정상 경로이므로 유지한다. + +이번 변경에 URL 우회·자동 재시도·호환 alias fallback은 추가하지 않았다. + +## 3. 수정 파일과 실제 코드 + +소스18파일(경로는 SN 기준): + +1. `app/(map-layout)/map/page.tsx` +2. `app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx` +3. `app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx` +4. `app/(map-layout)/map/components/floodDngrMap/FloodDngrMapPopupPage.tsx` +5. `app/(map-layout)/map/components/sbmrgDngrMap/SbmrgDngrMapPopupPage.tsx` +6. `app/(map-layout)/map/components/fldngHstry/fldngHstryClient.ts` +7. `app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx` +8. `app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvExport.ts` +9. `app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx` +10. `app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx` +11. `app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx` +12. `app/(map-layout)/map/components/popupComponent/smh.tsx` +13. `app/(with-base-layout)/ii/irsetting/page.tsx` +14. `app/(with-base-layout)/ii/rdii/page.tsx` +15. `app/(with-base-layout)/ii/fileupload/page.tsx` +16. `src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx` +17. `app/html/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx` +18. `app/html/(map-layout)/map/components/popupComponent/smh.tsx` + +html2파일도 현재 제공되는 SN 호출부라 사용자의 모든 SN 경로 변경 범위에 포함했다. 시안 마크업·업무 로직은 바꾸지 않았다. + +추가로 `app/api/gis-upload/route.ts`를 `apply_patch`의 Move로 `app/api/gisUpload/route.ts`에 이전했다. 프록시의 POST 본문·인증 헤더·백엔드 주소·응답 status/body 전달 로직은 변경하지 않았다. GIS 화면의 프록시 호출만 `postMutiPart("/api/gisUpload", formData)`로 맞췄으며 구 route 파일은 남기지 않았다. + +대표적인 현재 코드: + +```ts +getData(`v1/fldng/hstry/rainAnls/${encodeURIComponent(fldngHstryFid)}`); +getData(`v1/fldng/hstry/depthMap/${encodeURIComponent(fldngHstryFid)}`); + +const data = await postMutiPart("v1/rdiiAnls/saveWithFile", analysisData); + +const result = await getData("v1/fcltPpl/mngInsts"); +``` + +GIS 목록 호출은 다음과 같이 바뀌었다. + +```ts +// 변경 전(현재 호출 금지) +`v1/map/gisUploads?${params.toString()}` +// 현재 +`v1/map/gisUploadHistories?${params.toString()}` +``` + +18파일 각각을 최초 단일화 직전 소스와 메모리에서 대조해 지정 URL 구간 치환 및 GIS GET 목록 변경 이외의 차이가 없음을 확인했다. 후속 자체 proxy 정리에서는 GIS 호출 한 줄과 route 경로만 추가 변경했다. 별도 snapshot·raw 파일은 만들지 않았다. query/body 키·서비스 접두어·GeoServer layer 문자열·기존 사용자 변경은 보존했다. + +`app/components/lib/common/apiHelpers.ts`도 직접 확인했다. 현재 CMS/검색 helper로 `fclt + suffix` 생성은 없으며, SN 전체 추가 검색에서도 해당 동적 조합을 찾지 못했다. 존재하지 않는 분기를 새로 만들지 않았다. + +## 4. 문서·정식 테스트와 검증 + +신규 정식 회귀 파일은 `tests/api-url-contract.test.cjs`다. + +1. SN app/src와 business_components의 구형 URL 구간 부재 및 실제 사용 중인 새 단일 경로 존재를 검사한다. 테스트의 retired 목록은 회귀 방지용 금지값이며 실제 호출 alias가 아니다. +2. 실제 `loadFldngHstryDetail`을 모의 HTTP로 실행해 `rainAnls`, `depthMap`, fid 인코딩을 확인한다. 기존 응답의 camel/snake 필드와 숫자0·상세행 보존도 검증한다. +3. SN POST proxy가 `/api/gisUpload`를 사용하고 구 route 파일이 없는지 검사한다. BE GET 목록은 `gisUploadHistories`, BE POST와 하위 작업은 `gisUploads`임을 함께 확인한다. +4. `/sewerage-service`, `/cms-service`, `/geoserver`, CMS 코드 계약과 동적 fclt 조합 부재를 검사한다. + +기존 앱문서14개도 처리했다. + +- 현재 작업카드/작업계획/검증계획4개는 API 표기와 URL을 새 계약으로 갱신했다. +- 과거 구현기록·검증결과10개는 실제 당시 URL을 증거로 보존하되, 현재 단일 계약으로 대체됐다는 2026-09-11 안내와 본 문서 링크를 추가했다. 여기에는 이전 자체 proxy 경로를 기록한 5차 보고서도 포함한다. + +실행 위치 `D:/workspace/IIPBS-FRONTEND/frontend/apps/sn`: + +```powershell +node --test tests/api-url-contract.test.cjs +pnpm run test:refactor +pnpm run typecheck +git diff --check +``` + +- 신규 회귀4/4 통과. 기존53건 포함 전체57/57 통과. +- 기본 `pnpm run typecheck` 통과(exit 0, `tsc --noEmit --incremental false --pretty false`). 임시 설정 우회·증분 캐시 생성 없이 실행했다. +- `git diff --check` 통과. 문서의 현재 계약 링크13개도 모두 유효함을 확인했다. +- source18파일의 변경 범위 대조18/18 일치. +- 최초 단일화 배치에서는 실제 서버 호출·브라우저 재검증·빌드를 수행하지 않았다. 후속 자체 proxy 보완은 아래 승인된 안전 GET 상태 확인만 추가했다. + +후속 자체 proxy 보완의 최종 결과: + +- `app/api/gisUpload/route.ts` 존재, 이전 `app/api/gis-upload/route.ts` 부재. route 원문을 이동 전후 메모리에서 대조해 내용이 동일함을 확인했다. +- route 이동 후 `pnpm run test:refactor` 전체57/57 통과. 새 proxy 경로·구 route 부재 검사가 포함돼 있다. +- route 이동 후 `pnpm run typecheck` 통과(exit0). 증분 캐시 없이 실행했다. +- 사용자 서버에 안전한 `GET http://localhost:3002/api/gisUpload`는 405를 반환했다. POST 전용 route의 존재를 확인한 것이며 실제 POST 처리 검증은 아니다. +- `GET http://localhost:3002/api/gis-upload`는 최초 20초 동안 응답하지 않았으나, 안전 GET을 한 번 재조회해 404를 확인했다. 첫 timeout을 성공이나 404로 간주하지 않았다. +- middleware matcher와 Next rewrites를 읽기 확인했다. 두 자체 API 경로를 외부로 rewrite하는 설정은 없어 405/404를 외부 서비스 응답으로 해석할 근거가 없다. +- 실제 POST 업로드, 사용자 서버의 종료·재시작, build, 설정 변경, 임시 파일 생성은 하지 않았다. + +## 5. 외부 영향·남은 위험·롤백 + +허용 밖 다른 앱·공통 패키지는 읽기 검색만 했다. 지정 구형 endpoint의 직접 소스 참조는 발견되지 않았다. business_components에도 대상 호출이 없어 변경0건이다. 동적 URL 또는 저장소 밖 외부 소비자까지 없는 것으로 단정하지 않는다. + +BE의 alias 제거와 FE 배포를 같은 계약으로 조율해야 한다. 특히 `fcltPpl/fcltMhl/fcltPump/fcltStofcbr`, `saveWithFile`, `gisUploadHistories`가 서버에 반영되기 전에 새 FE를 사용하면 404가 발생할 수 있다. 통합 QA에서 기존 경로 미지원·새 경로 정상 응답·저장 및 조회 파라미터 유지와 화면 진입을 확인해야 한다. + +롤백은 기존 미커밋 사용자·리팩토링 변경을 보존한 채 본 URL 치환 delta만 역패치한다. 서버가 옛 alias를 제거한 상태에서 FE만 되돌리면 호출이 실패하므로 BE와 같은 계약 단위로 되돌려야 한다. 서버 설정·DB·파일첨부 데이터 변경은 없다. 커밋하지 않았다. diff --git a/apps/sn/docs/작업계획/SN-로컬개발비교검증/001-qa-SN-로컬개발서버-메뉴별비교결과.md b/apps/sn/docs/작업계획/SN-로컬개발비교검증/001-qa-SN-로컬개발서버-메뉴별비교결과.md new file mode 100644 index 000000000..2732062e0 --- /dev/null +++ b/apps/sn/docs/작업계획/SN-로컬개발비교검증/001-qa-SN-로컬개발서버-메뉴별비교결과.md @@ -0,0 +1,121 @@ +# 로컬·개발서버 메뉴별 비교 검증 + +## 목적과 범위 + +- 요청: 로컬과 개발서버를 메뉴 > 화면명별로 비교하고 통과 여부를 기록한다. +- 검증일: 2026-09-11. 로컬 `http://localhost:3002`, 개발 `http://con.mind-one.co.kr:11302`. +- 사용자 확인: 두 환경은 같은 DB와 같은 테스트 계정을 사용한다. 계정은 정상 로그인에만 사용하며 비밀번호·토큰·쿠키를 보고서나 별도의 인증 덤프 파일로 추출하지 않았다. 브라우저가 자동 생성한 임시 프로필의 정리 한계는 마감 확인에 구분했다. +- 사용자 승인된 별도 Playwright 자동화를 사용했다. 사용자 Chrome 프로필과 분리된 headless Chrome 세션과 1600×1000 viewport에서 비교했다. 기존 백엔드와 업무 코드·설정은 변경하지 않았다. 로컬 SN만 응답 지연으로 사용자 승인을 받아 재기동했고, 검증 후에도 계속 실행하라는 추가 지시를 받았다. +- 조회·검색·상세 진입과 대표 정렬/페이징을 비교했다. 실제 등록·수정·삭제·파일 업로드·분석 실행은 제외했다. 브라우저 요청 경계에서 업무 쓰기 HTTP method를 차단했다. 정상 로그인·토큰 갱신, 자동 접속로그와 Next 개발 오류 프레임 조회는 허용했다. 이 자동 기록을 포함한 DB 쓰기가 전혀 없었다는 의미는 아니며, 업무 쓰기 요청을 실행하지 않았다는 의미다. + +## 판정 기준 + +- 통과: 양쪽 실제 UI에서 해당 읽기 시나리오가 동작하고 관련 응답에 기능 오류가 없으며 동일 조건의 결과가 일치한다. +- 차이/실패: 한쪽 기능 오류, 조회/화면 내용 차이 또는 양쪽에서 발생한 오류를 증거와 함께 구분한다. +- 제한/미검증: 필수 상세 식별자·선행 데이터·권한·자동화/외부 연동 문제로 시나리오를 완료하지 못했다. 초기 HTML200이나 빈 화면을 통과로 처리하지 않는다. +- 원본 설계와 실제 제공 메뉴가 다른 경우 현재 사용자 메뉴명으로 기록하고 원본 추적을 별도로 둔다. 관측시각에 따라 변하는 예측값·폴링값은 고정 조건 없이 단순 동일 비교하지 않는다. + +## 원본과 범위 근거 + +- 요구사항정의서 `3. (기능)요구사항정의 및 추적표`73~100행 SFR-010-001~014, SFR-011-001~009, SFR-012-001~005를 직접 읽었다. 73~75·80~81행에는 유지관리/암거 조사 일부 삭제·대체 검토 이력이 있어 원본 항목 수를 현행 메뉴 수로 간주하지 않는다. +- 화면설계서58장 중 메뉴구조5번과 SN 화면 ID가 있는7~19·21~24·28~29·31~37·39·43~51·53~58번을 직접 추출·대조했다. 대표 근거는 지도 SN_MN010101~010108, 위험도 SN_MN010201~010208, 편집 SN_MN010301~010304, 조사 SN_MN040101~040110, 준설 SN_MN040201~040203이다. 원본 일부 번호 뒤의65/265 표기는 별도 페이지문구 혼입으로 구분한다. +- 기존 화면 인벤토리와 현재 `popupDefinitions.ts`, `registry.tsx`, 본창 `page.tsx` 구성을 대조한다. 과거 인벤토리의 화면 수·통과 기록을 이번 환경의 새 검증 결과로 전용하지 않는다. + +## 실행 결과 + +판정은 아래에 명시한 읽기 시나리오에 한한다. 선택한 메뉴 및 연결 화면 **27개를 양쪽에서 확인**했다. 등록·분석 입력 화면 5개는 표시까지만 확인했으므로 전체 기능 통과에 합산하지 않는다. 개발용/별칭 페이지 전체, 모든 개별 GIS 객체와 모든 상세 모달을 전수 검증한 결과는 아니다. + +| 구분 | 화면 수 | 의미 | +|---|---:|---| +| 통과 | 21 | 지정한 화면 표출·읽기 조회 결과 일치 | +| 차이 확인 | 1 | 정보검색의 추가 등록정보 및 표시값 차이 | +| 부분 확인 | 5 | 입력/빈 편집 화면만 확인, 분석·저장 미실행 | +| 합계 | 27 | 각 화면을 로컬과 개발 환경에서 확인 | + +### 메뉴 > 화면명별 체크표 + +`통과`는 해당 행의 확인 범위에만 적용한다. 0건 조회의 통과는 빈 결과 처리 확인이며 실제 데이터 상세까지 검증했다는 뜻이 아니다. + +| 번호 | 메뉴 > 화면명 | 로컬 | 개발서버 | 최종 판정·확인 범위 | +|---:|---|---|---|---| +| 1 | 하수관망도 > 초기 화면 | 정상 표출 | 정상 표출 | **통과**. 베이스맵·시설 표시, canvas1, 레이어 설정92개·지반침하213건 응답 일치 | +| 2 | I/I 분석 > 침입수 분석 | 입력 화면 확인 | 입력 화면 확인 | **부분 확인**. 양평군 시설 조회 응답 일치. 파일 입력·분석 실행 미수행 | +| 3 | I/I 분석 > RDII 분석 | 입력 화면 확인 | 입력 화면 확인 | **부분 확인**. 시설 응답·입력 표 확인. 분석 실행 미수행 | +| 4 | 운영정보 > 도시침수예방사업 | 조회 정상 | 조회 정상 | **통과**. 지정일2025-09-11~2026-09-11, 총19건·첫10행·본문 일치 | +| 5 | 운영정보 > 도시침수예방사업 > 등록 | 폼 표시 확인 | 폼 표시 확인 | **부분 확인**. 등록·저장 미수행 | +| 6 | 운영정보 > 노후 하수관로 정비사업 | 조회 정상 | 조회 정상 | **통과**. 정밀조사 구분, 총128건·첫10행·본문 일치 | +| 7 | 운영정보 > 노후 하수관로 정비사업 > 등록 | 폼 표시 확인 | 폼 표시 확인 | **부분 확인**. 등록·저장 미수행 | +| 8 | 유지관리 > 조사관리 | 조회 정상 | 조회 정상 | **통과**. 총13건·기본 첫10행, 사업명 오름차순·2페이지3행 일치 | +| 9 | 유지관리 > 조사관리 > 조사현황(기존 경로) | 조회 정상 | 조회 정상 | **통과**. `/oper/exmn/status` 직접 진입,13건 일치. 현재 기본 메뉴는 `/oper/exmn/register`이며 기존 경로의 상단 제목은 양쪽 모두 빈 상태 | +| 10 | 유지관리 > 조사관리 > 조사 상세현황 | 상세 정상 | 상세 정상 | **통과**. 실제 사업행→덕풍처리분구→상세보기 팝업, 관로3건·집계값·본문 일치 | +| 11 | 유지관리 > 준설관리 | 조회 정상 | 조회 정상 | **통과**. 2025-09-11~2026-09-11,10건 및 표시값·본문 일치 | +| 12 | 유지관리 > 처리분구관리 | 조회 정상 | 조회 정상 | **통과**. 사용 중인 처리분구7건·본문 일치. 추가/수정/저장/삭제 미수행 | +| 13 | 하수관망도 > 분석도구 > 정보검색 | 조회 정상, 추가정보 있음 | 조회 정상, 일부 추가정보 없음 | **차이 확인**. 전체4,027,468건은 동일. 첫 페이지 관로2건의 처리구역 표시 및52개 응답 속성 차이 | +| 14 | 하수관망도 > 정보검색 > 하수도정비 기본계획보고서 | 조회 정상 | 조회 정상 | **통과**. 기본계획 분류 및 첫10행·첨부 메타데이터 응답 일치. 파일 원문 다운로드 미수행 | +| 15 | 하수관망도 > 분석도구 > 관로분석 | 조회 정상 | 조회 정상 | **통과**. 경과년도(YEAR) 기본 조회·첫10행·집계 응답 일치. 다른 분석 탭 전체·내려받기 미수행 | +| 16 | 하수관망도 > 분석도구 > 시도별 노후관로현황 | 조회 정상 | 조회 정상 | **통과**. 2024~2026년·경과30년 기준, 표와 응답 일치 | +| 17 | 하수관망도 > 분석도구 > 침수예측현황 | 0건 정상 | 0건 정상 | **통과**. 양쪽 UI에서2026-09-11 00:00~14:00으로 맞춘 조회·빈 결과 응답 일치. 실제 위험대상 상세 미검증 | +| 18 | 하수관망도 > 분석도구 > 통수 위험 | 0건 정상 | 0건 정상 | **통과**. 대상지역20개,14:00 현재분석(IN_PROGRESS)0건 및 응답 일치. 과거분석·위험대상 상세 미검증 | +| 19 | 하수관망도 > 분석도구 > 침입수 분석결과 | 조회 정상 | 조회 정상 | **통과**. 202601~202609·성공(Y),1건·본문 일치 | +| 20 | 하수관망도 > 분석도구 > RDII 분석결과 | 0건 정상 | 0건 정상 | **통과**. 같은 기간·성공(Y)0건 및 빈 결과 응답 일치 | +| 21 | 하수관망도 > 분석도구 > GIS 관리 | 이력 조회 정상 | 이력 조회 정상 | **통과**. 하남시4040000·PPL 이력0건·본문 일치. 양쪽 실제 지도 메뉴에서 팝업 열림 확인. 업로드/변환 작업 미실행 | +| 22 | 하수관망도 > 분석도구 > GIS 편집 | 빈 세션 표시 | 빈 세션 표시 | **부분 확인**. 편집 세션의 미선택·변경0건 상태만 확인. 실제 객체 선택·이동·삭제·저장 미수행 | +| 23 | 하수관망도 > 레이어 > 지반침하 목록 | 조회 정상 | 조회 정상 | **통과**. 20250911~20260911,54건·첫10행·본문 일치 | +| 24 | 하수관망도 > 레이어 > 노후관로 목록 | 조회 정상 | 조회 정상 | **통과**. 경과30년·19000101 제외,740,094건·첫10행·본문 일치 | +| 25 | 하수관망도 > 레이어 > 침수위험지도 목록 | 조회 정상 | 조회 정상 | **통과**. 목록50건·표시값·본문 일치 | +| 26 | 하수관망도 > 레이어 > 도시침수지도 목록 | 조회 정상 | 조회 정상 | **통과**. 목록894건·행정범위224개·첫10행·본문 일치 | +| 27 | 하수관망도 > 레이어 > 침수발생이력 목록 | 조회 정상 | 조회 정상 | **통과**. 총139,121건·첫10행·본문 일치. 전체13만건을 다운로드한 것은 아님 | + +### 비교 방법과 증거 + +- 양쪽 모두 실제 Playwright 브라우저에서 정상 로그인한 `mindone03` 계정임을 사용자 정보 응답으로 확인했다. 비밀번호·토큰 값은 결과에 남기지 않는다. +- 조회 버튼, GIS 관리의 관리기관 선택·목록 갱신, 침수예측 시간 입력, 조사 사업/처리분구 행 선택·실제 상세 팝업, 사업명 정렬과 페이지 이동을 조작했다. 대부분의 화면은 확인된 메뉴/팝업 경로로 직접 진입했다. 모든 메뉴 링크를 하나씩 클릭한 전수 내비게이션 시험과는 구분한다. +- 업무 응답의 성공 상태·건수·표시 행을 대조하고, 응답 래퍼의 매 요청 `timestamp`를 제외한 `data`를 키 정렬 후 SHA-256으로 비교했다. raw 응답이면 그 본문을 비교했다. 테스트 비교 키에서만 알려진 구/신 URL 표기를 정규화했으며 실제 브라우저 요청을 새 주소로 바꿔치기하지 않았다. +- 최종 유효한 동일 조건 결과를 판정에 사용했다. 로컬의 미컴파일 상태·늦은 hydration·장시간 실행 중 세션 만료에서 나온 중간 상태는 따로 기록하고 재확인했다. 최종 읽기 결과에 남은 업무 API4xx/5xx 또는 uncaught page error는 없었다. +- 하수관망도는 같은 계정의 양쪽 screenshot을 직접 확인했다. 초기 타일 로딩 중 빈 canvas를 최종 결과로 쓰지 않고 시설·배경지도가 표시된 후 비교했다. 이미지 파일은 디스크에 저장하지 않았다. +- 대표 본문 해시(앞20자리): 조사기본 `2b1b1248312d5a166309`, 사업명정렬 `5f5c556695db088118ce`, 조사2페이지 `1e3540ddd1fc746027c4`, 조사상세3시설 `120faf18062bf9f19c9e`, 준설 `7103ccc8862513e72503`, 처리분구 `5feef52afe91ee19870f`, 도시침수예방사업 `f5d6e4307ddf3624d469`, 노후정비사업 `31781ce6bb349a145cd0`, 지반침하54건 `cfc84e6d65d725eadb79`, 지도초기213건 `793e07b1efe286fd0535`, 침수이력 `263978e62e86478d918b`, 관로분석 `acfaf8ebc63c343632d3`, 노후관로 `a5663272a2aef50dad2b`, 시도별현황 `403651f3a8a7411e65fe`, GIS하남시이력 `52fa3ed0bd439c34a4d9`. 해당 조건에서 양쪽 일치했다. + +## 발견한 차이와 주의사항 + +### 1. 정보검색의 추가 등록정보 차이 — 확인됨 + +- 동일 시설 목록 요청(`tb_ms_swage_ppl`, page0/size10, `includeFlowDirectionBasis=true`, 설치일19000101~20260911)을 개발서버에서 재조회해도 차이가 재현됐다. +- 공통 JSON 필드의 값 차이는0이며 로컬에만 있는 속성이52개다. 첫 페이지의 관로61582·61583 각26개 속성으로, `reg_dt`, `fclt_mng_no`, `mng_inst_cd`, `swtrmt_zone_nm`과 대응 camelCase 등록정보 등이 포함된다. +- UI에서도 두 관로의 처리구역이 **로컬 `대전처리구역` / 개발 `-`**로 다르다. 단순 timestamp나 키 정렬 순서 차이가 아니다. +- 로컬 응답 해시 `aa3040101f8d1471a73e`, 개발 `891c0eff8313d0ad2d04`. 개발의 시설 추가정보 병합 적용 및 배포 버전 확인이 필요하다. 이 시험만으로 어느 쪽 데이터가 잘못됐다고 단정하지 않는다. + +### 2. API URL 배포 차이 — 확인됨 + +- 개발 화면은 `ppl-analysis`, `flood-dngr-map`, `sbmrg-dngr-map`, `aged-status-by-ctpv`, `fcltppl`, GET `map/gisUploads` 등의 기존 URL을 사용한다. +- 로컬 화면은 `pplAnalysis`, `floodDngrMap`, `sbmrgDngrMap`, `agedStatusByCtpv`, `fcltPpl`, GET `map/gisUploadHistories`를 사용한다. 양쪽의 각 현재 호출은 정상 응답했다. +- 따라서 개발서버는 이번 로컬 URL 단일화 상태와 동일한 배포 상태가 아니다. 구 URL을 제거한 백엔드만 먼저 배포하면 기존 개발 프론트 호출이 깨질 수 있으므로 BE·SN 동시 반영이 필요하다. 개발의 모든 새 URL 지원 여부를 별도로 전수 시험한 것은 아니다. + +### 3. 루트의 개발용 자동로그인 — 확인됨 + +- 개발서버에서 지정 계정 로그인 성공 후 `/`로 이동하면 앱이 개발용 자동로그인을 실행해 실제 사용자 정보가 `CLEANWATER`로 바뀌었다. +- 현재 로컬 `app/page.tsx`도 개발 호스트에서 로그인 상태와 무관하게 개발 계정 로그인 함수를 실행하는 코드다. 로컬에서 이 자동계정 전환을 별도로 끝까지 재현한 것으로 표현하지 않는다. +- 지정 계정 비교에서는 정상 로그인으로 발급된 세션을 사용하고, 시험 브라우저 안에서 `/` 문서 이동만 중단한 뒤 업무 화면에 직접 진입했다. 업무 코드·인증 서버·쿠키 값을 임의 위조하거나 수정한 것은 아니다. 일반 `/` 로그인 이동 흐름을 통과로 판정하지 않았다. +- 자동로그인의 의도·허용 호스트 및 기존 로그인 세션 우선 여부는 별도 확인 대상이다. 이번 요청은 비교 시험이므로 구현은 바꾸지 않았다. + +### 4. 로컬 지연과 세션 만료 — 확인됨 / 원인 일부 확인 필요 + +- 시작 시 로컬3002의 로그인 문서20초와 가벼운 API10초가 시간 초과했다. 사용자 승인을 받아 기존 SN 프로세스만 종료·재기동했다. 백엔드는 유지했다. +- 새 서버의 최초 컴파일은 로그인62.1초, 조사관리91.7초, 팝업 공통 경로207.8초였다. 미컴파일 상태의45/120초 timeout을 최종 기능 실패로 대체하지 않고 warm 상태에서 재검증했다. +- 검증 중 한 시점의 사용 가능 메모리는16GiB 중0.47GiB였다. 지연에 영향을 줄 수 있으나 단독 원인으로 확정하지 않는다. 개발 배포본과 로컬 dev/Turbopack의 시간을 동일한 성능 벤치마크로 해석하지 않는다. +- 장시간 실행 중 로컬에서 CMS401과 로그인 화면 이동을 관찰했다. 재로그인 후 지도·213건 응답·시각 표출을 재확인했다. 세션 만료/갱신의 전체 수명주기 시험은 별도이며 최종 정상 조회와 혼합하지 않는다. +- 초기에 시험용 쓰기 차단기가 자동 접속로그·Next 진단 요청도 차단해 Axios 오류를 만든 적이 있다. 해당 비업무 요청은 허용하고 영향 화면을 다시 확인했다. 이 시험 장치 유발 오류를 제품 결함으로 집계하지 않았다. + +## 영향·롤백·정리 + +- 업무 코드·DB 스키마·서버 설정 변경 없음. 새 산출물은 이 결과 문서이며, 이를 되돌릴 때 다른 사용자 변경을 reset하지 않는다. 비교 결과를 근거로 한 수정·배포는 실행하지 않았다. +- 승인받아 종료한 기존 SN: launcher273488/실제3002 PID269940와 확인된 하위 프로세스. 새 SN은 launcher307436/실제3002 PID307988이며, **사용자 요청에 따라 계속 실행**한다. 실행 명령은 기존과 같은 `next dev --turbopack -p 3002`다. 종료가 필요할 때 해당 PID가 여전히 SN임을 확인한 후 `taskkill /PID 307436 /T /F`로 해당 트리만 종료한다. 사용자 백엔드8883/PID261076은 건드리지 않았다. +- 시험용 브라우저/제어기는 처음 Node280440/Chrome302348, 이후 Node304312/Chrome281328 및 loopback8898을 사용했다. 초기 드라이버는 종료했고 최종 브라우저 종료·8898 해제·임시 드라이버 삭제는 아래 마감 확인에 기록한다. +- 원시 응답·storageState 인증 덤프·브라우저 trace·이미지를 별도 결과 파일로 저장하지 않았다. 브라우저 자동 생성 프로필은 아래 잔존 항목과 구분한다. 이전 작업에서 삭제 정책에 막힌 파일은 이번 시험 대상이 아니며 우회 삭제하지 않았다. + +### 마감 확인 + +- 마지막 점검은27개 화면 × 2환경의 최종54개 관측에서 계정 `mindone03`, 남은 업무 HTTP 오류·uncaught page error0, 실행된 업무 쓰기 요청0이었다. 최초 로딩/세션/시험 차단기 오류는 최종 판정에서 숨기지 않고 위 재검증 이력과 구분했다. +- 시험 브라우저의 정상 종료 요청이45초 내 완료되지 않아, 소유 명령행을 확인한 QA Node304312 트리만 종료했다. 최종 점검에서 Node304312·Chrome281328·이전 QA Node280440/Chrome302348 모두 부재, loopback8898 미리스닝을 확인했다. 사용자 SN307436/307988과 백엔드261076은 종료 대상에 포함하지 않았으며3002/8883 리스닝 유지도 확인했다. +- 일회성 `compare-browser.cjs` 1개는 삭제했다. 삭제한 드라이버는 시험용 코드이며 이번 보고서의 방법·조건으로 재작성할 수 있다. 업무 소스와 사용자 원본은 삭제하지 않았다. +- 자동 생성된 임시 브라우저 프로필2개는 남아 있다. 정확한 경로를 한정한 재귀 삭제 요청이 도구 정책에서 실행 전에 차단됐으므로 다른 도구로 우회하지 않았다. `C:/Users/mindone/AppData/Local/Temp/playwright_chromiumdev_profile-hFrSUU`, `C:/Users/mindone/AppData/Local/Temp/playwright_chromiumdev_profile-OND6dd`. 이 폴더까지 정리 완료했다고 보고하지 않는다. +- 최종 산출물은 이 Markdown 체크표다. 엑셀 감리 원본이나 업무 구현을 이번 시험 때문에 수정하지 않았다. diff --git a/apps/sn/docs/작업계획/SN-패키지구조진단/001-fe-SN-파일역할목록.md b/apps/sn/docs/작업계획/SN-패키지구조진단/001-fe-SN-파일역할목록.md new file mode 100644 index 000000000..5a2ec33fe --- /dev/null +++ b/apps/sn/docs/작업계획/SN-패키지구조진단/001-fe-SN-파일역할목록.md @@ -0,0 +1,1326 @@ +# 프론트 SN 파일 역할 목록 + +> 이 목록은 구조 정리 전 파일 역할 스냅샷이다. 2026-09-11 삭제한 61파일의 행은 링크 대신 삭제 표시로 갱신했다. 신규 분리 모듈·현재 행수·삭제 근거·검증은 [002 구현결과](002-fe-SN-구조정리-구현결과.md)를 기준으로 한다. + +2026-09-11 작업 트리 기준. 루트: `D:/workspace/IIPBS-FRONTEND/frontend/apps/sn`. + +- 목록 559개, 코드 398개. 생성물·설치 의존성·루트 docs·specs·.spar-kit 내부는 제외하고 별도 종합보고서에서 폴더 역할을 설명한다. +- 전체 파일의 선언·경로·주석·정적 import를 분류한 목록이다. 모든 메서드를 수작업으로 동작 검증한 결과는 아니다. +- 참조 수는 점검 대상 내부의 정적 소비 파일 수다. 런타임 DI, 리플렉션, 외부 소비자, 동적 자산 이름은 별도 확인한다. **참조0 또는 경로 미발견만으로 삭제 확정하지 않는다.** +- API·Entity·Next 라우트·타입 선언은 프레임워크가 자동 발견할 수 있다. test/fixture는 정식 회귀 자산이며 임시 테스트 결과가 아니다. +- 주석 발췌는 해당 파일의 설명 중 일부로서 보조 근거다. 파일 전체 역할은 역할 분류/선언과 실제 소비 경로를 함께 본다. +- 최초 읽기 전용 진단에서는 업무 파일을 변경하지 않았다. 후속 승인 정리에서는 61파일을 삭제하고 책임을 분리했으며, 현재 결과는 상단의 002 구현결과를 따른다. + +## (루트) + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [.env.local]() | 환경별 연동/설정값. 민감값은 열람·기재하지 않음 | | — | 0 | 용도 기준 보존 | +| [.env.mindone]() | 환경별 연동/설정값. 민감값은 열람·기재하지 않음 | | — | 0 | 용도 기준 보존 | +| [.env.production]() | 환경별 연동/설정값. 민감값은 열람·기재하지 않음 | | — | 0 | 용도 기준 보존 | +| [.gitignore]() | Git 추적 제외 규칙 | | — | 0 | 용도 기준 보존 | +| [.prettierrc]() | 코드 포맷 규칙 | | — | 0 | 용도 기준 보존 | +| [AGENTS.md]() | 해당 경로의 작업 범위·안전·문서 지침 | | — | 0 | 용도 기준 보존 | +| [components.json]() | UI 생성 도구의 별칭/스타일 설정 | | — | 0 | 용도 기준 보존 | +| [env.d.ts]() | 환경 변수/전역 타입 선언 | ImportMetaEnv, ImportMeta | 17 | 0 | 등록/도구 진입 규칙 대상 | +| [eslint.config.mjs]() | ESLint 규칙 | __filename, __dirname, isProd | 20 | 0 | 등록/도구 진입 규칙 대상 | +| [middleware.ts]() | 서비스 경로 프록시/rewrite와 인증 경계 연결 | AUTH_USER_ID, CLAIM_NAME, SERVER_API_URL / 공통: URLSearchParams의 값을 decodeURIComponent 적용하여 querystring 생성 | 166 | 0 | 등록/도구 진입 규칙 대상 | +| [next-env.d.ts]() | Next가 생성/관리하는 타입 참조. 자동 포함 | | 7 | 0 | 등록/도구 진입 규칙 대상 | +| [next.config.ts]() | Next standalone 출력·CMS rewrite·빌드 정책. 오류 무시 옵션 점검 대상 | subDomain, nextConfig | 49 | 0 | 등록/도구 진입 규칙 대상 | +| [package.json]() | SN 스크립트·의존성·앱 이름 정의 | | — | 0 | 용도 기준 보존 | +| [postcss.config.mjs]() | PostCSS/Tailwind 변환 설정 | config | 9 | 0 | 등록/도구 진입 규칙 대상 | +| [tailwind.config.ts]() | SN 스타일 토큰·콘텐츠 탐색 설정 | | 21 | 0 | 등록/도구 진입 규칙 대상 | +| [tsconfig.json]() | TS 컴파일·@ 별칭·SN 및 common 소스 경로 해석 | | — | 0 | 용도 기준 보존 | +| [tsconfig.tsbuildinfo]() | 재생성 가능한 TypeScript 증분 캐시. 소스 아님 | | — | 0 | 재생성 캐시 | + +## app + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [layout.tsx]() | Next 루트 HTML·테마·메타데이터·Provider 진입점 | metadata, RootLayout / ➡️ 경로 : 모든 페이지 | 44 | 0 | 등록/도구 진입 규칙 대상 | +| [page.tsx]() | 루트 진입/개발 자동로그인·지도 이동 | DEV_HOSTNAMES, DEV_LOGIN_PAYLOAD, Home / 홈 페이지 컴포넌트 | 51 | 0 | 등록/도구 진입 규칙 대상 | +| [providers.tsx]() | 실제 src/app/providers의 AppProvider 재수출. 현재 layout에서 사용 | | 2 | 1 | 정적 참조 있음 | + +## app/(auth)/auth + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [layout.tsx]() | 인증 — Next 자동 발견 진입 파일 | metadata, AuthLayout | 14 | 0 | 등록/도구 진입 규칙 대상 | + +## app/(auth)/auth/login + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [page.tsx]() | 인증 — Next 자동 발견 진입 파일 | | 3 | 0 | 등록/도구 진입 규칙 대상 | + +## app/(map-layout) + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [layout.tsx]() | Next 자동 발견 진입 파일 | MapLayout / ➡️ 경로 : 하수관망도 모든 페이지 | 69 | 0 | 등록/도구 진입 규칙 대상 | + +## app/(map-layout)/map + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [page.tsx]() | 지도 초기화·레이어·분석·팝업·편집 연동의 큰 조정 컴포넌트 | AnalysisPopupMenu, AGED_PIPE_CONTROL_NODE_ID, AGED_PIPE_HIGHLIGHT_LAYER_ID / ➡️ 경로 : 하수관망도 메인 페이지 | 9410 | 0 | 등록/도구 진입 규칙 대상 | + +## app/(map-layout)/map/agedPipePopup + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [page.tsx]() | 지도/GIS — Next 자동 발견 진입 파일 | LegacyAgedPipePopupPageProps, LegacyAgedPipePopupPage | 31 | 0 | 등록/도구 진입 규칙 대상 | + +## app/(map-layout)/map/components + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [InputSection.tsx]() | 지도/GIS — 화면/UI 조합 | InputSectionProps, InputSection / 시설 상세정보 화면의 운영정보 입력 영역을 공통 형식으로 표시한다. | 89 | 1 | 정적 참조 있음 | + +## app/(map-layout)/map/components/agedPipe + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [agedPipeExport.ts]() | 지도/GIS — 보조 함수/자료 구조 | AgedPipeExportProgress, downloadAgedPipeExcel / 노후관로 목록의 엑셀 다운로드 데이터와 서식을 생성한다. | 53 | 2 | 정적 참조 있음 | +| [AgedPipeLayerSettingPanel.tsx]() | 지도/GIS — 화면/UI 조합 | MaybePromise, Props, AgedPipeLayerSettingPanel / 화면명: 노후관로 레이어 설정 | 136 | 1 | 정적 참조 있음 | +| [AgedPipePopupPage.tsx]() | 지도/GIS — 화면/UI 조합 | POPUP_ROW_SELECT_MESSAGE, POPUP_MINIMIZE_MESSAGE, POPUP_FILTER_SYNC_MESSAGE / 화면명: 노후관로 목록 | 362 | 1 | 정적 참조 있음 | +| [agedPipeUtils.ts]() | 지도/GIS — 보조 함수/자료 구조 | AGED_PIPE_LAYER_TYPE, AGED_PIPE_DEFAULT_ELAPSED_YEARS, AGED_PIPE_PAGE_SIZE / 노후관로 검색 조건, 표시값 변환 및 지도 연계 유틸을 제공한다. | 325 | 5 | 정적 참조 있음 | + +## app/(map-layout)/map/components/agedPipeStatusByCtpv + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [agedPipeStatusByCtpvExport.ts]() | 지도/GIS — 보조 함수/자료 구조 | downloadAgedPipeStatusByCtpvExcel / 시도별 노후관로 현황의 연도 그룹형 엑셀 데이터를 생성한다. | 82 | 1 | 정적 참조 있음 | +| [AgedPipeStatusByCtpvPopupPage.tsx]() | 지도/GIS — 화면/UI 조합 | AGED_PIPE_STATUS_PAGE_SIZE, AgedPipeStatusByCtpvPopupPage / 화면명: 시도별 노후관로현황 | 316 | 1 | 정적 참조 있음 | +| [agedPipeStatusByCtpvUtils.ts]() | 지도/GIS — 보조 함수/자료 구조 | AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS, AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN, AGED_PIPE_STATUS_BY_CTPV_PAGE_SIZE / 시도별 노후관로 현황의 조회 연도와 그룹 컬럼을 구성한다. | 186 | 2 | 정적 참조 있음 | + +## app/(map-layout)/map/components/analysisMenu + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [AnalysisPopupMenu.tsx]() | 지도/GIS — 화면/UI 조합 | Props, MenuIconProps, InftwtMenuIcon / 화면명: 분석도구 메뉴 | 277 | 1 | 정적 참조 있음 | + +## app/(map-layout)/map/components/common + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [codeMapper.ts]() | 지도/GIS — 보조 함수/자료 구조 | CodeMapperProps, codeMapper, reverseCodeMapper / 지도 시설 상세정보의 코드값을 업무 표시명으로 변환한다. | 164 | 1 | 정적 참조 있음 | +| [index.ts]() | 지도/GIS — 보조 함수/자료 구조 | LayerComponentProps, LayerContentMap, layerContentMap / 선택한 지도 레이어 종류에 맞는 시설 상세 컴포넌트를 반환한다. | 73 | 2 | 정적 참조 있음 | +| [table.ts]() | 지도/GIS — 보조 함수/자료 구조 | MsSwagePplCol, MsSwagePplCol2, MsSwagePplExcelCol / 지도 시설 상세 및 목록에서 사용하는 컬럼·코드 정의를 제공한다. | 925 | 16 | 정적 참조 있음 | + +## app/(map-layout)/map/components/exmn + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [ExmnLayerSettingPanel.tsx]() | 조사관리 — 화면/UI 조합 | Props, GRADE_OPTIONS, ExmnLayerSettingPanel / 화면명: 관로조사 레이어 설정 | 102 | 1 | 정적 참조 있음 | +| [exmnMapApi.ts]() | 조사관리 — 보조 함수/자료 구조 | ExmnLayerScope, retainUpstreamManholeFeatures, getExmnMapLayer / 관로조사 지도 데이터 조회 API와 응답 변환 함수를 제공한다. | 52 | 1 | 정적 참조 있음 | +| [exmnMapTypes.ts]() | 조사관리 — 보조 함수/자료 구조 | EXMN_RESULT_ROOT_NODE_ID, EXMN_RESULT_PPL_NODE_ID, EXMN_RESULT_MNHL_NODE_ID / 관로조사 지도 화면에서 사용하는 타입·상수와 표시 조건을 정의한다. | 300 | 3 | 정적 참조 있음 | +| [ExmnSurveyResultSection.tsx]() | 조사관리 — 화면/UI 조합 | ExmnSurveyResultSectionProps, readRawFeatureProperty, readFeatureProperty / 화면명: 관로조사 결과 | 149 | 1 | 정적 참조 있음 | + +## app/(map-layout)/map/components/fldngHstry + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [fldngHstryClient.ts]() | 침수이력 — 보조 함수/자료 구조 | normalizeFldngHstryRainAnalysisPayload, loadFldngHstryDetail / 침수흔적 강우분석과 연결된 심선·위선 목록 조회를 담당한다. | 62 | 1 | 정적 참조 있음 | +| [FldngHstryDetailPanel.tsx]() | 침수이력 — 화면/UI 조합 | Props, DetailTab, renderRows / 화면명: 침수흔적도 상세정보 | 158 | 1 | 정적 참조 있음 | +| [FldngHstryFeatureClick.tsx]() | 침수이력 — 화면/UI 조합 | FldngHstryGisLayer, FldngHstryScrollButtonProps, FLDNG_HSTRY_SCROLL_CLICK_DISTANCE / 화면명: 침수흔적위선 연계정보 | 398 | 1 | 정적 참조 있음 | +| [FldngHstryLayerSettingPanel.tsx]() | 침수이력 — 화면/UI 조합 | Props, FldngHstryLayerSettingPanel / 화면명: 침수흔적도 레이어 설정 | 97 | 1 | 정적 참조 있음 | +| [FldngHstryPopupPage.tsx]() | 침수이력 — 화면/UI 조합 | unwrapPayload, resolveOptionText, resolveFldngHstryMapFeatureFid / 화면명: 침수발생이력 목록 | 474 | 1 | 정적 참조 있음 | +| [fldngHstryTypes.ts]() | 침수이력 — 보조 함수/자료 구조 | FLDNG_HSTRY_POPUP_KEY, FLDNG_HSTRY_ROW_SELECT_MESSAGE, FLDNG_HSTRY_LAYER_TYPE / 침수흔적 화면에서 사용하는 타입·상수와 화면 설정값을 정의한다. | 165 | 7 | 정적 참조 있음 | +| [fldngHstryUtils.ts]() | 침수이력 — 보조 함수/자료 구조 | normalizeFldngHstryWtlvDisplayId, normalizeFldngHstryWtlvApiFid, formatFldngHstryDateTime / 침수흔적 검색조건·표시값·geometry·레이어 판별 변환을 제공한다. | 441 | 5 | 정적 참조 있음 | + +## app/(map-layout)/map/components/floodDngrMap + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [FloodDngrMapPopupPage.tsx]() | 지도/GIS — 화면/UI 조합 | FLOOD_DNGR_MAP_REQUEST_TIMEOUT_MS, FLOOD_DNGR_MAP_LOADING_WATCHDOG_MS, getFloodDngrMapJson / 화면명: 도시침수지도 목록 | 401 | 1 | 정적 참조 있음 | +| [floodDngrMapTypes.ts]() | 지도/GIS — 보조 함수/자료 구조 | FLOOD_DNGR_MAP_POPUP_KEY, FLOOD_DNGR_MAP_LAYER_TYPE, FLOOD_DNGR_MAP_LAYER_NAME / 도시침수지도 화면의 검색 조건, 범례, 컬럼과 응답 변환을 정의한다. | 186 | 2 | 정적 참조 있음 | + +## app/(map-layout)/map/components/gisEdit + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [GisEditPipeSessionPopup.tsx]() | GIS 편집 — 화면/UI 조합 | Props, MngInstOption, EMPTY_SELECT_OPTION / 화면명: 하수관로 편집정보 | 473 | 1 | 정적 참조 있음 | +| [GisEditSessionPanel.tsx]() | GIS 편집 — 화면/UI 조합 | Props, buildChangeItemClassName, getChangeLabel / 화면명: 하수관로 편집 세션 | 211 | 2 | 정적 참조 있음 | +| [GisEditToolbar.tsx]() | GIS 편집 — 화면/UI 조합 | Props, TOOL_BUTTONS, GisEditToolbar / 화면명: 하수관로 편집 도구 | 167 | 1 | 정적 참조 있음 | +| [gisEditTypes.ts]() | GIS 편집 — 보조 함수/자료 구조 | GisEditTool, GisEditGeometryType, GisEditPreviewColors / 하수관로 GIS 편집에서 사용하는 도구·세션·변경 데이터 타입을 정의한다. | 113 | 7 | 정적 참조 있음 | +| [useGisEdit.ts]() | GIS 편집 — 보조 함수/자료 구조 | GisEditInteractionRequest, isActionableChange, isGeometryChange / 하수관로 GIS 편집 도구의 선택 상태와 변경 내역을 관리하는 훅이다. | 1239 | 1 | 정적 참조 있음 | +| [useGisEditCommonCodes.ts]() | GIS 편집 — 보조 함수/자료 구조 | GIS_EDIT_OPERATION_UP_CD, DEFAULT_GIS_EDIT_OPERATION_LABELS, cachedOperationLabels / 하수관로 GIS 편집에 필요한 공통코드를 조회하는 훅이다. | 77 | 2 | 정적 참조 있음 | + +## app/(map-layout)/map/components/gisManagement + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [GisManagementPopupPage.tsx]() | GIS 업로드 상태 — 화면/UI 조합 | UploadFacilityType, MngInstOption, UploadHistoryItem / 화면명: GIS 관리 | 1072 | 1 | 정적 참조 있음 | +| [GisUploadPreviewMap.tsx]() | GIS 업로드 상태 — 화면/UI 조합 | GisUploadPreviewMapProps, LayerSetting, MAP_SRS / 화면명: GIS 업로드 미리보기 지도 | 578 | 1 | 정적 참조 있음 | +| [useGisManagementCommonCodes.ts]() | GIS 업로드 상태 — 보조 함수/자료 구조 | CodeLabelMap, COMMON_CODE_GROUPS, DEFAULT_UPLOAD_STATUS_LABELS / GIS 관리 화면에 필요한 공통코드를 조회하고 선택 항목으로 제공한다. | 129 | 1 | 정적 참조 있음 | + +## app/(map-layout)/map/components/inftwtResult + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [InftwtResultPopupPage.tsx]() | 지도/GIS — 화면/UI 조합 | INFTWT_RESULT_PAGE_SIZE, INFTWT_RESULT_HEADLIST, InftwtResultFilters / 화면명: 침입수 분석결과 목록 | 413 | 1 | 정적 참조 있음 | +| [inftwtResultTypes.ts]() | 지도/GIS — 보조 함수/자료 구조 | INFTWT_RESULT_ROW_SELECT_MESSAGE, INFTWT_RESULT_OVERLAY_TYPE, INFTWT_RESULT_ROW_SELECT_STORAGE_KEY / 침입수 분석결과 화면의 타입·검색 조건·컬럼과 응답 변환을 정의한다. | 50 | 2 | 정적 참조 있음 | +| [useInftwtResultMap.ts]() | 지도/GIS — 보조 함수/자료 구조 | UseInftwtResultMapParams, INFTWT_ZONE_LAYER_ID, INFTWT_PIPE_LAYER_ID / 침입수 분석결과 선택 시 부모 지도에 결과 레이어를 표시하는 훅이다. | 512 | 1 | 정적 참조 있음 | + +## app/(map-layout)/map/components/layerSetting + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [LayerSettingCard.tsx]() | 지도/GIS — 화면/UI 조합 | Props, LayerSettingCard / 화면명: 선택 레이어 설정 카드 | 74 | 5 | 정적 참조 있음 | + +## app/(map-layout)/map/components/pipeAnalysis + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [PipeAnalysisDetailPopupPage.tsx]() | 지도/GIS — 화면/UI 조합 | DetailViewRow, PIPE_ANALYSIS_DETAIL_PAGE_SIZE, PIPE_ANALYSIS_MAP_WINDOW_NAME / 화면명: 관로현황 목록 | 490 | 1 | 정적 참조 있음 | +| [PipeAnalysisPopupPage.tsx]() | 지도/GIS — 화면/UI 조합 | SUMMARY_FILE_PREFIX, PIPE_ANALYSIS_SUMMARY_PAGE_SIZE, toExcelColumnName / 화면명: 관로분석 | 525 | 1 | 정적 참조 있음 | +| [PipeAnalysisSummaryTable.tsx]() | 지도/GIS — 화면/UI 조합 | SummaryRow, Props, FIXED_WIDTHS / 지역 식별 열은 가로 스크롤해도 남겨 두고, 모든 집계 방식에 같은 너비를 사용한다. | 250 | 1 | 정적 참조 있음 | +| [pipeAnalysisUtils.ts]() | 지도/GIS — 보조 함수/자료 구조 | PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE, PipeAnalysisType, PipeAnalysisSummaryFilters / 관로분석 검색 조건, 집계 컬럼과 표시값 변환 유틸을 제공한다. | 733 | 4 | 정적 참조 있음 | + +## app/(map-layout)/map/components/popupComponent + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [featureClickEventPopup.tsx]() | 지도/GIS — 화면/UI 조합 | linkedFacilityHighlightStyle, MngInstOption, FeatureClickEventPopup / 화면명: 시설 상세정보 | 1083 | 1 | 정적 참조 있음 | +| [MsSwagePpl.tsx]() | 지도/GIS — 화면/UI 조합 | inputColumn, operationDataType, MsSwagePpl / 화면명: 하수관로 정보 | 196 | 1 | 정적 참조 있음 | +| [sewagePump.tsx]() | 지도/GIS — 화면/UI 조합 | exclnInputColumn, drtwInputColumn, sewagePump / 화면명: 하수펌프장 정보 | 71 | 1 | 정적 참조 있음 | +| [smh.tsx]() | 지도/GIS — 화면/UI 조합 | searchUpperPpls, inputColumn, Smh / 화면명: 하수맨홀 정보 | 143 | 2 | 정적 참조 있음 | +| [Ssc.tsx]() | 지도/GIS — 화면/UI 조합 | inputColumn, toStandardHeadList, Ssc / 화면명: 우수토실 정보 | 367 | 2 | 정적 참조 있음 | +| [stormWaterPump.tsx]() | 지도/GIS — 화면/UI 조합 | inputColumn, StormWaterPump / 화면명: 빗물펌프 정보 | 39 | 1 | 정적 참조 있음 | +| [SwtrmtFclt.tsx]() | 지도/GIS — 화면/UI 조합 | headList, SwtrmtFclt / 화면명: 하수처리시설 정보 | 63 | 1 | 정적 참조 있음 | +| [SwtrmtPrcsZoneAnalysis.tsx]() | 지도/GIS — 화면/UI 조합 | analysisRows, repairHistoryStyle, selectedRepairHistoryStyle / 화면명: 처리구역내 관로분석정보 | 392 | 1 | 정적 참조 있음 | +| [swtrmtPrcsZoneAnalysisApi.ts]() | 지도/GIS — 보조 함수/자료 구조 | SwtrmtPrcsZoneAnalysisMetric, SwtrmtPrcsZoneAnalysisResponse, ZoneAnalysisSource / 처리구역 관로분석정보와 보수이력을 조회·다운로드하는 API를 제공한다. | 205 | 2 | 정적 참조 있음 | + +## app/(map-layout)/map/components/popupComponent/chart + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [aplcnYms.ts]() | 지도/GIS — 보조 함수/자료 구조 | TInftwtAnls, TInftwtAnlsRslt, TRdiiAnls / 침입수·RDII 분석의 적용연월 선택 상태와 월 목록을 제공한다. | 300 | 2 | 정적 참조 있음 | +| [infilChart.tsx]() | 지도/GIS — 화면/UI 조합 | ModalOpenProp, DataType, INITIAL_ANALYSIS_OPTIONS / 화면명: 침입수 분석 | 370 | 1 | 정적 참조 있음 | +| [rdiiChart.tsx]() | 지도/GIS — 화면/UI 조합 | ModalOpenProp, LineBarChartData, INITIAL_ANALYSIS_OPTIONS / 화면명: RDII 분석 | 349 | 1 | 정적 참조 있음 | + +## app/(map-layout)/map/components/rdiiResult + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [RdiiResultPopupPage.tsx]() | 지도/GIS — 화면/UI 조합 | RDII_RESULT_PAGE_SIZE, RDII_RESULT_HEADLIST, RdiiResultFilters / 화면명: RDII 분석결과 목록 | 415 | 1 | 정적 참조 있음 | +| [rdiiResultTypes.ts]() | 지도/GIS — 보조 함수/자료 구조 | RDII_RESULT_ROW_SELECT_MESSAGE, RDII_RESULT_OVERLAY_TYPE, RDII_RESULT_ROW_SELECT_STORAGE_KEY / RDII 분석결과 화면의 타입·검색 조건·컬럼과 응답 변환을 정의한다. | 49 | 2 | 정적 참조 있음 | +| [useRdiiResultMap.ts]() | 지도/GIS — 보조 함수/자료 구조 | UseRdiiResultMapParams, RDII_PIPE_LAYER_ID, DEFAULT_RESULT_FIT_PADDING / RDII 분석결과 선택 시 부모 지도에 결과 레이어를 표시하는 훅이다. | 370 | 1 | 정적 참조 있음 | + +## app/(map-layout)/map/components/repairHistory + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [RepairHistoryPopupPage.tsx]() | 지도/GIS — 화면/UI 조합 | REPAIR_HISTORY_PAGE_SIZE, MAP_LAYER_SETTING_PATH, PipeUseOption / 화면명: 보수이력 목록 | 489 | 1 | 정적 참조 있음 | + +## app/(map-layout)/map/components/sbmrgDngrMap + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [SbmrgDngrMapPopupPage.tsx]() | 지도/GIS — 화면/UI 조합 | unwrapPayload, SbmrgDngrMapPopupPage / 화면명: 침수위험지도 목록 | 315 | 1 | 정적 참조 있음 | +| [sbmrgDngrMapTypes.ts]() | 지도/GIS — 보조 함수/자료 구조 | SBMRG_DNGR_MAP_POPUP_KEY, SBMRG_DNGR_MAP_LAYER_TYPE, SBMRG_DNGR_MAP_LAYER_NAME / 침수위험지도 화면의 검색 조건, 컬럼과 응답 변환을 정의한다. | 163 | 2 | 정적 참조 있음 | + +## app/(map-layout)/map/components/sinkhole + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [SinkholeDetailPanel.tsx]() | 지반침하 — 화면/UI 조합 | Props, PipeUseCodeItem, pipeUseCodeCache / 화면명: 지반침하 상세정보 | 296 | 1 | 정적 참조 있음 | +| [SinkholeLayerSettingPanel.tsx]() | 지반침하 — 화면/UI 조합 | MaybePromise, Props, SinkholeLayerSettingPanel / 화면명: 지반침하정보 레이어 설정 | 274 | 1 | 정적 참조 있음 | +| [SinkholePopupPage.tsx]() | 지반침하 — 화면/UI 조합 | PAGE_SIZE, DOWNLOAD_BATCH_SIZE, SinkholeTableRow / 화면명: 지반침하 목록 | 520 | 1 | 정적 참조 있음 | +| [sinkholeTypes.ts]() | 지반침하 — 보조 함수/자료 구조 | SINKHOLE_CONTROL_NODE_ID, SINKHOLE_LAYER_TYPE, SINKHOLE_LAYER_NAME / 지반침하 화면에서 사용하는 타입·상수·검색 조건을 정의한다. | 366 | 5 | 정적 참조 있음 | +| [useSinkholeCommonCodes.ts]() | 지반침하 — 보조 함수/자료 구조 | CodeLabelMap, StatusOption, COMMON_CODE_GROUPS / 지반침하 화면에 필요한 공통코드를 조회하고 선택 항목으로 제공한다. | 140 | 2 | 정적 참조 있음 | + +## app/(map-layout)/map/components/waterRisk + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [waterRiskClient.ts]() | 통수위험 — 보조 함수/자료 구조 | WaterRiskResultQuery, WaterRiskApiResultRow, normalizeResultRow / 통수위험 분석 대상과 결과를 조회하는 API 함수를 제공한다. | 97 | 1 | 정적 참조 있음 | +| [WaterRiskPopupPage.tsx]() | 통수위험 — 화면/UI 조합 | WaterRiskResultMode, ResultFilters, WATER_RISK_PAGE_SIZE / 화면명: 통수위험 분석 | 713 | 1 | 정적 참조 있음 | +| [waterRiskTypes.ts]() | 통수위험 — 보조 함수/자료 구조 | WaterRiskStatus, WaterRiskCalculationMethod, WaterRiskPipeSummary / 통수위험 분석 화면에서 사용하는 타입·상수와 표시값 변환을 정의한다. | 105 | 3 | 정적 참조 있음 | + +## app/(map-layout)/map/searchModal + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [basicTab.tsx]() | 지도/GIS — 화면/UI 조합 | BASIC_TYPES, BasicTab / ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 | 75 | 2 | 정적 참조 있음 | +| [fldngWtlvMap.tsx]() | 지도/GIS — 화면/UI 조합 | fldngWtlvMapCqlFilters, FldngWtlvMap | 85 | 2 | 정적 참조 있음 | +| [operationTab.tsx]() | 지도/GIS — 화면/UI 조합 | OperationTab / ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 유지관리 정보 탭 | 123 | 1 | 정적 참조 있음 | +| [reportTab.tsx]() | 지도/GIS — 화면/UI 조합 | reportTableHeadlist, ReportTabProps, normalizeYear / ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 하수도정비기본계획보고서 탭 | 511 | 2 | 정적 참조 있음 | +| [searchModal.tsx]() | 지도/GIS — 화면/UI 조합 | SearchModalProps, SearchModal / ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 | 335 | 2 | 정적 참조 있음 | +| [stofcbr.tsx]() | 지도/GIS — 화면/UI 조합 | stofcbrCqlFilters, Stofcbr / ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 우수토실) | 91 | 2 | 정적 참조 있음 | +| [swageMhl.tsx]() | 지도/GIS — 화면/UI 조합 | swageMhlCqlFilters, SwageMhl / ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수맨홀) | 177 | 2 | 정적 참조 있음 | +| [swagePmstn.tsx]() | 지도/GIS — 화면/UI 조합 | swagePmstnCqlFilters, SwagePmstn / ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수펌프장) | 190 | 2 | 정적 참조 있음 | +| [swagePpl.tsx]() | 지도/GIS — 화면/UI 조합 | swagePplCqlFilters, SwagePpl / ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수관로) | 232 | 2 | 정적 참조 있음 | +| [swagePrcsFclt.tsx]() | 지도/GIS — 화면/UI 조합 | swagePrcsFcltCqlFilters, SwagePrcsFclt / ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수처리시설) | 168 | 2 | 정적 참조 있음 | +| [swagePrcsZone.tsx]() | 지도/GIS — 화면/UI 조합 | swagePrcsZoneCqlFilters, swagePrcsZone / ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수처리구역) | 99 | 2 | 정적 참조 있음 | +| [useBasicTab.ts]() | 지도/GIS — 보조 함수/자료 구조 | isPopupWindow, BASIC_EXCEL_BATCH_SIZE, useBasicTab / 검색 모달 기본정보 탭 훅 | 524 | 2 | 정적 참조 있음 | +| [useOperationTab.ts]() | 지도/GIS — 보조 함수/자료 구조 | isPopupWindow, useOperationTab / 검색 모달 유지관리 정보 탭 훅 | 600 | 3 | 정적 참조 있음 | +| [useReportTab.ts]() | 지도/GIS — 보조 함수/자료 구조 | Folder, FlattenedFolder, SelectDataType / 검색 모달 보고서 탭 훅 | 224 | 2 | 정적 참조 있음 | +| [useSearchModal.ts]() | 지도/GIS — 보조 함수/자료 구조 | isPopupWindow, useSearchModal / 검색 모달 훅 | 244 | 9 | 정적 참조 있음 | + +## app/(map-layout)/map/waterDepth + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [waterDepth.tsx]() | 지도/GIS — 화면/UI 조합 | WATER_DEPTH_LAYER_TYPES, WaterDepthLegendItem, getLayerTypeName / ➡️ 경로 : 하수관망도 메인페이지 내 침수심 범례 | 246 | 1 | 정적 참조 있음 | + +## app/(popupLayout) + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [layout.tsx]() | Next 자동 발견 진입 파일 | PopupLayout | 10 | 0 | 등록/도구 진입 규칙 대상 | + +## app/(popupLayout)/popup/[popupKey] + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [page.tsx]() | Next 자동 발견 진입 파일 | PopupPageProps, PopupPage | 34 | 0 | 등록/도구 진입 규칙 대상 | + +## app/(with-base-layout) + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [fileValidation.tsx]() | 화면/UI 조합 | validateFileExtension, FileValidationResult, FileValidationConfig / 파일 확장자 검증 유틸리티 | 56 | 2 | 정적 참조 있음 | +| [layout.tsx]() | Next 자동 발견 진입 파일 | WithBaseLayout / - 메뉴 클릭 이벤트 처리 및 라우팅 | 66 | 0 | 등록/도구 진입 규칙 대상 | +| [selectionValidation.tsx]() | 화면/UI 조합 | ApiSelectOption, SelectionValidationConfig, SelectionValidationResult / 선택 항목 검증 유틸리티 | 165 | 2 | 정적 참조 있음 | + +## app/(with-base-layout)/ii + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [analysisResultView.ts]() | 보조 함수/자료 구조 | AnalysisResultPopupKey, ANALYSIS_RESULT_MAP_WINDOW_NAME, AnalysisResultAlert | 41 | 2 | 정적 참조 있음 | +| [BaseLayout.tsx]() | 화면/UI 조합 | TitleName, SelectBox, Label / 분석 페이지 기본 레이아웃 컴포넌트 | 349 | 4 | 정적 참조 있음 | +| [useAnalysisCompletionWatcher.tsx]() | 화면/UI 조합 | AnalysisStatusResponse, AnalysisStatusEnvelope, unwrapAnalysisStatusResponse | 468 | 3 | 정적 참조 있음 | + +## app/(with-base-layout)/ii/fileupload + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [page.tsx]() | Next 자동 발견 진입 파일 | SEWERAGE_URL, FileUpload / 파일 업로드 테스트 페이지 컴포넌트 | 157 | 0 | 등록/도구 진입 규칙 대상 | + +## app/(with-base-layout)/ii/irsetting + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [page.tsx]() | Next 자동 발견 진입 파일 | formSchema, UploadedFileInfo, initialFileInfo / 침입수 분석 설정 페이지 컴포넌트 | 644 | 0 | 등록/도구 진입 규칙 대상 | + +## app/(with-base-layout)/ii/rdii + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [page.tsx]() | Next 자동 발견 진입 파일 | formSchema, FormSchemaType, defaultFormValues / RDII 분석 페이지 컴포넌트 | 674 | 0 | 등록/도구 진입 규칙 대상 | + +## app/(with-base-layout)/ii/rdiiResult + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [page.tsx]() | Next 자동 발견 진입 파일 | formSchema, ResultFormValues, tableColumn / RDII 분석 결과 페이지 컴포넌트 | 388 | 0 | 등록/도구 진입 규칙 대상 | + +## app/(with-base-layout)/ii/result + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [page.tsx]() | Next 자동 발견 진입 파일 | formSchema, ResultFormValues, tableColumn / 침입수 분석 결과 페이지 컴포넌트 | 387 | 0 | 등록/도구 진입 규칙 대상 | + +## app/(with-base-layout)/ii/test + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `page_bar.tsx` (삭제) | 화면/UI 조합 | TestPage | 143 | 0 | 삭제 완료(2026-09-11), 결과002 참조 | +| `page_pie.tsx` (삭제) | 화면/UI 조합 | Page | 146 | 0 | 삭제 완료(2026-09-11), 결과002 참조 | +| [page.tsx]() | Next 자동 발견 진입 파일 | Page | 105 | 0 | 등록/도구 진입 규칙 대상 | + +## app/(with-base-layout)/oper + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `index.ts` (삭제) | 운영사업 — 보조 함수/자료 구조 | tableHeadlist, tableHeadlistOldPp / 운영 관련 테이블 헤더 설정 파일 | 29 | 0 | 삭제 완료(2026-09-11), 결과002 참조 | +| `tableSort.ts` (삭제) | 운영사업 — 보조 함수/자료 구조 | TABLE_SORT_COLLATOR, isEmptySortValue, toComparableNumber / 업로드된 엑셀의 실제 데이터 컬럼만 공통 UiTable 정렬 대상으로 만든다. | 72 | 0 | 삭제 완료(2026-09-11), 결과002 참조 | + +## app/(with-base-layout)/oper/components + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [SearchDatePicker.tsx]() | 운영사업 — 화면/UI 조합 | CalendarProps, SearchDatePickerProps, formatDateLabel | 74 | 2 | 정적 참조 있음 | + +## app/(with-base-layout)/oper/ctyFldngPrvtnBiz + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [page.tsx]() | 도시침수예방사업 — Next 자동 발견 진입 파일 | Page / 도시침수예방사업 목록 경로다. | 5 | 0 | 등록/도구 진입 규칙 대상 | + +## app/(with-base-layout)/oper/ctyFldngPrvtnBiz/register + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [page.tsx]() | 도시침수예방사업 — Next 자동 발견 진입 파일 | Page / 도시침수예방사업 신규 등록 경로다. | 5 | 0 | 등록/도구 진입 규칙 대상 | + +## app/(with-base-layout)/oper/ctyFldngPrvtnBiz/register/[id] + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [page.tsx]() | 도시침수예방사업 — Next 자동 발견 진입 파일 | Page / 도시침수예방사업 수정·삭제 경로다. | 5 | 0 | 등록/도구 진입 규칙 대상 | + +## app/(with-base-layout)/oper/dredging + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [page.tsx]() | 준설관리 — Next 자동 발견 진입 파일 | PAGE_SIZE, ALL_REGION_OPTION, pipeMaterialNameByCode / ➡️ 경로 : 유지관리 정보 - 준설관리 | 917 | 0 | 등록/도구 진입 규칙 대상 | + +## app/(with-base-layout)/oper/dredging/components + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [DredgingDetailPopupPage.tsx]() | 준설관리 — 화면/UI 조합 | DredgingLocationMap, Props, DredgingPhotoPreview / 상세 UI를 공통 기반으로 사용해 조회와 수정 상태의 시각 구조를 동일하게 유지한다. | 920 | 1 | 정적 참조 있음 | +| [DredgingLocationMap.tsx]() | 준설관리 — 화면/UI 조합 | DredgingLocationMapProps, LayerSetting, GisCore / 공통 관망도와 같은 DB 레이어 설정으로 관로를 그리고, 이 화면이 소유한 마커와 | 400 | 2 | 정적 참조 있음 | +| [DredgingRegisterModal.tsx]() | 준설관리 — 화면/UI 조합 | DredgingLocationMap, DredgingRegisterModalProps, createGroup / 화면명: 준설실적 등록 | 1093 | 1 | 정적 참조 있음 | +| [DredgingRegisterPopupPage.tsx]() | 준설관리 — 화면/UI 조합 | DredgingRegisterPopupPage / 기존 준설 등록 흐름을 공통 WindowPopup 화면 규격으로 표시한다. | 25 | 1 | 정적 참조 있음 | + +## app/(with-base-layout)/oper/dredging/lib + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [client.ts]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | / 기존 준설 화면의 API import를 유지한다. 구현과 첨부 업로드 책임은 feature로 분리했다. | 5 | 3 | 정적 참조 있음 | +| [date.ts]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | | 3 | 2 | 정적 참조 있음 | +| [exif.ts]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | | 3 | 2 | 정적 참조 있음 | +| [registration-request.ts]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | | 3 | 1 | 정적 참조 있음 | +| [types.ts]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | | 3 | 4 | 정적 참조 있음 | +| [useDredgingCommonCodes.ts]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | | 3 | 2 | 정적 참조 있음 | +| [uuid.ts]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | | 3 | 3 | 정적 참조 있음 | + +## app/(with-base-layout)/oper/exmn/register + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [page.tsx]() | 조사관리 — Next 자동 발견 진입 파일 | Page / SFR-010-004 사업정보·조사결과 등록 화면 라우트다. | 7 | 0 | 등록/도구 진입 규칙 대상 | + +## app/(with-base-layout)/oper/exmn/status + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [page.tsx]() | 조사관리 — Next 자동 발견 진입 파일 | Page / SFR-010-005 조사현황과 처리분구별 조사 상세현황 라우트다. | 7 | 0 | 등록/도구 진입 규칙 대상 | + +## app/(with-base-layout)/oper/floodPre + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `headMakeFloodPre.tsx` (삭제) | 운영사업 — 화면/UI 조합 | TableUpperProps, chHeadType, HeadMakeFloodPre / 침수예방 테이블 헤더 생성 컴포넌트 | 86 | 1 | 삭제 완료(2026-09-11), 결과002 참조 | +| `inputTableBody.tsx` (삭제) | 운영사업 — 화면/UI 조합 | formatCommaNumber, handleCommaNumberChange, handleChange / 침수예방 입력 테이블 바디 컴포넌트 | 214 | 0 | 삭제 완료(2026-09-11), 결과002 참조 | +| [page.tsx]() | 운영사업 — Next 자동 발견 진입 파일 | PAGE_SIZE, FloodPreRow, FloodPreSearch / ➡️ 경로 : 유지관리 정보 - 도시침수예방사업 | 359 | 0 | 등록/도구 진입 규칙 대상 | +| [tableModal.tsx]() | 운영사업 — 화면/UI 조합 | TABS, PAGE_SIZE, ExcelTableHeader / 침수예방 엑셀 데이터 테이블 팝업 | 293 | 1 | 정적 참조 있음 | + +## app/(with-base-layout)/oper/obsltPplBiz + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [page.tsx]() | 운영사업 — Next 자동 발견 진입 파일 | Page / 노후하수관로 정밀조사·정비사업 목록 경로다. | 7 | 0 | 등록/도구 진입 규칙 대상 | + +## app/(with-base-layout)/oper/obsltPplBiz/register + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [page.tsx]() | 운영사업 — Next 자동 발견 진입 파일 | Page / 노후하수관로 사업 신규 등록 경로다. | 7 | 0 | 등록/도구 진입 규칙 대상 | + +## app/(with-base-layout)/oper/obsltPplBiz/register/[id] + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [page.tsx]() | 운영사업 — Next 자동 발견 진입 파일 | Page / 노후하수관로 사업 수정·삭제 경로다. | 8 | 0 | 등록/도구 진입 규칙 대상 | + +## app/(with-base-layout)/oper/oldPp + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [headList.ts]() | 운영사업 — 보조 함수/자료 구조 | oldPpHeadListType, CommonTableProps, oldPpTable2HeadList / 오래된 하수관로 테이블 헤더 및 노드 설정 파일 | 1722 | 1 | 정적 참조 있음 | +| [page.tsx]() | 운영사업 — Next 자동 발견 진입 파일 | PAGE_SIZE, OldPpRow, OldPpSearch / 유지관리 정보 - 노후 하수관로 정비사업 파일 목록 | 331 | 0 | 등록/도구 진입 규칙 대상 | +| [tableModal.tsx]() | 운영사업 — 화면/UI 조합 | TABS, PAGE_SIZE, ExcelHeader / 노후 하수관로 정비사업 첨부 엑셀 상세 팝업 테이블 | 366 | 1 | 정적 참조 있음 | + +## app/(with-base-layout)/oper/operStore + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [page.tsx]() | 운영사업 — Next 자동 발견 진입 파일 | DEFAULT_OPTION, Page | 119 | 0 | 등록/도구 진입 규칙 대상 | + +## app/(with-base-layout)/oper/trtdst + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [page.tsx]() | 운영사업 — Next 자동 발견 진입 파일 | Page / 처리분구관리 업무 화면 라우트다. | 7 | 0 | 등록/도구 진입 규칙 대상 | + +## app/api/auth/logout + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [route.ts]() | 인증 — Next 자동 발견 진입 파일 | POST | 36 | 0 | 등록/도구 진입 규칙 대상 | + +## app/api/gisUpload + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [route.ts]() | Next 자동 발견 진입 파일 | AUTH_USER_ID, CLAIM_NAME, GIS_UPLOAD_PATH / multipart 본문을 한 번 버퍼링해 백엔드에 정확한 Content-Length로 전달한다. | 100 | 0 | 등록/도구 진입 규칙 대상 | + +## app/api/proxy/[...url] + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [route.ts]() | Next 자동 발견 진입 파일 | handler, POST, PUT / API 프록시 라우트 | 133 | 0 | 등록/도구 진입 규칙 대상 | + +## app/components/lib/api + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.ts]() | 보조 함수/자료 구조 | BASE_URL, DEFAULT_HEADERS, LOGIN_PAGE_URL / API 호출 유틸리티 파일 | 255 | 72 | 정적 참조 있음 | + +## app/components/lib/api/attachment + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [attachment-interface.tsx]() | HTTP 호출·응답 어댑터 | SortConfig, PageableConfig, PageableList / 첨부 파일 관련 인터페이스 정의 파일 | 86 | 1 | 정적 참조 있음 | +| [attachment-service.tsx]() | HTTP 호출·응답 어댑터 | ENDPOINT, AttachmentApiService, AttachmentApi / 첨부 파일 서비스 클래스 | 59 | 2 | 정적 참조 있음 | + +## app/components/lib/common + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [apiHelpers.ts]() | 보조 함수/자료 구조 | CommonCodeItem, getCommonCodes, searchData / 공통 코드 cs-service API 호출 유틸리티 | 79 | 8 | 정적 참조 있음 | + +## app/components/lib/utils + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [date.ts]() | 보조 함수/자료 구조 | formatDate, yearOptions, getQuarterOptions / 날짜 관련 유틸리티 파일 | 123 | 2 | 정적 참조 있음 | +| [excelToJson.ts]() | 보조 함수/자료 구조 | TEST_UUID, parseAndFormatDateTimeToISOCompatibleString, MatchingResultZod / 엑셀 데이터 JSON 변환 유틸리티 | 212 | 1 | 정적 참조 있음 | +| [fileDeleteUtil.tsx]() | 화면/UI 조합 | AUTH_USER_ID, CLAIM_NAME, DeleteOptions / 파일 삭제 유틸리티 | 124 | 1 | 정적 참조 있음 | +| [fileDownload.ts]() | 보조 함수/자료 구조 | handleDownload, fileDownload / 파일 다운로드 유틸리티 | 43 | 3 | 정적 참조 있음 | +| [fileUploadUtil.tsx]() | 화면/UI 조합 | AUTH_USER_ID, CLAIM_NAME, UploadOptions / 파일 업로드 유틸리티 | 70 | 1 | 정적 참조 있음 | +| [formatDisplayValue.tsx]() | 화면/UI 조합 | formatDisplayData, formatDisplayValue / 데이터 표시 포맷팅 유틸리티 | 114 | 17 | 정적 참조 있음 | +| [index.tsx]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | | 8 | 13 | 정적 참조 있음 | + +## app/components/ui + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `contentLayout.tsx` (삭제) | 화면/UI 조합 | LayoutNode, ContentLayout / 콘텐츠 레이아웃 컴포넌트 | 33 | 0 | 삭제 완료(2026-09-11), 결과002 참조 | +| [ctpvSggPrcsFcltSelectBox.tsx]() | 화면/UI 조합 | ApiSelectOption, CtpvSggPrcsFcltSelectBoxProps, defaultOption / - 시도 선택 및 시군구 필터링 기능 | 227 | 18 | 정적 참조 있음 | +| [EditableFormTable.tsx]() | 화면/UI 조합 | EditableTableHeadItem, EditableFormTableProps, EditableFormTable / 편집 가능한 폼 테이블 컴포넌트 | 142 | 1 | 정적 참조 있음 | +| [fileUploadForm.tsx]() | 화면/UI 조합 | FileUploadFormProps, FileUploadForm / 파일 업로드 폼 컴포넌트 | 242 | 3 | 정적 참조 있음 | +| `formSection.tsx` (삭제) | 화면/UI 조합 | FormSectionProps, FormSection / 폼 섹션 컴포넌트 | 36 | 0 | 삭제 완료(2026-09-11), 결과002 참조 | +| [operFileUploadForm.tsx]() | 화면/UI 조합 | operFileUploadForm / 운영 파일 업로드 폼 컴포넌트 | 103 | 2 | 정적 참조 있음 | +| [toast.tsx]() | 화면/UI 조합 | confirmToast, showConfirmToast / 토스트 알림 컴포넌트 | 70 | 2 | 정적 참조 있음 | +| [verticalTable.tsx]() | 화면/UI 조합 | UiTableProps, UiSelectOption, UiHeadProps / 세로 방향 테이블 컴포넌트 | 449 | 17 | 정적 참조 있음 | +| [YearRangeSelectBox.tsx]() | 화면/UI 조합 | YearRangeSelectBoxProps, getStartYearArray, getEndYearArray / 년도 범위 선택 컴포넌트 | 125 | 16 | 정적 참조 있음 | + +## app/components/ui/LoadingBar + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 화면/UI 조합 | LoadingOverlayProps, LoadingOverlay / 로딩 오버레이 컴포넌트 | 32 | 38 | 정적 참조 있음 | + +## app/html + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `06. 노후관로관리_시나리오_v1.0.md` (삭제) | 사용법·설계 참고 문서 | | — | 0 | 삭제 완료(2026-09-11), 결과002 참조 | +| `AGENTS.md` (삭제) | 해당 경로의 작업 범위·안전·문서 지침 | | — | 0 | 삭제 완료(2026-09-11), 결과002 참조 | + +## app/html/(map-layout) + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `layout.tsx` (삭제) | Next 자동 발견 진입 파일 | MapLayout / ➡️ 경로 : 하수관망도 모든 페이지 | 59 | 0 | 삭제 완료(2026-09-11), 결과002 참조 | + +## app/html/(map-layout)/aged-pipe + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `AgedPipeDetailPanel.tsx` (삭제) | 화면/UI 조합 | Props, AgedPipeDetailPanel | 66 | 1 | 삭제 완료(2026-09-11), 결과002 참조 | +| `AgedPipeFilterPanel.tsx` (삭제) | 화면/UI 조합 | OverlayProps, Props, AgedPipeFilterPanel | 74 | 1 | 삭제 완료(2026-09-11), 결과002 참조 | +| `AgedPipeListModal.tsx` (삭제) | 화면/UI 조합 | Props, AgedPipeListModal | 178 | 1 | 삭제 완료(2026-09-11), 결과002 참조 | +| `agedPipeUtils.ts` (삭제) | 보조 함수/자료 구조 | AgedPipeFilters, AgedPipeRow, PipeSource | 222 | 4 | 삭제 완료(2026-09-11), 결과002 참조 | +| `page.tsx` (삭제) | Next 자동 발견 진입 파일 | agedPipeHighlightStyle, PIPE_LAYER_TYPE, EmptySearchView | 408 | 0 | 삭제 완료(2026-09-11), 결과002 참조 | + +## app/html/(map-layout)/map + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `page.tsx` (삭제) | 지도/GIS — Next 자동 발견 진입 파일 | Map / ➡️ 경로 : 하수관망도 메인 페이지 | 170 | 0 | 삭제 완료(2026-09-11), 결과002 참조 | + +## app/html/(map-layout)/map/components + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `InputSection.tsx` (삭제) | 지도/GIS — 화면/UI 조합 | InputSectionProps, InputSection | 58 | 1 | 삭제 완료(2026-09-11), 결과002 참조 | + +## app/html/(map-layout)/map/components/common + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `codeMapper.ts` (삭제) | 지도/GIS — 보조 함수/자료 구조 | CodeMapperProps, codeMapper, reverseCodeMapper / 레이어별 코드 변환 함수 | 162 | 1 | 삭제 완료(2026-09-11), 결과002 참조 | +| `index.ts` (삭제) | 지도/GIS — 보조 함수/자료 구조 | LayerComponentProps, LayerContentMap, layerContentMap | 69 | 2 | 삭제 완료(2026-09-11), 결과002 참조 | +| `table.ts` (삭제) | 지도/GIS — 보조 함수/자료 구조 | MsSwagePplCol, MsSwagePplCol2, MsSwagePplExcelCol | 849 | 9 | 삭제 완료(2026-09-11), 결과002 참조 | + +## app/html/(map-layout)/map/components/popupComponent + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `chartModalStore.ts` (삭제) | 지도/GIS — 보조 함수/자료 구조 | ModalStore, useInfilChartModalStore, useRdiiChartModalStore | 16 | 3 | 삭제 완료(2026-09-11), 결과002 참조 | +| `featureClickEventPopup.tsx` (삭제) | 지도/GIS — 화면/UI 조합 | linkedFacilityHighlightStyle, FeatureClickEventPopup / ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 | 630 | 1 | 삭제 완료(2026-09-11), 결과002 참조 | +| `MsSwagePpl.tsx` (삭제) | 지도/GIS — 화면/UI 조합 | inputColumn, operationDataType, MsSwagePpl / ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수관로) | 163 | 1 | 삭제 완료(2026-09-11), 결과002 참조 | +| `sewagePump.tsx` (삭제) | 지도/GIS — 화면/UI 조합 | exclnInputColumn, drtwInputColumn, sewagePump / ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수펌프장) | 70 | 1 | 삭제 완료(2026-09-11), 결과002 참조 | +| `smh.tsx` (삭제) | 지도/GIS — 화면/UI 조합 | searchUpperPpls, inputColumn, Smh / ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수맨홀) | 127 | 2 | 삭제 완료(2026-09-11), 결과002 참조 | +| `Ssc.tsx` (삭제) | 지도/GIS — 화면/UI 조합 | inputColumn, Ssc / ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (우수토실) | 329 | 1 | 삭제 완료(2026-09-11), 결과002 참조 | +| `stormWaterPump.tsx` (삭제) | 지도/GIS — 화면/UI 조합 | inputColumn, StormWaterPump / ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (빗물펌프) | 37 | 1 | 삭제 완료(2026-09-11), 결과002 참조 | +| `SwtrmtFclt.tsx` (삭제) | 지도/GIS — 화면/UI 조합 | headList, SwtrmtFclt / ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수처리시설) | 73 | 1 | 삭제 완료(2026-09-11), 결과002 참조 | + +## app/html/(map-layout)/map/components/popupComponent/chart + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `aplcnYms.ts` (삭제) | 지도/GIS — 보조 함수/자료 구조 | TInftwtAnls, TInftwtAnlsRslt, TRdiiAnls | 271 | 2 | 삭제 완료(2026-09-11), 결과002 참조 | +| `infilChart.tsx` (삭제) | 지도/GIS — 화면/UI 조합 | ModalOpenProp, DataType, INITIAL_ANALYSIS_OPTIONS | 298 | 1 | 삭제 완료(2026-09-11), 결과002 참조 | +| `rdiiChart.tsx` (삭제) | 지도/GIS — 화면/UI 조합 | ModalOpenProp, LineBarChartData, INITIAL_ANALYSIS_OPTIONS | 358 | 1 | 삭제 완료(2026-09-11), 결과002 참조 | + +## app/html/(map-layout)/map/searchModal + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `basicTab.tsx` (삭제) | 지도/GIS — 화면/UI 조합 | BASIC_TYPES, BasicTab / ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 | 67 | 2 | 삭제 완료(2026-09-11), 결과002 참조 | +| `operationTab.tsx` (삭제) | 지도/GIS — 화면/UI 조합 | OperationTab / ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 유지관리 정보 탭 | 98 | 1 | 삭제 완료(2026-09-11), 결과002 참조 | +| `reportFilesModal.tsx` (삭제) | 지도/GIS — 화면/UI 조합 | ModalOpenProp, ReportFilesModal / ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 하수도정비기본계획보고서 탭 (셀 클릭) | 108 | 1 | 삭제 완료(2026-09-11), 결과002 참조 | +| `reportTab.tsx` (삭제) | 지도/GIS — 화면/UI 조합 | reportTableHeadlist, ReportTabProps, ReportTab / ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 하수도정비기본계획보고서 탭 | 272 | 2 | 삭제 완료(2026-09-11), 결과002 참조 | +| `searchModal.tsx` (삭제) | 지도/GIS — 화면/UI 조합 | SearchModal / ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 | 239 | 1 | 삭제 완료(2026-09-11), 결과002 참조 | +| `stofcbr.tsx` (삭제) | 지도/GIS — 화면/UI 조합 | stofcbrCqlFilters, Stofcbr / ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 우수토실) | 93 | 2 | 삭제 완료(2026-09-11), 결과002 참조 | +| `swageMhl.tsx` (삭제) | 지도/GIS — 화면/UI 조합 | swageMhlCqlFilters, SwageMhl / ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수맨홀) | 157 | 2 | 삭제 완료(2026-09-11), 결과002 참조 | +| `swagePmstn.tsx` (삭제) | 지도/GIS — 화면/UI 조합 | swagePmstnCqlFilters, SwagePmstn / ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수펌프장) | 176 | 2 | 삭제 완료(2026-09-11), 결과002 참조 | +| `swagePpl.tsx` (삭제) | 지도/GIS — 화면/UI 조합 | swagePplCqlFilters, SwagePpl / ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수관로) | 210 | 2 | 삭제 완료(2026-09-11), 결과002 참조 | +| `swagePrcsFclt.tsx` (삭제) | 지도/GIS — 화면/UI 조합 | swagePrcsFcltCqlFilters, SwagePrcsFclt / ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수처리시설) | 151 | 2 | 삭제 완료(2026-09-11), 결과002 참조 | +| `swagePrcsZone.tsx` (삭제) | 지도/GIS — 화면/UI 조합 | swagePrcsZoneCqlFilters, swagePrcsZone / ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수처리구역) | 94 | 2 | 삭제 완료(2026-09-11), 결과002 참조 | +| `useBasicTab.ts` (삭제) | 지도/GIS — 보조 함수/자료 구조 | useBasicTab / 검색 모달 기본정보 탭 훅 | 349 | 2 | 삭제 완료(2026-09-11), 결과002 참조 | +| `useOperationTab.ts` (삭제) | 지도/GIS — 보조 함수/자료 구조 | useOperationTab / 검색 모달 유지관리 정보 탭 훅 | 452 | 3 | 삭제 완료(2026-09-11), 결과002 참조 | +| `useReportTab.ts` (삭제) | 지도/GIS — 보조 함수/자료 구조 | Folder, FlattenedFolder, SelectDataType / 검색 모달 보고서 탭 훅 | 189 | 2 | 삭제 완료(2026-09-11), 결과002 참조 | +| `useSearchModal.ts` (삭제) | 지도/GIS — 보조 함수/자료 구조 | useSearchModal / 검색 모달 훅 | 162 | 8 | 삭제 완료(2026-09-11), 결과002 참조 | + +## app/html/(map-layout)/map/waterDepth + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `waterDepth.tsx` (삭제) | 지도/GIS — 화면/UI 조합 | WATER_DEPTH_LAYER_TYPES, WaterDepthProps, WaterDepth / ➡️ 경로 : 하수관망도 메인페이지 내 침수심 범례 | 98 | 1 | 삭제 완료(2026-09-11), 결과002 참조 | + +## app/html/(with-base-layout) + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `layout.tsx` (삭제) | Next 자동 발견 진입 파일 | WithBaseLayout / - 메뉴 클릭 이벤트 처리 및 라우팅 | 60 | 0 | 삭제 완료(2026-09-11), 결과002 참조 | + +## app/html/old-pipe-component-sample + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `index.html` (삭제) | 설정/배포 리소스(파일 용도 추가 확인) | | — | 0 | 삭제 완료(2026-09-11), 결과002 참조 | + +## app/store + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.ts]() | 보조 함수/자료 구조 | BIZ_CD_TYPE, fetchCtpv, fetchSgg / Zustand 스토어 인덱스 파일 | 115 | 1 | 정적 참조 있음 | +| [useOnsemiroAddrStore.ts]() | 상태/훅/타입·검증 모델 | OnsemiroAddrStoreState, CITY_SORT_ORDER, toResponseList / 시도 SelectBox에 적용할 행정구역 표준 표시 순서다. | 165 | 2 | 정적 참조 있음 | +| [useReportTabStore.ts]() | 상태/훅/타입·검증 모델 | useReportTabStoreType, useReportTabStore / 보고서 탭 Zustand 스토어 | 67 | 5 | 정적 참조 있음 | +| [useSearchModalStore.ts]() | 상태/훅/타입·검증 모델 | SEARCH_MODAL_DEFAULT_BASIC_TYPE, SEARCH_MODAL_DEFAULT_WORK_TYPE, AppliedBasicQuery / 검색 모달 Zustand 스토어 | 232 | 32 | 정적 참조 있음 | +| [useStore.ts]() | 상태/훅/타입·검증 모델 | BIZ_CD_TYPE, SnStoreState, useStore / SN 앱 Zustand 스토어 | 46 | 42 | 정적 참조 있음 | + +## public + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [favicon.ico]() | 정적 이미지·아이콘·폰트 자산 | | — | 0 | 용도 기준 보존 | +| [help.png]() | 정적 이미지·아이콘·폰트 자산 | | — | 0 | 용도 기준 보존 | + +## public/images + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [bg_login.jpg]() | 정적 이미지·아이콘·폰트 자산 | | — | 0 | 용도 기준 보존 | +| [help.png]() | 정적 이미지·아이콘·폰트 자산 | | — | 0 | 용도 기준 보존 | + +## public/images/map + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [help.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | + +## public/images/map/layericon + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [BML_CBAS_PS copy.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [BML_CBAS_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [BML_ELEV_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [BML_ETCP_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [BML_STMS_PS copy.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [BML_STMS_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [BML_SYMB_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [default.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [Layer_Select_BTN.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [RDL_BYST_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [RDL_CROS_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [RDL_ETCT_PS copy.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [RDL_ETCT_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [RDL_OCUP_PS copy.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [RDL_OCUP_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [RDL_RDSN_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [RDL_SCLT_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [RDL_SEPC_PS copy.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [RDL_SEPC_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [RDL_STAT_PS copy.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [RDL_STAT_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [RDL_STLT_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [RDL_TFSN_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [RDL_TRCT_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [RDL_TREE_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [RDL_TRSN_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [RDL_TSCT_PS copy.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [RDL_TSCT_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [RDL_TSCT_PS500.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [reset.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SN_EXMN_MANHOLE_PURPLE.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SN_RAIN_OBSVTR_PS_V3.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SN_SINKHOLE_SPOT_BLUE.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SN_SINKHOLE_SPOT_BLUE.svg]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SN_SINKHOLE_SPOT_ORANGE.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SN_SINKHOLE_SPOT_ORANGE.svg]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SWL_CLAY_PS_RED.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SWL_CLAY_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SWL_DEPT_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SWL_MANH_PS_BLACK.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SWL_MANH_PS_BLUE.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SWL_MANH_PS_GRAY.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SWL_MANH_PS_GREEN.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SWL_MANH_PS_ORANGE.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SWL_MANH_PS_RED.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SWL_MANH_PS_YELLOW.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SWL_MANH_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SWL_SPEW_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SWL_SPOT_PS_BLACK.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SWL_SPOT_PS_BLUE.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SWL_SPOT_PS_GREEN.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SWL_SPOT_PS_GREY.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SWL_SPOT_PS_RED.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SWL_SPOT_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [SWL_VENT_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [TL_SPBD_ENTRC.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [TL_SPGF_LOC.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [TL_SPOT_NOIN.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [TL_SPRD_BANO.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [TL_SPRD_CRSRD.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [TL_SPRD_NONM.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [TL_SPRD_RENM.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [TL_SPRD_RWNM.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [UFL_BMAN_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [UFL_BOPC_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [UFL_BPIP_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [UFL_BPOL_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [UFL_BTRS_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [UFL_GMNT_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [UFL_GPIP_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [UFL_GPRS_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [UFL_GVAL_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [UFL_KMAN_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [UFL_KPOL_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [UFL_VALV_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [WTL_FIRE_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [WTL_FLOW_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [WTL_MANH_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [WTL_META_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [WTL_PIPE_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [WTL_RSRV_PS_BLACK.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [WTL_RSRV_PS_GRAY.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [WTL_RSRV_PS_GREEN.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [WTL_RSRV_PS_ORANGE.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [WTL_RSRV_PS_PURPLE.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [WTL_RSRV_PS_RED.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [WTL_RSRV_PS_YELLOW.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [WTL_RSRV_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [WTL_VALV_PS.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | + +## public/images/map/toolbar + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [arrow_down.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [arrow_right.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [backward_icon.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_area_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_area.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_next_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_next.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_prev_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_prev.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_print_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_print.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_ruler_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_ruler.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_save_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_save.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_sketch_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_sketch.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_zoomin_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_zoomin.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_zoomout_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_zoomout.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_add_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_add.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_del_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_del.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_next_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_next.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_prev_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_prev.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_save_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_save.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_slt_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_slt.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_snap_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_snap.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [close_icon.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [ico_gis_sprite.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [icon-viewclose.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [img_base_map.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [img_network_map.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [img_road_map.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [img_settle_map.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [loading.gif]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [map_pin.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [pause_button.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [play_button_arrowhead.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [toggle_off.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [toggle_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | + +## public/samples + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [ir_setting_sample.zip]() | 사용자 입력 양식/샘플 배포 자산 | | — | 0 | 용도 기준 보존 | +| [manhole-consumer-matching-template.xlsx]() | 사용자 입력 양식/샘플 배포 자산 | | — | 0 | 용도 기준 보존 | +| [rdii_sample.zip]() | 사용자 입력 양식/샘플 배포 자산 | | — | 0 | 용도 기준 보존 | + +## scripts + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [audit-screen-inventory.mjs]() | 개발/정식 검사 스크립트 | APP_DIR, POPUP_DEFINITIONS, POPUP_REGISTRY | 103 | 0 | 정적 경로 미발견·검토 | +| [check-module-boundaries.cjs]() | 개발/정식 검사 스크립트 | fs, path, ts / 정적·동적 import, export-from, require의 문자열 모듈 참조를 수집한다. | 165 | 0 (+시험1) | 정적 경로 미발견·검토 | +| [run-refactor-tests.cjs]() | 개발/정식 검사 스크립트 | fs, path, root | 19 | 0 | 정적 경로 미발견·검토 | + +## src/app + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `index.tsx` (삭제) | 예전 App HTML/Provider 래퍼. 현재 정적 진입 경로 미확인 | App | 19 | 0 | 삭제 완료(2026-09-11), 결과002 참조 | + +## src/app/providers + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [app-provider.tsx]() | 화면/UI 조합 | LazyReactQueryDevtools, AppProvider | 61 | 1 | 정적 참조 있음 | +| [index.ts]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | | 2 | 2 | 정적 참조 있음 | + +## src/app/styles + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [base.css]() | 스타일/레이아웃 리소스 | | — | 3 | 용도 기준 보존 | +| [components.css]() | 스타일/레이아웃 리소스 | | — | 2 | 용도 기준 보존 | + +## src/auth/login + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.ts]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | | 2 | 1 | 정적 참조 있음 | + +## src/auth/login/ui + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [login-page.tsx]() | 인증 — 화면/UI 조합 | LoginPage | 157 | 1 | 정적 참조 있음 | +| [login.css]() | 스타일/레이아웃 리소스 | | — | 1 | 용도 기준 보존 | + +## src/entities/auth + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.ts]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | | 4 | 5 | 정적 참조 있음 | + +## src/entities/auth/api + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [auth-service.ts]() | 인증 — HTTP 호출·응답 어댑터 | login, silentRefresh, logout | 116 | 2 | 정적 참조 있음 | + +## src/entities/auth/lib + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [auth-wrapper.tsx]() | 인증 — 화면/UI 조합 | AuthWrapper | 89 | 1 | 정적 참조 있음 | +| [device-id.ts]() | 인증 — 보조 함수/자료 구조 | DEVICE_ID_KEY, getOrCreateDeviceId, clearDeviceId | 21 | 1 | 정적 참조 있음 | +| [token-manager.ts]() | 인증 — 보조 함수/자료 구조 | tokenManager | 16 | 8 | 정적 참조 있음 | + +## src/entities/auth/model + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `login-interface.ts` (삭제) | 인증 — 상태/훅/타입·검증 모델 | ILogin, LoginService | 10 | 0 | 삭제 완료(2026-09-11), 결과002 참조 | + +## src/entities/menu + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.ts]() | 메뉴 — 모듈 공개 API/재수출 | | 4 | 8 | 정적 참조 있음 | + +## src/entities/menu-authority + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.ts]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | | 10 | 1 | 정적 참조 있음 | + +## src/entities/menu-authority/@x + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [menu.ts]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | | 2 | 1 | 정적 참조 있음 | +| [posting.ts]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | | 2 | 0 | 정적 경로 미발견·검토 | + +## src/entities/menu-authority/api + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [menu-authority-service.ts]() | HTTP 호출·응답 어댑터 | MenuAuthorityApiService, MenuAuthorityApi | 41 | 3 | 정적 참조 있음 | + +## src/entities/menu-authority/model + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [menu-authority-interface.ts]() | 상태/훅/타입·검증 모델 | MenuAuthorityParams, PageMenuAuthority, menuAuthoritySchema | 49 | 6 | 정적 참조 있음 | +| [menu-authority-table-columns.tsx]() | 상태/훅/타입·검증 모델 | createColumns | 118 | 1 | 정적 참조 있음 | + +## src/entities/menu-authority/ui + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [authority-button.tsx]() | 화면/UI 조합 | AuthorityButtonProps, AuthorityButton | 27 | 2 | 정적 참조 있음 | +| [management-authority-dialog.tsx]() | 화면/UI 조합 | ManagementAuthorityDialogProps, ManagementAuthorityDialog | 104 | 1 | 정적 참조 있음 | + +## src/entities/menu/api + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [menu-service.ts]() | 메뉴 — HTTP 호출·응답 어댑터 | MenuApiService, MenuApi | 14 | 2 | 정적 참조 있음 | + +## src/entities/menu/lib + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [menu-wrapper.tsx]() | 메뉴 — 화면/UI 조합 | MenuWrapper | 60 | 1 | 정적 참조 있음 | +| [utils.ts]() | 메뉴 — 보조 함수/자료 구조 | getCurrentMenu | 21 | 1 | 정적 참조 있음 | + +## src/entities/menu/model + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [menu-interface.ts]() | 메뉴 — 상태/훅/타입·검증 모델 | MenuItem, MenuService | 24 | 3 | 정적 참조 있음 | + +## src/entities/user + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `index.ts` (삭제) | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | | 18 | 1 | 삭제 완료(2026-09-11), 결과002 참조 | + +## src/entities/user/api + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `user-service.ts` (삭제) | 사용자 — HTTP 호출·응답 어댑터 | UserApiService, UserApi | 226 | 1 | 삭제 완료(2026-09-11), 결과002 참조 | + +## src/entities/user/model + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `user-interface.ts` (삭제) | 사용자 — 상태/훅/타입·검증 모델 | USER_STATE_CODE, UserStateCode, UserSearchParams | 363 | 2 | 삭제 완료(2026-09-11), 결과002 참조 | + +## src/entities/user/ui + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `password-reset-dialog.tsx` (삭제) | 사용자 — 화면/UI 조합 | PasswordResetDialogProps, PasswordResetDialog | 125 | 1 | 삭제 완료(2026-09-11), 결과002 참조 | + +## src/features/cfmtnBgt/api + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [cfmtn-bgt-api.ts]() | 최종확정예산 — HTTP 호출·응답 어댑터 | unwrap, getCfmtnBgtCodes, getCfmtnBgtPage / 공통 응답 래퍼가 있으면 실제 data 값을 반환한다. | 29 | 1 | 정적 참조 있음 | + +## src/features/cfmtnBgt/lib + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [cfmtn-bgt-excel.ts]() | 최종확정예산 — 보조 함수/자료 구조 | TOP_HEADERS, SUB_HEADERS, MONEY_COLUMN_INDEXES / 화면 목록과 동일한 Excel 상위 다단 헤더다. | 136 | 1 | 정적 참조 있음 | + +## src/features/cfmtnBgt/model + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [cfmtn-bgt-types.ts]() | 최종확정예산 — 상태/훅/타입·검증 모델 | CfmtnBgtCode, CfmtnBgtRow, CfmtnBgtPage | 60 | 3 | 정적 참조 있음 | + +## src/features/cfmtnBgt/ui + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [CfmtnBgtPopupContent.tsx]() | 최종확정예산 — 화면/UI 조합 | SelectOption, CfmtnBgtTableRow, ALL / onsemiro 최종확정예산사업 팝업 | 687 | 1 | 정적 참조 있음 | + +## src/features/ctyFldngPrvtnBiz/api + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [cty-fldng-prvtn-biz-api.ts]() | 도시침수예방사업 — HTTP 호출·응답 어댑터 | API_PATH, unwrapData, getCtyFldngPrvtnBusinesses / 공통 응답 래퍼가 있으면 실제 data를 꺼낸다. | 79 | 2 | 정적 참조 있음 | + +## src/features/ctyFldngPrvtnBiz/lib + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [cty-fldng-prvtn-biz-excel.ts]() | 도시침수예방사업 — 보조 함수/자료 구조 | SHEET_NAME, DATA_START_ROW, FIRST_YEAR / 공백과 줄바꿈을 제거해 병합 헤더를 비교한다. | 834 | 1 | 정적 참조 있음 | + +## src/features/ctyFldngPrvtnBiz/model + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [cty-fldng-prvtn-biz-types.ts]() | 도시침수예방사업 — 상태/훅/타입·검증 모델 | CtyFldngFacility, CtyFldngSettlement, CtyFldngDetail / 시설 추가에도 컬럼을 늘리지 않는 JSONB 배열 항목이다. | 98 | 4 | 정적 참조 있음 | +| [xlsx-populate-browser.d.ts]() | 도시침수예방사업 — 자동 포함 타입 선언 | | 27 | 0 | 등록/도구 진입 규칙 대상 | + +## src/features/ctyFldngPrvtnBiz/ui + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [CtyFldngPrvtnBizFormPage.tsx]() | 도시침수예방사업 — 화면/UI 조합 | DETAIL_META, BASIC_FIELDS, DETAIL_FIELDS / 등록 화면의 빈 기본·결산·4단계 상세 값을 생성한다. | 619 | 2 | 정적 참조 있음 | +| [CtyFldngPrvtnBizListPage.tsx]() | 도시침수예방사업 — 화면/UI 조합 | PAGE_SIZE, ALL, EMPTY_PAGE / 브라우저 로컬 날짜를 검색 input이 사용하는 YYYY-MM-DD 형식으로 변환한다. | 782 | 1 | 정적 참조 있음 | + +## src/features/dredging + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.ts]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | / 준설 기능의 공개 API. 기존 라우트 UI는 호환 파일을 통해 같은 구현을 사용한다. | 11 | 1 | 정적 참조 있음 | + +## src/features/dredging/api + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [backend-mode.ts]() | 준설관리 — HTTP 호출·응답 어댑터 | isDredgingBackendEnabled / 기존 FE 선행 모의 모드 계약을 유지하며 API와 첨부 adapter가 공유한다. | 4 | 4 | 정적 참조 있음 | +| [dredging-api.ts]() | 준설관리 — HTTP 호출·응답 어댑터 | LocationRequest, fetchDredgingRecords, fetchAllDredgingRecords / 실제 준설관리 API에서 기간 내 등록 기록을 조회한다. | 259 | 2 | 정적 참조 있음 | +| [dredging-error.ts]() | 준설관리 — HTTP 호출·응답 어댑터 | toDredgingApiError / 백엔드·프록시의 상태코드, HTML 오류 본문과 파싱 문구를 화면에 전달하지 않는다. | 20 | 2 | 정적 참조 있음 | +| [photo-upload.ts]() | 준설관리 — HTTP 호출·응답 어댑터 | uploadOnePhoto, uploadDredgingPhotos, uploadDredgingPhotoDrafts / 공통 TUS helper의 사진 한 장 업로드와 X-File-Info 응답을 Promise로 연결한다. | 83 | 2 | 정적 참조 있음 | +| [response-adapters.ts]() | 준설관리 — HTTP 호출·응답 어댑터 | NearestPipeResponseItem, nearestPipeFailureMessage, DredgingRecordListResponse / 사진 목록의 기존 null-to-empty fallback만 적용하며 첨부·촬영 원본 필드는 보존한다. | 84 | 1 | 정적 참조 있음 | + +## src/features/dredging/lib + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [date.ts]() | 준설관리 — 보조 함수/자료 구조 | formatDredgingCapturedDate / 촬영일시 원본은 유지하고 등록·수정 화면 표출값만 YYYY-MM-DD로 정규화한다. | 22 | 2 | 정적 참조 있음 | +| [exif.ts]() | 준설관리 — 보조 함수/자료 구조 | ByteOrder, IfdEntry, JPEG_START / JPEG EXIF TIFF 블록을 읽는 범위 제한 reader이다. | 335 | 2 | 정적 참조 있음 | +| [photo-upload-queue.ts]() | 준설관리 — 보조 함수/자료 구조 | uploadPhotosWithReferences / 저장 세션이 소유한 Map에 사진 한 장의 업로드 성공 직후 참조를 기록한다. | 21 | 1 | 정적 참조 있음 | +| [use-dredging-common-codes.ts]() | 준설관리 — 상태/훅/타입·검증 모델 | CodeLabelMap, COMMON_CODE_GROUPS, DEFAULT_REGISTRATION_MODE_LABELS / 같은 브라우저 세션에서는 준설 코드그룹별 CMS 요청을 한 번만 수행한다. | 136 | 2 | 정적 참조 있음 | +| [uuid.ts]() | 준설관리 — 보조 함수/자료 구조 | createDredgingClientUuid / 백엔드 clientRequestId가 UUID 타입이므로 fallback도 같은 36자 형식을 유지한다. | 32 | 3 | 정적 참조 있음 | + +## src/features/dredging/model + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [registration-request.ts]() | 준설관리 — 상태/훅/타입·검증 모델 | AttachmentReference, buildRegistrationBody, DredgingRegistrationRequest / 공통 첨부 업로드가 완료된 사진의 서버 식별자다. | 85 | 5 | 정적 참조 있음 | +| [types.ts]() | 준설관리 — 상태/훅/타입·검증 모델 | DredgingViewType, DredgingRegistrationMode, DredgingLocationSource / 상세 기반 수정 팝업에서 선택한 한 관로의 위치·사진을 함께 갱신한다. | 123 | 9 | 정적 참조 있음 | + +## src/features/exmn + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.ts]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | | 11 | 1 | 정적 참조 있음 | + +## src/features/exmn/api + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [exmn-business-api.ts]() | 조사관리 — HTTP 호출·응답 어댑터 | EXMN_BUSINESS_API_PATH, AttachmentMetadata, unwrapData / 조사사업 API의 공통 기준 경로다. | 413 | 8 | 정적 참조 있음 | +| [trtdst-api.ts]() | 조사관리 — HTTP 호출·응답 어댑터 | TRTDST_API_PATH, getTrtdstSettings, saveTrtdstSettings / 조사사업 공통 선행 처리분구 API의 기준 경로다. | 38 | 3 | 정적 참조 있음 | + +## src/features/exmn/lib + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [exmn-business-validation.ts]() | 조사관리 — 보조 함수/자료 구조 | EXMN_MAX_FILE_SIZE, EXMN_FILE_RULES, validateExmnBusiness / 브라우저 1차 검사에서 허용하는 조사파일 최대 크기(200MB)다. | 55 | 3 | 정적 참조 있음 | +| [trtdst-validation.ts]() | 조사관리 — 보조 함수/자료 구조 | normalizeName, validateTrtdstRows, buildTrtdstBatchItems / 처리분구명 비교 시 공백과 영문 대소문자 차이를 제거한다. | 43 | 2 | 정적 참조 있음 | +| [useExmnCommonCodes.ts]() | 조사관리 — 보조 함수/자료 구조 | CodeLabelMap, ExmnCodeGroup, COMMON_CODE_GROUPS / 조사 코드값과 표 열 순서는 유지하고 화면에서 사용할 CMS 코드명만 제공한다. | 138 | 4 | 정적 참조 있음 | + +## src/features/exmn/model + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [exmn-business-types.ts]() | 조사관리 — 상태/훅/타입·검증 모델 | ExmnPage, ExmnBusiness, ExmnBusinessDistrict / Spring Page 응답에서 조사사업 화면이 사용하는 공통 페이징 계약이다. | 219 | 13 | 정적 참조 있음 | +| [exmn-detail-table.ts]() | 조사관리 — 상태/훅/타입·검증 모델 | EXMN_DETAIL_SEVERITIES, ExmnDetailSeverity, ExmnDefectGroup / 엑셀 상세표의 고정 심각도 열 순서다. 모든 이상항목은 대·중·소 3열을 유지한다. | 150 | 5 | 정적 참조 있음 | +| [trtdst-types.ts]() | 조사관리 — 상태/훅/타입·검증 모델 | TrtdstRowStatus, TrtdstSettingDto, TrtdstTableRow / 처리분구 행이 화면에서 가진 변경 상태다. | 69 | 5 | 정적 참조 있음 | + +## src/features/exmn/ui + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [ExmnBusinessRegisterPopupPage.tsx]() | 조사관리 — 화면/UI 조합 | Notice, ExmnBusinessRegisterPopupPageProps, sortDistricts / 처리분구 이름을 공백 제거 후 한글 가나다순으로 정렬한다. | 687 | 1 | 정적 참조 있음 | +| [ExmnPplUnitDetailTable.tsx]() | 조사관리 — 화면/UI 조합 | DEFAULT_SEVERITY_LABELS, COLUMN_WIDTHS, ExmnPplUnitDetailTableProps / 연속된 동일 그룹을 찾아 엑셀의 관로번호·단위구간 병합 셀에 사용할 rowSpan을 계산한다. | 267 | 1 | 정적 참조 있음 | +| [ExmnRegistrationPage.tsx]() | 조사관리 — 화면/UI 조합 | BUSINESS_LIST_PAGE_SIZE, BUSINESS_TABLE_HEAD, BusinessFilters / 사업정보 조회·등록과 선택 사업의 조사결과 등록 진입을 제공하는 SFR-010-004 메인 화면이다. | 709 | 3 | 정적 참조 있음 | +| [ExmnResultRegisterPopupPage.tsx]() | 조사관리 — 화면/UI 조합 | FILE_TYPES, VALIDATION_FILE_TYPES, VALIDATION_BUTTON_FILE_TYPES / 파일별로 실제 수행하는 검토 항목과 아직 적용하지 않은 범위를 사용자 문구로 정의한다. | 642 | 1 | 정적 참조 있음 | +| [ExmnStandardDataTable.tsx]() | 조사관리 — 화면/UI 조합 | ExmnStandardDataTableProps, ROW_NUMBER_IDS, EXMN_TABLE_HEADER_HEIGHT_REM / 조사관리 표를 CS 목록 규격과 역순 순번 계약으로 연결한다. | 144 | 4 | 정적 참조 있음 | +| [ExmnStatusDetailPanel.tsx]() | 조사관리 — 화면/UI 조합 | SUMMARY_GRID_ROW_HEIGHT_REM, PIPE_SUMMARY_TABLE_HEAD, MANHOLE_SUMMARY_TABLE_HEAD / 서버가 생성한 다중 시트 XLSX Blob을 브라우저 다운로드로 전달한다. | 575 | 2 | 정적 참조 있음 | +| [ExmnStatusPage.tsx]() | 조사관리 — 화면/UI 조합 | ExmnStatusPage / 조사현황 URL도 PPT 3·4와 동일한 사업목록·행 선택 상세 통합 화면을 사용한다. | 7 | 2 | 정적 참조 있음 | +| [ExmnSurveyDetailPopupPage.tsx]() | 조사관리 — 화면/UI 조합 | DEFAULT_SEVERITY_LABELS, PPL_DETAIL_MODE_LABELS, DEFAULT_DEFECT_TYPE_LABELS / 관로 상세보기에서 조회 결과 표를 전환하는 모드 표시 문구다. | 1300 | 1 | 정적 참조 있음 | +| [ExmnSurveyStandardDataTable.tsx]() | 조사관리 — 화면/UI 조합 | ExmnSurveyStandardDataTableProps, ExmnSurveyStandardDataTable / 조사 복합표의 병합 셀은 업무 화면에서 유지하고 카드·헤더·로딩·빈 상태·스크롤은 | 72 | 1 | 정적 참조 있음 | +| `PplFacilityDetailModal.tsx` (삭제) | 미연결 후보: 과거 조사 시설 상세 Dialog. 미사용 요약 Dialog에서만 참조 | UNIT_TABLE_HEAD, DETAIL_TABLE_HEAD, PplFacilityDetailModalProps / PPL_UNIT_V1 정규화 응답을 단위 구간과 상태·결함 기록으로 표시한다. | 247 | 1 | 삭제 완료(2026-09-11), 결과002 참조 | +| `PplSummaryModal.tsx` (삭제) | 미연결 후보: 과거 조사 요약 Dialog. 하위 PplFacilityDetailModal을 포함 | SUMMARY_TABLE_HEAD, FACILITY_TABLE_HEAD, SUMMARY_CLICKABLE_HEAD_IDS / 숫자 값을 목록에서 단위 없이 천단위 구분 형식으로 표시한다. | 600 | 0 | 삭제 완료(2026-09-11), 결과002 참조 | +| [TrtdstManagementPage.tsx]() | 조사관리 — 화면/UI 조합 | TRTDST_TABLE_HEAD, ALL_OPTION, Notice / 처리분구 목록 표에 표시할 업무 비종속 열 정의다. | 688 | 2 | 정적 참조 있음 | + +## src/features/fldngPredcPrst/lib + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [fldng-predc-prst-excel.ts]() | 침수예측현황 — 보조 함수/자료 구조 | FloodPredictionExcelRow, HEADERS, formatDateTime / yyyyMMddHHmm 형식의 API 시각을 Excel 표시 문자열로 변환한다. | 67 | 1 | 정적 참조 있음 | + +## src/features/fldngPredcPrst/model + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [analysisTimeRange.ts]() | 침수예측현황 — 상태/훅/타입·검증 모델 | createPredictionAnalysisTimeRange / 침수예측현황 목록과 분석도구 메뉴가 공유하는 기본 분석시각 범위다. | 10 | 2 | 정적 참조 있음 | + +## src/features/fldngPredcPrst/ui + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [FldngPredcPrstPopupContent.tsx]() | 침수예측현황 — 화면/UI 조합 | ApiRow, TableRow, Filters / 도시침수지도 DB의 3자리 빈도 코드로 변환한다. 30년 이하는 030, 100년 초과는 100으로 제한한다. | 606 | 1 | 정적 참조 있음 | + +## src/features/gisManagement + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.ts]() | GIS 업로드 상태 — 모듈 공개 API/재수출 | / GIS 업로드 부분 성공 상태와 재시도 정책의 공개 경계다. | 8 | 1 | 정적 참조 있음 | + +## src/features/gisManagement/model + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [upload-retry-guard.ts]() | GIS 업로드 상태 — 상태/훅/타입·검증 모델 | ErrorRecord, GisPartialSuccessBlock, errorRecord / Axios·SN transport의 기존 오류 경계에서 부분 성공 body만 확인한다. | 59 | 1 | 정적 참조 있음 | + +## src/features/mapLayers + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.ts]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | / 지도 레이어 메타·표출 조건과 독립 이벤트 구독의 공개 경계다. | 4 | 1 | 정적 참조 있음 | + +## src/features/mapLayers/lib + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [flow-direction-listener.ts]() | 지도 레이어 — 보조 함수/자료 구조 | subscribeFlowDirectionMoveEnd / 유하방향 moveend 구독 수명을 소유한다. 매 이벤트마다 최신 체크·레이어 상태를 읽는다. | 18 | 1 | 정적 참조 있음 | +| [layer-info.ts]() | 지도 레이어 — 보조 함수/자료 구조 | FLOW_DIRECTION_LAYER_TYPE, FLOW_DIRECTION_LAYER_NAME, FLOW_DIRECTION_DEFAULT_MIN_ZOOM / 레이어 설정 응답의 호환 필드·조회 모드·BBOX 계산을 UI 수명과 분리한다. | 198 | 1 | 정적 참조 있음 | + +## src/features/obsltPpl/api + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [obslt-ppl-api.ts]() | 노후관로 정비사업 — HTTP 호출·응답 어댑터 | API_PATH, EXCEL_DOWNLOAD_PAGE_SIZE, unwrapData / 공통 응답 래퍼가 있는 JSON 응답에서 data를 안전하게 꺼낸다. | 87 | 2 | 정적 참조 있음 | + +## src/features/obsltPpl/lib + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [obslt-ppl-excel.ts]() | 노후관로 정비사업 — 보조 함수/자료 구조 | BusinessType, SheetContract, SHEET_CONTRACTS / API 문자열에 남은 Excel 개행 이스케이프를 실제 줄바꿈으로 정리한다. | 357 | 1 | 정적 참조 있음 | +| [obslt-ppl-region.ts]() | 노후관로 정비사업 — 보조 함수/자료 구조 | RegionOption, normalizeRegionText, OBSLT_PPL_ENV_OFFICES / 시군구명에서 상위 시만 대표명으로 추출한다. | 100 | 2 | 정적 참조 있음 | + +## src/features/obsltPpl/model + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [obslt-ppl-types.ts]() | 노후관로 정비사업 — 상태/훅/타입·검증 모델 | ObsltPplBusiness, ObsltPplBusinessSaveRequest, ObsltPplPage / 노후하수관로 정밀조사·정비사업 한 건의 API 계약이다. | 51 | 4 | 정적 참조 있음 | + +## src/features/obsltPpl/ui + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [ObsltPplFormPage.tsx]() | 노후관로 정비사업 — 화면/UI 조합 | FormKey, FormState, FieldErrorKey / 등록·수정 표에서 필드 순서, 라벨, 입력 종류와 필수 여부를 한 곳에서 관리한다. | 564 | 2 | 정적 참조 있음 | +| [ObsltPplListPage.tsx]() | 노후관로 정비사업 — 화면/UI 조합 | DEFAULT_LIST_SIZE, ALL_REGION_OPTION, ObsltPplTableRow / 노후 하수관로 정비사업 | 686 | 1 | 정적 참조 있음 | + +## src/features/windowPopup + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.ts]() | 공통 창 팝업 — 모듈 공개 API/재수출 | | 15 | 0 | 정적 경로 미발견·검토 | +| [registry.tsx]() | 팝업 key를 동적 로딩 컴포넌트에 연결 | WindowPopupModule, PopupChunkLoading, loadPopupContent | 270 | 2 | 정적 참조 있음 | + +## src/features/windowPopup/lib + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [openWindowPopup.ts]() | 공통 창 팝업 — 보조 함수/자료 구조 | buildQueryString, clamp, buildPopupFeatures | 139 | 18 | 정적 참조 있음 | +| [popupMessage.ts]() | 공통 창 팝업 — 보조 함수/자료 구조 | postPopupMessage, postPopupMessageAndClose | 25 | 10 | 정적 참조 있음 | + +## src/features/windowPopup/model + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [popupDefinitions.ts]() | 팝업 이름·크기·필수 파라미터 계약 | windowPopupDefinitions, WindowPopupKey, isWindowPopupKey | 236 | 25 | 정적 참조 있음 | +| [types.ts]() | 공통 창 팝업 — 상태/훅/타입·검증 모델 | WindowPopupSearchParams, WindowPopupContentProps, WindowPopupDefinition / 팝업별 기본 정의를 유지하면서 호출 화면에서 필요한 크기와 최대화 여부를 재정의한다. | 76 | 46 | 정적 참조 있음 | + +## src/features/windowPopup/ui + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [AgedPipePopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | AgedPipePopupContent | 11 | 1 | 정적 참조 있음 | +| [AgedPipeStatusByCtpvPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | AgedPipeStatusByCtpvPopupContent | 11 | 1 | 정적 참조 있음 | +| [DredgingEditPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | DredgingEditPopupContent / 상세 화면과 같은 구조에서 선택 관로의 사진·위치·준설일자를 수정한다. | 28 | 1 | 정적 참조 있음 | +| [dredgingPopupRecord.ts]() | 공통 창 팝업 — 보조 함수/자료 구조 | getParam, getNumber, getNullableNumber / 목록 행의 직렬화된 값을 준설 수정 화면 모델로 복원한다. | 50 | 1 | 정적 참조 있음 | +| [DredgingRegisterPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | DredgingRegisterPopupContent / 준설결과 등록 화면을 공통 WindowPopup 레이아웃에 연결한다. | 20 | 1 | 정적 참조 있음 | +| [ExmnBusinessRegisterPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | getParam, ExmnBusinessRegisterPopupContent / query parameter가 단일 문자열인지 안전하게 해석한다. | 31 | 1 | 정적 참조 있음 | +| [ExmnPipeReportPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | getParam, getReportErrorMessage, ExmnPipeReportPopupContent / 공통 Blob API 오류 문자열에서 백엔드의 사용자 안내 메시지만 추출한다. | 109 | 1 | 정적 참조 있음 | +| [ExmnResultRegisterPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | getParam, buildBusinessSummary, ExmnResultRegisterPopupContent / query parameter가 단일 문자열인지 안전하게 해석한다. | 50 | 1 | 정적 참조 있음 | +| [ExmnSurveyDetailPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | getParam, getJobType, getDefectType / query parameter가 단일 문자열인지 안전하게 해석한다. | 70 | 1 | 정적 참조 있음 | +| [FeaturePlaceholderPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | Props, FeaturePlaceholderPopupContent | 25 | 1 | 정적 참조 있음 | +| [FldngHstryPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | FldngHstryPopupContent | 6 | 1 | 정적 참조 있음 | +| [FloodDngrMapPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | FloodDngrMapPopupContent / 도시침수지도 목록 WindowPopup을 실제 목록 화면에 연결한다. | 8 | 1 | 정적 참조 있음 | +| [FloodPreTablePopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | TableModal, FloodPreTablePopupContent | 46 | 1 | 정적 참조 있음 | +| [FloodPreUploadPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | FloodPreUploadPopupContent | 55 | 1 | 정적 참조 있음 | +| [GisEditSessionPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | EMPTY_SNAPSHOT, postMessageToOpener, GisEditSessionPopupContent | 106 | 1 | 정적 참조 있음 | +| [GisManagementPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | GisManagementPopupContent | 11 | 1 | 정적 참조 있음 | +| [InfilChartPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | InfilChartPopupContent | 34 | 1 | 정적 참조 있음 | +| [InftwtResultPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | InftwtResultPopupContent | 20 | 1 | 정적 참조 있음 | +| [MapPopupDataTable.tsx]() | 공통 창 팝업 — 화면/UI 조합 | MapPopupDataTableProps, ROW_NUMBER_IDS, formatCellValue / 지도 WindowPopup 목록을 CS 공통 표·순번·페이징 규칙으로 연결한다. | 204 | 18 | 정적 참조 있음 | +| [OldPpTablePopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | TableModal, OldPpTablePopupContent | 46 | 1 | 정적 참조 있음 | +| [OldPpUploadPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | OldPpUploadPopupContent | 55 | 1 | 정적 참조 있음 | +| [PipeAnalysisDetailPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | PipeAnalysisDetailPopupContent | 11 | 1 | 정적 참조 있음 | +| [PipeAnalysisPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | PipeAnalysisPopupContent | 11 | 1 | 정적 참조 있음 | +| [popupContentUtils.ts]() | 공통 창 팝업 — 보조 함수/자료 구조 | getPopupParamValue | 7 | 7 | 정적 참조 있음 | +| [PopupMissingParams.tsx]() | 공통 창 팝업 — 화면/UI 조합 | PopupMissingParams | 25 | 6 | 정적 참조 있음 | +| [PopupSearchPanel.tsx]() | 공통 창 팝업 — 화면/UI 조합 | PopupSearchPanelProps, PopupSearchFieldProps, PopupSearchActionsProps / WindowPopup 조회조건의 외곽 카드, 패딩과 가로 스크롤 규칙을 한 곳에서 관리한다. | 95 | 19 | 정적 참조 있음 | +| [RdiiChartPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | RdiiChartPopupContent | 34 | 1 | 정적 참조 있음 | +| [RdiiResultPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | RdiiResultPopupContent | 20 | 1 | 정적 참조 있음 | +| [RepairHistoryPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | RepairHistoryPopupContent | 7 | 1 | 정적 참조 있음 | +| [ReportFilesPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | ReportDataFile, REPORT_FILES_HEADLIST, ReportFilesPopupContent | 124 | 1 | 정적 참조 있음 | +| [ReportTabPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | ReportTabPopupContent | 38 | 1 | 정적 참조 있음 | +| [SbmrgDngrMapPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | SbmrgDngrMapPopupContent / 침수위험지도 목록 WindowPopup을 실제 목록 화면에 연결한다. | 8 | 1 | 정적 참조 있음 | +| [SearchModalPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | SearchModalPopupContent | 45 | 1 | 정적 참조 있음 | +| [SinkholePopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | SinkholePopupContent | 11 | 1 | 정적 참조 있음 | +| [SscDetailPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | SscDetailPopupContent | 37 | 1 | 정적 참조 있음 | +| [WaterRiskPopupContent.tsx]() | 공통 창 팝업 — 화면/UI 조합 | WaterRiskPopupContent | 13 | 1 | 정적 참조 있음 | +| [WindowPopupRenderer.tsx]() | 공통 창 팝업 — 화면/UI 조합 | WindowPopupRendererProps, WindowPopupRenderer / WindowPopup 구현들은 지도·엑셀처럼 브라우저 전용 모듈을 포함할 수 있다. | 23 | 1 | 정적 참조 있음 | + +## src/shared/api + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [client.ts]() | HTTP 호출·응답 어댑터 | client | 12 | 1 | 정적 참조 있음 | +| [http-transport.ts]() | HTTP 상태·오류 본문·JSON/빈 응답 처리를 통일하는 하위 전송 계층 | ApiRequestError, HttpTransportOptions, requestError / 정상 JSON의 빈 본문은 null이며, 비어 있지 않은 잘못된 JSON은 오류다. | 133 | 2 | 정적 참조 있음 | +| [index.ts]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | | 4 | 4 | 정적 참조 있음 | + +## src/shared/config + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [api.ts]() | 보조 함수/자료 구조 | BASE_URL, ENDPOINT, JWT_EXPIRATION_TIME | 86 | 2 | 정적 참조 있음 | +| [auth.ts]() | 보조 함수/자료 구조 | authConfig | 18 | 1 | 정적 참조 있음 | +| [env.ts]() | 보조 함수/자료 구조 | SITE_ID, PORT, PROXY_HOST | 18 | 4 | 정적 참조 있음 | +| [index.ts]() | 보조 함수/자료 구조 | DEFAULT_LIST_SIZE | 5 | 17 | 정적 참조 있음 | + +## src/shared/lib + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [bbs-notice-menu.ts]() | 보조 함수/자료 구조 | BBS_NOTICE_MENU_MARKER, isBbsNoticeMenu | 13 | 1 | 정적 참조 있음 | +| [cookie.ts]() | 보조 함수/자료 구조 | getCookie, setCookie, removeCookie | 32 | 1 | 정적 참조 있음 | +| [download-excel.ts]() | 보조 함수/자료 구조 | ExcelDownloadWithHeaderProps, ExcelRow, EXCEL_DATA_ROWS_PER_SHEET / 다중 시트일 때 접미사가 잘리지 않도록 시트명 길이를 함께 조정한다. | 154 | 13 | 정적 참조 있음 | +| [fetch-all-pages.ts]() | 보조 함수/자료 구조 | FetchAllPagesOptions, FetchAllPagesResult, DEFAULT_BATCH_SIZE / 서버의 Pageable 최대 크기에 의존하지 않고 모든 페이지를 순회한다. | 49 | 15 | 정적 참조 있음 | +| [index.ts]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | | 14 | 57 | 정적 참조 있음 | +| [statistics-menu-url.ts]() | 보조 함수/자료 구조 | ParsedStatisticsMenuUrl, parseStatisticsMenuUrlPath, resolveStatisticsCategory / 메뉴 urlPath에서 통계 화면 카테고리·컴포넌트명을 파싱합니다. | 85 | 2 | 정적 참조 있음 | +| [use-alert-store.ts]() | 상태/훅/타입·검증 모델 | useAlertStore | 15 | 4 | 정적 참조 있음 | +| `use-data-store.ts` (삭제) | 상태/훅/타입·검증 모델 | useDataStore | 15 | 0 | 삭제 완료(2026-09-11), 결과002 참조 | +| [use-menu-store.ts]() | 상태/훅/타입·검증 모델 | useMenuStore | 15 | 3 | 정적 참조 있음 | +| [use-mutation-wrapper.tsx]() | 상태/훅/타입·검증 모델 | useMutationWrapper | 32 | 1 | 정적 참조 있음 | +| [use-oper-business-common-codes.ts]() | 상태/훅/타입·검증 모델 | CodeLabelMap, OperCodeGroup, COMMON_CODE_GROUPS / 운영사업의 기존 한글 저장계약을 유지하면서 CMS 선택지·표시명을 제공한다. | 156 | 4 | 정적 참조 있음 | +| [use-prompt.tsx]() | 상태/훅/타입·검증 모델 | usePrompt | 27 | 2 | 정적 참조 있음 | +| [use-user-store.ts]() | 상태/훅/타입·검증 모델 | useUserStore | 15 | 5 | 정적 참조 있음 | + +## src/shared/model + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [api-interface.ts]() | 상태/훅/타입·검증 모델 | SortConfig, PageableConfig, PageableList | 35 | 1 | 정적 참조 있음 | +| [index.ts]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | | 2 | 2 | 정적 참조 있음 | + +## src/shared/providers + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.ts]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | | 3 | 1 | 정적 참조 있음 | +| [prompt-provider.tsx]() | 화면/UI 조합 | PromptContext, PromptContext, PromptProvider | 39 | 2 | 정적 참조 있음 | +| [store-provider.tsx]() | 화면/UI 조합 | StoreContextType, StoreContext, StoreProvider | 42 | 5 | 정적 참조 있음 | + +## src/shared/store + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [alert-store.ts]() | 상태/훅/타입·검증 모델 | AlertState, AlertActions, AlertStore | 26 | 2 | 정적 참조 있음 | +| [attachment-store.ts]() | 상태/훅/타입·검증 모델 | AttachmentState, AttachmentActions, AttachmentStore | 26 | 1 | 정적 참조 있음 | +| [data-store.ts]() | 상태/훅/타입·검증 모델 | Option, BIZ_CD_TYPE, DataState | 89 | 2 | 정적 참조 있음 | +| [index.ts]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | | 5 | 4 | 정적 참조 있음 | +| [menu-store.ts]() | 상태/훅/타입·검증 모델 | BBS_TYPE, BbsType, IBbs | 94 | 2 | 정적 참조 있음 | +| [user-store.ts]() | 상태/훅/타입·검증 모델 | IUserInfo, UserState, UserActions | 58 | 2 | 정적 참조 있음 | + +## src/shared/ui + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [alert-dialog.tsx]() | 화면/UI 조합 | AlertDialog | 35 | 1 | 정적 참조 있음 | +| [business-user-message-bridge.tsx]() | 화면/UI 조합 | BusinessUserMessageBridge / 공통 비즈니스 컴포넌트의 사용자 알림을 SN 공통 메시지 다이얼로그로 연결한다. | 20 | 1 | 정적 참조 있음 | +| [confirm-dialog.tsx]() | 화면/UI 조합 | ConfirmDialogProps, ConfirmDialog | 49 | 1 | 정적 참조 있음 | +| `form.tsx` (삭제) | 화면/UI 조합 | Form, FormFieldContextValue, FormFieldContext | 176 | 1 | 삭제 완료(2026-09-11), 결과002 참조 | +| `map-layout.tsx` (삭제) | 화면/UI 조합 | MapLayout | 14 | 0 | 삭제 완료(2026-09-11), 결과002 참조 | +| [page-layout.tsx]() | 화면/UI 조합 | PageLayout | 23 | 2 | 정적 참조 있음 | +| [prompt-dialog.tsx]() | 화면/UI 조합 | PromptDialog | 46 | 1 | 정적 참조 있음 | + +## src/shared/util + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [util.ts]() | 보조 함수/자료 구조 | appendApiSearchParams, formatDate / 빈 값과 화면의 전체 선택값 0을 제외하고 API 검색 파라미터를 추가한다. | 30 | 13 | 정적 참조 있음 | + +## src/store + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [tab-store.ts]() | 상태/훅/타입·검증 모델 | Tab, fetchTabPageMenuAuthority, scheduleTabPageMenuAuthorityFetch / persist 복원·새로고침 후 활성 탭 메뉴 권한이 없으면 조회 | 328 | 1 | 정적 참조 있음 | + +## src/types + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [lucide-react.d.ts]() | 자동 포함 타입 선언 | | 46 | 0 | 등록/도구 진입 규칙 대상 | + +## tests + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [api-transport.test.cjs]() | 정식 회귀 테스트/테스트 지원 | assert, transport, api | 190 | 0 | 유지: 회귀 근거 | +| [api-url-contract.test.cjs]() | 정식 회귀 테스트/테스트 지원 | assert, fs, path | 147 | 0 | 유지: 회귀 근거 | +| [dredging-module-boundary.test.cjs]() | 정식 회귀 테스트/테스트 지원 | assert, fs, path | 89 | 0 | 유지: 회귀 근거 | +| [dredging-registration.test.cjs]() | 정식 회귀 테스트/테스트 지원 | assert, base, fixture | 164 | 0 | 유지: 회귀 근거 | +| [dredging-response-adapters.test.cjs]() | 정식 회귀 테스트/테스트 지원 | assert, apiPath, adapters | 172 | 0 | 유지: 회귀 근거 | +| [dredging-upload.test.cjs]() | 정식 회귀 테스트/테스트 지원 | assert, photos, browserStubs | 172 | 0 | 유지: 회귀 근거 | +| [gis-upload-retry-guard.test.cjs]() | 정식 회귀 테스트/테스트 지원 | assert, fs, path | 131 | 0 | 유지: 회귀 근거 | +| [load-typescript.cjs]() | 정식 회귀 테스트/테스트 지원 | fs, path, vm | 48 | 0 (+시험8) | 유지: 회귀 근거 | +| [map-layer-model.test.cjs]() | 정식 회귀 테스트/테스트 지원 | assert, layer | 167 | 0 | 유지: 회귀 근거 | +| [module-boundary-policy.test.cjs]() | 정식 회귀 테스트/테스트 지원 | assert, path | 77 | 0 | 유지: 회귀 근거 | + + +## 검증 한계와 롤백 + +문서만 추가했다. 코드·DB·프로세스 변경은 없으며 업무 롤백은 필요 없다. 실제 삭제/이동은 프레임워크 등록·동적 참조·외부 소비자를 확인하고 테스트/브라우저 회귀 후 별도 승인 단위로 진행해야 한다. diff --git a/apps/sn/docs/작업계획/SN-패키지구조진단/002-fe-SN-구조정리-구현결과.md b/apps/sn/docs/작업계획/SN-패키지구조진단/002-fe-SN-구조정리-구현결과.md new file mode 100644 index 000000000..6b80c702f --- /dev/null +++ b/apps/sn/docs/작업계획/SN-패키지구조진단/002-fe-SN-구조정리-구현결과.md @@ -0,0 +1,283 @@ +# SN 구조 정리 구현결과 + +2026-09-11 구현, 2026-09-14 최종 재검증. 이번 승인 범위는 큰 SN 파일5개의 책임 분리와 미사용/시안 정리다. 이전 API 단일화·재시도·오류처리 변경을 기준선으로 보존했다. business_components는 메인이 별도로 소유하며 이 FE 작업에서 수정하지 않았다. + +## 1. 목표·원본·범위 + +- 목표: 지도 계산/표시, GIS 편집 정책, 조사 Excel, 준설 폼 표시, GIS 다운로드의 책임을 분리한다. 사용하지 않는 실행 코드는 실제 서비스 근거를 확인한 뒤 제거한다. +- 허용: `apps/sn/**`의 코드·정식 테스트·관련 문서. 금지: BE/Java/Docker/배포·환경·package/tsconfig/next 설정·다른 앱·공통 패키지 수정. +- 사용자 소유3002 서버는 유지한다. 종료·재시작·next build를 실행하지 않는다. 실제 브라우저 검증은 root/QA가 별도로 담당한다. +- 완료 조건: 기존 효과/handler 수명·API/DB/UI 계약 유지, 추출 함수 행동 회귀, 기존 전체 테스트와 타입 검사, 삭제 근거와 정확한 목록, 잔여 위험 기록. + +직접 확인한 원본은 `D:/workspace/IIPBS/docs/기준문서/요구사항정의서.xlsx`, `화면설계서.pptx`다. 원본 파일은 변경하지 않았다. + +| 원본 추적 | 직접 확인한 기능·항목 | +| --- | --- | +| XLSX 기능 추적표76/77/78행, SFR-010-004/005/006. PPT39·41·43·45, SN_MN040105/040101/040103 | 조사사업·처리분구, 관로/맨홀 결과, 구조·운영결함, 본단위구간·종합집계·보고서·Excel. 선택된 결과와 동적 결함열·합계를 유지 | +| XLSX84행 SFR-010-012. PPT53 SN_MN040201 | 준설 등록·관로추가·사진최대5장·촬영일/GPS·위치보완·저장. 본문 표시를 분리해도 기존 조작·메타 상태 유지 | +| XLSX96~99행 SFR-012-001/002/003/004. PPT34/35 SN_MN010301/010302 | GIS 조회/편집/업로드, 레이어 선택·추가·이동·삭제·저장, 원본과 편집자료 분리·업로드현황/다운로드 | +| XLSX87행 SFR-011-001. PPT7·9 SN_MN010101/010102 | 지반침하/노후관로 지도·목록·검색·상세·다운로드. 시안 삭제는 정식 지도/노후관로 화면을 남기는 조건 | + +## 2. 큰 파일 전후와 추출 책임 + +행 수는 UTF-8 원문을 줄바꿈 기준으로 나누되 마지막 빈 행을 제외한 기준이다. 기존 진단표보다 전 단계 값이1행 작게 보이는 것은 EOF 집계 차이다. 후 값은 타입 import 보완·미사용 import 정리까지 반영한 9월 14일 재측정값이다. + +| 기존 파일 | 전 | 후 | 감소 | 분리한 책임 | +| --- | ---: | ---: | ---: | --- | +| `app/(map-layout)/map/page.tsx` | 9,409 | 7,757 | 1,652 | vertex 메타·핸들·geometry 변경, preview style, 유하방향 화살표, 우측 상세 탭 표시 | +| `src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx` | 1,299 | 1,057 | 242 | Excel 열/행·소계/합계·병합 구성과 공유 결함합계 | +| `app/(map-layout)/map/components/gisEdit/useGisEdit.ts` | 1,238 | 790 | 448 | 편집 레이어/속성 정책과 feature·BBOX snapshot | +| `app/(with-base-layout)/oper/dredging/components/DredgingRegisterModal.tsx` | 1,092 | 678 | 414 | 사진·날짜·위치·지도 본문의 controlled 표시 컴포넌트 | +| `app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx` | 1,071 | 859 | 212 | 응답 타입·표시 규칙·download job 접수/polling·파일 전달·opener 메시지 | +| 합계 | 14,109 | 11,141 | 2,968 | 함수 내용을 삭제해 줄인 것이 아니라 역할별 구현으로 이전 | + +큰 지도는 아직 약7.8천행이다. 특히 Map 함수 안의 조회·레이어 ref·선택·저장·팝업 상태 조정이 남는다. 이번 배치에서는 효과를 억지로 조각내지 않고 순수 계산·표시 경계를 분리했다. 상호 의존하는 sinkhole/GIS edit/WaterRisk 조회 수명을 추가로 나누려면 실제 지도 이벤트 회귀가 선행되어야 한다. 전체 지도 아키텍처 정리 완료로 표현하지 않는다. + +### 신규 운영 모듈17개 + +```text +src/features/gisEdit/index.ts +src/features/gisEdit/model/types.ts +src/features/gisEdit/model/vertex-handles.ts +src/features/gisEdit/lib/edit-model.ts +src/features/gisEdit/lib/feature-snapshot.ts +src/features/gisEdit/lib/preview-styles.ts +src/features/gisEdit/lib/vertex-handles.ts +src/features/gisEdit/lib/geometry-mutations.ts +src/features/mapLayers/lib/flow-direction-styles.ts +app/(map-layout)/map/components/rightPanel/RightPanelBookmarkTabs.tsx +src/features/exmn/lib/download-exmn-details.ts +src/features/exmn/lib/exmn-detail-totals.ts +src/features/dredging/ui/DredgingRegistrationBody.tsx +src/features/gisManagement/model/upload-types.ts +src/features/gisManagement/lib/upload-display.ts +src/features/gisManagement/lib/popup-refresh.ts +src/features/gisManagement/api/gis-download.ts +``` + +추가 변경: 원래 `gisEditTypes.ts`는 새 model로 재수출하는 호환 경계가 됐다. `scripts/check-module-boundaries.cjs`는 새 gisEdit 모듈을 검사하고, `audit-screen-inventory.mjs`의 삭제된 html alias2개 분류를 제거했다. API 계약 테스트의 제목도 실제 서비스 범위에 맞췄다. 환경·package 설정은 변경하지 않았다. + +## 3. 실제 수정 코드와 설계 이유 + +### 3.1 지도 계산과 상태 수명 분리 + +기존 page 내부에서 선언했던 vertex 계산을 기능 모듈로 옮기고 page는 다음 공개 함수를 호출한다. + +```ts +import { + applyVertexHandleMove, + deleteSelectedVertexHandles, + applyGeometryVertexSnaps, + applyGeometryMoveSnap, +} from "@/features/gisEdit/lib/geometry-mutations"; +``` + +핵심 계산은 원형을 유지했다. 예를 들어 선형 꼭지점 삭제는 중복 인덱스를 역순 처리하고 최소2점을 보존한다. + +```ts +const uniqueIndexes = Array.from(new Set(coordIndexes)).sort( + (left, right) => right - left, +); +if (nextCoordinates.length - uniqueIndexes.length < 2) { + return null; +} +uniqueIndexes.forEach((coordIndex) => { + nextCoordinates.splice(coordIndex, 1); +}); +``` + +폴리곤은 폐합점을 유지하고, 다중선/다중폴리곤의 일부 삭제가 실패하면 원본 geometry에 적용하지 않는 기존 동작을 테스트했다. map의 interaction 등록·cleanup, effect 의존성, 선택 ref, 저장 handler는 변경하지 않았다. + +`RightPanelBookmarkTabs`는 타입·분류·동일 탭 그룹 처리·JSX를 함께 옮겨 단순 상수 이동을 넘는 표시 경계로 분리했다. 유하방향은 대표 선분 선택·중앙 위치/각도·색상·화살표 renderer를 한 모듈로 묶었다. 원래 함수 옆에 있어야 하는 filterSql 해석 주석도 해당 함수 위치에 유지했다. + +### 3.2 useGisEdit의 정책과 snapshot + +이전에는 React hook 파일이 레이어 whitelist와 색상, 저장 속성 정리, GeoJSON snapshot까지 모두 정의했다. 이제 `edit-model.ts`가 편집 정책, `feature-snapshot.ts`가 feature 해석을 소유한다. + +```ts +const normalizedValue = + fieldId === "instlYmd" + ? String(value ?? "").replaceAll("-", "") + : value; +acc[fieldId] = + typeof normalizedValue === "string" && normalizedValue.trim() === "" + ? null + : normalizedValue; +``` + +허용 속성만 저장하고 날짜 구분자 제거·빈 문자열→null의 기존 계약을 그대로 유지한다. 실제 선택/추가/변경·취소·저장 state 업데이트는 원래 hook에 남아 있다. 타입은 feature model로 옮겼지만 기존 app import 경로는 재수출로 보존했다. + +### 3.3 조사 Excel + +이전 `handleDownload`의 약226행은 화면 state와 Excel 구성 책임이 섞여 있었다. 화면은 현재 확정된 rows·unitRows·결함열·합계·표시명·선택 분구를 전달하고 새 함수가 기존 Excel 옵션을 구성한다. + +```ts +const handleDownload = () => downloadExmnSurveyDetails({ + selectedTrtdstNm, + defectGroupTitle: definition.defectGroupTitle, + isUnitMode, defectTypeLabel, unitRows, rows, + recordTypeLabels, pipeItemLabels, severityLabels, visibleDefectColumns, + totals, defectSummary, summaryLabelColumnCount, + fixedColumnCount: fixedColumns.length, + setNotice, +}); +``` + +동적 결함열의 순서, 소계/합계 행, 병합 범위, null/0/하이픈과 본단위구간 표기를 보존한다. 실제 Excel 파일을 새로 만든 검증이 아니라 생성 옵션의 값을 모의 downloader로 검사했다. + +### 3.4 준설 등록 본문 + +이전 모달 안의437행 `modalBody`를 `DredgingRegistrationBody`로 분리했다. 파일·준설일자·현재 그룹·라벨·location draft와 typed callback을 전달한다. + +```tsx + +``` + +지도 컴포넌트는 주입하므로 새 feature가 app의 지도 UI를 역참조하지 않는다. 기존 dynamic 컴포넌트 identity를 그대로 넘기고 상위의 EXIF/관로매칭·업로드·등록 ID·saving ref 수명은 보존한다. 27개의 표시/조작 prop은 기존 상태를 새 저장소로 복제하지 않기 위한 명시적인 경계다. 추가 pane 분할은 후속 개선 여지가 있다. + +### 3.5 GIS 다운로드 + +page에 있던 접수·polling·연결 재시도·Blob 전달을 다운로드 모듈로 분리했다. 전송 함수를 주입받아 feature가 기존 app API helper를 직접 역참조하지 않는다. + +```ts +const { downloadGeoPackage, startDownloadJob, waitForDownloadJob } = + createGisDownloadClient({ getData, postRawJson, getBlobDataWithHeaders }); +``` + +```ts +const response = (await postRawJson(url, {})) as { data?: unknown } | null; +const job = (response?.data ?? response) as DownloadJob | null; +``` + +`unknown` 입력 경계에는 정적 타입 단언만 추가했다. 기존 raw/wrapper 해석·jobId 검사·READY/FAILED·최소 polling 간격·30분 제한·네트워크 오류만1회 재시도하는 정책은 동일하다. GIS 부분 성공 재시도 guard와 원래 upload handler도 그대로 유지했다. + +## 4. 삭제 판정과 목록 + +### HTML42파일 + +- SN app/html 밖의 서비스 import/URL 참조0. +- 전체 apps/packages 직접 URL/경로 검색도 정식 audit의 분류2개 외에0. +- 실제 CMS: 동일 DB를 사용하는 승인된 개발11302에서 정상 로그인/info/me200, `GET /server/cms-service/api/v1/menu-roles/SN`200. 전체13노드·URL10개와 JSON 전체에서 /html 참조0. 실제 menu에는 /map·조사·준설·처리분구 등 정식 경로만 존재했다. +- local3002 로그인은180초 응답 지연으로 이 메뉴 확인은 개발 서버 기준이다. 로컬이 정상이라고 주장하지 않는다. +- dirty HTML2파일은 직전 작업의 API camel 경로 보완뿐이었고 별도 사용자 마크업 변경은 없었다. +- 메인이 사용자 지시에 따라42파일 전체(내부 시안MD·AGENTS 포함) 삭제 범위를 재확인했다. 공통 원본 PPTX/XLSX와 상위 지침은 보존했다. + +### 미사용21후보 + +19파일은 현재 서비스·라우트/등록·외부 앱 소비를 찾지 못했고 참조는 삭제 묶음 내부에만 있었다. `windowPopup/index.ts`와 `menu-authority/@x/posting.ts`는 의도적인 공개/교차 계층 경계라 유지했다. 정식 검사 scripts3개는 CLI 진입점이므로 미사용으로 삭제하지 않았다. 개발 route인 /ii/test·/ii/fileupload·/oper/operStore의 page는 이 배치에서 삭제하지 않았다. + +삭제한 실제61파일(모두 SN 상대경로): + +```text +app/html/06. 노후관로관리_시나리오_v1.0.md +app/html/AGENTS.md +app/html/old-pipe-component-sample/index.html +app/html/(map-layout)/layout.tsx +app/html/(with-base-layout)/layout.tsx +app/html/(map-layout)/map/page.tsx +app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx +app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx +app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx +app/html/(map-layout)/aged-pipe/agedPipeUtils.ts +app/html/(map-layout)/aged-pipe/page.tsx +app/html/(map-layout)/map/components/InputSection.tsx +app/html/(map-layout)/map/waterDepth/waterDepth.tsx +app/html/(map-layout)/map/components/popupComponent/sewagePump.tsx +app/html/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx +app/html/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +app/html/(map-layout)/map/components/popupComponent/chartModalStore.ts +app/html/(map-layout)/map/components/popupComponent/Ssc.tsx +app/html/(map-layout)/map/components/popupComponent/smh.tsx +app/html/(map-layout)/map/components/popupComponent/stormWaterPump.tsx +app/html/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx +app/html/(map-layout)/map/searchModal/searchModal.tsx +app/html/(map-layout)/map/searchModal/swagePrcsFclt.tsx +app/html/(map-layout)/map/searchModal/reportTab.tsx +app/html/(map-layout)/map/searchModal/reportFilesModal.tsx +app/html/(map-layout)/map/searchModal/swagePpl.tsx +app/html/(map-layout)/map/searchModal/operationTab.tsx +app/html/(map-layout)/map/searchModal/swagePmstn.tsx +app/html/(map-layout)/map/searchModal/basicTab.tsx +app/html/(map-layout)/map/searchModal/swageMhl.tsx +app/html/(map-layout)/map/searchModal/stofcbr.tsx +app/html/(map-layout)/map/searchModal/useSearchModal.ts +app/html/(map-layout)/map/components/common/index.ts +app/html/(map-layout)/map/components/common/codeMapper.ts +app/html/(map-layout)/map/components/common/table.ts +app/html/(map-layout)/map/searchModal/swagePrcsZone.tsx +app/html/(map-layout)/map/searchModal/useBasicTab.ts +app/html/(map-layout)/map/searchModal/useReportTab.ts +app/html/(map-layout)/map/searchModal/useOperationTab.ts +app/html/(map-layout)/map/components/popupComponent/chart/aplcnYms.ts +app/html/(map-layout)/map/components/popupComponent/chart/infilChart.tsx +app/html/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx +app/(with-base-layout)/ii/test/page_bar.tsx +app/(with-base-layout)/ii/test/page_pie.tsx +app/(with-base-layout)/oper/index.ts +app/(with-base-layout)/oper/tableSort.ts +app/(with-base-layout)/oper/floodPre/headMakeFloodPre.tsx +app/(with-base-layout)/oper/floodPre/inputTableBody.tsx +app/components/ui/contentLayout.tsx +app/components/ui/formSection.tsx +src/app/index.tsx +src/entities/auth/model/login-interface.ts +src/entities/user/index.ts +src/entities/user/api/user-service.ts +src/entities/user/model/user-interface.ts +src/entities/user/ui/password-reset-dialog.tsx +src/features/exmn/ui/PplFacilityDetailModal.tsx +src/features/exmn/ui/PplSummaryModal.tsx +src/shared/lib/use-data-store.ts +src/shared/ui/form.tsx +src/shared/ui/map-layout.tsx +``` + +`001-fe-SN-파일역할목록.md`의 해당61행은 깨진 링크 대신 삭제 표시로 바꾸고, 상단에 정리 전 스냅샷임과 본 결과 링크를 표시했다. 삭제된 추적 파일은 Git 이력으로 복구할 수 있으며, HTML의 이전 미커밋 변경은 API 경로 보완이었음을 함께 기록한다. + +### 빈 디렉터리 정책 차단 + +파일61개는 apply_patch로 삭제됐다. 삭제 전 HTML 절대경로와 reparse point 부재를 확인했다. 이후 절대경로·잔여파일 재검사를 포함한 빈 디렉터리 비재귀 Remove-Item 명령은 실행 전에 도구의 자동 정책 검토가 `blocked by policy`로 거절했다. 추가 이유는 제공되지 않았다. 같은 삭제를 재시도하거나 다른 도구로 우회하지 않았다. HTML 안의 파일0개는 별도 정식 회귀 테스트로 확인했다. 따라서 로컬 빈 HTML 디렉터리는 잔존할 수 있으나 실행 파일은 없다. + +## 5. 검증 결과 + +실행 위치는 `D:/workspace/IIPBS-FRONTEND/frontend/apps/sn`다. + +```powershell +node --test tests/sn-structure-extraction.test.cjs +pnpm run check:boundaries +pnpm run test:refactor +pnpm run typecheck +git diff --check +``` + +| 검증 | 결과·한계 | +| --- | --- | +| 새 집중 행동검증 | 12/12 통과. 단순 파일 분리가 아니라 GIS 정책·snapshot·vertex·폐합/원자성·겹친 핸들·화살표·다운로드 재시도/polling·Excel 합계/병합·준설 callback 결과 검사 | +| 기존 전체 회귀 | 9/14 재실행: 기존57개 보존 + 신규12개 =69/69 통과. 실패·skip·todo0 | +| 구조 검사 | 9/14 재실행: 40파일, 위반0. 신규 gisEdit도 검사 대상으로 추가 | +| 타입 검사 | 9/14 최종 tsc --noEmit --incremental false --pretty false 종료0. 앞선 동시 삭제 중 TS6053 및 RightPanel/geometry-mutations의 타입 import 누락4건을 보완하고 다시 확인 | +| 포맷 | 신규 운영모듈·집중 테스트에 프로젝트 formatter 적용. 압축된 타입/옵션 선언도 정리 | +| 브라우저 | 9/11 변경 전후 로그인/요청180초·10초·15초 timeout으로 미검증. 9/14 09:58에는3002·8883 리스너0. 서버 기동/재시작 없이 제한을 보고 | +| 실제 저장 | 본 FE 테스트에서 실제 업로드·DB 저장·삭제 없음. 모의 HTTP/DOM으로 결과 확인 | + +unit 테스트는 실제 OpenLayers renderer 전체를 브라우저에서 확인한 것이 아니다. vertex 연산은 기존 인터페이스를 모의한 geometry로, Excel은 workbook 생성 옵션으로 검증했다. 다음 QA에서 /map의 편집 도구·꼭지점·우측 탭·유하방향, 조사 상세 필터/Excel, 준설 파일/날짜/위치, GIS 다운로드의 실제 화면 흐름을 확인한다. + +실제 CMS 메뉴 확인과 별도 브라우저 결과는 [003 QA 회귀검증](003-qa-SN-구조정리-회귀검증.md)에 기록한다. 공통 패키지 삭제·공개 계약 검증은 메인 소유의 [004 business_components 구현결과](004-fe-business_components-구조정리-구현결과.md)와 구분한다. + +## 6. 잔여 위험과 롤백 + +- Map 함수의 긴 조회·state/ref·팝업·GIS 편집 조정은 남아 있다. 실제 이벤트 회귀 없이 더 큰 hook 이동을 강행하지 않았다. +- 기존 API/DB/UI 규칙은 보존했다. 현재 lowerCamelCase 단일 경로와 GIS 부분 성공·준설 중복방지 정책도 유지한다. +- 전체 공통 패키지 구조·환경/빌드 정책은 이번 FE 소유가 아니다. config/package/Java/Docker 파일을 수정하지 않았다. +- 브라우저의 외부 북마크까지 미사용임을 증명한 것은 아니다. HTML은 source/CMS 서비스 근거와 명시 승인으로 폐기했으며 필요하면 Git 이력을 기준으로 복원한다. +- 롤백은 이 작업의 추출 import/함수 이동과 삭제61파일만 대상으로 한다. 이전 미커밋 API 단일화·재시도 수정과 다른 작업자 BC 변경을 통째로 되돌리지 않는다. 새 helper와 호출부는 같은 단위로 원복해야 한다. +- 임시 검증 스크립트·로그·JSON·PNG를 새로 남기지 않았다. 정식 집중 테스트와 본 최종 문서는 보존한다. + +파일·업무 로직 변경 후 테스트/타입 증거와 QA 한계를 구분해 합류한다. 빈 폴더 제거는 자동 정책 차단으로 수행하지 못했으며 재시도하지 않았다. diff --git a/apps/sn/docs/작업계획/SN-패키지구조진단/002-fe-business_components-파일역할목록.md b/apps/sn/docs/작업계획/SN-패키지구조진단/002-fe-business_components-파일역할목록.md new file mode 100644 index 000000000..827c7209f --- /dev/null +++ b/apps/sn/docs/작업계획/SN-패키지구조진단/002-fe-business_components-파일역할목록.md @@ -0,0 +1,427 @@ +# 공통 business_components 파일 역할 목록 + +2026-09-11 작업 트리 기준. 루트: `D:/workspace/IIPBS-FRONTEND/frontend/packages/common/business_components`. + +- 최초 진단 목록 167개, 코드 97개. 2026-09-11 승인 정리로 13파일(코드12/CSS1)을 삭제해 현재 같은 범위의 파일은 154개, 코드는 85개다. 삭제 행은 이력으로 남기고 링크를 제거했다. 생성물·설치 의존성·루트 docs·specs·.spar-kit 내부는 제외한다. +- 전체 파일의 선언·경로·주석·정적 import를 분류한 목록이다. 모든 메서드를 수작업으로 동작 검증한 결과는 아니다. +- 참조 수는 점검 대상 내부의 정적 소비 파일 수다. 런타임 DI, 리플렉션, 외부 소비자, 동적 자산 이름은 별도 확인한다. **참조0 또는 경로 미발견만으로 삭제 확정하지 않는다.** +- API·Entity·Next 라우트·타입 선언은 프레임워크가 자동 발견할 수 있다. test/fixture는 정식 회귀 자산이며 임시 테스트 결과가 아니다. +- 주석 발췌는 해당 파일의 설명 중 일부로서 보조 근거다. 파일 전체 역할은 역할 분류/선언과 실제 소비 경로를 함께 본다. +- 최초 진단은 읽기 전용이었다. 후속 승인 정리 결과와 전체 앱 소비 확인은 [공유 코드 정리 결과]()를 따른다. 기존 행 수/참조 수는 최초 진단 시점 값이다. + +## (루트) + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [.gitignore]() | Git 추적 제외 규칙 | | — | 0 | 용도 기준 보존 | +| [.prettierrc]() | 코드 포맷 규칙 | | — | 0 | 용도 기준 보존 | +| [components.json]() | 공통 UI 생성 도구 설정 | | — | 0 | 용도 기준 보존 | +| [eslint.config.js]() | 공유 컴포넌트 정적 분석 규칙 | | 29 | 0 | 등록/도구 진입 규칙 대상 | +| [package.json]() | 공유 컴포넌트의 공개 exports·배포 파일·의존성 계약. index.mjs 경로 점검 필요 | | — | 0 | 용도 기준 보존 | +| [postcss.config.js]() | 공통 CSS 빌드 설정 | | 7 | 0 | 등록/도구 진입 규칙 대상 | +| [README.md]() | 사용법·설계 참고 문서 | | — | 0 | 용도 기준 보존 | +| [tailwind.config.js]() | 공통 스타일 토큰·콘텐츠 설정 | | 9 | 0 | 등록/도구 진입 규칙 대상 | +| [tsconfig.build.json]() | tsup용 TS 설정·스토리 제외 | | — | 0 | 용도 기준 보존 | +| [tsconfig.json]() | 공유 패키지 TS 범위·별칭. 존재하지 않는 SN include 경로 포함 | | — | 0 | 용도 기준 보존 | +| [tsup.config.ts]() | src/index.ts로부터 ESM·타입·CSS 번들 생성 | | 27 | 0 | 등록/도구 진입 규칙 대상 | + +## src + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [base.css]() | 공유 기본 스타일 | | — | 0 | 용도 기준 보존 | +| [chart.md]() | 사용법·설계 참고 문서 | | — | 0 | 용도 기준 보존 | +| [env.ts]() | 보조 함수/자료 구조 | BASE_ASSETS_PATH | 2 | 0 | 정적 경로 미발견·검토 | +| [form.md]() | 사용법·설계 참고 문서 | | — | 0 | 용도 기준 보존 | +| [index.ts]() | 공유 패키지 공개 API 진입점 | | 3 | 62 | 정적 참조 있음 | +| [vite-env.d.ts]() | 자동 포함 타입 선언 | | 2 | 0 | 등록/도구 진입 규칙 대상 | + +## src/lib + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [user-message.ts]() | 보조 함수/자료 구조 | BusinessUserMessageHandler, userMessageHandler, setBusinessUserMessageHandler / 사용하는 앱이 공통 비즈니스 컴포넌트의 사용자 메시지 표시 방식을 연결한다. | 28 | 9 | 정적 참조 있음 | +| [utils.ts]() | 보조 함수/자료 구조 | cn | 7 | 21 | 정적 참조 있음 | + +## src/ui + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.ts]() | 공개 UI 모듈 재수출 목록 | | 32 | 39 | 정적 참조 있음 | + +## src/ui/alert_dialog_ui + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `index.tsx` (삭제) | 옛 대화상자 조합 | ModalType, UiModal | 54 | 0 | 전체 프로젝트 미참조 확인 후 2026-09-11 삭제 | + +## src/ui/chart + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [BarChart.tsx]() | 화면/UI 조합 | customStyles, fncNumberFormet, BarChartProps | 155 | 1 | 정적 참조 있음 | +| [DoubleBar.tsx]() | 화면/UI 조합 | customStyles, BarChartProps, CustomTooltip | 137 | 1 | 정적 참조 있음 | +| [DoughnutChart.tsx]() | 화면/UI 조합 | ChartDataItem, TooltipPayloadItem, LegendEntry / 차트 기본 스타일 (라인차트와 동일하게) | 193 | 1 | 정적 참조 있음 | +| [index.tsx]() | 화면/UI 조합 | CHART_FORM, ChartConfigItem, ChartConfig / 차트 FORM | 94 | 1 | 정적 참조 있음 | +| [LineBarChart.tsx]() | 화면/UI 조합 | customStyles, LineBarChartProps, LegendEntry / 차트 기본 스타일 | 287 | 1 | 정적 참조 있음 | +| [LineBarChart2.tsx]() | 화면/UI 조합 | customStyles, LineBarChartProps, LegendEntry / 차트 기본 스타일 | 187 | 1 | 정적 참조 있음 | +| [LineChart.tsx]() | 화면/UI 조합 | LineChartProps, customStyles, LegendEntry / 차트 기본 스타일 | 283 | 1 | 정적 참조 있음 | + +## src/ui/chart/lib + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [card.tsx]() | 화면/UI 조합 | Card, CardHeader, CardTitle | 78 | 2 | 정적 참조 있음 | +| [chart.tsx]() | 화면/UI 조합 | THEMES, ChartConfig, ChartContextProps | 365 | 6 | 정적 참조 있음 | +| [function.tsx]() | 화면/UI 조합 | formatCommaNumber | 12 | 1 | 정적 참조 있음 | + +## src/ui/date_month_picker + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 화면/UI 조합 | MonthPickerProps, parseYYYYMM, formatDateToYYYYMM | 92 | 3 | 정적 참조 있음 | +| [mothPicker.tsx]() | 화면/UI 조합 | MonthPickerProps, MonthPicker | 146 | 1 | 정적 참조 있음 | + +## src/ui/date_picker + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [date-picker.css]() | 스타일/레이아웃 리소스 | | — | 2 | 용도 기준 보존 | +| [index.tsx]() | 화면/UI 조합 | DatePickerProps, DatePicker | 122 | 1 | 정적 참조 있음 | + +## src/ui/date_picker_range + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 화면/UI 조합 | DatePickerRangeProps, DatePickerRange | 50 | 1 | 정적 참조 있음 | + +## src/ui/dialog_ui + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 화면/UI 조합 | ModalType, UiModal | 59 | 2 | 정적 참조 있음 | + +## src/ui/excel + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [excelStyle.tsx]() | 화면/UI 조합 | loadXlsxPopulate, makeCellStyle, makeExcelFile | 143 | 1 | 정적 참조 있음 | +| [index.tsx]() | 화면/UI 조합 | ExcelDownloadProps, SheetParameter, applyRangeFormat | 439 | 2 | 정적 참조 있음 | + +## src/ui/file-upload + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [file-upload.css]() | 스타일/레이아웃 리소스 | | — | 1 | 용도 기준 보존 | +| [index.tsx]() | 화면/UI 조합 | dropZoneConfig, FileUploadProps, FileUpload / 공통 파일 업로드 UI | 72 | 3 | 정적 참조 있음 | + +## src/ui/file-upload/lib + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [file_upload.tsx]() | 화면/UI 조합 | DirectionOptions, FileUploaderContextType, FileUploaderContext / 파일 업로드의 공통 동작을 담당하는 컨테이너 컴포넌트 | 422 | 1 | 정적 참조 있음 | + +## src/ui/form + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 화면/UI 조합 | Form, FormFieldContextValue, FormFieldContext | 179 | 5 | 정적 참조 있음 | +| `test.tsx` (삭제) | 정식 테스트가 아닌 폼 데모 | Test | 56 | 0 | 미참조 데모 묶음 삭제 | + +## src/ui/form_ui + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [form-ui.css]() | 스타일/레이아웃 리소스 | | — | 1 | 용도 기준 보존 | +| [index.tsx]() | 화면/UI 조합 | BZNM_TOT_NO, CANDIDATE, FormUiProps | 77 | 2 | 정적 참조 있음 | + +## src/ui/form/lib + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `button.tsx` (삭제) | 기초 Button 복제본 | buttonVariants, ButtonProps, Button | 59 | 0 | 전체 프로젝트 미참조 확인 후 삭제 | +| `form.tsx` (삭제) | 데모용 Form 복제본 | Form, FormFieldContextValue, FormFieldContext | 175 | 1 | 삭제한 데모에서만 사용, 함께 삭제 | +| [label.tsx]() | 화면/UI 조합 | labelVariants, Label | 27 | 2 | 정적 참조 있음 | + +## src/ui/list_div + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 화면/UI 조합 | ListDiv | 11 | 1 | 정적 참조 있음 | + +## src/ui/loadingBar + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 화면/UI 조합 | LoadingOverlayProps, LoadingOverlay | 28 | 3 | 정적 참조 있음 | + +## src/ui/main_content_div + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 화면/UI 조합 | MainDiv | 11 | 1 | 정적 참조 있음 | + +## src/ui/map + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [Gis.js]() | 지도/GIS — 보조 함수/자료 구조 | GisContext, useGisContext, Gis | 411 | 15 | 정적 참조 있음 | +| `GisContent.js` (삭제) | 미연결 지도 조합 시안 | GisContent | 62 | 0 | SN/WR 등 전체 프로젝트 미참조 확인 후 삭제 | +| `GisEdit.js` (삭제) | 옛 GIS 편집 UI | | 175 | 0 | 실제 SN 편집 모듈과 별개인 미사용 구현 삭제 | +| `GisLonLat.js` (삭제) | 옛 지도 좌표 표시 | | 45 | 0 | 실제 import 없음, 시안과 함께 삭제 | +| [GisMapToggle.js]() | 지도/GIS — 보조 함수/자료 구조 | | 68 | 1 | 정적 참조 있음 | +| [GisOverlay.js]() | 지도/GIS — 보조 함수/자료 구조 | createOverlayManager | 224 | 1 | 정적 참조 있음 | +| [GisSelect.js]() | 지도/GIS — 보조 함수/자료 구조 | | 82 | 1 | 정적 참조 있음 | +| [GisToolBar.js]() | 지도/GIS — 보조 함수/자료 구조 | | 103 | 1 | 정적 참조 있음 | +| [GisTree.js]() | 지도/GIS — 보조 함수/자료 구조 | makeLegendSrc, makeLegendImage, handleLayerImgClick / Props 요약 | 819 | 1 | 정적 참조 있음 | +| [GisZoomLevel.js]() | 지도/GIS — 보조 함수/자료 구조 | DPI, INCHES_PER_METER, formatScaleValue | 173 | 1 | 정적 참조 있음 | + +## src/ui/map/lib + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [gis.core.js]() | 지도/GIS — 보조 함수/자료 구조 | GisApp / GisApp 전역변수 생성 | 1009 | 31 | 정적 참조 있음 | +| [gis.edit.js]() | 지도/GIS — 보조 함수/자료 구조 | GisOlEditLayer / [공통] Interaction 추가 | 522 | 1 | 정적 참조 있음 | +| [gis.layer.js]() | 지도/GIS — 보조 함수/자료 구조 | GisOlLayer / [사용]빈 레이어 생성 | 851 | 1 | 정적 참조 있음 | +| [gis.measure.js]() | 지도/GIS — 보조 함수/자료 구조 | GisOlMeasure | 205 | 1 | 정적 참조 있음 | +| [gis.select.js]() | 지도/GIS — 보조 함수/자료 구조 | GisOlSelect / SELECT 이벤트 등록 | 520 | 1 | 정적 참조 있음 | +| [gis.style.js]() | 지도/GIS — 보조 함수/자료 구조 | GisOlStyle | 403 | 1 | 정적 참조 있음 | +| [gis.wms-sld.js]() | 지도/GIS — 보조 함수/자료 구조 | FLOOD_DNGR_MAP_LAYER_TYPE, FLOOD_DEPTH_ATTRIBUTE, FLOOD_DEPTH_CODES / 빈도 부모 레이어 바로 아래에 있는 침수심도 설정만 선별한다. | 104 | 1 | 정적 참조 있음 | +| [util.js]() | 지도/GIS — 보조 함수/자료 구조 | util, getArrayByObjectKey, numberDotToUnderscore / 다이렉트로 import하여 사용하기 위해 export로 수정중 | 646 | 3 | 정적 참조 있음 | + +## src/ui/map/lib/css + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [gis.common.css]() | 스타일/레이아웃 리소스 | | — | 1 | 용도 기준 보존 | +| [gis.edit.css]() | 스타일/레이아웃 리소스 | | — | 1 | 용도 기준 보존 | +| [gis.roadview.css]() | 스타일/레이아웃 리소스 | | — | 0 | 용도 기준 보존 | +| [gis.sketch.css]() | 스타일/레이아웃 리소스 | | — | 0 | 용도 기준 보존 | +| [gis.tooltip.css]() | 스타일/레이아웃 리소스 | | — | 0 | 용도 기준 보존 | + +## src/ui/map/lib/images/marker + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [marker-icon-boon.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [marker-icon-hab.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [marker-icon.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | + +## src/ui/map/lib/images/toolbar + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [btn_cont_area_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_area.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_next_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_next.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_prev_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_prev.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_print_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_print.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_ruler_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_ruler.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_save_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_save.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_sketch_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_sketch.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_zoomin_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_zoomin.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_zoomout_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_cont_zoomout.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_add_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_add.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_del_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_del.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_next_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_next.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_prev_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_prev.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_save_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_save.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_slt_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_slt.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_snap_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [btn_edit_snap.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [ico_gis_sprite.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [icon-viewclose.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [img_map1.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [img_map2.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [img_map3.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [img_map4.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [loading.gif]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [map_pin.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [toggle_off.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | +| [toggle_on.png]() | 지도 아이콘·폰트 등 정적 자산(동적 참조 별도 확인) | | — | 0 | 용도 기준 보존 | + +## src/ui/map/searchBar + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | | 3 | 2 | 정적 참조 있음 | +| [procSearchBar.tsx]() | 지도/GIS — 화면/UI 조합 | geoserverUrl, FACILITY_TYPE_URL_MAP, DEFAULT_SWAGE_PRCS_FCLT_LABEL / 관망도 전용 3개 셀렉트와 조회 버튼을 하나로 연결한 검색바 레이아웃이다. | 602 | 1 | 정적 참조 있음 | +| [searchBar.tsx]() | 지도/GIS — 화면/UI 조합 | FACILITY_TYPE_URL_MAP, SearchBarProps, CenterSelectDataType | 472 | 1 | 정적 참조 있음 | +| [SearchInputText.tsx]() | 지도/GIS — 화면/UI 조합 | SearchInputTextProps, SearchInputText | 125 | 1 | 정적 참조 있음 | +| [utils.ts]() | 지도/GIS — 보조 함수/자료 구조 | getGeometryCenter, getMapCenterCoordinate, moveMapToCenter / 이미 5179좌표계이면 변환하지 않음. | 409 | 4 | 정적 참조 있음 | + +## src/ui/popover_ui + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 화면/UI 조합 | PopoverUi | 23 | 1 | 정적 참조 있음 | + +## src/ui/scroll + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 화면/UI 조합 | ScrollArea, ScrollBar | 47 | 1 | 정적 참조 있음 | + +## src/ui/sdmInsertForm + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [form.tsx]() | 화면/UI 조합 | DemandCompType, SelectDataType, FormSchema | 605 | 2 | 정적 참조 있음 | +| [index.tsx]() | 화면/UI 조합 | thisProps, createFormSchema, FormSchema / 수요처 등록 폼 | 249 | 1 | 정적 참조 있음 | +| [sdmNameBox.tsx]() | 화면/UI 조합 | thisProps, SdmSearchForm / 수요처 검색 컴포넌트 | 143 | 1 | 정적 참조 있음 | + +## src/ui/search_address + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.ts]() | 기존/공개 import 경로를 실제 구현으로 연결하는 재수출 | | 3 | 1 | 정적 참조 있음 | +| [SearchAddressModal.tsx]() | 화면/UI 조합 | SearchAddressModalProps, useDebouncedValue, SearchAddressModal / 주소검색 | 343 | 2 | 정적 참조 있음 | +| [zipService.ts]() | 보조 함수/자료 구조 | SortConfig, PageableConfig, PageableList / 입력 문자열에서 자동완성 조회용 도로명만 추출한다. | 340 | 2 | 정적 참조 있음 | + +## src/ui/search_base_form + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 화면/UI 조합 | SearchBaseType, ReturnType, SearchBaseForm | 206 | 1 | 정적 참조 있음 | + +## src/ui/search_form + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 화면/UI 조합 | SearchDiv | 10 | 1 | 정적 참조 있음 | + +## src/ui/search_form_left + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 화면/UI 조합 | SearchFormLeftProps, EnvPartSelectDataType, selectPartData / 검색 폼 왼쪽 컴포넌트 타입 | 407 | 2 | 정적 참조 있음 | +| [searchFormLeft.md]() | 사용법·설계 참고 문서 | | — | 0 | 용도 기준 보존 | + +## src/ui/search_form_right + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 화면/UI 조합 | SearchFormRight | 12 | 2 | 정적 참조 있음 | + +## src/ui/search_icon + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `index.tsx` (삭제) | 옛 검색 SVG | SearchIcon | 21 | 0 | 전체 프로젝트 미참조 확인 후 삭제 | +| `search-icon.css` (삭제) | 검색 SVG 전용 스타일 | | — | 1 | 삭제한 SearchIcon에서만 import/클래스 사용 | + +## src/ui/search_input + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 화면/UI 조합 | SearchInput | 33 | 1 | 정적 참조 있음 | +| [search-input.css]() | 스타일/레이아웃 리소스 | | — | 1 | 용도 기준 보존 | + +## src/ui/search_input_address + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| `findAddress.ts` (삭제) | 옛 주소 검색/필터 | AddressApiResponse, addressType, findAddress | 121 | 0 | 활성 search_address와 별개인 미사용 묶음 삭제 | +| `index.tsx` (삭제) | 전체 주석 상태의 주소 입력 시안 | | 132 | 0 | 실행 구현/public export 없음 | +| `zipService.ts` (삭제) | 옛 주소 모델 | SortConfig, PageableConfig, PageableList | 67 | 1 | 삭제한 findAddress에서만 사용 | + +## src/ui/search_input_select + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 화면/UI 조합 | SelectDataType, SearchInputSelect / 검색 입력 및 선택 컴포넌트 | 49 | 4 | 정적 참조 있음 | +| [search-input-select.css]() | 스타일/레이아웃 리소스 | | — | 1 | 용도 기준 보존 | + +## src/ui/select + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 화면/UI 조합 | SelectBox1, SelectBox / 차트 기본 스타일 | 117 | 7 | 정적 참조 있음 | +| [select.css]() | 스타일/레이아웃 리소스 | | — | 1 | 용도 기준 보존 | + +## src/ui/select/lib + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [selectBox.tsx]() | 화면/UI 조합 | Select, SelectGroup, SelectValue | 159 | 2 | 정적 참조 있음 | + +## src/ui/standard_data_table + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 화면/UI 조합 | ScrollMode, StandardDataTableRenderContext, StandardDataTableLayout / 팝업처럼 부모가 카드 간격을 직접 관리할 때 바깥 카드의 기본 margin을 제거한다. | 568 | 1 | 정적 참조 있음 | +| [sort.tsx]() | 화면/UI 조합 | STANDARD_TABLE_SORT_COLLATOR, isEmptySortValue, toComparableNumber / 공통 표 헤더의 asc → desc → 기본 순환 상태를 계산한다. | 146 | 1 | 정적 참조 있음 | +| [standard-data-table.css]() | 스타일/레이아웃 리소스 | | — | 1 | 용도 기준 보존 | + +## src/ui/standard_form + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 화면/UI 조합 | StandardFormColumns, StandardFormFieldSpan, SECTION_GRID_CLASS / CS 표형 입력의 섹션 헤더와 반응형 2~3열 배치를 업무 화면에 공통 제공한다. | 119 | 1 | 정적 참조 있음 | + +## src/ui/standard_popup + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 화면/UI 조합 | StandardPopupLayoutProps, StandardPopupLayout / 이미 업무별 취소 액션을 제공하는 화면에서만 false로 설정한다. | 86 | 1 | 정적 참조 있음 | + +## src/ui/tab + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 화면/UI 조합 | TabItem, UITabProps, UITab | 62 | 2 | 정적 참조 있음 | +| [tab.css]() | 스타일/레이아웃 리소스 | | — | 1 | 용도 기준 보존 | + +## src/ui/table + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [HeadMakeColSpan.tsx]() | 화면/UI 조합 | HeadMakeColSpanProps, HeadMakeColSpan / 마지막 헤더 행까지 차지하는 실제 데이터 컬럼인지 판별한다. | 121 | 1 | 정적 참조 있음 | +| [index.tsx]() | 화면/UI 조합 | ChkClass, UiTableProps, UiTable / 정렬 가능한 헤더를 asc, desc, 기본 순서로 순환시킨다. | 386 | 6 | 정적 참조 있음 | +| `table.tsx` (삭제) | 기초 Table 복제본 | Table, TableHeader, TableBody | 118 | 0 | 활성 index.tsx는 공통 Table 사용, 복제본 소비 없음 | +| [types.ts]() | 보조 함수/자료 구조 | UiTableSortDirection, UiTableSort, UiHeadProps | 20 | 3 | 정적 참조 있음 | +| [uiPageNations.tsx]() | 화면/UI 조합 | CommonPageNationsProps, UiPageNations | 81 | 2 | 정적 참조 있음 | + +## src/ui/table_div + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 화면/UI 조합 | ContentsDiv | 11 | 1 | 정적 참조 있음 | + +## src/ui/table/lib + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [button.tsx]() | 화면/UI 조합 | buttonVariants, ButtonProps, Button | 57 | 1 | 정적 참조 있음 | +| [pagination.tsx]() | 화면/UI 조합 | Pagination, PaginationContent, PaginationItem | 119 | 1 | 정적 참조 있음 | + +## src/ui/title + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [index.tsx]() | 화면/UI 조합 | Title | 11 | 1 | 정적 참조 있음 | + +## types + +| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | +|---|---|---|---:|---:|---| +| [mdx.d.ts]() | 자동 포함 타입 선언 | | 6 | 0 | 등록/도구 진입 규칙 대상 | + + +## 검증 한계와 롤백 + +문서만 추가했다. 코드·DB·프로세스 변경은 없으며 업무 롤백은 필요 없다. 실제 삭제/이동은 프레임워크 등록·동적 참조·외부 소비자를 확인하고 테스트/브라우저 회귀 후 별도 승인 단위로 진행해야 한다. diff --git a/apps/sn/docs/작업계획/SN-패키지구조진단/003-qa-SN-구조정리-회귀검증.md b/apps/sn/docs/작업계획/SN-패키지구조진단/003-qa-SN-구조정리-회귀검증.md new file mode 100644 index 000000000..83a532a8d --- /dev/null +++ b/apps/sn/docs/작업계획/SN-패키지구조진단/003-qa-SN-구조정리-회귀검증.md @@ -0,0 +1,94 @@ +# SN 구조정리 회귀 검증 + +## 작업 목적과 범위 + +- 2026-09-11 구조정리 후 기존 지도, 조사 상세, 준설 등록 입력 화면, GIS 관리 조회 흐름의 회귀를 확인한다. +- 직접 수정 범위는 이 보고서와 이번 QA 전용 임시 실행 파일이다. BE/FE 업무 코드, 설정, 패키지, DB와 사용자 실행 서버는 수정하지 않는다. +- 사용자 SN `http://localhost:3002`를 사용한다. 개발 서버 종료·재시작·빌드는 수행하지 않는다. +- 완료 조건은 CMS 메뉴의 `/html` 참조 확인, 주요 시나리오 실제 조작, API/화면 결과와 제한 사항 기록 및 QA 소유 브라우저·임시 파일 정리다. + +## 원본 직접 확인 + +요구사항정의서 `3. (기능)요구사항정의 및 추적표`의 다음 행을 직접 읽고 화면설계서 원본의 해당 슬라이드 텍스트와 대조했다. 원본 파일은 변경하지 않았다. + +| 원본 요구사항 | 시트 행 | 화면설계서 근거 | 이번 검증 관점 | +| --- | ---: | --- | --- | +| SFR-010-004 | 76 | 39장 SN_MN040105, 43장 SN_MN040101 | 사업·처리분구·조사결과 상세 표시와 GIS 번호 연계. 업로드·검토·저장 실제 실행은 제외 | +| SFR-010-012, SFR-010-013 | 84, 85 | 53장 SN_MN040201 | 준설 입력 폼·관로 추가·사진 5장 안내·촬영일자/GPS·위치등록 안내 표시. 저장 제외 | +| SFR-012-001 | 96 | 36장 SN_MN010303 | 지도와 시설 속성 및 추가 조사 현황 | +| SFR-012-002 | 97 | 35장 SN_MN010302 | GIS 관리 시설 구분·업로드 현황 조회. 원본과 편집본 분리 조건 확인, 업로드·다운로드 작업 제외 | +| SFR-012-003, SFR-012-004 | 98, 99 | 34장 SN_MN010301 | GIS 편집 UI 연결과 원본 분리 보관 조건 확인. 객체 변경·저장 제외 | +| SFR-012-005 | 100 | 위 지도 화면과 연결 요구사항 | 시종점 관저고 고저차 기반 유하방향 조건. 이번 제한된 UI 회귀로 계산 정확성 전체를 판정하지 않음 | + +- 직접 열람한 원본 크기: 요구사항정의서 124,187바이트, 화면설계서 10,736,400바이트. +- 원본의 업무 저장·업로드·편집 요구사항 전체를 통과시키는 인수시험이 아니라, 승인된 구조정리에 따른 읽기 및 입력 화면 회귀다. + +## 방법과 실행 상태 + +- 기존 `SN-로컬개발비교검증/001-qa-SN-로컬개발서버-메뉴별비교결과.md`에 기록된 사용자 승인 독립 Playwright 방식을 적용한다. +- 번들 Node/Playwright와 설치된 `C:/Program Files/Google/Chrome/Application/chrome.exe`를 이용하며 1600×1000 크기의 별도 headless 세션을 사용한다. +- 정상 로그인 세션을 사용한다. 인증정보·토큰·쿠키·storageState·원시 응답을 결과 파일에 출력하지 않는다. +- 업무 쓰기 HTTP method는 브라우저 요청 경계에서 차단한다. 정상 로그인·refresh·CMS 접속로그·Next 오류 프레임 조회만 예외로 허용한다. +- 루트 `/`의 기존 자동로그인이 계정을 바꿀 수 있으므로 시험 세션의 루트 문서 이동만 차단하고, 정상 로그인 후 업무 경로로 직접 이동한다. +- 추가 서버, 빌드, 대형 AST 스캔은 실행하지 않는다. + +| 확인 항목 | 현재 상태 | 근거·제약 | +| --- | --- | --- | +| CMS 메뉴 `/html` 참조 | 승인된 개발 환경에서 0건 확인 | 17:08~17:09 `mindone03` 정상 로그인 및 사용자 정보 200 확인 후 GET `/server/cms-service/api/v1/menu-roles/SN` 200. 전체 13개 노드·10개 URL의 재귀 검사 및 전체 JSON 문자열에서 `/html` 0건 | +| 지도 초기화·레이어·유하방향·GIS 편집 도구 | 미검증 | FE 동결 후에도 로컬 로그인 불응답. 지도 렌더링·도구 조작에 도달하지 못함 | +| 조사 상세·Excel 다운로드 | 미검증 | 실제 사업·처리분구·상세 진입 및 다운로드에 도달하지 못함 | +| 준설 등록 입력·취소 | 미검증 | 등록 폼 열기·취소에 도달하지 못함. 저장 요청 없음 | +| GIS 관리 열기·조회 | 미검증 | 이력 조회에 도달하지 못함. 업로드·변환 요청 없음 | +| 활성 UiTable·UiForm 조작 | 미검증 | 변경 후 실제 사용자 조작에 도달하지 못함 | + +초기 QA 브라우저 제어 호출이 도구 시간 제한으로 종료되어 자동 커널 초기화가 발생했다. 이후 기동·문서 이동을 비동기 promise로 분리했다. 실제 로그인 경로는 `/auth/login`이며, 로컬 문서 이동은 180초, 별도 읽기 요청은 10초에 각각 시간 초과했다. 환경 설정을 변경하거나 사용자 서버를 재시작하지 않았다. + +같은 DB·같은 계정 사용이 승인된 개발 환경 `http://con.mind-one.co.kr:11302`로 메뉴만 확인했다. 정상 로그인 후 루트 문서 이동을 차단하고 `/oper/exmn/register`로 직접 이동했다. 사용자 정보 `/server/user-service/api/v1/users/info/me`의 200 응답에서 `mindone03`임을 확인했다. 초기 prefix 없는 CMS 요청의 401과 이후 실제 `/server` 경로의 메뉴 200을 구분한다. 개발 환경의 응답을 로컬 구조정리 후 브라우저 회귀 통과로 사용하지 않는다. + +확인된 메뉴 URL은 `/map`, `/ii/irsetting`, `/ii/result`, `/ii/rdii`, `/ii/rdiiResult`, `/oper/ctyFldngPrvtnBiz`, `/oper/obsltPplBiz`, `/oper/exmn/register`, `/oper/dredging`, `/oper/trtdst`다. `/html/map`과 `/html/aged-pipe`를 포함한 `/html` 참조는 없다. 이번 계정의 권한별 메뉴 트리 범위이며 CMS의 모든 미사용/비공개 메뉴 행을 전수 조회한 결과는 아니다. + +재현은 승인된 계정으로 `/auth/login`에서 정상 로그인한 후 위 업무 경로의 Network에서 GET `/server/cms-service/api/v1/menu-roles/SN` 응답을 확인한다. 응답의 `children`을 재귀 순회하여 `urlPath`에서 `/html`을 검사한다. 브라우저 실행은 번들 Playwright의 `chromium.launch({ executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true })`와 `page.goto(..., { waitUntil: 'domcontentloaded', timeout: 180000 })`를 사용했다. 실제 계정 정보와 인증값은 명령/결과에 기록하지 않는다. + +## API/DB 영향과 남은 위험 + +- QA에 따른 업무 API 계약·DB 스키마 변경은 없다. 업무 등록·수정·삭제 요청은 실행하지 않는다. 로그인·접속로그의 정상 자동 기록은 업무 쓰기와 구분한다. +- 로컬 주요 업무 시나리오는 FE 동결 후에도 계속된 응답 지연으로 미검증이다. 단순 포트 리스닝이나 HTML 상태 코드로 회귀 통과를 대체하지 않는다. +- FE와 메인 에이전트에게 개발 환경의 현재 계정 메뉴 `/html` 참조 0건 근거를 전달했다. 다른 권한 계정의 숨은 메뉴나 직접 북마크 사용 여부는 이 결과만으로 전수 입증하지 않는다. +- QA 브라우저 소유 PID와 종료·임시 파일 정리 결과는 최종 검증 후 이 문서에 반영한다. + +## FE 동결 후 최종 판정 + +FE 코드 동결과 QA 도구 전용 시간 통지를 받은 뒤 로컬 로그인을 재확인했다. 2026-09-11 17:28:11 KST에 시작한 15초 제한 요청이 17:28:26에 `The operation was aborted due to timeout`으로 종료됐다. 실행한 읽기 요청은 다음과 같다. + +```javascript +fetch('http://localhost:3002/auth/login', { + signal: AbortSignal.timeout(15000), +}); +``` + +변경 전 브라우저 문서 이동 180초 및 읽기 요청 10초, 변경 후 읽기 요청 15초가 모두 시간 초과했다. 메인 에이전트 지시에 따라 추가 브라우저 기동·반복 접속을 중지했다. 후속 회귀용 새 Chrome은 기동하지 않았으며 사용자 서버는 재시작하지 않았다. + +- **변경본 브라우저 회귀는 미완료다.** 지도·조사·준설·GIS·UiTable·UiForm을 통과로 판정하지 않는다. +- 로컬 업무 API 응답 코드와 화면 콘솔 오류를 수집할 단계에 도달하지 못했다. 이 검증에서 업무 4xx/5xx가 없었다고 주장하지 않는다. +- 같은 증상이 변경 전후 나타났으므로 현재 원인은 로컬 응답 지연으로 분류하며, 이번 구조정리의 코드 결함 또는 정상 동작으로 단정하지 않는다. +- 개발 CMS 확인 시 정상 로그인·사용자정보·메뉴 응답은 200이었다. 초기 잘못된 prefix의 CMS 요청은 401이었고, 개발 페이지 오류 이벤트 `H` 1건을 관찰했다. 이후 실제 `/server` 경로 메뉴 조회는 성공했다. 이 초기 오류를 로컬 변경본 결함으로 전용하지 않는다. +- 기존에 승인된 독립 Playwright 절차와 원본 요구사항 확인에 따라 검증 범위를 정했다. 업무 코드·설정·DB는 변경하지 않았다. + +## 환경 및 정리 중간 기록 + +- 초기 자동 커널 초기화 뒤 남은 QA Chrome은 PID 23576, 부모 334812이며 명령행의 별도 Playwright 프로필 `C:/Users/mindone/AppData/Local/Temp/playwright_chromiumdev_profile-fmC4MK`로 소유를 확인했다. 사용자 Chrome PID 27904는 대상에서 제외했다. +- 정확한 QA PID 23576에 대한 `taskkill /PID 23576 /T /F`는 종료 코드 255, 출력 없음으로 끝났다. 이후 `tasklist /FI "PID eq 23576" /FO CSV /NH`에서는 124KB 상태로 잔존했다. 프로세스 정리를 완료했다고 판정하지 않는다. +- 메뉴 검증에 사용한 두 번째 별도 QA 브라우저에는 `browser.close()`를 비동기로 요청했다. 이후 단순 상태 출력 호출마저 30초 도구 timeout으로 자동 커널 초기화되어 정상 종료 여부를 확인하지 못했다. 명시적인 커널 reset은 호출하지 않았다. 후속 실행 전 QA 잔존 프로세스 확인이 필요하다. +- 이번 QA에서 일회성 runner, 캡처, 로그, 인증 덤프나 원시 응답 파일은 만들지 않았다. 삭제 완료된 원시 파일은 0개다. 자동 생성 브라우저 프로필은 프로세스 종료 확인 전 임의 삭제하지 않았다. 이전 작업에서 정책 차단된 임시 경로도 재삭제하지 않았다. + +## 정리 최종 확인 + +- 후속 `tasklist /FI "PID eq 23576" /FO CSV /NH`는 종료 코드 0과 `No tasks are running which match the specified criteria`를 반환했다. 초기 QA PID 23576의 종료를 최종 확인했다. +- Chrome 프로세스 중 명령행에 `playwright_chromiumdev_profile`이 있는 항목을 읽기 전용으로 조회한 PowerShell 명령도 종료 코드 0, 일치 결과 없음이었다. 앞선 중간 잔존 기록과 구분한다. 사용자 Chrome 및 SN/BE 서버 종료 요청은 하지 않았다. +- 처음 생성한 프로필 `C:/Users/mindone/AppData/Local/Temp/playwright_chromiumdev_profile-fmC4MK`는 남아 있다. 정확한 절대 경로와 reparse point 검사를 포함한 `Remove-Item -LiteralPath ... -Recurse -Force` 요청이 자동 승인 검토에서 실행 전에 `blocked by policy`로 거절됐다. 구체적인 차단 사유는 제공되지 않았다. 다른 도구로 우회 삭제하지 않았다. +- 따라서 원시 runner/로그/캡처 삭제 대상은 0개이며, 알려진 자동 생성 프로필 1개는 정책 차단으로 보존된 상태다. 삭제가 실행되지 않아 이 프로필의 복구 절차는 필요하지 않다. 다른 작업의 정책 차단 경로는 재시도하지 않았다. +- 후속 회귀용 새 브라우저는 기동하지 않았다. 최종 보고서는 이 문서만 추가했으며 한글 UTF-8 재열람을 확인했다. + +## 롤백 + +이번 QA의 영구 산출물은 이 문서다. 업무 코드·DB·서버 설정을 변경하지 않았으므로 업무 롤백은 필요하지 않다. diff --git a/apps/sn/docs/작업계획/SN-패키지구조진단/004-fe-business_components-구조정리-구현결과.md b/apps/sn/docs/작업계획/SN-패키지구조진단/004-fe-business_components-구조정리-구현결과.md new file mode 100644 index 000000000..30fc1dae4 --- /dev/null +++ b/apps/sn/docs/작업계획/SN-패키지구조진단/004-fe-business_components-구조정리-구현결과.md @@ -0,0 +1,86 @@ +# business_components 미사용 코드 정리 결과 + +## 1. 작업 목적과 범위 + +- 2026-09-11 사용자 승인: 패키지진단 7.3의 공유 코드 후보를 SN뿐 아니라 전체 프로젝트에서 참조 확인 후 정리. +- 대상: `packages/common/business_components/src` 내부. 다른 앱과 다른 공유 패키지는 읽기 전용 조사만 수행했다. +- 배포·Java·Docker·패키지/빌드/환경 설정은 변경하지 않았다. `src/env.ts`는 미참조 후보지만 환경 변경 금지 지시를 우선해 보존했다. + +## 2. 확인한 근거 + +- `apps` 전체: `_template`, `as`, `cm`, `common`, `cs`, `dc`, `dm`, `fo`, `sn`, `st`, `wr`. `apps/common` 하위도 포함했다. +- `packages` 전체: common뿐 아니라 다른 회사/프로젝트 패키지도 검색했다. 설치/빌드 산출물(`node_modules`, `.next`, `dist`)과 과거 보고서의 문자열은 실행 소스의 참조와 구분했다. +- TS/JS/JSX/TSX/MJS/CJS/JSON/HTML/CSS에서 하위 경로와 모듈명, 전용 CSS명/클래스를 검색했다. `search_input_address`, `search_icon`, `alert_dialog_ui`, `ui/table/table`, `ui/form/test`, `ui/form/lib/form`, `ui/form/lib/button`, `map/GisContent`, `map/GisLonLat`, `map/GisEdit`의 외부 소비를 찾지 못했다. +- `src/index.ts`와 `src/ui/index.ts` 및 실제 table/form 진입점을 직접 확인했다. 삭제 후보는 공개 export 경로에 포함되지 않았다. `search_input_address` export는 실행되지 않는 주석이었다. +- WR의 `components/ui/modal/fcltReg/fcOrgwtsRegModal/fcOrgwtsRegModal.tsx`는 `@common/business_components/ui/form/lib/label`을 실제 사용하므로 `form/lib` 폴더 전체를 삭제하지 않았다. +- 후속 정적 import/export 대조: public/vendor를 제외한 TS/JS 계열 소스 3,042개에서 BC 관련 소스를 선별하고 프로젝트별 tsconfig로 모듈을 해석했다. DM·FO·SN·WR 4개 앱의 활성 참조 388개와 공개 진입점 export 32개를 확인했다. 삭제 후보의 잔존 해석 참조는 0개였고, 선별된 BC 관련 소스의 비리터럴 import/require도 0개였다. public/HTML/CSS는 앞의 별도 문자열 검색으로 확인했다. +- 9월 14일에도 전체 apps/packages에서 삭제 후보 경로·전용 CSS 이름을 다시 검색해 일치0건을 확인했다. 문서·설치 의존성·생성물은 제외하고 실행 소스/설정/HTML/CSS를 대상으로 했다. +- 삭제 전 `git status --short -- packages/common/business_components`는 변경이 없었다. 기존 사용자 수정 파일을 삭제한 것은 아니다. +- 요구사항정의서 `3. (기능)요구사항정의 및 추적표` SFR-010-004/012/013, SFR-012-001~005 및 화면설계서 5/34/35/36/43/53슬라이드를 직접 확인했다. 이번 정리는 공유 UI의 미사용 구현 제거이며 원본 화면/업무 계약 변경이 아니다. + +## 3. 삭제 파일과 이유 + +아래 경로는 `D:/workspace/IIPBS-FRONTEND/frontend/packages/common/business_components/src/` 기준이다. 삭제된 파일이므로 링크 대신 경로를 기록한다. + +| 삭제 파일 | 기존 역할 | 삭제 이유 | +|---|---|---| +| `ui/table/table.tsx` | 기초 Table 요소 복제본 | 활성 UiTable은 `@common/components`의 Table을 사용. 이 복제본의 소비 없음 | +| `ui/search_input_address/index.tsx` | 이전 주소 입력 시안 | 구현 전체가 주석이고 공개 export도 주석 | +| `ui/search_input_address/findAddress.ts` | 이전 주소 검색 요청/필터 | 활성 `search_address`와 별개이며 소비 없음 | +| `ui/search_input_address/zipService.ts` | 이전 주소 모델 | 삭제하는 findAddress만 참조하는 닫힌 묶음 | +| `ui/search_icon/index.tsx` | 별도 검색 SVG | 전체 앱/패키지에서 소비 없음 | +| `ui/search_icon/search-icon.css` | 검색 SVG 전용 스타일 | 삭제하는 SearchIcon에서만 import, 클래스도 그곳에서만 사용 | +| `ui/alert_dialog_ui/index.tsx` | 이전 UiModal 구현 | 활성 dialog_ui와 별개, 공개/하위 import 없음 | +| `ui/map/GisContent.js` | 옛 지도 조합 시안 | 실제 지도는 SN/WR에서 구성. 소비 없음, 미정의 JSX/핸들러가 남은 시안 | +| `ui/map/GisLonLat.js` | 옛 좌표 표시 컨트롤 | 실제 import 없음. 옛 GisContent 내 미import JSX만 존재 | +| `ui/map/GisEdit.js` | 이전 GIS 편집 UI | 현재 SN gisEdit 모듈과 별개이며 소비 없음 | +| `ui/form/test.tsx` | 사용자명 폼 데모 | 실제 앱/공개 export와 연결 없음. 정식 회귀 테스트가 아님 | +| `ui/form/lib/form.tsx` | 데모용 Form 복제본 | 삭제하는 데모에서만 import | +| `ui/form/lib/button.tsx` | 기초 Button 복제본 | 소비 없음 | + +총 **13파일(코드12, CSS1)**을 삭제했다. `src/ui/index.ts`에서는 삭제한 옛 주소 입력의 주석 export 한 줄만 제거했다. + +## 4. 수정 코드와 설계 이유 + +변경 전 공개 목록의 일부: + +```ts +export * from "./search_input_select"; +// export * from "./search_input_address"; +export * from "./search_address"; +``` + +변경 후: + +```ts +export * from "./search_input_select"; +export * from "./search_address"; +``` + +실행되는 export 목록은 동일하다. 삭제된 구현을 가리키는 주석만 없애 유지보수자가 잘못된 진입점을 다시 활성화하지 않도록 했다. 미사용 코드에 새 wrapper나 대체 구현은 만들지 않았다. + +## 5. 유지 파일 및 영향 경계 + +- `src/env.ts`, package.json, tsconfig, tsup 설정, dist: 환경/소비 계약 보존. +- `ui/table/index.tsx`, HeadMakeColSpan/types/uiPageNations: 실제 공통 표 유지. +- `ui/form/index.tsx`, `ui/form/lib/label.tsx`: 실제 공통 폼 및 WR 하위 경로 소비 유지. +- `ui/dialog_ui`, `ui/search_address`: 실제 대화상자/주소검색 유지. +- `ui/map/Gis.js`, GisTree 및 `map/lib` GIS 엔진, `gis.edit.css`: 실제 지도 기반 유지. GisContent가 참조했다고 함께 삭제하지 않았다. +- 공개 API/스타일은 변경 의도가 없으며 데이터·인증·DB·외부 URL도 변경하지 않았다. + +## 6. 테스트 / 검증 + +- 확인됨: 전체 앱/패키지 경로 검색 및 후보 전용 CSS 참조 확인, public export/활성 형제 파일 수동 대조, 삭제 전 공유 패키지 dirty 상태 없음. +- 확인됨: 삭제 파일 13개 존재 여부 재확인 결과 모두 없음. 후보 전용 폴더 이외의 table/form/map 구현은 보존했다. +- 확인됨: `git diff --check -- packages/common/business_components` 종료코드 0, 오류/경고 출력 없음. +- 분석 도중 첫 전체 AST 실행은 지연으로 중단했고, 두 번째 실행은 병행 삭제된 파일을 읽다가 실패했다. vendor 파싱을 제외하고 파일 존재를 확인하도록 보완한 세 번째 실행이 종료코드 0으로 완료됐다. 실패한 실행을 검증 통과 건수에 포함하지 않았다. +- 9월 14일 통합 SN typecheck 종료0, 전체 정식 테스트69/69(실패/skip0), 모듈 경계40파일/위반0을 확인했다. 브라우저 회귀는9/11 로컬 응답 timeout, 9/14 서버 미기동으로 미완료다. `003-qa-SN-구조정리-회귀검증.md` 및 공통 통합 결과에서 한계를 구분한다. +- 타 프로젝트 전체 빌드/브라우저 회귀를 수행했다는 뜻은 아니다. 이번 공유 정리는 소비처가 없는 소스의 제거이며 모든 타 앱의 런타임을 통과 처리하지 않는다. + +## 7. 롤백과 남은 리스크 + +- 삭제 파일은 Git 추적 파일이다. 이번 변경의 삭제 diff만 역적용해 복구할 수 있다. 다른 사용자 수정까지 되돌리는 저장소 전체 reset은 사용하지 않는다. +- 파일 삭제 후 빈 폴더 `ui/search_input_address`, `ui/search_icon`, `ui/alert_dialog_ui` 3개를 절대경로 검증·비재귀 방식으로 제거하려 했으나 도구 정책이 차단했다. 다른 도구로 우회하지 않았으며 빈 디렉터리는 남아 있다. 삭제한 파일 13개가 다시 존재한다는 의미는 아니다. +- 이번 작업의 일회성 분석 스크립트 `audit-bc-cleanup.cjs`는 결과를 본 문서에 요약한 후 삭제했다. 별도 원시 JSON/캡처 파일은 생성하지 않았다. +- 워크스페이스 밖의 미제공 외부 프로젝트나 런타임에 문자열을 조립하는 소비는 정적 검색만으로 완전히 증명할 수 없다. 이 저장소의 apps/packages 및 공개 진입점에 대한 확인 범위와 구분한다. +- 기존 패키지 ESM 진입점 불일치 등 환경/빌드 문제는 사용자 제외 범위이므로 수정하지 않았다. diff --git a/apps/sn/docs/작업계획/SN-프론트엔드리팩토링/001-fe-SN-프론트엔드리팩토링-결과보고서.md b/apps/sn/docs/작업계획/SN-프론트엔드리팩토링/001-fe-SN-프론트엔드리팩토링-결과보고서.md new file mode 100644 index 000000000..c1fe6254e --- /dev/null +++ b/apps/sn/docs/작업계획/SN-프론트엔드리팩토링/001-fe-SN-프론트엔드리팩토링-결과보고서.md @@ -0,0 +1,362 @@ +# SN 프론트엔드 1차 리팩토링 결과보고서 + +> 2026-09-11 API 계약 대체 이력: 본문의 과거 호출 URL은 당시 검증·구현 증거이며 현재 호출 계약이 아니다. 현재는 lowerCamelCase 단일 경로만 지원하고 기존 alias는 제거된다. [경로 단일화 변경기록](../../작업계획/SN-API경로단일화/001-fe-SN-API경로단일화-변경기록.md)의 현재 경로로 검증한다. + +작성일: 2026-09-10. 범위: API 통신 기반 분리, HTTP 오류 전달, 준설 등록 재시도, QueryClient 수명. 코드 반영·독립 검증 완료, 실제 사용자 시나리오 검증은 환경 차단으로 미완료다. + +## 1. 작업 목표와 원본 근거 + +- 목표: 실패한 등록·삭제가 성공 경로로 진행되는 문제를 막고, 준설 등록의 동일 요청 재시도에 같은 중복방지 ID를 사용한다. API 통신 기반과 화면의 책임을 분리한다. +- 허용 경로: `frontend/apps/sn/**`, `frontend/packages/common/business_components/**`. 이번 변경은 모두 `apps/sn/**` 안에 있다. +- 금지 경로: 다른 앱, `packages/common/network`, `auth`, `components`, `config`, 프론트 루트 문서, BE 코드·설정·DB. 해당 경로는 변경하지 않았다. +- 완료 기준: 기존 요청·성공 응답·화면 항목 보존, 실패 reject, 재시도 키 검증, 타입검사, 사용자 흐름·상태 영향·QA 재현 절차와 미검증 위험 기록. 브라우저 시나리오 통과를 별도 남은 게이트로 둔다. +- 기준: 공통 및 SN·BE `AGENTS.md`, 공통 `008/010/011/012` 기준, SN `003-API사용`, `005-상태관리정책`, `006-검증정책`, `015-하네스엔지니어링정책`, 공통 현황판·점유현황·결정로그, 기존 아키텍처 진단 보고서. + +원본은 PowerPoint/OpenXML 슬라이드 텍스트·표·화면 설명과 XLSX shared string·행·추적 열을 직접 읽었다. 상위 에이전트 요약으로 대체하지 않았다. 문서 읽기에는 Presentations·Spreadsheets 스킬의 읽기 전용 기준을 적용했고 원본 파일은 수정하지 않았다. + +| 원본 | 직접 확인한 범위 | 유지할 기능·항목 | +| --- | --- | --- | +| `D:/workspace/IIPBS/docs/기준문서/요구사항정의서.xlsx` | `3. (기능)요구사항정의 및 추적표` 84행, SFR-010-012, PR-SN-021 | 준설정보 입력·지도 표출, 지도 위치 클릭과 관련 정보 등록, 보안 조회 대책 | +| 같은 XLSX | 같은 시트 76행, SFR-010-004, PR-SN-020 | CCTV 조사보고서 등록·조회·수정·삭제, 사업정보와 조사결과·보고서 연계 | +| `D:/workspace/IIPBS/docs/기준문서/화면설계서.pptx` | 슬라이드 53, SN_MN040201, 준설 결과 업로드 | 준설년도·시도·시군구 검색, 등록·찾기·관로추가·저장·조회·다운로드, 관로별 사진 최대 5장, 촬영일자·GPS 검토, 메타 훼손 시 위치등록 안내 | +| 같은 PPTX | 슬라이드 53 표 | 시도, 시군구, 관리번호, 관경(mm), 관재질, 매설년도, 준설일자, 등록사진건수, 등록일자 | +| 같은 PPTX | 슬라이드 39, SN_MN040105, 사업정보 등록 | 사업년도·시도·시군구·사업명·사업차수·정밀조사연장, 사업대상 처리분구 선택, 저장·양식받기·조사보고서 양식·결과등록, 사업현황·보고서·파일다운로드 | + +원본 파일 확인 시 PPTX는 10,736,400 bytes / 수정 2026-08-20 14:57:36, XLSX는 124,187 bytes / 수정 2026-07-27 13:36:19였다. + +준설의 사진 없는 수동 등록 등 이미 구현된 후속 동작은 유지한다. 기존 작업카드의 FE 선행 모드 설명은 현행 코드의 `NEXT_PUBLIC_DREDGING_BACKEND_ENABLED !== "false"`와 차이가 있어, 이번 배치에서는 코드의 실제 모드 기본값을 변경하지 않았다. 과거 작업카드의 API index 점유 금지는 당시 진행 작업의 점유 기록이며, 이번 메인 배정과 새 점유 확인에 따라 해당 파일을 수정했다. + +`/oper/oldPp`, `/oper/floodPre` 레거시 파일 등록·삭제 화면은 원본에서 대응하는 신규 화면 ID를 확인하지 못했다. 이 두 화면은 API helper 오류 전달 변경에 필요한 소비자 예외처리만 보완했으며, 원본에 없는 신규 기능·입력항목을 만들지 않았다. 해당 사실을 메인에게 보고했다. + +## 2. 수정 파일과 책임 + +아래 경로는 `frontend/apps/sn/` 기준이다. + +| 파일 | 변경과 이유 | +| --- | --- | +| `src/shared/api/http-transport.ts` 신규 | `ApiRequestError`, `createHttpTransport` 도입. URL·헤더·HTTP 상태·오류 body·정상 JSON 파싱을 한곳에서 처리한다. 인증 처리는 callback으로 받아 하위 기반이 `entities`/`app`을 참조하지 않는다. | +| `src/shared/api/index.ts` | 기존 `client` export를 유지하며 transport 공개 export 추가. 기존 axios 공통 client를 교체하지 않는다. | +| `app/components/lib/api/index.ts` | `getData/postData/putData/getRawJson/postRawJson/deleteData` 구현을 transport 생성 결과로 제공한다. 기존 import 경로·함수명을 유지한다. 파일·multipart·CMS·DC·GeoServer·쿠키 helper는 남긴다. | +| `src/app/providers/app-provider.tsx` | 렌더마다 QueryClient 생성하던 코드를 `useState` lazy 초기화로 변경한다. | +| `app/(with-base-layout)/oper/dredging/lib/registration-request.ts` 신규 | 등록 payload mapper와 등록 세션의 payload별 요청 ID 저장을 분리한다. `AttachmentReference`, `DredgingRegistrationRequest` 타입도 이 경계에 둔다. | +| `app/(with-base-layout)/oper/dredging/lib/client.ts` | `saveDredgingRegistrations(request)`가 확정 요청을 전송하도록 변경. API 함수 내부 UUID 생성·payload 조립 제거. 기존 `AttachmentReference` import 호환을 위해 재수출한다. | +| `app/(with-base-layout)/oper/dredging/components/DredgingRegisterModal.tsx` | 모달 세션·저장 중 잠금 ref 추가. 모달 재진입에서 세션·첨부 초기화, 재시도에서 보존, 동시 저장 호출 차단. | +| `app/(with-base-layout)/oper/oldPp/page.tsx` | 첨부·업무 삭제 두 호출의 예외를 안내. 성공 판정을 HTTP 200 하나로 한정하지 않고 helper가 resolve한 성공 경로로 통일한다. | +| `app/(with-base-layout)/oper/floodPre/page.tsx` | 위와 동일한 삭제 소비자 호환 처리. | +| `src/features/windowPopup/ui/OldPpUploadPopupContent.tsx` | POST reject를 catch하고 등록 실패 안내. 실패 시 완료 메시지를 부모 창에 보내지 않는다. | +| `src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx` | 위와 동일한 등록 소비자 호환 처리. | +| `tests/load-typescript.cjs` 신규 | 설치된 TypeScript로 실제 소스를 메모리에서 변환하는 테스트 로더. `.next`·소스 산출물 생성과 패키지 설치가 없다. | +| `tests/api-transport.test.cjs` 신규 | 실제 legacy facade와 transport에 대한 성공·실패·204·HTML·원문·취소 호환 테스트. | +| `tests/dredging-registration.test.cjs` 신규 | 실제 등록 세션·API 함수에 대한 응답 유실 재시도·수정·복원·새 세션·첨부 검증. | + +구조 변경은 `화면 → 기존 API 진입점 → SN shared transport`와 `준설 화면 → 등록 세션/mapper → 준설 API → transport`로 제한한다. 지도 전체 분할, 준설 라우트 전체의 `src/features` 이동, 다른 업무 API adapter 일괄 이전은 후속 단계다. + +## 3. 수정 전후 코드와 설계 이유 + +### 3.1 POST·DELETE HTTP 실패를 정상 반환하던 문제 + +수정 전 `app/components/lib/api/index.ts`의 실제 핵심 코드: + +```ts +// postData +const res = await fetch(`${BASE_URL}/${url}`, options); +if (res.status === 401) { + handleUnauthorizedResponse(); +} +if (res.ok) { + const resData = await res.json(); + console.log('postData response:', resData); + return resData.data; +} + +// deleteData +const res = await fetch(resolveApiUrl(url), options); +if (res.status === 401) { + handleUnauthorizedResponse(); +} +if (res.status === 204) return true +return res; +``` + +POST는 HTTP 500에서 함수 끝으로 빠져 `undefined`로 resolve했다. DELETE는 HTTP 500의 `Response`를 resolve했다. `await deleteData()` 후 성공 메시지를 표시하는 화면에서는 서버 실패가 성공처럼 이어졌다. + +수정 후 `src/shared/api/http-transport.ts`: + +```ts +const request = async (url: string, init: RequestInit): Promise => { + const headers = new Headers(init.headers); + new Headers(options.headers).forEach((value, key) => + headers.set(key, value), + ); + const response = await fetch(resolveUrl(url), { ...init, headers }); + if (response.status === 401) options.onUnauthorized?.(); + if (!response.ok) { + throw requestError(response, await readErrorBody(response)); + } + return response; +}; +``` + +실패 판정을 본문 파싱보다 먼저 고정했다. HTML 오류도 원래 HTTP 502와 본문을 보존하며 `SyntaxError`로 바뀌지 않는다. JSON 오류의 `message` 또는 `error.message`가 있으면 사용한다. HTML 원문은 사용자 메시지에 붙이지 않고 `error.body`에만 보관한다. + +```ts +export class ApiRequestError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly body: unknown, + ) { + super(message); + this.name = "ApiRequestError"; + } +} +``` + +정상 계약은 메서드별로 유지한다. GET·POST는 `data`를 꺼내고 raw 함수는 원문을 반환하며, PUT의 기존 raw 호환은 유지한다. DELETE는 기존 소비자가 `.status`나 `.json()`을 사용할 수 있도록 204의 `true`, 나머지 성공의 읽지 않은 `Response`를 유지한다. + +```ts +postData: async (url: string, data: object): Promise => { + const body = await requestJson(url, jsonInit("POST", data)); + return body === null ? null : body.data; +}, +deleteData: async ( + url: string, + data?: object, +): Promise => { + const response = await request(url, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: data ? JSON.stringify(data) : undefined, + }); + return response.status === 204 ? true : response; +}, +``` + +기존 import의 대량 이동을 피하고 점진 이전이 가능하도록 facade를 남겼다. + +```ts +const httpTransport = createHttpTransport({ + baseUrl: BASE_URL, + headers: DEFAULT_HEADERS, + onUnauthorized: handleUnauthorizedResponse, +}); +export const { getData, postData, putData, getRawJson, postRawJson, deleteData } = + httpTransport; +``` + +인증 callback은 기존 파일이 소유한다. facade는 순수 `@/shared/api/http-transport`를 직접 import한다. 기존 `@/shared/api` barrel은 `client.ts`의 쿠키 초기화와 공용 network 초기화를 포함하므로, 단순 HTTP helper 분리로 그 부수효과가 새로 실행되지 않도록 했다. + +### 3.2 삭제 화면의 실패·성공 판정 + +수정 전 `oldPp/page.tsx`: + +```ts +const businessResult = await deleteData(`v1/dcrpPpl/${businessId}`); +if (businessResult.status === 200) { + alert("삭제되었습니다.", () => void fetchData()); + return; +} +alert("삭제에 실패했습니다."); +``` + +수정 후 실제 코드: + +```ts +try { + await deleteData(`/attach-service/api/v1/atfl/attachments/${attachmentId}`); + await deleteData(`v1/dcrpPpl/${businessId}`); + alert("삭제되었습니다.", () => void fetchData()); +} catch { + alert("삭제에 실패했습니다."); +} +``` + +HTTP 오류가 reject되므로 화면은 두 삭제가 모두 resolve된 뒤에만 성공을 안내한다. 정상 204도 성공으로 처리한다. `floodPre`에도 같은 정책을 적용했다. 두 호출 사이의 원자성은 기존과 같으며 첨부 성공·업무 실패 시 복구는 BE/첨부서비스 협의가 필요한 후속 위험이다. + +등록 팝업 두 곳도 기존 `postData` 호출과 완료 메시지 전송을 `try` 안에 두고 아래 catch를 추가했다. + +```ts +} catch { + alert("파일 등록에 실패했습니다. 잠시 후 다시 시도해주세요."); +} +``` + +지도 시설정보 저장과 침입수·RDII 삭제 화면은 이미 `try/catch`가 있어 수정 없이 새 reject를 수용한다. 준설 삭제는 자체 catch를 유지한다. `obsltPpl`·`ctyFldngPrvtnBiz` 기능 API도 오류를 상위 화면으로 전달한다. `app/html`은 별도 퍼블리싱 영역이므로 수정하지 않았다. + +### 3.3 QueryClient 수명 + +수정 전: + +```ts +const queryClient = new QueryClient(); +``` + +수정 후: + +```ts +// Provider가 다시 렌더되어도 조회 캐시와 진행 중 요청을 같은 인스턴스에 유지한다. +const [queryClient] = useState(() => new QueryClient()); +``` + +Provider의 한 마운트 동안 캐시 소유자를 유지한다. 모듈 전역 singleton으로 만들지 않아 서로 다른 Provider 수명을 강제로 공유하지 않는다. 인증·메뉴·Dnd·Alert Provider의 순서와 Query 기본 옵션은 그대로다. + +### 3.4 준설 중복방지 ID의 소유권 + +수정 전 `client.ts`는 호출 때마다 새 키를 발급했다. + +```ts +export const saveDredgingRegistrations = async ( + groups: DredgingSaveGroup[], + attachments: Map, +) => { + // 중략: FE 선행 모드 + const clientRequestId = createDredgingClientUuid(); + try { + return await postRawJson("v1/dredging/registrations", { + clientRequestId, + dredgingDate: groups[0]?.dredgingDate, + // 기존 group·photo DTO 조립 + }); + } catch (error) { + // 기존 업무별 오류 변환 + } +}; +``` + +서버의 `DredgingService.register`는 `findRegisteredJob(request.getClientRequestId())`로 기존 등록을 찾는다. 서버 저장 후 응답만 유실되어도 프론트가 새 키로 재시도하면 기존 중복방지 장치를 활용하지 못했다. + +수정 후 세션은 실제 전송 body를 일정한 필드 순서로 조립하고, 그 값에 대응하는 키를 보관한다. + +```ts +const requestIds = new Map(); +// createDredgingRegistrationSession().prepare 내부 +const body = buildRegistrationBody(groups, attachments, requireAttachments); +const signature = JSON.stringify(body); +let clientRequestId = requestIds.get(signature); +if (!clientRequestId) { + clientRequestId = createId(); + requestIds.set(signature, clientRequestId); +} +return { clientRequestId, ...body }; +``` + +`buildRegistrationBody`는 기존 `dredgingDate`, 그룹 순번·등록방식·위치·expectedPipe·사진 첨부와 촬영 메타데이터만 추출한다. 표시용 관경·거리·그룹 localId는 키에 포함하지 않는다. 첨부는 `atflCd`, `atflId` 순서로 명시해 객체 생성 순서가 달라도 같은 의도를 인식한다. + +화면의 실제 저장 연결 코드: + +```ts +const session = + registrationSessionRef.current ?? createDredgingRegistrationSession(); +registrationSessionRef.current = session; +await saveDredgingRegistrations( + session.prepare(saveGroups, attachments, isDredgingBackendEnabled), +); +``` + +API 함수는 UUID를 생성하지 않고 `postRawJson("v1/dredging/registrations", request)`를 실행한다. 프론트 선행 모의 모드에서는 첨부를 요구하지 않고 기존처럼 외부 저장을 하지 않는다. 실제 연동 모드의 첨부 식별자 누락은 키 발급 전 거부한다. + +| 상태·사용자 조작 | 요청 ID·첨부 처리 | +| --- | --- | +| 등록 모달 진입 | 새 세션, 이전 첨부 참조 초기화 | +| 같은 입력으로 실패 후 재시도 | 같은 ID와 성공한 첨부 참조 재사용 | +| 날짜·위치·관로·사진·첨부 전송값 변경 | 새 ID | +| 실패했던 이전 입력으로 복원 | 해당 payload의 기존 ID 재사용 | +| 탭·localId·관경 표시·거리 변화 | 기존 ID 유지 | +| 성공 안내가 닫히기 전 재클릭 | 기존 ID·첨부 유지 | +| 모달 닫고 새로 등록 / 새 창 / 새로고침 | 새 세션. 이전 세션 중복방지 연속성은 보장하지 않음 | + +추가한 `savingRef`는 React가 저장 버튼 disabled 상태를 렌더하기 전에 발생하는 연속 클릭을 막는다. 기존 `isSaving`은 화면 표시, ref는 동기 진입 잠금 역할이다. + +## 4. 화면·API·공통 패키지 영향 + +### API 명세 현행화용 전달 정보 + +| 대상 | 요청·응답 유지사항 | 이번 내부 처리 변화 | +| --- | --- | --- | +| `POST /sewerage-service/api/v1/dredging/registrations` | `clientRequestId` UUID, `dredgingDate`, `groups[]`와 그 하위 필드·URL·method 유지 | 동일 모달 세션의 동일 전송값 재시도에서 ID 유지. API 호출 함수가 확정 body를 받음 | +| 같은 API 응답 | raw 또는 기존 래퍼 body, `registeredCount`, `alreadyRegisteredCount`, `groups` 계약 유지 | 전송값의 자동 camel/snake 변환 없음. 서버 중복방지 응답 판정 규칙 추가 없음 | +| `POST v1/dcrpPpl`, `POST v1/sbmrgPrevnt`, `POST v1/fcltppl` 등 `postData` 소비자 | JSON 요청·사이트 헤더, 정상 `data` 해제 유지 | HTTP non-2xx는 예외로 전달. 정상 빈 JSON body는 null | +| `DELETE v1/dcrpPpl/{id}`, `v1/sbmrgPrevnt/{id}`, `v1/dredging/records/{id}`, 분석결과·첨부 API | URL·body 유지. 정상 204=true, 기타 정상=Response | non-2xx 예외로 성공 후속 흐름 차단 | +| raw GET/POST 및 PUT | GeoJSON과 기존 필드 이름, 기존 PUT 래퍼/raw 호환, AbortSignal 유지 | HTML 오류에서도 HTTP status·body 보존 | +| HTTP 오류 객체 | 서버 오류 body를 변경하지 않음 | FE 내부 `ApiRequestError { name, message, status, body }`. API 응답의 새 JSON 필드를 뜻하지 않음 | + +기존 `SITE_ID`, 인증 만료 판정·로그인 이동, 공통 패키지 설정은 변경하지 않았다. `business_components`를 포함한 공통 패키지 수정은 0건이다. 기존 `shared/api/client.ts` 공용 network 기반 client도 그대로 유지한다. 따라서 이번 배치를 전 앱 API client 통합 완료로 해석하면 안 된다. + +### 프로그램 명세 현행화용 전달 정보 + +| 프로그램·모듈 | 신설·변경 함수 | 입력 / 출력 / 책임 | +| --- | --- | --- | +| SN HTTP transport | `createHttpTransport`, 내부 `request`, `requestJson`, `readErrorBody`, `readJsonBody`, `requestError`, `jsonInit`, `resolveUrl` | baseUrl·headers·인증 callback → 6개 API 함수. 네트워크·오류 처리를 소유 | +| SN HTTP 오류 | `ApiRequestError` | message·status·body → Error. HTTP 오류 증거 보존 | +| 기존 API Utility | transport 생성 및 함수 재수출 | 기존 import path 보존. 인증 callback과 미이전 helper 조합 | +| AppProvider | `AppProvider` | children → 기존 Provider tree. QueryClient 한 마운트 수명 유지 | +| 준설 요청 모듈 | `buildRegistrationBody`, `createDredgingRegistrationSession`, `prepare` | save groups·attachments·실제 연동 여부 → 기존 등록 request. payload mapper와 ID 세션 소유 | +| 준설 API | `saveDredgingRegistrations` | 기존 `(groups, attachments)` → `(request: DredgingRegistrationRequest)`로 FE 내부 함수 시그니처 변경. 외부 API는 동일 | +| 준설 등록 화면 | `handleSave`, open effect | 등록 session ref와 저장 잠금 관리. upload → prepare → save → 기존 성공 안내 | +| 레거시 운영 삭제 | `oldPp.handleDelete`, `floodPre.isDeleteFile` | 기존 선택값으로 순차 DELETE, 모두 성공 시 안내·재조회 | +| 레거시 운영 등록 팝업 | 두 컴포넌트의 `handleSubmit` | 기존 POST 유지, 실패 catch·완료 메시지 차단 | + +프로그램·API 명세 XLSX는 메인 에이전트 소유이며 이 FE 작업에서는 직접 변경하지 않았다. + +## 5. 검증 결과와 QA 재현 기준 + +실행 위치: `D:/workspace/IIPBS-FRONTEND/frontend/apps/sn`. + +```powershell +node --test tests/api-transport.test.cjs tests/dredging-registration.test.cjs +pnpm exec tsc --noEmit --incremental false --pretty false --ignoreDeprecations 5.0 +git diff --check +pnpm exec eslint src/shared/api/http-transport.ts src/app/providers/app-provider.tsx +``` + +| 검증 | 결과 | +| --- | --- | +| Node 테스트 | 22건 통과, 실패 0. 실제 소스를 메모리 transpile하고 fetch·외부 인증 의존성만 모의 처리 | +| 핵심 테스트 | 6개 helper HTTP 500, nested 403, 빈 503, HTML 502, 정상 POST 201·204, DELETE 200의 읽지 않은 Response, 실패 후 성공 경로 차단, GeoJSON 필드 보존, AbortSignal·Headers, 401 callback | +| 준설 테스트 | 네트워크 응답 유실 후 동일 request 전송, 날짜·위치·관로·사진 변경 새 키, 원래 입력 복원, 표시 상태 변화 무시, 새 모달 UUID, 첨부 속성 순서, 실제 모드 누락 첨부 거부·모의 모드 허용 | +| TypeScript | CLI에 `--ignoreDeprecations 5.0`을 지정한 검사 통과. 저장소 설정 수정·증분 캐시 쓰기 없음. 기본 설정은 5.9.3과 `ignoreDeprecations: "6.0"` 불일치가 남음 | +| diff 공백 검사 | 통과 | +| ESLint | 검사 전 기존 `eslint.config.mjs:15`의 객체 spread `...(isProd ? {...} : {})`가 iterable이 아니어서 TypeError로 중단. lint 통과로 계산하지 않음 | +| 실제 브라우저 | `http://localhost:3002/oper/dredging` 접속 시 `net::ERR_CONNECTION_REFUSED`. 3002 리스너 없음. 로그인·폼·Network 시나리오 미실시 | +| build | 실행하지 않음. 개발서버·빌드·설정 변경 없이 독립 검증 실시 | +| 실데이터 | BE 저장·삭제, DB 변경, 첨부 업로드 실행 없음 | + +테스트는 HTTP 응답을 모의한 단위·계약 수준이다. 운영 서버 중복 등록 방지와 실제 UI 렌더·클릭 검증을 대체하지 않는다. QueryClient lazy 수명은 코드·타입 확인이며 실제 Provider 재렌더 캐시 회귀는 아래 QA 항목으로 남긴다. + +QA가 사용 가능한 로컬 서버·격리 테스트 자료를 준비한 뒤 다음을 확인한다. 개발 서버 실행 중에는 `next build`를 동시에 실행하지 않는다. 빌드가 필요할 때의 기준 명령은 프론트 루트에서 `pnpm --filter sn run build`다. + +| 화면 진입·조작 | Network 확인 | 화면·상태 기대 결과 | +| --- | --- | --- | +| `/oper/dredging` → 등록 → 날짜·사진 또는 기존 수동 위치 입력 → 관로 매핑 → 저장 | nearestPipes·attach 업로드 이후 registrations 요청의 ID·groups | 기존 최대 5장·필수값 검증 유지, 저장 중 버튼 비활성 | +| 같은 모달에서 저장 응답 유실을 시험용 응답 제어로 재현 → 입력을 바꾸지 않고 재시도 | 두 registrations의 `clientRequestId`와 body 완전 동일, 성공한 첨부 재업로드 없음 | 실패 안내 후 재시도 가능, BE `alreadyRegisteredCount`와 실제 작업 건수 중복 없음 | +| 실패 후 날짜·위치·관로·사진 수정 → 저장 | 바뀐 body의 새 ID | 변경한 내용을 기존 ID로 잘못 성공 처리하지 않음 | +| 이전 입력 복원 → 저장, 닫고 다시 등록 비교 | 같은 세션 복원은 이전 ID, 새 모달은 새 ID | 세션 경계대로 동작 | +| `/oper/oldPp`, `/oper/floodPre` → 시험용 파일 선택 → 삭제 | 첨부 DELETE 또는 업무 DELETE 500/403 | 실패 안내, 성공 메시지 없음. 첫 삭제 실패 시 두 번째 미호출 | +| 같은 삭제에서 200·204 정상 응답 | 두 API 정상 HTTP | 성공 안내와 목록 재조회 | +| 두 운영 화면의 등록 팝업 → 등록 실패 | dcrpPpl/sbmrgPrevnt POST non-2xx | 실패 안내, 부모 창 완료 메시지 없음 | +| `/ii/result`, `/ii/rdiiResult` → 시험용 분석결과 삭제 실패 | 첫 분석결과 DELETE non-2xx | 기존 catch 안내, 다음 삭제·성공 알림 미실행 | +| `/map` → 시설 자체입력정보 저장 실패 | fcltppl 등 POST non-2xx | 기존 저장 오류 안내. camel/snake 필드 변경 없음 | +| React Query 사용 화면 → 상위 Provider 재렌더 | 동일 query의 불필요한 재조회·취소 여부와 devtools cache | 같은 QueryClient와 조회 캐시 유지 | + +## 6. 남은 위험·BE/QA 확인 필요사항 + +1. 실제 브라우저·API 회귀 게이트가 남아 있어 배포 검증 완료가 아니다. 서버를 새로 기동하지 않았고 사용자 서버를 종료·교체하지 않았다. 실행한 단위검사·타입검사·lint·formatter는 종료를 확인했다. 최종 점검에서 3002·8080 리스너는 없었고 임시 로그 파일을 생성하지 않았다. +2. 준설 키는 열린 모달 메모리에만 보관한다. 새로고침·창 닫기 후 재입력까지 중복방지를 연속 보장하지 않는다. 요청 결과가 불명확한 상태에서 입력을 바꾸면 새 의도로 새 ID를 쓰므로, 이전 등록 확인·복구 UI는 후속 설계 대상이다. +3. BE가 같은 ID에 다른 payload를 받았을 때 내용 비교·소유자 범위·동시 요청을 어떻게 보장하는지는 별도 검증해야 한다. 이번 FE는 기존 `findRegisteredJob` 계약을 활용할 뿐 서버의 보장을 확대했다고 주장하지 않는다. +4. 준설 사진 업로드 도중 일부 성공 후 다음 사진이 실패한 경우, 기존 `uploadDredgingPhotoDrafts`의 복사 Map이 반환되지 않아 부분 성공 참조가 유지되지 않는 위험은 이번 범위 밖이다. 첨부 전체 성공 후 등록 실패 재시도는 참조를 보존한다. +5. 운영 파일 업로드 폼은 기존에 submit 완료를 await하지 않고 선택 상태를 초기화한다. 이번 팝업 catch는 실패 안내·unhandled rejection·잘못된 완료 메시지를 막지만 파일 draft 보존·중복 TUS 업로드 전체 흐름은 후속 정리 대상이다. +6. 첨부 삭제와 업무 삭제는 별도 HTTP 호출이다. 뒤 요청 실패 시 앞 삭제를 자동 복구하지 않는다. 순서를 임의로 바꾸거나 BE 원자성을 가정하지 않았다. +7. HTTP 200 body 안의 업무 오류를 모든 API에서 추측 판정하지 않는다. HTTP 오류와 본문 상태의 정합성은 BE 응답 계약 검증과 함께 확인해야 한다. +8. 공통 `@common/network`·`auth`·`components`는 수정하지 않았다. 기존 client와 fetch helper가 모두 존재하며 미이전 파일·multipart·외부서비스 helper와 지도 대형 컴포넌트는 다음 단계다. +9. 기본 TypeScript 설정과 ESLint config 오류는 분리된 환경·설정 정비 항목이다. 이번 배치에서 조용히 변경하지 않았다. + +## 7. 롤백과 다음 배치 + +커밋은 생성하지 않았다. 변경 전 FE 작업트리는 깨끗했다. 롤백 시 이후 사용자·다른 작업자의 수정을 보존한 채 이 보고서의 파일별 변경만 역패치한다. 일괄 `git reset --hard`는 사용하지 않는다. + +- transport 배치: legacy API 6함수 원복, `shared/api/index.ts`의 새 export만 제거, 신규 transport와 그 테스트 제거. 소비자 catch는 남겨도 기존 정상 API 계약과 충돌하지 않는다. +- 준설 배치: modal·client 시그니처·신규 registration-request를 함께 역패치한다. 어느 한쪽만 원복하면 호출 계약이 맞지 않는다. 이 롤백은 재시도 ID 재생성 문제도 되돌린다는 점을 기록한다. +- QueryClient 배치: 한 줄 초기화와 useState import를 함께 원복 가능하지만 캐시 수명 문제도 복귀한다. +- DB·서버 API·공통 패키지 migration은 없으므로 데이터 롤백은 없다. + +다음 배치는 실제 브라우저 회귀와 설정 baseline을 확보한 뒤, 준설 draft·사진 업로드 수명과 기능 API를 `src/features/dredging`로 이동하는 작업이 적절하다. 지도 전체를 먼저 이동하지 않고 기능 단위의 import 호환·상태 책임·사용자 흐름을 함께 이전한다. diff --git a/apps/sn/docs/작업계획/SN-프론트엔드리팩토링/002-fe-SN-프론트엔드리팩토링-2차결과보고서.md b/apps/sn/docs/작업계획/SN-프론트엔드리팩토링/002-fe-SN-프론트엔드리팩토링-2차결과보고서.md new file mode 100644 index 000000000..00e16f7d9 --- /dev/null +++ b/apps/sn/docs/작업계획/SN-프론트엔드리팩토링/002-fe-SN-프론트엔드리팩토링-2차결과보고서.md @@ -0,0 +1,235 @@ +# SN 프론트엔드 2차 리팩토링 결과보고서 + +작성일: 2026-09-10. 기준선은 미커밋 상태의 1차 리팩토링 결과다. 1차 코드·문서를 보존하고 준설의 API·모델·유틸리티 책임을 기능 폴더로 옮겼다. 실제 브라우저 회귀는 사용자 추가 지시에 따라 최종 6차 코드 정리 후 메인이 기동을 조율하는 BE·FE 환경에서 수행한다. + +## 1. 목표·근거·수정 범위 + +- 목표: 라우트 아래 섞인 준설 HTTP·첨부 업로드·draft 모델·EXIF·UUID를 `src/features/dredging`의 `api/model/lib`로 분리하고 기존 경로와 HTTP 계약을 유지한다. 사진 일부 업로드 성공 후 실패할 때 성공 참조가 사라지던 문제를 보완한다. +- 근거: 사용자의 2차 리팩토링·병렬 FE 진행 요청, 공통·SN `AGENTS.md`, 원본 요구사항과 화면설계서, 1차 결과보고서의 후속 단계·위험 4, 기존 준설 작업카드·공통 결정로그. +- 코드 허용: `apps/sn/**`, `packages/common/business_components/**`. 실제 코드 변경은 `apps/sn/**`만이다. 메인이 지정한 단계별 기준선 보존용 `D:/workspace/outputs/sn-refactor-phase2-20260910/fe-before`에는 원본 복사본만 추가했다. +- 코드 금지: 다른 앱, 다른 공통 패키지, BE·DB·환경설정·인증·프록시, 감리 XLSX와 공통 현황판. 모두 수정하지 않았다. +- 완료 조건: 호환 re-export, 원본 기능·화면 문구·payload 보존, 이동 후 기존 22건과 신규 업로드·의존성 회귀 검증, 타입검사, 1차 대비 전후 코드와 롤백·명세 현행화 정보 기록. 실브라우저 회귀는 최종 통합 게이트다. + +작업 시작 시 두 원본을 직접 다시 읽었다. PowerPoint 슬라이드 설명·표·텍스트와 XLSX shared string·행·추적 열을 확인했다. + +| 원본 | 직접 확인 | 유지 대상 | +| --- | --- | --- | +| `D:/workspace/IIPBS/docs/기준문서/요구사항정의서.xlsx` | `3. (기능)요구사항정의 및 추적표` 84행, SFR-010-012, PR-SN-021 | 준설정보 입력·지도 표출, 지도 위치 지정·관련 내용 입력 | +| `D:/workspace/IIPBS/docs/기준문서/화면설계서.pptx` | 53슬라이드, SN_MN040201, 준설 결과 업로드 | 등록→사진찾기→메타검토→위치등록 보완→관로추가→저장, 관로별 최대 사진 5장 | +| 같은 PPTX | 53슬라이드 검색·표·버튼 | 준설년도·시도·시군구, 관리번호·관경·관재질·매설년도·준설일자·등록사진건수·등록일자, 조회·다운로드·등록·찾기·관로추가·저장 | + +파일 크기·수정일은 1차와 동일했다(PPTX 10,736,400 bytes / 2026-08-20 14:57:36, XLSX 124,187 bytes / 2026-07-27 13:36:19). 화면 항목·문구·동작 규칙 추가가 아닌 기존 구현의 책임 이전이다. + +## 2. 파일 이동과 수정 코드 + +경로는 `frontend/apps/sn` 기준이다. + +| 1차 위치 | 2차 실제 구현 위치 | 역할·변경 | +| --- | --- | --- | +| `app/(with-base-layout)/oper/dredging/lib/client.ts` | `src/features/dredging/api/dredging-api.ts` | 목록·사진조회·최근접관로·등록·수정·삭제 HTTP 흐름 | +| 같은 client의 사진 업로드 | `src/features/dredging/api/photo-upload.ts` | 브라우저/TUS callback adapter, 업로드 실패 안내 | +| 같은 client의 업로드 순회 | `src/features/dredging/lib/photo-upload-queue.ts` | 성공 참조 즉시 기록·재시도 skip 정책, 브라우저·HTTP 비의존 | +| 같은 client의 모의 모드 상수·오류변환 | `api/backend-mode.ts`, `api/dredging-error.ts` | 기존 모드와 화면 오류 메시지를 REST·업로드 adapter가 공유 | +| `.../lib/types.ts` | `src/features/dredging/model/types.ts` | 기존 draft·record·photo·pipe·location 모델과 EMPTY_PHOTO_METADATA | +| `.../lib/registration-request.ts` | `src/features/dredging/model/registration-request.ts` | 1차의 payload mapper·세션 ID 정책 그대로 이동 | +| `.../lib/uuid.ts`, `date.ts`, `exif.ts` | `src/features/dredging/lib/uuid.ts`, `date.ts`, `exif.ts` | 기존 UUID·날짜 표출·EXIF 파서 그대로 이동 | +| `.../lib/useDredgingCommonCodes.ts` | `src/features/dredging/lib/use-dredging-common-codes.ts` | CMS 코드 표시 훅. 기존 저장 코드·라벨·캐시 보존 | +| 신규 | `src/features/dredging/index.ts` | 준설 기능의 공개 export | +| `src/features/windowPopup/ui/dredgingPopupRecord.ts` | 같은 파일 | 타입을 라우트에서 읽던 역참조를 feature 공개 모델로 변경 | +| `tests/load-typescript.cjs` | 같은 파일 | legacy re-export의 `@/features/dredging` 경로를 실제 소스로 해석하도록 테스트 로더 보완 | +| 신규 테스트 | `tests/dredging-upload.test.cjs`, `tests/dredging-module-boundary.test.cjs` | 부분 업로드·실패 callback·타이머·모의 모드와 호환 경계 검증 | + +### 2.1 호환 경로를 유지한 이동 + +1차 `app/.../dredging/lib/client.ts`는 1개 파일에서 HTTP와 TUS, 오류 안내, 모의 모드까지 직접 구현했다. 2차의 같은 파일 전체 코드는 다음과 같다. + +```ts +/** 기존 준설 화면의 API import를 유지한다. 구현과 첨부 업로드 책임은 feature로 분리했다. */ +export * from "@/features/dredging/api/dredging-api"; +export * from "@/features/dredging/api/photo-upload"; +export { isDredgingBackendEnabled } from "@/features/dredging/api/backend-mode"; +``` + +나머지 6개 `lib` 파일도 주석과 새 구현을 향한 re-export만 남겼다. 예를 들어 기존 `types.ts`는 다음 코드로 바뀌었다. + +```ts +/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */ +export * from "@/features/dredging/model/types"; +``` + +UI 전체 이동은 하지 않았다. 목록 page, 등록 모달, 상세 화면, 지도는 기존 상대 import를 사용하면서 같은 feature 구현을 받는다. 새 feature 내부가 이전 준설 라우트나 UI를 읽는 순환 의존은 없다. 기존 API Utility·공통코드·TUS helper의 `@/components/lib/...` 호환 기반 의존은 남아 있으며 이번 이동을 전 앱 기반 정리 완료로 보지 않는다. + +팝업 직렬화 모델의 실제 전후 코드: + +```ts +// 1차 +import type { DredgingRecord } from "@/app/(with-base-layout)/oper/dredging/lib/types"; + +// 2차 +import type { DredgingRecord } from "@/features/dredging"; +``` + +화면의 타입 소유자가 라우트라는 역의존을 제거했다. type-only import라서 팝업이 준설 API·지도 코드를 실행하기 위한 변경이 아니다. + +### 2.2 부분 업로드 성공 참조 유실 + +1차의 실제 핵심 코드: + +```ts +const references = new Map(existingReferences); +if (!isDredgingBackendEnabled) return references; + +try { + for (const photo of photos) { + // DB 저장만 실패한 재시도에서는 이미 성공한 공통 첨부 업로드를 재사용한다. + if (references.has(photo.key)) continue; + references.set(photo.key, await uploadOnePhoto(photo.file)); + } +} catch (error) { + throw toDredgingApiError( + error, + "준설사진을 업로드하지 못했습니다. 잠시 후 다시 시도해주세요.", + "준설사진 업로드", + ); +} +return references; +``` + +화면은 전체 성공 반환 뒤 `uploadedAttachmentsRef.current = attachments`를 실행한다. 따라서 사진 A 성공 후 B가 실패하면 복사 Map이 반환되지 않아 A의 참조가 화면 세션에 반영되지 않았다. 같은 모달 재시도에서도 A를 다시 업로드할 수 있었다. + +2차는 브라우저 비의존 queue에서 세션 Map에 한 장씩 기록한다. + +```ts +export const uploadPhotosWithReferences = async ( + photos: DredgingPhotoDraft[], + references: Map, + uploadOne: (file: File) => Promise, +): Promise> => { + for (const photo of photos) { + // 전체 업로드 후 DB 실패뿐 아니라 일부 업로드 실패의 재시도에서도 재사용한다. + if (references.has(photo.key)) continue; + references.set(photo.key, await uploadOne(photo.file)); + } + return references; +}; +``` + +성공 확인된 참조만 기록하고 실패 사진은 Map에 넣지 않는다. 뒤 사진이 실패해도 호출자가 전달한 같은 Map에 앞 성공이 남으므로 추가 state 변경이나 오류 payload를 만들 필요가 없다. 등록·상세 수정 UI 모두 기존 ref를 전달하므로 함께 적용된다. + +`photo-upload.ts`가 queue를 호출하는 실제 코드: + +```ts +if (!isDredgingBackendEnabled) return existingReferences; +try { + return await uploadPhotosWithReferences( + photos, + existingReferences, + uploadOnePhoto, + ); +} catch (error) { + throw toDredgingApiError( + error, + "준설사진을 업로드하지 못했습니다. 잠시 후 다시 시도해주세요.", + "준설사진 업로드", + ); +} +``` + +### 2.3 TUS 비동기 실패를 즉시 연결 + +1차는 다음처럼 응답 callback만 전달했다. + +```ts +handleUpload(transfer.files, { handleResponse }); +``` + +공통 TUS helper에 이미 있는 `handleError`를 전달하지 않아 네트워크 업로드 실패가 나도 준설 Promise는 최대 90초 timeout을 기다릴 수 있었다. + +2차의 실제 코드: + +```ts +const handleError = (error: unknown) => { + window.clearTimeout(timeoutId); + reject(error); +}; + +try { + handleUpload(transfer.files, { handleResponse, handleError }); +} catch (error) { + handleError(error); +} +``` + +동기 예외·잘못된 첨부 식별자·TUS 비동기 오류가 모두 같은 타이머 정리 경로로 들어간다. timeout 길이와 사용자 오류 문구는 변경하지 않았다. 공통 TUS helper 자체는 수정하지 않았다. + +## 3. 화면·API·상태 영향과 감리 명세 갱신표 + +### 프로그램 명세 + +| 프로그램·함수 | 반영 항목 | +| --- | --- | +| 준설 HTTP API | 구현 위치 `src/features/dredging/api/dredging-api.ts`; `fetchDredgingRecords`, `fetchAllDredgingRecords`, `fetchDredgingPhotos`, `updateDredgingRecord`, `deleteDredgingRecord`, `fetchDredgingPhotoBlob`, `findNearestPipeMatches`, `saveDredgingRegistrations` | +| 준설 사진 업로드 | 구현 위치 `api/photo-upload.ts`; `uploadDredgingPhotos`, `uploadDredgingPhotoDrafts`, 내부 `uploadOnePhoto`·`handleResponse`·`handleError` | +| 업로드 재시도 정책 | 신규 `lib/photo-upload-queue.ts::uploadPhotosWithReferences`; 입력 photos·세션 Map·사진 업로드 함수, 출력 같은 Map, 성공마다 Map 변경 | +| 준설 요청 모델 | `model/registration-request.ts`; 1차 등록 payload·중복방지 세션 정책 동일, 위치만 이전 | +| 준설 업무 모델 | `model/types.ts`; 공개 모델·기본 메타상태 동일 | +| 준설 메타데이터·표시 | `lib/exif.ts`, `date.ts`, `uuid.ts`, `use-dredging-common-codes.ts`; 알고리즘·문구·형식 보존 | +| 이전 경로 | `app/.../dredging/lib/*.ts` 7개는 호환 re-export로 기록. 중복 구현으로 세지 않음 | +| 소비자 | windowPopup의 `buildDredgingPopupRecord`가 feature 공개 타입 사용. 입력 query와 반환 모델 동일 | + +### API 명세 + +외부 API URL·HTTP method·요청/응답 body·헤더는 1차와 동일하다. 이 배치로 새 API를 추가하지 않았다. + +| API·연동 | 설명에 반영할 내부 책임 | +| --- | --- | +| `GET v1/dredging/records`, `GET records/{id}/photos`, `PUT/DELETE records/{id}` | 기존 HTTP 계약을 `dredging-api.ts`가 담당 | +| `POST v1/dredging/nearestPipes`, `POST v1/dredging/registrations` | URL·필드·1차 clientRequestId 정책 유지 | +| 공통 attach TUS, 응답 `X-File-Info` | `photo-upload.ts`가 `atflCd/atflId`를 검증. 같은 세션에서 일부 성공 이후 재시도 시 성공 사진 재사용 | +| 오류 | 기존 준설 사용자 문구 유지. TUS callback 실패 시 즉시 reject하고 타이머 정리 | +| CMS 준설 코드 | 기존 `DRDGNREGMODE`, `DRDGNLOCSRC`, `DRDGNPHOTOSTTS` 조회·fallback·저장값 동일 | + +메인 에이전트가 감리 XLSX를 갱신한다. FE는 XLSX와 공통 작업현황판을 변경하지 않았다. + +## 4. 검증·실제 브라우저 인계 + +실행 위치 `D:/workspace/IIPBS-FRONTEND/frontend/apps/sn`: + +```powershell +node --test tests/api-transport.test.cjs tests/dredging-registration.test.cjs tests/dredging-upload.test.cjs tests/dredging-module-boundary.test.cjs +pnpm exec tsc --noEmit --incremental false --pretty false --ignoreDeprecations 5.0 +git diff --check +``` + +- Node 회귀 30건 통과: 기존 22건 + 업로드 5건 + 경계 3건. +- 이동 후 기존 경로를 경유해 실제 등록 API·요청 세션·UUID·날짜·EXIF 구현을 실행했다. +- 사진 A 성공→B 실패→재시도에서 업로드 순서가 `A, B, B, C`인지 검증했다. A를 다시 업로드하지 않고 같은 Map에 A/B/C가 남는다. +- 실제 TUS adapter를 모의 브라우저·callback으로 실행해 비동기 실패 즉시 reject, 성공 참조 보존, 타이머 해제, 잘못된 X-File-Info 거부, 모의 모드 전송 없음 확인. +- TypeScript 검사 통과(exit 0). 1차와 같은 CLI 임시 `ignoreDeprecations 5.0`을 사용했고 저장소 설정은 이 단계에서 수정하지 않았다. +- `git diff --check` 통과. formatter는 신규 feature·변경 테스트만 대상으로 실행했다. +- 2차 시작 시 3002·8080 리스너 없음. 이 단계에서 서버를 기동하거나 실제 데이터를 업로드·저장·삭제하지 않았다. 사용자가 최종 서버 기동·회귀를 요청했으므로 최종 코드 정리 후 메인과 조율해 실브라우저 검증을 수행한다. + +실브라우저 QA: + +1. `http://localhost:3002/oper/dredging` → 등록 팝업 → 사진 2~3장 선택, 기존 메타 검토·위치 보완·관로 매핑이 유지되는지 확인. +2. 격리한 테스트 자료로 첫 사진 업로드 성공 뒤 다음 사진 TUS 실패를 제어한다. 실패 안내가 timeout까지 기다리지 않고 표시되는지 확인. +3. 같은 모달에서 다시 저장한다. Network에서 첫 사진 TUS가 재호출되지 않고 실패 사진부터 진행하는지 확인한다. registrations payload의 첨부 ID가 첫 성공 참조를 유지해야 한다. +4. 준설 상세 수정 팝업에서도 신규 사진 추가 후 같은 실패·재시도 경로를 확인한다. +5. 등록 API 응답 유실 후 재시도 ID 유지, 모달 재진입 새 키 등 1차 시나리오를 회귀한다. +6. 코드 표시명·사진 촬영일 표출·최대 5장·관로추가·목록 행·상세 지도 동작을 확인한다. + +## 5. 남은 위험·롤백 + +- 실제 서버·브라우저 회귀는 최종 통합 게이트로 남아 있다. 단위 테스트를 실제 TUS 서버·DB 검증으로 해석하지 않는다. +- 업로드 timeout은 기존처럼 TUS 전송 자체를 중단시키지 않는다. 서버가 성공했으나 첨부 참조 응답이 유실된 경우까지 중복을 보장하지 않는다. resumable upload 세션 복구는 별도 범위다. +- 모달 새로고침·새 창을 넘는 성공 참조·중복방지 키 보존은 없다. 같은 열린 저장 세션 범위에서만 개선했다. +- 준설 모델·API는 feature로 이동했지만 화면·지도는 app 아래 남는다. 레거시 기반 helper 의존도 후속 정리 대상이다. +- public export를 사용한 type-only 소비와 기존 compatibility export를 유지한다. 공통 패키지 변경 0건, DB migration 0건이다. + +1차 대비 기준선은 `D:/workspace/outputs/sn-refactor-phase2-20260910/fe-before`에 11파일을 복사하고 각 SHA-256 일치를 확인했다: 기존 준설 lib7개, 기존 tests3개, 팝업 모델1개. 새 feature·신규 테스트·본 보고서는 기준선에 없던 파일이다. + +2차만 롤백할 때는 이후 변경을 먼저 비교한 뒤 snapshot의 해당 11파일을 원래 앱 상대경로에 복구하고 이 단계 신규 feature12파일·신규 테스트2파일을 제거한다. 1차 API transport·QueryClient·모달 ID·운영 화면 개선과 1차 보고서는 그대로 둔다. 3차 이후 수정과 겹치면 파일 통째 덮어쓰기 대신 2차 delta만 역패치한다. `git reset --hard`로 1차 결과를 지우지 않는다. + +## 6. 다음 단계 경계 + +3차는 대형 지도 page의 WaterRisk 점유 블록을 건드리지 않고 독립 레이어 메타정보·정규화 순수 함수부터 분리한다. 4차는 준설 HTTP 응답과 화면 모델·페이지 adapter의 경계를 정리한다. 6차는 import 방향·계약·회귀 실행을 모아 최종 서버·브라우저 검증에 연결한다. 각 단계 시작 전 별도 snapshot과 원본 확인 근거를 남긴다. diff --git a/apps/sn/docs/작업계획/SN-프론트엔드리팩토링/003-fe-SN-프론트엔드리팩토링-3차결과보고서.md b/apps/sn/docs/작업계획/SN-프론트엔드리팩토링/003-fe-SN-프론트엔드리팩토링-3차결과보고서.md new file mode 100644 index 000000000..e027339ea --- /dev/null +++ b/apps/sn/docs/작업계획/SN-프론트엔드리팩토링/003-fe-SN-프론트엔드리팩토링-3차결과보고서.md @@ -0,0 +1,141 @@ +# SN 프론트엔드 3차 리팩토링 결과보고서 + +작성일 2026-09-10. 2차까지의 미커밋 변경을 유지한 채 대형 지도 page의 레이어 메타 규칙과 유하방향 moveend 구독 수명을 분리했다. 새 화면·API·DB 변경은 없다. + +## 1. 목표·원본·경계 + +- 목표: `app/(map-layout)/map/page.tsx`가 레이어 별칭·필터·BBOX 계산과 이벤트 구독 구현까지 직접 소유하는 범위를 줄인다. UI 상태와 지도 엔진 수명은 page에 남기되 독립 정책을 feature로 이전한다. +- 허용: `apps/sn/**`, 필요시 `business_components/**`. 실제 변경은 SN만이다. 다른 앱·공통 패키지·BE·설정·감리 XLSX는 수정하지 않는다. +- 점유 보호: WaterRisk 스타일·메시지·조회·상태 블록은 수정하지 않는다. 유하방향 독립 moveend 구독만 분리한다. +- 완료 조건: 기존 계산·형식·메타 속성·origin/조회 정책 보존, 레이어 입력 경계와 이벤트 재구독·해제 테스트, 타입검사, 실제 코드·롤백·감리 반영정보. 서버·브라우저 회귀는 최종 6차 후 메인과 수행한다. + +직접 원본을 다시 열람했다. XLSX `3. (기능)요구사항정의 및 추적표` 100행 SFR-012-005 / PR-SN-030은 상하류 맨홀·관저고 기반 유하방향 표출, 95행 SFR-011-009 / PR-SN-028은 과거 침수흔적·강우 표출이다. + +- PPTX 37슬라이드 SN_MN010304: 유하방향 체크→관로 화살표 표출, 시종점 관저고 계산·정/역방향·수평 시 정방향 기준. 이번 단계는 해당 계산을 바꾸지 않고 조회 레이어 메타·BBOX·이동 후 재조회 연결만 옮겼다. +- PPTX 31슬라이드 SN_MN010207: 시도·시군구·년도 조회, 침수발생이력 목록의 시도·시군구·침수년도·면적·강우시작/종료·총강우·강우강도, 행 선택 시 지도 이동. +- PPTX 32슬라이드 SN_MN010208: 침수흔적도와 강우관측소·티센망, 현재강우·침수발생강우 표출. 관측소 비선택 정규화와 기존 속성은 그대로 이동했다. + +원본 경로는 `D:/workspace/IIPBS/docs/기준문서/화면설계서.pptx`, `요구사항정의서.xlsx`다. 설명·표·추적 열을 직접 확인했고 원본을 변경하지 않았다. + +## 2. 수정 파일·실제 전후 코드 + +| 파일(SN 기준) | 역할 | +| --- | --- | +| `app/(map-layout)/map/page.tsx` | 기존 메타 함수 정의를 feature import로 교체. flowDirection moveend effect는 새 구독 함수를 호출해 cleanup을 반환 | +| `src/features/mapLayers/lib/layer-info.ts` 신규 | 레이어 타입·필터 별칭, 처리시설 용량 구분, 유하방향·강우관측소 트리 정규화, 숫자·줌·zIndex·BBOX·feature cache key | +| `src/features/mapLayers/lib/flow-direction-listener.ts` 신규 | 지도 moveend 구독과 해제. 최신 상태 getter·조회 callback·OpenLayers disposer를 주입 | +| `src/features/mapLayers/index.ts` 신규 | 위 기능 공개 export | +| `tests/map-layer-model.test.cjs` 신규 | 계산·별칭·중첩·불변성·구독 수명 6건 | + +page는 실제 파일 행수 기준 9,580줄에서 9,409줄로 줄었다. 줄 수보다 책임 이전에 의미가 있다. 레이어 메타 규칙 약 194줄이 독립 모듈로 옮겨졌으며 기존 함수 본문은 변경하지 않았다. + +### 2.1 메타 규칙 이동 + +2차 page 안의 코드: + +```ts +const getLayerTypeName = (layerInfo: any) => + String( + layerInfo?.lyrTyNm ?? layerInfo?.lyrTypeNm ?? layerInfo?.lyr_type_nm ?? "", + ).trim(); +``` + +3차 `layer-info.ts`의 실제 구현: + +```ts +export const getLayerTypeName = (layerInfo: any) => + String( + layerInfo?.lyrTyNm ?? layerInfo?.lyrTypeNm ?? layerInfo?.lyr_type_nm ?? "", + ).trim(); +``` + +입력 별칭과 우선순위를 유지한다. `lyrTyNm`이 없을 때만 `lyrTypeNm`, 그다음 `lyr_type_nm`을 쓰는 기존 동작을 전역 필드 자동변환으로 바꾸지 않는다. 메타정규화 함수도 원본 트리를 직접 수정하지 않고 같은 속성 보정 결과를 반환한다. + +같은 방식으로 이동한 함수: + +`getFlowDirectionModeByPathname`, `getLayerFilterSql`, `getSwagePrcsFcltCapacityKind`, `getSwagePrcsFcltLayerMode`, `isFlowDirectionLayerInfo`, `normalizeFlowDirectionLayerInfos`, `normalizeRainObservationLayerInteraction`, `getFiniteNumber`, `getFlowDirectionMinZoom`, `getFlowDirectionZIndex`, `expandFlowDirectionExtent`, `formatFlowDirectionBbox`, `getFlowDirectionFeatureCacheKey`. + +관련 타입·상수도 필요한 범위만 옮겼다. flow endpoint·좌표계·스타일·지도 객체·request cache 소유권은 page에 남아 있다. + +### 2.2 moveend 수명 분리 + +수정 전 page의 실제 코드: + +```ts +const key = gisMaps.current.on("moveend", () => { + if ( + !flowDirectionCheckedRef.current || + !flowDirectionLayerInfoRef.current + ) + return; + loadFlowDirectionLayer(flowDirectionLayerInfoRef.current); +}); + +return () => { + unByKey(key); +}; +``` + +수정 후 page: + +```ts +return subscribeFlowDirectionMoveEnd( + gisMaps.current, + () => ({ + checked: flowDirectionCheckedRef.current, + layerInfo: flowDirectionLayerInfoRef.current, + }), + loadFlowDirectionLayer, + unByKey, +); +``` + +새 모듈의 실제 구독·해제 코드: + +```ts +const key = map.on("moveend", () => { + const state = readState(); + if (!state.checked || !state.layerInfo) return; + loadLayer(state.layerInfo); +}); +return () => unsubscribe(key); +``` + +체크 상태와 layerInfo를 구독 시점 값으로 고정하면 오래된 조건으로 재조회할 수 있다. 따라서 기존 ref 읽기를 getter로 전달해 매 이벤트마다 최신 상태를 읽는다. React effect의 의존성 `[loadFlowDirectionLayer, mapReady]`, `mapReady`·지도 준비 guard, cleanup 시점은 그대로 유지했다. 구독 키 해제를 주입해 순수 테스트에서 listener 제거·재구독을 확인한다. + +## 3. 화면/API·감리 반영정보 + +| 명세 항목 | 반영 정보 | +| --- | --- | +| 지도 프로그램 | 메타 규칙은 `features/mapLayers/lib/layer-info.ts`, moveend 수명은 `flow-direction-listener.ts`로 이전. page는 상태·지도 객체·조회 함수 조합 | +| 주요 입력 | 서버 레이어 메타 tree, 체크 노드, pathname, bbox extent, feature geometry, 최신 체크/ref getter | +| 주요 출력 | 기존 보정 tree·시설 용량 mode·숫자·bbox 문자열·cache key, cleanup callback | +| API 계약 | 기존 레이어설정·유하방향 BBOX URL·method·파라미터·응답 그대로. 새 API 없음 | +| 상태 수명 | moveend 등록 1건, 최신 checked/layerInfo 확인 후 기존 load 호출, effect cleanup에서 같은 구독 키 해제 | +| UI·타 기능 | 레이어명·메시지·색상·원본 계산·WaterRisk 블록 불변. WaterRisk 스타일 범위 `buildWaterRiskStatusFillColor`부터 `getSelectedFeatureLayerTypeName` 직전까지 snapshot과 동일함 확인 | + +감리 XLSX는 메인 소유다. 이 보고서는 프로그램 모듈 위치·책임 설명에 반영할 근거이며 서버 API 변경을 뜻하지 않는다. + +## 4. 검증과 남은 회귀 + +```powershell +# apps/sn에서 +node --test tests/map-layer-model.test.cjs +pnpm exec tsc --noEmit --incremental false --pretty false --ignoreDeprecations 5.0 +git diff --check +``` + +- 신규 테스트 6/6 통과. camel/snake 메타 별칭 우선순위, 500 이상/미만/동시/없음, 중첩 트리·원본 불변, minZoom 0/빈값, map/map2, BBOX precision, 같은 시설의 다른 geometry cache key를 검증했다. +- moveend 테스트에서 체크 해제 시 미호출, 최신 layerInfo 반영, layerInfo 없음 미호출, cleanup 뒤 미호출, 재구독 후 1회 호출·최종 listener 0건을 확인했다. +- TypeScript 검사 통과(exit 0). 1차와 동일한 CLI 임시 ignoreDeprecations 5.0을 사용했다. +- formatter는 새 모듈·테스트만 실행하고 대형 page 전체 포맷은 하지 않았다. +- WaterRisk 블록은 변경하지 않았고 기존 스타일 본문을 단계 snapshot과 비교해 동일함을 확인했다. +- 최종 통합 브라우저에서 `/map`, `/map2` 유하방향 체크→이동/확대축소→BBOX 재조회→체크 해제→지도 재진입을 조작한다. Network의 URL·bbox·중복 요청, 화살표·최소 줌·재선택 동작을 확인한다. 레이어 목록의 처리시설 500 이상/미만·강우관측소 표시도 회귀한다. + +페이지 전체 조회·팝업·GIS edit interaction을 이 단계에 모두 옮기지 않았다. 점유 중인 WaterRisk 및 다수 업무 상태를 함께 건드리는 변경은 독립 테스트·동작 보존 근거가 부족해 후속 과제로 남긴다. + +## 5. 롤백 + +3차 시작 전에 `D:/workspace/outputs/sn-refactor-phase3-20260910/fe-before/app/(map-layout)/map/page.tsx`로 복사하고 SHA-256 일치를 확인했다. 이 파일은 2차까지의 기준선이다. + +3차만 롤백할 때 이후 타 작업 변경을 먼저 비교하고 page의 import·메타 정의·moveend 변경만 역패치한다. 새 mapLayers 3파일·새 테스트를 함께 제거한다. 1·2차 준설·transport 변경은 유지한다. 지도 page가 타 작업과 겹치면 snapshot 전체 덮어쓰기를 하지 않는다. DB·공통 패키지 롤백은 없다. diff --git a/apps/sn/docs/작업계획/SN-프론트엔드리팩토링/004-fe-SN-프론트엔드리팩토링-4차결과보고서.md b/apps/sn/docs/작업계획/SN-프론트엔드리팩토링/004-fe-SN-프론트엔드리팩토링-4차결과보고서.md new file mode 100644 index 000000000..c7624c6fd --- /dev/null +++ b/apps/sn/docs/작업계획/SN-프론트엔드리팩토링/004-fe-SN-프론트엔드리팩토링-4차결과보고서.md @@ -0,0 +1,117 @@ +# SN 프론트엔드 4차 리팩토링 결과보고서 + +작성일 2026-09-10. 준설 HTTP 전송과 응답 해석·페이지·최근접관로 모델 변환을 분리했다. 1~3차 변경을 보존하며 외부 URL·payload·화면 문구는 바꾸지 않았다. + +## 1. 범위와 직접 확인한 원본 + +- 목표: `features/dredging/api/dredging-api.ts`의 네트워크 흐름과 응답 adapter를 분리하고 기존 raw/wrapper·page0·null·관로 기본값 동작을 테스트로 고정한다. +- 허용: SN 및 business_components. 실제 수정은 SN 내부 API1파일·신규 adapter1파일·신규 테스트1파일·본 보고서다. +- 금지: BE·다른 앱·공통 패키지·환경·DB·감리 XLSX. 모두 변경하지 않았다. +- 완료 조건: API 필드·기존 호출 계약 보존, adapter와 실제 API 연결 테스트, 타입검사, 전후 코드·명세 현행화 정보·rollback. 실제 브라우저는 6차 후 메인 조율 환경에서 수행한다. + +작업 직전 원본 PPTX 53슬라이드 SN_MN040201/SFR-010-012와 XLSX `3. (기능)요구사항정의 및 추적표` 84행 SFR-010-012/PR-SN-021을 직접 다시 읽었다. 원본 경로는 `D:/workspace/IIPBS/docs/기준문서`다. + +준설년도·시도·시군구 조회, 등록·찾기·관로추가·저장·다운로드, 사진 최대 5장·EXIF 촬영일/GPS·메타 이상 시 위치등록, 표의 관리번호·관경·관재질·매설년도·준설일자·사진건수·등록일자를 유지한다. 이 배치는 원본 기능의 API 응답 사용 위치만 정리한다. + +## 2. 수정 파일과 전후 코드 + +| 파일(SN 기준) | 변경 이유 | +| --- | --- | +| `src/features/dredging/api/dredging-api.ts` | fetch/URL/query/오류와 호출 흐름을 유지하면서 응답 해석을 named adapter에 위임 | +| `src/features/dredging/api/response-adapters.ts` 신규 | `toDredgingRecordPage`, `toDredgingPhotos`, `toNearestPipeMatches`, wire 타입·실패 안내 규칙 | +| `tests/dredging-response-adapters.test.cjs` 신규 | 페이지·사진·관로·부분 실패·실제 API query·전체페이지·409 회귀 7건 | + +### 2.1 목록·사진 응답 + +3차 HTTP 함수 내부: + +```ts +return (body?.data ?? body) as DredgingRecordListResponse; +// 사진 목록 +return (body?.data ?? body ?? []) as DredgingPhoto[]; +``` + +4차 HTTP 함수 내부: + +```ts +return toDredgingRecordPage(body); +// 사진 목록 +return toDredgingPhotos(body); +``` + +새 adapter의 실제 코드: + +```ts +export const toDredgingRecordPage = (body: any): DredgingRecordListResponse => + (body?.data ?? body) as DredgingRecordListResponse; + +export const toDredgingPhotos = (body: any): DredgingPhoto[] => + (body?.data ?? body ?? []) as DredgingPhoto[]; +``` + +adapter가 필드를 새로 이름변환하거나 page 번호를 보정하지 않는다. 현재 서버 계약·기존 fallback을 같은 코드로 유지한다. 아직 runtime schema 검증을 추가한 것이 아니므로, 잘못된 200 응답에 대한 엄격 검증까지 완료했다고 해석하면 안 된다. + +### 2.2 최근접 관로 모델 변환 + +3차 `findNearestPipeMatches`는 POST·오류변환 다음에 선택 관로의 모든 필드를 직접 매핑했다. 4차는 기존 POST 후 아래 한 줄을 실행한다. + +```ts +return toNearestPipeMatches(body); +``` + +새 adapter 내부의 기존 매핑을 옮긴 실제 코드: + +```ts +matches.set(item.locationKey, { + fid: item.selected.fid, + mngInstCd: item.selected.mngInstCd, + fcltMngNo: item.selected.fcltMngNo, + pipeDiameterMm: item.selected.pipeDiameterMm ?? 0, + pipeLengthM: item.selected.pipeLengthM ?? 0, + pipeMaterial: item.selected.pipeMaterial ?? "", + installationYear: item.selected.installationYear ?? null, + swtrmtZoneFid: item.selected.swtrmtZoneFid ?? null, + swtrmtZoneNm: item.selected.swtrmtZoneNm ?? "", + distanceMeters: item.selected.distanceMeters, + snappedLongitude: item.selected.snappedLongitude, + snappedLatitude: item.selected.snappedLatitude, + isMock: false, +}); +``` + +기존 PIPE_NOT_FOUND 문구, 서버 failureMessage trim, 누락 선택의 실패수집, 중복 안내 제거, 일부 실패 시 Map 미반환도 그대로 옮겼다. 네트워크 오류와 업무상 후보 없음은 기존처럼 서로 다른 안내 경로다. + +## 3. API·화면·감리 반영정보 + +| 구분 | 명세 반영사항 | +| --- | --- | +| 프로그램 | HTTP 파일에서 `response-adapters.ts`로 3개 응답 함수와 wire 타입 이전. 목록/사진/관로 모델 변환의 책임 경계 표시 | +| 페이지 입력·출력 | raw 또는 data wrapper, 기존 `items/total/page/size` 그대로. 0-based page와 전체페이지 수집 유지 | +| 사진 | `photoSn/atflCd/atflId/originalFileName/capturedAt` 등 원본 보존, 기존 null→[] fallback | +| 최근접관로 | `locationKey/selected/failureCode/failureMessage` → 기존 `Map`, 필드별 nullable/0/문자열 기본값 유지 | +| HTTP 요청 | 기존 records·photos·nearestPipes·registrations URL·query·method 동일. 신규 lowerCamelCase BE alias로 강제 전환하지 않음 | +| BE 5차 조율 | 동일키 상이내용/다른등록자 409 및 미인증 401은 기존 HTTP 실패로 전달. FE가 자동 재시도하지 않음. 기존 alreadyRegistered 성공 응답 유지 | +| 저장·수정 UI | 1차 세션 중복방지 ID·2차 업로드 참조 재사용 유지. 안내 문구 변경 없음 | + +BE의 신규 alias 추가와 409 계약 정보는 담당 에이전트와 조율했다. 이번 프론트 배치에서 서버 오류 body를 새로 정의하지 않았다. 감리 XLSX는 메인이 프로그램 위치·책임 설명을 갱신한다. + +## 4. 검증·남은 위험 + +```powershell +# apps/sn +node --test tests/dredging-response-adapters.test.cjs +pnpm exec tsc --noEmit --incremental false --pretty false --ignoreDeprecations 5.0 +git diff --check +``` + +- 신규 테스트 7/7 통과. raw/wrapper 페이지의 page0·total0·items·추가필드, 촬영일시·첨부 원본, 최근접 선택 기본값, 부분 실패 안내, 실제 query·인코딩, 전체페이지 0→1 수집, 409 자동재시도 없음 확인. +- 기존 1~3차 36건을 포함한 전체 검증은 6차의 통합 명령에서 수행한다. +- TypeScript 검사 통과(exit 0). CLI 임시 ignoreDeprecations 5.0 기준이다. +- 실행 서비스·DB·첨부 서버를 호출하지 않았다. 실브라우저 최종 QA에서 `/oper/dredging` 조회→페이지/Excel→등록 위치탐색→상세 사진을 조작하고 Network의 기존 응답과 표출 필드를 대조한다. +- Runtime schema 검증·공통 페이지 타입 일괄 교체·HTTP URL alias 전환은 이번 범위가 아니다. 해당 계약을 추정 강화하지 않았다. + +## 5. 롤백 + +4차 시작 전 `src/features/dredging/api/dredging-api.ts`를 `D:/workspace/outputs/sn-refactor-phase4-20260910/fe-before` 아래 같은 상대경로로 복사하고 SHA-256 일치를 확인했다. + +4차만 되돌릴 때 이후 변경을 비교한 뒤 API 파일의 adapter import·호출을 원래 매핑으로 역패치하고 신규 response-adapters·신규 테스트를 제거한다. 준설의 feature 이동·upload queue·모달 ID 및 지도 3차 변경은 유지한다. DB·공통 패키지 rollback은 없다. diff --git a/apps/sn/docs/작업계획/SN-프론트엔드리팩토링/005-fe-SN-프론트엔드리팩토링-5차결과보고서.md b/apps/sn/docs/작업계획/SN-프론트엔드리팩토링/005-fe-SN-프론트엔드리팩토링-5차결과보고서.md new file mode 100644 index 000000000..8ffd80ded --- /dev/null +++ b/apps/sn/docs/작업계획/SN-프론트엔드리팩토링/005-fe-SN-프론트엔드리팩토링-5차결과보고서.md @@ -0,0 +1,140 @@ +# SN 프론트엔드 5차 리팩토링 결과보고서 + +> 2026-09-11 API 계약 대체 이력: 본문에 기록한 과거 SN proxy `/api/gis-upload`는 현재 `/api/gisUpload` 단일 경로로 대체되고 구 route는 제거됐다. 프록시의 반영·오류 전달 로직은 동일하다. [현재 API 계약](../../작업계획/SN-API경로단일화/001-fe-SN-API경로단일화-변경기록.md)을 기준으로 한다. + +작성일 2026-09-10. 사용자 추가 승인과 BE 담당자의 확정 계약에 따라 GIS 업로드 부분 성공을 일반 실패와 구분하고 같은 세션의 무조건 재업로드를 차단했다. 6차 검증 도구 준비 중 추가된 연동 배치이며 최종 build는 5차까지 포함해 한 번 수행한다. + +## 1. 목적·원본·수정 범위 + +- 목표: MS 관로 반영 단계가 완료된 후 SN 업로드 이력 저장만 실패했을 때 사용자가 같은 파일을 반복 반영하지 않도록 안내·버튼·handler 상태를 연결한다. +- 원본: `D:/workspace/IIPBS/docs/기준문서/요구사항정의서.xlsx`의 `3. (기능)요구사항정의 및 추적표` 97행 SFR-012-002/PR-SN-030, `화면설계서.pptx` 35슬라이드 SN_MN010302를 직접 다시 확인했다. +- 화면: GIS관리 진입→시설구분 선택→찾기→업로드, 업로드 현황의 시설구분·파일명·등록일자·등록자, 행 선택 다운로드, 원본과 편집자료 분리 유지. 기존 정상 입력·버튼·표를 제거하지 않았다. +- 허용: SN 및 business_components. 실제 변경은 `apps/sn`의 GIS 화면1파일·feature2파일·테스트1파일·본 문서다. 공통 package, BE, DB, proxy, 감리 XLSX를 수정하지 않았다. +- 완료 조건: 확정 오류 식별, scope별 세션 차단, 파일명변경/기관전환복귀로 해제되지 않음, 새 유효 현재본의 수신 후에만 신규 작업 허용, 실제 handler 회귀 테스트, 타입검사. 최종 실브라우저 QA는 메인 기동 환경에서 수행한다. + +부분 성공은 원본 화면에 별도 정의된 상태가 아니라 사용자 추가 요청과 BE 5차 확정 계약에 따른 오류 안전성 보완이다. + +## 2. BE 계약과 정상 작업 해제 조건 + +BE 담당자와 직접 조율한 응답: + +```json +{ + "success": false, + "status": 500, + "code": "GIS_UPLOAD_HISTORY_SAVE_FAILED", + "message": "관로 변경 반영 단계는 완료되었으나 업로드 이력을 저장하지 못했습니다. ...", + "uploadId": "GUP...", + "msApplied": true, + "retryAllowed": false +} +``` + +실제 HTTP도 500이다. SN `postMutiPart`는 기존대로 오류의 `body`에 서버 body를 보관하며, Next `/api/gis-upload` proxy는 upstream status/body를 전달한다. proxy·공통 helper 코드는 이 단계에서 수정하지 않았다. + +차단 해제는 다음 조건을 모두 만족해야 한다. + +1. 차단된 관리기관 scope와 동일하다. 현재 지원 시설은 PPL로 고정이다. +2. 현재 관로 GeoPackage 다운로드 작업이 READY이고 실제 파일 응답을 수신했다. +3. 기존 `saveBlob`으로 브라우저 저장을 요청한 후다. OS의 실제 파일 저장 완료까지 확인한 의미는 아니다. +4. 응답 `X-Gis-Download-Id` 또는 READY 작업의 ID가 비어 있지 않고 차단 때의 downloadId와 다르다. +5. 새 작업 허용 시 이전 선택 파일을 초기화한다. 과거 업로드 ID의 이력 복구는 관리자 확인이 필요하다고 계속 안내한다. + +파일명·파일 재선택·기관 전환 후 복귀·이력 다운로드로 차단을 해제하지 않는다. BE는 downloadId가 현재 관로 baseline 서명이라 이 조건이 신규 작업 경계로 적합함을 확인했다. 변경건수 0이면 새 현재본의 ID가 같을 수 있으며 이때 해제되지 않는 것은 안전한 차단이다. 새 다운로드가 과거 이력 복구를 증명하지는 않는다. + +## 3. 수정 파일과 실제 전후 코드 + +| SN 파일 | 역할 | +| --- | --- | +| `src/features/gisManagement/model/upload-retry-guard.ts` 신규 | 오류 식별·안내·scope별 차단 Map·새 현재본 확인 정책 | +| `src/features/gisManagement/index.ts` 신규 | 모델의 공개 export | +| `app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx` | 세션 guard 소유, handler 차단, 부분 성공 catch, 기존 notice·업로드 disabled 연결, 새 현재본 시 파일 초기화 | +| `tests/gis-upload-retry-guard.test.cjs` 신규 | 오류형식·scope·해제조건·실제 handler 재호출 5건 | + +### 3.1 오류 식별 + +수정 전 GIS 화면 catch는 모든 오류를 같은 안내 함수로 처리했다. + +```ts +} catch (error) { + console.error("GIS 업로드 실패:", error); + alert( + getErrorMessage(error, "GeoPackage 업로드 중 오류가 발생했습니다."), + ); +} +``` + +수정 후에는 확정 부분 성공을 먼저 기록한다. + +```ts +const partial = uploadRetryGuard.recordFailure(selectedScopeKey, latestDownloadId, error); +if (partial) { + const message = getGisPartialSuccessNotice(partial); + setNotice(message); + alert(message); + return; +} +``` + +판정은 `code`와 `msApplied === true`, `retryAllowed === false`를 모두 요구한다. 일반500·네트워크 오류·이름만 같은 불완전 응답은 부분 성공으로 가정하지 않는다. `error.body`, Axios `response.data`, 기존 data wrapper 위치를 지원한다. + +### 3.2 세션별 차단과 재시도 + +새 guard의 핵심 구현: + +```ts +const blockedScopes = new Map(); +// recordFailure +const partial = readGisPartialSuccess(error); +if (!partial) return null; +const block = { ...partial, downloadId }; +blockedScopes.set(scope, block); +return block; +``` + +화면은 ref에 guard를 두어 파일·기관 선택으로 객체가 초기화되지 않도록 했다. 실제 POST 앞에서도 다시 검사한다. + +```ts +const blocked = uploadRetryGuard.getBlock(selectedScopeKey); +if (blocked) { + alert(getGisPartialSuccessNotice(blocked)); + return; +} +``` + +업로드 버튼 disabled 조건도 기존 관리기관·파일·loading 조건에 `Boolean(partialSuccessBlock)`을 추가했다. 기존 notice 영역에는 부분 성공 경고를 우선 표시하므로 목록조회 등으로 안내 문자열이 바뀌어도 차단 사실이 사라지지 않는다. + +### 3.3 새 작업과 과거 이력 복구의 구분 + +```ts +const block = blockedScopes.get(scope); +const nextId = downloadId.trim(); +if (!block || !nextId || nextId === block.downloadId) return null; +blockedScopes.delete(scope); +return block; +``` + +위 `acceptFreshDownload`는 현재본 파일을 수신하고 `saveBlob`을 실행한 경로에서만 호출한다. 반환된 이전 block이 있으면 선택파일을 초기화하고 기존 다운로드 완료 안내에 이전 uploadId의 관리자 이력 확인 안내를 붙인다. 이력 다운로드 handler와 파일선택 handler는 이 함수를 호출하지 않는다. + +## 4. 검증·감리 현행화 + +- 새 테스트 5/5 통과. transport/Axios/wrapper 오류 식별, 일반500 배제, 기관별 차단 유지, 빈/같은 ID 해제 금지, 다른 기관 다운로드 해제 금지, 새 현재본 ID 허용과 과거 ID 반환을 확인했다. +- 실제 `GisManagementPopupPage.tsx`의 `handleUpload` AST를 추출해 HTTP만 모의했다. 첫 POST가 부분 성공 오류를 반환한 후 파일이 달라진 상태로 같은 handler를 호출해도 총 POST가 1건이고 두 안내 모두 GUP-1을 포함함을 확인했다. +- 전체 누적 검증 53건 통과, 기본 `pnpm run typecheck` 통과. 6차 도구 보고서에 통합 결과를 기록한다. +- 테스트는 실제 DB의 MS 성공/SN 실패를 강제로 만든 검증이 아니다. 최종 브라우저 QA에서 오류 fixture를 명확히 구분해 안내·버튼·Network 재호출 차단과 새 다운로드 경로를 확인한다. 실제 운영 데이터에 실패를 유도하지 않는다. + +감리 반영: + +| 명세 | 항목 | +| --- | --- | +| 프로그램 | `readGisPartialSuccess`, `getGisPartialSuccessNotice`, `createGisUploadRetryGuard`의 getBlock/recordFailure/acceptFreshDownload와 세션 scope 설명 | +| 프로그램 | GIS 화면의 handleUpload·handleDownloadCurrent·기존 notice·disabled 분기에 부분 성공 계약 연결 | +| API | 기존 GIS 업로드 URL·정상 요청·응답 유지. 확정 HTTP500 부분 성공 body의 code/uploadId/msApplied/retryAllowed 사용 위치 | +| 오류·재시도 | 일반 실패와 구분, 같은 scope 재업로드 차단, 다른 유효 현재본 downloadId 확인 후 다음 작업 허용 | +| 제한 | 영속 복구 상태·DB 보상·서버 재시작 후 연속 차단 보장 없음 | + +## 5. 위험·롤백 + +차단은 열린 팝업 메모리 상태다. 새로고침·새 창·프로세스 재시작 이후까지 영속 보장하지 않는다. MS 적용 결과·누락 이력 복구는 운영자가 uploadId로 확인해야 한다. 이 변경을 부분 성공의 자동 복구로 표현하면 안 된다. + +5차 전 화면 원본은 `D:/workspace/outputs/sn-refactor-phase5-20260910/fe-before/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx`에 복사했고 SHA-256 동일함을 확인했다. 5차만 롤백할 때 이후 변경과 비교해 화면의 guard·해제·안내 delta를 역패치하고 신규 feature2파일·테스트를 제거한다. 6차 구조검사 대상의 gisManagement 항목도 함께 조정한다. BE가 부분 성공 오류를 내리는 상태에서 FE guard를 롤백하면 잘못된 재업로드 위험이 다시 생긴다. diff --git a/apps/sn/docs/작업계획/SN-프론트엔드리팩토링/006-fe-SN-프론트엔드리팩토링-6차검증및구조게이트.md b/apps/sn/docs/작업계획/SN-프론트엔드리팩토링/006-fe-SN-프론트엔드리팩토링-6차검증및구조게이트.md new file mode 100644 index 000000000..0c13af6a7 --- /dev/null +++ b/apps/sn/docs/작업계획/SN-프론트엔드리팩토링/006-fe-SN-프론트엔드리팩토링-6차검증및구조게이트.md @@ -0,0 +1,142 @@ +# SN 프론트엔드 6차 검증·구조 게이트 보고서 + +작성일 2026-09-10. 1~5차 코드를 보존하고 SN 내부 검사 명령·TypeScript 설정·ESLint 로딩을 정비했다. 최종 build와 실제 서버·브라우저 회귀는 별도 결과로 표시한다. + +## 1. 목표·근거·범위 + +- 목표: 같은 검증을 재현 가능한 SN 명령으로 실행하고 신규 모듈이 다시 app·UI에 의존하거나 legacy에 구현이 복제되는 것을 자동으로 차단한다. +- 근거: 사용자의 6차까지 진행·최종 서버 기동/회귀 요청, 메인의 SN local 설정·검사 복구 승인. 원본 기능은 앞 단계에서 직접 확인한 SFR-010-012(XLSX84/PPT53), SFR-012-005(XLSX100/PPT37), SFR-011-009(XLSX95/PPT31·32), SFR-012-002(XLSX97/PPT35) 범위를 유지한다. 원본 경로·화면 항목은 2~5차 보고서에 남겼다. +- 수정 허용: `apps/sn/**`, 필요시 `business_components/**`. 실제 수정은 SN의 package·tsconfig·eslint config·검증 스크립트·테스트·보고서 및 이동 후 미사용 type import1건이다. +- 금지: 다른 앱/공통 package·의존성 설치/갱신·루트 설정·BE·DB·감리 XLSX. 모두 변경하지 않았다. +- 완료 기준: 기본 typecheck 통과, 전체 회귀, 실제 위반을 막는 구조 검사, 범위가 명시된 lint baseline, dev 서버 없는 상태의 production build, 최종 조율 환경의 브라우저 결과와 잔여 위험 기록. + +## 2. 수정 파일과 실제 전후 코드 + +### 2.1 TypeScript 버전 정합성 + +현재 설치된 TypeScript는 5.9.3이다. `tsconfig.json`의 기존 값 `ignoreDeprecations: "6.0"`은 이 버전에서 TS5103을 발생시켜 기본 typecheck를 시작하지 못했다. 1~5차에서 CLI 임시값으로 검증했던 값으로 정합화했다. + +```json +// 이전 +"ignoreDeprecations": "6.0" +// 이후 +"ignoreDeprecations": "5.0" +``` + +타입 규칙·strict·경로 alias·대상 lib는 변경하지 않았다. 이제 임시 옵션 없이 `pnpm run typecheck`를 실행한다. + +### 2.2 ESLint config 로딩 복구 + +기존 코드: + +```js +...(isProd ? { "react-hooks/exhaustive-deps": "off" } : {}), +``` + +배열 안에서 객체를 spread해 `is not iterable` 오류가 발생했다. 수정 후: + +```js +// Flat config는 설정 객체의 배열을 펼쳐야 한다. 기존 production 의도를 유지한다. +...(isProd ? [{ rules: { "react-hooks/exhaustive-deps": "off" } }] : []), +``` + +전체 앱 rule을 끄지 않았다. ESLint 9.39.4가 설정을 읽을 수 있게 하고 기존 production 규칙 의도를 올바른 Flat Config 형식으로 표현했다. `dredging-api.ts`에서 4차 adapter로 옮긴 후 남은 미사용 `DredgingRecord` type import도 제거했다. + +### 2.3 재현 가능한 package 명령 + +추가한 실제 명령: + +```json +"test:refactor": "node scripts/run-refactor-tests.cjs", +"typecheck": "tsc --noEmit --incremental false --pretty false", +"check:boundaries": "node scripts/check-module-boundaries.cjs", +"lint:refactor": "eslint src/shared/api/http-transport.ts src/app/providers/app-provider.tsx src/features/dredging src/features/mapLayers src/features/gisManagement --rule \"@typescript-eslint/no-explicit-any:warn\" --max-warnings 22" +``` + +Windows Node22에서 `node --test tests`는 디렉터리를 모듈로 찾다가 실패했다. 실행 script는 파일 목록을 명시해 셸 glob 차이를 제거한다. + +```js +const files = fs.readdirSync(path.join(root, "tests")) + .filter((name) => name.endsWith(".test.cjs")) + .sort() + .map((name) => path.join(root, "tests", name)); +const result = spawnSync(process.execPath, ["--test", ...files], { + cwd: root, + stdio: "inherit", +}); +process.exitCode = result.status ?? 1; +``` + +원래 화면·helper에서 이동한 `any`와 기존 호환 API 반환의 `any` 22건을 scoped lint 경고 기준선으로 남겼다. 다른 rule은 error를 유지하고 경고가 22건을 넘으면 실패한다. 이는 전체 앱 strict lint 통과가 아니다. 무리한 `any→unknown` 전환으로 기존 API·메타 입력 계약을 이 배치에서 바꾸지 않았다. + +### 2.4 실제 차단 가능한 모듈 경계 + +`scripts/check-module-boundaries.cjs`는 TypeScript AST로 import/export-from/dynamic import/require 문자열을 수집해 다음을 검사한다. + +- 신규 `features/dredging`, `features/mapLayers`, `features/gisManagement`의 app 라우트 역참조 금지. +- 신규 feature의 `model`에서 API·UI·app·components 의존 금지(type-only도 포함). +- shared HTTP transport의 features·entities·app·components 역참조 금지. +- 옛 준설 lib7파일은 새 feature를 향한 re-export만 허용. 함수·상태 등 구현을 다시 추가하면 실패. +- 남아 있는 legacy 기반 의존은 아래 3개의 파일/모듈 쌍만 명시 허용. + +```js +const LEGACY_DEPENDENCIES = { + "src/features/dredging/api/dredging-api.ts": ["@/components/lib/api"], + "src/features/dredging/api/photo-upload.ts": ["@/components/lib/utils"], + "src/features/dredging/lib/use-dredging-common-codes.ts": ["@/components/lib/common/apiHelpers"], +}; +``` + +타입/UI의 라우트 역참조 제거와 transport/TUS/CMS 호환 helper 의존 잔존을 구분한다. 전체 FSD 전환 완료가 아니다. 사용자 지정 범위 밖 공통 패키지의 구조 부채를 이 게이트가 고쳤다고 주장하지 않는다. + +검사 실패 시 `process.exitCode = 1`을 반환한다. `tests/module-boundary-policy.test.cjs`는 잘못된 type-only API import, app dynamic import, 허용되지 않은 legacy helper 사용, legacy 구현 복귀, transport→auth 역참조를 넣었을 때 실제 오류가 나오는지 확인한다. + +## 3. 최종 실행 기준과 현재 결과 + +실행 위치 `D:/workspace/IIPBS-FRONTEND/frontend/apps/sn`: + +```powershell +pnpm run check:boundaries +pnpm run test:refactor +pnpm run typecheck +pnpm run lint:refactor +# 3002 리스너 없음 확인 후 실행 +pnpm run build +git diff --check +``` + +| 검증 | 결과 | +| --- | --- | +| 전체 Node 회귀 | 53/53 통과. API·준설등록·사진업로드·모듈이동·응답adapter·지도메타/수명·GIS부분성공·구조검사 포함 | +| 구조 검사 | 26파일, 위반 0. 허용 legacy3개를 출력해 잔존 경계를 드러냄 | +| 기본 typecheck | 통과(exit0). CLI ignoreDeprecations 우회 없이 실행 | +| scoped lint | error0, 기존 호환 any warning22. 최대 경고22 제한. 전체 앱 lint 통과를 뜻하지 않음 | +| diff 공백 | 통과 | +| production build | `pnpm run build` 1회 실행 exit0. Next15.5.18 컴파일 성공(3.0분), 정적페이지27/27·standalone trace 완료. `/map`, `/oper/dredging`, `/popup/[popupKey]` 포함 route 산출 확인 | +| 실제 브라우저 | build 종료 후 QA `regression_preflight`에 서버기동가능·브라우저 독점 인계. QA가 BE8883/FE3002 조율 환경에서 회귀하며 결과는 메인 통합 검증에 반영 | + +기존 `next.config.ts`의 `typescript.ignoreBuildErrors: true`, `eslint.ignoreDuringBuilds: true`는 변경하지 않았다. 따라서 build 결과와 위 별도 typecheck/lint 결과를 합쳐 판단해야 하며 build만으로 타입·lint 검증을 주장하지 않는다. 새 패키지 설치·업데이트·lockfile 변경은 없다. + +FE 에이전트는 build 실행 전 3002 리스너가 없음을 확인했고 개발 서버는 기동하지 않았다. build PID는 49584였으며 명령 exit0을 확인한 뒤 QA에게 기동을 인계했다. 임시 stdout/stderr 로그 파일을 생성하지 않았다. 이후 QA 소유 서버의 종료 책임·실브라우저 결과는 통합 검증에서 별도로 기록한다. + +## 4. 감리 프로그램/API 명세 전달 + +| 변경 파일 | 프로그램 역할 | +| --- | --- | +| `package.json` | SN 회귀·타입·경계·scoped lint 실행 명령 | +| `tsconfig.json` | 설치된 TypeScript5.9.3과 호환하는 deprecation 옵션 | +| `eslint.config.mjs` | Flat Config 배열 구성 복구 | +| `scripts/run-refactor-tests.cjs` 신규 | Windows/셸 독립 테스트 목록·Node 실행·종료코드 전달 | +| `scripts/check-module-boundaries.cjs` 신규 | 26파일의 계층 규칙·legacy 얇은 진입점·예외3개 검사 | +| `tests/module-boundary-policy.test.cjs` 신규 | 구조 위반이 실제 실패하는지 검증 | +| `src/features/dredging/api/dredging-api.ts` | 4차 후 미사용 타입 import 정리 | + +6차 자체는 외부 HTTP API 변경이 없다. 서버 alias·오류 확장은 BE 단계 명세와 5차 FE 사용 위치로 연결한다. 프로그램명세서에 호환 re-export를 중복 업무 구현으로 세지 않고 실제 feature·transport 구현 위치를 표기한다. + +## 5. 롤백·남은 위험 + +6차 전 snapshot은 `D:/workspace/outputs/sn-refactor-phase6-20260910/fe-before`에 package.json, tsconfig.json, eslint.config.mjs, `src/features/dredging/api/dredging-api.ts` 4파일을 복사하고 SHA-256 동일함을 확인했다. 신규 scripts2개·새 경계정책 test·본 보고서는 기준선에 없던 파일이다. + +6차만 되돌릴 때 해당 설정·미사용 import delta를 역패치하고 새 scripts와 정책 test를 제거한다. 이 경우 TypeScript 기본 설정 오류와 ESLint config 로딩 오류도 되돌아간다. 1~5차 업무·모듈 코드는 유지한다. 이후 다른 작업 변경이 있으면 snapshot 통째 덮어쓰기를 하지 않는다. + +남은 구조 위험은 legacy API/TUS/CMS3개 기반 의존, `app`에 남은 지도·준설 UI, `any` 경계22건, 전체 앱 lint 부채다. 실제 데이터 정확성·TUS 복구·지도 이벤트·GIS 부분성공 안내는 최종 브라우저/서버 검증 결과와 함께 보고한다. diff --git a/apps/sn/docs/작업내용/SFR-011-008-침수예측/001-fe-SFR-011-008-침수예측지도-격자표시-구현기록.md b/apps/sn/docs/작업내용/SFR-011-008-침수예측/001-fe-SFR-011-008-침수예측지도-격자표시-구현기록.md index 9a3b4f0b1..6f1f3f0a4 100644 --- a/apps/sn/docs/작업내용/SFR-011-008-침수예측/001-fe-SFR-011-008-침수예측지도-격자표시-구현기록.md +++ b/apps/sn/docs/작업내용/SFR-011-008-침수예측/001-fe-SFR-011-008-침수예측지도-격자표시-구현기록.md @@ -1,5 +1,7 @@ # SFR-011-008 침수예측지도 격자 표시 FE 구현기록 +> 2026-09-11 API 계약 대체 이력: 본문의 과거 호출 URL은 당시 검증·구현 증거이며 현재 호출 계약이 아니다. 현재는 lowerCamelCase 단일 경로만 지원하고 기존 alias는 제거된다. [경로 단일화 변경기록](../../작업계획/SN-API경로단일화/001-fe-SN-API경로단일화-변경기록.md)의 현재 경로로 검증한다. + ## 구현 내용 - 침수예측현황 선택행의 `analysisDateTime`을 지도 이동 메시지에 포함했다. diff --git a/apps/sn/docs/작업내용/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-통합구현기록.md b/apps/sn/docs/작업내용/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-통합구현기록.md index fa051e08d..6f77afc00 100644 --- a/apps/sn/docs/작업내용/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-통합구현기록.md @@ -1,5 +1,7 @@ # SFR-011-009 침수발생이력 통합 구현 기록 +> 2026-09-11 API 계약 대체 이력: 본문의 과거 호출 URL은 당시 검증·구현 증거이며 현재 호출 계약이 아니다. 현재는 lowerCamelCase 단일 경로만 지원하고 기존 alias는 제거된다. [경로 단일화 변경기록](../../작업계획/SN-API경로단일화/001-fe-SN-API경로단일화-변경기록.md)의 현재 경로로 검증한다. + ## 1. 문서 목적 - `SFR-011-009 침수발생이력`의 FE 구현 이력과 남은 리스크를 기능당 1개 문서로 누적 관리한다. diff --git a/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md b/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md index 63de2ce70..e5d422428 100644 --- a/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md @@ -1,5 +1,7 @@ # SFR-012-003 하수관로편집 통합 구현 기록 +> 2026-09-11 API 계약 대체 이력: 본문의 과거 호출 URL은 당시 검증·구현 증거이며 현재 호출 계약이 아니다. 현재는 lowerCamelCase 단일 경로만 지원하고 기존 alias는 제거된다. [경로 단일화 변경기록](../../작업계획/SN-API경로단일화/001-fe-SN-API경로단일화-변경기록.md)의 현재 경로로 검증한다. + ## 1. 문서 목적 - `SFR-012-003 하수관로편집`의 FE 구현 이력, 결정사항, 남은 리스크를 기능당 1개 문서로 누적 관리한다. diff --git a/apps/sn/docs/작업내용/SN-RDII분석결과-지도연계유지보수/001-fe-SN-RDII분석결과-지도연계유지보수-통합구현기록.md b/apps/sn/docs/작업내용/SN-RDII분석결과-지도연계유지보수/001-fe-SN-RDII분석결과-지도연계유지보수-통합구현기록.md index f16b9d37e..920408007 100644 --- a/apps/sn/docs/작업내용/SN-RDII분석결과-지도연계유지보수/001-fe-SN-RDII분석결과-지도연계유지보수-통합구현기록.md +++ b/apps/sn/docs/작업내용/SN-RDII분석결과-지도연계유지보수/001-fe-SN-RDII분석결과-지도연계유지보수-통합구현기록.md @@ -1,5 +1,7 @@ # SN RDII분석결과 지도연계 유지보수 FE 통합구현기록 +> 2026-09-11 API 계약 대체 이력: 본문의 과거 호출 URL은 당시 검증·구현 증거이며 현재 호출 계약이 아니다. 현재는 lowerCamelCase 단일 경로만 지원하고 기존 alias는 제거된다. [경로 단일화 변경기록](../../작업계획/SN-API경로단일화/001-fe-SN-API경로단일화-변경기록.md)의 현재 경로로 검증한다. + ## 1. 작업 목적 - RDII 분석결과를 좌측 LNB 대신 하수관망도 `분석도구` popup으로 옮기고, 침입수 분석결과와 같은 지도 연계 흐름으로 정리한다. diff --git a/apps/sn/docs/작업내용/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-통합구현기록.md b/apps/sn/docs/작업내용/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-통합구현기록.md index f1228deca..38b9213d9 100644 --- a/apps/sn/docs/작업내용/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-통합구현기록.md +++ b/apps/sn/docs/작업내용/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-통합구현기록.md @@ -1,5 +1,7 @@ # SN 침입수분석결과 지도연계 유지보수 FE 통합구현기록 +> 2026-09-11 API 계약 대체 이력: 본문의 과거 호출 URL은 당시 검증·구현 증거이며 현재 호출 계약이 아니다. 현재는 lowerCamelCase 단일 경로만 지원하고 기존 alias는 제거된다. [경로 단일화 변경기록](../../작업계획/SN-API경로단일화/001-fe-SN-API경로단일화-변경기록.md)의 현재 경로로 검증한다. + ## 1. 작업 목적 - 하수관망도 `분석도구`에 `침입수 분석` popup을 추가하고, 기존 `ii/result` 결과 조회 흐름을 지도 중심 popup으로 옮긴다. diff --git a/apps/sn/eslint.config.mjs b/apps/sn/eslint.config.mjs index 5524ee038..2ddf68aa8 100644 --- a/apps/sn/eslint.config.mjs +++ b/apps/sn/eslint.config.mjs @@ -12,7 +12,8 @@ const compat = new FlatCompat({ const eslintConfig = [ ...compat.extends("next/core-web-vitals", "next/typescript"), - ...(isProd ? { "react-hooks/exhaustive-deps": "off" } : {}), + // Flat config는 설정 객체의 배열을 펼쳐야 한다. 기존 production 의도를 유지한다. + ...(isProd ? [{ rules: { "react-hooks/exhaustive-deps": "off" } }] : []), ]; export default eslintConfig; diff --git a/apps/sn/package.json b/apps/sn/package.json index 1203cd1e1..8c3e4167c 100644 --- a/apps/sn/package.json +++ b/apps/sn/package.json @@ -7,7 +7,11 @@ "dev:webpack": "next dev -p 3002", "build": "next build", "start:sn": "next start -p 3002", - "lint": "next lint" + "lint": "next lint", + "test:refactor": "node scripts/run-refactor-tests.cjs", + "typecheck": "tsc --noEmit --incremental false --pretty false", + "check:boundaries": "node scripts/check-module-boundaries.cjs", + "lint:refactor": "eslint src/shared/api/http-transport.ts src/app/providers/app-provider.tsx src/features/dredging src/features/mapLayers src/features/gisManagement --rule \"@typescript-eslint/no-explicit-any:warn\" --max-warnings 22" }, "dependencies": { "@common/assets": "workspace:*", diff --git a/apps/sn/scripts/audit-screen-inventory.mjs b/apps/sn/scripts/audit-screen-inventory.mjs index 1ecd7c9b1..dd935efff 100644 --- a/apps/sn/scripts/audit-screen-inventory.mjs +++ b/apps/sn/scripts/audit-screen-inventory.mjs @@ -12,8 +12,6 @@ const POPUP_REGISTRY = path.resolve( const routeAliases = new Set([ "/map/agedPipePopup", "/map2", - "/html/aged-pipe", - "/html/map", ]); const developmentRoutes = new Set([ "/ii/fileupload", diff --git a/apps/sn/scripts/check-module-boundaries.cjs b/apps/sn/scripts/check-module-boundaries.cjs new file mode 100644 index 000000000..96bd69ef1 --- /dev/null +++ b/apps/sn/scripts/check-module-boundaries.cjs @@ -0,0 +1,165 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const ts = require("typescript"); + +// 2~4차가 아직 사용하는 기존 기반 helper만 파일·모듈 쌍으로 좁게 허용한다. +const LEGACY_DEPENDENCIES = { + "src/features/dredging/api/dredging-api.ts": ["@/components/lib/api"], + "src/features/dredging/api/photo-upload.ts": ["@/components/lib/utils"], + "src/features/dredging/lib/use-dredging-common-codes.ts": [ + "@/components/lib/common/apiHelpers", + ], +}; +const LEGACY_DIRECTORY = "app/(with-base-layout)/oper/dredging/lib/"; +const LEGACY_FILES = [ + "client", + "types", + "uuid", + "date", + "exif", + "registration-request", + "useDredgingCommonCodes", +].map((name) => `${LEGACY_DIRECTORY}${name}.ts`); + +/** 정적·동적 import, export-from, require의 문자열 모듈 참조를 수집한다. */ +function moduleSpecifiers(sourceFile) { + const modules = []; + function visit(node) { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + modules.push(node.moduleSpecifier.text); + } + if ( + ts.isCallExpression(node) && + node.arguments.length && + ts.isStringLiteral(node.arguments[0]) && + (node.expression.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(node.expression) && + node.expression.text === "require")) + ) { + modules.push(node.arguments[0].text); + } + ts.forEachChild(node, visit); + } + visit(sourceFile); + return modules; +} + +/** 새 feature의 모델·기반과 legacy 진입점에만 검증 범위를 한정한다. */ +function validateSource(relativePath, content) { + const sourceFile = ts.createSourceFile( + relativePath, + content, + ts.ScriptTarget.Latest, + true, + ); + const errors = []; + if (LEGACY_FILES.includes(relativePath)) { + if ( + !sourceFile.statements.length || + sourceFile.statements.some( + (statement) => + !ts.isExportDeclaration(statement) || + !statement.moduleSpecifier || + !ts.isStringLiteral(statement.moduleSpecifier) || + !statement.moduleSpecifier.text.startsWith("@/features/dredging/"), + ) + ) { + errors.push( + `${relativePath}: legacy 파일은 준설 feature 재수출만 허용합니다.`, + ); + } + } + for (const specifier of moduleSpecifiers(sourceFile)) { + const resolved = specifier.startsWith("@/") + ? `src/${specifier.slice(2)}` + : specifier.startsWith(".") + ? path.posix.normalize( + path.posix.join(path.posix.dirname(relativePath), specifier), + ) + : specifier; + if ( + /^src\/features\/(dredging|mapLayers|gisManagement|gisEdit)\//.test(relativePath) + ) { + if (specifier.startsWith("@/app/") || resolved.startsWith("app/")) { + errors.push(`${relativePath}: app 라우트 역참조 금지 (${specifier})`); + } + if ( + specifier.startsWith("@/components/") && + !(LEGACY_DEPENDENCIES[relativePath] ?? []).includes(specifier) + ) { + errors.push( + `${relativePath}: 승인되지 않은 legacy 기반 의존 (${specifier})`, + ); + } + if ( + relativePath.includes("/model/") && + /(?:^|\/)(?:api|ui|app|components)(?:\/|$)/.test(resolved) + ) { + errors.push( + `${relativePath}: model이 API/UI/라우트에 의존할 수 없습니다 (${specifier})`, + ); + } + } + if ( + relativePath === "src/shared/api/http-transport.ts" && + /^(?:src\/)?(?:features|entities|app|components)\//.test(resolved) + ) { + errors.push( + `${relativePath}: transport의 상위 계층 역참조 금지 (${specifier})`, + ); + } + } + return errors; +} + +/** 신규 feature만 재귀 검사한다. 타 앱·공용 패키지의 부채는 이 게이트에 혼합하지 않는다. */ +function sourceFiles(directory) { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const filename = path.join(directory, entry.name); + return entry.isDirectory() + ? sourceFiles(filename) + : /\.tsx?$/.test(entry.name) + ? [filename] + : []; + }); +} + +function checkWorkspace(root) { + const files = [ + ...LEGACY_FILES.map((relative) => path.join(root, relative)), + ...sourceFiles(path.join(root, "src/features/dredging")), + ...sourceFiles(path.join(root, "src/features/mapLayers")), + ...sourceFiles(path.join(root, "src/features/gisManagement")), + ...sourceFiles(path.join(root, "src/features/gisEdit")), + path.join(root, "src/shared/api/http-transport.ts"), + ]; + const errors = files.flatMap((filename) => + validateSource( + path.relative(root, filename).split(path.sep).join("/"), + fs.readFileSync(filename, "utf8"), + ), + ); + return { + checkedFiles: files.length, + legacyDependencies: LEGACY_DEPENDENCIES, + errors, + }; +} + +if (require.main === module) { + const result = checkWorkspace(path.resolve(__dirname, "..")); + console.log( + `SN module boundaries: ${result.checkedFiles} files, ${result.errors.length} violations`, + ); + console.log( + "Retained legacy bridges: dredging-api -> API utility; photo-upload -> TUS utility; common-code hook -> CMS helper", + ); + for (const error of result.errors) console.error(error); + if (result.errors.length) process.exitCode = 1; +} + +module.exports = { validateSource, checkWorkspace }; diff --git a/apps/sn/scripts/run-refactor-tests.cjs b/apps/sn/scripts/run-refactor-tests.cjs new file mode 100644 index 000000000..a84e74aba --- /dev/null +++ b/apps/sn/scripts/run-refactor-tests.cjs @@ -0,0 +1,18 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +// Windows 셸의 glob 확장 여부와 무관하게 실제 테스트 파일을 Node runner에 전달한다. +const root = path.resolve(__dirname, ".."); +const files = fs + .readdirSync(path.join(root, "tests")) + .filter((name) => name.endsWith(".test.cjs")) + .sort() + .map((name) => path.join(root, "tests", name)); +if (!files.length) throw new Error("No refactor tests found"); +const result = spawnSync(process.execPath, ["--test", ...files], { + cwd: root, + stdio: "inherit", +}); +if (result.error) throw result.error; +process.exitCode = result.status ?? 1; diff --git a/apps/sn/src/app/index.tsx b/apps/sn/src/app/index.tsx deleted file mode 100644 index 5a73f2fdd..000000000 --- a/apps/sn/src/app/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import "./styles/base.css"; -import "./styles/components.css"; -import { AppProvider } from "./providers"; -import { cookies } from "next/headers"; - -const App = async ({ children }: Readonly<{ children: React.ReactNode }>) => { - const theme = (await cookies()).get("site-theme-key"); - - return ( - - - {children} - - - ); -}; - -export { App }; diff --git a/apps/sn/src/app/providers/app-provider.tsx b/apps/sn/src/app/providers/app-provider.tsx index b4750452f..f665ac235 100644 --- a/apps/sn/src/app/providers/app-provider.tsx +++ b/apps/sn/src/app/providers/app-provider.tsx @@ -1,6 +1,7 @@ "use client"; import { DndProvider } from "react-dnd"; +import { useState } from "react"; import { MultiBackend, getBackendOptions } from "@minoru/react-dnd-treeview"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ConfirmProvider } from "@frontend-opensource/use-react-hooks"; @@ -23,7 +24,8 @@ const LazyReactQueryDevtools = dynamic( ); const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { - const queryClient = new QueryClient(); + // Provider가 다시 렌더되어도 조회 캐시와 진행 중 요청을 같은 인스턴스에 유지한다. + const [queryClient] = useState(() => new QueryClient()); return ( diff --git a/apps/sn/src/app/styles/components.css b/apps/sn/src/app/styles/components.css index b6eb7c567..f8519fe40 100644 --- a/apps/sn/src/app/styles/components.css +++ b/apps/sn/src/app/styles/components.css @@ -39,6 +39,67 @@ @apply h-[40px] w-[200px] cursor-pointer rounded-md border border-input bg-field px-3 py-2 text-sm text-foreground; } + /* + * SN의 일반 콤보박스는 블랙모드에서 공통 form 색상으로 통일한다. + * 비활성 콤보만 field 색상을 사용해 입력 가능 여부를 시각적으로 구분한다. + */ + [data-theme="black"] + :where(.selectbox_trigger, button[role="combobox"], select) { + background-color: var(--form) !important; + color: var(--foreground) !important; + color-scheme: dark; + } + + [data-theme="black"] + :where( + button[role="combobox"]:disabled, + button[role="combobox"][aria-disabled="true"], + button[role="combobox"][data-disabled], + select:disabled + ) { + background-color: var(--field) !important; + color: var(--muted-foreground) !important; + } + + [data-theme="black"] select option { + background-color: var(--popover); + color: var(--popover-foreground); + } + + /* 입력 가능한 텍스트 계열 필드도 콤보와 동일한 form 색상을 사용한다. */ + [data-theme="black"] + :where( + input:not([type]), + input[type="text"], + input[type="search"], + input[type="email"], + input[type="tel"], + input[type="url"], + input[type="password"], + input[type="number"], + input[type="date"], + input[type="datetime-local"], + input[type="month"], + input[type="week"], + input[type="time"], + textarea + ) { + background-color: var(--form) !important; + color: var(--foreground) !important; + color-scheme: dark; + } + + [data-theme="black"] + :where( + input:disabled, + input[readonly], + textarea:disabled, + textarea[readonly] + ) { + background-color: var(--field) !important; + color: var(--muted-foreground) !important; + } + .sn-popup-page { @apply flex h-screen min-w-0 flex-col overflow-hidden bg-background text-foreground; } @@ -299,10 +360,6 @@ input.text-label { color: var(--label); } - button[role="combobox"] { - color: var(--foreground); - background-color: var(--field); - } div [role="tablist"] { border: unset; gap: 4px; diff --git a/apps/sn/src/entities/auth/model/login-interface.ts b/apps/sn/src/entities/auth/model/login-interface.ts deleted file mode 100644 index 5aaf53044..000000000 --- a/apps/sn/src/entities/auth/model/login-interface.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface ILogin { - userId: string; - password: string; -} - -export interface LoginService { - login(payload: ILogin): Promise; - silentRefresh(): Promise; -} diff --git a/apps/sn/src/entities/user/api/user-service.ts b/apps/sn/src/entities/user/api/user-service.ts deleted file mode 100644 index 482d51e16..000000000 --- a/apps/sn/src/entities/user/api/user-service.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { client } from "@/shared/api"; -import { ENDPOINT } from "@/shared/config"; -import type { - UserService, - UserSearchParams, - UserList, - UserDetailResponse, - UserSttsStatsList, - UserUpdateData, - UserPasswordUpdateData, - UserFindPasswordUpdateData, - UserPasswordMatchData, - UserUpdateInfoData, - UserSaveData, - UserFindPasswordSaveData, - UserVerifyData, - UserEmailData, - UserIdData, - AdminUserList, -} from "../model/user-interface"; - -export class UserApiService implements UserService { - // 사용자 목록 조회 - public async findPageUser(params: UserSearchParams): Promise { - const response = await client.get( - `${ENDPOINT.USER_SERVICE.USERS}`, - { params }, - ); - return response.data; - } - - // 사용자 단건 조회 - public async findByUserUnqId(userUnqId: string): Promise { - const response = await client.get( - `${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`, - ); - return response.data; - } - - // 로그인한 사용자 정보 조회 - public async findByUserUnqIdByLogin(): Promise { - const response = await client.get( - `${ENDPOINT.USER_SERVICE.USERS}/info/me`, - ); - return response.data; - } - - // 사용자 회원상태 통계 조회 - public async selectUserSttsStats(): Promise { - const response = await client.get( - `${ENDPOINT.USER_SERVICE.USERS}/stts/stats`, - ); - return response.data; - } - - // 사용자 저장 - public async saveUser(data: UserSaveData): Promise { - const response = await client.post( - `${ENDPOINT.USER_SERVICE.USERS}`, - data, - ); - return response.data; - } - - // 사용자 수정 - public async updateUser( - userUnqId: string, - data: UserUpdateData, - ): Promise { - const response = await client.put( - `${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`, - data, - ); - return response.data; - } - - // 사용자 삭제 - public async deleteUser(userUnqId: string): Promise { - const response = await client.delete( - `${ENDPOINT.USER_SERVICE.USERS}/delete/${userUnqId}`, - ); - return response.data; - } - - // 사용자 계정잠금 처리 - public async lockUser(userUnqId: string): Promise { - const response = await client.put( - `${ENDPOINT.USER_SERVICE.USERS}/lock/${userUnqId}`, - ); - return response.data; - } - - // 사용자 계정잠금 해제처리 - public async unlockUser(userUnqId: string): Promise { - const response = await client.put( - `${ENDPOINT.USER_SERVICE.USERS}/unlock/${userUnqId}`, - ); - return response.data; - } - - // 리프레시 토큰 정보 재발급 - public async refreshToken(): Promise { - await client.put(`${ENDPOINT.USER_SERVICE.USERS}/token/refresh`); - } - - // 사용자 비밀번호 변경 - public async updatePassword(data: UserPasswordUpdateData): Promise { - const response = await client.put( - `${ENDPOINT.USER_SERVICE.USERS}/password/update`, - data, - ); - return response.data; - } - - // 사용자 비밀번호 초기화(관리자용) - public async initializePassword( - userUnqId: string, - data: UserPasswordUpdateData, - ): Promise { - const response = await client.put( - `${ENDPOINT.USER_SERVICE.USERS}/password/initialize/${userUnqId}`, - data, - ); - return response.data; - } - - // 사용자 비밀번호 찾기 변경 - public async changePassword( - data: UserFindPasswordUpdateData, - ): Promise { - const response = await client.put( - `${ENDPOINT.USER_SERVICE.USERS}/password/change`, - data, - ); - return response.data; - } - - // 사용자 비밀번호 확인 - public async matchPassword(data: UserPasswordMatchData): Promise { - const response = await client.post( - `${ENDPOINT.USER_SERVICE.USERS}/password/match`, - data, - ); - return response.data; - } - - // 사용자 비밀번호 찾기 - public async findPasswordUser( - data: UserFindPasswordSaveData, - ): Promise { - const response = await client.post( - `${ENDPOINT.USER_SERVICE.USERS}/password/find`, - data, - ); - return response.data; - } - - // 사용자 비밀번호 찾기 유효성 확인 - public async validPassword(token: string): Promise { - const response = await client.get( - `${ENDPOINT.USER_SERVICE.USERS}/password/valid/${token}`, - ); - return response.data; - } - - // 사용자 회원정보 변경 - public async updateInfo( - userUnqId: string, - data: UserUpdateInfoData, - ): Promise { - const response = await client.put( - `${ENDPOINT.USER_SERVICE.USERS}/info/${userUnqId}`, - data, - ); - return response.data; - } - - // 사용자 승인처리 - public async applyUser(userUnqId: string): Promise { - const response = await client.put( - `${ENDPOINT.USER_SERVICE.USERS}/apply/${userUnqId}`, - ); - return response.data; - } - - // 사용자 회원탈퇴 - public async leaveUser(data: UserVerifyData): Promise { - const response = await client.post( - `${ENDPOINT.USER_SERVICE.USERS}/leave`, - data, - ); - return response.data; - } - - // 사용자 이메일 중복 체크 - public async existsEmail(data: UserEmailData): Promise { - const response = await client.post( - `${ENDPOINT.USER_SERVICE.USERS}/exists`, - data, - ); - return response.data; - } - - // 사용자 아이디 중복 체크 - public async existsUserId(data: UserIdData): Promise { - const response = await client.post( - `${ENDPOINT.USER_SERVICE.USERS}/existsUserId`, - data, - ); - return response.data; - } - - // 관리자 목록 조회 - public async findPageAdminUser( - params: UserSearchParams, - ): Promise { - const response = await client.get( - `${ENDPOINT.USER_SERVICE.USERS}/admin/list`, - { params }, - ); - return response.data; - } -} - -export const UserApi = new UserApiService(); diff --git a/apps/sn/src/entities/user/index.ts b/apps/sn/src/entities/user/index.ts deleted file mode 100644 index 8927256f9..000000000 --- a/apps/sn/src/entities/user/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { UserApi } from "./api/user-service"; -export { - USER_STATE_CODE, - userSaveSchema, - userPasswordUpdateSchema, - userUpdateSchema, - type UserStateCode, - type UserSearchParams, - type UserListResponse, - type UserSaveData, - type UserDetailResponse, - type UserPasswordUpdateData, - type UserUpdateData, - type AdminUserList, - type AdminUserListResponse, -} from "./model/user-interface"; -export { PasswordResetDialog } from "./ui/password-reset-dialog"; diff --git a/apps/sn/src/entities/user/model/user-interface.ts b/apps/sn/src/entities/user/model/user-interface.ts deleted file mode 100644 index 012fe9e92..000000000 --- a/apps/sn/src/entities/user/model/user-interface.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { z } from "zod"; -import type { PageableList, PageableParams } from "@/shared/model"; - -export const USER_STATE_CODE = { - 대기: "00", - 정상: "01", - 정지: "07", - 탈퇴: "08", - 삭제: "09", -} as const; - -export type UserStateCode = - (typeof USER_STATE_CODE)[keyof typeof USER_STATE_CODE]; - -// 사용자 검색 파라미터 -export interface UserSearchParams extends PageableParams { - keywordType?: string; - keyword?: string; - userStateCode?: string; - aprvYn?: string; - grpId?: string; - frDateYmd?: string; - toDateYmd?: string; - userName?: string; -} - -// 사용자 목록 응답 인터페이스 -export interface UserListResponse { - userId: string; - userName: string; - email: string; - roleId: string; - roleName: string; - userStateCode: UserStateCode; - userStateCodeName: string; - lastLoginDate: string; - loginFailCount: number; - userUnqId: string; - mphn: string; - telno: string; - aprvYn: string; - regDt: string; - laeSeCd: string; - laeSeNm: string; - ogdpSeCd: string; - ogdpSeNm: string; - instSeCd: string; - instSeNm: string; - aprvYmd: string; -} - -// 사용자 상세 응답 인터페이스 -export interface UserDetailResponse { - regDt: string; - mdfcnDt: string; - rgtr: string; - mdfr: string; - userUnqId: string; - userId: string; - userName: string; - email: string; - encryptedPassword: string; - refreshToken: string; - userStateCode: UserStateCode; - lastLoginDate: string; - loginFailCount: number; - pswdHnt: string; - pswdAns: string; - mphn: string; - telno: string; - zip: string; - addr: string; - daddr: string; - laeSeCd: string; - ogdpSeCd: string; - aprvYn: string; - autzrId: string; - expryYmd: string; - lastPswdChgDt: string; - googleId: string; - kakaoId: string; - naverId: string; - isSocialUser: boolean; - hasPassword: boolean; - twoFactorEnabled: boolean; - adminRoleYn: string; - instSeCd: string; - aprvYmd: string; - enoCd: string; - dtlInstCd: string; - laeSeNm: string; - ogdpSeNm: string; - instSeNm: string; - userStateNm: string; - pswdHntNm: string; - ecoNm: string; - dtlInstNm: string; -} - -// 사용자 상태 통계 응답 인터페이스 -export interface UserSttsStatsResponse { - sttsCd: string; - sttsNm: string; - cnt: number; -} - -// 관리자 목록 응답 인터페이스 -export interface AdminUserListResponse { - userUnqId: string; - userId: string; - userName: string; - groupId: string; - groupNm: string; - userStateCode: string; - userStateCodeName: string; - laeSeCd: string; - laeSeNm: string; - ogdpSeCd: string; - ogdpSeNm: string; - instSeCd: string; - instSeNm: string; - dtlInstCd: string; - dtlInstNm: string; - mdfr: string; - mdfrNm: string; - mdfcnDt: string; -} - -// 사용자 수정 요청 스키마 -export const userUpdateSchema = z.object({ - userId: z.string().optional(), - userName: z.string(), - email: z.string().email().optional(), - password: z - .string() - .regex( - /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, - "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", - ) - .optional(), - roleId: z.string().optional(), - userStateCode: z.string(), - pswdHnt: z.string().max(10), - pswdAns: z.string().max(100).optional(), - mphn: z.string().max(11).optional(), - telno: z.string().max(11).optional(), - zip: z.string().max(5).optional(), - addr: z.string().max(200).optional(), - daddr: z.string().max(200).optional(), - laeSeCd: z.string().max(10), - ogdpSeCd: z.string().max(10), - aprvYn: z.string(), - autzrId: z.string().uuid().optional(), - expryYmd: z.string().max(8), - instSeCd: z.string().optional(), - dtlInstCd: z.string().optional(), - aprvYmd: z.string().max(8).optional(), - enoCd: z.string().optional(), -}); - -// 사용자 비밀번호 변경 스키마 -export const userPasswordUpdateSchema = z - .object({ - password: z.string().optional(), - provider: z.string().optional(), - token: z.string().optional(), - newPassword: z - .string() - .regex( - /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, - "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", - ), - newPasswordConfirm: z.string(), - }) - .refine((data) => data.newPassword === data.newPasswordConfirm, { - message: "비밀번호가 일치하지 않습니다.", - path: ["newPasswordConfirm"], - }); - -// 사용자 비밀번호 찾기 변경 스키마 -export const userFindPasswordUpdateSchema = z.object({ - tokenValue: z.string(), - password: z - .string() - .regex( - /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, - "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", - ), -}); - -// 사용자 비밀번호 확인 스키마 -export const userPasswordMatchSchema = z.object({ - password: z - .string() - .regex( - /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, - "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", - ), -}); - -// 사용자 회원정보 변경 스키마 -export const userUpdateInfoSchema = z.object({ - password: z - .string() - .regex( - /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, - "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", - ) - .optional(), - provider: z.string().optional(), - token: z.string().optional(), - userName: z.string(), - email: z.string().email().optional(), -}); - -// 사용자 저장 스키마 -export const userSaveSchema = z.object({ - userId: z.string(), - userName: z.string(), - email: z.string().email().optional(), - password: z - .string() - .regex( - /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, - "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", - ), - pswdHnt: z.string().max(10), - pswdAns: z.string().max(100).optional(), - mphn: z.string().max(11).optional(), - telno: z.string().max(11).optional(), - zip: z.string().max(5).optional(), - addr: z.string().max(200).optional(), - daddr: z.string().max(200).optional(), - laeSeCd: z.string().max(10), - ogdpSeCd: z.string().max(10), - expryYmd: z.string().max(8), - instSeCd: z.string().optional(), - dtlInstCd: z.string().optional(), - enoCd: z.string().optional(), -}); - -// 사용자 비밀번호 찾기 저장 스키마 -export const userFindPasswordSaveSchema = z.object({ - userName: z.string(), - emailAddr: z.string().email(), - mainUrl: z.string(), - changePasswordUrl: z.string(), -}); - -// 사용자 인증 요청 스키마 -export const userVerifySchema = z.object({ - password: z - .string() - .regex(/((?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20})|()/) - .optional(), - provider: z.string().optional(), - token: z.string().optional(), -}); - -// 사용자 이메일 요청 스키마 -export const userEmailSchema = z.object({ - userId: z.string().optional(), - email: z.string().email(), -}); - -// 사용자 ID 요청 스키마 -export const userIdSchema = z.object({ - userId: z.string(), -}); - -// Zod 스키마 타입 정의 -export type UserUpdateData = z.infer; -export type UserPasswordUpdateData = z.infer; -export type UserFindPasswordUpdateData = z.infer< - typeof userFindPasswordUpdateSchema ->; -export type UserPasswordMatchData = z.infer; -export type UserUpdateInfoData = z.infer; -export type UserSaveData = z.infer; -export type UserFindPasswordSaveData = z.infer< - typeof userFindPasswordSaveSchema ->; -export type UserVerifyData = z.infer; -export type UserEmailData = z.infer; -export type UserIdData = z.infer; - -// 페이지네이션 타입 정의 -export type UserList = PageableList; -export type UserSttsStatsList = UserSttsStatsResponse[]; -export type AdminUserList = PageableList; - -// 사용자 서비스 인터페이스 -export interface UserService { - // 사용자 목록 조회 - findPageUser: (params: UserSearchParams) => Promise; - - // 사용자 단건 조회 - findByUserUnqId: (userUnqId: string) => Promise; - - // 로그인한 사용자 정보 조회 - findByUserUnqIdByLogin: () => Promise; - - // 사용자 회원상태 통계 조회 - selectUserSttsStats: () => Promise; - - // 사용자 저장 - saveUser: (data: UserSaveData) => Promise; - - // 사용자 수정 - updateUser: (userUnqId: string, data: UserUpdateData) => Promise; - - // 사용자 삭제 - deleteUser: (userUnqId: string) => Promise; - - // 사용자 계정잠금 처리 - lockUser: (userUnqId: string) => Promise; - - // 사용자 계정잠금 해제처리 - unlockUser: (userUnqId: string) => Promise; - - // 리프레시 토큰 정보 재발급 - refreshToken: () => Promise; - - // 사용자 비밀번호 변경 - updatePassword: (data: UserPasswordUpdateData) => Promise; - - // 사용자 비밀번호 초기화(관리자용) - initializePassword: ( - userUnqId: string, - data: UserPasswordUpdateData, - ) => Promise; - - // 사용자 비밀번호 찾기 변경 - changePassword: (data: UserFindPasswordUpdateData) => Promise; - - // 사용자 비밀번호 확인 - matchPassword: (data: UserPasswordMatchData) => Promise; - - // 사용자 비밀번호 찾기 - findPasswordUser: (data: UserFindPasswordSaveData) => Promise; - - // 사용자 비밀번호 찾기 유효성 확인 - validPassword: (token: string) => Promise; - - // 사용자 회원정보 변경 - updateInfo: (userUnqId: string, data: UserUpdateInfoData) => Promise; - - // 사용자 승인처리 - applyUser: (userUnqId: string) => Promise; - - // 사용자 회원탈퇴 - leaveUser: (data: UserVerifyData) => Promise; - - // 사용자 이메일 중복 체크 - existsEmail: (data: UserEmailData) => Promise; - - // 사용자 아이디 중복 체크 - existsUserId: (data: UserIdData) => Promise; - - // 관리자 목록 조회 - findPageAdminUser: (params: UserSearchParams) => Promise; -} diff --git a/apps/sn/src/entities/user/ui/password-reset-dialog.tsx b/apps/sn/src/entities/user/ui/password-reset-dialog.tsx deleted file mode 100644 index 548e8fa1a..000000000 --- a/apps/sn/src/entities/user/ui/password-reset-dialog.tsx +++ /dev/null @@ -1,124 +0,0 @@ -"use client"; - -import { useState, type ReactNode } from "react"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, - DialogClose, - Button, - Input, - DialogTrigger, -} from "@common/components"; -import { - UserApi, - userPasswordUpdateSchema, - type UserPasswordUpdateData, -} from "@/entities/user"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/shared/ui/form"; -import { useAlertStore, useMutationWrapper } from "@/shared/lib"; - -interface PasswordResetDialogProps { - userUnqId: string; - children: ReactNode; -} - -export function PasswordResetDialog({ - userUnqId, - children, -}: PasswordResetDialogProps) { - const alert = useAlertStore((state) => state.setMessage); - - const [open, setOpen] = useState(false); - - const form = useForm({ - resolver: zodResolver(userPasswordUpdateSchema), - defaultValues: { - newPassword: "", - newPasswordConfirm: "", - provider: "password", - }, - }); - - const { mutate: onPasswordReset } = useMutationWrapper({ - mutationFn: (data: UserPasswordUpdateData) => - UserApi.initializePassword(userUnqId, data), - onSuccess: () => { - alert("비밀번호가 초기화되었습니다.", () => { - setOpen(false); - form.reset(); - }); - }, - }); - - return ( - - {children} - - - 비밀번호 초기화 - -
- onPasswordReset(data))} - className="space-y-6" - > - ( - - 새 비밀번호 - - - - - - )} - /> - ( - - 새 비밀번호 확인 - - - - - - )} - /> - - - - - - - - -
-
- ); -} diff --git a/apps/sn/src/features/dredging/api/backend-mode.ts b/apps/sn/src/features/dredging/api/backend-mode.ts new file mode 100644 index 000000000..5ed540230 --- /dev/null +++ b/apps/sn/src/features/dredging/api/backend-mode.ts @@ -0,0 +1,3 @@ +/** 기존 FE 선행 모의 모드 계약을 유지하며 API와 첨부 adapter가 공유한다. */ +export const isDredgingBackendEnabled = + process.env.NEXT_PUBLIC_DREDGING_BACKEND_ENABLED !== "false"; diff --git a/apps/sn/src/features/dredging/api/dredging-api.ts b/apps/sn/src/features/dredging/api/dredging-api.ts new file mode 100644 index 000000000..ec7b26900 --- /dev/null +++ b/apps/sn/src/features/dredging/api/dredging-api.ts @@ -0,0 +1,258 @@ +/** 준설 조회·등록·수정·삭제와 최근접 관로 HTTP 계약을 담당한다. */ +import { + deleteData, + getBlobData, + getRawJson, + postRawJson, + putData, +} from "@/components/lib/api"; +import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; +import { isDredgingBackendEnabled } from "./backend-mode"; +import { toDredgingApiError } from "./dredging-error"; +import { + toDredgingRecordPage, + toDredgingPhotos, + toNearestPipeMatches, + type DredgingRecordListResponse, +} from "./response-adapters"; + +import type { + DredgingRecordUpdate, + DredgingPhoto, + GeoPoint, + PipeMatch, +} from "../model/types"; +import type { + AttachmentReference, + DredgingRegistrationRequest, +} from "../model/registration-request"; +export type { AttachmentReference } from "../model/registration-request"; + +type LocationRequest = GeoPoint & { + locationKey: string; +}; + +/** 실제 준설관리 API에서 기간 내 등록 기록을 조회한다. */ +export const fetchDredgingRecords = async (params: { + fromDate: string; + toDate: string; + ctpv?: string; + sgg?: 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), + }); + if (params.ctpv?.trim()) search.set("ctpv", params.ctpv.trim()); + if (params.sgg?.trim()) search.set("sgg", params.sgg.trim()); + try { + const body = await getRawJson(`v1/dredging/records?${search.toString()}`); + return toDredgingRecordPage(body); + } catch (error) { + throw toDredgingApiError( + error, + "준설현황을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.", + "준설현황 조회", + ); + } +}; + +/** 조회조건에 맞는 준설기록 전체 페이지를 순회해 목록·Excel 건수를 일치시킨다. */ +export const fetchAllDredgingRecords = async (params: { + fromDate: string; + toDate: string; + ctpv?: string; + sgg?: string; +}) => + fetchAllPages({ + fetchPage: (page, size) => fetchDredgingRecords({ ...params, page, size }), + getItems: (response) => response.items ?? [], + getTotal: (response) => Number(response.total ?? 0), + }); + +/** 등록된 준설사진의 공통 첨부파일 참조를 조회한다. */ +export const fetchDredgingPhotos = async ( + dredgingJobSn: string | number, + pipeFid: string, +): Promise => { + if (!isDredgingBackendEnabled) return []; + try { + const body = await getRawJson( + `v1/dredging/records/${encodeURIComponent(dredgingJobSn)}/photos?fid=${encodeURIComponent(pipeFid)}`, + ); + return toDredgingPhotos(body); + } catch (error) { + throw toDredgingApiError( + error, + "등록된 준설사진을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.", + "준설사진 목록 조회", + ); + } +}; + +/** 상세 기반 수정 팝업에서 선택 관로의 일자·위치·사진 구성을 함께 수정한다. */ +export const updateDredgingRecord = async ( + dredgingJobSn: string | number, + update: DredgingRecordUpdate, + attachments: Map, +) => { + if (!isDredgingBackendEnabled) return true; + try { + await putData(`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}`, { + dredgingDate: update.dredgingDate, + pipeFid: update.originalPipeFid, + registrationMode: update.registrationMode, + location: { + source: update.locationSource, + longitude: update.location.longitude, + latitude: update.location.latitude, + }, + expectedPipe: { + fid: update.pipe.fid, + mngInstCd: update.pipe.mngInstCd, + fcltMngNo: update.pipe.fcltMngNo, + }, + retainedPhotoSns: update.retainedPhotoSns, + newPhotos: update.newPhotos.map((photo) => { + const attachment = attachments.get(photo.key); + if (!attachment) { + throw new Error(`${photo.file.name}의 첨부 식별자가 없습니다.`); + } + return { + ...attachment, + originalFileName: photo.file.name, + clientCapturedAt: photo.metadata.capturedAt, + clientLongitude: photo.metadata.location?.longitude ?? null, + clientLatitude: photo.metadata.location?.latitude ?? null, + }; + }), + }); + return true; + } catch (error) { + throw toDredgingApiError( + error, + "준설정보를 수정하지 못했습니다. 잠시 후 다시 시도해주세요.", + "준설작업 수정", + ); + } +}; + +/** 목록에서 선택한 준설작업과 관로·사진 참조를 모두 물리 삭제한다. */ +export const deleteDredgingRecord = async (dredgingJobSn: string | number) => { + if (!isDredgingBackendEnabled) return true; + try { + const response = await deleteData( + `v1/dredging/records/${encodeURIComponent(dredgingJobSn)}`, + ); + if (response instanceof Response && !response.ok) { + throw new Error(`준설작업 삭제 응답 오류: ${response.status}`); + } + return true; + } catch (error) { + throw toDredgingApiError( + error, + "준설정보를 삭제하지 못했습니다. 잠시 후 다시 시도해주세요.", + "준설작업 삭제", + ); + } +}; + +/** 공통 첨부서비스에서 준설사진 원본을 Blob으로 내려받는다. */ +export const fetchDredgingPhotoBlob = async (atflId: string) => { + try { + return await getBlobData( + `/attach-service/api/v1/download/${encodeURIComponent(atflId)}`, + ); + } catch (error) { + throw toDredgingApiError( + error, + "사진 원본을 불러오지 못했습니다.", + "준설사진 원본 조회", + ); + } +}; + +const mockPipeForLocations = (locations: LocationRequest[]): PipeMatch => { + const first = locations[0]; + const longitude = first?.longitude ?? 127.5; + const latitude = first?.latitude ?? 36.5; + const seed = Math.abs( + Math.round(longitude * 100_000) + Math.round(latitude * 100_000), + ); + const distanceMeters = Number(((seed % 850) / 100 + 1.2).toFixed(2)); + + return { + fid: `MOCK-${String(seed % 1_000_000).padStart(6, "0")}`, + mngInstCd: "4173000", + fcltMngNo: `PPL-${String(seed % 1_000_000).padStart(6, "0")}`, + pipeDiameterMm: [450, 600, 700, 900][seed % 4] ?? 450, + pipeLengthM: Number((((seed % 8_000) + 2_000) / 100).toFixed(2)), + pipeMaterial: ["콘크리트", "PVC", "PE"][seed % 3] ?? "콘크리트", + installationYear: 1995 + (seed % 25), + swtrmtZoneFid: "MOCK-ZONE", + swtrmtZoneNm: "모의처리구역", + distanceMeters, + snappedLongitude: longitude + 0.00001, + snappedLatitude: latitude - 0.00001, + isMock: true, + }; +}; + +/** + * 위치 목록을 한 번에 보내고 사진별 최근접 관로를 받는다. + * FE 선행 모드에서는 같은 그룹의 상태 전이를 검증할 수 있는 명시적 모의 관로를 반환한다. + */ +export const findNearestPipeMatches = async ( + locations: LocationRequest[], +): Promise> => { + if (locations.length === 0) return new Map(); + + if (!isDredgingBackendEnabled) { + const mockPipe = mockPipeForLocations(locations); + return new Map( + locations.map((location) => [location.locationKey, mockPipe]), + ); + } + + let body: any; + try { + body = await postRawJson("v1/dredging/nearestPipes", { locations }); + } catch (error) { + throw toDredgingApiError( + error, + "최근접 관로를 조회하지 못했습니다. 잠시 후 다시 시도해주세요.", + "최근접 관로 조회", + ); + } + return toNearestPipeMatches(body); +}; + +/** UI 세션이 확정한 요청을 전송하며 transport 내부에서는 ID를 재생성하지 않는다. */ +export const saveDredgingRegistrations = async ( + request: DredgingRegistrationRequest, +) => { + if (!isDredgingBackendEnabled) { + return { + mock: true, + registeredCount: 1, + clientRequestId: request.clientRequestId, + }; + } + + try { + return await postRawJson("v1/dredging/registrations", request); + } catch (error) { + throw toDredgingApiError( + error, + "준설결과를 저장하지 못했습니다. 잠시 후 다시 시도해주세요.", + "준설결과 저장", + ); + } +}; diff --git a/apps/sn/src/features/dredging/api/dredging-error.ts b/apps/sn/src/features/dredging/api/dredging-error.ts new file mode 100644 index 000000000..73944d70c --- /dev/null +++ b/apps/sn/src/features/dredging/api/dredging-error.ts @@ -0,0 +1,19 @@ +/** + * 백엔드·프록시의 상태코드, HTML 오류 본문과 파싱 문구를 화면에 전달하지 않는다. + * 상세 응답은 브라우저 Network에서 확인하고 화면에는 업무별 재시도 안내만 노출한다. + */ +export const toDredgingApiError = ( + error: unknown, + fallbackMessage: string, + operation: string, +) => { + const status = + typeof error === "object" && error !== null && "status" in error + ? (error as { status?: unknown }).status + : undefined; + console.warn(`[준설관리] ${operation} 실패`, { + status: typeof status === "number" ? status : undefined, + errorType: error instanceof Error ? error.name : typeof error, + }); + return new Error(fallbackMessage); +}; diff --git a/apps/sn/src/features/dredging/api/photo-upload.ts b/apps/sn/src/features/dredging/api/photo-upload.ts new file mode 100644 index 000000000..b8ad4520e --- /dev/null +++ b/apps/sn/src/features/dredging/api/photo-upload.ts @@ -0,0 +1,82 @@ +import { handleUpload } from "@/components/lib/utils"; +import type { HttpResponse } from "tus-js-client"; + +import { uploadPhotosWithReferences } from "../lib/photo-upload-queue"; +import type { AttachmentReference } from "../model/registration-request"; +import type { DredgingPhotoDraft, DredgingSaveGroup } from "../model/types"; +import { isDredgingBackendEnabled } from "./backend-mode"; +import { toDredgingApiError } from "./dredging-error"; + +/** 공통 TUS helper의 사진 한 장 업로드와 X-File-Info 응답을 Promise로 연결한다. */ +const uploadOnePhoto = (file: File): Promise => + new Promise((resolve, reject) => { + const transfer = new DataTransfer(); + transfer.items.add(file); + + const timeoutId = window.setTimeout(() => { + reject(new Error(`${file.name} 첨부 업로드 응답 시간이 초과되었습니다.`)); + }, 90_000); + + /** 동기 예외·TUS 비동기 실패를 같은 경로에서 처리하고 대기 타이머를 해제한다. */ + const handleError = (error: unknown) => { + window.clearTimeout(timeoutId); + reject(error); + }; + + /** 실제 첨부서비스가 반환한 식별자만 세션에 전달한다. */ + const handleResponse = (response: HttpResponse) => { + const fileInfo = response.getHeader("X-File-Info"); + if (!fileInfo) return; + + try { + const parsed = JSON.parse(fileInfo) as Partial; + if (!parsed.atflCd || !parsed.atflId) { + throw new Error("첨부 식별자가 응답에 없습니다."); + } + window.clearTimeout(timeoutId); + resolve({ atflCd: parsed.atflCd, atflId: parsed.atflId }); + } catch (error) { + handleError(error); + } + }; + + try { + handleUpload(transfer.files, { handleResponse, handleError }); + } catch (error) { + handleError(error); + } + }); + +/** + * 공통 attach-service TUS 업로드를 순차 실행한다. + * 등록 API가 아직 없는 FE 선행 모드에서는 고아 첨부 생성을 막기 위해 전송하지 않는다. + */ +export const uploadDredgingPhotos = async ( + groups: DredgingSaveGroup[], + existingReferences: Map = new Map(), +) => + uploadDredgingPhotoDrafts( + groups.flatMap((group) => group.photos), + existingReferences, + ); + +/** 등록·수정 화면의 세션 Map을 직접 갱신해 부분 업로드 성공도 재시도에서 보존한다. */ +export const uploadDredgingPhotoDrafts = async ( + photos: DredgingPhotoDraft[], + existingReferences: Map = new Map(), +) => { + if (!isDredgingBackendEnabled) return existingReferences; + try { + return await uploadPhotosWithReferences( + photos, + existingReferences, + uploadOnePhoto, + ); + } catch (error) { + throw toDredgingApiError( + error, + "준설사진을 업로드하지 못했습니다. 잠시 후 다시 시도해주세요.", + "준설사진 업로드", + ); + } +}; diff --git a/apps/sn/src/features/dredging/api/response-adapters.ts b/apps/sn/src/features/dredging/api/response-adapters.ts new file mode 100644 index 000000000..c3b23b48f --- /dev/null +++ b/apps/sn/src/features/dredging/api/response-adapters.ts @@ -0,0 +1,83 @@ +import type { DredgingPhoto, DredgingRecord, PipeMatch } from "../model/types"; + +type NearestPipeResponseItem = { + locationKey: string; + failureCode?: "PIPE_NOT_FOUND" | string | null; + failureMessage?: string | null; + selected?: { + fid: string; + mngInstCd: string; + fcltMngNo: string; + pipeDiameterMm?: number; + pipeLengthM?: number; + pipeMaterial?: string; + installationYear?: number | null; + swtrmtZoneFid?: string | null; + swtrmtZoneNm?: string | null; + distanceMeters: number; + snappedLongitude: number; + snappedLatitude: number; + } | null; +}; + +const nearestPipeFailureMessage = (item: NearestPipeResponseItem) => { + switch (item.failureCode) { + case "PIPE_NOT_FOUND": + return "50m 이내에 관로가 없습니다. 지도에서 준설위치를 다시 확인해주세요."; + default: + return ( + item.failureMessage?.trim() ?? + "최근접 관로를 자동으로 연결하지 못했습니다." + ); + } +}; + +export type DredgingRecordListResponse = { + items: DredgingRecord[]; + total: number; + page: number; + size: number; +}; + +/** 목록의 기존 raw/wrapper와 페이지 필드를 그대로 유지하는 응답 경계다. */ +export const toDredgingRecordPage = (body: any): DredgingRecordListResponse => + (body?.data ?? body) as DredgingRecordListResponse; + +/** 사진 목록의 기존 null-to-empty fallback만 적용하며 첨부·촬영 원본 필드는 보존한다. */ +export const toDredgingPhotos = (body: any): DredgingPhoto[] => + (body?.data ?? body ?? []) as DredgingPhoto[]; + +/** 최근접 관로 응답의 wire 필드와 기본값을 화면 PipeMatch 모델로 명시적으로 매핑한다. */ +export const toNearestPipeMatches = (body: any): Map => { + const items = (body?.data ?? body) as NearestPipeResponseItem[]; + const matches = new Map(); + const failureMessages: string[] = []; + + for (const item of items ?? []) { + if (!item.selected) { + failureMessages.push(nearestPipeFailureMessage(item)); + continue; + } + matches.set(item.locationKey, { + fid: item.selected.fid, + mngInstCd: item.selected.mngInstCd, + fcltMngNo: item.selected.fcltMngNo, + pipeDiameterMm: item.selected.pipeDiameterMm ?? 0, + pipeLengthM: item.selected.pipeLengthM ?? 0, + pipeMaterial: item.selected.pipeMaterial ?? "", + installationYear: item.selected.installationYear ?? null, + swtrmtZoneFid: item.selected.swtrmtZoneFid ?? null, + swtrmtZoneNm: item.selected.swtrmtZoneNm ?? "", + distanceMeters: item.selected.distanceMeters, + snappedLongitude: item.selected.snappedLongitude, + snappedLatitude: item.selected.snappedLatitude, + isMock: false, + }); + } + + if (failureMessages.length > 0) { + throw new Error(Array.from(new Set(failureMessages)).join(" ")); + } + + return matches; +}; diff --git a/apps/sn/src/features/dredging/index.ts b/apps/sn/src/features/dredging/index.ts new file mode 100644 index 000000000..b0cc3d1a2 --- /dev/null +++ b/apps/sn/src/features/dredging/index.ts @@ -0,0 +1,10 @@ +/** 준설 기능의 공개 API. 기존 라우트 UI는 호환 파일을 통해 같은 구현을 사용한다. */ +export * from "./model/types"; +export * from "./model/registration-request"; +export * from "./api/dredging-api"; +export * from "./api/photo-upload"; +export { isDredgingBackendEnabled } from "./api/backend-mode"; +export { formatDredgingCapturedDate } from "./lib/date"; +export { readPhotoMetadata } from "./lib/exif"; +export { createDredgingClientUuid } from "./lib/uuid"; +export { useDredgingCommonCodes } from "./lib/use-dredging-common-codes"; diff --git a/apps/sn/src/features/dredging/lib/date.ts b/apps/sn/src/features/dredging/lib/date.ts new file mode 100644 index 000000000..011944168 --- /dev/null +++ b/apps/sn/src/features/dredging/lib/date.ts @@ -0,0 +1,21 @@ +/** 촬영일시 원본은 유지하고 등록·수정 화면 표출값만 YYYY-MM-DD로 정규화한다. */ +export const formatDredgingCapturedDate = ( + value: string | null | undefined, +) => { + const normalized = String(value ?? "").trim(); + const matched = normalized.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (!matched) return null; + + const year = Number(matched[1]); + const month = Number(matched[2]); + const day = Number(matched[3]); + const date = new Date(year, month - 1, day); + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + return null; + } + return matched[0]; +}; diff --git a/apps/sn/src/features/dredging/lib/exif.ts b/apps/sn/src/features/dredging/lib/exif.ts new file mode 100644 index 000000000..aed6df841 --- /dev/null +++ b/apps/sn/src/features/dredging/lib/exif.ts @@ -0,0 +1,334 @@ +import type { ExifStatus, GeoPoint, PhotoMetadata } from "../model/types"; + +type ByteOrder = "LE" | "BE"; + +type IfdEntry = { + type: number; + count: number; + valueOffset: number; + entryOffset: number; +}; + +const JPEG_START = 0xffd8; +const APP1_MARKER = 0xe1; +const EXIF_IFD_POINTER = 0x8769; +const GPS_IFD_POINTER = 0x8825; +const DATE_TIME = 0x0132; +const DATE_TIME_ORIGINAL = 0x9003; +const DATE_TIME_DIGITIZED = 0x9004; + +const typeByteLength = (type: number) => { + if (type === 1 || type === 2 || type === 7) return 1; + if (type === 3) return 2; + if (type === 4 || type === 9) return 4; + if (type === 5 || type === 10) return 8; + return 0; +}; + +/** + * JPEG EXIF TIFF 블록을 읽는 범위 제한 reader이다. + * 손상된 offset이 파일 범위를 벗어나면 예외를 발생시켜 수동 입력 흐름으로 전환한다. + */ +class TiffReader { + private readonly view: DataView; + private readonly tiffStart: number; + private readonly order: ByteOrder; + + constructor(buffer: ArrayBuffer, tiffStart: number) { + this.view = new DataView(buffer); + this.tiffStart = tiffStart; + this.ensureRange(tiffStart, 8); + + const byteOrder = this.view.getUint16(tiffStart, false); + if (byteOrder === 0x4949) { + this.order = "LE"; + } else if (byteOrder === 0x4d4d) { + this.order = "BE"; + } else { + throw new Error("지원하지 않는 TIFF 바이트 순서입니다."); + } + + if (this.uint16(2) !== 0x002a) { + throw new Error("유효한 TIFF 헤더가 아닙니다."); + } + } + + private get littleEndian() { + return this.order === "LE"; + } + + private absolute(relativeOffset: number) { + return this.tiffStart + relativeOffset; + } + + private ensureRange(absoluteOffset: number, byteLength: number) { + if ( + absoluteOffset < 0 || + byteLength < 0 || + absoluteOffset + byteLength > this.view.byteLength + ) { + throw new Error("EXIF offset이 파일 범위를 벗어났습니다."); + } + } + + uint16(relativeOffset: number) { + const absoluteOffset = this.absolute(relativeOffset); + this.ensureRange(absoluteOffset, 2); + return this.view.getUint16(absoluteOffset, this.littleEndian); + } + + uint32(relativeOffset: number) { + const absoluteOffset = this.absolute(relativeOffset); + this.ensureRange(absoluteOffset, 4); + return this.view.getUint32(absoluteOffset, this.littleEndian); + } + + private readIfd(relativeOffset: number) { + const entryCount = this.uint16(relativeOffset); + const entries = new Map(); + + for (let index = 0; index < entryCount; index += 1) { + const entryOffset = relativeOffset + 2 + index * 12; + const tag = this.uint16(entryOffset); + const type = this.uint16(entryOffset + 2); + const count = this.uint32(entryOffset + 4); + const valueOffset = this.uint32(entryOffset + 8); + entries.set(tag, { type, count, valueOffset, entryOffset }); + } + + return entries; + } + + private dataRelativeOffset(entry: IfdEntry) { + const byteLength = typeByteLength(entry.type) * entry.count; + if (byteLength <= 4) return entry.entryOffset + 8; + return entry.valueOffset; + } + + private ascii(entry: IfdEntry) { + const relativeOffset = this.dataRelativeOffset(entry); + const absoluteOffset = this.absolute(relativeOffset); + const byteLength = typeByteLength(entry.type) * entry.count; + this.ensureRange(absoluteOffset, byteLength); + + const bytes = new Uint8Array(this.view.buffer, absoluteOffset, byteLength); + return new TextDecoder("ascii").decode(bytes).replace(/\0+$/g, "").trim(); + } + + private rational(relativeOffset: number) { + const numerator = this.uint32(relativeOffset); + const denominator = this.uint32(relativeOffset + 4); + if (denominator === 0) return null; + return numerator / denominator; + } + + private rationals(entry: IfdEntry) { + const relativeOffset = this.dataRelativeOffset(entry); + const values: number[] = []; + for (let index = 0; index < entry.count; index += 1) { + const value = this.rational(relativeOffset + index * 8); + if (value === null) return null; + values.push(value); + } + return values; + } + + private pointer(entry: IfdEntry | undefined) { + if (!entry || entry.type !== 4 || entry.count !== 1) return null; + return entry.valueOffset; + } + + private parseDate(value: string | null) { + if (!value) return null; + const match = value.match( + /^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/, + ); + if (!match) return null; + + const [, year, month, day, hour, minute, second] = match; + if (!year || !month || !day || !hour || !minute || !second) return null; + + const monthNumber = Number(month); + const dayNumber = Number(day); + const hourNumber = Number(hour); + const minuteNumber = Number(minute); + const secondNumber = Number(second); + if ( + monthNumber < 1 || + monthNumber > 12 || + dayNumber < 1 || + dayNumber > 31 || + hourNumber > 23 || + minuteNumber > 59 || + secondNumber > 59 + ) { + return null; + } + + return { + capturedAt: `${year}-${month}-${day}T${hour}:${minute}:${second}`, + dredgingDate: `${year}-${month}-${day}`, + }; + } + + private parseGps(entries: Map): GeoPoint | null { + const latitudeRefEntry = entries.get(0x0001); + const latitudeEntry = entries.get(0x0002); + const longitudeRefEntry = entries.get(0x0003); + const longitudeEntry = entries.get(0x0004); + if ( + !latitudeRefEntry || + !latitudeEntry || + !longitudeRefEntry || + !longitudeEntry + ) { + return null; + } + + const latitudeRef = this.ascii(latitudeRefEntry).toUpperCase(); + const longitudeRef = this.ascii(longitudeRefEntry).toUpperCase(); + const latitudeDms = this.rationals(latitudeEntry); + const longitudeDms = this.rationals(longitudeEntry); + if ( + !latitudeDms || + latitudeDms.length < 3 || + !longitudeDms || + longitudeDms.length < 3 + ) { + return null; + } + + const toDecimal = (values: number[]) => + (values[0] ?? 0) + (values[1] ?? 0) / 60 + (values[2] ?? 0) / 3600; + + let latitude = toDecimal(latitudeDms); + let longitude = toDecimal(longitudeDms); + if (latitudeRef === "S") latitude *= -1; + if (longitudeRef === "W") longitude *= -1; + + if ( + !Number.isFinite(latitude) || + !Number.isFinite(longitude) || + latitude < -90 || + latitude > 90 || + longitude < -180 || + longitude > 180 + ) { + return null; + } + + return { latitude, longitude }; + } + + parse() { + const ifd0Offset = this.uint32(4); + const ifd0 = this.readIfd(ifd0Offset); + + const exifIfdOffset = this.pointer(ifd0.get(EXIF_IFD_POINTER)); + const exifIfd = exifIfdOffset === null ? null : this.readIfd(exifIfdOffset); + const gpsIfdOffset = this.pointer(ifd0.get(GPS_IFD_POINTER)); + const gpsIfd = gpsIfdOffset === null ? null : this.readIfd(gpsIfdOffset); + + const dateValue = + (exifIfd?.get(DATE_TIME_ORIGINAL) + ? this.ascii(exifIfd.get(DATE_TIME_ORIGINAL)!) + : null) ?? + (exifIfd?.get(DATE_TIME_DIGITIZED) + ? this.ascii(exifIfd.get(DATE_TIME_DIGITIZED)!) + : null) ?? + (ifd0.get(DATE_TIME) ? this.ascii(ifd0.get(DATE_TIME)!) : null); + + return { + date: this.parseDate(dateValue), + location: gpsIfd ? this.parseGps(gpsIfd) : null, + }; + } +} + +const toMetadata = ( + capturedAt: string | null, + dredgingDate: string | null, + location: GeoPoint | null, + unsupported = false, +): PhotoMetadata => { + let status: ExifStatus; + let message: string; + + if (unsupported) { + status = "UNSUPPORTED"; + message = "JPEG EXIF가 없어 준설일자와 위치를 직접 입력해야 합니다."; + } else if (dredgingDate && location) { + status = "COMPLETE"; + message = "촬영일자와 GPS를 확인했습니다."; + } else if (!dredgingDate && !location) { + status = "MISSING_BOTH"; + message = "촬영일자와 GPS가 없어 수동 입력이 필요합니다."; + } else if (!dredgingDate) { + status = "MISSING_DATE"; + message = "촬영일자가 없어 준설일자를 직접 입력해야 합니다."; + } else { + status = "MISSING_GPS"; + message = "GPS가 없어 지도에서 준설위치를 등록해야 합니다."; + } + + return { capturedAt, dredgingDate, location, status, message }; +}; + +/** + * 브라우저에서 즉시 보여 줄 JPEG EXIF 미리보기를 만든다. + * 서버는 저장 시 원본 첨부파일을 기준으로 동일 정보를 다시 검증해야 한다. + */ +export const readPhotoMetadata = async (file: File): Promise => { + if (file.type !== "image/jpeg" && !/\.jpe?g$/i.test(file.name)) { + return toMetadata(null, null, null, true); + } + + try { + const buffer = await file.arrayBuffer(); + const view = new DataView(buffer); + if (view.byteLength < 4 || view.getUint16(0, false) !== JPEG_START) { + return toMetadata(null, null, null, true); + } + + let offset = 2; + while (offset + 4 <= view.byteLength) { + if (view.getUint8(offset) !== 0xff) break; + + const marker = view.getUint8(offset + 1); + if (marker === 0xda || marker === 0xd9) break; + + const segmentLength = view.getUint16(offset + 2, false); + if (segmentLength < 2 || offset + 2 + segmentLength > view.byteLength) { + break; + } + + if (marker === APP1_MARKER) { + const exifStart = offset + 4; + const exifSignature = + exifStart + 6 <= view.byteLength + ? String.fromCharCode( + view.getUint8(exifStart), + view.getUint8(exifStart + 1), + view.getUint8(exifStart + 2), + view.getUint8(exifStart + 3), + ) + : ""; + + if (exifSignature === "Exif") { + const parsed = new TiffReader(buffer, exifStart + 6).parse(); + return toMetadata( + parsed.date?.capturedAt ?? null, + parsed.date?.dredgingDate ?? null, + parsed.location, + ); + } + } + + offset += 2 + segmentLength; + } + } catch (error) { + console.warn("준설 사진 EXIF 분석 실패", error); + } + + return toMetadata(null, null, null, false); +}; diff --git a/apps/sn/src/features/dredging/lib/photo-upload-queue.ts b/apps/sn/src/features/dredging/lib/photo-upload-queue.ts new file mode 100644 index 000000000..600e34df8 --- /dev/null +++ b/apps/sn/src/features/dredging/lib/photo-upload-queue.ts @@ -0,0 +1,20 @@ +import type { AttachmentReference } from "../model/registration-request"; +import type { DredgingPhotoDraft } from "../model/types"; + +/** + * 저장 세션이 소유한 Map에 사진 한 장의 업로드 성공 직후 참조를 기록한다. + * 다음 사진이 실패해 전체 Promise가 reject해도 성공 참조는 세션에 남는다. + * 실제 TUS·브라우저 의존성은 uploadOne으로 받아 순서·재시도 정책과 분리한다. + */ +export const uploadPhotosWithReferences = async ( + photos: DredgingPhotoDraft[], + references: Map, + uploadOne: (file: File) => Promise, +): Promise> => { + for (const photo of photos) { + // 전체 업로드 후 DB 실패뿐 아니라 일부 업로드 실패의 재시도에서도 재사용한다. + if (references.has(photo.key)) continue; + references.set(photo.key, await uploadOne(photo.file)); + } + return references; +}; diff --git a/apps/sn/src/features/dredging/lib/use-dredging-common-codes.ts b/apps/sn/src/features/dredging/lib/use-dredging-common-codes.ts new file mode 100644 index 000000000..3f4083b8a --- /dev/null +++ b/apps/sn/src/features/dredging/lib/use-dredging-common-codes.ts @@ -0,0 +1,135 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { getCommonCodes } from "@/components/lib/common/apiHelpers"; + +import type { + DredgingLocationSource, + DredgingRegistrationMode, + ExifStatus, +} from "../model/types"; + +type CodeLabelMap = Record; + +const COMMON_CODE_GROUPS = { + registrationMode: "DRDGNREGMODE", + locationSource: "DRDGNLOCSRC", + photoStatus: "DRDGNPHOTOSTTS", +} as const; + +const DEFAULT_REGISTRATION_MODE_LABELS: Record< + DredgingRegistrationMode, + string +> = { + PHOTO: "사진 기반 등록", + MANUAL: "사진 없이 직접 등록", +}; + +const DEFAULT_LOCATION_SOURCE_LABELS: Record = { + EXIF: "사진 GPS", + MANUAL: "지도 지정", +}; + +const DEFAULT_PHOTO_STATUS_LABELS: Record = { + COMPLETE: "정상", + MISSING_DATE: "촬영일 없음", + MISSING_GPS: "GPS 없음", + MISSING_BOTH: "촬영일·GPS 없음", + UNSUPPORTED: "수동 입력 필요", +}; + +const cachedLabelMaps = new Map(); +const pendingLabelMaps = new Map>(); + +/** 같은 브라우저 세션에서는 준설 코드그룹별 CMS 요청을 한 번만 수행한다. */ +const loadCodeLabelMap = async ( + upCd: string, + fallback: CodeLabelMap, +): Promise => { + const cached = cachedLabelMaps.get(upCd); + if (cached) return cached; + + const pending = pendingLabelMaps.get(upCd); + if (pending) return pending; + + const request = getCommonCodes(upCd) + .then((codes) => { + const labels = { ...fallback }; + codes.forEach((code) => { + if (code.lwpoCd && code.lwpoCdNm) { + labels[code.lwpoCd] = code.lwpoCdNm; + } + }); + cachedLabelMaps.set(upCd, labels); + return labels; + }) + .finally(() => { + pendingLabelMaps.delete(upCd); + }); + + pendingLabelMaps.set(upCd, request); + return request; +}; + +/** 준설 저장 코드값은 유지하고 버튼·상세·사진상태 표시명만 CMS 기준으로 제공한다. */ +export const useDredgingCommonCodes = () => { + const [registrationModeLabels, setRegistrationModeLabels] = + useState( + cachedLabelMaps.get(COMMON_CODE_GROUPS.registrationMode) ?? + DEFAULT_REGISTRATION_MODE_LABELS, + ); + const [locationSourceLabels, setLocationSourceLabels] = + useState( + cachedLabelMaps.get(COMMON_CODE_GROUPS.locationSource) ?? + DEFAULT_LOCATION_SOURCE_LABELS, + ); + const [photoStatusLabels, setPhotoStatusLabels] = useState( + cachedLabelMaps.get(COMMON_CODE_GROUPS.photoStatus) ?? + DEFAULT_PHOTO_STATUS_LABELS, + ); + + useEffect(() => { + let cancelled = false; + + const applyLabels = ( + upCd: string, + fallback: CodeLabelMap, + setter: (labels: CodeLabelMap) => void, + ) => { + loadCodeLabelMap(upCd, fallback) + .then((labels) => { + if (!cancelled) setter(labels); + }) + .catch((error) => { + console.error(`${upCd} 공통코드 조회에 실패했습니다.`, error); + }); + }; + + applyLabels( + COMMON_CODE_GROUPS.registrationMode, + DEFAULT_REGISTRATION_MODE_LABELS, + setRegistrationModeLabels, + ); + applyLabels( + COMMON_CODE_GROUPS.locationSource, + DEFAULT_LOCATION_SOURCE_LABELS, + setLocationSourceLabels, + ); + applyLabels( + COMMON_CODE_GROUPS.photoStatus, + DEFAULT_PHOTO_STATUS_LABELS, + setPhotoStatusLabels, + ); + + return () => { + cancelled = true; + }; + }, []); + + return { + registrationModeLabels, + locationSourceLabels, + photoStatusLabels, + }; +}; diff --git a/apps/sn/src/features/dredging/lib/uuid.ts b/apps/sn/src/features/dredging/lib/uuid.ts new file mode 100644 index 000000000..eb47a4613 --- /dev/null +++ b/apps/sn/src/features/dredging/lib/uuid.ts @@ -0,0 +1,31 @@ +/** + * 일반 HTTP에서는 `crypto.randomUUID`가 제공되지 않을 수 있어 Web Crypto 난수로 UUID v4를 생성한다. + * 백엔드 `clientRequestId`가 UUID 타입이므로 fallback도 같은 36자 형식을 유지한다. + */ +export const createDredgingClientUuid = () => { + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { + return crypto.randomUUID(); + } + + const bytes = new Uint8Array(16); + if ( + typeof crypto !== "undefined" && + typeof crypto.getRandomValues === "function" + ) { + crypto.getRandomValues(bytes); + } else { + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Math.floor(Math.random() * 256); + } + } + + bytes[6] = (bytes[6]! & 0x0f) | 0x40; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = Array.from(bytes, (value) => + value.toString(16).padStart(2, "0"), + ).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +}; diff --git a/apps/sn/src/features/dredging/model/registration-request.ts b/apps/sn/src/features/dredging/model/registration-request.ts new file mode 100644 index 000000000..b24813293 --- /dev/null +++ b/apps/sn/src/features/dredging/model/registration-request.ts @@ -0,0 +1,84 @@ +import type { DredgingSaveGroup } from "./types"; +import { createDredgingClientUuid } from "../lib/uuid"; + +/** 공통 첨부 업로드가 완료된 사진의 서버 식별자다. */ +export type AttachmentReference = { atflCd: string; atflId: string }; + +/** 화면 draft에서 기존 등록 DTO에 해당하는 필드만 명시적으로 추출한다. */ +const buildRegistrationBody = ( + groups: DredgingSaveGroup[], + attachments: Map, + requireAttachments: boolean, +) => ({ + dredgingDate: groups[0]?.dredgingDate, + groups: groups.map((group, groupIndex) => ({ + groupSn: groupIndex + 1, + registrationMode: group.registrationMode, + location: { + source: group.locationSource, + longitude: group.location.longitude, + latitude: group.location.latitude, + }, + expectedPipe: { + fid: group.pipe.fid, + mngInstCd: group.pipe.mngInstCd, + fcltMngNo: group.pipe.fcltMngNo, + }, + photos: group.photos.map((photo) => { + const attachment = attachments.get(photo.key); + if (!attachment && requireAttachments) { + throw new Error(`${photo.file.name}의 첨부 식별자가 없습니다.`); + } + return { + atflCd: attachment?.atflCd, + atflId: attachment?.atflId, + originalFileName: photo.file.name, + clientCapturedAt: photo.metadata.capturedAt, + clientLongitude: photo.metadata.location?.longitude ?? null, + clientLatitude: photo.metadata.location?.latitude ?? null, + }; + }), + })), +}); + +/** 기존 POST /v1/dredging/registrations의 요청 body를 그대로 표현한다. */ +export type DredgingRegistrationRequest = ReturnType< + typeof buildRegistrationBody +> & { + clientRequestId: string; +}; + +/** + * 모달을 연 한 번의 등록 세션이 전송 payload별 중복방지 ID를 소유한다. + * 응답 유실 후 같은 입력 재시도와 수정 후 원래 입력 복원은 같은 ID를 쓴다. + * 날짜·관로·위치·사진 등 실제 전송값이 바뀌면 새 ID를 발급한다. + * 화면 그룹 ID, 관경 표시값, 선택 탭 등 비전송 상태는 등록 의도를 바꾸지 않는다. + */ +export const createDredgingRegistrationSession = ( + createId: () => string = createDredgingClientUuid, +) => { + // 세션 밖으로 공유하지 않으며 모달을 다시 열 때 새 Map으로 교체한다. + const requestIds = new Map(); + return { + /** 첨부 식별자까지 확정된 동일 payload에 항상 같은 요청 ID를 부여한다. */ + prepare: ( + groups: DredgingSaveGroup[], + attachments: Map, + // FE 선행 모의 모드만 첨부 업로드 없이 payload 구성 검증을 허용한다. + requireAttachments = true, + ): DredgingRegistrationRequest => { + const body = buildRegistrationBody( + groups, + attachments, + requireAttachments, + ); + const signature = JSON.stringify(body); + let clientRequestId = requestIds.get(signature); + if (!clientRequestId) { + clientRequestId = createId(); + requestIds.set(signature, clientRequestId); + } + return { clientRequestId, ...body }; + }, + }; +}; diff --git a/apps/sn/src/features/dredging/model/types.ts b/apps/sn/src/features/dredging/model/types.ts new file mode 100644 index 000000000..8fbe9d815 --- /dev/null +++ b/apps/sn/src/features/dredging/model/types.ts @@ -0,0 +1,122 @@ +export type DredgingViewType = "MUNICIPALITY" | "DIAMETER" | "ELAPSED_YEAR"; + +export type DredgingRegistrationMode = "PHOTO" | "MANUAL"; + +export type DredgingLocationSource = "EXIF" | "MANUAL"; + +export type ExifStatus = + | "COMPLETE" + | "MISSING_DATE" + | "MISSING_GPS" + | "MISSING_BOTH" + | "UNSUPPORTED"; + +export type GeoPoint = { + longitude: number; + latitude: number; +}; + +export type PhotoMetadata = { + capturedAt: string | null; + dredgingDate: string | null; + location: GeoPoint | null; + status: ExifStatus; + message: string; +}; + +export type DredgingPhotoDraft = { + key: string; + file: File; + metadata: PhotoMetadata; +}; + +export type PipeMatch = { + fid: string; + mngInstCd: string; + fcltMngNo: string; + pipeDiameterMm: number; + pipeLengthM: number; + pipeMaterial: string; + installationYear: number | null; + swtrmtZoneFid: string | null; + swtrmtZoneNm: string; + distanceMeters: number; + snappedLongitude: number; + snappedLatitude: number; + isMock: boolean; +}; + +export type DredgingDraftGroup = { + id: string; + registrationMode: DredgingRegistrationMode; + photos: DredgingPhotoDraft[]; + manualLocation: GeoPoint | null; + matchedPipe: PipeMatch | null; + matchError: string | null; + isMatching: boolean; +}; + +export type DredgingRecord = { + id: string; + dredgingJobSn: number | string; + pipeFid: string; + mngInstCd: string; + ctpv: string; + sgg: string; + treatmentZone: string; + fcltMngNo: string; + pipeDiameterMm: number; + pipeLengthM: number; + pipeMaterial: string; + installationYear: number | null; + dredgingDate: string; + photoCount: number; + registeredDate: string; + longitude: number; + latitude: number; + snappedLongitude: number; + snappedLatitude: number; + registrationMode: DredgingRegistrationMode; + registrationSource: DredgingLocationSource; + matchDistanceMeters: number; + isMock: boolean; +}; + +export type DredgingPhoto = { + photoSn: number; + pipeFid: string; + atflCd: string; + atflId: string; + originalFileName: string; + capturedAt: string | null; +}; + +export type DredgingSaveGroup = { + localId: string; + registrationMode: DredgingRegistrationMode; + dredgingDate: string; + location: GeoPoint; + locationSource: DredgingLocationSource; + pipe: PipeMatch; + photos: DredgingPhotoDraft[]; +}; + +/** 상세 기반 수정 팝업에서 선택한 한 관로의 위치·사진을 함께 갱신한다. */ +export type DredgingRecordUpdate = { + dredgingDate: string; + originalPipeFid: string; + registrationMode: DredgingRegistrationMode; + location: GeoPoint; + locationSource: DredgingLocationSource; + pipe: PipeMatch; + retainedPhotoSns: number[]; + newPhotos: DredgingPhotoDraft[]; +}; + +export const EMPTY_PHOTO_METADATA: PhotoMetadata = { + capturedAt: null, + dredgingDate: null, + location: null, + status: "UNSUPPORTED", + message: "메타정보를 확인하고 있습니다.", +}; diff --git a/apps/sn/src/features/dredging/ui/DredgingRegistrationBody.tsx b/apps/sn/src/features/dredging/ui/DredgingRegistrationBody.tsx new file mode 100644 index 000000000..870be5cd0 --- /dev/null +++ b/apps/sn/src/features/dredging/ui/DredgingRegistrationBody.tsx @@ -0,0 +1,513 @@ +"use client"; +import type { ComponentType } from "react"; +import { FileUpload } from "@common/business_components/ui/file-upload"; +import { + Button, + InputDatePicker, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; +import type { DredgingDraftGroup, GeoPoint, PipeMatch } from "../model/types"; +import { formatDredgingCapturedDate } from "../lib/date"; + +type LocationMapViewProps = { + value: GeoPoint | null; + onChange?: (point: GeoPoint) => void; + interactive?: boolean; + statusText?: string; + matchedPipe?: PipeMatch | null; + height?: string; +}; +type DredgingRegistrationBodyProps = { + presentation: "modal" | "popup"; + groups: DredgingDraftGroup[]; + activeGroup: DredgingDraftGroup | null; + representativeDredgingDate: string; + setRepresentativeDredgingDate: (date: string) => void; + parseLocalDate: (value: string) => Date; + formatLocalDate: (date: Date) => string; + getResolvedLocation: (group: DredgingDraftGroup) => GeoPoint | null; + addGroup: () => void; + removeGroup: (groupId: string) => void; + selectGroup: (groupId: string) => void; + changeRegistrationMode: ( + groupId: string, + mode: "PHOTO" | "MANUAL", + ) => void | Promise; + handleFilesChange: ( + groupId: string, + files: File[] | null, + ) => void | Promise; + removePhoto: (groupId: string, photoKey: string) => void; + registrationModeLabels: Record; + photoStatusLabels: Record; + locationSourceLabels: Record; + isEditingActiveLocation: boolean; + locationDraft: GeoPoint | null; + applyLocationDraft: () => void | Promise; + cancelLocationEditing: () => void; + startLocationEditing: (group: DredgingDraftGroup) => void; + activeResolvedLocation: GeoPoint | null; + hasUnappliedLocationDraft: boolean; + statusMessage: string; + changeLocationDraft: (point: GeoPoint) => void; + LocationMap: ComponentType; +}; +/** 등록 폼·사진목록·지도 배치를 담당한다. 상태·업로드·저장 수명은 상위 등록 화면이 소유한다. */ +export const DredgingRegistrationBody = ({ + presentation, + groups, + activeGroup, + representativeDredgingDate, + setRepresentativeDredgingDate, + parseLocalDate, + formatLocalDate, + getResolvedLocation, + addGroup, + removeGroup, + selectGroup, + changeRegistrationMode, + handleFilesChange, + removePhoto, + registrationModeLabels, + photoStatusLabels, + locationSourceLabels, + isEditingActiveLocation, + locationDraft, + applyLocationDraft, + cancelLocationEditing, + startLocationEditing, + activeResolvedLocation, + hasUnappliedLocationDraft, + statusMessage, + changeLocationDraft, + LocationMap, +}: DredgingRegistrationBodyProps) => ( +
+
+ 등록 안내 + 관로별 사진은 최대 5장까지 등록할 수 있습니다. + + 모든 등록 건은 하나의 준설작업이며 동일한 준설일자를 사용합니다. + +
+ + {activeGroup ? ( +
group.id === activeGroup.id) + 1 + }`} + > + + +
+
+

위치 선택 지도

+
+
+ +
+
+
+ ) : null} +
+); diff --git a/apps/sn/src/features/exmn/lib/download-exmn-details.ts b/apps/sn/src/features/exmn/lib/download-exmn-details.ts new file mode 100644 index 000000000..3ee83ec7e --- /dev/null +++ b/apps/sn/src/features/exmn/lib/download-exmn-details.ts @@ -0,0 +1,291 @@ +import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; +import { + EXMN_DETAIL_SEVERITIES, + type ExmnDetailSeverity, + type ExmnDetailDefectColumn, + type ExmnPplUnitDetailRow, + type ExmnSurveyDetailRow, + type ExmnSeverityCounts, +} from "../model/exmn-detail-table"; +import { getVisibleAnomalyTotals } from "./exmn-detail-totals"; + +type ExmnDetailDownloadOptions = { + selectedTrtdstNm: string; + defectGroupTitle: string; + isUnitMode: boolean; + defectTypeLabel: string; + unitRows: ExmnPplUnitDetailRow[]; + rows: ExmnSurveyDetailRow[]; + recordTypeLabels: Record; + pipeItemLabels: Record; + severityLabels: Record; + visibleDefectColumns: ExmnDetailDefectColumn[]; + totals: { + extensionM: number; + drivingDistanceM: number; + structuralGrade: number; + operationalGrade: number; + }; + defectSummary: { + anomalySubtotals: ExmnSeverityCounts; + anomalyTotal: number; + defectSubtotals: Record; + defectTotals: Record; + }; + summaryLabelColumnCount: number; + fixedColumnCount: number; + setNotice: (notice: { type: "info"; text: string } | null) => void; +}; + +/** 가변 결함 열 수에 맞춰 엑셀의 마지막 열 이름(A, Z, AA 등)을 계산한다. */ +const getExcelColumnName = (columnCount: number) => { + let remaining = columnCount; + let result = ""; + while (remaining > 0) { + remaining -= 1; + result = String.fromCharCode(65 + (remaining % 26)) + result; + remaining = Math.floor(remaining / 26); + } + return result || "A"; +}; + +/** 현재 조회·필터·표 순서의 조사 결과로 Excel 열·상세·소계·합계·병합을 구성한다. */ +export const downloadExmnSurveyDetails = async ({ + selectedTrtdstNm, + defectGroupTitle, + isUnitMode, + defectTypeLabel, + unitRows, + rows, + recordTypeLabels, + pipeItemLabels, + severityLabels, + visibleDefectColumns, + totals, + defectSummary, + summaryLabelColumnCount, + fixedColumnCount, + setNotice, +}: ExmnDetailDownloadOptions) => { + const downloadDate = new Date().toISOString().slice(0, 10); + const safeFilePrefix = `${selectedTrtdstNm}_${defectGroupTitle}조사_${ + isUnitMode ? "본단위등급표" : defectTypeLabel + }`.replace(/[\\/:*?"<>|]/g, "_"); + + try { + if (isUnitMode) { + if (unitRows.length === 0) { + setNotice({ + type: "info", + text: "다운로드할 본단위구간 데이터가 없습니다.", + }); + return; + } + + const unitHeaderMapping: Record = { + pipeNo: "관로번호", + unitSeq: "단위구간", + unitLengthM: "단위길이(m)", + defectPositionM: "결함위치(m)", + defectName: "결함 종류", + code: "결함 코드", + severity: "결함 등급", + occurrencePosition: "발생위치", + infiltrationBonus: "침입수 가점", + continuous: "연속결함", + structuralScore: "구조적 등급점수", + operationalScore: "운영적 등급점수", + surveyStopReason: "조사중단사유", + remark: "비고", + }; + const unitDownloadRows = unitRows.map((row) => ({ + pipeNo: row.pipeNo, + unitSeq: row.unitSeq, + unitLengthM: row.unitLengthM, + defectPositionM: row.defectPositionM, + defectName: row.recordType + ? `(${recordTypeLabels[row.recordType] || row.recordType})${pipeItemLabels[row.code] || row.defectName.replace(/^\([^)]*\)/, "")}` + : row.defectName, + code: row.code, + severity: row.severity ? severityLabels[row.severity] : "-", + occurrencePosition: row.occurrencePosition, + infiltrationBonus: row.infiltrationBonus, + continuous: row.continuous == null ? "-" : row.continuous ? "Y" : "N", + structuralScore: row.structuralScore ?? "-", + operationalScore: row.operationalScore ?? "-", + surveyStopReason: row.surveyStopReason ?? "-", + remark: row.remark ?? "-", + })); + + await downloadExcelWithKoreanHeaders({ + data: unitDownloadRows as unknown as JSON[], + fileName: `${safeFilePrefix}_${downloadDate}.xlsx`, + sheetNameArray: ["본단위등급표"], + headerRange: "A1:N1", + headerMapping: unitHeaderMapping, + }); + return; + } + + if (rows.length === 0) { + setNotice({ + type: "info", + text: "다운로드할 상세현황 데이터가 없습니다.", + }); + return; + } + + const detailHeaderMapping: Record = { + reportNo: "보고서번호", + pipeNo: "관로번호", + upstreamManhole: "상류맨홀", + downstreamManhole: "하류맨홀", + drainageType: "배수방식", + pipeType: "관종", + specification: "규격", + extensionM: "연장(m)", + drivingDistanceM: "주행거리(m)", + }; + Object.assign(detailHeaderMapping, { + structuralGrade: "구조적 내부결함", + operationalGrade: "운영적 내부결함", + }); + Object.assign(detailHeaderMapping, { + anomalyL: "이상항목 합계-대", + anomalyM: "이상항목 합계-중", + anomalyS: "이상항목 합계-소", + }); + visibleDefectColumns.forEach((column) => { + EXMN_DETAIL_SEVERITIES.forEach((severity) => { + detailHeaderMapping[`${column.code}_${severity}`] = + `${column.label} (${column.code})-${severityLabels[severity]}`; + }); + }); + + const detailDownloadRows: Record[] = rows.map( + (row) => { + const anomalyTotals = getVisibleAnomalyTotals( + row, + visibleDefectColumns, + ); + const result: Record = { + reportNo: row.reportNo, + pipeNo: row.pipeNo, + upstreamManhole: row.upstreamManhole, + downstreamManhole: row.downstreamManhole, + drainageType: row.drainageTypeName?.trim() || row.drainageType, + pipeType: row.pipeTypeName?.trim() || row.pipeType, + specification: row.specification, + extensionM: row.extensionM, + drivingDistanceM: row.drivingDistanceM, + }; + Object.assign(result, { + structuralGrade: row.structuralGrade, + operationalGrade: row.operationalGrade, + }); + Object.assign(result, { + anomalyL: anomalyTotals.L, + anomalyM: anomalyTotals.M, + anomalyS: anomalyTotals.S, + }); + visibleDefectColumns.forEach((column) => { + EXMN_DETAIL_SEVERITIES.forEach((severity) => { + result[`${column.code}_${severity}`] = + row.defectCounts[column.code]?.[severity] ?? 0; + }); + }); + return result; + }, + ); + const subtotalRow: Record = { + reportNo: "소계", + pipeNo: "", + upstreamManhole: "", + downstreamManhole: "", + drainageType: "", + pipeType: "", + specification: "", + extensionM: totals.extensionM, + drivingDistanceM: totals.drivingDistanceM, + }; + const totalRow: Record = { + reportNo: "합계", + pipeNo: "", + upstreamManhole: "", + downstreamManhole: "", + drainageType: "", + pipeType: "", + specification: "", + extensionM: totals.extensionM, + drivingDistanceM: totals.drivingDistanceM, + }; + Object.assign(subtotalRow, { + structuralGrade: totals.structuralGrade, + operationalGrade: totals.operationalGrade, + }); + Object.assign(totalRow, { + structuralGrade: totals.structuralGrade, + operationalGrade: totals.operationalGrade, + }); + Object.assign(subtotalRow, { + anomalyL: defectSummary.anomalySubtotals.L, + anomalyM: defectSummary.anomalySubtotals.M, + anomalyS: defectSummary.anomalySubtotals.S, + }); + Object.assign(totalRow, { + anomalyL: defectSummary.anomalyTotal, + anomalyM: "", + anomalyS: "", + }); + visibleDefectColumns.forEach((column) => { + EXMN_DETAIL_SEVERITIES.forEach((severity) => { + subtotalRow[`${column.code}_${severity}`] = + defectSummary.defectSubtotals[column.code]?.[severity] ?? 0; + }); + totalRow[`${column.code}_L`] = + defectSummary.defectTotals[column.code] ?? 0; + totalRow[`${column.code}_M`] = ""; + totalRow[`${column.code}_S`] = ""; + }); + detailDownloadRows.push(subtotalRow, totalRow); + + const subtotalExcelRow = rows.length + 2; + const totalExcelRow = rows.length + 3; + const summaryLabelLastColumn = getExcelColumnName(summaryLabelColumnCount); + const mergeRanges = [ + `A${subtotalExcelRow}:${summaryLabelLastColumn}${subtotalExcelRow}`, + `A${totalExcelRow}:${summaryLabelLastColumn}${totalExcelRow}`, + ]; + for ( + let groupStartColumn = fixedColumnCount + 1; + groupStartColumn < Object.keys(detailHeaderMapping).length; + groupStartColumn += EXMN_DETAIL_SEVERITIES.length + ) { + mergeRanges.push( + `${getExcelColumnName(groupStartColumn)}${totalExcelRow}:${getExcelColumnName( + groupStartColumn + EXMN_DETAIL_SEVERITIES.length - 1, + )}${totalExcelRow}`, + ); + } + + await downloadExcelWithKoreanHeaders({ + data: detailDownloadRows as unknown as JSON[], + fileName: `${safeFilePrefix}_${downloadDate}.xlsx`, + sheetNameArray: [ + `${defectGroupTitle}조사 ${defectTypeLabel}`.slice(0, 31), + ], + headerRange: `A1:${getExcelColumnName(Object.keys(detailHeaderMapping).length)}1`, + headerMapping: detailHeaderMapping, + mergeRanges, + }); + } catch (error) { + console.error("조사 상세현황 엑셀 다운로드 오류:", error); + setNotice({ + type: "info", + text: + error instanceof Error + ? error.message + : "엑셀 다운로드 중 오류가 발생했습니다.", + }); + } +}; diff --git a/apps/sn/src/features/exmn/lib/exmn-detail-totals.ts b/apps/sn/src/features/exmn/lib/exmn-detail-totals.ts new file mode 100644 index 000000000..5619b194e --- /dev/null +++ b/apps/sn/src/features/exmn/lib/exmn-detail-totals.ts @@ -0,0 +1,20 @@ +import { + EXMN_DETAIL_SEVERITIES, + type ExmnSurveyDetailRow, + type ExmnDetailDefectColumn, + type ExmnSeverityCounts, +} from "../model/exmn-detail-table"; +/** 고정 열의 숫자·한글·번호 혼합 값을 공통 그리드와 같은 방향으로 비교한다. */ +/** 현재 화면에 노출하는 결함 열만 합산해 행의 대·중·소 합계를 계산한다. */ +export const getVisibleAnomalyTotals = ( + row: ExmnSurveyDetailRow, + defectColumns: ExmnDetailDefectColumn[], +): ExmnSeverityCounts => { + const totals: ExmnSeverityCounts = { L: 0, M: 0, S: 0 }; + defectColumns.forEach((column) => { + EXMN_DETAIL_SEVERITIES.forEach((severity) => { + totals[severity] += row.defectCounts[column.code]?.[severity] ?? 0; + }); + }); + return totals; +}; diff --git a/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx b/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx index 6ff255ceb..9287345ac 100644 --- a/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx @@ -20,7 +20,8 @@ import { SelectValue, Table, } from "@common/components"; -import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; +import { downloadExmnSurveyDetails } from "../lib/download-exmn-details"; +import { getVisibleAnomalyTotals } from "../lib/exmn-detail-totals"; import PopupSearchPanel, { PopupSearchActions, } from "@/features/windowPopup/ui/PopupSearchPanel"; @@ -38,12 +39,10 @@ import type { import { EXMN_DETAIL_SEVERITIES, EXMN_DETAIL_TABLE_DEFINITIONS, - type ExmnDetailDefectColumn, type ExmnDetailDefectType, type ExmnDetailSeverity, type ExmnPplDetailMode, type ExmnPplUnitDetailRow, - type ExmnSeverityCounts, type ExmnSurveyDetailRow, } from "../model/exmn-detail-table"; import { useExmnCommonCodes } from "../lib/useExmnCommonCodes"; @@ -108,17 +107,6 @@ const formatNumber = (value: number) => const formatCount = (value: number | null | undefined) => Number(value ?? 0) === 0 ? "-" : Number(value).toLocaleString("ko-KR"); -/** 가변 결함 열 수에 맞춰 엑셀의 마지막 열 이름(A, Z, AA 등)을 계산한다. */ -const getExcelColumnName = (columnCount: number) => { - let remaining = columnCount; - let result = ""; - while (remaining > 0) { - remaining -= 1; - result = String.fromCharCode(65 + (remaining % 26)) + result; - remaining = Math.floor(remaining / 26); - } - return result || "A"; -}; /** 공통 고정 열의 행 값을 화면용 문자열로 변환한다. */ const getFixedCellValue = ( @@ -135,20 +123,6 @@ const getFixedCellValue = ( return typeof value === "number" ? formatNumber(value) : value; }; -/** 고정 열의 숫자·한글·번호 혼합 값을 공통 그리드와 같은 방향으로 비교한다. */ -/** 현재 화면에 노출하는 결함 열만 합산해 행의 대·중·소 합계를 계산한다. */ -const getVisibleAnomalyTotals = ( - row: ExmnSurveyDetailRow, - defectColumns: ExmnDetailDefectColumn[], -): ExmnSeverityCounts => { - const totals: ExmnSeverityCounts = { L: 0, M: 0, S: 0 }; - defectColumns.forEach((column) => { - EXMN_DETAIL_SEVERITIES.forEach((severity) => { - totals[severity] += row.defectCounts[column.code]?.[severity] ?? 0; - }); - }); - return totals; -}; /** 엑셀 `01. 덕풍`·`01. 덕풍 (2)` 구조를 관로·맨홀 공통 WindowPopup으로 표시한다. */ export function ExmnSurveyDetailPopupPage({ @@ -512,232 +486,16 @@ export function ExmnSurveyDetailPopupPage({ }, [rows, visibleDefectColumns]); /** 현재 화면에 표시된 관로·맨홀 상세 결과를 한글 헤더 엑셀로 내려받는다. */ - const handleDownload = async () => { - const downloadDate = new Date().toISOString().slice(0, 10); - const safeFilePrefix = - `${selectedTrtdstNm}_${definition.defectGroupTitle}조사_${ - isUnitMode ? "본단위등급표" : defectTypeLabel - }`.replace(/[\\/:*?"<>|]/g, "_"); - - try { - if (isUnitMode) { - if (unitRows.length === 0) { - setNotice({ - type: "info", - text: "다운로드할 본단위구간 데이터가 없습니다.", - }); - return; - } - - const unitHeaderMapping: Record = { - pipeNo: "관로번호", - unitSeq: "단위구간", - unitLengthM: "단위길이(m)", - defectPositionM: "결함위치(m)", - defectName: "결함 종류", - code: "결함 코드", - severity: "결함 등급", - occurrencePosition: "발생위치", - infiltrationBonus: "침입수 가점", - continuous: "연속결함", - structuralScore: "구조적 등급점수", - operationalScore: "운영적 등급점수", - surveyStopReason: "조사중단사유", - remark: "비고", - }; - const unitDownloadRows = unitRows.map((row) => ({ - pipeNo: row.pipeNo, - unitSeq: row.unitSeq, - unitLengthM: row.unitLengthM, - defectPositionM: row.defectPositionM, - defectName: row.recordType - ? `(${recordTypeLabels[row.recordType] || row.recordType})${pipeItemLabels[row.code] || row.defectName.replace(/^\([^)]*\)/, "")}` - : row.defectName, - code: row.code, - severity: row.severity ? severityLabels[row.severity] : "-", - occurrencePosition: row.occurrencePosition, - infiltrationBonus: row.infiltrationBonus, - continuous: row.continuous == null ? "-" : row.continuous ? "Y" : "N", - structuralScore: row.structuralScore ?? "-", - operationalScore: row.operationalScore ?? "-", - surveyStopReason: row.surveyStopReason ?? "-", - remark: row.remark ?? "-", - })); - - await downloadExcelWithKoreanHeaders({ - data: unitDownloadRows as unknown as JSON[], - fileName: `${safeFilePrefix}_${downloadDate}.xlsx`, - sheetNameArray: ["본단위등급표"], - headerRange: "A1:N1", - headerMapping: unitHeaderMapping, - }); - return; - } - - if (rows.length === 0) { - setNotice({ - type: "info", - text: "다운로드할 상세현황 데이터가 없습니다.", - }); - return; - } - - const detailHeaderMapping: Record = { - reportNo: "보고서번호", - pipeNo: "관로번호", - upstreamManhole: "상류맨홀", - downstreamManhole: "하류맨홀", - drainageType: "배수방식", - pipeType: "관종", - specification: "규격", - extensionM: "연장(m)", - drivingDistanceM: "주행거리(m)", - }; - Object.assign(detailHeaderMapping, { - structuralGrade: "구조적 내부결함", - operationalGrade: "운영적 내부결함", - }); - Object.assign(detailHeaderMapping, { - anomalyL: "이상항목 합계-대", - anomalyM: "이상항목 합계-중", - anomalyS: "이상항목 합계-소", - }); - visibleDefectColumns.forEach((column) => { - EXMN_DETAIL_SEVERITIES.forEach((severity) => { - detailHeaderMapping[`${column.code}_${severity}`] = - `${column.label} (${column.code})-${severityLabels[severity]}`; - }); - }); - - const detailDownloadRows: Record[] = rows.map( - (row) => { - const anomalyTotals = getVisibleAnomalyTotals( - row, - visibleDefectColumns, - ); - const result: Record = { - reportNo: row.reportNo, - pipeNo: row.pipeNo, - upstreamManhole: row.upstreamManhole, - downstreamManhole: row.downstreamManhole, - drainageType: row.drainageTypeName?.trim() || row.drainageType, - pipeType: row.pipeTypeName?.trim() || row.pipeType, - specification: row.specification, - extensionM: row.extensionM, - drivingDistanceM: row.drivingDistanceM, - }; - Object.assign(result, { - structuralGrade: row.structuralGrade, - operationalGrade: row.operationalGrade, - }); - Object.assign(result, { - anomalyL: anomalyTotals.L, - anomalyM: anomalyTotals.M, - anomalyS: anomalyTotals.S, - }); - visibleDefectColumns.forEach((column) => { - EXMN_DETAIL_SEVERITIES.forEach((severity) => { - result[`${column.code}_${severity}`] = - row.defectCounts[column.code]?.[severity] ?? 0; - }); - }); - return result; - }, - ); - const subtotalRow: Record = { - reportNo: "소계", - pipeNo: "", - upstreamManhole: "", - downstreamManhole: "", - drainageType: "", - pipeType: "", - specification: "", - extensionM: totals.extensionM, - drivingDistanceM: totals.drivingDistanceM, - }; - const totalRow: Record = { - reportNo: "합계", - pipeNo: "", - upstreamManhole: "", - downstreamManhole: "", - drainageType: "", - pipeType: "", - specification: "", - extensionM: totals.extensionM, - drivingDistanceM: totals.drivingDistanceM, - }; - Object.assign(subtotalRow, { - structuralGrade: totals.structuralGrade, - operationalGrade: totals.operationalGrade, - }); - Object.assign(totalRow, { - structuralGrade: totals.structuralGrade, - operationalGrade: totals.operationalGrade, - }); - Object.assign(subtotalRow, { - anomalyL: defectSummary.anomalySubtotals.L, - anomalyM: defectSummary.anomalySubtotals.M, - anomalyS: defectSummary.anomalySubtotals.S, - }); - Object.assign(totalRow, { - anomalyL: defectSummary.anomalyTotal, - anomalyM: "", - anomalyS: "", - }); - visibleDefectColumns.forEach((column) => { - EXMN_DETAIL_SEVERITIES.forEach((severity) => { - subtotalRow[`${column.code}_${severity}`] = - defectSummary.defectSubtotals[column.code]?.[severity] ?? 0; - }); - totalRow[`${column.code}_L`] = - defectSummary.defectTotals[column.code] ?? 0; - totalRow[`${column.code}_M`] = ""; - totalRow[`${column.code}_S`] = ""; - }); - detailDownloadRows.push(subtotalRow, totalRow); - - const subtotalExcelRow = rows.length + 2; - const totalExcelRow = rows.length + 3; - const summaryLabelLastColumn = getExcelColumnName( - summaryLabelColumnCount, - ); - const mergeRanges = [ - `A${subtotalExcelRow}:${summaryLabelLastColumn}${subtotalExcelRow}`, - `A${totalExcelRow}:${summaryLabelLastColumn}${totalExcelRow}`, - ]; - for ( - let groupStartColumn = fixedColumns.length + 1; - groupStartColumn < Object.keys(detailHeaderMapping).length; - groupStartColumn += EXMN_DETAIL_SEVERITIES.length - ) { - mergeRanges.push( - `${getExcelColumnName(groupStartColumn)}${totalExcelRow}:${getExcelColumnName( - groupStartColumn + EXMN_DETAIL_SEVERITIES.length - 1, - )}${totalExcelRow}`, - ); - } - - await downloadExcelWithKoreanHeaders({ - data: detailDownloadRows as unknown as JSON[], - fileName: `${safeFilePrefix}_${downloadDate}.xlsx`, - sheetNameArray: [ - `${definition.defectGroupTitle}조사 ${defectTypeLabel}`.slice(0, 31), - ], - headerRange: `A1:${getExcelColumnName(Object.keys(detailHeaderMapping).length)}1`, - headerMapping: detailHeaderMapping, - mergeRanges, - }); - } catch (error) { - console.error("조사 상세현황 엑셀 다운로드 오류:", error); - setNotice({ - type: "info", - text: - error instanceof Error - ? error.message - : "엑셀 다운로드 중 오류가 발생했습니다.", - }); - } - }; + /** 화면은 확정 조회/표 상태만 전달하고 Excel 조립·저장은 기능 모듈이 담당한다. */ + const handleDownload = () => downloadExmnSurveyDetails({ + selectedTrtdstNm, + defectGroupTitle: definition.defectGroupTitle, + isUnitMode, defectTypeLabel, unitRows, rows, + recordTypeLabels, pipeItemLabels, severityLabels, visibleDefectColumns, + totals, defectSummary, summaryLabelColumnCount, + fixedColumnCount: fixedColumns.length, + setNotice, + }); const tableWidth = useMemo( () => diff --git a/apps/sn/src/features/exmn/ui/PplFacilityDetailModal.tsx b/apps/sn/src/features/exmn/ui/PplFacilityDetailModal.tsx deleted file mode 100644 index 936ae0431..000000000 --- a/apps/sn/src/features/exmn/ui/PplFacilityDetailModal.tsx +++ /dev/null @@ -1,246 +0,0 @@ -"use client"; - -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@common/components/ui"; -import { useEffect, useMemo, useState } from "react"; -import { useExmnCommonCodes } from "../lib/useExmnCommonCodes"; -import type { PplFacility } from "../model/exmn-business-types"; -import { ExmnStandardDataTable } from "./ExmnStandardDataTable"; - -const UNIT_TABLE_HEAD = [ - { id: "unitSeq", title: "단위", widthPercent: 8 }, - { id: "startPositionM", title: "시작위치(m)", widthPercent: 14 }, - { id: "unitLengthM", title: "단위연장(m)", widthPercent: 14 }, - { id: "structuralScore", title: "구조점수", widthPercent: 13 }, - { id: "structuralGrade", title: "구조등급", widthPercent: 13 }, - { id: "operationalScore", title: "운영점수", widthPercent: 13 }, - { id: "operationalGrade", title: "운영등급", widthPercent: 13 }, - { id: "detailCount", title: "기록수", widthPercent: 12 }, -]; - -const DETAIL_TABLE_HEAD = [ - { id: "detailSeq", title: "순번", widthPercent: 6 }, - { id: "positionM", title: "위치(m)", widthPercent: 9 }, - { id: "code", title: "코드", widthPercent: 8 }, - { id: "recordType", title: "기록유형", widthPercent: 10 }, - { id: "typeText", title: "결함구분", widthPercent: 10 }, - { id: "severityText", title: "심각도", widthPercent: 8 }, - { id: "occurrencePositionText", title: "발생위치", widthPercent: 10 }, - { id: "continuousText", title: "연속결함", widthPercent: 9 }, - { id: "structuralScoreText", title: "구조점수", widthPercent: 10 }, - { id: "operationalScoreText", title: "운영점수", widthPercent: 10 }, - { id: "statusGroupText", title: "상태그룹", widthPercent: 10 }, -]; - -interface PplFacilityDetailModalProps { - open: boolean; - facility: PplFacility | null; - onOpenChange: (open: boolean) => void; -} - -/** PPL_UNIT_V1 정규화 응답을 단위 구간과 상태·결함 기록으로 표시한다. */ -export function PplFacilityDetailModal({ - open, - facility, - onOpenChange, -}: PplFacilityDetailModalProps) { - // 정규화 상세의 코드값은 유지하고 기록유형·분류·심각도·상태그룹 표시명만 변환한다. - const { - defectGradeOptions, - recordTypeLabels, - defectTypeLabels, - severityLabels, - statusGroupLabels, - } = useExmnCommonCodes(); - // 선택 단위는 원문 배열 인덱스가 아니라 unitSeq로 보관해 서버 정렬과 무관하게 유지한다. - const [selectedUnitSeq, setSelectedUnitSeq] = useState(null); - - const defectGradeLabels = useMemo( - () => - Object.fromEntries( - defectGradeOptions.map((option) => [option.value, option.label]), - ), - [defectGradeOptions], - ); - - /** 시설이 바뀌면 첫 단위 구간을 기본 선택한다. */ - useEffect(() => { - if (!open) return; - setSelectedUnitSeq(facility?.unitSections?.[0]?.unitSeq ?? null); - }, [facility, open]); - - const unitRows = useMemo( - () => - (facility?.unitSections ?? []).map((unit) => ({ - ...unit, - structuralGrade: - defectGradeLabels[String(unit.structuralGrade)] || - unit.structuralGrade, - operationalGrade: - defectGradeLabels[String(unit.operationalGrade)] || - unit.operationalGrade, - detailCount: unit.details?.length ?? 0, - })), - [defectGradeLabels, facility], - ); - - const selectedUnit = useMemo( - () => - facility?.unitSections?.find( - (unit) => unit.unitSeq === selectedUnitSeq, - ) ?? null, - [facility, selectedUnitSeq], - ); - - const detailRows = useMemo( - () => - (selectedUnit?.details ?? []).map((detail) => { - const defectType = detail.type ?? ""; - const severity = detail.severity ?? ""; - const statusGroup = detail.statusGroup ?? ""; - return { - ...detail, - recordType: - recordTypeLabels[detail.recordType] || detail.recordType || "-", - typeText: defectTypeLabels[defectType] || defectType || "-", - severityText: severityLabels[severity] || severity || "-", - occurrencePositionText: detail.occurrencePosition ?? "-", - continuousText: - detail.continuous == null ? "-" : detail.continuous ? "Y" : "N", - structuralScoreText: detail.structuralScore ?? "-", - operationalScoreText: detail.operationalScore ?? "-", - statusGroupText: - statusGroupLabels[statusGroup] || statusGroup || "-", - }; - }), - [ - defectTypeLabels, - recordTypeLabels, - selectedUnit, - severityLabels, - statusGroupLabels, - ], - ); - - return ( - - - - - PPL 조사시설 상세 - - -
-
-
- 보고서번호 -

{facility?.rptpNo || "-"}

-
-
- GIS 관로관리번호 -

- {facility?.exmnFcltyGisMngNo || "-"} -

-
-
- 처리분구 -

{facility?.trtdstNm || "-"}

-
-
- 상류/하류 맨홀 -

- {facility?.upstmMnhlNo || "-"} / {facility?.dnstmMnhlNo || "-"} -

-
-
- 배수방식 -

{facility?.drgptnCd || "-"}

-
-
- 관종/규격 -

- {facility?.pptyCd || "-"} / {facility?.spcfctCd || "-"} -

-
-
- 연장/주행거리 -

- {facility?.prlg ?? "-"} / {facility?.drvngDstnc ?? "-"} -

-
-
- 구조/운영 등급 -

- {facility?.strctDfcGrdCd || "-"} /{" "} - {facility?.operDfcGrdCd || "-"} -

-
-
- -
- - > - } - title="단위 구간 목록" - headlist={UNIT_TABLE_HEAD} - total={unitRows.length} - pageSize={Math.max(unitRows.length, 1)} - page={1} - onRowClick={(row) => setSelectedUnitSeq(row.unitSeq)} - onPageChange={() => undefined} - /> -
- -
- 조사중단사유:{" "} - {selectedUnit?.surveyStopReason || "없음"} - | - 비고: {selectedUnit?.remark || "없음"} -
- -
- - > - } - title="상태·결함 상세 기록 목록" - headlist={DETAIL_TABLE_HEAD} - total={detailRows.length} - pageSize={Math.max(detailRows.length, 1)} - page={1} - onPageChange={() => undefined} - scrollMode="horizontal" - minTableWidth="140rem" - /> -
- -
- - 시설 보고서 파일 매핑 원천이 확정되기 전까지 보고서 조회는 - 제공하지 않습니다. - - -
-
- - - -
-
- ); -} diff --git a/apps/sn/src/features/exmn/ui/PplSummaryModal.tsx b/apps/sn/src/features/exmn/ui/PplSummaryModal.tsx deleted file mode 100644 index 7ab586316..000000000 --- a/apps/sn/src/features/exmn/ui/PplSummaryModal.tsx +++ /dev/null @@ -1,599 +0,0 @@ -"use client"; - -import LoadingOverlay from "@/components/ui/LoadingBar"; -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - Input, -} from "@common/components/ui"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { - getExmnBusiness, - getPplFacilities, - getPplSummaries, -} from "../api/exmn-business-api"; -import type { - ExmnBusiness, - ExmnBusinessDetail, - PplFacility, - PplSummaryRow, -} from "../model/exmn-business-types"; -import { PplFacilityDetailModal } from "./PplFacilityDetailModal"; -import { ExmnStandardDataTable } from "./ExmnStandardDataTable"; -import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; -import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; -import { useExmnCommonCodes } from "../lib/useExmnCommonCodes"; - -const SUMMARY_TABLE_HEAD = [ - { id: "trtdstNm", title: "처리분구", widthPercent: 20 }, - { - id: "trgtPrlgText", - title: "대상연장(m)", - widthPercent: 13, - }, - { - id: "drvngDstncText", - title: "주행거리(m)", - widthPercent: 13, - }, - { - id: "pplExmnWholAnmlyArtclCnt", - title: "PPL 전체이상", - widthPercent: 14, - }, - { - id: "pplExmnStrctDfcCnt", - title: "구조결함", - widthPercent: 13, - }, - { - id: "pplExmnOperDfcCnt", - title: "운영결함", - widthPercent: 13, - }, - { id: "pplExmnEtcCnt", title: "기타", widthPercent: 14 }, -]; - -const FACILITY_TABLE_HEAD = [ - { id: "trtdstNm", title: "처리분구", widthPercent: 13 }, - { id: "rptpNo", title: "보고서번호", widthPercent: 12 }, - { - id: "exmnFcltyGisMngNo", - title: "GIS 관로관리번호", - widthPercent: 18, - }, - { id: "upstreamDownstream", title: "상류/하류 맨홀", widthPercent: 17 }, - { id: "pipeSpec", title: "관종/규격", widthPercent: 13 }, - { - id: "prlgText", - title: "연장(m)", - widthPercent: 9, - }, - { - id: "strctDfcGrdCdText", - title: "구조등급", - widthPercent: 9, - }, - { - id: "operDfcGrdCdText", - title: "운영등급", - widthPercent: 9, - }, -]; - -const SUMMARY_CLICKABLE_HEAD_IDS = [ - "pplExmnWholAnmlyArtclCnt", - "pplExmnStrctDfcCnt", - "pplExmnOperDfcCnt", - "pplExmnEtcCnt", -]; - -type Notice = { type: "success" | "error"; text: string } | null; -type PplDefectType = "ALL" | "STRUCTURAL" | "OPERATIONAL" | "ETC"; - -type PplFacilityFilters = { - trtdstId: string; - sectionType: string; - gisManagementNo: string; - structuralGrade: string; - operationalGrade: string; - defectType: PplDefectType; -}; - -const DEFAULT_PPL_FACILITY_FILTERS: PplFacilityFilters = { - trtdstId: "", - sectionType: "", - gisManagementNo: "", - structuralGrade: "", - operationalGrade: "", - defectType: "ALL", -}; - -interface PplSummaryModalProps { - open: boolean; - business: ExmnBusiness | null; - onOpenChange: (open: boolean) => void; -} - -/** 숫자 값을 목록에서 단위 없이 천단위 구분 형식으로 표시한다. */ -const formatNumber = (value: number | null | undefined) => - Number(value ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 3 }); - -/** 처리분구별 PPL 집계에서 결함 수를 따라 시설 목록과 단위 상세까지 탐색하는 모달이다. */ -export function PplSummaryModal({ - open, - business, - onOpenChange, -}: PplSummaryModalProps) { - // 조사시설 검색 코드값은 유지하고 등급·결함분류 표시명만 CMS 기준으로 사용한다. - const { defectGradeOptions, defectTypeLabels } = useExmnCommonCodes(); - const [businessDetail, setBusinessDetail] = - useState(null); - const [summaryRows, setSummaryRows] = useState([]); - const [summaryTotal, setSummaryTotal] = useState(0); - const [summaryPage, setSummaryPage] = useState(0); - const [facilityRows, setFacilityRows] = useState([]); - const [facilityTotal, setFacilityTotal] = useState(0); - const [facilityPage, setFacilityPage] = useState(0); - // 집계 건수 클릭과 상세 조회조건이 공유하는 처리분구·결함유형 컨텍스트다. - const [trtdstId, setTrtdstId] = useState(""); - const [sectionType, setSectionType] = useState(""); - const [gisManagementNo, setGisManagementNo] = useState(""); - const [structuralGrade, setStructuralGrade] = useState(""); - const [operationalGrade, setOperationalGrade] = useState(""); - const [defectType, setDefectType] = useState("ALL"); - const [selectedFacility, setSelectedFacility] = useState( - null, - ); - const [isLoading, setIsLoading] = useState(false); - const [notice, setNotice] = useState(null); - - const displayedSummaryRows = useMemo( - () => - summaryRows.map((row) => ({ - ...row, - trgtPrlgText: formatNumber(row.trgtPrlg), - drvngDstncText: formatNumber(row.drvngDstnc), - })), - [summaryRows], - ); - - const displayedFacilityRows = useMemo( - () => - facilityRows.map((row) => ({ - ...row, - upstreamDownstream: `${row.upstmMnhlNo || "-"} / ${row.dnstmMnhlNo || "-"}`, - pipeSpec: `${row.pptyCd || "-"} / ${row.spcfctCd || "-"}`, - prlgText: row.prlg == null ? "-" : formatNumber(row.prlg), - strctDfcGrdCdText: row.strctDfcGrdCd || "-", - operDfcGrdCdText: row.operDfcGrdCd || "-", - })), - [facilityRows], - ); - - /** 현재 상세 필터로 PPL 시설 페이지를 요청한다. */ - const fetchFacilities = useCallback( - async ( - nextPage = facilityPage, - filters: PplFacilityFilters = { - trtdstId, - sectionType, - gisManagementNo, - structuralGrade, - operationalGrade, - defectType, - }, - ) => { - if (!business?.exmnBizId) return; - setIsLoading(true); - setNotice(null); - try { - const page = await getPplFacilities({ - exmnBizId: business.exmnBizId, - trtdstId: filters.trtdstId || undefined, - sectionType: filters.sectionType || undefined, - gisManagementNo: filters.gisManagementNo || undefined, - structuralGrade: filters.structuralGrade || undefined, - operationalGrade: filters.operationalGrade || undefined, - defectType: filters.defectType, - page: nextPage, - size: 10, - }); - setFacilityRows(page.content); - setFacilityTotal(page.totalElements); - setFacilityPage(page.number); - } catch (error) { - setFacilityRows([]); - setFacilityTotal(0); - setNotice({ - type: "error", - text: - error instanceof Error - ? error.message - : "PPL 시설 조회 중 오류가 발생했습니다.", - }); - } finally { - setIsLoading(false); - } - }, - [ - business, - defectType, - facilityPage, - gisManagementNo, - operationalGrade, - sectionType, - structuralGrade, - trtdstId, - ], - ); - - /** 모달 진입과 집계 정렬·페이지 변경 시 사업 상세와 PPL 집계를 읽는다. */ - useEffect(() => { - if (!open || !business?.exmnBizId) return; - let active = true; - setIsLoading(true); - setNotice(null); - Promise.all([ - getExmnBusiness(business.exmnBizId), - getPplSummaries(business.exmnBizId, { - trtdstId: trtdstId || undefined, - page: summaryPage, - size: 10, - }), - ]) - .then(([detail, page]) => { - if (!active) return; - setBusinessDetail(detail); - setSummaryRows(page.content); - setSummaryTotal(page.totalElements); - }) - .catch((error: unknown) => { - if (!active) return; - setSummaryRows([]); - setSummaryTotal(0); - setNotice({ - type: "error", - text: - error instanceof Error - ? error.message - : "PPL 집계 조회 중 오류가 발생했습니다.", - }); - }) - .finally(() => { - if (active) setIsLoading(false); - }); - return () => { - active = false; - }; - }, [business, open, summaryPage, trtdstId]); - - /** 새 사업을 열 때 상세 필터·페이지·선택 시설을 초기화한다. */ - useEffect(() => { - if (!open) return; - setSummaryPage(0); - setFacilityRows([]); - setFacilityTotal(0); - setFacilityPage(0); - setTrtdstId(""); - setSectionType(""); - setGisManagementNo(""); - setStructuralGrade(""); - setOperationalGrade(""); - setDefectType("ALL"); - setSelectedFacility(null); - }, [business, open]); - - /** 집계의 전체·구조·운영·기타 건수를 클릭하면 해당 처리분구 시설 조회로 연결한다. */ - const handleSummaryCellClick = ( - index: number | undefined, - headId: string, - ) => { - if (index == null) return; - const row = summaryRows[index]; - if (!row) return; - const nextDefectType: PplDefectType = - headId === "pplExmnStrctDfcCnt" - ? "STRUCTURAL" - : headId === "pplExmnOperDfcCnt" - ? "OPERATIONAL" - : headId === "pplExmnEtcCnt" - ? "ETC" - : "ALL"; - setTrtdstId(row.trtdstId); - setDefectType(nextDefectType); - setFacilityPage(0); - setNotice(null); - // React 상태 반영 전 값 대신 클릭 컨텍스트로 즉시 요청한다. - setIsLoading(true); - getPplFacilities({ - exmnBizId: business?.exmnBizId ?? "", - trtdstId: row.trtdstId, - sectionType: sectionType || undefined, - gisManagementNo: gisManagementNo || undefined, - structuralGrade: structuralGrade || undefined, - operationalGrade: operationalGrade || undefined, - defectType: nextDefectType, - page: 0, - size: 10, - }) - .then((page) => { - setFacilityRows(page.content); - setFacilityTotal(page.totalElements); - setFacilityPage(page.number); - }) - .catch((error: unknown) => { - setFacilityRows([]); - setFacilityTotal(0); - setNotice({ - type: "error", - text: - error instanceof Error - ? error.message - : "PPL 시설 조회 중 오류가 발생했습니다.", - }); - }) - .finally(() => setIsLoading(false)); - }; - - /** 현재 처리분구 조건의 PPL 집계 엑셀을 다운로드한다. */ - const handleExport = async () => { - if (!business) return; - setIsLoading(true); - setNotice(null); - try { - const result = await fetchAllPages({ - fetchPage: (page, size) => - getPplSummaries(business.exmnBizId, { - trtdstId: trtdstId || undefined, - page, - size, - }), - getItems: (page) => page.content ?? [], - getTotal: (page) => Number(page.totalElements ?? 0), - }); - const excelRows = result.items.map((row) => ({ - trtdstNm: row.trtdstNm, - trgtPrlg: row.trgtPrlg, - drvngDstnc: row.drvngDstnc, - pplExmnWholAnmlyArtclCnt: row.pplExmnWholAnmlyArtclCnt, - pplExmnStrctDfcCnt: row.pplExmnStrctDfcCnt, - pplExmnOperDfcCnt: row.pplExmnOperDfcCnt, - pplExmnEtcCnt: row.pplExmnEtcCnt, - })); - await downloadExcelWithKoreanHeaders({ - data: excelRows as unknown as JSON[], - fileName: `${business.bizNm}_PPL집계.xlsx`, - sheetNameArray: ["처리분구별 PPL 집계"], - headerRange: "A1:G1", - headerMapping: { - trtdstNm: "처리분구", - trgtPrlg: "대상연장(m)", - drvngDstnc: "주행거리(m)", - pplExmnWholAnmlyArtclCnt: "PPL 전체이상", - pplExmnStrctDfcCnt: "구조결함", - pplExmnOperDfcCnt: "운영결함", - pplExmnEtcCnt: "기타", - }, - }); - } catch (error) { - setNotice({ - type: "error", - text: - error instanceof Error - ? error.message - : "PPL 엑셀 다운로드 중 오류가 발생했습니다.", - }); - } finally { - setIsLoading(false); - } - }; - - return ( - <> - - - - - PPL 조사 집계·상세 - - -
- - {notice && ( -
- {notice.text} -
- )} -
- {business?.bizNm ?? "선택 사업"} - | - {business?.bizYr}년 {business?.bizCycl ?? "-"}차 - | - {business?.ctpvNm} {business?.sggNm} -
- -
- - > - } - title="처리분구별 PPL 집계 목록" - headlist={SUMMARY_TABLE_HEAD} - total={summaryTotal} - pageSize={10} - page={summaryPage + 1} - clickableHeadIds={SUMMARY_CLICKABLE_HEAD_IDS} - onCellClick={(_row, index, headId) => - handleSummaryCellClick(index, headId) - } - onPageChange={(page) => setSummaryPage(page - 1)} - actions={ - - } - /> -
- -
-
- - - - - -
-
- - 결함 분류: {defectTypeLabels[defectType] || defectType} - - - -
-
- -
- - > - } - title="PPL 시설 목록" - headlist={FACILITY_TABLE_HEAD} - total={facilityTotal} - pageSize={10} - page={facilityPage + 1} - onRowClick={(_row, index) => - setSelectedFacility(facilityRows[index] ?? null) - } - onPageChange={(page) => void fetchFacilities(page - 1)} - scrollMode="horizontal" - minTableWidth="120rem" - /> -

- 시설 행을 선택하면 단위 구간과 상태·결함 상세가 열립니다. -

-
-
- - - -
-
- { - if (!nextOpen) setSelectedFacility(null); - }} - /> - - ); -} diff --git a/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx b/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx index 38abae35b..2f3aa7978 100644 --- a/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx +++ b/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx @@ -349,7 +349,7 @@ export default function FldngPredcPrstPopupContent() { }); // 백엔드 공통 응답({ data: ... })의 data 영역을 반환하는 API 헬퍼를 사용한다. const extentData = await getData( - `/sewerage-service/api/v1/fldng/flood-dngr-map/group/extent?${params.toString()}`, + `/sewerage-service/api/v1/fldng/floodDngrMap/group/extent?${params.toString()}`, ); const extent = [ extentData?.minX, diff --git a/apps/sn/src/features/gisEdit/index.ts b/apps/sn/src/features/gisEdit/index.ts new file mode 100644 index 000000000..81ae75ff0 --- /dev/null +++ b/apps/sn/src/features/gisEdit/index.ts @@ -0,0 +1,4 @@ +/** GIS 편집의 모델·snapshot 규칙을 화면에 제공하는 공개 경계다. */ +export * from "./model/types"; +export * from "./lib/edit-model"; +export * from "./lib/feature-snapshot"; diff --git a/apps/sn/src/features/gisEdit/lib/edit-model.ts b/apps/sn/src/features/gisEdit/lib/edit-model.ts new file mode 100644 index 000000000..550dc66b8 --- /dev/null +++ b/apps/sn/src/features/gisEdit/lib/edit-model.ts @@ -0,0 +1,343 @@ +/** 편집 가능한 레이어·속성·미리보기 규칙을 React 상태 변경과 분리한다. */ +/** 편집 가능한 레이어·속성·미리보기 규칙을 React 상태 변경과 분리한다. */ +import type { + GisEditChange, + GisEditGeometryType, + GisEditLayerInfo, + GisEditPipeBasicInfo, + GisEditPreviewColors, +} from "../model/types"; + +export type GisEditInteractionRequest = { + changeId: string; + editType: "MOVE" | "VERTEX_EDIT"; +}; + +export const isActionableChange = (change: GisEditChange) => + !change.attributeOnly || Boolean(change.basicInfoSavedAt); + +export const isGeometryChange = (change: GisEditChange) => + !change.attributeOnly && change.editType !== "ADD"; + +export const buildPreviewFocusMessage = ( + change: Pick, +) => { + if (change.editType === "MOVE") { + return `${change.layerName} ${change.objectLabel} 미리보기 객체를 드래그해 위치를 조정하세요.`; + } + if (change.editType === "VERTEX_EDIT") { + return `${change.layerName} ${change.objectLabel}의 주황색 실꼭지점을 드래그하거나, 선분 위 흰색 midpoint를 끌어 새 꼭지점을 추가하세요.`; + } + if (change.editType === "DELETE") { + return `${change.layerName} ${change.objectLabel} 삭제 미리보기를 유지 중입니다.`; + } + return `${change.layerName} ${change.objectLabel} 신규 객체 미리보기를 확인하세요.`; +}; + +export const EDITABLE_LAYER_RULES: Record< + string, + Omit +> = { + tb_ms_swage_ppl: { + layerName: "하수관로", + geometryType: "LineString", + allowedTools: ["ADD", "MOVE", "VERTEX_EDIT", "DELETE"], + previewColors: { + pointColor: "rgba(22, 163, 74, 0.92)", + lineColor: "rgba(22, 163, 74, 0.95)", + fillColor: "rgba(22, 163, 74, 0.18)", + }, + }, + tb_ms_swage_mhl: { + layerName: "하수맨홀", + geometryType: "Point", + allowedTools: ["ADD", "MOVE", "DELETE"], + previewColors: { + pointColor: "rgba(5, 150, 105, 0.92)", + lineColor: "rgba(5, 150, 105, 0.95)", + fillColor: "rgba(5, 150, 105, 0.18)", + }, + }, + tb_ms_swage_pmstn: { + layerName: "하수펌프장", + geometryType: "Point", + allowedTools: ["ADD", "MOVE", "DELETE"], + previewColors: { + pointColor: "rgba(59, 130, 246, 0.92)", + lineColor: "rgba(59, 130, 246, 0.95)", + fillColor: "rgba(59, 130, 246, 0.18)", + }, + }, + tb_il_swage_prcs_fclt: { + layerName: "하수처리시설", + geometryType: "Point", + allowedTools: ["ADD", "MOVE", "DELETE"], + previewColors: { + pointColor: "rgba(168, 85, 247, 0.92)", + lineColor: "rgba(168, 85, 247, 0.95)", + fillColor: "rgba(168, 85, 247, 0.18)", + }, + }, + tb_ms_swage_prcs_zone: { + layerName: "하수처리구역", + geometryType: "Polygon", + allowedTools: ["ADD", "MOVE", "VERTEX_EDIT", "DELETE"], + previewColors: { + pointColor: "rgba(249, 115, 22, 0.92)", + lineColor: "rgba(249, 115, 22, 0.95)", + fillColor: "rgba(249, 115, 22, 0.22)", + }, + }, + tb_ms_stofcbr: { + layerName: "우수토실", + geometryType: "Point", + allowedTools: ["ADD", "MOVE", "DELETE"], + previewColors: { + pointColor: "rgba(234, 88, 12, 0.92)", + lineColor: "rgba(234, 88, 12, 0.95)", + fillColor: "rgba(234, 88, 12, 0.18)", + }, + }, + om_dt_untrt_swg_rain: { + layerName: "중점우수토실", + geometryType: "Point", + allowedTools: ["ADD", "MOVE", "DELETE"], + previewColors: { + pointColor: "rgba(220, 38, 38, 0.92)", + lineColor: "rgba(220, 38, 38, 0.95)", + fillColor: "rgba(220, 38, 38, 0.18)", + }, + }, +}; + +export const FLOW_DIRECTION_SRS = "EPSG:5179"; +export const SCREEN_ID = "SN_MN010301"; +export const GIS_EDIT_ALLOWED_LAYER_IDS = new Set(["tb_ms_swage_ppl"]); +export const GIS_EDIT_PIPE_ATTRIBUTE_FIELD_IDS = [ + "mngInstCd", + "fcltMngNo", + "instMngNo", + "instlYmd", + "mapshtNo", + "pdmtVl", + "prlgVl", + "usgCd", + "mqltCd", + "avgGrdn", + "drtwMltvArea", + "exclnMltvArea", + "tmptDpth", + "tmptMhlMngNo", + "scendDpth", + "scendMhlMngNo", + "trflnCnt", + "vrtcLen", + "wdthLen", + "instlMbdyCd", + "swtrmtZoneNm", + "mltvZoneNm", + "mltvDtdvNm", + "lineCd", + "floCd", + "layCd", +] as const; + +export const buildPipeSaveAttributes = ( + basicInfo: GisEditPipeBasicInfo | null | undefined, +) => { + if (!basicInfo) return undefined; + + return GIS_EDIT_PIPE_ATTRIBUTE_FIELD_IDS.reduce( + (acc, fieldId) => { + if (!Object.prototype.hasOwnProperty.call(basicInfo, fieldId)) return acc; + + const value = basicInfo[fieldId]; + const normalizedValue = + fieldId === "instlYmd" + ? String(value ?? "").replaceAll("-", "") + : value; + acc[fieldId] = + typeof normalizedValue === "string" && normalizedValue.trim() === "" + ? null + : normalizedValue; + return acc; + }, + {}, + ); +}; + +export const EMPTY_GIS_EDIT_PIPE_BASIC_INFO: GisEditPipeBasicInfo = { + mngInstCd: "", + fcltMngNo: "", + instMngNo: "", + instlYmd: "", + usgCd: "", + instlMbdyCd: "", + swtrmtZoneNm: "", + mltvZoneNm: "", + mltvDtdvNm: "", + lineCd: "", + floCd: "", + layCd: "", +}; + +export const normalizeLayerIdCandidate = (value: unknown) => { + const raw = String(value ?? "").trim(); + if (!raw) return ""; + + const withoutNamespace = raw.includes(":") + ? (raw.split(":").pop() ?? raw) + : raw; + return withoutNamespace.split(".")[0] ?? withoutNamespace; +}; + +// 현재 레이어 트리 구조 안에서 실제 편집 대상으로 풀 수 있는 레이어만 추출한다. +export const getLayerTypeName = (layerInfo: any) => + String( + layerInfo?.lyrTyNm ?? layerInfo?.lyrTypeNm ?? layerInfo?.lyr_type_nm ?? "", + ).trim(); + +export const getLayerName = (layerInfo: any) => + String(layerInfo?.lyrNm ?? layerInfo?.name ?? "").trim(); + +export const normalizeGeometryType = (value: unknown): GisEditGeometryType => { + const normalized = String(value ?? "").toLowerCase(); + if (normalized.includes("polygon")) return "Polygon"; + if (normalized.includes("line")) return "LineString"; + return "Point"; +}; + +export const getColorString = (value: unknown) => { + const resolved = String(value ?? "").trim(); + return resolved || null; +}; + +export const withAlpha = (color: string | null | undefined, alpha: number) => { + const resolvedColor = String(color ?? "").trim(); + if (!resolvedColor) return null; + + const rgbaMatch = resolvedColor.match(/^rgba?\(([^)]+)\)$/i); + if (rgbaMatch) { + const [red = "40", green = "90", blue = "180"] = (rgbaMatch[1] ?? "") + .split(",") + .map((part) => part.trim()); + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; + } + + const hexMatch = resolvedColor.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (hexMatch) { + const hex = hexMatch[1] ?? ""; + const normalized = + hex.length === 3 + ? hex + .split("") + .map((char) => char + char) + .join("") + : hex; + const red = parseInt(normalized.slice(0, 2), 16); + const green = parseInt(normalized.slice(2, 4), 16); + const blue = parseInt(normalized.slice(4, 6), 16); + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; + } + + return resolvedColor; +}; + +export const resolvePreviewColors = ( + layerInfo: any, + fallback: GisEditPreviewColors, +): GisEditPreviewColors => { + const lineColor = + withAlpha( + getColorString( + layerInfo?.lineColr ?? + layerInfo?.line_colr ?? + layerInfo?.lineColor ?? + layerInfo?.fontInsdColr ?? + layerInfo?.font_insd_colr, + ), + 0.95, + ) ?? fallback.lineColor; + const pointColor = + withAlpha( + getColorString( + layerInfo?.lineColr ?? + layerInfo?.line_colr ?? + layerInfo?.fontInsdColr ?? + layerInfo?.font_insd_colr ?? + layerInfo?.fontExtnlColr ?? + layerInfo?.font_extnl_colr, + ), + 0.92, + ) ?? fallback.pointColor; + const fillColor = + withAlpha( + getColorString( + layerInfo?.spacColr ?? + layerInfo?.spac_colr ?? + layerInfo?.spaceColor ?? + layerInfo?.lineColr ?? + layerInfo?.line_colr, + ), + 0.22, + ) ?? fallback.fillColor; + + return { + pointColor, + lineColor, + fillColor, + }; +}; + +// 현행 운영 기준상 GIS 편집은 하수관로만 다루므로 treeData에서도 해당 레이어만 추린다. +export const collectEditableLayers = (treeData: any[]): GisEditLayerInfo[] => { + const results = new Map(); + + const visit = (items: any[]) => { + items.forEach((item) => { + const data = item?.data ?? item; + const layerTypeName = getLayerTypeName(data); + const layerRule = EDITABLE_LAYER_RULES[layerTypeName]; + + if ( + layerRule && + GIS_EDIT_ALLOWED_LAYER_IDS.has(layerTypeName) && + !results.has(layerTypeName) + ) { + results.set(layerTypeName, { + layerId: layerTypeName, + layerName: layerRule.layerName, + geometryType: layerRule.geometryType, + editableYn: "Y", + allowedTools: layerRule.allowedTools, + previewColors: resolvePreviewColors(data, layerRule.previewColors), + }); + } + + if (Array.isArray(item?.children)) { + visit(item.children); + } + if (Array.isArray(data?.children)) { + visit(data.children); + } + }); + }; + + visit(Array.isArray(treeData) ? treeData : []); + + if (results.size === 0) { + Object.entries(EDITABLE_LAYER_RULES).forEach(([layerId, layerRule]) => { + if (!GIS_EDIT_ALLOWED_LAYER_IDS.has(layerId)) return; + results.set(layerId, { + layerId, + layerName: layerRule.layerName, + geometryType: layerRule.geometryType, + editableYn: "Y", + allowedTools: layerRule.allowedTools, + previewColors: layerRule.previewColors, + }); + }); + } + + return Array.from(results.values()); +}; diff --git a/apps/sn/src/features/gisEdit/lib/feature-snapshot.ts b/apps/sn/src/features/gisEdit/lib/feature-snapshot.ts new file mode 100644 index 000000000..f71bf9864 --- /dev/null +++ b/apps/sn/src/features/gisEdit/lib/feature-snapshot.ts @@ -0,0 +1,170 @@ +/** 원본 GIS feature와 viewport를 저장 가능한 snapshot으로 변환한다. */ +import GeoJSON from "ol/format/GeoJSON.js"; +import type { GisEditFeatureRef } from "../model/types"; +import { normalizeLayerIdCandidate, FLOW_DIRECTION_SRS } from "./edit-model"; +// 지도에서 선택한 OpenLayers Feature의 현재 geometry를 저장 payload 형태로 스냅샷한다. +export const toGeometryObject = (featureData: any) => { + if (!featureData || typeof featureData.getGeometry !== "function") + return null; + + try { + return new GeoJSON().writeGeometryObject(featureData.getGeometry(), { + featureProjection: FLOW_DIRECTION_SRS, + dataProjection: FLOW_DIRECTION_SRS, + }) as Record; + } catch (error) { + console.error("GIS 편집 geometry 변환 실패:", error); + return null; + } +}; + +// 저장 시점의 지도 범위를 함께 남겨 BE가 편집 세션 컨텍스트를 해석할 수 있게 한다. +export const buildBbox = (map: any) => { + try { + const size = map?.getSize?.(); + const view = map?.getView?.(); + if (!size || !view?.calculateExtent) return null; + return view + .calculateExtent(size) + .map((value: number) => value.toFixed(3)) + .join(","); + } catch (error) { + console.error("GIS 편집 BBOX 계산 실패:", error); + return null; + } +}; + +export const getFeatureLayerId = (featureData: any) => { + if (typeof featureData?.get === "function") { + const previewLayerId = normalizeLayerIdCandidate( + featureData.get("layerId"), + ); + if (previewLayerId) return previewLayerId; + } + if (typeof featureData?.getId === "function") { + return normalizeLayerIdCandidate(featureData.getId()); + } + if (typeof featureData?.id_ === "string") { + return normalizeLayerIdCandidate(featureData.id_); + } + return normalizeLayerIdCandidate( + featureData?.lyrTypeNm ?? featureData?.lyrTyNm ?? "", + ); +}; + +export const stripLayerPrefix = (value: unknown) => { + const raw = String(value ?? "").trim(); + if (!raw) return ""; + + const withoutNamespace = raw.includes(":") + ? (raw.split(":").pop() ?? raw) + : raw; + if (withoutNamespace.startsWith("tb_ms_swage_ppl.")) { + return withoutNamespace.slice("tb_ms_swage_ppl.".length); + } + return withoutNamespace; +}; + +export const getFeatureFid = (featureData: any) => + stripLayerPrefix( + featureData?.get?.("fid") ?? + featureData?.values_?.fid ?? + featureData?.fid ?? + featureData?.getId?.() ?? + featureData?.id_ ?? + "", + ); + +export const getFeatureObjectId = (featureData: any) => + String( + getFeatureFid(featureData) || + String( + featureData?.get?.("objectId") ?? + featureData?.values_?.fclt_mng_no ?? + featureData?.values_?.swtrmt_fclt_cd ?? + featureData?.fcltMngNo ?? + featureData?.fid ?? + "", + ), + ).trim(); + +export const getFeatureObjectLabel = (featureData: any) => { + const values = featureData?.values_ ?? {}; + return String( + featureData?.get?.("fclt_mng_no") ?? + values?.fclt_mng_no ?? + featureData?.fcltMngNo ?? + featureData?.get?.("objectId") ?? + values?.swtrmt_fclt_cd ?? + getFeatureFid(featureData) ?? + "", + ).trim(); +}; + +export const getFeatureRef = ( + featureData: any, +): GisEditFeatureRef | undefined => { + const mapValues = featureData?.values_ ?? {}; + const featureRef: GisEditFeatureRef = { + fcltMngNo: + String( + featureData?.get?.("fclt_mng_no") ?? + mapValues?.fclt_mng_no ?? + featureData?.fcltMngNo ?? + "", + ).trim() || undefined, + mngInstCd: + String( + featureData?.get?.("mng_inst_cd") ?? + mapValues?.mng_inst_cd ?? + featureData?.mngInstCd ?? + "", + ).trim() || undefined, + fid: getFeatureFid(featureData) || undefined, + }; + + if (!featureRef.fcltMngNo && !featureRef.mngInstCd && !featureRef.fid) { + return undefined; + } + + return featureRef; +}; + +// 선택한 원본 관로의 속성 snapshot을 보관해 preview 재선택 시 상세정보가 비지 않게 한다. +export const getFeatureProperties = ( + featureData: any, +): Record | null => { + const values = + featureData?.values_ ?? featureData?.getProperties?.() ?? featureData; + if (!values || typeof values !== "object") return null; + + const { geometry, ...properties } = values; + return properties; +}; + +export const normalizePipeGeometry = ( + layerId: string, + geometry: Record | null, +): Record | null => { + if (layerId !== "tb_ms_swage_ppl" || !geometry) return geometry; + if (geometry.type !== "LineString") return geometry; + + const coordinates = geometry.coordinates; + if (!Array.isArray(coordinates)) return geometry; + + return { + ...geometry, + type: "MultiLineString", + coordinates: [coordinates], + }; +}; + +export const createChangeId = (prefix: string, sequence: number) => { + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { + return `${prefix}-${crypto.randomUUID()}`; + } + return `${prefix}-${Date.now()}-${sequence}`; +}; diff --git a/apps/sn/src/features/gisEdit/lib/geometry-mutations.ts b/apps/sn/src/features/gisEdit/lib/geometry-mutations.ts new file mode 100644 index 000000000..a3c6b5374 --- /dev/null +++ b/apps/sn/src/features/gisEdit/lib/geometry-mutations.ts @@ -0,0 +1,517 @@ +/** 편집 geometry의 이동·꼭지점 삭제·스냅 규칙을 담당한다. */ +import { + type VertexHandleMeta, + type VertexHandleCandidate, + type VertexDeleteResult, + cloneCoordinates, + buildVertexCoordinateKey, + getSquaredPixelDistance, +} from "../model/vertex-handles"; +import { collectVertexHandleCandidates } from "./vertex-handles"; + +export const updateLineCoordinates = ( + coordinates: number[][], + meta: VertexHandleMeta, + nextCoordinate: number[], +) => { + const nextCoordinates = cloneCoordinates(coordinates); + if (meta.kind === "vertex") { + nextCoordinates[meta.coordIndex] = nextCoordinate; + } else { + nextCoordinates.splice(meta.coordIndex + 1, 0, nextCoordinate); + } + return nextCoordinates; +}; + +export const updatePolygonRingCoordinates = ( + coordinates: number[][], + meta: VertexHandleMeta, + nextCoordinate: number[], +) => { + const nextCoordinates = cloneCoordinates(coordinates); + const logicalVertexCount = Math.max(0, nextCoordinates.length - 1); + if (logicalVertexCount === 0) return nextCoordinates; + + if (meta.kind === "vertex") { + nextCoordinates[meta.coordIndex] = nextCoordinate; + if (meta.coordIndex === 0) { + nextCoordinates[logicalVertexCount] = nextCoordinate; + } + } else { + const insertIndex = + meta.coordIndex === logicalVertexCount - 1 + ? logicalVertexCount + : meta.coordIndex + 1; + nextCoordinates.splice(insertIndex, 0, nextCoordinate); + } + + return nextCoordinates; +}; + +export const applyVertexHandleMove = ( + geometry: any, + meta: VertexHandleMeta, + nextCoordinate: number[], +) => { + const geometryType = geometry?.getType?.(); + if (!geometryType) return; + + if (geometryType === "LineString") { + geometry.setCoordinates( + updateLineCoordinates( + geometry.getCoordinates?.() ?? [], + meta, + nextCoordinate, + ), + ); + return; + } + + if (geometryType === "MultiLineString") { + const parts = cloneCoordinates(geometry.getCoordinates?.() ?? []); + const targetPartIndex = meta.partIndex ?? 0; + parts[targetPartIndex] = updateLineCoordinates( + parts[targetPartIndex] ?? [], + meta, + nextCoordinate, + ); + geometry.setCoordinates(parts); + return; + } + + if (geometryType === "Polygon") { + const rings = cloneCoordinates(geometry.getCoordinates?.() ?? []); + const targetRingIndex = meta.ringIndex ?? 0; + rings[targetRingIndex] = updatePolygonRingCoordinates( + rings[targetRingIndex] ?? [], + meta, + nextCoordinate, + ); + geometry.setCoordinates(rings); + return; + } + + if (geometryType === "MultiPolygon") { + const polygons = cloneCoordinates(geometry.getCoordinates?.() ?? []); + const targetPolygonIndex = meta.polygonIndex ?? 0; + const targetRingIndex = meta.ringIndex ?? 0; + polygons[targetPolygonIndex][targetRingIndex] = + updatePolygonRingCoordinates( + polygons[targetPolygonIndex][targetRingIndex] ?? [], + meta, + nextCoordinate, + ); + geometry.setCoordinates(polygons); + } +}; + +export const deleteLineVertices = ( + coordinates: number[][], + coordIndexes: number[], +) => { + const nextCoordinates = cloneCoordinates(coordinates); + const uniqueIndexes = Array.from(new Set(coordIndexes)).sort( + (left, right) => right - left, + ); + if (nextCoordinates.length - uniqueIndexes.length < 2) { + return null; + } + uniqueIndexes.forEach((coordIndex) => { + nextCoordinates.splice(coordIndex, 1); + }); + return nextCoordinates; +}; + +export const deletePolygonVertices = ( + coordinates: number[][], + coordIndexes: number[], +) => { + const logicalVertexCount = Math.max(0, coordinates.length - 1); + const uniqueIndexes = Array.from(new Set(coordIndexes)).sort( + (left, right) => right - left, + ); + if (logicalVertexCount - uniqueIndexes.length < 3) { + return null; + } + + const logicalCoordinates = cloneCoordinates( + coordinates.slice(0, logicalVertexCount), + ); + uniqueIndexes.forEach((coordIndex) => { + logicalCoordinates.splice(coordIndex, 1); + }); + if (logicalCoordinates.length < 3) return null; + + return [...logicalCoordinates, cloneCoordinates(logicalCoordinates[0] ?? [])]; +}; + +export const deleteSelectedVertexHandles = ( + geometry: any, + metaList: VertexHandleMeta[], +): VertexDeleteResult => { + if (!geometry || metaList.length === 0) { + return { ok: false, message: "삭제할 꼭지점 정보를 찾지 못했습니다." }; + } + + if (metaList.some((meta) => meta.kind !== "vertex")) { + return { + ok: false, + message: + "흰색 midpoint는 삭제 대상이 아닙니다. 주황색 실꼭지점을 선택하세요.", + }; + } + + const geometryType = geometry?.getType?.(); + if (!geometryType) { + return { + ok: false, + message: "꼭지점 삭제 대상 geometry를 찾지 못했습니다.", + }; + } + + if (geometryType === "LineString") { + const nextCoordinates = deleteLineVertices( + geometry.getCoordinates?.() ?? [], + metaList.map((meta) => meta.coordIndex), + ); + if (!nextCoordinates) { + return { + ok: false, + message: + "하수관로는 최소 2개의 꼭지점이 필요해 더 이상 삭제할 수 없습니다.", + }; + } + geometry.setCoordinates(nextCoordinates); + return { ok: true }; + } + + if (geometryType === "MultiLineString") { + const nextParts = cloneCoordinates(geometry.getCoordinates?.() ?? []); + const metaGroups = metaList.reduce>( + (acc, meta) => { + const partIndex = meta.partIndex ?? 0; + if (!acc[partIndex]) acc[partIndex] = []; + acc[partIndex]!.push(meta.coordIndex); + return acc; + }, + {}, + ); + + for (const [partIndexRaw, coordIndexes] of Object.entries(metaGroups)) { + const partIndex = Number(partIndexRaw); + const nextCoordinates = deleteLineVertices( + nextParts[partIndex] ?? [], + coordIndexes, + ); + if (!nextCoordinates) { + return { + ok: false, + message: "하수관로는 각 선형마다 최소 2개의 꼭지점이 필요합니다.", + }; + } + nextParts[partIndex] = nextCoordinates; + } + + geometry.setCoordinates(nextParts); + return { ok: true }; + } + + if (geometryType === "Polygon") { + const nextRings = cloneCoordinates(geometry.getCoordinates?.() ?? []); + const metaGroups = metaList.reduce>( + (acc, meta) => { + const ringIndex = meta.ringIndex ?? 0; + if (!acc[ringIndex]) acc[ringIndex] = []; + acc[ringIndex]!.push(meta.coordIndex); + return acc; + }, + {}, + ); + + for (const [ringIndexRaw, coordIndexes] of Object.entries(metaGroups)) { + const ringIndex = Number(ringIndexRaw); + const nextCoordinates = deletePolygonVertices( + nextRings[ringIndex] ?? [], + coordIndexes, + ); + if (!nextCoordinates) { + return { + ok: false, + message: + "폴리곤은 최소 3개의 꼭지점이 필요해 더 이상 삭제할 수 없습니다.", + }; + } + nextRings[ringIndex] = nextCoordinates; + } + + geometry.setCoordinates(nextRings); + return { ok: true }; + } + + if (geometryType === "MultiPolygon") { + const nextPolygons = cloneCoordinates(geometry.getCoordinates?.() ?? []); + const metaGroups = metaList.reduce>( + (acc, meta) => { + const polygonIndex = meta.polygonIndex ?? 0; + const ringIndex = meta.ringIndex ?? 0; + const groupKey = `${polygonIndex}:${ringIndex}`; + if (!acc[groupKey]) acc[groupKey] = []; + acc[groupKey]!.push(meta.coordIndex); + return acc; + }, + {}, + ); + + for (const [groupKey, coordIndexes] of Object.entries(metaGroups)) { + const [polygonIndexText, ringIndexText] = groupKey.split(":"); + const polygonIndex = Number(polygonIndexText); + const ringIndex = Number(ringIndexText); + const nextCoordinates = deletePolygonVertices( + nextPolygons[polygonIndex]?.[ringIndex] ?? [], + coordIndexes, + ); + if (!nextCoordinates) { + return { + ok: false, + message: + "폴리곤은 최소 3개의 꼭지점이 필요해 더 이상 삭제할 수 없습니다.", + }; + } + nextPolygons[polygonIndex][ringIndex] = nextCoordinates; + } + + geometry.setCoordinates(nextPolygons); + return { ok: true }; + } + + return { + ok: false, + message: "현재 geometry 유형은 꼭지점 삭제를 지원하지 않습니다.", + }; +}; + +export const getClosestSnapCoordinateFromSource = ( + source: VectorSource | null, + map: any, + coordinate: number[], + tolerancePx = 18, +) => { + if (!source || !Array.isArray(coordinate)) return null; + const nearestFeature = source.getClosestFeatureToCoordinate?.(coordinate); + const nearestGeometry = nearestFeature?.getGeometry?.(); + if (!nearestGeometry?.getClosestPoint) return null; + + const closestCoordinate = nearestGeometry.getClosestPoint(coordinate); + if (!Array.isArray(closestCoordinate)) return null; + + const distance = Math.sqrt( + getSquaredPixelDistance(map, coordinate, closestCoordinate), + ); + if (!Number.isFinite(distance) || distance > tolerancePx) return null; + + return { + coordinate: closestCoordinate as number[], + distance, + }; +}; + +export const applyGeometryVertexSnaps = ( + geometry: any, + source: VectorSource | null, + map: any, + tolerancePx = 18, +) => { + if (!geometry || !source || !map) return false; + + const vertexCandidates = collectVertexHandleCandidates(geometry).filter( + (candidate: VertexHandleCandidate) => candidate.meta.kind === "vertex", + ); + let changed = false; + + vertexCandidates.forEach((candidate: VertexHandleCandidate) => { + const snapResult = getClosestSnapCoordinateFromSource( + source, + map, + candidate.coordinate, + tolerancePx, + ); + if (!snapResult) return; + + const currentKey = buildVertexCoordinateKey(candidate.coordinate); + const nextKey = buildVertexCoordinateKey(snapResult.coordinate); + if (currentKey === nextKey) return; + + applyVertexHandleMove(geometry, candidate.meta, snapResult.coordinate); + changed = true; + }); + + return changed; +}; + +export const applyGeometryMoveSnap = ( + geometry: any, + source: VectorSource | null, + map: any, + anchorCoordinate?: number[] | null, + tolerancePx = 18, +) => { + if (!geometry || !source || !map || typeof geometry.translate !== "function") + return false; + + if (Array.isArray(anchorCoordinate)) { + const snapResult = getClosestSnapCoordinateFromSource( + source, + map, + anchorCoordinate, + tolerancePx, + ); + if (snapResult) { + const deltaX = + Number(snapResult.coordinate[0] ?? 0) - + Number(anchorCoordinate[0] ?? 0); + const deltaY = + Number(snapResult.coordinate[1] ?? 0) - + Number(anchorCoordinate[1] ?? 0); + if (deltaX !== 0 || deltaY !== 0) { + geometry.translate(deltaX, deltaY); + return true; + } + } + } + + const vertexCandidates = collectVertexHandleCandidates(geometry).filter( + (candidate: VertexHandleCandidate) => candidate.meta.kind === "vertex", + ); + type GeometryMoveSnapCandidate = { + original: number[]; + snapped: number[]; + distance: number; + }; + let bestCandidate: GeometryMoveSnapCandidate | null = null; + + vertexCandidates.forEach((candidate: VertexHandleCandidate) => { + const snapResult = getClosestSnapCoordinateFromSource( + source, + map, + candidate.coordinate, + tolerancePx, + ); + if (!snapResult) return; + + if (!bestCandidate || snapResult.distance < bestCandidate.distance) { + bestCandidate = { + original: candidate.coordinate, + snapped: snapResult.coordinate, + distance: snapResult.distance, + }; + } + }); + + if (!bestCandidate) return false; + const resolvedBestCandidate = bestCandidate as GeometryMoveSnapCandidate; + + const deltaX = + Number(resolvedBestCandidate.snapped[0] ?? 0) - + Number(resolvedBestCandidate.original[0] ?? 0); + const deltaY = + Number(resolvedBestCandidate.snapped[1] ?? 0) - + Number(resolvedBestCandidate.original[1] ?? 0); + if (deltaX === 0 && deltaY === 0) return false; + + geometry.translate(deltaX, deltaY); + return true; +}; + +export const getClosestGeometryVertexCoordinate = ( + geometry: any, + map: any, + coordinate: number[] | null, +) => { + if (!geometry || !map || !Array.isArray(coordinate)) return null; + + const vertexCandidates = collectVertexHandleCandidates(geometry).filter( + (candidate: VertexHandleCandidate) => candidate.meta.kind === "vertex", + ); + let bestCandidate: VertexHandleCandidate | null = null; + let minimumDistance = Number.POSITIVE_INFINITY; + + vertexCandidates.forEach((candidate: VertexHandleCandidate) => { + const distance = getSquaredPixelDistance( + map, + coordinate, + candidate.coordinate, + ); + if (distance < minimumDistance) { + minimumDistance = distance; + bestCandidate = candidate; + } + }); + + return (bestCandidate as VertexHandleCandidate | null)?.coordinate ?? null; +}; + +export const getClosestGeometryVertexMeta = ( + geometry: any, + map: any, + coordinate: number[] | null, +) => { + if (!geometry || !map || !Array.isArray(coordinate)) return null; + + const vertexCandidates = collectVertexHandleCandidates(geometry).filter( + (candidate: VertexHandleCandidate) => candidate.meta.kind === "vertex", + ); + let bestCandidate: VertexHandleCandidate | null = null; + let minimumDistance = Number.POSITIVE_INFINITY; + + vertexCandidates.forEach((candidate: VertexHandleCandidate) => { + const distance = getSquaredPixelDistance( + map, + coordinate, + candidate.coordinate, + ); + if (distance < minimumDistance) { + minimumDistance = distance; + bestCandidate = candidate; + } + }); + + return (bestCandidate as VertexHandleCandidate | null)?.meta ?? null; +}; + +export const getGeometryVertexCoordinateByMeta = ( + geometry: any, + meta: VertexHandleMeta | null, +) => { + if (!geometry || !meta) return null; + const geometryType = geometry?.getType?.(); + if (!geometryType) return null; + + if (geometryType === "LineString") { + return geometry.getCoordinates?.()?.[meta.coordIndex] ?? null; + } + + if (geometryType === "MultiLineString") { + return ( + geometry.getCoordinates?.()?.[meta.partIndex ?? 0]?.[meta.coordIndex] ?? + null + ); + } + + if (geometryType === "Polygon") { + return ( + geometry.getCoordinates?.()?.[meta.ringIndex ?? 0]?.[meta.coordIndex] ?? + null + ); + } + + if (geometryType === "MultiPolygon") { + return ( + geometry.getCoordinates?.()?.[meta.polygonIndex ?? 0]?.[ + meta.ringIndex ?? 0 + ]?.[meta.coordIndex] ?? null + ); + } + + return null; +}; +import type VectorSource from "ol/source/Vector.js"; diff --git a/apps/sn/src/features/gisEdit/lib/preview-styles.ts b/apps/sn/src/features/gisEdit/lib/preview-styles.ts new file mode 100644 index 000000000..82b93a0e3 --- /dev/null +++ b/apps/sn/src/features/gisEdit/lib/preview-styles.ts @@ -0,0 +1,255 @@ +/** GIS 편집 미리보기와 꼭지점 표시 스타일을 지도 상태에서 분리한다. */ +import Fill from "ol/style/Fill.js"; +import Point from "ol/geom/Point.js"; +import CircleStyle from "ol/style/Circle.js"; +import Stroke from "ol/style/Stroke.js"; +import Style from "ol/style/Style.js"; +import Text from "ol/style/Text.js"; +import type { VertexHandleMeta } from "../model/vertex-handles"; + +export const getGeometryExtentCenterPoint = (geometry: any) => { + const extent = geometry?.getExtent?.(); + if (!extent || extent.length < 4) return null; + + const [minX = 0, minY = 0, maxX = 0, maxY = 0] = extent; + return new Point([(minX + maxX) / 2, (minY + maxY) / 2]); +}; + +// MultiPolygon/MultiLineString preview 라벨이 각 조각마다 반복되지 않도록 대표 지점 1곳에만 텍스트를 배치한다. +export const getGisEditPreviewLabelGeometry = (geometry: any) => { + const geometryType = geometry?.getType?.(); + if (!geometryType) return null; + + if (geometryType === "Point") { + return geometry; + } + + if (geometryType === "Polygon") { + return ( + geometry.getInteriorPoint?.() ?? getGeometryExtentCenterPoint(geometry) + ); + } + + if (geometryType === "MultiPolygon") { + const polygons = geometry.getPolygons?.() ?? []; + if (polygons.length > 0) { + const representativePolygon = polygons.reduce( + (largest: any, current: any) => + (current?.getArea?.() ?? 0) > (largest?.getArea?.() ?? 0) + ? current + : largest, + polygons[0], + ); + return ( + representativePolygon?.getInteriorPoint?.() ?? + getGeometryExtentCenterPoint(representativePolygon) + ); + } + return getGeometryExtentCenterPoint(geometry); + } + + return getGeometryExtentCenterPoint(geometry); +}; + +export const createGisEditPreviewStyle = (feature: any) => { + const editType = String(feature.get("editType") ?? ""); + const geometry = feature.getGeometry?.(); + const geometryType = geometry?.getType?.(); + const previewLabel = String(feature.get("previewLabel") ?? "").trim(); + const isInteractionTarget = Boolean(feature.get("isInteractionTarget")); + const isDragging = Boolean(feature.get("isDragging")); + const layerPointColor = String(feature.get("layerPointColor") ?? "").trim(); + const layerLineColor = String(feature.get("layerLineColor") ?? "").trim(); + const layerFillColor = String(feature.get("layerFillColor") ?? "").trim(); + const pointColor = + editType === "DELETE" + ? "rgba(220, 38, 38, 0.85)" + : editType === "VERTEX_EDIT" + ? "rgba(245, 158, 11, 0.92)" + : editType === "MOVE" + ? layerPointColor || "rgba(37, 99, 235, 0.92)" + : editType === "ADD" + ? "rgba(15, 23, 42, 0.96)" + : layerPointColor || "rgba(22, 163, 74, 0.92)"; + const lineColor = + editType === "DELETE" + ? "rgba(220, 38, 38, 0.9)" + : editType === "VERTEX_EDIT" + ? "rgba(245, 158, 11, 0.95)" + : editType === "MOVE" + ? layerLineColor || "rgba(37, 99, 235, 0.95)" + : editType === "ADD" + ? "rgba(15, 23, 42, 0.96)" + : layerLineColor || "rgba(22, 163, 74, 0.95)"; + const fillColor = + editType === "DELETE" + ? "rgba(220, 38, 38, 0.18)" + : editType === "VERTEX_EDIT" + ? "rgba(245, 158, 11, 0.2)" + : editType === "MOVE" + ? layerFillColor || "rgba(37, 99, 235, 0.18)" + : editType === "ADD" + ? "rgba(15, 23, 42, 0.2)" + : layerFillColor || "rgba(22, 163, 74, 0.18)"; + const highlightColor = isDragging + ? "rgba(14, 116, 144, 0.98)" + : isInteractionTarget + ? "rgba(15, 23, 42, 0.9)" + : lineColor; + const emphasisWidth = isDragging + ? 7 + : isInteractionTarget + ? 6 + : editType === "ADD" + ? 6 + : 4; + const textStyle = previewLabel + ? new Text({ + text: previewLabel, + font: "600 12px sans-serif", + fill: new Fill({ color: "#ffffff" }), + backgroundFill: new Fill({ color: highlightColor }), + padding: [3, 6, 3, 6], + offsetY: geometryType === "Point" ? -18 : -12, + overflow: true, + }) + : undefined; + const labelGeometry = previewLabel + ? getGisEditPreviewLabelGeometry(geometry) + : null; + + if (geometryType === "Point") { + return new Style({ + image: new CircleStyle({ + radius: isInteractionTarget ? 10 : 8, + fill: new Fill({ color: pointColor }), + stroke: new Stroke({ + color: isDragging ? "rgba(255,255,255,1)" : "rgba(255,255,255,0.95)", + width: isInteractionTarget ? 3 : 2, + }), + }), + text: textStyle, + zIndex: 10001, + }); + } + + if (geometryType === "Polygon") { + const polygonStyles: Style[] = [ + new Style({ + fill: new Fill({ + color: + isInteractionTarget && editType !== "DELETE" + ? fillColor.replace(/0\.\d+\)$/, "0.28)") + : fillColor, + }), + stroke: new Stroke({ + color: highlightColor, + width: emphasisWidth, + lineDash: editType === "DELETE" ? [8, 6] : undefined, + }), + zIndex: 10001, + }), + ]; + + if (textStyle && labelGeometry) { + polygonStyles.push( + new Style({ + geometry: labelGeometry, + text: textStyle, + zIndex: 10002, + }), + ); + } + + return polygonStyles; + } + + const lineStyles: Style[] = + editType === "ADD" + ? [ + new Style({ + stroke: new Stroke({ + color: "rgba(255, 255, 255, 0.98)", + width: emphasisWidth + 4, + }), + zIndex: 10000, + }), + new Style({ + stroke: new Stroke({ + color: highlightColor, + width: emphasisWidth, + }), + zIndex: 10003, + }), + ] + : [ + new Style({ + stroke: new Stroke({ + color: highlightColor, + width: emphasisWidth, + lineDash: editType === "DELETE" ? [10, 6] : undefined, + }), + zIndex: 10001, + }), + ]; + + if (textStyle && labelGeometry) { + lineStyles.push( + new Style({ + geometry: labelGeometry, + text: textStyle, + zIndex: 10002, + }), + ); + } + + return lineStyles; +}; + +export const GIS_EDIT_VERTEX_HANDLE_STYLE = new Style({ + image: new CircleStyle({ + radius: 10, + fill: new Fill({ color: "rgba(245, 158, 11, 0.98)" }), + stroke: new Stroke({ color: "rgba(255,255,255,0.98)", width: 3 }), + }), + zIndex: 10005, +}); + +export const GIS_EDIT_VERTEX_MIDPOINT_STYLE = new Style({ + image: new CircleStyle({ + radius: 6, + fill: new Fill({ color: "rgba(255, 255, 255, 0.98)" }), + stroke: new Stroke({ color: "rgba(245, 158, 11, 0.98)", width: 2.5 }), + }), + zIndex: 10004, +}); + +export const GIS_EDIT_VERTEX_DRAGGING_STYLE = new Style({ + image: new CircleStyle({ + radius: 11, + fill: new Fill({ color: "rgba(59, 130, 246, 0.95)" }), + stroke: new Stroke({ color: "rgba(255,255,255,0.98)", width: 3 }), + }), + zIndex: 10006, +}); + +export const GIS_EDIT_VERTEX_SELECTED_STYLE = new Style({ + image: new CircleStyle({ + radius: 11, + fill: new Fill({ color: "rgba(217, 119, 6, 0.98)" }), + stroke: new Stroke({ color: "rgba(15, 23, 42, 0.95)", width: 3 }), + }), + zIndex: 10007, +}); + +export const getGisEditVertexHandleStyle = ( + kind: VertexHandleMeta["kind"], + isSelected = false, +) => { + if (isSelected && kind === "vertex") { + return GIS_EDIT_VERTEX_SELECTED_STYLE; + } + return kind === "midpoint" + ? GIS_EDIT_VERTEX_MIDPOINT_STYLE + : GIS_EDIT_VERTEX_HANDLE_STYLE; +}; diff --git a/apps/sn/src/features/gisEdit/lib/vertex-handles.ts b/apps/sn/src/features/gisEdit/lib/vertex-handles.ts new file mode 100644 index 000000000..7f9922a59 --- /dev/null +++ b/apps/sn/src/features/gisEdit/lib/vertex-handles.ts @@ -0,0 +1,386 @@ +/** 꼭지점·중간점 후보와 OpenLayers 핸들 feature 생성을 담당한다. */ +import Feature from "ol/Feature.js"; +import Point from "ol/geom/Point.js"; +import { getGisEditVertexHandleStyle } from "./preview-styles"; +import { + type VertexHandleMeta, + type VertexHandleCandidate, + getMidpoint, + buildVertexCoordinateKey, + buildVertexHandleSelectionKey, + getSquaredPixelDistance, + GIS_EDIT_VERTEX_CLUSTER_PIXEL_THRESHOLD, + getAverageCoordinate, + getPixelDistanceToSegment, +} from "../model/vertex-handles"; + +export const createVertexHandleFeature = ( + coordinate: number[], + meta: VertexHandleMeta, + metaList: VertexHandleMeta[] = [meta], +) => { + const handleFeature = new Feature({ + geometry: new Point(coordinate), + }); + handleFeature.set("handleKind", meta.kind); + handleFeature.set("handleMeta", meta); + handleFeature.set("handleMetaList", metaList); + handleFeature.set( + "handleSelectionKey", + buildVertexHandleSelectionKey(metaList), + ); + handleFeature.setStyle(getGisEditVertexHandleStyle(meta.kind)); + return handleFeature; +}; + +export const collectLineVertexHandleCandidates = ( + coordinates: number[][], + geometryType: string, + partIndex?: number, +) => { + const candidates: VertexHandleCandidate[] = []; + coordinates.forEach((coordinate, coordIndex) => { + candidates.push({ + coordinate, + meta: { + kind: "vertex", + geometryType, + partIndex, + coordIndex, + }, + }); + }); + return candidates; +}; + +export const collectPolygonRingVertexHandleCandidates = ( + coordinates: number[][], + geometryType: string, + polygonIndex?: number, + ringIndex?: number, +) => { + const candidates: VertexHandleCandidate[] = []; + const logicalVertexCount = Math.max(0, coordinates.length - 1); + for (let coordIndex = 0; coordIndex < logicalVertexCount; coordIndex += 1) { + const coordinate = coordinates[coordIndex]; + const nextCoordinate = + coordinates[(coordIndex + 1) % logicalVertexCount] ?? coordinate; + if (!coordinate || !nextCoordinate) continue; + candidates.push({ + coordinate, + meta: { + kind: "vertex", + geometryType, + polygonIndex, + ringIndex, + coordIndex, + }, + }); + } + return candidates; +}; + +export const collectVertexHandleCandidates = (geometry: any) => { + const geometryType = geometry?.getType?.(); + if (!geometryType) return []; + + if (geometryType === "LineString") { + return collectLineVertexHandleCandidates( + geometry.getCoordinates?.() ?? [], + geometryType, + ); + } + + if (geometryType === "MultiLineString") { + const parts = geometry.getCoordinates?.() ?? []; + return parts.flatMap((part: number[][], partIndex: number) => + collectLineVertexHandleCandidates(part, geometryType, partIndex), + ); + } + + if (geometryType === "Polygon") { + const rings = geometry.getCoordinates?.() ?? []; + return rings.flatMap((ring: number[][], ringIndex: number) => + collectPolygonRingVertexHandleCandidates( + ring, + geometryType, + undefined, + ringIndex, + ), + ); + } + + if (geometryType === "MultiPolygon") { + const polygons = geometry.getCoordinates?.() ?? []; + return polygons.flatMap((polygon: number[][][], polygonIndex: number) => + polygon.flatMap((ring: number[][], ringIndex: number) => + collectPolygonRingVertexHandleCandidates( + ring, + geometryType, + polygonIndex, + ringIndex, + ), + ), + ); + } + + return []; +}; + +export const buildVertexHandleFeatures = ( + geometry: any, + map?: any, +): Feature[] => { + const groupedCandidates: Array<{ + coordinate: number[]; + metaList: VertexHandleMeta[]; + sourceCoordinates: number[][]; + }> = []; + + collectVertexHandleCandidates(geometry).forEach( + (candidate: VertexHandleCandidate) => { + const existing = groupedCandidates.find((group) => { + if (!map) { + return ( + buildVertexCoordinateKey(group.coordinate) === + buildVertexCoordinateKey(candidate.coordinate) + ); + } + + return ( + getSquaredPixelDistance( + map, + group.coordinate, + candidate.coordinate, + ) <= + GIS_EDIT_VERTEX_CLUSTER_PIXEL_THRESHOLD ** 2 + ); + }); + + if (existing) { + existing.metaList.push(candidate.meta); + existing.sourceCoordinates.push(candidate.coordinate); + existing.coordinate = getAverageCoordinate(existing.sourceCoordinates); + return; + } + + groupedCandidates.push({ + coordinate: candidate.coordinate, + metaList: [candidate.meta], + sourceCoordinates: [candidate.coordinate], + }); + }, + ); + + return groupedCandidates.map(({ coordinate, metaList }) => + createVertexHandleFeature(coordinate, metaList[0]!, metaList), + ); +}; + +export const collectLineMidpointCandidates = ( + coordinates: number[][], + geometryType: string, + partIndex?: number, +) => { + const candidates: VertexHandleCandidate[] = []; + for ( + let coordIndex = 0; + coordIndex < coordinates.length - 1; + coordIndex += 1 + ) { + const startCoordinate = coordinates[coordIndex]; + const endCoordinate = coordinates[coordIndex + 1]; + if (!startCoordinate || !endCoordinate) continue; + candidates.push({ + coordinate: getMidpoint(startCoordinate, endCoordinate), + meta: { + kind: "midpoint", + geometryType, + partIndex, + coordIndex, + }, + }); + } + return candidates; +}; + +export const collectPolygonRingMidpointCandidates = ( + coordinates: number[][], + geometryType: string, + polygonIndex?: number, + ringIndex?: number, +) => { + const candidates: VertexHandleCandidate[] = []; + const logicalVertexCount = Math.max(0, coordinates.length - 1); + for (let coordIndex = 0; coordIndex < logicalVertexCount; coordIndex += 1) { + const startCoordinate = coordinates[coordIndex]; + const endCoordinate = coordinates[(coordIndex + 1) % logicalVertexCount]; + if (!startCoordinate || !endCoordinate) continue; + candidates.push({ + coordinate: getMidpoint(startCoordinate, endCoordinate), + meta: { + kind: "midpoint", + geometryType, + polygonIndex, + ringIndex, + coordIndex, + }, + }); + } + return candidates; +}; + +export const collectMidpointCandidates = (geometry: any) => { + const geometryType = geometry?.getType?.(); + if (!geometryType) return []; + + if (geometryType === "LineString") { + return collectLineMidpointCandidates( + geometry.getCoordinates?.() ?? [], + geometryType, + ); + } + + if (geometryType === "MultiLineString") { + const parts = geometry.getCoordinates?.() ?? []; + return parts.flatMap((part: number[][], partIndex: number) => + collectLineMidpointCandidates(part, geometryType, partIndex), + ); + } + + if (geometryType === "Polygon") { + const rings = geometry.getCoordinates?.() ?? []; + return rings.flatMap((ring: number[][], ringIndex: number) => + collectPolygonRingMidpointCandidates( + ring, + geometryType, + undefined, + ringIndex, + ), + ); + } + + if (geometryType === "MultiPolygon") { + const polygons = geometry.getCoordinates?.() ?? []; + return polygons.flatMap((polygon: number[][][], polygonIndex: number) => + polygon.flatMap((ring: number[][], ringIndex: number) => + collectPolygonRingMidpointCandidates( + ring, + geometryType, + polygonIndex, + ringIndex, + ), + ), + ); + } + + return []; +}; + +export const getNearestVertexPixelDistance = ( + map: any, + geometry: any, + coordinate: number[], +): number => { + return buildVertexHandleFeatures(geometry, map).reduce( + (minimumDistance: number, feature: Feature) => { + const vertexCoordinate = ( + feature.getGeometry?.() as any + )?.getCoordinates?.(); + if (!Array.isArray(vertexCoordinate)) return minimumDistance; + return Math.min( + minimumDistance, + Math.sqrt(getSquaredPixelDistance(map, coordinate, vertexCoordinate)), + ); + }, + Number.POSITIVE_INFINITY, + ); +}; + +export const getSegmentCoordinatesFromMeta = ( + geometry: any, + meta: VertexHandleMeta, +): [number[], number[]] | null => { + const geometryType = geometry?.getType?.(); + if (!geometryType) return null; + + if (geometryType === "LineString") { + const coordinates = geometry.getCoordinates?.() ?? []; + return [coordinates[meta.coordIndex], coordinates[meta.coordIndex + 1]]; + } + + if (geometryType === "MultiLineString") { + const parts = geometry.getCoordinates?.() ?? []; + const targetPart = parts[meta.partIndex ?? 0] ?? []; + return [targetPart[meta.coordIndex], targetPart[meta.coordIndex + 1]]; + } + + if (geometryType === "Polygon") { + const rings = geometry.getCoordinates?.() ?? []; + const targetRing = rings[meta.ringIndex ?? 0] ?? []; + const logicalVertexCount = Math.max(0, targetRing.length - 1); + if (logicalVertexCount === 0) return null; + return [ + targetRing[meta.coordIndex], + targetRing[(meta.coordIndex + 1) % logicalVertexCount], + ]; + } + + if (geometryType === "MultiPolygon") { + const polygons = geometry.getCoordinates?.() ?? []; + const targetPolygon = polygons[meta.polygonIndex ?? 0] ?? []; + const targetRing = targetPolygon[meta.ringIndex ?? 0] ?? []; + const logicalVertexCount = Math.max(0, targetRing.length - 1); + if (logicalVertexCount === 0) return null; + return [ + targetRing[meta.coordIndex], + targetRing[(meta.coordIndex + 1) % logicalVertexCount], + ]; + } + + return null; +}; + +export const findHoveredMidpointCandidate = ( + map: any, + geometry: any, + coordinate: number[], + tolerancePx = 18, +): VertexHandleCandidate | null => { + const nearestVertexDistance = getNearestVertexPixelDistance( + map, + geometry, + coordinate, + ); + if (nearestVertexDistance <= 14) { + return null; + } + + const midpointCandidates = collectMidpointCandidates(geometry); + let bestCandidate: VertexHandleCandidate | null = null; + let minimumDistance = Number.POSITIVE_INFINITY; + + midpointCandidates.forEach((candidate: VertexHandleCandidate) => { + const segmentCoordinates = getSegmentCoordinatesFromMeta( + geometry, + candidate.meta, + ); + if (!segmentCoordinates) return; + const [startCoordinate, endCoordinate] = segmentCoordinates; + if (!Array.isArray(startCoordinate) || !Array.isArray(endCoordinate)) + return; + + const segmentDistance = getPixelDistanceToSegment( + map, + coordinate, + startCoordinate, + endCoordinate, + ); + if (segmentDistance < minimumDistance) { + minimumDistance = segmentDistance; + bestCandidate = candidate; + } + }); + + return minimumDistance <= tolerancePx ? bestCandidate : null; +}; diff --git a/apps/sn/src/features/gisEdit/model/types.ts b/apps/sn/src/features/gisEdit/model/types.ts new file mode 100644 index 000000000..ed6021e94 --- /dev/null +++ b/apps/sn/src/features/gisEdit/model/types.ts @@ -0,0 +1,112 @@ +/** 하수관로 GIS 편집에서 사용하는 도구·세션·변경 데이터 타입을 정의한다. */ +export type GisEditTool = "ADD" | "MOVE" | "VERTEX_EDIT" | "DELETE"; + +export type GisEditGeometryType = "Point" | "LineString" | "Polygon"; + +export type GisEditPreviewColors = { + pointColor: string; + lineColor: string; + fillColor: string; +}; + +export type GisEditFeatureRef = { + fcltMngNo?: string; + mngInstCd?: string; + fid?: string; +}; + +export type GisEditPipeBasicInfo = { + [key: string]: string | null | undefined; + fcltMngNo?: string | null; + mngInstCd?: string | null; + instMngNo?: string | null; + instlYmd?: string | null; + mapshtNo?: string | null; + pdmtVl?: string | null; + prlgVl?: string | null; + usgCd?: string | null; + mqltCd?: string | null; + avgGrdn?: string | null; + drtwMltvArea?: string | null; + exclnMltvArea?: string | null; + tmptDpth?: string | null; + tmptMhlMngNo?: string | null; + scendDpth?: string | null; + scendMhlMngNo?: string | null; + trflnCnt?: string | null; + vrtcLen?: string | null; + wdthLen?: string | null; + instlMbdyCd?: string | null; + swtrmtZoneNm?: string | null; + mltvZoneNm?: string | null; + mltvDtdvNm?: string | null; + lineCd?: string | null; + floCd?: string | null; + layCd?: string | null; +}; + +export type GisEditLayerInfo = { + layerId: string; + layerName: string; + geometryType: GisEditGeometryType; + editableYn: "Y" | "N"; + allowedTools: GisEditTool[]; + previewColors: GisEditPreviewColors; + disabledReason?: string; +}; + +export type GisEditChange = { + changeId: string; + editType: GisEditTool; + layerId: string; + layerName: string; + objectId?: string; + fid?: string; + baseEditSn?: string; + featureRef?: GisEditFeatureRef; + objectLabel: string; + beforeGeometry?: Record | null; + afterGeometry?: Record | null; + featureProperties?: Record | null; + basicInfo?: GisEditPipeBasicInfo | null; + basicInfoSavedAt?: string | null; + attributeOnly?: boolean; + summary: string; + createdAt: string; + source: "MAP_SELECTION" | "MAP_CENTER" | "MAP_DRAW"; +}; + +export type GisEditSavePayload = { + screenId: string; + layerId: string; + layerName: string; + saveTargetType: "VERSION" | "FINAL"; + view: { + srs: string; + bbox: string | null; + }; + changes: Array<{ + changeId: string; + editType: GisEditTool; + objectId?: string; + clientTempId?: string; + fid?: string; + baseEditSn?: string; + featureRef?: GisEditFeatureRef; + attributes?: GisEditPipeBasicInfo | null; + beforeGeometry?: Record | null; + afterGeometry?: Record | null; + geometry?: Record | null; + }>; +}; + +export type GisEditSessionSnapshot = { + isEditMode: boolean; + selectedLayerName: string; + activeTool: GisEditTool | null; + isSnapEnabled: boolean; + changes: GisEditChange[]; + lastSavedPayload: GisEditSavePayload | null; + canUndo: boolean; + focusedChangeId: string | null; +}; diff --git a/apps/sn/src/features/gisEdit/model/vertex-handles.ts b/apps/sn/src/features/gisEdit/model/vertex-handles.ts new file mode 100644 index 000000000..7e94e013d --- /dev/null +++ b/apps/sn/src/features/gisEdit/model/vertex-handles.ts @@ -0,0 +1,101 @@ +/** 꼭지점 메타와 좌표·픽셀 연산의 공통 모델이다. */ +export type VertexHandleMeta = { + kind: "vertex" | "midpoint"; + geometryType: string; + partIndex?: number; + polygonIndex?: number; + ringIndex?: number; + coordIndex: number; +}; + +export type VertexHandleCandidate = { + coordinate: number[]; + meta: VertexHandleMeta; +}; + +export type VertexDeleteResult = { + ok: boolean; + message?: string; +}; + +export const cloneCoordinates = (value: T): T => + JSON.parse(JSON.stringify(value)) as T; + +export const getMidpoint = (start: number[], end: number[]) => [ + (Number(start?.[0] ?? 0) + Number(end?.[0] ?? 0)) / 2, + (Number(start?.[1] ?? 0) + Number(end?.[1] ?? 0)) / 2, +]; + +export const buildVertexCoordinateKey = (coordinate: number[]) => + `${Number(coordinate?.[0] ?? 0)}:${Number(coordinate?.[1] ?? 0)}`; + +export const buildVertexHandleMetaKey = (meta: VertexHandleMeta) => + [ + meta.kind, + meta.geometryType, + meta.partIndex ?? -1, + meta.polygonIndex ?? -1, + meta.ringIndex ?? -1, + meta.coordIndex, + ].join(":"); + +export const buildVertexHandleSelectionKey = (metaList: VertexHandleMeta[]) => + metaList.map(buildVertexHandleMetaKey).sort().join("|"); + +export const getSquaredPixelDistance = ( + map: any, + startCoordinate: number[], + endCoordinate: number[], +) => { + const [startX = 0, startY = 0] = + map?.getPixelFromCoordinate?.(startCoordinate) ?? []; + const [endX = 0, endY = 0] = + map?.getPixelFromCoordinate?.(endCoordinate) ?? []; + return (endX - startX) ** 2 + (endY - startY) ** 2; +}; + +export const GIS_EDIT_VERTEX_CLUSTER_PIXEL_THRESHOLD = 12; + +export const getAverageCoordinate = (coordinates: number[][]) => { + if (coordinates.length === 0) return [0, 0]; + + const [sumX, sumY] = coordinates.reduce<[number, number]>( + ([accX, accY], [x = 0, y = 0]) => [accX + Number(x), accY + Number(y)], + [0, 0], + ); + + return [sumX / coordinates.length, sumY / coordinates.length]; +}; + +export const getPixelDistanceToSegment = ( + map: any, + coordinate: number[], + startCoordinate: number[], + endCoordinate: number[], +) => { + const [pointX = 0, pointY = 0] = + map?.getPixelFromCoordinate?.(coordinate) ?? []; + const [startX = 0, startY = 0] = + map?.getPixelFromCoordinate?.(startCoordinate) ?? []; + const [endX = 0, endY = 0] = + map?.getPixelFromCoordinate?.(endCoordinate) ?? []; + const deltaX = endX - startX; + const deltaY = endY - startY; + const segmentLengthSquared = deltaX ** 2 + deltaY ** 2; + + if (segmentLengthSquared === 0) { + return Math.hypot(pointX - startX, pointY - startY); + } + + const ratio = Math.max( + 0, + Math.min( + 1, + ((pointX - startX) * deltaX + (pointY - startY) * deltaY) / + segmentLengthSquared, + ), + ); + const projectedX = startX + deltaX * ratio; + const projectedY = startY + deltaY * ratio; + return Math.hypot(pointX - projectedX, pointY - projectedY); +}; diff --git a/apps/sn/src/features/gisManagement/api/gis-download.ts b/apps/sn/src/features/gisManagement/api/gis-download.ts new file mode 100644 index 000000000..ccaf6db4d --- /dev/null +++ b/apps/sn/src/features/gisManagement/api/gis-download.ts @@ -0,0 +1,94 @@ +import type { + DownloadJob, + DownloadJobStatusListener, +} from "../model/upload-types"; +type DownloadApi = { + getData: (url: string) => Promise; + postRawJson: (url: string, data: object) => Promise; + getBlobDataWithHeaders: ( + url: string, + ) => Promise<{ blob: Blob; fileName?: string; headers: Headers }>; +}; +const DOWNLOAD_NETWORK_RETRY_DELAY_MS = 500; +const DOWNLOAD_JOB_POLL_INTERVAL_MS = 1_000; +const DOWNLOAD_JOB_MAX_WAIT_MS = 30 * 60 * 1_000; + +/** 전송 함수를 주입받아 다운로드 접수·상태 polling·연결 재시도 책임을 소유한다. */ +export const createGisDownloadClient = ({ + getData, + postRawJson, + getBlobDataWithHeaders, +}: DownloadApi) => { + // 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); + } + }; + + // 파일 생성 요청과 상태 조회를 짧은 HTTP 요청으로 분리해 서버의 10초 요청 제한을 넘지 않는다. + const startDownloadJob = async (url: string): Promise => { + const response = (await postRawJson(url, {})) as { data?: unknown } | null; + const job = (response?.data ?? response) as DownloadJob | null; + if (!job?.jobId) { + throw new Error("다운로드 작업 접수 정보를 확인하지 못했습니다."); + } + return job; + }; + + const waitForDownloadJob = async ( + jobId: string, + onStatus?: DownloadJobStatusListener, + ): Promise => { + const startedAt = Date.now(); + while (Date.now() - startedAt < DOWNLOAD_JOB_MAX_WAIT_MS) { + const job = (await getData( + `v1/map/gisDownloadJobs/${encodeURIComponent(jobId)}`, + )) as DownloadJob; + onStatus?.(job); + if (job?.status === "READY") return job; + if (job?.status === "FAILED") { + throw new Error( + job.message || "GeoPackage 다운로드 파일 생성에 실패했습니다.", + ); + } + const retryAfter = Math.max( + Number(job?.retryAfterMillis) || DOWNLOAD_JOB_POLL_INTERVAL_MS, + DOWNLOAD_JOB_POLL_INTERVAL_MS, + ); + await new Promise((resolve) => window.setTimeout(resolve, retryAfter)); + } + throw new Error( + "GeoPackage 파일 생성 대기 시간이 초과되었습니다. 다시 시도하세요.", + ); + }; + return { downloadGeoPackage, startDownloadJob, waitForDownloadJob }; +}; + +// Keep the object URL alive until Chrome has accepted the synthetic download click. +export const saveBlob = (blob: Blob, fileName: string) => { + const url = window.URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => window.URL.revokeObjectURL(url), 1_000); +}; diff --git a/apps/sn/src/features/gisManagement/index.ts b/apps/sn/src/features/gisManagement/index.ts new file mode 100644 index 000000000..d0e7b8d10 --- /dev/null +++ b/apps/sn/src/features/gisManagement/index.ts @@ -0,0 +1,7 @@ +/** GIS 업로드 부분 성공 상태와 재시도 정책의 공개 경계다. */ +export { + createGisUploadRetryGuard, + getGisPartialSuccessNotice, + readGisPartialSuccess, +} from "./model/upload-retry-guard"; +export type { GisPartialSuccessBlock } from "./model/upload-retry-guard"; diff --git a/apps/sn/src/features/gisManagement/lib/popup-refresh.ts b/apps/sn/src/features/gisManagement/lib/popup-refresh.ts new file mode 100644 index 000000000..24640a63c --- /dev/null +++ b/apps/sn/src/features/gisManagement/lib/popup-refresh.ts @@ -0,0 +1,24 @@ +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import type { GisUploadResponse } from "../model/upload-types"; +/** 성공한 GIS 업로드의 기존 refresh 메시지를 opener에 전달한다. */ +export const postRefreshMessageToOpener = (response: GisUploadResponse) => { + if ( + !response.mapRefreshRequired || + typeof window === "undefined" || + !window.opener + ) { + return; + } + + window.opener.postMessage( + { + type: WINDOW_POPUP_MESSAGE_TYPE.GIS_UPLOAD_COMPLETED, + payload: { + refreshLayerTypeNames: JSON.stringify( + response.refreshLayerTypeNames ?? ["tb_ms_swage_ppl"], + ), + }, + }, + window.location.origin, + ); +}; diff --git a/apps/sn/src/features/gisManagement/lib/upload-display.ts b/apps/sn/src/features/gisManagement/lib/upload-display.ts new file mode 100644 index 000000000..5cb73feb5 --- /dev/null +++ b/apps/sn/src/features/gisManagement/lib/upload-display.ts @@ -0,0 +1,74 @@ +/** GIS 파일 검증·목록 필드·오류 안내의 표시 규칙이다. */ +import type { ValidationErrorItem } from "../model/upload-types"; +/** 백엔드 검증 코드를 사용자가 이해할 수 있는 한글 오류내용으로 변환한다. */ +export const GIS_VALIDATION_ERROR_MESSAGES: Record = { + GIS_UPLOAD_MNG_INST_MISMATCH: + "선택한 관리기관과 다른 관로가 포함되어 있습니다.", + GIS_UPLOAD_FID_DUPLICATED: "업로드 파일에 중복된 시설물 식별번호가 있습니다.", + GIS_UPLOAD_DELETE_LIMIT_EXCEEDED: + "삭제 대상 건수가 허용 범위를 초과했습니다.", +}; + +/** 형상 오류는 같은 오류코드 안에서도 누락과 형식 오류를 구분해 안내한다. */ +export const getLocalizedValidationErrorMessage = ( + error: ValidationErrorItem, +) => { + const errorCode = String(error.errorCode ?? "").trim(); + const errorMessage = String(error.errorMessage ?? "").trim(); + // 저장 API가 제공하는 한글 설명을 보존하고 과거 기술용어 응답만 아래에서 변환한다. + if ( + /[가-힣]/.test(errorMessage) && + !/geometry|feature|LineString|count\s*=/i.test(errorMessage) + ) { + return errorMessage; + } + + if (errorCode === "GIS_UPLOAD_INVALID_GEOMETRY") { + return errorMessage.toLowerCase().includes("없는 feature") + ? "공간정보가 없는 시설물입니다." + : "하수관로는 선 또는 다중선 형식의 공간정보만 업로드할 수 있습니다."; + } + + const localizedMessage = GIS_VALIDATION_ERROR_MESSAGES[errorCode]; + if (localizedMessage) { + const count = errorMessage.match(/count\s*=\s*(\d+)/i)?.[1]; + return count ? `${localizedMessage} (대상: ${count}건)` : localizedMessage; + } + + // 서버가 완전한 한글 문구를 제공한 신규 오류만 보존하고 영문이 섞인 문구는 일반 안내로 대체한다. + return /[가-힣]/.test(errorMessage) && !/[A-Za-z]/.test(errorMessage) + ? errorMessage + : "업로드 파일 검증 중 오류가 발생했습니다."; +}; + +export const getFileExtension = (fileName: string) => { + const lastDotIndex = fileName.lastIndexOf("."); + if (lastDotIndex < 0) return ""; + return fileName.slice(lastDotIndex).toLowerCase(); +}; + +export const formatDateTime = (value?: string | null) => { + if (!value) return "-"; + return value.replace("T", " ").slice(0, 19); +}; + +export const toScopeKey = (mngInstCd: string) => mngInstCd || "__UNSELECTED__"; + +export const buildManagementLabel = ( + mngInstCd?: string | null, + mngInstNm?: string | null, +) => { + if (!mngInstCd) return "-"; + return mngInstNm || mngInstCd; +}; + +export const getErrorMessage = (error: unknown, fallback: string) => { + const apiError = error as any; + return ( + apiError?.response?.data?.message ?? + apiError?.response?.data?.data?.message ?? + apiError?.body?.message ?? + apiError?.message ?? + fallback + ); +}; diff --git a/apps/sn/src/features/gisManagement/model/upload-retry-guard.ts b/apps/sn/src/features/gisManagement/model/upload-retry-guard.ts new file mode 100644 index 000000000..61d2b1b3b --- /dev/null +++ b/apps/sn/src/features/gisManagement/model/upload-retry-guard.ts @@ -0,0 +1,58 @@ +type ErrorRecord = Record; + +export type GisPartialSuccessBlock = { uploadId: string; downloadId: string }; + +/** Axios·SN transport의 기존 오류 경계에서 부분 성공 body만 확인한다. */ +const errorRecord = (value: unknown): ErrorRecord | null => + typeof value === "object" && value !== null ? (value as ErrorRecord) : null; + +/** BE가 명시한 부분 성공 플래그를 모두 확인하며 일반 500과 구분한다. */ +export const readGisPartialSuccess = ( + error: unknown, +): { uploadId: string } | null => { + const root = errorRecord(error); + const response = errorRecord(root?.response); + const body = errorRecord(root?.body ?? response?.data); + const candidate = errorRecord(body?.data) ?? body; + if ( + candidate?.code !== "GIS_UPLOAD_HISTORY_SAVE_FAILED" || + candidate.msApplied !== true || + candidate.retryAllowed !== false + ) + return null; + return { + uploadId: + typeof candidate.uploadId === "string" ? candidate.uploadId.trim() : "", + }; +}; + +/** 기존 안내 영역에서 반영 단계와 이력 실패를 구분하고 운영 확인용 ID를 표시한다. */ +export const getGisPartialSuccessNotice = (block: GisPartialSuccessBlock) => + `관로 변경 반영 단계는 완료되었으나 업로드 이력을 저장하지 못했습니다. 같은 파일을 다시 업로드하지 말고 관리자에게 반영 결과와 이력 복구를 확인하세요. 업로드 식별번호: ${block.uploadId || "확인 필요"}. 다음 작업은 최신 관로 자료를 새로 다운로드한 후 진행하세요.`; + +/** + * 열린 GIS 관리 세션의 기관 scope별 부분 성공 재시도를 차단한다. + * 파일명·파일선택·기관 전환으로 해제하지 않는다. 현재본 다운로드 완료 뒤 + * 이전과 다른 유효 downloadId가 확인된 경우에만 새 작업을 허용한다. + */ +export const createGisUploadRetryGuard = () => { + const blockedScopes = new Map(); + return { + getBlock: (scope: string) => blockedScopes.get(scope) ?? null, + recordFailure: (scope: string, downloadId: string, error: unknown) => { + const partial = readGisPartialSuccess(error); + if (!partial) return null; + const block = { ...partial, downloadId }; + blockedScopes.set(scope, block); + return block; + }, + /** READY 현재본의 실제 파일 수신·브라우저 저장 요청까지 완료한 호출자만 사용한다. */ + acceptFreshDownload: (scope: string, downloadId: string) => { + const block = blockedScopes.get(scope); + const nextId = downloadId.trim(); + if (!block || !nextId || nextId === block.downloadId) return null; + blockedScopes.delete(scope); + return block; + }, + }; +}; diff --git a/apps/sn/src/features/gisManagement/model/upload-types.ts b/apps/sn/src/features/gisManagement/model/upload-types.ts new file mode 100644 index 000000000..0191b7f00 --- /dev/null +++ b/apps/sn/src/features/gisManagement/model/upload-types.ts @@ -0,0 +1,61 @@ +/** GIS 관리 목록·검증·다운로드 작업의 서버 응답 계약이다. */ +export type UploadFacilityType = { + facilityTypeCode: string; + facilityTypeName: string; + layerId: string; + allowedExtensions: string[]; + maxFileSizeMb: number; + requiredDownloadFormat: string; +}; + +export type MngInstOption = { + mngInstCd: string; + lwrInstNm: string; +}; + +export type UploadHistoryItem = { + uploadId: string; + downloadId?: string; + mngInstCd?: string | null; + mngInstNm?: string | null; + fileName: string; + status?: string; + totalCount?: number | null; + changedCount?: number | null; + invalidCount?: number | null; + registeredAt: string; + registeredBy: string; + registeredByName?: string | null; + message?: string | null; +}; + +export type GisUploadResponse = { + uploadId: string; + status: "COMPLETED" | "VALIDATION_FAILED" | string; + mapRefreshRequired?: boolean; + refreshLayerTypeNames?: string[]; + validationErrors?: ValidationErrorItem[]; + invalidCount?: number; + message?: string; +}; + +export type ValidationErrorItem = { + rowNo?: number | null; + fid?: string | null; + errorCode?: string | null; + errorMessage?: string | null; +}; + +export type ViewMode = "history" | "validationErrors" | "preview"; + +export type DownloadJob = { + jobId: string; + status: "QUEUED" | "RUNNING" | "READY" | "FAILED" | string; + message?: string | null; + errorCode?: string | null; + downloadId?: string | null; + fileName?: string | null; + retryAfterMillis?: number | null; +}; + +export type DownloadJobStatusListener = (job: DownloadJob) => void; diff --git a/apps/sn/src/features/mapLayers/index.ts b/apps/sn/src/features/mapLayers/index.ts new file mode 100644 index 000000000..6544b8437 --- /dev/null +++ b/apps/sn/src/features/mapLayers/index.ts @@ -0,0 +1,3 @@ +/** 지도 레이어 메타·표출 조건과 독립 이벤트 구독의 공개 경계다. */ +export * from "./lib/layer-info"; +export { subscribeFlowDirectionMoveEnd } from "./lib/flow-direction-listener"; diff --git a/apps/sn/src/features/mapLayers/lib/flow-direction-listener.ts b/apps/sn/src/features/mapLayers/lib/flow-direction-listener.ts new file mode 100644 index 000000000..fe3dba44b --- /dev/null +++ b/apps/sn/src/features/mapLayers/lib/flow-direction-listener.ts @@ -0,0 +1,17 @@ +/** + * 유하방향 moveend 구독 수명을 소유한다. 매 이벤트마다 최신 체크·레이어 상태를 읽는다. + * OpenLayers 해제 함수는 주입받아 모델·계약 테스트에서 지도 엔진 없이 검증할 수 있다. + */ +export const subscribeFlowDirectionMoveEnd = ( + map: { on: (event: "moveend", listener: () => void) => TSubscription }, + readState: () => { checked: boolean; layerInfo: TLayer | null }, + loadLayer: (layerInfo: TLayer) => unknown, + unsubscribe: (key: TSubscription) => void, +): (() => void) => { + const key = map.on("moveend", () => { + const state = readState(); + if (!state.checked || !state.layerInfo) return; + loadLayer(state.layerInfo); + }); + return () => unsubscribe(key); +}; diff --git a/apps/sn/src/features/mapLayers/lib/flow-direction-styles.ts b/apps/sn/src/features/mapLayers/lib/flow-direction-styles.ts new file mode 100644 index 000000000..ba6e2ac73 --- /dev/null +++ b/apps/sn/src/features/mapLayers/lib/flow-direction-styles.ts @@ -0,0 +1,286 @@ +/** 유하방향 선/화살표의 색상·대표 선분·중앙 배치를 표출 정책으로 분리한다. */ +import Style from "ol/style/Style.js"; +import Stroke from "ol/style/Stroke.js"; +import Point from "ol/geom/Point.js"; +export const FLOW_DIRECTION_DEFAULT_COLOR = "rgba(40, 90, 180, 0.84)"; + +export const FLOW_DIRECTION_NETWORK_CORRECTION_COLOR = + "rgba(0, 168, 132, 0.95)"; + +export type FlowDirectionUsageStyle = { + color: string; + lineColor: string; +}; + +// 관종별 원본 레이어 색을 유하방향 선/화살표에 재사용할 때 투명도만 조정한다. +export const withAlpha = (color: string | null | undefined, alpha: number) => { + const resolvedColor = String(color ?? "").trim(); + if (!resolvedColor) return `rgba(40, 90, 180, ${alpha})`; + + const rgbaMatch = resolvedColor.match(/^rgba?\(([^)]+)\)$/i); + if (rgbaMatch) { + const [red = "40", green = "90", blue = "180"] = (rgbaMatch[1] ?? "") + .split(",") + .map((part) => part.trim()); + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; + } + + const hexMatch = resolvedColor.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (hexMatch) { + const hex = hexMatch[1] ?? ""; + const normalized = + hex.length === 3 + ? hex + .split("") + .map((char) => char + char) + .join("") + : hex; + const red = parseInt(normalized.slice(0, 2), 16); + const green = parseInt(normalized.slice(2, 4), 16); + const blue = parseInt(normalized.slice(4, 6), 16); + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; + } + + return resolvedColor; +}; + +// 화살표 위치와 대표 선분 선택을 위해 LineString 좌표열의 대략적인 길이를 계산한다. +export const estimateLineLength = (coordinates: number[][]) => { + let length = 0; + for (let i = 1; i < coordinates.length; i += 1) { + const previous = coordinates[i - 1]; + const current = coordinates[i]; + if (!previous || !current) continue; + + const [x1 = 0, y1 = 0] = previous; + const [x2 = 0, y2 = 0] = current; + length += Math.hypot(x2 - x1, y2 - y1); + } + return length; +}; + +// MultiLineString 관로는 가장 긴 선분을 대표 선으로 삼아 화살표 위치와 각도를 안정적으로 계산한다. +export const getRepresentativeLineCoordinates = (geometry: any): number[][] => { + const type = geometry?.getType?.(); + const coordinates = geometry?.getCoordinates?.(); + + if (type === "LineString") return coordinates ?? []; + if (type === "MultiLineString" && Array.isArray(coordinates)) { + return coordinates.reduce( + (longest: number[][], current: number[][]) => + estimateLineLength(current) > estimateLineLength(longest) + ? current + : longest, + [], + ); + } + + return []; +}; + +// 관로 중앙 지점이 속한 실제 선분을 찾아 화살표 위치와 해당 선분의 방향각을 함께 계산한다. +export const getArrowPlacementAtHalfLength = (coordinates: number[][]) => { + if (coordinates.length === 0) return null; + if (coordinates.length === 1) { + const coordinate = coordinates[0]; + return coordinate ? { coordinate, angle: 0 } : null; + } + + const totalLength = estimateLineLength(coordinates); + if (totalLength === 0) { + const coordinate = coordinates[Math.floor(coordinates.length / 2)]; + return coordinate ? { coordinate, angle: 0 } : null; + } + + let accumulated = 0; + const targetLength = totalLength / 2; + let lastSegmentAngle = 0; + + for (let i = 1; i < coordinates.length; i += 1) { + const previous = coordinates[i - 1]; + const current = coordinates[i]; + if (!previous || !current) continue; + + const [previousX = 0, previousY = 0] = previous; + const [currentX = 0, currentY = 0] = current; + const segmentLength = Math.hypot( + currentX - previousX, + currentY - previousY, + ); + if (segmentLength > 0) { + lastSegmentAngle = Math.atan2(currentY - previousY, currentX - previousX); + } + + if (accumulated + segmentLength >= targetLength) { + const ratio = + segmentLength === 0 ? 0 : (targetLength - accumulated) / segmentLength; + return { + coordinate: [ + previousX + (currentX - previousX) * ratio, + previousY + (currentY - previousY) * ratio, + ], + angle: lastSegmentAngle, + }; + } + + accumulated += segmentLength; + } + + const coordinate = coordinates[coordinates.length - 1]; + return coordinate ? { coordinate, angle: lastSegmentAngle } : null; +}; + +// SVG 없이 Canvas renderer로 짧은 꼬리선과 화살촉을 직접 그려 방향성을 분명하게 만든다. +export const createFlowDirectionArrowRenderer = ( + color: string, + angle: number, + scale: number, +) => { + return (coordinates: any, state: any) => { + const pixel = Array.isArray(coordinates?.[0]) + ? coordinates[0] + : coordinates; + const [x, y] = pixel ?? []; + const context = state?.context as CanvasRenderingContext2D | undefined; + if (!context || !Number.isFinite(x) || !Number.isFinite(y)) return; + + const length = 17 * scale; + const tailLength = 9 * scale; + const headLength = 7 * scale; + const headHalfWidth = 4.5 * scale; + const shaftWidth = 2.4 * scale; + + context.save(); + context.translate(x, y); + const viewRotation = Number(state?.rotation ?? 0); + const screenAngle = + (Number.isFinite(viewRotation) ? viewRotation : 0) - angle; + + context.rotate(screenAngle); + context.lineCap = "round"; + context.lineJoin = "round"; + + context.strokeStyle = "rgba(255, 255, 255, 0.9)"; + context.lineWidth = shaftWidth + 2.4; + context.beginPath(); + context.moveTo(-tailLength, 0); + context.lineTo(length - headLength, 0); + context.stroke(); + + context.fillStyle = "rgba(255, 255, 255, 0.9)"; + context.beginPath(); + context.moveTo(length, 0); + context.lineTo(length - headLength, -headHalfWidth); + context.lineTo(length - headLength, headHalfWidth); + context.closePath(); + context.fill(); + + context.strokeStyle = color; + context.lineWidth = shaftWidth; + context.beginPath(); + context.moveTo(-tailLength, 0); + context.lineTo(length - headLength, 0); + context.stroke(); + + context.fillStyle = color; + context.beginPath(); + context.moveTo(length, 0); + context.lineTo(length - headLength, -headHalfWidth); + context.lineTo(length - headLength, headHalfWidth); + context.closePath(); + context.fill(); + + context.restore(); + }; +}; + +// 유하방향 신뢰도와 관종 색상에 따라 선과 화살표 스타일을 만든다. LOW는 운영 검수 대상이라 더 흐리게 표시한다. +export const createFlowDirectionStyle = ( + feature: any, + usageStyleMap: Record, +) => { + const confidence = String( + feature.get("directionConfidence") ?? + feature.get("direction_confidence") ?? + "", + ).toUpperCase(); + const geometry = feature.getGeometry(); + const coordinates = getRepresentativeLineCoordinates(geometry); + const arrowPlacement = getArrowPlacementAtHalfLength(coordinates); + const usageCode = String( + feature.get("usgCd") ?? feature.get("usg_cd") ?? "", + ).trim(); + const usageStyle = usageStyleMap[usageCode]; + const baseColor = usageStyle?.color ?? FLOW_DIRECTION_DEFAULT_COLOR; + const isNetworkCorrected = + String( + feature.get("networkCorrectionYn") ?? + feature.get("network_correction_yn") ?? + "", + ).toUpperCase() === "Y"; + + const styleByConfidence: Record< + string, + { + color: string; + lineColor: string; + scale: number; + width: number; + lineDash?: number[]; + } + > = { + HIGH: { + color: baseColor, + lineColor: usageStyle?.lineColor ?? withAlpha(baseColor, 0.42), + scale: 1, + width: 1.3, + }, + MEDIUM: { + color: baseColor, + lineColor: usageStyle?.lineColor ?? withAlpha(baseColor, 0.42), + scale: 1, + width: 1.3, + }, + LOW: { + color: withAlpha(baseColor, 0.72), + lineColor: withAlpha(baseColor, 0.28), + scale: 0.9, + width: 1.1, + }, + }; + const style = isNetworkCorrected + ? { + color: FLOW_DIRECTION_NETWORK_CORRECTION_COLOR, + lineColor: withAlpha(FLOW_DIRECTION_NETWORK_CORRECTION_COLOR, 0.5), + scale: 1.08, + width: 2, + lineDash: [8, 4], + } + : (styleByConfidence[confidence] ?? styleByConfidence.LOW!); + const styles = [ + new Style({ + stroke: new Stroke({ + color: style.lineColor, + width: style.width, + lineDash: style.lineDash, + lineCap: "round", + lineJoin: "round", + }), + }), + ]; + + if (arrowPlacement) { + styles.push( + new Style({ + geometry: new Point(arrowPlacement.coordinate), + renderer: createFlowDirectionArrowRenderer( + style.color, + arrowPlacement.angle, + style.scale, + ), + }), + ); + } + + return styles; +}; diff --git a/apps/sn/src/features/mapLayers/lib/layer-info.ts b/apps/sn/src/features/mapLayers/lib/layer-info.ts new file mode 100644 index 000000000..4022dc385 --- /dev/null +++ b/apps/sn/src/features/mapLayers/lib/layer-info.ts @@ -0,0 +1,197 @@ +/** 레이어 설정 응답의 호환 필드·조회 모드·BBOX 계산을 UI 수명과 분리한다. */ +export const FLOW_DIRECTION_LAYER_TYPE = "tb_sn_swage_ppl_flow_direction"; + +export const FLOW_DIRECTION_LAYER_NAME = "유하방향"; + +export const FLOW_DIRECTION_DEFAULT_MIN_ZOOM = 13; + +export const FLOW_DIRECTION_BBOX_BUFFER_RATIO = 0.35; + +export type FlowDirectionMode = "PIPE_START_END" | "PIPE_DEPTH"; + +// /map은 관로 깊이 기준, /map2는 관로 원천 geometry 시점->종점 기준만 유지한다. 두 화면 모두 BBOX 조회만 사용한다. +export const getFlowDirectionModeByPathname = ( + pathname: string | null, +): FlowDirectionMode => { + const normalizedPathname = String(pathname ?? "").replace(/\/$/, ""); + if (normalizedPathname.endsWith("/map2")) return "PIPE_START_END"; + return "PIPE_DEPTH"; +}; + +export const SWAGE_PRCS_FCLT_LAYER_TYPE = "tb_il_swage_prcs_fclt"; +export const RAIN_OBSERVATION_LAYER_TYPE = "tb_ms_rain_obsvtr_pstn_info"; + +export type SwagePrcsFcltLayerMode = "all" | "gte500" | "lt500" | "none"; + +// 레이어 설정 응답이 lyrTyNm/lyrTypeNm/lyr_type_nm처럼 혼재되어 내려오는 경우를 단일 기준으로 정규화한다. +export const getLayerTypeName = (layerInfo: any) => + String( + layerInfo?.lyrTyNm ?? layerInfo?.lyrTypeNm ?? layerInfo?.lyr_type_nm ?? "", + ).trim(); + +export const getLayerFilterSql = (layerInfo: any) => + String( + layerInfo?.filtrSql ?? + layerInfo?.filtr_sql ?? + layerInfo?.flterSql ?? + layerInfo?.filterSql ?? + layerInfo?.filter_sql ?? + "", + ) + .replace(/\s+/g, "") + .toLowerCase(); + +export const getSwagePrcsFcltCapacityKind = ( + layerInfo: any, +): Exclude | null => { + if (getLayerTypeName(layerInfo) !== SWAGE_PRCS_FCLT_LAYER_TYPE) return null; + + const layerName = String(layerInfo?.lyrNm ?? layerInfo?.name ?? "").replace( + /\s+/g, + "", + ); + const filterSql = getLayerFilterSql(layerInfo); + + if ( + filterSql.includes("fclt_cpct>=500") || + layerName.includes("500이상") || + layerName.includes("500톤이상") + ) + return "gte500"; + if ( + filterSql.includes("fclt_cpct<500") || + layerName.includes("500미만") || + layerName.includes("500톤미만") + ) + return "lt500"; + + return null; +}; + +export const getSwagePrcsFcltLayerMode = ( + checkedNodesData: any[], +): SwagePrcsFcltLayerMode => { + const checkedKinds = new Set( + checkedNodesData + .map((node) => getSwagePrcsFcltCapacityKind(node?.data ?? node)) + .filter(Boolean), + ); + const hasGte500 = checkedKinds.has("gte500"); + const hasLt500 = checkedKinds.has("lt500"); + + if (hasGte500 && hasLt500) return "all"; + if (hasGte500) return "gte500"; + if (hasLt500) return "lt500"; + return "none"; +}; + +// 유하방향 노드는 타입명 또는 표시명으로 식별한다. DB DML 반영 전후 응답 차이를 모두 흡수하기 위한 판별 함수이다. +export const isFlowDirectionLayerInfo = (layerInfo: any) => { + const layerTypeName = getLayerTypeName(layerInfo); + const layerName = String(layerInfo?.lyrNm ?? layerInfo?.name ?? "").trim(); + + return ( + layerTypeName === FLOW_DIRECTION_LAYER_TYPE || + layerName === FLOW_DIRECTION_LAYER_NAME + ); +}; + +// 유하방향 노드를 일반 레이어 토글 대상에서 제외하고, 앱 전용 API vector layer로 처리하도록 속성을 보정한다. +export const normalizeFlowDirectionLayerInfos = (data: any[]): any[] => { + if (!Array.isArray(data)) return data; + + return data.map((item) => { + const children = Array.isArray(item.children) + ? normalizeFlowDirectionLayerInfos(item.children) + : item.children; + + if (!isFlowDirectionLayerInfo(item)) { + return { ...item, children }; + } + + return { + ...item, + children, + lyrTyNm: + item.lyrTyNm || + item.lyrTypeNm || + item.lyr_type_nm || + FLOW_DIRECTION_LAYER_TYPE, + lyrNm: item.lyrNm || FLOW_DIRECTION_LAYER_NAME, + gobjTy: item.gobjTy || "line", + srvcNm: "api", + cmncNm: "bbox", + actlLyrYn: "N", + clickPsblYn: "N", + initEprssYn: "N", + minViewSize: + item.minViewSize ?? + item.minZoom ?? + String(FLOW_DIRECTION_DEFAULT_MIN_ZOOM), + }; + }); +}; + +// 강우관측소는 위치와 라벨만 제공하며, 과거 DB 설정값이 Y여도 일반 속성 패널 선택 대상에서는 제외한다. +export const normalizeRainObservationLayerInteraction = ( + data: any[], +): any[] => { + if (!Array.isArray(data)) return data; + + return data.map((item) => { + const children = Array.isArray(item.children) + ? normalizeRainObservationLayerInteraction(item.children) + : item.children; + + return getLayerTypeName(item) === RAIN_OBSERVATION_LAYER_TYPE + ? { ...item, children, clickPsblYn: "N" } + : { ...item, children }; + }); +}; + +// 레이어 설정의 최소 표출 줌을 읽되, 값이 없으면 유하방향 기본 줌 기준을 적용한다. +export const getFiniteNumber = (value: unknown) => { + if (value === null || value === undefined || String(value).trim() === "") + return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +export const getFlowDirectionMinZoom = (layerInfo: any) => { + return ( + getFiniteNumber(layerInfo?.minViewSize ?? layerInfo?.minZoom) ?? + FLOW_DIRECTION_DEFAULT_MIN_ZOOM + ); +}; + +// 유하방향 화살표는 관로 위에 얹는 보조 레이어라 레이어 설정의 drawOrdr를 우선 사용하고 없으면 높은 zIndex를 준다. +export const getFlowDirectionZIndex = (layerInfo: any) => { + return getFiniteNumber(layerInfo?.drawOrdr) ?? 9998; +}; + +// 현재 지도 화면 BBOX 주변을 함께 조회해 이동 직후 가장자리 유하방향이 비어 보이는 현상을 줄인다. +export const expandFlowDirectionExtent = (extent: number[]) => { + const [minX = 0, minY = 0, maxX = 0, maxY = 0] = extent; + const width = Math.max(0, maxX - minX); + const height = Math.max(0, maxY - minY); + const bufferX = width * FLOW_DIRECTION_BBOX_BUFFER_RATIO; + const bufferY = height * FLOW_DIRECTION_BBOX_BUFFER_RATIO; + return [minX - bufferX, minY - bufferY, maxX + bufferX, maxY + bufferY]; +}; + +// API 요청과 디버깅 출력에 같은 BBOX 문자열 포맷을 사용해 FE 계산값과 네트워크 파라미터를 쉽게 대조한다. +export const formatFlowDirectionBbox = (extent: number[]) => + extent.map((value: number) => value.toFixed(3)).join(","); + +// 유하방향 캐시 중복 제거 키이다. BE 응답은 같은 관로 id가 여러 geometry로 반복될 수 있어 geometry까지 포함한다. +export const getFlowDirectionFeatureCacheKey = (feature: any) => { + const featureId = feature.getId?.(); + const fcltMngNo = feature.get("fcltMngNo") ?? feature.get("fclt_mng_no"); + const usageCode = feature.get("usgCd") ?? feature.get("usg_cd") ?? ""; + const coordinates = feature.getGeometry?.()?.getCoordinates?.(); + const geometryKey = JSON.stringify(coordinates); + + if (featureId) return `id:${String(featureId)}:${geometryKey}`; + if (fcltMngNo) return `fclt:${String(fcltMngNo).trim()}:${geometryKey}`; + return `geom:${String(usageCode).trim()}:${geometryKey}`; +}; diff --git a/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts b/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts index 1ada996e0..30f07d153 100644 --- a/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts +++ b/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts @@ -1,8 +1,14 @@ +import { setCookie } from "@/shared/lib/cookie"; + import { isWindowPopupKey, windowPopupDefinitions, type WindowPopupKey, } from "../model/popupDefinitions"; +import { + resolveWindowPopupTheme, + WINDOW_POPUP_THEME_PARAM, +} from "../model/popupTheme"; import type { OpenWindowPopupOptions, OpenWindowPopupParams, @@ -105,7 +111,18 @@ export const openWindowPopup = ( const definition: WindowPopupDefinition = windowPopupDefinitions[popupKey]; const shouldMaximize = options?.maximize ?? definition.maximize ?? false; - const popupUrl = `/popup/${popupKey}${buildQueryString(params)}`; + // 새 창은 메인 창의 실시간 테마를 쿼리로 함께 받아 쿠키 갱신 시점과 무관하게 동일한 모드로 연다. + const currentTheme = resolveWindowPopupTheme( + document.documentElement.getAttribute("data-theme"), + ); + const popupParams: OpenWindowPopupParams = { + ...params, + ...(currentTheme ? { [WINDOW_POPUP_THEME_PARAM]: currentTheme } : {}), + }; + if (currentTheme) { + setCookie("site-theme-key", currentTheme, { path: "/" }); + } + const popupUrl = `/popup/${popupKey}${buildQueryString(popupParams)}`; const popupFeatures = buildPopupFeatures({ ...definition, width: options?.width ?? definition.width, diff --git a/apps/sn/src/features/windowPopup/model/popupTheme.ts b/apps/sn/src/features/windowPopup/model/popupTheme.ts new file mode 100644 index 000000000..1ccbaf905 --- /dev/null +++ b/apps/sn/src/features/windowPopup/model/popupTheme.ts @@ -0,0 +1,19 @@ +const WINDOW_POPUP_THEMES = ["white", "blue", "green", "red", "black"] as const; + +export const WINDOW_POPUP_THEME_PARAM = "_theme"; +export type WindowPopupTheme = (typeof WINDOW_POPUP_THEMES)[number]; + +/** 현재 화면·쿠키의 테마값을 SN이 지원하는 5개 모드로 제한한다. */ +export function resolveWindowPopupTheme( + value: string | string[] | null | undefined, +): WindowPopupTheme | undefined { + const rawValue = Array.isArray(value) ? value[0] : value; + const normalizedValue = String(rawValue ?? "").trim().toLowerCase(); + const themeValue = normalizedValue === "dark" ? "black" : normalizedValue; + + return WINDOW_POPUP_THEMES.includes(themeValue as WindowPopupTheme) + ? (themeValue as WindowPopupTheme) + : undefined; +} + +export { WINDOW_POPUP_THEMES }; diff --git a/apps/sn/src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx index 0f9f40bb2..f06521c16 100644 --- a/apps/sn/src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx +++ b/apps/sn/src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx @@ -20,21 +20,26 @@ export default function FloodPreUploadPopupContent() { cd: string, id: string, ) => { - const data = await postData("v1/sbmrgPrevnt", { - mngYr: year, - mngQu: quater, - atflId: id, - atflCd: cd, - rgtr: tokenManager.getToken(AUTH_USER_ID) || null, - sbmrgPrevntBizNm: fileNm, - }); + // 공통 POST가 reject한 HTTP 오류를 팝업에서 처리해 완료 메시지를 보내지 않는다. + try { + const data = await postData("v1/sbmrgPrevnt", { + mngYr: year, + mngQu: quater, + atflId: id, + atflCd: cd, + rgtr: tokenManager.getToken(AUTH_USER_ID) || null, + sbmrgPrevntBizNm: fileNm, + }); - if (data) { - alert("파일이 등록되었습니다.", () => - postPopupMessageAndClose( - WINDOW_POPUP_MESSAGE_TYPE.FLOOD_PRE_UPLOAD_COMPLETED, - ), - ); + if (data) { + alert("파일이 등록되었습니다.", () => + postPopupMessageAndClose( + WINDOW_POPUP_MESSAGE_TYPE.FLOOD_PRE_UPLOAD_COMPLETED, + ), + ); + } + } catch { + alert("파일 등록에 실패했습니다. 잠시 후 다시 시도해주세요."); } }; diff --git a/apps/sn/src/features/windowPopup/ui/OldPpUploadPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/OldPpUploadPopupContent.tsx index 3af698e5a..e12bcbece 100644 --- a/apps/sn/src/features/windowPopup/ui/OldPpUploadPopupContent.tsx +++ b/apps/sn/src/features/windowPopup/ui/OldPpUploadPopupContent.tsx @@ -20,21 +20,26 @@ export default function OldPpUploadPopupContent() { cd: string, id: string, ) => { - const data = await postData("v1/dcrpPpl", { - mngYr: year, - mngQu: quater, - atflCd: cd, - atflId: id, - rgtr: tokenManager.getToken(AUTH_USER_ID) || null, - dcrpPplMtncBizNm: fileNm, - }); + // 공통 POST가 reject한 HTTP 오류를 팝업에서 처리해 완료 메시지를 보내지 않는다. + try { + const data = await postData("v1/dcrpPpl", { + mngYr: year, + mngQu: quater, + atflCd: cd, + atflId: id, + rgtr: tokenManager.getToken(AUTH_USER_ID) || null, + dcrpPplMtncBizNm: fileNm, + }); - if (data) { - alert("파일이 등록되었습니다.", () => - postPopupMessageAndClose( - WINDOW_POPUP_MESSAGE_TYPE.OLD_PP_UPLOAD_COMPLETED, - ), - ); + if (data) { + alert("파일이 등록되었습니다.", () => + postPopupMessageAndClose( + WINDOW_POPUP_MESSAGE_TYPE.OLD_PP_UPLOAD_COMPLETED, + ), + ); + } + } catch { + alert("파일 등록에 실패했습니다. 잠시 후 다시 시도해주세요."); } }; diff --git a/apps/sn/src/features/windowPopup/ui/PopupThemeSync.tsx b/apps/sn/src/features/windowPopup/ui/PopupThemeSync.tsx new file mode 100644 index 000000000..efa9f2ed4 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/PopupThemeSync.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { useEffect } from "react"; + +import type { WindowPopupTheme } from "../model/popupTheme"; + +/** 서버 렌더를 바꾸지 않고 팝업 마운트 후 메인 창의 현재 테마를 적용한다. */ +export default function PopupThemeSync({ theme }: { theme: WindowPopupTheme }) { + useEffect(() => { + document.documentElement.setAttribute("data-theme", theme); + }, [theme]); + + return null; +} diff --git a/apps/sn/src/features/windowPopup/ui/WindowPopupRenderer.tsx b/apps/sn/src/features/windowPopup/ui/WindowPopupRenderer.tsx index 320cd9afd..9eccc7610 100644 --- a/apps/sn/src/features/windowPopup/ui/WindowPopupRenderer.tsx +++ b/apps/sn/src/features/windowPopup/ui/WindowPopupRenderer.tsx @@ -1,8 +1,12 @@ "use client"; import type { WindowPopupKey } from "../model/popupDefinitions"; +import type { WindowPopupRegistryItem } from "../model/types"; import { windowPopupRegistry } from "../registry"; +import { getPopupParamValue } from "./popupContentUtils"; +import PopupMissingParams from "./PopupMissingParams"; + type WindowPopupRendererProps = { popupKey: WindowPopupKey; searchParams: Record; @@ -16,7 +20,18 @@ export default function WindowPopupRenderer({ popupKey, searchParams, }: WindowPopupRendererProps) { - const PopupComponent = windowPopupRegistry[popupKey].component; + const popup: WindowPopupRegistryItem = windowPopupRegistry[popupKey]; + const missingParams = (popup.requiredParams ?? []).filter( + (name) => !getPopupParamValue(searchParams[name]).trim(), + ); + + // 직접 URL 진입이나 잘못된 호출에서도 빈 값으로 API를 실행하지 않고 + // 모든 WindowPopup이 동일한 누락 안내를 표시한다. + if (missingParams.length > 0) { + return ; + } + + const PopupComponent = popup.component; return ; } diff --git a/apps/sn/src/features/windowPopup/ui/dredgingPopupRecord.ts b/apps/sn/src/features/windowPopup/ui/dredgingPopupRecord.ts index 1694f8fbc..43061f6f7 100644 --- a/apps/sn/src/features/windowPopup/ui/dredgingPopupRecord.ts +++ b/apps/sn/src/features/windowPopup/ui/dredgingPopupRecord.ts @@ -1,4 +1,4 @@ -import type { DredgingRecord } from "@/app/(with-base-layout)/oper/dredging/lib/types"; +import type { DredgingRecord } from "@/features/dredging"; import type { WindowPopupContentProps } from "../model/types"; diff --git a/apps/sn/src/shared/api/http-transport.ts b/apps/sn/src/shared/api/http-transport.ts new file mode 100644 index 000000000..e124b1616 --- /dev/null +++ b/apps/sn/src/shared/api/http-transport.ts @@ -0,0 +1,132 @@ +/** HTTP 실패 응답의 상태와 본문을 보존해 기능별 오류 안내에 전달한다. */ +export class ApiRequestError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly body: unknown, + ) { + super(message); + this.name = "ApiRequestError"; + } +} + +type HttpTransportOptions = { + baseUrl: string; + headers?: HeadersInit; + onUnauthorized?: () => void; +}; + +/** JSON 오류는 서버 메시지를 사용하고 HTML·빈 오류는 HTTP 상태로 설명한다. */ +const requestError = (response: Response, body: unknown) => { + const bodyMessage = + typeof body === "object" && body !== null + ? ((body as { message?: unknown }).message ?? + (body as { error?: { message?: unknown } }).error?.message) + : undefined; + return new ApiRequestError( + typeof bodyMessage === "string" && bodyMessage.trim() + ? bodyMessage + : `API request failed: ${response.status} ${response.statusText}`.trim(), + response.status, + body, + ); +}; + +/** HTTP 실패에서는 파싱 실패로 원래 상태를 가리지 않고 원문을 보관한다. */ +const readErrorBody = async (response: Response): Promise => { + const text = await response.text().catch(() => ""); + if (!text.trim()) return null; + try { + return JSON.parse(text); + } catch { + return text; + } +}; + +/** 정상 JSON의 빈 본문은 null이며, 비어 있지 않은 잘못된 JSON은 오류다. */ +const readJsonBody = async (response: Response, url: string): Promise => { + const text = await response.text(); + if (!text.trim()) return null; + try { + return JSON.parse(text); + } catch { + throw new ApiRequestError( + `JSON 응답이 아닌 데이터를 받았습니다: ${url}`, + response.status, + text, + ); + } +}; + +/** + * URL·헤더·HTTP 성공 여부를 한곳에서 처리한다. + * 인증 정책은 호출자가 주입하므로 shared가 entities 또는 app을 역참조하지 않는다. + * 래퍼 해제와 raw 응답은 명시적으로 구분하며 필드 이름을 자동 변환하지 않는다. + */ +export const createHttpTransport = (options: HttpTransportOptions) => { + const resolveUrl = (url: string) => + url.startsWith("/") ? url : `${options.baseUrl}/${url}`; + + /** 요청 헤더 형식(객체·Headers·튜플)에 관계없이 사이트 헤더를 보존한다. */ + const request = async (url: string, init: RequestInit): Promise => { + const headers = new Headers(init.headers); + new Headers(options.headers).forEach((value, key) => + headers.set(key, value), + ); + const response = await fetch(resolveUrl(url), { ...init, headers }); + if (response.status === 401) options.onUnauthorized?.(); + if (!response.ok) { + throw requestError(response, await readErrorBody(response)); + } + return response; + }; + + /** GET/POST/PUT가 같은 상태 판정·빈 본문·JSON 파싱 규칙을 공유한다. */ + const requestJson = async (url: string, init: RequestInit) => + readJsonBody(await request(url, init), url); + + /** JSON 쓰기 요청의 직렬화와 Content-Type을 통일한다. */ + const jsonInit = (method: string, data: object): RequestInit => ({ + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + return { + /** 기존 공통 래퍼 조회 계약을 유지한다. */ + getData: async (url: string): Promise => { + const body = await requestJson(url, { method: "GET" }); + return body === null ? null : body.data; + }, + /** 등록 실패는 reject하며 정상 공통 래퍼의 data만 반환한다. */ + postData: async (url: string, data: object): Promise => { + const body = await requestJson(url, jsonInit("POST", data)); + return body === null ? null : body.data; + }, + /** 기존 PUT의 래퍼·raw 호환을 유지한다. */ + putData: async (url: string, data: object): Promise => { + const body = await requestJson(url, jsonInit("PUT", data)); + return typeof body === "object" && body !== null && "data" in body + ? (body.data ?? null) + : body; + }, + /** GeoJSON 등 원문 JSON과 AbortSignal을 그대로 유지한다. */ + getRawJson: (url: string, init: RequestInit = {}): Promise => + requestJson(url, { ...init, method: "GET" }), + /** 성공 응답의 래퍼 유무를 기능 adapter가 결정하도록 원문을 반환한다. */ + postRawJson: (url: string, data: object): Promise => + requestJson(url, jsonInit("POST", data)), + /** 204=true, 그 외 성공=읽지 않은 Response인 기존 소비자 계약을 유지한다. */ + deleteData: async ( + url: string, + data?: object, + ): Promise => { + const response = await request(url, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: data ? JSON.stringify(data) : undefined, + }); + return response.status === 204 ? true : response; + }, + }; +}; diff --git a/apps/sn/src/shared/api/index.ts b/apps/sn/src/shared/api/index.ts index b7b9d6121..e7cf5731d 100644 --- a/apps/sn/src/shared/api/index.ts +++ b/apps/sn/src/shared/api/index.ts @@ -1 +1,3 @@ export { client } from "./client"; +/** SN 내부 HTTP 기반의 공개 진입점. 인증·화면 정책은 호출자가 조합한다. */ +export { ApiRequestError, createHttpTransport } from "./http-transport"; diff --git a/apps/sn/src/shared/lib/use-data-store.ts b/apps/sn/src/shared/lib/use-data-store.ts deleted file mode 100644 index fc88ad3bb..000000000 --- a/apps/sn/src/shared/lib/use-data-store.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useStore } from "zustand"; -import { useContext } from "react"; -import type { DataStore } from "../store/data-store"; -import { StoreContext } from "../providers/store-provider"; - -export const useDataStore = (selector: (store: DataStore) => T): T => { - const storeContext = useContext(StoreContext); - - if (!storeContext) { - throw new Error("useDataStore must be used within StoreProvider"); - } - - return useStore(storeContext.dataStore, selector); -}; diff --git a/apps/sn/src/shared/ui/form.tsx b/apps/sn/src/shared/ui/form.tsx deleted file mode 100644 index bd579d263..000000000 --- a/apps/sn/src/shared/ui/form.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import * as React from "react"; -import * as LabelPrimitive from "@radix-ui/react-label"; -import { Slot } from "@radix-ui/react-slot"; -import { - Controller, - FormProvider, - useFormContext, - type ControllerProps, - type FieldPath, - type FieldValues, -} from "react-hook-form"; -import { cn, Label } from "@common/components"; - -const Form = FormProvider; - -type FormFieldContextValue< - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, -> = { - name: TName; -}; - -const FormFieldContext = React.createContext( - {} as FormFieldContextValue, -); - -const FormField = < - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, ->({ - ...props -}: ControllerProps) => { - return ( - - - - ); -}; - -const useFormField = () => { - const fieldContext = React.useContext(FormFieldContext); - const itemContext = React.useContext(FormItemContext); - const { getFieldState, formState } = useFormContext(); - - const fieldState = getFieldState(fieldContext.name, formState); - - if (!fieldContext) { - throw new Error("useFormField should be used within "); - } - - const { id } = itemContext; - - return { - id, - name: fieldContext.name, - formItemId: `${id}-form-item`, - formDescriptionId: `${id}-form-item-description`, - formMessageId: `${id}-form-item-message`, - ...fieldState, - }; -}; - -type FormItemContextValue = { - id: string; -}; - -const FormItemContext = React.createContext( - {} as FormItemContextValue, -); - -const FormItem = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => { - const id = React.useId(); - - return ( - -
- - ); -}); -FormItem.displayName = "FormItem"; - -const FormLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { error, formItemId } = useFormField(); - - return ( -