노후관로 ai 개발 기능 테스트_V1.0
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
+95
-12
@@ -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/**` 대체 가능 여부를 먼저 판단한다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<NoFooterLayout
|
||||
queryClient={queryClient}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import AgedPipePopupPage from "../components/agedPipe/AgedPipePopupPage";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<AgedPipePopupPage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="overflow-hidden rounded-sm border border-red-100 bg-red-50/35">
|
||||
<div className="relative flex min-h-[30px] items-center justify-center border-b border-red-100 bg-white px-8">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-2 top-1/2 h-[18px] w-[18px] -translate-y-1/2 bg-transparent text-[1.2rem] leading-none text-[#555]"
|
||||
onClick={() => setIsCollapsed((value) => !value)}
|
||||
title={isCollapsed ? "펼치기" : "접기"}
|
||||
>
|
||||
{isCollapsed ? "›" : "⌄"}
|
||||
</button>
|
||||
<span className="text-[1.2rem] font-semibold text-red-700">노후관로 설정</span>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="px-2 py-2">
|
||||
<div className="flex min-h-[30px] items-center gap-1 whitespace-nowrap text-[1.2rem] text-[#333]">
|
||||
<label htmlFor="aged-pipe-elapsed-years">경과년도(년)</label>
|
||||
<input
|
||||
id="aged-pipe-elapsed-years"
|
||||
className="h-[22px] w-14 border border-[#ccd6df] px-1 text-center text-[1.2rem]"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autoComplete="off"
|
||||
value={elapsedYearsText}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onChange={(event) => {
|
||||
const digits = event.target.value.replace(/[^0-9]/g, "");
|
||||
setElapsedYearsText(digits);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") commitElapsedYears();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-[22px] w-[22px] place-items-center rounded-full bg-[#31a83b] text-[1.4rem] font-bold leading-none text-white"
|
||||
onClick={commitElapsedYears}
|
||||
title="적용"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<AgedPipeFilters>(() =>
|
||||
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<AgedPipeRow[]>([]);
|
||||
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<string, unknown>, 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 (
|
||||
<main className="flex h-screen flex-col overflow-hidden bg-white">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
|
||||
<h1 className="text-[1.45rem] font-semibold text-white">노후관로 현황</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
size="xs"
|
||||
className="border border-white/30 px-3 py-1 text-[1.05rem] text-white"
|
||||
onClick={handleMinimize}
|
||||
>
|
||||
최소화
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="overflow-y-auto p-4">
|
||||
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[140px_minmax(0,1fr)_140px_minmax(0,1fr)_auto_auto]">
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">경과년도</div>
|
||||
<Input
|
||||
className="h-10 !text-[1.1rem]"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autoComplete="off"
|
||||
value={String(filters.elapsedYears ?? "")}
|
||||
onChange={(event) => {
|
||||
const digits = event.target.value.replace(/[^0-9]/g, "");
|
||||
setFilters({
|
||||
...filters,
|
||||
elapsedYears: digits === "" ? ("" as unknown as number) : Number(digits),
|
||||
});
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") handleSearch(1);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
value={filters.ctpvCd}
|
||||
onChange={(event) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
ctpvCd: event.target.value,
|
||||
sggCd: "0",
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
{ctpv.map((item) => (
|
||||
<option key={item.val} value={String(item.val)}>
|
||||
{item.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
value={filters.sggCd}
|
||||
onChange={(event) => setFilters({ ...filters, sggCd: event.target.value })}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
{sggOptions.map((item) => (
|
||||
<option key={item.val} value={String(item.val)}>
|
||||
{item.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-2 md:col-span-3 md:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
onClick={() => handleSearch(1)}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="green"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UiTable<AgedPipeRow>
|
||||
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}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
const useMap = new Map(MS_SWAGE_PPL_USE_CODES.map((item) => [item.val.trim(), item.text]));
|
||||
const materialMap = new Map(MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text]));
|
||||
|
||||
export const AGED_PIPE_DEFAULT_FILTERS: AgedPipeFilters = {
|
||||
elapsedYears: 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("노후관로")
|
||||
);
|
||||
};
|
||||
@@ -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)' },
|
||||
|
||||
@@ -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<number[]>; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; };
|
||||
const [layerNm, setLayerNm] = useState<string>(""); // 레이어 이름 상태
|
||||
@@ -88,7 +88,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
|
||||
const linkedFacilityOriginalStyleRef = useRef<any>(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,
|
||||
</div>
|
||||
<div className="overflow-y-auto overflow-x-hidden flex-1" ref={contentRef}>
|
||||
<VerticalTable
|
||||
headName='기본정보'
|
||||
headName={
|
||||
layerNm === '하수관로' && tableData?.elapsedYears
|
||||
? <>기본정보 <span className="ml-2 rounded-sm bg-red-50 px-2 py-0.5 text-[1.1rem] font-semibold text-red-700">경과년도 {tableData.elapsedYears}</span></>
|
||||
: '기본정보'
|
||||
}
|
||||
tableName1={layerNm === '중점우수토실' ? "측정지점" : undefined}
|
||||
tableData={[tableData]}
|
||||
headlist={headlist}
|
||||
|
||||
@@ -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<any[]>([]); // 초기값을 객체 또는 null로 설정
|
||||
const gisMaps = useRef<any | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태
|
||||
const [featureData, setFeatureData] = useState(null); // 선택된 피쳐 데이터
|
||||
const [featureData, setFeatureData] = useState<any>(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<AgedPipeFilters>(AGED_PIPE_DEFAULT_FILTERS);
|
||||
const [appliedAgedPipeFilters, setAppliedAgedPipeFilters] =
|
||||
useState<AgedPipeFilters>(AGED_PIPE_DEFAULT_FILTERS);
|
||||
const [isAgedPipeListOpen, setIsAgedPipeListOpen] = useState(false);
|
||||
const [isAgedPipeListMinimized, setIsAgedPipeListMinimized] = useState(false);
|
||||
const [agedPipeRows, setAgedPipeRows] = useState<AgedPipeRow[]>([]);
|
||||
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<Window | null>(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<string, unknown>, 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 (
|
||||
<AgedPipeLayerSettingPanel
|
||||
filters={draftAgedPipeFilters}
|
||||
onFiltersChange={setDraftAgedPipeFilters}
|
||||
onApply={handleApplyAgedPipeFilters}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[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 (
|
||||
<>
|
||||
<div className="bg-container">
|
||||
<div className="bg-container relative h-[calc(100dvh-5rem)] overflow-hidden">
|
||||
<Gis
|
||||
treeData={treeData}
|
||||
isSearch={true}
|
||||
@@ -150,8 +503,11 @@ const Map = () => {
|
||||
isSearchModalOpen={isSearchModalOpen}
|
||||
setIsSearchModalOpen={setIsSearchModalOpen}
|
||||
onCheckedLayersChange={handleCheckedLayersChange}
|
||||
onOpenPopup={handleOpenLayerPopup}
|
||||
searchBar={<ProcSearchBar gisMaps={gisMaps} facilityTypes={facilityTypes} />}
|
||||
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 && (
|
||||
<div className="fixed left-[480px] top-[68px] z-[90] flex min-w-[250px] items-center gap-3 rounded-sm border border-red-200 bg-white/95 px-3 py-2 shadow-[0_8px_22px_rgba(15,23,42,0.22)] backdrop-blur">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-sm bg-red-50">
|
||||
<span className="block h-4 w-4 rounded-sm border-2 border-red-500 bg-red-500/20" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[1.25rem] font-semibold leading-tight text-red-700">노후관로 현황</div>
|
||||
<div className="text-[1rem] leading-tight text-slate-500">
|
||||
{appliedAgedPipeFilters.elapsedYears}년 이상 · {agedPipeTotalCount.toLocaleString()}건
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
size="xs"
|
||||
className="h-7 px-3"
|
||||
onClick={openAgedPipeList}
|
||||
>
|
||||
열기
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
size="xs"
|
||||
className="h-7 px-3"
|
||||
onClick={() => setIsAgedPipeListMinimized(false)}
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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<Record<string, string>>((acc, field) => {
|
||||
acc[field.id] =
|
||||
field.id === "elapsedYears"
|
||||
? elapsedYears === null
|
||||
? "확인불가"
|
||||
: `${elapsedYears}년`
|
||||
: String(getPipeDisplayValue(source, field.id) ?? "-");
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="absolute right-0 top-0 z-[50] flex h-[calc(100vh-5rem)] w-[320px] flex-col overflow-hidden border-l border-slate-200 bg-white shadow-[-12px_0_24px_rgba(15,23,42,0.1)]">
|
||||
<div className="flex items-start justify-between border-b border-slate-200 bg-slate-900 px-4 py-3">
|
||||
<div className="absolute right-0 top-0 z-[50] flex h-[calc(100vh-5rem)] w-[350px] flex-col overflow-hidden border-l border-gray-300 bg-white">
|
||||
<div className="card-header relative flex items-center justify-between bg-primary p-4">
|
||||
<div>
|
||||
<div className="text-[1.45rem] font-semibold text-white">노후관로 상세</div>
|
||||
<div className="mt-1 text-[1.05rem] text-slate-300">시설관리번호 {facilityNo}</div>
|
||||
<div className="mt-1 text-[1.05rem] text-amber-300">
|
||||
경과년도 {elapsedYears ?? "-"}년
|
||||
<div className="text-2xl font-medium text-white text-label">
|
||||
하수관로 정보 <br />관리번호 : {facilityNo}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
size="xs"
|
||||
className="border border-white/30 px-3 py-1 text-[1.05rem] text-white"
|
||||
className="text-white hover:text-gray-300"
|
||||
onClick={onClose}
|
||||
>
|
||||
닫기
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto px-4 py-3">
|
||||
<div className="space-y-2">
|
||||
{AGED_PIPE_DETAIL_FIELDS.map((field) => (
|
||||
<div key={field.id} className="rounded-lg border border-slate-200 px-3 py-2.5">
|
||||
<div className="text-[1rem] font-medium text-slate-500">{field.title}</div>
|
||||
<div className="mt-1 break-words text-[1.15rem] leading-6 text-slate-900">
|
||||
{String(getPipeDisplayValue(source, field.id) ?? "-")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<VerticalTable
|
||||
headName="기본정보"
|
||||
tableData={[tableData]}
|
||||
headlist={detailFields}
|
||||
pageSize={20}
|
||||
total={0}
|
||||
cellClick={undefined}
|
||||
onPageChange={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className="w-[280px] rounded-xl border border-slate-200 bg-white/95 p-3 shadow-[0_14px_30px_rgba(15,23,42,0.16)] backdrop-blur">
|
||||
<div className="mb-2">
|
||||
<div className="text-[1.35rem] font-semibold text-slate-900">노후관로 기준</div>
|
||||
<div className="mt-1 text-[1.05rem] leading-5 text-slate-500">
|
||||
경과년수 이상 관로만 지도와 목록에 반영합니다.
|
||||
<div className="overflow-hidden rounded-sm border border-[#bdbdbd] bg-white">
|
||||
<div className="relative flex min-h-[34px] items-center justify-center border-b border-[#eeeeee] bg-white px-8">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-2 top-1/2 h-[18px] w-[18px] -translate-y-1/2 bg-transparent text-[1.2rem] leading-none text-[#555]"
|
||||
onClick={() => setIsCollapsed((value) => !value)}
|
||||
title={isCollapsed ? "펼치기" : "접기"}
|
||||
>
|
||||
{isCollapsed ? "›" : "⌄"}
|
||||
</button>
|
||||
<span className="text-[1.3rem] font-semibold text-[#4b5563]">{title}</span>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="px-3 py-2">
|
||||
<div className="flex min-h-[30px] items-center gap-1 whitespace-nowrap text-[1.2rem] text-[#333]">
|
||||
<label htmlFor="aged-pipe-elapsed-years">경과년도(년)</label>
|
||||
<input
|
||||
id="aged-pipe-elapsed-years"
|
||||
className="h-[22px] w-10 border border-[#ccd6df] px-1 text-center text-[1.2rem]"
|
||||
inputMode="numeric"
|
||||
value={String(filters.elapsedYears)}
|
||||
onChange={(event) => {
|
||||
const next = Number(event.target.value.replace(/[^0-9]/g, "") || "0");
|
||||
onFiltersChange({ ...filters, elapsedYears: next });
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") onApply();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-[22px] w-[22px] place-items-center rounded-full bg-[#31a83b] text-[1.4rem] font-bold leading-none text-white"
|
||||
onClick={onApply}
|
||||
title="적용"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">경과년수</div>
|
||||
<Input
|
||||
className="h-10 !text-[1.15rem]"
|
||||
inputMode="numeric"
|
||||
value={String(filters.elapsedYears)}
|
||||
onChange={(event) => {
|
||||
const next = Number(event.target.value.replace(/[^0-9]/g, "") || "0");
|
||||
onFiltersChange({ ...filters, elapsedYears: next });
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") onApply();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
value={filters.ctpvCd}
|
||||
onChange={(event) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
ctpvCd: event.target.value,
|
||||
sggCd: "0",
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
{ctpv.map((item) => (
|
||||
<option key={item.val} value={String(item.val)}>
|
||||
{item.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
value={filters.sggCd}
|
||||
onChange={(event) => onFiltersChange({ ...filters, sggCd: event.target.value })}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
{sggOptions.map((item) => (
|
||||
<option key={item.val} value={String(item.val)}>
|
||||
{item.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button className="mt-3 h-10 w-full !text-[1.15rem]" onClick={onApply}>
|
||||
GIS 반영
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,14 +64,11 @@ export default function AgedPipeListModal({
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-[70] flex items-center justify-center px-6 py-8">
|
||||
<div className="pointer-events-auto relative flex max-h-[82vh] w-[min(1120px,94vw)] flex-col overflow-hidden rounded-2xl border border-slate-300 bg-white shadow-[0_28px_80px_rgba(15,23,42,0.28)]">
|
||||
<div className="pointer-events-auto relative flex max-h-[82vh] w-[min(1120px,94vw)] flex-col overflow-hidden border border-[#dbe2e6] bg-white shadow-[0_0_12px_rgba(0,0,0,0.25)]">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-900 px-5 py-3">
|
||||
<div className="flex items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
|
||||
<div>
|
||||
<div className="text-[1.45rem] font-semibold text-white">노후관로 목록</div>
|
||||
<div className="mt-1 text-[1rem] text-slate-300">
|
||||
지도 위에 띄워지는 윈도우형 팝업 미리보기
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -85,7 +82,7 @@ export default function AgedPipeListModal({
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-y-auto p-4">
|
||||
<div className="grid grid-cols-1 gap-3 pb-4 md:grid-cols-[110px_minmax(0,1fr)_110px_minmax(0,1fr)_auto_auto]">
|
||||
<div className="grid grid-cols-1 gap-3 bg-slate-50 pb-4 md:grid-cols-[110px_minmax(0,1fr)_110px_minmax(0,1fr)_auto_auto]">
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">경과년수</div>
|
||||
<Input
|
||||
@@ -141,7 +138,7 @@ export default function AgedPipeListModal({
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-2 md:col-span-3 md:justify-end">
|
||||
<Button type="button" size="sm" className="h-10 px-5 !text-[1.1rem]" onClick={() => onSearch(1)}>
|
||||
<Button type="button" size="sm" color="primary" className="h-10 px-5 !text-[1.1rem]" onClick={() => onSearch(1)}>
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -7,7 +7,7 @@ import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
|
||||
import Stroke from "ol/style/Stroke.js";
|
||||
import Style from "ol/style/Style.js";
|
||||
import useStore from "@/store/useStore";
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
import { getData, getRawJson } from "@/components/lib/api";
|
||||
import AgedPipeFilterPanel from "./AgedPipeFilterPanel";
|
||||
import AgedPipeDetailPanel from "./AgedPipeDetailPanel";
|
||||
import AgedPipeListModal from "./AgedPipeListModal";
|
||||
@@ -321,12 +321,12 @@ const Map = () => {
|
||||
};
|
||||
}, [appliedFilters, mapReady]);
|
||||
|
||||
const FilterOverlay = useMemo(
|
||||
const LayerSettingView = useMemo(
|
||||
() =>
|
||||
function FilterOverlayComponent(props: any) {
|
||||
function LayerSettingComponent() {
|
||||
return (
|
||||
<AgedPipeFilterPanel
|
||||
{...props}
|
||||
title="지반침하정보"
|
||||
filters={draftFilters}
|
||||
onFiltersChange={setDraftFilters}
|
||||
onApply={handleApplyFilters}
|
||||
@@ -356,7 +356,7 @@ const Map = () => {
|
||||
setIsSearchModalOpen={setIsSearchModalOpen}
|
||||
onCheckedLayersChange={() => undefined}
|
||||
searchBar={<></>}
|
||||
waterDepth={FilterOverlay}
|
||||
layerSettingView={LayerSettingView}
|
||||
showZoomLevel={true}
|
||||
defaultZoom={7.5}
|
||||
defaultLonLat={[127.75, 36]}
|
||||
|
||||
@@ -4,20 +4,38 @@
|
||||
|
||||
## 화면구성
|
||||
### GIS 기본화면
|
||||
-노후관로 조건에 맞지 않는 관로를 숨기지 않고, 기존 시설물 레이어는 원래대로 모두 보이게 두고 조건에 맞는 관로만 빨간 강조선을 추가하도록 한다.
|
||||
- 좌측 상단 (시설물 레이어 트리 및 목록 아이콘): 시설, 침수위험지도, 지반침하정보(우측에 목록 아이콘), 노후관로(우측에 목록 아이콘)
|
||||
- 좌측 하단 (레이어별 설정 항목 및 적용 버튼): 경과년도(년) [ 디폴트 30년 ]
|
||||
- 노후관로 조건에 맞지 않는 관로를 숨기지 않고, 기존 시설물 레이어는 원래대로 모두 보이게 두고 조건에 맞는 관로만 빨간 강조선을 추가하도록 한다.
|
||||
- 좌측 상단 레이어 트리 최상위 노출 순서는 시설, 침수위험지도(중점관리지역), 도시침수지도(정보제공포털), 노후관로 순으로 한다.
|
||||
- 노후관로는 레이어 트리 최상위 항목으로 노출하고 우측에 목록 아이콘을 제공한다.
|
||||
- 레이어별 설정 항목 및 적용 버튼은 해당 레이어 트리 항목 바로 아래에 인라인으로 표시한다.
|
||||
- 노후관로 인라인 설정: 경과년도(년) [ 디폴트 30년 ]
|
||||
- 경과년도 입력은 키보드 입력 중 포커스가 유지되어야 하며, 적용 버튼 또는 Enter 입력 시 지도 강조선과 목록 조회 조건에 반영한다.
|
||||
- 우측 영역(배경 지도 및 시설레이어 표출): 하수관로 중 경과년도가 30년 이상된 관로들을 시각적으로 강조 표시
|
||||
|
||||
### 요구사항 명세
|
||||
- 노후관로 레이어는 기존 하수관로 시설물 레이어와 별도 제어 항목으로 제공한다.
|
||||
- 노후관로, 지반침하정보처럼 레이어별 설정이 필요한 항목은 공통 레이어 트리의 인라인 설정 확장 포인트를 통해 추가한다.
|
||||
- 인라인 설정 확장 포인트는 특정 레이어 노드 조건에 따라 해당 노드 바로 아래에 설정 컴포넌트를 표시할 수 있어야 한다.
|
||||
- 향후 지반침하정보는 노후관로와 동일한 방식으로 지반침하정보 노드 아래에 조회조건/적용 버튼을 추가할 수 있어야 한다.
|
||||
- 노후관로 체크 시 현재 지도 줌 레벨에서 조회 가능한 노후관로 강조 레이어를 표출한다.
|
||||
- 노후관로 강조선은 사용자가 쉽게 구분할 수 있도록 선명한 반투명 빨간 선으로 표출한다.
|
||||
- 경과년도 기본값은 30년으로 한다.
|
||||
- 경과년도는 숫자만 입력 가능해야 하며, 입력 중 컴포넌트가 재생성되어 포커스를 잃으면 안 된다.
|
||||
- 노후관로 목록 행 클릭 시 해당 관로 위치로 지도 이동, 선택 피처 표시, 우측 상세정보 패널 표시가 함께 수행되어야 한다.
|
||||
- 목록 행 선택으로 지도 이동 시 선택 관로를 지도 정중앙에 배치하고, 흰색 외곽선과 보라색 선택선으로 일반 노후관로 강조선과 명확히 구분한다.
|
||||
|
||||
### 관로정보 상세화면
|
||||
- GIS 화면에서 관로 위치 클릭시 우측 정렬로 세로형 영역으로 관로정보 상세화면 팝업
|
||||
- 1차년도에서 개발한 관로정보 표출 : 시설관리번호, 기관관리번호, 설치일자, 도엽번호 등 전체 항목 그대로 표출
|
||||
|
||||
### 노후관로정보 목록화면
|
||||
- 윈도우형 팝업
|
||||
- GIS 기본화면 좌측 상단의 레이어 트리에서 노후관로 목록 아이콘 클릭시 윈도우 팝업으로 가운데 영역으로 노후관로정보 목록화면 팝업
|
||||
- GIS 기본화면 좌측 상단의 레이어 트리에서 노후관로 목록 아이콘 클릭시 브라우저 윈도우 팝업으로 노후관로정보 목록화면 팝업
|
||||
- 상단 조회조건( 조회조건 영역 ) : 경과년도, 시도, 시군구, 버튼 2개 (조회, 다운로드)
|
||||
- 하단 가로형 그리드 (노후관로 현황) : NO, 시설관리번호, 설치일자, 관경(mm), 연장(m), 용도, 재질, 경과년도
|
||||
- 윈도우 팝업은 실제 Next 라우트(`/map/aged-pipe-popup`)로 열고, 지도 화면과의 연동은 `postMessage`로 처리한다.
|
||||
- 팝업 내부 앱 버튼은 `최소화`만 제공한다. 닫기는 브라우저 창 X를 사용한다.
|
||||
- 지도 좌측 인라인 경과년도 적용 시 열린 윈도우 팝업의 조회조건과 목록도 함께 갱신한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -29,8 +47,9 @@
|
||||
// 화면 로드 시 초기 데이터 및 환경 설정
|
||||
ON_LOAD:
|
||||
CALL GIS_지도_레이어_초기화()
|
||||
SET 기본_경과년도 = 20 // 회의록(260311) 반영 디폴트 설정
|
||||
SET 현재_조회_조건 = { "경과년도": 20, "관종": "전체" }
|
||||
SET 기본_경과년도 = 30
|
||||
SET 현재_조회_조건 = { "경과년도": 30, "시도": "전체", "시군구": "전체" }
|
||||
CALL 레이어트리_정렬(["시설", "침수위험지도(중점관리지역)", "도시침수지도(정보제공포털)", "노후관로"])
|
||||
|
||||
// 초기 데이터 로드
|
||||
CALL 노후관로_데이터_조회(현재_조회_조건)
|
||||
@@ -43,14 +62,21 @@ ON_LOAD:
|
||||
// 노후관로 조회 조건 변경 및 적용
|
||||
IF 노후관로_조회_버튼.CLICK THEN
|
||||
SET 입력_경과년도 = 사이드바.경과년도_INPUT.Value
|
||||
SET 선택_관종 = 사이드바.관종_SELECT.Value
|
||||
SET 선택_시도 = 모달.시도_SELECT.Value
|
||||
SET 선택_시군구 = 모달.시군구_SELECT.Value
|
||||
|
||||
// 공간 및 속성 필터링 실행
|
||||
CALL 노후관로_필터링_프로세스(입력_경과년도, 선택_관종)
|
||||
CALL 노후관로_필터링_프로세스(입력_경과년도, 선택_시도, 선택_시군구)
|
||||
REFRESH_GIS_LAYER(노후관로_레이어)
|
||||
SHOW MESSAGE "설정된 조건으로 노후관로가 필터링되었습니다."
|
||||
END IF
|
||||
|
||||
// 경과년도 입력
|
||||
IF 사이드바.경과년도_INPUT.CHANGE THEN
|
||||
SET 로컬_입력값 = 숫자만_필터링(입력값)
|
||||
KEEP_FOCUS(레이어트리.노후관로.경과년도_INPUT)
|
||||
END IF
|
||||
|
||||
// 목록 아이콘 클릭 (모달 호출)
|
||||
IF 레이어트리.노후관로_목록아이콘.CLICK THEN
|
||||
DISPLAY 노후관로_목록_모달 = TRUE
|
||||
@@ -85,13 +111,24 @@ IF 모달.다운로드_버튼.CLICK THEN
|
||||
END IF
|
||||
|
||||
// 목록 그리드 행 선택 시 로직
|
||||
IF 모달.그리드_행.DOUBLE_CLICK THEN
|
||||
IF 모달.그리드_행.CLICK THEN
|
||||
SET 좌표 = 현재행.중심좌표
|
||||
CALL GIS_지도_줌인(좌표)
|
||||
CALL GIS_지도_줌인(좌표, "선택관로_정중앙배치")
|
||||
CALL 노후관로_상세정보_표출(현재행.ID)
|
||||
CLOSE_MODAL(노후관로_목록_모달)
|
||||
// 사용자가 직접 최소화를 누르기 전에는 목록 창을 자동 최소화하지 않는다.
|
||||
END IF
|
||||
```
|
||||
|
||||
### 2026-04-23 구현 확정 보정
|
||||
|
||||
| 항목 | 확정 동작 |
|
||||
|---|---|
|
||||
| 목록 표시 방식 | 지도 내부 모달/fixed popup이 아니라 `/map/aged-pipe-popup` 브라우저 윈도우 팝업으로 표시 |
|
||||
| 팝업 최소화 | 목록 행 클릭이 아니라 팝업 헤더의 `최소화` 클릭 시에만 지도 위 최소화 카드 표시 |
|
||||
| 팝업 복원 | 최소화 카드의 `열기` 클릭 시 동일 조건으로 윈도우 팝업 재오픈 |
|
||||
| 설정 동기화 | 좌측 노후관로 설정의 경과년도 적용 시 열린 팝업에 `SN_AGED_PIPE_FILTER_SYNC` 메시지 전송 |
|
||||
| 행 선택 연동 | 팝업 목록 행 클릭 시 `SN_AGED_PIPE_ROW_SELECT` 메시지로 부모 지도에 row 전달 |
|
||||
| 선택선 | 선택 관로는 다중 stroke(soft glow, white halo, violet core, highlight)로 강조 |
|
||||
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 또는 응답 필드 변경
|
||||
- 컴포넌트 구조 변경
|
||||
- 상태관리 방식 변경
|
||||
- 브라우저 검증 결과 확정
|
||||
- 미확인사항 해결
|
||||
- 백엔드 계약 변경
|
||||
@@ -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 계약 확인
|
||||
→ 재사용 가능 여부 판단
|
||||
→ 최소 구현
|
||||
→ 브라우저 검증
|
||||
→ 결과 기록
|
||||
```
|
||||
|
||||
세부 구현은 상황에 맞게 조정하되, 기존 화면 패턴 재사용과 실제 브라우저 검증은 항상 유지한다.
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# 노후관로관리 범위
|
||||
|
||||
## 목적
|
||||
|
||||
노후관로관리 및 노후관로분석 화면의 프론트엔드 구현 범위를 정의한다.
|
||||
|
||||
## 포함 범위
|
||||
|
||||
- 노후관로 레이어 체크/표출
|
||||
- 좌측 하단 경과년도 설정 영역
|
||||
- 노후관로 목록 모달
|
||||
- 목록 행 더블클릭과 지도/상세 연동
|
||||
- 관로 상세정보 표시
|
||||
- 하수처리구역 기준 매설년도 분석정보 표시
|
||||
- 보수이력 진입 버튼 또는 링크
|
||||
- 엑셀 다운로드 버튼 연결
|
||||
|
||||
## 제외 범위
|
||||
|
||||
- 백엔드 API 신규 구현
|
||||
- DB 스키마 변경
|
||||
- 백엔드 공통 응답 구조 변경
|
||||
- `sn` 외 다른 앱 화면 변경
|
||||
- `business_components` 외 공통 패키지 직접 수정
|
||||
|
||||
## 기준 문서
|
||||
|
||||
- 기능 요구사항 명세서 v2.0
|
||||
- 시나리오 v2.0
|
||||
- `01-기존화면기반-컴포넌트분해.md`
|
||||
- `02-컴포넌트별-작업목록.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` 수정이 필요한 경우 타 앱 영향 범위를 먼저 정리한다.
|
||||
@@ -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)를 적용한다. |
|
||||
@@ -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로 받을지
|
||||
@@ -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 노후관로분석
|
||||
|
||||
| 트리거 | 동작 | 결과 |
|
||||
|---|---|---|
|
||||
| 화면 진입 | 하수처리구역 레이어 표시 | 분석 대상 구역 확인 |
|
||||
| 처리구역 클릭 | 구역 기본정보 및 관로 분석정보 조회/계산 | 우측 상세 패널 표시 |
|
||||
| 보수이력 클릭 | 보수이력 팝업 또는 화면 호출 | 이력 목록 표시 |
|
||||
@@ -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` 빌드만 우선 수행한다.
|
||||
|
||||
## 네트워크 검증
|
||||
|
||||
- 레이어 설정 조회
|
||||
- 관로 목록 조회
|
||||
- 관로 상세 조회
|
||||
- 다운로드 요청
|
||||
- 처리구역 분석 요청 또는 계산용 데이터 조회
|
||||
@@ -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 개발 환경 권한 확인이 필요하다.
|
||||
@@ -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 검증 방법을 함께 제시해줘.
|
||||
|
||||
결과 보고는 아래 형식으로 해줘.
|
||||
- 작업 목적
|
||||
- 수정 대상 파일
|
||||
- 변경 내용 요약
|
||||
- 영향 범위
|
||||
- 테스트 / 검증 방법
|
||||
- 롤백 방법
|
||||
- 남은 리스크
|
||||
```
|
||||
|
||||
@@ -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/레이어 설정은 별도 확정 필요
|
||||
@@ -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/필드/응답 사용 방식만 매핑한다.
|
||||
- 화면설계서와 다른 독립 디자인을 만들지 않는다.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -10,3 +10,7 @@ body {
|
||||
font-family: "Noto Sans KR", sans-serif;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
body.sn-map-page-lock {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# 프론트엔드 AI 개발 문서
|
||||
|
||||
## 목적
|
||||
|
||||
이 디렉터리는 `FE_REPO_ROOT` 전체에 적용되는 프론트엔드 공통 개발 기준을 관리한다.
|
||||
특정 앱의 화면/기능 상세가 아니라, 여러 프론트엔드 앱에서 함께 참고할 수 있는 구조, 실행, 응답 처리, 검증, 템플릿 기준을 둔다.
|
||||
|
||||
## 디렉터리 구조
|
||||
|
||||
| 경로 | 역할 |
|
||||
|---|---|
|
||||
| `구조/` | 저장소 구조, 폴더 구조, 상태관리, 화면흐름 같은 공통 구조 기준 |
|
||||
| `실행명세/` | 프론트엔드 실행 흐름, 기능 목록, 증적, 진행 로그 기준 |
|
||||
| `응답표준화/` | API 응답을 프론트에서 호출, 변환, 표시, 오류 처리하는 기준 |
|
||||
| `검증/` | 브라우저, 빌드, 네트워크 검증 기준과 로그 |
|
||||
| `템플릿/` | 기능별 문서 작성에 재사용할 표준 템플릿 |
|
||||
|
||||
## 작성 범위
|
||||
|
||||
- 프론트엔드 앱 공통 구조
|
||||
- 공통 컴포넌트 사용 기준
|
||||
- 화면 실행/브라우저 검증 기준
|
||||
- API 응답 사용/표시/오류 처리 기준
|
||||
- 기능별 산출물 템플릿
|
||||
|
||||
## 작성 금지 범위
|
||||
|
||||
- 백엔드 Controller, Service, Repository 구현 상세
|
||||
- DB 스키마 상세
|
||||
- 특정 앱에만 해당하는 화면별 구현 상세
|
||||
- `sn` 전용 기능 산출물
|
||||
|
||||
특정 앱 기능 문서는 해당 앱의 `docs/` 아래에 둔다.
|
||||
예: `SN_APP_ROOT/docs/노후관로관리/**`
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# 프론트엔드 검증 기준
|
||||
|
||||
## 기본 원칙
|
||||
|
||||
- 화면 변경은 브라우저에서 실제로 확인한다.
|
||||
- API 연동 화면은 네트워크 요청과 응답을 함께 확인한다.
|
||||
- 공통 패키지를 수정하면 해당 패키지 빌드와 사용 앱 빌드를 모두 확인한다.
|
||||
- 검증하지 못한 항목은 결과 보고에 남긴다.
|
||||
|
||||
## 검증 구분
|
||||
|
||||
| 구분 | 목적 |
|
||||
|---|---|
|
||||
| 브라우저 검증 | 화면 배치, 상호작용, 모달, 상세 패널 확인 |
|
||||
| 빌드 검증 | 타입, 번들, 패키지 의존성 확인 |
|
||||
| 네트워크 검증 | 요청 파라미터, 응답 필드, 오류 처리 확인 |
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# 브라우저 검증
|
||||
|
||||
## 확인 항목
|
||||
|
||||
- 화면이 기존 레이아웃과 같은 위치/톤을 유지하는가
|
||||
- 버튼, 체크박스, 토글, 모달, 상세 패널이 동작하는가
|
||||
- 텍스트가 겹치거나 잘리지 않는가
|
||||
- 지도 화면은 레이어 표시/선택/확대 이동이 동작하는가
|
||||
- 목록 행 선택과 상세 패널이 연동되는가
|
||||
|
||||
## 기록 항목
|
||||
|
||||
- 확인 일시
|
||||
- 확인 URL
|
||||
- 브라우저
|
||||
- 주요 동작
|
||||
- 캡처 또는 증적 경로
|
||||
- 남은 이슈
|
||||
|
||||
@@ -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]"
|
||||
```
|
||||
|
||||
## 기록 항목
|
||||
|
||||
- 실행 명령
|
||||
- 성공/실패
|
||||
- 실패 로그 핵심
|
||||
- 영향 범위
|
||||
- 후속 조치
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# 네트워크 검증
|
||||
|
||||
## 확인 항목
|
||||
|
||||
- 요청 URL
|
||||
- HTTP method
|
||||
- query/body 파라미터
|
||||
- 응답 상태 코드
|
||||
- 화면 표시 필드와 응답 필드 매핑
|
||||
- 오류 응답 처리
|
||||
- 다운로드 응답 처리
|
||||
|
||||
## 기록 기준
|
||||
|
||||
특정 기능의 상세 네트워크 검증 결과는 앱 기능 문서에 작성한다.
|
||||
공통 규칙은 이 문서에 유지한다.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# 프론트엔드 검증로그
|
||||
|
||||
| 일자 | 대상 | 검증 유형 | 결과 | 후속 조치 |
|
||||
|---|---|---|---|---|
|
||||
| - | - | - | - | - |
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# 검증 문서
|
||||
|
||||
## 목적
|
||||
|
||||
프론트엔드 작업의 공통 검증 기준을 관리한다.
|
||||
브라우저, 빌드, 네트워크 검증을 분리해 기록한다.
|
||||
|
||||
## 포함 문서
|
||||
|
||||
| 문서 | 역할 |
|
||||
|---|---|
|
||||
| `00-검증기준.md` | 공통 검증 원칙 |
|
||||
| `01-브라우저검증.md` | 화면/상호작용 검증 기준 |
|
||||
| `02-빌드검증.md` | 빌드 명령과 실패 처리 기준 |
|
||||
| `03-네트워크검증.md` | API 요청/응답 확인 기준 |
|
||||
| `99-검증로그.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 계약, 응답 구조, 권한, 지도 레이어 영향이 있으면 백엔드 문서와 함께 확인한다.
|
||||
@@ -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. 기존 컴포넌트로 불가능한 경우에만 최소 범위의 컴포넌트 보완을 검토한다.
|
||||
@@ -0,0 +1,28 @@
|
||||
# 프론트엔드 상태관리 구조
|
||||
|
||||
## 목적
|
||||
|
||||
`sn` 앱의 화면 상태, 조회 조건, 지도 선택 객체, 모달 상태를 일관되게 다루기 위한 기준 문서이다.
|
||||
|
||||
## 기본 원칙
|
||||
|
||||
- 기존 `sn` 앱에서 사용하는 상태 관리 방식과 훅 구조를 먼저 확인한다.
|
||||
- 화면 단위 상태는 가능하면 해당 화면 또는 feature 범위 안에서 관리한다.
|
||||
- 여러 컴포넌트가 공유하는 상태는 기존 store 또는 공통 훅 패턴을 따른다.
|
||||
- 지도 선택 객체, 목록 선택 행, 상세 패널 표시 상태는 서로 불일치하지 않도록 한 흐름에서 갱신한다.
|
||||
|
||||
## 확인 대상
|
||||
|
||||
- `SN_APP_ROOT/app/store/**`
|
||||
- `SN_APP_ROOT/src/**`
|
||||
- 기존 지도 화면의 선택 객체, 레이어 표시, 검색 모달 상태 흐름
|
||||
|
||||
## 문서화 기준
|
||||
|
||||
상태 흐름을 새로 추가하거나 변경할 때는 아래 내용을 작업 문서에 남긴다.
|
||||
|
||||
- 상태 이름과 소유 컴포넌트
|
||||
- 상태 변경 트리거
|
||||
- 백엔드/API 응답과의 관계
|
||||
- 지도, 그리드, 상세 패널 간 동기화 방식
|
||||
- 초기화와 오류 처리 방식
|
||||
@@ -0,0 +1,29 @@
|
||||
# 프론트엔드 화면흐름
|
||||
|
||||
## 목적
|
||||
|
||||
프론트엔드 화면 이동, 지도 상호작용, 모달/상세 패널 표시 흐름을 정리하기 위한 기준 문서이다.
|
||||
|
||||
## 기본 원칙
|
||||
|
||||
- 신규 화면은 기존 1차 화면의 레이아웃과 조작 방식을 우선 따른다.
|
||||
- 디자인을 새로 만드는 대신 기존 페이지, 모달, 테이블, 지도 패널 컴포넌트 조합을 재사용한다.
|
||||
- 화면 ID, 메뉴 경로, API 계약, 권한 조건을 함께 기록한다.
|
||||
|
||||
## 화면흐름 기록 항목
|
||||
|
||||
- 화면 ID
|
||||
- 메뉴 경로
|
||||
- 진입 조건
|
||||
- 주요 조회 조건
|
||||
- 지도/그리드/상세 패널 연동
|
||||
- 모달 또는 팝업 호출 조건
|
||||
- 다운로드/저장/삭제 같은 주요 액션
|
||||
- 권한별 노출 차이
|
||||
- 오류/빈 데이터 처리
|
||||
|
||||
## `sn` 화면 개발 기준
|
||||
|
||||
- 실제 페이지는 `SN_APP_ROOT/app/**`를 기준으로 확인한다.
|
||||
- 지도 기반 화면은 기존 `(map-layout)` 구조와 `Gis` 관련 컴포넌트 사용 방식을 먼저 확인한다.
|
||||
- 공통 비즈니스 UI가 필요하면 `BUSINESS_COMPONENTS_ROOT/src/**`를 우선 검토한다.
|
||||
@@ -0,0 +1,20 @@
|
||||
# 프론트엔드 미확인사항
|
||||
|
||||
## 목적
|
||||
|
||||
프론트엔드 2차사업 개발 중 아직 확정되지 않은 계약, 화면, 권한, 공통 컴포넌트 이슈를 기록한다.
|
||||
|
||||
## 기록 형식
|
||||
|
||||
| 일자 | 구분 | 내용 | 영향 범위 | 확인 필요 대상 | 상태 |
|
||||
|---|---|---|---|---|---|
|
||||
| - | - | - | - | - | - |
|
||||
|
||||
## 주요 확인 대상
|
||||
|
||||
- 백엔드 API 요청/응답 계약
|
||||
- 지도 레이어명, 속성명, zoom level별 표시 조건
|
||||
- 1차 화면과 다른 UI 변경 필요 여부
|
||||
- `BUSINESS_COMPONENTS_ROOT` 수정 시 타 앱 영향 가능성
|
||||
- 대용량 목록/엑셀 다운로드 처리 방식
|
||||
- 권한별 조회 범위와 버튼 노출 기준
|
||||
@@ -0,0 +1,23 @@
|
||||
# 구조 문서
|
||||
|
||||
## 목적
|
||||
|
||||
프론트엔드 저장소의 공통 구조를 설명한다.
|
||||
여기에는 특정 화면의 구현 세부가 아니라, 여러 앱에서 공유할 수 있는 구조 기준만 작성한다.
|
||||
|
||||
## 포함 문서
|
||||
|
||||
| 문서 | 역할 |
|
||||
|---|---|
|
||||
| `00-시스템맵.md` | 앱과 공통 패키지, 백엔드 연동 지점의 큰 그림 |
|
||||
| `01-폴더구조.md` | 저장소 폴더와 수정 가능 범위 |
|
||||
| `03-상태관리구조.md` | 상태관리 기준 |
|
||||
| `04-화면흐름.md` | 화면 진입, 이동, 모달, 상세 패널 흐름 기준 |
|
||||
| `99-미확인사항.md` | 구조 관련 미확인사항 |
|
||||
|
||||
## 작성 기준
|
||||
|
||||
- `FE_REPO_ROOT` 전체에 적용되는 기준만 작성한다.
|
||||
- 특정 앱의 기능 상세는 앱 내부 문서에 작성한다.
|
||||
- 백엔드 구현 상세는 작성하지 않는다.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# 프론트엔드 기능명세 계획
|
||||
|
||||
## 목적
|
||||
|
||||
프론트엔드 기능명세 작성 전에 화면 단위로 어떤 산출물을 만들지 정리한다.
|
||||
|
||||
## 작성 단위
|
||||
|
||||
- 화면 ID
|
||||
- 메뉴 경로
|
||||
- 주요 컴포넌트
|
||||
- 상태 관리
|
||||
- API 호출부
|
||||
- 사용자 이벤트
|
||||
- 브라우저 검증 방법
|
||||
|
||||
## 작성 위치 기준
|
||||
|
||||
- 여러 앱 공통 기준: `FE_REPO_ROOT/docs/실행명세/**`
|
||||
- `sn` 특정 기능: `SN_APP_ROOT/docs/**`
|
||||
|
||||
## 노후관로관리 적용 예
|
||||
|
||||
- `SN_MN010102`: 노후관로관리
|
||||
- `SN_MN010103`: 노후관로분석
|
||||
- 세부 문서 위치: `SN_APP_ROOT/docs/노후관로관리/**`
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# 프론트엔드 기능목록
|
||||
|
||||
## 목적
|
||||
|
||||
프론트엔드에서 구현하거나 검증해야 하는 기능을 화면 기준으로 추적한다.
|
||||
|
||||
| 화면 ID | 기능 | 주요 컴포넌트 | 상태 | 문서 위치 |
|
||||
|---|---|---|---|---|
|
||||
| SN_MN010102 | 노후관로관리 | `Gis`, `GisTree`, `UiTable`, `FeatureClickEventPopup` | 정리 중 | `SN_APP_ROOT/docs/노후관로관리/**` |
|
||||
| SN_MN010103 | 노후관로분석 | `Gis`, `FeatureClickEventPopup`, `VerticalTable` | 정리 중 | `SN_APP_ROOT/docs/노후관로관리/**` |
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# 프론트엔드 증적목록
|
||||
|
||||
## 목적
|
||||
|
||||
프론트엔드 작업의 실행, 빌드, 브라우저 확인 증적을 추적한다.
|
||||
|
||||
| 일자 | 기능 | 증적 유형 | 경로/내용 | 상태 |
|
||||
|---|---|---|---|---|
|
||||
| - | - | - | - | - |
|
||||
|
||||
## 증적 유형
|
||||
|
||||
- 정적 HTML 샘플
|
||||
- 브라우저 캡처
|
||||
- 빌드 로그
|
||||
- 네트워크 응답 확인
|
||||
- 수동 테스트 결과
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# 프론트엔드 실행명세 진행로그
|
||||
|
||||
| 일자 | 작업 | 결과 | 후속 작업 |
|
||||
|---|---|---|---|
|
||||
| 2026-04-21 | 노후관로관리 화면 기준 정리 | 기존 화면 기반 컴포넌트 분해 및 작업 목록 작성 | API/응답 필드 매핑표 작성 |
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# 실행명세 문서
|
||||
|
||||
## 목적
|
||||
|
||||
프론트엔드 화면을 실제로 실행하고 검증하기 위한 기준과 진행 기록을 관리한다.
|
||||
|
||||
## 포함 문서
|
||||
|
||||
| 문서 | 역할 |
|
||||
|---|---|
|
||||
| `00-범위.md` | 실행명세의 범위와 제외 범위 |
|
||||
| `02-기능명세계획.md` | 화면/기능 단위 산출물 계획 |
|
||||
| `03-기능목록.md` | 추적할 기능 목록 |
|
||||
| `04-증적목록.md` | 캡처, 로그, 빌드 결과 같은 증적 목록 |
|
||||
| `99-진행로그.md` | 실행명세 진행 기록 |
|
||||
|
||||
## 작성 기준
|
||||
|
||||
- 프론트엔드 실행과 브라우저 동작 기준만 작성한다.
|
||||
- 서버 실행 로그를 인용할 수는 있지만, 백엔드 내부 구현 분석은 쓰지 않는다.
|
||||
- 특정 앱 기능의 상세 실행 결과는 앱 내부 문서에 작성한다.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# 프론트엔드 응답표준화 범위
|
||||
|
||||
## 목적
|
||||
|
||||
프론트엔드 응답표준화 문서는 백엔드 응답을 화면에서 어떻게 호출, 변환, 표시, 오류 처리할지 정리한다.
|
||||
백엔드 응답 구조 자체를 바꾸는 문서가 아니라, 프론트엔드 사용 기준을 정의한다.
|
||||
|
||||
## 작성 범위
|
||||
|
||||
- API 호출 위치
|
||||
- 요청 파라미터
|
||||
- 응답 필드 사용처
|
||||
- 화면 표시 필드 매핑
|
||||
- 오류/빈 데이터 처리
|
||||
- 다운로드/파일 응답 처리
|
||||
- 기존 응답과 신규 후보 API 비교
|
||||
|
||||
## 제외 범위
|
||||
|
||||
- 백엔드 공통 응답 포맷 변경
|
||||
- DB 컬럼 설계
|
||||
- Repository/Service 구현 상세
|
||||
|
||||
@@ -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가 연결되는 흐름은 화면 동작까지 확인한다.
|
||||
- 백엔드 실패, 권한 부족, 데이터 없음, 대용량 다운로드 같은 예외 흐름을 함께 점검한다.
|
||||
@@ -0,0 +1,22 @@
|
||||
# 프론트엔드 응답 처리 규칙
|
||||
|
||||
## 기본 원칙
|
||||
|
||||
- 백엔드 응답 구조를 프론트에서 임의로 바꾸어 가정하지 않는다.
|
||||
- 화면에서 필요한 필드는 실제 API 응답과 DTO를 확인해 매핑한다.
|
||||
- 코드값은 기존 코드 매핑 유틸 또는 공통 코드 목록을 우선 사용한다.
|
||||
- 빈 값, NULL, 권한 오류, 네트워크 오류는 화면별로 표시 기준을 둔다.
|
||||
|
||||
## 표시 규칙
|
||||
|
||||
- 값 없음: `-`
|
||||
- 설치일자 없음: `확인불가`
|
||||
- 숫자: 기존 테이블 포맷 규칙을 따른다.
|
||||
- 코드값: 기존 `codeMapper`, `MS_SWAGE_PPL_CODES`, `MS_SWAGE_PPL_USE_CODES` 등을 우선 확인한다.
|
||||
|
||||
## 오류 처리
|
||||
|
||||
- 조회 실패: 기존 toast/alert 패턴을 따른다.
|
||||
- 다운로드 실패: 사용자에게 실패 메시지를 표시한다.
|
||||
- 권한 오류: 버튼/조회 범위 노출 조건을 백엔드 권한과 맞춘다.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# 프론트엔드 응답 전환 계획
|
||||
|
||||
## 목적
|
||||
|
||||
기존 API 호출 방식과 신규 후보 API를 비교해 화면별 전환 필요성을 판단한다.
|
||||
|
||||
## 전환 기준
|
||||
|
||||
| 구분 | 기준 |
|
||||
|---|---|
|
||||
| 기존 API 재사용 | 현재 화면 필드와 동작을 충족하는 경우 |
|
||||
| 프론트 변환 추가 | 응답은 충분하지만 화면 표시 필드 변환이 필요한 경우 |
|
||||
| 신규 API 요청 | 기존 API로 성능, 필드, 권한, 다운로드 요구사항을 충족하지 못하는 경우 |
|
||||
|
||||
## 작업 순서
|
||||
|
||||
1. 현재 호출 API 확인
|
||||
2. 화면 표시 필드 매핑
|
||||
3. 누락 필드 정리
|
||||
4. 오류/빈 데이터 처리 정리
|
||||
5. 신규 API 필요 여부 판단
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# 프론트엔드 응답 회귀점검목록
|
||||
|
||||
| 항목 | 확인 내용 | 상태 |
|
||||
|---|---|---|
|
||||
| 목록 조회 | 요청 파라미터와 응답 목록이 화면에 맞는지 확인 | 대기 |
|
||||
| 상세 조회 | 상세 패널 필드가 기존 화면과 동일한 톤으로 표시되는지 확인 | 대기 |
|
||||
| 코드 매핑 | 용도/재질 코드가 한글명으로 표시되는지 확인 | 대기 |
|
||||
| 빈 데이터 | 데이터 없음/NULL이 표시 규칙을 따르는지 확인 | 대기 |
|
||||
| 다운로드 | 조회 조건과 다운로드 결과가 일치하는지 확인 | 대기 |
|
||||
| 오류 처리 | API 실패 시 사용자 메시지가 표시되는지 확인 | 대기 |
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# 프론트엔드 응답표준화 미확인사항
|
||||
|
||||
| 일자 | 구분 | 내용 | 확인 필요 대상 | 상태 |
|
||||
|---|---|---|---|---|
|
||||
| - | - | - | - | - |
|
||||
|
||||
## 노후관로관리 우선 확인 대상
|
||||
|
||||
- 기존 `v1/fclts`로 노후관로 목록 조건을 충분히 처리할 수 있는지
|
||||
- 경과년도 계산을 프론트에서 할지, 백엔드 응답 필드로 받을지
|
||||
- 설치일자 NULL/미래일자 처리 기준
|
||||
- 엑셀 다운로드를 기존 프론트 유틸로 처리할지, 백엔드 파일 API로 처리할지
|
||||
- 하수처리구역 분석 통계를 프론트 계산으로 할지, 백엔드 API로 받을지
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# 응답표준화 문서
|
||||
|
||||
## 목적
|
||||
|
||||
프론트엔드가 백엔드 API 응답을 어떻게 호출, 변환, 표시, 오류 처리하는지에 대한 공통 기준을 관리한다.
|
||||
|
||||
## 포함 문서
|
||||
|
||||
| 문서 | 역할 |
|
||||
|---|---|
|
||||
| `00-범위.md` | 프론트엔드 응답표준화의 범위 |
|
||||
| `01-API구조.md` | 프론트엔드 API 호출 구조 |
|
||||
| `02-프론트엔드응답처리규칙.md` | 값 표시, 코드 매핑, 오류 처리 규칙 |
|
||||
| `03-프론트엔드전환계획.md` | 기존 API와 신규 후보 API 비교 기준 |
|
||||
| `04-프론트엔드회귀점검목록.md` | 응답 처리 회귀 점검 항목 |
|
||||
| `99-미확인사항.md` | 응답 관련 미확인사항 |
|
||||
|
||||
## 작성 기준
|
||||
|
||||
- 백엔드 응답 구조 자체를 바꾸는 문서가 아니다.
|
||||
- 프론트 화면에서 응답을 어떻게 사용하는지에 집중한다.
|
||||
- 특정 기능의 API 필드 매핑표는 해당 앱 기능 문서에 작성한다.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# API 응답 필드 매핑표
|
||||
|
||||
## 목적
|
||||
|
||||
-
|
||||
|
||||
## API 목록
|
||||
|
||||
| 구분 | Method | Endpoint | 사용 화면/컴포넌트 | 상태 |
|
||||
|---|---|---|---|---|
|
||||
| | | | | |
|
||||
|
||||
## 필드 매핑
|
||||
|
||||
| 화면 영역 | 표시명 | 응답 필드 | 변환/코드매핑 | 빈 값 처리 | 비고 |
|
||||
|---|---|---|---|---|---|
|
||||
| | | | | | |
|
||||
|
||||
## 오류 처리
|
||||
|
||||
| 상황 | 처리 방식 |
|
||||
|---|---|
|
||||
| | |
|
||||
|
||||
## 미확인사항
|
||||
|
||||
-
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# 문서 템플릿
|
||||
|
||||
## 목적
|
||||
|
||||
기능별 프론트엔드 문서를 일관된 형식으로 작성하기 위한 템플릿을 제공한다.
|
||||
|
||||
## 사용 기준
|
||||
|
||||
- 새 기능 문서 폴더를 만들 때 필요한 템플릿을 복사해 사용한다.
|
||||
- 템플릿은 공통 형식만 제공한다.
|
||||
- 기능별 구체 내용은 각 앱의 `docs/{기능명}/` 아래에 작성한다.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# 검증 결과
|
||||
|
||||
## 검증 개요
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 검증 일자 | |
|
||||
| 검증자 | |
|
||||
| 대상 화면 | |
|
||||
| 실행 URL | |
|
||||
| 브라우저 | |
|
||||
|
||||
## 검증 항목
|
||||
|
||||
| 항목 | 결과 | 증적 | 비고 |
|
||||
|---|---|---|---|
|
||||
| | | | |
|
||||
|
||||
## 남은 리스크
|
||||
|
||||
-
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# 기능명
|
||||
|
||||
## 목적
|
||||
|
||||
-
|
||||
|
||||
## 화면 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 화면 ID | |
|
||||
| 메뉴 경로 | |
|
||||
| 대상 앱 | |
|
||||
| 관련 요구사항 | |
|
||||
|
||||
## 범위
|
||||
|
||||
### 포함
|
||||
|
||||
-
|
||||
|
||||
### 제외
|
||||
|
||||
-
|
||||
|
||||
## 관련 문서
|
||||
|
||||
-
|
||||
|
||||
## 남은 이슈
|
||||
|
||||
-
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# 컴포넌트별 작업 목록
|
||||
|
||||
## 목적
|
||||
|
||||
-
|
||||
|
||||
## 작업 단위 요약
|
||||
|
||||
| 순서 | 작업 단위 | 주요 파일 | 구분 | 목적 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | | | | |
|
||||
|
||||
## 컴포넌트별 상세
|
||||
|
||||
### 컴포넌트명
|
||||
|
||||
#### 현재 상태
|
||||
|
||||
-
|
||||
|
||||
#### 변경 방향
|
||||
|
||||
-
|
||||
|
||||
#### 검증 항목
|
||||
|
||||
-
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded px-2 py-1 hover:bg-gray-100"
|
||||
style={{
|
||||
marginInlineStart: depth * 16,
|
||||
display: isVisible ? "flex" : "none",
|
||||
}}
|
||||
>
|
||||
{hasVisibleChildren ? (
|
||||
<i
|
||||
onClick={onToggle}
|
||||
className={`diveicon di-arrow-forward mr-2 text-base text-label ${isOpen ? "rotate-90" : ""
|
||||
}`}
|
||||
title={isOpen ? "닫기" : "열기"}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ display: "inline-block", width: 16 }} />
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked.includes(node.id)}
|
||||
onChange={() => handleCheck(node.id)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
{node.icon && <span>{node.icon}</span>}
|
||||
<span className="text-[1.3rem] font-medium text-label">
|
||||
{node.text}
|
||||
</span>
|
||||
{imageVisible && depth > 0 && (
|
||||
<span>
|
||||
<img
|
||||
className="h-5 w-5 cursor-pointer"
|
||||
src="/images/map/layericon/Layer_Select_BTN.png"
|
||||
onClick={() =>
|
||||
handleLayerImgClick(
|
||||
node.data.lyrTyNm,
|
||||
node.id
|
||||
)
|
||||
}
|
||||
alt={node.text}
|
||||
<div style={{ display: isVisible ? "block" : "none" }}>
|
||||
<div
|
||||
className="flex items-center gap-2 rounded px-2 py-1 hover:bg-gray-100"
|
||||
style={{
|
||||
marginInlineStart: depth * 16,
|
||||
}}
|
||||
>
|
||||
{hasVisibleChildren ? (
|
||||
<i
|
||||
onClick={onToggle}
|
||||
className={`diveicon di-arrow-forward mr-2 text-base text-label ${isOpen ? "rotate-90" : ""
|
||||
}`}
|
||||
title={isOpen ? "닫기" : "열기"}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ display: "inline-block", width: 16 }} />
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked.includes(node.id)}
|
||||
onChange={() => handleCheck(node.id)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
{node.icon && <span>{node.icon}</span>}
|
||||
<span className="text-[1.3rem] font-medium text-label">
|
||||
{node.text}
|
||||
</span>
|
||||
{((depth > 0 && imageVisible) || layerIconVisible?.(node)) && (
|
||||
<span>
|
||||
<img
|
||||
className="h-5 w-5 cursor-pointer"
|
||||
src="/images/map/layericon/Layer_Select_BTN.png"
|
||||
onClick={() =>
|
||||
handleLayerImgClick(
|
||||
node.data.lyrTyNm,
|
||||
node.id,
|
||||
node
|
||||
)
|
||||
}
|
||||
alt={node.text}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{inlineLayerSetting && (
|
||||
<div className="mt-2 pb-3 pr-1" style={{ marginInlineStart: depth * 8 + 12 }}>
|
||||
{typeof inlineLayerSetting === "function"
|
||||
? React.createElement(inlineLayerSetting)
|
||||
: inlineLayerSetting}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -588,6 +604,11 @@ export default React.memo(function GisTree({
|
||||
/>
|
||||
</DndProvider>
|
||||
</div>
|
||||
{LayerSettingView && (
|
||||
<div className="shrink-0 bg-white px-2 pb-3">
|
||||
<LayerSettingView />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setVisible(false)}
|
||||
className="absolute right-[-3px] top-1/2 z-50 -translate-y-1/2 transform rounded-l-none rounded-r border border-l border-gray-300 bg-white px-1 py-2 shadow-sm"
|
||||
|
||||
@@ -57,6 +57,40 @@ GisOlSelect.prototype = {
|
||||
width : 10,
|
||||
})
|
||||
});
|
||||
let focusedLineStyle = [
|
||||
new Style({
|
||||
stroke : new Stroke({
|
||||
color : 'rgba(88, 28, 135, 0.26)',
|
||||
width : 18,
|
||||
lineCap : 'round',
|
||||
lineJoin : 'round',
|
||||
}),
|
||||
}),
|
||||
new Style({
|
||||
stroke : new Stroke({
|
||||
color : 'rgba(255, 255, 255, 0.96)',
|
||||
width : 12,
|
||||
lineCap : 'round',
|
||||
lineJoin : 'round',
|
||||
}),
|
||||
}),
|
||||
new Style({
|
||||
stroke : new Stroke({
|
||||
color : 'rgba(109, 40, 217, 0.98)',
|
||||
width : 8,
|
||||
lineCap : 'round',
|
||||
lineJoin : 'round',
|
||||
}),
|
||||
}),
|
||||
new Style({
|
||||
stroke : new Stroke({
|
||||
color : 'rgba(216, 180, 254, 0.92)',
|
||||
width : 3,
|
||||
lineCap : 'round',
|
||||
lineJoin : 'round',
|
||||
}),
|
||||
}),
|
||||
];
|
||||
|
||||
this.selectInteraction = new Select({
|
||||
style: function (feature, resolution) {
|
||||
@@ -72,7 +106,12 @@ GisOlSelect.prototype = {
|
||||
styles.push(style);
|
||||
}
|
||||
}
|
||||
styles.push(baseStyle);
|
||||
const geometryType = feature?.getGeometry?.()?.getType?.();
|
||||
if (geometryType === 'LineString' || geometryType === 'MultiLineString') {
|
||||
styles.push(...focusedLineStyle);
|
||||
} else {
|
||||
styles.push(baseStyle);
|
||||
}
|
||||
return styles;
|
||||
},
|
||||
layers: function (layer) {
|
||||
|
||||
Reference in New Issue
Block a user