Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eddb1b24d6 | ||
|
|
cf2cc11167 | ||
|
|
9e71f529e7 | ||
|
|
36b11d6b00 | ||
|
|
79f70d8b4b | ||
|
|
7911cff96a | ||
|
|
fe501965bf | ||
|
|
fb82cadc82 | ||
|
|
f5b6effeb3 | ||
|
|
63c2901ae3 | ||
|
|
0356aef7b2 | ||
|
|
f709dc99f6 | ||
|
|
728a66bac2 | ||
|
|
c850b9ce1e | ||
|
|
791bc20e8e | ||
|
|
fed1383ced | ||
|
|
91ff80a255 | ||
|
|
5e43d69d8e | ||
|
|
7f10e20a41 | ||
|
|
9971e512c8 | ||
|
|
0c77ed9bba | ||
|
|
e759c96ade | ||
|
|
f602b75268 | ||
|
|
11d0378060 | ||
|
|
8ed4f959cb | ||
|
|
e5f7778861 | ||
|
|
2d086a05b2 | ||
|
|
6ef4603a7c |
+2
-1
@@ -1,6 +1,7 @@
|
||||
# turbo
|
||||
.turbo/
|
||||
.vscode/**/*
|
||||
**/.vscode/
|
||||
*.code-workspace
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
Vendored
-88
@@ -1,88 +0,0 @@
|
||||
{
|
||||
// IntelliSense를 사용하여 가능한 특성에 대해 알아보세요.
|
||||
// 기존 특성에 대한 설명을 보려면 가리킵니다.
|
||||
// 자세한 내용을 보려면 https://go.microsoft.com/fwlink/?linkid=830387을(를) 방문하세요.
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "dev:as 디버그",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev:as"
|
||||
],
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"sourceMaps": true,
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--inspect"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "dev:cs 디버그",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev:cs"
|
||||
],
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"sourceMaps": true,
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--inspect"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "attach",
|
||||
"name": "dev:cs 서버 연결 (attach)",
|
||||
"port": 9229,
|
||||
"restart": true,
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"sourceMaps": true,
|
||||
"resolveSourceMapLocations": [
|
||||
"${workspaceFolder}/apps/cs/**",
|
||||
"!**/node_modules/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"name": "dev:cs 클라이언트 디버그 (Chrome)",
|
||||
"url": "http://localhost:3000",
|
||||
"webRoot": "${workspaceFolder}/apps/cs",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "dev:st 디버그",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev:st"
|
||||
],
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"sourceMaps": true,
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--inspect"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
-5
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"pnpm": true
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,19 @@
|
||||
## Project
|
||||
This repository contains a Next.js web application.
|
||||
|
||||
## Portable paths
|
||||
|
||||
Resolve paths from this `apps/dm/AGENTS.md` file, not from a developer-specific drive or the current shell directory.
|
||||
|
||||
- `FE_APP_ROOT`: directory containing this file (`apps/dm`)
|
||||
- `FE_REPO_ROOT`: `FE_APP_ROOT/../..` (`frontend`)
|
||||
- `WORKSPACE_ROOT`: `FE_REPO_ROOT/..`
|
||||
- `COMMON_ROOT_AGENTS`: `WORKSPACE_ROOT/AGENTS.md`
|
||||
- `BE_REPO_ROOT`: `WORKSPACE_ROOT/IIPBS` (Backend repository root)
|
||||
- `BE_SERVICE_ROOT`: `BE_REPO_ROOT/sewerage-service`
|
||||
|
||||
Use an explicitly supplied repository path when the repositories are installed under different parent directories. Verify a calculated path exists before using it.
|
||||
|
||||
Main technologies:
|
||||
- Next.js
|
||||
- TypeScript
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
## Project
|
||||
This repository contains a Next.js web application.
|
||||
|
||||
## Portable paths
|
||||
|
||||
Resolve paths from this `apps/fo/AGENTS.md` file, not from a developer-specific drive or the current shell directory.
|
||||
|
||||
- `FE_APP_ROOT`: directory containing this file (`apps/fo`)
|
||||
- `FE_REPO_ROOT`: `FE_APP_ROOT/../..` (`frontend`)
|
||||
- `WORKSPACE_ROOT`: `FE_REPO_ROOT/..`
|
||||
- `COMMON_ROOT_AGENTS`: `WORKSPACE_ROOT/AGENTS.md`
|
||||
- `BE_REPO_ROOT`: `WORKSPACE_ROOT/IIPBS` (Backend repository root)
|
||||
- `BE_SERVICE_ROOT`: `BE_REPO_ROOT/sewerage-service`
|
||||
|
||||
Use an explicitly supplied repository path when the repositories are installed under different parent directories. Verify a calculated path exists before using it.
|
||||
|
||||
Main technologies:
|
||||
- Next.js
|
||||
- TypeScript
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
|
||||
#NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr/openapi/Gettile.do?apiKey=04trYP9_xwLAfALjwZ-B8g&Service=WMTS&Request=GetTile&Version=1.0.0&Format=image/png&layer=korean_map&style=korean&tilematrixset=EPSG:5179&TileMatrix={z}&TileCol={x}&TileRow={y}
|
||||
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
|
||||
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
|
||||
SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
SERVER_API_URL = https://apigateway.onasys.co.kr
|
||||
SEWERAGE_API_URL = http://localhost:8883
|
||||
# SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
|
||||
DC_URL = https://apigateway.apps.rsonesoft.com
|
||||
|
||||
@@ -6,7 +6,7 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
|
||||
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
|
||||
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11302
|
||||
NEXT_PUBLIC_SUB_DOMAIN = http://con.mind-one.co.kr:13000
|
||||
SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
SERVER_API_URL = https://apigateway.onasys.co.kr
|
||||
NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver
|
||||
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
|
||||
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
|
||||
|
||||
+36
-3
@@ -43,14 +43,46 @@
|
||||
|
||||
## 공통 시작 기준
|
||||
|
||||
Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작 기준은 `AGENTS.md`를 따른다.
|
||||
개발자마다 드라이브와 상위 작업 폴더 위치가 다르므로 특정 드라이브·사용자 디렉터리의 절대경로를 기준으로 사용하지 않는다.
|
||||
|
||||
경로 별칭은 `apps/sn/AGENTS.md`가 있는 디렉터리(`FE_APP_ROOT`)를 기준으로 다음과 같이 해석한다. 상대경로는 현재 셸의 실행 위치가 아니라 이 파일의 위치를 기준으로 계산한다.
|
||||
|
||||
- `FE_APP_ROOT`: 이 파일이 있는 `apps/sn` 디렉터리
|
||||
- `FE_REPO_ROOT`: `FE_APP_ROOT/../..`
|
||||
- `WORKSPACE_ROOT`: `FE_APP_ROOT/../../..`
|
||||
- `COMMON_ROOT_AGENTS`: `WORKSPACE_ROOT/AGENTS.md`
|
||||
- `BE_REPO_ROOT`: `WORKSPACE_ROOT/IIPBS` (Backend Git 저장소 루트)
|
||||
- `BE_SERVICE_ROOT`: `BE_REPO_ROOT/sewerage-service` (하수관로 백엔드 서비스 모듈)
|
||||
- `COMMON_DOCS_ROOT`: `BE_REPO_ROOT/docs`
|
||||
- `FE_APP_DOCS_ROOT`: `FE_APP_ROOT/docs`
|
||||
|
||||
경로 결정은 아래 우선순위를 따른다.
|
||||
|
||||
1. 작업 환경이나 사용자가 `WORKSPACE_ROOT`, `COMMON_DOCS_ROOT`, `BE_REPO_ROOT`, `BE_SERVICE_ROOT` 등의 유효한 경로를 명시한 경우 그 값을 우선한다. 저장소를 서로 다른 상위 폴더에 배치한 개발자도 이 방식으로 사용할 수 있다.
|
||||
2. 명시값이 없으면 위의 `FE_APP_ROOT` 기준 상대경로로 계산한다. 드라이브 문자나 사용자별 상위 디렉터리 이름이 달라도 같은 저장소 배치 구조를 사용할 수 있다.
|
||||
3. 계산한 경로는 사용 전에 대상 파일이나 디렉터리의 존재 여부를 확인한다. 없거나 후보가 여러 개인 경우 특정 절대경로를 추측하지 말고 현재 저장소에서 확인 가능한 작업만 진행한 뒤 필요한 경로를 사용자에게 확인한다.
|
||||
4. 작업 문서와 결과 보고에는 특별한 사유가 없으면 개발자별 절대경로 대신 위 경로 별칭이나 저장소 기준 상대경로를 기록한다.
|
||||
|
||||
PowerShell에서 현재 프론트엔드 저장소를 기준으로 확인할 때는 다음처럼 동적으로 계산할 수 있다. 각 단계는 앞 단계에서 찾은 저장소 위치를 기반으로 하므로 개발자별 설치 경로에 의존하지 않는다.
|
||||
|
||||
```powershell
|
||||
$feRepoRoot = (git rev-parse --show-toplevel).Trim()
|
||||
$feAppRoot = Join-Path $feRepoRoot 'apps/sn'
|
||||
$workspaceRoot = Split-Path $feRepoRoot -Parent
|
||||
$commonRootAgents = Join-Path $workspaceRoot 'AGENTS.md'
|
||||
$beRepoRoot = Join-Path $workspaceRoot 'IIPBS'
|
||||
$beServiceRoot = Join-Path $beRepoRoot 'sewerage-service'
|
||||
$commonDocsRoot = Join-Path $beRepoRoot 'docs'
|
||||
```
|
||||
|
||||
공통 시작 기준은 `COMMON_ROOT_AGENTS`를 따른다.
|
||||
|
||||
이 파일은 프론트엔드 `sn` 앱 세부 기준이다.
|
||||
|
||||
백엔드(`sewerage-service`)와 함께 수정하거나 API/응답/DB/인증/권한 연동 영향이 있는 작업은 아래 파일을 함께 확인한다.
|
||||
|
||||
- 공통 시작 기준: `AGENTS.md`
|
||||
- 백엔드 세부 기준: `BE_REPO_ROOT/AGENTS.md`
|
||||
- 백엔드 세부 기준: `BE_SERVICE_ROOT/AGENTS.md`
|
||||
- 프론트엔드 `sn` 앱 기준: `FE_APP_ROOT/AGENTS.md`
|
||||
|
||||
---
|
||||
@@ -134,6 +166,7 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작
|
||||
|
||||
## 작업 원칙
|
||||
|
||||
- `sn` 프론트엔드 엑셀 다운로드 기능을 신규 개발하거나 수정할 때는 `src/shared/util/download-excel-workbook.ts` 재사용을 먼저 검토한다. 세부 기준은 [컴포넌트정책의 공통 유틸 재사용 기준](docs/기준문서/004-컴포넌트정책.md#공통-유틸-재사용-기준)을 따른다. 이 코드 경로는 `FE_APP_ROOT` 기준이다.
|
||||
- 일반 목록·모든 팝업은 공통 AGENTS.md의 서버 페이징·지도·Excel 계약을 따른다. 지도 전체 표출에 현재 그리드 페이지 배열을 재사용하지 않는다. Excel은 적용조건 전체를 사용하고 대량 원시 자료는 서버 생성으로 분리한다. 화면 페이징/페이징 없음 예외는 사유와 검증 범위를 메뉴 > 화면명별로 기록한다.
|
||||
|
||||
1. 모든 변경은 원본 요구사항정의서와 화면설계서, 2차 요구사항, 기능 명세 또는 승인된 작업 문서를 근거로 한다.
|
||||
@@ -230,7 +263,7 @@ VSCode나 별도 터미널에서 `sn` Next 개발 서버가 실행 중이면 `ne
|
||||
|
||||
### 프론트엔드 실행 프로세스 관리
|
||||
|
||||
- 모든 세션·역할·문서/검증 섹션에 공통 D:/workspace/AGENTS.md의 테스트 파일 정리 기준을 적용한다. 에이전트가 테스트를 위해 만든 파일은 정식 회귀 테스트·재사용 가능한 소스·fixture·mock·임시 라우트를 포함해 위치와 관계없이 결과를 요약한 뒤 작업 종료 전에 삭제한다(2026-09-23 사용자 지시). 전용 빈 폴더와 끊어진 실행 명령도 정리한다. 명시 보존 요청 파일·최종 산출물·사용자 원본/복구 백업은 유지한다. 기존 타 작업자 테스트 자산은 삭제 권한을 먼저 확인한다. 사용자 dev server의 .next·공유 캐시·node_modules는 포괄 삭제하지 않는다.
|
||||
- 모든 세션·역할·문서/검증 섹션에 `COMMON_ROOT_AGENTS`의 테스트 파일 정리 기준을 적용한다. 에이전트가 테스트를 위해 만든 파일은 정식 회귀 테스트·재사용 가능한 소스·fixture·mock·임시 라우트를 포함해 위치와 관계없이 결과를 요약한 뒤 작업 종료 전에 삭제한다(2026-09-23 사용자 지시). 전용 빈 폴더와 끊어진 실행 명령도 정리한다. 명시 보존 요청 파일·최종 산출물·사용자 원본/복구 백업은 유지한다. 기존 타 작업자 테스트 자산은 삭제 권한을 먼저 확인한다. 사용자 dev server의 .next·공유 캐시·node_modules는 포괄 삭제하지 않는다.
|
||||
|
||||
- Codex 에이전트가 작업 또는 검증을 위해 직접 기동한 `next dev`, `dev:sn`, Turborepo dev와 기타 프론트엔드 장시간 실행 프로세스는 작업 종료 전에 반드시 종료한다.
|
||||
- 셸, 백그라운드, 숨김 창 등 VSCode 밖에서 에이전트가 기동한 프로세스도 종료 대상이다.
|
||||
|
||||
+3
-2
@@ -66,7 +66,8 @@ export const normalizeAgedPipeStatusByCtpvFilters = (
|
||||
filters: Partial<AgedPipeStatusByCtpvFilters>,
|
||||
): AgedPipeStatusByCtpvFilters => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
let startYear = Math.trunc(Number(filters.startYear ?? currentYear - 2));
|
||||
// 최초 진입·초기화는 당해연도 1년 조회로 시작하며 직접 선택한 범위는 유지한다.
|
||||
let startYear = Math.trunc(Number(filters.startYear ?? currentYear));
|
||||
let endYear = Math.trunc(Number(filters.endYear ?? currentYear));
|
||||
let elapsedYears = Math.trunc(
|
||||
Number(
|
||||
@@ -74,7 +75,7 @@ export const normalizeAgedPipeStatusByCtpvFilters = (
|
||||
),
|
||||
);
|
||||
|
||||
if (!Number.isFinite(startYear)) startYear = currentYear - 2;
|
||||
if (!Number.isFinite(startYear)) startYear = currentYear;
|
||||
if (!Number.isFinite(endYear)) endYear = currentYear;
|
||||
if (
|
||||
!Number.isFinite(elapsedYears) ||
|
||||
|
||||
@@ -10,9 +10,7 @@ import {
|
||||
ChartColumnBig,
|
||||
ChevronUp,
|
||||
PanelsTopLeft,
|
||||
PencilRuler,
|
||||
Search,
|
||||
Settings2,
|
||||
TriangleAlert,
|
||||
Waves,
|
||||
} from "lucide-react";
|
||||
@@ -28,8 +26,6 @@ import { getRawJson } from "@/shared/api/sewerage-api";
|
||||
type Props = {
|
||||
onOpenPopup: (popupKey: WindowPopupKey) => Window | null;
|
||||
onOpenInfoSearch: () => void;
|
||||
onToggleGisEdit?: () => void;
|
||||
isGisEditActive?: boolean;
|
||||
isInfoSearchActive?: boolean;
|
||||
initialActiveKey?: WindowPopupKey | null;
|
||||
};
|
||||
@@ -89,16 +85,6 @@ const ANALYSIS_POPUP_ITEMS: Array<{
|
||||
label: windowPopupDefinitions.agedPipeStatusByCtpvPopup.title,
|
||||
icon: ChartColumnBig,
|
||||
},
|
||||
{
|
||||
key: "gisEditPopup",
|
||||
label: windowPopupDefinitions.gisEditPopup.title,
|
||||
icon: PencilRuler,
|
||||
},
|
||||
{
|
||||
key: "gisManagementPopup",
|
||||
label: windowPopupDefinitions.gisManagementPopup.title,
|
||||
icon: Settings2,
|
||||
},
|
||||
{
|
||||
key: "flowRiskPopup",
|
||||
label: windowPopupDefinitions.flowRiskPopup.title,
|
||||
@@ -119,8 +105,6 @@ const ANALYSIS_POPUP_ITEMS: Array<{
|
||||
export default function AnalysisPopupMenu({
|
||||
onOpenPopup,
|
||||
onOpenInfoSearch,
|
||||
onToggleGisEdit,
|
||||
isGisEditActive = false,
|
||||
isInfoSearchActive = false,
|
||||
initialActiveKey = null,
|
||||
}: Props) {
|
||||
@@ -155,12 +139,6 @@ export default function AnalysisPopupMenu({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGisEditActive && activeKey === "gisEditPopup") {
|
||||
setActiveKey(null);
|
||||
}
|
||||
}, [activeKey, isGisEditActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInfoSearchActive && activeKey === "infoSearch") {
|
||||
setActiveKey(null);
|
||||
@@ -214,10 +192,7 @@ export default function AnalysisPopupMenu({
|
||||
<div className="flex flex-col gap-[3px] p-[6px]">
|
||||
{ANALYSIS_POPUP_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive =
|
||||
item.key === "gisEditPopup"
|
||||
? isGisEditActive
|
||||
: activeKey === item.key;
|
||||
const isActive = activeKey === item.key;
|
||||
const label =
|
||||
item.key === "fldngPredcPrst"
|
||||
? `침수예측현황(${seriousPredictionCount}건)`
|
||||
@@ -233,12 +208,6 @@ export default function AnalysisPopupMenu({
|
||||
: "border-transparent bg-background text-foreground hover:border-border hover:bg-accent hover:text-primary"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (item.key === "gisEditPopup" && onToggleGisEdit) {
|
||||
setActiveKey(item.key);
|
||||
onToggleGisEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveKey(item.key);
|
||||
|
||||
if (item.key === "infoSearch") {
|
||||
|
||||
@@ -127,7 +127,7 @@ const normalizeControlNode = (
|
||||
...node,
|
||||
lyrSn: nodeId,
|
||||
id: nodeId,
|
||||
// DB에 저장된 과거 명칭과 무관하게 조사관리 표준 화면 명칭으로 표시한다.
|
||||
// DB에 저장된 과거 명칭과 무관하게 조사사업 표준 화면 명칭으로 표시한다.
|
||||
lyrNm: fallbackName,
|
||||
upperLyrSn: upperId,
|
||||
upId: upperId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { beginMapNavigation } from "@/features/map-search/lib/map-navigation";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
@@ -86,11 +87,13 @@ export const useExmnResultMap = (options: Options) => {
|
||||
[gisApp, mapRef],
|
||||
);
|
||||
|
||||
/** 지역 검색값 없이 전체를 요청하고, 선택 진입 context는 위치 이동 범위 조회에만 사용한다. */
|
||||
/** 일반 진입은 전체, 조사관리 지도보기는 선택 사업·처리분구만 조회해 겹친 타 사업 선택을 방지한다. */
|
||||
const loadLayer = useCallback(
|
||||
async (job: ExmnMapJobType, filters: ExmnMapFilters) => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const navigation = entry.jobType === job && entry.exmnBizId && entry.trtdstId
|
||||
? beginMapNavigation(map) : null;
|
||||
removeLayer(job);
|
||||
const state = layers.current[job];
|
||||
const sequence = state.sequence;
|
||||
@@ -106,7 +109,9 @@ export const useExmnResultMap = (options: Options) => {
|
||||
const response = await getExmnMapLayer(
|
||||
job,
|
||||
filters,
|
||||
{},
|
||||
entry.exmnBizId && entry.trtdstId
|
||||
? { exmnBizId: entry.exmnBizId, trtdstId: entry.trtdstId }
|
||||
: {},
|
||||
controller.signal,
|
||||
);
|
||||
if (!active()) return;
|
||||
@@ -184,7 +189,7 @@ export const useExmnResultMap = (options: Options) => {
|
||||
const extent = selectedSource.getExtent();
|
||||
if (
|
||||
fittedContext.current !== contextKey &&
|
||||
extent.every(Number.isFinite)
|
||||
extent.every(Number.isFinite) && navigation?.commit(extent)
|
||||
) {
|
||||
map
|
||||
.getView()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* 우측 정보 화면의 심선·위선 목록, 선택 상태와 화면 생명주기 캐시를 관리한다.
|
||||
*/
|
||||
"use client";
|
||||
import { mapSearchNavigationOptions } from "@/features/map-search/lib/map-navigation";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
@@ -23,6 +24,7 @@ type FldngHstryGisLayer = {
|
||||
showSearchResultsFromGeoJSON: (
|
||||
layerTypeName: string,
|
||||
featureId: string,
|
||||
options?: { beforeFocus: (extent: unknown) => boolean },
|
||||
) => void | Promise<void>;
|
||||
};
|
||||
|
||||
@@ -127,11 +129,14 @@ const displayCachedFldngHstryFeatures = (
|
||||
gisLayer: FldngHstryGisLayer,
|
||||
layerTypeName: string,
|
||||
features: Feature[],
|
||||
beforeFocus: (extent: unknown) => boolean,
|
||||
) => {
|
||||
const matched = Object.values(GisApp.layerCode ?? {}).find(
|
||||
(item: any) => item?.lyrTyNm === layerTypeName,
|
||||
) as any;
|
||||
if (!matched || features.length === 0) return;
|
||||
// 캐시를 재사용해도 최신 선택의 유효 도형일 때만 검색조건을 초기화한다.
|
||||
if (!beforeFocus(features[0]?.getGeometry()?.getExtent())) return;
|
||||
|
||||
gisLayer.clearSearchLayer(matched);
|
||||
const layer = gisLayer.ensureSearchLayer(matched);
|
||||
@@ -148,17 +153,20 @@ const showFldngHstryFeature = async (
|
||||
featureId: string,
|
||||
) => {
|
||||
const cacheKey = `${layerTypeName}:${featureId}`;
|
||||
/** 캐시 대기와 신규 WFS 조회가 동일한 이동 성공/취소 계약을 사용한다. */
|
||||
const navigationOptions = mapSearchNavigationOptions((GisApp.Module.core as any).map);
|
||||
const cachedRequest = cache.get(cacheKey);
|
||||
if (cachedRequest) {
|
||||
displayCachedFldngHstryFeatures(
|
||||
gisLayer,
|
||||
layerTypeName,
|
||||
await cachedRequest,
|
||||
navigationOptions.beforeFocus,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await gisLayer.showSearchResultsFromGeoJSON(layerTypeName, featureId);
|
||||
await gisLayer.showSearchResultsFromGeoJSON(layerTypeName, featureId, navigationOptions);
|
||||
const source = gisLayer.searchLayer?.getSource?.();
|
||||
if (!source) return;
|
||||
|
||||
|
||||
+4
-4
@@ -5,7 +5,7 @@
|
||||
"use client";
|
||||
|
||||
import { SelectBox } from "@common/business_components/ui";
|
||||
import { FLDNG_HSTRY_MIN_YEAR, type FldngHstryYearRange } from "@/entities/flood-history/model/flood-history-types";
|
||||
import { FLDNG_HSTRY_MIN_YEAR, FLDNG_HSTRY_MAX_YEAR, type FldngHstryYearRange } from "@/entities/flood-history/model/flood-history-types";
|
||||
import { normalizeFldngHstryYearRange } from "@/entities/flood-history/lib/flood-history-utils";
|
||||
import LayerSettingCard from "../layer-setting/layer-setting-card";
|
||||
|
||||
@@ -26,10 +26,10 @@ export default function FldngHstryLayerSettingPanel({
|
||||
onClose,
|
||||
}: Props) {
|
||||
const normalizedRange = normalizeFldngHstryYearRange(yearRange);
|
||||
const currentYear = new Date().getFullYear();
|
||||
// 제공 자료의 최대 연도부터 최소 연도까지 선택 목록을 구성한다.
|
||||
const yearOptions = Array.from(
|
||||
{ length: currentYear - FLDNG_HSTRY_MIN_YEAR + 1 },
|
||||
(_, index) => String(currentYear - index),
|
||||
{ length: FLDNG_HSTRY_MAX_YEAR - FLDNG_HSTRY_MIN_YEAR + 1 },
|
||||
(_, index) => String(FLDNG_HSTRY_MAX_YEAR - index),
|
||||
);
|
||||
const startYearOptions = yearOptions
|
||||
.filter((year) => Number(year) <= Number(normalizedRange.endYear))
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useAlertStore } from "@/shared/lib";
|
||||
import useStore from "@/entities/reference-data/model/reference-data-store";
|
||||
import LoadingOverlay from "@/components/ui/loading-bar";
|
||||
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
|
||||
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
|
||||
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
||||
import { FLDNG_HSTRY_LAYER_TYPE, FLDNG_HSTRY_DEFAULT_FILTERS, FLDNG_HSTRY_LIST_HEADLIST, FLDNG_HSTRY_PAGE_SIZE, FLDNG_HSTRY_ROW_SELECT_MESSAGE, type FldngHstryApiItem, type FldngHstryFilters, type FldngHstryTableRow } from "@/entities/flood-history/model/flood-history-types";
|
||||
import { attachFldngHstryGeometry, buildFldngHstryListQueryString, buildFldngHstryYearOptions, normalizeFldngHstryFilters, toFldngHstryTableRows } from "@/entities/flood-history/lib/flood-history-utils";
|
||||
@@ -421,9 +422,13 @@ export default function FldngHstryPopupPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="rounded-sm bg-secondary px-4 py-3 text-[1.2rem] text-muted-foreground">
|
||||
행을 클릭하면 메인 지도에서 해당 침수흔적을 표시합니다.
|
||||
</div>
|
||||
{/* 기존 행 클릭 안내에 인수테스트 요구(자료 원천 표시) 출처 문구를 글머리표로 더한다. */}
|
||||
<PopupFooterNote
|
||||
items={[
|
||||
"행을 클릭하면 메인 지도에서 해당 침수흔적을 표시합니다.",
|
||||
"출처 : 행정안전부 재난보험과(2002년~2025년)",
|
||||
]}
|
||||
/>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
+16
-7
@@ -11,6 +11,7 @@ import { getData } from "@/shared/api/sewerage-api";
|
||||
import LoadingOverlay from "@/components/ui/loading-bar";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
|
||||
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
|
||||
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
|
||||
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
||||
import useStore from "@/entities/reference-data/model/reference-data-store";
|
||||
import {
|
||||
@@ -226,7 +227,12 @@ export default function FloodDngrMapPopupPage() {
|
||||
const response = await getFloodDngrMapJson(
|
||||
`/sewerage-service/api/v1/fldng/floodDngrMap/group/extent?${params.toString()}`,
|
||||
);
|
||||
const payload = response as FloodDngrMapExtent;
|
||||
const payload = response as FloodDngrMapExtent | null | undefined;
|
||||
// 성공 응답의 null·생략된 data는 위치 부재이다. 좌표 접근과 지도 메시지를 중단한다.
|
||||
if (payload == null) {
|
||||
setErrorMessage("위치 정보가 없습니다");
|
||||
return;
|
||||
}
|
||||
const extent = [
|
||||
payload?.minX,
|
||||
payload?.minY,
|
||||
@@ -237,7 +243,7 @@ export default function FloodDngrMapPopupPage() {
|
||||
extent.length !== 4 ||
|
||||
extent.some((value) => !Number.isFinite(value))
|
||||
) {
|
||||
throw new Error("해당 도시침수지도 피처의 위치 정보가 없습니다.");
|
||||
throw new Error("도시침수지도 위치 응답의 좌표가 올바르지 않습니다.");
|
||||
}
|
||||
window.opener?.postMessage(
|
||||
{
|
||||
@@ -253,11 +259,9 @@ export default function FloodDngrMapPopupPage() {
|
||||
} catch (error) {
|
||||
console.error("도시침수지도 위치 이동 중 오류:", error);
|
||||
const message =
|
||||
error instanceof Error && error.message.includes("404")
|
||||
? "해당 도시침수지도 데이터에 위치 정보가 없어 이동할 수 없습니다."
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "도시침수지도 위치 이동 중 오류가 발생했습니다.";
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "도시침수지도 위치 이동 중 오류가 발생했습니다.";
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -395,6 +399,11 @@ export default function FloodDngrMapPopupPage() {
|
||||
onRowClick={(row) => void handleRowClick(row)}
|
||||
rowId={(row, index) => `${row.sggCd}-${row.sbmrgFrqExpln}-${index}`}
|
||||
/>
|
||||
|
||||
{/* 인수테스트 요구: 팝업 자료의 원천을 목록 아래에 표시한다. */}
|
||||
<PopupFooterNote
|
||||
items={["출처 : 한강홍수통제소, 행정구역별 도시침수지도(2025.09)"]}
|
||||
/>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ import type {
|
||||
|
||||
export const useGisEdit = (treeData: any[]) => {
|
||||
const editableLayers = useMemo(() => collectEditableLayers(treeData), [treeData]);
|
||||
// GIS 편집 모드 on/off는 우측 분석 메뉴의 GIS 편집 버튼과 직접 연결된다.
|
||||
// GIS 편집 모드 on/off는 지도 우측 GIS 편집 아이콘과 직접 연결된다.
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
// 현재 선택 레이어를 기준으로 허용 도구와 저장 payload의 layerId를 결정한다.
|
||||
const [selectedLayerId, setSelectedLayerId] = useState("tb_ms_swage_ppl");
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { PencilRuler, Settings2 } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
isGisEditActive: boolean;
|
||||
onToggleGisEdit: () => void;
|
||||
onOpenGisManagement: () => Window | null;
|
||||
};
|
||||
|
||||
/** 기존 지도 도구와 같은 크기로 GIS 버튼의 기본·활성·키보드 포커스를 표시한다. */
|
||||
const getButtonClass = (isActive: boolean) =>
|
||||
`flex h-[40px] w-[40px] items-center justify-center p-0 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary ${
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-background text-label hover:bg-primary hover:text-primary-foreground"
|
||||
}`;
|
||||
|
||||
/** 분석도구 메뉴와 독립적으로 GIS 편집 모드와 관리 팝업의 진입 버튼을 제공한다. */
|
||||
export default function GisToolButtons({
|
||||
isGisEditActive,
|
||||
onToggleGisEdit,
|
||||
onOpenGisManagement,
|
||||
}: Props) {
|
||||
// 관리 팝업이 닫히면 버튼의 활성 표시도 해제한다.
|
||||
const [isGisManagementActive, setIsGisManagementActive] = useState(false);
|
||||
// 렌더링과 무관한 감지 타이머를 보관해 재클릭·화면 이탈 시 중복 실행을 방지한다.
|
||||
const closeWatcherRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (closeWatcherRef.current !== null) {
|
||||
window.clearInterval(closeWatcherRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 기존 팝업 실행 함수를 호출하고 차단·닫힘 상태를 버튼에 반영한다. */
|
||||
const handleOpenGisManagement = () => {
|
||||
if (closeWatcherRef.current !== null) {
|
||||
window.clearInterval(closeWatcherRef.current);
|
||||
closeWatcherRef.current = null;
|
||||
}
|
||||
const popup = onOpenGisManagement();
|
||||
setIsGisManagementActive(Boolean(popup && !popup.closed));
|
||||
if (!popup || popup.closed) return;
|
||||
|
||||
closeWatcherRef.current = window.setInterval(() => {
|
||||
if (!popup.closed) return;
|
||||
if (closeWatcherRef.current !== null) {
|
||||
window.clearInterval(closeWatcherRef.current);
|
||||
closeWatcherRef.current = null;
|
||||
}
|
||||
setIsGisManagementActive(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="GIS 도구"
|
||||
className="absolute right-[16px] top-[222.67px] z-[45] flex w-[40px] flex-col overflow-hidden rounded-[3px] bg-background shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title="GIS 편집"
|
||||
aria-label="GIS 편집"
|
||||
aria-pressed={isGisEditActive}
|
||||
onClick={onToggleGisEdit}
|
||||
className={`${getButtonClass(isGisEditActive)} border-b border-[#eeeeee]`}
|
||||
>
|
||||
<PencilRuler className="h-[20px] w-[20px]" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="GIS 관리"
|
||||
aria-label="GIS 관리"
|
||||
aria-haspopup="dialog"
|
||||
className={getButtonClass(isGisManagementActive)}
|
||||
onClick={handleOpenGisManagement}
|
||||
>
|
||||
<Settings2 className="h-[20px] w-[20px]" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -77,6 +77,8 @@ const DEFAULT_FILTERS: InftwtResultFilters = {
|
||||
};
|
||||
|
||||
const postRowSelectMessage = (row: InftwtResultRow) => {
|
||||
// 같은 클릭의 message/storage 전달을 식별한다.
|
||||
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
@@ -84,6 +86,7 @@ const postRowSelectMessage = (row: InftwtResultRow) => {
|
||||
JSON.stringify({
|
||||
row,
|
||||
timestamp: Date.now(),
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -96,6 +99,7 @@ const postRowSelectMessage = (row: InftwtResultRow) => {
|
||||
{
|
||||
type: INFTWT_RESULT_ROW_SELECT_MESSAGE,
|
||||
row,
|
||||
requestId,
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { beginMapNavigation, fitMapSelection, mapSearchNavigationOptions } from "@/features/map-search/lib/map-navigation";
|
||||
|
||||
/** 침입수 분석결과 선택 시 부모 지도에 결과 레이어를 표시하는 훅이다. */
|
||||
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
|
||||
@@ -35,7 +36,6 @@ type UseInftwtResultMapParams = {
|
||||
const INFTWT_ZONE_LAYER_ID = "SN_INFTWT_RESULT_ZONE_LAYER";
|
||||
const INFTWT_PIPE_LAYER_ID = "SN_INFTWT_RESULT_PIPE_LAYER";
|
||||
const DEFAULT_RESULT_FIT_PADDING: [number, number, number, number] = [120, 120, 120, 120];
|
||||
const INFTWT_PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH";
|
||||
|
||||
const zoneStyle = new Style({
|
||||
fill: new Fill({
|
||||
@@ -302,7 +302,7 @@ export const useInftwtResultMap = ({
|
||||
return;
|
||||
}
|
||||
|
||||
map.getView?.().fit?.(extent, {
|
||||
fitMapSelection(map, extent, {
|
||||
duration: 350,
|
||||
padding: DEFAULT_RESULT_FIT_PADDING,
|
||||
maxZoom: oilqPipeExtent ? 17 : shouldUseZoneExtent ? 15 : 16,
|
||||
@@ -319,7 +319,7 @@ export const useInftwtResultMap = ({
|
||||
if (normalizedSggCd && normalizedSggCd !== "0") {
|
||||
const sigunguCenter = getSigunguCenter(normalizedSggCd);
|
||||
if (sigunguCenter) {
|
||||
moveMapToCenter(gisMaps, sigunguCenter, 12);
|
||||
moveMapToCenter(gisMaps, sigunguCenter, 12, mapSearchNavigationOptions(gisMaps.current).beforeFocus);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -328,7 +328,7 @@ export const useInftwtResultMap = ({
|
||||
if (normalizedCtpvCd && normalizedCtpvCd !== "0") {
|
||||
const sidoCenterCoordinate = sidoCenter[normalizedCtpvCd.slice(0, 2)];
|
||||
if (sidoCenterCoordinate) {
|
||||
moveMapToCenter(gisMaps, sidoCenterCoordinate, 11);
|
||||
moveMapToCenter(gisMaps, sidoCenterCoordinate, 11, mapSearchNavigationOptions(gisMaps.current).beforeFocus);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -395,8 +395,17 @@ export const useInftwtResultMap = ({
|
||||
[clearLayers, ensurePipeLayer, ensureZoneLayer, gisMaps, moveToStableFocus],
|
||||
);
|
||||
|
||||
/** message/storage가 전달하는 동일 선택 요청을 한 번만 처리한다. */
|
||||
const selectionRequestsRef = useRef(new Set<string>());
|
||||
const handleRowSelect = useCallback(
|
||||
async (row: InftwtResultRow) => {
|
||||
async (row: InftwtResultRow, requestId?: string) => {
|
||||
if (requestId) {
|
||||
const requests = selectionRequestsRef.current;
|
||||
if (requests.has(requestId)) return;
|
||||
requests.add(requestId);
|
||||
if (requests.size > 64) requests.delete(requests.values().next().value!);
|
||||
}
|
||||
const navigation = beginMapNavigation(gisMaps.current);
|
||||
try {
|
||||
activeResultRowRef.current = row;
|
||||
setFeatureData(null);
|
||||
@@ -412,21 +421,9 @@ export const useInftwtResultMap = ({
|
||||
mapViewCacheRef.current.set(row.inftwtAnlsSn, mapView);
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(INFTWT_PROC_SEARCH_EVENT, {
|
||||
detail: {
|
||||
ctpvCd: row.ctpvCd ?? "0",
|
||||
sggCd: row.sggCd ?? "0",
|
||||
swtrmtFcltNm: row.swtrmtFcltNm ?? "",
|
||||
suppressMapMove: (mapView.oilqPipeCount ?? 0) > 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!navigation.isCurrent()) return;
|
||||
setActiveMapView(mapView);
|
||||
void renderMapView(mapView);
|
||||
await renderMapView(mapView);
|
||||
} catch (error) {
|
||||
console.error("移⑥엯??遺꾩꽍寃곌낵 吏???곌퀎 以??ㅻ쪟:", error);
|
||||
alert("침입수 분석결과를 지도에 연계하지 못했습니다.");
|
||||
@@ -439,16 +436,16 @@ export const useInftwtResultMap = ({
|
||||
const handlePopupMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (event.data?.type !== INFTWT_RESULT_ROW_SELECT_MESSAGE || !event.data?.row) return;
|
||||
void handleRowSelect(event.data.row as InftwtResultRow);
|
||||
void handleRowSelect(event.data.row as InftwtResultRow, event.data.requestId);
|
||||
};
|
||||
|
||||
const handleStorageEvent = (event: StorageEvent) => {
|
||||
if (event.key !== INFTWT_RESULT_ROW_SELECT_STORAGE_KEY || !event.newValue) return;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(event.newValue) as { row?: InftwtResultRow };
|
||||
const payload = JSON.parse(event.newValue) as { row?: InftwtResultRow; requestId?: string };
|
||||
if (!payload?.row) return;
|
||||
void handleRowSelect(payload.row);
|
||||
void handleRowSelect(payload.row, payload.requestId);
|
||||
} catch (error) {
|
||||
console.warn("移⑥엯??遺꾩꽍寃곌낵 storage ?숆린??媛믪쓣 ?댁꽍?섏? 紐삵뻽?듬땲??", error);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useUserStore } from "@/shared/lib/use-user-store";
|
||||
import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup";
|
||||
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
|
||||
import PipeAnalysisSummaryTable from "./pipe-analysis-summary-table";
|
||||
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
|
||||
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
||||
import {
|
||||
PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS,
|
||||
@@ -522,6 +523,13 @@ export default function PipeAnalysisPopupPage() {
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 인수테스트 요구: 관로분석의 기반 자료(원천)를 목록 아래에 표시한다. */}
|
||||
<PopupFooterNote
|
||||
items={[
|
||||
"국토교통부로부터 제공받은 GIS 관로정보(2025년)를 기반으로 분석하고 있음",
|
||||
]}
|
||||
/>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,6 +79,8 @@ const DEFAULT_FILTERS: RdiiResultFilters = {
|
||||
};
|
||||
|
||||
const postRowSelectMessage = (row: RdiiResultRow) => {
|
||||
// 같은 클릭의 message/storage 전달을 식별한다.
|
||||
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
@@ -86,6 +88,7 @@ const postRowSelectMessage = (row: RdiiResultRow) => {
|
||||
JSON.stringify({
|
||||
row,
|
||||
timestamp: Date.now(),
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -98,6 +101,7 @@ const postRowSelectMessage = (row: RdiiResultRow) => {
|
||||
{
|
||||
type: RDII_RESULT_ROW_SELECT_MESSAGE,
|
||||
row,
|
||||
requestId,
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** RDII 분석결과 선택 시 부모 지도에 결과 레이어를 표시하는 훅이다. */
|
||||
"use client";
|
||||
import { beginMapNavigation, fitMapSelection, mapSearchNavigationOptions } from "@/features/map-search/lib/map-navigation";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
|
||||
import GeoJSON from "ol/format/GeoJSON.js";
|
||||
@@ -32,7 +33,6 @@ type UseRdiiResultMapParams = {
|
||||
|
||||
const RDII_PIPE_LAYER_ID = "SN_RDII_RESULT_PIPE_LAYER";
|
||||
const DEFAULT_RESULT_FIT_PADDING: [number, number, number, number] = [120, 120, 120, 120];
|
||||
const PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH";
|
||||
|
||||
const pipeStyle = [
|
||||
new Style({
|
||||
@@ -186,7 +186,7 @@ export const useRdiiResultMap = ({
|
||||
return;
|
||||
}
|
||||
|
||||
map.getView?.().fit?.(pipeExtent, {
|
||||
fitMapSelection(map, pipeExtent, {
|
||||
duration: 350,
|
||||
padding: DEFAULT_RESULT_FIT_PADDING,
|
||||
maxZoom: 16,
|
||||
@@ -201,7 +201,7 @@ export const useRdiiResultMap = ({
|
||||
if (normalizedSggCd && normalizedSggCd !== "0") {
|
||||
const sigunguCenter = getSigunguCenter(normalizedSggCd);
|
||||
if (sigunguCenter) {
|
||||
moveMapToCenter(gisMaps, sigunguCenter, 12);
|
||||
moveMapToCenter(gisMaps, sigunguCenter, 12, mapSearchNavigationOptions(gisMaps.current).beforeFocus);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -210,7 +210,7 @@ export const useRdiiResultMap = ({
|
||||
if (normalizedCtpvCd && normalizedCtpvCd !== "0") {
|
||||
const sidoCenterCoordinate = sidoCenter[normalizedCtpvCd.slice(0, 2)];
|
||||
if (sidoCenterCoordinate) {
|
||||
moveMapToCenter(gisMaps, sidoCenterCoordinate, 11);
|
||||
moveMapToCenter(gisMaps, sidoCenterCoordinate, 11, mapSearchNavigationOptions(gisMaps.current).beforeFocus);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -260,38 +260,37 @@ export const useRdiiResultMap = ({
|
||||
[clearLayers, ensurePipeLayer, gisMaps, moveToStableFocus],
|
||||
);
|
||||
|
||||
/** message/storage가 전달하는 동일 선택 요청을 한 번만 처리한다. */
|
||||
const selectionRequestsRef = useRef(new Set<string>());
|
||||
const handleRowSelect = useCallback(
|
||||
async (row: RdiiResultRow) => {
|
||||
async (row: RdiiResultRow, requestId?: string) => {
|
||||
if (requestId) {
|
||||
const requests = selectionRequestsRef.current;
|
||||
if (requests.has(requestId)) return;
|
||||
requests.add(requestId);
|
||||
if (requests.size > 64) requests.delete(requests.values().next().value!);
|
||||
}
|
||||
const navigation = beginMapNavigation(gisMaps.current);
|
||||
try {
|
||||
activeResultRowRef.current = row;
|
||||
setFeatureData(null);
|
||||
setIsOpen(false);
|
||||
setIsSearchModalOpen(false);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(PROC_SEARCH_EVENT, {
|
||||
detail: {
|
||||
ctpvCd: row.ctpvCd ?? "0",
|
||||
sggCd: row.sggCd ?? "0",
|
||||
swtrmtFcltNm: row.swtrmtFcltNm ?? "",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const cachedMapView = mapViewCacheRef.current.get(row.rainInfwtAnlsSn);
|
||||
if (cachedMapView) {
|
||||
if (!navigation.isCurrent()) return;
|
||||
setActiveMapView(cachedMapView);
|
||||
void renderMapView(cachedMapView);
|
||||
await renderMapView(cachedMapView);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await getRawJson(`/sewerage-service/api/v1/rdiiAnlsRslt/mapView/${row.rainInfwtAnlsSn}`);
|
||||
const mapView = (payload?.data ?? payload) as RdiiResultMapViewResponse;
|
||||
mapViewCacheRef.current.set(row.rainInfwtAnlsSn, mapView);
|
||||
if (!navigation.isCurrent()) return;
|
||||
setActiveMapView(mapView);
|
||||
void renderMapView(mapView);
|
||||
await renderMapView(mapView);
|
||||
} catch (error) {
|
||||
console.error("RDII 분석결과 지도 연계 중 오류:", error);
|
||||
alert("RDII 분석결과를 지도에 연계하지 못했습니다.");
|
||||
@@ -304,16 +303,16 @@ export const useRdiiResultMap = ({
|
||||
const handlePopupMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (event.data?.type !== RDII_RESULT_ROW_SELECT_MESSAGE || !event.data?.row) return;
|
||||
void handleRowSelect(event.data.row as RdiiResultRow);
|
||||
void handleRowSelect(event.data.row as RdiiResultRow, event.data.requestId);
|
||||
};
|
||||
|
||||
const handleStorageEvent = (event: StorageEvent) => {
|
||||
if (event.key !== RDII_RESULT_ROW_SELECT_STORAGE_KEY || !event.newValue) return;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(event.newValue) as { row?: RdiiResultRow };
|
||||
const payload = JSON.parse(event.newValue) as { row?: RdiiResultRow; requestId?: string };
|
||||
if (!payload?.row) return;
|
||||
void handleRowSelect(payload.row);
|
||||
void handleRowSelect(payload.row, payload.requestId);
|
||||
} catch (error) {
|
||||
console.warn("RDII 분석결과 storage 동기화 값을 해석하지 못했습니다.", error);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { StandardPopupLayout } from "@common/business_components";
|
||||
import { getRawJson } from "@/shared/api/sewerage-api";
|
||||
import LoadingOverlay from "@/components/ui/loading-bar";
|
||||
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
|
||||
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
|
||||
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
@@ -309,6 +310,11 @@ export default function SbmrgDngrMapPopupPage() {
|
||||
].join(":")
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 인수테스트 요구: 팝업 자료의 원천을 목록 아래에 표시한다. */}
|
||||
<PopupFooterNote
|
||||
items={["출처 : 하수도정비 중점관리지역 20개소 과업 산출물"]}
|
||||
/>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/** 지반침하 상세의 표시값과 다운로드 시점 관로 조회 결과를 두 시트로 저장한다. */
|
||||
import { getRawJson } from "@/shared/api/sewerage-api";
|
||||
import {
|
||||
downloadExcelWorkbook,
|
||||
type ExcelCellValue,
|
||||
type ExcelSheetSpec,
|
||||
} from "@/shared/util/download-excel-workbook";
|
||||
import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkhole-types";
|
||||
|
||||
type DownloadOptions = {
|
||||
detail: SinkholeDetail;
|
||||
radiusMeters: number;
|
||||
summaryEntries: Array<[string, string]>;
|
||||
getPipeUseName: (pipe: SinkholeNearbyPipe) => string;
|
||||
};
|
||||
|
||||
/** 식별자는 문자열을 유지하고 결측 숫자는 0으로 바꾸지 않는다. */
|
||||
const numberCell = (value: unknown): ExcelCellValue => {
|
||||
if (value == null || String(value).trim() === "") return "-";
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : String(value);
|
||||
};
|
||||
|
||||
/** 화면과 같은 순서의 발생정보 및 반경 내 관로 목록 시트 명세를 만든다. */
|
||||
export const buildSinkholeDetailSheets = (
|
||||
options: DownloadOptions,
|
||||
pipes: SinkholeNearbyPipe[],
|
||||
noCoordinates: boolean,
|
||||
): ExcelSheetSpec[] => {
|
||||
const pipeRows: ExcelCellValue[][] = pipes.map((pipe, index) => [
|
||||
index + 1,
|
||||
String(pipe.pipeId ?? "").trim() || "-",
|
||||
options.getPipeUseName(pipe),
|
||||
numberCell(pipe.elapsedYears),
|
||||
numberCell(pipe.distanceMeters),
|
||||
]);
|
||||
if (pipeRows.length === 0)
|
||||
pipeRows.push([
|
||||
noCoordinates
|
||||
? "좌표가 없어 반경 내 하수관로를 조회할 수 없습니다."
|
||||
: "반경 내 하수관로 정보가 없습니다.",
|
||||
]);
|
||||
return [
|
||||
{
|
||||
name: "지반침하정보",
|
||||
rows: [["항목", "내용"], ...options.summaryEntries],
|
||||
columnWidths: [22, 75],
|
||||
},
|
||||
{
|
||||
name: "하수관로목록",
|
||||
rows: [
|
||||
["사고번호", String(options.detail.acdntNo)],
|
||||
["조회 반경(m)", options.radiusMeters],
|
||||
["조회 기준", "기존 반경 조회 기준, 최대 200건 (가까운 거리순)"],
|
||||
[],
|
||||
["NO", "관로 ID", "용도", "경과년도(년)", "거리(m)"],
|
||||
...pipeRows,
|
||||
],
|
||||
columnWidths: [12, 36, 24, 20, 18],
|
||||
autoFilterRange: `A5:E${5 + pipes.length}`,
|
||||
mergeRanges: ["B3:E3", ...(pipes.length === 0 ? ["A6:E6"] : [])],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
/** 기존 반경 API를 다시 조회하여 실패를 빈 목록으로 내보내지 않는다. */
|
||||
export const downloadSinkholeDetailExcel = async (options: DownloadOptions) => {
|
||||
// 비동기 재조회 동안 호출자의 상세·반경·표시 행이 변경되어도 클릭 시점을 유지한다.
|
||||
const snapshot: DownloadOptions = {
|
||||
...options,
|
||||
detail: { ...options.detail },
|
||||
summaryEntries: options.summaryEntries.map(([title, value]) => [
|
||||
title,
|
||||
value,
|
||||
]),
|
||||
};
|
||||
const { detail, radiusMeters } = snapshot;
|
||||
const noCoordinates = String(detail.coordStatus ?? "").trim() !== "SUCCESS";
|
||||
let pipes: SinkholeNearbyPipe[] = [];
|
||||
if (!noCoordinates) {
|
||||
const hasPoint =
|
||||
detail.x != null &&
|
||||
detail.y != null &&
|
||||
String(detail.x).trim() !== "" &&
|
||||
String(detail.y).trim() !== "" &&
|
||||
Number.isFinite(Number(detail.x)) &&
|
||||
Number.isFinite(Number(detail.y));
|
||||
const params = new URLSearchParams({ radiusMeters: String(radiusMeters) });
|
||||
if (hasPoint) {
|
||||
params.set("x", String(detail.x));
|
||||
params.set("y", String(detail.y));
|
||||
}
|
||||
const path = hasPoint
|
||||
? "nearbyPipesByPoint"
|
||||
: `${encodeURIComponent(detail.acdntNo)}/nearbyPipes`;
|
||||
const raw = await getRawJson(
|
||||
`/sewerage-service/api/v1/sinkholes/${path}?${params}`,
|
||||
);
|
||||
const response = raw?.data ?? raw;
|
||||
if (response?.coordStatus !== "SUCCESS")
|
||||
throw new Error(
|
||||
"관로 조회 좌표를 확인할 수 없습니다. 상세정보를 다시 조회해 주세요.",
|
||||
);
|
||||
if (
|
||||
!Array.isArray(response?.items) ||
|
||||
response.items.some(
|
||||
(item: unknown) =>
|
||||
!item ||
|
||||
typeof item !== "object" ||
|
||||
Array.isArray(item) ||
|
||||
!("pipeId" in item) ||
|
||||
!("fid" in item),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
"관로 조회 응답이 올바르지 않습니다. 다시 시도해 주세요.",
|
||||
);
|
||||
pipes = response.items;
|
||||
}
|
||||
const now = new Date();
|
||||
const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||
await downloadExcelWorkbook({
|
||||
fileName: `지반침하정보_${detail.acdntNo}_${date}.xlsx`,
|
||||
sheets: buildSinkholeDetailSheets(snapshot, pipes, noCoordinates),
|
||||
});
|
||||
};
|
||||
@@ -4,12 +4,13 @@
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@common/components";
|
||||
import { VerticalTable } from "@/components/ui/vertical-table";
|
||||
import { getCmsData } from "@/shared/api/external-service-api";
|
||||
import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkhole-types";
|
||||
import { useSinkholeCommonCodes } from "./use-sinkhole-common-codes";
|
||||
import { downloadSinkholeDetailExcel } from "./sinkhole-detail-excel";
|
||||
|
||||
type Props = {
|
||||
detail: SinkholeDetail;
|
||||
@@ -81,6 +82,9 @@ export default function SinkholeDetailPanel({
|
||||
}: Props) {
|
||||
// 상세의 원천 복구상태 값은 유지하고 CMS에 대응하는 코드명만 표시한다.
|
||||
const { restoreStatusLabels } = useSinkholeCommonCodes();
|
||||
// 파일 생성 중 버튼 표시와 같은 이벤트 루프의 중복 클릭을 함께 제어한다.
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const downloadInProgressRef = useRef(false);
|
||||
const [pipeUseCodeMap, setPipeUseCodeMap] = useState<Map<string, string>>(
|
||||
new Map(),
|
||||
);
|
||||
@@ -127,27 +131,24 @@ export default function SinkholeDetailPanel({
|
||||
return { average, oldest };
|
||||
}, [nearbyPipes]);
|
||||
|
||||
const summaryTableData = useMemo(
|
||||
() => {
|
||||
const restoreStatus = String(detail.restoreStatus ?? "").trim();
|
||||
return [
|
||||
{
|
||||
acdntNo: display(detail.acdntNo),
|
||||
ocrnYmd: display(detail.ocrnYmd),
|
||||
fullAddress: display(detail.fullAddress || detail.daddr),
|
||||
sizeWidth: display(detail.sizeWidth),
|
||||
sizeLength: display(detail.sizeLength),
|
||||
sizeDepth: display(detail.sizeDepth),
|
||||
detailCause: display(detail.detailCause),
|
||||
restoreStatus: display(
|
||||
restoreStatusLabels[restoreStatus] || restoreStatus,
|
||||
),
|
||||
restoreCompletedYmd: display(detail.restoreCompletedYmd),
|
||||
},
|
||||
];
|
||||
},
|
||||
[detail, restoreStatusLabels],
|
||||
);
|
||||
const summaryTableData = useMemo(() => {
|
||||
const restoreStatus = String(detail.restoreStatus ?? "").trim();
|
||||
return [
|
||||
{
|
||||
acdntNo: display(detail.acdntNo),
|
||||
ocrnYmd: display(detail.ocrnYmd),
|
||||
fullAddress: display(detail.fullAddress || detail.daddr),
|
||||
sizeWidth: display(detail.sizeWidth),
|
||||
sizeLength: display(detail.sizeLength),
|
||||
sizeDepth: display(detail.sizeDepth),
|
||||
detailCause: display(detail.detailCause),
|
||||
restoreStatus: display(
|
||||
restoreStatusLabels[restoreStatus] || restoreStatus,
|
||||
),
|
||||
restoreCompletedYmd: display(detail.restoreCompletedYmd),
|
||||
},
|
||||
];
|
||||
}, [detail, restoreStatusLabels]);
|
||||
|
||||
const summaryHeadList = [
|
||||
{ id: "acdntNo", title: "사고번호" },
|
||||
@@ -161,6 +162,35 @@ export default function SinkholeDetailPanel({
|
||||
{ id: "restoreCompletedYmd", title: "복구완료일자" },
|
||||
];
|
||||
|
||||
/** 클릭 시의 상세·반경·표시값을 고정해 탭 전환 중에도 한 사고만 내보낸다. */
|
||||
const handleDownload = async () => {
|
||||
if (downloadInProgressRef.current) return;
|
||||
downloadInProgressRef.current = true;
|
||||
setIsDownloading(true);
|
||||
const summary = summaryTableData[0] as Record<string, string>;
|
||||
try {
|
||||
await downloadSinkholeDetailExcel({
|
||||
detail: { ...detail },
|
||||
radiusMeters,
|
||||
summaryEntries: summaryHeadList.map(({ id, title }) => [
|
||||
title,
|
||||
summary[id] ?? "-",
|
||||
]),
|
||||
getPipeUseName,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("지반침하 상세 엑셀 다운로드 실패:", error);
|
||||
alert(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "엑셀 다운로드 중 오류가 발생했습니다.",
|
||||
);
|
||||
} finally {
|
||||
downloadInProgressRef.current = false;
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id="popup"
|
||||
@@ -198,9 +228,20 @@ export default function SinkholeDetailPanel({
|
||||
|
||||
<section className="pb-[1.2rem]">
|
||||
<div className="card-header !block px-[1.2rem]">
|
||||
<h3 className="mx-3 whitespace-nowrap text-2xl font-medium text-label">
|
||||
침하발생 반경내 관로정보
|
||||
</h3>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="ml-3 min-w-0 flex-1 text-2xl font-medium text-label">
|
||||
침하발생 반경내 관로정보
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
className="hover:bg-primary/90 mr-3 shrink-0 bg-primary text-primary-foreground"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
aria-busy={isDownloading}
|
||||
>
|
||||
{isDownloading ? "다운로드 중..." : "엑셀다운로드"}
|
||||
</Button>
|
||||
</div>
|
||||
{canShowNearby ? (
|
||||
<div className="mx-3 mt-2 whitespace-nowrap text-left text-[0.95rem] leading-5 text-muted-foreground">
|
||||
<span>반경 {radiusMeters}m · </span>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
import useStore from "@/entities/reference-data/model/reference-data-store";
|
||||
import LoadingOverlay from "@/components/ui/loading-bar";
|
||||
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
|
||||
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
|
||||
import PopupSearchPanel, {
|
||||
PopupSearchActions,
|
||||
PopupSearchField,
|
||||
@@ -514,6 +515,13 @@ export default function SinkholePopupPage() {
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 인수테스트 요구: 지반침하 정보의 연계 원천과 연계 주기를 목록 아래에 표시한다. */}
|
||||
<PopupFooterNote
|
||||
items={[
|
||||
"출처 : 지하안전정보시스템 지반침하사고 정보 연계 표출(연계 주기 : 매 시간 연계)",
|
||||
]}
|
||||
/>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,19 @@ import type {
|
||||
export type WaterRiskResultQuery = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
/** 현재 탭 분석기준시간(정시) 정확 일치 조건. 분석시각 기간과 함께 보내지 않는다. */
|
||||
analysisBaseAt?: string;
|
||||
/** 과거이력 분석시각 기간 시작(yyyy-MM-ddTHH:00:00, 포함). 종료와 함께 보낸다. */
|
||||
analysisBaseFrom?: string;
|
||||
/** 과거이력 분석시각 기간 종료(yyyy-MM-ddTHH:00:00, 포함). 시작과 함께 보낸다. */
|
||||
analysisBaseTo?: string;
|
||||
/**
|
||||
* 과거이력 강우시각(강우 시작 일시) 기간 시작(yyyy-MM-ddTHH:00:00, 포함). 종료와 함께 보낸다.
|
||||
* 보내면 백엔드가 startDate·endDate보다 이 기간을 우선한다.
|
||||
*/
|
||||
rainStartedFrom?: string;
|
||||
/** 과거이력 강우시각(강우 시작 일시) 기간 종료(yyyy-MM-ddTHH:00:00, 포함). 시작과 함께 보낸다. */
|
||||
rainStartedTo?: string;
|
||||
ctpvCd?: string;
|
||||
sggCd?: string;
|
||||
areaId?: string;
|
||||
@@ -54,6 +66,14 @@ export const fetchWaterRiskResults = async (
|
||||
});
|
||||
if (query.ctpvCd) params.set("ctpvCd", query.ctpvCd);
|
||||
if (query.analysisBaseAt) params.set("analysisBaseAt", query.analysisBaseAt);
|
||||
if (query.analysisBaseFrom && query.analysisBaseTo) {
|
||||
params.set("analysisBaseFrom", query.analysisBaseFrom);
|
||||
params.set("analysisBaseTo", query.analysisBaseTo);
|
||||
}
|
||||
if (query.rainStartedFrom && query.rainStartedTo) {
|
||||
params.set("rainStartedFrom", query.rainStartedFrom);
|
||||
params.set("rainStartedTo", query.rainStartedTo);
|
||||
}
|
||||
if (query.sggCd) params.set("sggCd", query.sggCd);
|
||||
if (query.areaId) params.set("areaId", query.areaId);
|
||||
if (query.riskStatus) params.set("riskStatus", query.riskStatus);
|
||||
@@ -78,15 +98,12 @@ export const fetchWaterRiskResults = async (
|
||||
export const fetchWaterRiskMapView = async (
|
||||
row: WaterRiskResultRow,
|
||||
): Promise<WaterRiskMapView> => {
|
||||
if (!row.analysisDate || !row.ctpvCd || !row.sggCd) {
|
||||
throw new Error("지도 조회에 필요한 분석일·행정구역 정보가 없습니다.");
|
||||
// 날짜·지역이 아니라 누른 행의 SWMM 실행 ID로 조회해야 같은 날 같은 지역 결과가 여러 건이어도 같은 결과를 그린다.
|
||||
if (!row.executionId) {
|
||||
throw new Error("지도 조회에 필요한 SWMM 실행 ID가 없습니다.");
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
ctpvCd: row.ctpvCd,
|
||||
sggCd: row.sggCd,
|
||||
});
|
||||
const response = await getData(
|
||||
`v1/waterRisk/results/${encodeURIComponent(row.analysisDate)}/areas/${encodeURIComponent(row.areaId)}/mapView?${params.toString()}`,
|
||||
`v1/waterRisk/executions/${encodeURIComponent(row.executionId)}/mapView`,
|
||||
);
|
||||
return {
|
||||
...response,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** 백엔드의 양 끝 날짜 포함 최대 366일 계약을 캘린더와 조회 검증에서 함께 사용한다. */
|
||||
/** 백엔드의 양 끝 날짜 포함 최대 366일 계약을 기본값과 조회 검증에서 함께 사용한다. */
|
||||
export const WATER_RISK_MAX_SEARCH_DAYS = 366;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -24,31 +24,82 @@ export const getWaterRiskMinStartDate = (
|
||||
.slice(0, 10);
|
||||
};
|
||||
|
||||
/** 달력 제한을 우회한 직접 입력도 API 요청 전에 검증한다. */
|
||||
export const validateWaterRiskDateRange = (
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
today: string,
|
||||
/**
|
||||
* 시간 단위 검색 입력(`datetime-local`, `step=3600`)의 값 형식 `yyyy-MM-ddTHH:00`이다.
|
||||
* 분은 항상 00이며, 첫 번째 그룹은 날짜(yyyy-MM-dd)다.
|
||||
*/
|
||||
const HOUR_VALUE_PATTERN = /^(\d{4}-\d{2}-\d{2})T\d{2}:00$/;
|
||||
|
||||
/**
|
||||
* `datetime-local` 입력값의 분을 00으로 맞춘다.
|
||||
* 브라우저는 값의 분이 00이면 분 칸을 고정하지만, 빈 값에서 처음 입력하거나 직접 입력해 분이 들어온 경우는
|
||||
* 스스로 고치지 않으므로 저장 전에 시간 단위로 내린다. 형식이 아니면(빈 값 포함) 그대로 둔다.
|
||||
*/
|
||||
export const toWaterRiskHourValue = (value: string) =>
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)
|
||||
? `${value.slice(0, 13)}:00`
|
||||
: value;
|
||||
|
||||
/** 로컬 시각을 시간 단위 입력값(yyyy-MM-ddTHH:00)으로 내린다. 기본 종료값과 미래 시각 제한에 쓴다. */
|
||||
export const formatWaterRiskHourValue = (date: Date) => {
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:00`;
|
||||
};
|
||||
|
||||
/** 시간 단위 입력값을 API 정시 일시(yyyy-MM-ddTHH:00:00)로 바꾼다. 빈 값은 빈 문자열이다. */
|
||||
export const toWaterRiskApiDateTime = (value: string) =>
|
||||
value ? `${value}:00` : "";
|
||||
|
||||
/**
|
||||
* 시간 단위 기간을 백엔드 계약(시작·종료 함께, 정시, 시작 ≤ 종료, 날짜 기준 최대 366일)과
|
||||
* 화면 조건(종료 ≤ 현재 정시)으로 검증한다. 오류가 없으면 빈 문자열을 반환한다.
|
||||
* `required`가 아니면 시작·종료를 모두 비웠을 때 조건 없이 조회한다.
|
||||
*/
|
||||
const validateHourPeriod = (
|
||||
from: string,
|
||||
to: string,
|
||||
currentHourValue: string,
|
||||
{ label, required }: { label: string; required: boolean },
|
||||
): string => {
|
||||
if (!startDate || !endDate) return "조회 시작일과 종료일을 입력해야 합니다.";
|
||||
const start = parseDate(startDate);
|
||||
const end = parseDate(endDate);
|
||||
if (start === null || end === null)
|
||||
return "올바른 조회 날짜를 입력해 주세요.";
|
||||
if (endDate > today) return "조회 종료일은 오늘보다 늦을 수 없습니다.";
|
||||
if (start > end) return "조회 시작일은 종료일보다 늦을 수 없습니다.";
|
||||
if (!from && !to) {
|
||||
return required ? `${label} 시작과 종료를 입력해야 합니다.` : "";
|
||||
}
|
||||
if (!from || !to) {
|
||||
return required
|
||||
? `${label} 시작과 종료를 입력해야 합니다.`
|
||||
: `${label}은 시작과 종료를 모두 입력하거나 모두 비워야 합니다.`;
|
||||
}
|
||||
const fromMatch = HOUR_VALUE_PATTERN.exec(from);
|
||||
const toMatch = HOUR_VALUE_PATTERN.exec(to);
|
||||
const start = fromMatch ? parseDate(fromMatch[1]!) : null;
|
||||
const end = toMatch ? parseDate(toMatch[1]!) : null;
|
||||
if (start === null || end === null) return `올바른 ${label}을 입력해 주세요.`;
|
||||
if (to > currentHourValue) return `${label} 종료는 현재 시각보다 늦을 수 없습니다.`;
|
||||
if (from > to) return `${label} 시작은 종료보다 늦을 수 없습니다.`;
|
||||
if ((end - start) / DAY_MS + 1 > WATER_RISK_MAX_SEARCH_DAYS) {
|
||||
return "강우 발생기간은 종료일 기준 최대 1년(366일)까지 조회할 수 있습니다.";
|
||||
return `${label}은 최대 1년(366일)까지 조회할 수 있습니다.`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
/** 종료일 변경으로 기존 시작일이 허용 범위를 벗어난 경우 최근 1년 시작일로 맞춘다. */
|
||||
export const adjustWaterRiskStartDate = (
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): string => {
|
||||
const min = getWaterRiskMinStartDate(endDate);
|
||||
if (!min) return startDate;
|
||||
return !startDate || startDate < min || startDate > endDate ? min : startDate;
|
||||
};
|
||||
/** 과거이력 강우시각(강우 시작 일시) 기간을 검증한다. 필수 조건이다. */
|
||||
export const validateWaterRiskRainStartedPeriod = (
|
||||
from: string,
|
||||
to: string,
|
||||
currentHourValue: string,
|
||||
) =>
|
||||
validateHourPeriod(from, to, currentHourValue, {
|
||||
label: "강우시각",
|
||||
required: true,
|
||||
});
|
||||
|
||||
/** 과거이력 분석시각 기간을 검증한다. 선택 조건이며 모두 비우면 적용하지 않는다. */
|
||||
export const validateWaterRiskAnalysisPeriod = (
|
||||
from: string,
|
||||
to: string,
|
||||
currentHourValue: string,
|
||||
) =>
|
||||
validateHourPeriod(from, to, currentHourValue, {
|
||||
label: "분석시각",
|
||||
required: false,
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
|
||||
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
|
||||
import {
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type WindowPopupContentProps,
|
||||
} from "@/features/window-popup/model/types";
|
||||
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
|
||||
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
|
||||
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
||||
import {
|
||||
StandardPopupLayout,
|
||||
@@ -21,9 +22,12 @@ import {
|
||||
import type { UiTableSort } from "@common/business_components/ui/table";
|
||||
import { Button } from "@common/components";
|
||||
import {
|
||||
adjustWaterRiskStartDate,
|
||||
formatWaterRiskHourValue,
|
||||
getWaterRiskMinStartDate,
|
||||
validateWaterRiskDateRange,
|
||||
toWaterRiskApiDateTime,
|
||||
toWaterRiskHourValue,
|
||||
validateWaterRiskAnalysisPeriod,
|
||||
validateWaterRiskRainStartedPeriod,
|
||||
} from "./water-risk-date-range";
|
||||
|
||||
import {
|
||||
@@ -34,25 +38,42 @@ import {
|
||||
import {
|
||||
buildWaterRiskStatusLabel,
|
||||
EMPTY_WATER_RISK_SESSION_SNAPSHOT,
|
||||
isWaterRiskDanger,
|
||||
type WaterRiskAreaOption,
|
||||
type WaterRiskResultPage,
|
||||
type WaterRiskResultRow,
|
||||
type WaterRiskSessionSnapshot,
|
||||
type WaterRiskStatus,
|
||||
} from "./water-risk-types";
|
||||
|
||||
import LoadingOverlay from "@/components/ui/loading-bar";
|
||||
|
||||
type WaterRiskResultMode = "current" | "history";
|
||||
|
||||
/**
|
||||
* 과거이력 검색조건이다. 시각 값은 모두 시간 단위 입력값(yyyy-MM-ddTHH:00)이며
|
||||
* 시작·종료 정시를 모두 포함해 조회한다. 현재 탭은 중점관리지역만 이 값을 쓴다.
|
||||
*/
|
||||
type ResultFilters = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
ctpvCd: string;
|
||||
sggCd: string;
|
||||
areaId: string;
|
||||
riskStatus: "ALL" | "DANGER" | "NORMAL";
|
||||
riskStatus: "ALL" | WaterRiskStatus;
|
||||
/** 과거이력 분석시각 기간 시작. 비우면 분석시각 조건을 적용하지 않는다. */
|
||||
analysisFrom: string;
|
||||
/** 과거이력 분석시각 기간 종료. 시작과 함께 입력한다. */
|
||||
analysisTo: string;
|
||||
/** 과거이력 강우시각(강우 시작 일시) 기간 시작. 필수이며 기본은 최근 1년 시작일 00시다. */
|
||||
rainStartedFrom: string;
|
||||
/** 과거이력 강우시각(강우 시작 일시) 기간 종료. 필수이며 기본은 현재 정시다. */
|
||||
rainStartedTo: string;
|
||||
};
|
||||
|
||||
/** 과거이력 시각 범위 입력 키. 분석시각·강우시각 입력을 같은 모양으로 그릴 때 쓴다. */
|
||||
type HourRangeKeys =
|
||||
| { fromKey: "analysisFrom"; toKey: "analysisTo" }
|
||||
| { fromKey: "rainStartedFrom"; toKey: "rainStartedTo" };
|
||||
|
||||
const WATER_RISK_PAGE_SIZE = 10;
|
||||
const EMPTY_RESULT_PAGE: WaterRiskResultPage = {
|
||||
items: [],
|
||||
@@ -69,6 +90,8 @@ type WaterRiskTableRow = Record<string, unknown> & {
|
||||
sidoName: string;
|
||||
sggName: string;
|
||||
areaName: string;
|
||||
/** 대표실행 분석기준시각(excn.anls_crtr_dt)을 yyyy.MM.dd HH:mm으로 표시한 값 */
|
||||
analysisBaseAt: string;
|
||||
rainStartedAt: string;
|
||||
currentAccumRainQty: string;
|
||||
forecastRainQty: string;
|
||||
@@ -81,53 +104,56 @@ const WATER_RISK_RESULT_HEADLIST: UiHeadProps[] = [
|
||||
{
|
||||
id: "rowNo",
|
||||
title: "순번",
|
||||
widthPercent: 6,
|
||||
widthPercent: 5,
|
||||
sortable: true,
|
||||
sortKey: "rowNumber",
|
||||
},
|
||||
{
|
||||
id: "sidoName",
|
||||
title: "시도",
|
||||
widthPercent: 8,
|
||||
widthPercent: 7,
|
||||
sortable: true,
|
||||
sortKey: "ctpvName",
|
||||
},
|
||||
{ id: "sggName", title: "시군구", widthPercent: 8, sortable: true },
|
||||
{ id: "areaName", title: "중점관리지역", widthPercent: 14, sortable: true },
|
||||
{ id: "sggName", title: "시군구", widthPercent: 7, sortable: true },
|
||||
{ id: "areaName", title: "중점관리지역", widthPercent: 12, sortable: true },
|
||||
// 대표실행 분석기준시각. 컬럼 id가 백엔드 정렬 키(analysisBaseAt)와 같다.
|
||||
{ id: "analysisBaseAt", title: "분석시각", widthPercent: 11, sortable: true },
|
||||
{
|
||||
id: "rainStartedAt",
|
||||
title: "강우시작시간",
|
||||
widthPercent: 13,
|
||||
title: "강우시작시각",
|
||||
widthPercent: 11,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
id: "currentAccumRainQty",
|
||||
title: "누적강우량(mm)",
|
||||
widthPercent: 11,
|
||||
widthPercent: 9,
|
||||
align: "right",
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
id: "forecastRainQty",
|
||||
title: "예측강우량(mm)",
|
||||
widthPercent: 11,
|
||||
widthPercent: 9,
|
||||
align: "right",
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
id: "maxFloodingAt",
|
||||
title: "최대 침수 예상시점",
|
||||
widthPercent: 13,
|
||||
widthPercent: 11,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
id: "maxFloodingFlow",
|
||||
title: "최대 침수유량(m³/s)",
|
||||
widthPercent: 10,
|
||||
widthPercent: 9,
|
||||
align: "right",
|
||||
sortable: true,
|
||||
},
|
||||
{ id: "riskStatus", title: "위험 여부", widthPercent: 7 },
|
||||
// "위험(매핑관로없음)" 문구가 들어가도록 기존보다 넓게 둔다.
|
||||
{ id: "riskStatus", title: "위험 여부", widthPercent: 9 },
|
||||
];
|
||||
|
||||
const formatOptionalNumber = (
|
||||
@@ -155,17 +181,20 @@ const formatAnalysisBaseAt = (value?: string | null) => {
|
||||
return `${date.replaceAll("-", ".")} ${time.slice(0, 5)}`.trim();
|
||||
};
|
||||
|
||||
/** 현재 위험 조회와 검색 초기화에 사용하는 최근 1년의 기본 조회조건이다. */
|
||||
/** 최초 진입과 검색 초기화에 사용하는 기본 조회조건이다. 강우시각은 최근 1년이다. */
|
||||
const buildDefaultResultFilters = (): ResultFilters => {
|
||||
const endDate = new Date();
|
||||
const endDateValue = formatLocalDate(endDate);
|
||||
const now = new Date();
|
||||
return {
|
||||
startDate: getWaterRiskMinStartDate(endDateValue)!,
|
||||
endDate: endDateValue,
|
||||
ctpvCd: "ALL",
|
||||
sggCd: "ALL",
|
||||
areaId: "ALL",
|
||||
riskStatus: "ALL",
|
||||
// 분석시각 기간은 선택 조건이므로 기본·초기화 상태는 비워 둔다.
|
||||
analysisFrom: "",
|
||||
analysisTo: "",
|
||||
// 강우시각은 필수 조건이다. 365일 전 00시 ~ 현재 정시(종료일 포함 최대 366일)를 기본으로 한다.
|
||||
rainStartedFrom: `${getWaterRiskMinStartDate(formatLocalDate(now))!}T00:00`,
|
||||
rainStartedTo: formatWaterRiskHourValue(now),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -178,12 +207,16 @@ const postMessageToOpener = (
|
||||
window.opener.postMessage({ type, payload }, window.location.origin);
|
||||
};
|
||||
|
||||
/** 위험 여부에 맞는 기존 팝업 badge 스타일을 반환한다. */
|
||||
const getRiskBadgeClassName = (riskStatus: "DANGER" | "NORMAL") =>
|
||||
riskStatus === "DANGER" ? "sn-popup-badge-danger" : "sn-popup-badge-normal";
|
||||
/** 위험 여부에 맞는 기존 팝업 badge 스타일을 반환한다. 위험(매핑관로없음)도 위험 배지를 쓴다. */
|
||||
const getRiskBadgeClassName = (riskStatus: WaterRiskStatus) =>
|
||||
isWaterRiskDanger(riskStatus) ? "sn-popup-badge-danger" : "sn-popup-badge-normal";
|
||||
|
||||
/** 동일 날짜·지역에 여러 강우이벤트가 있어도 실행별 행 키가 겹치지 않게 식별한다. */
|
||||
/**
|
||||
* 목록 행을 대표 SWMM 실행 ID로 식별한다. 행 하나가 실행 하나이므로 같은 날·지역 행도 겹치지 않는다.
|
||||
* 실행 ID가 없는 응답(구 BE)에서는 기존 날짜·시각·지역 조합으로 대체한다.
|
||||
*/
|
||||
const buildResultRowKey = (row: WaterRiskResultRow) =>
|
||||
row.executionId ||
|
||||
[
|
||||
row.analysisDate ?? "",
|
||||
row.rainStartedAt ?? "",
|
||||
@@ -195,10 +228,10 @@ const buildResultRowKey = (row: WaterRiskResultRow) =>
|
||||
|
||||
/** 실제 통수위험 API를 조회하고 선택 결과를 부모 지도에 전달하는 팝업이다. */
|
||||
export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
// 최초 진입과 초기화 모두 오늘을 종료일로 하는 최근 1년을 사용한다.
|
||||
// 최초 진입과 초기화 모두 현재 정시를 종료로 하는 최근 1년 강우시각을 사용한다.
|
||||
const initialFilters = useMemo(() => buildDefaultResultFilters(), []);
|
||||
// 과거분석은 오늘까지 선택 가능하며, 시작일의 범위는 선택 종료일에 따라 달라진다.
|
||||
const today = formatLocalDate(new Date());
|
||||
// 과거이력 시각 입력은 현재 정시까지 고를 수 있다. 렌더링마다 다시 계산해 정시가 바뀌면 따라간다.
|
||||
const currentHourValue = formatWaterRiskHourValue(new Date());
|
||||
const [resultMode, setResultMode] = useState<WaterRiskResultMode>("current");
|
||||
const [draftResultFilters, setDraftResultFilters] =
|
||||
useState<ResultFilters>(initialFilters);
|
||||
@@ -241,15 +274,38 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
let active = true;
|
||||
setIsLoading(true);
|
||||
setErrorMessage("");
|
||||
// 과거 이력의 고정 기본기간이 현재 진행 중인 강우 조회를 제한하지 않게 분리한다.
|
||||
const dateFilters = resultMode === "current"
|
||||
? buildDefaultResultFilters()
|
||||
: appliedResultFilters;
|
||||
const isHistory = resultMode === "history";
|
||||
// 현재 탭은 진행 중 강우를 오늘까지 최근 1년 강우시작일로 찾고, 과거이력의 적용 기간이 이를 제한하지 않게 분리한다.
|
||||
// 과거이력은 적용 시 검증을 통과한 강우시각 기간을 보내며, 강우시작일도 그 날짜로 맞춘다.
|
||||
const requestToday = formatLocalDate(new Date());
|
||||
const startDate = isHistory
|
||||
? appliedResultFilters.rainStartedFrom.slice(0, 10)
|
||||
: getWaterRiskMinStartDate(requestToday)!;
|
||||
const endDate = isHistory
|
||||
? appliedResultFilters.rainStartedTo.slice(0, 10)
|
||||
: requestToday;
|
||||
// 과거이력의 분석시각 기간은 적용 시 검증을 통과한 값이며, 비어 있으면 조건 없이 조회한다.
|
||||
const analysisBaseFrom = isHistory
|
||||
? toWaterRiskApiDateTime(appliedResultFilters.analysisFrom)
|
||||
: "";
|
||||
const analysisBaseTo = isHistory
|
||||
? toWaterRiskApiDateTime(appliedResultFilters.analysisTo)
|
||||
: "";
|
||||
const rainStartedFrom = isHistory
|
||||
? toWaterRiskApiDateTime(appliedResultFilters.rainStartedFrom)
|
||||
: "";
|
||||
const rainStartedTo = isHistory
|
||||
? toWaterRiskApiDateTime(appliedResultFilters.rainStartedTo)
|
||||
: "";
|
||||
fetchWaterRiskResults({
|
||||
startDate: dateFilters.startDate,
|
||||
endDate: dateFilters.endDate,
|
||||
startDate,
|
||||
endDate,
|
||||
analysisBaseAt:
|
||||
resultMode === "current" ? currentAnalysisBaseAt : undefined,
|
||||
analysisBaseFrom: analysisBaseFrom || undefined,
|
||||
analysisBaseTo: analysisBaseTo || undefined,
|
||||
rainStartedFrom: rainStartedFrom || undefined,
|
||||
rainStartedTo: rainStartedTo || undefined,
|
||||
ctpvCd:
|
||||
resultMode === "current" || appliedResultFilters.ctpvCd === "ALL"
|
||||
? undefined
|
||||
@@ -313,9 +369,14 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
const nextSnapshot = (event.data.payload ??
|
||||
EMPTY_WATER_RISK_SESSION_SNAPSHOT) as WaterRiskSessionSnapshot;
|
||||
if (nextSnapshot.selectedAreaId) {
|
||||
const matchingRow = resultPage.items.find(
|
||||
(row) => row.areaId === nextSnapshot.selectedAreaId,
|
||||
);
|
||||
// 지도에 표시 중인 실행 ID가 있으면 그 행만 선택한다. 같은 지역 첫 행으로 바꾸면 다른 결과가 선택돼 보인다.
|
||||
const matchingRow = nextSnapshot.selectedExecutionId
|
||||
? resultPage.items.find(
|
||||
(row) => row.executionId === nextSnapshot.selectedExecutionId,
|
||||
)
|
||||
: resultPage.items.find(
|
||||
(row) => row.areaId === nextSnapshot.selectedAreaId,
|
||||
);
|
||||
if (matchingRow) setSelectedResultKey(buildResultRowKey(matchingRow));
|
||||
}
|
||||
};
|
||||
@@ -418,6 +479,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
sidoName: row.sidoName || "-",
|
||||
sggName: row.sggName || "-",
|
||||
areaName: row.areaName,
|
||||
analysisBaseAt: formatAnalysisBaseAt(row.analysisBaseAt),
|
||||
rainStartedAt: formatAnalysisBaseAt(row.rainStartedAt),
|
||||
currentAccumRainQty: formatOptionalNumber(row.currentAccumRainQty, 1),
|
||||
forecastRainQty: formatOptionalNumber(row.forecastRainQty, 1),
|
||||
@@ -432,11 +494,25 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
/** 검증된 검색조건을 적용하고 첫 페이지를 새로 조회한다. */
|
||||
const handleApplyResultFilters = () => {
|
||||
if (resultMode === "history") {
|
||||
const dateError = validateWaterRiskDateRange(
|
||||
draftResultFilters.startDate, draftResultFilters.endDate, today,
|
||||
const nowHourValue = formatWaterRiskHourValue(new Date());
|
||||
// 분석시각 기간은 선택 조건이다. 입력한 경우에만 시작·종료 짝, 순서, 기간, 현재 정시 이하를 확인한다.
|
||||
const analysisPeriodError = validateWaterRiskAnalysisPeriod(
|
||||
draftResultFilters.analysisFrom,
|
||||
draftResultFilters.analysisTo,
|
||||
nowHourValue,
|
||||
);
|
||||
if (dateError) {
|
||||
setErrorMessage(dateError);
|
||||
if (analysisPeriodError) {
|
||||
setErrorMessage(analysisPeriodError);
|
||||
return;
|
||||
}
|
||||
// 강우시각 기간은 필수 조건이다. 시작·종료 짝, 순서, 최대 366일, 현재 정시 이하를 확인한다.
|
||||
const rainStartedPeriodError = validateWaterRiskRainStartedPeriod(
|
||||
draftResultFilters.rainStartedFrom,
|
||||
draftResultFilters.rainStartedTo,
|
||||
nowHourValue,
|
||||
);
|
||||
if (rainStartedPeriodError) {
|
||||
setErrorMessage(rainStartedPeriodError);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -462,29 +538,78 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
setSelectedResultKey("");
|
||||
};
|
||||
|
||||
/** 늦게 끝난 이전 행 요청이 최신 선택을 덮지 않도록 식별한다. */
|
||||
const mapRequestRef = useRef<string>("");
|
||||
|
||||
/** 선택 행의 실제 GeoJSON을 조회한 뒤 부모 지도 창에 전달한다. */
|
||||
const handleSelectResultRow = async (row: WaterRiskResultRow) => {
|
||||
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
mapRequestRef.current = requestId;
|
||||
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT, {
|
||||
phase: "loading", requestId,
|
||||
});
|
||||
setSelectedResultKey(buildResultRowKey(row));
|
||||
setIsMapLoading(true);
|
||||
setErrorMessage("");
|
||||
try {
|
||||
const mapView = await fetchWaterRiskMapView(row);
|
||||
if (mapRequestRef.current !== requestId) return;
|
||||
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT, {
|
||||
requestId,
|
||||
areaId: row.areaId,
|
||||
resultRow: row,
|
||||
mapView,
|
||||
});
|
||||
} catch (error) {
|
||||
if (mapRequestRef.current !== requestId) return;
|
||||
setErrorMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "통수위험 지도 결과를 조회하지 못했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setIsMapLoading(false);
|
||||
if (mapRequestRef.current === requestId) setIsMapLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 침수예측현황과 같은 형태(라벨 + 시작 `~` 종료 `datetime-local`)로 과거이력 시각 범위를 그린다.
|
||||
* `step=3600`이라 값의 분이 00이면 브라우저가 분 칸을 고정하고, 분이 들어온 경우는 저장 전에 00으로 내린다.
|
||||
* 현재 정시 이후는 고를 수 없게 `max`만 둔다(`min`은 step 기준점을 바꾸므로 두지 않는다).
|
||||
*/
|
||||
const renderHourRange = (label: string, { fromKey, toKey }: HourRangeKeys) => (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
{label}
|
||||
</div>
|
||||
<input
|
||||
type="datetime-local"
|
||||
step={3600}
|
||||
max={currentHourValue}
|
||||
value={draftResultFilters[fromKey]}
|
||||
onChange={(event) => {
|
||||
const nextValue = toWaterRiskHourValue(event.target.value);
|
||||
setDraftResultFilters((current) => ({ ...current, [fromKey]: nextValue }));
|
||||
}}
|
||||
aria-label={`${label} 시작`}
|
||||
className="h-[3.2rem] w-[17rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<span className="text-xl font-medium text-label">~</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
step={3600}
|
||||
max={currentHourValue}
|
||||
value={draftResultFilters[toKey]}
|
||||
onChange={(event) => {
|
||||
const nextValue = toWaterRiskHourValue(event.target.value);
|
||||
setDraftResultFilters((current) => ({ ...current, [toKey]: nextValue }));
|
||||
}}
|
||||
aria-label={`${label} 종료`}
|
||||
className="h-[3.2rem] w-[17rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<StandardPopupLayout title={windowPopupDefinitions.flowRiskPopup.title}>
|
||||
<section
|
||||
@@ -518,45 +643,9 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
</div>
|
||||
|
||||
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
|
||||
{/* 과거이력 검색조건 순서: 시도 → 시군구 → 중점관리지역 → 분석시각 → 강우시각 → 위험상태 */}
|
||||
{resultMode === "history" ? (
|
||||
<>
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
강우 발생기간
|
||||
</div>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={draftResultFilters.startDate}
|
||||
min={getWaterRiskMinStartDate(draftResultFilters.endDate)}
|
||||
max={draftResultFilters.endDate || today}
|
||||
onChange={(event) =>
|
||||
setDraftResultFilters((current) => ({
|
||||
...current,
|
||||
startDate: event.target.value,
|
||||
}))
|
||||
}
|
||||
aria-label="조회 시작일"
|
||||
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<span className="text-xl font-medium text-label">~</span>
|
||||
<input
|
||||
type="date"
|
||||
value={draftResultFilters.endDate}
|
||||
max={today}
|
||||
onChange={(event) =>
|
||||
setDraftResultFilters((current) => ({
|
||||
...current,
|
||||
startDate: adjustWaterRiskStartDate(current.startDate, event.target.value),
|
||||
endDate: event.target.value,
|
||||
}))
|
||||
}
|
||||
aria-label="조회 종료일"
|
||||
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
시도
|
||||
@@ -641,27 +730,39 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
</label>
|
||||
|
||||
{resultMode === "history" ? (
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
위험상태
|
||||
</div>
|
||||
<select
|
||||
value={draftResultFilters.riskStatus}
|
||||
onChange={(event) =>
|
||||
setDraftResultFilters((current) => ({
|
||||
...current,
|
||||
riskStatus: event.target
|
||||
.value as ResultFilters["riskStatus"],
|
||||
}))
|
||||
}
|
||||
aria-label="위험상태"
|
||||
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="ALL">전체</option>
|
||||
<option value="DANGER">위험</option>
|
||||
<option value="NORMAL">정상</option>
|
||||
</select>
|
||||
</label>
|
||||
<>
|
||||
{/* 분석시각(선택)·강우시각(필수): 시간 단위로만 고르며 시작·종료 정시를 모두 포함해 조회한다. */}
|
||||
{renderHourRange("분석시각", {
|
||||
fromKey: "analysisFrom",
|
||||
toKey: "analysisTo",
|
||||
})}
|
||||
{renderHourRange("강우시각", {
|
||||
fromKey: "rainStartedFrom",
|
||||
toKey: "rainStartedTo",
|
||||
})}
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
위험상태
|
||||
</div>
|
||||
<select
|
||||
value={draftResultFilters.riskStatus}
|
||||
onChange={(event) =>
|
||||
setDraftResultFilters((current) => ({
|
||||
...current,
|
||||
riskStatus: event.target
|
||||
.value as ResultFilters["riskStatus"],
|
||||
}))
|
||||
}
|
||||
aria-label="위험상태"
|
||||
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="ALL">전체</option>
|
||||
<option value="DANGER">위험</option>
|
||||
<option value="DANGER_UNMAPPED">위험(매핑관로없음)</option>
|
||||
<option value="NORMAL">정상</option>
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
|
||||
@@ -711,25 +812,38 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
renderCell={(row, headId) =>
|
||||
headId === "riskStatus" ? (
|
||||
<span
|
||||
className={`sn-popup-badge min-w-[5.6rem] ${getRiskBadgeClassName(row.riskStatus)}`}
|
||||
className={`sn-popup-badge min-w-[5.6rem] whitespace-nowrap ${getRiskBadgeClassName(row.riskStatus)}`}
|
||||
>
|
||||
{buildWaterRiskStatusLabel(row.riskStatus)}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="124rem"
|
||||
// 분석시각 컬럼 추가로 기존 124rem보다 넓힌다.
|
||||
minTableWidth="136rem"
|
||||
emptyMessage="조회된 결과가 없습니다."
|
||||
sort={resultSort}
|
||||
onSortChange={handleSortChange}
|
||||
actions={
|
||||
<span className="text-xl font-normal text-muted-foreground">
|
||||
SWMM 시뮬레이션 기반 통수위험 분석 결과 (GIS 매핑 관로 중
|
||||
Capacity 0.8 이상이 1개라도 있으면{" "}
|
||||
<span className="font-bold text-[#ff1c15]">위험</span>, 없으면{" "}
|
||||
<span className="font-bold text-[#1677ff]">정상</span>)
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{/*
|
||||
인수테스트 요구: 판정 기준을 목록 머리글 오른쪽에서 침수예측현황과 같은 하단 위치로 옮기고,
|
||||
분석 원천·실행 주기·침수 항목 설명을 글머리표로 함께 표시한다.
|
||||
*/}
|
||||
<PopupFooterNote
|
||||
items={[
|
||||
<>
|
||||
분석 결과 관로의 Capacity(통수능) 값이 0.8 이상이 1개라도 있으면{" "}
|
||||
<span className="font-bold text-[#ff1c15]">위험</span>, 매핑되지
|
||||
않은 관로만 0.8 이상이면{" "}
|
||||
<span className="font-bold text-[#ff1c15]">위험(매핑관로없음)</span>,
|
||||
없으면 <span className="font-bold text-[#1677ff]">정상</span>
|
||||
</>,
|
||||
"통수능이 위험에 해당하지만 지도화면에 해당 관로를 매핑할 수 없을 때, 빨간색 관로는 보이지 않을 수 있습니다.",
|
||||
"분석 실행 시기 : 통수위험은 관측강우가 0mm이상으로 강우가 발생시 1시간 주기로 분석 자동실행",
|
||||
"분석 방법 : 기상청 향후 6시간 예측강우를 20개소 중점관리지역의 관망모델(SWMM모델)에 연동하여 관로 통수량을 분석하여 통수위험여부를 분석",
|
||||
"최대 침수예상시점과 최대침수유량은 맨홀 월류량이 있을때 그 시점과 값이 나타나는것입니다.",
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
</StandardPopupLayout>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
/** 통수위험 분석 화면에서 사용하는 타입·상수와 표시값 변환을 정의한다. */
|
||||
"use client";
|
||||
|
||||
export type WaterRiskStatus = "DANGER" | "NORMAL";
|
||||
/**
|
||||
* 백엔드 위험상태 코드이다.
|
||||
* DANGER: 유효 GIS 매핑 관로 중 Capacity 기준 이상 관로가 있음.
|
||||
* DANGER_UNMAPPED: 매핑 위험 관로는 없고, 유효 GIS 매핑이 없는 관로만 기준 이상임.
|
||||
* NORMAL: 기준 이상 관로가 없음.
|
||||
*/
|
||||
export type WaterRiskStatus = "DANGER" | "DANGER_UNMAPPED" | "NORMAL";
|
||||
export type WaterRiskCalculationMethod = "SWMM";
|
||||
|
||||
/** 대표실행에서 capacity가 가장 큰 관로의 실제 SWMM 결과이다. */
|
||||
@@ -14,6 +20,9 @@ export type WaterRiskPipeSummary = {
|
||||
|
||||
/** 강우이벤트 대표실행 한 건을 팝업 목록에 표시하는 모델이다. */
|
||||
export type WaterRiskResultRow = {
|
||||
/** 대표 SWMM 실행 ID(UUID). 목록 행 식별과 지도 조회(executions/{executionId}/mapView) 키다. */
|
||||
executionId: string;
|
||||
/** 강우시작일(yyyy-MM-dd). 분석시각(analysisBaseAt)과 다른 값이다. */
|
||||
analysisDate?: string;
|
||||
analysisBaseAt?: string | null;
|
||||
rainStartedAt?: string | null;
|
||||
@@ -36,14 +45,25 @@ export type WaterRiskResultRow = {
|
||||
/** 부모 지도와 별도 팝업 사이에서 공유하는 최소 선택 상태이다. */
|
||||
export type WaterRiskSessionSnapshot = {
|
||||
selectedAreaId: string | null;
|
||||
/** 지도에 표시 중인 SWMM 실행 ID. 같은 지역 행이 여러 개여도 팝업이 정확한 행을 다시 선택하게 한다. */
|
||||
selectedExecutionId: string | null;
|
||||
};
|
||||
|
||||
export const EMPTY_WATER_RISK_SESSION_SNAPSHOT: WaterRiskSessionSnapshot = {
|
||||
selectedAreaId: null,
|
||||
selectedExecutionId: null,
|
||||
};
|
||||
|
||||
export const buildWaterRiskStatusLabel = (status: WaterRiskStatus) =>
|
||||
status === "DANGER" ? "위험" : "정상";
|
||||
/** 위험상태 코드를 목록 배지·지도 라벨·정보카드에 쓰는 화면 문구로 바꾼다. */
|
||||
export const buildWaterRiskStatusLabel = (status: WaterRiskStatus) => {
|
||||
if (status === "DANGER") return "위험";
|
||||
if (status === "DANGER_UNMAPPED") return "위험(매핑관로없음)";
|
||||
return "정상";
|
||||
};
|
||||
|
||||
/** 배지·지도 색을 위험 계열(빨강)로 표시할 상태인지 판별한다. 매핑관로없음도 위험 색을 쓴다. */
|
||||
export const isWaterRiskDanger = (status: WaterRiskStatus | null | undefined) =>
|
||||
status === "DANGER" || status === "DANGER_UNMAPPED";
|
||||
|
||||
export type WaterRiskAreaOption = {
|
||||
areaId: string;
|
||||
@@ -70,7 +90,14 @@ export type WaterRiskGeoJsonGeometry = {
|
||||
|
||||
/** 선택한 대표실행의 실제 중점관리지역 및 관로 지도 결과이다. */
|
||||
export type WaterRiskMapView = {
|
||||
/** 지도에 표시한 SWMM 실행 ID. 요청한 목록 행의 executionId와 같다. */
|
||||
executionId?: string;
|
||||
/** 강우시작일(yyyy-MM-dd) */
|
||||
analysisDate: string;
|
||||
/** 분석기준시각 */
|
||||
analysisBaseAt?: string | null;
|
||||
/** 강우시작시각 */
|
||||
rainStartedAt?: string | null;
|
||||
areaId: string;
|
||||
areaName: string;
|
||||
ctpvCd: string;
|
||||
|
||||
@@ -104,7 +104,7 @@ export const getRightPanelFeatureDisplayLabel = (
|
||||
? "관로조사"
|
||||
: jobType === "MNHL"
|
||||
? "맨홀조사"
|
||||
: "조사관리";
|
||||
: "조사사업";
|
||||
return `${surveyLabel}(${managementNo})`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"use client";
|
||||
import { fitMapSelection } from "@/features/map-search/lib/map-navigation";
|
||||
|
||||
import type * as React from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import Feature from "ol/Feature.js";
|
||||
@@ -122,7 +124,7 @@ export function useFloodHistorySelection({
|
||||
if (!gisMaps.current) return;
|
||||
|
||||
const geometry = new Polygon([detail.areaRing]);
|
||||
gisMaps.current.getView?.().fit?.(geometry.getExtent(), {
|
||||
fitMapSelection(gisMaps.current, geometry.getExtent(), {
|
||||
duration: 350,
|
||||
padding: FLDNG_HSTRY_SELECTION_PADDING,
|
||||
maxZoom: 15,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"use client";
|
||||
import { fitMapSelection, mapSearchNavigationOptions } from "@/features/map-search/lib/map-navigation";
|
||||
|
||||
import type { useFloodPredictionMap } from "@/features/fldng-predc-prst/lib/use-flood-prediction-map";
|
||||
import {
|
||||
useEffect,
|
||||
@@ -46,7 +48,7 @@ export function useMapRuntimeBridge({
|
||||
const fid = data.payload?.fid;
|
||||
if (!layerNm || !fid || !core?.showSearchResultsFromGeoJSON) return;
|
||||
|
||||
await core.showSearchResultsFromGeoJSON(layerNm, fid);
|
||||
await core.showSearchResultsFromGeoJSON(layerNm, fid, mapSearchNavigationOptions(gisMaps.current));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -64,7 +66,7 @@ export function useMapRuntimeBridge({
|
||||
return;
|
||||
}
|
||||
|
||||
await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids);
|
||||
await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids, mapSearchNavigationOptions(gisMaps.current));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -80,7 +82,7 @@ export function useMapRuntimeBridge({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
map.getView().fit(extent, {
|
||||
fitMapSelection(map, extent, {
|
||||
duration: 300,
|
||||
padding: [50, 50, 50, 50],
|
||||
maxZoom: 16,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"use client";
|
||||
import { beginMapNavigation } from "@/features/map-search/lib/map-navigation";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Feature from "ol/Feature.js";
|
||||
import GeoJSON from "ol/format/GeoJSON.js";
|
||||
@@ -35,6 +37,9 @@ export function useWaterRiskLayer({
|
||||
mapReady,
|
||||
onSelectResult,
|
||||
}: Options) {
|
||||
/** 팝업의 조회 시작부터 지도 이동까지 같은 요청 세대를 사용한다. */
|
||||
const selectionNavigationRef = useRef<ReturnType<typeof beginMapNavigation> | null>(null);
|
||||
const selectionRequestRef = useRef<string | undefined>(undefined);
|
||||
const waterRiskPopupWindowRef = useRef<Window | null>(null);
|
||||
|
||||
const waterRiskPopupCloseWatcherRef = useRef<number | null>(null);
|
||||
@@ -96,7 +101,8 @@ export function useWaterRiskLayer({
|
||||
[selectedWaterRiskApiResult],
|
||||
);
|
||||
|
||||
// 지도카드는 실제 GIS geometry까지 결합된 mapView 위험상태를 목록 상태보다 우선한다.
|
||||
// 지도카드는 mapView 위험상태를 우선하고, mapView 응답 전에는 목록 상태를 쓴다.
|
||||
// 2026-10-20부터 mapView 위험상태는 목록과 같은 대표결과 판정값이므로 두 값이 다르지 않다.
|
||||
const selectedWaterRiskDisplayStatus = useMemo<
|
||||
WaterRiskResultRow["riskStatus"]
|
||||
>(
|
||||
@@ -122,7 +128,7 @@ export function useWaterRiskLayer({
|
||||
const [pixelX = 0, pixelY = 0] =
|
||||
map.getPixelFromCoordinate?.(waterRiskInfoAnchor) ?? [];
|
||||
const cardWidth = 244;
|
||||
const cardHeight = 180;
|
||||
const cardHeight = 190;
|
||||
const left = pixelX + 22;
|
||||
const top = pixelY - cardHeight / 2;
|
||||
|
||||
@@ -143,6 +149,12 @@ export function useWaterRiskLayer({
|
||||
setWaterRiskInfoCardStyle({ left, top });
|
||||
}, [waterRiskInfoAnchor]);
|
||||
|
||||
/** 정보 카드만 닫고 지도 결과·범례는 유지하며, 다음 결과 선택 시 다시 표시한다. */
|
||||
const dismissWaterRiskInfoCard = useCallback(() => {
|
||||
setWaterRiskInfoAnchor(null);
|
||||
setWaterRiskInfoCardStyle(null);
|
||||
}, []);
|
||||
|
||||
const clearWaterRiskPopupCloseWatcher = useCallback(() => {
|
||||
if (waterRiskPopupCloseWatcherRef.current !== null) {
|
||||
window.clearInterval(waterRiskPopupCloseWatcherRef.current);
|
||||
@@ -247,7 +259,7 @@ export function useWaterRiskLayer({
|
||||
const renderWaterRiskSelection = useCallback(
|
||||
(areaId: string | null, mapView: WaterRiskMapView | null) => {
|
||||
const map = gisMaps.current;
|
||||
if (!map) return;
|
||||
if (!map || !selectionNavigationRef.current?.isCurrent()) return;
|
||||
|
||||
const areaSource = ensureWaterRiskAreaLayer();
|
||||
if (!areaSource) return;
|
||||
@@ -318,6 +330,7 @@ export function useWaterRiskLayer({
|
||||
const extent = focusGeometry.getExtent();
|
||||
const [, minY = 0, maxX = 0, maxY = 0] = extent;
|
||||
setWaterRiskInfoAnchor([maxX, (minY + maxY) / 2]);
|
||||
if (!selectionNavigationRef.current?.commit(extent)) return;
|
||||
map.getView().fit(extent, {
|
||||
duration: 300,
|
||||
padding: [120, 120, 120, 120],
|
||||
@@ -389,6 +402,17 @@ export function useWaterRiskLayer({
|
||||
}
|
||||
|
||||
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT) {
|
||||
if (data.payload?.phase === "loading") {
|
||||
selectionRequestRef.current = data.payload.requestId;
|
||||
selectionNavigationRef.current = beginMapNavigation(gisMaps.current);
|
||||
return;
|
||||
}
|
||||
if (data.payload?.requestId) {
|
||||
if (data.payload.requestId !== selectionRequestRef.current ||
|
||||
!selectionNavigationRef.current?.isCurrent()) return;
|
||||
} else {
|
||||
selectionNavigationRef.current = beginMapNavigation(gisMaps.current);
|
||||
}
|
||||
const areaId = String(data.payload?.areaId ?? "").trim();
|
||||
if (!areaId) return;
|
||||
const resultRow = data.payload?.resultRow as
|
||||
@@ -396,11 +420,20 @@ export function useWaterRiskLayer({
|
||||
| undefined;
|
||||
const mapView = data.payload?.mapView as WaterRiskMapView | undefined;
|
||||
if (!resultRow || !mapView || mapView.areaId !== areaId) return;
|
||||
// 지도 응답이 누른 행과 다른 실행이면 표시하지 않는다(실행 ID 기준 조회의 안전장치).
|
||||
if (
|
||||
mapView.executionId &&
|
||||
resultRow.executionId &&
|
||||
mapView.executionId !== resultRow.executionId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setSelectedWaterRiskApiResult(resultRow);
|
||||
setSelectedWaterRiskMapView(mapView);
|
||||
setWaterRiskSession((current) => ({
|
||||
...current,
|
||||
selectedAreaId: areaId,
|
||||
selectedExecutionId: resultRow.executionId || null,
|
||||
}));
|
||||
onSelectResult();
|
||||
return;
|
||||
@@ -453,6 +486,7 @@ export function useWaterRiskLayer({
|
||||
waterRiskInfoCardStyle,
|
||||
selectedWaterRiskMapView,
|
||||
selectedWaterRiskDisplayStatus,
|
||||
dismissWaterRiskInfoCard,
|
||||
watchPopup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,19 +4,21 @@ import CircleStyle from "ol/style/Circle.js";
|
||||
import Stroke from "ol/style/Stroke.js";
|
||||
import Style from "ol/style/Style.js";
|
||||
import Text from "ol/style/Text.js";
|
||||
import { buildWaterRiskStatusLabel } from "@/app/(map-layout)/map/components/water-risk/water-risk-types";
|
||||
import {
|
||||
buildWaterRiskStatusLabel,
|
||||
isWaterRiskDanger,
|
||||
type WaterRiskStatus,
|
||||
} from "@/app/(map-layout)/map/components/water-risk/water-risk-types";
|
||||
|
||||
export const buildWaterRiskStatusFillColor = (
|
||||
riskStatus: "DANGER" | "NORMAL",
|
||||
) =>
|
||||
riskStatus === "DANGER"
|
||||
/** 위험·위험(매핑관로없음)은 빨강, 정상은 파랑 반투명으로 중점관리지역 면을 채운다(화면설계서 21쪽). */
|
||||
export const buildWaterRiskStatusFillColor = (riskStatus: WaterRiskStatus) =>
|
||||
isWaterRiskDanger(riskStatus)
|
||||
? "rgba(198, 40, 40, 0.24)"
|
||||
: "rgba(21, 101, 192, 0.2)";
|
||||
|
||||
export const buildWaterRiskStatusStrokeColor = (
|
||||
riskStatus: "DANGER" | "NORMAL",
|
||||
) =>
|
||||
riskStatus === "DANGER"
|
||||
/** 중점관리지역 경계선·라벨 배경색. 위험 계열 판정은 면 색과 같다. */
|
||||
export const buildWaterRiskStatusStrokeColor = (riskStatus: WaterRiskStatus) =>
|
||||
isWaterRiskDanger(riskStatus)
|
||||
? "rgba(198, 40, 40, 0.92)"
|
||||
: "rgba(21, 101, 192, 0.92)";
|
||||
|
||||
@@ -31,9 +33,9 @@ export const buildWaterRiskCapacityColor = (capacityRatio: number) => {
|
||||
|
||||
export const createWaterRiskAreaStyle = (feature: any) => {
|
||||
const areaName = String(feature.get("areaName") ?? "");
|
||||
const riskStatus = String(feature.get("riskStatus") ?? "NORMAL") as
|
||||
| "DANGER"
|
||||
| "NORMAL";
|
||||
const riskStatus = String(
|
||||
feature.get("riskStatus") ?? "NORMAL",
|
||||
) as WaterRiskStatus;
|
||||
return new Style({
|
||||
fill: new Fill({ color: buildWaterRiskStatusFillColor(riskStatus) }),
|
||||
stroke: new Stroke({
|
||||
|
||||
@@ -25,6 +25,8 @@ export const EXMN_PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH";
|
||||
// 지반침하 지도 포인트와 같은 파란 계열의 기존 레이어 트리 심볼 자산을 재사용한다.
|
||||
export const SINKHOLE_TREE_ICON_NAME = "SN_SINKHOLE_SPOT_BLUE.png";
|
||||
|
||||
// EPSG:5179의 남한·도서 주변 여유 범위 [minX, minY, maxX, maxY].
|
||||
// 화면 비율 때문에 전체 범위가 잘리지 않도록 Gis의 viewShowFullExtent와 함께 사용한다.
|
||||
export const SOUTH_KOREA_VIEW_EXTENT: [number, number, number, number] = [
|
||||
600000, 1300000, 1500000, 2500000,
|
||||
];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { mapSearchNavigationOptions } from "@/features/map-search/lib/map-navigation";
|
||||
/**
|
||||
* 📄 useBasicTab Hook
|
||||
* 검색 모달 기본정보 탭 훅
|
||||
@@ -189,7 +190,7 @@ export default function useBasicTab() {
|
||||
const layerNm = row.lyrTypeNm;
|
||||
const fid = row.fid;
|
||||
const core = GisApp.Module.layer as any;
|
||||
await core.showSearchResultsFromGeoJSON(layerNm, fid);
|
||||
await core.showSearchResultsFromGeoJSON(layerNm, fid, mapSearchNavigationOptions((GisApp.Module.core as any).map));
|
||||
setIsLoading(false);
|
||||
setIsSearchModalOpen(false); // SearchModal 닫기
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { mapSearchNavigationOptions } from "@/features/map-search/lib/map-navigation";
|
||||
/**
|
||||
* 📄 useOperationTab Hook
|
||||
* 검색 모달 유지관리 정보 탭 훅
|
||||
@@ -284,7 +285,7 @@ export default function useOperationTab() {
|
||||
const layerNm = row.lyrTypeNm;
|
||||
const fid = row.fid;
|
||||
const core = GisApp.Module.layer as any;
|
||||
await core.showSearchResultsFromGeoJSON(layerNm, fid);
|
||||
await core.showSearchResultsFromGeoJSON(layerNm, fid, mapSearchNavigationOptions((GisApp.Module.core as any).map));
|
||||
setIsLoading(false);
|
||||
setIsSearchModalOpen(false); // SearchModal 닫기
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { mapSearchNavigationOptions } from "@/features/map-search/lib/map-navigation";
|
||||
/**
|
||||
* 📄 useSearchModal Hook
|
||||
* 검색 모달 훅
|
||||
@@ -198,7 +199,7 @@ export default function useSearchModal() {
|
||||
const layerNm = row.lyrTypeNm;
|
||||
const fids = tableData.map((item) => item.fid);
|
||||
const core = GisApp.Module.layer as any;
|
||||
await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids);
|
||||
await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids, mapSearchNavigationOptions((GisApp.Module.core as any).map));
|
||||
setIsLoading(false);
|
||||
setIsSearchModalOpen(false);
|
||||
}
|
||||
@@ -222,7 +223,7 @@ export default function useSearchModal() {
|
||||
const layerNm = row.lyrTypeNm;
|
||||
const fids = detailTableData.map((item) => item.fid);
|
||||
const core = GisApp.Module.layer as any;
|
||||
await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids);
|
||||
await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids, mapSearchNavigationOptions((GisApp.Module.core as any).map));
|
||||
setIsLoading(false);
|
||||
setIsSearchModalOpen(false);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
/** 지도 route에만 적용하는 기존 전역 컨트롤 CSS. 선택자와 스타일 값은 유지한다. */
|
||||
/** 지도 route의 기존 배치를 유지하고 SN 컨트롤의 쌓임 순서를 맞춘다. */
|
||||
export default function MapControlStyles() {
|
||||
return (
|
||||
<style jsx global>{`
|
||||
@@ -8,6 +8,7 @@ export default function MapControlStyles() {
|
||||
top: 16px;
|
||||
right: 120px;
|
||||
bottom: auto;
|
||||
z-index: 45;
|
||||
}
|
||||
|
||||
.sn-map-control-layout .map-type-tabs,
|
||||
@@ -28,6 +29,7 @@ export default function MapControlStyles() {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
bottom: auto;
|
||||
z-index: 45;
|
||||
}
|
||||
|
||||
.sn-map-control-layout .tool-bar-vector-icons ul li div {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
|
||||
"use client";
|
||||
import { fitMapSelection, mapSearchNavigationOptions, beginMapNavigation } from "@/features/map-search/lib/map-navigation";
|
||||
import { useFloodPredictionMap } from "@/features/fldng-predc-prst/lib/use-flood-prediction-map";
|
||||
import {
|
||||
FLOOD_DNGR_MAP_LAYER_NAME,
|
||||
@@ -62,7 +63,7 @@ import {
|
||||
normalizeRainObservationLayerInteraction,
|
||||
getFlowDirectionMinZoom,
|
||||
} from "@/features/map-layers";
|
||||
import { ListTree } from "lucide-react";
|
||||
import { ListTree, X } from "lucide-react";
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
@@ -181,7 +182,12 @@ import {
|
||||
WATER_DEPTH_LAYER_TYPES,
|
||||
extractWaterDepthLegendItems,
|
||||
} from "../water-depth/water-depth";
|
||||
import { buildWaterRiskStatusLabel } from "../components/water-risk/water-risk-types";
|
||||
import {
|
||||
buildWaterRiskStatusLabel,
|
||||
isWaterRiskDanger,
|
||||
} from "../components/water-risk/water-risk-types";
|
||||
|
||||
import GisToolButtons from "../components/gis-tools/gis-tool-buttons";
|
||||
|
||||
const AnalysisPopupMenu = dynamic(
|
||||
() => import("../components/analysis-menu/analysis-popup-menu"),
|
||||
@@ -295,6 +301,7 @@ const MapPageClient = () => {
|
||||
waterRiskInfoCardStyle,
|
||||
selectedWaterRiskMapView,
|
||||
selectedWaterRiskDisplayStatus,
|
||||
dismissWaterRiskInfoCard,
|
||||
watchPopup: watchWaterRiskPopup,
|
||||
} = useWaterRiskLayer({
|
||||
gisMaps,
|
||||
@@ -627,7 +634,7 @@ const MapPageClient = () => {
|
||||
const currentZoom = Number(view?.getZoom?.());
|
||||
const maxZoom =
|
||||
Number.isFinite(currentZoom) && currentZoom > 18 ? currentZoom : 18;
|
||||
view?.fit?.(extent, {
|
||||
fitMapSelection(map, extent, {
|
||||
duration: 350,
|
||||
padding: [80, 420, 80, 80],
|
||||
maxZoom,
|
||||
@@ -1292,6 +1299,7 @@ const MapPageClient = () => {
|
||||
void core?.showSearchResultsFromGeoJSON?.(
|
||||
layerTypeName,
|
||||
`${layerTypeName}.${featureIdentifier}`,
|
||||
mapSearchNavigationOptions(gisMaps.current),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1384,7 +1392,9 @@ const MapPageClient = () => {
|
||||
focusMap = true,
|
||||
fallbackPoint?: { x: number; y: number } | null,
|
||||
fallbackDetail?: Partial<SinkholeDetail> | null,
|
||||
resetSearch = false,
|
||||
) => {
|
||||
const navigation = resetSearch ? beginMapNavigation(gisMaps.current) : null;
|
||||
if (!acdntNo) return;
|
||||
|
||||
try {
|
||||
@@ -1540,6 +1550,7 @@ const MapPageClient = () => {
|
||||
}
|
||||
: detail;
|
||||
|
||||
if (navigation && !navigation.isCurrent()) return;
|
||||
setSelectedSinkholeDetail(normalizedDetail);
|
||||
setSelectedSinkholeNearbyPipes(nearbyPipes);
|
||||
renderSelectedSinkhole(normalizedDetail, sinkholeRadiusMeters);
|
||||
@@ -1555,6 +1566,9 @@ const MapPageClient = () => {
|
||||
normalizedDetail.x != null &&
|
||||
normalizedDetail.y != null
|
||||
) {
|
||||
const x = Number(normalizedDetail.x);
|
||||
const y = Number(normalizedDetail.y);
|
||||
if (navigation && !navigation.commit([x, y, x, y])) return;
|
||||
focusSinkholeOnMap(normalizedDetail, sinkholeRadiusMeters);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1615,6 +1629,7 @@ const MapPageClient = () => {
|
||||
|
||||
const focusSinkholeNearbyPipe = useCallback(
|
||||
async (pipe: SinkholeNearbyPipe) => {
|
||||
const navigation = beginMapNavigation(gisMaps.current);
|
||||
const fid = String(pipe?.fid ?? "").trim();
|
||||
if (!fid) {
|
||||
alert("표시할 관로 식별값이 없습니다.");
|
||||
@@ -1654,7 +1669,7 @@ const MapPageClient = () => {
|
||||
});
|
||||
|
||||
const firstFeature = features[0];
|
||||
if (firstFeature) {
|
||||
if (firstFeature && navigation.commit(firstFeature.getGeometry()?.getExtent())) {
|
||||
syncRightPanelFeatureSelectionOnMap(firstFeature);
|
||||
setFeatureData(firstFeature);
|
||||
setIsOpen(true);
|
||||
@@ -2005,7 +2020,7 @@ const MapPageClient = () => {
|
||||
try {
|
||||
shouldFocusAgedPipeOnNextSelectRef.current = true;
|
||||
const core = GisApp.Module.layer as any;
|
||||
await core.showSearchResultsFromGeoJSON(AGED_PIPE_LAYER_TYPE, row.fid);
|
||||
await core.showSearchResultsFromGeoJSON(AGED_PIPE_LAYER_TYPE, row.fid, mapSearchNavigationOptions(gisMaps.current));
|
||||
} catch (error) {
|
||||
shouldFocusAgedPipeOnNextSelectRef.current = false;
|
||||
console.error("노후관로 위치 이동 중 오류:", error);
|
||||
@@ -2025,7 +2040,7 @@ const MapPageClient = () => {
|
||||
try {
|
||||
shouldFocusAgedPipeOnNextSelectRef.current = true;
|
||||
const core = GisApp.Module.layer as any;
|
||||
await core.showSearchResultsFromGeoJSON(AGED_PIPE_LAYER_TYPE, row.fid);
|
||||
await core.showSearchResultsFromGeoJSON(AGED_PIPE_LAYER_TYPE, row.fid, mapSearchNavigationOptions(gisMaps.current));
|
||||
} catch (error) {
|
||||
shouldFocusAgedPipeOnNextSelectRef.current = false;
|
||||
console.error("관로 위치 이동 중 오류:", error);
|
||||
@@ -2149,6 +2164,7 @@ const MapPageClient = () => {
|
||||
true,
|
||||
fallbackPoint,
|
||||
row,
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -3116,26 +3132,38 @@ const MapPageClient = () => {
|
||||
// 위험/정상 배지는 통수위험 상태를 즉시 구분하는 업무 의미색이므로 고정색을 유지한다.
|
||||
<div
|
||||
style={waterRiskInfoCardStyle}
|
||||
className="absolute z-[61] w-[244px] overflow-hidden rounded-[12px] border border-border bg-background shadow-blur"
|
||||
className="absolute z-[44] w-[244px] overflow-hidden rounded-[12px] border border-border bg-background shadow-blur"
|
||||
>
|
||||
<div className="flex items-center justify-between bg-secondary px-[9px] py-[7px]">
|
||||
<div>
|
||||
<div className="bg-secondary px-[9px] py-[7px]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[10px] font-semibold text-muted-foreground">
|
||||
중점관리지역
|
||||
</div>
|
||||
<div className="mt-[2px] text-[13px] font-bold text-foreground">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-[20px] w-[20px] items-center justify-center rounded-[3px] text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
onClick={dismissWaterRiskInfoCard}
|
||||
aria-label="통수위험 정보 카드 닫기"
|
||||
title="닫기"
|
||||
>
|
||||
<X className="h-[14px] w-[14px]" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-[2px] flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 text-[13px] font-bold text-foreground">
|
||||
{selectedWaterRiskResultRow.areaName}
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex min-w-[48px] shrink-0 items-center justify-center rounded-full px-[7px] py-[5px] text-[10px] font-bold ${
|
||||
// 위험(매핑관로없음)도 위험과 같은 빨간 배지로 표시하고 문구로 구분한다.
|
||||
isWaterRiskDanger(selectedWaterRiskDisplayStatus)
|
||||
? "bg-[#ff1c15] text-white"
|
||||
: "bg-[#e8f6f4] text-[#0f766e]"
|
||||
}`}
|
||||
>
|
||||
{buildWaterRiskStatusLabel(selectedWaterRiskDisplayStatus)}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex min-w-[48px] items-center justify-center rounded-full px-[7px] py-[5px] text-[10px] font-bold ${
|
||||
selectedWaterRiskDisplayStatus === "DANGER"
|
||||
? "bg-[#ff1c15] text-white"
|
||||
: "bg-[#e8f6f4] text-[#0f766e]"
|
||||
}`}
|
||||
>
|
||||
{buildWaterRiskStatusLabel(selectedWaterRiskDisplayStatus)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-background px-[9px] py-[6px] text-[9px] text-muted-foreground">
|
||||
<div>GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면</div>
|
||||
@@ -3187,7 +3215,7 @@ const MapPageClient = () => {
|
||||
) : null}
|
||||
{selectedWaterRiskMapView ? (
|
||||
// Capacity와 Flooding 색상은 SWMM 결과의 업무 의미색이므로 테마와 무관하게 유지한다.
|
||||
<div className="absolute bottom-4 left-[270px] z-[61] rounded-md border border-border bg-background px-3 py-2 text-[10px] text-foreground shadow-md">
|
||||
<div className="absolute bottom-4 left-[270px] z-[44] rounded-md border border-border bg-background px-3 py-2 text-[10px] text-foreground shadow-md">
|
||||
<div className="mb-1 font-bold">Capacity</div>
|
||||
<div className="space-y-1 font-semibold">
|
||||
{[
|
||||
@@ -3226,10 +3254,13 @@ const MapPageClient = () => {
|
||||
initialActiveKey={initialAnalysisPopupKey}
|
||||
onOpenPopup={openAnalysisPopup}
|
||||
onOpenInfoSearch={() => setIsSearchModalOpen(true)}
|
||||
onToggleGisEdit={handleToggleGisEdit}
|
||||
isGisEditActive={gisEdit.isEditMode}
|
||||
isInfoSearchActive={isSearchModalOpen}
|
||||
/>
|
||||
<GisToolButtons
|
||||
isGisEditActive={gisEdit.isEditMode}
|
||||
onToggleGisEdit={handleToggleGisEdit}
|
||||
onOpenGisManagement={() => openAnalysisPopup("gisManagementPopup")}
|
||||
/>
|
||||
{isGisEditSessionInlineVisible ? (
|
||||
<div
|
||||
data-testid="gis-edit-session-inline"
|
||||
@@ -3325,6 +3356,7 @@ const MapPageClient = () => {
|
||||
defaultZoom={7.5}
|
||||
defaultLonLat={[129, 36.3]}
|
||||
viewExtent={SOUTH_KOREA_VIEW_EXTENT}
|
||||
viewShowFullExtent={true}
|
||||
/>
|
||||
{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-background px-3 py-2 shadow-[0_8px_22px_rgba(15,23,42,0.22)] backdrop-blur">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ComponentProps } from "react";
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore, type ComponentProps } from "react";
|
||||
import { getMapSearchRevision, invalidateMapNavigation, subscribeMapSearch } from "@/features/map-search/lib/map-navigation";
|
||||
import { ProcSearchBar } from "@common/business_components/ui/map/searchBar";
|
||||
|
||||
/** 지도 검색바의 Radix Select는 브라우저 마운트 이후 생성한다.
|
||||
@@ -8,6 +9,12 @@ import { ProcSearchBar } from "@common/business_components/ui/map/searchBar";
|
||||
* hydration 불일치를 피한다. 공통 Select 및 다른 앱의 렌더링 방식은 변경하지 않는다.
|
||||
*/
|
||||
export default function MapSearchBar(props: ComponentProps<typeof ProcSearchBar>) {
|
||||
/** 부모의 지도 준비 렌더에 맞춰 해당 인스턴스만 구독한다. */
|
||||
const map = props.gisMaps.current;
|
||||
const subscribe = useCallback((listener: () => void) => subscribeMapSearch(map, listener), [map]);
|
||||
const getSnapshot = useCallback(() => getMapSearchRevision(map), [map]);
|
||||
const resetRevision = useSyncExternalStore(subscribe, getSnapshot, () => 0);
|
||||
useEffect(() => () => invalidateMapNavigation(map), [map]);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -25,5 +32,5 @@ export default function MapSearchBar(props: ComponentProps<typeof ProcSearchBar>
|
||||
);
|
||||
}
|
||||
|
||||
return <ProcSearchBar {...props} />;
|
||||
return <ProcSearchBar {...props} resetRevision={resetRevision} onSearchInteraction={() => invalidateMapNavigation(map)} />;
|
||||
}
|
||||
|
||||
@@ -198,11 +198,8 @@ export function VerticalTable({
|
||||
head.type === "select" ? (
|
||||
<select
|
||||
id={head.id}
|
||||
value={
|
||||
formData[head.id] !== undefined
|
||||
? String(formData[head.id])
|
||||
: ""
|
||||
}
|
||||
// DB 값이 null 이면 "null" 문자열이 선택값으로 들어가 목록에 없는 값이 되므로 빈 값으로 맞춘다.
|
||||
value={String(formData[head.id] ?? "")}
|
||||
onChange={(e) =>
|
||||
handleInputChange(head.id, e.target.value)
|
||||
}
|
||||
@@ -323,11 +320,8 @@ export function VerticalTable({
|
||||
head.type === "select" ? (
|
||||
<select
|
||||
id={head.id}
|
||||
value={
|
||||
formData[head.id] !== undefined
|
||||
? String(formData[head.id])
|
||||
: ""
|
||||
}
|
||||
// DB 값이 null 이면 "null" 문자열이 선택값으로 들어가 목록에 없는 값이 되므로 빈 값으로 맞춘다.
|
||||
value={String(formData[head.id] ?? "")}
|
||||
onChange={(e) =>
|
||||
handleInputChange(head.id, e.target.value)
|
||||
}
|
||||
@@ -357,11 +351,8 @@ export function VerticalTable({
|
||||
? "number"
|
||||
: "text"
|
||||
}
|
||||
value={
|
||||
formData[head.id] !== undefined
|
||||
? String(formData[head.id])
|
||||
: ""
|
||||
}
|
||||
// DB 값이 null 이면 "null" 글자가 입력칸에 보이므로 빈칸으로 맞춘다.
|
||||
value={String(formData[head.id] ?? "")}
|
||||
onChange={(e) => {
|
||||
if (head.isNumber) {
|
||||
const newValue = e.target.value;
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
- 확인됨: 공통 컴포넌트 수정은 `sn`에 실제로 필요한 범위로 제한한다. 근거: `apps/sn/AGENTS.md`
|
||||
- 추정: 여러 화면에서 반복되는 `sn` 전용 로직은 먼저 `apps/sn` 내부 공통화로 해결하고, 여러 업무 화면에서 공유되는 비즈니스 UI일 때만 `business_components`를 검토하는 것이 안전하다. 근거: `apps/sn/AGENTS.md`
|
||||
|
||||
## 공통 유틸 재사용 기준
|
||||
|
||||
- `sn` 프론트엔드의 엑셀 다운로드 기능을 신규 개발하거나 수정할 때는 `apps/sn/src/shared/util/download-excel-workbook.ts`의 재사용을 먼저 검토한다. 근거: 사용자 지시(2026-10-22).
|
||||
- 업무별 데이터 조회·가공·한글 항목 매핑은 각 기능에서 담당하고, 워크북 생성·시트 구성·파일 저장은 공통 유틸을 우선 사용한다. 시트 생성은 `buildExcelWorkbook`, 브라우저 다운로드는 `downloadExcelWorkbook`을 검토한다.
|
||||
- 기존 다운로드 기능을 관련 없는 작업에서 일괄 전환하지 않는다. 변경 대상 기능의 요구사항과 기존 동작을 확인한 뒤 적용한다.
|
||||
- 공통 유틸로 요구사항을 충족하기 어려우면 그 사유와 대안을 작업계획에 기록한다. 특수 서식이나 기존 템플릿 등 요구사항에 맞춰 적용 여부를 판단한다.
|
||||
- 다운로드 대상 데이터의 범위·전체 건수·API 제한과 대량 자료의 서버 생성 기준은 별도로 확인한다. 공통 유틸 사용만으로 데이터 수집 범위가 보장되지는 않는다.
|
||||
|
||||
## 작업카드에 남길 항목
|
||||
- 확인 필요: 신규 또는 수정 컴포넌트 경로
|
||||
- 확인 필요: 호출하는 화면 경로
|
||||
@@ -19,4 +27,4 @@
|
||||
- 확인 필요: 브라우저 확인 화면
|
||||
|
||||
## 외부 범위 이슈
|
||||
- 확인됨: 권한 밖 공통 패키지 수정이 필요해 보이면 직접 고치지 않고 문제 원인, 수정 필요 경로, `sn` 영향 범위, 타 앱 영향 가능성을 문서화한다. 근거: `apps/sn/AGENTS.md`
|
||||
- 확인됨: 권한 밖 공통 패키지 수정이 필요해 보이면 직접 고치지 않고 문제 원인, 수정 필요 경로, `sn` 영향 범위, 타 앱 영향 가능성을 문서화한다. 근거: `apps/sn/AGENTS.md`
|
||||
|
||||
@@ -275,3 +275,8 @@
|
||||
|
||||
- 레이어 API 단위 테스트는 실제 서비스가 호출하는 Repository 메서드와 동일한 메서드를 모킹해야 한다.
|
||||
- 전체 빌드가 장시간 걸리는 환경에서는 변경 파일 정적 검사 후 백엔드 대상 테스트와 로그인 브라우저 회귀를 분리한다.
|
||||
|
||||
|
||||
## 2026-10-19 맨홀 PDF GIS 번호 검증
|
||||
- 188001 테스트 PDF 실제 판독·쪽 추출, PPL/MNHL 첨부 분리, 미첨부·없는 번호·중복 시설·다중/비연속 번호를 검증한다.
|
||||
- 실제 서버에서는 맨홀 첨부 저장 → 상류맨홀 지도 선택 → 맨홀 보고서 1쪽 표시와 관로 회귀를 확인한다. 자동검증/실브라우저 여부는 통합검증결과에 구분한다.
|
||||
|
||||
@@ -669,3 +669,53 @@
|
||||
- 저장 API는 실제 시트명 등록을 계속 요구하며 미준비면 기존 결과를 변경하기 전에 차단한다. 프론트는 첨부 업로드 전에 최신 상태를 확인하며 서버에서 같은 오류가 반환돼도 alert로 안내한다.
|
||||
- 검증: ExmnTemplateGateTest 7건 통과(Excel/PPL PDF/MNHL 검토 독립, 미준비 commit 무쓰기 차단, 기존 시트명 보존, 준비 완료 commit, 해시 불일치 차단), FE 타입 검사 통과. 테스트는 mock 기반이며 실제 파일 저장·DB 변경 및 브라우저 끝단 저장은 수행하지 않았다.
|
||||
- 임시 테스트 소스·산출물은 요약 후 정리한다. 이전 ‘검토 전 양식 확인’ 기록은 이 지시로 대체한다.
|
||||
|
||||
## 2026-10-02 quick_vibe 등록 입력 필수 표시 보완
|
||||
|
||||
- 사업대상 처리분구 제목에 기존 폼과 같은 붉은 별표와 접근성용 필수 문구를 추가했다. 기존 1개 이상 선택 검증은 유지한다.
|
||||
- 정밀조사연장은 숫자·소수점 하나·입력 삭제만 허용한다. 문자/한글/부호/지수/콤마/복수 소수점 입력 및 붙여넣기는 거부한다. km 소수 입력과 기존 저장 검증은 유지한다.
|
||||
- 원본: 요구사항 기능 시트76행 SFR-010-004, PPT 실제39슬라이드 SN_MN040105 사업정보 등록/처리분구 선택/정밀조사연장(km).
|
||||
- 검증: SN 전체 타입검사 exit0, 숫자/소수/삭제 및 거부 문자열 Node 검증 통과, git diff --check 통과. 실브라우저 입력 검증은 미수행.
|
||||
- 수정 소스: `frontend/apps/sn/src/features/exmn/ui/exmn-business-register-popup-page.tsx`. API/백엔드 코드·저장 계약 변경 없음. 기존 GIS 작업은 변경하지 않았다.
|
||||
- 롤백: 해당 소스의 이번 필수 표시/입력 제한만 복원한다.
|
||||
|
||||
|
||||
## 2026-10-19 맨홀 조사보고서 GIS 번호 연결 (maintenance)
|
||||
|
||||
- 목적: 별도 맨홀 식별체계 확정 전 맨홀 PDF의 `맨홀번호`에 조사시설 GIS 번호(테스트 188001)를 넣고 지도에서 해당 보고서를 열람한다. 지도 도형은 기존 상류맨홀 매칭을 유지한다.
|
||||
- 근거: 요구사항정의서 `3. (기능)요구사항정의 및 추적표` SFR-010-006/007, 화면설계서 실제 42쪽의 식별기준 미확정 메모 및 43쪽 `SN_MN040101` 조사결과 등록. 이번 사용자 지시로 임시 GIS 번호 연결 기준을 확정했다.
|
||||
- BE: `ExmnManholeReportService.java` 신규, `ExmnPipePdfValidator.java` 표제별 불변 판독기, `ExmnBusinessController.java`의 `GET /api/v1/exmnBusinesses/{exmnBizId}/manholeReports/{pipeNumber}` 및 `/availability`, `ExmnFacilityRepository.java`의 조사유형별 첨부 존재 여부 분기.
|
||||
- 맨홀 첨부 `mnhlExmnAtchFileId`와 활성 MNHL의 `gisManagementNo`만 사용한다. 선택 번호가 없거나 활성 시설이 중복되면 임의 보고서를 반환하지 않는다. 매 요청 현재 PDF를 판독하므로 이전 관로 쪽 캐시를 사용하지 않는다. DB 스키마 및 업무 데이터 변경 없음.
|
||||
- FE: `exmn-business-api.ts`, `exmn-survey-result-section.tsx`, `exmn-pipe-report-popup-content.tsx`, `exmn-result-register-popup-page.tsx`. MNHL에서도 보고서 버튼을 표시하고 jobType에 따라 API·제목·번호 안내를 선택한다. PPL 기본 호출은 유지한다.
|
||||
- 업로드 검토의 PDF 내부 번호 대조는 이번 범위에 추가하지 않았다. 번호 입력 기준과 열람 시 대조한다는 안내를 결과등록 화면에 표시한다.
|
||||
- 산출물: 작업루트 `output/pdf/맨홀_현황조사_보고서_188001.pdf`. 사용자 원본 1쪽의 맨홀번호만 188001로 변경했으며 보고서번호·노선명 등 예시값과 사진은 원본을 유지했다. 실제 납품 조사자료가 아닌 기능 테스트용이다.
|
||||
- 검증: BE compileJava --offline 성공, SN 전체 typecheck 성공, 양 저장소 diff --check 성공. 임시 JUnit 5건 성공(실제 PDF 번호/쪽 추출, PPL 표제 분리, 다중/비연속 번호 거부·연속 쪽, MNHL 첨부 선택/DB 캐시 미사용/미첨부, 없는 번호/중복 시설). PDF 재열람·텍스트 및 렌더링 육안 검증 완료.
|
||||
- 미검증: 로컬 3002/8883 실행 포트가 없으므로 실제 서버 업로드·저장·지도 선택·브라우저 팝업은 수행하지 않았다. 188001 MNHL 조사시설 및 상류맨홀 GIS 도형의 실제 존재를 확인한 것으로 간주하지 않는다.
|
||||
- 수동 절차: 변경 BE/FE 실행 → 188001을 가진 MNHL 조사자료의 사업에서 결과등록 → CCTV 조사결과(맨홀)에 테스트 PDF 등록·저장 → 맨홀조사 레이어의 해당 상류맨홀 선택 → 조사보고서 보기 → 제목 맨홀 조사보고서/GIS 번호188001/1쪽 확인. 관로 보고서와 미첨부·없는 번호도 회귀 확인한다.
|
||||
- 롤백: 위 소스의 이번 변경과 신규 ExmnManholeReportService를 함께 복원한다. DB 롤백은 없다. 실제 첨부 등록을 수행한 경우에는 별도로 이전 첨부를 복원한다.
|
||||
- 남은 리스크: 번호는 확정 맨홀번호가 아닌 조사시설 GIS 번호의 임시 계약이며, 스캔 PDF는 판독 불가하다. 맨홀 PDF는 availability/열람 때 각각 판독하므로 대형 파일 성능은 후속 검증 대상이다. 테스트 소스·원시 결과·렌더링 임시 이미지는 요약 후 정리하고 최종 PDF는 보존한다.
|
||||
|
||||
|
||||
## 2026-10-19 후속: 기존 사업 맨홀 PDF 단독 교체
|
||||
- 원인: 보고서 열람을 추가했지만 결과등록 화면은 모든 검토에 Excel을 요구하고 저장에 Excel·PPL 검토 성공을 요구했다.
|
||||
- 변경: 맨홀 PDF 하나만 선택하면 기존 활성 MNHL 조사시설의 GIS 번호로 PDF 번호를 대조한다. 기존 조사자료 없음·불일치·중복·잘못된 PDF는 차단한다. 선택 PDF의 일부 시설 포함은 허용한다.
|
||||
- 저장 직전 다시 검토한 뒤 선택한 MNHL 첨부만 기존 attachments API로 연결한다. Excel imports/commit과 양식 생성 확인은 호출하지 않아 기존 시설·집계·관로첨부를 유지한다. Excel을 선택한 신규/전체 결과 저장 흐름은 유지한다.
|
||||
- 수정: BE ExmnBusinessController(선택 mnhlFile 입력 및 Excel 없는 MNHL 검토 분기), ExmnManholeReportService(validateReplacement), FE exmn-result-register-popup-page(검토·저장 조건과 검토안내).
|
||||
- 검증: BE compileJava, SN typecheck, 임시 JUnit 4건(기존번호 정상·무쓰기, 기존결과 없음, 번호 불일치/중복, 누락/손상 PDF) 통과. 원본 요구사항·지도 기준은 앞선 절과 동일하다. DB 스키마 변경 없음.
|
||||
- 롤백: 이번 단독 교체 분기만 복원한다. 앞선 맨홀 보고서 열람 구현은 유지할 수 있다.
|
||||
|
||||
- 실제 브라우저 확인: 기존 테스트 사업 `6fe818cb-0a7a-4379-969c-150150155f91`에서 Excel/PPL 미선택·188001 맨홀 PDF만 선택 → 검토 완료 → 저장 → 부모 사업목록 성공 안내 확인. 등록한 맨홀 PDF를 보고서 팝업에서 GIS 번호188001/1쪽으로 실제 표시했다. 지도 클릭 경로 자체는 이번 후속에서 재실행하지 않았다.
|
||||
- 검토 중 기존8883 서버의 닫힌 Spring 컨텍스트로 일반 API도500 발생. 사용자 명시 승인 후 동일 실행인수·local 프로필·포트8883·context /sewerage-service로 재시작하여 정상화했다. 교체 서버 PID24204는 사용자가 이어서 테스트하도록 유지한다. 종료명령: Stop-Process -Id 24204. 실행중 필요한 classpath 사본과 stdout/stderr는 작업루트 .codex-tmp/manhole-backend-classpath.jar 및 manhole-backend.out.log/err.log에 유지한다(테스트용 임시 서버가 아닌 승인된 사용자 서버 교체).
|
||||
- 부모 성공문구도 첨부 교체에 맞게 `조사결과 저장을 완료했습니다.`로 보정했다(exmn-registration-page.tsx). 실제 첨부는 변경했으며 기존 시설·집계는 Excel commit을 호출하지 않아 유지한다. 되돌릴 경우 이전 맨홀 첨부를 재등록한다.
|
||||
|
||||
|
||||
## 2026-10-19 최종 정정: 실제 GIS 맨홀번호와 선택 사업 기준
|
||||
- 사용자 확정: PDF 맨홀번호는 조사자료 관로번호가 아닌 실제 GIS 맨홀번호이다. 앞선 gisManagementNo 기준 설명·구현은 이 절로 대체한다.
|
||||
- 원인: 지도보기 진입은 테스트 사업이었지만 전체 사업 레이어의 겹친 시설이 선택됐다. 실제 화면은 오류 확인 테스트_수정/조사관로188003/시설관리번호188001이었다. 사용자 선택 실수가 아닌 지도 범위와 번호 계약 불일치이다.
|
||||
- FE use-exmn-result-map.ts: 조사관리에서 사업·처리분구를 지정해 진입하면 해당 범위만 표출·선택한다. 일반 지도 진입은 기존 전체 범위를 유지한다. 기존 2026-10-01 전체표출 정책의 조사관리 진입 부분을 이 결정으로 대체한다.
|
||||
- FE exmn-survey-result-section.tsx: MNHL 보고서 키는 fclt_mng_no/fcltMngNo, PPL은 기존 gisManagementNo. 조사 결과 표에 GIS 번호를 명시한다. 결과등록 안내도 실제 GIS 맨홀번호로 정정했다.
|
||||
- BE ExmnManholeReportService: 기존 ExmnMapLayerService의 동일 지역·상류맨홀 매칭 결과 fclt_mng_no 집합으로 업로드 검토·열람 대상을 확인한다. 조사 관로번호를 맨홀번호로 대체하지 않는다. 같은 실제 맨홀에 여러 조사관로가 연결되면 번호 집합으로 처리한다. 스키마·조사시설·원천 GIS 변경 없음.
|
||||
- 검증: 실제 GIS188001/조사관로188003 분리, PDF 검토·열람 성공, 조사관로 번호 직접 요청 거부 및 GIS 미연결 거부를 묶은 JUnit1건 통과. BE 컴파일·FE 타입검사·diff --check 통과. 임시 테스트 소스와 결과 정리 완료.
|
||||
- 실브라우저: 테스트 사업→초이처리분구→지도3개 표시→실제188001 클릭→사업명 테스트/GIS 맨홀번호188001 확인→조사보고서 보기→1쪽 PDF와188001 표시 성공. 사용자도 최종 정상동작 확인했다.
|
||||
- 실행 서버: 기존 재시작 승인 범위에서 코드 반영 후8883 PID27788로 유지한다(이전PID24204 종료). classpath/실행로그는 기존 .codex-tmp/manhole-backend-* 경로 유지. 종료명령 Stop-Process -Id 27788.
|
||||
- 롤백: 위 FE3개 소스와 ExmnManholeReportService의 이번 변경을 함께 복원한다. DB 롤백 없음. 잔여 리스크는 GIS 미연결 자료 열람 불가 및 대형 사업의 지도 매칭·PDF 판독 비용이며, 임의 위치/다른 사업 fallback을 하지 않는다.
|
||||
|
||||
@@ -422,3 +422,15 @@ Chrome 확장의 파일 URL 접근 권한이 꺼져 있어 이번 회귀에서 E
|
||||
- `git diff --check`는 줄바꿈 변환 경고 외 whitespace 오류 없이 통과했다.
|
||||
- 사용자 요청에 따라 전체 TypeScript 검사와 Next 빌드는 수행하지 않았다.
|
||||
- 등록·수정 팝업에서 버튼명, 위치출처, 사진상태 표시와 기존 저장 흐름의 수동 검증이 남아 있다.
|
||||
|
||||
## 2026-10-02 quick_vibe 등록 입력 필수 표시 보완
|
||||
|
||||
- 설정된 SN DB의 information_schema.columns를 psql로 읽기 전용 조회했다(default_transaction_read_only=on). 대상은 sm_bs_sn.tb_sn_drdgn_job, tb_sn_drdgn_ppl, tb_sn_drdgn_job_photo 3개 테이블이다. DB 데이터/DDL 변경 없음.
|
||||
- 작업 테이블의 drdgn_ymd/reg_se_cd/pstn_src_cd/lot/lat, 관로 테이블의 fid/mng_inst_cd/fclt_mng_no/관로 및 준설 좌표/거리 등 NOT NULL을 Repository 저장값과 대조했다.
|
||||
- 사용자 확인이 필요한 위치등록·자동 연결 관로에 기존 TableHead data-required 별표를 적용했다. 준설일자의 기존 필수 표시는 유지했다.
|
||||
- 사진은 DredgingRequestValidator의 PHOTO 방식 1장 이상 규칙에 따라 PHOTO 모드에서만 별표를 표시한다. MANUAL에는 필수 표시하지 않는다. 촬영일시 sht_dt는 nullable이므로 필수로 표시하지 않는다.
|
||||
- ID/순번/등록시각/사진건수 및 자동 매핑된 관로 속성은 시스템이 채우므로 개별 사용자 입력 필수 항목으로 추가하지 않는다. 등록 방식도 기존 기본 선택/버튼을 유지한다.
|
||||
- 원본: 요구사항 기능 시트84행 SFR-010-012, PPT 실제53슬라이드 SN_MN040201 준설 결과 업로드.
|
||||
- 검증: DB 메타데이터 조회 성공, 기존 서버/FE 저장 검증 대조, SN 전체 타입검사 exit0, git diff --check 통과. 실브라우저 표시는 미수행.
|
||||
- 수정 소스: `frontend/apps/sn/src/features/dredging/ui/dredging-registration-body.tsx`. API/백엔드 코드·저장 계약 변경 없음. 기존 GIS 작업은 변경하지 않았다.
|
||||
- 롤백: 해당 소스의 이번 필수 표시/입력 제한만 복원한다.
|
||||
|
||||
@@ -15,3 +15,14 @@
|
||||
- 순번은 전체 건수 기준 역순으로 계산해 로컬 3002에서 1페이지 18~9, 2페이지 8~1 표출을 확인했다.
|
||||
- 원본 근거는 요구사항정의서 `SFR-011-001`, 화면설계서 8~9번 `SN_MN010102`이며 순번·제목 건수는 원본 필수항목이 아닌 공통 그리드 표시 규칙이다.
|
||||
- API, 조회조건, 연도 그룹 헤더와 Excel 다운로드 형식은 변경하지 않았다.
|
||||
|
||||
## 4. 2026-10-08 기본 검색기간 단년 변경
|
||||
|
||||
- 사용자 요청으로 디지털하수관로의 시도별 노후관로현황 기본 검색기간을 최근 3년에서 당해연도 1년으로 변경했다. 최초 진입·초기화·잘못된 연도값의 기본 처리가 동일한 정규화 함수를 사용한다.
|
||||
- 원본 근거: 요구사항정의서 `3. (기능)요구사항정의 및 추적표` 87행 `SFR-011-001`, 화면설계서 슬라이드 9 `SN_MN010102`. 화면설계서의 시작·종료 연도 예시는 모두 2026년이다.
|
||||
- 수정: `aged-pipe-status-by-ctpv-utils.ts`의 기본 시작연도와 비정상 시작연도 fallback만 당해연도로 변경. 사용자가 선택한 최대 3년 범위, 경과년도 기본 30년, API 계약·DB·지도 기능·Excel 적용조건 처리는 유지한다.
|
||||
- 로컬 3002 실제 브라우저: 최초 진입 `2026~2026` → 2026년 단일 그룹/10행, 직접 `2024~2026` 선택 후 조회 → 3개 연도 그룹, 초기화 → `2026~2026` 복귀 확인. 해당 API 요청 모두 HTTP 200.
|
||||
- 기본값·초기화 입력·비정상 연도·명시적 3년 유지·4년 제한 등 6개 확인과 `git diff --check` 통과. 사용자 개발 서버가 실행 중이므로 빌드/서버 재시작은 하지 않았다. 일회성 테스트 파일은 생성하지 않았다.
|
||||
- 시도 집계 목록의 기존 클라이언트 페이징 예외(18개 고정 집계)는 변경하지 않았다. 지도 호출 영향 없음. Excel은 동일한 마지막 적용조건을 사용하는 기존 코드 유지, 실제 다운로드 재검증은 이번 범위에서 수행하지 않았다.
|
||||
- 배포는 사용자가 수행한다. 원복은 기본 시작연도 및 fallback을 `currentYear - 2`로 되돌린다. 단년 조건의 성능 통과 여부는 배포 후 재시험 전까지 미확정이다.
|
||||
- 후속 결과서: 시스템명은 `디지털하수관로`, 표시 메뉴명에는 `SN01` 등 시험 ID를 붙이지 않는다. 사용자가 수정한 HWP를 기준으로 추가 편집하고, 실제 캡처는 마우스 커서가 보이지 않게 준비한다. 기존 측정 원본은 변경하지 않는다.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# SN GIS 지도 이동 검색조건 초기화 통합검증계획
|
||||
|
||||
## 2026-10-02 계획 / 실행 전
|
||||
|
||||
공통 설계의 15개 기능 묶음에 아래 항목을 적용한다. 원본 근거는 PPT 실제 슬라이드 19/SN_MN010201·21/SN_MN010202의 행 선택 지도 이동, 이번 사용자 확정은 상단 검색조건만 초기화이다.
|
||||
|
||||
| 확인 항목 | 기대 결과 |
|
||||
| --- | --- |
|
||||
| 서울특별시/강남구/시설 선택 후 통수위험 다른 지역 행 선택 | 기존 기준대로 이동, 상단 3개 선택은 기본값, 위험도·선택 강조·정보 카드 정상 유지 |
|
||||
| 필수 기능 각각의 행/셀 선택 | 동일 초기화. 새 결과의 범례/레이어/지도 확대 규칙 정상 |
|
||||
| 침입수 실측관로 있음/없음, RDII 지역 중심/extent fallback | 기존 최종 이동 유지, 지역/시설 자동 재채움 없음 |
|
||||
| 인라인 정보검색과 별도 창 정보검색 | 양쪽 모두 동일 동작. 팝업 origin 검증 유지 |
|
||||
| 조회 실패, geometry 없음, 빈 피처, NaN/무한/뒤집힌 extent | 초기화 및 잘못된 이동 없음. 기존 오류 안내 유지 |
|
||||
| 침수예측 레이어 준비 거부/취소 | 성공 이동이 없으면 상단 조건 유지 |
|
||||
| 이미 같은 위치인 행 재선택 | 이동할 대상이 유효하면 검색조건 초기화, moveend 발생 유무에 의존하지 않음 |
|
||||
| 상단 시도/시군구 조회, 상단 시설 선택 | 직접 선택한 검색값 유지. 공통 검색 함수 확장의 역효과 없음 |
|
||||
| 일반 드래그·줌·지도 피처 클릭 | 본 변경으로 검색조건 초기화하지 않음 |
|
||||
| 초기화 직전 시설 후보 요청을 느리게 응답 | 예전 시설 후보/선택/대기 시설명이 복원되지 않음 |
|
||||
| A→B 행을 빠르게 선택, A 응답이 나중 도착 | 마지막 선택만 유효. 이전 이동·검색바 변경 없음 |
|
||||
| message/storage로 같은 행 중복 도착 | 지도 이동/초기화 중복 처리 방지 |
|
||||
| 행 선택 후 응답 전에 사용자가 상단 조건 변경 | 오래된 작업이 최신 사용자 의도를 덮지 않음 |
|
||||
| 지도 미준비/검색바 마운트 전 메시지 | 초기화 의도 유실·다른 지도 영향 없음 |
|
||||
| 필터가 다른 하수처리시설 기본 라벨 | 현재 레이어 필터의 기본 라벨과 시설 유형 유지 |
|
||||
| GIS 편집 변경 목록 | 검색조건만 초기화, 미저장 변경/편집 세션 유지 |
|
||||
| 별도 준설 위치 지도·GIS 업로드 미리보기 | 메인 지도 검색조건에 영향 없음 |
|
||||
| 다른 ProcSearchBar/GIS 검색 함수 소비자 | optional 인자를 생략하면 이전과 같은 동작 |
|
||||
| 백엔드 요청 비교 | 선택 행 식별자·ctpvCd/sggCd·분석번호 보존. 검색바 초기값이 API 조건으로 섞이지 않음 |
|
||||
|
||||
## 검증 순서와 현재 결과
|
||||
|
||||
- 이번에는 정적 경로 추적과 원본 대조만 수행했다. 위 표의 동작 시험은 모두 미실행이다.
|
||||
- 구현 후 상태 초기화·응답 역전·유효 geometry 성공 callback 검증 → 대표 통수위험 브라우저 재현 → 기능별 회귀 순으로 진행한다.
|
||||
- 공통 패키지 변경은 SN 외 소비자의 기본값 보존을 함께 확인한다. 사용자 dev 서버와 build 동시 실행 금지.
|
||||
- 서버와 테스트를 실행한다면 적용 AGENTS.md의 소유 프로세스 종료·임시검증물 정리 규칙을 따른다.
|
||||
- 신규 버그/호출부 누락이 있으면 공통 설계 목록을 갱신한다. 코드 검색 결과를 실브라우저 통과로 기록하지 않는다.
|
||||
|
||||
## 2026-10-02 구현 후 진행 상태
|
||||
|
||||
- 자동/모의 실행 검증은 결과 문서 `002-qa-SN-GIS-지도이동검색조건초기화-통합검증결과.md`에 기록했다.
|
||||
- 실제 지도 로그인 후 기능별 회귀는 대기다. 위 표를 브라우저 통과로 일괄 표시하지 않는다.
|
||||
|
||||
## 2026-10-02 추가 회귀 대상
|
||||
|
||||
- 침수흔적 상세 심선/위선 연도 선택을 최초 조회와 캐시 재선택으로 나누어 검증한다. 캐시 대기 중 다른 대상을 선택하면 이전 요청이 검색바를 초기화하지 않아야 한다.
|
||||
- 상단 조건을 다시 선택한 뒤 침수흔적 상세 탭 복귀로 FID 재조회·지도 이동 시 초기화되는지 확인한다.
|
||||
- 모의 실행 결과는 결과 문서에 기록했고 실제 화면 확인은 미완료다.
|
||||
@@ -0,0 +1,51 @@
|
||||
# SN GIS 지도 이동 검색조건 초기화 통합검증결과
|
||||
|
||||
## 2026-10-02 자동검증 완료 / 실브라우저 대기
|
||||
|
||||
| 검증 | 결과 |
|
||||
| --- | --- |
|
||||
| SN 전체 TypeScript (`node node_modules/typescript/bin/tsc --noEmit --incremental false --pretty false`) | 최종 exit 0. 초기 sandbox EPERM 후 승인된 외부 실행, 중간 타입 차이 수정 후 최종 통과 |
|
||||
| 지도 공통 모듈 실제 함수 실행 | 초기화 1회/지도별 격리/빈·NaN·무한·역전 범위 거부/이전 요청 취소/검색바 조작 우선/동일 위치 재선택/구독 해제 통과 |
|
||||
| 실제 gis.layer 메서드와 모의 source 이벤트 실행 | 로드 전 초기화 없음/도형 로드 후 callback/취소/옵션 없는 기존 호출/이전 source 무시/다중 선택 성공·취소 통과 |
|
||||
| 침입수/RDII 실제 송신 함수 및 중심 이동 함수 실행 | message/storage 동일 ID, 같은 행 재클릭 새 ID, crypto 의존 없음, 중심 이동 승인·취소·기존 호출·비정상 좌표 거부 통과 |
|
||||
| ProcSearchBar 로직 hook 모의 실행 | 상단 직접 조회값 유지, 3개 선택값·후보 초기화, 늦은 시설 응답 무시, 초기화 자체의 지도 이동 없음, 시설 기본 라벨 유지 통과 |
|
||||
| UTF-8/클라이언트 지시문 순서/diff 공백 | 확인 통과 |
|
||||
| 실 API/DB | 미수행. BE 코드·계약·DB 변경 없음 |
|
||||
| 브라우저 | localhost:3002/map 진입 후 /auth/login으로 이동. 로그인 대기. 아직 실제 지도 동작 통과 아님 |
|
||||
| build | 사용자 dev 서버 실행 중이므로 미수행 |
|
||||
|
||||
- Node 검증은 실제 소스에서 함수/로직을 읽어 모의 지도·네트워크·hook 상태로 실행했다. 실제 React DOM/OpenLayers/GeoServer를 연결한 브라우저 통합시험을 대체하지 않는다.
|
||||
- 검증용 파일·서버를 만들지 않았다. 사용자 서버 3002/PID 40220 및 8883/PID 2676 보존.
|
||||
- 원본 근거: 요구사항 기능 시트 93/SFR-011-007, 96/SFR-012-001, PPT 실제 슬라이드 19/SN_MN010201·21/SN_MN010202.
|
||||
- 남은 검증: 통합검증계획의 기능별 실데이터 회귀와 다른 공통 컴포넌트 소비자의 화면 동작.
|
||||
|
||||
## 2026-10-02 사용자 요청에 따른 적용 경로 재점검
|
||||
|
||||
- 이전 `15개 기능 적용` 보고는 대표 목록 경로 기준이었다. 재점검에서 침수흔적 상세 내부의 추가 선택 경로가 누락된 것을 발견하여 보완했다. 이전 보고를 모든 하위 동작의 실검증 완료로 해석하지 않는다.
|
||||
- 보완 1: `components/fldng-hstry/fldng-hstry-feature-click.tsx`의 심선·위선 연도 선택. 신규 WFS 조회는 beforeFocus를 전달하고 캐시 재사용은 유효 도형/최신 요청 확인 후 초기화한다.
|
||||
- 보완 2: `ui/map-page-client.tsx`의 침수흔적 상세 탭 복귀 시 FID 재조회. 같은 성공 callback을 연결했다.
|
||||
- 본 라운드 수정 소스 2개. 누적 소스는 기존21+신규1=22개다. API·DB 변경 없음.
|
||||
|
||||
| 대조한 기능 | 초기화 연결 근거 |
|
||||
| --- | --- |
|
||||
| 통수위험 현재/과거 | use-water-risk-layer의 selectionNavigationRef.commit → fit |
|
||||
| 침입수·RDII | 각 결과 hook의 fitMapSelection / 지역중심 beforeMove |
|
||||
| 노후관로·관로분석 상세 | map-page-client의 두 행 선택 handler → mapSearchNavigationOptions |
|
||||
| 정보검색 기본/유지관리 결과 | use-basic-tab / use-operation-tab의 직접 검색 및 runtime bridge의 popup 수신 |
|
||||
| 정보검색 선택/전체 지도보기 | use-search-modal / runtime bridge의 다중 검색 callback |
|
||||
| 지반침하 목록·주변 관로 | loadSinkholeDetail의 resetSearch=true / focusSinkholeNearbyPipe의 commit |
|
||||
| 침수발생이력 목록 | runtime bridge의 일반 FID 검색 / use-flood-history-selection의 fitMapSelection |
|
||||
| 침수위험지도 중점관리지역 | MAP_SEARCH_SHOW_MULTIPLE → runtime bridge |
|
||||
| 도시침수지도·침수예측현황 | MAP_SEARCH_SHOW_FLOOD_GROUP → use-flood-prediction-map의 commit |
|
||||
| 처리구역 보수이력 | swtrmt-prcs-zone-analysis의 fitMapSelection |
|
||||
| GIS 편집 변경 목록 | focusGisEditChangeOnMap의 fitMapSelection |
|
||||
| 조사결과 지도보기 | use-exmn-result-map의 navigation.commit → fit |
|
||||
| 맨홀 상류관로 | smh의 fitMapSelection |
|
||||
| 침수흔적 상세 심선·위선 | 이번 보완: 신규 WFS callback + 캐시 beforeFocus |
|
||||
| 침수흔적 상세 탭 복귀 재조회 | 이번 보완: FID 검색 callback |
|
||||
|
||||
- `fit`/optional fit/animate/setCenter/지역중심 함수/단일·다중 GIS 검색/피처 선택 및 메시지 경로를 SN TS·TSX·JS·JSX 범위에서 다시 검색했다. 직접 fit 잔여는 상위 commit으로 보호되거나 아래 제외 경로임을 확인했다.
|
||||
- 의도적 제외: 상단 직접 검색/시설 선택, 일반 지도 클릭·드래그·줌, GIS 편집 저장 후 자동 재초점, 별도 GIS 업로드 미리보기·준설 위치 지도. 원래 위치를 이동하지 않는 상세 탭 선택/업로드 이력 선택도 강제 초기화하지 않는다.
|
||||
- 검증: SN 전체 TypeScript 최종 exit 0, diff 공백 검사 통과. 실제 침수흔적 helper를 읽어 모의 실행한 캐시 성공/늦은 캐시 응답 거부/신규 조회 callback 전달 시험 통과. 테스트 파일/프로세스 생성 없음.
|
||||
- 현재 결론은 정적 적용 경로 및 자동검증 기준이다. 로그인 이후 전 기능 실브라우저 검증은 여전히 별도 미완료다.
|
||||
- 이번 보완 롤백: 위 2개 소스의 beforeFocus 추가만 복원. 전체 기능 롤백은 누적22개 변경을 함께 복원하며 DB 작업은 없다.
|
||||
@@ -0,0 +1,139 @@
|
||||
# 지반침하 상세정보·하수관로 목록 엑셀 다운로드 작업계획
|
||||
|
||||
- 작성일: 2026-10-22
|
||||
- 요구사항: SFR-010-010
|
||||
- 상태: 사용자 승인 후 구현 완료. 타입·XLSX 검증 완료, 실제 브라우저 클릭 검증은 미완료.
|
||||
- 작업 목적: 지도 우측 지반침하 상세정보에서 발생정보와 해당 반경 내 하수관로 목록을 하나의 XLSX 파일로 내려받는다.
|
||||
- 범위: FE만 수정한다. 공통 유틸은 사용자 지정 경로인 `apps/sn/src/shared/util`에 둔다.
|
||||
|
||||
## 1. 요구사항 및 원본 확인
|
||||
|
||||
| 근거 | 직접 확인한 내용 |
|
||||
| --- | --- |
|
||||
| 사용자 요청 | 우측 상세정보에 엑셀 다운로드 추가. 상세 상단을 첫 시트, 하수관로 목록을 둘째 시트로 구성. 다른 화면을 참조해 공통화하고 승인 후 구현 |
|
||||
| `COMMON_DOCS_ROOT/기준문서/요구사항정의서.xlsx` | `3. (기능)요구사항정의 및 추적표` 시트 82행, `SFR-010-010`, 싱크홀 발생 정보 지도 서비스. 지반침하 용어 변경, 연계 정보 지도 표출, 기본 반경 100m 및 사용자 입력 반경, 좌표 없는 자료의 목록 표출 근거 확인 |
|
||||
| `COMMON_DOCS_ROOT/기준문서/화면설계서.pptx` | 슬라이드 7, 화면 ID `SN_MN010101`, 하수관망도 > 지반침하정보. 발생정보 상세, 반경 내 관로 추출, 관로 행 클릭 강조, 발생이력 목록 다운로드 확인 |
|
||||
| 기존 작업카드 | 같은 폴더의 `001-fe-SFR-010-010-지반침하정보-작업카드.md` 확인. 현재 경로와 실제 구현을 기준으로 추가 계획 작성 |
|
||||
|
||||
원본에는 우측 상세정보를 두 시트로 내려받는 구체적 구성이 없으므로 이번 사용자 요청을 추가 요구사항으로 삼는다. 원본의 피해 인원 항목을 새로 추가하지 않고 현재 상세 상단에 표시되는 9개 항목을 내보낸다.
|
||||
|
||||
원본 파일 정보: 화면설계서 10,736,400바이트 / 수정일 2026-08-13, 요구사항정의서 124,187바이트 / 수정일 2026-08-06.
|
||||
|
||||
## 2. 현재 구현과 참조 결과
|
||||
|
||||
| 파일 | 확인 결과 / 활용 방향 |
|
||||
| --- | --- |
|
||||
| `app/(map-layout)/map/components/sinkhole/sinkhole-detail-panel.tsx` | `detail`, `nearbyPipes`, `radiusMeters`를 받아 발생정보 9개 항목과 관로 5개 열을 표시한다. 다운로드 버튼은 없다. 복구상태와 관로 용도는 공통코드 표시명 사용 |
|
||||
| `app/(map-layout)/map/components/sinkhole/sinkhole-popup-page.tsx` | 기존 지반침하 목록 다운로드는 적용조건 전체를 페이지 순회하여 수집하고 한글 헤더, 로딩 및 오류 안내를 적용한다. 해당 목록 기능은 유지 |
|
||||
| `src/shared/lib/download-excel.ts` | `xlsx` 동적 import, 한글 헤더, 열 너비, 시트명 정리, 압축 저장 참조. `sheetNameArray`는 현재 첫 이름만 사용하며 동일 구조 데이터의 대량 분할용이므로 이종 데이터 두 시트를 직접 표현하기 어렵다 |
|
||||
| `app/(map-layout)/map/components/aged-pipe-status-by-ctpv/aged-pipe-status-by-ctpv-export.ts` | `aoa_to_sheet`, 병합, 열 너비, 자동필터, `book_append_sheet`, XLSX 저장 방식 참조 |
|
||||
| `app/(map-layout)/map/ui/map-page-client.tsx` | 활성 지반침하 상세 탭의 상세와 관로 배열을 패널에 전달한다. 기존 조회 실패도 빈 배열이 되므로 관로 0건과 조회 실패를 현재 props만으로 구분할 수 없다 |
|
||||
|
||||
백엔드는 읽기 전용으로 현행 제약만 확인했다. `SinkholeQueryService.getNearbyPipes`, `getNearbyPipesByPoint` 모두 관로 조회 상한이 200건이다. 호출 인자로 전체 건수나 페이지를 요청할 수 없다.
|
||||
|
||||
## 3. 결과 파일과 UI 제안
|
||||
|
||||
- 버튼: 사용자 최종 후속 요청에 따라 하수관로 목록의 `침하발생 반경내 관로정보` 제목과 같은 헤더 행의 오른쪽에 배치한다. 하수처리구역 상세의 `보수이력`과 동일한 Button 기본 크기와 색상·여백 클래스를 사용한다. 좁은 패널에서는 제목 줄바꿈을 허용하고 버튼 크기를 유지한다.
|
||||
- 파일명: `지반침하정보_사고번호_YYYY-MM-DD.xlsx`. 파일명에 쓸 수 없는 문자는 정리하고 날짜는 사용자 로컬 날짜를 사용한다.
|
||||
- 두 시트의 순서는 고정한다.
|
||||
|
||||
| 순서 / 시트명 | 구성 |
|
||||
| --- | --- |
|
||||
| 1 / `지반침하정보` | `항목`, `내용`의 세로 표. 사고번호, 발생일자, 주소, 발생규모폭, 발생규모연장, 발생규모깊이, 상세발생원인, 복구상태, 복구완료일자 순으로 현재 상세와 동일한 표시값 사용 |
|
||||
| 2 / `하수관로목록` | 상단에 사고번호와 조회 반경(m)을 표시하고, `NO`, `관로 ID`, `용도`, `경과년도(년)`, `거리(m)` 열로 관로 목록 구성. 조회 순서를 유지하고 자동필터는 목록 헤더부터 적용 |
|
||||
|
||||
- 첫 시트는 현재 `summaryTableData`와 `summaryHeadList`를 재사용해 화면과 엑셀의 값·항목 순서가 달라지지 않게 한다.
|
||||
- 관로 용도는 현재 화면의 공통코드명 변환 함수를 재사용한다. 코드명 미확보 시 화면과 동일하게 원래 코드를 사용한다.
|
||||
- 관로 ID와 사고번호는 문자열로 보존한다. 숫자형 경과년도·거리는 숫자로 저장하며 헤더에 단위를 표기한다. 결측값은 `-`로 처리하고 0은 유지한다.
|
||||
- 관로가 정상 조회되어 0건이면 둘째 시트에 헤더와 `반경 내 하수관로 정보가 없습니다.` 안내를 넣어 두 시트를 모두 생성한다.
|
||||
- 좌표가 없는 상세도 첫 시트는 다운로드할 수 있게 하고, 둘째 시트에는 좌표 부재로 관로를 조회할 수 없다는 안내를 넣는다.
|
||||
- 다운로드 중에는 버튼을 비활성화하고 진행 상태를 표시한다. 실패하면 안내 후 버튼을 복구한다.
|
||||
|
||||
## 4. 공통화 설계
|
||||
|
||||
### 신규 공통 유틸
|
||||
|
||||
경로: `apps/sn/src/shared/util/download-excel-workbook.ts`
|
||||
|
||||
`downloadExcelWorkbook({ fileName, sheets })` 형태로 서로 다른 구조의 시트를 순서대로 받는다. 각 시트는 시트명, 명시적 셀 행 배열, 열 너비, 선택적인 자동필터 범위·병합 범위를 전달한다. 셀 값은 문자열·숫자·불리언·null로 제한하고 업무 데이터나 API 호출은 유틸에 넣지 않는다.
|
||||
|
||||
공통 책임은 XLSX 동적 import, 워크북/시트 생성, 시트명 금지문자·31자 제한·중복 이름 정리, 열 너비 설정, 자동필터 및 병합 적용, 파일 확장자·파일명 정리, 브라우저 저장이다. 문자열을 수식으로 전환하지 않는다. 빈 데이터에서도 전달된 헤더·안내 행을 저장할 수 있어야 한다.
|
||||
|
||||
기존 `src/shared/lib/download-excel.ts`의 공개 인터페이스와 호출부는 이번에 변경하지 않는다. 두 함수의 목적이 달라 기존 목록 다운로드 전체를 이관하는 작업은 제외하고 새 기능에서 공통 유틸을 사용한다. 다른 화면도 이종 시트가 필요할 때 같은 유틸을 사용할 수 있다.
|
||||
|
||||
### 지반침하 전용 변환
|
||||
|
||||
경로: `app/(map-layout)/map/components/sinkhole/sinkhole-detail-excel.ts`
|
||||
|
||||
화면에서 전달한 발생정보 표시값, 관로 표시값, 사고번호, 반경을 XLSX 시트 명세로 변환한다. 지반침하 항목명과 빈 목록·좌표 부재 안내는 이 파일에서 관리하여 공통 유틸과 분리한다.
|
||||
|
||||
## 5. 데이터 범위와 오류 처리
|
||||
|
||||
- 이번 다운로드 대상은 선택한 사고의 현재 적용 반경에 대해 기존 API가 제공하는 관로 목록이다. 전체 사고 목록이나 전체 하수관로를 내보내는 기능이 아니다.
|
||||
- 기존 관로 API가 최대 200건을 반환하므로 FE만으로 200건 초과 전체를 보장할 수 없다. 둘째 시트에 `기존 반경 조회 기준, 최대 200건`을 표시한다. 200건 초과 전체 추출은 백엔드 계약 보완이 필요한 별도 범위다.
|
||||
- 기존 패널 props의 빈 배열은 조회 실패와 0건을 구분하지 못한다. 이를 숨기지 않도록 다운로드 클릭 시 기존 조회 API로 선택 사고·반경의 관로 목록을 다시 확인한다. 새 API는 만들지 않는다.
|
||||
- 유효한 상세 x/y가 있으면 기존 `nearbyPipesByPoint`, 없으면 좌표 조회 가능 상태를 확인한 뒤 기존 사고번호별 `nearbyPipes`를 사용한다. 좌표 부재 상태는 조회를 생략하고 해당 사유를 시트에 남긴다.
|
||||
- 요청 실패·비정상 응답은 0건으로 바꾸지 않고 다운로드를 중단해 안내한다. 정상 응답의 빈 `items`만 0건으로 처리한다.
|
||||
- 클릭 시 활성 상세, 반경, 코드명 정보를 고정하여 비동기 요청 도중 다른 사고로 이동해도 서로 다른 사고 정보가 섞이지 않게 한다.
|
||||
- 재조회 중 데이터가 변경될 수 있으므로 두 번째 시트는 다운로드 시점의 같은 사고·반경 조회 결과다. 지도·화면의 상태를 다운로드 때문에 갱신하지 않는다.
|
||||
|
||||
## 6. 수정 예정 파일
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
| --- | --- |
|
||||
| `src/shared/util/download-excel-workbook.ts` | 신규. 업무에 독립적인 다중 시트 XLSX 생성/저장 |
|
||||
| `app/(map-layout)/map/components/sinkhole/sinkhole-detail-excel.ts` | 신규. 발생정보/관로 두 시트 구성 및 기존 API 재조회 처리를 담당. API 호출은 공통 유틸에 포함하지 않음 |
|
||||
| `app/(map-layout)/map/components/sinkhole/sinkhole-detail-panel.tsx` | 버튼, 진행/오류 처리, 기존 표시값 전달 |
|
||||
| 이 작업계획 문서 | 승인 및 구현 후 검증 결과 기록 |
|
||||
|
||||
백엔드, 의존성, 공용 패키지 및 다른 페이지의 코드는 변경하지 않는다. 기존에 수정 중인 파일은 덮어쓰지 않는다. 메인 지도 상태 변경은 현재 계획에서 제외한다.
|
||||
|
||||
## 7. 구현 절차
|
||||
|
||||
1. 공통 워크북 유틸을 작성한다. 업무 데이터와 분리해 서로 다른 행 구조를 가진 시트를 하나의 파일로 생성할 수 있게 한다.
|
||||
2. 지반침하 전용 변환·조회 함수를 작성한다. 클릭 시 사고와 반경을 고정하고 기존 API 응답을 검증한 뒤 발생정보와 관로 시트 명세를 만든다.
|
||||
3. 우측 상세에 다운로드 버튼을 연결한다. 현재 표시값을 재사용하고 진행 중 중복 실행과 오류 안내를 처리한다.
|
||||
4. 아래 검증을 수행하고 결과를 기록한다. 파일 자체와 실제 다운로드 흐름이 요구사항에 맞는지 확인한 뒤 완료 보고한다.
|
||||
|
||||
## 8. 검증 및 완료 기준
|
||||
|
||||
- `git diff --check`, `pnpm --filter sn run typecheck`로 변경 오류를 확인한다.
|
||||
- 사용자의 개발 서버가 실행 중이면 종료·재시작 및 `next build`를 하지 않는다. 실제 앱 포트와 실행 상태를 확인한 뒤 가능한 환경에서 UI를 검증한다.
|
||||
- 실제 XLSX 파일을 다시 읽어 시트가 정확히 두 개이고 순서·이름·9개 발생정보 항목·관로 5개 열·행 수·값이 맞는지 확인한다.
|
||||
- 정상 관로 여러 건, 정상 0건, 좌표 없음, API 실패, 비정상 응답, 코드명/원코드 대체, 결측값과 0, 숫자형 ID의 문자열 보존, 중복 클릭을 확인한다.
|
||||
- 반경 변경 후 다운로드와 활성 사고 탭 전환 중 다운로드에서 사고·반경이 섞이지 않는지 확인한다.
|
||||
- 한글·긴 주소·발생원인의 열 너비와 줄바꿈, 필터 위치, 파일명과 날짜를 확인한다. XLSX 라이브러리에서 지원하지 않는 장식 스타일을 완료 조건으로 약속하지 않는다.
|
||||
- 기존 관로 행 클릭·지도 강조·닫기·다른 상세 탭과 기존 지반침하 목록 다운로드가 유지되는지 확인한다.
|
||||
- 검증용 파일/스크립트는 결과 기록 후 정리하며 사용자 원본과 다른 작업자의 파일은 건드리지 않는다.
|
||||
|
||||
## 9. 영향 범위·롤백·잔여 제약
|
||||
|
||||
- 영향: 지반침하 우측 상세의 다운로드 기능과 신규 FE 공통 유틸에 한정한다. 기존 조회·지도·목록 다운로드 계약은 유지한다.
|
||||
- 롤백: 패널의 이번 버튼/핸들러/import 변경만 되돌리고 신규 전용 함수와 신규 공통 유틸을 제거한다. 기존 타 작업 변경은 보존한다.
|
||||
- 잔여 제약: 최대 200건 API 상한, 다운로드 시점 재조회와 기존 화면 사이의 데이터 갱신 차이, 대량 XLSX 생성의 브라우저 메모리 사용. 전체 관로 추출이나 서버 생성 방식은 별도 요청/승인 범위다.
|
||||
|
||||
## 10. 승인 및 구현 결과
|
||||
|
||||
- 사용자 승인: 이 작업계획대로 코드 수정 요청을 받아 구현했다.
|
||||
- 신규 공통 유틸 `src/shared/util/download-excel-workbook.ts`: `buildExcelWorkbook`으로 시트 생성과 다운로드를 분리하고 `downloadExcelWorkbook`으로 브라우저 저장을 제공한다. 동적 XLSX 로딩, 시트 순서, 이름 중복/금지문자/길이 정리, 파일명 정리, 열 너비·필터·병합을 지원한다.
|
||||
- 신규 `sinkhole-detail-excel.ts`: 발생정보 9개 항목과 관로 5개 열을 두 시트로 구성한다. API 재조회 실패와 비정상 응답은 다운로드를 중단하고, 정상 0건·좌표 부재는 별도 안내를 내보낸다. 사고·반경·표시 행은 재조회 전에 복사해 고정한다.
|
||||
- 수정 `sinkhole-detail-panel.tsx`: 하수관로 목록 제목 오른쪽에 버튼을 배치했다. 사용자 후속 요청에 따라 하수처리구역 상세의 보수이력 버튼과 같은 기본 크기·색상·여백을 적용했다. 진행 상태를 state로 표시하고 ref로 즉시 중복 실행을 막으며 오류 후 상태를 복구한다.
|
||||
- 기존 목록 다운로드, 메인 지도, 백엔드, 의존성 및 타 페이지는 수정하지 않았다. 공통 유틸 우선 검토 기준문서 추가는 별도 제안 단계이므로 이번 구현 범위에 포함하지 않았다.
|
||||
|
||||
### 검증 결과
|
||||
|
||||
| 항목 | 결과 / 한계 |
|
||||
| --- | --- |
|
||||
| `pnpm --filter sn run typecheck` | 통과 |
|
||||
| `git diff --check` | 통과 |
|
||||
| XLSX 실제 직렬화 후 재읽기 | 두 시트 이름·순서, 첫 시트 9개 항목, 둘째 시트 5개 열, 필터 범위, 관로 순서 확인 |
|
||||
| 데이터 형식 | 문자열 관로 ID `00123`의 앞자리 0 보존, 용도명/원코드 대체, 경과년도·거리의 숫자 0과 결측 `-` 확인 |
|
||||
| 예외 / 빈 목록 | 정상 0건 안내, 좌표 부재 시 API 요청 생략 및 안내, 요청 실패·비정상 응답 시 저장 중단 확인 |
|
||||
| 비동기 데이터 고정 | 조회 중 호출자의 사고번호·반경·요약 행을 바꿔도 파일에는 클릭 시 데이터 유지 확인 |
|
||||
| 공통 유틸 | 금지문자, 대소문자 중복, 31자 제한, 빈 시트, 수식처럼 보이는 문자열의 일반 문자열 저장 확인 |
|
||||
| API 분기 / 파일명 | 좌표 기준 및 사고번호 기준 요청, 사고번호 URL 인코딩, 파일명 금지문자 정리 확인 |
|
||||
| 개발 서버 | 기존 3002 포트 서버 유지, `/map` HTTP 200 확인. 빌드·재시작하지 않음 |
|
||||
| 브라우저 / 실제 API | 연결된 브라우저가 없어 실제 버튼 클릭·다운로드·지도 회귀 및 실제 API 응답 검증은 미완료. 위 데이터/예외 검증은 요청·저장 함수를 대체한 메모리 기반 검증이며 XLSX 직렬화/재읽기는 실제 라이브러리 사용 |
|
||||
| 시각 서식 | 열 너비·병합 설정 확인. 줄바꿈 스타일은 라이브러리에 전달하되 무료 SheetJS의 지원 및 Excel 실제 화면은 미검증 |
|
||||
|
||||
검증 코드는 셸 표준입력으로 실행했고 디스크에 테스트 파일·XLSX를 남기지 않았다. 롤백과 최대 200건 제약은 9절을 따른다.
|
||||
@@ -41,3 +41,41 @@
|
||||
- 롤백: `waterRiskClient.ts`를 제거하고 popup의 실제 조회/부모 메시지 변경 및 `map/page.tsx` 통수위험 mapView 처리를 되돌린다.
|
||||
- 자동화 도구가 새 창 popup을 부모 탭과 동시에 제어하는 데 제한이 있어, popup의 실제 mapView 요청과 부모 메시지 처리 코드를 각각 확인했다. 최종 사용자 회귀 시 부모 지도에서 popup을 열고 한 행을 선택하는 시나리오를 한 번 더 수행할 수 있다.
|
||||
- 현재누적강우량은 백엔드 원천 미확보로 `-`이며, 예측강우 격자 overlay는 이번 합리식 결과 범위에 포함하지 않는다.
|
||||
|
||||
## 5. 2026-10-20 위험(매핑관로없음)·분석시각 컬럼·분석시각 기간 검색
|
||||
|
||||
- 근거: `COMMON_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/004-de-...-매핑관로없음위험-분석시각-작업계획.md` 1~8 승인. 원본 화면설계서 19·21쪽(SN_MN010201·010202), 요구사항 SFR-011-007.
|
||||
- `components/water-risk/water-risk-types.ts`: `WaterRiskStatus`에 `DANGER_UNMAPPED` 추가, 라벨 `위험(매핑관로없음)`, 위험 색 판별 `isWaterRiskDanger()`.
|
||||
- `components/water-risk/water-risk-popup-page.tsx`
|
||||
- 목록: `중점관리지역` 다음 `분석시각` 컬럼(정렬 가능, 컬럼 id = BE 정렬 키 `analysisBaseAt`), 컬럼 폭 재배분, 최소 폭 124rem → 136rem, 위험 배지 줄바꿈 방지.
|
||||
- 과거이력 검색패널: `분석시각` 기간(시작 날짜·시 ~ 종료 날짜·시, 선택). 기본·초기화는 빈 값이며 적용 시 `validateWaterRiskAnalysisPeriod`로 검증한다. 위험상태 필터에 `위험(매핑관로없음)` 추가. 하단 판정 안내 문구 갱신.
|
||||
- 현재 탭은 기존처럼 현재 정시 `analysisBaseAt`만 보낸다.
|
||||
- `components/water-risk/water-risk-client.ts`: `analysisBaseFrom`·`analysisBaseTo`를 둘 다 있을 때만 쿼리스트링에 넣는다.
|
||||
- `components/water-risk/water-risk-date-range.ts`: `buildWaterRiskAnalysisDateTime`, `validateWaterRiskAnalysisPeriod`(짝 입력, 시작 ≤ 종료, 종료 ≤ 현재 정시, 366일) 추가.
|
||||
- `lib/water-risk-styles.ts`, `ui/map-page-client.tsx`: 지도 구역 색·정보카드 배지를 `isWaterRiskDanger()`로 판정해 위험(매핑관로없음)도 빨강으로 표시한다.
|
||||
- 검증: `git diff --check` 통과, `tsc --noEmit`(sn 전체) 통과, 변경 6개 파일 ESLint 문제 수가 수정 전(HEAD)과 동일(기존 `map-page-client.tsx` 46 errors 등은 baseline). 개발 서버가 꺼져 있어 화면 확인은 미실행, `next build` 미실행.
|
||||
- 롤백: 위 6개 파일을 되돌린다.
|
||||
- 2026-10-20 추가: BE mapView 위험상태가 목록과 같은 대표결과 판정값으로 바뀌어, `lib/use-water-risk-layer.ts`의 정보카드 상태 우선순위 주석만 갱신했다(동작 변경 없음).
|
||||
|
||||
## 6. 2026-10-21 지도 조회 키를 SWMM 실행 ID로 변경
|
||||
|
||||
- 근거: `COMMON_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/004-de-...-작업계획.md` 15절 9~13 승인.
|
||||
- `components/water-risk/water-risk-types.ts`: `WaterRiskResultRow.executionId`, `WaterRiskMapView.executionId·analysisBaseAt·rainStartedAt`, `WaterRiskSessionSnapshot.selectedExecutionId` 추가.
|
||||
- `components/water-risk/water-risk-client.ts`: 지도 조회를 `v1/waterRisk/executions/{executionId}/mapView`로 변경(날짜·지역 경로 미사용).
|
||||
- `components/water-risk/water-risk-popup-page.tsx`: 행 식별 키를 `executionId`로 변경(없으면 기존 조합), 부모 동기화 메시지에 `selectedExecutionId`가 있으면 그 행만 선택 표시.
|
||||
- `lib/use-water-risk-layer.ts`: 지도 응답과 행의 실행 ID가 다르면 무시, 세션에 `selectedExecutionId` 저장.
|
||||
- 검증: `tsc --noEmit` 통과, 변경 4개 파일 ESLint 문제 수 수정 전과 동일, `git diff --check` 통과. 화면 확인은 BE 재기동 후 필요(현재 BE는 이전 코드라 새 지도 API 없음).
|
||||
- 롤백: 위 4개 파일 복원. BE의 구 지도 API가 유지되는 동안은 FE만 되돌려도 기존 동작으로 복귀한다.
|
||||
|
||||
## 7. 2026-10-21 과거이력 검색조건 순서·명칭·시간 단위 입력
|
||||
|
||||
- 근거: `COMMON_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/004-de-...-작업계획.md` 16절 결정 14~17 승인. 원본 화면설계서 19쪽 SN_MN010201, 요구사항 SFR-011-007.
|
||||
- `components/water-risk/water-risk-popup-page.tsx`
|
||||
- 과거이력 검색조건 순서를 시도 → 시군구 → 중점관리지역 → 분석시각 → 강우시각 → 위험상태로 바꿨다(현재 탭은 중점관리지역만).
|
||||
- 명칭: 검색란 `강우 발생기간` → `강우시각`, 목록 컬럼 `강우시작시간` → `강우시작시각`(컬럼 id·정렬 키 `rainStartedAt` 유지).
|
||||
- 분석시각·강우시각 입력을 침수예측현황과 같은 `datetime-local` 시작 `~` 종료(`w-[17rem]`)로 바꾸고 `step={3600}`을 줬다. 분은 00으로 고정되고, 분이 들어오면 저장 전에 00으로 내린다. 현재 정시 이후는 `max`로 막는다.
|
||||
- 검색조건 상태를 `rainStartedFrom`·`rainStartedTo`(필수, 기본 365일 전 00시 ~ 현재 정시), `analysisFrom`·`analysisTo`(선택)로 정리했다. 조회 시 `yyyy-MM-ddTHH:00:00`으로 보내고, `startDate`·`endDate`는 강우시각 날짜로 맞춘다. 현재 탭은 기존처럼 최근 1년 날짜와 현재 정시 `analysisBaseAt`만 보낸다.
|
||||
- `components/water-risk/water-risk-date-range.ts`: 시간 단위 값 변환(`toWaterRiskHourValue`·`formatWaterRiskHourValue`·`toWaterRiskApiDateTime`)과 기간 검증(`validateWaterRiskRainStartedPeriod` 필수, `validateWaterRiskAnalysisPeriod` 선택: 짝 입력·정시·시작 ≤ 종료·종료 ≤ 현재 정시·366일)으로 정리. 오류 문구는 `강우시각…`/`분석시각…`.
|
||||
- `components/water-risk/water-risk-client.ts`: `rainStartedFrom`·`rainStartedTo`를 둘 다 있을 때만 전달.
|
||||
- 검증: `tsc --noEmit`(sn 전체) 통과, 변경 3개 파일 ESLint 0건(수정 전과 같음), `git diff --check` 통과. `step=3600` 입력의 분 고정은 앱 내장 브라우저(Chromium 152)에서 따로 확인했다. 로그인이 필요해 실제 팝업 화면은 미확인, `next build` 미실행.
|
||||
- 롤백: 위 3개 파일을 되돌린다. BE의 새 파라미터는 선택값이라 FE만 되돌려도 동작한다.
|
||||
|
||||
@@ -77,3 +77,53 @@
|
||||
- `fldng-predc-prst-excel.ts`가 화면 열 순서의 단일 시트 `침수예측현황.xlsx`를 브라우저에서 생성한다.
|
||||
- 서버 `/prst/excel` Blob 호출과 `getBlobData`, `fileDownload` 사용을 제거했다.
|
||||
- 독립 TypeScript `--noEmit` 검증을 통과했다.
|
||||
|
||||
## 2026-10-21 강우 명칭·시간 단위 입력 통일 계획 (2026-10-21 승인·구현)
|
||||
|
||||
### 배경
|
||||
|
||||
- 사용자 지시(2026-10-21)
|
||||
- 강우 관련 명칭: 검색란 `강우시각`, 목록 `강우시작시각`. 침수예측현황도 바꿔야 하면 이 문서에 추가한 뒤 승인 후 수정한다.
|
||||
- 검색란의 분석시각·강우시각은 `datetime-local`(`2026-10-21 오후 03:00` 형태)로 두되 **시간까지만 선택**한다. 분은 `00`으로 고정하고 수정할 수 없다. 침수예측현황도 같이 수정한다.
|
||||
- 통수위험 쪽 계획: `COMMON_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/004-de-SFR-011-007-통수위험분석-매핑관로없음위험-분석시각-작업계획.md` 16절.
|
||||
|
||||
### 현재 명칭
|
||||
|
||||
| 위치 | 현재 | 기준과 일치 |
|
||||
| --- | --- | --- |
|
||||
| 검색란 라벨 (`ui/fldng-predc-prst-popup-content.tsx`) | `강우시각` | 예 |
|
||||
| 목록 컬럼 (`ui/fldng-predc-prst-popup-content.tsx`) | `강우시작시각` | 예 |
|
||||
| 지도 정보 (`lib/use-flood-prediction-map.ts`) | `강우시작시각` | 예 |
|
||||
| 검색 오류 문구 (`ui/fldng-predc-prst-popup-content.tsx` `validateFilters`) | `강우 시작시각의 시작은 종료보다 늦을 수 없습니다.` | 아니오(검색란 이름은 `강우시각`) |
|
||||
| Excel 머리글 (`lib/fldng-predc-prst-excel.ts` `HEADERS.rainBeginningDateTime`) | `강우 시작시각` | 아니오(목록은 붙여 쓴 `강우시작시각`) |
|
||||
|
||||
- 화면에 보이는 라벨과 목록 컬럼 이름은 이미 기준과 같다. 이름은 문구 2곳만 맞춘다.
|
||||
|
||||
### 시간 단위 입력 결론
|
||||
|
||||
- 현재 분석시각·강우시각 입력(`renderDateTimeRange`)은 `datetime-local`이라 분까지 고를 수 있다.
|
||||
- `step={3600}`(1시간)을 주면 값의 분이 `00`일 때 브라우저가 분 칸을 고정해 바꿀 수 없다. 빈 값에서 처음 입력할 때처럼 분이 들어오는 경우는 `onChange`에서 분을 `00`으로 맞춘다. 입력 폭(`w-[17rem]`)과 표시 형태는 그대로다.
|
||||
- API는 지금처럼 `yyyyMMddHHmm`을 보내며 분만 항상 `00`이 된다. BE는 문자열 시작 이상·종료 이하로 비교하므로(`FldngPredcPrstRepository.addRange`) **BE 변경은 없다.**
|
||||
- 개발 DB `tb_sn_fldng_predc_anls` 208건의 분석시각(`obsrvn_tm`)·강우시작시각(`rain_bgng_tm`)이 모두 정시(분 `00`)라서 시간 단위 조건으로 빠지는 자료는 없다.
|
||||
|
||||
### 수정 계획
|
||||
|
||||
| 번호 | 파일 | 변경 |
|
||||
| --- | --- | --- |
|
||||
| 1 | `src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx` | 오류 문구 `강우 시작시각의 시작은 종료보다 늦을 수 없습니다.` → `강우시각의 시작은 종료보다 늦을 수 없습니다.` |
|
||||
| 2 | `src/features/fldng-predc-prst/lib/fldng-predc-prst-excel.ts` | Excel 머리글 `강우 시작시각` → `강우시작시각` |
|
||||
| 3 | `src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx` | `renderDateTimeRange`의 시작·종료 입력 4개(분석시각·강우시각)에 `step={3600}` 추가, `onChange`에서 분을 `00`으로 맞춰 저장 |
|
||||
|
||||
- 바꾸지 않는 것: API 파라미터·정렬 키, 기본값(빈 값), 분석도구 메뉴의 침수예측 건수 조회 범위(`model/analysis-time-range.ts`, 입력란과 무관).
|
||||
|
||||
### 검증·영향·롤백
|
||||
|
||||
- 검증: 분석시각·강우시각 입력에서 분 칸이 `00`으로 고정되는지, 빈 값에서 새로 입력해도 분이 `00`이 되는지 확인. 시작 > 종료로 강우시각을 넣어 바뀐 오류 문구 확인, 조회 요청의 시각 파라미터 끝 두 자리가 `00`인지 확인, Excel 다운로드 머리글 확인. `tsc --noEmit`·`git diff --check`.
|
||||
- 영향: 침수예측현황 팝업의 검색 입력 4개, 오류 문구, Excel 머리글 1칸. API·DB·지도 영향 없음.
|
||||
- 롤백: 위 2개 파일을 되돌린다.
|
||||
|
||||
### 구현 결과 (2026-10-21)
|
||||
|
||||
- 계획 1~3을 그대로 반영했다. `ui/fldng-predc-prst-popup-content.tsx`: 오류 문구 `강우시각의 시작은 종료보다 늦을 수 없습니다.`, 분석시각·강우시각 입력 4개에 `step={3600}`과 분 00 고정(`toHourInputValue`). `lib/fldng-predc-prst-excel.ts`: Excel 머리글 `강우시작시각`.
|
||||
- 검증: `tsc --noEmit`(sn 전체) 통과, `git diff --check` 통과. ESLint는 수정 전과 같은 1건(기존 미사용 import `createPredictionAnalysisTimeRange`, 이번 범위 밖). 로그인이 필요해 실제 팝업 화면과 Excel 다운로드는 미확인.
|
||||
- BE·API·DB 변경 없음.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# SFR-011-008 침수예측현황 확률강우량 FE 유지보수 작업계획
|
||||
|
||||
## 작업 목적
|
||||
|
||||
침수예측현황의 선택 시군구가 어떤 확률강우량 기준으로 위험도가 산정되는지 확인할 수 있도록 목록 열과 기준표 팝업을 추가한다.
|
||||
|
||||
## 승인 상태
|
||||
|
||||
- `maintenance` 작업계획
|
||||
- 2026-10-02 사용자 승인, 코드 구현 진행
|
||||
|
||||
## 구현 계획
|
||||
|
||||
- `FldngPredcPrstPopupContent`의 `침수위험도` 오른쪽에 `확률강우량` 액션을 추가한다.
|
||||
- 기준표 팝업 컴포넌트 파일은 표준용어를 사용한 `PrbbtRnflDialog.tsx`로 둔다.
|
||||
- 목록의 확률강우량은 `predc_rain_hr` 기준 `N시간` 형식의 클릭 가능한 지속시간으로 표시한다. 기존 `predictionHour`(`predc_hr`)와 구분한다.
|
||||
- 선택 행의 `sggCd`로 BE 확률강우량 조회 API를 호출한다.
|
||||
- API 경로는 사용자 지정 `GET /api/v1/fldng/predc/prst/prbbt-rnfl`을 사용한다.
|
||||
- 팝업 표는 재현기간을 행, `1시간`, `2시간`, `3시간` 형식의 지속시간을 열로 표시한다.
|
||||
- 팝업 하단에 확률관측소코드·관측소명·면적비율(%) 비율표를 표시한다.
|
||||
- 팝업 맨 아래에 `시간은 강우가 지속된 시간입니다` 안내를 표시한다.
|
||||
- 지속시간 헤더는 기존 팝업 표 헤더 스타일을 유지한다.
|
||||
- 선택 행의 대표 지속시간과 초과빈도년도가 교차하는 확률강우량 기준값 한 셀만 강조 색상으로 표시한다. 표 헤더는 기존 스타일을 유지한다.
|
||||
- 팝업 표가 표시되면 선택한 지속시간 열이 보이도록 표 내부 가로 스크롤을 자동으로 맞춘다.
|
||||
- 선택 행의 초과빈도년도에 해당하는 재현기간 행은 년도와 강수량 글씨를 굵게 표시한다.
|
||||
- 강수량은 mm 단위로 표시하고 로딩·빈 결과·오류 상태를 제공한다.
|
||||
- 기존 행 클릭의 도시침수지도 이동과 별도 액션으로 동작시킨다.
|
||||
- 현황 목록의 분석시각·강우시작시각은 분까지 말줄임 없이 표시하며, 좁은 팝업에서는 표의 가로 스크롤을 사용한다.
|
||||
|
||||
## 검증 계획
|
||||
|
||||
- 제주·서울 등 서로 다른 시군구 기준표 조회
|
||||
- 1~72시간 행과 빈도년도 열 표시
|
||||
- API 오류·빈 결과 표시
|
||||
- 기존 지도 이동 회귀
|
||||
- `pnpm --filter sn run build`
|
||||
|
||||
## 구현 결과 (2026-10-02)
|
||||
|
||||
- 현황 목록에 `확률강우량` 열을 추가하고 대표 지속시간 `predcRainHr`를 `N시간` 링크로 표시한다.
|
||||
- 링크 클릭은 행 선택 지도 이동을 실행하지 않으며, 시군구별 기준표 팝업을 연다.
|
||||
- 팝업은 재현기간 행×지속시간 열의 확률강우량, 하단 관측소 면적비율과 API 합계를 표시한다.
|
||||
- 선택 지속시간·초과빈도년도의 교차값 한 셀만 색으로 강조하고 헤더는 기존 스타일을 유지한다.
|
||||
- 팝업 표의 선택 지속시간 열은 로딩 완료 후 표 내부 가로 스크롤 영역 중앙에 자동으로 나타난다.
|
||||
- 선택 초과빈도년도의 재현기간 행은 년도와 강수량을 굵게 표시한다.
|
||||
- 현황 목록은 자동 표 레이아웃을 유지하고 `widthPercent` 합계를 100%로 맞춘다. 분석시각·강우시작시각은 각각 13%를 적용해 분까지 표시하며, 최소 표 너비 112rem과 가로 스크롤을 유지한다.
|
||||
- `pnpm --filter sn run typecheck`, `git diff --check`가 통과했다. 빌드 중 3002 포트 개발 서버가 확인되어 에이전트가 시작한 빌드만 중단했으며, 빌드 완료와 실화면 검증은 미확인이다.
|
||||
@@ -0,0 +1,31 @@
|
||||
# 관망도 남한 전체 축소 표시
|
||||
|
||||
- 작업일: 2026-10-07
|
||||
- 목적: 최소 줌을 낮춰도 지정 영역 제한 때문에 남한 전체까지 축소되지 않는 현상 보완.
|
||||
- 근거: 사용자 요청. 화면설계서 7번 SN_MN010101의 하수관망도, 요구사항정의서 기능 추적표 97~100행(PR-SN-030)을 확인했다. 해당 원본에는 정확한 줌/좌표 제한값이 없으므로 이번 값은 원본 요구사항으로 주장하지 않는다.
|
||||
|
||||
## 수정
|
||||
|
||||
| 파일 | 내용 |
|
||||
| --- | --- |
|
||||
| app/(map-layout)/map/model/map-constants.ts | SOUTH_KOREA_VIEW_EXTENT의 좌표계·용도 주석 추가. 기존 좌표 유지 |
|
||||
| app/(map-layout)/map/ui/map-page-client.tsx | Gis에 viewShowFullExtent=true 전달 |
|
||||
| packages/common/business_components/src/ui/map/Gis.js | 선택 속성을 View.showFullExtent에 연결. 기본 false로 다른 앱의 기존 동작 유지 |
|
||||
|
||||
범위는 EPSG:5179 `[600000, 1300000, 1500000, 2500000]`이다. 이 범위에는 남한이 포함되어 있으나, OpenLayers의 기본 extent 제약은 화면 가로·세로 중 어느 방향도 범위 밖으로 나가지 못하도록 축소를 제한한다. showFullExtent는 지정 영역 전체가 보이도록 한 축의 화면이 범위를 넘는 축소를 허용한다. 영역을 무제한으로 확장하거나 minZoom을 무시하는 설정이 아니다.
|
||||
|
||||
## 검증
|
||||
|
||||
- 사용자 SN 개발서버 3002(PID 69796)를 유지하고 HMR로 검증했다. 서버 재시작과 Next build는 수행하지 않았다.
|
||||
- 기존 Chrome 탭 연결은 시간 초과하여 별도 인앱 브라우저 검증 탭을 사용했다.
|
||||
- localhost:3002에서 승인된 테스트 계정으로 로그인 후 관망도 진입.
|
||||
- 1280×720 브라우저에서 축소 버튼으로 줌 7.5 → 6.5 → 6.0 이동 확인.
|
||||
- 줌 6.0에서 남한 본토, 제주도와 울릉도·독도가 함께 보이는 화면을 확인했다. 레이어 패널과 분석도구 패널을 유지한 상태다.
|
||||
- git diff --check 통과. 기존 배경지도 타일의 모든 외부 네트워크 요청, 전체 업무 API 및 다른 화면 회귀까지 검증한 것은 아니다.
|
||||
|
||||
## 영향 및 롤백
|
||||
|
||||
- API/DB/레이어 검색·조회조건 변경 없음. 초기 줌 7.5와 중심좌표는 유지한다.
|
||||
- 다른 앱은 선택 속성을 전달하지 않으므로 기존 기본값 false를 유지한다.
|
||||
- 롤백은 이번 선택 속성 전달과 View 연결, 설명 주석만 제거한다. 사용자 변경이나 공통 minZoom은 되돌리지 않는다.
|
||||
- 별도 테스트 파일·캡처 파일을 생성하지 않았다. 최종 문서만 보존하며 검증용 브라우저 탭은 종료한다.
|
||||
@@ -0,0 +1,28 @@
|
||||
# FE 도시침수지도 빈 결과 안내 작업카드
|
||||
|
||||
- 작성일: 2026-10-13 / 상태: 구현 완료 / 실서버·브라우저 검증 대기
|
||||
- 근거: 사용자 요청. extent 위치 정보가 없으면 `위치 정보가 없습니다`로 안내한다.
|
||||
|
||||
## 변경 계획
|
||||
|
||||
- 목록 200·items=[]·total=0: 총 0건과 빈 표를 표시하고 이전 행·오류·로딩 상태를 정리한다.
|
||||
- 단건·그룹 extent 200 응답에 위치 데이터가 없으면 `위치 정보가 없습니다`를 표시하고 지도 이동·선택 강조를 수행하지 않는다.
|
||||
- 공통 응답에서 data가 null이거나 생략된 경우를 좌표 접근 전에 처리한다. 404 문자열로 위치 부재를 판단하는 기존 분기는 제거한다.
|
||||
- 위치 데이터가 있으면 기존 지도 이동을 유지한다. 실제 HTTP 오류·잘못된 좌표는 기존 오류 처리로 구분한다.
|
||||
|
||||
## 수정 대상
|
||||
|
||||
- `app/(map-layout)/map/components/flood-dngr-map/flood-dngr-map-popup-page.tsx`: 목록·그룹 extent·안내 처리.
|
||||
- sn 내 단건·그룹 extent 호출부(침수예측 현황·지도 포함): 검색으로 사용처를 확정하고 동일한 빈 결과 처리 적용.
|
||||
- `src/shared/api/sewerage-api.ts`, `http-transport.ts`: null·data 생략 응답의 전달 방식 확인. 전역 오류 치환은 하지 않는다.
|
||||
|
||||
## 검증·영향
|
||||
|
||||
- 두 extent의 위치 없음: 200, 지정 안내 문구, 지도 이동 없음, null 접근 오류 없음.
|
||||
- 위치 있음: 기존 이동 정상. 실제 404·401·403·5xx·timeout: 오류 안내 유지.
|
||||
- 목록은 데이터 있음 → 0건 → 데이터 있음 재검색 시 행·건수·로딩이 일치한다.
|
||||
- BE 성공 JSON의 data 생략·null 처리를 연동 확인한다. 빈 본문·비정상 응답을 임의로 위치 없음으로 처리하지 않는다.
|
||||
- 영향은 sn의 extent 사용 화면. UI·DB 변경 없음. 롤백은 실제 수정한 기능 파일 복원.
|
||||
- 남은 확인: 전체 extent 호출부와 실제 응답 형태·브라우저 동작.
|
||||
|
||||
FE 구현 및 타입 검사·클릭 처리 모의 검증 완료. 로그인 브라우저 검증은 미수행이다.
|
||||
@@ -0,0 +1,44 @@
|
||||
# SN 인수테스트 팝업 자료 출처 표시 FE 작업 결과
|
||||
|
||||
## 1. 작업 목적
|
||||
|
||||
- 인수테스트(2026-10-16) 고객 요구: "팝업화면 자료의 원천 표시 및 자료 업데이트 주기 표시".
|
||||
- 대상 팝업 7개(침수예측, 침수흔적도, 지반침하정보, 통수위험, 침수위험지도, 도시침수지도, 관로분석)의 목록 아래에 자료 출처·분석 주기 문구를 넣는다.
|
||||
- 기존 하단 설명이 있는 팝업은 그 설명을 유지하고 글머리표로 문구를 더한다(사용자 지시 2026-10-21).
|
||||
|
||||
## 2. 근거
|
||||
|
||||
- `인수테스트_요구사항_1016.pptx` 1쪽(공통 요청), 2~8쪽(화면별 문구). 원본 파일은 사용자 공유 드라이브에 있으며 저장소에는 넣지 않았다.
|
||||
- 문구는 슬라이드 내용을 그대로 옮겼다. 줄 끝 쉼표 제거, `출처 :` 띄어쓰기 통일, 문장 끝 마침표 정도만 맞췄다.
|
||||
|
||||
## 3. 화면별 반영 내용
|
||||
|
||||
| 슬라이드 | 팝업 | 수정 파일 | 하단 글머리표 |
|
||||
| --- | --- | --- | --- |
|
||||
| 2 | 침수예측현황 | `src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx` | (기존) 빈도별 위험·경계·주의 기준, (기존) 행 선택 안내, 분석 실행 시기, 분석 방법(확률강우량 출처 map.wamis.go.kr, 도시침수지도 출처 한강홍수통제소) |
|
||||
| 3 | 통수위험 | `app/(map-layout)/map/components/water-risk/water-risk-popup-page.tsx` | 판정 기준(위험·위험(매핑관로없음)·정상), 지도에 빨간 관로가 안 보일 수 있는 경우, 분석 실행 시기, 분석 방법(SWMM), 최대 침수예상시점·최대침수유량 설명 |
|
||||
| 4 | 침수발생이력(침수흔적도) | `app/(map-layout)/map/components/fldng-hstry/fldng-hstry-popup-page.tsx` | (기존) 행 클릭 안내, 출처 : 행정안전부 재난보험과(2002년~2025년) |
|
||||
| 5 | 도시침수지도 목록 | `app/(map-layout)/map/components/flood-dngr-map/flood-dngr-map-popup-page.tsx` | 출처 : 한강홍수통제소, 행정구역별 도시침수지도(2025.09) |
|
||||
| 6 | 침수위험지도 목록 | `app/(map-layout)/map/components/sbmrg-dngr-map/sbmrg-dngr-map-popup-page.tsx` | 출처 : 하수도정비 중점관리지역 20개소 과업 산출물 |
|
||||
| 7 | 지반침하 목록 | `app/(map-layout)/map/components/sinkhole/sinkhole-popup-page.tsx` | 출처 : 지하안전정보시스템 지반침하사고 정보 연계 표출(연계 주기 : 매 시간 연계) |
|
||||
| 8 | 관로분석 | `app/(map-layout)/map/components/pipe-analysis/pipe-analysis-popup-page.tsx` | 국토교통부로부터 제공받은 GIS 관로정보(2025년)를 기반으로 분석하고 있음 |
|
||||
|
||||
- 공통 표시 컴포넌트 `src/features/window-popup/ui/popup-footer-note.tsx`(신규): 침수예측현황 하단 안내와 같은 글꼴(`text-xl text-muted-foreground`)의 글머리표 목록. 7개 팝업이 같은 모양을 쓴다.
|
||||
- 통수위험: 판정 기준 문구를 목록 머리글 오른쪽에서 하단으로 옮겼다(슬라이드 3 "침수예측과 동일한 위치에 표시할것").
|
||||
- 침수발생이력: 기존 회색 상자 안내를 공통 글머리표 모양으로 바꿨다(문구 유지).
|
||||
|
||||
## 4. 검증
|
||||
|
||||
- `tsc --noEmit`(sn 전체) 통과.
|
||||
- ESLint: 변경 8개 파일 모두 수정 전과 같은 결과(침수예측현황의 기존 미사용 import `createPredictionAnalysisTimeRange` 1건 외 0건).
|
||||
- `git diff --check` 통과.
|
||||
- 미실행: 로그인이 필요해 실제 팝업 화면 확인 못 함. `next build` 미실행.
|
||||
|
||||
## 5. 영향·롤백·남은 위험
|
||||
|
||||
- 영향: 7개 팝업의 목록 아래 문구 표시만 바뀐다. API·상태·데이터 변경 없음.
|
||||
- 롤백: 위 7개 파일을 되돌리고 `popup-footer-note.tsx`를 삭제한다.
|
||||
- 남은 위험
|
||||
- 팝업 높이가 낮으면 하단 문구가 길어(통수위험 5줄) 목록 영역이 그만큼 줄어든다. 화면 확인 후 필요하면 글꼴·간격을 조정한다.
|
||||
- 슬라이드 문구 중 "관측강우가 0mm이상으로 강우가 발생시"는 고객 문구 그대로 두었다. 의미상 "0mm 초과"인지 고객 확인이 필요할 수 있다.
|
||||
- 슬라이드 4의 "25년까지 데이터가 있는데 자동으로 26년으로 설정되는 문제"(지도 레이어 침수흔적도 연도 기본값)는 이번 범위(하단 문구)가 아니라 수정하지 않았다.
|
||||
@@ -995,3 +995,53 @@
|
||||
### 2026-10-01 조사양식 안내창 폭 조정
|
||||
- 해당 안내만 56rem 폭과 화면 여백 최대폭을 적용한 모달로 표시한다. 단어 유지와 균형 줄바꿈으로 두 줄 표기를 유도하며 문구와 저장 시점은 유지한다. 다른 공통 알림창은 변경하지 않는다.
|
||||
- git diff --check 통과. 좁은 화면에서는 잘림 방지를 위해 추가 줄바꿈을 허용한다.
|
||||
|
||||
## 2026-10-02 quick_vibe 등록 입력 필수 표시 보완
|
||||
|
||||
- 사업대상 처리분구 제목에 기존 폼과 같은 붉은 별표와 접근성용 필수 문구를 추가했다. 기존 1개 이상 선택 검증은 유지한다.
|
||||
- 정밀조사연장은 숫자·소수점 하나·입력 삭제만 허용한다. 문자/한글/부호/지수/콤마/복수 소수점 입력 및 붙여넣기는 거부한다. km 소수 입력과 기존 저장 검증은 유지한다.
|
||||
- 원본: 요구사항 기능 시트76행 SFR-010-004, PPT 실제39슬라이드 SN_MN040105 사업정보 등록/처리분구 선택/정밀조사연장(km).
|
||||
- 검증: SN 전체 타입검사 exit0, 숫자/소수/삭제 및 거부 문자열 Node 검증 통과, git diff --check 통과. 실브라우저 입력 검증은 미수행.
|
||||
- 수정 소스: `frontend/apps/sn/src/features/exmn/ui/exmn-business-register-popup-page.tsx`. API/백엔드 코드·저장 계약 변경 없음. 기존 GIS 작업은 변경하지 않았다.
|
||||
- 롤백: 해당 소스의 이번 필수 표시/입력 제한만 복원한다.
|
||||
|
||||
|
||||
## 2026-10-19 맨홀 조사보고서 GIS 번호 연결 (maintenance)
|
||||
|
||||
- 목적: 별도 맨홀 식별체계 확정 전 맨홀 PDF의 `맨홀번호`에 조사시설 GIS 번호(테스트 188001)를 넣고 지도에서 해당 보고서를 열람한다. 지도 도형은 기존 상류맨홀 매칭을 유지한다.
|
||||
- 근거: 요구사항정의서 `3. (기능)요구사항정의 및 추적표` SFR-010-006/007, 화면설계서 실제 42쪽의 식별기준 미확정 메모 및 43쪽 `SN_MN040101` 조사결과 등록. 이번 사용자 지시로 임시 GIS 번호 연결 기준을 확정했다.
|
||||
- BE: `ExmnManholeReportService.java` 신규, `ExmnPipePdfValidator.java` 표제별 불변 판독기, `ExmnBusinessController.java`의 `GET /api/v1/exmnBusinesses/{exmnBizId}/manholeReports/{pipeNumber}` 및 `/availability`, `ExmnFacilityRepository.java`의 조사유형별 첨부 존재 여부 분기.
|
||||
- 맨홀 첨부 `mnhlExmnAtchFileId`와 활성 MNHL의 `gisManagementNo`만 사용한다. 선택 번호가 없거나 활성 시설이 중복되면 임의 보고서를 반환하지 않는다. 매 요청 현재 PDF를 판독하므로 이전 관로 쪽 캐시를 사용하지 않는다. DB 스키마 및 업무 데이터 변경 없음.
|
||||
- FE: `exmn-business-api.ts`, `exmn-survey-result-section.tsx`, `exmn-pipe-report-popup-content.tsx`, `exmn-result-register-popup-page.tsx`. MNHL에서도 보고서 버튼을 표시하고 jobType에 따라 API·제목·번호 안내를 선택한다. PPL 기본 호출은 유지한다.
|
||||
- 업로드 검토의 PDF 내부 번호 대조는 이번 범위에 추가하지 않았다. 번호 입력 기준과 열람 시 대조한다는 안내를 결과등록 화면에 표시한다.
|
||||
- 산출물: 작업루트 `output/pdf/맨홀_현황조사_보고서_188001.pdf`. 사용자 원본 1쪽의 맨홀번호만 188001로 변경했으며 보고서번호·노선명 등 예시값과 사진은 원본을 유지했다. 실제 납품 조사자료가 아닌 기능 테스트용이다.
|
||||
- 검증: BE compileJava --offline 성공, SN 전체 typecheck 성공, 양 저장소 diff --check 성공. 임시 JUnit 5건 성공(실제 PDF 번호/쪽 추출, PPL 표제 분리, 다중/비연속 번호 거부·연속 쪽, MNHL 첨부 선택/DB 캐시 미사용/미첨부, 없는 번호/중복 시설). PDF 재열람·텍스트 및 렌더링 육안 검증 완료.
|
||||
- 미검증: 로컬 3002/8883 실행 포트가 없으므로 실제 서버 업로드·저장·지도 선택·브라우저 팝업은 수행하지 않았다. 188001 MNHL 조사시설 및 상류맨홀 GIS 도형의 실제 존재를 확인한 것으로 간주하지 않는다.
|
||||
- 수동 절차: 변경 BE/FE 실행 → 188001을 가진 MNHL 조사자료의 사업에서 결과등록 → CCTV 조사결과(맨홀)에 테스트 PDF 등록·저장 → 맨홀조사 레이어의 해당 상류맨홀 선택 → 조사보고서 보기 → 제목 맨홀 조사보고서/GIS 번호188001/1쪽 확인. 관로 보고서와 미첨부·없는 번호도 회귀 확인한다.
|
||||
- 롤백: 위 소스의 이번 변경과 신규 ExmnManholeReportService를 함께 복원한다. DB 롤백은 없다. 실제 첨부 등록을 수행한 경우에는 별도로 이전 첨부를 복원한다.
|
||||
- 남은 리스크: 번호는 확정 맨홀번호가 아닌 조사시설 GIS 번호의 임시 계약이며, 스캔 PDF는 판독 불가하다. 맨홀 PDF는 availability/열람 때 각각 판독하므로 대형 파일 성능은 후속 검증 대상이다. 테스트 소스·원시 결과·렌더링 임시 이미지는 요약 후 정리하고 최종 PDF는 보존한다.
|
||||
|
||||
|
||||
## 2026-10-19 후속: 기존 사업 맨홀 PDF 단독 교체
|
||||
- 원인: 보고서 열람을 추가했지만 결과등록 화면은 모든 검토에 Excel을 요구하고 저장에 Excel·PPL 검토 성공을 요구했다.
|
||||
- 변경: 맨홀 PDF 하나만 선택하면 기존 활성 MNHL 조사시설의 GIS 번호로 PDF 번호를 대조한다. 기존 조사자료 없음·불일치·중복·잘못된 PDF는 차단한다. 선택 PDF의 일부 시설 포함은 허용한다.
|
||||
- 저장 직전 다시 검토한 뒤 선택한 MNHL 첨부만 기존 attachments API로 연결한다. Excel imports/commit과 양식 생성 확인은 호출하지 않아 기존 시설·집계·관로첨부를 유지한다. Excel을 선택한 신규/전체 결과 저장 흐름은 유지한다.
|
||||
- 수정: BE ExmnBusinessController(선택 mnhlFile 입력 및 Excel 없는 MNHL 검토 분기), ExmnManholeReportService(validateReplacement), FE exmn-result-register-popup-page(검토·저장 조건과 검토안내).
|
||||
- 검증: BE compileJava, SN typecheck, 임시 JUnit 4건(기존번호 정상·무쓰기, 기존결과 없음, 번호 불일치/중복, 누락/손상 PDF) 통과. 원본 요구사항·지도 기준은 앞선 절과 동일하다. DB 스키마 변경 없음.
|
||||
- 롤백: 이번 단독 교체 분기만 복원한다. 앞선 맨홀 보고서 열람 구현은 유지할 수 있다.
|
||||
|
||||
- 실제 브라우저 확인: 기존 테스트 사업 `6fe818cb-0a7a-4379-969c-150150155f91`에서 Excel/PPL 미선택·188001 맨홀 PDF만 선택 → 검토 완료 → 저장 → 부모 사업목록 성공 안내 확인. 등록한 맨홀 PDF를 보고서 팝업에서 GIS 번호188001/1쪽으로 실제 표시했다. 지도 클릭 경로 자체는 이번 후속에서 재실행하지 않았다.
|
||||
- 검토 중 기존8883 서버의 닫힌 Spring 컨텍스트로 일반 API도500 발생. 사용자 명시 승인 후 동일 실행인수·local 프로필·포트8883·context /sewerage-service로 재시작하여 정상화했다. 교체 서버 PID24204는 사용자가 이어서 테스트하도록 유지한다. 종료명령: Stop-Process -Id 24204. 실행중 필요한 classpath 사본과 stdout/stderr는 작업루트 .codex-tmp/manhole-backend-classpath.jar 및 manhole-backend.out.log/err.log에 유지한다(테스트용 임시 서버가 아닌 승인된 사용자 서버 교체).
|
||||
- 부모 성공문구도 첨부 교체에 맞게 `조사결과 저장을 완료했습니다.`로 보정했다(exmn-registration-page.tsx). 실제 첨부는 변경했으며 기존 시설·집계는 Excel commit을 호출하지 않아 유지한다. 되돌릴 경우 이전 맨홀 첨부를 재등록한다.
|
||||
|
||||
|
||||
## 2026-10-19 최종 정정: 실제 GIS 맨홀번호와 선택 사업 기준
|
||||
- 사용자 확정: PDF 맨홀번호는 조사자료 관로번호가 아닌 실제 GIS 맨홀번호이다. 앞선 gisManagementNo 기준 설명·구현은 이 절로 대체한다.
|
||||
- 원인: 지도보기 진입은 테스트 사업이었지만 전체 사업 레이어의 겹친 시설이 선택됐다. 실제 화면은 오류 확인 테스트_수정/조사관로188003/시설관리번호188001이었다. 사용자 선택 실수가 아닌 지도 범위와 번호 계약 불일치이다.
|
||||
- FE use-exmn-result-map.ts: 조사관리에서 사업·처리분구를 지정해 진입하면 해당 범위만 표출·선택한다. 일반 지도 진입은 기존 전체 범위를 유지한다. 기존 2026-10-01 전체표출 정책의 조사관리 진입 부분을 이 결정으로 대체한다.
|
||||
- FE exmn-survey-result-section.tsx: MNHL 보고서 키는 fclt_mng_no/fcltMngNo, PPL은 기존 gisManagementNo. 조사 결과 표에 GIS 번호를 명시한다. 결과등록 안내도 실제 GIS 맨홀번호로 정정했다.
|
||||
- BE ExmnManholeReportService: 기존 ExmnMapLayerService의 동일 지역·상류맨홀 매칭 결과 fclt_mng_no 집합으로 업로드 검토·열람 대상을 확인한다. 조사 관로번호를 맨홀번호로 대체하지 않는다. 같은 실제 맨홀에 여러 조사관로가 연결되면 번호 집합으로 처리한다. 스키마·조사시설·원천 GIS 변경 없음.
|
||||
- 검증: 실제 GIS188001/조사관로188003 분리, PDF 검토·열람 성공, 조사관로 번호 직접 요청 거부 및 GIS 미연결 거부를 묶은 JUnit1건 통과. BE 컴파일·FE 타입검사·diff --check 통과. 임시 테스트 소스와 결과 정리 완료.
|
||||
- 실브라우저: 테스트 사업→초이처리분구→지도3개 표시→실제188001 클릭→사업명 테스트/GIS 맨홀번호188001 확인→조사보고서 보기→1쪽 PDF와188001 표시 성공. 사용자도 최종 정상동작 확인했다.
|
||||
- 실행 서버: 기존 재시작 승인 범위에서 코드 반영 후8883 PID27788로 유지한다(이전PID24204 종료). classpath/실행로그는 기존 .codex-tmp/manhole-backend-* 경로 유지. 종료명령 Stop-Process -Id 27788.
|
||||
- 롤백: 위 FE3개 소스와 ExmnManholeReportService의 이번 변경을 함께 복원한다. DB 롤백 없음. 잔여 리스크는 GIS 미연결 자료 열람 불가 및 대형 사업의 지도 매칭·PDF 판독 비용이며, 임의 위치/다른 사업 fallback을 하지 않는다.
|
||||
|
||||
@@ -400,3 +400,15 @@
|
||||
- 기존 `SFR-010-012/013` 사진 확인 흐름에서 사용자 요청에 따른 표시 보완이다. 등록 화면, API·DB와 업로드·저장 처리는 변경하지 않았다.
|
||||
- 로컬 HMR 후 실제 사진 4건의 12px/18px/500 적용, 가로 잘림 없음과 밑줄 표시를 확인했다. 사진 선택·미리보기 전환 후 기존 선택으로 복원했다. 신규 사진 업로드는 실행하지 않았다.
|
||||
- `git diff --check` 통과. 롤백은 파일명·촬영일자의 글자 클래스만 기존 값으로 되돌린다.
|
||||
|
||||
## 2026-10-02 quick_vibe 등록 입력 필수 표시 보완
|
||||
|
||||
- 설정된 SN DB의 information_schema.columns를 psql로 읽기 전용 조회했다(default_transaction_read_only=on). 대상은 sm_bs_sn.tb_sn_drdgn_job, tb_sn_drdgn_ppl, tb_sn_drdgn_job_photo 3개 테이블이다. DB 데이터/DDL 변경 없음.
|
||||
- 작업 테이블의 drdgn_ymd/reg_se_cd/pstn_src_cd/lot/lat, 관로 테이블의 fid/mng_inst_cd/fclt_mng_no/관로 및 준설 좌표/거리 등 NOT NULL을 Repository 저장값과 대조했다.
|
||||
- 사용자 확인이 필요한 위치등록·자동 연결 관로에 기존 TableHead data-required 별표를 적용했다. 준설일자의 기존 필수 표시는 유지했다.
|
||||
- 사진은 DredgingRequestValidator의 PHOTO 방식 1장 이상 규칙에 따라 PHOTO 모드에서만 별표를 표시한다. MANUAL에는 필수 표시하지 않는다. 촬영일시 sht_dt는 nullable이므로 필수로 표시하지 않는다.
|
||||
- ID/순번/등록시각/사진건수 및 자동 매핑된 관로 속성은 시스템이 채우므로 개별 사용자 입력 필수 항목으로 추가하지 않는다. 등록 방식도 기존 기본 선택/버튼을 유지한다.
|
||||
- 원본: 요구사항 기능 시트84행 SFR-010-012, PPT 실제53슬라이드 SN_MN040201 준설 결과 업로드.
|
||||
- 검증: DB 메타데이터 조회 성공, 기존 서버/FE 저장 검증 대조, SN 전체 타입검사 exit0, git diff --check 통과. 실브라우저 표시는 미수행.
|
||||
- 수정 소스: `frontend/apps/sn/src/features/dredging/ui/dredging-registration-body.tsx`. API/백엔드 코드·저장 계약 변경 없음. 기존 GIS 작업은 변경하지 않았다.
|
||||
- 롤백: 해당 소스의 이번 필수 표시/입력 제한만 복원한다.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# SFR-011-008 침수예측현황 확률강우량 FE 구현기록
|
||||
|
||||
## 구현 내용
|
||||
- 확률강우량 팝업 컴포넌트는 `PrbbtRnflDialog.tsx`에 둔다.
|
||||
- 침수위험도 오른쪽에 확률강우량 열을 추가하고 대표 누적강우 지속시간 `predcRainHr`를 `N시간` 링크로 표시했다.
|
||||
- 링크는 `GET /api/v1/fldng/predc/prst/prbbt-rnfl?sggCd=...`를 호출한다.
|
||||
- 팝업 본문에 재현기간 행×지속시간 열의 확률강우량표를 표시한다.
|
||||
- 선택 지속시간·초과빈도년도의 교차값 한 셀만 색으로 강조하고 표 헤더는 기존 스타일을 유지한다.
|
||||
- 팝업 표가 로딩되면 목록에서 선택한 지속시간 열이 보이도록 표 내부 가로 스크롤을 자동으로 맞춘다.
|
||||
- 선택 행의 초과빈도년도에 해당하는 재현기간 행은 년도와 강수량을 굵게 표시한다.
|
||||
- 팝업 하단에 확률관측소코드·관측소명·면적비율 및 API 합계를 표시한다.
|
||||
- 팝업 맨 아래에 `시간은 강우가 지속된 시간입니다` 안내를 표시한다.
|
||||
- 링크 클릭 이벤트는 기존 행 선택의 지도 이동으로 전파하지 않는다.
|
||||
- 침수예측현황 목록은 자동 표 레이아웃에서 `widthPercent` 합계 100%를 CSS 너비로 전달한다. 분석시각·강우시작시각은 각각 13%를 적용해 말줄임 없이 분까지 표시하고, 최소 표 너비 112rem과 가로 스크롤을 유지한다.
|
||||
|
||||
## 검증
|
||||
- `pnpm --filter sn run typecheck` 성공
|
||||
- `git diff --check` 성공
|
||||
- 빌드 중 개발 서버(3002)가 확인되어 에이전트가 시작한 빌드만 중단했다. 빌드 완료와 실화면 검증은 미확인이다.
|
||||
|
||||
## 수동 확인 필요
|
||||
- 실제 로그인 화면에서 팝업 크기·가로 스크롤·강조 색상 및 지도 이동 회귀 확인
|
||||
@@ -0,0 +1,62 @@
|
||||
# SN GIS 지도 이동 검색조건 초기화 통합구현기록
|
||||
|
||||
## 2026-10-02 유지보수 사전 분석 — 구현 미착수
|
||||
|
||||
- 사용자 범위: 프론트+백엔드 영향 분석. 실제 구현은 다음 단계.
|
||||
- 확정 대상: 상단 지역·시설 검색조건. 목록에서 선택한 분석 결과/레이어 상태 유지.
|
||||
- 원본: 요구사항정의서 기능 시트 93행 SFR-011-007, 96행 SFR-012-001; 화면설계서 실제 슬라이드 19/SN_MN010201, 21/SN_MN010202.
|
||||
- `ProcSearchBar` 내부 state와 공통 GeoJSON 검색/직접 fit의 분산 구조를 확인했다. 통수위험은 검색바 갱신이 없고 침입수/RDII는 반대로 지역·시설 동기화 이벤트가 있다.
|
||||
- 필수 대상 15개 기능 묶음과 별도 버튼/지도 진입 경로는 공통 설계의 목록을 기준으로 한다. 기능 목록은 정적 코드 기준이며 브라우저에서 전수 검증한 목록이 아니다.
|
||||
- 제안: SN 지도별 공통 이동 처리, 검색바 optional reset revision, 공통 GeoJSON 검색의 optional 성공 callback. `moveend` 일괄 초기화 금지.
|
||||
- 구현 시 핵심 파일: `map/ui/map-search-bar.tsx`, `map/ui/map-page-client.tsx`, `map/lib/use-map-runtime-bridge.ts`, 각 결과 지도 hook, `src/features/facility-detail`의 직접 이동부. 허용 공통 경로는 `packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx` 및 필요한 `lib/gis.layer.js`로 한정한다.
|
||||
- 비동기 시설 후보 조회의 늦은 응답, message/storage 중복, 실패 시 유지, 검색바 직접 조회의 값 보존을 검증한다.
|
||||
- 이번 실제 수정은 문서뿐이다. 타입검사·빌드·브라우저·API 시험은 미수행이며 새 테스트 파일/서버를 생성하지 않았다.
|
||||
- 기존 문서 경로와 파일명은 유지했다. 기존 한글 주석의 일부 손상은 본 작업에서 수정하지 않았다.
|
||||
- 롤백: 이 기록 및 공통 설계/검증계획의 이번 추가분만 복원. 코드·API·DB 영향 없음.
|
||||
- 상세 설계: `IIPBS/docs/작업계획/SN-GIS-지도이동검색조건초기화/001-de-SN-GIS-지도이동검색조건초기화-설계.md`.
|
||||
|
||||
## 2026-10-02 구현 및 자동검증
|
||||
|
||||
- 사용자 후속 구현 지시에 따라 공통 지도 이동/검색조건 초기화를 반영했다.
|
||||
- 지도별 요청 세대와 reset revision, 검색바 optional props, GIS 비동기 성공 callback, 직접 fit/지역 이동 경로를 연결했다.
|
||||
- 기존 분석 결과·선택 강조·레이어·시설유형/필터·상단 직접 검색 동작은 유지한다. 실행 경로별 보존은 브라우저 회귀에서 추가 확인한다.
|
||||
- 침입수/RDII 기존 검색바 채우기 제거, 선택 요청 ID 중복 제거, 통수위험의 조회 시작/결과 식별과 오래된 응답 방어를 구현했다.
|
||||
- 타입검사 최종 exit 0, 파일 없는 Node 실행 검증 4종 및 diff 공백 검사 통과. 중간의 FitOptions/부분 지도 인터페이스 타입 오류는 수정 후 재검사했다.
|
||||
- 사용자 개발 서버 가동을 확인하여 build/재시작하지 않았다. 새 프로세스/검증파일 생성 없음. 브라우저 로그인 대기.
|
||||
|
||||
### 실제 소스 변경 파일 (frontend 기준)
|
||||
|
||||
- `apps/sn/app/(map-layout)/map/components/exmn/use-exmn-result-map.ts`
|
||||
- `apps/sn/app/(map-layout)/map/components/inftwt-result/inftwt-result-popup-page.tsx`
|
||||
- `apps/sn/app/(map-layout)/map/components/inftwt-result/use-inftwt-result-map.ts`
|
||||
- `apps/sn/app/(map-layout)/map/components/rdii-result/rdii-result-popup-page.tsx`
|
||||
- `apps/sn/app/(map-layout)/map/components/rdii-result/use-rdii-result-map.ts`
|
||||
- `apps/sn/app/(map-layout)/map/components/water-risk/water-risk-popup-page.tsx`
|
||||
- `apps/sn/app/(map-layout)/map/lib/use-flood-history-selection.ts`
|
||||
- `apps/sn/app/(map-layout)/map/lib/use-map-runtime-bridge.ts`
|
||||
- `apps/sn/app/(map-layout)/map/lib/use-water-risk-layer.ts`
|
||||
- `apps/sn/app/(map-layout)/map/search-modal/use-basic-tab.ts`
|
||||
- `apps/sn/app/(map-layout)/map/search-modal/use-operation-tab.ts`
|
||||
- `apps/sn/app/(map-layout)/map/search-modal/use-search-modal.ts`
|
||||
- `apps/sn/app/(map-layout)/map/ui/map-page-client.tsx`
|
||||
- `apps/sn/app/(map-layout)/map/ui/map-search-bar.tsx`
|
||||
- `apps/sn/src/features/facility-detail/ui/smh.tsx`
|
||||
- `apps/sn/src/features/facility-detail/ui/swtrmt-prcs-zone-analysis.tsx`
|
||||
- `apps/sn/src/features/fldng-predc-prst/lib/use-flood-prediction-map.ts`
|
||||
- `packages/common/business_components/src/ui/map/lib/gis.layer.js`
|
||||
- `packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx`
|
||||
- `packages/common/business_components/src/ui/map/searchBar/utils.ts`
|
||||
- `apps/sn/src/features/map-search/lib/map-navigation.ts`
|
||||
|
||||
### 남은 검증과 롤백
|
||||
|
||||
- 실제 데이터로 15개 기능의 지도 이동/상단 초기화 및 분석 표시 보존 확인은 로그인 후 수행한다.
|
||||
- 롤백 시 위 소스 변경을 함께 복원한다. BE/DB 롤백 없음.
|
||||
|
||||
## 2026-10-02 적용 누락 재점검 및 보완
|
||||
|
||||
- 사용자 재확인 요청으로 전체 지도 이동 호출·메시지 경로를 대조했다. 침수흔적 상세 심선/위선 연도 선택(신규 조회·캐시)과 상세 탭 복귀 FID 재조회 경로의 초기화 누락을 발견해 보완했다.
|
||||
- 수정: `frontend/apps/sn/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-feature-click.tsx`, `frontend/apps/sn/app/(map-layout)/map/ui/map-page-client.tsx`. 누적 소스 변경은22개다.
|
||||
- 기존15개 기능 묶음 및 추가 경로의 적용표를 공통 설계/통합검증결과에 기록했다. 초기 대표 목록 적용 보고와 하위 상세 경로 누락을 구분했다.
|
||||
- SN 전체 타입검사 exit0, 캐시 성공·지연 응답 거부·신규 callback 전달 실행검증 및 diff 공백 검사 통과. 임시파일·서버 생성 없음, BE/API/DB 변경 없음.
|
||||
- 실브라우저 전수 검증은 로그인 대기 상태로 유지한다. 롤백은 위2개 파일의 이번 callback 추가를 복원한다.
|
||||
@@ -52,7 +52,7 @@
|
||||
| 행 선택 지도 이동 | 기존 그룹 범위 이동 유지 |
|
||||
| 일반 지도 통신 | 빈도 부모 3개 TileWMS |
|
||||
| 빈도 제한 | 부모 `flterSql`을 WMS `CQL_FILTER`로 전달 |
|
||||
| 침수심도 스타일 | 자식 5건 기반 동적 SLD |
|
||||
| 침수심도 스타일 | GeoServer 레이어 기본 스타일(2026-10-16부터, 11절). 이전에는 자식 5건 기반 동적 SLD |
|
||||
| 범례 | 숨겨진 자식 설정의 기존 색상·명칭 유지 |
|
||||
| 100년 빈도 | 화면 및 WMS 전환 대상에서 제외 |
|
||||
| 기존 다른 레이어 | 기존 WFS 경로 유지 |
|
||||
@@ -88,3 +88,16 @@
|
||||
- 운영에서 행 선택 후 그룹 extent API가 `ST_Envelope` 스키마 해석 오류로 실패해 지도 이동이 중단된 원인을 백엔드 SQL에서 보완했다.
|
||||
- 기존 시군구·침수빈도 그룹 범위 이동 계약과 화면 동작은 유지한다.
|
||||
- 재배포 후 목록 행 클릭, 지도 중심 이동, 선택 빈도 WMS 표출을 회귀 확인한다.
|
||||
|
||||
## 11. 2026-10-16 WMS SLD_BODY 제거
|
||||
|
||||
- 근거: `COMMON_DOCS_ROOT/작업계획/SN-도시침수지도-정보제공포털-목록보기/003-de-SN-도시침수지도-WMS502-SLD_BODY제거-작업계획.md` (SFR-011-008·009, 화면설계서 24·28쪽 SN_MN010205·SN_MN010206)
|
||||
- 운영에서 `SLD_BODY`가 포함된 WMS 요청이 nginx 502로 실패해, 침수심도 스타일을 GeoServer 레이어 기본 스타일로 옮기고 요청에서 `SLD_BODY`를 뺐다.
|
||||
- `gis.layer.js`
|
||||
- `getWmsLayer()`에서 도시침수지도 `SLD_BODY` 생성 분기와 `gis.wms-sld.js` import를 제거했다.
|
||||
- `STYLES=''`(레이어 기본 스타일), 빈도 `CQL_FILTER`, `drawOrdr`, `minViewSize`는 그대로다.
|
||||
- `gis.wms-sld.js`: 삭제했다(사용처가 `gis.layer.js` 하나뿐).
|
||||
- 범례(`water-depth.tsx`)는 기존처럼 숨겨진 자식 5건의 DB 색상을 쓴다. GeoServer 기본 스타일이 제공 SLD(`tb_ms_flood_dngr_map_depth.sld`)로 교체되어야 지도 색과 범례가 일치한다.
|
||||
- 검증: 남은 참조 없음, `git diff --check`·ESLint 통과, 개발 GeoServer 동일 파라미터 요청 HTTP 200 PNG.
|
||||
- 미수행: 사용자 개발 서버 실행 중이라 `business_components`·`sn` 빌드 보류, 로그인 필요로 브라우저 Network 확인 보류.
|
||||
- 롤백: `gis.layer.js`의 SLD 분기와 `gis.wms-sld.js`를 복원한다.
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FLDNG_HSTRY_LAYER_NAME,
|
||||
FLDNG_HSTRY_LAYER_TYPE,
|
||||
FLDNG_HSTRY_MIN_YEAR,
|
||||
FLDNG_HSTRY_MAX_YEAR,
|
||||
FLDNG_HSTRY_PREVIEW_LAYER_TYPE,
|
||||
FLDNG_HSTRY_ROOT_NAME,
|
||||
type FldngHstryApiItem,
|
||||
@@ -50,11 +51,10 @@ export const formatFldngHstryDateTime = (
|
||||
return [normalizedDate, normalizedTime].filter(Boolean).join(" ");
|
||||
};
|
||||
|
||||
export const buildFldngHstryDefaultYearRange = (
|
||||
currentYear = new Date().getFullYear(),
|
||||
): FldngHstryYearRange => ({
|
||||
/** 침수흔적도 초기 조회 범위를 제공 자료의 연도 범위로 설정한다. */
|
||||
export const buildFldngHstryDefaultYearRange = (): FldngHstryYearRange => ({
|
||||
startYear: String(FLDNG_HSTRY_MIN_YEAR),
|
||||
endYear: String(currentYear),
|
||||
endYear: String(FLDNG_HSTRY_MAX_YEAR),
|
||||
});
|
||||
|
||||
export const normalizeFldngHstryYearRange = (
|
||||
|
||||
@@ -14,6 +14,8 @@ export const FLDNG_HSTRY_DEPTH_LAYER_ID = "SN_FLOOD_HISTORY_DEPTH";
|
||||
export const FLDNG_HSTRY_PAGE_SIZE = 10;
|
||||
export const FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE = 2000;
|
||||
export const FLDNG_HSTRY_MIN_YEAR = 2002;
|
||||
/** 침수흔적도 연도 선택 목록과 초기 조회 범위의 최대 연도다. */
|
||||
export const FLDNG_HSTRY_MAX_YEAR = 2025;
|
||||
|
||||
export type FldngHstryDetailCacheValue = {
|
||||
rainAnalysis: Record<string, unknown>;
|
||||
|
||||
@@ -40,6 +40,12 @@ export const getAllCtyFldngPrvtnBusinesses = async (
|
||||
return result.items;
|
||||
};
|
||||
|
||||
/** 등록 화면에 자동으로 넣을 다음 사업순번을 조회한다. */
|
||||
export const getNextCtyFldngPrvtnSeqNo = async () => {
|
||||
const body = await getData(`${API_PATH}/nextSeqNo`);
|
||||
return Number(unwrapData<number>(body));
|
||||
};
|
||||
|
||||
/** 수정 화면의 사업 한 건을 조회한다. */
|
||||
export const getCtyFldngPrvtnBusiness = (id: string) =>
|
||||
getData(`${API_PATH}/${encodeURIComponent(id)}`) as Promise<CtyFldngPrvtnBiz>;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
createCtyFldngPrvtnBusiness,
|
||||
deleteCtyFldngPrvtnBusiness,
|
||||
getCtyFldngPrvtnBusiness,
|
||||
getNextCtyFldngPrvtnSeqNo,
|
||||
updateCtyFldngPrvtnBusiness,
|
||||
} from "../api/cty-fldng-prvtn-biz-api";
|
||||
import type {
|
||||
@@ -91,6 +92,35 @@ const DETAIL_FIELDS = [
|
||||
["rmrkSrcCn", "비고출처", "text"],
|
||||
] as const;
|
||||
type BasicKey = (typeof BASIC_FIELDS)[number][0];
|
||||
/** 저장 전에 반드시 입력해야 하는 항목이다. 시도는 별도 선택이라 region 키로 관리한다. */
|
||||
const REQUIRED_BASIC_KEYS = new Set<string>(["seqNo", "dsgnYmd", "bizNm"]);
|
||||
type FieldErrorKey = BasicKey | "region" | `soa-${string}`;
|
||||
|
||||
/** 금액·면적 입력의 DB 자릿수 규칙이다. intDigits 는 정수부, scale 은 소수부 한계다. */
|
||||
const NUMBER_RULES: Record<string, { label: string; intDigits: number; scale: number }> = {
|
||||
dsgnArea: { label: "지정면적", intDigits: 10, scale: 9 },
|
||||
tpco: { label: "총사업비", intDigits: 20, scale: 2 },
|
||||
gvexp: { label: "국비", intDigits: 20, scale: 2 },
|
||||
lcexp: { label: "지방비", intDigits: 20, scale: 2 },
|
||||
};
|
||||
const SETTLEMENT_RULE = { label: "결산금액", intDigits: 20, scale: 2 };
|
||||
|
||||
/** 숫자 입력이 비었는지, 숫자인지, 0 이상인지, 자릿수를 넘지 않는지 확인한다. */
|
||||
const numberError = (
|
||||
value: unknown,
|
||||
rule: { label: string; intDigits: number; scale: number },
|
||||
) => {
|
||||
const text = String(value ?? "").trim();
|
||||
if (!text) return undefined;
|
||||
if (!/^\d*(\.\d+)?$/.test(text))
|
||||
return `${rule.label}은(는) 0 이상 숫자로 입력해주세요.`;
|
||||
const [integerPart = "", decimalPart = ""] = text.split(".");
|
||||
if (decimalPart.length > rule.scale)
|
||||
return `${rule.label}은(는) 소수점 ${rule.scale}자리까지 입력할 수 있습니다.`;
|
||||
if (integerPart.replace(/^0+/, "").length > rule.intDigits)
|
||||
return `${rule.label}은(는) 정수 ${rule.intDigits}자리를 넘을 수 없습니다.`;
|
||||
return undefined;
|
||||
};
|
||||
type DetailKey = (typeof DETAIL_FIELDS)[number][0];
|
||||
|
||||
/** 등록 화면의 빈 기본·결산·4단계 상세 값을 생성한다. */
|
||||
@@ -294,6 +324,18 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
|
||||
const [notice, setNotice] = useState("");
|
||||
const [ctpvCd, setCtpvCd] = useState("0");
|
||||
const [sggCd, setSggCd] = useState("0");
|
||||
const [fieldErrors, setFieldErrors] = useState<
|
||||
Partial<Record<FieldErrorKey, string>>
|
||||
>({});
|
||||
|
||||
/** 입력을 고치면 해당 항목의 오류 문구를 지운다. */
|
||||
const clearFieldError = (key: FieldErrorKey) =>
|
||||
setFieldErrors((current) => {
|
||||
if (!current[key]) return current;
|
||||
const next = { ...current };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
const listHref = `/oper/ctyFldngPrvtnBiz${searchParams.toString() ? `?${searchParams}` : ""}`;
|
||||
const availableSgg = useMemo(
|
||||
() =>
|
||||
@@ -308,6 +350,13 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
|
||||
if (!ctpv.length) void fetchCtpv();
|
||||
if (!sgg.length) void fetchSgg();
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
useEffect(() => {
|
||||
if (id) return;
|
||||
// 등록 화면은 다음 사업순번을 자동으로 채우고 사용자가 바꾸지 못하게 한다.
|
||||
getNextCtyFldngPrvtnSeqNo()
|
||||
.then((seqNo) => setForm((current) => ({ ...current, seqNo })))
|
||||
.catch(() => setNotice("다음 사업순번을 조회하지 못했습니다."));
|
||||
}, [id]);
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
@@ -337,14 +386,43 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
|
||||
|
||||
/** 필수값·날짜·숫자를 검사한 뒤 등록 또는 수정한다. */
|
||||
const handleSave = async () => {
|
||||
const dates = [
|
||||
form.dsgnYmd,
|
||||
...form.details.flatMap((d) => [d.bgngYmd, d.cmcnYmd]),
|
||||
];
|
||||
const nextErrors: Partial<Record<FieldErrorKey, string>> = {};
|
||||
if (!String(form.ctpvNm ?? "").trim()) nextErrors.region = "시도는 필수입니다.";
|
||||
if (form.seqNo === null || form.seqNo === undefined || String(form.seqNo) === "")
|
||||
nextErrors.seqNo = "사업순번은 필수입니다.";
|
||||
if (!String(form.bizNm ?? "").trim()) nextErrors.bizNm = "사업명은 필수입니다.";
|
||||
if (!String(form.dsgnYmd ?? "").trim())
|
||||
nextErrors.dsgnYmd = "지정일은 필수입니다.";
|
||||
else if (!validDate(form.dsgnYmd))
|
||||
nextErrors.dsgnYmd = "YYYY-MM-DD 형식의 유효한 날짜로 입력해주세요.";
|
||||
const requiredMissing = Object.keys(nextErrors).length > 0;
|
||||
Object.entries(NUMBER_RULES).forEach(([key, rule]) => {
|
||||
const message = numberError(form[key as BasicKey], rule);
|
||||
if (message) nextErrors[key as BasicKey] = message;
|
||||
});
|
||||
form.settlements.forEach((settlement) => {
|
||||
const message = numberError(settlement.soaAmt, {
|
||||
...SETTLEMENT_RULE,
|
||||
label: `${settlement.soaYr}년 결산금액`,
|
||||
});
|
||||
if (message) nextErrors[`soa-${settlement.soaYr}`] = message;
|
||||
});
|
||||
if (Object.keys(nextErrors).length > 0) {
|
||||
setFieldErrors(nextErrors);
|
||||
setNotice(
|
||||
requiredMissing
|
||||
? "필수 항목을 입력해주세요."
|
||||
: "입력값을 확인해주세요.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const dates = form.details.flatMap((d) => [d.bgngYmd, d.cmcnYmd]);
|
||||
if (dates.some((value) => !validDate(value))) {
|
||||
setNotice("일자는 YYYY-MM-DD 형식의 유효한 날짜여야 합니다.");
|
||||
return;
|
||||
}
|
||||
setFieldErrors({});
|
||||
setNotice("");
|
||||
setLoading(true);
|
||||
try {
|
||||
if (id) await updateCtyFldngPrvtnBusiness(id, form);
|
||||
@@ -393,14 +471,16 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
|
||||
suggestionOptions.length > 0 ? `${fieldId}-suggestions` : undefined;
|
||||
const span: 1 | 2 | 3 =
|
||||
key === "excptnMttrCn" ? 3 : key === "bizNm" || key === "pstn" ? 2 : 1;
|
||||
// 수정 화면에서는 중복 판정 업무키인 사업순번과 단계를 변경할 수 없다.
|
||||
const isBusinessKeyLocked = Boolean(id) && (key === "seqNo" || key === "stp");
|
||||
// 사업순번은 등록 시 자동 번호를 보여주고, 수정 시에는 중복 판정 업무키라 바꿀 수 없다.
|
||||
const isBusinessKeyLocked =
|
||||
key === "seqNo" || (Boolean(id) && key === "stp");
|
||||
return (
|
||||
<StandardFormField
|
||||
key={key}
|
||||
id={fieldId}
|
||||
label={label}
|
||||
required={key === "bizNm"}
|
||||
required={REQUIRED_BASIC_KEYS.has(key)}
|
||||
error={fieldErrors[key]}
|
||||
span={span}
|
||||
>
|
||||
<>
|
||||
@@ -408,19 +488,21 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
|
||||
id={fieldId}
|
||||
className="w-full !text-[1.3rem]"
|
||||
maxLength={standardMaxLengths[key]}
|
||||
min={NUMBER_RULES[key] ? 0 : undefined}
|
||||
type={type === "date" ? "date" : type}
|
||||
list={suggestionListId}
|
||||
disabled={isBusinessKeyLocked}
|
||||
value={String(form[key] ?? "")}
|
||||
onChange={(event) =>
|
||||
onChange={(event) => {
|
||||
clearFieldError(key);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
[key]:
|
||||
type === "date"
|
||||
? formatDateInput(event.target.value)
|
||||
: inputValue(event.target.value, type),
|
||||
}))
|
||||
}
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{suggestionListId ? (
|
||||
<datalist id={suggestionListId}>
|
||||
@@ -455,11 +537,17 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
|
||||
)}
|
||||
|
||||
<StandardFormSection title="기본 정보">
|
||||
<StandardFormField id="cty-fldng-ctpv" label="시도">
|
||||
<StandardFormField
|
||||
id="cty-fldng-ctpv"
|
||||
label="시도"
|
||||
required
|
||||
error={fieldErrors.region}
|
||||
>
|
||||
<Select
|
||||
value={ctpvCd}
|
||||
onValueChange={(value) => {
|
||||
const code = String(value);
|
||||
clearFieldError("region");
|
||||
setCtpvCd(code);
|
||||
setSggCd("0");
|
||||
setForm((current) => ({
|
||||
@@ -533,13 +621,16 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
|
||||
key={year}
|
||||
id={`cty-fldng-settlement-${year}`}
|
||||
label={`${year}년`}
|
||||
error={fieldErrors[`soa-${year}`]}
|
||||
>
|
||||
<Input
|
||||
id={`cty-fldng-settlement-${year}`}
|
||||
className="w-full !text-[1.3rem]"
|
||||
type="number"
|
||||
min={0}
|
||||
value={item?.soaAmt ?? ""}
|
||||
onChange={(event) =>
|
||||
onChange={(event) => {
|
||||
clearFieldError(`soa-${year}`);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
settlements: [
|
||||
@@ -554,8 +645,8 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
|
||||
: Number(event.target.value),
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</StandardFormField>
|
||||
);
|
||||
|
||||
@@ -157,21 +157,11 @@ type Search = {
|
||||
};
|
||||
|
||||
/** 브라우저 로컬 날짜를 검색 input이 사용하는 YYYY-MM-DD 형식으로 변환한다. */
|
||||
const formatLocalDateInputValue = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
/** 지정일 기본 검색기간을 최초 사업자료 기준일인 2013-01-01부터 오늘까지로 생성한다. */
|
||||
const createDefaultDesignationDateRange = () => {
|
||||
const to = new Date();
|
||||
return {
|
||||
dsgnYmdFrom: "2013-01-01",
|
||||
dsgnYmdTo: formatLocalDateInputValue(to),
|
||||
};
|
||||
};
|
||||
/** 지정일 검색조건은 처음부터 비워 둔다. 지정일이 없는 사업도 목록에서 보이도록 하기 위함이다. */
|
||||
const createDefaultDesignationDateRange = () => ({
|
||||
dsgnYmdFrom: "",
|
||||
dsgnYmdTo: "",
|
||||
});
|
||||
|
||||
type TableRow = Record<string, string | number | null | undefined> & {
|
||||
ctyFldngPrvtnBizSn: number;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DREDGING_EXTERNAL_MODE } from "../model/disclosure";
|
||||
/** 준설 조회·등록·수정·삭제와 최근접 관로 HTTP 계약을 담당한다. */
|
||||
import {
|
||||
deleteData,
|
||||
@@ -110,12 +111,13 @@ export const fetchDredgingRecords = async (params: {
|
||||
/** 등록된 준설사진의 공통 첨부파일 참조를 조회한다. */
|
||||
export const fetchDredgingPhotos = async (
|
||||
dredgingJobSn: string | number,
|
||||
pipeFid: string,
|
||||
pipeFid: string | null,
|
||||
targetSn?: number,
|
||||
): Promise<DredgingPhoto[]> => {
|
||||
if (!isDredgingBackendEnabled) return [];
|
||||
try {
|
||||
const body = await getRawJson(
|
||||
`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}/photos?fid=${encodeURIComponent(pipeFid)}`,
|
||||
`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}/photos?${targetSn ? `targetSn=${targetSn}` : `fid=${encodeURIComponent(pipeFid ?? "")}`}`,
|
||||
);
|
||||
return toDredgingPhotos(body);
|
||||
} catch (error) {
|
||||
@@ -137,18 +139,18 @@ export const updateDredgingRecord = async (
|
||||
try {
|
||||
await putData(`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}`, {
|
||||
dredgingDate: update.dredgingDate,
|
||||
pipeFid: update.originalPipeFid,
|
||||
targetSn: update.targetSn,
|
||||
registrationMode: update.registrationMode,
|
||||
location: {
|
||||
source: update.locationSource,
|
||||
longitude: update.location.longitude,
|
||||
latitude: update.location.latitude,
|
||||
},
|
||||
expectedPipe: {
|
||||
expectedPipe: update.pipe && !DREDGING_EXTERNAL_MODE ? {
|
||||
fid: update.pipe.fid,
|
||||
mngInstCd: update.pipe.mngInstCd,
|
||||
fcltMngNo: update.pipe.fcltMngNo,
|
||||
},
|
||||
} : null,
|
||||
retainedPhotoSns: update.retainedPhotoSns,
|
||||
newPhotos: update.newPhotos.map((photo) => {
|
||||
const attachment = attachments.get(photo.key);
|
||||
@@ -242,7 +244,7 @@ const mockPipeForLocations = (locations: LocationRequest[]): PipeMatch => {
|
||||
export const findNearestPipeMatches = async (
|
||||
locations: LocationRequest[],
|
||||
): Promise<Map<string, PipeMatch>> => {
|
||||
if (locations.length === 0) return new Map();
|
||||
if (DREDGING_EXTERNAL_MODE || locations.length === 0) return new Map();
|
||||
|
||||
if (!isDredgingBackendEnabled) {
|
||||
const mockPipe = mockPipeForLocations(locations);
|
||||
|
||||
@@ -55,7 +55,7 @@ export const toNearestPipeMatches = (body: any): Map<string, PipeMatch> => {
|
||||
|
||||
for (const item of items ?? []) {
|
||||
if (!item.selected) {
|
||||
failureMessages.push(nearestPipeFailureMessage(item));
|
||||
if (item.failureCode !== "PIPE_NOT_FOUND") failureMessages.push(nearestPipeFailureMessage(item));
|
||||
continue;
|
||||
}
|
||||
matches.set(item.locationKey, {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** 외부용 앱은 관망 정보를 요청하거나 화면에 표시하지 않는다. */
|
||||
export const DREDGING_EXTERNAL_MODE = false;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DREDGING_EXTERNAL_MODE } from "./disclosure";
|
||||
/** 준설 목록·집계 표의 컬럼·표시 변환. 서버 조회/React 상태와 분리한다. */
|
||||
import type { DredgingRecord } from "./types";
|
||||
import { MS_SWAGE_PPL_CODES } from "@/entities/facility/model/facility-code-fallbacks";
|
||||
@@ -103,6 +104,8 @@ export const buildDredgingPopupParams = (record: DredgingRecord) => ({
|
||||
sourcePath: window.location.pathname,
|
||||
id: record.id,
|
||||
dredgingJobSn: record.dredgingJobSn,
|
||||
targetSn: record.targetSn,
|
||||
mappingStatus: record.mappingStatus,
|
||||
pipeFid: record.pipeFid,
|
||||
mngInstCd: record.mngInstCd,
|
||||
ctpv: record.ctpv,
|
||||
@@ -140,7 +143,8 @@ export type TableHeadDefinition = {
|
||||
align?: "left" | "center" | "right";
|
||||
};
|
||||
|
||||
export const municipalityHeadlist: TableHeadDefinition[] = [
|
||||
const fullMunicipalityHeadlist: TableHeadDefinition[] = [
|
||||
{ id: "mappingStatusLabel", title: "관로 지정" },
|
||||
{ id: "rowNumber", title: "순번" },
|
||||
{ id: "ctpv", title: "시도" },
|
||||
{ id: "sgg", title: "시군구" },
|
||||
@@ -156,6 +160,10 @@ export const municipalityHeadlist: TableHeadDefinition[] = [
|
||||
{ id: "registeredDate", title: "등록일자" },
|
||||
];
|
||||
|
||||
export const municipalityHeadlist = DREDGING_EXTERNAL_MODE
|
||||
? fullMunicipalityHeadlist.filter(column => ["rowNumber", "ctpv", "sgg", "dredgingDate", "photoCount", "registeredDate"].includes(column.id))
|
||||
: fullMunicipalityHeadlist;
|
||||
|
||||
export const diameterHeadlist: TableHeadDefinition[] = [
|
||||
{ id: "ctpv", title: "시도" },
|
||||
{ id: "totalLengthKm", title: "관로연장(km)", align: "right" },
|
||||
@@ -202,33 +210,33 @@ export const buildDiameterRows = (records: DredgingRecord[]): TableRow[] =>
|
||||
roundKm(
|
||||
items
|
||||
.filter(predicate)
|
||||
.reduce((sum, record) => sum + record.pipeLengthM, 0),
|
||||
.reduce((sum, record) => sum + (record.pipeLengthM ?? 0), 0),
|
||||
);
|
||||
|
||||
return {
|
||||
ctpv,
|
||||
totalLengthKm: roundKm(
|
||||
items.reduce((sum, record) => sum + record.pipeLengthM, 0),
|
||||
items.reduce((sum, record) => sum + (record.pipeLengthM ?? 0), 0),
|
||||
),
|
||||
diameter450: sumLength((record) => record.pipeDiameterMm <= 450),
|
||||
diameter450: sumLength((record) => (record.pipeDiameterMm ?? 0) <= 450),
|
||||
diameter600: sumLength(
|
||||
(record) => record.pipeDiameterMm > 450 && record.pipeDiameterMm <= 600,
|
||||
(record) => (record.pipeDiameterMm ?? 0) > 450 && (record.pipeDiameterMm ?? 0) <= 600,
|
||||
),
|
||||
diameter700: sumLength(
|
||||
(record) => record.pipeDiameterMm > 600 && record.pipeDiameterMm <= 700,
|
||||
(record) => (record.pipeDiameterMm ?? 0) > 600 && (record.pipeDiameterMm ?? 0) <= 700,
|
||||
),
|
||||
diameter900: sumLength(
|
||||
(record) => record.pipeDiameterMm > 700 && record.pipeDiameterMm <= 900,
|
||||
(record) => (record.pipeDiameterMm ?? 0) > 700 && (record.pipeDiameterMm ?? 0) <= 900,
|
||||
),
|
||||
diameter1000: sumLength(
|
||||
(record) =>
|
||||
record.pipeDiameterMm > 900 && record.pipeDiameterMm <= 1000,
|
||||
(record.pipeDiameterMm ?? 0) > 900 && (record.pipeDiameterMm ?? 0) <= 1000,
|
||||
),
|
||||
diameter1200: sumLength(
|
||||
(record) =>
|
||||
record.pipeDiameterMm > 1000 && record.pipeDiameterMm <= 1200,
|
||||
(record.pipeDiameterMm ?? 0) > 1000 && (record.pipeDiameterMm ?? 0) <= 1200,
|
||||
),
|
||||
diameter1500: sumLength((record) => record.pipeDiameterMm >= 1500),
|
||||
diameter1500: sumLength((record) => (record.pipeDiameterMm ?? 0) >= 1500),
|
||||
recordIds: items.map((record) => record.id),
|
||||
};
|
||||
});
|
||||
@@ -239,7 +247,7 @@ export const buildElapsedYearRows = (records: DredgingRecord[]): TableRow[] =>
|
||||
roundKm(
|
||||
items
|
||||
.filter(predicate)
|
||||
.reduce((sum, record) => sum + record.pipeLengthM, 0),
|
||||
.reduce((sum, record) => sum + (record.pipeLengthM ?? 0), 0),
|
||||
);
|
||||
const elapsedYears = (record: DredgingRecord) =>
|
||||
record.installationYear === null
|
||||
@@ -249,7 +257,7 @@ export const buildElapsedYearRows = (records: DredgingRecord[]): TableRow[] =>
|
||||
return {
|
||||
ctpv,
|
||||
totalLengthKm: roundKm(
|
||||
items.reduce((sum, record) => sum + record.pipeLengthM, 0),
|
||||
items.reduce((sum, record) => sum + (record.pipeLengthM ?? 0), 0),
|
||||
),
|
||||
under10: sumLength((record) => {
|
||||
const elapsed = elapsedYears(record);
|
||||
|
||||
@@ -19,11 +19,11 @@ const buildRegistrationBody = (
|
||||
longitude: group.location.longitude,
|
||||
latitude: group.location.latitude,
|
||||
},
|
||||
expectedPipe: {
|
||||
expectedPipe: group.pipe ? {
|
||||
fid: group.pipe.fid,
|
||||
mngInstCd: group.pipe.mngInstCd,
|
||||
fcltMngNo: group.pipe.fcltMngNo,
|
||||
},
|
||||
} : null,
|
||||
photos: group.photos.map((photo) => {
|
||||
const attachment = attachments.get(photo.key);
|
||||
if (!attachment && requireAttachments) {
|
||||
|
||||
@@ -59,32 +59,35 @@ export type DredgingDraftGroup = {
|
||||
export type DredgingRecord = {
|
||||
id: string;
|
||||
dredgingJobSn: number | string;
|
||||
pipeFid: string;
|
||||
mngInstCd: string;
|
||||
targetSn?: number;
|
||||
mappingStatus?: "AUTO" | "UNMAPPED";
|
||||
pipeFid: string | null;
|
||||
mngInstCd: string | null;
|
||||
ctpv: string;
|
||||
sgg: string;
|
||||
treatmentZone: string;
|
||||
fcltMngNo: string;
|
||||
pipeDiameterMm: number;
|
||||
pipeLengthM: number;
|
||||
pipeMaterial: string;
|
||||
treatmentZone: string | null;
|
||||
fcltMngNo: string | null;
|
||||
pipeDiameterMm: number | null;
|
||||
pipeLengthM: number | null;
|
||||
pipeMaterial: string | null;
|
||||
installationYear: number | null;
|
||||
dredgingDate: string;
|
||||
photoCount: number;
|
||||
registeredDate: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
snappedLongitude: number;
|
||||
snappedLatitude: number;
|
||||
snappedLongitude: number | null;
|
||||
snappedLatitude: number | null;
|
||||
registrationMode: DredgingRegistrationMode;
|
||||
registrationSource: DredgingLocationSource;
|
||||
matchDistanceMeters: number;
|
||||
matchDistanceMeters: number | null;
|
||||
isMock: boolean;
|
||||
};
|
||||
|
||||
export type DredgingPhoto = {
|
||||
targetSn?: number;
|
||||
photoSn: number;
|
||||
pipeFid: string;
|
||||
pipeFid?: string | null;
|
||||
atflCd: string;
|
||||
atflId: string;
|
||||
originalFileName: string;
|
||||
@@ -97,18 +100,19 @@ export type DredgingSaveGroup = {
|
||||
dredgingDate: string;
|
||||
location: GeoPoint;
|
||||
locationSource: DredgingLocationSource;
|
||||
pipe: PipeMatch;
|
||||
pipe: PipeMatch | null;
|
||||
photos: DredgingPhotoDraft[];
|
||||
};
|
||||
|
||||
/** 상세 기반 수정 팝업에서 선택한 한 관로의 위치·사진을 함께 갱신한다. */
|
||||
export type DredgingRecordUpdate = {
|
||||
dredgingDate: string;
|
||||
originalPipeFid: string;
|
||||
originalPipeFid: string | null;
|
||||
targetSn: number;
|
||||
registrationMode: DredgingRegistrationMode;
|
||||
location: GeoPoint;
|
||||
locationSource: DredgingLocationSource;
|
||||
pipe: PipeMatch;
|
||||
pipe: PipeMatch | null;
|
||||
retainedPhotoSns: number[];
|
||||
newPhotos: DredgingPhotoDraft[];
|
||||
};
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"use client";
|
||||
import { DredgingRegistrationModeSelector } from "./dredging-registration-mode-selector";
|
||||
import { DREDGING_EXTERNAL_MODE } from "../model/disclosure";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
@@ -134,25 +136,25 @@ const DredgingDetailPopupPage = ({
|
||||
const [statusMessage, setStatusMessage] = useState("");
|
||||
const uploadedAttachmentsRef = useRef(new Map<string, AttachmentReference>());
|
||||
|
||||
const initialMatchedPipe = useMemo<PipeMatch>(
|
||||
() => ({
|
||||
const initialMatchedPipe = useMemo<PipeMatch | null>(
|
||||
() => !record.pipeFid || DREDGING_EXTERNAL_MODE ? null : ({
|
||||
fid: record.pipeFid,
|
||||
mngInstCd: record.mngInstCd,
|
||||
fcltMngNo: record.fcltMngNo,
|
||||
pipeDiameterMm: record.pipeDiameterMm,
|
||||
pipeLengthM: record.pipeLengthM,
|
||||
pipeMaterial: record.pipeMaterial,
|
||||
mngInstCd: record.mngInstCd ?? "",
|
||||
fcltMngNo: record.fcltMngNo ?? "",
|
||||
pipeDiameterMm: record.pipeDiameterMm ?? 0,
|
||||
pipeLengthM: record.pipeLengthM ?? 0,
|
||||
pipeMaterial: record.pipeMaterial ?? "",
|
||||
installationYear: record.installationYear,
|
||||
swtrmtZoneFid: null,
|
||||
swtrmtZoneNm: record.treatmentZone,
|
||||
distanceMeters: record.matchDistanceMeters,
|
||||
swtrmtZoneNm: record.treatmentZone ?? "",
|
||||
distanceMeters: record.matchDistanceMeters ?? 0,
|
||||
snappedLongitude: record.snappedLongitude || record.longitude,
|
||||
snappedLatitude: record.snappedLatitude || record.latitude,
|
||||
isMock: record.isMock,
|
||||
}),
|
||||
[record],
|
||||
);
|
||||
const [matchedPipe, setMatchedPipe] = useState<PipeMatch>(initialMatchedPipe);
|
||||
const [matchedPipe, setMatchedPipe] = useState<PipeMatch | null>(initialMatchedPipe);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -168,6 +170,7 @@ const DredgingDetailPopupPage = ({
|
||||
const items = await fetchDredgingPhotos(
|
||||
record.dredgingJobSn,
|
||||
record.pipeFid,
|
||||
record.targetSn,
|
||||
);
|
||||
const previews = await Promise.all(
|
||||
items.map(async (photo): Promise<DredgingPhotoPreview> => {
|
||||
@@ -214,7 +217,7 @@ const DredgingDetailPopupPage = ({
|
||||
cancelled = true;
|
||||
createdUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [record.dredgingJobSn, record.pipeFid]);
|
||||
}, [record.dredgingJobSn, record.pipeFid, record.targetSn]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -258,19 +261,22 @@ const DredgingDetailPopupPage = ({
|
||||
}
|
||||
};
|
||||
|
||||
const pipeInfo = [
|
||||
const pipeInfo = DREDGING_EXTERNAL_MODE ? [
|
||||
["시도", record.ctpv || "확인불가"], ["시군구", record.sgg || "확인불가"],
|
||||
["위치등록", locationSourceLabels[locationSource] || locationSource],
|
||||
] : [
|
||||
["시도", record.ctpv || "확인불가"],
|
||||
["시군구", record.sgg || "확인불가"],
|
||||
[
|
||||
"처리구역",
|
||||
matchedPipe.swtrmtZoneNm || record.treatmentZone || "확인불가",
|
||||
matchedPipe?.swtrmtZoneNm || record.treatmentZone || "확인불가",
|
||||
],
|
||||
["관경", `${matchedPipe.pipeDiameterMm.toLocaleString()} mm`],
|
||||
["관로연장", `${matchedPipe.pipeLengthM.toLocaleString()} m`],
|
||||
["관재질", resolvePipeMaterialName(matchedPipe.pipeMaterial)],
|
||||
["관경", matchedPipe ? `${matchedPipe.pipeDiameterMm.toLocaleString()} mm` : "미산정"],
|
||||
["관로연장", matchedPipe ? `${matchedPipe.pipeLengthM.toLocaleString()} m` : "미산정"],
|
||||
["관재질", matchedPipe ? resolvePipeMaterialName(matchedPipe.pipeMaterial) : "미확인"],
|
||||
[
|
||||
"매설년도",
|
||||
matchedPipe.installationYear
|
||||
matchedPipe?.installationYear
|
||||
? `${matchedPipe.installationYear}년`
|
||||
: "확인불가",
|
||||
],
|
||||
@@ -280,7 +286,7 @@ const DredgingDetailPopupPage = ({
|
||||
{ length: Math.ceil(pipeInfo.length / 2) },
|
||||
(_, index) => pipeInfo.slice(index * 2, index * 2 + 2),
|
||||
);
|
||||
const automaticMappingValue = `${matchedPipe.distanceMeters.toFixed(2)} m`;
|
||||
const automaticMappingValue = DREDGING_EXTERNAL_MODE ? "위치 등록" : matchedPipe ? `${matchedPipe.distanceMeters.toFixed(2)} m` : "관로 미지정";
|
||||
const totalPhotoCount = photos.length + newPhotos.length;
|
||||
|
||||
const handleNewFiles = async (files: File[] | null) => {
|
||||
@@ -407,17 +413,12 @@ const DredgingDetailPopupPage = ({
|
||||
{ locationKey, ...locationDraft },
|
||||
]);
|
||||
const nextPipe = matches.get(locationKey);
|
||||
if (!nextPipe) {
|
||||
throw new Error(
|
||||
"50m 이내에 관로가 없습니다. 지도에서 준설위치를 다시 확인해주세요.",
|
||||
);
|
||||
}
|
||||
setLocation(locationDraft);
|
||||
setLocationSource("MANUAL");
|
||||
setMatchedPipe(nextPipe);
|
||||
setMatchedPipe(nextPipe ?? null);
|
||||
setLocationDraft(null);
|
||||
setIsEditingLocation(false);
|
||||
setStatusMessage("변경한 위치와 최근접 관로를 확인했습니다.");
|
||||
setStatusMessage(DREDGING_EXTERNAL_MODE ? "변경한 준설위치를 적용했습니다." : nextPipe ? "변경한 위치와 최근접 관로를 확인했습니다." : "관로 미지정으로 저장할 수 있습니다.");
|
||||
} catch (matchFailure) {
|
||||
setMatchError(
|
||||
matchFailure instanceof Error
|
||||
@@ -449,6 +450,8 @@ const DredgingDetailPopupPage = ({
|
||||
alert("선택한 지도 위치를 먼저 적용해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!record.targetSn) { alert("화면을 새로고침해 준설대상번호를 확인해주세요."); return; }
|
||||
if (!DREDGING_EXTERNAL_MODE && !matchedPipe && !(await confirm("관로 미지정 상태로 수정 내용을 저장하시겠습니까?"))) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
setStatusMessage(
|
||||
@@ -466,6 +469,7 @@ const DredgingDetailPopupPage = ({
|
||||
{
|
||||
dredgingDate,
|
||||
originalPipeFid: record.pipeFid,
|
||||
targetSn: record.targetSn,
|
||||
registrationMode,
|
||||
location,
|
||||
locationSource:
|
||||
@@ -524,10 +528,12 @@ const DredgingDetailPopupPage = ({
|
||||
<aside className="flex min-h-0 min-w-0 flex-col gap-3 overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable]">
|
||||
<section className="card card-border !m-0 shrink-0 overflow-hidden !p-0">
|
||||
<div className="card-header !m-0">
|
||||
<h2 className="text-2xl font-medium text-label">관로 정보</h2>
|
||||
<span className="truncate text-base text-muted-foreground">
|
||||
관로번호 {matchedPipe.fcltMngNo || "확인불가"}
|
||||
</span>
|
||||
<h2 className="text-2xl font-medium text-label">{DREDGING_EXTERNAL_MODE ? "준설 정보" : "관로 정보"}</h2>
|
||||
{!DREDGING_EXTERNAL_MODE && (
|
||||
<span className="truncate text-base text-muted-foreground">
|
||||
{`관로번호 ${matchedPipe?.fcltMngNo || "관로 미지정"}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<Table variant="secondary">
|
||||
@@ -558,7 +564,7 @@ const DredgingDetailPopupPage = ({
|
||||
dredgingDate
|
||||
)}
|
||||
</TableCell>
|
||||
<TableHead>자동매핑</TableHead>
|
||||
<TableHead>{DREDGING_EXTERNAL_MODE ? "위치" : "자동매핑"}</TableHead>
|
||||
<TableCell
|
||||
className="truncate border px-4"
|
||||
title={automaticMappingValue}
|
||||
@@ -604,34 +610,16 @@ const DredgingDetailPopupPage = ({
|
||||
className="card card-border !m-0 flex !min-h-[40rem] flex-1 flex-col overflow-hidden !p-0"
|
||||
data-testid="dredging-detail-photo-panel"
|
||||
>
|
||||
<div className="card-header !m-0 shrink-0 gap-2">
|
||||
<div className="card-header !m-0 shrink-0 flex-wrap gap-2">
|
||||
<h2 className="text-2xl font-medium text-label">준설 사진</h2>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{editable ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color={registrationMode === "PHOTO" ? "primary" : "white"}
|
||||
aria-pressed={registrationMode === "PHOTO"}
|
||||
onClick={() => void changeRegistrationMode("PHOTO")}
|
||||
data-testid="dredging-edit-photo-mode"
|
||||
>
|
||||
{registrationModeLabels.PHOTO}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color={
|
||||
registrationMode === "MANUAL" ? "primary" : "white"
|
||||
}
|
||||
aria-pressed={registrationMode === "MANUAL"}
|
||||
onClick={() => void changeRegistrationMode("MANUAL")}
|
||||
data-testid="dredging-edit-manual-mode"
|
||||
>
|
||||
{registrationModeLabels.MANUAL}
|
||||
</Button>
|
||||
</>
|
||||
<DredgingRegistrationModeSelector
|
||||
value={registrationMode}
|
||||
labels={registrationModeLabels}
|
||||
onChange={(mode) => void changeRegistrationMode(mode)}
|
||||
testIdPrefix="dredging-edit"
|
||||
/>
|
||||
) : null}
|
||||
<span className="text-base text-muted-foreground">
|
||||
{totalPhotoCount}/5장
|
||||
@@ -838,13 +826,13 @@ const DredgingDetailPopupPage = ({
|
||||
<div className="card-header !m-0 flex-wrap gap-x-2 gap-y-1 !border-b-0">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-2xl font-medium text-label">
|
||||
위치 및 관로
|
||||
{DREDGING_EXTERNAL_MODE ? "준설 위치" : "위치 및 관로"}
|
||||
</h2>
|
||||
<p
|
||||
className={`mt-1 truncate text-sm ${matchError ? "text-rose-700" : "text-muted-foreground"}`}
|
||||
>
|
||||
자동 연결 관로 · {matchedPipe.fcltMngNo} ·{" "}
|
||||
{matchError ?? `${matchedPipe.distanceMeters.toFixed(2)}m`}
|
||||
{DREDGING_EXTERNAL_MODE ? "배경지도에서 실제 준설 위치를 지정해주세요." :
|
||||
matchError ?? (matchedPipe ? `자동 연결 관로 · ${matchedPipe.fcltMngNo} · ${matchedPipe.distanceMeters.toFixed(2)}m` : "관로 미지정")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
@@ -883,7 +871,7 @@ const DredgingDetailPopupPage = ({
|
||||
data-testid="dredging-edit-status"
|
||||
>
|
||||
{statusMessage ||
|
||||
"준설일자·사진·위치와 자동 연결 관로를 확인해주세요."}
|
||||
(DREDGING_EXTERNAL_MODE ? "준설일자·사진·위치를 확인해주세요." : "준설일자·사진·위치와 자동 연결 관로를 확인해주세요.")}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -914,8 +902,8 @@ const DredgingDetailPopupPage = ({
|
||||
isEditingLocation
|
||||
? "지도에서 변경할 준설위치를 클릭한 뒤 선택 위치 적용을 눌러주세요."
|
||||
: editable
|
||||
? "현재 준설위치와 자동 연결된 관로를 표시하고 있습니다."
|
||||
: "준설위치와 자동 연결된 관로를 표시하고 있습니다."
|
||||
? (DREDGING_EXTERNAL_MODE ? "현재 준설위치를 표시하고 있습니다." : "현재 준설위치와 자동 연결된 관로를 표시하고 있습니다.")
|
||||
: (DREDGING_EXTERNAL_MODE ? "준설위치를 표시하고 있습니다." : "준설위치와 자동 연결된 관로를 표시하고 있습니다.")
|
||||
}
|
||||
height="h-full min-h-0"
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* - 지자체별/관경별/경과년도별 조회와 XLSX 다운로드 제공
|
||||
*/
|
||||
"use client";
|
||||
import { DREDGING_EXTERNAL_MODE } from "../model/disclosure";
|
||||
import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-code-catalog";
|
||||
import { useDredgingListData } from "../lib/use-dredging-list-data";
|
||||
import {
|
||||
@@ -187,8 +188,8 @@ const Page = () => {
|
||||
return filteredRecords.map((record, index) => ({
|
||||
rowNumber: index + 1,
|
||||
...record,
|
||||
pipeLengthM: Number(record.pipeLengthM.toFixed(2)),
|
||||
pipeMaterial: resolvePipeMaterialName(record.pipeMaterial, materialNames),
|
||||
pipeLengthM: Number((record.pipeLengthM ?? 0).toFixed(2)),
|
||||
pipeMaterial: resolvePipeMaterialName(record.pipeMaterial ?? "", materialNames),
|
||||
installationYear: record.installationYear ?? "확인불가",
|
||||
elapsedYears:
|
||||
record.installationYear === null
|
||||
@@ -372,27 +373,28 @@ const Page = () => {
|
||||
const addedRecords = savedGroups.map<DredgingRecord>((group, index) => ({
|
||||
id: `${localJobSn}-${index + 1}`,
|
||||
dredgingJobSn: localJobSn,
|
||||
pipeFid: group.pipe.fid,
|
||||
mngInstCd: group.pipe.mngInstCd,
|
||||
targetSn: index + 1,
|
||||
pipeFid: group.pipe?.fid ?? null,
|
||||
mngInstCd: group.pipe?.mngInstCd ?? null,
|
||||
ctpv: ctpvName || "경기도",
|
||||
sgg: sggName || "양평군",
|
||||
treatmentZone: group.pipe.swtrmtZoneNm || "확인불가",
|
||||
fcltMngNo: group.pipe.fcltMngNo,
|
||||
pipeDiameterMm: group.pipe.pipeDiameterMm,
|
||||
pipeLengthM: group.pipe.pipeLengthM,
|
||||
pipeMaterial: group.pipe.pipeMaterial,
|
||||
installationYear: group.pipe.installationYear,
|
||||
treatmentZone: group.pipe?.swtrmtZoneNm || "확인불가",
|
||||
fcltMngNo: group.pipe?.fcltMngNo ?? null,
|
||||
pipeDiameterMm: group.pipe?.pipeDiameterMm ?? null,
|
||||
pipeLengthM: group.pipe?.pipeLengthM ?? null,
|
||||
pipeMaterial: group.pipe?.pipeMaterial ?? null,
|
||||
installationYear: group.pipe?.installationYear ?? null,
|
||||
dredgingDate: group.dredgingDate,
|
||||
photoCount: group.photos.length,
|
||||
registeredDate,
|
||||
longitude: group.location.longitude,
|
||||
latitude: group.location.latitude,
|
||||
snappedLongitude: group.pipe.snappedLongitude,
|
||||
snappedLatitude: group.pipe.snappedLatitude,
|
||||
snappedLongitude: group.pipe?.snappedLongitude ?? null,
|
||||
snappedLatitude: group.pipe?.snappedLatitude ?? null,
|
||||
registrationMode: group.registrationMode,
|
||||
registrationSource: group.locationSource,
|
||||
matchDistanceMeters: group.pipe.distanceMeters,
|
||||
isMock: group.pipe.isMock || !isDredgingBackendEnabled,
|
||||
matchDistanceMeters: group.pipe?.distanceMeters ?? null,
|
||||
isMock: Boolean(group.pipe?.isMock) || !isDredgingBackendEnabled,
|
||||
}));
|
||||
prependDemoRecords(addedRecords);
|
||||
setCurrentPage(1);
|
||||
@@ -536,8 +538,8 @@ const Page = () => {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MUNICIPALITY">지자체별</SelectItem>
|
||||
<SelectItem value="DIAMETER">관경별</SelectItem>
|
||||
<SelectItem value="ELAPSED_YEAR">경과년도별</SelectItem>
|
||||
{!DREDGING_EXTERNAL_MODE && (<SelectItem value="DIAMETER">관경별</SelectItem>)}
|
||||
{!DREDGING_EXTERNAL_MODE && (<SelectItem value="ELAPSED_YEAR">경과년도별</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { DREDGING_EXTERNAL_MODE } from "../model/disclosure";
|
||||
|
||||
import Feature from "ol/Feature.js";
|
||||
import type { FeatureLike } from "ol/Feature.js";
|
||||
@@ -265,6 +266,12 @@ const LocationInteraction = ({
|
||||
previousValueRef.current = value;
|
||||
}, [gisMap, interactive, value?.latitude, value?.longitude]);
|
||||
|
||||
if (DREDGING_EXTERNAL_MODE) {
|
||||
return <div className="rounded-sm border border-border bg-background px-2 py-1.5 text-xs text-label shadow">
|
||||
{interactive ? "지도를 클릭해 준설위치를 선택하세요." : "등록된 준설위치를 확인합니다."}
|
||||
</div>;
|
||||
}
|
||||
|
||||
const pipeStatusText = (() => {
|
||||
if (pipeLayerSettings.length === 0)
|
||||
return "관망도 레이어 설정을 불러오는 중입니다.";
|
||||
@@ -329,6 +336,7 @@ const DredgingLocationMap = ({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (DREDGING_EXTERNAL_MODE) return; // 외부에서는 관망 레이어 설정과 도형을 요청하지 않는다.
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
@@ -370,8 +378,8 @@ const DredgingLocationMap = ({
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
interactive={interactive}
|
||||
matchedPipe={matchedPipe}
|
||||
pipeLayerSettings={pipeLayerSettings}
|
||||
matchedPipe={DREDGING_EXTERNAL_MODE ? null : matchedPipe}
|
||||
pipeLayerSettings={DREDGING_EXTERNAL_MODE ? [] : pipeLayerSettings}
|
||||
/>
|
||||
}
|
||||
defaultZoom={value ? 17 : 7.5}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
import { DREDGING_EXTERNAL_MODE } from "../model/disclosure";
|
||||
/**
|
||||
* 화면명: 준설실적 등록
|
||||
* 준설 사진을 등록하고 촬영정보·위치·인접 관로를 확인하여 준설실적을 저장하는 화면이다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
@@ -196,7 +198,9 @@ const DredgingRegisterModal = ({
|
||||
try {
|
||||
const matches = await findNearestPipeMatches(locations);
|
||||
if (matches.size !== locations.length) {
|
||||
throw new Error("탐색거리 안에서 관로를 찾지 못한 위치가 있습니다.");
|
||||
commitGroups(current => current.map(item => item.id === group.id
|
||||
? { ...item, matchedPipe: null, isMatching: false, matchError: DREDGING_EXTERNAL_MODE ? null : "관로 미지정으로 저장할 수 있습니다." } : item));
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedPipes = Array.from(matches.values());
|
||||
@@ -453,18 +457,15 @@ const DredgingRegisterModal = ({
|
||||
if (!location) {
|
||||
throw new Error(`${label}: 지도에서 준설위치를 등록해주세요.`);
|
||||
}
|
||||
if (!group.matchedPipe) {
|
||||
throw new Error(
|
||||
`${label}: ${group.matchError ?? "최근접 관로 자동 매핑을 완료해주세요."}`,
|
||||
);
|
||||
}
|
||||
if (group.matchError?.startsWith("사진 위치가 서로 다른 관로")) throw new Error(group.matchError);
|
||||
if (group.isMatching) throw new Error(`${label}: 위치 확인이 끝난 뒤 저장해주세요.`);
|
||||
saveGroups.push({
|
||||
localId: group.id,
|
||||
registrationMode: group.registrationMode,
|
||||
dredgingDate: representativeDredgingDate,
|
||||
location,
|
||||
locationSource: group.manualLocation ? "MANUAL" : "EXIF",
|
||||
pipe: group.matchedPipe,
|
||||
pipe: DREDGING_EXTERNAL_MODE ? null : group.matchedPipe,
|
||||
photos: group.photos,
|
||||
});
|
||||
}
|
||||
@@ -476,6 +477,8 @@ const DredgingRegisterModal = ({
|
||||
if (savingRef.current) return;
|
||||
try {
|
||||
const saveGroups = validateAndBuildSaveGroups();
|
||||
if (!DREDGING_EXTERNAL_MODE && saveGroups.some(group => !group.pipe)
|
||||
&& !(await confirm("관로가 지정되지 않은 대상이 있습니다. 사진과 위치를 저장하시겠습니까?"))) return;
|
||||
savingRef.current = true;
|
||||
setIsSaving(true);
|
||||
setStatusMessage(
|
||||
@@ -491,7 +494,7 @@ const DredgingRegisterModal = ({
|
||||
uploadedAttachmentsRef.current = attachments;
|
||||
setStatusMessage(
|
||||
isDredgingBackendEnabled
|
||||
? "준설 결과와 관로 매핑을 저장하고 있습니다."
|
||||
? (DREDGING_EXTERNAL_MODE ? "준설 결과를 저장하고 있습니다." : "준설 결과와 관로 매핑을 저장하고 있습니다.")
|
||||
: "준설 결과 등록을 완료했습니다.",
|
||||
);
|
||||
const session =
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { DredgingRegistrationModeSelector } from "./dredging-registration-mode-selector";
|
||||
import { DREDGING_EXTERNAL_MODE } from "../model/disclosure";
|
||||
import type { ComponentType } from "react";
|
||||
import { FileUpload } from "@common/business_components/ui/file-upload";
|
||||
import {
|
||||
@@ -96,7 +99,7 @@ export const DredgingRegistrationBody = ({
|
||||
>
|
||||
<div className="bg-secondary/40 mb-3 flex shrink-0 flex-wrap items-center gap-x-4 gap-y-1 rounded-sm border border-border px-4 py-2 text-sm text-label">
|
||||
<strong className="text-foreground">등록 안내</strong>
|
||||
<span>관로별 사진은 최대 5장까지 등록할 수 있습니다.</span>
|
||||
<span>{DREDGING_EXTERNAL_MODE ? "등록 건별" : "관로별"} 사진은 최대 5장까지 등록할 수 있습니다.</span>
|
||||
<span>
|
||||
모든 등록 건은 하나의 준설작업이며 동일한 준설일자를 사용합니다.
|
||||
</span>
|
||||
@@ -114,7 +117,7 @@ export const DredgingRegistrationBody = ({
|
||||
<div className="card-header !m-0">
|
||||
<h3 className="text-2xl font-medium text-label">등록 정보</h3>
|
||||
<Button type="button" size="sm" onClick={addGroup}>
|
||||
관로 추가
|
||||
{DREDGING_EXTERNAL_MODE ? "등록 건 추가" : "관로 추가"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
@@ -151,7 +154,7 @@ export const DredgingRegistrationBody = ({
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>등록 관로</TableHead>
|
||||
<TableHead>{DREDGING_EXTERNAL_MODE ? "등록 대상" : "등록 관로"}</TableHead>
|
||||
<TableCell className="border px-4">
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
@@ -224,43 +227,25 @@ export const DredgingRegistrationBody = ({
|
||||
>
|
||||
<div className="card-header !m-0 flex-wrap gap-2">
|
||||
<div>
|
||||
<h3 className="text-2xl font-medium text-label">준설 사진</h3>
|
||||
<h3 className="text-2xl font-medium text-label">
|
||||
{activeGroup.registrationMode === "PHOTO" && (
|
||||
<span className="mr-1 text-red-600" aria-hidden="true">*</span>
|
||||
)}
|
||||
준설 사진
|
||||
{activeGroup.registrationMode === "PHOTO" && (
|
||||
<span className="sr-only"> (필수)</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
촬영일과 GPS 정보를 확인합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color={
|
||||
activeGroup.registrationMode === "PHOTO"
|
||||
? "primary"
|
||||
: "white"
|
||||
}
|
||||
aria-pressed={activeGroup.registrationMode === "PHOTO"}
|
||||
onClick={() =>
|
||||
void changeRegistrationMode(activeGroup.id, "PHOTO")
|
||||
}
|
||||
>
|
||||
{registrationModeLabels.PHOTO}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color={
|
||||
activeGroup.registrationMode === "MANUAL"
|
||||
? "primary"
|
||||
: "white"
|
||||
}
|
||||
aria-pressed={activeGroup.registrationMode === "MANUAL"}
|
||||
onClick={() =>
|
||||
void changeRegistrationMode(activeGroup.id, "MANUAL")
|
||||
}
|
||||
>
|
||||
{registrationModeLabels.MANUAL}
|
||||
</Button>
|
||||
</div>
|
||||
<DredgingRegistrationModeSelector
|
||||
value={activeGroup.registrationMode}
|
||||
labels={registrationModeLabels}
|
||||
onChange={(mode) => void changeRegistrationMode(activeGroup.id, mode)}
|
||||
testIdPrefix="dredging-register"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col p-3">
|
||||
{activeGroup.registrationMode === "PHOTO" ? (
|
||||
@@ -371,7 +356,7 @@ export const DredgingRegistrationBody = ({
|
||||
|
||||
<section className="card card-border !m-0 shrink-0 overflow-hidden !p-0">
|
||||
<div className="card-header !m-0 flex-wrap gap-2">
|
||||
<h3 className="text-2xl font-medium text-label">위치 및 관로</h3>
|
||||
<h3 className="text-2xl font-medium text-label">{DREDGING_EXTERNAL_MODE ? "준설 위치" : "위치 및 관로"}</h3>
|
||||
<div className="flex shrink-0 flex-wrap justify-end gap-1">
|
||||
{isEditingActiveLocation ? (
|
||||
<>
|
||||
@@ -415,7 +400,7 @@ export const DredgingRegistrationBody = ({
|
||||
</colgroup>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableHead>위치등록</TableHead>
|
||||
<TableHead data-required>위치등록</TableHead>
|
||||
<TableCell className="border px-4">
|
||||
<span
|
||||
className={`inline-flex rounded-sm px-2 py-1 text-sm font-medium ${
|
||||
@@ -438,6 +423,7 @@ export const DredgingRegistrationBody = ({
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{!DREDGING_EXTERNAL_MODE && (
|
||||
<TableRow>
|
||||
<TableHead>자동 연결 관로</TableHead>
|
||||
<TableCell className="border px-4">
|
||||
@@ -465,7 +451,7 @@ export const DredgingRegistrationBody = ({
|
||||
"위치를 지정하면 50m 이내 최근접 관로를 자동으로 연결합니다."}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableRow>)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -476,7 +462,7 @@ export const DredgingRegistrationBody = ({
|
||||
data-testid="dredging-registration-status"
|
||||
>
|
||||
{statusMessage ||
|
||||
"사진·준설일자·위치와 자동 연결 관로를 확인해주세요."}
|
||||
(DREDGING_EXTERNAL_MODE ? "사진·준설일자·준설위치를 확인해주세요." : "사진·준설일자·위치를 확인해주세요. 관로 미지정 상태도 저장할 수 있습니다.")}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -496,7 +482,7 @@ export const DredgingRegistrationBody = ({
|
||||
statusText={
|
||||
isEditingActiveLocation
|
||||
? hasUnappliedLocationDraft
|
||||
? "새 위치를 선택했습니다. 관로 다시 찾기를 눌러 적용해주세요."
|
||||
? (DREDGING_EXTERNAL_MODE ? "새 위치를 선택했습니다. 선택 위치 적용을 눌러주세요." : "새 위치를 선택했습니다. 관로 다시 찾기를 눌러 적용해주세요.")
|
||||
: "지도에서 새 준설위치를 클릭해주세요."
|
||||
: activeResolvedLocation
|
||||
? "등록된 준설위치를 표시하고 있습니다."
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import type { DredgingRegistrationMode } from "../model/types";
|
||||
|
||||
type Props = {
|
||||
value: DredgingRegistrationMode;
|
||||
labels: Record<string, string>;
|
||||
onChange: (mode: DredgingRegistrationMode) => void;
|
||||
testIdPrefix: string;
|
||||
};
|
||||
|
||||
/** 업로드 실행 버튼과 구분되는 등록 방식 선택. 기존 전환 처리는 호출 화면이 담당한다. */
|
||||
export const DredgingRegistrationModeSelector = ({
|
||||
value, labels, onChange, testIdPrefix,
|
||||
}: Props) => {
|
||||
const name = useId();
|
||||
|
||||
return (
|
||||
<fieldset className="m-0 flex flex-wrap items-center gap-x-4 gap-y-2 border-0 p-0">
|
||||
<legend className="sr-only">등록 방식</legend>
|
||||
{(["PHOTO", "MANUAL"] as const).map((mode) => (
|
||||
<label key={mode} className="inline-flex cursor-pointer items-center gap-2 py-1 text-sm text-label">
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
value={mode}
|
||||
checked={value === mode}
|
||||
onChange={() => onChange(mode)}
|
||||
className="h-4 w-4 shrink-0 cursor-pointer accent-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||
data-testid={`${testIdPrefix}-${mode.toLowerCase()}-mode`}
|
||||
/>
|
||||
<span className={value === mode ? "font-medium text-foreground" : ""}>{labels[mode]}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
@@ -341,9 +341,10 @@ export const downloadExmnBusinessReport = async (
|
||||
export const downloadExmnPipeReport = async (
|
||||
exmnBizId: string,
|
||||
pipeNumber: string,
|
||||
jobType: "PPL" | "MNHL" = "PPL",
|
||||
): Promise<Blob> =>
|
||||
getBlobData(
|
||||
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/pipeReports/${encodeURIComponent(pipeNumber)}`,
|
||||
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/${jobType === "MNHL" ? "manholeReports" : "pipeReports"}/${encodeURIComponent(pipeNumber)}`,
|
||||
);
|
||||
|
||||
/** 사업 파일별 첨부서비스 키 쌍을 조사사업에 연결한다. */
|
||||
@@ -351,9 +352,10 @@ export const downloadExmnPipeReport = async (
|
||||
export const getExmnPipeReportAvailability = async (
|
||||
exmnBizId: string,
|
||||
pipeNumber: string,
|
||||
jobType: "PPL" | "MNHL" = "PPL",
|
||||
): Promise<ExmnPipeReportAvailability> =>
|
||||
getData(
|
||||
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/pipeReports/${encodeURIComponent(pipeNumber)}/availability`,
|
||||
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/${jobType === "MNHL" ? "manholeReports" : "pipeReports"}/${encodeURIComponent(pipeNumber)}/availability`,
|
||||
) as Promise<ExmnPipeReportAvailability>;
|
||||
|
||||
export const updateExmnAttachments = async (
|
||||
|
||||
@@ -9,12 +9,12 @@ export const getExmnTemplatePrerequisiteMessage = (
|
||||
detail: Pick<ExmnBusinessDetail, "districts">,
|
||||
): string | null => {
|
||||
if (!detail.districts?.length) {
|
||||
return "조사관리에서 해당 사업 행을 선택하고 ‘수정’을 눌러 처리분구를 등록한 뒤 ‘조사양식 받기’를 실행해 주세요. 이후 결과등록을 다시 진행해 주세요.";
|
||||
return "조사사업에서 해당 사업 행을 선택하고 ‘수정’을 눌러 처리분구를 등록한 뒤 ‘조사양식 받기’를 실행해 주세요. 이후 결과등록을 다시 진행해 주세요.";
|
||||
}
|
||||
if (detail.districts.some(district =>
|
||||
!district.pplExmnSheetNm?.trim() || !district.mnhlExmnSheetNm?.trim(),
|
||||
)) {
|
||||
return "조사관리에서 해당 사업 행 선택 → ‘수정’ → ‘조사양식 받기’를 먼저 실행한 후 결과등록을 다시 진행해 주세요.";
|
||||
return "조사사업에서 해당 사업 행 선택 → ‘수정’ → ‘조사양식 받기’를 먼저 실행한 후 결과등록을 다시 진행해 주세요.";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -536,7 +536,11 @@ export function ExmnBusinessRegisterPopupPage({
|
||||
maxLength={10}
|
||||
value={prcisExmnPrlg}
|
||||
disabled={formLocked}
|
||||
onChange={(event) => setPrcisExmnPrlg(event.target.value)}
|
||||
onChange={(event) => {
|
||||
// km 단위 소수 입력과 삭제는 허용하고 문자·부호·지수 표기는 거부한다.
|
||||
const value = event.target.value;
|
||||
if (/^\d*(?:\.\d*)?$/.test(value)) setPrcisExmnPrlg(value);
|
||||
}}
|
||||
/>
|
||||
<span className="shrink-0 text-[1.2rem] text-label">km</span>
|
||||
</div>
|
||||
@@ -612,7 +616,10 @@ export function ExmnBusinessRegisterPopupPage({
|
||||
|
||||
<section className="card card-border !m-0 min-h-0 shrink-0 overflow-hidden !p-[0.6rem]">
|
||||
<div className="card-header flex min-h-[5.2rem] flex-wrap items-center justify-between gap-4">
|
||||
<h2 className="text-2xl font-medium text-label">사업대상 처리분구</h2>
|
||||
<h2 className="text-2xl font-medium text-label">
|
||||
<span className="mr-1 text-red-600" aria-hidden="true">*</span>
|
||||
사업대상 처리분구<span className="sr-only"> (필수)</span>
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Input
|
||||
type="search"
|
||||
|
||||
@@ -287,7 +287,7 @@ export function ExmnRegistrationPage() {
|
||||
if (!popup) {
|
||||
setNotice({
|
||||
type: "error",
|
||||
text: "사업등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.",
|
||||
text: "조사사업 등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -385,7 +385,7 @@ export function ExmnRegistrationPage() {
|
||||
if (refreshed)
|
||||
setNotice({
|
||||
type: "success",
|
||||
text: "조사결과 최종 저장과 PPL/MNHL 집계를 완료했습니다.",
|
||||
text: "조사결과 저장을 완료했습니다.",
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -660,7 +660,7 @@ export function ExmnRegistrationPage() {
|
||||
<div className="mt-4 min-w-0">
|
||||
<StandardDataTable
|
||||
table={table}
|
||||
title="조사 목록"
|
||||
title="조사사업 목록"
|
||||
className={EXMN_TABLE_HEADER_CLASS}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="110rem"
|
||||
@@ -673,7 +673,7 @@ export function ExmnRegistrationPage() {
|
||||
disabled={isLoading}
|
||||
onClick={openBusinessRegister}
|
||||
>
|
||||
사업등록
|
||||
조사사업등록
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -71,7 +71,7 @@ const VALIDATION_CHECK_GUIDES: Partial<
|
||||
},
|
||||
MNHL_EXMN_PDF: {
|
||||
checkedItems: ["PDF 파일 형식", "조사결과 Excel 구조 및 저장 가능 여부"],
|
||||
pendingNote: "PDF 내부 맨홀번호 대조는 검토 기준 협의 후 적용 예정입니다.",
|
||||
pendingNote: "맨홀보고서의 맨홀번호에는 실제 GIS 맨홀번호를 입력해주세요. 보고서 열람 시 번호를 대조하며, 업로드 검토에는 PDF 내부 번호 대조가 포함되지 않습니다.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -179,6 +179,9 @@ export function ExmnResultRegisterPopupPage({
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
|
||||
/** 맨홀 PDF 하나만 선택한 경우 기존 조사자료의 첨부 교체로 처리한다. */
|
||||
const isManholeOnly = Boolean(files.MNHL_EXMN_PDF) && Object.keys(files).length === 1;
|
||||
|
||||
/** popup layout 상위 html/body에서 생기는 문서 스크롤바를 팝업이 열린 동안 잠근다. */
|
||||
useEffect(() => {
|
||||
const previousHtmlOverflow = document.documentElement.style.overflow;
|
||||
@@ -310,7 +313,9 @@ export function ExmnResultRegisterPopupPage({
|
||||
const fileMap = Object.fromEntries(
|
||||
Object.entries(files).map(([type, selected]) => [type, selected?.file]),
|
||||
) as Partial<Record<ExmnFileType, File>>;
|
||||
const validationMessage = validateExmnFiles(fileMap);
|
||||
const validationMessage = isManholeOnly
|
||||
? validateExmnFile("MNHL_EXMN_PDF", files.MNHL_EXMN_PDF!.file)
|
||||
: validateExmnFiles(fileMap);
|
||||
if (validationMessage) {
|
||||
setFileValidationStatuses((current) => ({
|
||||
...current,
|
||||
@@ -373,22 +378,22 @@ export function ExmnResultRegisterPopupPage({
|
||||
/** 정상 검토된 조사결과 Excel을 저장하고 부모 화면에 완료 메시지를 보낸 뒤 팝업을 닫는다. */
|
||||
const handleCommit = async () => {
|
||||
const excelValidation = validationResults.EXMN_EXCEL;
|
||||
if (
|
||||
!excelValidation?.valid ||
|
||||
fileValidationStatuses.EXMN_EXCEL !== "success" ||
|
||||
fileValidationStatuses.PPL_EXMN_PDF !== "success"
|
||||
)
|
||||
return;
|
||||
if (!canCommit) return;
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
if (!(await ensureTemplateReady())) return;
|
||||
if (!isManholeOnly && !(await ensureTemplateReady())) return;
|
||||
// 로딩 화면이 저장 확인창을 가리지 않도록 사전 확인과 업로드 상태를 나눈다.
|
||||
setIsLoading(false);
|
||||
if (!(await confirm("조사결과를 저장하시겠습니까?"))) return;
|
||||
setIsLoading(true);
|
||||
if (isManholeOnly) {
|
||||
// 검토 이후 기존 조사자료가 변경됐을 수 있어 첨부 업로드 직전에 다시 대조한다.
|
||||
const result = await validateExmnImport(exmnBizId, buildImportFormData(files, null, "MNHL_EXMN_PDF"));
|
||||
if (!result.valid) throw new Error(result.errors.map((item) => item.message).join("\n"));
|
||||
}
|
||||
await uploadAndLinkAttachments();
|
||||
await commitExmnImport(
|
||||
if (!isManholeOnly) await commitExmnImport(
|
||||
exmnBizId,
|
||||
buildImportFormData(files, excelValidation),
|
||||
);
|
||||
@@ -418,6 +423,11 @@ export function ExmnResultRegisterPopupPage({
|
||||
(fileType) => fileValidationStatuses[fileType],
|
||||
);
|
||||
const excelValidation = validationResults.EXMN_EXCEL;
|
||||
/** 단독 첨부 교체와 신규 Excel 결과 저장의 필수 검토 조건을 분리한다. */
|
||||
const canCommit = isManholeOnly
|
||||
? fileValidationStatuses.MNHL_EXMN_PDF === "success" && Boolean(validationResults.MNHL_EXMN_PDF?.valid)
|
||||
: Boolean(excelValidation?.valid) && fileValidationStatuses.EXMN_EXCEL === "success"
|
||||
&& fileValidationStatuses.PPL_EXMN_PDF === "success";
|
||||
|
||||
return (
|
||||
<StandardPopupLayout
|
||||
@@ -429,10 +439,7 @@ export function ExmnResultRegisterPopupPage({
|
||||
type="button"
|
||||
color="primary"
|
||||
disabled={
|
||||
isLoading ||
|
||||
!excelValidation?.valid ||
|
||||
fileValidationStatuses.EXMN_EXCEL !== "success" ||
|
||||
fileValidationStatuses.PPL_EXMN_PDF !== "success"
|
||||
isLoading || !canCommit
|
||||
}
|
||||
onClick={() => void handleCommit()}
|
||||
>
|
||||
@@ -614,7 +621,9 @@ export function ExmnResultRegisterPopupPage({
|
||||
const status = fileValidationStatuses[fileType]!;
|
||||
const result = validationResults[fileType];
|
||||
const messages = fileValidationMessages[fileType] ?? [];
|
||||
const guide = VALIDATION_CHECK_GUIDES[fileType];
|
||||
const guide = fileType === "MNHL_EXMN_PDF" && isManholeOnly
|
||||
? { checkedItems: ["PDF 맨홀번호 판독", "기존 조사자료에 연결된 실제 GIS 맨홀번호와 대조"], pendingNote: "저장 시 기존 조사자료는 유지하고 맨홀 보고서만 교체합니다." }
|
||||
: VALIDATION_CHECK_GUIDES[fileType];
|
||||
const countText =
|
||||
fileType === "EXMN_EXCEL"
|
||||
? `관로 ${result?.summary.pplCount ?? 0}건 · 맨홀 ${result?.summary.mnhlCount ?? 0}건`
|
||||
|
||||
@@ -43,7 +43,7 @@ const formatValue = (value: unknown) => {
|
||||
: String(value);
|
||||
};
|
||||
|
||||
/** 조사관리 표를 CS 목록 규격과 페이지 연속 오름차순 순번으로 연결한다. */
|
||||
/** 조사사업 표를 CS 목록 규격과 페이지 연속 오름차순 순번으로 연결한다. */
|
||||
export function ExmnStandardDataTable<T extends Record<string, unknown>>({
|
||||
rows,
|
||||
headlist,
|
||||
|
||||
@@ -52,21 +52,22 @@ export default function ExmnSurveyResultSection({
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const exmnBizId = readFeatureProperty(featureData, ["exmnBizId"]);
|
||||
const jobType = readFeatureProperty(featureData, ["jobType"]);
|
||||
const pipeNumber = readFeatureProperty(featureData, [
|
||||
"gisManagementNo",
|
||||
"fclt_mng_no",
|
||||
"fcltMngNo",
|
||||
]);
|
||||
// 맨홀은 조사 관로번호가 아니라 지도 원천의 실제 시설관리번호로 보고서를 조회한다.
|
||||
const pipeNumber = readFeatureProperty(featureData, jobType === "MNHL"
|
||||
? ["fclt_mng_no", "fcltMngNo"]
|
||||
: ["gisManagementNo", "fclt_mng_no", "fcltMngNo"]);
|
||||
const reportAvailable = readBooleanFeatureProperty(featureData, [
|
||||
"reportAvailable",
|
||||
]);
|
||||
/** 지도는 상류맨홀 위치를 유지하고 맨홀 보고서는 실제 GIS 맨홀번호로 연결한다. */
|
||||
const reportLabel = jobType === "MNHL" ? "맨홀" : "관로";
|
||||
const canOpenPipeReport =
|
||||
jobType === "PPL" && Boolean(exmnBizId && pipeNumber);
|
||||
(jobType === "PPL" || jobType === "MNHL") && Boolean(exmnBizId && pipeNumber);
|
||||
|
||||
const openPipeReport = async () => {
|
||||
if (!canOpenPipeReport) return;
|
||||
if (!reportAvailable) {
|
||||
alert("등록된 관로 조사보고서가 없습니다.");
|
||||
alert(`등록된 ${reportLabel} 조사보고서가 없습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -74,11 +75,12 @@ export default function ExmnSurveyResultSection({
|
||||
const availability = await getExmnPipeReportAvailability(
|
||||
exmnBizId,
|
||||
pipeNumber,
|
||||
jobType === "MNHL" ? "MNHL" : "PPL",
|
||||
);
|
||||
if (!availability.available) {
|
||||
alert(
|
||||
availability.message?.trim() ||
|
||||
`선택한 관로번호의 조사보고서가 등록된 PDF에 없습니다: ${pipeNumber}`,
|
||||
`선택한 GIS 번호의 조사보고서가 등록된 PDF에 없습니다: ${pipeNumber}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -86,6 +88,7 @@ export default function ExmnSurveyResultSection({
|
||||
const reportWindow = openWindowPopup("exmnPipeReport", {
|
||||
exmnBizId,
|
||||
pipeNumber,
|
||||
jobType,
|
||||
reportNo: readFeatureProperty(featureData, ["reportNo"]),
|
||||
});
|
||||
if (!reportWindow) {
|
||||
@@ -97,7 +100,7 @@ export default function ExmnSurveyResultSection({
|
||||
alert(
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: "관로 조사보고서 열람 가능 여부를 확인하지 못했습니다.",
|
||||
: `${reportLabel} 조사보고서 열람 가능 여부를 확인하지 못했습니다.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -107,6 +110,7 @@ export default function ExmnSurveyResultSection({
|
||||
["사업차수", readFeatureProperty(featureData, ["bizCycl"])],
|
||||
["처리분구", readFeatureProperty(featureData, ["trtdstNm"])],
|
||||
["조사유형", jobType === "MNHL" ? "맨홀조사" : "관로조사"],
|
||||
[jobType === "MNHL" ? "GIS 맨홀번호" : "GIS 관로번호", pipeNumber],
|
||||
["보고서번호", readFeatureProperty(featureData, ["reportNo"])],
|
||||
["구조적결함 등급", readFeatureProperty(featureData, ["structuralGrade"])],
|
||||
["운영적결함 등급", readFeatureProperty(featureData, ["operationalGrade"])],
|
||||
@@ -116,7 +120,7 @@ export default function ExmnSurveyResultSection({
|
||||
<section className="border-b border-border bg-background">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="mx-3 text-2xl font-medium text-label">조사 결과</h3>
|
||||
{jobType === "PPL" && (
|
||||
{(jobType === "PPL" || jobType === "MNHL") && (
|
||||
<Button
|
||||
onClick={openPipeReport}
|
||||
disabled={!canOpenPipeReport}
|
||||
|
||||
@@ -130,7 +130,7 @@ const toSavedTableRows = (
|
||||
const getErrorMessage = (error: unknown, fallback: string) =>
|
||||
error instanceof Error && error.message.trim() ? error.message : fallback;
|
||||
|
||||
/** 사업등록 후보로 사용할 처리분구 기준정보를 직접 관리하는 FE-0 화면이다. */
|
||||
/** 조사사업 등록 후보로 사용할 처리분구 기준정보를 직접 관리하는 FE-0 화면이다. */
|
||||
export function TrtdstManagementPage() {
|
||||
const { ctpv, sgg } = useStore();
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
@@ -766,7 +766,7 @@ const FeatureClickEventPopup = ({
|
||||
<div className="min-w-0">
|
||||
{surveyMode && (
|
||||
<h3 className="break-words text-2xl font-medium text-primary-foreground">
|
||||
조사관리 <br />
|
||||
조사사업 <br />
|
||||
관로번호 :{" "}
|
||||
{featureData?.get?.("gisManagementNo") ??
|
||||
featureData?.values_?.gisManagementNo ??
|
||||
|
||||
@@ -81,7 +81,7 @@ const MsSwagePpl = ({
|
||||
const requestFcltMngNo = String(
|
||||
featureData?.fclt_mng_no ?? featureData?.fcltMngNo ?? "",
|
||||
).trim();
|
||||
// 조사관리 전용 탭은 기본·입력정보와 조사결과만 표시하고 일반 시설 유지관리 이력은 숨긴다.
|
||||
// 조사사업 전용 탭은 기본·입력정보와 조사결과만 표시하고 일반 시설 유지관리 이력은 숨긴다.
|
||||
const showOperationSections = !surveyResultContent;
|
||||
|
||||
// 마운트 및 업데이트 시 API 호출
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fitMapSelection } from "@/features/map-search/lib/map-navigation";
|
||||
/**
|
||||
* 화면명: 하수맨홀 정보
|
||||
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수맨홀)
|
||||
@@ -69,7 +70,7 @@ export async function searchUpperPpls(
|
||||
const geometry = foundFeature?.getGeometry();
|
||||
if (geometry) {
|
||||
const extent = geometry.getExtent();
|
||||
gisMap.getView().fit(extent, {
|
||||
fitMapSelection(gisMap, extent, {
|
||||
duration: 300,
|
||||
padding: [50, 50, 50, 50],
|
||||
maxZoom: 14,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* 선택한 하수처리구역의 관로 분석 현황과 보수이력 연계 기능을 제공한다.
|
||||
*/
|
||||
"use client";
|
||||
import { fitMapSelection } from "@/features/map-search/lib/map-navigation";
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
@@ -228,7 +229,7 @@ const SwtrmtPrcsZoneAnalysis = ({
|
||||
repairLayerRef.current?.changed();
|
||||
const extent = selectedFeature.getGeometry()?.getExtent();
|
||||
if (extent) {
|
||||
gisMap.getView().fit(extent, {
|
||||
fitMapSelection(gisMap, extent, {
|
||||
padding: [100, 100, 100, 460],
|
||||
duration: 350,
|
||||
maxZoom: 18,
|
||||
|
||||
@@ -17,7 +17,7 @@ const HEADERS: Record<string, string> = {
|
||||
ctpvNm: "시도",
|
||||
sggNm: "시군구",
|
||||
analysisDateTime: "분석시각",
|
||||
rainBeginningDateTime: "강우 시작시각",
|
||||
rainBeginningDateTime: "강우시작시각",
|
||||
observedAccumulatedRainfall: "관측 누적강수량(mm)",
|
||||
predictionHour: "예측시간",
|
||||
predictedAccumulatedRainfall: "예측 누적강수량(mm)",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { beginMapNavigation } from "@/features/map-search/lib/map-navigation";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
@@ -6,7 +7,7 @@ import Overlay from "ol/Overlay.js";
|
||||
|
||||
import GisApp from "@common/business_components/ui/map/lib/gis.core";
|
||||
|
||||
import { getRawJson } from "@/shared/api/sewerage-api";
|
||||
import { getData } from "@/shared/api/sewerage-api";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
|
||||
|
||||
import { getFloodRiskGradeClassName } from "./flood-risk-grade";
|
||||
@@ -217,6 +218,8 @@ export const useFloodPredictionMap = ({
|
||||
floodLayerType,
|
||||
onRequestFloodLayer,
|
||||
}: UseFloodPredictionMapOptions) => {
|
||||
/** 레이어 준비 대기와 늦은 오버레이 응답도 같은 선택 세대로 제한한다. */
|
||||
const navigationRef = useRef<ReturnType<typeof beginMapNavigation> | null>(null);
|
||||
// 자동 레이어 선택이 완료된 뒤 다시 적용할 침수예측 행을 보관한다.
|
||||
const [pendingSelection, setPendingSelection] =
|
||||
useState<FloodPredictionSelection | null>(null);
|
||||
@@ -241,16 +244,18 @@ export const useFloodPredictionMap = ({
|
||||
/** 선택행의 시군구·분석시각으로 도시침수지도 중앙 오버레이를 표시한다. */
|
||||
const loadFloodPredictionOverlay = useCallback(
|
||||
async (selection: FloodPredictionSelection) => {
|
||||
const navigation = navigationRef.current;
|
||||
const { sggCd, analysisDateTime, extent } = selection;
|
||||
const map = mapRef.current;
|
||||
if (!map || !/^\d{12}$/.test(analysisDateTime)) return;
|
||||
removeFloodPredictionOverlay();
|
||||
|
||||
try {
|
||||
const response = await getRawJson(
|
||||
// 공통 응답의 data만 읽어 null·생략된 값을 빈 오버레이 결과로 처리한다.
|
||||
const payload = await getData(
|
||||
`/sewerage-service/api/v1/fldng/predc/prst/gridMap?sggCd=${encodeURIComponent(sggCd)}&analysisDateTime=${encodeURIComponent(analysisDateTime)}`,
|
||||
);
|
||||
const payload = response?.data ?? response;
|
||||
if (!navigation?.isCurrent()) return;
|
||||
const row =
|
||||
payload && typeof payload === "object" && !Array.isArray(payload)
|
||||
? (payload as FloodPredictionOverlayRow)
|
||||
@@ -284,6 +289,8 @@ export const useFloodPredictionMap = ({
|
||||
/** 활성화된 도시침수지도에서 선택 행의 시군구·빈도 피처를 선택하고 지도와 오버레이를 이동한다. */
|
||||
const applyFloodPredictionSelection = useCallback(
|
||||
async (selection: FloodPredictionSelection) => {
|
||||
const navigation = navigationRef.current;
|
||||
if (!navigation?.isCurrent()) return true;
|
||||
const map = mapRef.current;
|
||||
const select = GisApp.Module.select as unknown as
|
||||
| FloodSelectionModule
|
||||
@@ -302,10 +309,12 @@ export const useFloodPredictionMap = ({
|
||||
);
|
||||
if (floodLayers.length === 0) return false;
|
||||
|
||||
await select.resetSelectedFeature?.();
|
||||
if (!navigation.commit(selection.extent)) return true;
|
||||
select.resetSelectedFeature?.();
|
||||
const selectedFeatureIds = new Set<string>();
|
||||
/** 현재 로드된 동일 시군구·빈도 피처를 중복 없이 선택한다. */
|
||||
const selectLoadedGroupFeatures = (source: FloodPredictionSource) => {
|
||||
if (!navigation.isCurrent()) return;
|
||||
const features = source?.getFeatures?.() ?? [];
|
||||
features.forEach((feature) => {
|
||||
const featureSggCd = String(feature.get?.("sgg_cd") ?? "").trim();
|
||||
@@ -352,6 +361,8 @@ export const useFloodPredictionMap = ({
|
||||
async (payload: unknown, popupContext: FloodPredictionPopupContext) => {
|
||||
const selection = parseFloodPredictionSelection(payload);
|
||||
if (!selection) return;
|
||||
navigationRef.current = beginMapNavigation(mapRef.current);
|
||||
setPendingSelection(null);
|
||||
if (await applyFloodPredictionSelection(selection)) return;
|
||||
|
||||
pendingPopupRef.current = popupContext;
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useLayoutEffect, useMemo, useRef } from "react";
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@common/components";
|
||||
|
||||
/** 시군구별 면적가중 확률강우량 한 셀의 API 응답이다. */
|
||||
export type PrbbtRnflCell = {
|
||||
frequencyYear: number;
|
||||
durationHour: number;
|
||||
rainfall: number;
|
||||
};
|
||||
|
||||
/** 확률관측소의 시군구 교차면적 비율 API 응답이다. */
|
||||
export type PrbbtRnflStation = {
|
||||
prbbtObsvtrCd: string;
|
||||
prbbtObsvtrNm: string;
|
||||
areaRt: number;
|
||||
};
|
||||
|
||||
/** 확률강우량 표와 그 계산에 사용한 관측소 비율을 함께 받는다. */
|
||||
export type PrbbtRnflResponse = {
|
||||
sggCd: string;
|
||||
rainfall: PrbbtRnflCell[];
|
||||
stations: PrbbtRnflStation[];
|
||||
areaRtTotal: number;
|
||||
};
|
||||
|
||||
type PrbbtRnflDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
sggName: string;
|
||||
selectedDurationHour: number | null;
|
||||
selectedFrequencyYear: number | null;
|
||||
data: PrbbtRnflResponse | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
const formatRainfall = (value: number) =>
|
||||
Number.isFinite(Number(value)) ? Number(value).toFixed(4) : "-";
|
||||
|
||||
const formatAreaRatio = (value: number) =>
|
||||
Number.isFinite(Number(value)) ? Number(value).toFixed(3) : "-";
|
||||
|
||||
/** 재현기간을 행, 강우지속시간을 열로 구성하고 선택한 두 기준이 교차하는 값만 색으로 강조한다. */
|
||||
export default function PrbbtRnflDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
sggName,
|
||||
selectedDurationHour,
|
||||
selectedFrequencyYear,
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
}: PrbbtRnflDialogProps) {
|
||||
/** 선택한 지속시간 열을 모달 안에서 찾기 위한 표 스크롤 영역이다. */
|
||||
const tableScrollRef = useRef<HTMLDivElement>(null);
|
||||
/** 목록에서 클릭한 지속시간에 대응하는 표 헤더이다. */
|
||||
const selectedDurationHeaderRef = useRef<HTMLTableCellElement>(null);
|
||||
|
||||
const { frequencies, durations, rainfallByKey } = useMemo(() => {
|
||||
const rainfall = data?.rainfall ?? [];
|
||||
return {
|
||||
frequencies: [...new Set(rainfall.map((item) => item.frequencyYear))].sort(
|
||||
(a, b) => a - b,
|
||||
),
|
||||
durations: [...new Set(rainfall.map((item) => item.durationHour))].sort(
|
||||
(a, b) => a - b,
|
||||
),
|
||||
rainfallByKey: new Map(
|
||||
rainfall.map((item) => [
|
||||
`${item.frequencyYear}:${item.durationHour}`,
|
||||
item.rainfall,
|
||||
]),
|
||||
),
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
/** 표가 표시되면 선택 지속시간 열을 가로 스크롤 영역의 가운데로 맞춘다. */
|
||||
useLayoutEffect(() => {
|
||||
if (!open || loading || error || selectedDurationHour == null) return;
|
||||
|
||||
const scrollArea = tableScrollRef.current;
|
||||
const selectedHeader = selectedDurationHeaderRef.current;
|
||||
if (!scrollArea || !selectedHeader) return;
|
||||
|
||||
const scrollAreaRect = scrollArea.getBoundingClientRect();
|
||||
const selectedHeaderRect = selectedHeader.getBoundingClientRect();
|
||||
scrollArea.scrollLeft +=
|
||||
selectedHeaderRect.left -
|
||||
scrollAreaRect.left -
|
||||
(scrollArea.clientWidth - selectedHeaderRect.width) / 2;
|
||||
}, [open, loading, error, selectedDurationHour, durations]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="flex max-h-[90vh] w-[94vw] !max-w-[150rem] flex-col gap-4 overflow-hidden"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{sggName} 확률강우량</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-xl" role="status">
|
||||
확률강우량을 조회하고 있습니다.
|
||||
</p>
|
||||
) : error ? (
|
||||
<p className="py-8 text-center text-xl text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : !frequencies.length || !durations.length ? (
|
||||
<p className="py-8 text-center text-xl" role="status">
|
||||
해당 시군구의 확률강우량 기준자료가 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div ref={tableScrollRef} className="min-h-0 overflow-auto rounded-md border border-border">
|
||||
<table className="w-max min-w-full border-collapse text-right text-lg">
|
||||
<caption className="sr-only">
|
||||
{sggName} 재현기간 및 강우지속시간별 확률강우량(mm)
|
||||
</caption>
|
||||
<thead className="sticky top-0 z-10 bg-muted text-foreground">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="sticky left-0 z-20 min-w-[8rem] border border-border bg-muted px-3 py-2 text-center"
|
||||
>
|
||||
재현기간(년)
|
||||
</th>
|
||||
{durations.map((durationHour) => (
|
||||
<th
|
||||
key={durationHour}
|
||||
ref={durationHour === selectedDurationHour ? selectedDurationHeaderRef : undefined}
|
||||
scope="col"
|
||||
className="min-w-[9rem] border border-border px-3 py-2 text-center"
|
||||
>
|
||||
{durationHour}시간
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{frequencies.map((frequencyYear) => (
|
||||
<tr
|
||||
key={frequencyYear}
|
||||
className={frequencyYear === selectedFrequencyYear ? "font-bold" : ""}
|
||||
>
|
||||
<th
|
||||
scope="row"
|
||||
className={`sticky left-0 z-[1] border border-border bg-background px-3 py-2 text-right ${
|
||||
frequencyYear === selectedFrequencyYear ? "font-bold" : "font-medium"
|
||||
}`}
|
||||
>
|
||||
{frequencyYear}
|
||||
</th>
|
||||
{durations.map((durationHour) => {
|
||||
const rainfall = rainfallByKey.get(
|
||||
`${frequencyYear}:${durationHour}`,
|
||||
);
|
||||
return (
|
||||
<td
|
||||
key={durationHour}
|
||||
className={`border border-border px-3 py-2 tabular-nums ${
|
||||
rainfall != null &&
|
||||
durationHour === selectedDurationHour &&
|
||||
frequencyYear === selectedFrequencyYear
|
||||
? "bg-amber-100 font-bold text-amber-950"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{rainfall == null ? "-" : formatRainfall(rainfall)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 space-y-2">
|
||||
<h3 className="text-xl font-semibold">확률관측소 면적비율</h3>
|
||||
<div className="max-h-[16rem] overflow-auto rounded-md border border-border">
|
||||
<table className="w-full border-collapse text-lg">
|
||||
<thead className="bg-muted text-foreground">
|
||||
<tr>
|
||||
<th scope="col" className="border border-border px-3 py-2">
|
||||
확률관측소코드
|
||||
</th>
|
||||
<th scope="col" className="border border-border px-3 py-2">
|
||||
관측소명
|
||||
</th>
|
||||
<th scope="col" className="border border-border px-3 py-2 text-right">
|
||||
면적비율(%)
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data?.stations ?? []).map((station) => (
|
||||
<tr key={station.prbbtObsvtrCd}>
|
||||
<td className="border border-border px-3 py-2 text-center">
|
||||
{station.prbbtObsvtrCd}
|
||||
</td>
|
||||
<td className="border border-border px-3 py-2 text-center">
|
||||
{station.prbbtObsvtrNm || "-"}
|
||||
</td>
|
||||
<td className="border border-border px-3 py-2 text-right tabular-nums">
|
||||
{formatAreaRatio(station.areaRt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="font-semibold">
|
||||
<th colSpan={2} scope="row" className="border border-border px-3 py-2 text-center">
|
||||
합계
|
||||
</th>
|
||||
<td className="border border-border px-3 py-2 text-right tabular-nums">
|
||||
{formatAreaRatio(data?.areaRtTotal ?? Number.NaN)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="shrink-0 text-right text-sm text-muted-foreground">
|
||||
시간은 강우가 지속된 시간입니다
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import LoadingOverlay from "@/components/ui/loading-bar";
|
||||
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
|
||||
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
|
||||
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
|
||||
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import useStore from "@/entities/reference-data/model/reference-data-store";
|
||||
@@ -21,6 +22,9 @@ import { createPredictionAnalysisTimeRange } from "../model/analysis-time-range"
|
||||
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
import { downloadFloodPredictionStatusExcel } from "../lib/fldng-predc-prst-excel";
|
||||
import { getFloodRiskGradeClassName } from "../lib/flood-risk-grade";
|
||||
import PrbbtRnflDialog, {
|
||||
type PrbbtRnflResponse,
|
||||
} from "./PrbbtRnflDialog";
|
||||
|
||||
type ApiRow = {
|
||||
sggCd: string;
|
||||
@@ -31,6 +35,7 @@ type ApiRow = {
|
||||
analysisDateTime: string;
|
||||
observedAccumulatedRainfall: number | null;
|
||||
predictionHour: number | null;
|
||||
predcRainHr: number | null;
|
||||
predictedAccumulatedRainfall: number | null;
|
||||
exceededFrequencyYear: number | null;
|
||||
floodRiskGrade: string;
|
||||
@@ -45,6 +50,7 @@ type TableRow = ApiRow &
|
||||
predictionHourDisplay: string;
|
||||
predictedAccumulatedRainfallDisplay: string;
|
||||
exceededFrequencyYearDisplay: string;
|
||||
probabilityRainfallDisplay: string;
|
||||
};
|
||||
|
||||
type Filters = {
|
||||
@@ -71,9 +77,9 @@ const toFloodMapFrequencyCode = (value: number) => {
|
||||
};
|
||||
|
||||
const HEADS: UiHeadProps[] = [
|
||||
{ id: "rowNo", title: "순번", widthPercent: 6 },
|
||||
{ id: "ctpvNm", title: "시도", widthPercent: 8 },
|
||||
{ id: "sggNm", title: "시군구", widthPercent: 9 },
|
||||
{ id: "rowNo", title: "순번", widthPercent: 5 },
|
||||
{ id: "ctpvNm", title: "시도", widthPercent: 7 },
|
||||
{ id: "sggNm", title: "시군구", widthPercent: 8 },
|
||||
{
|
||||
id: "analysisDateTimeDisplay",
|
||||
title: "분석시각",
|
||||
@@ -82,32 +88,39 @@ const HEADS: UiHeadProps[] = [
|
||||
{
|
||||
id: "rainBeginningDateTimeDisplay",
|
||||
title: "강우시작시각",
|
||||
widthPercent: 16,
|
||||
widthPercent: 13,
|
||||
},
|
||||
{
|
||||
id: "observedAccumulatedRainfallDisplay",
|
||||
title: "관측 누적강수량(mm)",
|
||||
widthPercent: 13,
|
||||
widthPercent: 11,
|
||||
align: "right",
|
||||
},
|
||||
{
|
||||
id: "predictionHourDisplay",
|
||||
title: "예측시간",
|
||||
widthPercent: 9,
|
||||
widthPercent: 7,
|
||||
},
|
||||
{
|
||||
id: "predictedAccumulatedRainfallDisplay",
|
||||
title: "예측 누적강수량(mm)",
|
||||
widthPercent: 15,
|
||||
widthPercent: 12,
|
||||
align: "right",
|
||||
},
|
||||
{
|
||||
id: "exceededFrequencyYearDisplay",
|
||||
title: "초과 빈도(년)",
|
||||
widthPercent: 10,
|
||||
widthPercent: 8,
|
||||
align: "right",
|
||||
},
|
||||
{ id: "floodRiskGrade", title: "침수위험도", widthPercent: 10 },
|
||||
{ id: "floodRiskGrade", title: "침수위험도", widthPercent: 8 },
|
||||
{ id: "probabilityRainfallDisplay", title: "확률강우량", widthPercent: 8 },
|
||||
];
|
||||
|
||||
/** 날짜·시각의 뒷부분이 잘리지 않도록 전체 표시할 목록 열이다. */
|
||||
const FULL_TEXT_TIME_HEAD_IDS = [
|
||||
"analysisDateTimeDisplay",
|
||||
"rainBeginningDateTimeDisplay",
|
||||
];
|
||||
|
||||
/** 최초·초기화 시 시각 범위를 제한하지 않고 최신 침수예측 데이터부터 조회한다. */
|
||||
@@ -123,6 +136,16 @@ const buildInitialFilters = (): Filters => ({
|
||||
|
||||
const apiDate = (value: string) => value.replace(/[^0-9]/g, "").slice(0, 12);
|
||||
|
||||
/**
|
||||
* 검색 시각 입력(`datetime-local`, `step=3600`)의 분을 00으로 맞춘다.
|
||||
* 브라우저는 값의 분이 00이면 분 칸을 고정하지만, 빈 값에서 처음 입력하거나 직접 입력해 분이 들어온 경우는
|
||||
* 스스로 고치지 않으므로 저장 전에 시간 단위로 내린다. 형식이 아니면(빈 값 포함) 그대로 둔다.
|
||||
*/
|
||||
const toHourInputValue = (value: string) =>
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)
|
||||
? `${value.slice(0, 13)}:00`
|
||||
: value;
|
||||
|
||||
const displayDate = (value: string) =>
|
||||
/^\d{12}$/.test(value)
|
||||
? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)} ${value.slice(8, 10)}:${value.slice(10, 12)}`
|
||||
@@ -134,6 +157,12 @@ const predictionHourText = (value: number | null) => {
|
||||
return hour > 0 ? `${hour}시간 후` : "-";
|
||||
};
|
||||
|
||||
/** 분석 결과의 대표 누적강우시간을 확률강우량 기준표 링크 문구로 표시한다. */
|
||||
const probabilityDurationText = (value: number | null) => {
|
||||
const hour = Number(value);
|
||||
return Number.isInteger(hour) && hour > 0 ? `${hour}시간` : "-";
|
||||
};
|
||||
|
||||
const formatNumber = (value: number | null, digits: number) =>
|
||||
value == null || !Number.isFinite(Number(value))
|
||||
? "-"
|
||||
@@ -166,7 +195,7 @@ const validateFilters = (filters: Filters) => {
|
||||
filters.rainBgngTo &&
|
||||
filters.rainBgngFrom > filters.rainBgngTo
|
||||
) {
|
||||
return "강우 시작시각의 시작은 종료보다 늦을 수 없습니다.";
|
||||
return "강우시각의 시작은 종료보다 늦을 수 없습니다.";
|
||||
}
|
||||
if (
|
||||
filters.analysisFrom &&
|
||||
@@ -201,6 +230,14 @@ export default function FldngPredcPrstPopupContent() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedRowId, setSelectedRowId] = useState("");
|
||||
// 확률강우량 기준표는 목록의 선택 행과 별도로 열고 닫는다.
|
||||
const [probabilityRow, setProbabilityRow] = useState<TableRow | null>(null);
|
||||
const [probabilityData, setProbabilityData] =
|
||||
useState<PrbbtRnflResponse | null>(null);
|
||||
const [probabilityLoading, setProbabilityLoading] = useState(false);
|
||||
const [probabilityError, setProbabilityError] = useState<string | null>(null);
|
||||
// 연속 클릭 또는 닫기 이후 도착한 이전 응답이 현재 팝업을 덮지 않도록 요청 순서를 보관한다.
|
||||
const probabilityRequestIdRef = useRef(0);
|
||||
// 개발 StrictMode에서도 최초 목록 API를 한 번만 호출하기 위한 ref다.
|
||||
const didInitialLoadRef = useRef(false);
|
||||
|
||||
@@ -267,6 +304,9 @@ export default function FldngPredcPrstPopupContent() {
|
||||
item.exceededFrequencyYear == null
|
||||
? "-"
|
||||
: String(item.exceededFrequencyYear),
|
||||
probabilityRainfallDisplay: probabilityDurationText(
|
||||
item.predcRainHr,
|
||||
),
|
||||
})),
|
||||
);
|
||||
setTotal(Number(payload?.totalElements ?? 0));
|
||||
@@ -338,6 +378,11 @@ export default function FldngPredcPrstPopupContent() {
|
||||
const extentData = await getData(
|
||||
`/sewerage-service/api/v1/fldng/floodDngrMap/group/extent?${params.toString()}`,
|
||||
);
|
||||
// null·생략된 data는 정상적인 위치 부재이며 부모 지도 이동·강조를 요청하지 않는다.
|
||||
if (extentData == null) {
|
||||
alert("해당 도시침수지도가 없습니다.");
|
||||
return;
|
||||
}
|
||||
const extent = [
|
||||
extentData?.minX,
|
||||
extentData?.minY,
|
||||
@@ -345,7 +390,7 @@ export default function FldngPredcPrstPopupContent() {
|
||||
extentData?.maxY,
|
||||
].map(Number);
|
||||
if (extent.some((value) => !Number.isFinite(value))) {
|
||||
throw new Error("도시침수지도 위치 정보가 없습니다.");
|
||||
throw new Error("도시침수지도 위치 응답의 좌표가 올바르지 않습니다.");
|
||||
}
|
||||
window.opener.postMessage(
|
||||
{
|
||||
@@ -372,6 +417,43 @@ export default function FldngPredcPrstPopupContent() {
|
||||
}
|
||||
};
|
||||
|
||||
/** 선택 시군구의 확률강우량과 관측소 비율을 조회한다. 지도 행 클릭은 버튼에서 차단한다. */
|
||||
const handleProbabilityClick = async (row: TableRow) => {
|
||||
if (!row.sggCd || !Number.isInteger(Number(row.predcRainHr)) || Number(row.predcRainHr) <= 0) {
|
||||
return;
|
||||
}
|
||||
const requestId = ++probabilityRequestIdRef.current;
|
||||
setProbabilityRow(row);
|
||||
setProbabilityData(null);
|
||||
setProbabilityError(null);
|
||||
setProbabilityLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ sggCd: row.sggCd });
|
||||
const response = await getRawJson(
|
||||
`/sewerage-service/api/v1/fldng/predc/prst/prbbt-rnfl?${params.toString()}`,
|
||||
);
|
||||
if (requestId !== probabilityRequestIdRef.current) return;
|
||||
const payload: PrbbtRnflResponse = response?.data ?? response;
|
||||
setProbabilityData(payload);
|
||||
} catch (error) {
|
||||
if (requestId !== probabilityRequestIdRef.current) return;
|
||||
console.error("시군구 확률강우량 조회 중 오류:", error);
|
||||
setProbabilityError("확률강우량 기준표를 조회하지 못했습니다.");
|
||||
} finally {
|
||||
if (requestId === probabilityRequestIdRef.current) {
|
||||
setProbabilityLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** 닫힌 팝업으로 늦게 도착하는 API 응답을 무시한다. */
|
||||
const handleProbabilityOpenChange = (open: boolean) => {
|
||||
if (open) return;
|
||||
probabilityRequestIdRef.current += 1;
|
||||
setProbabilityRow(null);
|
||||
setProbabilityLoading(false);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!total) {
|
||||
alert("다운로드할 데이터가 없습니다.");
|
||||
@@ -406,6 +488,11 @@ export default function FldngPredcPrstPopupContent() {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 분석시각·강우시각 범위를 시간 단위로만 고르게 그린다.
|
||||
* `step=3600`이라 값의 분이 00이면 브라우저가 분 칸을 고정해 바꿀 수 없고, 분이 들어온 경우는 저장 전에 00으로 내린다.
|
||||
* 침수예측 자료의 분석·강우시작 시각은 정시 단위이므로 시간 단위 조건으로 빠지는 자료는 없다.
|
||||
*/
|
||||
const renderDateTimeRange = (
|
||||
label: string,
|
||||
fromKey: "rainBgngFrom" | "analysisFrom",
|
||||
@@ -418,27 +505,31 @@ export default function FldngPredcPrstPopupContent() {
|
||||
<input
|
||||
className="h-[3.2rem] w-[17rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
type="datetime-local"
|
||||
step={3600}
|
||||
value={filters[fromKey]}
|
||||
aria-label={`${label} 시작`}
|
||||
onChange={(event) =>
|
||||
onChange={(event) => {
|
||||
const nextValue = toHourInputValue(event.target.value);
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
[fromKey]: event.target.value,
|
||||
}))
|
||||
}
|
||||
[fromKey]: nextValue,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<span className="text-xl font-medium text-label">~</span>
|
||||
<input
|
||||
className="h-[3.2rem] w-[17rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
type="datetime-local"
|
||||
step={3600}
|
||||
value={filters[toKey]}
|
||||
aria-label={`${label} 종료`}
|
||||
onChange={(event) =>
|
||||
onChange={(event) => {
|
||||
const nextValue = toHourInputValue(event.target.value);
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
[toKey]: event.target.value,
|
||||
}))
|
||||
}
|
||||
[toKey]: nextValue,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
@@ -561,10 +652,27 @@ export default function FldngPredcPrstPopupContent() {
|
||||
>
|
||||
{row.floodRiskGrade || "-"}
|
||||
</span>
|
||||
) : headId === "probabilityRainfallDisplay" ? (
|
||||
row.probabilityRainfallDisplay === "-" ? (
|
||||
"-"
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium text-primary underline underline-offset-2 hover:opacity-75 focus-visible:rounded-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||
aria-label={`${row.sggNm} ${row.probabilityRainfallDisplay} 확률강우량 기준표 보기`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleProbabilityClick(row);
|
||||
}}
|
||||
>
|
||||
{row.probabilityRainfallDisplay}
|
||||
</button>
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="100rem"
|
||||
minTableWidth="112rem"
|
||||
fullTextHeadIds={FULL_TEXT_TIME_HEAD_IDS}
|
||||
fillAvailableHeight={rows.length === 0}
|
||||
emptyMessage="조회 조건에 해당하는 침수예측현황이 없습니다."
|
||||
actions={
|
||||
@@ -579,17 +687,32 @@ export default function FldngPredcPrstPopupContent() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-1 px-1 text-xl text-muted-foreground">
|
||||
<p>
|
||||
30년빈도 이상인 경우{" "}
|
||||
<span className="font-semibold text-[#dc2626]">위험</span>, 10년빈도
|
||||
이상인 경우{" "}
|
||||
<span className="font-semibold text-[#f97316]">경계</span>, 2년빈도
|
||||
이상인 경우{" "}
|
||||
<span className="font-semibold text-[#ca8a04]">주의</span>
|
||||
</p>
|
||||
<p>행을 선택하면 해당 시군구·빈도의 도시침수지도로 이동합니다.</p>
|
||||
</div>
|
||||
{/* 판정 기준·행 선택 안내에 인수테스트 요구(자료 원천·분석 주기 표시) 문구를 글머리표로 더한다. */}
|
||||
<PopupFooterNote
|
||||
items={[
|
||||
<>
|
||||
30년빈도 이상인 경우{" "}
|
||||
<span className="font-semibold text-[#dc2626]">위험</span>,
|
||||
10년빈도 이상인 경우{" "}
|
||||
<span className="font-semibold text-[#f97316]">경계</span>,
|
||||
2년빈도 이상인 경우{" "}
|
||||
<span className="font-semibold text-[#ca8a04]">주의</span>
|
||||
</>,
|
||||
"행을 선택하면 해당 시군구·빈도의 도시침수지도로 이동합니다.",
|
||||
"분석 실행 시기 : 침수예측은 관측강우가 10mm이상으로 강우가 발생시 1시간 주기로 분석 자동실행",
|
||||
"분석 방법 : 기상청 관측강우와 향후 6시간 예측강우를 누적하여 지속시간별 빈도별 확률강우량(출처 : map.wamis.go.kr)과 근접한 강우량을 찾아 해당되는 빈도의 도시침수지도(출처 : 한강홍수통제소)를 표출",
|
||||
]}
|
||||
/>
|
||||
<PrbbtRnflDialog
|
||||
open={probabilityRow !== null}
|
||||
onOpenChange={handleProbabilityOpenChange}
|
||||
sggName={probabilityRow?.sggNm ?? ""}
|
||||
selectedDurationHour={probabilityRow?.predcRainHr ?? null}
|
||||
selectedFrequencyYear={probabilityRow?.exceededFrequencyYear ?? null}
|
||||
data={probabilityData}
|
||||
loading={probabilityLoading}
|
||||
error={probabilityError}
|
||||
/>
|
||||
</section>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type Map from "ol/Map";
|
||||
import type { FitOptions } from "ol/View";
|
||||
|
||||
/** 지도 객체별 이동 요청과 검색바 초기화 revision을 보관한다. 다른 지도와 상태를 공유하지 않는다. */
|
||||
type NavigationState = {
|
||||
generation: number;
|
||||
revision: number;
|
||||
listeners: Set<() => void>;
|
||||
};
|
||||
const states = new WeakMap<object, NavigationState>();
|
||||
/** 부분 지도 인터페이스를 사용하는 기능도 동일한 인스턴스로 요청을 관리한다. */
|
||||
type NavigationTarget = { getView?: () => unknown };
|
||||
|
||||
/** 지도 생명주기와 함께 회수되는 상태를 반환한다. */
|
||||
function stateFor(map: object): NavigationState {
|
||||
let state = states.get(map);
|
||||
if (!state) {
|
||||
state = { generation: 0, revision: 0, listeners: new Set() };
|
||||
states.set(map, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/** 빈/역전/비수치 범위를 이동 성공으로 취급하지 않는다. */
|
||||
export function isNavigationExtent(extent: unknown): extent is number[] {
|
||||
return Array.isArray(extent) && extent.length === 4 &&
|
||||
extent.every(Number.isFinite) && extent[0] <= extent[2] && extent[1] <= extent[3];
|
||||
}
|
||||
|
||||
/** 검색바 조작은 아직 완료되지 않은 목록 이동보다 우선한다. */
|
||||
export function invalidateMapNavigation(map: object | null | undefined) {
|
||||
if (map) stateFor(map).generation += 1;
|
||||
}
|
||||
|
||||
/** 검색바는 이미 발생한 초기화도 revision으로 읽어 늦은 마운트에서 유실하지 않는다. */
|
||||
export function getMapSearchRevision(map: object | null | undefined) {
|
||||
return map ? stateFor(map).revision : 0;
|
||||
}
|
||||
|
||||
/** 한 지도 검색바의 초기화만 구독한다. */
|
||||
export function subscribeMapSearch(map: object | null | undefined, listener: () => void) {
|
||||
if (!map) return () => {};
|
||||
const state = stateFor(map);
|
||||
state.listeners.add(listener);
|
||||
return () => { state.listeners.delete(listener); };
|
||||
}
|
||||
|
||||
/** 조회 시작에 발급하고 도형 확보 뒤 commit한다. 최신 요청만 한 번 검색바를 초기화한다. */
|
||||
export function beginMapNavigation(map: NavigationTarget | null | undefined) {
|
||||
const state = map ? stateFor(map) : null;
|
||||
const generation = state ? ++state.generation : -1;
|
||||
let committed = false;
|
||||
const isCurrent = () => Boolean(state && state.generation === generation);
|
||||
return {
|
||||
isCurrent,
|
||||
commit(extent: unknown) {
|
||||
if (!isCurrent() || !map?.getView?.() || !isNavigationExtent(extent)) return false;
|
||||
if (!committed && state) {
|
||||
committed = true;
|
||||
state.revision += 1;
|
||||
state.listeners.forEach((listener) => listener());
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 도형이 이미 준비된 목록 선택은 기존 fit 옵션을 그대로 사용한다. */
|
||||
export function fitMapSelection(map: Pick<Map, "getView"> | null | undefined, extent: unknown, options: FitOptions & { constrainResolution?: boolean }) {
|
||||
if (!map?.getView?.()?.fit || !isNavigationExtent(extent)) return false;
|
||||
const navigation = beginMapNavigation(map);
|
||||
if (!navigation.commit(extent)) return false;
|
||||
map.getView().fit(extent, options);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 공통 GeoJSON 검색의 비동기 로드 완료 시 호출할 선택적 계약이다. */
|
||||
export function mapSearchNavigationOptions(map: NavigationTarget | null | undefined) {
|
||||
const navigation = beginMapNavigation(map);
|
||||
return { beforeFocus: (extent: unknown) => navigation.commit(extent) };
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export const windowPopupDefinitions = {
|
||||
title: "준설결과 수정",
|
||||
width: 1280,
|
||||
height: 900,
|
||||
requiredParams: ["dredgingJobSn", "pipeFid", "longitude", "latitude"],
|
||||
requiredParams: ["dredgingJobSn", "targetSn", "longitude", "latitude"],
|
||||
},
|
||||
exmnPipeReport: {
|
||||
key: "exmnPipeReport",
|
||||
|
||||
@@ -23,14 +23,16 @@ export const buildDredgingPopupRecord = (
|
||||
): DredgingRecord => ({
|
||||
id: getParam(searchParams.id),
|
||||
dredgingJobSn: getParam(searchParams.dredgingJobSn),
|
||||
pipeFid: getParam(searchParams.pipeFid),
|
||||
targetSn: getNumber(searchParams.targetSn),
|
||||
mappingStatus: getParam(searchParams.mappingStatus) === "UNMAPPED" ? "UNMAPPED" : "AUTO",
|
||||
pipeFid: getParam(searchParams.pipeFid) || null,
|
||||
mngInstCd: getParam(searchParams.mngInstCd),
|
||||
ctpv: getParam(searchParams.ctpv),
|
||||
sgg: getParam(searchParams.sgg),
|
||||
treatmentZone: getParam(searchParams.treatmentZone),
|
||||
fcltMngNo: getParam(searchParams.fcltMngNo),
|
||||
pipeDiameterMm: getNumber(searchParams.pipeDiameterMm),
|
||||
pipeLengthM: getNumber(searchParams.pipeLengthM),
|
||||
pipeDiameterMm: getNullableNumber(searchParams.pipeDiameterMm),
|
||||
pipeLengthM: getNullableNumber(searchParams.pipeLengthM),
|
||||
pipeMaterial: getParam(searchParams.pipeMaterial),
|
||||
installationYear: getNullableNumber(searchParams.installationYear),
|
||||
dredgingDate: getParam(searchParams.dredgingDate),
|
||||
@@ -44,6 +46,6 @@ export const buildDredgingPopupRecord = (
|
||||
getParam(searchParams.registrationMode) === "MANUAL" ? "MANUAL" : "PHOTO",
|
||||
registrationSource:
|
||||
getParam(searchParams.registrationSource) === "MANUAL" ? "MANUAL" : "EXIF",
|
||||
matchDistanceMeters: getNumber(searchParams.matchDistanceMeters),
|
||||
matchDistanceMeters: getNullableNumber(searchParams.matchDistanceMeters),
|
||||
isMock: getParam(searchParams.isMock) === "true",
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ const getParam = (value: string | string[] | undefined) =>
|
||||
/** 공통 Blob API 오류 문자열에서 백엔드의 사용자 안내 메시지만 추출한다. */
|
||||
const getReportErrorMessage = (error: unknown) => {
|
||||
if (!(error instanceof Error)) {
|
||||
return "관로 조사보고서를 불러오지 못했습니다.";
|
||||
return "조사보고서를 불러오지 못했습니다.";
|
||||
}
|
||||
|
||||
const backendMessage = error.message.match(
|
||||
@@ -35,6 +35,9 @@ export default function ExmnPipeReportPopupContent({
|
||||
}: WindowPopupContentProps) {
|
||||
const exmnBizId = getParam(searchParams.exmnBizId);
|
||||
const pipeNumber = getParam(searchParams.pipeNumber);
|
||||
/** 조사유형별 첨부 API와 표시 제목을 선택한다. */
|
||||
const jobType = getParam(searchParams.jobType) === "MNHL" ? "MNHL" : "PPL";
|
||||
const reportLabel = jobType === "MNHL" ? "맨홀" : "관로";
|
||||
const reportNo = getParam(searchParams.reportNo);
|
||||
const [reportUrl, setReportUrl] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
@@ -46,7 +49,7 @@ export default function ExmnPipeReportPopupContent({
|
||||
setReportUrl("");
|
||||
setErrorMessage("");
|
||||
|
||||
void downloadExmnPipeReport(exmnBizId, pipeNumber)
|
||||
void downloadExmnPipeReport(exmnBizId, pipeNumber, jobType)
|
||||
.then((blob) => {
|
||||
if (!active) return;
|
||||
|
||||
@@ -59,7 +62,7 @@ export default function ExmnPipeReportPopupContent({
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!active) return;
|
||||
console.warn("관로 조사보고서 조회 실패:", error);
|
||||
console.warn("조사보고서 조회 실패:", error);
|
||||
setErrorMessage(getReportErrorMessage(error));
|
||||
});
|
||||
|
||||
@@ -67,16 +70,16 @@ export default function ExmnPipeReportPopupContent({
|
||||
active = false;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [exmnBizId, pipeNumber]);
|
||||
}, [exmnBizId, pipeNumber, jobType]);
|
||||
|
||||
return (
|
||||
<StandardPopupLayout
|
||||
title="관로 조사보고서"
|
||||
title={`${reportLabel} 조사보고서`}
|
||||
showDefaultCancelAction={false}
|
||||
actions={
|
||||
<>
|
||||
<span className="text-[1.05rem] text-label">
|
||||
관로번호 {pipeNumber}
|
||||
{jobType === "MNHL" ? "GIS 번호" : "관로번호"} {pipeNumber}
|
||||
{reportNo ? ` · 보고서번호 ${reportNo}` : ""}
|
||||
</span>
|
||||
<Button type="button" color="white" onClick={() => window.close()}>
|
||||
@@ -93,7 +96,7 @@ export default function ExmnPipeReportPopupContent({
|
||||
</div>
|
||||
) : reportUrl ? (
|
||||
<iframe
|
||||
title={`${pipeNumber} 관로 조사보고서`}
|
||||
title={`${pipeNumber} ${reportLabel} 조사보고서`}
|
||||
src={reportUrl}
|
||||
className="h-full w-full border border-border bg-background"
|
||||
/>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user