노후관로 ai 개발 기능 테스트_V1.0

This commit is contained in:
kiminchan
2026-04-23 17:27:19 +09:00
parent e30d62b429
commit 9d573e6ad1
66 changed files with 4568 additions and 188 deletions
+1 -1
View File
@@ -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
+32
View File
@@ -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
View File
@@ -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/**` 대체 가능 여부를 먼저 판단한다.
---
+13
View File
@@ -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}
+395 -8
View File
@@ -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 계약 확인
→ 재사용 가능 여부 판단
→ 최소 구현
→ 브라우저 검증
→ 결과 기록
```
세부 구현은 상황에 맞게 조정하되, 기존 화면 패턴 재사용과 실제 브라우저 검증은 항상 유지한다.
+40
View File
@@ -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/레이어 설정은 별도 확정 필요
+32
View File
@@ -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

+4
View File
@@ -10,3 +10,7 @@ body {
font-family: "Noto Sans KR", sans-serif;
font-size: 10px;
}
body.sn-map-page-lock {
overflow: hidden;
}
+35
View File
@@ -0,0 +1,35 @@
# 프론트엔드 AI 개발 문서
## 목적
이 디렉터리는 `FE_REPO_ROOT` 전체에 적용되는 프론트엔드 공통 개발 기준을 관리한다.
특정 앱의 화면/기능 상세가 아니라, 여러 프론트엔드 앱에서 함께 참고할 수 있는 구조, 실행, 응답 처리, 검증, 템플릿 기준을 둔다.
## 디렉터리 구조
| 경로 | 역할 |
|---|---|
| `구조/` | 저장소 구조, 폴더 구조, 상태관리, 화면흐름 같은 공통 구조 기준 |
| `실행명세/` | 프론트엔드 실행 흐름, 기능 목록, 증적, 진행 로그 기준 |
| `응답표준화/` | API 응답을 프론트에서 호출, 변환, 표시, 오류 처리하는 기준 |
| `검증/` | 브라우저, 빌드, 네트워크 검증 기준과 로그 |
| `템플릿/` | 기능별 문서 작성에 재사용할 표준 템플릿 |
## 작성 범위
- 프론트엔드 앱 공통 구조
- 공통 컴포넌트 사용 기준
- 화면 실행/브라우저 검증 기준
- API 응답 사용/표시/오류 처리 기준
- 기능별 산출물 템플릿
## 작성 금지 범위
- 백엔드 Controller, Service, Repository 구현 상세
- DB 스키마 상세
- 특정 앱에만 해당하는 화면별 구현 상세
- `sn` 전용 기능 산출물
특정 앱 기능 문서는 해당 앱의 `docs/` 아래에 둔다.
예: `SN_APP_ROOT/docs/노후관로관리/**`
+17
View File
@@ -0,0 +1,17 @@
# 프론트엔드 검증 기준
## 기본 원칙
- 화면 변경은 브라우저에서 실제로 확인한다.
- API 연동 화면은 네트워크 요청과 응답을 함께 확인한다.
- 공통 패키지를 수정하면 해당 패키지 빌드와 사용 앱 빌드를 모두 확인한다.
- 검증하지 못한 항목은 결과 보고에 남긴다.
## 검증 구분
| 구분 | 목적 |
|---|---|
| 브라우저 검증 | 화면 배치, 상호작용, 모달, 상세 패널 확인 |
| 빌드 검증 | 타입, 번들, 패키지 의존성 확인 |
| 네트워크 검증 | 요청 파라미터, 응답 필드, 오류 처리 확인 |
+19
View File
@@ -0,0 +1,19 @@
# 브라우저 검증
## 확인 항목
- 화면이 기존 레이아웃과 같은 위치/톤을 유지하는가
- 버튼, 체크박스, 토글, 모달, 상세 패널이 동작하는가
- 텍스트가 겹치거나 잘리지 않는가
- 지도 화면은 레이어 표시/선택/확대 이동이 동작하는가
- 목록 행 선택과 상세 패널이 연동되는가
## 기록 항목
- 확인 일시
- 확인 URL
- 브라우저
- 주요 동작
- 캡처 또는 증적 경로
- 남은 이슈
+27
View File
@@ -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]"
```
## 기록 항목
- 실행 명령
- 성공/실패
- 실패 로그 핵심
- 영향 범위
- 후속 조치
+17
View File
@@ -0,0 +1,17 @@
# 네트워크 검증
## 확인 항목
- 요청 URL
- HTTP method
- query/body 파라미터
- 응답 상태 코드
- 화면 표시 필드와 응답 필드 매핑
- 오류 응답 처리
- 다운로드 응답 처리
## 기록 기준
특정 기능의 상세 네트워크 검증 결과는 앱 기능 문서에 작성한다.
공통 규칙은 이 문서에 유지한다.
+6
View File
@@ -0,0 +1,6 @@
# 프론트엔드 검증로그
| 일자 | 대상 | 검증 유형 | 결과 | 후속 조치 |
|---|---|---|---|---|
| - | - | - | - | - |
+23
View File
@@ -0,0 +1,23 @@
# 검증 문서
## 목적
프론트엔드 작업의 공통 검증 기준을 관리한다.
브라우저, 빌드, 네트워크 검증을 분리해 기록한다.
## 포함 문서
| 문서 | 역할 |
|---|---|
| `00-검증기준.md` | 공통 검증 원칙 |
| `01-브라우저검증.md` | 화면/상호작용 검증 기준 |
| `02-빌드검증.md` | 빌드 명령과 실패 처리 기준 |
| `03-네트워크검증.md` | API 요청/응답 확인 기준 |
| `99-검증로그.md` | 검증 수행 기록 |
## 작성 기준
- 앱 공통 검증 기준만 작성한다.
- 기능별 구체 검증 결과는 해당 앱 기능 문서에 작성한다.
- 검증 실패 시 원인, 영향 범위, 재현 방법을 함께 기록한다.
+27
View File
@@ -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 계약, 응답 구조, 권한, 지도 레이어 영향이 있으면 백엔드 문서와 함께 확인한다.
+42
View File
@@ -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. 기존 컴포넌트로 불가능한 경우에만 최소 범위의 컴포넌트 보완을 검토한다.
+28
View File
@@ -0,0 +1,28 @@
# 프론트엔드 상태관리 구조
## 목적
`sn` 앱의 화면 상태, 조회 조건, 지도 선택 객체, 모달 상태를 일관되게 다루기 위한 기준 문서이다.
## 기본 원칙
- 기존 `sn` 앱에서 사용하는 상태 관리 방식과 훅 구조를 먼저 확인한다.
- 화면 단위 상태는 가능하면 해당 화면 또는 feature 범위 안에서 관리한다.
- 여러 컴포넌트가 공유하는 상태는 기존 store 또는 공통 훅 패턴을 따른다.
- 지도 선택 객체, 목록 선택 행, 상세 패널 표시 상태는 서로 불일치하지 않도록 한 흐름에서 갱신한다.
## 확인 대상
- `SN_APP_ROOT/app/store/**`
- `SN_APP_ROOT/src/**`
- 기존 지도 화면의 선택 객체, 레이어 표시, 검색 모달 상태 흐름
## 문서화 기준
상태 흐름을 새로 추가하거나 변경할 때는 아래 내용을 작업 문서에 남긴다.
- 상태 이름과 소유 컴포넌트
- 상태 변경 트리거
- 백엔드/API 응답과의 관계
- 지도, 그리드, 상세 패널 간 동기화 방식
- 초기화와 오류 처리 방식
+29
View File
@@ -0,0 +1,29 @@
# 프론트엔드 화면흐름
## 목적
프론트엔드 화면 이동, 지도 상호작용, 모달/상세 패널 표시 흐름을 정리하기 위한 기준 문서이다.
## 기본 원칙
- 신규 화면은 기존 1차 화면의 레이아웃과 조작 방식을 우선 따른다.
- 디자인을 새로 만드는 대신 기존 페이지, 모달, 테이블, 지도 패널 컴포넌트 조합을 재사용한다.
- 화면 ID, 메뉴 경로, API 계약, 권한 조건을 함께 기록한다.
## 화면흐름 기록 항목
- 화면 ID
- 메뉴 경로
- 진입 조건
- 주요 조회 조건
- 지도/그리드/상세 패널 연동
- 모달 또는 팝업 호출 조건
- 다운로드/저장/삭제 같은 주요 액션
- 권한별 노출 차이
- 오류/빈 데이터 처리
## `sn` 화면 개발 기준
- 실제 페이지는 `SN_APP_ROOT/app/**`를 기준으로 확인한다.
- 지도 기반 화면은 기존 `(map-layout)` 구조와 `Gis` 관련 컴포넌트 사용 방식을 먼저 확인한다.
- 공통 비즈니스 UI가 필요하면 `BUSINESS_COMPONENTS_ROOT/src/**`를 우선 검토한다.
+20
View File
@@ -0,0 +1,20 @@
# 프론트엔드 미확인사항
## 목적
프론트엔드 2차사업 개발 중 아직 확정되지 않은 계약, 화면, 권한, 공통 컴포넌트 이슈를 기록한다.
## 기록 형식
| 일자 | 구분 | 내용 | 영향 범위 | 확인 필요 대상 | 상태 |
|---|---|---|---|---|---|
| - | - | - | - | - | - |
## 주요 확인 대상
- 백엔드 API 요청/응답 계약
- 지도 레이어명, 속성명, zoom level별 표시 조건
- 1차 화면과 다른 UI 변경 필요 여부
- `BUSINESS_COMPONENTS_ROOT` 수정 시 타 앱 영향 가능성
- 대용량 목록/엑셀 다운로드 처리 방식
- 권한별 조회 범위와 버튼 노출 기준
+23
View File
@@ -0,0 +1,23 @@
# 구조 문서
## 목적
프론트엔드 저장소의 공통 구조를 설명한다.
여기에는 특정 화면의 구현 세부가 아니라, 여러 앱에서 공유할 수 있는 구조 기준만 작성한다.
## 포함 문서
| 문서 | 역할 |
|---|---|
| `00-시스템맵.md` | 앱과 공통 패키지, 백엔드 연동 지점의 큰 그림 |
| `01-폴더구조.md` | 저장소 폴더와 수정 가능 범위 |
| `03-상태관리구조.md` | 상태관리 기준 |
| `04-화면흐름.md` | 화면 진입, 이동, 모달, 상세 패널 흐름 기준 |
| `99-미확인사항.md` | 구조 관련 미확인사항 |
## 작성 기준
- `FE_REPO_ROOT` 전체에 적용되는 기준만 작성한다.
- 특정 앱의 기능 상세는 앱 내부 문서에 작성한다.
- 백엔드 구현 상세는 작성하지 않는다.
+31
View File
@@ -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/노후관로관리/**`
+11
View File
@@ -0,0 +1,11 @@
# 프론트엔드 기능목록
## 목적
프론트엔드에서 구현하거나 검증해야 하는 기능을 화면 기준으로 추적한다.
| 화면 ID | 기능 | 주요 컴포넌트 | 상태 | 문서 위치 |
|---|---|---|---|---|
| SN_MN010102 | 노후관로관리 | `Gis`, `GisTree`, `UiTable`, `FeatureClickEventPopup` | 정리 중 | `SN_APP_ROOT/docs/노후관로관리/**` |
| SN_MN010103 | 노후관로분석 | `Gis`, `FeatureClickEventPopup`, `VerticalTable` | 정리 중 | `SN_APP_ROOT/docs/노후관로관리/**` |
+18
View File
@@ -0,0 +1,18 @@
# 프론트엔드 증적목록
## 목적
프론트엔드 작업의 실행, 빌드, 브라우저 확인 증적을 추적한다.
| 일자 | 기능 | 증적 유형 | 경로/내용 | 상태 |
|---|---|---|---|---|
| - | - | - | - | - |
## 증적 유형
- 정적 HTML 샘플
- 브라우저 캡처
- 빌드 로그
- 네트워크 응답 확인
- 수동 테스트 결과
+6
View File
@@ -0,0 +1,6 @@
# 프론트엔드 실행명세 진행로그
| 일자 | 작업 | 결과 | 후속 작업 |
|---|---|---|---|
| 2026-04-21 | 노후관로관리 화면 기준 정리 | 기존 화면 기반 컴포넌트 분해 및 작업 목록 작성 | API/응답 필드 매핑표 작성 |
+22
View File
@@ -0,0 +1,22 @@
# 실행명세 문서
## 목적
프론트엔드 화면을 실제로 실행하고 검증하기 위한 기준과 진행 기록을 관리한다.
## 포함 문서
| 문서 | 역할 |
|---|---|
| `00-범위.md` | 실행명세의 범위와 제외 범위 |
| `02-기능명세계획.md` | 화면/기능 단위 산출물 계획 |
| `03-기능목록.md` | 추적할 기능 목록 |
| `04-증적목록.md` | 캡처, 로그, 빌드 결과 같은 증적 목록 |
| `99-진행로그.md` | 실행명세 진행 기록 |
## 작성 기준
- 프론트엔드 실행과 브라우저 동작 기준만 작성한다.
- 서버 실행 로그를 인용할 수는 있지만, 백엔드 내부 구현 분석은 쓰지 않는다.
- 특정 앱 기능의 상세 실행 결과는 앱 내부 문서에 작성한다.
+23
View File
@@ -0,0 +1,23 @@
# 프론트엔드 응답표준화 범위
## 목적
프론트엔드 응답표준화 문서는 백엔드 응답을 화면에서 어떻게 호출, 변환, 표시, 오류 처리할지 정리한다.
백엔드 응답 구조 자체를 바꾸는 문서가 아니라, 프론트엔드 사용 기준을 정의한다.
## 작성 범위
- API 호출 위치
- 요청 파라미터
- 응답 필드 사용처
- 화면 표시 필드 매핑
- 오류/빈 데이터 처리
- 다운로드/파일 응답 처리
- 기존 응답과 신규 후보 API 비교
## 제외 범위
- 백엔드 공통 응답 포맷 변경
- DB 컬럼 설계
- Repository/Service 구현 상세
+24
View File
@@ -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로 받을지
+23
View File
@@ -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 | 사용 화면/컴포넌트 | 상태 |
|---|---|---|---|---|
| | | | | |
## 필드 매핑
| 화면 영역 | 표시명 | 응답 필드 | 변환/코드매핑 | 빈 값 처리 | 비고 |
|---|---|---|---|---|---|
| | | | | | |
## 오류 처리
| 상황 | 처리 방식 |
|---|---|
| | |
## 미확인사항
-
+12
View File
@@ -0,0 +1,12 @@
# 문서 템플릿
## 목적
기능별 프론트엔드 문서를 일관된 형식으로 작성하기 위한 템플릿을 제공한다.
## 사용 기준
- 새 기능 문서 폴더를 만들 때 필요한 템플릿을 복사해 사용한다.
- 템플릿은 공통 형식만 제공한다.
- 기능별 구체 내용은 각 앱의 `docs/{기능명}/` 아래에 작성한다.
+22
View File
@@ -0,0 +1,22 @@
# 검증 결과
## 검증 개요
| 항목 | 내용 |
|---|---|
| 검증 일자 | |
| 검증자 | |
| 대상 화면 | |
| 실행 URL | |
| 브라우저 | |
## 검증 항목
| 항목 | 결과 | 증적 | 비고 |
|---|---|---|---|
| | | | |
## 남은 리스크
-
+33
View File
@@ -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) {