From 9d573e6ad1dc3e4fb8d58fc9dce074789c00b2ac Mon Sep 17 00:00:00 2001 From: kiminchan Date: Thu, 23 Apr 2026 17:27:19 +0900 Subject: [PATCH] =?UTF-8?q?=EB=85=B8=ED=9B=84=EA=B4=80=EB=A1=9C=20ai=20?= =?UTF-8?q?=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 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 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 && ( +
+ +
+ )}