From 9d573e6ad1dc3e4fb8d58fc9dce074789c00b2ac Mon Sep 17 00:00:00 2001 From: kiminchan Date: Thu, 23 Apr 2026 17:27:19 +0900 Subject: [PATCH 01/11] =?UTF-8?q?=EB=85=B8=ED=9B=84=EA=B4=80=EB=A1=9C=20ai?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=20=EA=B8=B0=EB=8A=A5=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=5FV1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/.env.local | 2 +- apps/sn/.next-dev.log | 32 + apps/sn/AGENTS.md | 107 +- apps/sn/app/(map-layout)/layout.tsx | 13 + .../(map-layout)/map/aged-pipe-popup/page.tsx | 10 + .../agedPipe/AgedPipeLayerSettingPanel.tsx | 81 ++ .../components/agedPipe/AgedPipePopupPage.tsx | 304 +++++ .../map/components/agedPipe/agedPipeUtils.ts | 236 ++++ .../map/components/common/table.ts | 1 + .../popupComponent/featureClickEventPopup.tsx | 24 +- apps/sn/app/(map-layout)/map/page.tsx | 403 +++++- .../aged-pipe/AgedPipeDetailPanel.tsx | 50 +- .../aged-pipe/AgedPipeFilterPanel.tsx | 116 +- .../aged-pipe/AgedPipeListModal.tsx | 11 +- .../app/html/(map-layout)/aged-pipe/page.tsx | 10 +- .../html/06. 노후관로관리_시나리오_v1.0.md | 59 +- .../html/old-pipe-component-sample/index.html | 1188 +++++++++++++++++ .../AI코딩하네스/기준문서/008-문서구조기준.md | 160 +++ .../기준문서/009-AI개발작업흐름.md | 244 ++++ apps/sn/docs/README.md | 40 + apps/sn/docs/노후관로관리/00-범위.md | 32 + .../01-기존화면기반-컴포넌트분해.md | 158 +++ .../노후관로관리/02-컴포넌트별-작업목록.md | 240 ++++ .../docs/노후관로관리/03-API응답필드매핑표.md | 73 + apps/sn/docs/노후관로관리/04-화면흐름.md | 40 + apps/sn/docs/노후관로관리/05-검증계획.md | 39 + apps/sn/docs/노후관로관리/06-검증결과.md | 114 ++ .../노후관로관리/07-백엔드-착수프롬프트.md | 55 + apps/sn/docs/노후관로관리/99-미확인사항.md | 27 + apps/sn/docs/노후관로관리/README.md | 32 + .../images/map/layericon/Layer_Select_BTN.png | Bin 0 -> 14434 bytes apps/sn/src/app/styles/base.css | 4 + docs/README.md | 35 + docs/검증/00-검증기준.md | 17 + docs/검증/01-브라우저검증.md | 19 + docs/검증/02-빌드검증.md | 27 + docs/검증/03-네트워크검증.md | 17 + docs/검증/99-검증로그.md | 6 + docs/검증/README.md | 23 + docs/구조/00-시스템맵.md | 27 + docs/구조/01-폴더구조.md | 42 + docs/구조/03-상태관리구조.md | 28 + docs/구조/04-화면흐름.md | 29 + docs/구조/99-미확인사항.md | 20 + docs/구조/README.md | 23 + docs/실행명세/00-범위.md | 31 + docs/실행명세/02-기능명세계획.md | 27 + docs/실행명세/03-기능목록.md | 11 + docs/실행명세/04-증적목록.md | 18 + docs/실행명세/99-진행로그.md | 6 + docs/실행명세/README.md | 22 + docs/응답표준화/00-범위.md | 23 + docs/응답표준화/01-API구조.md | 24 + docs/응답표준화/02-프론트엔드응답처리규칙.md | 22 + docs/응답표준화/03-프론트엔드전환계획.md | 22 + docs/응답표준화/04-프론트엔드회귀점검목록.md | 11 + docs/응답표준화/99-미확인사항.md | 14 + docs/응답표준화/README.md | 23 + docs/템플릿/API응답필드매핑_TEMPLATE.md | 28 + docs/템플릿/README.md | 12 + docs/템플릿/검증결과_TEMPLATE.md | 22 + docs/템플릿/기능문서_TEMPLATE.md | 33 + docs/템플릿/컴포넌트작업목록_TEMPLATE.md | 28 + .../business_components/src/ui/map/Gis.js | 17 +- .../business_components/src/ui/map/GisTree.js | 103 +- .../src/ui/map/lib/gis.select.js | 41 +- 66 files changed, 4568 insertions(+), 188 deletions(-) create mode 100644 apps/sn/.next-dev.log create mode 100644 apps/sn/app/(map-layout)/map/aged-pipe-popup/page.tsx create mode 100644 apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx create mode 100644 apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx create mode 100644 apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts create mode 100644 apps/sn/app/html/old-pipe-component-sample/index.html create mode 100644 apps/sn/docs/AI코딩하네스/기준문서/008-문서구조기준.md create mode 100644 apps/sn/docs/AI코딩하네스/기준문서/009-AI개발작업흐름.md create mode 100644 apps/sn/docs/README.md create mode 100644 apps/sn/docs/노후관로관리/00-범위.md create mode 100644 apps/sn/docs/노후관로관리/01-기존화면기반-컴포넌트분해.md create mode 100644 apps/sn/docs/노후관로관리/02-컴포넌트별-작업목록.md create mode 100644 apps/sn/docs/노후관로관리/03-API응답필드매핑표.md create mode 100644 apps/sn/docs/노후관로관리/04-화면흐름.md create mode 100644 apps/sn/docs/노후관로관리/05-검증계획.md create mode 100644 apps/sn/docs/노후관로관리/06-검증결과.md create mode 100644 apps/sn/docs/노후관로관리/07-백엔드-착수프롬프트.md create mode 100644 apps/sn/docs/노후관로관리/99-미확인사항.md create mode 100644 apps/sn/docs/노후관로관리/README.md create mode 100644 apps/sn/public/images/map/layericon/Layer_Select_BTN.png create mode 100644 docs/README.md create mode 100644 docs/검증/00-검증기준.md create mode 100644 docs/검증/01-브라우저검증.md create mode 100644 docs/검증/02-빌드검증.md create mode 100644 docs/검증/03-네트워크검증.md create mode 100644 docs/검증/99-검증로그.md create mode 100644 docs/검증/README.md create mode 100644 docs/구조/00-시스템맵.md create mode 100644 docs/구조/01-폴더구조.md create mode 100644 docs/구조/03-상태관리구조.md create mode 100644 docs/구조/04-화면흐름.md create mode 100644 docs/구조/99-미확인사항.md create mode 100644 docs/구조/README.md create mode 100644 docs/실행명세/00-범위.md create mode 100644 docs/실행명세/02-기능명세계획.md create mode 100644 docs/실행명세/03-기능목록.md create mode 100644 docs/실행명세/04-증적목록.md create mode 100644 docs/실행명세/99-진행로그.md create mode 100644 docs/실행명세/README.md create mode 100644 docs/응답표준화/00-범위.md create mode 100644 docs/응답표준화/01-API구조.md create mode 100644 docs/응답표준화/02-프론트엔드응답처리규칙.md create mode 100644 docs/응답표준화/03-프론트엔드전환계획.md create mode 100644 docs/응답표준화/04-프론트엔드회귀점검목록.md create mode 100644 docs/응답표준화/99-미확인사항.md create mode 100644 docs/응답표준화/README.md create mode 100644 docs/템플릿/API응답필드매핑_TEMPLATE.md create mode 100644 docs/템플릿/README.md create mode 100644 docs/템플릿/검증결과_TEMPLATE.md create mode 100644 docs/템플릿/기능문서_TEMPLATE.md create mode 100644 docs/템플릿/컴포넌트작업목록_TEMPLATE.md diff --git a/apps/sn/.env.local b/apps/sn/.env.local index 2bf41d1d9..ea85705ec 100644 --- a/apps/sn/.env.local +++ b/apps/sn/.env.local @@ -15,4 +15,4 @@ NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000 NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com # NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login -# NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883 +NEXT_PUBLIC_SEWERAGE_API_URL=http://localhost:8883 diff --git a/apps/sn/.next-dev.log b/apps/sn/.next-dev.log new file mode 100644 index 000000000..864e8810b --- /dev/null +++ b/apps/sn/.next-dev.log @@ -0,0 +1,32 @@ + +> monorepo@1.0.0 dev:sn C:\mindone_project\iipbs-workspace\frontend +> turbo run dev --filter=sn + +turbo 2.4.2 + + WARNING Issues occurred when constructing package graph. Turbo will function, but some features may not be available: + x Could not resolve workspaces. + `-> Lockfile not found at C:\mindone_project\iipbs-workspace\frontend\pnpm- + lock.yaml + +• Packages in scope: sn +• Running dev in 1 packages +• Remote caching disabled +sn:dev: cache bypass, force executing f85e1edb26cd7ef0 +sn:dev: +sn:dev: > sn@0.1.0 dev C:\mindone_project\iipbs-workspace\frontend\apps\sn +sn:dev: > next dev -p 3002 +sn:dev: +sn:dev: ▲ Next.js 15.3.8 +sn:dev: - Local: http://localhost:3002 +sn:dev: - Network: http://192.168.1.65:3002 +sn:dev: - Environments: .env.local +sn:dev: - Experiments (use with caution): +sn:dev: · proxyTimeout: 600000 +sn:dev: +sn:dev: ✓ Starting... +sn:dev: ✓ Ready in 3.3s +sn:dev: ○ Compiling /auth/login ... +sn:dev:  ELIFECYCLE  Command failed with exit code 3221225786. +sn:dev: ERROR: command finished with error: command (C:\mindone_project\iipbs-workspace\frontend\apps\sn) C:\Users\user\AppData\Local\pnpm\.tools\pnpm\10.25.0\bin\pnpm.CMD run dev exited (-1073741510) +^C ELIFECYCLE  Command failed with exit code 3221225786. diff --git a/apps/sn/AGENTS.md b/apps/sn/AGENTS.md index ba16d7f48..53507b711 100644 --- a/apps/sn/AGENTS.md +++ b/apps/sn/AGENTS.md @@ -29,6 +29,9 @@ Codex 작업 루트는 가능하면 `D:\workspace`로 잡고, 공통 시작 기 - `apps/sn/**` - `packages/common/business_components/**` +문서 작업은 예외적으로 프론트엔드 AI 개발 공통 문서에 한해 `FE_REPO_ROOT/docs/**`를 수정할 수 있다. +단, 특정 `sn` 화면/기능 산출물은 기본적으로 `apps/sn/docs/**`에 작성하고, 여러 앱이 함께 보는 기준 문서만 `FE_REPO_ROOT/docs/**`에 작성한다. + 위 두 경로를 제외한 나머지 경로는 모두 기본적으로 읽기 전용으로 간주한다. 예를 들어 아래 경로는 직접 수정하면 안 된다. @@ -43,7 +46,7 @@ Codex 작업 루트는 가능하면 `D:\workspace`로 잡고, 공통 시작 기 - `apps/st/**` - `apps/wr/**` - `apps/common/**` -- `docs/**` +- `docs/**` (단, 프론트엔드 AI 개발 공통 문서 예외는 위 기준을 따른다) - `packages/common/assets/**` - `packages/common/auth/**` - `packages/common/components/**` @@ -62,6 +65,28 @@ Codex 작업 루트는 가능하면 `D:\workspace`로 잡고, 공통 시작 기 --- +## 경로 및 참조 기준 + +문서와 작업 지시에서는 개발자별 로컬 절대경로 대신 아래 기준 변수를 우선 사용한다. + +- `WORKSPACE_ROOT`: 백엔드와 프론트엔드를 함께 두는 공통 작업 루트 +- `FE_REPO_ROOT`: `WORKSPACE_ROOT/frontend` +- `SN_APP_ROOT`: `FE_REPO_ROOT/apps/sn` +- `BUSINESS_COMPONENTS_ROOT`: `FE_REPO_ROOT/packages/common/business_components` + +화면, 페이지, 컴포넌트 참조는 아래 기준을 따른다. + +- 실제 `sn` 페이지/라우트: `SN_APP_ROOT/app/**` +- `sn` 앱 내부 전용 모듈: `SN_APP_ROOT/app/**`, `SN_APP_ROOT/src/**` +- `sn` 내부 검토용 HTML/시안: `SN_APP_ROOT/app/html/**` +- 공통 비즈니스 UI 컴포넌트: `BUSINESS_COMPONENTS_ROOT/src/**` +- 직접 수정 금지 공통 패키지: `FE_REPO_ROOT/packages/common/components/**`, `FE_REPO_ROOT/packages/common/auth/**`, `FE_REPO_ROOT/packages/common/network/**` + +코드 import 경로를 설명할 때는 실제 코드의 alias를 함께 확인한다. +예를 들어 `sn` 앱 내부는 `@/...`, 공통 비즈니스 컴포넌트는 `@common/business_components` 참조를 우선 확인한다. + +--- + ## Monorepo 이해 이 저장소는 pnpm workspace + Turborepo 기반 monorepo이며, 여러 회사/서비스가 함께 들어 있다. @@ -96,6 +121,7 @@ Codex 작업 루트는 가능하면 `D:\workspace`로 잡고, 공통 시작 기 - 백엔드(`sewerage-service`)와의 연동을 기준으로 화면, 폼, 조회/저장 흐름을 정확히 맞춘다. - `business_components` 수정이 필요할 때는 `sn` 요구사항을 만족하는 최소 범위로 수행한다. - monorepo 내 타 앱 영향은 최소화한다. +- 1차 개발 화면의 레이아웃, 컴포넌트 조합, 테이블/모달/폼/지도 패턴을 우선 재사용한다. --- @@ -105,14 +131,40 @@ Codex 작업 루트는 가능하면 `D:\workspace`로 잡고, 공통 시작 기 2. `apps/sn`과 `packages/common/business_components` 범위를 넘는 수정은 하지 않는다. 3. 공통 패키지 수정은 `sn` 요구사항을 충족하기 위한 최소 범위로 제한한다. 4. 다른 앱 전용 로직을 `business_components`에 넣지 않는다. -5. 불확실한 경우 먼저 계획을 제시한다. -6. 변경 전후 차이, 영향 범위, 검증 방법을 명확히 설명한다. -7. 기존 주석은 임의로 제거하지 않는다. -8. 복잡한 컴포넌트, 훅, 분기 로직, 상태 흐름, 데이터 변환 로직은 함수/메서드 단위 설명 주석을 충분히 작성한다. -9. 한글이 깨지지 않도록 반드시 UTF-8로 유지한다. -10. 작업 범위를 넓히기보다 `sn` 내부 해결을 우선한다. -11. 백엔드 API 응답과 실제 브라우저 동작까지 함께 확인하는 것을 기본으로 한다. -12. 작업 문서는 `apps/sn/docs/` 하위에 작업 단위 폴더를 만들어 기록한다. +5. 신규 화면을 만들더라도 먼저 기존 `sn` 페이지와 `business_components`에서 동일하거나 유사한 컴포넌트, 레이아웃, 테이블, 모달, 입력 폼, 지도 패널 패턴을 찾는다. +6. 기존 1차 디자인과 다른 독립 UI 스타일, 별도 디자인 시스템, 임의 색상/간격/컴포넌트 구조를 새로 만들지 않는다. +7. 기존 컴포넌트로 표현이 어려운 경우에는 먼저 어떤 컴포넌트가 부족한지, `SN_APP_ROOT`에서 조합 가능한지, `BUSINESS_COMPONENTS_ROOT` 수정이 필요한지 구분해서 보고한다. +8. 불확실한 경우 먼저 계획을 제시한다. +9. 변경 전후 차이, 영향 범위, 검증 방법을 명확히 설명한다. +10. 기존 주석은 임의로 제거하지 않는다. +11. 복잡한 컴포넌트, 훅, 분기 로직, 상태 흐름, 데이터 변환 로직은 함수/메서드 단위 설명 주석을 충분히 작성한다. +12. 한글이 깨지지 않도록 반드시 UTF-8로 유지한다. +13. 작업 범위를 넓히기보다 `sn` 내부 해결을 우선한다. +14. 백엔드 API 응답과 실제 브라우저 동작까지 함께 확인하는 것을 기본으로 한다. +15. 작업 문서는 `apps/sn/docs/` 하위에 작업 단위 폴더를 만들어 기록한다. + +--- + +## 기존 컴포넌트 사용 기준 + +2차 고도화 화면은 1차 개발 산출물을 기반으로 확장한다. +따라서 화면을 새로 디자인하기보다 기존 컴포넌트를 조합하고 필요한 데이터/API 연동만 보강한다. + +우선 재사용 대상은 아래 순서로 확인한다. + +1. `SN_APP_ROOT/app/**`에 이미 존재하는 동일 업무 화면, 지도 화면, 검색 모달, 상세 패널, 상태 관리 패턴 +2. `SN_APP_ROOT/src/**`에 있는 앱 내부 공통 모듈, feature/shared 구조 +3. `BUSINESS_COMPONENTS_ROOT/src/**`에 있는 공통 비즈니스 UI, 지도, 테이블, 엑셀, 폼 관련 컴포넌트 + +아래 작업은 금지한다. + +- 실제 서비스 화면에 독립 정적 HTML 디자인을 그대로 반영 +- 기존 테이블/모달/폼/버튼/지도 패널이 있는데 별도 UI를 새로 구현 +- 기존 1차 화면과 다른 신규 색상 체계, 카드형 레이아웃, 랜딩형 디자인을 임의 적용 +- `@common/components`, `@common/auth`, `@common/network` 등 수정 금지 패키지를 직접 변경 + +`apps/sn/app/html/**`의 정적 HTML은 요구사항 해석, 퍼블리싱 비교, 로직 검토용으로만 사용한다. +실제 개발로 전환할 때는 반드시 기존 React/Next 페이지와 `business_components` 기반 컴포넌트 조합으로 다시 구현한다. --- @@ -236,20 +288,51 @@ pnpm turbo run build --filter="...[origin/main]" ## 문서 경로 기준 -`sn` 관련 문서는 앱 내부 문서로 관리하는 것을 기본으로 한다. +프론트엔드 AI 개발 공통 문서는 백엔드 문서 구조와 동일한 방식으로 `FE_REPO_ROOT/docs/**`에서 관리할 수 있다. +`sn` 특정 화면/기능 산출물은 앱 내부 문서로 관리하는 것을 기본으로 한다. -권장 경로: +프론트엔드 공통 AI 개발 문서 권장 경로: + +- `FE_REPO_ROOT/docs/README.md` +- `FE_REPO_ROOT/docs/구조/00-시스템맵.md` +- `FE_REPO_ROOT/docs/구조/01-폴더구조.md` +- `FE_REPO_ROOT/docs/구조/03-상태관리구조.md` +- `FE_REPO_ROOT/docs/구조/04-화면흐름.md` +- `FE_REPO_ROOT/docs/구조/99-미확인사항.md` +- `FE_REPO_ROOT/docs/실행명세/` +- `FE_REPO_ROOT/docs/응답표준화/` +- `FE_REPO_ROOT/docs/검증/` +- `FE_REPO_ROOT/docs/템플릿/` +- `FE_REPO_ROOT/docs/AI코딩하네스/` + +프론트엔드 공통 문서는 백엔드 문서 체계처럼 주제별 디렉터리로 나눈다. +예를 들어 화면 실행/브라우저 검증 흐름은 `실행명세`, API 응답 사용/표시/오류 처리 기준은 `응답표준화`, 저장소와 화면 구조 기준은 `구조`에 둔다. +단, `FE_REPO_ROOT/docs/**`에는 프론트엔드 공통 기준만 작성하고, 백엔드 Controller/Service/Repository/DB 상세는 작성하지 않는다. + +`sn` 관련 문서 권장 경로: - `apps/sn/docs/` +- `apps/sn/docs/README.md` - `apps/sn/docs/AI코딩하네스/` - `apps/sn/docs/응답표준화/` - `apps/sn/docs/검증/` +- `apps/sn/docs/{기능명}/README.md` +- `apps/sn/docs/{기능명}/00-범위.md` +- `apps/sn/docs/{기능명}/01-기존화면기반-컴포넌트분해.md` +- `apps/sn/docs/{기능명}/02-컴포넌트별-작업목록.md` +- `apps/sn/docs/{기능명}/03-API응답필드매핑표.md` +- `apps/sn/docs/{기능명}/04-화면흐름.md` +- `apps/sn/docs/{기능명}/05-검증계획.md` +- `apps/sn/docs/{기능명}/06-검증결과.md` +- `apps/sn/docs/{기능명}/99-미확인사항.md` 1차 프로젝트 당시에는 AI 코딩 문서 체계를 사용하지 않았으므로, 신규 문서를 `phase2`처럼 별도 단계 폴더로 분리하지 않고 `apps/sn/docs/` 바로 아래 주제별 한글 폴더로 관리한다. 문서 작업 시에도 `sn` 범위를 벗어나는 앱 문서는 임의로 수정하지 않는다. -루트 `docs/**`는 다른 회사/서비스 리소스를 포함할 수 있으므로 `sn` 작업 중 임의로 수정하지 않는다. 루트 `docs/**` 변경이 필요해 보이면 변경 이유, 영향 범위, `apps/sn/docs/**` 대체 가능 여부를 먼저 정리하고 사용자 승인을 받는다. +`FE_REPO_ROOT/docs/**`는 여러 앱이 함께 보는 프론트엔드 AI 개발 기준 문서로만 사용한다. +특정 회사/서비스 앱의 구현 상세, 화면별 산출물, 검증 결과는 해당 앱 문서 위치에 작성한다. +`FE_REPO_ROOT/docs/**` 변경이 필요한 경우에는 백엔드 문서 구조와 맞추기 위한 목적, 영향 범위, `apps/sn/docs/**` 대체 가능 여부를 먼저 판단한다. --- diff --git a/apps/sn/app/(map-layout)/layout.tsx b/apps/sn/app/(map-layout)/layout.tsx index f58e67489..01d2b166a 100644 --- a/apps/sn/app/(map-layout)/layout.tsx +++ b/apps/sn/app/(map-layout)/layout.tsx @@ -33,6 +33,15 @@ export default function MapLayout({ const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo); const { fetchCtpv, fetchSgg, fetchBIZ_SE_CD, fetchBIZ_TY_CD } = useStore(); + const isPopupPage = pathname === "/map/aged-pipe-popup"; + + useEffect(() => { + document.body.classList.add("sn-map-page-lock"); + + return () => { + document.body.classList.remove("sn-map-page-lock"); + }; + }, []); useEffect(() => { fetchCtpv(); @@ -46,6 +55,10 @@ export default function MapLayout({ router.push(item.urlPath); }; + if (isPopupPage) { + return <>{children}; + } + return ( + + + ); +} diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx new file mode 100644 index 000000000..9162074f4 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { AgedPipeFilters } from "./agedPipeUtils"; + +type Props = { + filters: AgedPipeFilters; + onFiltersChange: (filters: AgedPipeFilters) => void; + onApply: (filters?: AgedPipeFilters) => void; +}; + +export default function AgedPipeLayerSettingPanel({ + filters, + onFiltersChange, + onApply, +}: Props) { + const [isCollapsed, setIsCollapsed] = useState(false); + const [elapsedYearsText, setElapsedYearsText] = useState(String(filters.elapsedYears ?? "")); + + useEffect(() => { + setElapsedYearsText(String(filters.elapsedYears ?? "")); + }, [filters.elapsedYears]); + + const commitElapsedYears = () => { + const nextFilters = { + ...filters, + elapsedYears: elapsedYearsText === "" ? ("" as unknown as number) : Number(elapsedYearsText), + }; + onFiltersChange(nextFilters); + onApply(nextFilters); + }; + + return ( +
+
+ + 노후관로 설정 +
+ + {!isCollapsed && ( +
+
+ + event.currentTarget.select()} + onChange={(event) => { + const digits = event.target.value.replace(/[^0-9]/g, ""); + setElapsedYearsText(digits); + }} + onKeyDown={(event) => { + if (event.key === "Enter") commitElapsedYears(); + }} + /> + +
+
+ )} +
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx new file mode 100644 index 000000000..efea0197c --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx @@ -0,0 +1,304 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { Button, Input } from "@common/components"; +import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; +import { getData } from "@/components/lib/api"; +import useStore from "@/store/useStore"; +import LoadingOverlay from "@/components/ui/LoadingBar"; +import { + AGED_PIPE_DEFAULT_FILTERS, + AGED_PIPE_EXCEL_HEADER, + AGED_PIPE_LAYER_TYPE, + AGED_PIPE_LIST_HEADLIST, + AGED_PIPE_PAGE_SIZE, + buildAgedPipeCqlFilters, + normalizeAgedPipeFilters, + toDisplayPipeRow, + validateElapsedYears, + type AgedPipeFilters, + type AgedPipeRow, +} from "./agedPipeUtils"; + +const POPUP_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT"; +const POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE"; +const POPUP_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC"; + +export default function AgedPipePopupPage() { + const searchParams = useSearchParams(); + const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); + const [filters, setFilters] = useState(() => + normalizeAgedPipeFilters({ + elapsedYears: Number(searchParams.get("elapsedYears") ?? AGED_PIPE_DEFAULT_FILTERS.elapsedYears), + ctpvCd: searchParams.get("ctpvCd") ?? AGED_PIPE_DEFAULT_FILTERS.ctpvCd, + sggCd: searchParams.get("sggCd") ?? AGED_PIPE_DEFAULT_FILTERS.sggCd, + }), + ); + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [isLoading, setIsLoading] = useState(false); + + const sggOptions = useMemo( + () => + filters.ctpvCd === "0" + ? sgg + : sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))), + [filters.ctpvCd, sgg], + ); + + useEffect(() => { + if (ctpv.length === 0) fetchCtpv(); + if (sgg.length === 0) fetchSgg(); + }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); + + const fetchRows = useCallback( + async (page: number, nextFilters: AgedPipeFilters, size: number) => { + const normalized = normalizeAgedPipeFilters(nextFilters); + const queryParams = new URLSearchParams({ + lyrTypeNm: AGED_PIPE_LAYER_TYPE, + page: String(page - 1), + size: String(size), + sort: "string", + }); + const cqlFilters = buildAgedPipeCqlFilters(normalized); + if (cqlFilters.length > 0) { + queryParams.append("cqlFiter", cqlFilters.join(" and ")); + } + + const response = await getData(`v1/fclts?${queryParams.toString()}`); + const responseRows = Array.isArray(response?.data) ? response.data : []; + + return { + rows: responseRows.map((item: Record, index: number) => + toDisplayPipeRow(item, index, page, ctpv, sgg), + ), + total: Number(response?.count ?? 0), + }; + }, + [ctpv, sgg], + ); + + const loadRows = useCallback( + async (page = 1, nextFilters = filters) => { + setIsLoading(true); + try { + const response = await fetchRows(page, nextFilters, AGED_PIPE_PAGE_SIZE); + setRows(response.rows); + setTotal(response.total); + setCurrentPage(page); + } catch (error) { + console.error("노후관로 목록 조회 중 오류:", error); + setRows([]); + setTotal(0); + } finally { + setIsLoading(false); + } + }, + [fetchRows, filters], + ); + + useEffect(() => { + loadRows(1, filters); + // 최초 로딩만 수행한다. 이후 조회는 버튼/페이지 변경으로 처리한다. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const handleFilterSyncMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== POPUP_FILTER_SYNC_MESSAGE) return; + if (!event.data?.filters) return; + + const normalized = normalizeAgedPipeFilters(event.data.filters as AgedPipeFilters); + setFilters(normalized); + setSelectedIndex(-1); + loadRows(1, normalized); + }; + + window.addEventListener("message", handleFilterSyncMessage); + return () => window.removeEventListener("message", handleFilterSyncMessage); + }, [loadRows]); + + const handleSearch = (page = 1) => { + if (!validateElapsedYears(Number(filters.elapsedYears))) { + alert("경과년도는 1년~100년 범위 내에서 입력해주세요."); + return; + } + + const normalized = normalizeAgedPipeFilters(filters); + setFilters(normalized); + loadRows(page, normalized); + }; + + const handleRowClick = (index?: number) => { + if (typeof index !== "number") return; + const row = rows[index]; + if (!row) return; + + setSelectedIndex(index); + window.opener?.postMessage( + { + type: POPUP_ROW_SELECT_MESSAGE, + row, + }, + window.location.origin, + ); + }; + + const handleDownload = async () => { + if (total === 0) { + alert("다운로드할 데이터가 없습니다."); + return; + } + + try { + setIsLoading(true); + const response = await fetchRows(1, filters, 10000); + await downloadExcelWithKoreanHeaders({ + data: response.rows as JSON[], + fileName: `노후관로_현황_${new Date().toISOString().slice(0, 10)}`, + sheetNameArray: ["노후관로 현황"], + headerRange: "A1:J1", + headerMapping: AGED_PIPE_EXCEL_HEADER, + }); + } catch (error) { + console.error("노후관로 목록 다운로드 중 오류:", error); + alert("노후관로 목록 다운로드 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const handleMinimize = () => { + window.opener?.postMessage( + { + type: POPUP_MINIMIZE_MESSAGE, + filters: normalizeAgedPipeFilters(filters), + total, + }, + window.location.origin, + ); + window.close(); + }; + + return ( +
+ +
+

노후관로 현황

+
+ +
+
+ +
+
+ + + + + + +
+ + +
+
+ + + headName={<>노후관로 조회 결과 총 {total.toLocaleString()}건} + tableData={rows} + headlist={AGED_PIPE_LIST_HEADLIST} + pageSize={10} + total={total} + cellClick={(index) => handleRowClick(index)} + onPageChange={(page) => handleSearch(page)} + curPage={currentPage} + index={selectedIndex} + widthFixed={true} + /> +
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts new file mode 100644 index 000000000..62bccc416 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts @@ -0,0 +1,236 @@ +"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, +} from "../common/table"; + +export const AGED_PIPE_LAYER_TYPE = "tb_ms_swage_ppl"; +export const AGED_PIPE_DEFAULT_ELAPSED_YEARS = 30; +export const AGED_PIPE_PAGE_SIZE = 10; +export const AGED_PIPE_MIN_ZOOM = 14; +export const AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES = ["19000101", "19990101"]; + +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; + +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: AGED_PIPE_DEFAULT_ELAPSED_YEARS, + 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: "경과년도", + ctpvNm: "시도", + sggNm: "시군구", +}; + +const pad2 = (value: number) => String(value).padStart(2, "0"); + +const normalizeYmd = (value: unknown) => + String(value ?? "").replace(/[^0-9]/g, "").slice(0, 8); + +const getToday = () => new Date(); + +export const normalizeAgedPipeFilters = (filters: AgedPipeFilters): AgedPipeFilters => { + const elapsedYears = Math.trunc(Number(filters.elapsedYears)); + + return { + ...filters, + elapsedYears: + Number.isFinite(elapsedYears) && elapsedYears >= 1 && elapsedYears <= 100 + ? elapsedYears + : AGED_PIPE_DEFAULT_ELAPSED_YEARS, + }; +}; + +export const validateElapsedYears = (value: number) => + Number.isInteger(value) && value >= 1 && value <= 100; + +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())}`; +}; + +export const getElapsedYears = (value: unknown) => { + const normalized = normalizeYmd(value); + if (normalized.length < 4 || isInvalidInstallYmd(value)) 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 isInvalidInstallYmd = (value: unknown) => { + const normalized = normalizeYmd(value); + return ( + normalized.length < 8 || + AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES.some((prefix) => normalized.startsWith(prefix)) + ); +}; + +export const getElapsedYearsDisplayText = (value: unknown) => { + const elapsedYears = getElapsedYears(value); + return elapsedYears == null ? "확인불가" : `${elapsedYears}년`; +}; + +export const buildAgedPipeCqlFilters = (filters: AgedPipeFilters) => { + const normalized = normalizeAgedPipeFilters(filters); + const cqlFilters = [ + "instl_ymd IS NOT NULL", + ...AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES.map((prefix) => `instl_ymd NOT LIKE '${prefix}%'`), + `instl_ymd <= '${getCutoffYmd(normalized.elapsedYears)}'`, + ]; + + if (normalized.sggCd !== "0") { + cqlFilters.push(`pbadms_emd_cd LIKE '${normalized.sggCd}%'`); + } else if (normalized.ctpvCd !== "0") { + cqlFilters.push(`pbadms_emd_cd LIKE '${normalized.ctpvCd}%'`); + } + + return cqlFilters; +}; + +const getRegionCode = (source: PipeSource) => + String(source.pbadmsEmdCd ?? source.pbadms_emd_cd ?? "").trim(); + +export const matchesAgedPipeFilters = (source: PipeSource, filters: AgedPipeFilters) => { + const normalized = normalizeAgedPipeFilters(filters); + const instlYmd = normalizeYmd(source.instl_ymd ?? source.instlYmd); + if (!instlYmd || isInvalidInstallYmd(instlYmd) || instlYmd > getCutoffYmd(normalized.elapsedYears)) return false; + + const regionCode = getRegionCode(source); + if (normalized.sggCd !== "0") return regionCode.startsWith(normalized.sggCd); + if (normalized.ctpvCd !== "0") return regionCode.startsWith(normalized.ctpvCd); + return true; +}; + +const resolveRegionName = ( + code: string, + directName: unknown, + options: SelectDataType[], + prefixLength: number, +) => { + const direct = String(directName ?? "").trim(); + if (direct) return direct; + + const prefix = code.slice(0, prefixLength); + return options.find((item) => String(item.val).trim() === prefix)?.text ?? ""; +}; + +export const toDisplayPipeRow = ( + source: PipeSource, + index: number, + page: number, + ctpvOptions: SelectDataType[], + sggOptions: SelectDataType[], +): AgedPipeRow => { + const formatted = formatDisplayData(source) as PipeSource; + const codePrefix = getRegionCode(source); + const instlYmd = formatted.instlYmd ?? source.instlYmd ?? formatted.instl_ymd ?? source.instl_ymd; + const invalidInstallYmd = isInvalidInstallYmd(source.instlYmd ?? source.instl_ymd ?? instlYmd); + 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 ?? "-"; + const fcltMngNo = String(formatted.fcltMngNo ?? source.fcltMngNo ?? source.fclt_mng_no ?? "-"); + + return { + ...source, + ...formatted, + fid: String(source.fid ?? ""), + lyrTypeNm: String(source.lyrTypeNm ?? AGED_PIPE_LAYER_TYPE), + rowNumber: (page - 1) * AGED_PIPE_PAGE_SIZE + index + 1, + fcltMngNo, + swtrmtFcltCd: source.swtrmtFcltCd ?? fcltMngNo, + instlYmd: invalidInstallYmd ? "확인불가" : 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 isAgedPipeLayerNode = (node: any) => { + const data = node?.data ?? node; + const layerName = String(data?.lyrNm ?? node?.text ?? ""); + const layerTypeName = String(data?.lyrTyNm ?? ""); + + return ( + layerTypeName === "tb_old_pipe" || + layerName.includes("노후관로") + ); +}; diff --git a/apps/sn/app/(map-layout)/map/components/common/table.ts b/apps/sn/app/(map-layout)/map/components/common/table.ts index 4e501fb20..81e14a83f 100644 --- a/apps/sn/app/(map-layout)/map/components/common/table.ts +++ b/apps/sn/app/(map-layout)/map/components/common/table.ts @@ -3,6 +3,7 @@ export const MsSwagePplCol = [ { id: 'fclt_mng_no', title: '시설관리번호' }, { id: 'inst_mng_no', title: '기관관리번호' }, { id: 'instl_ymd', title: '설치일자' }, + { id: 'elapsedYears', title: '경과년도' }, { id: 'mapsht_no', title: '도엽번호' }, { id: 'pdmt_vl', title: '관경(mm)' }, { id: 'prlg_vl', title: '연장(m)' }, diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx index 11e61b006..81be89a5d 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx @@ -41,6 +41,7 @@ import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue'; import { codeMapper } from "../common/codeMapper"; import LoadingOverlay from '@/components/ui/LoadingBar'; import { searchUpperPpls } from "./smh"; +import { getElapsedYearsDisplayText, isInvalidInstallYmd } from "../agedPipe/agedPipeUtils"; const linkedFacilityHighlightStyle = new Style({ image: new CircleStyle({ @@ -63,7 +64,6 @@ const linkedFacilityHighlightStyle = new Style({ zIndex: 9999, }); - const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData: any }) => { const { gisMap } = useGisContext() as unknown as { checkedLayerIds: React.MutableRefObject; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; }; const [layerNm, setLayerNm] = useState(""); // 레이어 이름 상태 @@ -88,7 +88,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const linkedFacilityOriginalStyleRef = useRef(null); // 🎯 관로관리번호 추출 - const pplMngNo = featureData?.values_?.fclt_mng_no || ""; + const pplMngNo = featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || ""; const featureId = typeof featureData.getId === 'function' ? featureData.getId() @@ -258,7 +258,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, case 'tb_ms_swage_ppl': { result = '하수관로'; setHeadlist(MsSwagePplCol2) - setLayerSpecificNm(featureData.swtrmtFcltCd); + setLayerSpecificNm(featureData.fcltMngNo ?? featureData.swtrmtFcltCd); break; } case 'tb_ms_swage_mhl': { @@ -341,6 +341,14 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, // 🎯 기본 데이터 포맷팅 const formattedData = formatDisplayData(data); + if (selectedLayerName === '하수관로') { + const installYmd = data.instl_ymd ?? data.instlYmd; + formattedData.elapsedYears = getElapsedYearsDisplayText(installYmd); + if (isInvalidInstallYmd(installYmd)) { + formattedData.instl_ymd = "확인불가"; + formattedData.instlYmd = "확인불가"; + } + } setFetchData(formattedData); setTableData(formattedData); @@ -407,8 +415,8 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, // 자체입력정보 API 엔드포인트 선택 let apiEndpoint = ''; - const fcltMngNo = featureData?.values_?.fclt_mng_no || null; - const mngInstCd = featureData?.values_?.mng_inst_cd || null; + const fcltMngNo = featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || null; + const mngInstCd = featureData?.values_?.mng_inst_cd || featureData?.mngInstCd || null; if (selectedLayerName === '하수관로') apiEndpoint = `v1/fcltppl/${fcltMngNo}/${mngInstCd}`; else if (selectedLayerName === '우수토실') apiEndpoint = `v1/fcltstofcbr/${fcltMngNo}/${mngInstCd}`; else if (selectedLayerName === '하수펌프장') apiEndpoint = `v1/fcltpump/${fcltMngNo}/${mngInstCd}`; @@ -598,7 +606,11 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
기본정보 경과년도 {tableData.elapsedYears} + : '기본정보' + } tableName1={layerNm === '중점우수토실' ? "측정지점" : undefined} tableData={[tableData]} headlist={headlist} diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 6c684b642..cc09bcbf1 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -18,33 +18,130 @@ "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 { getData, getRawJson } from "../../components/lib/api"; +import { useCallback, useEffect, useMemo, 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 { Button } from "@common/components"; import useSearchModalStore from "@/store/useSearchModalStore"; +import useStore from "@/store/useStore"; +import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; +import AgedPipeLayerSettingPanel from "./components/agedPipe/AgedPipeLayerSettingPanel"; +import { + AGED_PIPE_EXCEL_HEADER, + AGED_PIPE_MIN_ZOOM, + AGED_PIPE_LAYER_TYPE, + AGED_PIPE_PAGE_SIZE, + buildAgedPipeCqlFilters, + isAgedPipeLayerNode, + matchesAgedPipeFilters, + normalizeAgedPipeFilters, + toDisplayPipeRow, + validateElapsedYears, + type AgedPipeFilters, + type AgedPipeRow, + AGED_PIPE_DEFAULT_FILTERS, +} from "./components/agedPipe/agedPipeUtils"; + +const AGED_PIPE_CONTROL_NODE_ID = "SN_AGED_PIPE"; +const AGED_PIPE_HIGHLIGHT_LAYER_ID = "SN_AGED_PIPE_HIGHLIGHT"; +const AGED_PIPE_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT"; +const AGED_PIPE_POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE"; +const AGED_PIPE_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC"; + +const AGED_PIPE_FOCUS_PADDING: [number, number, number, number] = [120, 120, 120, 120]; +const DEFAULT_FEATURE_FIT_PADDING: [number, number, number, number] = [50, 50, 50, 50]; + +const appendAgedPipeControlNode = (data: any[]) => { + if (!Array.isArray(data)) return data; + if (data.some((item) => String(item.lyrSn) === AGED_PIPE_CONTROL_NODE_ID || item.lyrNm === "노후관로")) { + return data; + } + + return [ + ...data, + { + lyrSn: AGED_PIPE_CONTROL_NODE_ID, + id: AGED_PIPE_CONTROL_NODE_ID, + lyrNm: "노후관로", + upperLyrSn: null, + upId: null, + sortOrdr: "4", + drawOrdr: null, + flterSql: null, + gobjTy: "", + lyrTyNm: "tb_old_pipe", + useYn: "Y", + minViewSize: null, + iconNm: null, + iconSize: null, + lblAtrbNm: null, + fontNm: null, + fontIntnColor: null, + fontExtnColor: null, + fontExtnWdth: null, + lnWdth: null, + lnColor: null, + polgnColor: null, + initEprssYn: "Y", + clickPsblYn: "N", + exprsYn: "Y", + cmncNm: "all", + srvcNm: "wfs", + actlLyrYn: "N", + clckZoom: null, + children: null, + }, + ]; +}; const Map = () => { const { isSearchModalOpen, setIsSearchModalOpen } = useSearchModalStore(); - const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정 + const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정 const gisMaps = useRef(null); const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태 - const [featureData, setFeatureData] = useState(null); // 선택된 피쳐 데이터 + const [featureData, setFeatureData] = useState(null); // 선택된 피쳐 데이터 const [showWaterDepth, setShowWaterDepth] = useState(false); // WaterDepth 표시 여부 const [isPlaying, setIsPlaying] = useState(false); + const [mapReady, setMapReady] = useState(false); + const [isAgedPipeChecked, setIsAgedPipeChecked] = useState(false); + const [draftAgedPipeFilters, setDraftAgedPipeFilters] = + useState(AGED_PIPE_DEFAULT_FILTERS); + const [appliedAgedPipeFilters, setAppliedAgedPipeFilters] = + useState(AGED_PIPE_DEFAULT_FILTERS); + const [isAgedPipeListOpen, setIsAgedPipeListOpen] = useState(false); + const [isAgedPipeListMinimized, setIsAgedPipeListMinimized] = useState(false); + const [agedPipeRows, setAgedPipeRows] = useState([]); + const [agedPipeTotalCount, setAgedPipeTotalCount] = useState(0); + const [agedPipeCurrentPage, setAgedPipeCurrentPage] = useState(1); + const [isAgedPipeListLoading, setIsAgedPipeListLoading] = useState(false); + const { ctpv, sgg } = useStore(); + const shouldFocusAgedPipeOnNextSelectRef = useRef(false); + const agedPipePopupWindowRef = useRef(null); useEffect(() => { fetchData(); }, []); + useEffect(() => { + const timer = window.setInterval(() => { + if (gisMaps.current) { + setMapReady(true); + window.clearInterval(timer); + } + }, 300); + + return () => window.clearInterval(timer); + }, []); + const fetchData = async () => { try { const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK"); - setTreeData(data); + setTreeData(appendAgedPipeControlNode(data)); } catch (error) { console.error('JSON parsing error:', error); // JSON 파싱에 실패한 경우, 원본 텍스트 또는 null 반환 @@ -97,13 +194,23 @@ const Map = () => { if (geometry) { let extent = geometry.getExtent(); + const layerTypeName = typeof foundFeature.getId === "function" + ? String(foundFeature.getId()).split(".")[0] + : data?.lyrTypeNm; + const shouldUseAgedPipeFocus = + shouldFocusAgedPipeOnNextSelectRef.current && layerTypeName === AGED_PIPE_LAYER_TYPE; + map.getView().fit(extent, { duration: 300, - padding: [50, 50, 50, 50], - maxZoom: 16, + padding: shouldUseAgedPipeFocus ? AGED_PIPE_FOCUS_PADDING : DEFAULT_FEATURE_FIT_PADDING, + maxZoom: shouldUseAgedPipeFocus ? 18 : 16, constrainResolution: true, nearest: true, }); + + if (shouldUseAgedPipeFocus) { + shouldFocusAgedPipeOnNextSelectRef.current = false; + } } select.selectFeature(foundFeature, true); } else { @@ -116,6 +223,181 @@ const Map = () => { }; const facilityTypes = [{ text: "하수처리시설", val: "tb_il_swage_prcs_fclt" }]; + + const fetchAgedPipeRows = useCallback( + async (page: number, filters: AgedPipeFilters, size: number) => { + const normalized = normalizeAgedPipeFilters(filters); + const queryParams = new URLSearchParams({ + lyrTypeNm: AGED_PIPE_LAYER_TYPE, + page: String(page - 1), + size: String(size), + sort: "string", + }); + + const cqlFilters = buildAgedPipeCqlFilters(normalized); + 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, index: number) => + toDisplayPipeRow(item, index, page, ctpv, sgg), + ), + total: Number(response?.count ?? 0), + }; + }, + [ctpv, sgg], + ); + + const loadAgedPipeList = useCallback( + async (page = 1, filters = appliedAgedPipeFilters) => { + setIsAgedPipeListLoading(true); + try { + const response = await fetchAgedPipeRows(page, filters, AGED_PIPE_PAGE_SIZE); + setAgedPipeRows(response.rows); + setAgedPipeTotalCount(response.total); + setAgedPipeCurrentPage(page); + } catch (error) { + console.error("노후관로 목록 조회 중 오류:", error); + setAgedPipeRows([]); + setAgedPipeTotalCount(0); + } finally { + setIsAgedPipeListLoading(false); + } + }, + [appliedAgedPipeFilters, fetchAgedPipeRows], + ); + + const syncAgedPipePopupFilters = useCallback((filters: AgedPipeFilters) => { + const popup = agedPipePopupWindowRef.current; + if (!popup || popup.closed) { + agedPipePopupWindowRef.current = null; + return; + } + + popup.postMessage( + { + type: AGED_PIPE_FILTER_SYNC_MESSAGE, + filters: normalizeAgedPipeFilters(filters), + }, + window.location.origin, + ); + }, []); + + const handleApplyAgedPipeFilters = useCallback((filters = draftAgedPipeFilters) => { + if (!validateElapsedYears(Number(filters.elapsedYears))) { + alert("경과년도는 1년~100년 범위 내에서 입력해주세요."); + return; + } + + const normalized = normalizeAgedPipeFilters(filters); + setDraftAgedPipeFilters(normalized); + setAppliedAgedPipeFilters(normalized); + syncAgedPipePopupFilters(normalized); + + if (isAgedPipeListOpen) { + loadAgedPipeList(1, normalized); + } + }, [draftAgedPipeFilters, isAgedPipeListOpen, loadAgedPipeList, syncAgedPipePopupFilters]); + + const openAgedPipePopupWindow = useCallback(() => { + const popupParams = new URLSearchParams({ + elapsedYears: String(appliedAgedPipeFilters.elapsedYears), + ctpvCd: appliedAgedPipeFilters.ctpvCd, + sggCd: appliedAgedPipeFilters.sggCd, + }); + const popup = window.open( + `/map/aged-pipe-popup?${popupParams.toString()}`, + "sn-aged-pipe-list", + "popup=yes,width=1180,height=760,left=120,top=80,resizable=yes,scrollbars=no", + ); + + if (!popup) { + alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); + return null; + } + + agedPipePopupWindowRef.current = popup; + setIsAgedPipeListOpen(true); + setIsAgedPipeListMinimized(false); + return popup; + }, [appliedAgedPipeFilters]); + + const openAgedPipeList = useCallback(() => { + openAgedPipePopupWindow(); + }, [openAgedPipePopupWindow]); + + const handleAgedPipeRowDoubleClick = useCallback( + async (row: AgedPipeRow) => { + try { + shouldFocusAgedPipeOnNextSelectRef.current = true; + const core = GisApp.Module.layer as any; + await core.showSearchResultsFromGeoJSON(AGED_PIPE_LAYER_TYPE, row.fid); + } catch (error) { + shouldFocusAgedPipeOnNextSelectRef.current = false; + console.error("노후관로 위치 이동 중 오류:", error); + } + + setFeatureData(row as any); + setIsOpen(true); + setIsSearchModalOpen(false); + }, + [setIsSearchModalOpen], + ); + + const handleAgedPipeDownload = useCallback(async () => { + if (agedPipeTotalCount === 0) { + alert("다운로드할 데이터가 없습니다."); + return; + } + + try { + setIsAgedPipeListLoading(true); + const response = await fetchAgedPipeRows(1, appliedAgedPipeFilters, 10000); + await downloadExcelWithKoreanHeaders({ + data: response.rows as JSON[], + fileName: `노후관로_현황_${new Date().toISOString().slice(0, 10)}`, + sheetNameArray: ["노후관로 현황"], + headerRange: "A1:J1", + headerMapping: AGED_PIPE_EXCEL_HEADER, + }); + } catch (error) { + console.error("노후관로 목록 다운로드 중 오류:", error); + alert("노후관로 목록 다운로드 중 오류가 발생했습니다."); + } finally { + setIsAgedPipeListLoading(false); + } + }, [agedPipeTotalCount, appliedAgedPipeFilters, fetchAgedPipeRows]); + + useEffect(() => { + const handleAgedPipePopupMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + + if (event.data?.type === AGED_PIPE_ROW_SELECT_MESSAGE) { + if (!event.data?.row) return; + handleAgedPipeRowDoubleClick(event.data.row as AgedPipeRow); + return; + } + + if (event.data?.type === AGED_PIPE_POPUP_MINIMIZE_MESSAGE) { + agedPipePopupWindowRef.current = null; + if (event.data?.filters) { + const normalized = normalizeAgedPipeFilters(event.data.filters as AgedPipeFilters); + setDraftAgedPipeFilters(normalized); + setAppliedAgedPipeFilters(normalized); + } + setAgedPipeTotalCount(Number(event.data?.total ?? agedPipeTotalCount)); + setIsAgedPipeListMinimized(true); + } + }; + + window.addEventListener("message", handleAgedPipePopupMessage); + return () => window.removeEventListener("message", handleAgedPipePopupMessage); + }, [agedPipeTotalCount, handleAgedPipeRowDoubleClick]); + // WaterDepth 표시 여부를 결정하는 함수 const handleWaterDepthVisibility = useCallback((checkedIds: string[], checkedNodesData: any[]) => { const waterDepthLayers = checkedNodesData.filter(node => @@ -129,12 +411,83 @@ const Map = () => { // 체크된 레이어 변경 처리 함수 const handleCheckedLayersChange = useCallback((checkedIds: string[], checkedNodesData: any[]) => { handleWaterDepthVisibility(checkedIds, checkedNodesData); + setIsAgedPipeChecked(checkedNodesData.some((node) => isAgedPipeLayerNode(node))); }, [handleWaterDepthVisibility]); + const handleOpenLayerPopup = useCallback( + (_layerNm: string, node?: any) => { + if (isAgedPipeLayerNode(node)) { + openAgedPipeList(); + return false; + } + + return true; + }, + [openAgedPipeList], + ); + + const inlineLayerSettingView = useCallback( + (node: any) => { + if (!isAgedPipeChecked || !isAgedPipeLayerNode(node)) return null; + + return ( + + ); + }, + [draftAgedPipeFilters, handleApplyAgedPipeFilters, isAgedPipeChecked], + ); + + useEffect(() => { + if (!mapReady || !gisMaps.current) return; + + const core = GisApp.Module.core as any; + core?.deleteLayer?.(AGED_PIPE_HIGHLIGHT_LAYER_ID); + + if (!isAgedPipeChecked) return; + + const baseLayerCode = Object.values(GisApp.layerCode ?? {}).find( + (item: any) => item?.lyrTyNm === AGED_PIPE_LAYER_TYPE && item?.actlLyrYn === "Y", + ) as any; + + if (!baseLayerCode) return; + + const layerCode = { + ...baseLayerCode, + id: AGED_PIPE_HIGHLIGHT_LAYER_ID, + lyrSn: AGED_PIPE_HIGHLIGHT_LAYER_ID, + lyrNm: "노후관로", + lyrTyNm: AGED_PIPE_LAYER_TYPE, + gobjTy: "line", + actlLyrYn: "Y", + exprsYn: "Y", + clickPsblYn: "Y", + cmncNm: "bbox", + srvcNm: "wfs", + flterSql: buildAgedPipeCqlFilters(appliedAgedPipeFilters).join(" and "), + lnWdth: "4", + lnColor: "rgba(235, 40, 40, 0.82)", + drawOrdr: "9999", + minViewSize: String(AGED_PIPE_MIN_ZOOM), + }; + + const layer = core?.addWfsLayer?.(layerCode); + layer?.set?.("id", AGED_PIPE_HIGHLIGHT_LAYER_ID); + layer?.set?.("lyrNm", "노후관로"); + layer?.set?.("lyrTyNm", AGED_PIPE_LAYER_TYPE); + + return () => { + core?.deleteLayer?.(AGED_PIPE_HIGHLIGHT_LAYER_ID); + }; + }, [appliedAgedPipeFilters, isAgedPipeChecked, mapReady]); + return ( <> -
+
{ isSearchModalOpen={isSearchModalOpen} setIsSearchModalOpen={setIsSearchModalOpen} onCheckedLayersChange={handleCheckedLayersChange} + onOpenPopup={handleOpenLayerPopup} searchBar={} waterDepth={showWaterDepth ? WaterDepth : undefined} + inlineLayerSettingView={inlineLayerSettingView} + layerIconVisible={isAgedPipeLayerNode} isPlaying={isPlaying} setIsPlaying={setIsPlaying as any} targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any} @@ -161,6 +517,37 @@ const Map = () => { defaultZoom={7.5} defaultLonLat={[127.75, 36]} /> + {isAgedPipeListMinimized && ( +
+
+ +
+
+
노후관로 현황
+
+ {appliedAgedPipeFilters.elapsedYears}년 이상 · {agedPipeTotalCount.toLocaleString()}건 +
+
+ + +
+ )}
); diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx index cf6db8572..88b5ddbe8 100644 --- a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx +++ b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx @@ -1,6 +1,7 @@ "use client"; import { Button } from "@common/components"; +import { VerticalTable } from "@/components/ui/verticalTable"; import { AGED_PIPE_DETAIL_FIELDS, getElapsedYears, @@ -16,39 +17,48 @@ 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>((acc, field) => { + acc[field.id] = + field.id === "elapsedYears" + ? elapsedYears === null + ? "확인불가" + : `${elapsedYears}년` + : String(getPipeDisplayValue(source, field.id) ?? "-"); + return acc; + }, {}); return ( -
-
+
+
-
노후관로 상세
-
시설관리번호 {facilityNo}
-
- 경과년도 {elapsedYears ?? "-"}년 +
+ 하수관로 정보
관리번호 : {facilityNo}
-
-
- {AGED_PIPE_DETAIL_FIELDS.map((field) => ( -
-
{field.title}
-
- {String(getPipeDisplayValue(source, field.id) ?? "-")} -
-
- ))} -
+
+ undefined} + />
); diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx index 5a94014a2..a80ced9c9 100644 --- a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx +++ b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx @@ -1,7 +1,6 @@ "use client"; -import { Button, Input } from "@common/components"; -import useStore from "@/store/useStore"; +import { useState } from "react"; import type { AgedPipeFilters } from "./agedPipeUtils"; type OverlayProps = { @@ -13,89 +12,62 @@ type OverlayProps = { }; type Props = OverlayProps & { + title?: string; filters: AgedPipeFilters; onFiltersChange: (filters: AgedPipeFilters) => void; onApply: () => void; }; export default function AgedPipeFilterPanel({ + title = "지반침하정보", filters, onFiltersChange, onApply, }: Props) { - const { ctpv, sgg } = useStore(); - const sggOptions = filters.ctpvCd === "0" - ? sgg - : sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))); + const [isCollapsed, setIsCollapsed] = useState(false); return ( -
-
-
노후관로 기준
-
- 경과년수 이상 관로만 지도와 목록에 반영합니다. +
+
+ + {title} +
+ + {!isCollapsed && ( +
+
+ + { + const next = Number(event.target.value.replace(/[^0-9]/g, "") || "0"); + onFiltersChange({ ...filters, elapsedYears: next }); + }} + onKeyDown={(event) => { + if (event.key === "Enter") onApply(); + }} + /> + +
-
- -
- - - - - -
- - + )}
); } diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx index bf6a8fb02..a5c90e184 100644 --- a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx +++ b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx @@ -64,14 +64,11 @@ export default function AgedPipeListModal({ return (
-
+
-
+
노후관로 목록
-
- 지도 위에 띄워지는 윈도우형 팝업 미리보기 -
+
+
HASUON
+
디지털하수관로 시스템
+
+
+ +
+ + +
+ + +
+ + +
+
+
+
+
+
+ + 하수처리시설 + 왕숙천 + 진건로 + +
+
+
+
+ + + + + + + + +
+ + + + + + + +
+
+
+ +
+ +
+ +
+ + + + diff --git a/apps/sn/docs/AI코딩하네스/기준문서/008-문서구조기준.md b/apps/sn/docs/AI코딩하네스/기준문서/008-문서구조기준.md new file mode 100644 index 000000000..88c7dede9 --- /dev/null +++ b/apps/sn/docs/AI코딩하네스/기준문서/008-문서구조기준.md @@ -0,0 +1,160 @@ +# 프론트 AI 문서 구조 기준 + +## 1. 목적 + +이 문서는 `sn` 프론트엔드 작업에서 AI와 개발자가 같은 기준으로 문서를 읽고 작성하기 위한 문서 구조 기준이다. + +프론트 문서는 단순 기록이 아니라, 화면 구현 전에 요구사항, 컴포넌트, API 사용, 상태 흐름, 브라우저 검증 기준을 빠르게 파악하기 위한 작업 입력이다. + +## 2. 적용 범위 + +| 구분 | 기준 경로 | 역할 | +| --- | --- | --- | +| 프론트 앱 기준 | `SN_APP_ROOT/AGENTS.md` | `sn` 작업 규칙과 수정 가능 범위 | +| AI 기준문서 | `SN_APP_ROOT/docs/AI코딩하네스/기준문서/` | 앱 작업 정책 | +| 작업계획 | `SN_APP_ROOT/docs/AI코딩하네스/작업계획/` | 화면/기능별 작업카드 | +| 검증 | `SN_APP_ROOT/docs/AI코딩하네스/검증/` | 브라우저/빌드/네트워크 검증 기준 | +| 보고 | `SN_APP_ROOT/docs/AI코딩하네스/보고/` | 작업 결과와 이슈 기록 | +| 기능 문서 | `SN_APP_ROOT/docs/{기능명}/` | 화면별 구현 산출물 | +| 프론트 공통 문서 | `FE_REPO_ROOT/docs/` | 여러 앱이 공유하는 FE 기준 | + +## 3. 기준문서 구조 + +경로: + +```text +SN_APP_ROOT/docs/AI코딩하네스/기준문서/ +``` + +현재 기준: + +```text +001-프로젝트.md +002-화면맵.md +003-API사용.md +004-컴포넌트정책.md +005-상태관리정책.md +006-검증정책.md +007-멀티에이전트운영.md +008-문서구조기준.md +009-AI개발작업흐름.md +``` + +역할: + +- `sn` 앱의 작업 대상과 금지 경로 확인 +- 화면/라우트 구조 확인 +- API 호출 방식 확인 +- 기존 컴포넌트 재사용 기준 확인 +- 상태관리 기준 확인 +- 검증 기준 확인 +- BE/FE 역할 분리 기준 확인 + +## 4. 기능 문서 구조 + +기능별 문서는 아래 위치에 둔다. + +```text +SN_APP_ROOT/docs/{기능명}/ +``` + +권장 구조: + +```text +README.md +00-범위.md +01-기존화면기반-컴포넌트분해.md +02-컴포넌트별-작업목록.md +03-API응답필드매핑표.md +04-화면흐름.md +05-검증계획.md +06-검증결과.md +99-미확인사항.md +``` + +문서별 역할: + +| 문서 | 역할 | +| --- | --- | +| `README.md` | 기능 문서 진입점 | +| `00-범위.md` | 구현 대상과 제외 범위 | +| `01-기존화면기반-컴포넌트분해.md` | 기존 화면/컴포넌트 재사용 후보 | +| `02-컴포넌트별-작업목록.md` | 컴포넌트 단위 작업 목록 | +| `03-API응답필드매핑표.md` | 화면에서 사용하는 API와 응답 필드 | +| `04-화면흐름.md` | 사용자 동작과 화면 상태 흐름 | +| `05-검증계획.md` | 브라우저/네트워크/빌드 검증 계획 | +| `06-검증결과.md` | 실제 검증 결과 | +| `99-미확인사항.md` | BE/FE/API/DB 미확정 이슈 | + +## 5. 프론트 공통 문서와 `sn` 문서 구분 + +`SN_APP_ROOT/docs/**`는 `sn` 앱 특정 화면과 기능을 다룬다. + +`FE_REPO_ROOT/docs/**`는 여러 앱이 함께 보는 공통 기준만 다룬다. + +| 상황 | 문서 위치 | +| --- | --- | +| `sn` 특정 화면 구현 | `SN_APP_ROOT/docs/{기능명}/` | +| `sn` API 필드 매핑 | `SN_APP_ROOT/docs/{기능명}/03-API응답필드매핑표.md` | +| `sn` 검증 결과 | `SN_APP_ROOT/docs/{기능명}/06-검증결과.md` | +| 여러 앱 공통 상태관리 기준 | `FE_REPO_ROOT/docs/구조/` | +| 여러 앱 공통 응답 처리 기준 | `FE_REPO_ROOT/docs/응답표준화/` | + +## 6. 백엔드 문서와 연결 기준 + +프론트 기능이 백엔드 계약에 의존하면 아래 문서를 함께 본다. + +```text +BE_SERVICE_ROOT/docs/AI코딩하네스/작업계획/{작업카드}.md +BE_SERVICE_ROOT/docs/AI코딩하네스/DB/{DB문서}.md +BE_SERVICE_ROOT/docs/실행명세/** +``` + +연결 방식: + +| 프론트 문서 | 연결되는 백엔드 문서 | +| --- | --- | +| API 응답 필드 매핑표 | BE 작업카드, API 정책 | +| 화면흐름 | BE API 처리 흐름 | +| 미확인사항 | BE 작업계획, DB 문서 | +| 검증계획 | QA 카드, BE 테스트/실행명세 | + +## 7. 작성 원칙 + +1. 화면 구현 전에 기능 문서를 먼저 확인한다. +2. 기존 컴포넌트 재사용 가능성을 먼저 기록한다. +3. API 응답 필드는 화면 표시명과 실제 필드명을 함께 적는다. +4. 미확정 API/DB 이슈는 `99-미확인사항.md`에 남긴다. +5. 검증 결과는 실제 브라우저 동작 기준으로 남긴다. +6. 정적 HTML은 참고 자료이고 실제 구현은 `apps/sn` 코드와 기존 컴포넌트 조합으로 한다. +7. 공통 패키지 수정이 필요하면 영향 범위를 먼저 정리한다. + +## 8. 적용 예시: 노후관로관리 + +노후관로관리 문서는 아래 역할로 사용한다. + +```text +SN_APP_ROOT/app/html/06. 노후관로관리_시나리오_v1.0.md +→ 화면 요구사항과 사용자 동작 확인 + +SN_APP_ROOT/docs/노후관로관리/03-API응답필드매핑표.md +→ 목록/상세/위치 이동에 필요한 API 필드 확인 + +SN_APP_ROOT/docs/노후관로관리/99-미확인사항.md +→ 백엔드 확인 이슈 관리 + +BE_SERVICE_ROOT/docs/AI코딩하네스/DB/001-노후관로-레이어설정.md +→ 레이어 트리 DB 기준 데이터 반영안 확인 +``` + +## 9. 유지보수 기준 + +아래 상황에서는 문서를 갱신한다. + +- 화면 흐름 변경 +- API endpoint 또는 응답 필드 변경 +- 컴포넌트 구조 변경 +- 상태관리 방식 변경 +- 브라우저 검증 결과 확정 +- 미확인사항 해결 +- 백엔드 계약 변경 diff --git a/apps/sn/docs/AI코딩하네스/기준문서/009-AI개발작업흐름.md b/apps/sn/docs/AI코딩하네스/기준문서/009-AI개발작업흐름.md new file mode 100644 index 000000000..91d079074 --- /dev/null +++ b/apps/sn/docs/AI코딩하네스/기준문서/009-AI개발작업흐름.md @@ -0,0 +1,244 @@ +# 프론트 AI 개발 작업 흐름 + +## 1. 목적 + +이 문서는 `sn` 프론트엔드 기능 개발을 AI와 함께 진행할 때 따르는 표준 작업 흐름이다. + +기능마다 화면과 API는 달라질 수 있지만, 작업 기준 확인, 기존 화면/컴포넌트 확인, API 계약 확인, 최소 구현, 브라우저 검증, 결과 기록의 큰 흐름은 유지한다. + +## 2. 기본 원칙 + +1. `AGENTS.md`와 `sn` 기준문서를 먼저 확인한다. +2. 기존 화면과 컴포넌트를 먼저 찾는다. +3. `apps/sn` 내부 해결을 우선한다. +4. 공통 패키지는 `business_components`만 허용 범위에서 수정한다. +5. `@common/components`, `@common/network`, `@common/auth` 등 금지 범위는 직접 수정하지 않는다. +6. 백엔드 응답을 임의로 가정하지 않고 API 문서와 실제 응답을 확인한다. +7. 구현 후 브라우저 동작과 네트워크 응답을 확인한다. + +## 3. 표준 작업 흐름 + +```text +1. 작업 기준 확인 +2. 기능 문서 확인 +3. 기존 화면/컴포넌트 탐색 +4. API 사용부와 응답 필드 확인 +5. 상태관리와 화면 흐름 확인 +6. 구현 방향 결정 +7. 최소 범위 구현 +8. 빌드/브라우저/네트워크 검증 +9. 검증 결과와 남은 리스크 기록 +``` + +## 4. 단계별 기준 + +### 4.1 작업 기준 확인 + +먼저 아래 문서를 확인한다. + +```text +WORKSPACE_ROOT/AGENTS.md +SN_APP_ROOT/AGENTS.md +BE_SERVICE_ROOT/AGENTS.md +``` + +확인할 내용: + +- 수정 가능한 경로 +- `apps/sn` 내부 해결 가능 여부 +- `business_components` 수정 가능 여부 +- 공통 패키지 수정 금지 범위 +- 백엔드 연동 영향 여부 + +### 4.2 기능 문서 확인 + +확인 대상: + +```text +SN_APP_ROOT/docs/{기능명}/00-범위.md +SN_APP_ROOT/docs/{기능명}/03-API응답필드매핑표.md +SN_APP_ROOT/docs/{기능명}/04-화면흐름.md +SN_APP_ROOT/docs/{기능명}/99-미확인사항.md +``` + +확인할 내용: + +- 화면 목적 +- 사용자 동작 흐름 +- 호출 API +- 필요한 응답 필드 +- 미확정 백엔드 계약 +- 검증해야 할 브라우저 동작 + +### 4.3 기존 화면/컴포넌트 탐색 + +새 UI를 만들기 전에 기존 구현을 찾는다. + +우선순위: + +```text +1. SN_APP_ROOT/app/** +2. SN_APP_ROOT/src/** +3. BUSINESS_COMPONENTS_ROOT/src/** +``` + +확인할 내용: + +- 유사 화면 +- 기존 테이블/모달/검색/상세 패널 +- 지도 레이어/트리/팝업 패턴 +- 기존 상태관리 store +- 기존 API 유틸 + +### 4.4 API 사용부와 응답 필드 확인 + +확인할 내용: + +- API 호출 함수 +- 요청 파라미터 +- 응답 wrapper 구조 +- 화면 표시 필드 +- 코드값 매핑 +- 오류 처리 + +백엔드와 계약이 불명확하면 `99-미확인사항.md`에 기록하고 BE 작업카드와 연결한다. + +### 4.5 상태관리와 화면 흐름 확인 + +확인할 내용: + +- local state로 충분한가 +- 기존 Zustand store를 써야 하는가 +- URL/query state가 필요한가 +- 모달/패널/지도 선택 상태가 어떻게 연결되는가 +- API 재조회 시점은 언제인가 + +### 4.6 구현 방향 결정 + +가능한 방향: + +```text +1. 기존 화면/컴포넌트 조합만으로 구현 +2. apps/sn 내부 컴포넌트 추가 +3. business_components 최소 보강 +4. 백엔드 API 계약 확인 요청 +5. 백엔드 변경 필요 사항 정리 +``` + +기본 방침: + +```text +기존 컴포넌트로 가능하면 재사용한다. +apps/sn 내부 해결을 우선한다. +공통 패키지 수정은 마지막에 판단한다. +백엔드 응답 구조를 프론트에서 임의로 바꾸지 않는다. +``` + +### 4.7 최소 범위 구현 + +구현 원칙: + +- 기존 디자인과 컴포넌트 패턴을 따른다. +- 신규 스타일은 최소화한다. +- 화면 전체 구조를 불필요하게 바꾸지 않는다. +- 텍스트/버튼/테이블이 모바일과 데스크톱에서 넘치지 않게 한다. +- 지도 화면은 전체 페이지 스크롤보다 내부 패널 스크롤을 우선한다. +- API 변환 로직은 한 곳에 모은다. + +### 4.8 빌드/브라우저/네트워크 검증 + +수정 범위에 따라 검증한다. + +`apps/sn`만 수정한 경우: + +```powershell +pnpm --filter sn run build +pnpm turbo run build --filter=sn +``` + +`business_components` 수정 시: + +```powershell +pnpm --filter @common/business_components run build +pnpm --filter sn run build +``` + +브라우저 검증: + +- 화면 진입 +- 검색/조회 +- 모달/패널 열림 +- 테이블 표시 +- 지도 레이어 표시 +- 네트워크 요청/응답 +- 오류 처리 +- 스크롤/레이아웃 깨짐 여부 + +### 4.9 결과 기록 + +작업 후 아래 항목을 기록한다. + +- 작업 목적 +- 수정 대상 파일 +- 변경 내용 요약 +- 영향 범위 +- 검증 방법 +- 롤백 방법 +- 남은 리스크 + +기능 문서가 있다면 `06-검증결과.md` 또는 `99-미확인사항.md`를 갱신한다. + +## 5. BE/FE 연동 작업 흐름 + +프론트가 백엔드 계약에 의존하는 경우 아래 순서로 진행한다. + +```text +1. FE 화면흐름 확인 +2. FE API 응답 필드 매핑표 확인 +3. FE 미확인사항 확인 +4. BE 작업카드/API/DB 문서 확인 +5. 실제 API 응답 확인 +6. 프론트 구현 +7. 브라우저와 네트워크 응답 검증 +8. 미확인사항 해결 여부 갱신 +``` + +## 6. 적용 예시: 노후관로관리 + +노후관로관리 작업에서는 이 흐름을 아래처럼 적용했다. + +```text +시나리오 문서 확인 +→ API 응답 필드 매핑표 확인 +→ 미확인사항 확인 +→ 기존 GIS/레이어 트리 패턴 확인 +→ 기존 /api/v1/fclts 재사용 결정 +→ 노후관로 가상 노드 fallback 구현 +→ DB 정식 노드 반영 후 중복 방지 +→ 목록/지도 강조선/상세 패널 검증 +``` + +프론트 최종 방향: + +```text +노후관로 목록: 기존 v1/fclts 재사용 +노후관로 트리 노드: DB 노드 우선, 없으면 프론트 fallback +노후관로 강조선: tb_ms_swage_ppl WFS 조건 레이어 추가 +상세 패널: 기존 하수관로 상세 패널 재사용 +``` + +## 7. 고정할 큰 틀 + +기능이 달라져도 아래 흐름은 유지한다. + +```text +문서 확인 +→ 기존 화면/컴포넌트 확인 +→ API 계약 확인 +→ 재사용 가능 여부 판단 +→ 최소 구현 +→ 브라우저 검증 +→ 결과 기록 +``` + +세부 구현은 상황에 맞게 조정하되, 기존 화면 패턴 재사용과 실제 브라우저 검증은 항상 유지한다. diff --git a/apps/sn/docs/README.md b/apps/sn/docs/README.md new file mode 100644 index 000000000..f88e3e658 --- /dev/null +++ b/apps/sn/docs/README.md @@ -0,0 +1,40 @@ +# `sn` 앱 문서 + +## 목적 + +이 디렉터리는 하수관로 운영관리 시스템 `sn` 앱의 화면/기능별 산출물을 관리한다. +프론트엔드 공통 기준은 `FE_REPO_ROOT/docs/**`에 두고, `sn` 특정 화면과 기능 상세는 이 디렉터리에 둔다. + +## 작성 범위 + +- `sn` 화면별 요구사항 해석 +- 기존 화면 기반 컴포넌트 분해 +- 컴포넌트별 작업 목록 +- API 응답 필드 매핑 +- 화면 흐름 +- 검증 계획과 결과 +- 기능별 미확인사항 + +## 작성 금지 범위 + +- 다른 앱 전용 기능 상세 +- 백엔드 내부 구현 상세 +- 공통 프론트엔드 규칙 전체 + +## 기능별 문서 구조 + +기능별 문서는 아래 형식을 권장한다. + +```text +apps/sn/docs/{기능명}/ + README.md + 00-범위.md + 01-기존화면기반-컴포넌트분해.md + 02-컴포넌트별-작업목록.md + 03-API응답필드매핑표.md + 04-화면흐름.md + 05-검증계획.md + 06-검증결과.md + 99-미확인사항.md +``` + diff --git a/apps/sn/docs/노후관로관리/00-범위.md b/apps/sn/docs/노후관로관리/00-범위.md new file mode 100644 index 000000000..6456beda2 --- /dev/null +++ b/apps/sn/docs/노후관로관리/00-범위.md @@ -0,0 +1,32 @@ +# 노후관로관리 범위 + +## 목적 + +노후관로관리 및 노후관로분석 화면의 프론트엔드 구현 범위를 정의한다. + +## 포함 범위 + +- 노후관로 레이어 체크/표출 +- 좌측 하단 경과년도 설정 영역 +- 노후관로 목록 모달 +- 목록 행 더블클릭과 지도/상세 연동 +- 관로 상세정보 표시 +- 하수처리구역 기준 매설년도 분석정보 표시 +- 보수이력 진입 버튼 또는 링크 +- 엑셀 다운로드 버튼 연결 + +## 제외 범위 + +- 백엔드 API 신규 구현 +- DB 스키마 변경 +- 백엔드 공통 응답 구조 변경 +- `sn` 외 다른 앱 화면 변경 +- `business_components` 외 공통 패키지 직접 수정 + +## 기준 문서 + +- 기능 요구사항 명세서 v2.0 +- 시나리오 v2.0 +- `01-기존화면기반-컴포넌트분해.md` +- `02-컴포넌트별-작업목록.md` + diff --git a/apps/sn/docs/노후관로관리/01-기존화면기반-컴포넌트분해.md b/apps/sn/docs/노후관로관리/01-기존화면기반-컴포넌트분해.md new file mode 100644 index 000000000..624534fa9 --- /dev/null +++ b/apps/sn/docs/노후관로관리/01-기존화면기반-컴포넌트분해.md @@ -0,0 +1,158 @@ +# 노후관로관리 기존 화면 기반 컴포넌트 분해 + +## 목적 + +노후관로관리 2차 고도화 화면은 신규 디자인을 만들지 않고, 1차 개발 화면의 레이아웃과 컴포넌트 톤을 유지한다. +이 문서는 실제 `sn` 화면 코드 기준으로 화면을 어떤 단위로 나누어 개발해야 하는지 정리한다. + +## 기준 화면 구조 + +실제 화면은 아래 계층으로 구성된다. + +```text +NoFooterLayout + Header + LNB + MapLayout children + Gis + GisTree + GisMapToggle + GisToolBar + GisZoomLevel + ProcSearchBar + FeatureClickEventPopup +``` + +## 실제 참조 파일 + +| 구분 | 기준 파일 | 개발 기준 | +|---|---|---| +| 전체 레이아웃 | `packages/common/components/src/ui/layout/nofooter.tsx` | `Header` + `LNB` + 본문 3단 구조 유지 | +| 상단 헤더 | `packages/common/components/src/ui/header/index.tsx` | 흰색 헤더, 로고, 상단 시스템 메뉴, 검색, 사용자 영역 유지 | +| 좌측 메뉴 | `packages/common/components/src/ui/lnb/index.tsx` | `하수관망도`, `I/I 분석`, `운영정보` 계층 유지 | +| 지도 컴포넌트 | `packages/common/business_components/src/ui/map/Gis.js` | OpenLayers 기반 `Gis` 사용 | +| 시설물 트리 | `packages/common/business_components/src/ui/map/GisTree.js` | 폭 250px, 제목 `시설물`, 체크박스 트리 유지 | +| 지도 도구 | `packages/common/business_components/src/ui/map/GisToolBar.js` | 기존 우측 도구 버튼 위치/크기 유지 | +| 검색 바 | `packages/common/business_components/src/ui/map/searchBar` | 기존 지도 상단 검색 조건 영역 유지 | +| 상세 팝업 | `apps/sn/app/html/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx` | 우측 350px 팝업, 파란 헤더, `VerticalTable` 유지 | +| 세로 테이블 | `apps/sn/app/components/ui/verticalTable.tsx` | `bg-blue-100` 라벨셀, 2열 세로 테이블 유지 | +| 목록 그리드 | `packages/common/business_components/src/ui/table/index.tsx` | `UiTable` 기반 목록/페이징 유지 | + +## 레이아웃 기준값 + +| 영역 | 기준 | +|---|---| +| Header | 높이 `6rem`, 흰색 배경, 하단 border | +| LNB | 폭 `22rem`, 흰색 배경, `하수관망도` active | +| GisTree | 폭 `250px`, 지도 내부 좌측 absolute. 상단 레이어 트리와 하단 설정 영역을 같은 패널 안에서 자연스럽게 분리 | +| Map body | `calc(100vh - 50px)` 계열, `Gis` 영역 full | +| Detail popup | 우측 absolute, 폭 `350px`, 높이 `calc(100vh - 5rem)` | +| Detail header | `bg-primary`, 흰색 글자, 닫기 `✕` | +| Detail table | `VerticalTable`, 왼쪽 라벨셀 `bg-blue-100`, 오른쪽 값셀 흰색 | + +## 색상 및 폰트 기준 + +노후관로관리 추가 UI는 기존 우측 상세정보의 `FeatureClickEventPopup` + `VerticalTable` 색상과 폰트 톤을 따른다. +좌측 하단 설정 영역도 별도 신규 색을 쓰지 않고 같은 토큰을 사용한다. + +| 항목 | 기준값 | 사용 위치 | +|---|---|---| +| 기본 폰트 | `Noto Sans KR`, `Malgun Gothic` fallback | 전체 화면 | +| 기본 글자색 | `#111`, `#333`, `#555` | 본문, 트리, 입력 라벨 | +| Primary blue | `#258eea` | 우측 상세 헤더, 조회 버튼 등 기존 primary 영역 | +| Dark navy | `#152346` | 기존 강조 버튼 또는 별도 강조 영역이 필요한 경우만 사용 | +| Download green | `#1fd078` | 목록/엑셀 다운로드 버튼 | +| Label cell blue | `#dbeafe` | `VerticalTable` 왼쪽 라벨셀 | +| Table border | `#dbe2e6`, `#e5e7eb` | 세로 테이블/목록 테이블 구분선 | +| Panel border | `#d1d5db`, `#d7dde3` | 좌측 패널, 설정 영역 외곽 | +| Panel background | `#fff` | LNB, GisTree, 설정 영역, 상세 팝업 | +| Apply green | `#31a83b` | 좌측 하단 설정 영역의 적용 아이콘 | + +### 좌측 하단 설정 영역 색상 기준 + +- 외곽은 `Panel border`를 사용한다. +- 헤더는 진한 남색 바를 쓰지 않고, 위 `시설물` 패널처럼 흰 배경의 제목 행을 사용한다. +- 입력 라벨, 값, 적용 아이콘 폰트는 기존 `Noto Sans KR` 계열과 동일하게 둔다. +- 적용은 텍스트 버튼이 아니라 녹색 원형 체크 아이콘 형태로 표현한다. +- 트리 영역과 설정 영역 사이에는 굵은 검정선 대신 `#d1d5db` 계열의 얇은 경계와 여백을 사용한다. +- 목적은 강한 분리선이 아니라, 같은 좌측 패널 안에서 서로 다른 기능 영역임을 자연스럽게 구분하는 것이다. + +### 목록 모달 버튼 색상 기준 + +- `조회`는 공통 `Button`의 `primary` 톤을 따른다. +- `다운로드`는 공통 `Button`의 `green` 톤을 따른다. +- 모달 안에서 임의의 남색 버튼이나 흰색 outline 다운로드 버튼을 새로 만들지 않는다. +- 정적 HTML 샘플에서도 `조회=#258eea`, `다운로드=#1fd078` 조합으로 표현한다. + +## 화면설계서 번호 기준 영역 + +노후관로관리 화면은 화면설계서의 번호 기준대로 영역을 분리해서 봐야 한다. +특히 `경과년도(년)` 입력 영역은 지도 위에 뜨는 독립 팝업이 아니라, 좌측 `시설물` 트리 하단에 붙는 별도 설정 영역으로 본다. + +| 번호 | 화면 영역 | 위치 | 기존 컴포넌트 기준 | 동작 | +|---|---|---|---|---| +| ① | 노후관로 레이어 체크박스 | 좌측 `시설물` 트리 내부, `노후관로` 행 | `GisTree` 노드 | 체크 ON/OFF로 노후관로 레이어 표출 제어 | +| ② | 경과년도 설정 영역 | 좌측 `시설물` 트리 하단, 트리 목록 아래 별도 접힘/펼침 박스로 자연스럽게 분리 | `GisTree` 하단 확장 영역 또는 지도 오버레이가 아닌 좌측 설정 블록 | `경과년도(년)` 단일 입력 후 초록 체크 적용 | +| ③ | 지도 관로 선택 영역 | 지도 본문 | `Gis`, `GisSelect`, `onFeatureSelect` | 관로 클릭 시 우측 상세 팝업 표출 | +| ④ | 노후관로 목록 아이콘 | 좌측 `시설물` 트리 내부, `노후관로` 행 우측 | `GisTree`의 `onLayerIconClick`/`onOpenPopup` | 노후관로 현황 목록 모달 호출 | + +### 좌측 영역 분리 원칙 + +```text +GisTree (좌측 250px) + 시설물 제목 + 레이어 트리 영역 + 시설 + 하수관로 + ... + 노후관로 체크박스 ① + 노후관로 목록 아이콘 ④ + (여백) + 레이어별 설정 영역 ② + 제목 행: 지반침하정보 또는 대상 레이어명 + 입력 행: 경과년도(년) [30] [초록 체크] +``` + +이 구조를 따르면 화면설계서의 ①, ②, ④가 모두 좌측 `GisTree` 계열 안에서 구분된다. +따라서 노후관로 기준 패널을 지도 중앙이나 지도 좌하단의 독립 카드처럼 띄우지 않는다. +또한 트리와 설정 영역 사이를 검정 굵은 선으로 끊지 않고, 실제 화면처럼 여백과 연한 경계선으로 자연스럽게 구분한다. + +### 접힘/펼침 기준 + +- 상단 `시설물` 영역과 하단 설정 영역은 각각 접힘/펼침이 가능해야 한다. +- 접힘 상태에서는 제목 행만 남기고 내부 행은 숨긴다. +- 하단 설정 영역은 사용자가 현재 조정할 수 있는 입력만 보여준다. +- 화면설계서 예시처럼 `지반침하정보` 설정이면 `경과년도(년) [30] [초록 체크]`만 표시한다. +- 노후관로 기준을 같은 패턴으로 적용할 때도 입력 필드를 과도하게 늘리지 않고, 대상 레이어명과 경과년도 입력만 노출한다. + +## 노후관로관리 추가 시 분해 + +| 요구사항 | 기존 컴포넌트 기반 구현 위치 | +|---|---| +| 노후관로 레이어 ON/OFF | `GisTree` 레이어 노드 추가 또는 기존 하수관로 레이어 스타일 보강 | +| 경과년도 조건 | `GisTree` 하단의 접힘/펼침 가능한 레이어별 설정 영역으로 구성. `경과년도(년)` 단일 입력만 노출 | +| 목록 아이콘 | `GisTree`의 `노후관로` 행 우측 아이콘으로 배치하고 `onLayerIconClick`/`onOpenPopup` 흐름 활용 | +| 목록 팝업 | `UiTable` 기반 모달로 구성 | +| 지도-그리드 연동 | 기존 `onFeatureSelect`, `GisApp.Module.layer.showSearchResultsFromGeoJSON` 흐름 활용 | +| 상세정보 | `FeatureClickEventPopup` 또는 동일한 `VerticalTable` 구조로 확장 | +| 노후관로분석 | 하수처리구역 피처 클릭 시 기존 상세 팝업에 분석정보 섹션 추가 | + +## 금지 방향 + +- 별도 전체 화면 레이아웃을 새로 만들지 않는다. +- 어두운 헤더/새 사이드바/카드형 상세 패널 등 기존과 다른 디자인을 넣지 않는다. +- `경과년도(년)` 설정을 지도 위 플로팅 카드로 분리하지 않는다. +- 트리와 설정 영역 사이에 굵은 검정 구분선을 사용하지 않는다. +- 좌측 하단 설정 영역에 남색 헤더 바와 남색 텍스트 적용 버튼을 사용하지 않는다. +- 하단 설정 영역에 시도/시군구/목록 버튼을 넣지 않는다. +- `apps/sn/app/html/**` 정적 샘플을 실제 서비스 코드로 그대로 옮기지 않는다. +- 기존 `FeatureClickEventPopup`, `VerticalTable`, `UiTable`, `GisTree`로 가능한 UI를 별도 컴포넌트로 새로 만들지 않는다. + +## 개발 전 확인 순서 + +1. 실제 메뉴/라우트가 `NoFooterLayout` 하위에서 열리는지 확인한다. +2. 노후관로 레이어가 기존 `SN_NETWORK` 레이어 설정에 포함되는지 확인한다. +3. 하수관로 속성명(`instl_ymd`, `fclt_mng_no`, `mqlt_cd`, `usg_cd`)을 실제 API 응답과 맞춘다. +4. 상세 패널은 기존 `FeatureClickEventPopup` 확장 가능성을 먼저 검토한다. +5. 목록 팝업은 `UiTable`과 기존 검색 모달 패턴으로 구성한다. +6. `business_components` 수정이 필요한 경우 타 앱 영향 범위를 먼저 정리한다. diff --git a/apps/sn/docs/노후관로관리/02-컴포넌트별-작업목록.md b/apps/sn/docs/노후관로관리/02-컴포넌트별-작업목록.md new file mode 100644 index 000000000..cf00b3c96 --- /dev/null +++ b/apps/sn/docs/노후관로관리/02-컴포넌트별-작업목록.md @@ -0,0 +1,240 @@ +# 노후관로관리 컴포넌트별 작업 목록 + +## 목적 + +이 문서는 노후관로관리 화면을 실제 React 코드로 옮기기 전에, 어떤 기존 컴포넌트를 재사용하고 어떤 컴포넌트만 최소 확장할지 정리한 작업 목록이다. +기준은 `01-기존화면기반-컴포넌트분해.md`와 `old-pipe-component-sample/index.html`의 최종 확인 화면이다. + +## 구현 원칙 + +- 새 디자인 시스템을 만들지 않는다. +- 실제 화면은 `NoFooterLayout > Header/LNB > Gis > GisTree/FeatureClickEventPopup/UiTable` 흐름을 따른다. +- 좌측 하단 설정 영역은 지도 위 플로팅 카드가 아니라, `GisTree` 내부 또는 `GisTree`와 붙은 좌측 패널 영역으로 구현한다. +- 좌측 하단 설정 영역에는 `경과년도(년)` 단일 입력과 초록 적용 아이콘만 둔다. +- 노후관로 현황 목록의 `조회`는 `Button color="primary"`, `다운로드`는 `Button color="green"` 톤을 따른다. +- 우측 상세정보는 기존 `FeatureClickEventPopup` + `VerticalTable` 톤을 우선한다. + +## 작업 단위 요약 + +| 순서 | 작업 단위 | 주요 파일 | 구분 | 목적 | +|---|---|---|---|---| +| 1 | 좌측 하단 설정 영역 확장 지점 확보 | `BUSINESS_COMPONENTS_ROOT/src/ui/map/Gis.js`, `GisTree.js` | 최소 공통 확장 후보 | `GisTree` 아래에 설정 패널을 넣을 수 있는 optional slot 확보 | +| 2 | 노후관로 설정 패널 정리 | `SN_APP_ROOT/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx` | 구조 변경 | 지도 오버레이/시도/시군구 제거, 경과년도 단일 입력 + 초록 체크로 변경 | +| 3 | 노후관로 페이지 상태/이벤트 정리 | `SN_APP_ROOT/app/(map-layout)/map/page.tsx` | 구조 변경 | `Gis`, `GisTree`, 윈도우 팝업, 상세 팝업 연동의 중심 상태 관리 | +| 4 | 목록 윈도우 팝업 정리 | `AgedPipePopupPage.tsx`, `/map/aged-pipe-popup` | 스타일/동작 보완 | 기존 `UiTable`, `Button primary/green`, 목록 아이콘 호출 흐름 정리 | +| 5 | 상세 패널 기존화 | `AgedPipeDetailPanel.tsx`, `FeatureClickEventPopup.tsx`, `VerticalTable` | 대체/확장 검토 | 별도 slate 카드형 상세를 제거하고 기존 세로 테이블 톤으로 통합 | +| 6 | 유틸/필터 정리 | `agedPipeUtils.ts` | 유지/보완 | 경과년도 산정, CQL 필터, 표시값 매핑 유지 | +| 7 | 처리구역 분석 확장 | `FeatureClickEventPopup.tsx` 또는 신규 `AgedPipeAreaAnalysisSection.tsx` | 추가 | 하수처리구역 클릭 시 평균/최장/최신 매설년도 섹션 추가 | +| 8 | 검증 | 브라우저, build, 네트워크 | 검증 | 화면/지도/목록/상세/다운로드 회귀 확인 | + +## 1. `Gis` / `GisTree` 확장 후보 + +### 현재 상태 + +- `Gis.js`는 내부에서 `GisTree`를 직접 렌더링한다. +- `GisTree.js`는 좌측 250px 패널 전체를 `grow overflow-y-auto` 트리 영역으로 사용한다. +- 현재 구조만으로는 트리 하단에 `경과년도(년) [30] [초록 체크]` 설정 박스를 자연스럽게 넣기 어렵다. + +### 적용 결정 + +- `sn` 전용 임시 overlay가 아니라 `Gis`/`GisTree`의 optional slot 방식으로 진행한다. +- prop 이름은 `layerSettingView`를 사용한다. +- prop을 넘기지 않는 기존 사용처는 기존과 동일하게 동작한다. + +### 작업 내용 + +- `Gis.js`에 optional prop을 추가한다. + - 이름: `layerSettingView` + - 기본값은 `undefined`로 두어 기존 앱 영향이 없게 한다. +- `GisTree.js`에 optional footer 영역을 렌더링한다. + - 상단 `시설물` 트리 영역과 하단 설정 영역을 같은 250px 패널 안에서 분리한다. + - 상단/하단 영역 모두 접힘/펼침 가능해야 한다. +- 기존 사용처는 prop을 넘기지 않으면 기존과 동일하게 동작해야 한다. + +### 주의사항 + +- `business_components` 수정이므로 타 앱 영향 가능성을 반드시 점검한다. +- className 변경은 최소화하고, optional footer가 없을 때 기존 DOM 구조/스타일을 최대한 유지한다. +- 이 공통 확장이 부담되면 `sn` 전용으로 `Gis` 주변에 좌측 하단 패널을 겹쳐 배치하는 대안을 검토할 수 있으나, 화면설계서처럼 같은 좌측 패널 안에서 보이려면 optional slot 방식이 더 자연스럽다. + +## 2. `AgedPipeFilterPanel.tsx` + +### 현재 상태 + +- 지도 위 플로팅 카드 형태다. +- `경과년수`, `시도`, `시군구`, `GIS 반영` 버튼을 갖고 있다. +- 최종 확인 기준과 다르다. + +### 변경 방향 + +- 이름은 유지하거나 `AgedPipeLayerSettingPanel.tsx`로 변경한다. +- 화면 구성은 아래처럼 단순화한다. + +```text +지반침하정보 또는 노후관로 + 경과년도(년) [30] [초록 체크] +``` + +### 작업 내용 + +- `ctpvCd`, `sggCd` 선택 UI 제거. +- `GIS 반영` 텍스트 버튼 제거. +- 초록 원형 체크 아이콘 버튼으로 적용 액션 처리. +- `title` prop을 받아 `지반침하정보`, `노후관로` 등 대상 레이어명을 표시할 수 있게 한다. +- `elapsedYears` 값만 직접 입력받는다. +- Enter 입력과 체크 버튼 클릭 모두 `onApply`를 호출한다. + +### props 후보 + +```ts +type AgedPipeLayerSettingPanelProps = { + title: string; + elapsedYears: number; + onElapsedYearsChange: (value: number) => void; + onApply: () => void; + defaultCollapsed?: boolean; +}; +``` + +## 3. `page.tsx` + +### 현재 상태 + +- `AgedPipeFilterPanel`을 `waterDepth` prop으로 넘겨 지도 오버레이처럼 표시한다. +- `AgedPipeDetailPanel`을 `detailView`로 사용한다. +- `getData`를 사용하지만 import가 보이지 않으므로 실제 빌드 전 확인이 필요하다. +- 샘플용 `DESIGN_PREVIEW_FEATURE`, 기본 목록 오픈 상태 등이 남아 있다. + +### 변경 방향 + +- `Gis`에 `treeFooterView` 또는 `layerSettingView`를 넘겨 좌측 하단 설정 영역으로 렌더링한다. +- `waterDepth` prop은 침수심 애니메이션 목적이므로 노후관로 설정 패널 용도로 쓰지 않는다. +- 상세는 `AgedPipeDetailPanel` 단독 사용보다 기존 `FeatureClickEventPopup` 확장 또는 동일 `VerticalTable` 구조를 우선한다. + +### 작업 내용 + +- `draftFilters`와 `appliedFilters`는 유지하되, 좌측 하단 패널에서는 `elapsedYears`만 수정한다. +- 시도/시군구는 목록 모달 조회조건에만 남긴다. +- 노후관로 목록 아이콘 클릭은 `GisTree`의 `onLayerIconClick`/`onOpenPopup` 흐름으로 연결한다. +- `handleTreePopup`은 `layerNm` 문자열 대신 실제 노드 정보 또는 `lyrTyNm` 기준으로 판정하는 방향을 검토한다. +- `DESIGN_PREVIEW_FEATURE`, 기본 `isListOpen=true`, 기본 `isDetailOpen=true`는 개발 완료 전 제거한다. +- `getData` import 누락 여부를 확인하고 실제 API 유틸 경로를 맞춘다. + +## 4. 노후관로 현황 윈도우 팝업 + +### 현재 상태 + +- 노후관로 현황은 `/map/aged-pipe-popup` Next 라우트를 별도 윈도우 팝업으로 연다. +- 기존 `about:blank`에 React/CSS를 주입하던 방식은 dev CSS/chunk 404 위험 때문에 사용하지 않는다. +- 팝업 페이지는 자체적으로 목록 API를 조회하고 `UiTable`로 렌더링한다. +- 행 선택은 `window.opener.postMessage`로 부모 `/map`에 전달하여 지도 이동/상세 패널 표시를 수행한다. + +### 변경 방향 + +- 지도 내부 Dialog/modal이 아니라 브라우저 밖 윈도우 팝업을 기본 동작으로 고정한다. +- 테이블은 현재처럼 `UiTable`을 계속 사용한다. +- 버튼 색상은 아래 기준으로 고정한다. + - 조회: `Button color="primary"` + - 다운로드: `Button color="green"` +- 팝업 내부 앱 버튼은 `최소화`만 유지한다. 닫기는 브라우저 창 X를 사용한다. + +### 작업 내용 + +- 헤더 색상을 기존 primary blue 톤과 맞춘다. +- `/map/aged-pipe-popup`는 공통 `NoFooterLayout` 없이 목록 화면만 렌더링한다. +- 조회조건은 명세서대로 `경과년도`, `시도`, `시군구` 유지. +- 그리드 기본 정렬은 `경과년도 DESC`. +- 행 클릭은 부모 `/map`에 row를 전달하고 기존 `showSearchResultsFromGeoJSON` 위치 이동 흐름을 사용한다. +- 좌측 `노후관로 설정` 적용 시 열린 팝업에는 `SN_AGED_PIPE_FILTER_SYNC` 메시지를 보내 필터와 목록을 동기화한다. +- 팝업 `최소화` 클릭 시 부모 `/map`에 최소화 메시지를 보내고, 부모는 지도 위 최소화 카드를 표시한다. +- 최소화 카드의 `열기`는 다시 `/map/aged-pipe-popup` 윈도우 팝업을 실행한다. +- 다운로드는 `downloadExcelWithKoreanHeaders` 호출 유지하되, 5만 건 이상 비동기 처리 여부는 백엔드 API 매핑 단계에서 확정한다. + +## 5. 상세 패널 + +### 현재 상태 + +- `AgedPipeDetailPanel.tsx`는 별도 slate 카드형 UI다. +- 실제 화면은 `FeatureClickEventPopup`의 파란 헤더 + `VerticalTable` 구조다. + +### 변경 방향 + +- 우선순위 1: `FeatureClickEventPopup`에 노후관로 경과년도 필드만 추가해서 재사용한다. +- 우선순위 2: `FeatureClickEventPopup` 재사용이 어렵다면 `AgedPipeDetailPanel`을 `VerticalTable`과 동일한 구조로 다시 만든다. + +### 작업 내용 + +- `AGED_PIPE_DETAIL_FIELDS`는 기존 `MsSwagePplCol` 기반을 유지한다. +- `경과년도` 계산 필드를 상세 테이블 마지막에 추가한다. +- 상세 헤더는 `하수관로 정보 / 관리번호 : {시설관리번호}` 흐름을 따른다. +- `침입수분석`, `RDII분석` 버튼은 기존 `FeatureClickEventPopup`의 하수관로 조건부 버튼을 그대로 사용한다. + +## 6. `agedPipeUtils.ts` + +### 유지할 항목 + +- `getElapsedYears` +- `getCutoffYmd` +- `buildAgedPipeCqlFilters` +- `matchesAgedPipeFilters` +- `toDisplayPipeRow` +- 코드 매핑: `MS_SWAGE_PPL_USE_CODES`, `MS_SWAGE_PPL_CODES` + +### 보완할 항목 + +- `AgedPipeFilters`는 전체 상태로는 `elapsedYears`, `ctpvCd`, `sggCd`를 유지하되 좌측 하단 설정 패널에서는 `elapsedYears`만 다룬다. +- 설치일자 NULL이면 `확인불가` 그룹으로 분류할 수 있는 유틸을 추가한다. +- 미래 설치일자 방어 로직을 추가한다. +- 엑셀 다운로드용 전체 필드 매핑은 API 매핑 단계에서 확정한다. + +## 7. 노후관로분석 화면 + +### 구현 위치 후보 + +- 기존 하수처리구역 클릭 상세: `FeatureClickEventPopup` +- 처리구역 전용 부가 섹션: 신규 `AgedPipeAreaAnalysisSection.tsx` + +### 작업 내용 + +- 하수처리구역 피처 클릭 시 기존 기본정보는 유지한다. +- 기본정보 아래에 `하수관로 분석정보` 섹션을 추가한다. +- 평균/최장/최신 매설년도는 `VerticalTable` 또는 기존 테이블 톤으로 표시한다. +- `보수이력` 링크/버튼은 기존 `Button` 톤을 사용한다. +- 처리구역 내 관로 0건이면 명세서 문구를 표시한다. + +## 8. 검증 목록 + +| 구분 | 확인 내용 | +|---|---| +| 화면 | Header/LNB/GisTree/상세 팝업이 기존 화면과 같은 위치와 톤인지 확인 | +| 좌측 설정 | `시설물` 영역과 하단 설정 영역이 각각 접힘/펼침 되는지 확인 | +| 입력 | 하단 설정 영역은 `경과년도(년)` 단일 입력만 노출되는지 확인 | +| 레이어 | 노후관로 체크 ON/OFF 시 지도 스타일만 바뀌고 기존 하수관로 레이어가 사라지지 않는지 확인 | +| 목록 | 노후관로 목록 아이콘 클릭 시 `/map/aged-pipe-popup` 윈도우 팝업이 열리는지 확인 | +| 버튼 | 목록 윈도우 팝업 `조회=primary`, `다운로드=green`인지 확인 | +| 상세 | 지도 관로 클릭 및 팝업 목록 행 클릭 시 기존 상세 팝업 톤으로 표시되는지 확인 | +| 다운로드 | 조회 결과 엑셀 다운로드가 동작하는지 확인 | +| 빌드 | `pnpm --filter @common/business_components run build`, `pnpm --filter sn run build` | + +## 권장 구현 순서 + +1. `AgedPipeFilterPanel`을 단일 입력 설정 패널로 먼저 정리한다. +2. `Gis`/`GisTree`에 optional footer slot을 추가할지, `sn` 전용 좌측 패널 조합으로 갈지 결정한다. +3. `page.tsx`에서 지도 오버레이 방식(`waterDepth`)을 제거하고 좌측 설정 영역으로 연결한다. +4. `/map/aged-pipe-popup` 윈도우 팝업을 기존 버튼/테이블 톤에 맞춘다. +5. 상세는 `FeatureClickEventPopup` 재사용 가능성을 먼저 구현 검토한다. +6. 노후관로분석의 처리구역 분석 섹션을 추가한다. +7. 브라우저에서 화면설계서 기준 ①~④ 동작을 확인한다. + +## 2026-04-23 추가 보완 작업 + +| 순서 | 보완 항목 | 주요 파일 | 처리 방향 | +|---|---|---|---| +| 1 | 상세 기본정보 헤더 경과년도 표시 | `FeatureClickEventPopup.tsx`, `agedPipeUtils.ts` | 하수관로 상세의 `기본정보` 옆에 `경과년도 n년` 배지를 표시한다. | +| 2 | 경과년도 인라인 입력 안정화 | `page.tsx`, `AgedPipeLayerSettingPanel.tsx`, `GisTree.js` | 입력 중 컴포넌트 재마운트로 포커스가 끊기지 않도록 inline setting 렌더링을 React node 방식으로 안정화한다. | +| 3 | 목록을 윈도우 팝업으로 변경 | `AgedPipePopupPage.tsx`, `/map/aged-pipe-popup` | Dialog modal 또는 지도 내부 fixed popup 대신 별도 Next 라우트를 브라우저 윈도우 팝업으로 표시한다. | +| 4 | 오수관/노후관로 색상 분리 | `tb_sn_lyr_stng`, `DB/001-노후관로-레이어설정.md` | 오수관은 주황색, 노후관로 강조선은 빨간색으로 구분한다. | +| 5 | sentinel 설치일자 제외 | `agedPipeUtils.ts` | `1900-01-01`, `1999-01-01`로 등록된 설치일자는 조회 CQL과 경과년도 계산에서 제외한다. | +| 6 | 노후관로 줌 레벨 기준 명시 | `page.tsx`, `agedPipeUtils.ts` | 노후관로 강조 레이어 표출 기준은 `minViewSize = 14`로 관리한다. | +| 7 | 윈도우 팝업 상태 연동 | `page.tsx`, `AgedPipePopupPage.tsx` | 좌측 설정 적용, 목록 행 선택, 최소화 상태를 `postMessage`로 부모 지도와 팝업 사이에 동기화한다. | +| 8 | 선택 관로 시각 강조 | `BUSINESS_COMPONENTS_ROOT/src/ui/map/lib/gis.select.js` | 선택된 선 피처에 다중 stroke(soft glow, white halo, violet core, highlight)를 적용한다. | diff --git a/apps/sn/docs/노후관로관리/03-API응답필드매핑표.md b/apps/sn/docs/노후관로관리/03-API응답필드매핑표.md new file mode 100644 index 000000000..3819b2da2 --- /dev/null +++ b/apps/sn/docs/노후관로관리/03-API응답필드매핑표.md @@ -0,0 +1,73 @@ +# 노후관로관리 API 응답 필드 매핑표 + +## 목적 + +노후관로관리 화면에서 사용할 API와 응답 필드를 화면 컴포넌트 기준으로 매핑한다. +이 문서는 다음 단계에서 상세 작성한다. + +## API 목록 + +| 구분 | Method | Endpoint | 사용 컴포넌트 | 상태 | +|---|---|---|---|---| +| 레이어 설정 | GET | `/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK` | `Gis`, `GisTree` | 기존 | +| 관로 목록 | GET | `v1/fclts?lyrTypeNm=tb_ms_swage_ppl&cqlFiter=...` | `AgedPipePopupPage`, `UiTable` | 기존 재사용, 백엔드 검증 필요 | +| 관로 상세 | GET | `v1/fcltppl/{fcltMngNo}/{mngInstCd}` | `FeatureClickEventPopup`, `VerticalTable` | 기존 재사용 | +| 엑셀 다운로드 | GET + 프론트 생성 | `v1/fclts?lyrTypeNm=tb_ms_swage_ppl&cqlFiter=...` | `AgedPipePopupPage` | 임시 재사용 | +| 위치 이동 | WFS/GeoServer | `tb_ms_swage_ppl.{fid}` | `GisApp.Module.layer.showSearchResultsFromGeoJSON` | `fid` 계약 검증 필요 | +| 처리구역 분석 | - | 미확정 | 분석정보 섹션 | 확인 필요 | +| 보수이력 | - | 미확정 | 보수이력 버튼/팝업 | 확인 필요 | + +## 조회 조건 매핑 + +| 조건 | 화면 기본값 | API 전달 방식 | 비고 | +|---|---:|---|---| +| 경과년도 | 30 | `instl_ymd <= '{오늘 기준 경과년도 차감일자}'` CQL | 사용자 지시 기준. 요구사항 원문/기존 문서의 20년과 차이가 있어 30년으로 보정 | +| 시도 | 전체 | `pbadms_emd_cd LIKE '{ctpvCd}%'` | 시군구가 전체일 때만 적용 | +| 시군구 | 전체 | `pbadms_emd_cd LIKE '{sggCd}%'` | 시군구 선택 시 시도 조건보다 우선 | + +### 제외 조건 + +설치일자가 실제 null 대신 sentinel 값으로 적재된 데이터는 노후관로 목록/강조선 조회에서 제외한다. + +| 제외 대상 | API 전달 방식 | 비고 | +|---|---|---| +| 설치일자 없음 | `instl_ymd IS NOT NULL` | CQL 기본 조건 | +| `1900-01-01` | `instl_ymd NOT LIKE '19000101%'` | 원본 `19000101 ` 형태 대응 | +| `1999-01-01` | `instl_ymd NOT LIKE '19990101%'` | null 대체값으로 등록된 데이터 제외 요청 반영 | + +## 필드 매핑 + +| 화면 영역 | 표시명 | 응답 필드 | 변환/코드매핑 | 빈 값 처리 | 비고 | +|---|---|---|---|---|---| +| 목록 | 시설관리번호 | `fclt_mng_no`, `fcltMngNo` | 문자열 | `-` | | +| 목록 | 관리기관코드 | `mng_inst_cd`, `mngInstCd` | 문자열 | `-` | 상세 API path 변수 | +| 목록 | GeoServer Feature ID | `fid` | 문자열 | 필수 | 목록 행 클릭 위치 이동 기준 | +| 목록 | 설치일자 | `instl_ymd`, `instlYmd` | 날짜 표시 | `확인불가` | 경과년도 산정 기준 | +| 목록 | 관경(mm) | `pdmt_vl`, `pdmtVl` | 숫자 | `-` | | +| 목록 | 연장(m) | `prlg_vl`, `prlgVl` | 숫자 | `-` | | +| 목록 | 용도 | `usg_cd`, `usgCd` | `MS_SWAGE_PPL_USE_CODES` | `-` | | +| 목록 | 재질 | `mqlt_cd`, `mqltCd` | `MS_SWAGE_PPL_CODES` | `-` | | +| 목록/상세 | 경과년도 | 설치일자 기반 계산 | `현재연도 - 설치연도` | `확인불가` | 백엔드 필드 여부 확인 필요 | + +## 현재 확정 + +- 노후관로 목록은 기존 `GET v1/fclts?lyrTypeNm=tb_ms_swage_ppl&cqlFiter=...`를 재사용한다. +- `cqlFiter` 오탈자 파라미터를 유지하되, 백엔드는 `cqlFilter`도 호환한다. +- 노후관로 전용 신규 API는 만들지 않는다. +- 상세 기본정보 헤더에는 설치일자 기반 경과년도를 배지 형태로 함께 표시한다. +- 노후관로 현황은 `/map/aged-pipe-popup` 윈도우 팝업에서 자체 조회한다. +- 부모 지도와 팝업 간 상태 동기화는 API가 아니라 `postMessage`로 처리한다. + - `SN_AGED_PIPE_ROW_SELECT`: 팝업 목록 행 선택 row를 부모 지도에 전달 + - `SN_AGED_PIPE_FILTER_SYNC`: 부모 좌측 설정의 적용 필터를 열린 팝업에 전달 + - `SN_AGED_PIPE_POPUP_MINIMIZE`: 팝업 최소화 상태를 부모 지도에 전달 + +## 미확인사항 + +- 기존 `v1/fclts`로 1차 구현은 가능하나 운영 기준 전용 API가 필요한지 백엔드에서 판단 필요 +- `cqlFiter` 파라미터명 오탈자 호환 여부 확인 필요 +- `cqlFiter`를 문자열로 받는 경우 허용 필드와 연산자 제한 필요 +- 경과년도 계산을 프론트에서 계속 할지, 백엔드 응답 필드로 받을지 확인 필요 +- `fid`가 GeoServer `FEATUREID`와 항상 일치하는지 확인 필요 +- `mngInstCd`가 없는 노후관로 행에서 상세 API를 어떻게 처리할지 확인 필요 +- 엑셀 다운로드는 현재 프론트 유틸로 1만 건까지 생성하며, 5만 건 이상 서버 비동기 파일 API 필요 여부 확인 필요 +- 처리구역 분석 통계를 프론트 계산으로 할지, 백엔드 API로 받을지 diff --git a/apps/sn/docs/노후관로관리/04-화면흐름.md b/apps/sn/docs/노후관로관리/04-화면흐름.md new file mode 100644 index 000000000..2a81730c7 --- /dev/null +++ b/apps/sn/docs/노후관로관리/04-화면흐름.md @@ -0,0 +1,40 @@ +# 노후관로관리 화면흐름 + +## 목적 + +사용자 이벤트와 화면 상태 변화를 정리한다. + +## SN_MN010102 노후관로관리 + +| 트리거 | 동작 | 결과 | +|---|---|---| +| 화면 진입 | `Gis` 초기화, `SN_NETWORK` 레이어 설정 조회 | 지도와 시설물 트리 표시 | +| 노후관로 체크 ON | 경과년도 기준에 맞는 관로 스타일 강조 | 지도에 노후관로 표출 | +| 좌측 하단 경과년도 적용 | 기준값 갱신, 레이어 스타일 재적용, 열린 윈도우 팝업에 필터 동기화 메시지 전송 | 지도/목록 조건 갱신 | +| 노후관로 목록 아이콘 클릭 | `/map/aged-pipe-popup` 윈도우 팝업 오픈 | `UiTable` 목록 표시 | +| 목록 조회 | 경과년도/시도/시군구 조건 조회 | 목록 갱신 | +| 목록 다운로드 | 조회 결과 다운로드 | 엑셀 또는 파일 응답 | +| 목록 행 클릭 | `postMessage`로 부모 지도에 row 전달 | 지도 위치 이동, `FeatureClickEventPopup` 표시 | +| 팝업 최소화 | 팝업에서 최소화 메시지 전송 후 창 닫기 | 지도 위 최소화 카드 표시 | +| 최소화 카드 열기 | `/map/aged-pipe-popup` 윈도우 팝업 재오픈 | 동일 조건의 목록 표시 | +| 지도 관로 클릭 | 피처 선택 | 상세정보 표시 | + +## 2026-04-23 보완 흐름 + +| 트리거 | 동작 | 결과 | +|---|---|---| +| 경과년도 입력 | 로컬 입력 상태만 변경 | 입력 중 포커스 유지 | +| 경과년도 적용 버튼 또는 Enter | 적용 조건 확정, CQL 재생성, 열린 팝업 목록 재조회 | 노후관로 강조 레이어와 윈도우 팝업 목록 갱신 | +| 목록 팝업 열기 | 실제 Next 라우트 `/map/aged-pipe-popup` 실행 | 브라우저 밖 윈도우 팝업으로 동작 | +| 상세 패널 표시 | 설치일자 기반 경과년도 계산 | `기본정보` 옆에 경과년도 배지 표시 | +| 노후관로 조회 | sentinel 설치일자 제외 조건 추가 | `1900-01-01`, `1999-01-01` 데이터 제외 | +| 노후관로 체크 ON | `minViewSize = 14` 기준 강조 레이어 추가 | 줌 레벨 14 이상에서 일반 노후관로 강조선 확인 | +| 선택 관로 표시 | 선 피처 선택 스타일 적용 | 다중 stroke로 보라색 선택선 강조 | + +## SN_MN010103 노후관로분석 + +| 트리거 | 동작 | 결과 | +|---|---|---| +| 화면 진입 | 하수처리구역 레이어 표시 | 분석 대상 구역 확인 | +| 처리구역 클릭 | 구역 기본정보 및 관로 분석정보 조회/계산 | 우측 상세 패널 표시 | +| 보수이력 클릭 | 보수이력 팝업 또는 화면 호출 | 이력 목록 표시 | diff --git a/apps/sn/docs/노후관로관리/05-검증계획.md b/apps/sn/docs/노후관로관리/05-검증계획.md new file mode 100644 index 000000000..da4060510 --- /dev/null +++ b/apps/sn/docs/노후관로관리/05-검증계획.md @@ -0,0 +1,39 @@ +# 노후관로관리 검증계획 + +## 브라우저 검증 + +| 항목 | 확인 내용 | 상태 | +|---|---|---| +| 기존 레이아웃 | Header/LNB/GisTree/상세 패널 위치가 기존 화면과 같은지 확인 | 대기 | +| 좌측 설정 | 하단 경과년도 설정 영역이 좌측 패널 내부에 표시되는지 확인 | 대기 | +| 접힘/펼침 | 시설물 영역과 설정 영역이 각각 접히고 펼쳐지는지 확인 | 대기 | +| 노후관로 체크 | 체크 ON/OFF 시 레이어 강조가 바뀌는지 확인 | 대기 | +| 목록 윈도우 팝업 | 목록 아이콘 클릭 시 `/map/aged-pipe-popup`이 브라우저 밖 윈도우 팝업으로 열리는지 확인 | 대기 | +| 버튼 색상 | 조회 primary, 다운로드 green 톤인지 확인 | 대기 | +| 상세 연동 | 지도 클릭/팝업 목록 행 클릭 시 상세 패널 표시 | 대기 | +| 상세 경과년도 | 하수관로 상세 `기본정보` 옆에 경과년도 배지가 표시되는지 확인 | 대기 | +| 경과년도 입력 안정성 | 숫자 연속 입력 중 포커스가 유지되고 적용/Enter 후 조건이 반영되는지 확인 | 대기 | +| 설정-팝업 동기화 | 좌측 `노후관로 설정` 적용 시 열린 윈도우 팝업의 경과년도/목록이 같이 갱신되는지 확인 | 대기 | +| 팝업 최소화 | 윈도우 팝업의 `최소화` 클릭 시 지도 위 최소화 카드가 표시되고 `열기`로 다시 윈도우 팝업이 열리는지 확인 | 대기 | +| 설치일자 제외 | `1900-01-01`, `1999-01-01` 데이터가 목록/강조선 조회에서 제외되는지 확인 | 대기 | +| 색상 구분 | 오수관은 주황색, 노후관로 강조선은 빨간색으로 구분되는지 확인 | 대기 | +| 줌 레벨 | 줌 레벨 14 이상에서 노후관로 강조선이 표시되는지 확인 | 대기 | +| 선택 관로 강조 | 선택된 관로가 soft glow/white halo/violet core 다중 stroke로 지도 위에서 명확히 보이는지 확인 | 대기 | +| dev CSS/chunk 404 | 팝업 실행 후 `/_next/static/css/app/layout.css?...` 404 반복 호출이 발생하지 않는지 확인 | 대기 | + +## 빌드 검증 + +```powershell +pnpm --filter @common/business_components run build +pnpm --filter sn run build +``` + +`business_components`를 수정하지 않는 경우 `sn` 빌드만 우선 수행한다. + +## 네트워크 검증 + +- 레이어 설정 조회 +- 관로 목록 조회 +- 관로 상세 조회 +- 다운로드 요청 +- 처리구역 분석 요청 또는 계산용 데이터 조회 diff --git a/apps/sn/docs/노후관로관리/06-검증결과.md b/apps/sn/docs/노후관로관리/06-검증결과.md new file mode 100644 index 000000000..d83e549e7 --- /dev/null +++ b/apps/sn/docs/노후관로관리/06-검증결과.md @@ -0,0 +1,114 @@ +# 노후관로관리 검증결과 + +## 2026-04-22 구현 후 빌드 검증 + +| 구분 | 명령 | 결과 | 비고 | +|---|---|---|---| +| 공통 비즈니스 컴포넌트 | `pnpm --filter @common/business_components run build` | 성공 | `Gis`, `GisTree` optional 확장 포함 | +| sn 앱 | `pnpm --filter sn run build` | 성공 | Next 컴파일/페이지 생성 성공 | + +## 남은 검증 + +- 브라우저에서 `/map` 진입 후 실제 `SN_NETWORK` 응답에 `노후관로` 노드가 내려오는지 확인 +- 노후관로 체크 ON/OFF 시 좌측 하단 설정 컴포넌트 표시/숨김 확인 +- 노후관로 목록 아이콘 클릭 시 현황 모달 표시 확인 +- `v1/fclts`의 `cqlFiter` 조건이 경과년도/시도/시군구 조합대로 동작하는지 네트워크 확인 +- 지도 halo 강조가 기존 하수관로 선 색상을 덮지 않는지 브라우저 확인 +- 목록 행 더블클릭 시 지도 이동과 `FeatureClickEventPopup` 상세 표시 확인 + +## 특이사항 + +- `sn` 빌드 마지막 단계에서 Windows symlink 권한으로 standalone trace 복사 경고가 발생했지만, Next 빌드 자체는 성공했다. + +## 2026-04-23 보완 검증 + +| 구분 | 확인 내용 | 결과 | 비고 | +|---|---|---|---| +| 상세 경과년도 | 하수관로 상세 `기본정보` 헤더 옆 경과년도 배지 추가 | 반영 | `FeatureClickEventPopup`에서 `elapsedYears` 계산값 표시 | +| 경과년도 입력 안정화 | 좌측 인라인 설정 입력 중 재마운트 방지 | 반영 | `GisTree`가 React node inline view를 렌더링하도록 보완 | +| 목록 윈도우 팝업 | 노후관로 현황을 Dialog modal 대신 드래그 가능한 fixed popup으로 표시 | 반영 | `AgedPipeListModal`에서 헤더 pointer drag 처리 | +| sentinel 설치일자 제외 | `1900-01-01`, `1999-01-01` 데이터 제외 | 성공 | CQL 조건 API 200, 결과 첫 5건 sentinel 미포함 | +| 오수관 색상 | 일반 오수관 색상을 주황색으로 변경 | 성공 | `lyr_sn=6`, `line_colr=rgba(255, 128, 0, 1)` 확인 | +| 노후관로 줌 레벨 | 노후관로 강조 레이어 최소 줌 기준 | 확인 | `AGED_PIPE_MIN_ZOOM=14`, `minViewSize=14` | +| 공통 컴포넌트 빌드 | `business_components` 빌드 | 성공 | `pnpm --filter @common/business_components run build` | +| `sn` 앱 빌드 | `sn` 빌드 | 성공 | Next 컴파일/페이지 생성 성공. standalone symlink 권한 경고 발생 | + +## 2026-04-23 추가 UX 보완 검증 + +| 구분 | 확인 내용 | 결과 | 비고 | +|---|---|---|---| +| 브라우저 밖 팝업 | 노후관로 현황을 열 때 처음부터 별도 창으로 표시 | 반영 | 사용자 클릭 시점에 `/map/aged-pipe-popup` Next 페이지를 `window.open`으로 실행 | +| 새 창 렌더링 | 별도 창을 실제 Next 라우트로 분리 | 반영 | `about:blank`에 React/CSS를 주입하던 방식 제거. `/map/aged-pipe-popup`에서 목록 자체 조회/렌더링 | +| 팝업 동작 기준 | 노후관로 현황 기본 동작을 별도 윈도우 팝업으로 고정 | 반영 | `새 창`/`지도 안으로` 전환 버튼 제거. 팝업 내부 앱 버튼은 `최소화`만 유지 | +| 새 창 CSS 404 방지 | 새 창 CSS 복제 방식 제거 | 반영 | `/_next/static/css/app/layout.css?...` dev 404 반복 호출 방지를 위해 stylesheet 복사/주입 구조 제거 | +| 목록 행 선택 연동 | 새 창 목록 행 클릭 시 부모 지도 화면으로 선택 row 전달 | 반영 | `window.opener.postMessage`로 row를 전달하고 부모 `/map`에서 위치 이동/상세 패널 표시 | +| 최소화 카드 기준 | 팝업에서 `최소화` 클릭 시 지도 위 최소화 카드 표시 | 반영 | 최소화 카드의 `열기`는 다시 `/map/aged-pipe-popup` 윈도우 팝업을 실행 | +| 지도 설정-팝업 필터 동기화 | 좌측 `노후관로 설정` 적용 시 열린 윈도우 팝업 목록도 재조회 | 반영 | 부모 `/map`에서 `SN_AGED_PIPE_FILTER_SYNC` 메시지 전송, 팝업에서 필터/목록 1페이지 갱신 | +| 선택 관로 시각 강조 | 선택된 선 피처 스타일 개선 | 반영 | soft violet glow, white halo, violet core, lavender highlight 다중 stroke 적용 | +| dev CSS/chunk 404 복구 | dev 서버 중 `next build` 실행으로 `.next` 산출물 혼재 발생 | 조치 | `apps/sn/.next` 삭제 후 dev 서버 재시작 필요. dev 실행 중에는 `next build` 병행 금지 | +| 줌 레벨 기준 | 노후관로 강조 레이어 표시 시작 줌 레벨 | 확인 | `AGED_PIPE_MIN_ZOOM=14`, `minViewSize=14` | +| 지도 표시 원복 | 추가 안내 배지/범례/강조선 스타일 변경 제거 | 반영 | `lnWdth=4`, `lnColor=rgba(235, 40, 40, 0.82)` 유지 | +| 서버 상태 | 백엔드/프론트 포트 확인 | 성공 | `localhost:8883`, `localhost:3002` TCP 연결 성공 | +| `/map` 응답 | 프론트 `/map` HTTP 응답 확인 | 성공 | `GET http://localhost:3002/map` 200 OK | +| `sn` 앱 빌드 | 변경 후 `sn` 빌드 | 성공 | Next 컴파일/페이지 생성 성공. standalone symlink 권한 경고 발생 | +| TypeScript 검사 | `pnpm --filter sn exec tsc --noEmit --pretty false` | 기존 오류 잔존 | 이번 변경 신규 오류는 해소. 기존 `common/index.ts`, `html/(with-base-layout)`, chart implicit any 오류 잔존 | + +### 브라우저 검증 제약 + +- 현재 workspace에 Playwright CLI가 설치되어 있지 않아 자동 브라우저 조작 검증은 수행하지 못했다. +- 실행 중인 Chrome은 remote debugging port가 열려 있지 않았다. +- Headless Chrome 스크린샷은 임시 브라우저 세션 특성상 사용자 로그인/지도 리소스 상태를 재현하지 못해 최종 UI 증적으로 사용하지 않았다. + +### API 검증 + +```text +GET /sewerage-service/api/v1/fclts + ?lyrTypeNm=tb_ms_swage_ppl + &cqlFiter=instl_ymd IS NOT NULL + and instl_ymd NOT LIKE '19000101%' + and instl_ymd NOT LIKE '19990101%' + and instl_ymd <= '19960423' + &page=0 + &size=5 +``` + +결과: + +```text +status=200 +count=735119 +첫 5건 설치일자: 19730731, 19830331, 19690630, 19870509, 19841020 +``` + +### DB 검증 + +```text +lyr_sn=6 +lyr_nm=오수관 +line_colr=rgba(255, 128, 0, 1) +``` + +## 검증 개요 + +| 항목 | 내용 | +|---|---| +| 검증 일자 | 2026-04-22 | +| 검증자 | Codex | +| 대상 화면 | SN_MN010102, SN_MN010103 | +| 실행 URL | 빌드 검증 기준 | +| 브라우저 | 미수행 | + +## 검증 결과 + +| 항목 | 결과 | 증적 | 비고 | +|---|---|---|---| +| 공통 비즈니스 컴포넌트 빌드 | 성공 | `pnpm --filter @common/business_components run build` | `startOfMonth` unused 경고는 기존 빌드 경고로 보임 | +| `sn` 앱 빌드 | 성공 | `pnpm --filter sn run build` | 종료 코드 0. Windows symlink 권한 경고 발생 | +| 타입 검사 | 부분 실패 | `pnpm --filter sn exec tsc --noEmit` | 신규 `layerSettingView` 관련 오류는 해소. 기존 `SwtrmtFclt`, `with-base-layout`, chart implicit any 오류 잔존 | +| HTML/코드 공백 검사 | 성공 | `git diff --check` | 관련 수정 파일 기준 | + +## 남은 리스크 + +- 브라우저에서 `/map` 실제 화면 조작 검증은 사용자가 최종 확인해야 한다. +- `tsc --noEmit`의 기존 타입 오류는 별도 정리가 필요하다. +- Next standalone trace 중 symlink 권한 경고가 있어 Windows 개발 환경 권한 확인이 필요하다. diff --git a/apps/sn/docs/노후관로관리/07-백엔드-착수프롬프트.md b/apps/sn/docs/노후관로관리/07-백엔드-착수프롬프트.md new file mode 100644 index 000000000..d3179e6c6 --- /dev/null +++ b/apps/sn/docs/노후관로관리/07-백엔드-착수프롬프트.md @@ -0,0 +1,55 @@ +# 노후관로관리 백엔드 착수 프롬프트 + +아래 프롬프트는 새 스레드에서 백엔드 작업을 시작할 때 그대로 사용한다. + +```text +WORKSPACE_ROOT=C:\mindone_project\iipbs-workspace 기준으로 작업해줘. + +프론트 노후관로관리 1차 구현은 마무리했고, 이제 sewerage-service 백엔드 계약 정리와 보강을 시작하려고 한다. + +반드시 먼저 아래 AGENTS.md를 확인해줘. +- WORKSPACE_ROOT/AGENTS.md +- WORKSPACE_ROOT/IIPBS/sewerage-service/AGENTS.md +- WORKSPACE_ROOT/frontend/apps/sn/AGENTS.md + +이번 백엔드 작업의 기준 문서는 아래야. +- IIPBS/sewerage-service/docs/AI코딩하네스/작업계획/001-BE-P2-노후관로관리-작업카드.md +- frontend/apps/sn/app/html/06. 노후관로관리_시나리오_v1.0.md +- frontend/apps/sn/docs/노후관로관리/03-API응답필드매핑표.md +- frontend/apps/sn/docs/노후관로관리/99-미확인사항.md + +현재 프론트에서 확정된 동작은 다음과 같아. +1. /map 좌측 레이어 트리 최상위 순서는 시설, 침수위험지도(중점관리지역), 도시침수지도(정보제공포털), 노후관로 순서다. +2. 노후관로는 기존 하수관로 시설물 레이어와 별도 제어 항목이다. +3. 노후관로 체크 시 경과년도/지역 조건에 맞는 하수관로를 지도에 빨간 강조선으로 표출한다. +4. 노후관로 항목 바로 아래 인라인 설정으로 경과년도(기본 30년)를 입력한다. +5. 노후관로 목록은 GET v1/fclts?lyrTypeNm=tb_ms_swage_ppl&cqlFiter=... 를 현재 재사용한다. +6. 목록 행 클릭 시 GeoServer FEATUREID(fid)로 위치 이동하고, GET v1/fcltppl/{fcltMngNo}/{mngInstCd} 상세를 우측 패널에 표시한다. +7. 목록 필수 필드는 fid, fcltMngNo/fclt_mng_no, mngInstCd/mng_inst_cd, instlYmd/instl_ymd, pdmtVl/pdmt_vl, prlgVl/prlg_vl, usgCd/usg_cd, mqltCd/mqlt_cd 이다. + +백엔드에서 우선 확인/정리할 것은 다음이야. +1. GET /api/v1/fclts 구현 위치와 파라미터 처리 방식 확인 +2. cqlFiter 파라미터명 오탈자 호환 여부 확인 또는 cqlFilter와의 호환 처리 방안 제안 +3. CQL 문자열 직접 전달 시 허용 필드/연산자 제한 또는 안전한 검색 DTO로 전환 가능성 검토 +4. instl_ymd <= cutoff, pbadms_emd_cd LIKE 조건이 tb_ms_swage_ppl 기준으로 정상 동작하는지 확인 +5. 목록 응답 fid가 GeoServer FEATUREID로 사용 가능한지 확인 +6. mngInstCd 누락 행이 있을 때 상세 API 처리 방식 확인 +7. tb_sn_lyr_stng에 정식 노후관로 레이어 설정을 넣는 것이 맞는지 분석 +8. 필요하면 노후관로 전용 read-only API를 설계하되 기존 프론트/기존 API 계약은 깨지 않게 해줘. + +작업 방식: +- 먼저 코드와 문서를 읽고 영향 범위를 정리해줘. +- DB 스키마 변경은 기본적으로 하지 말고, 필요한 경우 이유/영향/롤백을 먼저 정리해줘. +- 백엔드 변경이 필요하면 최소 범위로 구현해줘. +- 가능한 테스트나 API 검증 방법을 함께 제시해줘. + +결과 보고는 아래 형식으로 해줘. +- 작업 목적 +- 수정 대상 파일 +- 변경 내용 요약 +- 영향 범위 +- 테스트 / 검증 방법 +- 롤백 방법 +- 남은 리스크 +``` + diff --git a/apps/sn/docs/노후관로관리/99-미확인사항.md b/apps/sn/docs/노후관로관리/99-미확인사항.md new file mode 100644 index 000000000..20f025ff8 --- /dev/null +++ b/apps/sn/docs/노후관로관리/99-미확인사항.md @@ -0,0 +1,27 @@ +# 노후관로관리 미확인사항 + +| 일자 | 구분 | 내용 | 확인 필요 대상 | 상태 | +|---|---|---|---|---| +| 2026-04-22 | BE | `v1/fclts`의 `cqlFiter` 파라미터명 오탈자 호환 여부 | BE | 확인 필요 | +| 2026-04-22 | BE | CQL 문자열 직접 전달 방식의 허용 필드/연산자 제한 여부 | BE | 확인 필요 | +| 2026-04-22 | BE | 목록 응답 `fid`가 GeoServer `FEATUREID` 위치 이동에 항상 사용 가능한지 | BE/GeoServer | 확인 필요 | +| 2026-04-22 | BE | `mngInstCd` 누락 행의 상세 API 처리 방식 | BE | 확인 필요 | +| 2026-04-22 | BE | `tb_sn_lyr_stng`에 정식 노후관로 레이어 설정을 추가할지 여부 | BE/DB | 확인 필요 | +| 2026-04-22 | BE | 대량 엑셀 다운로드를 프론트 생성으로 유지할지 서버 파일 API로 전환할지 | BE/FE | 확인 필요 | +| 2026-04-23 | FE | 상세 패널 기본정보 헤더에 노후관로 경과년도 표시 | FE | 반영 | +| 2026-04-23 | FE | 경과년도 입력 중 포커스 끊김/적용 누락 문제 | FE | 반영, 브라우저 검증 필요 | +| 2026-04-23 | FE | 노후관로 현황 목록을 모달이 아닌 윈도우형 팝업으로 표시 | FE | 반영, 브라우저 검증 필요 | +| 2026-04-23 | DB/FE | 오수관 색상을 주황색으로 변경하고 노후관로 빨간 강조선과 구분 | DB/FE | DB 반영 | +| 2026-04-23 | FE | `1900-01-01`, `1999-01-01` sentinel 설치일자 제외 | FE | 반영, API 검증 필요 | +| 2026-04-23 | FE | 노후관로 강조 레이어 표출 줌 레벨 기준 | FE | `minViewSize=14` 확인 | +| 2026-04-23 | FE | 윈도우 팝업과 지도 좌측 설정의 경과년도 조건 동기화 | FE | 반영, 브라우저 검증 필요 | +| 2026-04-23 | FE | 팝업 최소화/복원 기준 | FE | 팝업 `최소화` 클릭 시만 최소화 카드 표시로 반영 | +| 2026-04-23 | FE | 선택 관로 보라색 강조선 시각 개선 | FE/공통컴포넌트 | 반영, 브라우저 검증 필요 | + +## 우선 확인 대상 + +- 기존 `v1/fclts`의 필터 조건으로 경과년도/지역 조회를 모두 처리할 수 있는지 +- 경과년도 기준값 저장/로그 API가 필요한지 +- 처리구역 분석 통계를 프론트 계산으로 할지 백엔드 API로 받을지 +- 엑셀 다운로드 5만 건 이상 처리 방식 +- 지반침하정보도 노후관로와 같은 인라인 설정 확장 포인트를 사용하되, 백엔드 API/레이어 설정은 별도 확정 필요 diff --git a/apps/sn/docs/노후관로관리/README.md b/apps/sn/docs/노후관로관리/README.md new file mode 100644 index 000000000..d7c1a18e7 --- /dev/null +++ b/apps/sn/docs/노후관로관리/README.md @@ -0,0 +1,32 @@ +# 노후관로관리 문서 + +## 목적 + +노후관로관리 및 노후관로분석 화면을 `sn` 기존 화면/컴포넌트 기준으로 구현하기 위한 기능별 산출물 묶음이다. + +## 대상 화면 + +| 화면 ID | 화면명 | 메뉴 경로 | +|---|---|---| +| `SN_MN010102` | 노후관로관리 | 하수관망도 > 노후관로관리 | +| `SN_MN010103` | 노후관로분석 | 하수관망도 > 노후관로분석 | + +## 문서 순서 + +| 문서 | 역할 | +|---|---| +| `00-범위.md` | 기능 범위와 제외 범위 | +| `01-기존화면기반-컴포넌트분해.md` | 기존 화면 기준 영역/컴포넌트/색상 분해 | +| `02-컴포넌트별-작업목록.md` | 실제 구현 전 작업 단위 목록 | +| `03-API응답필드매핑표.md` | API와 화면 표시 필드 매핑 | +| `04-화면흐름.md` | 사용자 이벤트와 화면 상태 흐름 | +| `05-검증계획.md` | 구현 후 검증 계획 | +| `06-검증결과.md` | 검증 수행 결과 | +| `99-미확인사항.md` | 남은 질문과 의사결정 대기 항목 | + +## 작성 기준 + +- 실제 React 구현은 기존 `NoFooterLayout`, `Gis`, `GisTree`, `FeatureClickEventPopup`, `VerticalTable`, `UiTable` 흐름을 우선한다. +- 백엔드 상세 구현은 작성하지 않고, 필요한 API/필드/응답 사용 방식만 매핑한다. +- 화면설계서와 다른 독립 디자인을 만들지 않는다. + diff --git a/apps/sn/public/images/map/layericon/Layer_Select_BTN.png b/apps/sn/public/images/map/layericon/Layer_Select_BTN.png new file mode 100644 index 0000000000000000000000000000000000000000..1e810feafce85d43db278f048d541272f6d82253 GIT binary patch literal 14434 zcmbumbyyVNA2xh;X;3f#5hPSP1q75_P*A$NrMtTpP*jv|kdg)|iKV+k5D=u5l$K_R z9xX{;u z=h!Lqh3z6Ct%eVMeDO`+LHh)bFST6&Anv&OgT=8CQ$Yvsx=L!fsydjvdKf#K0S^xk zPAhvG7ZYPgGfoF*i6o8O zBHkdGgF_$OipR%)^5iSF7TwJorY^9Xu#4IZy6^|| zf%K^Qq5ZRm;?gzJ+l~5e>z_o^QKh`4UW18(id?AmwY+sTX{dewZy&X|9AdgR4i3ME z`g6i6^tc{bMa#x4qt%jf`+mHnrj;5}#RCVdveby{HFf4Xk-F6_&Kx{zPW=Z$3ER_) z3TLY8e?!NQ30iVe(6~vD^|eK3XAIUyx}q8L!io6Cj|1Eg3>=TwL=)zh;|Fb)laH$n zr?3iwLko85K8t%*qs^!0;&wPHs2S|EJa70Nm%#I>3az8{j{DCAy^aR!0}3|q%)Hhk zCDv0i*GD*fO;npAYPXoyBnuW{g-C^_SEsq%CD~%q80!8Cm-$`UdS16j_th{P2U~-_ zLJlWym%U?7e;vM-!ZYOd4GtV}S?i9b7F$%P=X7Il%(wM$S(Ej%;JwVx-cs%6VT|)z zg@Nd$5#e5ieCr2FwZ?y@Q_em(qmy_VS=`vcdO>AeRo~$szeOMC6h5fy`s)a(EFQyz z%cPZJw<*D~C8l@k=gbHzA+G|7fLeqdyZio-f_AK*`NaIsk+If~&l&2XWCe}~dG}@Y zyrutC%TD)dF2umVm!)mF>Ior#V~;#yQ+XM&fq}`kV~>3<>EuK~a`1 zs$)P@cyaXzJ(l@J!Rj`WsQRz;BKsw6%%B`Kk&?9QU&Jf_)()woIn<1?Vs^OclN>p~ zIWse1W#<1TdrPHTg~0UyC~2VFsg82}cFLM_h)vCMWC7>gNP0?dX|&D$SUEu0#o4>& zSNiPbHaewG_f=In7Kko5$nVS7)f#e4H0OomRk}XRMBMiI=2v z(4)-0e@x;|qLdxEmi&K-irJ+ZZpvRWOupC)vug5czaW>st54tzCWCgvy!Ze1H?A+p&-4=V+|PV7r4;YLH4z>?$81#`)P7>-BF%5tHOsH| z*Kd>_+)><*kjC`R>D-ks=vwL9cS(@5^F3}sS7)Qn;NbLo&h*J^_xdvpRrj)`Qm)Oe z5|iV`EGHG?W8IR!*uUB|Pd|JWzRV$HxtR8dnmC5SW zC!wEJybk?@x;{!;aL4aOzEsO-`OIlIX`YLA5ubh`2NdmFds={G(ia{?eXBV4iB7wl zbs;zDel}y?+THhiNBxG!Wnv2HY4qC7p^B}$=P*#dJ@K`z#MC9Z44*AqEC%W%i}OZ-b_Ro z0N!tU=r%M9h+V1s(rtW{7}8UNjFNQafTZfEqz?P|MI9c(g_Y8 z>7>4&ROXy*uA{BZS{@;4+GTBIG)im_HH7`OEgiO7iKqSMf&s`e;!h5;Gy4xEP&V;+ zv_hI19hAsaZ~NaYKj6Iew#~XaE5o1$7W!zTBk$t%%4X~xoVR@Z72Rm>jtX*0mG_RY_WXVe89s>%qkXR>xaJk={|EvDjato_ zXL^)`d>HKs{&!?HmDnvaxwy950=z3)0!BU%JP`0}a-+X!=Dt|`osNzFG`@t+0B)3g z@2~PZyjPtB0HI_aaBtL8puv&=QP~Y#tk#orMWvixwzZ`N!9d27``uqyVADF{P{mlH zuwUCBB&u)#LT!b}m%SShw>JinftX~GQh(p*BIW>{#P!Y@g0fo-HvkCI8T=_Qm9(4KZrQ*GogY&pJAxVgt0hzumT^u3 z@{0&<-%z;`BJ%(YxW(YvvaP7WRj-OF%}652m;(X0p(gpQ?Y%dp5%b4O0A6>RAfdsE zEuzG649CI+bo5tSU`FP49YH2T2NlqGE{gJsGO14?9GE!D3u7_)ypW1YV$tgaDN;kb~BOHo#>6sI2t07dbS zWkT*ekPn@_p;Ti1MgJi*kaU%VreOCLGIRjysnUzxalkxNGsq8(GiyOx&A;19vWc4s z*~68B?%)GNCM(ZT=Pbb^^F;uR1`?s+rpurc^H2Zs!13NA0LWAVxg#r}JLG3tl7W>= zXiU}L*f|#Nl7dNWsL>MR;}6Y&9CTx)XY^39F{QBz5HuL5X2b_giRf57VWRG&E9iPZ z3fd?=+f~5&@{#}>AS0dMq|}9@Z`>n;j$6OOzM2SyT~Y`D)FdUlnmlblhZGVZy$k=> zXub}o7K*6R`4yXb55sB4Y1Rb(_;asl`RGb_@%u~BG%?c>W*DE^?f$*<@!jzTw20=< z+c=#7JX8(5PoqSvyHxP(eMMMK#h+~&hI(t9@a6*+nJkF zHoqfFnZOec&&5vrUUmL9Z+n}IZCf3Ns3nUAv9;JLnpa2qMb1iO7lTM@`0I)?SOTl$ z(8u3?4o-a7^@k&VJWqw=kGHDk;jgn7wJhESRz4>HBq%jJDhZk%U ze**A}*ZU?sQROC<#hJFwn5WWw`V_09mp{LeC{p8qY$>=BZ(hS-UhPICoVJ&UNB+iM z^jb__^KAOF-|8^aH>Z07f+U^yg3`L z^f0es){q4qUa-jgJ~$r^@_BC|DV>Haa>9+(yz?J@q^qErSNC@eO~3(Wk({u6mEIZ> zUwg6iY&i$nr^lk}vL?+^ZSuTW&4Zv!4kuT3u^5K`v$g^^!vpi&^)GjFPxr6XANGKBncw*Xc&96`LX&QrUiL zjNDPhgJnd#KDVNFqJ-kyuYuWwp#CkFG>3aNlg;%THqqNCb_{!1eKMon{WT!H7|dr& zc+mt94E~mJ&2VH|>aM$p?npLLevYf(z+(Wl$pxFf7Zr2#m&-JMp4n>a=4I9orq5j4 z-1I<8oD4VMJs+rgG&VAIUtqU)>4m?g!ftRgz2C{SJ1R+(%#mzMCph@qJPhf`>U_`f zYVNEc*69)$@y?&@Nsiat*K^5Q)#jhyH>x16-o8j?w-V3Cg^lU+Spz6iSvQ<}Tdr8+ ze#+jt_N`pYy=@W+eJ^9$K-%{vzwl6QY>Xt$?7m}Es#$jt3@ zrNZ+i2-S$F52sW9e4GM(U*P)o9fXJ)&N}#xNkLVo^-v<`>?l>UoqIK^WX{%{s{VjI zX}3ypE%B_K^J09K%kXq!<-8R=E=_%V#F#LH^VIGMA==x!M@_6=b9L}iF*Sw*C2yu^ z)mNZobnq&`1~b_-d2?}mW5#2_Ak{?7S$bb6%0X8sOUI=uq=|jS>{I_% zcB2sOkUlMK|DDu>j1ZX#+9@eUzcXGRk8cJZ){B3SKiO@K-8>8%@f&Z?f-Bv=c>If9 z`IKv0+Q&^HVLFPfp_B}rg{R@%^N^?xE1Fo28b6e5=P}}q(DnDDz-LUYMxLdQr-#8; zglX%?q^|jk_&q;Z*NT4nWc_~0nE1hm!jA#F;~{T0GuHH!Zw33DBITHJ!?VIA-12jp z*w@_ldhyV)MzmV9>PDNg`qDUE^h>c_met_i#O8KXy}{4X>(Z9W3PbCh+fC0#G*$6} zUeS9AevxB{tiwmk-ml%2(a?8Z<1qX7Cy-_EO^MkZwx<@z-e$3EfZN4t-eg zovAph9Q_BgL7_qJ_%rn9>?0iTF{nRHz2`42Dh5wud9nZt;q($%eCz8LI zJY-)oXE2LoU5PLd-d0Fh!M45ia&avp&;PjQ1FpUNT@}NEeJO<5U1sF)m+wzxX18q^ zM3~&zA4(L%ZH`bRe`B*U6tbesbh{)~LkhbQ|eU>3I6nQs^TO{v<5CbJvizhM}KQw|+B$;ZIAJ-k$(_ z%*H_7G>wPy#_3r3L0mNbuFWkzSjI!vx8a@vCMP{O?uN&Tlkq%_y5=7Fg$@?$ggBZ( z{r-%TnH%z!_PgIpBhGSe%xZNvC+hy*Cq>LYA7CUR+jEnnJ`oFfJAv!lQb4u&K4!7Wnj||sFI|24JlT@~(R}UkMbOa0x>kp&;Ak2- zYCPmE{2!}QU)WS&`R}dAb2W?S4M+ef$GR0Vb9nNO?O~G5o{j{Hp1FIZO! z62>Lm1mbP=dZY&39D0rbB55hp-%aw~r6*yAsDHsJvSwfBF2mXf{-4#y_%d*ef#LkO|{!WW}|Ex@lElYd{~HA zo%Ep5{qGxWZ7gkm-P||axT6NLdXnk*RwE9F=UrwulW~ z7X#>71IGp}hAQ4zTWaim4R~!8bkpEj?eg1e#5(HXo487n3|yD8+_#ZPYT-6Y;XE{T zOcPWO{eb8}feW60yx=t`JZsj_a&QL}#OA?~654Z~&2j?S;SclqOh?R5yO@RszsXVD z2eG&W2>I)24zgeEO$3r=wNG%x%S(RL?`29Y()BX4!rpyYM5@1k8YxI6JWoSS+2iiG z)(u;%Q885Q-hv_ltQhM-ft4ySUPKa>5zl0_fhb0EbPd z(|fv~1S!CwQrvDtJ2IoSVv9hUSIphfB26yur4E~$9PE5j?((p=w6X+RT#?DWr1m&I z9ju;X?a0DMctHW^vD!fw%G55#Xii5KF7Y*g_K8~w>2;4C8kS-Vt$0qbT3DYKfhEF( zn%6SfW#+-F(}wiaj++7KJYKg5?g@iv#++~)Yz!wF?KLiZ*io^SIo1kVlCx>16X7jo zqWfg^iX*MTZ%?CUGqjQ zRt=|%gB4K3wQ@}xGFh$N_@bm1$i~a5g+AHt>JQPM0W68P*lUQU}bJ zSU|~OHy5y~z+r&yG09`xF5#w+{|L>07(Dch!ss88Ge-V194>^J) zP3JH|`|9%i&`)n}A3iYxV4YF3#z|=9){|VQZ2tX!JgSoLqporuC{(&CTFLrR_dk|! z5APa4yd=AOcEwwio;ji5;ge4QU{H%Ik61*)0DdvN=+_lx_}AdVt9E8w3!xG)As;Qe zVja(|e1GB3KUO{d!8yE@;s1Jii(=$YlD-gx)v7qs7N zm2*E#X)o$pd@_z8hiH=89Rd)-_vFvy=NEN5N@=tk=3KN#&1&Iq(m13?8RQ*SW=F$C zKl{1Ye-}Rd$h(<276?Nmb?Ra#atL(^bU;hUE9#4;%wlrUkw)zihQ9M<9;=^4zPUA% zc*$PJb6FN{VxB$s=VYjFAWwU-!UMcIb|W&$6n`HDYR^6t8S;NMQl#=?G?<0+a%OB7 zJ@|t5HbN@ z2ISX3G+$kYE^_YD-_S#%!lyXJ{XIX$hM{KbI8Vh6t(&RE(1OGDjA{)&H^C{k2O;Ch zRKguhI&+PB8f(#)$sc*w+Oo{u+~uKH3zJ~eNh{K*=hg8<8dwk7{XW?ike}joqi_AX ztZvBB@4BY^;(FtknmagPS=|?|^!3)u)W*x6^Wd~Kj?Tf}2-{G)1-@RFl>JN+bZpz4 zDLA}+iYG^Z#Z~oQ&{ja~V#g=Fxnn@_CH0H%a!0hO?#CTtPT-G2rhiRG2Ps0{Z0_Q7 zcHo%PN0}Pi)5aa2`Qg7QR4{~mtP2yO52M_8d9fv6Il1lSXA+y_+|Di}K9Z7!jRi8h z%4KNq^xDwG!oA@fmfsr7Y_7SRiPIqkKM3L|y@8gVB)8PYAEa6i3)SVVja=;$y3L={SV-mlIg@0?3_W!|QP~;4XBBF^#pu zSw8)Cvp7V8)6&Taf;RxOPUXyJR)$Eyr<;!gLY7jl*7A6Z39s?h(fc4ezwM{yw>)s8 zv!&zPJQDUZLI{7dW2lNP-5*WrQ!GSyBQi~^A} z^d`9kh~mE#MqxF!GA%!jP^2O!vLgj~Q}c4}dMy**g-w-ihx${4(}{PASe1N++^Dt) zAsnSZXbtDKde!oJef4)TC3QcQ*rAkwmXGQktFPHiL45dti~>^-af9F+X>zJxmO|8VB50-(vwR7j!VvrAQOs zVyC%TZTpnubM0uI0uu?z=V_~cDvWM3TgD0V)3yTso?Um3EMchPoSDLM5*k&2APE=l z5>T4Ec)dEC6s^;aspZ@u$Tu}s4?QVfiZ1nG%AT9uu5+9@U zWZx{N8p3iPeyU2qoCF(LLzo>W%J8ESm&vug-%`l{Jut!Tz2*(kS?F1DF?v>|oEA(>7G%a4hCp~e{R zOqh{kmRE13ha+?tD~k5*`vBknL;c5yLuvKlw%3`$NpZtgv-xc}U*X zCd0#K6MWq9z$bf3#H0gnDZPDAVC`FV#xVNqd*SXy2_QMp(CH8}&jstbB)B2=n&U4I z1qWzi&nMq+ha)81zFl->d%z*Q?c<*h~_F<~dqgjs90UcFhar}JJ$Q}%LBnC zSCtLQBzu`gA%kTxvDokw|6PYgN-=015$hqdfA}Tw39h%UAXJf@Fj z(`ine*Ab`M+TG2bWI*uqp7sw)@UeIEcJe$op8(&aS9FL|ojPLSUSC%3wn zmLFy4Arw|LBsxhWOMPqP0W3c%tr~8yMyu1-*Lq8OF!DHH>Qm33)MIU3<~5;yF2LLf zMTZi{;MaJ6GRp5U1b}yJM0@qGlHU38E5eNA_Fm7Y%8?(;4F);}6hYZZIoQ}c7TtTS zTzu#*aZ1Wyxxs{*H&hi=Bez>Av5=wfj@dYMoybhN?zn|Mn(;NxAL@`FB|%%B@kus^ zJR{zX=Mm#Kia~F-Dmy^g;UaS9i68G4Y002$%tSEk&JG{@=#QPJO@1NE?y8zi^jiHz zb;c`Pt~(7A`ntHbN8&6-Php5H^_*w-p?=A^JN&93W9)JJk`lowO>J^(;p;D=R|AI| zWgKt>pW{8pFxTX;bPF#gH;SBvT*K^e7=pI2rH0JT{r);;i6X`YcS|w)g3;}s*{dY| z1VLPIkw<7k+rMGmmBXgm{pdj^Dw+o4{W-Ji@J%BlV2-LJKoA^Blu+%Tw*}!&q#ZYh zH__IiFTc@LN;WoTfwE})3*&1LCA`_h+;`de^h^~CRkK!d>z-8fxy!NH+Y^A`kdy~~ zBi~IV)21y{x^E2ptXS9mcGs5sl7y~C@3%j``u>w&N{P7m+vN%ToN;Rpr%%dodE@#IzgW@ zw=7dA!S%2%k13ACV@#08P6?c9t9Lc;DZ=QI2{!n{8HsiB{6B9B-=E-d=Aja@g{$3>vmKS415!P4GwfLWQF{Q46Jp+RwH z#w+;9h-stY`!Y#(dP;;xuj{54E$xX!jR!F(4C4dCXj-a(fH_M$rqR;qgw1wC>I7Qn zZ@a}YZA~_y%=+?P`xjJEvML};!fNF)6!}K;*J)5w)?ayD3w|0SEtGb=8(5l6jEx8r zb~|U|4$(Lt_3HlF<`O}hm{h;DG4iaS|42ly)^xQpWiTP##djlwVuuW<>9e@Q^%l=L z2oHZm2yhuB3kXE5-R2Qd3F5)L-SA@iEzM7;Lw&gzwrJ5GMLytx)i10zN2`? ztJ9yyTQaZGrNKz)$iAPjDz;vZ0js4zOp>~j;yHm*It}Aqx_Q+31akWg5r1=)U?dj4Vp>=G_m>O1 zb`1Z4_RJa!*y}bEC=Xwl-=qB`$CuD(`8e!~8MKAg;Ua0b0aCEAjYRYRVtaNtBmjhb zE`jAs{7dK;@*Js~Jfwrvp3e_VX#YtrHwd81`AF>uTiE{bjY=hx3`spD1oYPbAK7O? z6j$rNriJ~^J1h*TO&ndz5hl87MxQF)BMEvyQRSzwYr+4eLV5dWVPOA#t?kx>?UW=g zr-|FSo2@fK$08>kHS>eCl?peHagqM*PAytl)VPhcnAmO3ZSSJ&HI>!QTG?ne)Pm?I zJ;tOZ=Uz(h?WJ#@lhSSG|Ey?QgpCU{@A~`Xml**g^(zr(Y!o~*CO}Z0eOefLyfYB( zM(i}v6t>TVfnVa8w3mDVv8Fe%X50HcXvcl}k(V`UU z_wReHQY0~wIgid=>QHhvN{^%*i4^;#vZcH2Dlcwti#!5<6gx$5Ek*Q%zi1^U* zB#YC1Zz~)imHpUne!Wv3$m~5Rw?6wcjzQR;8Vy7T+%3_o7DtWNS`Wqe7CY|H05#1q z>@)MqMqFi@cOiV*y+OsGC~hoC~V-(Ak6xyg+)0-z3Q zhI}STjUsO7M)rgh{>hDfS;MLQ6hfdFWka?&Ns$ZoWE5bggcI730J0!9bl?K@4VJ(GSSA%j4F6|+$bFc1q zUd0_5%tDKu{jF*d@JCtvM-UXP(^!6D`=4+ZrOA=I{(X#oGiP~m{HfhmxyN18qH9&C zz7EY0&4_2s<0t{k*mjmCx10&BB@SnSf-I`4y~@1VFn_#Nk{A>1x)wD^o49csmh!1` zH6!5|-xR!ijh&&uo_^2ruwiCzqx6%R?1zAxD!OR(O#Vs77{Q^jSkh7~pb{#8lVKf; zu+F`?-8eu)G(N`6$5+Hj}NyDdnbK~6TJ7Uz}ZV^Kd|}RQ#F$QFNWUm14yb?>LRXNk-&83VLYET#drIh zAN!>Jm}5+6)->GQ6dW7ljkV|tevbQb_#K8$zE*I`q#EXPqDihC^@=r7lU)%QM1LoZd6azZf!6g~<{t;87K?qpW)#@7s!*4Bqn>whGbl}79+=6GBYZyU=vg;9M&s0HaZJ+Pqg z>kPlOEgWKG%H1Bl=Y!i6PX5##VN%6Y@w!i28~y}SPJ$SGeX5X4<9loF@_K=x&(O>K za@G?4tE_08=e0--Yh)=$p#}D!9h~Fs>^gv8?#c({m}%XFK2=_=(2bP&bU`>N(^R`&E$L>1`;j`yyY4RUg}t4;aM-)<(wEfY zVm(-4e)j!Sp>GJmBTB4d*{VvH=*>t%Z9+bqCcbSUZWTK z+2AOHewrXL9Vzdi-)81YZ^7apHUj^QkK_=vP(UT|bKJdG>6BcZEcUY36M>tFXNc9BEBB85q$Dy`kUZN%%bax14KBvqO6>f>>LA zgSMKr2CP;h(#0LWVQeZm)Jka*y3D^oxG&64@fw8 zvwR1cTm-okOpy7xV+{Bp|4BGjNsOq@?oFQdl?|N#a(W!Fd>Mjl1wCz{8K%V{g0bO0 z%Y%o{7?Du>M-=M)Iwy#J%;i6}okuFdJZ++b8o2=7@*d*y zeeGTUWIm9^VWpIHz5U=ASzNq0hi=tv;&J`iCWf--TDZF~+qAGzD_B8)xq}=O6|@L( z!>YyuaTKV95||N9hMM!FANk%hZaap_!I<<3?Y$M3sSoXZ!ZXFO%j;kGsskFQCVY2( zPGKXhi#kk@WG}N>Td8L2i>a1p?=_!?u2{|IC~>tP6d^cZ$m}x7v&81Chb6F{AER?R zN3kEU0)st&(Bf{#uQ?Q;m__n%L+pY?xqzi^eFxK&iBbVsFKnk~eR zOJ0dBYCrMwLu@m=a%CfOIro_w2NaTd+dqre;R7>r4%naFyI>qVKe$LbA~6%>M9W}Y zCrW8(tOtpA4d5+~Gi`d@0l5jsETHdFSw8va>DPLD1?m^@Sz*-i%r1>h9t5S+3t!qb zJ}`bz`IPs|w7LbC=I~aa>_++zdQ@u>vHfhKAPsmKhMmz>B5JvS#W>w9|17gl-K;TQ zJ!W&d6l1dAF}5QF&PS73f5m-*_Y`vDsd$JzDdU@%C5arH$6H7&84X$)FiW`)LkNo| z?<(3fy)w3ZQzGa%BcP9-W;k3c^sM*Ydp(|V6=PIiz=4@K97vC-?@$KR>IwB7bdN2c zA~m;@0?hY1R;Im{NPq?@2aH0Uz*PQ@mCXGTVaH!gUiDQu*&kH9Z?)_UZi$sn1gCR_JP$1D?;H1AsSzPz1^vPQ6gw$WXL<+n=Pc$&5KJUhbb739yix)DsppM zHv(g-Q7&i`vMqL?HXV0{Dv3D=74UCtlwCm^bNXauYpmC3EDA6RiP=212nfi%c(%Yy0YF;BaD%kOcSZm7d?ZeP2QJ*y0^OE z;8G>3oXZ-Irf^9)>iBD^eq-~6`Utw8Oa6@3;bOWa`mk|QYoeZiQVy%!q%1@Xiao|Q zM!$^xm$8_OB8MouKM6vPCzG$fY_s!R&5%XsHW%!dg%HRnkq8UCDSM^B7seu>H2lLW zT#wJJ$v_Cmya z60$L|(_mo(I=WH>+OV+i-x21kBmZQD#7jIdrk-yFik6&OVj))(^M75+R1w4ZYq}i# z3Jx;9df)9R^t@L_`d1mB9J&kZ0}?IWh^Ob=5PLyfkMq77A{{OD&W2D`ATCSC4oMbj z&(q%c_(ojy?gl9VwB=I^TSQ)o8ETLsUksU=NVgC7N$H>#7e2E8ucCjWbYI*{Q*5!Z0Ro|p-tBj4xw4=Is{j2T-%wiW#3@PFr?amh_sP|v;EnwJ1GmuUDs`;Me;k;1efy4u7KO+P(lW9f$#{#3-hil08 z?>}LM&WT{p;%_N=AeW5V zyUaxO&r$Ol0MNx=5sVdoxxBKU6+%_vRPn8W%s^@Ax^oc(>j@)0H)z zD0EAiMc<#+)gHtCRV|?4+mdsJrGUjeGKqNMMPDu`HvIt z`}%(Lgeoksv)?1AUA73ZpP1n!m|f}n%mh%)P3VWx%|tVsAw+Md@^G#kxXRk6o@dvj zxrnFo*N#y;GYu@3DecTgge^0(T!huczj|*;=N{X;4URN^?j`{(kE$Eo*F^0F=}p(> z<)hCg;&*axtDj>dRL1v8`-)Ajoq1L93(;`&XV2 z$@%kKufVyCxICQk-ob#HA2T_;q23~p-*9qoD(B2qe}Cp+z<71%$3EY&MXAHA-q;39 z8Jy0`K0W;B`E!AK(|((`=KJsH0A1|WU~)B^{|V4@k9rk*kx;Q5xA*z=&kPT>>QB>{ zk^vLi^?hA!_l_ZVYlFLJQGgV!UUu6`bv~|;o1%XiTtwlff3#(|`P2JFq|X7lj4)R^ z>IA2c%0o8Pzp}Kqq-UAw{8e6{uE{(R7#6Ee6gaT14Hz)3CwHuuoRzW|HMAGo4TNtW zD!yR?(Fc1rQ?yZD18YdKq!RAq{Vqj{n#~nEJ165~9H7=!$>#gYV`O+*&%F#zyICQ~ zD73OFR4uAIc7bu5$L6me!_yhEyi^-8$C>vIG!=V{7OuUtEbeOY+t1U@`$YkXv}NtW z4x;1KQ$4)X;`XDn=V8&gq<4d*J@|904V=2!zUVLcCCm$jCFrAEd1Gb^Y4YISGpq$1 zYbYKOy2&hOL+1Cd0fL)QKjgZ!e64(xubnj2OYf*`jW$nmKAwOdiYQtS#n2QcSwO#L zHgTPro~27c2H{12!G_iLzDdAtwuZRIr8|&FWmeQ{qO#NN*=mX_V*QDC{CO&NTV@vC zt4p$zS`P)<%k3Tk?PBx36j_yx-SNy+maWT3o9VIgvFoYAN>R;lf1`uD&Bh}wH4jx@ zD2JB*sH|<$$eYAO$#Th36P+sK2%=Ta8Q|}Xb7=n9^Whr!OxSGET75<*jJOKv8eE*X zKw7+fbedQ-`JW|NwrU^^{@O)valPI5p|et%f{<*E65X%mJoP&L8W!-JXG{?)tm=y4 zzZ2I;lFq(~&HJBU6qtCatTpkEmIRzXO>OWpTVDf$-mwa67#-Nh;ZnU>{l2~aOX-|V z75?VdiQVmyetC`NhyJqY_W*H^@1*=NM#1OTV6%VYg@_v+8{3v6@0NS|lY^6C+r##U zL5RU088M|HBS$BGeQtBH<1N3;BZJDBi5l}nym)r9_N_l2MvosCM8vFX&- zoX+XV$TKtdplpZ|!H|j3YGbUt4`N4dGy2*0wgnEIn``PO3noV8&vajWO-a*gp0@Ip z8u2eMfq6?ZksQ3)S=RPH5t)`hd3NH$}TIM(f57}{V)nLKgQOe!Y+gROPtnBjvLH>F@1L+Q+_Yj z_I+}*tCOmy!f(kNB}bJTj`xR%Vf()4IyR0^i~OB}#nn00&$H*=$_rvU+|DTZfEll_ z+MOJ;QLL0~;(`O2bB>G{yvUCmG)ej^gM<(7)4?QOA=X+ZoVm>Y>*tPHaEGA z>e|oo4KBa;2Lpdd;L^B8Z&W7}6M9Z^ckGtFg{EmwlFB#l?p@97V d_A$!Fh@EA1-a2yU5WT8NT2kS8iG)$m{|5uK)Q|uG literal 0 HcmV?d00001 diff --git a/apps/sn/src/app/styles/base.css b/apps/sn/src/app/styles/base.css index 8453ff9e8..6cc507bb9 100644 --- a/apps/sn/src/app/styles/base.css +++ b/apps/sn/src/app/styles/base.css @@ -10,3 +10,7 @@ body { font-family: "Noto Sans KR", sans-serif; font-size: 10px; } + +body.sn-map-page-lock { + overflow: hidden; +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..4cfdcddcd --- /dev/null +++ b/docs/README.md @@ -0,0 +1,35 @@ +# 프론트엔드 AI 개발 문서 + +## 목적 + +이 디렉터리는 `FE_REPO_ROOT` 전체에 적용되는 프론트엔드 공통 개발 기준을 관리한다. +특정 앱의 화면/기능 상세가 아니라, 여러 프론트엔드 앱에서 함께 참고할 수 있는 구조, 실행, 응답 처리, 검증, 템플릿 기준을 둔다. + +## 디렉터리 구조 + +| 경로 | 역할 | +|---|---| +| `구조/` | 저장소 구조, 폴더 구조, 상태관리, 화면흐름 같은 공통 구조 기준 | +| `실행명세/` | 프론트엔드 실행 흐름, 기능 목록, 증적, 진행 로그 기준 | +| `응답표준화/` | API 응답을 프론트에서 호출, 변환, 표시, 오류 처리하는 기준 | +| `검증/` | 브라우저, 빌드, 네트워크 검증 기준과 로그 | +| `템플릿/` | 기능별 문서 작성에 재사용할 표준 템플릿 | + +## 작성 범위 + +- 프론트엔드 앱 공통 구조 +- 공통 컴포넌트 사용 기준 +- 화면 실행/브라우저 검증 기준 +- API 응답 사용/표시/오류 처리 기준 +- 기능별 산출물 템플릿 + +## 작성 금지 범위 + +- 백엔드 Controller, Service, Repository 구현 상세 +- DB 스키마 상세 +- 특정 앱에만 해당하는 화면별 구현 상세 +- `sn` 전용 기능 산출물 + +특정 앱 기능 문서는 해당 앱의 `docs/` 아래에 둔다. +예: `SN_APP_ROOT/docs/노후관로관리/**` + diff --git a/docs/검증/00-검증기준.md b/docs/검증/00-검증기준.md new file mode 100644 index 000000000..c6ac467fa --- /dev/null +++ b/docs/검증/00-검증기준.md @@ -0,0 +1,17 @@ +# 프론트엔드 검증 기준 + +## 기본 원칙 + +- 화면 변경은 브라우저에서 실제로 확인한다. +- API 연동 화면은 네트워크 요청과 응답을 함께 확인한다. +- 공통 패키지를 수정하면 해당 패키지 빌드와 사용 앱 빌드를 모두 확인한다. +- 검증하지 못한 항목은 결과 보고에 남긴다. + +## 검증 구분 + +| 구분 | 목적 | +|---|---| +| 브라우저 검증 | 화면 배치, 상호작용, 모달, 상세 패널 확인 | +| 빌드 검증 | 타입, 번들, 패키지 의존성 확인 | +| 네트워크 검증 | 요청 파라미터, 응답 필드, 오류 처리 확인 | + diff --git a/docs/검증/01-브라우저검증.md b/docs/검증/01-브라우저검증.md new file mode 100644 index 000000000..d83fabdfb --- /dev/null +++ b/docs/검증/01-브라우저검증.md @@ -0,0 +1,19 @@ +# 브라우저 검증 + +## 확인 항목 + +- 화면이 기존 레이아웃과 같은 위치/톤을 유지하는가 +- 버튼, 체크박스, 토글, 모달, 상세 패널이 동작하는가 +- 텍스트가 겹치거나 잘리지 않는가 +- 지도 화면은 레이어 표시/선택/확대 이동이 동작하는가 +- 목록 행 선택과 상세 패널이 연동되는가 + +## 기록 항목 + +- 확인 일시 +- 확인 URL +- 브라우저 +- 주요 동작 +- 캡처 또는 증적 경로 +- 남은 이슈 + diff --git a/docs/검증/02-빌드검증.md b/docs/검증/02-빌드검증.md new file mode 100644 index 000000000..267c3ceba --- /dev/null +++ b/docs/검증/02-빌드검증.md @@ -0,0 +1,27 @@ +# 빌드 검증 + +## 기본 명령 + +`sn`만 수정한 경우: + +```powershell +pnpm --filter sn run build +pnpm turbo run build --filter=sn +``` + +`business_components`를 수정한 경우: + +```powershell +pnpm --filter @common/business_components run build +pnpm --filter sn run build +pnpm turbo run build --filter="...[origin/main]" +``` + +## 기록 항목 + +- 실행 명령 +- 성공/실패 +- 실패 로그 핵심 +- 영향 범위 +- 후속 조치 + diff --git a/docs/검증/03-네트워크검증.md b/docs/검증/03-네트워크검증.md new file mode 100644 index 000000000..9a7a48641 --- /dev/null +++ b/docs/검증/03-네트워크검증.md @@ -0,0 +1,17 @@ +# 네트워크 검증 + +## 확인 항목 + +- 요청 URL +- HTTP method +- query/body 파라미터 +- 응답 상태 코드 +- 화면 표시 필드와 응답 필드 매핑 +- 오류 응답 처리 +- 다운로드 응답 처리 + +## 기록 기준 + +특정 기능의 상세 네트워크 검증 결과는 앱 기능 문서에 작성한다. +공통 규칙은 이 문서에 유지한다. + diff --git a/docs/검증/99-검증로그.md b/docs/검증/99-검증로그.md new file mode 100644 index 000000000..fd975ca5f --- /dev/null +++ b/docs/검증/99-검증로그.md @@ -0,0 +1,6 @@ +# 프론트엔드 검증로그 + +| 일자 | 대상 | 검증 유형 | 결과 | 후속 조치 | +|---|---|---|---|---| +| - | - | - | - | - | + diff --git a/docs/검증/README.md b/docs/검증/README.md new file mode 100644 index 000000000..990a29262 --- /dev/null +++ b/docs/검증/README.md @@ -0,0 +1,23 @@ +# 검증 문서 + +## 목적 + +프론트엔드 작업의 공통 검증 기준을 관리한다. +브라우저, 빌드, 네트워크 검증을 분리해 기록한다. + +## 포함 문서 + +| 문서 | 역할 | +|---|---| +| `00-검증기준.md` | 공통 검증 원칙 | +| `01-브라우저검증.md` | 화면/상호작용 검증 기준 | +| `02-빌드검증.md` | 빌드 명령과 실패 처리 기준 | +| `03-네트워크검증.md` | API 요청/응답 확인 기준 | +| `99-검증로그.md` | 검증 수행 기록 | + +## 작성 기준 + +- 앱 공통 검증 기준만 작성한다. +- 기능별 구체 검증 결과는 해당 앱 기능 문서에 작성한다. +- 검증 실패 시 원인, 영향 범위, 재현 방법을 함께 기록한다. + diff --git a/docs/구조/00-시스템맵.md b/docs/구조/00-시스템맵.md new file mode 100644 index 000000000..7a3a56568 --- /dev/null +++ b/docs/구조/00-시스템맵.md @@ -0,0 +1,27 @@ +# 프론트엔드 시스템맵 + +## 목적 + +프론트엔드 저장소의 앱, 공통 패키지, 백엔드 연동 지점을 한눈에 파악하기 위한 AI 개발 기준 문서이다. +백엔드 `sewerage-service/docs/00-시스템맵.md`와 같은 역할을 한다. + +## 주요 기준 경로 + +- `FE_REPO_ROOT`: `WORKSPACE_ROOT/frontend` +- `SN_APP_ROOT`: `FE_REPO_ROOT/apps/sn` +- `BUSINESS_COMPONENTS_ROOT`: `FE_REPO_ROOT/packages/common/business_components` + +## 시스템 구성 + +- `apps/sn`: 하수관로 운영관리 시스템 `sn` 서비스 앱 +- `packages/common/business_components`: 여러 앱에서 사용하는 공통 비즈니스 UI 컴포넌트 +- `packages/common/components`: 공통 UI 컴포넌트 패키지. `sn` 작업 중 직접 수정하지 않는다. +- `packages/common/auth`: 인증/권한 관련 공통 패키지. `sn` 작업 중 직접 수정하지 않는다. +- `packages/common/network`: 네트워크 관련 공통 패키지. `sn` 작업 중 직접 수정하지 않는다. + +## 2차사업 개발 원칙 + +- 1차 개발 화면의 디자인, 레이아웃, 컴포넌트 조합을 우선 유지한다. +- 신규 화면도 기존 `SN_APP_ROOT` 화면 구조와 `BUSINESS_COMPONENTS_ROOT` 컴포넌트 조합으로 구현한다. +- 독립 HTML 시안은 요구사항 검토용으로만 사용하고 실제 서비스 화면에는 그대로 반영하지 않는다. +- 백엔드 API 계약, 응답 구조, 권한, 지도 레이어 영향이 있으면 백엔드 문서와 함께 확인한다. diff --git a/docs/구조/01-폴더구조.md b/docs/구조/01-폴더구조.md new file mode 100644 index 000000000..d9488bf83 --- /dev/null +++ b/docs/구조/01-폴더구조.md @@ -0,0 +1,42 @@ +# 프론트엔드 폴더구조 + +## 목적 + +프론트엔드 저장소에서 AI 개발자가 읽고 수정할 수 있는 범위와 참조해야 하는 경로를 정의한다. + +## 저장소 구조 + +```text +FE_REPO_ROOT/ + apps/ + sn/ + app/ + src/ + docs/ + AGENTS.md + packages/ + common/ + business_components/ + src/ +``` + +## 직접 수정 가능 범위 + +- `SN_APP_ROOT/app/**` +- `SN_APP_ROOT/src/**` +- `SN_APP_ROOT/docs/**` +- `BUSINESS_COMPONENTS_ROOT/src/**` +- `FE_REPO_ROOT/docs/**` 중 프론트엔드 AI 개발 공통 문서 + +## 직접 수정 금지 범위 + +- `FE_REPO_ROOT/apps/sn` 외 다른 앱 +- `FE_REPO_ROOT/packages/common/business_components` 외 공통 패키지 +- 인증, 네트워크, 공통 컴포넌트 패키지 + +## 화면 개발 참조 순서 + +1. 동일 업무 또는 유사 화면이 `SN_APP_ROOT/app/**`에 있는지 확인한다. +2. 앱 내부 feature/shared 구조가 `SN_APP_ROOT/src/**`에 있는지 확인한다. +3. 공통 비즈니스 컴포넌트가 `BUSINESS_COMPONENTS_ROOT/src/**`에 있는지 확인한다. +4. 기존 컴포넌트로 불가능한 경우에만 최소 범위의 컴포넌트 보완을 검토한다. diff --git a/docs/구조/03-상태관리구조.md b/docs/구조/03-상태관리구조.md new file mode 100644 index 000000000..d0edb71a8 --- /dev/null +++ b/docs/구조/03-상태관리구조.md @@ -0,0 +1,28 @@ +# 프론트엔드 상태관리 구조 + +## 목적 + +`sn` 앱의 화면 상태, 조회 조건, 지도 선택 객체, 모달 상태를 일관되게 다루기 위한 기준 문서이다. + +## 기본 원칙 + +- 기존 `sn` 앱에서 사용하는 상태 관리 방식과 훅 구조를 먼저 확인한다. +- 화면 단위 상태는 가능하면 해당 화면 또는 feature 범위 안에서 관리한다. +- 여러 컴포넌트가 공유하는 상태는 기존 store 또는 공통 훅 패턴을 따른다. +- 지도 선택 객체, 목록 선택 행, 상세 패널 표시 상태는 서로 불일치하지 않도록 한 흐름에서 갱신한다. + +## 확인 대상 + +- `SN_APP_ROOT/app/store/**` +- `SN_APP_ROOT/src/**` +- 기존 지도 화면의 선택 객체, 레이어 표시, 검색 모달 상태 흐름 + +## 문서화 기준 + +상태 흐름을 새로 추가하거나 변경할 때는 아래 내용을 작업 문서에 남긴다. + +- 상태 이름과 소유 컴포넌트 +- 상태 변경 트리거 +- 백엔드/API 응답과의 관계 +- 지도, 그리드, 상세 패널 간 동기화 방식 +- 초기화와 오류 처리 방식 diff --git a/docs/구조/04-화면흐름.md b/docs/구조/04-화면흐름.md new file mode 100644 index 000000000..8dd1514b9 --- /dev/null +++ b/docs/구조/04-화면흐름.md @@ -0,0 +1,29 @@ +# 프론트엔드 화면흐름 + +## 목적 + +프론트엔드 화면 이동, 지도 상호작용, 모달/상세 패널 표시 흐름을 정리하기 위한 기준 문서이다. + +## 기본 원칙 + +- 신규 화면은 기존 1차 화면의 레이아웃과 조작 방식을 우선 따른다. +- 디자인을 새로 만드는 대신 기존 페이지, 모달, 테이블, 지도 패널 컴포넌트 조합을 재사용한다. +- 화면 ID, 메뉴 경로, API 계약, 권한 조건을 함께 기록한다. + +## 화면흐름 기록 항목 + +- 화면 ID +- 메뉴 경로 +- 진입 조건 +- 주요 조회 조건 +- 지도/그리드/상세 패널 연동 +- 모달 또는 팝업 호출 조건 +- 다운로드/저장/삭제 같은 주요 액션 +- 권한별 노출 차이 +- 오류/빈 데이터 처리 + +## `sn` 화면 개발 기준 + +- 실제 페이지는 `SN_APP_ROOT/app/**`를 기준으로 확인한다. +- 지도 기반 화면은 기존 `(map-layout)` 구조와 `Gis` 관련 컴포넌트 사용 방식을 먼저 확인한다. +- 공통 비즈니스 UI가 필요하면 `BUSINESS_COMPONENTS_ROOT/src/**`를 우선 검토한다. diff --git a/docs/구조/99-미확인사항.md b/docs/구조/99-미확인사항.md new file mode 100644 index 000000000..00b7d17e3 --- /dev/null +++ b/docs/구조/99-미확인사항.md @@ -0,0 +1,20 @@ +# 프론트엔드 미확인사항 + +## 목적 + +프론트엔드 2차사업 개발 중 아직 확정되지 않은 계약, 화면, 권한, 공통 컴포넌트 이슈를 기록한다. + +## 기록 형식 + +| 일자 | 구분 | 내용 | 영향 범위 | 확인 필요 대상 | 상태 | +|---|---|---|---|---|---| +| - | - | - | - | - | - | + +## 주요 확인 대상 + +- 백엔드 API 요청/응답 계약 +- 지도 레이어명, 속성명, zoom level별 표시 조건 +- 1차 화면과 다른 UI 변경 필요 여부 +- `BUSINESS_COMPONENTS_ROOT` 수정 시 타 앱 영향 가능성 +- 대용량 목록/엑셀 다운로드 처리 방식 +- 권한별 조회 범위와 버튼 노출 기준 diff --git a/docs/구조/README.md b/docs/구조/README.md new file mode 100644 index 000000000..41638356a --- /dev/null +++ b/docs/구조/README.md @@ -0,0 +1,23 @@ +# 구조 문서 + +## 목적 + +프론트엔드 저장소의 공통 구조를 설명한다. +여기에는 특정 화면의 구현 세부가 아니라, 여러 앱에서 공유할 수 있는 구조 기준만 작성한다. + +## 포함 문서 + +| 문서 | 역할 | +|---|---| +| `00-시스템맵.md` | 앱과 공통 패키지, 백엔드 연동 지점의 큰 그림 | +| `01-폴더구조.md` | 저장소 폴더와 수정 가능 범위 | +| `03-상태관리구조.md` | 상태관리 기준 | +| `04-화면흐름.md` | 화면 진입, 이동, 모달, 상세 패널 흐름 기준 | +| `99-미확인사항.md` | 구조 관련 미확인사항 | + +## 작성 기준 + +- `FE_REPO_ROOT` 전체에 적용되는 기준만 작성한다. +- 특정 앱의 기능 상세는 앱 내부 문서에 작성한다. +- 백엔드 구현 상세는 작성하지 않는다. + diff --git a/docs/실행명세/00-범위.md b/docs/실행명세/00-범위.md new file mode 100644 index 000000000..231cced87 --- /dev/null +++ b/docs/실행명세/00-범위.md @@ -0,0 +1,31 @@ +# 프론트엔드 실행명세 범위 + +## 목적 + +프론트엔드 실행명세는 `FE_REPO_ROOT` 안에서 실제 화면, 상태, API 호출, 브라우저 동작을 확인하기 위한 문서 묶음이다. +백엔드 실행명세처럼 서버, DB, Repository를 다루지 않고 프론트엔드 관점만 다룬다. + +## 작성 범위 + +- `SN_APP_ROOT` 화면 실행 흐름 +- 기존 컴포넌트 재사용 기준 +- 화면별 상태와 이벤트 흐름 +- 브라우저 동작 확인 방법 +- API 호출 위치와 응답 사용 방식 +- 빌드/실행/검증 증적 + +## 제외 범위 + +- 백엔드 Controller, Service, Repository 상세 +- DB 스키마 상세 +- 백엔드 공통 응답 구조 변경안 +- 타 앱 전용 화면 구현 상세 + +## 관련 문서 + +- `FE_REPO_ROOT/docs/구조/00-시스템맵.md` +- `FE_REPO_ROOT/docs/구조/01-폴더구조.md` +- `FE_REPO_ROOT/docs/구조/03-상태관리구조.md` +- `FE_REPO_ROOT/docs/구조/04-화면흐름.md` +- `FE_REPO_ROOT/docs/응답표준화/01-API구조.md` + diff --git a/docs/실행명세/02-기능명세계획.md b/docs/실행명세/02-기능명세계획.md new file mode 100644 index 000000000..e59c958c8 --- /dev/null +++ b/docs/실행명세/02-기능명세계획.md @@ -0,0 +1,27 @@ +# 프론트엔드 기능명세 계획 + +## 목적 + +프론트엔드 기능명세 작성 전에 화면 단위로 어떤 산출물을 만들지 정리한다. + +## 작성 단위 + +- 화면 ID +- 메뉴 경로 +- 주요 컴포넌트 +- 상태 관리 +- API 호출부 +- 사용자 이벤트 +- 브라우저 검증 방법 + +## 작성 위치 기준 + +- 여러 앱 공통 기준: `FE_REPO_ROOT/docs/실행명세/**` +- `sn` 특정 기능: `SN_APP_ROOT/docs/**` + +## 노후관로관리 적용 예 + +- `SN_MN010102`: 노후관로관리 +- `SN_MN010103`: 노후관로분석 +- 세부 문서 위치: `SN_APP_ROOT/docs/노후관로관리/**` + diff --git a/docs/실행명세/03-기능목록.md b/docs/실행명세/03-기능목록.md new file mode 100644 index 000000000..a3bd655a5 --- /dev/null +++ b/docs/실행명세/03-기능목록.md @@ -0,0 +1,11 @@ +# 프론트엔드 기능목록 + +## 목적 + +프론트엔드에서 구현하거나 검증해야 하는 기능을 화면 기준으로 추적한다. + +| 화면 ID | 기능 | 주요 컴포넌트 | 상태 | 문서 위치 | +|---|---|---|---|---| +| SN_MN010102 | 노후관로관리 | `Gis`, `GisTree`, `UiTable`, `FeatureClickEventPopup` | 정리 중 | `SN_APP_ROOT/docs/노후관로관리/**` | +| SN_MN010103 | 노후관로분석 | `Gis`, `FeatureClickEventPopup`, `VerticalTable` | 정리 중 | `SN_APP_ROOT/docs/노후관로관리/**` | + diff --git a/docs/실행명세/04-증적목록.md b/docs/실행명세/04-증적목록.md new file mode 100644 index 000000000..1512b273e --- /dev/null +++ b/docs/실행명세/04-증적목록.md @@ -0,0 +1,18 @@ +# 프론트엔드 증적목록 + +## 목적 + +프론트엔드 작업의 실행, 빌드, 브라우저 확인 증적을 추적한다. + +| 일자 | 기능 | 증적 유형 | 경로/내용 | 상태 | +|---|---|---|---|---| +| - | - | - | - | - | + +## 증적 유형 + +- 정적 HTML 샘플 +- 브라우저 캡처 +- 빌드 로그 +- 네트워크 응답 확인 +- 수동 테스트 결과 + diff --git a/docs/실행명세/99-진행로그.md b/docs/실행명세/99-진행로그.md new file mode 100644 index 000000000..a450231af --- /dev/null +++ b/docs/실행명세/99-진행로그.md @@ -0,0 +1,6 @@ +# 프론트엔드 실행명세 진행로그 + +| 일자 | 작업 | 결과 | 후속 작업 | +|---|---|---|---| +| 2026-04-21 | 노후관로관리 화면 기준 정리 | 기존 화면 기반 컴포넌트 분해 및 작업 목록 작성 | API/응답 필드 매핑표 작성 | + diff --git a/docs/실행명세/README.md b/docs/실행명세/README.md new file mode 100644 index 000000000..cc1ddf18f --- /dev/null +++ b/docs/실행명세/README.md @@ -0,0 +1,22 @@ +# 실행명세 문서 + +## 목적 + +프론트엔드 화면을 실제로 실행하고 검증하기 위한 기준과 진행 기록을 관리한다. + +## 포함 문서 + +| 문서 | 역할 | +|---|---| +| `00-범위.md` | 실행명세의 범위와 제외 범위 | +| `02-기능명세계획.md` | 화면/기능 단위 산출물 계획 | +| `03-기능목록.md` | 추적할 기능 목록 | +| `04-증적목록.md` | 캡처, 로그, 빌드 결과 같은 증적 목록 | +| `99-진행로그.md` | 실행명세 진행 기록 | + +## 작성 기준 + +- 프론트엔드 실행과 브라우저 동작 기준만 작성한다. +- 서버 실행 로그를 인용할 수는 있지만, 백엔드 내부 구현 분석은 쓰지 않는다. +- 특정 앱 기능의 상세 실행 결과는 앱 내부 문서에 작성한다. + diff --git a/docs/응답표준화/00-범위.md b/docs/응답표준화/00-범위.md new file mode 100644 index 000000000..82a3f6253 --- /dev/null +++ b/docs/응답표준화/00-범위.md @@ -0,0 +1,23 @@ +# 프론트엔드 응답표준화 범위 + +## 목적 + +프론트엔드 응답표준화 문서는 백엔드 응답을 화면에서 어떻게 호출, 변환, 표시, 오류 처리할지 정리한다. +백엔드 응답 구조 자체를 바꾸는 문서가 아니라, 프론트엔드 사용 기준을 정의한다. + +## 작성 범위 + +- API 호출 위치 +- 요청 파라미터 +- 응답 필드 사용처 +- 화면 표시 필드 매핑 +- 오류/빈 데이터 처리 +- 다운로드/파일 응답 처리 +- 기존 응답과 신규 후보 API 비교 + +## 제외 범위 + +- 백엔드 공통 응답 포맷 변경 +- DB 컬럼 설계 +- Repository/Service 구현 상세 + diff --git a/docs/응답표준화/01-API구조.md b/docs/응답표준화/01-API구조.md new file mode 100644 index 000000000..9dc638ace --- /dev/null +++ b/docs/응답표준화/01-API구조.md @@ -0,0 +1,24 @@ +# 프론트엔드 API 구조 + +## 목적 + +프론트엔드에서 백엔드 API를 호출하고 화면에 반영하는 흐름을 정리하기 위한 기준 문서이다. + +## 기본 원칙 + +- 백엔드 API 계약은 `BE_SERVICE_ROOT/docs`와 실제 Controller/DTO/Service 흐름을 함께 확인한다. +- 프론트엔드에서 백엔드 응답 구조를 임의로 바꾸거나 가정하지 않는다. +- API 응답 변경이 필요하면 영향 화면, 요청 파라미터, 응답 필드, 오류 처리 방식을 먼저 정리한다. +- 지도/GIS 레이어 호출은 실제 레이어 설정, zoom level, 표시 조건을 함께 확인한다. + +## `sn` API 연동 기준 + +- `SN_APP_ROOT/app/api/**`: Next 앱 내부 API 관련 코드 +- `SN_APP_ROOT/app/components/**`: 화면 컴포넌트에서 사용하는 API 유틸 참조 가능 +- `SN_APP_ROOT/src/**`: feature/shared 구조가 있는 경우 해당 구조를 우선 따른다. + +## 검증 기준 + +- 브라우저에서 실제 요청 파라미터와 네트워크 응답을 확인한다. +- 목록, 상세, 다운로드, 지도 이동처럼 UI와 API가 연결되는 흐름은 화면 동작까지 확인한다. +- 백엔드 실패, 권한 부족, 데이터 없음, 대용량 다운로드 같은 예외 흐름을 함께 점검한다. diff --git a/docs/응답표준화/02-프론트엔드응답처리규칙.md b/docs/응답표준화/02-프론트엔드응답처리규칙.md new file mode 100644 index 000000000..280e802de --- /dev/null +++ b/docs/응답표준화/02-프론트엔드응답처리규칙.md @@ -0,0 +1,22 @@ +# 프론트엔드 응답 처리 규칙 + +## 기본 원칙 + +- 백엔드 응답 구조를 프론트에서 임의로 바꾸어 가정하지 않는다. +- 화면에서 필요한 필드는 실제 API 응답과 DTO를 확인해 매핑한다. +- 코드값은 기존 코드 매핑 유틸 또는 공통 코드 목록을 우선 사용한다. +- 빈 값, NULL, 권한 오류, 네트워크 오류는 화면별로 표시 기준을 둔다. + +## 표시 규칙 + +- 값 없음: `-` +- 설치일자 없음: `확인불가` +- 숫자: 기존 테이블 포맷 규칙을 따른다. +- 코드값: 기존 `codeMapper`, `MS_SWAGE_PPL_CODES`, `MS_SWAGE_PPL_USE_CODES` 등을 우선 확인한다. + +## 오류 처리 + +- 조회 실패: 기존 toast/alert 패턴을 따른다. +- 다운로드 실패: 사용자에게 실패 메시지를 표시한다. +- 권한 오류: 버튼/조회 범위 노출 조건을 백엔드 권한과 맞춘다. + diff --git a/docs/응답표준화/03-프론트엔드전환계획.md b/docs/응답표준화/03-프론트엔드전환계획.md new file mode 100644 index 000000000..08a92ed07 --- /dev/null +++ b/docs/응답표준화/03-프론트엔드전환계획.md @@ -0,0 +1,22 @@ +# 프론트엔드 응답 전환 계획 + +## 목적 + +기존 API 호출 방식과 신규 후보 API를 비교해 화면별 전환 필요성을 판단한다. + +## 전환 기준 + +| 구분 | 기준 | +|---|---| +| 기존 API 재사용 | 현재 화면 필드와 동작을 충족하는 경우 | +| 프론트 변환 추가 | 응답은 충분하지만 화면 표시 필드 변환이 필요한 경우 | +| 신규 API 요청 | 기존 API로 성능, 필드, 권한, 다운로드 요구사항을 충족하지 못하는 경우 | + +## 작업 순서 + +1. 현재 호출 API 확인 +2. 화면 표시 필드 매핑 +3. 누락 필드 정리 +4. 오류/빈 데이터 처리 정리 +5. 신규 API 필요 여부 판단 + diff --git a/docs/응답표준화/04-프론트엔드회귀점검목록.md b/docs/응답표준화/04-프론트엔드회귀점검목록.md new file mode 100644 index 000000000..4a1f06c98 --- /dev/null +++ b/docs/응답표준화/04-프론트엔드회귀점검목록.md @@ -0,0 +1,11 @@ +# 프론트엔드 응답 회귀점검목록 + +| 항목 | 확인 내용 | 상태 | +|---|---|---| +| 목록 조회 | 요청 파라미터와 응답 목록이 화면에 맞는지 확인 | 대기 | +| 상세 조회 | 상세 패널 필드가 기존 화면과 동일한 톤으로 표시되는지 확인 | 대기 | +| 코드 매핑 | 용도/재질 코드가 한글명으로 표시되는지 확인 | 대기 | +| 빈 데이터 | 데이터 없음/NULL이 표시 규칙을 따르는지 확인 | 대기 | +| 다운로드 | 조회 조건과 다운로드 결과가 일치하는지 확인 | 대기 | +| 오류 처리 | API 실패 시 사용자 메시지가 표시되는지 확인 | 대기 | + diff --git a/docs/응답표준화/99-미확인사항.md b/docs/응답표준화/99-미확인사항.md new file mode 100644 index 000000000..ff8a56a35 --- /dev/null +++ b/docs/응답표준화/99-미확인사항.md @@ -0,0 +1,14 @@ +# 프론트엔드 응답표준화 미확인사항 + +| 일자 | 구분 | 내용 | 확인 필요 대상 | 상태 | +|---|---|---|---|---| +| - | - | - | - | - | + +## 노후관로관리 우선 확인 대상 + +- 기존 `v1/fclts`로 노후관로 목록 조건을 충분히 처리할 수 있는지 +- 경과년도 계산을 프론트에서 할지, 백엔드 응답 필드로 받을지 +- 설치일자 NULL/미래일자 처리 기준 +- 엑셀 다운로드를 기존 프론트 유틸로 처리할지, 백엔드 파일 API로 처리할지 +- 하수처리구역 분석 통계를 프론트 계산으로 할지, 백엔드 API로 받을지 + diff --git a/docs/응답표준화/README.md b/docs/응답표준화/README.md new file mode 100644 index 000000000..240111d97 --- /dev/null +++ b/docs/응답표준화/README.md @@ -0,0 +1,23 @@ +# 응답표준화 문서 + +## 목적 + +프론트엔드가 백엔드 API 응답을 어떻게 호출, 변환, 표시, 오류 처리하는지에 대한 공통 기준을 관리한다. + +## 포함 문서 + +| 문서 | 역할 | +|---|---| +| `00-범위.md` | 프론트엔드 응답표준화의 범위 | +| `01-API구조.md` | 프론트엔드 API 호출 구조 | +| `02-프론트엔드응답처리규칙.md` | 값 표시, 코드 매핑, 오류 처리 규칙 | +| `03-프론트엔드전환계획.md` | 기존 API와 신규 후보 API 비교 기준 | +| `04-프론트엔드회귀점검목록.md` | 응답 처리 회귀 점검 항목 | +| `99-미확인사항.md` | 응답 관련 미확인사항 | + +## 작성 기준 + +- 백엔드 응답 구조 자체를 바꾸는 문서가 아니다. +- 프론트 화면에서 응답을 어떻게 사용하는지에 집중한다. +- 특정 기능의 API 필드 매핑표는 해당 앱 기능 문서에 작성한다. + diff --git a/docs/템플릿/API응답필드매핑_TEMPLATE.md b/docs/템플릿/API응답필드매핑_TEMPLATE.md new file mode 100644 index 000000000..ffbade2f0 --- /dev/null +++ b/docs/템플릿/API응답필드매핑_TEMPLATE.md @@ -0,0 +1,28 @@ +# API 응답 필드 매핑표 + +## 목적 + +- + +## API 목록 + +| 구분 | Method | Endpoint | 사용 화면/컴포넌트 | 상태 | +|---|---|---|---|---| +| | | | | | + +## 필드 매핑 + +| 화면 영역 | 표시명 | 응답 필드 | 변환/코드매핑 | 빈 값 처리 | 비고 | +|---|---|---|---|---|---| +| | | | | | | + +## 오류 처리 + +| 상황 | 처리 방식 | +|---|---| +| | | + +## 미확인사항 + +- + diff --git a/docs/템플릿/README.md b/docs/템플릿/README.md new file mode 100644 index 000000000..b0876bb13 --- /dev/null +++ b/docs/템플릿/README.md @@ -0,0 +1,12 @@ +# 문서 템플릿 + +## 목적 + +기능별 프론트엔드 문서를 일관된 형식으로 작성하기 위한 템플릿을 제공한다. + +## 사용 기준 + +- 새 기능 문서 폴더를 만들 때 필요한 템플릿을 복사해 사용한다. +- 템플릿은 공통 형식만 제공한다. +- 기능별 구체 내용은 각 앱의 `docs/{기능명}/` 아래에 작성한다. + diff --git a/docs/템플릿/검증결과_TEMPLATE.md b/docs/템플릿/검증결과_TEMPLATE.md new file mode 100644 index 000000000..2f2cfef2b --- /dev/null +++ b/docs/템플릿/검증결과_TEMPLATE.md @@ -0,0 +1,22 @@ +# 검증 결과 + +## 검증 개요 + +| 항목 | 내용 | +|---|---| +| 검증 일자 | | +| 검증자 | | +| 대상 화면 | | +| 실행 URL | | +| 브라우저 | | + +## 검증 항목 + +| 항목 | 결과 | 증적 | 비고 | +|---|---|---|---| +| | | | | + +## 남은 리스크 + +- + diff --git a/docs/템플릿/기능문서_TEMPLATE.md b/docs/템플릿/기능문서_TEMPLATE.md new file mode 100644 index 000000000..cab46b785 --- /dev/null +++ b/docs/템플릿/기능문서_TEMPLATE.md @@ -0,0 +1,33 @@ +# 기능명 + +## 목적 + +- + +## 화면 정보 + +| 항목 | 내용 | +|---|---| +| 화면 ID | | +| 메뉴 경로 | | +| 대상 앱 | | +| 관련 요구사항 | | + +## 범위 + +### 포함 + +- + +### 제외 + +- + +## 관련 문서 + +- + +## 남은 이슈 + +- + diff --git a/docs/템플릿/컴포넌트작업목록_TEMPLATE.md b/docs/템플릿/컴포넌트작업목록_TEMPLATE.md new file mode 100644 index 000000000..fbfac95b6 --- /dev/null +++ b/docs/템플릿/컴포넌트작업목록_TEMPLATE.md @@ -0,0 +1,28 @@ +# 컴포넌트별 작업 목록 + +## 목적 + +- + +## 작업 단위 요약 + +| 순서 | 작업 단위 | 주요 파일 | 구분 | 목적 | +|---|---|---|---|---| +| 1 | | | | | + +## 컴포넌트별 상세 + +### 컴포넌트명 + +#### 현재 상태 + +- + +#### 변경 방향 + +- + +#### 검증 항목 + +- + diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index 249c94a13..d7bbf3fa5 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -32,6 +32,9 @@ export const GisContext = createContext({ }); export const useGisContext = () => useContext(GisContext); +/** + * @param {any} props + */ function Gis({ isOpen, //우측 상세 레이어 팝업 gisMaps, @@ -49,7 +52,10 @@ function Gis({ setIsSearchModalOpen, onCheckedLayersChange, searchBar: SearchBar, - waterDepth: WaterDepth, + waterDepth: WaterDepth = null, + layerSettingView: LayerSettingView = null, + inlineLayerSettingView, + layerIconVisible, isPlaying = false, setIsPlaying = () => { }, targetLayerTypes = [], @@ -281,18 +287,21 @@ function Gis({ visible={tabVisible} setVisible={setTabVisible} onCheckedChange={handleCheckedLayersChange} - onLayerIconClick={(layerNm) => { + onLayerIconClick={(layerNm, node) => { layerNmRef.current = layerNm; setLayerNm(layerNm); - setIsOpen(true); - onOpenPopup(layerNm) + const shouldOpenDetail = onOpenPopup(layerNm, node) + if (shouldOpenDetail !== false) setIsOpen(true); }} imageVisible={imgVisble} + layerIconVisible={layerIconVisible} isPlaying={isPlaying} setIsPlaying={setIsPlaying} targetLayerTypes={targetLayerTypes} checkInterval={checkInterval} onProgressChange={handleProgressChange} + layerSettingView={LayerSettingView} + inlineLayerSettingView={inlineLayerSettingView} /> {typeof WaterDepth === "function" && ( diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js index 197827010..38077c00a 100644 --- a/packages/common/business_components/src/ui/map/GisTree.js +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -27,6 +27,8 @@ import GisApp from "./lib/gis.core"; * - orderedLayers: 레이어 재생 순서를 지정하는 id 배열 (lyrSn) * - checkInterval: 자동 애니메이션 토글 간격(ms) * - onProgressChange(total, current): 애니메이션 진행상태 콜백 + * - layerSettingView: (선택) 좌측 레이어 트리 하단에 표시할 설정 영역 + * - layerIconVisible: (선택) 레이어 행 우측 액션 아이콘 노출 여부를 결정하는 함수 */ export default React.memo(function GisTree({ checkedNodes = [], @@ -41,6 +43,9 @@ export default React.memo(function GisTree({ targetLayerTypes = [], checkInterval = 3000, onProgressChange = () => { }, + layerSettingView: LayerSettingView = null, + layerIconVisible, + inlineLayerSettingView, }) { const { gisCore, treeData } = useGisContext(); @@ -494,9 +499,9 @@ export default React.memo(function GisTree({ /** * 각 레이어 행의 아이콘 클릭 시 호출. 선택되지 않은 레이어면 경고, 선택된 경우 콜백 전달. */ - const handleLayerImgClick = (nodeNm, nodeId) => { + const handleLayerImgClick = (nodeNm, nodeId, node) => { if (!checked.includes(nodeId)) alert("시설을 선택해주세요"); - else onLayerIconClick(nodeNm); + else onLayerIconClick(nodeNm, node); }; // ===================== @@ -534,48 +539,59 @@ export default React.memo(function GisTree({ child.parent === node.id && child.data.exprsYn === "Y" ); + const inlineLayerSetting = inlineLayerSettingView?.(node); + return ( -
- {hasVisibleChildren ? ( - - ) : ( - - )} - handleCheck(node.id)} - className="cursor-pointer" - /> - {node.icon && {node.icon}} - - {node.text} - - {imageVisible && depth > 0 && ( - - - handleLayerImgClick( - node.data.lyrTyNm, - node.id - ) - } - alt={node.text} +
+
+ {hasVisibleChildren ? ( + + ) : ( + + )} + handleCheck(node.id)} + className="cursor-pointer" + /> + {node.icon && {node.icon}} + + {node.text} + {((depth > 0 && imageVisible) || layerIconVisible?.(node)) && ( + + + handleLayerImgClick( + node.data.lyrTyNm, + node.id, + node + ) + } + alt={node.text} + /> + + )} +
+ {inlineLayerSetting && ( +
+ {typeof inlineLayerSetting === "function" + ? React.createElement(inlineLayerSetting) + : inlineLayerSetting} +
)}
); @@ -588,6 +604,11 @@ export default React.memo(function GisTree({ />
+ {LayerSettingView && ( +
+ +
+ )} +
+ )} - + {isMainSsc && renderMode === 'popup' && ( +
+

중점우수토실 운영정보

+ {renderModalBody()}
)}
diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx index 8b029673f..78e082414 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx @@ -9,11 +9,10 @@ * - 조회 버튼을 통한 모달 표시 및 ReportTab 연동 */ 'use client' -import React, { useState } from 'react' +import React 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'; +import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; // 하수처리시설 const headList = [ @@ -21,16 +20,26 @@ const headList = [ ]; const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => { - const [isModalOpen, setIsModalOpen] = useState(false); - const ctpvNm = gisObject?.ctpv_nm; const sggNm = gisObject?.sgg_nm; + const handleOpenReportPopup = () => { + const popupWindow = openWindowPopup("reportTab", { + ctpvNm, + sggNm, + sourcePath: window.location.pathname, + }); + + if (!popupWindow) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); + } + }; + return (
setIsModalOpen(true)}>조회 + title: }} headName='디지털 자료관 연계정보' tableData={[]} @@ -43,31 +52,8 @@ const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => { throw new Error("Function not implemented."); }} /> - - } - useDirectRender={true} - />
) } -export default SwtrmtFclt \ No newline at end of file +export default SwtrmtFclt diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx index 3836b87f6..91495f412 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx @@ -7,14 +7,12 @@ 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; // 관로관리번호 @@ -28,7 +26,6 @@ type DataType = { 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([]); @@ -66,12 +63,6 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => { setInftwtQty('0'); }; - useEffect(() => { - if (!isModalOpen) { - resetState(); // 모달이 닫힐 때 리셋 - } - }, [isModalOpen]); - const handleAplcnYmChange = async (val: string | number) => { const selectedValue = String(val); @@ -276,21 +267,10 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => { ); return ( - +
+

침입수 분석

+ {renderBody()} +
); }; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx index aa622be1b..ec1cec8b1 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx @@ -7,14 +7,12 @@ 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; // 관로관리번호 @@ -29,7 +27,6 @@ type LineBarChartData = { 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([]); @@ -72,12 +69,6 @@ const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => { setTotalVolume('0');// 총체적 m³/mm 초기화 }; - useEffect(() => { - if (!isModalOpen) { - resetState(); - } - }, [isModalOpen]); - const handleAplcnYmChange = async (val: string | number) => { const selectedValue = String(val); @@ -336,21 +327,10 @@ const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => { ); return ( - +
+

RDII 분석

+ {renderBody()} +
); }; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/chartModalStore.ts b/apps/sn/app/(map-layout)/map/components/popupComponent/chartModalStore.ts deleted file mode 100644 index c84fa48a7..000000000 --- a/apps/sn/app/(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((set) => ({ - isOpen: false, - setIsOpen: (open: boolean) => set({ isOpen: open }), -})); - -export const useRdiiChartModalStore = create((set) => ({ - isOpen: false, - setIsOpen: (open: boolean) => set({ isOpen: open }), -})); \ No newline at end of file diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx index 11e61b006..e199e7a0d 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx @@ -23,7 +23,6 @@ */ 'use client' import { getData, postData } from "@/components/lib/api"; -import Modal from "@/components/ui/modal"; import { VerticalTable } from "@/components/ui/verticalTable"; import { useGisContext } from "@common/business_components/ui/map/Gis"; import { Button } from "@common/components/ui"; @@ -34,13 +33,11 @@ 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"; +import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; const linkedFacilityHighlightStyle = new Style({ image: new CircleStyle({ @@ -74,12 +71,9 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const [buttonChk, setButtonChk] = useState(true) const [fetchData, setFetchData] = useState(); // api 호출여부 const contentRef = useRef(null); - const [isModalOpen, setIsModalOpen] = useState(false); // 모달 열기 상태 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(''); // 로딩 상태 @@ -461,16 +455,9 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, // 저장 시 모달 띄우기 const handleSaveClick = () => { - setIsModalOpen(true); // 모달 열기 - }; - - const handleModalSave = () => { - setIsModalOpen(false); // 모달 닫기 - btnClickEvent(); // 저장 이벤트 실행 - }; - - const handleModalClose = () => { - setIsModalOpen(false); // 모달 닫기 + const shouldSave = window.confirm('정보를 저장하시겠습니까?'); + if (!shouldSave) return; + btnClickEvent(); }; // 저장 버튼 이벤트 @@ -557,14 +544,26 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, {/* 하수관로일 때만 Infil/RDII 버튼 표시 */} {layerNm === '하수관로' && ( <> - -
- - -
) } diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 6c684b642..8f7bfb7e4 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -26,11 +26,13 @@ import FeatureClickEventPopup from "./components/popupComponent/featureClickEven import { WaterDepth, WATER_DEPTH_LAYER_TYPES } from "./waterDepth/waterDepth"; import { ProcSearchBar } from "@common/business_components/ui/map/searchBar"; import useSearchModalStore from "@/store/useSearchModalStore"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; const Map = () => { const { isSearchModalOpen, setIsSearchModalOpen } = useSearchModalStore(); const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정 + const [layerLoadError, setLayerLoadError] = useState(null); const gisMaps = useRef(null); const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태 const [featureData, setFeatureData] = useState(null); // 선택된 피쳐 데이터 @@ -41,14 +43,54 @@ const Map = () => { fetchData(); }, []); + useEffect(() => { + const handlePopupMessage = async (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + + const data = event.data; + const core = GisApp.Module.layer as any; + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE) { + const layerNm = data.payload?.layerNm; + const fid = data.payload?.fid; + if (!layerNm || !fid || !core?.showSearchResultsFromGeoJSON) return; + + await core.showSearchResultsFromGeoJSON(layerNm, fid); + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE) { + const layerNm = data.payload?.layerNm; + const fidsRaw = data.payload?.fids; + if (!layerNm || !fidsRaw || !core?.showMultipleSearchResultsFromGeoJSON) return; + + let fids: string[] = []; + try { + fids = JSON.parse(fidsRaw); + } catch (error) { + console.error("다중 시설 메시지 파싱 실패:", error); + return; + } + + await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids); + } + }; + + window.addEventListener("message", handlePopupMessage); + return () => window.removeEventListener("message", handlePopupMessage); + }, []); + const fetchData = async () => { try { const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK"); setTreeData(data); + setLayerLoadError(null); } catch (error) { - console.error('JSON parsing error:', error); - // JSON 파싱에 실패한 경우, 원본 텍스트 또는 null 반환 - throw new Error('Invalid JSON response from server'); + const message = + error instanceof Error ? error.message : "레이어 설정 조회 중 알 수 없는 오류가 발생했습니다."; + console.error("레이어 설정 조회 실패:", error); + setTreeData([]); + setLayerLoadError(message); } }; @@ -135,6 +177,11 @@ const Map = () => { return ( <>
+ {layerLoadError ? ( +
+ 레이어 설정 조회 실패: {layerLoadError} +
+ ) : null} = ({ 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 = () => ( - <> -
- - { - 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} - /> -
- - ); - - - return ( - - ); -}; - -export default React.memo(ReportFilesModal); \ No newline at end of file diff --git a/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx b/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx index ad57c0a5f..f054a9ed1 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx @@ -22,7 +22,7 @@ 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'; // 파일목록모달 +import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; // 하수도정비 기본계획 보고서 @@ -56,7 +56,6 @@ const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sg const [ctpvCd, setCtpvCd] = useState('0'); const [sggCd, setSggCd] = useState('0'); const [reportGubun, setReportGubun] = useState("0"); - const [clsfSystmSn, setClsfSystmSn] = useState(''); // 자료관 기본계획 일련번호 const { tableData, @@ -72,8 +71,6 @@ const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sg } = useSearchModalStore(); const { ctpv, sgg } = useStore(); const { - isOpen: isReportFilesModalOpen, - setIsOpen: setIsReportFilesModalOpen, dcTree: reportOriginData, fetchDcTree } = useReportTabStore(); // 모달 상태 관리 스토어 @@ -139,8 +136,14 @@ const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sg if (typeof index === "number" && tableData[index]) { const reportData = tableData[index]; if (reportData.fileSn !== "") { - setClsfSystmSn(reportData.clsfSystmSn); // 자료관 기본계획 일련번호 설정 - setIsReportFilesModalOpen(true); // 모달 열기 + const popupWindow = openWindowPopup("reportFiles", { + clsfSystmSn: reportData.clsfSystmSn, + sourcePath: window.location.pathname, + }); + + if (!popupWindow) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); + } } } }; @@ -263,10 +266,8 @@ const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sg />
- {/* 모달 컴포넌트 */} - {isReportFilesModalOpen && } ); }; -export default React.memo(ReportTab); \ No newline at end of file +export default React.memo(ReportTab); diff --git a/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx b/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx index 3ce334518..809647bf6 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx @@ -11,21 +11,26 @@ * - Zustand를 통한 검색 모달 상태 관리 * - 로딩 오버레이 및 선택 초기화 지원 */ +'use client'; import LoadingOverlay from '@/components/ui/LoadingBar'; -import { UiModal, UITab, UiTable } from '@common/business_components/ui'; +import { UITab, UiTable } from '@common/business_components/ui'; import { Button } from '@common/components/ui'; -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo, useRef } 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'; +import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; -const SearchModal: React.FC = () => { +type SearchModalProps = { + renderMode?: "launcher" | "popup"; +}; + +const SearchModal: React.FC = ({ renderMode = "launcher" }) => { const { showTable, isSearchModalOpen, setIsSearchModalOpen, @@ -59,6 +64,7 @@ const SearchModal: React.FC = () => { operationResultExcelDownload, handleOperationPageNation } = useOperationTab(); + const hasAutoOpenedReportRef = useRef(false); const dynamicHeadlist: { id: string, title: string }[] = useMemo(() => { @@ -87,6 +93,42 @@ const SearchModal: React.FC = () => { return []; }, [activeTab, workType]); + useEffect(() => { + if (renderMode !== "launcher" || !isSearchModalOpen) return; + + const popupWindow = openWindowPopup("mapSearch", { + sourcePath: window.location.pathname, + }); + + if (!popupWindow) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); + } + + setIsSearchModalOpen(false); + }, [renderMode, isSearchModalOpen]); + + const openReportTabPopup = () => { + const popupWindow = openWindowPopup("reportTab", { + sourcePath: window.location.pathname, + }); + + if (!popupWindow) { + alert('?앹뾽??李⑤떒?섏뿀?듬땲?? 釉뚮씪?곗? ?앹뾽 ?덉슜 ?ㅼ젙???뺤씤?댁<?몄슂.'); + } + }; + + useEffect(() => { + if (activeTab !== 'report') { + hasAutoOpenedReportRef.current = false; + return; + } + + if (hasAutoOpenedReportRef.current) return; + + openReportTabPopup(); + hasAutoOpenedReportRef.current = true; + }, [activeTab]); + useEffect(() => { resetFilters(); }, [activeTab, basicType, workType]); @@ -119,7 +161,23 @@ const SearchModal: React.FC = () => { /> {activeTab === 'basic' && ()} {/* 기본정보 탭 */} {activeTab === 'operation' && ()} {/* 유지관리 정보 탭 */} - {activeTab === 'report' && ()} {/* 하수도정비기본계획보고서 탭 */} + {activeTab === 'report' && ( +
+
+

+ 하수도정비 기본계획보고서 +

+

+ 보고서 상세는 모달 내부 탭 대신 +
+ 전용 윈도우 팝업으로 열리도록 전환되었습니다. +

+ +
+
+ )} {/* 하수도정비기본계획보고서 탭 */} {/* 📋 결과 리스트 */} @@ -214,22 +272,16 @@ const SearchModal: React.FC = () => { ); + if (renderMode === "launcher") { + return null; + } + return ( -
- +
+

관망도 검색

+
+ {renderBody()} +
); diff --git a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts b/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts index 9184286ed..d914d2dbb 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts @@ -31,6 +31,13 @@ import { swagePrcsZoneCqlFilters } from './swagePrcsZone'; import { swageMhlCqlFilters } from './swageMhl'; import { swagePmstnCqlFilters } from './swagePmstn'; import { stofcbrCqlFilters } from './stofcbr'; +import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage'; +import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; + +const isPopupWindow = () => + typeof window !== "undefined" && + !!window.opener && + window.location.pathname.startsWith("/popup/"); export default function useBasicTab() { @@ -115,9 +122,21 @@ export default function useBasicTab() { // 기본정보 테이블 cellClick 핸들러 const handleBasicCellClick = async (index?: number | undefined, headId?: String) => { if (typeof index === "number") { + const row = tableData[index]; + + if (isPopupWindow()) { + postPopupMessageAndClose( + WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE, + { + layerNm: row.lyrTypeNm, + fid: String(row.fid), + }, + ); + return; + } + setIsLoading(true); - const row = tableData[index]; const layerNm = row.lyrTypeNm; const fid = row.fid; const core = GisApp.Module.layer as any; diff --git a/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts b/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts index 669dd4cb5..3d80b54d9 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts @@ -23,6 +23,13 @@ import { confirmToast } from '@/components/ui/toast' import { toast } from "react-hot-toast"; import useSearchModalStore from '@/store/useSearchModalStore'; import useStore from '@/store/useStore'; +import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage'; +import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; + +const isPopupWindow = () => + typeof window !== "undefined" && + !!window.opener && + window.location.pathname.startsWith("/popup/"); export default function useOperationTab() { @@ -200,9 +207,21 @@ export default function useOperationTab() { // 유지관리 정보 디테일 테이블 cellClick 핸들러 const handleOperationResultCellClick = async (index?: number | undefined, headId?: String) => { if (typeof index === "number") { + const row = detailTableData[index]; + + if (isPopupWindow()) { + postPopupMessageAndClose( + WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE, + { + layerNm: row.lyrTypeNm, + fid: String(row.fid), + }, + ); + return; + } + setIsLoading(true); - const row = detailTableData[index]; const layerNm = row.lyrTypeNm; const fid = row.fid; const core = GisApp.Module.layer as any; @@ -434,6 +453,20 @@ export default function useOperationTab() { } }; + const emitMultipleFeaturesToMap = () => { + if (detailTableData.length === 0 || !isPopupWindow()) return false; + + const row = detailTableData[0]; + postPopupMessageAndClose( + WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE, + { + layerNm: row.lyrTypeNm, + fids: JSON.stringify(detailTableData.map((item) => item.fid)), + }, + ); + return true; + }; + return { WORK_TYPES, @@ -446,7 +479,8 @@ export default function useOperationTab() { handleOperationCellClick, handleOperationResultCellClick, handleOperationPageNation, + emitMultipleFeaturesToMap, operationExcelDownload, operationResultExcelDownload }; -}; \ No newline at end of file +}; diff --git a/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts b/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts index 1d317e3d4..585885521 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts @@ -19,6 +19,13 @@ import GisApp from "@common/business_components/ui/map/lib/gis.core"; import useSearchModalStore from '../../../store/useSearchModalStore'; import useBasicTab from './useBasicTab'; import useOperationTab from './useOperationTab'; +import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage'; +import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; + +const isPopupWindow = () => + typeof window !== "undefined" && + !!window.opener && + window.location.pathname.startsWith("/popup/"); export default function useSearchModal() { const { @@ -132,6 +139,18 @@ export default function useSearchModal() { const viewMultipleFeature = async () => { if (activeTab === 'basic') { if (!(tableData.length === 0)) { + if (isPopupWindow()) { + const row = tableData[0]; + postPopupMessageAndClose( + WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE, + { + layerNm: row.lyrTypeNm, + fids: JSON.stringify(tableData.map((item) => item.fid)), + }, + ); + return; + } + setIsLoading(true); const row = tableData[0]; @@ -144,6 +163,18 @@ export default function useSearchModal() { } } else if (activeTab === 'operation') { if (!(detailTableData.length === 0)) { + if (isPopupWindow()) { + const row = detailTableData[0]; + postPopupMessageAndClose( + WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE, + { + layerNm: row.lyrTypeNm, + fids: JSON.stringify(detailTableData.map((item) => item.fid)), + }, + ); + return; + } + setIsLoading(true); const row = detailTableData[0]; diff --git a/apps/sn/app/(popupLayout)/layout.tsx b/apps/sn/app/(popupLayout)/layout.tsx new file mode 100644 index 000000000..d02ca99ac --- /dev/null +++ b/apps/sn/app/(popupLayout)/layout.tsx @@ -0,0 +1,11 @@ +export default function PopupLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/sn/app/(popupLayout)/popup/[popupKey]/page.tsx b/apps/sn/app/(popupLayout)/popup/[popupKey]/page.tsx new file mode 100644 index 000000000..8231f4f4d --- /dev/null +++ b/apps/sn/app/(popupLayout)/popup/[popupKey]/page.tsx @@ -0,0 +1,30 @@ +import { isWindowPopupKey } from "@/features/windowPopup/model/popupDefinitions"; +import { windowPopupRegistry } from "@/features/windowPopup/registry"; + +type PopupPageProps = { + params: Promise<{ + popupKey: string; + }>; + searchParams: Promise>; +}; + +export default async function PopupPage({ + params, + searchParams, +}: PopupPageProps) { + const { popupKey } = await params; + const resolvedSearchParams = await searchParams; + + if (!isWindowPopupKey(popupKey)) { + return ( +
+

등록되지 않은 팝업입니다.

+

popupKey: {popupKey}

+
+ ); + } + + const PopupComponent = windowPopupRegistry[popupKey].component; + + return ; +} 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 4b1db105b..3df6a1541 100644 --- a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx @@ -17,22 +17,20 @@ * - 첨부파일 삭제및 Soft Delete 삭제: DELETE /api/v1/atfl/attachments/{atflId} (attach-service) */ 'use client' -import { deleteData, postData, useCookieAuth } from '@/components/lib/api' +import { deleteData, useCookieAuth } from '@/components/lib/api' import { searchData } from '@/components/lib/common/apiHelpers' import { formatDate, handleDownload } from '@/components/lib/utils' import LoadingOverlay from '@/components/ui/LoadingBar' -import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable, UiModal } from '@common/business_components/ui' +import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui' import ListDiv from '@common/business_components/ui/list_div' import { Button, DatePicker, Input, Label } from '@common/components/ui' import { useEffect, useState } from 'react' import { tableHeadlist } from '..' -import OperFileUploadForm from '../../../components/ui/operFileUploadForm' -import { tokenManager } from '@/entities/auth/lib/token-manager'; -import { AUTH_USER_ID } from "@/shared/config"; import { useQuery } from "@tanstack/react-query"; import { ENDPOINT } from "@common/components/config"; import { getUserInfo } from "@common/components/api"; -import TableModal from './tableModal'; +import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; +import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; const Page = () => { @@ -51,8 +49,6 @@ const Page = () => { const [curPage, setCurPage] = useState(1) //현재 페이지 const [totalData, setTotalData] = useState(0) //총 데이터 수 const [isLoading, setIsLoading] = useState(true) //로딩 상태 - const [isUploadModalOpen, setIsUploadModalOpen] = useState(false) // 등록 모달 상태 - const [isTableModalOpen, setIsTableModalOpen] = useState(false) // 테이블 모달 상태 const [resetSelection, setResetSelection] = useState(false); // 선택 초기화 상태 const [isResetPage, setIsResetPage] = useState(false); @@ -67,6 +63,17 @@ const Page = () => { fetchData() }, []) + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.FLOOD_PRE_UPLOAD_COMPLETED) return; + handleSearch(); + }; + + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, [fromDate, toDate, pjNm, curPage]); + // 페이지 변경 시마다 데이터 fetch useEffect(() => { setIsResetPage(false); @@ -140,27 +147,24 @@ const Page = () => { } } - // 현황 등록 - const registFile = async (fileName: string, year: string, quater: string, cd: string, id: string) => { - const json = { - mngYr: year, - mngQu: quater, - atflId: id, - atflCd: cd, - rgtr: tokenManager.getToken(AUTH_USER_ID) || null, - sbmrgPrevntBizNm: fileName - } - const data = await postData('v1/sbmrgPrevnt', json) - if (data) { - alert('파일이 등록되었습니다.'); - setIsUploadModalOpen(false); - handleSearch(); + // 등록 버튼 클릭 핸들러 + const handleRegisterClick = () => { + const popupWindow = openWindowPopup("floodPreUpload"); + if (!popupWindow) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); } } - // 등록 버튼 클릭 핸들러 - const handleRegisterClick = () => { - setIsUploadModalOpen(true); + const handleTablePopupOpen = (fileName: string, id: string) => { + const popupWindow = openWindowPopup("floodPreTable", { + fileNm: fileName, + atflId: id, + sourcePath: window.location.pathname, + }); + + if (!popupWindow) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); + } } @@ -246,7 +250,7 @@ const Page = () => { setFileNm(fileName); setAtflId(id); setSbmrgPrevntBizSn(tableData[index]?.sbmrgPrevntBizSn); - setIsTableModalOpen(true); + handleTablePopupOpen(fileName, id); } }} onPageChange={setCurPage} @@ -254,40 +258,8 @@ const Page = () => { resetPageNo={isResetPage} /> - {/* 파일 업로드 모달 */} - { - await registFile(file, year, quater, cd, id) - }} - /> - } - /> - {/* 엑셀 데이터 테이블 모달 */} - } - useDirectRender={true} - /> ) } -export default Page \ No newline at end of file +export default Page diff --git a/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx b/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx index f4acc2056..4b35069af 100644 --- a/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx +++ b/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx @@ -55,6 +55,12 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { return false } + if (atflId === '' || atflId === undefined) { + alert('첨부파일 ID가 없어 파일을 불러올 수 없습니다.'); + setIsLoading(false); + return false; + } + const blob = await getBlobData(`/attach-service/api/v1/download/${atflId}`); const file = new File([blob], fileNm, { @@ -110,6 +116,8 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { const filteredResultData = filterEmptyRows(resultData); setResultTableData(filteredResultData); } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('FloodPre attachment fetch details:', { atflId, fileNm, message }); console.error('파일 처리 중 오류 발생:', error); alert('파일 처리 중 오류가 발생했습니다.'); } finally { 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 a078a9795..86d5d4853 100644 --- a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx @@ -17,22 +17,20 @@ * - 첨부파일 삭제및 Soft Delete 삭제: DELETE /api/v1/atfl/attachments/{atflId} (attach-service) */ 'use client' -import { deleteData, postData, useCookieAuth } from '@/components/lib/api' +import { deleteData, useCookieAuth } from '@/components/lib/api' import { searchData } from '@/components/lib/common/apiHelpers' import { formatDate, handleDownload } from '@/components/lib/utils' import LoadingOverlay from '@/components/ui/LoadingBar' -import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable, UiModal } from '@common/business_components/ui' +import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui' import ListDiv from '@common/business_components/ui/list_div' import { Button, DatePicker, Input, Label } from '@common/components/ui' import { useEffect, useState } from 'react' import { tableHeadlistOldPp } from '..' -import OperFileUploadForm from '../../../components/ui/operFileUploadForm' -import { tokenManager } from '@/entities/auth/lib/token-manager'; -import { AUTH_USER_ID } from "@/shared/config"; import { useQuery } from "@tanstack/react-query"; import { ENDPOINT } from "@common/components/config"; import { getUserInfo } from "@common/components/api"; -import TableModal from './tableModal'; +import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; +import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; const Page = () => { @@ -49,8 +47,6 @@ const Page = () => { const [curPage, setCurPage] = useState(1) const [totalData, setTotalData] = useState(0) //총 데이터 수 const [isLoading, setIsLoading] = useState(true) //로딩 상태 - const [isModalOpen, setIsModalOpen] = useState(false) // 등록 모달 상태 - const [isTableModalOpen, setIsTableModalOpen] = useState(false) // 테이블 모달 상태 const [resetSelection, setResetSelection] = useState(false); // 선택 초기화 상태 const [isResetPage, setIsResetPage] = useState(false); @@ -65,6 +61,17 @@ const Page = () => { fetchData() }, []) + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.OLD_PP_UPLOAD_COMPLETED) return; + handleSearch(); + }; + + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, [fromDate, toDate, pjNm, curPage]); + // 페이지 변경 시마다 데이터 fetch useEffect(() => { setIsResetPage(false); @@ -137,27 +144,25 @@ const Page = () => { } } - // 파일 등록 - const registFile = async (fileNm: string, year: string, quater: string, cd: string, id: string) => { - const json = { - mngYr: year, - mngQu: quater, - atflCd: cd, - atflId: id, - rgtr: tokenManager.getToken(AUTH_USER_ID) || null, - dcrpPplMtncBizNm: fileNm - } - const data = await postData('v1/dcrpPpl', json); - if (data) { - alert('파일이 등록되었습니다.'); - setIsModalOpen(false); - handleSearch(); + // 등록 버튼 클릭 핸들러 + const handleRegisterClick = () => { + const popupWindow = openWindowPopup("oldPpUpload"); + if (!popupWindow) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); } } - // 등록 버튼 클릭 핸들러 - const handleRegisterClick = () => { - setIsModalOpen(true); + // 엑셀 데이터는 브라우저 윈도우 팝업에서 독립 조회 + const handleTablePopupOpen = (fileName: string, id: string) => { + const popupWindow = openWindowPopup("oldPpTable", { + fileNm: fileName, + atflId: id, + sourcePath: window.location.pathname, + }); + + if (!popupWindow) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); + } } @@ -242,47 +247,15 @@ const Page = () => { setFileNm(fileName); setAtflId(id); setDcrpPplMtncBizSn(tableData[index]?.dcrpPplMtncBizSn); - setIsTableModalOpen(true); + handleTablePopupOpen(fileName, id); } }} onPageChange={setCurPage} resetSelection={resetSelection} /> - {/* 파일 업로드 모달 */} - { - await registFile(file, year, quater, cd, id) - }} - /> - } - /> - {/* 엑셀 데이터 테이블 모달 */} - } - useDirectRender={true} - /> ) } -export default Page \ No newline at end of file +export default Page diff --git a/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx b/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx index 13921b2c1..f22df820c 100644 --- a/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx +++ b/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx @@ -64,6 +64,12 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { return false; } + if (atflId === '' || atflId === undefined) { + alert('첨부파일 ID가 없어 파일을 불러올 수 없습니다.'); + setIsLoading(false); + return false; + } + const blob = await getBlobData(`/attach-service/api/v1/download/${atflId}`); const file = new File([blob], fileNm, { @@ -242,6 +248,8 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { })); setTableData6(filteredData6); } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('OldPp attachment fetch details:', { atflId, fileNm, message }); console.error('파일 처리 중 오류 발생:', error); alert('파일 처리 중 오류가 발생했습니다.'); } finally { diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts index 73025226b..aba7a3092 100644 --- a/apps/sn/app/components/lib/api/index.ts +++ b/apps/sn/app/components/lib/api/index.ts @@ -153,7 +153,10 @@ export const getRawJson = async (url: string): Promise => { } if (!res.ok) { - throw new Error('Failed to fetch data'); + 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'); @@ -170,7 +173,7 @@ export const getRawJson = async (url: string): Promise => { return JSON.parse(text); } catch (error) { console.error('JSON parsing error:', error); - throw new Error('Invalid JSON response from server'); + throw new Error(`Invalid JSON response from server: ${url}`); } }; @@ -184,7 +187,10 @@ export const getBlobData = async (url: string): Promise => { } if (!res.ok) { - throw new Error('Failed to fetch data'); + const errorText = await res.text().catch(() => ""); + throw new Error( + `Failed to fetch blob: ${res.status} ${res.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""} (${url})`, + ); } return res.blob(); diff --git a/apps/sn/app/components/ui/modal.tsx b/apps/sn/app/components/ui/modal.tsx deleted file mode 100644 index dce0ee2b5..000000000 --- a/apps/sn/app/components/ui/modal.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/** - * 📄 Modal Component - * 모달 컴포넌트 - * - * - 모달 열기/닫기 상태 관리 - * - 제목 및 내용 표시 - * - 저장 및 닫기 버튼 지원 - * - 반응형 크기 조정 (최대/최소 너비 및 높이) - * - 오버레이 및 z-index를 통한 최상위 레이어 배치 - * - 접근성 지원 (aria-describedby) - */ -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogOverlay } from '@common/components/ui' -import React from 'react' - -interface ModalProps { - isModalOpen: boolean; - setIsModalOpen: (open: boolean) => void; - title?: string; - children: React.ReactNode; - closeModal: () => void; - saveModal?: () => void; - description?: string; -} - -const Modal: React.FC = ({ isModalOpen, setIsModalOpen, title, children, closeModal, saveModal }) => { - return ( - - - - {title ? - {title} - : null} - {children} - -
- {saveModal ? : null} - -
-
-
-
- ) -} - -export default Modal \ 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 index 11e61b006..d507a4342 100644 --- a/apps/sn/app/html/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +++ b/apps/sn/app/html/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx @@ -23,7 +23,6 @@ */ 'use client' import { getData, postData } from "@/components/lib/api"; -import Modal from "@/components/ui/modal"; import { VerticalTable } from "@/components/ui/verticalTable"; import { useGisContext } from "@common/business_components/ui/map/Gis"; import { Button } from "@common/components/ui"; @@ -74,7 +73,6 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const [buttonChk, setButtonChk] = useState(true) const [fetchData, setFetchData] = useState(); // api 호출여부 const contentRef = useRef(null); - const [isModalOpen, setIsModalOpen] = useState(false); // 모달 열기 상태 const [isMainSsc, setIsMainSsc] = useState(false); // 중점우수토실 여부 const [isDrtwSewagePump, setIsDrtwSewagePump] = useState(false); // 오수펌프장 여부 // 🎯 InfilChart 및 RdiiChart 관련 상태 추가 @@ -459,18 +457,11 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, }; }, [featureData, gisMap]); - // 저장 시 모달 띄우기 + // Use the browser confirm dialog for lightweight save confirmation. const handleSaveClick = () => { - setIsModalOpen(true); // 모달 열기 - }; - - const handleModalSave = () => { - setIsModalOpen(false); // 모달 닫기 - btnClickEvent(); // 저장 이벤트 실행 - }; - - const handleModalClose = () => { - setIsModalOpen(false); // 모달 닫기 + const shouldSave = window.confirm('정보를 저장하시겠습니까?'); + if (!shouldSave) return; + void btnClickEvent(); }; // 저장 버튼 이벤트 @@ -617,7 +608,6 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, total2={0} isEditable2={buttonChk} /> -
정보를 저장하시겠습니까?
{ContentComponent && ) { + const theme = (await cookies()).get("site-theme-key"); + + return ( + + + {children} + + + ); +} diff --git a/apps/sn/app/providers.tsx b/apps/sn/app/providers.tsx new file mode 100644 index 000000000..4bbe9f864 --- /dev/null +++ b/apps/sn/app/providers.tsx @@ -0,0 +1 @@ +export { AppProvider } from "../src/app/providers"; diff --git a/apps/sn/docs/팝업레이아웃/README.md b/apps/sn/docs/팝업레이아웃/README.md new file mode 100644 index 000000000..7a3bcf9ec --- /dev/null +++ b/apps/sn/docs/팝업레이아웃/README.md @@ -0,0 +1,44 @@ +# 팝업 레이아웃 전환 문서 + +## 목적 + +현재 `sn` 앱의 주요 팝업은 화면 내부 `UiModal` 또는 지도 내부 패널로 렌더링된다. 이번 작업의 목적은 모달로 구현된 팝업 중 업무상 독립 화면이 필요한 기능을 브라우저 윈도우 팝업으로 전환하기 위한 공통 구조를 설계하고, 작은 기능 하나를 기준으로 먼저 검증하는 것이다. + +## 적용 범위 + +- 작업 앱: `apps/sn` +- 신규 라우트 그룹명: `app/(popupLayout)` +- 실제 팝업 URL 기본 형태: `/popup/{popupKey}` +- 1차 테스트 대상: `app/(with-base-layout)/oper/oldPp`의 첨부 엑셀 데이터 테이블 모달 + +## 문서 구성 + +- `설계/001-popupLayout-라우팅-구조.md` + - Next.js App Router 레이아웃 적용 순서 + - `(popupLayout)` 디렉터리 구조 + - 팝업 registry와 open helper 설계 + - `features/windowPopup/model|lib|ui` 구조 기준 + +- `작업계획/001-oldPp-테이블모달-윈도우팝업-전환.md` + - 첫 테스트 대상 선정 이유 + - 기존 모달 흐름과 전환 후 흐름 + - 파일 단위 작업 계획 + - `oldPp` 도메인 파라미터와 팝업 UI 분리 기준 + +- `검증/001-popupLayout-검증시나리오.md` + - 핵심 검증 항목 요약 + - 구현 결과 요약 + - 선행 차단 이슈와 현재 상태 + +## 결정 사항 + +1. `aged-pipe-popup`처럼 특정 기능 팝업을 `map` 하위에 직접 추가하지 않는다. +2. 팝업 화면은 지도 레이아웃 또는 기본 레이아웃의 하위 개념이 아니므로 별도 route group인 `(popupLayout)`에 둔다. +3. 각 화면에서 팝업 URL을 직접 문자열로 하드코딩하지 않고, `popupKey` 기반 helper를 통해 연다. +4. 첫 구현 검증은 영향이 작은 `oldPp` 테이블 상세 모달을 대상으로 한다. + +## 보류 사항 + +- `@common/components`의 LNB 새 창 처리 방식은 현재 작업 범위 밖이다. +- 지도 검색 모달과 지도 상세 패널 전환은 첫 테스트 성공 후 별도 작업으로 진행한다. +- 저장/등록형 팝업의 부모 창 갱신은 `postMessage` 방식으로 설계하되, 첫 테스트 대상은 조회형 팝업이므로 최소 구현에서 제외한다. diff --git a/apps/sn/docs/팝업레이아웃/검증/001-popupLayout-검증시나리오.md b/apps/sn/docs/팝업레이아웃/검증/001-popupLayout-검증시나리오.md new file mode 100644 index 000000000..5887559a9 --- /dev/null +++ b/apps/sn/docs/팝업레이아웃/검증/001-popupLayout-검증시나리오.md @@ -0,0 +1,133 @@ +# popupLayout 검증 요약 + +## 검증 대상 + +- 신규 route group: `app/(popupLayout)` +- 신규 실제 URL: `/popup/{popupKey}` +- 호출 화면 + - `/oper/oldPp` + - `/oper/floodPre` + - `/map` +- 주요 popup key + - `oldPpUpload` + - `oldPpTable` + - `floodPreUpload` + - `floodPreTable` + - `mapSearch` + - `reportTab` + - `reportFiles` + - `infilChart` + - `rdiiChart` + - `sscDetail` + +## 핵심 확인 항목 + +### 1. 라우팅 + +- `/popup/[popupKey]` 경로가 정상 라우트로 인식되어야 한다. +- 등록되지 않은 `popupKey`는 안내 화면을 보여야 한다. + +### 2. 레이아웃 + +- 팝업 창에는 헤더, LNB, 푸터가 없어야 한다. +- 부모 화면은 기존 레이아웃을 그대로 유지해야 한다. + - `/oper/**`: `with-base-layout` + - `/map`: `map-layout` + +### 3. 기능 + +- `oldPp`, `floodPre` 등록/상세 진입이 `window.open` 기반 popupView로 열려야 한다. +- 지도 검색은 `mapSearch` popupView에서 동작해야 한다. +- 지도 검색 popup은 부모 지도와 `postMessage`로 연동되어야 한다. + - `MAP_SEARCH_SELECT_FEATURE` + - `MAP_SEARCH_SHOW_MULTIPLE` +- 보고서 조회, 보고서 파일 목록, 관로 분석 차트, 중점우수토실 상세가 popupView로 열려야 한다. + +### 4. 확인/오류 처리 + +- 팝업 차단 시 사용자 안내가 있어야 한다. +- 필수 파라미터 없이 팝업 URL에 직접 접근하면 누락 안내가 보여야 한다. +- 지도 우측 상세의 저장 확인은 별도 커스텀 모달 없이 브라우저 `window.confirm`으로 처리되어야 한다. + +## 구현 결과 요약 + +### 완료 + +- `(popupLayout)` route group 추가 +- `/popup/[popupKey]` 동적 팝업 라우트 추가 +- `windowPopupDefinitions`, `windowPopupRegistry`, `openWindowPopup` 추가 +- `oldPp` 등록/상세 popupView 전환 +- `floodPre` 등록/상세 popupView 전환 +- 지도 검색 `searchModal`을 `mapSearch` popupView로 전환 +- 지도 검색 popup과 부모 `/map` 페이지 간 `postMessage` 연동 추가 +- `SwtrmtFclt` 보고서 조회를 `reportTab` popupView로 전환 +- 보고서 파일 목록을 `reportFiles` popupView로 전환 +- 관로 분석 차트 `infilChart`, `rdiiChart`를 popupView로 전환 +- 중점우수토실 상세 `sscDetail` popupView 전환 +- 지도 우측 상세 `featureClickEventPopup.tsx`의 저장 확인을 `window.confirm`으로 치환 +- 실서비스에서 더 이상 사용하지 않는 `app/components/ui/modal.tsx` 제거 + +### 현재 확인됨 + +- 로컬 `sewerage-service` 포트는 `8883` 기준으로 맞춰져 있다. +- `.env.local`의 `NEXT_PUBLIC_SEWERAGE_API_URL=http://localhost:8883` 기준에서 `/map` 초기 API 호출이 가능하다. +- `featureClickEventPopup.tsx`는 더 이상 `Modal`, `chartModalStore`, 내부 차트 모달 컴포넌트에 의존하지 않는다. +- `app/components/ui/modal.tsx` 참조는 실서비스 경로뿐 아니라 `app/html/**` 샘플 경로에서도 제거했다. + +### 실서비스 기준 남은 모달 상태 + +- 실서비스 경로(`app/**`, 단 `app/html/**` 제외) 기준으로 popupLayout 전환 대상 모달은 정리되었다. +- 지도 우측 상세 저장 확인도 경량 confirm으로 치환되어 별도 모달 의존성이 없다. + +### 샘플/퍼블리싱 영역 참고 + +- `app/html/**`는 실서비스 전환 기준에서 제외한다. +- `app/html/**` 내부에는 여전히 예전 구조를 보여주는 샘플 코드가 일부 남아 있을 수 있다. +- 현재 남은 대표 예시는 `reportFilesModal.tsx`, `chartModalStore.ts`이며, 실서비스 동작과는 분리해서 본다. + +## 선행 차단 이슈 + +### 1. oldPp 목록이 비어 보이는 경우 + +로컬 API에 데이터가 있어도 화면 조회 기간이 최근 7일로 잡혀 있으면 기존 등록 데이터가 목록에 보이지 않을 수 있다. + +예시: + +- 화면 조회 기간: `2026-04-17 ~ 2026-04-24` +- 로컬 API 데이터 등록일 예시: `2025-12-19` + +따라서 popup 검증 전에는 조회 기간을 넓히거나 새 데이터를 등록해야 한다. + +### 2. 빌드 최종 단계 + +`pnpm --filter sn run build`는 코드/라우팅 문제와 별개로 `next.config.ts`의 `output: "standalone"` 단계에서 Windows `EPERM symlink` 문제로 마지막에 실패할 수 있다. + +이 이슈는 popupLayout 구조나 모달 제거 로직의 문제라기보다 로컬 Windows standalone 산출물 생성 환경 이슈다. + +## 현재 기준 수동 검증 방법 + +1. `sn` 개발 서버를 재시작한다. +2. `/map`이 정상 로드되는지 확인한다. +3. `/oper/oldPp`, `/oper/floodPre`에서 조회 기간을 넓혀 목록이 보이게 한다. +4. 등록 버튼과 목록 행 클릭으로 각각 popupView가 열리는지 확인한다. +5. `/map`에서 아래를 확인한다. + - 지도 검색 popup 열기 + - popup에서 단건 선택 후 부모 지도 반영 + - popup에서 다건 보기 후 부모 지도 반영 + - 보고서 조회 popup 열기 + - 보고서 파일 목록 popup 열기 + - 침입수분석/RDII 분석 popup 열기 + - 중점우수토실 상세 popup 열기 +6. 지도 우측 상세에서 저장 버튼 클릭 시 브라우저 confirm이 뜨고, 취소/확인 흐름이 정상 동작하는지 확인한다. + +## 자동/빌드 검증 메모 + +- import 점검 기준으로 `@/components/ui/modal` 참조는 제거되었다. +- 실서비스 경로에서 `chartModalStore`, `reportFilesModal` 참조는 제거되었다. +- 추가 자동 브라우저 검증은 아직 연결하지 않았고, 필요 시 별도 Playwright runner로 이어서 구성할 수 있다. + +## 남은 확인 항목 + +- `/popup/**` 경로에서 `MenuWrapper` 권한 흐름이 실제 브라우저에서 완전히 문제없는지 +- 한글 파일명/특수문자 query string encode/decode가 실브라우저에서 안정적인지 +- `oldPp`, `floodPre` 상세 popup content를 장기적으로 route 폴더 밖 공용 UI로 더 분리할지 여부 diff --git a/apps/sn/docs/팝업레이아웃/설계/001-popupLayout-라우팅-구조.md b/apps/sn/docs/팝업레이아웃/설계/001-popupLayout-라우팅-구조.md new file mode 100644 index 000000000..d5875d894 --- /dev/null +++ b/apps/sn/docs/팝업레이아웃/설계/001-popupLayout-라우팅-구조.md @@ -0,0 +1,228 @@ +# popupLayout 라우팅 구조 설계 + +## 배경 + +현재 `sn` 앱은 Next.js App Router 구조를 사용한다. 최상위 `app/layout.tsx`가 모든 페이지에 먼저 적용되고, 그 아래 route group인 `(map-layout)`, `(with-base-layout)`의 `layout.tsx`가 추가로 적용된다. + +현재 구조의 의미는 다음과 같다. + +```text +app/layout.tsx + 모든 라우트에 공통 적용 + AppProvider, AuthWrapper, MenuWrapper, QueryClient, StoreProvider 적용 + +app/(map-layout)/layout.tsx + /map 같은 지도 중심 화면에 적용 + NoFooterLayout 사용 + +app/(with-base-layout)/layout.tsx + /oper/oldPp 같은 기본 업무 화면에 적용 + BaseLayout, PageLayout 사용 +``` + +Next.js route group 폴더명은 괄호로 감싸므로 URL에는 포함되지 않는다. 예를 들어 `app/(with-base-layout)/oper/oldPp/page.tsx`의 실제 URL은 `/oper/oldPp`이다. + +## 문제 + +기능 팝업을 `app/(map-layout)/map/aged-pipe-popup`처럼 특정 레이아웃 하위에 직접 추가하면 다음 문제가 생긴다. + +1. 팝업이 지도 화면의 하위 개념처럼 배치되어 구조가 흐려진다. +2. 신규 팝업이 생길 때마다 특정 화면 또는 특정 URL 문자열을 직접 추가해야 한다. +3. 기본 화면 팝업, 지도 팝업, 업무 상세 팝업이 한 레이아웃 아래에 섞인다. +4. 윈도우 팝업에서 헤더, 푸터, LNB가 다시 렌더링될 가능성이 있다. + +## 목표 구조 + +팝업 전용 route group을 만든다. + +```text +app + layout.tsx + + (map-layout) + layout.tsx + map + page.tsx + + (with-base-layout) + layout.tsx + oper + oldPp + page.tsx + + (popupLayout) + layout.tsx + popup + [popupKey] + page.tsx + + components + store + +src + features + windowPopup + model + popupDefinitions.ts + types.ts + lib + openWindowPopup.ts + ui + OldPpTablePopupContent.tsx + PopupMissingParams.tsx + registry.tsx + index.ts +``` + +실제 URL은 다음 형태가 된다. + +```text +/popup/oldPpTable +/popup/mapSearch +/popup/agedPipeDetail +``` + +`(popupLayout)`은 URL에 포함되지 않는다. + +## popupLayout 책임 + +`app/(popupLayout)/layout.tsx`는 팝업 창에 필요한 최소 레이아웃만 담당한다. + +- 헤더, 푸터, LNB를 렌더링하지 않는다. +- `BaseLayout`, `NoFooterLayout`, `PageLayout`을 사용하지 않는다. +- 팝업 내부 스크롤과 배경 스타일만 제공한다. +- 최상위 `app/layout.tsx`의 `AppProvider/AuthWrapper/MenuWrapper`는 그대로 적용된다. + +예상 형태는 다음과 같다. + +```tsx +export default function PopupLayout({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} +``` + +## popupKey registry + +각 팝업은 URL 문자열을 직접 흩뿌리지 않고 registry로 관리한다. + +실제 위치: + +```text +src/features/windowPopup + registry.tsx + model + popupDefinitions.ts + types.ts + lib + openWindowPopup.ts + ui + OldPpTablePopupContent.tsx + PopupMissingParams.tsx +``` + +registry 예시: + +```tsx +export const windowPopupRegistry = { + oldPpTable: { + title: "노후 하수관로 정비사업 상세", + width: 1400, + height: 900, + component: OldPpTablePopupPage, + }, +}; +``` + +`popupKey`는 camelCase로 관리한다. + +## open helper + +각 화면은 `window.open`을 직접 호출하지 않고 helper를 사용한다. + +```ts +openWindowPopup("oldPpTable", { + fileNm, + atflId, + sourcePath: window.location.pathname, +}); +``` + +helper 책임: + +- registry에서 팝업 기본 크기 조회 +- query string 생성 +- 중앙 위치 계산 +- `noopener` 사용 여부 결정 +- 팝업 차단 시 사용자 안내 반환 + +## feature 분리 기준 + +팝업 구조는 아래처럼 역할별로 나눈다. + +- `app/(popupLayout)` + - URL과 layout + - dynamic route 진입점 + +- `src/features/windowPopup/model` + - popup key + - popup definition + - popup 공통 타입 + +- `src/features/windowPopup/lib` + - `window.open` 실행 + - query string 생성 + - 팝업 크기와 위치 계산 + +- `src/features/windowPopup/ui` + - 팝업 전용 UI + - 파라미터 누락 안내 UI + - 실제 팝업 콘텐츠 컴포넌트 + +이 구조를 택한 이유는 현재 단계에서 popup 관련 코드를 한 눈에 따라가기 쉽게 하기 위해서다. 아직 `oldPp` 팝업이 하나뿐이므로 파라미터 해석도 `windowPopup/ui` 안에서 처리한다. 추후 `oldPp` 전용 팝업이 여러 개로 늘어나면 그때 `src/features/oldPp/ui` 또는 `src/features/oldPp/model`로 승격한다. + +## 데이터 전달 기준 + +윈도우 팝업은 부모 React state를 공유하지 않는다. 따라서 전달 방식은 아래 기준을 따른다. + +| 데이터 유형 | 전달 방식 | +| --- | --- | +| 단순 식별자 | query string | +| 파일 ID, 관리 번호 | query string | +| 큰 객체 | ID만 전달 후 팝업에서 API 재조회 | +| 부모 갱신 필요 이벤트 | `window.opener.postMessage` | +| 지도 OpenLayers feature 객체 | 전달 금지, feature ID 또는 관리번호로 재조회 | + +## 권한 처리 기준 + +팝업 라우트도 최상위 `AppProvider/AuthWrapper/MenuWrapper`를 통과한다. `/popup/{popupKey}` 자체는 메뉴 URL이 아니므로 권한 판단 시 원본 메뉴 기준이 필요하다. + +1차 테스트에서는 조회형 팝업만 열기 때문에 기존 쿠키 기반 인증 확인만 우선 검증한다. + +향후 저장/수정/삭제가 있는 팝업은 다음 중 하나를 선택해야 한다. + +1. `sourceMenuCd`를 query string으로 전달한다. +2. `sourcePath`를 전달하고 `MenuWrapper`에서 원본 메뉴를 찾아 권한을 조회한다. +3. `/popup` 경로는 메뉴 매칭에서 제외하고, 호출한 부모 화면의 권한 버튼에서만 진입을 통제한다. + +1차 구현에서는 3번을 우선 검토한다. `MenuWrapper` 수정은 `apps/sn/src` 내부에서 가능한지 먼저 확인하고, 공통 패키지 변경은 하지 않는다. + +## 명명 기준 + +- route group: `(popupLayout)` +- URL segment: `popup` +- dynamic segment: `[popupKey]` +- popup key: `oldPpTable`, `mapSearch`, `agedPipeDetail` +- 컴포넌트명: `OldPpTablePopupContent`, `MapSearchPopupContent` + +## 롤백 기준 + +첫 테스트 중 문제가 생기면 다음 순서로 되돌린다. + +1. 부모 화면의 `openWindowPopup` 호출을 기존 `setIsTableModalOpen(true)`로 되돌린다. +2. 기존 `UiModal` 렌더링을 유지한다. +3. `(popupLayout)` 신규 라우트는 삭제하거나 미사용 상태로 둔다. +4. registry에서 해당 `popupKey`를 제거한다. diff --git a/apps/sn/docs/팝업레이아웃/작업계획/001-oldPp-테이블모달-윈도우팝업-전환.md b/apps/sn/docs/팝업레이아웃/작업계획/001-oldPp-테이블모달-윈도우팝업-전환.md new file mode 100644 index 000000000..dedb66b79 --- /dev/null +++ b/apps/sn/docs/팝업레이아웃/작업계획/001-oldPp-테이블모달-윈도우팝업-전환.md @@ -0,0 +1,184 @@ +# oldPp 테이블 모달 윈도우 팝업 전환 작업 계획 + +## 테스트 대상 + +첫 테스트 대상은 `app/(with-base-layout)/oper/oldPp/page.tsx`의 첨부 엑셀 데이터 테이블 모달이다. + +현재 흐름: + +```text +/oper/oldPp + 목록 행 클릭 + fileNm, atflId 상태 저장 + isTableModalOpen = true + UiModal 내부에서 tableModal.tsx 렌더링 +``` + +전환 후 흐름: + +```text +/oper/oldPp + 목록 행 클릭 + openWindowPopup("oldPpTable", { fileNm, atflId, sourcePath }) + +/popup/oldPpTable?fileNm=...&atflId=... + popupLayout 적용 + query string에서 fileNm, atflId 읽기 + 기존 tableModal 화면 내용 렌더링 +``` + +## 선정 이유 + +1. `oldPp` 테이블 모달은 조회형 팝업이라 부모 창 갱신이 필요 없다. +2. 팝업 데이터가 `fileNm`, `atflId` 두 값으로 충분하다. +3. 기존 `TableModal`은 내부에서 `getBlobData`를 호출하므로 새 창에서도 독립 실행이 가능하다. +4. `with-base-layout` 아래 화면에서 팝업을 여는 사례라, `(popupLayout)` 분리 효과를 확인하기 좋다. +5. 지도 팝업보다 OpenLayers 객체 공유 문제가 적어 첫 검증에 적합하다. + +## 예상 수정 파일 + +1차 구현 시 예상 파일은 다음과 같다. + +```text +app/(popupLayout)/layout.tsx +app/(popupLayout)/popup/[popupKey]/page.tsx +src/features/windowPopup/model/types.ts +src/features/windowPopup/model/popupDefinitions.ts +src/features/windowPopup/lib/openWindowPopup.ts +src/features/windowPopup/ui/OldPpTablePopupContent.tsx +src/features/windowPopup/ui/PopupMissingParams.tsx +src/features/windowPopup/registry.tsx +app/(with-base-layout)/oper/oldPp/page.tsx +app/(with-base-layout)/oper/oldPp/tableModal.tsx +``` + +`packages/common/components`는 수정하지 않는다. + +## 세부 작업 + +### 1. 팝업 레이아웃 생성 + +`app/(popupLayout)/layout.tsx`를 추가한다. + +역할: + +- 팝업 전용 최소 wrapper 제공 +- 헤더, 푸터, LNB 미사용 +- 전체 높이와 배경만 지정 + +### 2. 동적 팝업 라우트 생성 + +`app/(popupLayout)/popup/[popupKey]/page.tsx`를 추가한다. + +역할: + +- `params.popupKey`를 읽는다. +- registry에서 대응 컴포넌트를 찾는다. +- query string을 props로 넘긴다. +- 등록되지 않은 `popupKey`는 안내 화면을 표시한다. + +### 3. popup system feature 작성 + +아래 파일을 추가하거나 정리한다. + +```text +src/features/windowPopup/model/popupDefinitions.ts +src/features/windowPopup/model/types.ts +src/features/windowPopup/lib/openWindowPopup.ts +src/features/windowPopup/ui/OldPpTablePopupContent.tsx +src/features/windowPopup/ui/PopupMissingParams.tsx +src/features/windowPopup/registry.tsx +``` + +역할: + +- `model`: popup key, popup definition, popup 타입 +- `lib`: `window.open` 실행 helper +- `ui`: 팝업 콘텐츠와 누락 안내 UI +- `registry`: popup key와 실제 UI 매핑 + +1차 등록 대상: + +```text +popupKey: oldPpTable +title: 노후 하수관로 정비사업 상세 +width: 1400 +height: 900 +requiredParams: fileNm, atflId +``` + +### 4. oldPp 부모 화면 변경 + +`app/(with-base-layout)/oper/oldPp/page.tsx`의 행 클릭 로직을 변경한다. + +기존: + +```ts +setFileNm(fileName); +setAtflId(id); +setDcrpPplMtncBizSn(...); +setIsTableModalOpen(true); +``` + +변경: + +```ts +openWindowPopup("oldPpTable", { + fileNm: fileName, + atflId: id, + sourcePath: window.location.pathname, +}); +``` + +삭제 또는 보류: + +- `isTableModalOpen` +- table modal용 `UiModal` + +단, 1차 작업에서는 롤백을 쉽게 하기 위해 기존 모달 코드를 한 번에 삭제하지 않고, 팝업 검증 후 제거하는 방식을 권장한다. + +### 5. TableModal 독립 실행 점검 + +`tableModal.tsx`가 부모 모달에 의존하는지 확인한다. + +현재 확인된 독립 실행 조건: + +- props: `fileNm`, `atflId` +- 내부 API: `getBlobData('/attach-service/api/v1/download/{atflId}')` +- 내부 상태: activeTab, tableData, isLoading + +따라서 팝업 페이지에서 바로 렌더링 가능하다. + +단, 장기적으로는 `tableModal.tsx`도 route 폴더 밖 공용 UI로 승격할 수 있다. 현재 단계에서는 변경 범위를 줄이기 위해 기존 파일을 그대로 재사용한다. + +## 권한과 인증 + +새 창도 같은 origin에서 열리므로 쿠키는 공유된다. `AuthWrapper`의 인증 확인은 그대로 동작해야 한다. + +주의할 점: + +- `/popup/oldPpTable`은 메뉴 URL이 아니므로 메뉴 매칭이 안 될 수 있다. +- 현재 테스트 대상은 조회형 팝업이므로, 부모 화면에서 행 클릭이 가능한 권한을 전제로 한다. +- 팝업 내부에서 별도 저장/삭제 권한 판단은 하지 않는다. + +## 완료 기준 + +1. `/oper/oldPp`에서 목록 행 클릭 시 새 브라우저 창이 열린다. +2. 새 창 URL은 `/popup/oldPpTable?...` 형태다. +3. 새 창에는 헤더, 푸터, LNB가 없다. +4. 기존 `tableModal.tsx`의 탭과 테이블이 정상 표시된다. +5. 팝업 차단 시 부모 화면이 깨지지 않는다. +6. 기존 파일 다운로드, 삭제, 목록 조회 동작은 유지된다. + +## 롤백 방법 + +1. `oldPp/page.tsx`의 행 클릭 로직을 기존 `setIsTableModalOpen(true)`로 되돌린다. +2. 기존 `UiModal` 렌더링을 다시 사용한다. +3. 신규 `(popupLayout)` 라우트와 `windowPopup` registry는 미사용 상태로 둔다. + +## 남은 리스크 + +- `MenuWrapper`가 `/popup` 경로에서 메뉴 권한을 어떻게 처리하는지 브라우저에서 확인해야 한다. +- 브라우저 설정에 따라 팝업이 차단될 수 있다. +- 파일명이 한글 또는 특수문자를 포함할 때 query string encode/decode 검증이 필요하다. +- `tableModal.tsx` 파일명 자체가 모달 개념을 담고 있어, 장기적으로는 `oldPp` 도메인 UI로 다시 정리할 여지가 있다. diff --git a/apps/sn/src/app/index.tsx b/apps/sn/src/app/index.tsx index 692b55ef0..29d73b03f 100644 --- a/apps/sn/src/app/index.tsx +++ b/apps/sn/src/app/index.tsx @@ -1,7 +1,3 @@ -import "./styles/base.css"; -import "./styles/components.css"; -import "@fontsource-variable/noto-sans-kr"; - import { AppProvider } from "./providers"; import { cookies } from "next/headers"; diff --git a/apps/sn/src/features/windowPopup/index.ts b/apps/sn/src/features/windowPopup/index.ts new file mode 100644 index 000000000..8424dcc1d --- /dev/null +++ b/apps/sn/src/features/windowPopup/index.ts @@ -0,0 +1,14 @@ +export { openWindowPopup } from "./lib/openWindowPopup"; +export { + isWindowPopupKey, + windowPopupDefinitions, + type WindowPopupKey, +} from "./model/popupDefinitions"; +export { windowPopupRegistry } from "./registry"; +export type { + OpenWindowPopupParams, + WindowPopupContentProps, + WindowPopupMessageType, + WindowPopupSearchParams, +} from "./model/types"; +export { WINDOW_POPUP_MESSAGE_TYPE } from "./model/types"; diff --git a/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts b/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts new file mode 100644 index 000000000..dbb90794a --- /dev/null +++ b/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts @@ -0,0 +1,61 @@ +import { + isWindowPopupKey, + windowPopupDefinitions, + type WindowPopupKey, +} from "../model/popupDefinitions"; +import type { OpenWindowPopupParams } from "../model/types"; + +const buildQueryString = (params: OpenWindowPopupParams = {}) => { + const searchParams = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + if (value === null || value === undefined || value === "") return; + searchParams.set(key, String(value)); + }); + + const queryString = searchParams.toString(); + return queryString ? `?${queryString}` : ""; +}; + +const buildPopupFeatures = (width: number, height: number) => { + const screenLeft = window.screenX ?? window.screenLeft ?? 0; + const screenTop = window.screenY ?? window.screenTop ?? 0; + const outerWidth = window.outerWidth || window.screen.width; + const outerHeight = window.outerHeight || window.screen.height; + const left = Math.max(0, screenLeft + Math.round((outerWidth - width) / 2)); + const top = Math.max(0, screenTop + Math.round((outerHeight - height) / 2)); + + return [ + `width=${width}`, + `height=${height}`, + `left=${left}`, + `top=${top}`, + "resizable=yes", + "scrollbars=yes", + "status=no", + "location=no", + "menubar=no", + "toolbar=no", + ].join(","); +}; + +export const openWindowPopup = ( + popupKey: WindowPopupKey, + params?: OpenWindowPopupParams, +) => { + if (typeof window === "undefined") return null; + if (!isWindowPopupKey(popupKey)) return null; + + const definition = windowPopupDefinitions[popupKey]; + const popupUrl = `/popup/${popupKey}${buildQueryString(params)}`; + const popupFeatures = buildPopupFeatures(definition.width, definition.height); + const popupWindow = window.open( + popupUrl, + `sn-popup-${popupKey}`, + popupFeatures, + ); + + popupWindow?.focus(); + + return popupWindow; +}; diff --git a/apps/sn/src/features/windowPopup/lib/popupMessage.ts b/apps/sn/src/features/windowPopup/lib/popupMessage.ts new file mode 100644 index 000000000..6ae7ad7f9 --- /dev/null +++ b/apps/sn/src/features/windowPopup/lib/popupMessage.ts @@ -0,0 +1,24 @@ +import type { WindowPopupMessageType } from "../model/types"; + +export const postPopupMessage = ( + type: WindowPopupMessageType, + payload?: Record, +) => { + if (typeof window === "undefined" || !window.opener) return; + + window.opener.postMessage( + { + type, + payload, + }, + window.location.origin, + ); +}; + +export const postPopupMessageAndClose = ( + type: WindowPopupMessageType, + payload?: Record, +) => { + postPopupMessage(type, payload); + window.close(); +}; diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts new file mode 100644 index 000000000..b3184650d --- /dev/null +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -0,0 +1,76 @@ +import type { WindowPopupDefinition } from "./types"; + +export const windowPopupDefinitions = { + oldPpUpload: { + key: "oldPpUpload", + title: "노후 하수관로 정비사업 등록", + width: 860, + height: 360, + }, + oldPpTable: { + key: "oldPpTable", + title: "노후 하수관로 정비사업 상세", + width: 1400, + height: 900, + requiredParams: ["fileNm", "atflId"], + }, + floodPreUpload: { + key: "floodPreUpload", + title: "도시침수 예방사업 등록", + width: 860, + height: 360, + }, + floodPreTable: { + key: "floodPreTable", + title: "도시침수 예방사업 상세", + width: 1400, + height: 900, + requiredParams: ["fileNm", "atflId"], + }, + reportTab: { + key: "reportTab", + title: "하수도정비 기본계획보고서", + width: 1280, + height: 920, + }, + reportFiles: { + key: "reportFiles", + title: "보고서 파일 목록", + width: 860, + height: 720, + requiredParams: ["clsfSystmSn"], + }, + mapSearch: { + key: "mapSearch", + title: "관망도 검색", + width: 1280, + height: 980, + }, + infilChart: { + key: "infilChart", + title: "침입수 분석", + width: 1360, + height: 980, + requiredParams: ["pplMngNo"], + }, + rdiiChart: { + key: "rdiiChart", + title: "RDII 분석", + width: 1360, + height: 980, + requiredParams: ["pplMngNo"], + }, + sscDetail: { + key: "sscDetail", + title: "중점우수토실 운영정보", + width: 1180, + height: 980, + requiredParams: ["chmbrXcnts", "chmbrYdnts"], + }, +} as const satisfies Record; + +export type WindowPopupKey = keyof typeof windowPopupDefinitions; + +export const isWindowPopupKey = (key: string): key is WindowPopupKey => { + return key in windowPopupDefinitions; +}; diff --git a/apps/sn/src/features/windowPopup/model/types.ts b/apps/sn/src/features/windowPopup/model/types.ts new file mode 100644 index 000000000..8387e03c1 --- /dev/null +++ b/apps/sn/src/features/windowPopup/model/types.ts @@ -0,0 +1,37 @@ +import type { ComponentType } from "react"; + +export type WindowPopupSearchParams = Record< + string, + string | string[] | undefined +>; + +export type WindowPopupContentProps = { + searchParams: WindowPopupSearchParams; +}; + +export type WindowPopupDefinition = { + key: string; + title: string; + width: number; + height: number; + requiredParams?: string[]; +}; + +export type WindowPopupRegistryItem = WindowPopupDefinition & { + component: ComponentType; +}; + +export type OpenWindowPopupParams = Record< + string, + string | number | boolean | null | undefined +>; + +export const WINDOW_POPUP_MESSAGE_TYPE = { + OLD_PP_UPLOAD_COMPLETED: "sn:popup:oldPpUploadCompleted", + FLOOD_PRE_UPLOAD_COMPLETED: "sn:popup:floodPreUploadCompleted", + MAP_SEARCH_SELECT_FEATURE: "sn:popup:mapSearchSelectFeature", + MAP_SEARCH_SHOW_MULTIPLE: "sn:popup:mapSearchShowMultiple", +} as const; + +export type WindowPopupMessageType = + (typeof WINDOW_POPUP_MESSAGE_TYPE)[keyof typeof WINDOW_POPUP_MESSAGE_TYPE]; diff --git a/apps/sn/src/features/windowPopup/registry.tsx b/apps/sn/src/features/windowPopup/registry.tsx new file mode 100644 index 000000000..f8b247744 --- /dev/null +++ b/apps/sn/src/features/windowPopup/registry.tsx @@ -0,0 +1,55 @@ +import { windowPopupDefinitions, type WindowPopupKey } from "./model/popupDefinitions"; +import type { WindowPopupRegistryItem } from "./model/types"; +import FloodPreTablePopupContent from "./ui/FloodPreTablePopupContent"; +import FloodPreUploadPopupContent from "./ui/FloodPreUploadPopupContent"; +import InfilChartPopupContent from "./ui/InfilChartPopupContent"; +import OldPpUploadPopupContent from "./ui/OldPpUploadPopupContent"; +import OldPpTablePopupContent from "./ui/OldPpTablePopupContent"; +import RdiiChartPopupContent from "./ui/RdiiChartPopupContent"; +import ReportFilesPopupContent from "./ui/ReportFilesPopupContent"; +import ReportTabPopupContent from "./ui/ReportTabPopupContent"; +import SearchModalPopupContent from "./ui/SearchModalPopupContent"; +import SscDetailPopupContent from "./ui/SscDetailPopupContent"; + +export const windowPopupRegistry = { + oldPpUpload: { + ...windowPopupDefinitions.oldPpUpload, + component: OldPpUploadPopupContent, + }, + oldPpTable: { + ...windowPopupDefinitions.oldPpTable, + component: OldPpTablePopupContent, + }, + floodPreUpload: { + ...windowPopupDefinitions.floodPreUpload, + component: FloodPreUploadPopupContent, + }, + floodPreTable: { + ...windowPopupDefinitions.floodPreTable, + component: FloodPreTablePopupContent, + }, + reportTab: { + ...windowPopupDefinitions.reportTab, + component: ReportTabPopupContent, + }, + reportFiles: { + ...windowPopupDefinitions.reportFiles, + component: ReportFilesPopupContent, + }, + mapSearch: { + ...windowPopupDefinitions.mapSearch, + component: SearchModalPopupContent, + }, + infilChart: { + ...windowPopupDefinitions.infilChart, + component: InfilChartPopupContent, + }, + rdiiChart: { + ...windowPopupDefinitions.rdiiChart, + component: RdiiChartPopupContent, + }, + sscDetail: { + ...windowPopupDefinitions.sscDetail, + component: SscDetailPopupContent, + }, +} as const satisfies Record; diff --git a/apps/sn/src/features/windowPopup/ui/FloodPreTablePopupContent.tsx b/apps/sn/src/features/windowPopup/ui/FloodPreTablePopupContent.tsx new file mode 100644 index 000000000..bbc342b70 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/FloodPreTablePopupContent.tsx @@ -0,0 +1,36 @@ +import TableModal from "@/app/(with-base-layout)/oper/floodPre/tableModal"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import type { WindowPopupContentProps } from "../model/types"; +import PopupMissingParams from "./PopupMissingParams"; +import { getPopupParamValue } from "./popupContentUtils"; + +export default function FloodPreTablePopupContent({ + searchParams, +}: WindowPopupContentProps) { + const fileNm = getPopupParamValue(searchParams.fileNm); + const atflId = getPopupParamValue(searchParams.atflId); + const missingParams = [ + !fileNm ? "fileNm" : "", + !atflId ? "atflId" : "", + ].filter(Boolean); + + if (missingParams.length > 0) { + return ( + + ); + } + + return ( +
+

+ {windowPopupDefinitions.floodPreTable.title} +

+
+ +
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx new file mode 100644 index 000000000..629c6be53 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { postData } from "@/components/lib/api"; +import OperFileUploadForm from "@/components/ui/operFileUploadForm"; +import { tokenManager } from "@/entities/auth/lib/token-manager"; +import { AUTH_USER_ID } from "@/shared/config"; +import { postPopupMessageAndClose } from "../lib/popupMessage"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "../model/types"; + +export default function FloodPreUploadPopupContent() { + const handleSubmit = async ( + fileNm: string, + year: string, + quater: string, + 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, + }); + + if (data) { + alert("파일이 등록되었습니다."); + postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.FLOOD_PRE_UPLOAD_COMPLETED); + } + }; + + return ( +
+ +
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx new file mode 100644 index 000000000..f6a739f53 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx @@ -0,0 +1,22 @@ +import InfilChart from "@/app/(map-layout)/map/components/popupComponent/chart/infilChart"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import type { WindowPopupContentProps } from "../model/types"; +import PopupMissingParams from "./PopupMissingParams"; +import { getPopupParamValue } from "./popupContentUtils"; + +export default function InfilChartPopupContent({ + searchParams, +}: WindowPopupContentProps) { + const pplMngNo = getPopupParamValue(searchParams.pplMngNo); + + if (!pplMngNo) { + return ( + + ); + } + + return ; +} diff --git a/apps/sn/src/features/windowPopup/ui/OldPpTablePopupContent.tsx b/apps/sn/src/features/windowPopup/ui/OldPpTablePopupContent.tsx new file mode 100644 index 000000000..3d3439d9c --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/OldPpTablePopupContent.tsx @@ -0,0 +1,36 @@ +import TableModal from "@/app/(with-base-layout)/oper/oldPp/tableModal"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import type { WindowPopupContentProps } from "../model/types"; +import PopupMissingParams from "./PopupMissingParams"; +import { getPopupParamValue } from "./popupContentUtils"; + +export default function OldPpTablePopupContent({ + searchParams, +}: WindowPopupContentProps) { + const fileNm = getPopupParamValue(searchParams.fileNm); + const atflId = getPopupParamValue(searchParams.atflId); + const missingParams = [ + !fileNm ? "fileNm" : "", + !atflId ? "atflId" : "", + ].filter(Boolean); + + if (missingParams.length > 0) { + return ( + + ); + } + + return ( +
+

+ {windowPopupDefinitions.oldPpTable.title} +

+
+ +
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/OldPpUploadPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/OldPpUploadPopupContent.tsx new file mode 100644 index 000000000..e8e1b1e52 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/OldPpUploadPopupContent.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { postData } from "@/components/lib/api"; +import OperFileUploadForm from "@/components/ui/operFileUploadForm"; +import { tokenManager } from "@/entities/auth/lib/token-manager"; +import { AUTH_USER_ID } from "@/shared/config"; +import { postPopupMessageAndClose } from "../lib/popupMessage"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "../model/types"; + +export default function OldPpUploadPopupContent() { + const handleSubmit = async ( + fileNm: string, + year: string, + quater: string, + 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, + }); + + if (data) { + alert("파일이 등록되었습니다."); + postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.OLD_PP_UPLOAD_COMPLETED); + } + }; + + return ( +
+ +
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/PopupMissingParams.tsx b/apps/sn/src/features/windowPopup/ui/PopupMissingParams.tsx new file mode 100644 index 000000000..32b8bb088 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/PopupMissingParams.tsx @@ -0,0 +1,19 @@ +export default function PopupMissingParams({ + title, + missingParams, +}: { + title: string; + missingParams: string[]; +}) { + return ( +
+

{title}

+

+ 팝업을 여는 데 필요한 값이 누락되었습니다. +

+

+ 누락된 값: {missingParams.join(", ")} +

+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx new file mode 100644 index 000000000..8a591e004 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx @@ -0,0 +1,22 @@ +import RdiiChart from "@/app/(map-layout)/map/components/popupComponent/chart/rdiiChart"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import type { WindowPopupContentProps } from "../model/types"; +import PopupMissingParams from "./PopupMissingParams"; +import { getPopupParamValue } from "./popupContentUtils"; + +export default function RdiiChartPopupContent({ + searchParams, +}: WindowPopupContentProps) { + const pplMngNo = getPopupParamValue(searchParams.pplMngNo); + + if (!pplMngNo) { + return ( + + ); + } + + return ; +} diff --git a/apps/sn/src/features/windowPopup/ui/ReportFilesPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/ReportFilesPopupContent.tsx new file mode 100644 index 000000000..de3064383 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/ReportFilesPopupContent.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useEffect, useState } from "react"; +import LoadingOverlay from "@/components/ui/LoadingBar"; +import { getDcData } from "@/components/lib/api"; +import { UiTable } from "@common/business_components/ui"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import type { WindowPopupContentProps } from "../model/types"; +import PopupMissingParams from "./PopupMissingParams"; +import { getPopupParamValue } from "./popupContentUtils"; + +type ReportDataFile = { + no?: number; + fileTitle: string; + regDt: string; + fileSn: string; + filePath: string; +}; + +const REPORT_FILES_HEADLIST = [ + { id: "no", title: "NO" }, + { id: "fileTitle", title: "파일명" }, + { id: "regDt", title: "등록일자" }, +]; + +export default function ReportFilesPopupContent({ + searchParams, +}: WindowPopupContentProps) { + const clsfSystmSn = getPopupParamValue(searchParams.clsfSystmSn); + const [files, setFiles] = useState([]); + const [allFiles, setAllFiles] = useState([]); + const [curPage, setCurPage] = useState(1); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (!clsfSystmSn) return; + + const fetchFiles = async () => { + setIsLoading(true); + try { + const result = await getDcData(`/api/v1/file?clsfSystmSn=${clsfSystmSn}`); + const normalized = (result ?? []).map((item: ReportDataFile, index: number) => ({ + ...item, + no: index + 1, + })); + setAllFiles(normalized); + } catch (error) { + console.error("보고서 파일 목록 조회 실패:", error); + setAllFiles([]); + } finally { + setIsLoading(false); + } + }; + + fetchFiles(); + }, [clsfSystmSn]); + + useEffect(() => { + const paged = allFiles.slice((curPage - 1) * 10, curPage * 10); + setFiles(paged); + }, [allFiles, curPage]); + + const handleDownload = async (fileSn: string, fileTitle: string, filePath: string) => { + if (!filePath) { + alert("filePath 정보가 없으므로 다운로드가 불가합니다."); + return; + } + + try { + const responseBlob = await getDcData( + `/api/v1/fileDownload?filePath=${encodeURIComponent(filePath)}`, + "blob", + ); + + const objectUrl = URL.createObjectURL(responseBlob); + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = fileTitle; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(objectUrl); + } catch (error) { + console.error("보고서 파일 다운로드 실패:", error); + alert("파일 다운로드 중 오류가 발생했습니다."); + } + }; + + if (!clsfSystmSn) { + return ( + + ); + } + + return ( +
+

+ {windowPopupDefinitions.reportFiles.title} +

+
+ + { + if (typeof index !== "number" || !files[index]) return; + const target = files[index]; + handleDownload(target.fileSn, target.fileTitle, target.filePath); + }} + /> +
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx new file mode 100644 index 000000000..d2913df8e --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx @@ -0,0 +1,27 @@ +import ReportTab from "@/app/(map-layout)/map/searchModal/reportTab"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import type { WindowPopupContentProps } from "../model/types"; +import { getPopupParamValue } from "./popupContentUtils"; + +export default function ReportTabPopupContent({ + searchParams, +}: WindowPopupContentProps) { + const ctpvNm = getPopupParamValue(searchParams.ctpvNm); + const sggNm = getPopupParamValue(searchParams.sggNm); + + return ( +
+

+ {windowPopupDefinitions.reportTab.title} +

+
+ +
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx new file mode 100644 index 000000000..0bbb50bbe --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx @@ -0,0 +1,5 @@ +import SearchModal from "@/app/(map-layout)/map/searchModal/searchModal"; + +export default function SearchModalPopupContent() { + return ; +} diff --git a/apps/sn/src/features/windowPopup/ui/SscDetailPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/SscDetailPopupContent.tsx new file mode 100644 index 000000000..9e0ff6464 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/SscDetailPopupContent.tsx @@ -0,0 +1,36 @@ +import Ssc from "@/app/(map-layout)/map/components/popupComponent/Ssc"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import type { WindowPopupContentProps } from "../model/types"; +import PopupMissingParams from "./PopupMissingParams"; +import { getPopupParamValue } from "./popupContentUtils"; + +export default function SscDetailPopupContent({ + searchParams, +}: WindowPopupContentProps) { + const chmbrXcnts = getPopupParamValue(searchParams.chmbrXcnts); + const chmbrYdnts = getPopupParamValue(searchParams.chmbrYdnts); + + const missingParams = [ + !chmbrXcnts ? "chmbrXcnts" : "", + !chmbrYdnts ? "chmbrYdnts" : "", + ].filter(Boolean); + + if (missingParams.length > 0) { + return ( + + ); + } + + return ( + + ); +} diff --git a/apps/sn/src/features/windowPopup/ui/popupContentUtils.ts b/apps/sn/src/features/windowPopup/ui/popupContentUtils.ts new file mode 100644 index 000000000..5b2c1152f --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/popupContentUtils.ts @@ -0,0 +1,6 @@ +export const getPopupParamValue = ( + value: string | string[] | undefined, +): string => { + if (Array.isArray(value)) return value[0] ?? ""; + return value ?? ""; +}; From 8689b0a8a6fe2a4fb3d33310cea266e3749181af Mon Sep 17 00:00:00 2001 From: mind-one001 Date: Mon, 27 Apr 2026 15:51:36 +0900 Subject: [PATCH 04/11] =?UTF-8?q?md=20=EA=B5=AC=EC=A1=B0=20=EC=9E=A1?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/AGENTS.md | 23 +++-- apps/sn/docs/기준문서/001-프로젝트.md | 13 ++- apps/sn/docs/기준문서/007-멀티에이전트운영.md | 50 ++--------- apps/sn/docs/기준문서/008-설계정책.md | 4 +- apps/sn/docs/기준문서/010-용어사전.md | 6 +- apps/sn/docs/기준문서/011-환경빌드문제대응.md | 4 +- apps/sn/docs/기준문서/013-UI레이아웃가이드.md | 90 +++++++++++++++++++ ...{000-작업카드-템플릿.md => 작업카드-템플릿.md} | 14 ++- 8 files changed, 145 insertions(+), 59 deletions(-) create mode 100644 apps/sn/docs/기준문서/013-UI레이아웃가이드.md rename apps/sn/docs/작업계획/{000-작업카드-템플릿.md => 작업카드-템플릿.md} (85%) diff --git a/apps/sn/AGENTS.md b/apps/sn/AGENTS.md index 6ca968981..597a7af77 100644 --- a/apps/sn/AGENTS.md +++ b/apps/sn/AGENTS.md @@ -10,15 +10,15 @@ ## 공통 시작 기준 -Codex 작업 루트는 가능하면 `D:\workspace`로 잡고, 공통 시작 기준은 `D:\workspace\AGENTS.md`를 따른다. +Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작 기준은 `AGENTS.md`를 따른다. 이 파일은 프론트엔드 `sn` 앱 세부 기준이다. 백엔드(`sewerage-service`)와 함께 수정하거나 API/응답/DB/인증/권한 연동 영향이 있는 작업은 아래 파일을 함께 확인한다. -- 공통 시작 기준: `D:\workspace\AGENTS.md` -- 백엔드 세부 기준: `D:\workspace\IIPBS\sewerage-service\AGENTS.md` -- 프론트엔드 `sn` 앱 기준: `D:\workspace\IIPBS-FRONTEND\frontend\apps\sn\AGENTS.md` +- 공통 시작 기준: `AGENTS.md` +- 백엔드 세부 기준: `BE_REPO_ROOT/AGENTS.md` +- 프론트엔드 `sn` 앱 기준: `FE_APP_ROOT/AGENTS.md` --- @@ -113,6 +113,9 @@ Codex 작업 루트는 가능하면 `D:\workspace`로 잡고, 공통 시작 기 10. 작업 범위를 넓히기보다 `sn` 내부 해결을 우선한다. 11. 백엔드 API 응답과 실제 브라우저 동작까지 함께 확인하는 것을 기본으로 한다. 12. 작업 문서는 `apps/sn/docs/` 하위에 작업 단위 폴더를 만들어 기록한다. +13. 신규 화면이나 UI 변경은 먼저 기존 `sn` 화면과 공통 컴포넌트/레이아웃 패턴을 따르는 방향으로 설계한다. +14. `BaseLayout`, `NoFooterLayout`, `PageLayout`, `SearchDiv`, `SearchFormLeft/Right`, `ListDiv`, `UiTable`, `UiModal`, `Gis` 조합을 우선 재사용한다. +15. 새 UI 구조가 꼭 필요하면 기존 패턴으로 해결되지 않는 이유를 먼저 설명한다. --- @@ -166,7 +169,7 @@ Codex 작업 루트는 가능하면 `D:\workspace`로 잡고, 공통 시작 기 - 경로: `apps/sn/app/html` - 이 디렉터리는 퍼블리싱/정적 HTML/로직 검토 목적의 별도 영역으로 본다. -- 공식 HTML 시안은 기본적으로 `D:\workspace\IIPBS\docs\작업계획\<작업폴더>\` 아래 단일 `.html` 파일로 만든다. +- 공식 HTML 시안은 기본적으로 `COMMON_DOCS_ROOT/작업계획/<작업폴더>/` 아래 단일 `.html` 파일로 만든다. - `apps/sn/app/html` 작업은 사용자가 앱 라우트 기반 미리보기를 명시적으로 요청한 경우에만 사용한다. - `html` 하위 요청이라고 해서 `sn`의 실제 앱 코드 전체를 함께 수정하지 않는다. - `html` 작업과 실제 서비스 앱 로직 변경이 같이 필요해 보이면 먼저 분리해서 설명한다. @@ -247,18 +250,24 @@ pnpm turbo run build --filter="...[origin/main]" - `apps/sn/docs/작업계획/` - `apps/sn/docs/검증/` +공통 개발 흐름과 멀티에이전트 기준은 아래 공통 문서를 우선 참조한다. + +- `COMMON_DOCS_ROOT/기준문서/010-AI개발작업흐름.md` +- `COMMON_DOCS_ROOT/기준문서/011-멀티에이전트운영.md` + 1차 프로젝트 당시에는 AI 코딩 문서 체계를 사용하지 않았으므로, 신규 문서를 `phase2`처럼 별도 단계 폴더로 분리하지 않고 `apps/sn/docs/` 바로 아래 주제별 한글 폴더로 관리한다. 작업계획 문서는 아래 원칙으로 관리한다. -- `apps/sn/docs/작업계획/000-작업카드-템플릿.md`는 최상위에 유지한다. +- `apps/sn/docs/작업계획/작업카드-템플릿.md`는 최상위에 유지한다. - 실제 기능 문서는 `apps/sn/docs/작업계획/<기능ID-기능명>/` 폴더 단위로 관리한다. - 같은 기능의 FE 작업카드, 관련 메모, 추가 계획 문서는 같은 기능 폴더 안에 함께 둔다. - 역할이 있는 문서 파일명은 `<순번>-<역할>-<프로젝트>-<기능명>-<문서종류>.md` 형식을 우선 사용한다. - 날짜는 파일명 보조 정보로만 사용하고, `apps/sn/docs/작업계획/` 아래에 날짜 파일을 평면으로 계속 쌓지 않는다. - 기존 `md` 문서의 경로와 파일명은 사용자 요청이나 승인된 구조 개편 없이 임의로 바꾸지 않는다. - 기존 문서를 수정할 때는 현재 경로와 파일명을 유지한 채 내용을 갱신하는 것을 기본으로 한다. -- 공통 기준은 `D:\workspace\IIPBS\docs\작업계획\작업계획-폴더규칙.md`를 함께 참고한다. +- 공통 기준은 `COMMON_DOCS_ROOT/작업계획/작업계획-폴더규칙.md`를 함께 참고한다. +- UI/레이아웃 기준은 `FE_APP_DOCS_ROOT/기준문서/013-UI레이아웃가이드.md`를 함께 참고한다. 문서 작업 시에도 `sn` 범위를 벗어나는 앱 문서는 임의로 수정하지 않는다. diff --git a/apps/sn/docs/기준문서/001-프로젝트.md b/apps/sn/docs/기준문서/001-프로젝트.md index 0b06a37e5..3b076b4c9 100644 --- a/apps/sn/docs/기준문서/001-프로젝트.md +++ b/apps/sn/docs/기준문서/001-프로젝트.md @@ -17,6 +17,13 @@ ## 문서 기준 - 확인됨: `sn` 관련 2차사업 작업 문서는 `apps/sn/docs/` 바로 아래 주제별 한글 폴더로 관리한다. 근거: `apps/sn/AGENTS.md` -- 확인됨: AI 코딩 하네스 작업카드는 `apps/sn/docs/AI코딩하네스/작업계획/` 하위에 둔다. 근거: `apps/sn/AGENTS.md`, `apps/sn/docs/AI코딩하네스/개요.md` -- 확인됨: `phase2` 같은 별도 단계 폴더는 신규 문서 경로로 사용하지 않는다. 근거: `apps/sn/AGENTS.md`, `apps/sn/docs/AI코딩하네스/한글경로-영향.md` -- 추정: 백엔드와 함께 진행되는 기능은 백엔드 `docs/AI코딩하네스/작업계획/` 작업카드와 프론트 `apps/sn/docs/AI코딩하네스/작업계획/` 작업카드를 서로 참조하는 방식이 안전하다. 근거: `apps/sn/AGENTS.md`, 백엔드 `AGENTS.md` \ No newline at end of file +- 확인됨: 프론트 작업카드는 `apps/sn/docs/작업계획/작업카드-템플릿.md`를 기준으로 기능 폴더 아래에 작성한다. 근거: `apps/sn/AGENTS.md` +- 확인됨: `phase2` 같은 별도 단계 폴더는 신규 문서 경로로 사용하지 않는다. 근거: `apps/sn/AGENTS.md` +- 추정: 백엔드와 함께 진행되는 기능은 백엔드 `docs/작업계획/작업카드-템플릿.md`와 프론트 `apps/sn/docs/작업계획/작업카드-템플릿.md`를 각각 기능 폴더 아래에서 서로 참조하는 방식이 안전하다. 근거: `apps/sn/AGENTS.md`, 백엔드 `AGENTS.md` + +## 공통 동기화 기준 +- 새 채팅방에서 프론트 작업을 시작할 때는 아래 문서를 먼저 확인한다. + - `COMMON_DOCS_ROOT/작업계획/공통-작업동기화/002-공통-작업현황판.md` + - `COMMON_DOCS_ROOT/작업계획/공통-작업동기화/003-공통-파일점유현황.md` + - `COMMON_DOCS_ROOT/작업계획/공통-작업동기화/004-공통-결정로그.md` +- 같은 기능을 다른 채팅방에서 이어받는 경우 작업 ID, 현재 상태, 점유 파일을 먼저 갱신한다. diff --git a/apps/sn/docs/기준문서/007-멀티에이전트운영.md b/apps/sn/docs/기준문서/007-멀티에이전트운영.md index 1fb38be82..41406e465 100644 --- a/apps/sn/docs/기준문서/007-멀티에이전트운영.md +++ b/apps/sn/docs/기준문서/007-멀티에이전트운영.md @@ -1,45 +1,13 @@ -# 007. 멀티에이전트 운영 기준 +# 007. 멀티에이전트운영 ## 목적 -- `sn` 2차사업 프론트 작업을 BE, FE, QA 역할과 안전하게 연결하기 위한 기준임 -- AI_SAM `refactoring` 브랜치의 병렬 분업 구조를 참고하되, `sn` 앱과 허용된 `business_components` 범위를 우선함 +- 프론트 `sn` 앱 관점에서 멀티에이전트 작업 시 `fe` 역할이 따라야 하는 보조 기준을 정리한다. -## 프론트 역할 범위 -| 역할 | 주 책임 | 직접 수정 가능 범위 | 직접 수정 금지 범위 | -| --- | --- | --- | --- | -| FE | 화면, 컴포넌트, 훅, 상태관리, API 호출부, 타입 | `apps/sn/**`, `packages/common/business_components/**` | 타 앱, `business_components` 외 공통 패키지, 루트 `docs/**` | -| BE | API 계약, 요청/응답 DTO, 서비스 흐름, DB 영향 | 백엔드 저장소 기준 | 프론트 저장소 직접 수정은 별도 합류 기준 필요 | -| QA | 빌드, 브라우저 조작, 네트워크 응답, 회귀 포인트 확인 | 검증 문서, 결과보고 | 기능 코드 임의 수정 | +## 공통 기준 +- 공통 역할/합류/검증 기준은 `COMMON_DOCS_ROOT/기준문서\011-멀티에이전트운영.md`를 우선 따른다. +- 공통 개발 흐름은 `COMMON_DOCS_ROOT/기준문서\010-AI개발작업흐름.md`를 우선 따른다. -## FE 작업 시작 입력 -- 화면명과 진입 경로 -- 사용자 조작 흐름 -- 호출 API와 응답 필드 -- `apps/sn` 수정 파일 후보 -- `business_components` 수정 필요 여부 -- 허용 범위 밖 수정 필요 여부 -- 브라우저 검증 URL과 테스트 데이터 -- 완료 조건 - -## BE 합류 필요 조건 -- API 경로, method, 요청 파라미터, 응답 필드가 화면 요구사항과 불일치함 -- 오류 응답 구조 또는 HTTP 상태 코드 처리가 화면 요구사항과 충돌함 -- 저장/삭제/분석 실행이 DB 스키마 또는 데이터 보정에 의존함 -- 권한, 인증, 외부 연동 기준 확인이 필요함 - -## QA 합류 필요 조건 -- `sn` 빌드 확인 필요 -- `business_components` 변경으로 타 앱 영향 가능성이 있음 -- 브라우저 화면 조작 또는 네트워크 응답 확인이 완료 조건에 포함됨 -- 로그인, 권한, 테스트 데이터 제약으로 수동 확인 절차가 필요함 - -## 병렬 작업 기준 -- `apps/sn` 화면 구현과 백엔드 API 구현은 API 계약 초안이 고정된 뒤 병렬 진행 가능함 -- `business_components` 변경은 `sn` 화면 요구사항을 만족하는 최소 범위로 제한함 -- `@common/components`, `@common/network`, `@common/auth`, `@common/config` 문제가 원인으로 보여도 직접 수정하지 않고 이슈로 기록함 -- 같은 파일은 한 역할만 수정함 - -## 검증 보고 기준 -- FE는 빌드 명령, 화면 URL, 사용자 조작, 기대 결과, 네트워크 응답 확인 기준을 남김 -- BE는 API 요청/응답 예시와 DB 영향 여부를 남김 -- QA는 실행한 명령, 통과/실패 여부, 재현 절차, 남은 위험을 남김 \ No newline at end of file +## 프론트 보조 기준 +- `fe` 역할의 직접 수정 범위는 `apps/sn/**`, `packages/common/business_components/**` 이다. +- `@common/components`, `@common/network`, `@common/auth`, `@common/config`, 타 앱은 직접 수정하지 않는다. +- 프론트 결과 보고에는 화면 진입 경로, 사용자 조작, 빌드 결과, 네트워크 확인 기준, BE/QA 합류 필요사항을 포함한다. diff --git a/apps/sn/docs/기준문서/008-설계정책.md b/apps/sn/docs/기준문서/008-설계정책.md index 34016b5be..62e300a72 100644 --- a/apps/sn/docs/기준문서/008-설계정책.md +++ b/apps/sn/docs/기준문서/008-설계정책.md @@ -21,5 +21,5 @@ - 목록/상세/다운로드 API는 요청 파라미터와 응답 구조를 문서에 남긴다. ## 5. 공통 문서 참조 -- 공통 용어는 `D:\workspace\IIPBS\docs\기준문서\003-공통용어사전.md`를 우선 참조한다. -- 공통 환경/빌드 이슈는 `D:\workspace\IIPBS\docs\기준문서\004-환경빌드문제대응.md`를 함께 본다. +- 공통 용어는 `COMMON_DOCS_ROOT/기준문서/003-공통용어사전.md`를 우선 참조한다. +- 공통 환경/빌드 이슈는 `COMMON_DOCS_ROOT/기준문서/004-환경빌드문제대응.md`를 함께 본다. diff --git a/apps/sn/docs/기준문서/010-용어사전.md b/apps/sn/docs/기준문서/010-용어사전.md index 0ff835c76..33ced4ae7 100644 --- a/apps/sn/docs/기준문서/010-용어사전.md +++ b/apps/sn/docs/기준문서/010-용어사전.md @@ -5,9 +5,9 @@ - 같은 의미에 여러 이름이 섞여 있는 부분을 드러내고, 작업 시 어떤 이름을 기준으로 삼아야 하는지 고정한다. ## 2. 참조 기준 -- 공통 용어: `D:/workspace/docs/기준문서/003-공통용어사전.md` -- 백엔드 원본 용어: `D:/workspace/IIPBS/sewerage-service/docs/기준문서/004-용어.csv` -- 프론트 네이밍 규칙: `D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/docs/기준문서/009-네이밍규칙.md` +- 공통 용어: `COMMON_DOCS_ROOT/기준문서/003-공통용어사전.md` +- 백엔드 원본 용어: `BE_DOCS_ROOT/기준문서/004-용어.csv` +- 프론트 네이밍 규칙: `FE_APP_DOCS_ROOT/기준문서/009-네이밍규칙.md` ## 3. 정리 원칙 - 화면 라벨은 사용자에게 익숙한 한글 업무 용어를 사용한다. diff --git a/apps/sn/docs/기준문서/011-환경빌드문제대응.md b/apps/sn/docs/기준문서/011-환경빌드문제대응.md index c9620246e..4f7f39cc2 100644 --- a/apps/sn/docs/기준문서/011-환경빌드문제대응.md +++ b/apps/sn/docs/기준문서/011-환경빌드문제대응.md @@ -1,11 +1,11 @@ # 011. 환경빌드문제대응 ## 1. 공통 기준 -- 공통 환경/빌드 이슈는 `D:\workspace\IIPBS\docs\기준문서\004-환경빌드문제대응.md`를 함께 본다. +- 공통 환경/빌드 이슈는 `COMMON_DOCS_ROOT/기준문서/004-환경빌드문제대응.md`를 함께 본다. ## 2. 프론트 기본 점검 - `pnpm -v`, `node -v` 버전을 먼저 확인한다. -- 작업 루트는 기본적으로 `D:\workspace\IIPBS-FRONTEND\frontend`를 사용한다. +- 작업 루트는 기본적으로 `FE_REPO_ROOT/`를 사용한다. - 의존성 문제 의심 시 `pnpm install` 상태와 lockfile 변경 여부를 함께 본다. ## 3. 기본 빌드 명령 diff --git a/apps/sn/docs/기준문서/013-UI레이아웃가이드.md b/apps/sn/docs/기준문서/013-UI레이아웃가이드.md new file mode 100644 index 000000000..5bfb9c941 --- /dev/null +++ b/apps/sn/docs/기준문서/013-UI레이아웃가이드.md @@ -0,0 +1,90 @@ +# 013. UI레이아웃가이드 + +## 1. 목적 +- `sn` 앱 화면 작업 시 기존 하수관로시스템 UI 패턴을 그대로 따르기 위한 구현 기준을 정리한다. +- 이후 기능 추가나 화면 개선 시 “무엇을 재사용해야 하는지”를 빠르게 판단할 수 있게 한다. + +## 2. 이번 분석에서 확인한 기준 화면 +- 업무형 조회/등록 화면 + - `apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx` + - `apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx` + - `apps/sn/app/(with-base-layout)/ii/rdii/page.tsx` +- 지도형 화면 + - `apps/sn/app/(map-layout)/map/page.tsx` +- 공통 레이아웃/구성 + - `packages/common/components/src/ui/layout/base.tsx` + - `packages/common/components/src/ui/layout/nofooter.tsx` + - `apps/sn/src/shared/ui/page-layout.tsx` + - `packages/common/components/src/ui/layout/common.css` +- 공통 비즈니스 컴포넌트 + - `packages/common/business_components/src/ui/search_form/index.tsx` + - `packages/common/business_components/src/ui/search_form_left/index.tsx` + - `packages/common/business_components/src/ui/search_form_right/index.tsx` + - `packages/common/business_components/src/ui/list_div/index.tsx` + - `packages/common/business_components/src/ui/table/index.tsx` + - `packages/common/business_components/src/ui/map/Gis.js` + +## 3. 레이아웃 선택 기준 + +### 3.1 업무형 화면 +- 대상: 등록현황, 분석 조건 입력, 조회/목록/업로드/모달 중심 화면 +- 기본 구조: + - `BaseLayout` + - `PageLayout` + - 상단 검색영역: `SearchDiv` + - 좌측 검색조건: `SearchFormLeft` + - 우측 액션 버튼: `SearchFormRight` + - 본문 카드: `ListDiv` + - 목록: `UiTable` + - 팝업: `UiModal` + +### 3.2 지도형 화면 +- 대상: 레이어 트리, 피처 선택, 상세 팝업, 검색 모달, 지도 도구 중심 화면 +- 기본 구조: + - `NoFooterLayout` + - `Gis` + - `ProcSearchBar` 또는 지도 전용 검색 UI + - 우측 상세 팝업: 지도 피처 상세 컴포넌트 + - 레이어 트리/툴바/줌레벨: `Gis` 내부 패턴 유지 + +## 4. 시각 패턴 +- 기본 카드 스타일은 `common.css`의 `card`, `card-border`, `card-header`, `card-footer`를 우선 따른다. +- 글자 크기는 업무 화면에서 `text-2xl`, `text-xl` 계열을 많이 사용하므로, 신규 화면도 이 리듬을 우선 유지한다. +- 버튼은 화면마다 새 변형을 만들기보다 기존 `Button` 스타일과 색상 조합을 우선 사용한다. +- 리스트/테이블/검색영역은 유리질 카드나 대시보드형 실험적 스타일보다 현재 반투명 카드 + 경계선 + 표 구조를 우선 유지한다. + +## 5. 검색영역 규칙 +- 검색영역은 상단 1행 구조를 기본으로 한다. +- 왼쪽에는 조회 조건, 오른쪽에는 `조회`, `등록`, `다운로드` 같은 액션 버튼을 둔다. +- 검색 필드는 `Label + Input/DatePicker/Select` 조합을 유지한다. +- 새 화면도 가능하면 “등록기간/조건입력/조회버튼” 패턴을 크게 벗어나지 않는다. + +## 6. 목록/테이블 규칙 +- 데이터 목록은 `UiTable`을 우선 사용한다. +- 카드 헤더에는 제목과 총 건수, 우측 액션 버튼을 배치한다. +- 페이징, 선택 초기화, 셀 클릭 동작은 기존 `UiTable` 사용 패턴을 따른다. +- 새 테이블을 만들 때는 먼저 `headlist` 설정 파일로 컬럼 구조를 분리하는 방식을 검토한다. + +## 7. 모달/상세 규칙 +- 상세 보기, 등록, 엑셀 미리보기는 `UiModal` 패턴을 우선 사용한다. +- 지도형 화면 상세는 우측 고정 패널 또는 기존 지도 상세 팝업 위치를 우선 유지한다. +- 같은 기능에서 목록 선택 후 상세/모달이 열리는 흐름은 기존 `oldPp`, `floodPre`, `map` 패턴을 참고한다. + +## 8. 상태관리/API 연동 규칙 +- 화면 내부 로컬 상태로 충분한 것은 로컬 state를 우선 사용한다. +- 사용자/공통 코드/메뉴/검색 모달 상태는 기존 store 패턴을 우선 따른다. +- API 호출은 기존 `searchData`, `getData`, `postData`, `deleteData` 계열을 우선 사용한다. +- 프론트에서 백엔드 응답 구조를 임의 가정하지 않고, 현재 화면이 실제로 쓰는 필드 기준으로 맞춘다. + +## 9. 새 화면 설계 시 체크리스트 +- 이 화면이 업무형인지 지도형인지 먼저 구분했는가 +- 기존 레이아웃(`BaseLayout` / `NoFooterLayout`) 중 하나를 선택했는가 +- 검색영역을 `SearchDiv + SearchFormLeft/Right` 패턴으로 풀 수 있는가 +- 목록을 `UiTable`로 처리할 수 있는가 +- 상세/등록 흐름을 `UiModal` 또는 기존 상세 패널 패턴으로 맞출 수 있는가 +- 새 스타일을 만들기 전에 `@common/components`, `@common/business_components` 재사용 가능성을 확인했는가 + +## 10. 예외 처리 원칙 +- 기존 패턴으로 해결되지 않는 요구사항일 때만 새 UI 구조를 제안한다. +- 그 경우에도 “왜 기존 패턴으로 해결되지 않는지”를 먼저 설계 문서에 남긴다. +- 예외 구조를 만들 때는 이후 다른 화면에도 재사용 가능한지 같이 검토한다. diff --git a/apps/sn/docs/작업계획/000-작업카드-템플릿.md b/apps/sn/docs/작업계획/작업카드-템플릿.md similarity index 85% rename from apps/sn/docs/작업계획/000-작업카드-템플릿.md rename to apps/sn/docs/작업계획/작업카드-템플릿.md index 0527d861e..86cd29f5d 100644 --- a/apps/sn/docs/작업계획/000-작업카드-템플릿.md +++ b/apps/sn/docs/작업계획/작업카드-템플릿.md @@ -10,6 +10,12 @@ - 담당 범위: - 근거 문서: +## 1-1. 공통 동기화 확인 +- 공통 작업현황판 확인 여부: +- 파일점유현황 확인 여부: +- 결정로그 확인 여부: +- 기존 진행 중 작업과의 관계: + ## 2. 작업 목적 - 확인 필요: @@ -31,6 +37,7 @@ - 충돌 주의 파일: - 인수인계 메모: +- 파일점유 등록 여부: ## 4. 화면 흐름 - 확인 필요: 진입 경로 @@ -57,6 +64,11 @@ - 병렬 진행 가능 항목: - 순차 진행 필요 항목: +## 6-2. 채팅방 간 충돌 방지 +- 같은 기능을 다른 채팅방에서 진행 중인지: +- 겹치는 파일 또는 폴더: +- 점유 충돌 시 처리 방식: + ## 7. 검증 방법 - 확인 필요: 빌드 명령 - 확인 필요: 브라우저 URL @@ -71,4 +83,4 @@ - 확인 필요: ## 10. 완료 기준 -- 확인 필요: \ No newline at end of file +- 확인 필요: From 3293893b7f4247d2aa4637c32e288871f8c23d82 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Mon, 27 Apr 2026 16:42:47 +0900 Subject: [PATCH 05/11] =?UTF-8?q?merge=EC=A0=84=20md=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../page.tsx | 0 apps/sn/docs/AI코딩하네스/개요.md | 41 --- .../001-노후관로관리-브라우저-네트워크-증적.md | 62 ----- .../AI코딩하네스/기준문서/001-프로젝트.md | 22 -- .../docs/AI코딩하네스/기준문서/002-화면맵.md | 19 -- .../docs/AI코딩하네스/기준문서/003-API사용.md | 24 -- .../AI코딩하네스/기준문서/004-컴포넌트정책.md | 22 -- .../AI코딩하네스/기준문서/005-상태관리정책.md | 19 -- .../AI코딩하네스/기준문서/006-검증정책.md | 34 --- .../기준문서/007-멀티에이전트운영.md | 45 ---- .../AI코딩하네스/기준문서/008-문서구조기준.md | 160 ------------ .../기준문서/009-AI개발작업흐름.md | 244 ------------------ .../AI코딩하네스/보고/2026-04-16/개선사항.md | 5 - .../AI코딩하네스/보고/2026-04-16/결과보고.md | 51 ---- .../docs/AI코딩하네스/보고/2026-04-16/이슈.md | 8 - .../AI코딩하네스/보고/2026-04-16/작업목록.md | 10 - .../docs/AI코딩하네스/보고/2026-04-16/제안.md | 5 - .../AI코딩하네스/보고/2026-04-17/결과보고.md | 45 ---- apps/sn/docs/AI코딩하네스/보고/개요.md | 16 -- .../작업계획/000-작업카드-템플릿.md | 74 ------ .../작업계획/001-SN-P2-노후관로관리-작업카드.md | 55 ---- apps/sn/docs/AI코딩하네스/작업계획/개요.md | 17 -- apps/sn/docs/AI코딩하네스/작업목록.md | 19 -- apps/sn/docs/AI코딩하네스/한글경로-영향.md | 56 ---- apps/sn/docs/README.md | 40 --- apps/sn/docs/노후관로관리/00-범위.md | 32 --- .../01-기존화면기반-컴포넌트분해.md | 158 ------------ .../노후관로관리/02-컴포넌트별-작업목록.md | 240 ----------------- .../docs/노후관로관리/03-API응답필드매핑표.md | 73 ------ apps/sn/docs/노후관로관리/04-화면흐름.md | 40 --- apps/sn/docs/노후관로관리/05-검증계획.md | 39 --- apps/sn/docs/노후관로관리/06-검증결과.md | 114 -------- .../노후관로관리/07-백엔드-착수프롬프트.md | 55 ---- apps/sn/docs/노후관로관리/99-미확인사항.md | 27 -- apps/sn/docs/노후관로관리/README.md | 32 --- 35 files changed, 1903 deletions(-) rename apps/sn/app/(map-layout)/map/{aged-pipe-popup => agedPipePopup}/page.tsx (100%) delete mode 100644 apps/sn/docs/AI코딩하네스/개요.md delete mode 100644 apps/sn/docs/AI코딩하네스/검증/001-노후관로관리-브라우저-네트워크-증적.md delete mode 100644 apps/sn/docs/AI코딩하네스/기준문서/001-프로젝트.md delete mode 100644 apps/sn/docs/AI코딩하네스/기준문서/002-화면맵.md delete mode 100644 apps/sn/docs/AI코딩하네스/기준문서/003-API사용.md delete mode 100644 apps/sn/docs/AI코딩하네스/기준문서/004-컴포넌트정책.md delete mode 100644 apps/sn/docs/AI코딩하네스/기준문서/005-상태관리정책.md delete mode 100644 apps/sn/docs/AI코딩하네스/기준문서/006-검증정책.md delete mode 100644 apps/sn/docs/AI코딩하네스/기준문서/007-멀티에이전트운영.md delete mode 100644 apps/sn/docs/AI코딩하네스/기준문서/008-문서구조기준.md delete mode 100644 apps/sn/docs/AI코딩하네스/기준문서/009-AI개발작업흐름.md delete mode 100644 apps/sn/docs/AI코딩하네스/보고/2026-04-16/개선사항.md delete mode 100644 apps/sn/docs/AI코딩하네스/보고/2026-04-16/결과보고.md delete mode 100644 apps/sn/docs/AI코딩하네스/보고/2026-04-16/이슈.md delete mode 100644 apps/sn/docs/AI코딩하네스/보고/2026-04-16/작업목록.md delete mode 100644 apps/sn/docs/AI코딩하네스/보고/2026-04-16/제안.md delete mode 100644 apps/sn/docs/AI코딩하네스/보고/2026-04-17/결과보고.md delete mode 100644 apps/sn/docs/AI코딩하네스/보고/개요.md delete mode 100644 apps/sn/docs/AI코딩하네스/작업계획/000-작업카드-템플릿.md delete mode 100644 apps/sn/docs/AI코딩하네스/작업계획/001-SN-P2-노후관로관리-작업카드.md delete mode 100644 apps/sn/docs/AI코딩하네스/작업계획/개요.md delete mode 100644 apps/sn/docs/AI코딩하네스/작업목록.md delete mode 100644 apps/sn/docs/AI코딩하네스/한글경로-영향.md delete mode 100644 apps/sn/docs/README.md delete mode 100644 apps/sn/docs/노후관로관리/00-범위.md delete mode 100644 apps/sn/docs/노후관로관리/01-기존화면기반-컴포넌트분해.md delete mode 100644 apps/sn/docs/노후관로관리/02-컴포넌트별-작업목록.md delete mode 100644 apps/sn/docs/노후관로관리/03-API응답필드매핑표.md delete mode 100644 apps/sn/docs/노후관로관리/04-화면흐름.md delete mode 100644 apps/sn/docs/노후관로관리/05-검증계획.md delete mode 100644 apps/sn/docs/노후관로관리/06-검증결과.md delete mode 100644 apps/sn/docs/노후관로관리/07-백엔드-착수프롬프트.md delete mode 100644 apps/sn/docs/노후관로관리/99-미확인사항.md delete mode 100644 apps/sn/docs/노후관로관리/README.md diff --git a/apps/sn/app/(map-layout)/map/aged-pipe-popup/page.tsx b/apps/sn/app/(map-layout)/map/agedPipePopup/page.tsx similarity index 100% rename from apps/sn/app/(map-layout)/map/aged-pipe-popup/page.tsx rename to apps/sn/app/(map-layout)/map/agedPipePopup/page.tsx diff --git a/apps/sn/docs/AI코딩하네스/개요.md b/apps/sn/docs/AI코딩하네스/개요.md deleted file mode 100644 index 1f26ebdd2..000000000 --- a/apps/sn/docs/AI코딩하네스/개요.md +++ /dev/null @@ -1,41 +0,0 @@ -# sn 2차사업 하네스 운영 문서 - -## 목적 -이 문서는 하수관로 운영관리 시스템 `sn` 프론트엔드 2차사업 작업을 기능 단위로 안전하게 진행하기 위한 경량 하네스 기준이다. - -AI_SAM `refactoring` 브랜치의 하네스 운영 방식 중 우리 프로젝트에 필요한 부분만 수용한다. - -- 작업카드로 범위와 검증 방법을 먼저 고정한다. -- 실행 기록을 남겨 중간에 작업자가 바뀌어도 이어서 진행할 수 있게 한다. -- `sn` 앱과 허용된 공통 비즈니스 컴포넌트 범위를 벗어나는 수정을 방지한다. -- 백엔드 API 계약과 화면 설계 근거를 연결해서 브라우저 검증까지 추적한다. - -## 적용 범위 -- 확인됨: `apps/sn/**`은 이번 작업의 주 프론트엔드 범위이다. 근거: `apps/sn/AGENTS.md` -- 확인됨: `packages/common/business_components/**`만 수정 가능한 공통 비즈니스 컴포넌트 범위이다. 근거: `apps/sn/AGENTS.md` -- 확인됨: 그 외 앱과 공통 패키지는 기본적으로 읽기/분석 대상이며 직접 수정하지 않는다. 근거: `apps/sn/AGENTS.md` -- 추정: 기능 구현 시 백엔드 `sewerage-service` API와 함께 브라우저 동작을 확인해야 한다. 근거: `apps/sn/AGENTS.md` - -## 문서 구조 -- `작업목록.md`: 2차사업 프론트엔드 작업 목록과 상태 -- `기준문서/`: 프로젝트, 화면, API, 컴포넌트, 상태관리, 검증 기준 -- `작업계획/`: 작업카드 템플릿과 기능별 실행 계획 -- `보고/`: 일자별 작업 결과, 이슈, 제안, 개선 기록 - -## 멀티에이전트 기준 -- `sn` 작업에서 BE/FE/QA 역할 분리와 합류 기준은 `기준문서/007-멀티에이전트운영.md`를 따른다. -- 백엔드 API, DB, 서비스 흐름과 함께 작업하는 경우 백엔드 기준문서 `D:\workspace\IIPBS\sewerage-service\docs\AI코딩하네스\기준문서\009-멀티에이전트운영.md`도 함께 확인한다. - -## 운영 원칙 -- 기능 단위로 작업카드를 먼저 작성한다. -- 수정 전 영향 범위와 롤백 방법을 문서에 남긴다. -- API 계약 변경이 필요한 경우 프론트에서 임의로 처리하지 않고 백엔드 작업으로 분리한다. -- `packages/common/business_components` 수정은 `sn` 화면에서 실제로 재사용되는 범위로 제한한다. -- 브라우저 테스트가 필요한 작업은 URL, 사용자 조작, 기대 결과를 함께 기록한다. - -## 사용 순서 -1. `기준문서/` 문서에서 현재 기준을 확인한다. -2. `작업목록.md`에서 작업 상태를 확인한다. -3. `작업계획/000-작업카드-템플릿.md`를 복사해 기능별 계획을 작성한다. -4. 구현 또는 수정 후 `보고/YYYY-MM-DD/`에 결과를 기록한다. -5. 외부 범위 이슈는 `이슈.md`, 개선 제안은 `제안.md`, 반복 개선점은 `개선사항.md`에 남긴다. diff --git a/apps/sn/docs/AI코딩하네스/검증/001-노후관로관리-브라우저-네트워크-증적.md b/apps/sn/docs/AI코딩하네스/검증/001-노후관로관리-브라우저-네트워크-증적.md deleted file mode 100644 index 5997c0649..000000000 --- a/apps/sn/docs/AI코딩하네스/검증/001-노후관로관리-브라우저-네트워크-증적.md +++ /dev/null @@ -1,62 +0,0 @@ -# 노후관로관리 브라우저 수동 검증 체크리스트 및 네트워크 증적 양식 - -## 1. 대상 화면 -- 화면명: 노후관로정비사업 / 노후관로관리 -- 우선 검증 라우트: `/oper/oldPp` -- 관련 구현: `apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx` -- 관련 BE API: `GET /sewerage-service/api/v1/dcrpPpl` -- 관련 요구사항 근거: `apps/sn/app/html/06. 노후관로관리_시나리오_v1.0.md` - -## 2. 사전 조건 -| 항목 | 값 | -| --- | --- | -| 검증 일시 | | -| 검증자 | | -| 브라우저 | Chrome / Edge | -| FE URL | | -| BE URL | | -| 로그인 계정/권한 | | -| 테스트 데이터 기준 | | - -## 3. 수동 검증 체크리스트 -| 번호 | 단계 | 조작 | 기대 결과 | 결과 | 증적 | -| --- | --- | --- | --- | --- | --- | -| 1 | 화면 진입 | `/oper/oldPp` 또는 메뉴로 진입 | 검색 영역, 조회/등록 버튼, 목록 영역이 표시된다. | | 스크린샷 | -| 2 | 초기 조회 | 화면 로드 후 기본 조회 완료 대기 | 로딩이 종료되고 목록 또는 빈 목록이 표시된다. | | Network | -| 3 | 기간 조회 | 등록기간을 변경하고 조회 클릭 | `fromRegDt`, `toRegDt`가 Query String에 반영된다. | | Network | -| 4 | 명칭 조회 | 파일명/사업명 입력 후 Enter 또는 조회 클릭 | `dcrpPplMtncBizNm`이 Query String에 반영된다. | | Network | -| 5 | 페이지 이동 | 페이지네이션 클릭 | `page`가 변경되고 목록이 갱신된다. | | Network | -| 6 | 행 선택 | 목록 행 클릭 | 선택 행의 첨부 식별자가 상태에 반영되고 표 모달이 열린다. | | 스크린샷 | -| 7 | 데이터 없음 | 결과가 없는 조건으로 조회 | 오류 없이 빈 목록/0건 상태가 표시된다. | | 스크린샷 | -| 8 | 오류 응답 | BE 중지 또는 권한 오류 환경에서 조회 | 무한 로딩 없이 오류 로그/안내를 확인한다. | | Console/Network | - -## 4. 네트워크 응답 증적 양식 -| 항목 | 기록 | -| --- | --- | -| Request Name | | -| Method | `GET` | -| Request URL | `/sewerage-service/api/v1/dcrpPpl` | -| Query String | `fromRegDt=`, `toRegDt=`, `dcrpPplMtncBizNm=`, `auth=`, `page=`, `size=` | -| Status Code | | -| Response Headers 주요값 | `content-type`, `cache-control` | -| Response Body 주요 필드 | `content`, `totalElements`, `number`, `size`, `sort` | -| content 첫 행 주요 필드 | `dcrpPplMtncBizSn`, `mngYr`, `mngQu`, `atflId`, `dcrpPplMtncBizNm`, `rgtrNm` | -| 기대 결과 | Page 구조가 유지되고 JSON 파싱 오류가 없어야 한다. | -| 실제 결과 | | -| HAR/스크린샷 경로 | | - -## 5. 네트워크 증적 예시 -```text -GET /sewerage-service/api/v1/dcrpPpl?fromRegDt=2026-04-10&toRegDt=2026-04-17&dcrpPplMtncBizNm=&auth=&page=0&size=10 -Status: 200 -Response check: -- content: array -- totalElements: number -- number: 0 -- size: 10 -``` - -## 6. 이슈 기록 양식 -| 번호 | 증상 | 재현 단계 | 기대 | 실제 | 우선순위 | 담당 | 링크 | -| --- | --- | --- | --- | --- | --- | --- | --- | -| 1 | | | | | | | | diff --git a/apps/sn/docs/AI코딩하네스/기준문서/001-프로젝트.md b/apps/sn/docs/AI코딩하네스/기준문서/001-프로젝트.md deleted file mode 100644 index be374d764..000000000 --- a/apps/sn/docs/AI코딩하네스/기준문서/001-프로젝트.md +++ /dev/null @@ -1,22 +0,0 @@ -# 프로젝트 기준 - -## 프로젝트 식별 -- 확인됨: 프론트엔드 저장소는 pnpm workspace와 Turborepo 기반 모노레포이다. 근거: `apps/sn/AGENTS.md` -- 확인됨: 하수관로 운영관리 시스템 프론트엔드 작업 대상 앱은 `apps/sn`이다. 근거: `apps/sn/AGENTS.md` -- 확인됨: 직접 수정 가능한 공통 패키지는 `packages/common/business_components`이다. 근거: `apps/sn/AGENTS.md` -- 확인됨: 백엔드 기준 프로젝트는 `sewerage-service`이다. 근거: `apps/sn/AGENTS.md` - -## 작업 목적 -- 확인됨: 현재 목적은 리팩토링 전용이 아니라 하수관로 운영관리 시스템 2차사업 고도화 개발이다. 근거: `apps/sn/AGENTS.md` -- 확인됨: 기존 `sn` 화면의 안정성을 유지하면서 2차 요구사항에 맞는 화면 개발, 기능 확장, 백엔드 연동 보완을 수행한다. 근거: `apps/sn/AGENTS.md` - -## 직접 수정 허용 범위 -- 확인됨: `apps/sn/**`은 직접 수정 가능하다. 근거: `apps/sn/AGENTS.md` -- 확인됨: `packages/common/business_components/**`은 직접 수정 가능하다. 근거: `apps/sn/AGENTS.md` -- 확인됨: 위 두 경로 밖은 기본적으로 읽기 전용이며, 수정 필요성이 있으면 먼저 문제 원인과 영향 범위를 문서화한다. 근거: `apps/sn/AGENTS.md` - -## 문서 기준 -- 확인됨: `sn` 관련 2차사업 작업 문서는 `apps/sn/docs/` 바로 아래 주제별 한글 폴더로 관리한다. 근거: `apps/sn/AGENTS.md` -- 확인됨: AI 코딩 하네스 작업카드는 `apps/sn/docs/AI코딩하네스/작업계획/` 하위에 둔다. 근거: `apps/sn/AGENTS.md`, `apps/sn/docs/AI코딩하네스/개요.md` -- 확인됨: `phase2` 같은 별도 단계 폴더는 신규 문서 경로로 사용하지 않는다. 근거: `apps/sn/AGENTS.md`, `apps/sn/docs/AI코딩하네스/한글경로-영향.md` -- 추정: 백엔드와 함께 진행되는 기능은 백엔드 `docs/AI코딩하네스/작업계획/` 작업카드와 프론트 `apps/sn/docs/AI코딩하네스/작업계획/` 작업카드를 서로 참조하는 방식이 안전하다. 근거: `apps/sn/AGENTS.md`, 백엔드 `AGENTS.md` diff --git a/apps/sn/docs/AI코딩하네스/기준문서/002-화면맵.md b/apps/sn/docs/AI코딩하네스/기준문서/002-화면맵.md deleted file mode 100644 index b7b8868e4..000000000 --- a/apps/sn/docs/AI코딩하네스/기준문서/002-화면맵.md +++ /dev/null @@ -1,19 +0,0 @@ -# 화면 기준 - -## 화면 분석 원칙 -- 확인됨: `apps/sn` 내부 화면과 라우팅, 컴포넌트를 우선 분석한다. 근거: `apps/sn/AGENTS.md` -- 확인됨: `apps/sn/app/html`은 퍼블리싱, 정적 HTML, 로직 검토 목적의 별도 영역으로 본다. 근거: `apps/sn/AGENTS.md`, `apps/sn/app/html/AGENTS.md` -- 확인됨: `html` 하위 요청만으로 실제 `sn` 앱 코드 전체를 함께 수정하지 않는다. 근거: `apps/sn/AGENTS.md`, `apps/sn/app/html/AGENTS.md` - -## 화면 작업카드에 남길 항목 -- 확인 필요: 화면명 -- 확인 필요: 라우트 또는 접근 URL -- 확인 필요: 주요 사용자 조작 -- 확인 필요: 조회, 등록, 수정, 삭제, 엑셀, 지도, 팝업 등 기능 구분 -- 확인 필요: 호출 API -- 확인 필요: 사용 컴포넌트와 공통 비즈니스 컴포넌트 여부 -- 확인 필요: 브라우저 검증 절차 - -## 확인 필요 항목 -- 확인 필요: 2차사업 최종 화면명과 메뉴 트리는 화면설계서 기준으로 확정해야 한다. 근거: 2차사업 화면설계서 -- 확인 필요: 실제 운영 메뉴 권한과 라우팅 정책은 실행 환경에서 확인해야 한다. 근거: `apps/sn` 소스, 브라우저 검증 diff --git a/apps/sn/docs/AI코딩하네스/기준문서/003-API사용.md b/apps/sn/docs/AI코딩하네스/기준문서/003-API사용.md deleted file mode 100644 index 1c3471116..000000000 --- a/apps/sn/docs/AI코딩하네스/기준문서/003-API사용.md +++ /dev/null @@ -1,24 +0,0 @@ -# API 연동 기준 - -## 기본 원칙 -- 확인됨: 프론트엔드는 백엔드 `sewerage-service` API와 연동한다. 근거: `apps/sn/AGENTS.md` -- 확인됨: 백엔드 응답 구조나 API 계약을 프론트엔드에서 임의로 변경했다고 가정하지 않는다. 근거: `apps/sn/AGENTS.md` -- 확인됨: API 계약 변경이 필요하면 백엔드 작업으로 분리하고 영향 범위를 먼저 정리한다. 근거: `apps/sn/AGENTS.md` - -## 응답 처리 기준 -- 확인됨: 백엔드 공통 응답은 `status`, `message`, `data`, `timestamp` 형태로 내려오는 API가 존재한다. 근거: 사용자 확인 응답 예시, 백엔드 `docs/응답표준화/02-백엔드응답규칙.md` -- 추정: `ResponseEntity` 적용 여부는 프론트엔드 수신 JSON 구조보다 HTTP 상태 코드와 헤더 제어에 더 직접적인 영향을 준다. 근거: 백엔드 응답 표준화 문서 -- 확인 필요: 각 화면의 API 클라이언트가 공통 응답 래퍼를 어디서 해제하는지는 `apps/sn` 소스에서 화면별로 확인해야 한다. 근거: `apps/sn` 소스 분석 필요 - -## 작업카드에 남길 항목 -- 확인 필요: HTTP method -- 확인 필요: URL -- 확인 필요: query/body 파라미터 -- 확인 필요: 정상 응답 예시 -- 확인 필요: 오류 응답 처리 방식 -- 확인 필요: 화면에서 사용하는 `data` 경로 -- 확인 필요: 로딩, 빈 결과, 권한 오류, 서버 오류 표시 방식 - -## 금지 기준 -- 확인됨: 프론트엔드에서 백엔드 API 계약 변경을 전제로 구현하지 않는다. 근거: `apps/sn/AGENTS.md` -- 확인됨: 백엔드 응답을 임시로 우회하기 위한 화면 전용 하드코딩을 추가하지 않는다. 근거: `apps/sn/AGENTS.md` diff --git a/apps/sn/docs/AI코딩하네스/기준문서/004-컴포넌트정책.md b/apps/sn/docs/AI코딩하네스/기준문서/004-컴포넌트정책.md deleted file mode 100644 index 51c76c4c1..000000000 --- a/apps/sn/docs/AI코딩하네스/기준문서/004-컴포넌트정책.md +++ /dev/null @@ -1,22 +0,0 @@ -# 컴포넌트 기준 - -## 수정 범위 -- 확인됨: `apps/sn` 내부 컴포넌트는 직접 수정 가능하다. 근거: `apps/sn/AGENTS.md` -- 확인됨: 공통 비즈니스 컴포넌트는 `packages/common/business_components`만 직접 수정 가능하다. 근거: `apps/sn/AGENTS.md` -- 확인됨: `packages/common/components`, `packages/common/auth`, `packages/common/network` 등은 직접 수정하지 않는다. 근거: `apps/sn/AGENTS.md` - -## 컴포넌트 변경 원칙 -- 확인됨: `sn` 요구사항을 만족하기 위한 최소 범위로 수정한다. 근거: `apps/sn/AGENTS.md` -- 확인됨: 공통 컴포넌트 수정은 `sn`에 실제로 필요한 범위로 제한한다. 근거: `apps/sn/AGENTS.md` -- 추정: 여러 화면에서 반복되는 `sn` 전용 로직은 먼저 `apps/sn` 내부 공통화로 해결하고, 여러 업무 화면에서 공유되는 비즈니스 UI일 때만 `business_components`를 검토하는 것이 안전하다. 근거: `apps/sn/AGENTS.md` - -## 작업카드에 남길 항목 -- 확인 필요: 신규 또는 수정 컴포넌트 경로 -- 확인 필요: 호출하는 화면 경로 -- 확인 필요: props 변경 여부 -- 확인 필요: 기존 화면 영향 여부 -- 확인 필요: 공통 컴포넌트 수정 필요 여부 -- 확인 필요: 브라우저 확인 화면 - -## 외부 범위 이슈 -- 확인됨: 권한 밖 공통 패키지 수정이 필요해 보이면 직접 고치지 않고 문제 원인, 수정 필요 경로, `sn` 영향 범위, 타 앱 영향 가능성을 문서화한다. 근거: `apps/sn/AGENTS.md` diff --git a/apps/sn/docs/AI코딩하네스/기준문서/005-상태관리정책.md b/apps/sn/docs/AI코딩하네스/기준문서/005-상태관리정책.md deleted file mode 100644 index a6192d573..000000000 --- a/apps/sn/docs/AI코딩하네스/기준문서/005-상태관리정책.md +++ /dev/null @@ -1,19 +0,0 @@ -# 상태관리와 화면 흐름 기준 - -## 상태관리 원칙 -- 확인됨: 기존 `sn` 화면 흐름과 사용자 동작을 가능한 유지하면서 2차 요구사항을 반영한다. 근거: `apps/sn/AGENTS.md` -- 확인됨: 복잡한 컴포넌트, 탭 분기, 폼 처리, 데이터 변환, 지도 연동, 공통 컴포넌트 조합 로직은 함수 또는 메서드 단위 주석을 상세히 작성한다. 근거: `apps/sn/AGENTS.md` -- 추정: 화면별 상태는 기존 패턴을 우선 따르고, 새 전역 상태 구조는 필요성이 확인된 경우에만 검토해야 한다. 근거: `apps/sn/AGENTS.md` - -## 화면 흐름 기록 항목 -- 확인 필요: 초기 진입 시 호출되는 API -- 확인 필요: 검색 조건 초기값 -- 확인 필요: 페이지네이션 또는 무한스크롤 방식 -- 확인 필요: 선택 행 또는 상세 패널 상태 -- 확인 필요: 등록, 수정, 삭제 후 재조회 방식 -- 확인 필요: 팝업, 모달, 지도, 파일, 엑셀 흐름 -- 확인 필요: 에러 메시지 표시 위치 - -## 주의 사항 -- 확인됨: 요구사항 근거 없는 기능 추가는 금지한다. 근거: `apps/sn/AGENTS.md` -- 확인됨: 다른 앱에서 사용하는 공통 상태 로직을 `sn` 문제 해결 목적으로 직접 바꾸지 않는다. 근거: `apps/sn/AGENTS.md` diff --git a/apps/sn/docs/AI코딩하네스/기준문서/006-검증정책.md b/apps/sn/docs/AI코딩하네스/기준문서/006-검증정책.md deleted file mode 100644 index 0796edd18..000000000 --- a/apps/sn/docs/AI코딩하네스/기준문서/006-검증정책.md +++ /dev/null @@ -1,34 +0,0 @@ -# 검증 기준 - -## 기본 빌드 검증 -- 확인됨: `sn`만 수정한 경우 아래 명령으로 로컬 빌드를 확인한다. 근거: `apps/sn/AGENTS.md` - -```powershell -pnpm --filter sn run build -pnpm turbo run build --filter=sn -``` - -- 확인됨: `business_components`를 수정한 경우 아래 명령을 함께 확인한다. 근거: `apps/sn/AGENTS.md` - -```powershell -pnpm --filter @common/business_components run build -pnpm --filter sn run build -pnpm turbo run build --filter="...[origin/main]" -``` - -## pre-push 기준 검증 -- 확인됨: `main` 브랜치 push 전 `.husky/pre-push`에서 아래 형식의 빌드가 수행된다. 근거: `.husky/pre-push`, `apps/sn/AGENTS.md` - -```powershell -git fetch origin -pnpm turbo run build --filter="...[origin/main]" --dry-run -pnpm turbo run build --filter="...[origin/main]" -``` - -## 브라우저 검증 -- 확인됨: 백엔드 API 응답과 실제 브라우저 화면 동작을 함께 확인하는 것을 기본으로 한다. 근거: `apps/sn/AGENTS.md` -- 확인 필요: 각 기능별 URL, 로그인 계정, 권한, 테스트 데이터는 작업카드에서 확정한다. 근거: 실행 환경 확인 필요 - -## 검증 기록 -- 확인됨: 검증 결과는 `보고/YYYY-MM-DD/결과보고.md`에 기록한다. 근거: 이 하네스 운영 문서 -- 확인됨: 외부 범위 오류는 `보고/YYYY-MM-DD/이슈.md`에 기록한다. 근거: 이 하네스 운영 문서 diff --git a/apps/sn/docs/AI코딩하네스/기준문서/007-멀티에이전트운영.md b/apps/sn/docs/AI코딩하네스/기준문서/007-멀티에이전트운영.md deleted file mode 100644 index cd3d9c231..000000000 --- a/apps/sn/docs/AI코딩하네스/기준문서/007-멀티에이전트운영.md +++ /dev/null @@ -1,45 +0,0 @@ -# 007. 멀티에이전트 운영 기준 - -## 목적 -- `sn` 2차사업 프론트 작업을 BE, FE, QA 역할과 안전하게 연결하기 위한 기준임 -- AI_SAM `refactoring` 브랜치의 병렬 분업 구조를 참고하되, `sn` 앱과 허용된 `business_components` 범위를 우선함 - -## 프론트 역할 범위 -| 역할 | 주 책임 | 직접 수정 가능 범위 | 직접 수정 금지 범위 | -| --- | --- | --- | --- | -| FE | 화면, 컴포넌트, 훅, 상태관리, API 호출부, 타입 | `apps/sn/**`, `packages/common/business_components/**` | 타 앱, `business_components` 외 공통 패키지, 루트 `docs/**` | -| BE | API 계약, 요청/응답 DTO, 서비스 흐름, DB 영향 | 백엔드 저장소 기준 | 프론트 저장소 직접 수정은 별도 합류 기준 필요 | -| QA | 빌드, 브라우저 조작, 네트워크 응답, 회귀 포인트 확인 | 검증 문서, 결과보고 | 기능 코드 임의 수정 | - -## FE 작업 시작 입력 -- 화면명과 진입 경로 -- 사용자 조작 흐름 -- 호출 API와 응답 필드 -- `apps/sn` 수정 파일 후보 -- `business_components` 수정 필요 여부 -- 허용 범위 밖 수정 필요 여부 -- 브라우저 검증 URL과 테스트 데이터 -- 완료 조건 - -## BE 합류 필요 조건 -- API 경로, method, 요청 파라미터, 응답 필드가 화면 요구사항과 불일치함 -- 오류 응답 구조 또는 HTTP 상태 코드 처리가 화면 요구사항과 충돌함 -- 저장/삭제/분석 실행이 DB 스키마 또는 데이터 보정에 의존함 -- 권한, 인증, 외부 연동 기준 확인이 필요함 - -## QA 합류 필요 조건 -- `sn` 빌드 확인 필요 -- `business_components` 변경으로 타 앱 영향 가능성이 있음 -- 브라우저 화면 조작 또는 네트워크 응답 확인이 완료 조건에 포함됨 -- 로그인, 권한, 테스트 데이터 제약으로 수동 확인 절차가 필요함 - -## 병렬 작업 기준 -- `apps/sn` 화면 구현과 백엔드 API 구현은 API 계약 초안이 고정된 뒤 병렬 진행 가능함 -- `business_components` 변경은 `sn` 화면 요구사항을 만족하는 최소 범위로 제한함 -- `@common/components`, `@common/network`, `@common/auth`, `@common/config` 문제가 원인으로 보여도 직접 수정하지 않고 이슈로 기록함 -- 같은 파일은 한 역할만 수정함 - -## 검증 보고 기준 -- FE는 빌드 명령, 화면 URL, 사용자 조작, 기대 결과, 네트워크 응답 확인 기준을 남김 -- BE는 API 요청/응답 예시와 DB 영향 여부를 남김 -- QA는 실행한 명령, 통과/실패 여부, 재현 절차, 남은 위험을 남김 diff --git a/apps/sn/docs/AI코딩하네스/기준문서/008-문서구조기준.md b/apps/sn/docs/AI코딩하네스/기준문서/008-문서구조기준.md deleted file mode 100644 index 88c7dede9..000000000 --- a/apps/sn/docs/AI코딩하네스/기준문서/008-문서구조기준.md +++ /dev/null @@ -1,160 +0,0 @@ -# 프론트 AI 문서 구조 기준 - -## 1. 목적 - -이 문서는 `sn` 프론트엔드 작업에서 AI와 개발자가 같은 기준으로 문서를 읽고 작성하기 위한 문서 구조 기준이다. - -프론트 문서는 단순 기록이 아니라, 화면 구현 전에 요구사항, 컴포넌트, API 사용, 상태 흐름, 브라우저 검증 기준을 빠르게 파악하기 위한 작업 입력이다. - -## 2. 적용 범위 - -| 구분 | 기준 경로 | 역할 | -| --- | --- | --- | -| 프론트 앱 기준 | `SN_APP_ROOT/AGENTS.md` | `sn` 작업 규칙과 수정 가능 범위 | -| AI 기준문서 | `SN_APP_ROOT/docs/AI코딩하네스/기준문서/` | 앱 작업 정책 | -| 작업계획 | `SN_APP_ROOT/docs/AI코딩하네스/작업계획/` | 화면/기능별 작업카드 | -| 검증 | `SN_APP_ROOT/docs/AI코딩하네스/검증/` | 브라우저/빌드/네트워크 검증 기준 | -| 보고 | `SN_APP_ROOT/docs/AI코딩하네스/보고/` | 작업 결과와 이슈 기록 | -| 기능 문서 | `SN_APP_ROOT/docs/{기능명}/` | 화면별 구현 산출물 | -| 프론트 공통 문서 | `FE_REPO_ROOT/docs/` | 여러 앱이 공유하는 FE 기준 | - -## 3. 기준문서 구조 - -경로: - -```text -SN_APP_ROOT/docs/AI코딩하네스/기준문서/ -``` - -현재 기준: - -```text -001-프로젝트.md -002-화면맵.md -003-API사용.md -004-컴포넌트정책.md -005-상태관리정책.md -006-검증정책.md -007-멀티에이전트운영.md -008-문서구조기준.md -009-AI개발작업흐름.md -``` - -역할: - -- `sn` 앱의 작업 대상과 금지 경로 확인 -- 화면/라우트 구조 확인 -- API 호출 방식 확인 -- 기존 컴포넌트 재사용 기준 확인 -- 상태관리 기준 확인 -- 검증 기준 확인 -- BE/FE 역할 분리 기준 확인 - -## 4. 기능 문서 구조 - -기능별 문서는 아래 위치에 둔다. - -```text -SN_APP_ROOT/docs/{기능명}/ -``` - -권장 구조: - -```text -README.md -00-범위.md -01-기존화면기반-컴포넌트분해.md -02-컴포넌트별-작업목록.md -03-API응답필드매핑표.md -04-화면흐름.md -05-검증계획.md -06-검증결과.md -99-미확인사항.md -``` - -문서별 역할: - -| 문서 | 역할 | -| --- | --- | -| `README.md` | 기능 문서 진입점 | -| `00-범위.md` | 구현 대상과 제외 범위 | -| `01-기존화면기반-컴포넌트분해.md` | 기존 화면/컴포넌트 재사용 후보 | -| `02-컴포넌트별-작업목록.md` | 컴포넌트 단위 작업 목록 | -| `03-API응답필드매핑표.md` | 화면에서 사용하는 API와 응답 필드 | -| `04-화면흐름.md` | 사용자 동작과 화면 상태 흐름 | -| `05-검증계획.md` | 브라우저/네트워크/빌드 검증 계획 | -| `06-검증결과.md` | 실제 검증 결과 | -| `99-미확인사항.md` | BE/FE/API/DB 미확정 이슈 | - -## 5. 프론트 공통 문서와 `sn` 문서 구분 - -`SN_APP_ROOT/docs/**`는 `sn` 앱 특정 화면과 기능을 다룬다. - -`FE_REPO_ROOT/docs/**`는 여러 앱이 함께 보는 공통 기준만 다룬다. - -| 상황 | 문서 위치 | -| --- | --- | -| `sn` 특정 화면 구현 | `SN_APP_ROOT/docs/{기능명}/` | -| `sn` API 필드 매핑 | `SN_APP_ROOT/docs/{기능명}/03-API응답필드매핑표.md` | -| `sn` 검증 결과 | `SN_APP_ROOT/docs/{기능명}/06-검증결과.md` | -| 여러 앱 공통 상태관리 기준 | `FE_REPO_ROOT/docs/구조/` | -| 여러 앱 공통 응답 처리 기준 | `FE_REPO_ROOT/docs/응답표준화/` | - -## 6. 백엔드 문서와 연결 기준 - -프론트 기능이 백엔드 계약에 의존하면 아래 문서를 함께 본다. - -```text -BE_SERVICE_ROOT/docs/AI코딩하네스/작업계획/{작업카드}.md -BE_SERVICE_ROOT/docs/AI코딩하네스/DB/{DB문서}.md -BE_SERVICE_ROOT/docs/실행명세/** -``` - -연결 방식: - -| 프론트 문서 | 연결되는 백엔드 문서 | -| --- | --- | -| API 응답 필드 매핑표 | BE 작업카드, API 정책 | -| 화면흐름 | BE API 처리 흐름 | -| 미확인사항 | BE 작업계획, DB 문서 | -| 검증계획 | QA 카드, BE 테스트/실행명세 | - -## 7. 작성 원칙 - -1. 화면 구현 전에 기능 문서를 먼저 확인한다. -2. 기존 컴포넌트 재사용 가능성을 먼저 기록한다. -3. API 응답 필드는 화면 표시명과 실제 필드명을 함께 적는다. -4. 미확정 API/DB 이슈는 `99-미확인사항.md`에 남긴다. -5. 검증 결과는 실제 브라우저 동작 기준으로 남긴다. -6. 정적 HTML은 참고 자료이고 실제 구현은 `apps/sn` 코드와 기존 컴포넌트 조합으로 한다. -7. 공통 패키지 수정이 필요하면 영향 범위를 먼저 정리한다. - -## 8. 적용 예시: 노후관로관리 - -노후관로관리 문서는 아래 역할로 사용한다. - -```text -SN_APP_ROOT/app/html/06. 노후관로관리_시나리오_v1.0.md -→ 화면 요구사항과 사용자 동작 확인 - -SN_APP_ROOT/docs/노후관로관리/03-API응답필드매핑표.md -→ 목록/상세/위치 이동에 필요한 API 필드 확인 - -SN_APP_ROOT/docs/노후관로관리/99-미확인사항.md -→ 백엔드 확인 이슈 관리 - -BE_SERVICE_ROOT/docs/AI코딩하네스/DB/001-노후관로-레이어설정.md -→ 레이어 트리 DB 기준 데이터 반영안 확인 -``` - -## 9. 유지보수 기준 - -아래 상황에서는 문서를 갱신한다. - -- 화면 흐름 변경 -- API endpoint 또는 응답 필드 변경 -- 컴포넌트 구조 변경 -- 상태관리 방식 변경 -- 브라우저 검증 결과 확정 -- 미확인사항 해결 -- 백엔드 계약 변경 diff --git a/apps/sn/docs/AI코딩하네스/기준문서/009-AI개발작업흐름.md b/apps/sn/docs/AI코딩하네스/기준문서/009-AI개발작업흐름.md deleted file mode 100644 index 91d079074..000000000 --- a/apps/sn/docs/AI코딩하네스/기준문서/009-AI개발작업흐름.md +++ /dev/null @@ -1,244 +0,0 @@ -# 프론트 AI 개발 작업 흐름 - -## 1. 목적 - -이 문서는 `sn` 프론트엔드 기능 개발을 AI와 함께 진행할 때 따르는 표준 작업 흐름이다. - -기능마다 화면과 API는 달라질 수 있지만, 작업 기준 확인, 기존 화면/컴포넌트 확인, API 계약 확인, 최소 구현, 브라우저 검증, 결과 기록의 큰 흐름은 유지한다. - -## 2. 기본 원칙 - -1. `AGENTS.md`와 `sn` 기준문서를 먼저 확인한다. -2. 기존 화면과 컴포넌트를 먼저 찾는다. -3. `apps/sn` 내부 해결을 우선한다. -4. 공통 패키지는 `business_components`만 허용 범위에서 수정한다. -5. `@common/components`, `@common/network`, `@common/auth` 등 금지 범위는 직접 수정하지 않는다. -6. 백엔드 응답을 임의로 가정하지 않고 API 문서와 실제 응답을 확인한다. -7. 구현 후 브라우저 동작과 네트워크 응답을 확인한다. - -## 3. 표준 작업 흐름 - -```text -1. 작업 기준 확인 -2. 기능 문서 확인 -3. 기존 화면/컴포넌트 탐색 -4. API 사용부와 응답 필드 확인 -5. 상태관리와 화면 흐름 확인 -6. 구현 방향 결정 -7. 최소 범위 구현 -8. 빌드/브라우저/네트워크 검증 -9. 검증 결과와 남은 리스크 기록 -``` - -## 4. 단계별 기준 - -### 4.1 작업 기준 확인 - -먼저 아래 문서를 확인한다. - -```text -WORKSPACE_ROOT/AGENTS.md -SN_APP_ROOT/AGENTS.md -BE_SERVICE_ROOT/AGENTS.md -``` - -확인할 내용: - -- 수정 가능한 경로 -- `apps/sn` 내부 해결 가능 여부 -- `business_components` 수정 가능 여부 -- 공통 패키지 수정 금지 범위 -- 백엔드 연동 영향 여부 - -### 4.2 기능 문서 확인 - -확인 대상: - -```text -SN_APP_ROOT/docs/{기능명}/00-범위.md -SN_APP_ROOT/docs/{기능명}/03-API응답필드매핑표.md -SN_APP_ROOT/docs/{기능명}/04-화면흐름.md -SN_APP_ROOT/docs/{기능명}/99-미확인사항.md -``` - -확인할 내용: - -- 화면 목적 -- 사용자 동작 흐름 -- 호출 API -- 필요한 응답 필드 -- 미확정 백엔드 계약 -- 검증해야 할 브라우저 동작 - -### 4.3 기존 화면/컴포넌트 탐색 - -새 UI를 만들기 전에 기존 구현을 찾는다. - -우선순위: - -```text -1. SN_APP_ROOT/app/** -2. SN_APP_ROOT/src/** -3. BUSINESS_COMPONENTS_ROOT/src/** -``` - -확인할 내용: - -- 유사 화면 -- 기존 테이블/모달/검색/상세 패널 -- 지도 레이어/트리/팝업 패턴 -- 기존 상태관리 store -- 기존 API 유틸 - -### 4.4 API 사용부와 응답 필드 확인 - -확인할 내용: - -- API 호출 함수 -- 요청 파라미터 -- 응답 wrapper 구조 -- 화면 표시 필드 -- 코드값 매핑 -- 오류 처리 - -백엔드와 계약이 불명확하면 `99-미확인사항.md`에 기록하고 BE 작업카드와 연결한다. - -### 4.5 상태관리와 화면 흐름 확인 - -확인할 내용: - -- local state로 충분한가 -- 기존 Zustand store를 써야 하는가 -- URL/query state가 필요한가 -- 모달/패널/지도 선택 상태가 어떻게 연결되는가 -- API 재조회 시점은 언제인가 - -### 4.6 구현 방향 결정 - -가능한 방향: - -```text -1. 기존 화면/컴포넌트 조합만으로 구현 -2. apps/sn 내부 컴포넌트 추가 -3. business_components 최소 보강 -4. 백엔드 API 계약 확인 요청 -5. 백엔드 변경 필요 사항 정리 -``` - -기본 방침: - -```text -기존 컴포넌트로 가능하면 재사용한다. -apps/sn 내부 해결을 우선한다. -공통 패키지 수정은 마지막에 판단한다. -백엔드 응답 구조를 프론트에서 임의로 바꾸지 않는다. -``` - -### 4.7 최소 범위 구현 - -구현 원칙: - -- 기존 디자인과 컴포넌트 패턴을 따른다. -- 신규 스타일은 최소화한다. -- 화면 전체 구조를 불필요하게 바꾸지 않는다. -- 텍스트/버튼/테이블이 모바일과 데스크톱에서 넘치지 않게 한다. -- 지도 화면은 전체 페이지 스크롤보다 내부 패널 스크롤을 우선한다. -- API 변환 로직은 한 곳에 모은다. - -### 4.8 빌드/브라우저/네트워크 검증 - -수정 범위에 따라 검증한다. - -`apps/sn`만 수정한 경우: - -```powershell -pnpm --filter sn run build -pnpm turbo run build --filter=sn -``` - -`business_components` 수정 시: - -```powershell -pnpm --filter @common/business_components run build -pnpm --filter sn run build -``` - -브라우저 검증: - -- 화면 진입 -- 검색/조회 -- 모달/패널 열림 -- 테이블 표시 -- 지도 레이어 표시 -- 네트워크 요청/응답 -- 오류 처리 -- 스크롤/레이아웃 깨짐 여부 - -### 4.9 결과 기록 - -작업 후 아래 항목을 기록한다. - -- 작업 목적 -- 수정 대상 파일 -- 변경 내용 요약 -- 영향 범위 -- 검증 방법 -- 롤백 방법 -- 남은 리스크 - -기능 문서가 있다면 `06-검증결과.md` 또는 `99-미확인사항.md`를 갱신한다. - -## 5. BE/FE 연동 작업 흐름 - -프론트가 백엔드 계약에 의존하는 경우 아래 순서로 진행한다. - -```text -1. FE 화면흐름 확인 -2. FE API 응답 필드 매핑표 확인 -3. FE 미확인사항 확인 -4. BE 작업카드/API/DB 문서 확인 -5. 실제 API 응답 확인 -6. 프론트 구현 -7. 브라우저와 네트워크 응답 검증 -8. 미확인사항 해결 여부 갱신 -``` - -## 6. 적용 예시: 노후관로관리 - -노후관로관리 작업에서는 이 흐름을 아래처럼 적용했다. - -```text -시나리오 문서 확인 -→ API 응답 필드 매핑표 확인 -→ 미확인사항 확인 -→ 기존 GIS/레이어 트리 패턴 확인 -→ 기존 /api/v1/fclts 재사용 결정 -→ 노후관로 가상 노드 fallback 구현 -→ DB 정식 노드 반영 후 중복 방지 -→ 목록/지도 강조선/상세 패널 검증 -``` - -프론트 최종 방향: - -```text -노후관로 목록: 기존 v1/fclts 재사용 -노후관로 트리 노드: DB 노드 우선, 없으면 프론트 fallback -노후관로 강조선: tb_ms_swage_ppl WFS 조건 레이어 추가 -상세 패널: 기존 하수관로 상세 패널 재사용 -``` - -## 7. 고정할 큰 틀 - -기능이 달라져도 아래 흐름은 유지한다. - -```text -문서 확인 -→ 기존 화면/컴포넌트 확인 -→ API 계약 확인 -→ 재사용 가능 여부 판단 -→ 최소 구현 -→ 브라우저 검증 -→ 결과 기록 -``` - -세부 구현은 상황에 맞게 조정하되, 기존 화면 패턴 재사용과 실제 브라우저 검증은 항상 유지한다. diff --git a/apps/sn/docs/AI코딩하네스/보고/2026-04-16/개선사항.md b/apps/sn/docs/AI코딩하네스/보고/2026-04-16/개선사항.md deleted file mode 100644 index bbf213bbb..000000000 --- a/apps/sn/docs/AI코딩하네스/보고/2026-04-16/개선사항.md +++ /dev/null @@ -1,5 +0,0 @@ -# 2026-04-16 개선 기록 - -## 반복 개선점 -- 추정: 기능별 작업이 누적되면 `작업목록.md`에 화면, API, 검증 상태 컬럼을 추가해 진행률을 더 쉽게 볼 수 있다. -- 추정: 브라우저 테스트 절차가 반복되면 `기준문서/007-브라우저점검목록.md`를 추가해 공통 체크리스트로 분리할 수 있다. diff --git a/apps/sn/docs/AI코딩하네스/보고/2026-04-16/결과보고.md b/apps/sn/docs/AI코딩하네스/보고/2026-04-16/결과보고.md deleted file mode 100644 index a325e1585..000000000 --- a/apps/sn/docs/AI코딩하네스/보고/2026-04-16/결과보고.md +++ /dev/null @@ -1,51 +0,0 @@ -# 2026-04-16 작업 보고 - -## SN-P2-HARNESS-001 - -### 작업 목적 -- 확인됨: `sn` 2차사업 작업을 기능 단위 작업카드와 일자별 기록으로 관리하기 위한 경량 하네스 문서 구조를 만든다. 근거: `apps/sn/AGENTS.md` - -### 수정 파일 목록 -- 확인됨: `apps/sn/docs/AI코딩하네스/개요.md` -- 확인됨: `apps/sn/docs/AI코딩하네스/작업목록.md` -- 확인됨: `apps/sn/docs/AI코딩하네스/기준문서/*` -- 확인됨: `apps/sn/docs/AI코딩하네스/작업계획/*` -- 확인됨: `apps/sn/docs/AI코딩하네스/보고/*` - -### 변경 요약 -- 확인됨: 프로젝트 기준, 화면 기준, API 연동 기준, 컴포넌트 기준, 상태관리 기준, 검증 기준 문서를 추가했다. -- 확인됨: 기능별 작업카드 템플릿과 일자별 기록 파일을 추가했다. - -### 영향 범위 -- 확인됨: 문서만 추가했으므로 런타임 동작에는 영향이 없다. -- 확인됨: `apps/sn` 하위 문서만 추가했으며 `packages/common/business_components`는 변경하지 않았다. - -### 검증 방법 -- 확인됨: 문서 파일 생성 여부를 확인한다. -- 확인됨: 코드 변경이 없으므로 빌드 검증은 수행하지 않아도 된다. - -### 롤백 방법 -- 확인됨: `apps/sn/docs/AI코딩하네스` 하위에서 이번에 추가한 문서 파일을 제거하면 된다. - -### 남은 리스크 -- 확인 필요: 2차사업 기능별 실제 작업카드는 요구사항과 화면설계서 기준으로 별도 작성해야 한다. -- 확인 필요: 브라우저 검증 URL과 테스트 계정은 실행 환경 기준으로 확정해야 한다. - -## SN-DOC-KO-001 - -### 작업 목적 -- 확인됨: 프론트엔드 문서 폴더와 Markdown 파일명을 한글 기준으로 정리하고 팀 공유를 위해 Git 추적 대상에 포함한다. - -### 변경 요약 -- 확인됨: 루트 `docs`의 기존 문서 파일명을 한글명으로 변경했다. -- 확인됨: `docs/bbs`는 `docs/게시판`, `docs/dockerbuild`는 `docs/도커빌드`로 변경했다. -- 확인됨: `apps/sn/docs/phase2`는 `apps/sn/docs/AI코딩하네스`로 변경했다. -- 확인됨: 문서 전용 파일명은 한글로 변경하되, `docs/도커빌드/Dockerfile`, `front_docker_build.sh`, `next.config.ts`는 실행 의미가 있어 파일명을 유지했다. - -### 영향 범위 -- 확인됨: 문서 경로와 문서 내부 참조만 변경했다. -- 확인됨: 프론트 런타임, 빌드 설정, API 계약에는 영향이 없다. - -### 검증 방법 -- 확인됨: 문서 파일 경로를 조회해 한글 경로로 이동된 것을 확인한다. -- 확인됨: `git status --short`에서 문서 파일이 추적 대상 상태로 표시되는지 확인한다. diff --git a/apps/sn/docs/AI코딩하네스/보고/2026-04-16/이슈.md b/apps/sn/docs/AI코딩하네스/보고/2026-04-16/이슈.md deleted file mode 100644 index 25f52e360..000000000 --- a/apps/sn/docs/AI코딩하네스/보고/2026-04-16/이슈.md +++ /dev/null @@ -1,8 +0,0 @@ -# 2026-04-16 이슈 - -## 외부 범위 이슈 -- 확인 필요: 현재 등록된 외부 범위 이슈는 없다. - -## 미확정 항목 -- 확인 필요: 2차사업 최종 화면명, 메뉴명, URL은 화면설계서와 실행 환경 기준으로 확정해야 한다. -- 확인 필요: 기능별 테스트 계정, 권한, 데이터는 환경별로 별도 확인해야 한다. diff --git a/apps/sn/docs/AI코딩하네스/보고/2026-04-16/작업목록.md b/apps/sn/docs/AI코딩하네스/보고/2026-04-16/작업목록.md deleted file mode 100644 index c8c90bac6..000000000 --- a/apps/sn/docs/AI코딩하네스/보고/2026-04-16/작업목록.md +++ /dev/null @@ -1,10 +0,0 @@ -# 2026-04-16 작업 항목 - -| ID | 상태 | 작업명 | 범위 | 결과 | -| --- | --- | --- | --- | --- | -| SN-P2-HARNESS-001 | 완료 | `sn` 2차사업 경량 하네스 문서 구조 생성 | `apps/sn/docs/AI코딩하네스` | 문서 골격 생성 | -| SN-DOC-KO-001 | 완료 | 프론트 문서 폴더/파일명 한글화 | `docs`, `apps/sn/docs` | 문서 경로 한글화 및 Git 추적 확인 | - -## 확인 사항 -- 확인됨: 프론트엔드 하네스 문서는 `apps/sn/docs/AI코딩하네스` 하위에 생성했다. 근거: `apps/sn/AGENTS.md` -- 확인됨: 소스 코드, API 계약, DB 스키마는 변경하지 않았다. 근거: 작업 변경 범위 diff --git a/apps/sn/docs/AI코딩하네스/보고/2026-04-16/제안.md b/apps/sn/docs/AI코딩하네스/보고/2026-04-16/제안.md deleted file mode 100644 index be6ea5853..000000000 --- a/apps/sn/docs/AI코딩하네스/보고/2026-04-16/제안.md +++ /dev/null @@ -1,5 +0,0 @@ -# 2026-04-16 제안 - -## 후속 제안 -- 추정: 기능 개발 착수 전 `작업계획/SN-P2-001-*.md` 형식으로 작업카드를 먼저 작성하면 백엔드와 프론트엔드 변경 범위를 분리해서 추적하기 쉽다. -- 추정: 백엔드 API 변경이 동반되는 작업은 백엔드 작업카드 ID와 프론트 작업카드 ID를 서로 참조하는 방식이 좋다. diff --git a/apps/sn/docs/AI코딩하네스/보고/2026-04-17/결과보고.md b/apps/sn/docs/AI코딩하네스/보고/2026-04-17/결과보고.md deleted file mode 100644 index 05b870571..000000000 --- a/apps/sn/docs/AI코딩하네스/보고/2026-04-17/결과보고.md +++ /dev/null @@ -1,45 +0,0 @@ -# 2026-04-17 결과보고 - -## 작업 목적 -- `sn` 2차사업 고도화 작업을 멀티에이전트로 진행하기 전에 프론트 문서 기준과 공통 비즈니스 컴포넌트 문서의 오해 요소를 정리한다. - -## 수정 대상 파일 -- `apps/sn/docs/AI코딩하네스/기준문서/001-프로젝트.md` -- `apps/sn/docs/AI코딩하네스/기준문서/003-API사용.md` -- `apps/sn/docs/AI코딩하네스/기준문서/007-멀티에이전트운영.md` -- `apps/sn/docs/AI코딩하네스/개요.md` -- `apps/sn/docs/AI코딩하네스/작업목록.md` -- `apps/sn/docs/AI코딩하네스/작업계획/000-작업카드-템플릿.md` -- `apps/sn/docs/AI코딩하네스/한글경로-영향.md` -- `apps/sn/app/html/AGENTS.md` -- `packages/common/business_components/README.md` -- `packages/common/business_components/src/form.md` -- `packages/common/business_components/src/chart.md` -- `packages/common/business_components/src/ui/search_form_left/searchFormLeft.md` -- `apps/sn/docs/AI코딩하네스/보고/2026-04-17/결과보고.md` - -## 변경 내용 요약 -- 폐기된 `phase2` 문서 경로를 `apps/sn/docs/AI코딩하네스/작업계획/` 및 `apps/sn/docs/` 하위 주제별 폴더 기준으로 수정했다. -- 백엔드 응답표준화 문서 참조를 실제 한글 경로로 수정했다. -- 프론트 작업카드 템플릿에 쓰기 허용/금지 범위, 충돌 주의 파일, BE/FE/QA 산출물 항목을 추가했다. -- 프론트 기준문서에 `sn` 작업용 멀티에이전트 운영 기준을 추가하고, 하네스 개요와 작업목록에 연결했다. -- 한글 경로 영향 문서가 과거 기록임을 명시하고, 루트 `docs/**`를 승인 없이 수정하지 않는 기준을 덧붙였다. -- `business_components` 문서의 오타, 잘못된 form 설명, 깨진 chart 예시, `fo` 기준 예시의 적용 주의 문구를 보완했다. - -## 영향 범위 -- 코드, API, DB 스키마 영향은 없다. -- `sn` 작업자가 문서 기준을 따라 작업할 때 수정 가능 범위와 작업카드 작성 기준이 더 명확해진다. -- `business_components` 문서 예시를 참고할 때 타 앱 기준을 `sn`에 그대로 적용하는 위험이 줄어든다. -- 백엔드 API/DB 영향이 있는 화면 작업에서 BE/FE/QA 합류 조건을 더 빨리 판단할 수 있다. - -## 테스트 / 검증 방법 -- Markdown 문서의 주요 참조 경로와 문구를 수동 확인했다. -- 빌드 또는 테스트는 코드 변경이 아니므로 수행하지 않았다. - -## 롤백 방법 -- 각 Markdown 파일을 이전 문구로 되돌린다. -- `apps/sn/docs/AI코딩하네스/보고/2026-04-17/결과보고.md`를 삭제한다. - -## 남은 리스크 -- `business_components`의 실제 컴포넌트 API와 문서 예시 전체가 완전히 일치하는지는 별도 코드 기준 검증이 필요하다. -- 로그인, 권한, 테스트 데이터가 확정되지 않은 기능은 브라우저 검증 항목을 작업카드에서 다시 확정해야 한다. diff --git a/apps/sn/docs/AI코딩하네스/보고/개요.md b/apps/sn/docs/AI코딩하네스/보고/개요.md deleted file mode 100644 index 599676d4a..000000000 --- a/apps/sn/docs/AI코딩하네스/보고/개요.md +++ /dev/null @@ -1,16 +0,0 @@ -# 작업 기록 - -## 목적 -이 디렉터리는 `sn` 2차사업 작업의 일자별 진행 기록을 남긴다. - -## 파일 구성 -- `작업목록.md`: 해당 일자의 작업 항목과 상태 -- `결과보고.md`: 수행 결과와 검증 결과 -- `이슈.md`: 외부 범위, 미확정, 차단 이슈 -- `제안.md`: 후속 제안 -- `개선사항.md`: 반복 작업 개선점 - -## 기록 원칙 -- 확인됨: 작업 결과는 변경 파일, 영향 범위, 검증 방법, 롤백 방법을 포함한다. 근거: `apps/sn/AGENTS.md` -- 확인됨: 권한 밖 경로 문제는 직접 수정하지 않고 이슈로 기록한다. 근거: `apps/sn/AGENTS.md` -- 추정: 백엔드와 함께 진행하는 기능은 백엔드 작업 기록과 프론트 작업 기록을 서로 참조하면 회귀 추적에 유리하다. 근거: 백엔드 `docs/AI코딩하네스` 구조 diff --git a/apps/sn/docs/AI코딩하네스/작업계획/000-작업카드-템플릿.md b/apps/sn/docs/AI코딩하네스/작업계획/000-작업카드-템플릿.md deleted file mode 100644 index 6e0417aa3..000000000 --- a/apps/sn/docs/AI코딩하네스/작업계획/000-작업카드-템플릿.md +++ /dev/null @@ -1,74 +0,0 @@ -# SN-P2-000 작업카드 템플릿 - -## 1. 작업 개요 -- 작업 ID: -- 작업명: -- 상태: 대기 -- 작성일: -- 담당 역할: FE -- 관련 역할: BE, QA -- 담당 범위: -- 근거 문서: - -## 2. 작업 목적 -- 확인 필요: - -## 3. 수정 대상 파일 -| 구분 | 파일 경로 | 수정 여부 | 근거 | -| --- | --- | --- | --- | -| 화면 | 확인 필요 | 확인 필요 | 확인 필요 | -| 컴포넌트 | 확인 필요 | 확인 필요 | 확인 필요 | -| API 호출 | 확인 필요 | 확인 필요 | 확인 필요 | -| 공통 비즈니스 컴포넌트 | 확인 필요 | 확인 필요 | 확인 필요 | - -## 3-1. 쓰기 범위와 충돌 주의 -| 구분 | 경로 | 담당 역할 | 처리 기준 | -| --- | --- | --- | --- | -| 쓰기 허용 | `apps/sn/**` | FE | `sn` 화면/상태/API 호출 작업 | -| 쓰기 허용 | `packages/common/business_components/**` | FE | `sn` 요구사항에 필요한 최소 공통 컴포넌트 작업 | -| 쓰기 금지 | `packages/common/business_components` 외 공통 패키지 | FE | 문제 원인과 영향 범위만 기록 | -| 쓰기 금지 | 타 앱 `apps/*` | FE | 문제 원인과 영향 범위만 기록 | - -- 충돌 주의 파일: -- 인수인계 메모: - -## 4. 화면 흐름 -- 확인 필요: 진입 경로 -- 확인 필요: 사용자 조작 -- 확인 필요: 정상 흐름 -- 확인 필요: 빈 결과 흐름 -- 확인 필요: 오류 흐름 - -## 5. API 연동 -| 기능 | Method | URL | 요청 | 응답 | 확인 상태 | -| --- | --- | --- | --- | --- | --- | -| 확인 필요 | 확인 필요 | 확인 필요 | 확인 필요 | 확인 필요 | 확인 필요 | - -## 6. 영향 범위 -- 확인 필요: `apps/sn` 화면 영향 -- 확인 필요: `business_components` 영향 -- 확인 필요: 백엔드 API 영향 -- 확인 필요: 다른 앱 영향 가능성 - -## 6-1. 멀티에이전트 작업 분리 -- FE 담당 산출물: -- BE 담당 산출물: -- QA 담당 산출물: -- 병렬 진행 가능 항목: -- 순차 진행 필요 항목: - -## 7. 검증 방법 -- 확인 필요: 빌드 명령 -- 확인 필요: 브라우저 URL -- 확인 필요: 사용자 조작 -- 확인 필요: 기대 결과 -- 확인 필요: 네트워크 응답 확인 기준 - -## 8. 롤백 방법 -- 확인 필요: - -## 9. 예상 위험 -- 확인 필요: - -## 10. 완료 기준 -- 확인 필요: diff --git a/apps/sn/docs/AI코딩하네스/작업계획/001-SN-P2-노후관로관리-작업카드.md b/apps/sn/docs/AI코딩하네스/작업계획/001-SN-P2-노후관로관리-작업카드.md deleted file mode 100644 index 5b6bb4ce1..000000000 --- a/apps/sn/docs/AI코딩하네스/작업계획/001-SN-P2-노후관로관리-작업카드.md +++ /dev/null @@ -1,55 +0,0 @@ -# SN-P2-001 노후관로관리 작업카드 샘플 - -## 1. 작업 개요 -- 작업 ID: `SN-P2-001` -- 작업명: 노후관로관리 화면 조회/증적 기준 정리 -- 상태: 샘플 작성 -- 담당 역할: FE -- 관련 역할: BE, QA -- 대상 범위: `apps/sn/**` -- 근거 문서: - - `apps/sn/app/html/06. 노후관로관리_시나리오_v1.0.md` - - `D:/workspace/IIPBS/sewerage-service/docs/실행명세/기능/21-dcrp-ppl-관리.md` - - `D:/workspace/IIPBS/sewerage-service/docs/실행명세/기능/60-관로통계.md` - -## 2. 작업 목적 -- 노후관로정비사업 화면의 조회 조건과 백엔드 `GET /api/v1/dcrpPpl` 계약을 카드에 고정한다. -- GIS 노후관로관리 시나리오가 본 화면으로 확장될 때 필요한 API/브라우저 증적 항목을 미리 둔다. - -## 3. 수정 대상 파일 -| 구분 | 파일 경로 | 수정 여부 | 근거 | -| --- | --- | --- | --- | -| 화면 | `apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx` | 필요 시 최소 수정 | 노후관로정비사업 조회 화면 | -| 테이블 | `apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx` | 필요 시 최소 수정 | 행 선택 후 표 모달 | -| API 호출 | `apps/sn/app/components/lib/common/apiHelpers.ts` | 원칙상 변경 없음 | 기존 `searchData` 사용 | -| 공통 컴포넌트 | `packages/common/business_components/**` | 변경 없음 | 이번 카드에서는 직접 수정 금지 | - -## 4. 화면 흐름 -- 진입: `노후관로정비사업` 화면 또는 라우트 `/oper/oldPp` -- 기본 조회: 등록기간 기본값으로 `GET /api/v1/dcrpPpl` 호출 -- 검색 조건: 등록기간, 등록자 입력값, 파일명/사업명 입력값 -- 결과: 목록 테이블, 총 건수, 페이지 이동 -- 행 선택: 첨부 파일명과 식별자를 상태에 보관하고 표 모달을 연다. -- 파일 작업: 다운로드/삭제/등록은 외부 첨부 서비스와 데이터 변경이 있으므로 QA 승인 데이터가 필요하다. - -## 5. API 연동 -| 기능 | Method | URL | 요청 | 응답 | 확인 상태 | -| --- | --- | --- | --- | --- | --- | -| 목록 조회 | GET | `/sewerage-service/api/v1/dcrpPpl` | `fromRegDt`, `toRegDt`, `dcrpPplMtncBizNm`, `auth`, `page`, `size` | Page 구조 | 확인됨 | -| 등록 | POST | `/sewerage-service/api/v1/dcrpPpl` | `mngYr`, `mngQu`, `atflCd`, `atflId`, `rgtr`, `dcrpPplMtncBizNm` | 단건 DTO | 미검증 | -| 삭제 | DELETE | `/sewerage-service/api/v1/dcrpPpl/{dcrpPplMtncBizSn}` | path id | 200 empty | 미검증 | -| 파일 다운로드 | GET | `/attach-service/api/v1/download/{atflId}` | path id | 파일 | 미검증 | - -## 6. 검증 방법 -- 빌드: `pnpm --filter sn run build` -- 브라우저: `/oper/oldPp` 진입 후 조회 버튼, 페이지 이동, 행 선택, 모달 확인 -- 네트워크: `GET /sewerage-service/api/v1/dcrpPpl` 요청 URL, Query String, Status, Response JSON 기록 -- 상세 양식: `apps/sn/docs/AI코딩하네스/검증/001-노후관로관리-브라우저-네트워크-증적.md` - -## 7. 롤백 방법 -- 이번 카드에서 화면 코드가 변경되지 않았다면 문서만 제거한다. -- 화면 코드 변경이 발생한 경우 `oldPp/page.tsx`와 관련 모달 변경분을 되돌리고 빌드를 재실행한다. - -## 8. 예상 위험 -- `auth` 전달 방식과 사용자명 보강은 인증/권한 상태에 따라 결과가 달라질 수 있다. -- 등록/삭제/다운로드는 데이터 변경과 첨부 서비스 의존이 있어 수동 검증 전 테스트 데이터가 필요하다. diff --git a/apps/sn/docs/AI코딩하네스/작업계획/개요.md b/apps/sn/docs/AI코딩하네스/작업계획/개요.md deleted file mode 100644 index c9900ae47..000000000 --- a/apps/sn/docs/AI코딩하네스/작업계획/개요.md +++ /dev/null @@ -1,17 +0,0 @@ -# 작업카드 관리 - -## 목적 -이 디렉터리는 `sn` 2차사업 기능별 실행 계획을 보관한다. - -작업카드는 구현 전에 작성하며, 수정 범위, API 영향, 화면 영향, 검증 방법, 롤백 방법을 먼저 고정한다. - -## 작성 규칙 -- 확인됨: 기능 단위로 문서를 만든다. 근거: 이 하네스 운영 문서 -- 확인됨: 직접 수정 대상은 `apps/sn/**`와 `packages/common/business_components/**`로 제한한다. 근거: `apps/sn/AGENTS.md` -- 확인됨: 권한 밖 수정 필요성은 작업카드에 직접 구현 항목으로 넣지 않고 외부 이슈로 기록한다. 근거: `apps/sn/AGENTS.md` -- 추정: 백엔드 동시 변경이 필요한 기능은 백엔드 `docs/AI코딩하네스/작업계획` 작업카드 번호를 함께 적는 것이 추적에 유리하다. 근거: 백엔드 `docs/AI코딩하네스` 구조 - -## 파일명 규칙 -- 권장 형식: `SN-P2-001-기능명.md` -- 기능명은 화면명 또는 업무명을 기준으로 작성한다. -- 한 작업카드에 여러 화면을 무리하게 묶지 않는다. diff --git a/apps/sn/docs/AI코딩하네스/작업목록.md b/apps/sn/docs/AI코딩하네스/작업목록.md deleted file mode 100644 index 133371799..000000000 --- a/apps/sn/docs/AI코딩하네스/작업목록.md +++ /dev/null @@ -1,19 +0,0 @@ -# sn 2차사업 작업 목록 - -## 상태 값 -- `대기`: 아직 착수하지 않은 작업 -- `진행`: 현재 분석 또는 구현 중인 작업 -- `검증`: 구현 후 빌드, 브라우저, API 응답을 확인하는 작업 -- `완료`: 검증과 기록이 끝난 작업 -- `보류`: 외부 범위 또는 추가 확인이 필요한 작업 - -## 작업 목록 -| ID | 상태 | 작업명 | 범위 | 근거 | 비고 | -| --- | --- | --- | --- | --- | --- | -| SN-P2-000 | 대기 | 2차사업 기능 작업카드 등록 | `apps/sn/docs/AI코딩하네스/작업계획` | 확인됨: `apps/sn/AGENTS.md` | 기능별 작업 착수 전 작성 | -| SN-P2-HARNESS-002 | 완료 | AI_SAM 최신 하네스 구조 재확인 및 멀티에이전트 운영 기준 반영 | `apps/sn/docs/AI코딩하네스/기준문서` | 확인됨: AI_SAM `refactoring` 최신 HEAD | BE/FE/QA 합류 기준 추가 | - -## 운영 규칙 -- 확인됨: 작업은 `apps/sn/**`와 `packages/common/business_components/**` 범위 안에서만 직접 수정한다. 근거: `apps/sn/AGENTS.md` -- 확인됨: 다른 앱 또는 공통 패키지 문제가 발견되면 이 파일에 직접 작업으로 넣지 않고 `보고/YYYY-MM-DD/이슈.md`에 기록한다. 근거: `apps/sn/AGENTS.md` -- 추정: 실제 기능 작업은 2차사업 요구사항, 화면설계서, 백엔드 API 응답 근거를 함께 연결해야 한다. 근거: `apps/sn/AGENTS.md` diff --git a/apps/sn/docs/AI코딩하네스/한글경로-영향.md b/apps/sn/docs/AI코딩하네스/한글경로-영향.md deleted file mode 100644 index f2da3ec0b..000000000 --- a/apps/sn/docs/AI코딩하네스/한글경로-영향.md +++ /dev/null @@ -1,56 +0,0 @@ -# 한글 폴더 및 Markdown 파일명 영향 검토 - -> 이 문서는 2026-04-16 문서 경로 정리 당시의 영향 검토 기록이다. 이후 `sn` 작업에서 새 문서를 만들 때는 `apps/sn/AGENTS.md` 기준에 따라 `apps/sn/docs/` 하위만 사용하고, 프론트엔드 루트 `docs/**`는 사용자 승인 없이 수정하지 않는다. - -> 이 문서는 2026-04-16 문서 경로 정리 당시의 영향 검토 기록이다. 이후 `sn` 작업에서 새 문서를 만들 때는 `apps/sn/AGENTS.md` 기준에 따라 `apps/sn/docs/` 하위만 사용하고, 프론트엔드 루트 `docs/**`는 사용자 승인 없이 수정하지 않는다. - -## 1. 결론 - -- 프론트엔드 문서 전용 경로는 한글 폴더명과 한글 `.md` 파일명을 사용해도 Codex 작업 이해에는 문제가 없음 -- 현재 변경 범위는 루트 `docs/`와 `apps/sn/docs/` 하위 문서 경로에 한정되므로 프론트 런타임 영향은 없음 -- 빌드, 배포, 앱 라우팅, API 호출 경로는 변경하지 않음 - -## 2. 적용한 한글 경로 - -- `docs/게시판/` -- `docs/도커빌드/` -- `docs/앱라우트.md` -- `docs/인증서비스-리팩토링가이드.md` -- `docs/CS프로젝트구조.md` -- `docs/프론트디버깅.md` -- `docs/개선계획.md` -- `docs/요구사항.md` -- `apps/sn/docs/AI코딩하네스/` -- `apps/sn/docs/AI코딩하네스/기준문서/` -- `apps/sn/docs/AI코딩하네스/작업계획/` -- `apps/sn/docs/AI코딩하네스/보고/` - -## 3. 파일명을 유지한 항목 - -- `docs/도커빌드/Dockerfile` -- `docs/도커빌드/front_docker_build.sh` -- `docs/도커빌드/next.config.ts` -- `docs/IIPBS_단어목록.md` - -## 4. 영향 없음 - -- Next.js 앱 실행 -- Turborepo 빌드 -- pnpm workspace 설정 -- Dockerfile, PM2, Gitea workflow -- API 계약과 백엔드 연동 경로 -- `apps/sn` 화면 라우팅 - -## 5. 주의 필요 - -- 외부 문서 링크가 기존 영문 경로를 직접 참조하는 경우 새 한글 경로로 수정 필요 -- CI 또는 배포 스크립트가 `docs/dockerbuild`를 직접 참조하는 경우 새 `docs/도커빌드` 경로로 수정 필요 -- 일부 콘솔에서는 한글 경로가 이스케이프 문자열로 보일 수 있음 -- URL로 공유되는 Markdown 링크는 한글 경로가 퍼센트 인코딩되어 길게 표시될 수 있음 - -## 6. 운영 기준 - -- 코드, 라우팅, 빌드, 배포 스크립트가 직접 참조하는 파일명은 영문 유지 권장 -- 문서 전용 폴더와 `.md` 파일명은 한글 사용 가능 -- 신규 `sn` 문서는 `apps/sn/docs/` 바로 아래 주제별 한글 폴더로 관리 -- `phase2` 같은 단계 분리 폴더는 사용하지 않음 diff --git a/apps/sn/docs/README.md b/apps/sn/docs/README.md deleted file mode 100644 index f88e3e658..000000000 --- a/apps/sn/docs/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# `sn` 앱 문서 - -## 목적 - -이 디렉터리는 하수관로 운영관리 시스템 `sn` 앱의 화면/기능별 산출물을 관리한다. -프론트엔드 공통 기준은 `FE_REPO_ROOT/docs/**`에 두고, `sn` 특정 화면과 기능 상세는 이 디렉터리에 둔다. - -## 작성 범위 - -- `sn` 화면별 요구사항 해석 -- 기존 화면 기반 컴포넌트 분해 -- 컴포넌트별 작업 목록 -- API 응답 필드 매핑 -- 화면 흐름 -- 검증 계획과 결과 -- 기능별 미확인사항 - -## 작성 금지 범위 - -- 다른 앱 전용 기능 상세 -- 백엔드 내부 구현 상세 -- 공통 프론트엔드 규칙 전체 - -## 기능별 문서 구조 - -기능별 문서는 아래 형식을 권장한다. - -```text -apps/sn/docs/{기능명}/ - README.md - 00-범위.md - 01-기존화면기반-컴포넌트분해.md - 02-컴포넌트별-작업목록.md - 03-API응답필드매핑표.md - 04-화면흐름.md - 05-검증계획.md - 06-검증결과.md - 99-미확인사항.md -``` - diff --git a/apps/sn/docs/노후관로관리/00-범위.md b/apps/sn/docs/노후관로관리/00-범위.md deleted file mode 100644 index 6456beda2..000000000 --- a/apps/sn/docs/노후관로관리/00-범위.md +++ /dev/null @@ -1,32 +0,0 @@ -# 노후관로관리 범위 - -## 목적 - -노후관로관리 및 노후관로분석 화면의 프론트엔드 구현 범위를 정의한다. - -## 포함 범위 - -- 노후관로 레이어 체크/표출 -- 좌측 하단 경과년도 설정 영역 -- 노후관로 목록 모달 -- 목록 행 더블클릭과 지도/상세 연동 -- 관로 상세정보 표시 -- 하수처리구역 기준 매설년도 분석정보 표시 -- 보수이력 진입 버튼 또는 링크 -- 엑셀 다운로드 버튼 연결 - -## 제외 범위 - -- 백엔드 API 신규 구현 -- DB 스키마 변경 -- 백엔드 공통 응답 구조 변경 -- `sn` 외 다른 앱 화면 변경 -- `business_components` 외 공통 패키지 직접 수정 - -## 기준 문서 - -- 기능 요구사항 명세서 v2.0 -- 시나리오 v2.0 -- `01-기존화면기반-컴포넌트분해.md` -- `02-컴포넌트별-작업목록.md` - diff --git a/apps/sn/docs/노후관로관리/01-기존화면기반-컴포넌트분해.md b/apps/sn/docs/노후관로관리/01-기존화면기반-컴포넌트분해.md deleted file mode 100644 index 624534fa9..000000000 --- a/apps/sn/docs/노후관로관리/01-기존화면기반-컴포넌트분해.md +++ /dev/null @@ -1,158 +0,0 @@ -# 노후관로관리 기존 화면 기반 컴포넌트 분해 - -## 목적 - -노후관로관리 2차 고도화 화면은 신규 디자인을 만들지 않고, 1차 개발 화면의 레이아웃과 컴포넌트 톤을 유지한다. -이 문서는 실제 `sn` 화면 코드 기준으로 화면을 어떤 단위로 나누어 개발해야 하는지 정리한다. - -## 기준 화면 구조 - -실제 화면은 아래 계층으로 구성된다. - -```text -NoFooterLayout - Header - LNB - MapLayout children - Gis - GisTree - GisMapToggle - GisToolBar - GisZoomLevel - ProcSearchBar - FeatureClickEventPopup -``` - -## 실제 참조 파일 - -| 구분 | 기준 파일 | 개발 기준 | -|---|---|---| -| 전체 레이아웃 | `packages/common/components/src/ui/layout/nofooter.tsx` | `Header` + `LNB` + 본문 3단 구조 유지 | -| 상단 헤더 | `packages/common/components/src/ui/header/index.tsx` | 흰색 헤더, 로고, 상단 시스템 메뉴, 검색, 사용자 영역 유지 | -| 좌측 메뉴 | `packages/common/components/src/ui/lnb/index.tsx` | `하수관망도`, `I/I 분석`, `운영정보` 계층 유지 | -| 지도 컴포넌트 | `packages/common/business_components/src/ui/map/Gis.js` | OpenLayers 기반 `Gis` 사용 | -| 시설물 트리 | `packages/common/business_components/src/ui/map/GisTree.js` | 폭 250px, 제목 `시설물`, 체크박스 트리 유지 | -| 지도 도구 | `packages/common/business_components/src/ui/map/GisToolBar.js` | 기존 우측 도구 버튼 위치/크기 유지 | -| 검색 바 | `packages/common/business_components/src/ui/map/searchBar` | 기존 지도 상단 검색 조건 영역 유지 | -| 상세 팝업 | `apps/sn/app/html/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx` | 우측 350px 팝업, 파란 헤더, `VerticalTable` 유지 | -| 세로 테이블 | `apps/sn/app/components/ui/verticalTable.tsx` | `bg-blue-100` 라벨셀, 2열 세로 테이블 유지 | -| 목록 그리드 | `packages/common/business_components/src/ui/table/index.tsx` | `UiTable` 기반 목록/페이징 유지 | - -## 레이아웃 기준값 - -| 영역 | 기준 | -|---|---| -| Header | 높이 `6rem`, 흰색 배경, 하단 border | -| LNB | 폭 `22rem`, 흰색 배경, `하수관망도` active | -| GisTree | 폭 `250px`, 지도 내부 좌측 absolute. 상단 레이어 트리와 하단 설정 영역을 같은 패널 안에서 자연스럽게 분리 | -| Map body | `calc(100vh - 50px)` 계열, `Gis` 영역 full | -| Detail popup | 우측 absolute, 폭 `350px`, 높이 `calc(100vh - 5rem)` | -| Detail header | `bg-primary`, 흰색 글자, 닫기 `✕` | -| Detail table | `VerticalTable`, 왼쪽 라벨셀 `bg-blue-100`, 오른쪽 값셀 흰색 | - -## 색상 및 폰트 기준 - -노후관로관리 추가 UI는 기존 우측 상세정보의 `FeatureClickEventPopup` + `VerticalTable` 색상과 폰트 톤을 따른다. -좌측 하단 설정 영역도 별도 신규 색을 쓰지 않고 같은 토큰을 사용한다. - -| 항목 | 기준값 | 사용 위치 | -|---|---|---| -| 기본 폰트 | `Noto Sans KR`, `Malgun Gothic` fallback | 전체 화면 | -| 기본 글자색 | `#111`, `#333`, `#555` | 본문, 트리, 입력 라벨 | -| Primary blue | `#258eea` | 우측 상세 헤더, 조회 버튼 등 기존 primary 영역 | -| Dark navy | `#152346` | 기존 강조 버튼 또는 별도 강조 영역이 필요한 경우만 사용 | -| Download green | `#1fd078` | 목록/엑셀 다운로드 버튼 | -| Label cell blue | `#dbeafe` | `VerticalTable` 왼쪽 라벨셀 | -| Table border | `#dbe2e6`, `#e5e7eb` | 세로 테이블/목록 테이블 구분선 | -| Panel border | `#d1d5db`, `#d7dde3` | 좌측 패널, 설정 영역 외곽 | -| Panel background | `#fff` | LNB, GisTree, 설정 영역, 상세 팝업 | -| Apply green | `#31a83b` | 좌측 하단 설정 영역의 적용 아이콘 | - -### 좌측 하단 설정 영역 색상 기준 - -- 외곽은 `Panel border`를 사용한다. -- 헤더는 진한 남색 바를 쓰지 않고, 위 `시설물` 패널처럼 흰 배경의 제목 행을 사용한다. -- 입력 라벨, 값, 적용 아이콘 폰트는 기존 `Noto Sans KR` 계열과 동일하게 둔다. -- 적용은 텍스트 버튼이 아니라 녹색 원형 체크 아이콘 형태로 표현한다. -- 트리 영역과 설정 영역 사이에는 굵은 검정선 대신 `#d1d5db` 계열의 얇은 경계와 여백을 사용한다. -- 목적은 강한 분리선이 아니라, 같은 좌측 패널 안에서 서로 다른 기능 영역임을 자연스럽게 구분하는 것이다. - -### 목록 모달 버튼 색상 기준 - -- `조회`는 공통 `Button`의 `primary` 톤을 따른다. -- `다운로드`는 공통 `Button`의 `green` 톤을 따른다. -- 모달 안에서 임의의 남색 버튼이나 흰색 outline 다운로드 버튼을 새로 만들지 않는다. -- 정적 HTML 샘플에서도 `조회=#258eea`, `다운로드=#1fd078` 조합으로 표현한다. - -## 화면설계서 번호 기준 영역 - -노후관로관리 화면은 화면설계서의 번호 기준대로 영역을 분리해서 봐야 한다. -특히 `경과년도(년)` 입력 영역은 지도 위에 뜨는 독립 팝업이 아니라, 좌측 `시설물` 트리 하단에 붙는 별도 설정 영역으로 본다. - -| 번호 | 화면 영역 | 위치 | 기존 컴포넌트 기준 | 동작 | -|---|---|---|---|---| -| ① | 노후관로 레이어 체크박스 | 좌측 `시설물` 트리 내부, `노후관로` 행 | `GisTree` 노드 | 체크 ON/OFF로 노후관로 레이어 표출 제어 | -| ② | 경과년도 설정 영역 | 좌측 `시설물` 트리 하단, 트리 목록 아래 별도 접힘/펼침 박스로 자연스럽게 분리 | `GisTree` 하단 확장 영역 또는 지도 오버레이가 아닌 좌측 설정 블록 | `경과년도(년)` 단일 입력 후 초록 체크 적용 | -| ③ | 지도 관로 선택 영역 | 지도 본문 | `Gis`, `GisSelect`, `onFeatureSelect` | 관로 클릭 시 우측 상세 팝업 표출 | -| ④ | 노후관로 목록 아이콘 | 좌측 `시설물` 트리 내부, `노후관로` 행 우측 | `GisTree`의 `onLayerIconClick`/`onOpenPopup` | 노후관로 현황 목록 모달 호출 | - -### 좌측 영역 분리 원칙 - -```text -GisTree (좌측 250px) - 시설물 제목 - 레이어 트리 영역 - 시설 - 하수관로 - ... - 노후관로 체크박스 ① - 노후관로 목록 아이콘 ④ - (여백) - 레이어별 설정 영역 ② - 제목 행: 지반침하정보 또는 대상 레이어명 - 입력 행: 경과년도(년) [30] [초록 체크] -``` - -이 구조를 따르면 화면설계서의 ①, ②, ④가 모두 좌측 `GisTree` 계열 안에서 구분된다. -따라서 노후관로 기준 패널을 지도 중앙이나 지도 좌하단의 독립 카드처럼 띄우지 않는다. -또한 트리와 설정 영역 사이를 검정 굵은 선으로 끊지 않고, 실제 화면처럼 여백과 연한 경계선으로 자연스럽게 구분한다. - -### 접힘/펼침 기준 - -- 상단 `시설물` 영역과 하단 설정 영역은 각각 접힘/펼침이 가능해야 한다. -- 접힘 상태에서는 제목 행만 남기고 내부 행은 숨긴다. -- 하단 설정 영역은 사용자가 현재 조정할 수 있는 입력만 보여준다. -- 화면설계서 예시처럼 `지반침하정보` 설정이면 `경과년도(년) [30] [초록 체크]`만 표시한다. -- 노후관로 기준을 같은 패턴으로 적용할 때도 입력 필드를 과도하게 늘리지 않고, 대상 레이어명과 경과년도 입력만 노출한다. - -## 노후관로관리 추가 시 분해 - -| 요구사항 | 기존 컴포넌트 기반 구현 위치 | -|---|---| -| 노후관로 레이어 ON/OFF | `GisTree` 레이어 노드 추가 또는 기존 하수관로 레이어 스타일 보강 | -| 경과년도 조건 | `GisTree` 하단의 접힘/펼침 가능한 레이어별 설정 영역으로 구성. `경과년도(년)` 단일 입력만 노출 | -| 목록 아이콘 | `GisTree`의 `노후관로` 행 우측 아이콘으로 배치하고 `onLayerIconClick`/`onOpenPopup` 흐름 활용 | -| 목록 팝업 | `UiTable` 기반 모달로 구성 | -| 지도-그리드 연동 | 기존 `onFeatureSelect`, `GisApp.Module.layer.showSearchResultsFromGeoJSON` 흐름 활용 | -| 상세정보 | `FeatureClickEventPopup` 또는 동일한 `VerticalTable` 구조로 확장 | -| 노후관로분석 | 하수처리구역 피처 클릭 시 기존 상세 팝업에 분석정보 섹션 추가 | - -## 금지 방향 - -- 별도 전체 화면 레이아웃을 새로 만들지 않는다. -- 어두운 헤더/새 사이드바/카드형 상세 패널 등 기존과 다른 디자인을 넣지 않는다. -- `경과년도(년)` 설정을 지도 위 플로팅 카드로 분리하지 않는다. -- 트리와 설정 영역 사이에 굵은 검정 구분선을 사용하지 않는다. -- 좌측 하단 설정 영역에 남색 헤더 바와 남색 텍스트 적용 버튼을 사용하지 않는다. -- 하단 설정 영역에 시도/시군구/목록 버튼을 넣지 않는다. -- `apps/sn/app/html/**` 정적 샘플을 실제 서비스 코드로 그대로 옮기지 않는다. -- 기존 `FeatureClickEventPopup`, `VerticalTable`, `UiTable`, `GisTree`로 가능한 UI를 별도 컴포넌트로 새로 만들지 않는다. - -## 개발 전 확인 순서 - -1. 실제 메뉴/라우트가 `NoFooterLayout` 하위에서 열리는지 확인한다. -2. 노후관로 레이어가 기존 `SN_NETWORK` 레이어 설정에 포함되는지 확인한다. -3. 하수관로 속성명(`instl_ymd`, `fclt_mng_no`, `mqlt_cd`, `usg_cd`)을 실제 API 응답과 맞춘다. -4. 상세 패널은 기존 `FeatureClickEventPopup` 확장 가능성을 먼저 검토한다. -5. 목록 팝업은 `UiTable`과 기존 검색 모달 패턴으로 구성한다. -6. `business_components` 수정이 필요한 경우 타 앱 영향 범위를 먼저 정리한다. diff --git a/apps/sn/docs/노후관로관리/02-컴포넌트별-작업목록.md b/apps/sn/docs/노후관로관리/02-컴포넌트별-작업목록.md deleted file mode 100644 index cf00b3c96..000000000 --- a/apps/sn/docs/노후관로관리/02-컴포넌트별-작업목록.md +++ /dev/null @@ -1,240 +0,0 @@ -# 노후관로관리 컴포넌트별 작업 목록 - -## 목적 - -이 문서는 노후관로관리 화면을 실제 React 코드로 옮기기 전에, 어떤 기존 컴포넌트를 재사용하고 어떤 컴포넌트만 최소 확장할지 정리한 작업 목록이다. -기준은 `01-기존화면기반-컴포넌트분해.md`와 `old-pipe-component-sample/index.html`의 최종 확인 화면이다. - -## 구현 원칙 - -- 새 디자인 시스템을 만들지 않는다. -- 실제 화면은 `NoFooterLayout > Header/LNB > Gis > GisTree/FeatureClickEventPopup/UiTable` 흐름을 따른다. -- 좌측 하단 설정 영역은 지도 위 플로팅 카드가 아니라, `GisTree` 내부 또는 `GisTree`와 붙은 좌측 패널 영역으로 구현한다. -- 좌측 하단 설정 영역에는 `경과년도(년)` 단일 입력과 초록 적용 아이콘만 둔다. -- 노후관로 현황 목록의 `조회`는 `Button color="primary"`, `다운로드`는 `Button color="green"` 톤을 따른다. -- 우측 상세정보는 기존 `FeatureClickEventPopup` + `VerticalTable` 톤을 우선한다. - -## 작업 단위 요약 - -| 순서 | 작업 단위 | 주요 파일 | 구분 | 목적 | -|---|---|---|---|---| -| 1 | 좌측 하단 설정 영역 확장 지점 확보 | `BUSINESS_COMPONENTS_ROOT/src/ui/map/Gis.js`, `GisTree.js` | 최소 공통 확장 후보 | `GisTree` 아래에 설정 패널을 넣을 수 있는 optional slot 확보 | -| 2 | 노후관로 설정 패널 정리 | `SN_APP_ROOT/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx` | 구조 변경 | 지도 오버레이/시도/시군구 제거, 경과년도 단일 입력 + 초록 체크로 변경 | -| 3 | 노후관로 페이지 상태/이벤트 정리 | `SN_APP_ROOT/app/(map-layout)/map/page.tsx` | 구조 변경 | `Gis`, `GisTree`, 윈도우 팝업, 상세 팝업 연동의 중심 상태 관리 | -| 4 | 목록 윈도우 팝업 정리 | `AgedPipePopupPage.tsx`, `/map/aged-pipe-popup` | 스타일/동작 보완 | 기존 `UiTable`, `Button primary/green`, 목록 아이콘 호출 흐름 정리 | -| 5 | 상세 패널 기존화 | `AgedPipeDetailPanel.tsx`, `FeatureClickEventPopup.tsx`, `VerticalTable` | 대체/확장 검토 | 별도 slate 카드형 상세를 제거하고 기존 세로 테이블 톤으로 통합 | -| 6 | 유틸/필터 정리 | `agedPipeUtils.ts` | 유지/보완 | 경과년도 산정, CQL 필터, 표시값 매핑 유지 | -| 7 | 처리구역 분석 확장 | `FeatureClickEventPopup.tsx` 또는 신규 `AgedPipeAreaAnalysisSection.tsx` | 추가 | 하수처리구역 클릭 시 평균/최장/최신 매설년도 섹션 추가 | -| 8 | 검증 | 브라우저, build, 네트워크 | 검증 | 화면/지도/목록/상세/다운로드 회귀 확인 | - -## 1. `Gis` / `GisTree` 확장 후보 - -### 현재 상태 - -- `Gis.js`는 내부에서 `GisTree`를 직접 렌더링한다. -- `GisTree.js`는 좌측 250px 패널 전체를 `grow overflow-y-auto` 트리 영역으로 사용한다. -- 현재 구조만으로는 트리 하단에 `경과년도(년) [30] [초록 체크]` 설정 박스를 자연스럽게 넣기 어렵다. - -### 적용 결정 - -- `sn` 전용 임시 overlay가 아니라 `Gis`/`GisTree`의 optional slot 방식으로 진행한다. -- prop 이름은 `layerSettingView`를 사용한다. -- prop을 넘기지 않는 기존 사용처는 기존과 동일하게 동작한다. - -### 작업 내용 - -- `Gis.js`에 optional prop을 추가한다. - - 이름: `layerSettingView` - - 기본값은 `undefined`로 두어 기존 앱 영향이 없게 한다. -- `GisTree.js`에 optional footer 영역을 렌더링한다. - - 상단 `시설물` 트리 영역과 하단 설정 영역을 같은 250px 패널 안에서 분리한다. - - 상단/하단 영역 모두 접힘/펼침 가능해야 한다. -- 기존 사용처는 prop을 넘기지 않으면 기존과 동일하게 동작해야 한다. - -### 주의사항 - -- `business_components` 수정이므로 타 앱 영향 가능성을 반드시 점검한다. -- className 변경은 최소화하고, optional footer가 없을 때 기존 DOM 구조/스타일을 최대한 유지한다. -- 이 공통 확장이 부담되면 `sn` 전용으로 `Gis` 주변에 좌측 하단 패널을 겹쳐 배치하는 대안을 검토할 수 있으나, 화면설계서처럼 같은 좌측 패널 안에서 보이려면 optional slot 방식이 더 자연스럽다. - -## 2. `AgedPipeFilterPanel.tsx` - -### 현재 상태 - -- 지도 위 플로팅 카드 형태다. -- `경과년수`, `시도`, `시군구`, `GIS 반영` 버튼을 갖고 있다. -- 최종 확인 기준과 다르다. - -### 변경 방향 - -- 이름은 유지하거나 `AgedPipeLayerSettingPanel.tsx`로 변경한다. -- 화면 구성은 아래처럼 단순화한다. - -```text -지반침하정보 또는 노후관로 - 경과년도(년) [30] [초록 체크] -``` - -### 작업 내용 - -- `ctpvCd`, `sggCd` 선택 UI 제거. -- `GIS 반영` 텍스트 버튼 제거. -- 초록 원형 체크 아이콘 버튼으로 적용 액션 처리. -- `title` prop을 받아 `지반침하정보`, `노후관로` 등 대상 레이어명을 표시할 수 있게 한다. -- `elapsedYears` 값만 직접 입력받는다. -- Enter 입력과 체크 버튼 클릭 모두 `onApply`를 호출한다. - -### props 후보 - -```ts -type AgedPipeLayerSettingPanelProps = { - title: string; - elapsedYears: number; - onElapsedYearsChange: (value: number) => void; - onApply: () => void; - defaultCollapsed?: boolean; -}; -``` - -## 3. `page.tsx` - -### 현재 상태 - -- `AgedPipeFilterPanel`을 `waterDepth` prop으로 넘겨 지도 오버레이처럼 표시한다. -- `AgedPipeDetailPanel`을 `detailView`로 사용한다. -- `getData`를 사용하지만 import가 보이지 않으므로 실제 빌드 전 확인이 필요하다. -- 샘플용 `DESIGN_PREVIEW_FEATURE`, 기본 목록 오픈 상태 등이 남아 있다. - -### 변경 방향 - -- `Gis`에 `treeFooterView` 또는 `layerSettingView`를 넘겨 좌측 하단 설정 영역으로 렌더링한다. -- `waterDepth` prop은 침수심 애니메이션 목적이므로 노후관로 설정 패널 용도로 쓰지 않는다. -- 상세는 `AgedPipeDetailPanel` 단독 사용보다 기존 `FeatureClickEventPopup` 확장 또는 동일 `VerticalTable` 구조를 우선한다. - -### 작업 내용 - -- `draftFilters`와 `appliedFilters`는 유지하되, 좌측 하단 패널에서는 `elapsedYears`만 수정한다. -- 시도/시군구는 목록 모달 조회조건에만 남긴다. -- 노후관로 목록 아이콘 클릭은 `GisTree`의 `onLayerIconClick`/`onOpenPopup` 흐름으로 연결한다. -- `handleTreePopup`은 `layerNm` 문자열 대신 실제 노드 정보 또는 `lyrTyNm` 기준으로 판정하는 방향을 검토한다. -- `DESIGN_PREVIEW_FEATURE`, 기본 `isListOpen=true`, 기본 `isDetailOpen=true`는 개발 완료 전 제거한다. -- `getData` import 누락 여부를 확인하고 실제 API 유틸 경로를 맞춘다. - -## 4. 노후관로 현황 윈도우 팝업 - -### 현재 상태 - -- 노후관로 현황은 `/map/aged-pipe-popup` Next 라우트를 별도 윈도우 팝업으로 연다. -- 기존 `about:blank`에 React/CSS를 주입하던 방식은 dev CSS/chunk 404 위험 때문에 사용하지 않는다. -- 팝업 페이지는 자체적으로 목록 API를 조회하고 `UiTable`로 렌더링한다. -- 행 선택은 `window.opener.postMessage`로 부모 `/map`에 전달하여 지도 이동/상세 패널 표시를 수행한다. - -### 변경 방향 - -- 지도 내부 Dialog/modal이 아니라 브라우저 밖 윈도우 팝업을 기본 동작으로 고정한다. -- 테이블은 현재처럼 `UiTable`을 계속 사용한다. -- 버튼 색상은 아래 기준으로 고정한다. - - 조회: `Button color="primary"` - - 다운로드: `Button color="green"` -- 팝업 내부 앱 버튼은 `최소화`만 유지한다. 닫기는 브라우저 창 X를 사용한다. - -### 작업 내용 - -- 헤더 색상을 기존 primary blue 톤과 맞춘다. -- `/map/aged-pipe-popup`는 공통 `NoFooterLayout` 없이 목록 화면만 렌더링한다. -- 조회조건은 명세서대로 `경과년도`, `시도`, `시군구` 유지. -- 그리드 기본 정렬은 `경과년도 DESC`. -- 행 클릭은 부모 `/map`에 row를 전달하고 기존 `showSearchResultsFromGeoJSON` 위치 이동 흐름을 사용한다. -- 좌측 `노후관로 설정` 적용 시 열린 팝업에는 `SN_AGED_PIPE_FILTER_SYNC` 메시지를 보내 필터와 목록을 동기화한다. -- 팝업 `최소화` 클릭 시 부모 `/map`에 최소화 메시지를 보내고, 부모는 지도 위 최소화 카드를 표시한다. -- 최소화 카드의 `열기`는 다시 `/map/aged-pipe-popup` 윈도우 팝업을 실행한다. -- 다운로드는 `downloadExcelWithKoreanHeaders` 호출 유지하되, 5만 건 이상 비동기 처리 여부는 백엔드 API 매핑 단계에서 확정한다. - -## 5. 상세 패널 - -### 현재 상태 - -- `AgedPipeDetailPanel.tsx`는 별도 slate 카드형 UI다. -- 실제 화면은 `FeatureClickEventPopup`의 파란 헤더 + `VerticalTable` 구조다. - -### 변경 방향 - -- 우선순위 1: `FeatureClickEventPopup`에 노후관로 경과년도 필드만 추가해서 재사용한다. -- 우선순위 2: `FeatureClickEventPopup` 재사용이 어렵다면 `AgedPipeDetailPanel`을 `VerticalTable`과 동일한 구조로 다시 만든다. - -### 작업 내용 - -- `AGED_PIPE_DETAIL_FIELDS`는 기존 `MsSwagePplCol` 기반을 유지한다. -- `경과년도` 계산 필드를 상세 테이블 마지막에 추가한다. -- 상세 헤더는 `하수관로 정보 / 관리번호 : {시설관리번호}` 흐름을 따른다. -- `침입수분석`, `RDII분석` 버튼은 기존 `FeatureClickEventPopup`의 하수관로 조건부 버튼을 그대로 사용한다. - -## 6. `agedPipeUtils.ts` - -### 유지할 항목 - -- `getElapsedYears` -- `getCutoffYmd` -- `buildAgedPipeCqlFilters` -- `matchesAgedPipeFilters` -- `toDisplayPipeRow` -- 코드 매핑: `MS_SWAGE_PPL_USE_CODES`, `MS_SWAGE_PPL_CODES` - -### 보완할 항목 - -- `AgedPipeFilters`는 전체 상태로는 `elapsedYears`, `ctpvCd`, `sggCd`를 유지하되 좌측 하단 설정 패널에서는 `elapsedYears`만 다룬다. -- 설치일자 NULL이면 `확인불가` 그룹으로 분류할 수 있는 유틸을 추가한다. -- 미래 설치일자 방어 로직을 추가한다. -- 엑셀 다운로드용 전체 필드 매핑은 API 매핑 단계에서 확정한다. - -## 7. 노후관로분석 화면 - -### 구현 위치 후보 - -- 기존 하수처리구역 클릭 상세: `FeatureClickEventPopup` -- 처리구역 전용 부가 섹션: 신규 `AgedPipeAreaAnalysisSection.tsx` - -### 작업 내용 - -- 하수처리구역 피처 클릭 시 기존 기본정보는 유지한다. -- 기본정보 아래에 `하수관로 분석정보` 섹션을 추가한다. -- 평균/최장/최신 매설년도는 `VerticalTable` 또는 기존 테이블 톤으로 표시한다. -- `보수이력` 링크/버튼은 기존 `Button` 톤을 사용한다. -- 처리구역 내 관로 0건이면 명세서 문구를 표시한다. - -## 8. 검증 목록 - -| 구분 | 확인 내용 | -|---|---| -| 화면 | Header/LNB/GisTree/상세 팝업이 기존 화면과 같은 위치와 톤인지 확인 | -| 좌측 설정 | `시설물` 영역과 하단 설정 영역이 각각 접힘/펼침 되는지 확인 | -| 입력 | 하단 설정 영역은 `경과년도(년)` 단일 입력만 노출되는지 확인 | -| 레이어 | 노후관로 체크 ON/OFF 시 지도 스타일만 바뀌고 기존 하수관로 레이어가 사라지지 않는지 확인 | -| 목록 | 노후관로 목록 아이콘 클릭 시 `/map/aged-pipe-popup` 윈도우 팝업이 열리는지 확인 | -| 버튼 | 목록 윈도우 팝업 `조회=primary`, `다운로드=green`인지 확인 | -| 상세 | 지도 관로 클릭 및 팝업 목록 행 클릭 시 기존 상세 팝업 톤으로 표시되는지 확인 | -| 다운로드 | 조회 결과 엑셀 다운로드가 동작하는지 확인 | -| 빌드 | `pnpm --filter @common/business_components run build`, `pnpm --filter sn run build` | - -## 권장 구현 순서 - -1. `AgedPipeFilterPanel`을 단일 입력 설정 패널로 먼저 정리한다. -2. `Gis`/`GisTree`에 optional footer slot을 추가할지, `sn` 전용 좌측 패널 조합으로 갈지 결정한다. -3. `page.tsx`에서 지도 오버레이 방식(`waterDepth`)을 제거하고 좌측 설정 영역으로 연결한다. -4. `/map/aged-pipe-popup` 윈도우 팝업을 기존 버튼/테이블 톤에 맞춘다. -5. 상세는 `FeatureClickEventPopup` 재사용 가능성을 먼저 구현 검토한다. -6. 노후관로분석의 처리구역 분석 섹션을 추가한다. -7. 브라우저에서 화면설계서 기준 ①~④ 동작을 확인한다. - -## 2026-04-23 추가 보완 작업 - -| 순서 | 보완 항목 | 주요 파일 | 처리 방향 | -|---|---|---|---| -| 1 | 상세 기본정보 헤더 경과년도 표시 | `FeatureClickEventPopup.tsx`, `agedPipeUtils.ts` | 하수관로 상세의 `기본정보` 옆에 `경과년도 n년` 배지를 표시한다. | -| 2 | 경과년도 인라인 입력 안정화 | `page.tsx`, `AgedPipeLayerSettingPanel.tsx`, `GisTree.js` | 입력 중 컴포넌트 재마운트로 포커스가 끊기지 않도록 inline setting 렌더링을 React node 방식으로 안정화한다. | -| 3 | 목록을 윈도우 팝업으로 변경 | `AgedPipePopupPage.tsx`, `/map/aged-pipe-popup` | Dialog modal 또는 지도 내부 fixed popup 대신 별도 Next 라우트를 브라우저 윈도우 팝업으로 표시한다. | -| 4 | 오수관/노후관로 색상 분리 | `tb_sn_lyr_stng`, `DB/001-노후관로-레이어설정.md` | 오수관은 주황색, 노후관로 강조선은 빨간색으로 구분한다. | -| 5 | sentinel 설치일자 제외 | `agedPipeUtils.ts` | `1900-01-01`, `1999-01-01`로 등록된 설치일자는 조회 CQL과 경과년도 계산에서 제외한다. | -| 6 | 노후관로 줌 레벨 기준 명시 | `page.tsx`, `agedPipeUtils.ts` | 노후관로 강조 레이어 표출 기준은 `minViewSize = 14`로 관리한다. | -| 7 | 윈도우 팝업 상태 연동 | `page.tsx`, `AgedPipePopupPage.tsx` | 좌측 설정 적용, 목록 행 선택, 최소화 상태를 `postMessage`로 부모 지도와 팝업 사이에 동기화한다. | -| 8 | 선택 관로 시각 강조 | `BUSINESS_COMPONENTS_ROOT/src/ui/map/lib/gis.select.js` | 선택된 선 피처에 다중 stroke(soft glow, white halo, violet core, highlight)를 적용한다. | diff --git a/apps/sn/docs/노후관로관리/03-API응답필드매핑표.md b/apps/sn/docs/노후관로관리/03-API응답필드매핑표.md deleted file mode 100644 index 3819b2da2..000000000 --- a/apps/sn/docs/노후관로관리/03-API응답필드매핑표.md +++ /dev/null @@ -1,73 +0,0 @@ -# 노후관로관리 API 응답 필드 매핑표 - -## 목적 - -노후관로관리 화면에서 사용할 API와 응답 필드를 화면 컴포넌트 기준으로 매핑한다. -이 문서는 다음 단계에서 상세 작성한다. - -## API 목록 - -| 구분 | Method | Endpoint | 사용 컴포넌트 | 상태 | -|---|---|---|---|---| -| 레이어 설정 | GET | `/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK` | `Gis`, `GisTree` | 기존 | -| 관로 목록 | GET | `v1/fclts?lyrTypeNm=tb_ms_swage_ppl&cqlFiter=...` | `AgedPipePopupPage`, `UiTable` | 기존 재사용, 백엔드 검증 필요 | -| 관로 상세 | GET | `v1/fcltppl/{fcltMngNo}/{mngInstCd}` | `FeatureClickEventPopup`, `VerticalTable` | 기존 재사용 | -| 엑셀 다운로드 | GET + 프론트 생성 | `v1/fclts?lyrTypeNm=tb_ms_swage_ppl&cqlFiter=...` | `AgedPipePopupPage` | 임시 재사용 | -| 위치 이동 | WFS/GeoServer | `tb_ms_swage_ppl.{fid}` | `GisApp.Module.layer.showSearchResultsFromGeoJSON` | `fid` 계약 검증 필요 | -| 처리구역 분석 | - | 미확정 | 분석정보 섹션 | 확인 필요 | -| 보수이력 | - | 미확정 | 보수이력 버튼/팝업 | 확인 필요 | - -## 조회 조건 매핑 - -| 조건 | 화면 기본값 | API 전달 방식 | 비고 | -|---|---:|---|---| -| 경과년도 | 30 | `instl_ymd <= '{오늘 기준 경과년도 차감일자}'` CQL | 사용자 지시 기준. 요구사항 원문/기존 문서의 20년과 차이가 있어 30년으로 보정 | -| 시도 | 전체 | `pbadms_emd_cd LIKE '{ctpvCd}%'` | 시군구가 전체일 때만 적용 | -| 시군구 | 전체 | `pbadms_emd_cd LIKE '{sggCd}%'` | 시군구 선택 시 시도 조건보다 우선 | - -### 제외 조건 - -설치일자가 실제 null 대신 sentinel 값으로 적재된 데이터는 노후관로 목록/강조선 조회에서 제외한다. - -| 제외 대상 | API 전달 방식 | 비고 | -|---|---|---| -| 설치일자 없음 | `instl_ymd IS NOT NULL` | CQL 기본 조건 | -| `1900-01-01` | `instl_ymd NOT LIKE '19000101%'` | 원본 `19000101 ` 형태 대응 | -| `1999-01-01` | `instl_ymd NOT LIKE '19990101%'` | null 대체값으로 등록된 데이터 제외 요청 반영 | - -## 필드 매핑 - -| 화면 영역 | 표시명 | 응답 필드 | 변환/코드매핑 | 빈 값 처리 | 비고 | -|---|---|---|---|---|---| -| 목록 | 시설관리번호 | `fclt_mng_no`, `fcltMngNo` | 문자열 | `-` | | -| 목록 | 관리기관코드 | `mng_inst_cd`, `mngInstCd` | 문자열 | `-` | 상세 API path 변수 | -| 목록 | GeoServer Feature ID | `fid` | 문자열 | 필수 | 목록 행 클릭 위치 이동 기준 | -| 목록 | 설치일자 | `instl_ymd`, `instlYmd` | 날짜 표시 | `확인불가` | 경과년도 산정 기준 | -| 목록 | 관경(mm) | `pdmt_vl`, `pdmtVl` | 숫자 | `-` | | -| 목록 | 연장(m) | `prlg_vl`, `prlgVl` | 숫자 | `-` | | -| 목록 | 용도 | `usg_cd`, `usgCd` | `MS_SWAGE_PPL_USE_CODES` | `-` | | -| 목록 | 재질 | `mqlt_cd`, `mqltCd` | `MS_SWAGE_PPL_CODES` | `-` | | -| 목록/상세 | 경과년도 | 설치일자 기반 계산 | `현재연도 - 설치연도` | `확인불가` | 백엔드 필드 여부 확인 필요 | - -## 현재 확정 - -- 노후관로 목록은 기존 `GET v1/fclts?lyrTypeNm=tb_ms_swage_ppl&cqlFiter=...`를 재사용한다. -- `cqlFiter` 오탈자 파라미터를 유지하되, 백엔드는 `cqlFilter`도 호환한다. -- 노후관로 전용 신규 API는 만들지 않는다. -- 상세 기본정보 헤더에는 설치일자 기반 경과년도를 배지 형태로 함께 표시한다. -- 노후관로 현황은 `/map/aged-pipe-popup` 윈도우 팝업에서 자체 조회한다. -- 부모 지도와 팝업 간 상태 동기화는 API가 아니라 `postMessage`로 처리한다. - - `SN_AGED_PIPE_ROW_SELECT`: 팝업 목록 행 선택 row를 부모 지도에 전달 - - `SN_AGED_PIPE_FILTER_SYNC`: 부모 좌측 설정의 적용 필터를 열린 팝업에 전달 - - `SN_AGED_PIPE_POPUP_MINIMIZE`: 팝업 최소화 상태를 부모 지도에 전달 - -## 미확인사항 - -- 기존 `v1/fclts`로 1차 구현은 가능하나 운영 기준 전용 API가 필요한지 백엔드에서 판단 필요 -- `cqlFiter` 파라미터명 오탈자 호환 여부 확인 필요 -- `cqlFiter`를 문자열로 받는 경우 허용 필드와 연산자 제한 필요 -- 경과년도 계산을 프론트에서 계속 할지, 백엔드 응답 필드로 받을지 확인 필요 -- `fid`가 GeoServer `FEATUREID`와 항상 일치하는지 확인 필요 -- `mngInstCd`가 없는 노후관로 행에서 상세 API를 어떻게 처리할지 확인 필요 -- 엑셀 다운로드는 현재 프론트 유틸로 1만 건까지 생성하며, 5만 건 이상 서버 비동기 파일 API 필요 여부 확인 필요 -- 처리구역 분석 통계를 프론트 계산으로 할지, 백엔드 API로 받을지 diff --git a/apps/sn/docs/노후관로관리/04-화면흐름.md b/apps/sn/docs/노후관로관리/04-화면흐름.md deleted file mode 100644 index 2a81730c7..000000000 --- a/apps/sn/docs/노후관로관리/04-화면흐름.md +++ /dev/null @@ -1,40 +0,0 @@ -# 노후관로관리 화면흐름 - -## 목적 - -사용자 이벤트와 화면 상태 변화를 정리한다. - -## SN_MN010102 노후관로관리 - -| 트리거 | 동작 | 결과 | -|---|---|---| -| 화면 진입 | `Gis` 초기화, `SN_NETWORK` 레이어 설정 조회 | 지도와 시설물 트리 표시 | -| 노후관로 체크 ON | 경과년도 기준에 맞는 관로 스타일 강조 | 지도에 노후관로 표출 | -| 좌측 하단 경과년도 적용 | 기준값 갱신, 레이어 스타일 재적용, 열린 윈도우 팝업에 필터 동기화 메시지 전송 | 지도/목록 조건 갱신 | -| 노후관로 목록 아이콘 클릭 | `/map/aged-pipe-popup` 윈도우 팝업 오픈 | `UiTable` 목록 표시 | -| 목록 조회 | 경과년도/시도/시군구 조건 조회 | 목록 갱신 | -| 목록 다운로드 | 조회 결과 다운로드 | 엑셀 또는 파일 응답 | -| 목록 행 클릭 | `postMessage`로 부모 지도에 row 전달 | 지도 위치 이동, `FeatureClickEventPopup` 표시 | -| 팝업 최소화 | 팝업에서 최소화 메시지 전송 후 창 닫기 | 지도 위 최소화 카드 표시 | -| 최소화 카드 열기 | `/map/aged-pipe-popup` 윈도우 팝업 재오픈 | 동일 조건의 목록 표시 | -| 지도 관로 클릭 | 피처 선택 | 상세정보 표시 | - -## 2026-04-23 보완 흐름 - -| 트리거 | 동작 | 결과 | -|---|---|---| -| 경과년도 입력 | 로컬 입력 상태만 변경 | 입력 중 포커스 유지 | -| 경과년도 적용 버튼 또는 Enter | 적용 조건 확정, CQL 재생성, 열린 팝업 목록 재조회 | 노후관로 강조 레이어와 윈도우 팝업 목록 갱신 | -| 목록 팝업 열기 | 실제 Next 라우트 `/map/aged-pipe-popup` 실행 | 브라우저 밖 윈도우 팝업으로 동작 | -| 상세 패널 표시 | 설치일자 기반 경과년도 계산 | `기본정보` 옆에 경과년도 배지 표시 | -| 노후관로 조회 | sentinel 설치일자 제외 조건 추가 | `1900-01-01`, `1999-01-01` 데이터 제외 | -| 노후관로 체크 ON | `minViewSize = 14` 기준 강조 레이어 추가 | 줌 레벨 14 이상에서 일반 노후관로 강조선 확인 | -| 선택 관로 표시 | 선 피처 선택 스타일 적용 | 다중 stroke로 보라색 선택선 강조 | - -## SN_MN010103 노후관로분석 - -| 트리거 | 동작 | 결과 | -|---|---|---| -| 화면 진입 | 하수처리구역 레이어 표시 | 분석 대상 구역 확인 | -| 처리구역 클릭 | 구역 기본정보 및 관로 분석정보 조회/계산 | 우측 상세 패널 표시 | -| 보수이력 클릭 | 보수이력 팝업 또는 화면 호출 | 이력 목록 표시 | diff --git a/apps/sn/docs/노후관로관리/05-검증계획.md b/apps/sn/docs/노후관로관리/05-검증계획.md deleted file mode 100644 index da4060510..000000000 --- a/apps/sn/docs/노후관로관리/05-검증계획.md +++ /dev/null @@ -1,39 +0,0 @@ -# 노후관로관리 검증계획 - -## 브라우저 검증 - -| 항목 | 확인 내용 | 상태 | -|---|---|---| -| 기존 레이아웃 | Header/LNB/GisTree/상세 패널 위치가 기존 화면과 같은지 확인 | 대기 | -| 좌측 설정 | 하단 경과년도 설정 영역이 좌측 패널 내부에 표시되는지 확인 | 대기 | -| 접힘/펼침 | 시설물 영역과 설정 영역이 각각 접히고 펼쳐지는지 확인 | 대기 | -| 노후관로 체크 | 체크 ON/OFF 시 레이어 강조가 바뀌는지 확인 | 대기 | -| 목록 윈도우 팝업 | 목록 아이콘 클릭 시 `/map/aged-pipe-popup`이 브라우저 밖 윈도우 팝업으로 열리는지 확인 | 대기 | -| 버튼 색상 | 조회 primary, 다운로드 green 톤인지 확인 | 대기 | -| 상세 연동 | 지도 클릭/팝업 목록 행 클릭 시 상세 패널 표시 | 대기 | -| 상세 경과년도 | 하수관로 상세 `기본정보` 옆에 경과년도 배지가 표시되는지 확인 | 대기 | -| 경과년도 입력 안정성 | 숫자 연속 입력 중 포커스가 유지되고 적용/Enter 후 조건이 반영되는지 확인 | 대기 | -| 설정-팝업 동기화 | 좌측 `노후관로 설정` 적용 시 열린 윈도우 팝업의 경과년도/목록이 같이 갱신되는지 확인 | 대기 | -| 팝업 최소화 | 윈도우 팝업의 `최소화` 클릭 시 지도 위 최소화 카드가 표시되고 `열기`로 다시 윈도우 팝업이 열리는지 확인 | 대기 | -| 설치일자 제외 | `1900-01-01`, `1999-01-01` 데이터가 목록/강조선 조회에서 제외되는지 확인 | 대기 | -| 색상 구분 | 오수관은 주황색, 노후관로 강조선은 빨간색으로 구분되는지 확인 | 대기 | -| 줌 레벨 | 줌 레벨 14 이상에서 노후관로 강조선이 표시되는지 확인 | 대기 | -| 선택 관로 강조 | 선택된 관로가 soft glow/white halo/violet core 다중 stroke로 지도 위에서 명확히 보이는지 확인 | 대기 | -| dev CSS/chunk 404 | 팝업 실행 후 `/_next/static/css/app/layout.css?...` 404 반복 호출이 발생하지 않는지 확인 | 대기 | - -## 빌드 검증 - -```powershell -pnpm --filter @common/business_components run build -pnpm --filter sn run build -``` - -`business_components`를 수정하지 않는 경우 `sn` 빌드만 우선 수행한다. - -## 네트워크 검증 - -- 레이어 설정 조회 -- 관로 목록 조회 -- 관로 상세 조회 -- 다운로드 요청 -- 처리구역 분석 요청 또는 계산용 데이터 조회 diff --git a/apps/sn/docs/노후관로관리/06-검증결과.md b/apps/sn/docs/노후관로관리/06-검증결과.md deleted file mode 100644 index d83e549e7..000000000 --- a/apps/sn/docs/노후관로관리/06-검증결과.md +++ /dev/null @@ -1,114 +0,0 @@ -# 노후관로관리 검증결과 - -## 2026-04-22 구현 후 빌드 검증 - -| 구분 | 명령 | 결과 | 비고 | -|---|---|---|---| -| 공통 비즈니스 컴포넌트 | `pnpm --filter @common/business_components run build` | 성공 | `Gis`, `GisTree` optional 확장 포함 | -| sn 앱 | `pnpm --filter sn run build` | 성공 | Next 컴파일/페이지 생성 성공 | - -## 남은 검증 - -- 브라우저에서 `/map` 진입 후 실제 `SN_NETWORK` 응답에 `노후관로` 노드가 내려오는지 확인 -- 노후관로 체크 ON/OFF 시 좌측 하단 설정 컴포넌트 표시/숨김 확인 -- 노후관로 목록 아이콘 클릭 시 현황 모달 표시 확인 -- `v1/fclts`의 `cqlFiter` 조건이 경과년도/시도/시군구 조합대로 동작하는지 네트워크 확인 -- 지도 halo 강조가 기존 하수관로 선 색상을 덮지 않는지 브라우저 확인 -- 목록 행 더블클릭 시 지도 이동과 `FeatureClickEventPopup` 상세 표시 확인 - -## 특이사항 - -- `sn` 빌드 마지막 단계에서 Windows symlink 권한으로 standalone trace 복사 경고가 발생했지만, Next 빌드 자체는 성공했다. - -## 2026-04-23 보완 검증 - -| 구분 | 확인 내용 | 결과 | 비고 | -|---|---|---|---| -| 상세 경과년도 | 하수관로 상세 `기본정보` 헤더 옆 경과년도 배지 추가 | 반영 | `FeatureClickEventPopup`에서 `elapsedYears` 계산값 표시 | -| 경과년도 입력 안정화 | 좌측 인라인 설정 입력 중 재마운트 방지 | 반영 | `GisTree`가 React node inline view를 렌더링하도록 보완 | -| 목록 윈도우 팝업 | 노후관로 현황을 Dialog modal 대신 드래그 가능한 fixed popup으로 표시 | 반영 | `AgedPipeListModal`에서 헤더 pointer drag 처리 | -| sentinel 설치일자 제외 | `1900-01-01`, `1999-01-01` 데이터 제외 | 성공 | CQL 조건 API 200, 결과 첫 5건 sentinel 미포함 | -| 오수관 색상 | 일반 오수관 색상을 주황색으로 변경 | 성공 | `lyr_sn=6`, `line_colr=rgba(255, 128, 0, 1)` 확인 | -| 노후관로 줌 레벨 | 노후관로 강조 레이어 최소 줌 기준 | 확인 | `AGED_PIPE_MIN_ZOOM=14`, `minViewSize=14` | -| 공통 컴포넌트 빌드 | `business_components` 빌드 | 성공 | `pnpm --filter @common/business_components run build` | -| `sn` 앱 빌드 | `sn` 빌드 | 성공 | Next 컴파일/페이지 생성 성공. standalone symlink 권한 경고 발생 | - -## 2026-04-23 추가 UX 보완 검증 - -| 구분 | 확인 내용 | 결과 | 비고 | -|---|---|---|---| -| 브라우저 밖 팝업 | 노후관로 현황을 열 때 처음부터 별도 창으로 표시 | 반영 | 사용자 클릭 시점에 `/map/aged-pipe-popup` Next 페이지를 `window.open`으로 실행 | -| 새 창 렌더링 | 별도 창을 실제 Next 라우트로 분리 | 반영 | `about:blank`에 React/CSS를 주입하던 방식 제거. `/map/aged-pipe-popup`에서 목록 자체 조회/렌더링 | -| 팝업 동작 기준 | 노후관로 현황 기본 동작을 별도 윈도우 팝업으로 고정 | 반영 | `새 창`/`지도 안으로` 전환 버튼 제거. 팝업 내부 앱 버튼은 `최소화`만 유지 | -| 새 창 CSS 404 방지 | 새 창 CSS 복제 방식 제거 | 반영 | `/_next/static/css/app/layout.css?...` dev 404 반복 호출 방지를 위해 stylesheet 복사/주입 구조 제거 | -| 목록 행 선택 연동 | 새 창 목록 행 클릭 시 부모 지도 화면으로 선택 row 전달 | 반영 | `window.opener.postMessage`로 row를 전달하고 부모 `/map`에서 위치 이동/상세 패널 표시 | -| 최소화 카드 기준 | 팝업에서 `최소화` 클릭 시 지도 위 최소화 카드 표시 | 반영 | 최소화 카드의 `열기`는 다시 `/map/aged-pipe-popup` 윈도우 팝업을 실행 | -| 지도 설정-팝업 필터 동기화 | 좌측 `노후관로 설정` 적용 시 열린 윈도우 팝업 목록도 재조회 | 반영 | 부모 `/map`에서 `SN_AGED_PIPE_FILTER_SYNC` 메시지 전송, 팝업에서 필터/목록 1페이지 갱신 | -| 선택 관로 시각 강조 | 선택된 선 피처 스타일 개선 | 반영 | soft violet glow, white halo, violet core, lavender highlight 다중 stroke 적용 | -| dev CSS/chunk 404 복구 | dev 서버 중 `next build` 실행으로 `.next` 산출물 혼재 발생 | 조치 | `apps/sn/.next` 삭제 후 dev 서버 재시작 필요. dev 실행 중에는 `next build` 병행 금지 | -| 줌 레벨 기준 | 노후관로 강조 레이어 표시 시작 줌 레벨 | 확인 | `AGED_PIPE_MIN_ZOOM=14`, `minViewSize=14` | -| 지도 표시 원복 | 추가 안내 배지/범례/강조선 스타일 변경 제거 | 반영 | `lnWdth=4`, `lnColor=rgba(235, 40, 40, 0.82)` 유지 | -| 서버 상태 | 백엔드/프론트 포트 확인 | 성공 | `localhost:8883`, `localhost:3002` TCP 연결 성공 | -| `/map` 응답 | 프론트 `/map` HTTP 응답 확인 | 성공 | `GET http://localhost:3002/map` 200 OK | -| `sn` 앱 빌드 | 변경 후 `sn` 빌드 | 성공 | Next 컴파일/페이지 생성 성공. standalone symlink 권한 경고 발생 | -| TypeScript 검사 | `pnpm --filter sn exec tsc --noEmit --pretty false` | 기존 오류 잔존 | 이번 변경 신규 오류는 해소. 기존 `common/index.ts`, `html/(with-base-layout)`, chart implicit any 오류 잔존 | - -### 브라우저 검증 제약 - -- 현재 workspace에 Playwright CLI가 설치되어 있지 않아 자동 브라우저 조작 검증은 수행하지 못했다. -- 실행 중인 Chrome은 remote debugging port가 열려 있지 않았다. -- Headless Chrome 스크린샷은 임시 브라우저 세션 특성상 사용자 로그인/지도 리소스 상태를 재현하지 못해 최종 UI 증적으로 사용하지 않았다. - -### API 검증 - -```text -GET /sewerage-service/api/v1/fclts - ?lyrTypeNm=tb_ms_swage_ppl - &cqlFiter=instl_ymd IS NOT NULL - and instl_ymd NOT LIKE '19000101%' - and instl_ymd NOT LIKE '19990101%' - and instl_ymd <= '19960423' - &page=0 - &size=5 -``` - -결과: - -```text -status=200 -count=735119 -첫 5건 설치일자: 19730731, 19830331, 19690630, 19870509, 19841020 -``` - -### DB 검증 - -```text -lyr_sn=6 -lyr_nm=오수관 -line_colr=rgba(255, 128, 0, 1) -``` - -## 검증 개요 - -| 항목 | 내용 | -|---|---| -| 검증 일자 | 2026-04-22 | -| 검증자 | Codex | -| 대상 화면 | SN_MN010102, SN_MN010103 | -| 실행 URL | 빌드 검증 기준 | -| 브라우저 | 미수행 | - -## 검증 결과 - -| 항목 | 결과 | 증적 | 비고 | -|---|---|---|---| -| 공통 비즈니스 컴포넌트 빌드 | 성공 | `pnpm --filter @common/business_components run build` | `startOfMonth` unused 경고는 기존 빌드 경고로 보임 | -| `sn` 앱 빌드 | 성공 | `pnpm --filter sn run build` | 종료 코드 0. Windows symlink 권한 경고 발생 | -| 타입 검사 | 부분 실패 | `pnpm --filter sn exec tsc --noEmit` | 신규 `layerSettingView` 관련 오류는 해소. 기존 `SwtrmtFclt`, `with-base-layout`, chart implicit any 오류 잔존 | -| HTML/코드 공백 검사 | 성공 | `git diff --check` | 관련 수정 파일 기준 | - -## 남은 리스크 - -- 브라우저에서 `/map` 실제 화면 조작 검증은 사용자가 최종 확인해야 한다. -- `tsc --noEmit`의 기존 타입 오류는 별도 정리가 필요하다. -- Next standalone trace 중 symlink 권한 경고가 있어 Windows 개발 환경 권한 확인이 필요하다. diff --git a/apps/sn/docs/노후관로관리/07-백엔드-착수프롬프트.md b/apps/sn/docs/노후관로관리/07-백엔드-착수프롬프트.md deleted file mode 100644 index d3179e6c6..000000000 --- a/apps/sn/docs/노후관로관리/07-백엔드-착수프롬프트.md +++ /dev/null @@ -1,55 +0,0 @@ -# 노후관로관리 백엔드 착수 프롬프트 - -아래 프롬프트는 새 스레드에서 백엔드 작업을 시작할 때 그대로 사용한다. - -```text -WORKSPACE_ROOT=C:\mindone_project\iipbs-workspace 기준으로 작업해줘. - -프론트 노후관로관리 1차 구현은 마무리했고, 이제 sewerage-service 백엔드 계약 정리와 보강을 시작하려고 한다. - -반드시 먼저 아래 AGENTS.md를 확인해줘. -- WORKSPACE_ROOT/AGENTS.md -- WORKSPACE_ROOT/IIPBS/sewerage-service/AGENTS.md -- WORKSPACE_ROOT/frontend/apps/sn/AGENTS.md - -이번 백엔드 작업의 기준 문서는 아래야. -- IIPBS/sewerage-service/docs/AI코딩하네스/작업계획/001-BE-P2-노후관로관리-작업카드.md -- frontend/apps/sn/app/html/06. 노후관로관리_시나리오_v1.0.md -- frontend/apps/sn/docs/노후관로관리/03-API응답필드매핑표.md -- frontend/apps/sn/docs/노후관로관리/99-미확인사항.md - -현재 프론트에서 확정된 동작은 다음과 같아. -1. /map 좌측 레이어 트리 최상위 순서는 시설, 침수위험지도(중점관리지역), 도시침수지도(정보제공포털), 노후관로 순서다. -2. 노후관로는 기존 하수관로 시설물 레이어와 별도 제어 항목이다. -3. 노후관로 체크 시 경과년도/지역 조건에 맞는 하수관로를 지도에 빨간 강조선으로 표출한다. -4. 노후관로 항목 바로 아래 인라인 설정으로 경과년도(기본 30년)를 입력한다. -5. 노후관로 목록은 GET v1/fclts?lyrTypeNm=tb_ms_swage_ppl&cqlFiter=... 를 현재 재사용한다. -6. 목록 행 클릭 시 GeoServer FEATUREID(fid)로 위치 이동하고, GET v1/fcltppl/{fcltMngNo}/{mngInstCd} 상세를 우측 패널에 표시한다. -7. 목록 필수 필드는 fid, fcltMngNo/fclt_mng_no, mngInstCd/mng_inst_cd, instlYmd/instl_ymd, pdmtVl/pdmt_vl, prlgVl/prlg_vl, usgCd/usg_cd, mqltCd/mqlt_cd 이다. - -백엔드에서 우선 확인/정리할 것은 다음이야. -1. GET /api/v1/fclts 구현 위치와 파라미터 처리 방식 확인 -2. cqlFiter 파라미터명 오탈자 호환 여부 확인 또는 cqlFilter와의 호환 처리 방안 제안 -3. CQL 문자열 직접 전달 시 허용 필드/연산자 제한 또는 안전한 검색 DTO로 전환 가능성 검토 -4. instl_ymd <= cutoff, pbadms_emd_cd LIKE 조건이 tb_ms_swage_ppl 기준으로 정상 동작하는지 확인 -5. 목록 응답 fid가 GeoServer FEATUREID로 사용 가능한지 확인 -6. mngInstCd 누락 행이 있을 때 상세 API 처리 방식 확인 -7. tb_sn_lyr_stng에 정식 노후관로 레이어 설정을 넣는 것이 맞는지 분석 -8. 필요하면 노후관로 전용 read-only API를 설계하되 기존 프론트/기존 API 계약은 깨지 않게 해줘. - -작업 방식: -- 먼저 코드와 문서를 읽고 영향 범위를 정리해줘. -- DB 스키마 변경은 기본적으로 하지 말고, 필요한 경우 이유/영향/롤백을 먼저 정리해줘. -- 백엔드 변경이 필요하면 최소 범위로 구현해줘. -- 가능한 테스트나 API 검증 방법을 함께 제시해줘. - -결과 보고는 아래 형식으로 해줘. -- 작업 목적 -- 수정 대상 파일 -- 변경 내용 요약 -- 영향 범위 -- 테스트 / 검증 방법 -- 롤백 방법 -- 남은 리스크 -``` - diff --git a/apps/sn/docs/노후관로관리/99-미확인사항.md b/apps/sn/docs/노후관로관리/99-미확인사항.md deleted file mode 100644 index 20f025ff8..000000000 --- a/apps/sn/docs/노후관로관리/99-미확인사항.md +++ /dev/null @@ -1,27 +0,0 @@ -# 노후관로관리 미확인사항 - -| 일자 | 구분 | 내용 | 확인 필요 대상 | 상태 | -|---|---|---|---|---| -| 2026-04-22 | BE | `v1/fclts`의 `cqlFiter` 파라미터명 오탈자 호환 여부 | BE | 확인 필요 | -| 2026-04-22 | BE | CQL 문자열 직접 전달 방식의 허용 필드/연산자 제한 여부 | BE | 확인 필요 | -| 2026-04-22 | BE | 목록 응답 `fid`가 GeoServer `FEATUREID` 위치 이동에 항상 사용 가능한지 | BE/GeoServer | 확인 필요 | -| 2026-04-22 | BE | `mngInstCd` 누락 행의 상세 API 처리 방식 | BE | 확인 필요 | -| 2026-04-22 | BE | `tb_sn_lyr_stng`에 정식 노후관로 레이어 설정을 추가할지 여부 | BE/DB | 확인 필요 | -| 2026-04-22 | BE | 대량 엑셀 다운로드를 프론트 생성으로 유지할지 서버 파일 API로 전환할지 | BE/FE | 확인 필요 | -| 2026-04-23 | FE | 상세 패널 기본정보 헤더에 노후관로 경과년도 표시 | FE | 반영 | -| 2026-04-23 | FE | 경과년도 입력 중 포커스 끊김/적용 누락 문제 | FE | 반영, 브라우저 검증 필요 | -| 2026-04-23 | FE | 노후관로 현황 목록을 모달이 아닌 윈도우형 팝업으로 표시 | FE | 반영, 브라우저 검증 필요 | -| 2026-04-23 | DB/FE | 오수관 색상을 주황색으로 변경하고 노후관로 빨간 강조선과 구분 | DB/FE | DB 반영 | -| 2026-04-23 | FE | `1900-01-01`, `1999-01-01` sentinel 설치일자 제외 | FE | 반영, API 검증 필요 | -| 2026-04-23 | FE | 노후관로 강조 레이어 표출 줌 레벨 기준 | FE | `minViewSize=14` 확인 | -| 2026-04-23 | FE | 윈도우 팝업과 지도 좌측 설정의 경과년도 조건 동기화 | FE | 반영, 브라우저 검증 필요 | -| 2026-04-23 | FE | 팝업 최소화/복원 기준 | FE | 팝업 `최소화` 클릭 시만 최소화 카드 표시로 반영 | -| 2026-04-23 | FE | 선택 관로 보라색 강조선 시각 개선 | FE/공통컴포넌트 | 반영, 브라우저 검증 필요 | - -## 우선 확인 대상 - -- 기존 `v1/fclts`의 필터 조건으로 경과년도/지역 조회를 모두 처리할 수 있는지 -- 경과년도 기준값 저장/로그 API가 필요한지 -- 처리구역 분석 통계를 프론트 계산으로 할지 백엔드 API로 받을지 -- 엑셀 다운로드 5만 건 이상 처리 방식 -- 지반침하정보도 노후관로와 같은 인라인 설정 확장 포인트를 사용하되, 백엔드 API/레이어 설정은 별도 확정 필요 diff --git a/apps/sn/docs/노후관로관리/README.md b/apps/sn/docs/노후관로관리/README.md deleted file mode 100644 index d7c1a18e7..000000000 --- a/apps/sn/docs/노후관로관리/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# 노후관로관리 문서 - -## 목적 - -노후관로관리 및 노후관로분석 화면을 `sn` 기존 화면/컴포넌트 기준으로 구현하기 위한 기능별 산출물 묶음이다. - -## 대상 화면 - -| 화면 ID | 화면명 | 메뉴 경로 | -|---|---|---| -| `SN_MN010102` | 노후관로관리 | 하수관망도 > 노후관로관리 | -| `SN_MN010103` | 노후관로분석 | 하수관망도 > 노후관로분석 | - -## 문서 순서 - -| 문서 | 역할 | -|---|---| -| `00-범위.md` | 기능 범위와 제외 범위 | -| `01-기존화면기반-컴포넌트분해.md` | 기존 화면 기준 영역/컴포넌트/색상 분해 | -| `02-컴포넌트별-작업목록.md` | 실제 구현 전 작업 단위 목록 | -| `03-API응답필드매핑표.md` | API와 화면 표시 필드 매핑 | -| `04-화면흐름.md` | 사용자 이벤트와 화면 상태 흐름 | -| `05-검증계획.md` | 구현 후 검증 계획 | -| `06-검증결과.md` | 검증 수행 결과 | -| `99-미확인사항.md` | 남은 질문과 의사결정 대기 항목 | - -## 작성 기준 - -- 실제 React 구현은 기존 `NoFooterLayout`, `Gis`, `GisTree`, `FeatureClickEventPopup`, `VerticalTable`, `UiTable` 흐름을 우선한다. -- 백엔드 상세 구현은 작성하지 않고, 필요한 API/필드/응답 사용 방식만 매핑한다. -- 화면설계서와 다른 독립 디자인을 만들지 않는다. - From 4644c0ba3abf2d345b77c86314ae8ca9658137d9 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Tue, 28 Apr 2026 09:44:10 +0900 Subject: [PATCH 06/11] =?UTF-8?q?=EB=85=B8=ED=9B=84=EA=B4=80=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=EA=B3=B5=ED=86=B5=20=ED=8C=9D=EC=97=85=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/(map-layout)/layout.tsx | 5 --- .../(map-layout)/map/agedPipePopup/page.tsx | 34 +++++++++++++++---- apps/sn/app/(map-layout)/map/page.tsx | 8 ++--- .../windowPopup/model/popupDefinitions.ts | 6 ++++ apps/sn/src/features/windowPopup/registry.tsx | 5 +++ .../windowPopup/ui/AgedPipePopupContent.tsx | 10 ++++++ 6 files changed, 50 insertions(+), 18 deletions(-) create mode 100644 apps/sn/src/features/windowPopup/ui/AgedPipePopupContent.tsx diff --git a/apps/sn/app/(map-layout)/layout.tsx b/apps/sn/app/(map-layout)/layout.tsx index 01d2b166a..f840fa131 100644 --- a/apps/sn/app/(map-layout)/layout.tsx +++ b/apps/sn/app/(map-layout)/layout.tsx @@ -33,7 +33,6 @@ export default function MapLayout({ const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo); const { fetchCtpv, fetchSgg, fetchBIZ_SE_CD, fetchBIZ_TY_CD } = useStore(); - const isPopupPage = pathname === "/map/aged-pipe-popup"; useEffect(() => { document.body.classList.add("sn-map-page-lock"); @@ -55,10 +54,6 @@ export default function MapLayout({ router.push(item.urlPath); }; - if (isPopupPage) { - return <>{children}; - } - return ( - - +type LegacyAgedPipePopupPageProps = { + searchParams: Promise>; +}; + +export default async function LegacyAgedPipePopupPage({ + searchParams, +}: LegacyAgedPipePopupPageProps) { + const resolvedSearchParams = await searchParams; + const nextSearchParams = new URLSearchParams(); + + Object.entries(resolvedSearchParams).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach((item) => nextSearchParams.append(key, item)); + return; + } + + if (typeof value === "string") { + nextSearchParams.set(key, value); + } + }); + + const queryString = nextSearchParams.toString(); + redirect( + queryString + ? `/popup/agedPipePopup?${queryString}` + : "/popup/agedPipePopup", ); } diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index afbb91c47..cbbea24b5 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -45,6 +45,7 @@ import { type AgedPipeRow, AGED_PIPE_DEFAULT_FILTERS, } from "./components/agedPipe/agedPipeUtils"; +import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; const AGED_PIPE_CONTROL_NODE_ID = "SN_AGED_PIPE"; @@ -345,16 +346,11 @@ const Map = () => { }, [draftAgedPipeFilters, isAgedPipeListOpen, loadAgedPipeList, syncAgedPipePopupFilters]); const openAgedPipePopupWindow = useCallback(() => { - const popupParams = new URLSearchParams({ + const popup = openWindowPopup("agedPipePopup", { elapsedYears: String(appliedAgedPipeFilters.elapsedYears), ctpvCd: appliedAgedPipeFilters.ctpvCd, sggCd: appliedAgedPipeFilters.sggCd, }); - const popup = window.open( - `/map/aged-pipe-popup?${popupParams.toString()}`, - "sn-aged-pipe-list", - "popup=yes,width=1180,height=760,left=120,top=80,resizable=yes,scrollbars=no", - ); if (!popup) { alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts index b3184650d..b288fb969 100644 --- a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -67,6 +67,12 @@ export const windowPopupDefinitions = { height: 980, requiredParams: ["chmbrXcnts", "chmbrYdnts"], }, + agedPipePopup: { + key: "agedPipePopup", + title: "노후 하수관로 관리 ", + width: 1180, + height: 760, + }, } as const satisfies Record; export type WindowPopupKey = keyof typeof windowPopupDefinitions; diff --git a/apps/sn/src/features/windowPopup/registry.tsx b/apps/sn/src/features/windowPopup/registry.tsx index f8b247744..7343e26b7 100644 --- a/apps/sn/src/features/windowPopup/registry.tsx +++ b/apps/sn/src/features/windowPopup/registry.tsx @@ -1,5 +1,6 @@ import { windowPopupDefinitions, type WindowPopupKey } from "./model/popupDefinitions"; import type { WindowPopupRegistryItem } from "./model/types"; +import AgedPipePopupContent from "./ui/AgedPipePopupContent"; import FloodPreTablePopupContent from "./ui/FloodPreTablePopupContent"; import FloodPreUploadPopupContent from "./ui/FloodPreUploadPopupContent"; import InfilChartPopupContent from "./ui/InfilChartPopupContent"; @@ -52,4 +53,8 @@ export const windowPopupRegistry = { ...windowPopupDefinitions.sscDetail, component: SscDetailPopupContent, }, + agedPipePopup: { + ...windowPopupDefinitions.agedPipePopup, + component: AgedPipePopupContent, + }, } as const satisfies Record; diff --git a/apps/sn/src/features/windowPopup/ui/AgedPipePopupContent.tsx b/apps/sn/src/features/windowPopup/ui/AgedPipePopupContent.tsx new file mode 100644 index 000000000..9ff897803 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/AgedPipePopupContent.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react"; +import AgedPipePopupPage from "@/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage"; + +export default function AgedPipePopupContent() { + return ( + + + + ); +} From c6575a81821c8577944160dfb564ab287a6a0f58 Mon Sep 17 00:00:00 2001 From: mind-one001 Date: Tue, 28 Apr 2026 15:04:08 +0900 Subject: [PATCH 07/11] =?UTF-8?q?=EC=B2=98=EB=A6=AC=EA=B5=AC=EC=97=AD=20?= =?UTF-8?q?=20=EA=B4=80=EB=A1=9C=EB=B6=84=EC=84=9D=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/AGENTS.md | 3 + .../map/components/common/index.ts | 6 +- .../components/popupComponent/SwtrmtFclt.tsx | 4 +- .../popupComponent/SwtrmtPrcsZoneAnalysis.tsx | 161 ++++++++++++++++++ .../popupComponent/featureClickEventPopup.tsx | 3 +- .../swtrmtPrcsZoneAnalysisApi.ts | 99 +++++++++++ apps/sn/app/page.tsx | 44 ++--- apps/sn/docs/기준문서/003-API사용.md | 4 +- apps/sn/docs/기준문서/005-상태관리정책.md | 8 +- .../docs/기준문서/012-코드리뷰체크리스트.md | 4 + .../sn/src/entities/auth/lib/token-manager.ts | 2 +- 11 files changed, 311 insertions(+), 27 deletions(-) create mode 100644 apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx create mode 100644 apps/sn/app/(map-layout)/map/components/popupComponent/swtrmtPrcsZoneAnalysisApi.ts diff --git a/apps/sn/AGENTS.md b/apps/sn/AGENTS.md index 597a7af77..de337921b 100644 --- a/apps/sn/AGENTS.md +++ b/apps/sn/AGENTS.md @@ -178,6 +178,9 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작 ## 빌드 및 검증 기준 +VSCode나 별도 터미널에서 `sn` Next 개발 서버가 실행 중이면 `next build`, `pnpm --filter sn run build`, `pnpm turbo run build --filter=sn` 같은 빌드 명령을 실행하지 않는다. +개발 서버와 빌드가 동시에 `.next` 산출물을 갱신하면 개발 서버가 종료되거나 꼬일 수 있으므로, 빌드 검증은 사용자에게 확인한 뒤 개발 서버를 끄고 수행한다. + 이 저장소는 `main` 브랜치 push 전에 `.husky/pre-push`에서 아래 빌드를 수행한다. ```sh diff --git a/apps/sn/app/(map-layout)/map/components/common/index.ts b/apps/sn/app/(map-layout)/map/components/common/index.ts index d2aed5956..dd20952b3 100644 --- a/apps/sn/app/(map-layout)/map/components/common/index.ts +++ b/apps/sn/app/(map-layout)/map/components/common/index.ts @@ -4,6 +4,7 @@ import smh from "../popupComponent/smh"; import Ssc from "../popupComponent/Ssc"; import StormWaterPump from "../popupComponent/stormWaterPump"; import SwtrmtFclt from "../popupComponent/SwtrmtFclt"; +import SwtrmtPrcsZoneAnalysis from "../popupComponent/SwtrmtPrcsZoneAnalysis"; type LayerComponentProps = { featureData: any; @@ -15,6 +16,7 @@ type LayerComponentProps = { chmbrXcnts: string; chmbrYdnts: string; gisObject?: Record; // GIS 객체 추가 + rawFeatureData?: any; }; type LayerContentMap = { [key: string]: { @@ -26,7 +28,7 @@ type LayerContentMap = { // 레이어 선택시 변경되는 팝업 컨텐츠 동적으로 변경하도록 작업 const layerContentMap: LayerContentMap = { "하수처리시설": { component: SwtrmtFclt, }, // 디지털 자료관 연계정보가 없으니 일단 주석 처리 - "하수처리구역": {}, + "하수처리구역": { component: SwtrmtPrcsZoneAnalysis }, "우수토실": { component: Ssc, }, "중점우수토실": { component: Ssc, }, // 중점우수토실 여부 설정 "하수관로": { component: MsSwagePpl, }, @@ -65,4 +67,4 @@ export const handleInputChange = (section: string, updated[index][field as keyof typeof updated[number]] = value; } target.setData(updated); -}; \ No newline at end of file +}; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx index 8b029673f..6516c0cd3 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx @@ -20,7 +20,7 @@ const headList = [ { id: "title", title: "하수도정비 기본계획보고서" }, ]; -const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => { +const SwtrmtFclt = ({ gisObject }: { gisObject?: any }) => { const [isModalOpen, setIsModalOpen] = useState(false); const ctpvNm = gisObject?.ctpv_nm; @@ -70,4 +70,4 @@ const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => { ) } -export default SwtrmtFclt \ No newline at end of file +export default SwtrmtFclt diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx new file mode 100644 index 000000000..b02977a09 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx @@ -0,0 +1,161 @@ +"use client"; + +import React, { useEffect, useMemo, useState } from "react"; +import { Table, TableBody, TableCell, TableRow } from "@common/components"; +import { fetchSwtrmtPrcsZoneAnalysis } from "./swtrmtPrcsZoneAnalysisApi"; +import type { SwtrmtPrcsZoneAnalysisMetric, SwtrmtPrcsZoneAnalysisResponse } from "./swtrmtPrcsZoneAnalysisApi"; + +const analysisRows: { key: keyof NonNullable; title: string }[] = [ + { key: "total", title: "전체" }, + { key: "combined", title: "합류관" }, + { key: "rain", title: "우수관" }, + { key: "sewage", title: "오수관" }, +]; + +const formatValue = (value: unknown, unit = "") => { + if (value === null || value === undefined || value === "") return "데이터 없음"; + + const numberValue = typeof value === "number" ? value : Number(String(value).replaceAll(",", "")); + if (!Number.isNaN(numberValue) && Number.isFinite(numberValue)) { + const formatted = numberValue.toLocaleString("ko-KR", { maximumFractionDigits: 2 }); + return unit ? `${formatted}${unit}` : formatted; + } + + return String(value); +}; + +const getMetric = ( + analysis: SwtrmtPrcsZoneAnalysisResponse["analysis"] | undefined, + key: keyof NonNullable +): SwtrmtPrcsZoneAnalysisMetric => analysis?.[key] ?? {}; + +const metricGroups: { + title: string; + field: keyof SwtrmtPrcsZoneAnalysisMetric; +}[] = [ + { title: "평균매설년도(년)", field: "avgInstlYear" }, + { title: "최장매설년도(년)", field: "oldestInstlYear" }, + { title: "최신매설년도(년)", field: "latestInstlYear" }, +]; + +const MetricVerticalGrid = ({ + title, + field, + analysis, + isLoading, +}: { + title: string; + field: keyof SwtrmtPrcsZoneAnalysisMetric; + analysis: SwtrmtPrcsZoneAnalysisResponse["analysis"] | undefined; + isLoading?: boolean; +}) => ( +
+
{title}
+ + + {analysisRows.map((row) => { + const metric = getMetric(analysis, row.key); + return ( + + + {row.title} + + {isLoading ? "" : formatValue(metric[field])} + + ); + })} + +
+
+); + +const SwtrmtPrcsZoneAnalysis = ({ + featureData, + rawFeatureData, +}: { + featureData: any; + rawFeatureData?: any; +}) => { + const [analysisData, setAnalysisData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + const requestKey = useMemo(() => { + const rawValues = rawFeatureData?.values_ ?? rawFeatureData ?? {}; + return [ + rawFeatureData?.getId?.(), + rawFeatureData?.id_, + rawFeatureData?.fid, + rawValues?.fid, + rawValues?.swtrmt_zone_sn, + rawValues?.swtrmt_fclt_cd, + featureData?.fid, + featureData?.swtrmtZoneSn, + featureData?.swtrmt_zone_sn, + featureData?.swtrmtFcltCd, + featureData?.swtrmt_fclt_cd, + ].filter(Boolean).join("|"); + }, [featureData, rawFeatureData]); + + useEffect(() => { + let isMounted = true; + + const fetchAnalysis = async () => { + setIsLoading(true); + setErrorMessage(""); + setAnalysisData(null); + + try { + const data = await fetchSwtrmtPrcsZoneAnalysis(rawFeatureData, featureData); + if (!isMounted) return; + + if (!data) { + setErrorMessage("분석정보 조회에 필요한 처리구역 식별값이 없습니다."); + return; + } + + setAnalysisData(data); + } catch (error) { + if (!isMounted) return; + console.error("하수처리구역 분석정보 로딩 중 오류:", error); + setErrorMessage("분석정보를 불러오지 못했습니다."); + } finally { + if (isMounted) setIsLoading(false); + } + }; + + fetchAnalysis(); + + return () => { + isMounted = false; + }; + }, [featureData, rawFeatureData, requestKey]); + + return ( + <> +
+

처리구역내 관로분석정보

+
+
+
+ {!isLoading && errorMessage && ( +
{errorMessage}
+ )} + {(isLoading || !errorMessage) && ( + metricGroups.map((group) => ( + + )) + )} +
+
+ + ); +}; + +export default SwtrmtPrcsZoneAnalysis; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx index 11e61b006..6394ef64f 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx @@ -626,7 +626,8 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, onSaveClick={handleSaveClick} chmbrXcnts={chmbrXcnts} chmbrYdnts={chmbrYdnts} - gisObject={featureData.values_} + gisObject={featureData?.values_ ?? featureData} + rawFeatureData={featureData} /> }
diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/swtrmtPrcsZoneAnalysisApi.ts b/apps/sn/app/(map-layout)/map/components/popupComponent/swtrmtPrcsZoneAnalysisApi.ts new file mode 100644 index 000000000..27eff02de --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/swtrmtPrcsZoneAnalysisApi.ts @@ -0,0 +1,99 @@ +import { getData } from "@/components/lib/api"; + +export type SwtrmtPrcsZoneAnalysisMetric = { + avgInstlYear?: number | string | null; + oldestInstlYear?: number | string | null; + latestInstlYear?: number | string | null; + pipeCount?: number | string | null; + pipeLength?: number | string | null; +}; + +export type SwtrmtPrcsZoneAnalysisResponse = { + zone?: { + zoneId?: string | number | null; + swtrmtZoneNm?: string | null; + ctpvCd?: string | null; + ctpvNm?: string | null; + sggCd?: string | null; + sggNm?: string | null; + swtrmtFcltCd?: string | null; + swtrmtFcltNm?: string | null; + }; + analysis?: { + total?: SwtrmtPrcsZoneAnalysisMetric | null; + combined?: SwtrmtPrcsZoneAnalysisMetric | null; + rain?: SwtrmtPrcsZoneAnalysisMetric | null; + sewage?: SwtrmtPrcsZoneAnalysisMetric | null; + }; + excluded?: { + missingInstlYearCount?: number | string | null; + }; +}; + +type ZoneAnalysisSource = Record | null | undefined; + +const ANALYSIS_ENDPOINT_PREFIX = "v1/swagePrcsZones"; + +const firstValue = (...values: unknown[]) => { + const value = values.find((item) => { + if (item === null || item === undefined) return false; + const text = String(item).trim(); + return text !== "" && text !== "-"; + }); + + return value === undefined ? "" : String(value).trim(); +}; + +export const getSwtrmtPrcsZoneId = (rawFeatureData: any, featureData: ZoneAnalysisSource) => { + const rawValues = rawFeatureData?.values_ ?? rawFeatureData ?? {}; + + return firstValue( + rawFeatureData?.getId?.(), + rawFeatureData?.id_, + rawFeatureData?.fid, + rawValues?.fid, + featureData?.fid, + rawValues?.swtrmt_zone_sn, + rawValues?.swtrmtZoneSn, + featureData?.swtrmt_zone_sn, + featureData?.swtrmtZoneSn, + rawValues?.zoneId, + featureData?.zoneId, + rawValues?.swtrmt_fclt_cd, + rawValues?.swtrmtFcltCd, + featureData?.swtrmt_fclt_cd, + featureData?.swtrmtFcltCd + ); +}; + +export const getSwtrmtPrcsZoneAnalysisParams = (rawFeatureData: any, featureData: ZoneAnalysisSource) => { + const rawValues = rawFeatureData?.values_ ?? rawFeatureData ?? {}; + const params = new URLSearchParams(); + + const entries = { + ctpvCd: firstValue(rawValues?.ctpv_cd, rawValues?.ctpvCd, featureData?.ctpv_cd, featureData?.ctpvCd), + sggCd: firstValue(rawValues?.sgg_cd, rawValues?.sggCd, featureData?.sgg_cd, featureData?.sggCd), + swtrmtFcltCd: firstValue(rawValues?.swtrmt_fclt_cd, rawValues?.swtrmtFcltCd, featureData?.swtrmt_fclt_cd, featureData?.swtrmtFcltCd), + swtrmtZoneNm: firstValue(rawValues?.swtrmt_zone_nm, rawValues?.swtrmtZoneNm, featureData?.swtrmt_zone_nm, featureData?.swtrmtZoneNm), + }; + + Object.entries(entries).forEach(([key, value]) => { + if (value) params.append(key, value); + }); + + return params; +}; + +export const fetchSwtrmtPrcsZoneAnalysis = async ( + rawFeatureData: any, + featureData: ZoneAnalysisSource +): Promise => { + const zoneId = getSwtrmtPrcsZoneId(rawFeatureData, featureData); + if (!zoneId) return null; + + const params = getSwtrmtPrcsZoneAnalysisParams(rawFeatureData, featureData); + const query = params.toString(); + const path = `${ANALYSIS_ENDPOINT_PREFIX}/${encodeURIComponent(zoneId)}/pplAnalysis${query ? `?${query}` : ""}`; + + return (await getData(path)) ?? {}; +}; diff --git a/apps/sn/app/page.tsx b/apps/sn/app/page.tsx index 2a1db71d8..b02c4bdca 100644 --- a/apps/sn/app/page.tsx +++ b/apps/sn/app/page.tsx @@ -6,7 +6,7 @@ * * - 쿠키 기반 인증 토큰 검증 및 자동 로그인 처리 * - 로그인 성공 시 지도 페이지로 리다이렉트 - * - 기존 토큰 존재 시 바로 지도 페이지 이동 + * - 개발 환경에서는 루트 진입 시 자동 로그인 후 지도 페이지 이동 * - 로그인 실패 시 에러 로깅 및 처리 * - 사용자 세션 관리 및 초기 라우팅 제어 * - Next.js useRouter를 통한 클라이언트 사이드 라우팅 @@ -17,28 +17,34 @@ "use client"; import { LoginApi } from "@/entities/auth"; import { useEffect } from "react"; -import { getCookie } from "@/shared/lib"; -import { AUTH_USER_ID } from "@/shared/config"; + +const DEV_HOSTNAMES = new Set(["localhost", "127.0.0.1", "con.mind-one.co.kr"]); + +const DEV_LOGIN_PAYLOAD = { + userId: process.env.NEXT_PUBLIC_DEV_LOGIN_USER_ID || "CLEANWATER", + password: process.env.NEXT_PUBLIC_DEV_LOGIN_PASSWORD || "gktneh12!@", +}; export default function Home() { - const checkToken = getCookie(AUTH_USER_ID); - useEffect(() => { - const hostname = window.location.hostname; + const isDevHost = DEV_HOSTNAMES.has(hostname); - if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "con.mind-one.co.kr") { - if (!checkToken) { - async function doLogin() { - try { - await LoginApi.login({ userId: "CLEANWATER", password: "gktneh12!@" }); - window.location.href = "/map"; - } catch (err) { - console.error("로그인 실패", err); - } - } - doLogin(); - } else window.location.href = "/map"; - } else window.location.href = "/map"; + if (!isDevHost) { + window.location.replace("/map"); + return; + } + + async function doLogin() { + try { + // 개발 환경에서는 남아 있는 만료/부분 쿠키로 인한 /map <-> / 루프를 막기 위해 항상 토큰을 재발급한다. + await LoginApi.login(DEV_LOGIN_PAYLOAD); + window.location.replace("/map"); + } catch (err) { + console.error("로그인 실패", err); + } + } + + void doLogin(); }, []); } diff --git a/apps/sn/docs/기준문서/003-API사용.md b/apps/sn/docs/기준문서/003-API사용.md index bd17b1bde..6ecdfbcfc 100644 --- a/apps/sn/docs/기준문서/003-API사용.md +++ b/apps/sn/docs/기준문서/003-API사용.md @@ -4,6 +4,7 @@ - 확인됨: 프론트엔드는 백엔드 `sewerage-service` API와 연동한다. 근거: `apps/sn/AGENTS.md` - 확인됨: 백엔드 응답 구조나 API 계약을 프론트엔드에서 임의로 변경했다고 가정하지 않는다. 근거: `apps/sn/AGENTS.md` - 확인됨: API 계약 변경이 필요하면 백엔드 작업으로 분리하고 영향 범위를 먼저 정리한다. 근거: `apps/sn/AGENTS.md` +- 확인됨: 신규 또는 변경 API URL은 백엔드 기준에 맞춰 업무 리소스 path segment를 lowerCamelCase로 맞춘다. 근거: `COMMON_DOCS_ROOT/기준문서/010-AI개발작업흐름.md`, `BE_DOCS_ROOT/기준문서/005-API정책.md` ## 응답 처리 기준 - 확인됨: 백엔드 공통 응답은 `status`, `message`, `data`, `timestamp` 형태로 내려오는 API가 존재한다. 근거: 사용자 확인 응답 예시, 백엔드 `docs/응답표준화/02-백엔드응답규칙.md` @@ -13,6 +14,7 @@ ## 작업카드에 남길 항목 - 확인 필요: HTTP method - 확인 필요: URL +- 확인 필요: 신규 또는 변경 URL의 lowerCamelCase 준수 여부 - 확인 필요: query/body 파라미터 - 확인 필요: 정상 응답 예시 - 확인 필요: 오류 응답 처리 방식 @@ -21,4 +23,4 @@ ## 금지 기준 - 확인됨: 프론트엔드에서 백엔드 API 계약 변경을 전제로 구현하지 않는다. 근거: `apps/sn/AGENTS.md` -- 확인됨: 백엔드 응답을 임시로 우회하기 위한 화면 전용 하드코딩을 추가하지 않는다. 근거: `apps/sn/AGENTS.md` \ No newline at end of file +- 확인됨: 백엔드 응답을 임시로 우회하기 위한 화면 전용 하드코딩을 추가하지 않는다. 근거: `apps/sn/AGENTS.md` diff --git a/apps/sn/docs/기준문서/005-상태관리정책.md b/apps/sn/docs/기준문서/005-상태관리정책.md index 6bc7d99a3..8533b442c 100644 --- a/apps/sn/docs/기준문서/005-상태관리정책.md +++ b/apps/sn/docs/기준문서/005-상태관리정책.md @@ -4,6 +4,10 @@ - 확인됨: 기존 `sn` 화면 흐름과 사용자 동작을 가능한 유지하면서 2차 요구사항을 반영한다. 근거: `apps/sn/AGENTS.md` - 확인됨: 복잡한 컴포넌트, 탭 분기, 폼 처리, 데이터 변환, 지도 연동, 공통 컴포넌트 조합 로직은 함수 또는 메서드 단위 주석을 상세히 작성한다. 근거: `apps/sn/AGENTS.md` - 추정: 화면별 상태는 기존 패턴을 우선 따르고, 새 전역 상태 구조는 필요성이 확인된 경우에만 검토해야 한다. 근거: `apps/sn/AGENTS.md` +- 확인됨: 서버 조회 결과와 화면 UI 상태는 용도를 구분한다. 서버 상태는 React Query 또는 화면 전용 조회 훅을 우선 검토하고, Zustand에는 화면 전역 UI 상태, 사용자 선택, 모달 상태, 장기간 공유되는 기준 데이터만 둔다. +- 확인됨: Zustand에서 서버 기준 데이터를 조회해 보관하는 경우에는 로딩, 오류, 중복 호출 방지, 재조회 조건을 함께 정의한다. +- 확인됨: 같은 의미의 기준 데이터 store를 두 계열 이상 유지하지 않는다. 기존 store가 겹치면 조회 출처, 데이터 shape, 필터링 규칙을 비교하고 하나의 기준으로 정리한다. +- 확인됨: React Query를 사용하는 Provider는 QueryClient 인스턴스가 렌더마다 새로 생성되지 않도록 안정적으로 유지한다. ## 화면 흐름 기록 항목 - 확인 필요: 초기 진입 시 호출되는 API @@ -11,9 +15,11 @@ - 확인 필요: 페이지네이션 또는 무한스크롤 방식 - 확인 필요: 선택 행 또는 상세 패널 상태 - 확인 필요: 등록, 수정, 삭제 후 재조회 방식 +- 확인 필요: 서버 상태 캐시, 전역 store, 컴포넌트 local state 중 어디에 데이터를 둘지 +- 확인 필요: 로딩, 오류, 중복 호출 방지, 재조회 조건 - 확인 필요: 팝업, 모달, 지도, 파일, 엑셀 흐름 - 확인 필요: 에러 메시지 표시 위치 ## 주의 사항 - 확인됨: 요구사항 근거 없는 기능 추가는 금지한다. 근거: `apps/sn/AGENTS.md` -- 확인됨: 다른 앱에서 사용하는 공통 상태 로직을 `sn` 문제 해결 목적으로 직접 바꾸지 않는다. 근거: `apps/sn/AGENTS.md` \ No newline at end of file +- 확인됨: 다른 앱에서 사용하는 공통 상태 로직을 `sn` 문제 해결 목적으로 직접 바꾸지 않는다. 근거: `apps/sn/AGENTS.md` diff --git a/apps/sn/docs/기준문서/012-코드리뷰체크리스트.md b/apps/sn/docs/기준문서/012-코드리뷰체크리스트.md index ddf6698db..19e00b921 100644 --- a/apps/sn/docs/기준문서/012-코드리뷰체크리스트.md +++ b/apps/sn/docs/기준문서/012-코드리뷰체크리스트.md @@ -7,6 +7,7 @@ ## 2. API 연동 - 실제 백엔드 요청 파라미터와 응답 필드를 기준으로 구현했는가 +- 신규 또는 변경 API URL이 백엔드 lowerCamelCase 기준과 일치하는가 - 에러 응답 또는 예외 케이스를 화면에서 처리하는가 - 프론트 임의 가공 로직이 계약 불일치를 숨기고 있지 않은가 @@ -17,6 +18,9 @@ ## 4. 코드 품질 - 컴포넌트 책임이 과도하게 섞이지 않았는가 - 상태, 이벤트, 비동기 호출이 읽기 쉽게 분리되어 있는가 +- 서버 조회 결과를 store에 둘 때 로딩/오류/중복 호출 방지/재조회 기준이 있는가 +- 같은 기준 데이터를 관리하는 store가 중복되어 있지 않은가 +- React Query를 쓰는 경우 QueryClient가 안정적으로 유지되는가 - 이름이 역할을 설명하고 있는가 ## 5. 검증 diff --git a/apps/sn/src/entities/auth/lib/token-manager.ts b/apps/sn/src/entities/auth/lib/token-manager.ts index cbc9c508d..8c3292794 100644 --- a/apps/sn/src/entities/auth/lib/token-manager.ts +++ b/apps/sn/src/entities/auth/lib/token-manager.ts @@ -26,7 +26,7 @@ export class TokenManager { secure: window.location.protocol === "https:", sameSite: "lax", //domain: domain, - //path: "/", + path: "/", }); } From d81952810fbd482ee28bfa9b03b39a7df2189769 Mon Sep 17 00:00:00 2001 From: mind-one001 Date: Thu, 30 Apr 2026 16:54:06 +0900 Subject: [PATCH 08/11] =?UTF-8?q?=EC=9C=A0=ED=95=98=EB=B0=A9=ED=96=A5=20?= =?UTF-8?q?=ED=91=9C=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/AGENTS.md | 4 +- apps/sn/app/(map-layout)/map/page.tsx | 654 +++++++++++++++++- apps/sn/app/components/lib/api/index.ts | 6 +- .../business_components/src/ui/map/Gis.js | 12 +- 4 files changed, 668 insertions(+), 8 deletions(-) diff --git a/apps/sn/AGENTS.md b/apps/sn/AGENTS.md index de337921b..291eb61d6 100644 --- a/apps/sn/AGENTS.md +++ b/apps/sn/AGENTS.md @@ -108,7 +108,7 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작 5. 불확실한 경우 먼저 계획을 제시한다. 6. 변경 전후 차이, 영향 범위, 검증 방법을 명확히 설명한다. 7. 기존 주석은 임의로 제거하지 않는다. -8. 복잡한 컴포넌트, 훅, 분기 로직, 상태 흐름, 데이터 변환 로직은 함수/메서드 단위 설명 주석을 충분히 작성한다. +8. 신규/수정 함수, 주요 상수, 주요 변수, `useState`, `useRef`, `useMemo`, `useCallback`에는 화면/API/상태 흐름에서 맡는 역할을 알 수 있는 주석을 작성한다. 9. 한글이 깨지지 않도록 반드시 UTF-8로 유지한다. 10. 작업 범위를 넓히기보다 `sn` 내부 해결을 우선한다. 11. 백엔드 API 응답과 실제 브라우저 동작까지 함께 확인하는 것을 기본으로 한다. @@ -240,6 +240,8 @@ pnpm turbo run build --filter="...[origin/main]" - 한글이 많은 문서는 셸 리다이렉션(`>`, `>>`)이나 검증 없는 `Set-Content`, `Out-File`로 바로 덮어쓰지 않는다. - 기존 주석은 정당한 이유 없이 삭제하지 않는다. - 복잡한 렌더링 분기, 폼 처리, 데이터 변환, 트리 구조, 지도 연동, 표 컴포넌트 조합 로직은 함수/메서드 단위 주석을 상세히 작성한다. +- 신규/수정 상수와 변수는 이름만으로 의도가 충분히 드러나지 않으면 선언부 근처에 역할, 단위, 백엔드 계약, 화면 상태와의 관계를 주석으로 남긴다. +- `useRef`로 비동기 요청, 팝업 창, 지도 객체, 체크 상태를 보관하는 경우에는 왜 state가 아니라 ref를 쓰는지 주석으로 남긴다. --- diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index cbbea24b5..a227e1a45 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -21,6 +21,13 @@ import Gis from "@common/business_components/ui/map/Gis"; import { getData, getRawJson } from "../../components/lib/api"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import GisApp from "@common/business_components/ui/map/lib/gis.core"; +import GeoJSON from "ol/format/GeoJSON.js"; +import Point from "ol/geom/Point.js"; +import VectorLayer from "ol/layer/Vector.js"; +import { unByKey } from "ol/Observable.js"; +import VectorSource from "ol/source/Vector.js"; +import Stroke from "ol/style/Stroke.js"; +import Style from "ol/style/Style.js"; import SearchModal from "./searchModal/searchModal"; import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup"; import { WaterDepth, WATER_DEPTH_LAYER_TYPES } from "./waterDepth/waterDepth"; @@ -54,9 +61,393 @@ const AGED_PIPE_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT"; const AGED_PIPE_POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE"; const AGED_PIPE_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC"; +// 유하방향은 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_SRS = "EPSG:5179"; +const FLOW_DIRECTION_DEFAULT_MIN_ZOOM = 13; +const FLOW_DIRECTION_DEFAULT_LIMIT = 2000; +const FLOW_DIRECTION_DEFAULT_COLOR = "rgba(40, 90, 180, 0.84)"; +const FLOW_DIRECTION_BBOX_BUFFER_RATIO = 0.35; +const FLOW_DIRECTION_CACHE_MAX_FEATURES = 10000; + +type FlowDirectionUsageStyle = { + color: string; + lineColor: string; +}; + const AGED_PIPE_FOCUS_PADDING: [number, number, number, number] = [120, 120, 120, 120]; const DEFAULT_FEATURE_FIT_PADDING: [number, number, number, number] = [50, 50, 50, 50]; +// 레이어 설정 응답이 lyrTyNm/lyrTypeNm/lyr_type_nm처럼 혼재되어 내려오는 경우를 단일 기준으로 정규화한다. +const getLayerTypeName = (layerInfo: any) => + String( + layerInfo?.lyrTyNm ?? + layerInfo?.lyrTypeNm ?? + layerInfo?.lyr_type_nm ?? + "", + ).trim(); + +// 유하방향 노드는 타입명 또는 표시명으로 식별한다. 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), + }; + }); +}; + +// 레이어 설정의 최소 표출 줌을 읽되, 값이 없으면 유하방향 기본 줌 기준을 적용한다. +const getFlowDirectionMinZoom = (layerInfo: any) => { + const minZoom = Number(layerInfo?.minViewSize ?? layerInfo?.minZoom); + return Number.isFinite(minZoom) ? minZoom : FLOW_DIRECTION_DEFAULT_MIN_ZOOM; +}; + +// 유하방향 화살표는 관로 위에 얹는 보조 레이어라 레이어 설정의 drawOrdr를 우선 사용하고 없으면 높은 zIndex를 준다. +const getFlowDirectionZIndex = (layerInfo: any) => { + const drawOrder = Number(layerInfo?.drawOrdr); + return Number.isFinite(drawOrder) ? drawOrder : 9998; +}; + +// BE 응답이 FeatureCollection 그대로 오거나 공통 응답 wrapper의 data 안에 들어오는 두 계약을 모두 처리한다. +const getFeatureCollection = (payload: any) => { + if (payload?.type === "FeatureCollection") return payload; + if (payload?.data?.type === "FeatureCollection") return payload.data; + return { type: "FeatureCollection", features: [] }; +}; + +// 현재 지도 화면 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 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 styleByConfidence: Record = { + 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 = styleByConfidence[confidence] ?? styleByConfidence.LOW!; + const styles = [ + new Style({ + stroke: new Stroke({ + color: style.lineColor, + width: style.width, + 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(); + const directCode = layerInfo?.usgCd ?? layerInfo?.usg_cd; + if (directCode) usageCodes.add(String(directCode).trim()); + + const filterSql = String(layerInfo?.flterSql ?? layerInfo?.filterSql ?? ""); + const equalsMatches = filterSql.matchAll(/usg_cd\s*=\s*'([^']+)'/gi); + for (const match of equalsMatches) { + const code = match[1]?.trim(); + if (code) usageCodes.add(code); + } + + const inMatches = filterSql.matchAll(/usg_cd\s+in\s*\(([^)]+)\)/gi); + for (const match of inMatches) { + const codeList = match[1]; + if (!codeList) continue; + + codeList + .split(",") + .map((item) => item.replace(/'/g, "").trim()) + .filter(Boolean) + .forEach((code) => usageCodes.add(code)); + } + + return Array.from(usageCodes); +}; + +// 현재 줌에서 실제 표출 가능한 관로 레이어인지 확인해 숨겨진 관종의 유하방향 요청을 막는다. +const isLayerVisibleAtZoom = (layerInfo: any, zoom: number) => { + const minZoom = Number(layerInfo?.minViewSize ?? layerInfo?.minZoom); + return !Number.isFinite(minZoom) || zoom >= minZoom; +}; + +// 현재 체크되어 화면에 보이는 관로 레이어의 관종 코드와 선 색상을 유하방향 조회/스타일 기준으로 변환한다. +const extractCheckedPipeUsageStyles = (checkedNodesData: any[], zoom: number) => { + const usageStyleMap: Record = {}; + + checkedNodesData.forEach((node) => { + const data = node?.data ?? node; + if (!data || isFlowDirectionLayerInfo(data)) return; + if (!isLayerVisibleAtZoom(data, zoom)) return; + + const usageCodes = extractUsageCodesFromLayerInfo(data); + if (usageCodes.length === 0) return; + + const layerColor = String(data.lnColor ?? data.lineColor ?? FLOW_DIRECTION_DEFAULT_COLOR).trim(); + usageCodes.forEach((code) => { + usageStyleMap[code] = { + color: layerColor, + lineColor: withAlpha(layerColor, 0.28), + }; + }); + }); + + return usageStyleMap; +}; + const appendAgedPipeControlNode = (data: any[]) => { if (!Array.isArray(data)) return data; if (data.some((item) => String(item.lyrSn) === AGED_PIPE_CONTROL_NODE_ID || item.lyrNm === "노후관로")) { @@ -126,6 +517,31 @@ const Map = () => { const shouldFocusAgedPipeOnNextSelectRef = useRef(false); const agedPipePopupWindowRef = useRef(null); + // 유하방향 레이어 체크 여부를 React 렌더링 흐름에 반영하기 위한 state이다. + const [isFlowDirectionChecked, setIsFlowDirectionChecked] = useState(false); + // 체크된 유하방향 트리 노드의 레이어 설정을 보관해 API 조회와 vector layer 스타일에 사용한다. + const [flowDirectionLayerInfo, setFlowDirectionLayerInfo] = useState(null); + // 관종 레이어 체크 변경도 유하방향 재조회 조건이므로 별도 버전 state로 effect를 다시 실행한다. + const [flowDirectionSelectionVersion, setFlowDirectionSelectionVersion] = useState(0); + // moveend 이벤트 핸들러는 최신 state closure를 보장하지 않으므로 현재 체크 여부를 ref로 함께 보관한다. + const flowDirectionCheckedRef = useRef(false); + // 지도 이동 후 재조회할 때 최신 유하방향 레이어 설정을 참조하기 위한 ref이다. + const flowDirectionLayerInfoRef = useRef(null); + // 유하방향 API의 관종 필터를 만들기 위해 현재 체크된 트리 노드 목록을 ref로 보관한다. + const checkedNodesDataRef = useRef([]); + // 유하방향 전용 vector layer/source를 유지해 지도 이동 시 기존 피처를 지우지 않고 새 BBOX 응답만 병합한다. + const flowDirectionLayerRef = useRef(null); + const flowDirectionSourceRef = useRef(null); + // 같은 관로가 겹치는 BBOX에서 반복 조회될 수 있으므로 고유키와 삽입 순서를 캐시로 관리한다. + const flowDirectionFeatureKeySetRef = useRef>(new Set()); + const flowDirectionFeatureKeyQueueRef = useRef([]); + // 체크 해제나 재조회 중 늦게 도착한 이전 API 응답이 레이어를 다시 그리지 못하도록 요청 순번을 관리한다. + const flowDirectionRequestSeqRef = useRef(0); + // 지도 이동이 연속으로 발생하면 이전 bbox 요청을 중단해 늦은 응답과 불필요한 네트워크 대기를 줄인다. + const flowDirectionAbortControllerRef = useRef(null); + // 같은 장애 상황에서 alert가 반복 노출되지 않도록 실패 안내 여부를 기억한다. + const flowDirectionFailureAlertedRef = useRef(false); + useEffect(() => { fetchData(); }, []); @@ -180,7 +596,7 @@ const Map = () => { const fetchData = async () => { try { const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK"); - setTreeData(appendAgedPipeControlNode(data)); + setTreeData(appendAgedPipeControlNode(normalizeFlowDirectionLayerInfos(data))); setLayerLoadError(null); } catch (error) { const message = @@ -191,6 +607,182 @@ const Map = () => { } }; + // 유하방향은 일반 Gis core 레이어 등록 흐름을 타지 않으므로 전용 layer/source/cache를 직접 정리한다. + const removeFlowDirectionLayer = useCallback(() => { + const core = GisApp.Module.core as any; + core?.deleteLayer?.(FLOW_DIRECTION_LAYER_TYPE); + flowDirectionLayerRef.current = null; + flowDirectionSourceRef.current = null; + flowDirectionFeatureKeySetRef.current.clear(); + flowDirectionFeatureKeyQueueRef.current = []; + }, []); + + // 유하방향 cache/source만 비워 관종 변경처럼 조건이 달라진 경우 이전 조건의 피처가 남지 않게 한다. + const clearFlowDirectionFeatureCache = useCallback(() => { + flowDirectionSourceRef.current?.clear(); + flowDirectionFeatureKeySetRef.current.clear(); + flowDirectionFeatureKeyQueueRef.current = []; + }, []); + + // 유하방향 레이어는 하나만 유지하고 source에 피처를 누적한다. + const ensureFlowDirectionLayer = useCallback((layerInfo: any) => { + const map = gisMaps.current; + if (!map) return null; + + if (flowDirectionLayerRef.current && flowDirectionSourceRef.current) { + flowDirectionLayerRef.current.setMinZoom?.(getFlowDirectionMinZoom(layerInfo)); + flowDirectionLayerRef.current.setZIndex?.(getFlowDirectionZIndex(layerInfo)); + return flowDirectionSourceRef.current; + } + + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + minZoom: getFlowDirectionMinZoom(layerInfo), + zIndex: getFlowDirectionZIndex(layerInfo), + updateWhileAnimating: false, + updateWhileInteracting: false, + }); + + layer.set("id", FLOW_DIRECTION_LAYER_TYPE); + layer.set("lyrNm", FLOW_DIRECTION_LAYER_NAME); + layer.set("lyrTyNm", FLOW_DIRECTION_LAYER_TYPE); + layer.set("typeName", FLOW_DIRECTION_LAYER_TYPE); + layer.set("clickPsblYn", "N"); + layer.set("chcYn", "N"); + + const core = GisApp.Module.core as any; + core?.deleteLayer?.(FLOW_DIRECTION_LAYER_TYPE); + map.addLayer(layer); + flowDirectionLayerRef.current = layer; + flowDirectionSourceRef.current = source; + return source; + }, []); + + // BE에서 받은 보정 GeoJSON을 OpenLayers feature로 변환해 기존 유하방향 source에 중복 없이 병합한다. + const renderFlowDirectionLayer = useCallback(( + featureCollection: any, + layerInfo: any, + usageStyleMap: Record, + ) => { + const source = ensureFlowDirectionLayer(layerInfo); + if (!source) return; + + const features = new GeoJSON().readFeatures(featureCollection, { + dataProjection: FLOW_DIRECTION_SRS, + featureProjection: GisApp.TO_PROJ, + }); + + const nextFeatures = features.filter((feature) => { + const featureKey = getFlowDirectionFeatureCacheKey(feature); + if (flowDirectionFeatureKeySetRef.current.has(featureKey)) return false; + + feature.set("flowDirectionCacheKey", featureKey); + feature.setId(featureKey); + feature.setStyle(createFlowDirectionStyle(feature, usageStyleMap)); + flowDirectionFeatureKeySetRef.current.add(featureKey); + flowDirectionFeatureKeyQueueRef.current.push(featureKey); + return true; + }); + + if (nextFeatures.length > 0) { + source.addFeatures(nextFeatures); + } + + while (flowDirectionFeatureKeyQueueRef.current.length > FLOW_DIRECTION_CACHE_MAX_FEATURES) { + const staleKey = flowDirectionFeatureKeyQueueRef.current.shift(); + if (!staleKey) break; + + const staleFeature = source.getFeatures().find((feature: any) => + feature.get("flowDirectionCacheKey") === staleKey, + ); + if (staleFeature) { + source.removeFeature(staleFeature); + } + flowDirectionFeatureKeySetRef.current.delete(staleKey); + } + }, [ensureFlowDirectionLayer]); + + // 현재 지도 BBOX와 체크된 관종 필터로 유하방향 GeoJSON을 조회하고, 최신 요청만 지도에 반영한다. + const loadFlowDirectionLayer = useCallback( + async (layerInfo: any) => { + const map = gisMaps.current; + if (!map) return; + + const requestSeq = flowDirectionRequestSeqRef.current + 1; + flowDirectionRequestSeqRef.current = requestSeq; + flowDirectionAbortControllerRef.current?.abort(); + const abortController = new AbortController(); + flowDirectionAbortControllerRef.current = abortController; + + const view = map.getView(); + const zoom = view.getZoom() ?? 0; + const minZoom = getFlowDirectionMinZoom(layerInfo); + + if (zoom < minZoom) { + removeFlowDirectionLayer(); + return; + } + + const size = map.getSize(); + if (!size) return; + + const usageStyleMap = extractCheckedPipeUsageStyles(checkedNodesDataRef.current, zoom); + const usageCodes = Object.keys(usageStyleMap); + if (usageCodes.length === 0) { + removeFlowDirectionLayer(); + return; + } + + const screenExtent = view.calculateExtent(size); + const requestExtent = expandFlowDirectionExtent(screenExtent); + const screenBbox = formatFlowDirectionBbox(screenExtent); + const requestBbox = formatFlowDirectionBbox(requestExtent); + const params = new URLSearchParams({ + bbox: requestBbox, + srs: FLOW_DIRECTION_SRS, + limit: String(FLOW_DIRECTION_DEFAULT_LIMIT), + usgCd: usageCodes.join(","), + }); + + try { + const payload = await getRawJson( + `/sewerage-service/api/v1/map/flowDirectionGeometry?${params.toString()}`, + { signal: abortController.signal }, + ); + if (requestSeq !== flowDirectionRequestSeqRef.current) return; + + const featureCollection = getFeatureCollection(payload); + renderFlowDirectionLayer(featureCollection, layerInfo, usageStyleMap); + (window as any).__SN_FLOW_DIRECTION_DEBUG__ = { + screenBbox, + requestBbox, + requestSeq, + zoom, + usageCodes, + returnedFeatures: featureCollection.features?.length ?? 0, + cachedFeatures: flowDirectionSourceRef.current?.getFeatures?.().length ?? 0, + }; + flowDirectionFailureAlertedRef.current = false; + } catch (error) { + if (requestSeq !== flowDirectionRequestSeqRef.current) return; + if (typeof error === "object" && error !== null && "name" in error && error.name === "AbortError") return; + + console.error("유하방향 레이어 조회 실패:", error); + removeFlowDirectionLayer(); + if (!flowDirectionFailureAlertedRef.current) { + alert("유하방향 정보를 불러오지 못했습니다."); + flowDirectionFailureAlertedRef.current = true; + } + } finally { + if (flowDirectionAbortControllerRef.current === abortController) { + flowDirectionAbortControllerRef.current = null; + } + } + }, + [removeFlowDirectionLayer, renderFlowDirectionLayer], + ); + // SearchModal, GisContent 등에서 호출 const onFeatureSelect = (data: any) => { let map = gisMaps.current; @@ -447,9 +1039,34 @@ const Map = () => { // 체크된 레이어 변경 처리 함수 const handleCheckedLayersChange = useCallback((checkedIds: string[], checkedNodesData: any[]) => { + checkedNodesDataRef.current = checkedNodesData; + + // 유하방향은 일반 setLayerOnMap 대상이 아니므로 체크 노드 여부를 별도로 추적한다. + const flowDirectionNode = checkedNodesData.find((node) => + isFlowDirectionLayerInfo(node?.data ?? node), + ); + const nextFlowDirectionLayerInfo = flowDirectionNode?.data ?? flowDirectionNode ?? null; + flowDirectionCheckedRef.current = Boolean(nextFlowDirectionLayerInfo); + flowDirectionLayerInfoRef.current = nextFlowDirectionLayerInfo; + setIsFlowDirectionChecked(Boolean(nextFlowDirectionLayerInfo)); + setFlowDirectionLayerInfo(nextFlowDirectionLayerInfo); + if (nextFlowDirectionLayerInfo) { + clearFlowDirectionFeatureCache(); + setFlowDirectionSelectionVersion((version) => version + 1); + } + + if (!nextFlowDirectionLayerInfo) { + // 해제 직후 pending API 응답이 늦게 도착해도 renderFlowDirectionLayer가 실행되지 않도록 순번을 무효화한다. + flowDirectionRequestSeqRef.current += 1; + flowDirectionAbortControllerRef.current?.abort(); + flowDirectionAbortControllerRef.current = null; + flowDirectionFailureAlertedRef.current = false; + removeFlowDirectionLayer(); + } + handleWaterDepthVisibility(checkedIds, checkedNodesData); setIsAgedPipeChecked(checkedNodesData.some((node) => isAgedPipeLayerNode(node))); - }, [handleWaterDepthVisibility]); + }, [clearFlowDirectionFeatureCache, handleWaterDepthVisibility, removeFlowDirectionLayer]); const handleOpenLayerPopup = useCallback( (_layerNm: string, node?: any) => { @@ -521,6 +1138,38 @@ const Map = () => { }; }, [appliedAgedPipeFilters, isAgedPipeChecked, mapReady]); + useEffect(() => { + if (!mapReady || !flowDirectionLayerInfo) return; + + // 트리 체크 상태나 관종 레이어 선택이 바뀌면 유하방향 API vector layer를 추가/갱신/제거한다. + if (isFlowDirectionChecked) { + loadFlowDirectionLayer(flowDirectionLayerInfo); + } else { + removeFlowDirectionLayer(); + } + }, [ + flowDirectionLayerInfo, + flowDirectionSelectionVersion, + isFlowDirectionChecked, + loadFlowDirectionLayer, + mapReady, + removeFlowDirectionLayer, + ]); + + useEffect(() => { + if (!mapReady || !gisMaps.current) return; + + // 유하방향이 켜진 상태에서 지도 이동/확대축소가 끝나면 현재 BBOX 기준으로 다시 조회한다. + const key = gisMaps.current.on("moveend", () => { + if (!flowDirectionCheckedRef.current || !flowDirectionLayerInfoRef.current) return; + loadFlowDirectionLayer(flowDirectionLayerInfoRef.current); + }); + + return () => { + unByKey(key); + }; + }, [loadFlowDirectionLayer, mapReady]); + return ( <> @@ -554,6 +1203,7 @@ const Map = () => { setIsPlaying={setIsPlaying as any} targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any} checkInterval={2500} + checkedLayerChangeDelay={0} showZoomLevel={true} defaultZoom={7.5} diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts index aba7a3092..08d2dfbe8 100644 --- a/apps/sn/app/components/lib/api/index.ts +++ b/apps/sn/app/components/lib/api/index.ts @@ -143,10 +143,12 @@ export const deleteData = async (url: string, data?: object): Promise => { return res; }; -export const getRawJson = async (url: string): Promise => { +// 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(), + headers: withSiteIdHeaders(init.headers), }); if (res.status === 401) { handleUnauthorizedResponse(); diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index d7bbf3fa5..cdaf096c2 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -60,6 +60,8 @@ function Gis({ setIsPlaying = () => { }, targetLayerTypes = [], checkInterval = 3000, + // 체크 변경 콜백 지연 시간. 기본값은 기존 동작 유지, sn 유하방향처럼 즉시 반응이 필요한 화면은 0으로 전달한다. + checkedLayerChangeDelay = 1000, height: height = "h-[calc(100vh-5rem)]", onOpenPopup = (data) => { }, droughtDates = { startYmd: '', endYmd: '' }, @@ -251,13 +253,17 @@ function Gis({ // 체크된 레이어 정보를 처리하는 함수 const handleCheckedLayersChange = useCallback( (checkedIds, checkedNodesData) => { - setTimeout(() => { + // 콜백 지연 옵션을 한 곳에서 처리해 기존 화면과 즉시 반응 화면의 동작 차이를 prop으로 제어한다. + const applyCheckedLayersChange = () => { setCheckedLayers(checkedIds); setCheckedLayersData(checkedNodesData); onCheckedLayersChange?.(checkedIds, checkedNodesData); - }, 1000); + }; + + const delayMs = Math.max(0, Number(checkedLayerChangeDelay) || 0); + setTimeout(applyCheckedLayersChange, delayMs); }, - [onCheckedLayersChange], + [checkedLayerChangeDelay, onCheckedLayersChange], ); // 진행률 변경 핸들러 From 5515a987494d0e4729bf225f5330dbe9aa60ba62 Mon Sep 17 00:00:00 2001 From: mind-one001 Date: Thu, 30 Apr 2026 17:06:21 +0900 Subject: [PATCH 09/11] =?UTF-8?q?=ED=95=98=EC=88=98=EA=B4=80=EB=A7=9D?= =?UTF-8?q?=EB=8F=84=20=EC=98=81=EC=97=AD=20=EB=82=A8=ED=95=9C=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/(map-layout)/map/page.tsx | 7 +++++++ packages/common/business_components/src/ui/map/Gis.js | 5 ++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index a227e1a45..d6d0ec429 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -60,6 +60,12 @@ const AGED_PIPE_HIGHLIGHT_LAYER_ID = "SN_AGED_PIPE_HIGHLIGHT"; const AGED_PIPE_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT"; const AGED_PIPE_POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE"; const AGED_PIPE_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC"; +const SOUTH_KOREA_VIEW_EXTENT: [number, number, number, number] = [ + 720000, + 1445000, + 1410000, + 2090000, +]; // 유하방향은 GeoServer WMS/WFS 레이어가 아니라 BE 보정 GeoJSON API로 그리는 가상 레이어이다. const FLOW_DIRECTION_LAYER_TYPE = "tb_sn_swage_ppl_flow_direction"; @@ -1208,6 +1214,7 @@ const Map = () => { defaultZoom={7.5} defaultLonLat={[127.75, 36]} + viewExtent={SOUTH_KOREA_VIEW_EXTENT} /> {isAgedPipeListMinimized && (
diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index cdaf096c2..2f0c6920b 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -67,6 +67,8 @@ function Gis({ droughtDates = { startYmd: '', endYmd: '' }, defaultZoom = 15, defaultLonLat = [127.4875, 37.4916], + viewExtent, + viewMinZoom, showZoomLevel = false, }) { const mapRef = useRef(); @@ -145,8 +147,9 @@ function Gis({ GisApp.FROM_PROJ, GisApp.TO_PROJ, ), - minZoom: gisCore.defaults.minZoom, + minZoom: viewMinZoom ?? gisCore.defaults.minZoom, maxZoom: gisCore.defaults.maxZoom, + extent: viewExtent, }), }); } else { From 52d41166becdadd3756dccf4f8d293cfc62c04ab Mon Sep 17 00:00:00 2001 From: mind-one001 Date: Mon, 4 May 2026 15:55:42 +0900 Subject: [PATCH 10/11] =?UTF-8?q?=EC=9C=A0=ED=95=98=EB=B0=A9=ED=96=A5=20?= =?UTF-8?q?=ED=91=9C=EC=B6=9C=20=ED=94=BC=EC=B2=98=EC=88=98=202000->5000?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/(map-layout)/map/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index d6d0ec429..c053ed707 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -72,7 +72,7 @@ const FLOW_DIRECTION_LAYER_TYPE = "tb_sn_swage_ppl_flow_direction"; const FLOW_DIRECTION_LAYER_NAME = "유하방향"; const FLOW_DIRECTION_SRS = "EPSG:5179"; const FLOW_DIRECTION_DEFAULT_MIN_ZOOM = 13; -const FLOW_DIRECTION_DEFAULT_LIMIT = 2000; +const FLOW_DIRECTION_DEFAULT_LIMIT = 5000; const FLOW_DIRECTION_DEFAULT_COLOR = "rgba(40, 90, 180, 0.84)"; const FLOW_DIRECTION_BBOX_BUFFER_RATIO = 0.35; const FLOW_DIRECTION_CACHE_MAX_FEATURES = 10000; From 31ce13397e9a185759a230a7f0412cd1780f4b61 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Wed, 6 May 2026 14:42:21 +0900 Subject: [PATCH 11/11] =?UTF-8?q?=EA=B4=80=EB=A1=9C=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/agedPipe/AgedPipePopupPage.tsx | 10 +- .../analysisMenu/AnalysisPopupMenu.tsx | 52 ++ .../PipeAnalysisDetailPopupPage.tsx | 338 ++++++++++ .../pipeAnalysis/PipeAnalysisPopupPage.tsx | 526 +++++++++++++++ .../pipeAnalysis/pipeAnalysisUtils.ts | 628 ++++++++++++++++++ apps/sn/app/(map-layout)/map/page.tsx | 49 +- .../windowPopup/model/popupDefinitions.ts | 42 ++ apps/sn/src/features/windowPopup/registry.tsx | 58 ++ .../ui/FeaturePlaceholderPopupContent.tsx | 25 + .../ui/PipeAnalysisDetailPopupContent.tsx | 10 + .../ui/PipeAnalysisPopupContent.tsx | 10 + .../src/ui/excel/excelStyle.tsx | 14 +- .../src/ui/excel/index.tsx | 6 +- .../business_components/src/ui/map/Gis.js | 2 +- .../src/ui/table/index.tsx | 2 +- 15 files changed, 1759 insertions(+), 13 deletions(-) create mode 100644 apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx create mode 100644 apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx create mode 100644 apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx create mode 100644 apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipeAnalysisUtils.ts create mode 100644 apps/sn/src/features/windowPopup/ui/FeaturePlaceholderPopupContent.tsx create mode 100644 apps/sn/src/features/windowPopup/ui/PipeAnalysisDetailPopupContent.tsx create mode 100644 apps/sn/src/features/windowPopup/ui/PipeAnalysisPopupContent.tsx diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx index efea0197c..3626054c7 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx @@ -203,8 +203,8 @@ export default function AgedPipePopupPage() {
-
-
) : null} + ; export type WindowPopupKey = keyof typeof windowPopupDefinitions; diff --git a/apps/sn/src/features/windowPopup/registry.tsx b/apps/sn/src/features/windowPopup/registry.tsx index 7343e26b7..c76d9064f 100644 --- a/apps/sn/src/features/windowPopup/registry.tsx +++ b/apps/sn/src/features/windowPopup/registry.tsx @@ -11,6 +11,36 @@ import ReportFilesPopupContent from "./ui/ReportFilesPopupContent"; import ReportTabPopupContent from "./ui/ReportTabPopupContent"; import SearchModalPopupContent from "./ui/SearchModalPopupContent"; import SscDetailPopupContent from "./ui/SscDetailPopupContent"; +import type { WindowPopupContentProps } from "./model/types"; +import FeaturePlaceholderPopupContent from "./ui/FeaturePlaceholderPopupContent"; +import PipeAnalysisPopupContent from "./ui/PipeAnalysisPopupContent"; +import PipeAnalysisDetailPopupContent from "./ui/PipeAnalysisDetailPopupContent"; + +const FloodPredictionMapPopupContent = (_props: WindowPopupContentProps) => ( + +); + +const FloodHistoryPopupContent = (_props: WindowPopupContentProps) => ( + +); + +const GisEditPopupContent = (_props: WindowPopupContentProps) => ( + +); + +const GisManagementPopupContent = (_props: WindowPopupContentProps) => ( + +); + +const FlowRiskPopupContent = (_props: WindowPopupContentProps) => ( + +); export const windowPopupRegistry = { oldPpUpload: { @@ -57,4 +87,32 @@ export const windowPopupRegistry = { ...windowPopupDefinitions.agedPipePopup, component: AgedPipePopupContent, }, + pipeAnalysisPopup: { + ...windowPopupDefinitions.pipeAnalysisPopup, + component: PipeAnalysisPopupContent, + }, + pipeAnalysisDetailPopup: { + ...windowPopupDefinitions.pipeAnalysisDetailPopup, + component: PipeAnalysisDetailPopupContent, + }, + floodPredictionMapPopup: { + ...windowPopupDefinitions.floodPredictionMapPopup, + component: FloodPredictionMapPopupContent, + }, + floodHistoryPopup: { + ...windowPopupDefinitions.floodHistoryPopup, + component: FloodHistoryPopupContent, + }, + gisEditPopup: { + ...windowPopupDefinitions.gisEditPopup, + component: GisEditPopupContent, + }, + gisManagementPopup: { + ...windowPopupDefinitions.gisManagementPopup, + component: GisManagementPopupContent, + }, + flowRiskPopup: { + ...windowPopupDefinitions.flowRiskPopup, + component: FlowRiskPopupContent, + }, } as const satisfies Record; diff --git a/apps/sn/src/features/windowPopup/ui/FeaturePlaceholderPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/FeaturePlaceholderPopupContent.tsx new file mode 100644 index 000000000..e05b3b6b5 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/FeaturePlaceholderPopupContent.tsx @@ -0,0 +1,25 @@ +type Props = { + title: string; +}; + +export default function FeaturePlaceholderPopupContent({ title }: Props) { + return ( +
+
+

{title}

+
+ +
+
+

{title}

+

+ 현재는 팝업 창 구조만 우선 연결했습니다. +

+

+ 상세 기능은 추후 단계에서 구현될 예정입니다. +

+
+
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/PipeAnalysisDetailPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/PipeAnalysisDetailPopupContent.tsx new file mode 100644 index 000000000..c7d6f1f2a --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/PipeAnalysisDetailPopupContent.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react"; +import PipeAnalysisDetailPopupPage from "@/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage"; + +export default function PipeAnalysisDetailPopupContent() { + return ( + + + + ); +} diff --git a/apps/sn/src/features/windowPopup/ui/PipeAnalysisPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/PipeAnalysisPopupContent.tsx new file mode 100644 index 000000000..215b559a9 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/PipeAnalysisPopupContent.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react"; +import PipeAnalysisPopupPage from "@/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage"; + +export default function PipeAnalysisPopupContent() { + return ( + + + + ); +} diff --git a/packages/common/business_components/src/ui/excel/excelStyle.tsx b/packages/common/business_components/src/ui/excel/excelStyle.tsx index 221e1387c..1a654775c 100644 --- a/packages/common/business_components/src/ui/excel/excelStyle.tsx +++ b/packages/common/business_components/src/ui/excel/excelStyle.tsx @@ -70,7 +70,8 @@ export const excelStyleWithKoreanHeaders = async ( range: string = "A1:V3", fileNm: string, headerMapping: { [key: string]: string }, - sampleData: any + sampleData: any, + mergeRanges?: string[] ) => { const workbook = await XlsxPopulate.Workbook.fromData(data); const sheet = workbook.activeSheet(); @@ -83,7 +84,7 @@ export const excelStyleWithKoreanHeaders = async ( const englishHeaders = Object.keys(sampleData); englishHeaders.forEach((englishHeader, index) => { const koreanHeader = headerMapping[englishHeader] || englishHeader; - const cellAddress = String.fromCharCode(65 + index) + '1'; // A1, B1, C1, ... + const cellAddress = `${getExcelColumnLetter(index + 1)}1`; sheet.cell(cellAddress).value(koreanHeader); }); @@ -98,13 +99,20 @@ export const excelStyleWithKoreanHeaders = async ( const numRows = sheet.usedRange()?.endCell()?.rowNumber() || 1; if (numRows > 1) { - const dataRange = sheet.range(`A1:${String.fromCharCode(65 + numCols - 1)}${numRows}`); + const lastColLetter = getExcelColumnLetter(numCols); + const dataRange = sheet.range(`A1:${lastColLetter}${numRows}`); dataRange?.style((styleQ) => { styleQ.verticalAlignment("center"); styleQ.borderEach({ style: "thin", color: { rgb: "CCCCCC" } }); }); } + if (mergeRanges && mergeRanges.length > 0) { + mergeRanges.forEach((mergeRange) => { + sheet.range(mergeRange).merged(true); + }); + } + // 4. 파일 다운로드 await makeExcelFile(workbook, fileNm); }; diff --git a/packages/common/business_components/src/ui/excel/index.tsx b/packages/common/business_components/src/ui/excel/index.tsx index 54e4fe6a8..ef753f976 100644 --- a/packages/common/business_components/src/ui/excel/index.tsx +++ b/packages/common/business_components/src/ui/excel/index.tsx @@ -407,6 +407,7 @@ type ExcelDownloadWithHeaderProps = { sheetNameArray: string[]; headerRange?: string; headerMapping: { [key: string]: string }; + mergeRanges?: string[]; } export const downloadExcelWithKoreanHeaders = async ({ @@ -414,7 +415,8 @@ export const downloadExcelWithKoreanHeaders = async ({ fileName = new Date().getTime().toString() + '.xlsx', sheetNameArray = ["Sheet1"], headerRange = 'A1:V3', - headerMapping + headerMapping, + mergeRanges, }: ExcelDownloadWithHeaderProps) => { const dataArray = Array.isArray(data) ? data : [data]; @@ -432,5 +434,5 @@ export const downloadExcelWithKoreanHeaders = async ({ const buffer = XLSX.write(workbook, { bookType: "xlsx", type: "buffer" }); // 3. 헤더만 한글로 교체하는 커스텀 스타일 함수 호출 - await excelStyleWithKoreanHeaders(buffer, headerRange, fileName, headerMapping, dataArray[0]); + await excelStyleWithKoreanHeaders(buffer, headerRange, fileName, headerMapping, dataArray[0], mergeRanges); }; diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index d7bbf3fa5..d8076269f 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -343,7 +343,7 @@ function Gis({
{isSearch && ( -
+