Compare commits

...
18 Commits
Author SHA1 Message Date
mind-one005 eddb1b24d6 sn: 지반침하 지도상세정보 엑셀다운로드 2026-10-22 10:19:52 +09:00
mind-one005 cf2cc11167 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-10-22 09:44:24 +09:00
mind-one005 9e71f529e7 sn: 통수위험 위험여부란 수정 외 2026-10-22 09:33:27 +09:00
kiminchan 36b11d6b00 맨홀 조사보고서 단독 교체 및 GIS 번호 기반 조회 수정 2026-10-19 17:01:37 +09:00
mind-one005 79f70d8b4b sn: 도시침수지도 geoserver style적용 2026-10-16 18:05:48 +09:00
mind-one005 7911cff96a sn: GIS편집,GIS관리, 통수위험 정보카드 X버튼 2026-10-16 17:36:31 +09:00
mind-one005 fe501965bf sn: 조사관리>>조사사업 2026-10-16 16:51:47 +09:00
mind-one001 fb82cadc82 준설관리 텍스트 수정 2026-10-16 11:10:47 +09:00
mind-one001 f5b6effeb3 준설관리 관로 미표출로 인한 기능수정 2026-10-16 11:00:13 +09:00
mind-one001 63c2901ae3 feat(sn): allow dredging records without a mapped pipe 2026-10-16 10:09:44 +09:00
mind-one001 0356aef7b2 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-10-15 11:23:53 +09:00
mind-one001 f709dc99f6 chore: stop tracking local VS Code settings 2026-10-15 11:21:44 +09:00
mind-one005 728a66bac2 sn: apigateway.onasys.co.kr 2026-10-13 15:55:31 +09:00
mind-one005 c850b9ce1e sn: 해당 도시침수지도가 없습니다. 2026-10-13 15:48:24 +09:00
mind-one005 791bc20e8e sn: 지도 위치 정보가 없는 경우 404>>위치 정보가 없습니다. 2026-10-13 15:22:16 +09:00
mind-one001 fed1383ced 시도별 노후관로현황 기본 조회기간을 당해연도로 변경 2026-10-08 10:30:51 +09:00
mind-one001 91ff80a255 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-10-07 15:15:32 +09:00
mind-one001 7f10e20a41 전국 지도 보이게 줌 레벨 수정 2026-10-07 15:03:03 +09:00
77 changed files with 1670 additions and 711 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
# turbo # turbo
.turbo/ .turbo/
.vscode/**/* **/.vscode/
*.code-workspace
# dependencies # dependencies
node_modules/ node_modules/
-88
View File
@@ -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"
}
}
]
}
-5
View File
@@ -1,5 +0,0 @@
{
"chat.tools.terminal.autoApprove": {
"pnpm": true
}
}
+1 -1
View File
@@ -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/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_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} 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://localhost:8883
# SEWERAGE_API_URL = http://con.mind-one.co.kr:11083 # SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
DC_URL = https://apigateway.apps.rsonesoft.com DC_URL = https://apigateway.apps.rsonesoft.com
+1 -1
View File
@@ -6,7 +6,7 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11302 NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11302
NEXT_PUBLIC_SUB_DOMAIN = http://con.mind-one.co.kr:13000 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_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
+1
View File
@@ -166,6 +166,7 @@ $commonDocsRoot = Join-Path $beRepoRoot 'docs'
## 작업 원칙 ## 작업 원칙
- `sn` 프론트엔드 엑셀 다운로드 기능을 신규 개발하거나 수정할 때는 `src/shared/util/download-excel-workbook.ts` 재사용을 먼저 검토한다. 세부 기준은 [컴포넌트정책의 공통 유틸 재사용 기준](docs/기준문서/004-컴포넌트정책.md#공통-유틸-재사용-기준)을 따른다. 이 코드 경로는 `FE_APP_ROOT` 기준이다.
- 일반 목록·모든 팝업은 공통 AGENTS.md의 서버 페이징·지도·Excel 계약을 따른다. 지도 전체 표출에 현재 그리드 페이지 배열을 재사용하지 않는다. Excel은 적용조건 전체를 사용하고 대량 원시 자료는 서버 생성으로 분리한다. 화면 페이징/페이징 없음 예외는 사유와 검증 범위를 메뉴 > 화면명별로 기록한다. - 일반 목록·모든 팝업은 공통 AGENTS.md의 서버 페이징·지도·Excel 계약을 따른다. 지도 전체 표출에 현재 그리드 페이지 배열을 재사용하지 않는다. Excel은 적용조건 전체를 사용하고 대량 원시 자료는 서버 생성으로 분리한다. 화면 페이징/페이징 없음 예외는 사유와 검증 범위를 메뉴 > 화면명별로 기록한다.
1. 모든 변경은 원본 요구사항정의서와 화면설계서, 2차 요구사항, 기능 명세 또는 승인된 작업 문서를 근거로 한다. 1. 모든 변경은 원본 요구사항정의서와 화면설계서, 2차 요구사항, 기능 명세 또는 승인된 작업 문서를 근거로 한다.
@@ -66,7 +66,8 @@ export const normalizeAgedPipeStatusByCtpvFilters = (
filters: Partial<AgedPipeStatusByCtpvFilters>, filters: Partial<AgedPipeStatusByCtpvFilters>,
): AgedPipeStatusByCtpvFilters => { ): AgedPipeStatusByCtpvFilters => {
const currentYear = new Date().getFullYear(); 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 endYear = Math.trunc(Number(filters.endYear ?? currentYear));
let elapsedYears = Math.trunc( let elapsedYears = Math.trunc(
Number( 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(endYear)) endYear = currentYear;
if ( if (
!Number.isFinite(elapsedYears) || !Number.isFinite(elapsedYears) ||
@@ -10,9 +10,7 @@ import {
ChartColumnBig, ChartColumnBig,
ChevronUp, ChevronUp,
PanelsTopLeft, PanelsTopLeft,
PencilRuler,
Search, Search,
Settings2,
TriangleAlert, TriangleAlert,
Waves, Waves,
} from "lucide-react"; } from "lucide-react";
@@ -28,8 +26,6 @@ import { getRawJson } from "@/shared/api/sewerage-api";
type Props = { type Props = {
onOpenPopup: (popupKey: WindowPopupKey) => Window | null; onOpenPopup: (popupKey: WindowPopupKey) => Window | null;
onOpenInfoSearch: () => void; onOpenInfoSearch: () => void;
onToggleGisEdit?: () => void;
isGisEditActive?: boolean;
isInfoSearchActive?: boolean; isInfoSearchActive?: boolean;
initialActiveKey?: WindowPopupKey | null; initialActiveKey?: WindowPopupKey | null;
}; };
@@ -89,16 +85,6 @@ const ANALYSIS_POPUP_ITEMS: Array<{
label: windowPopupDefinitions.agedPipeStatusByCtpvPopup.title, label: windowPopupDefinitions.agedPipeStatusByCtpvPopup.title,
icon: ChartColumnBig, icon: ChartColumnBig,
}, },
{
key: "gisEditPopup",
label: windowPopupDefinitions.gisEditPopup.title,
icon: PencilRuler,
},
{
key: "gisManagementPopup",
label: windowPopupDefinitions.gisManagementPopup.title,
icon: Settings2,
},
{ {
key: "flowRiskPopup", key: "flowRiskPopup",
label: windowPopupDefinitions.flowRiskPopup.title, label: windowPopupDefinitions.flowRiskPopup.title,
@@ -119,8 +105,6 @@ const ANALYSIS_POPUP_ITEMS: Array<{
export default function AnalysisPopupMenu({ export default function AnalysisPopupMenu({
onOpenPopup, onOpenPopup,
onOpenInfoSearch, onOpenInfoSearch,
onToggleGisEdit,
isGisEditActive = false,
isInfoSearchActive = false, isInfoSearchActive = false,
initialActiveKey = null, initialActiveKey = null,
}: Props) { }: Props) {
@@ -155,12 +139,6 @@ export default function AnalysisPopupMenu({
}; };
}, []); }, []);
useEffect(() => {
if (!isGisEditActive && activeKey === "gisEditPopup") {
setActiveKey(null);
}
}, [activeKey, isGisEditActive]);
useEffect(() => { useEffect(() => {
if (!isInfoSearchActive && activeKey === "infoSearch") { if (!isInfoSearchActive && activeKey === "infoSearch") {
setActiveKey(null); setActiveKey(null);
@@ -214,10 +192,7 @@ export default function AnalysisPopupMenu({
<div className="flex flex-col gap-[3px] p-[6px]"> <div className="flex flex-col gap-[3px] p-[6px]">
{ANALYSIS_POPUP_ITEMS.map((item) => { {ANALYSIS_POPUP_ITEMS.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = const isActive = activeKey === item.key;
item.key === "gisEditPopup"
? isGisEditActive
: activeKey === item.key;
const label = const label =
item.key === "fldngPredcPrst" item.key === "fldngPredcPrst"
? `침수예측현황(${seriousPredictionCount}건)` ? `침수예측현황(${seriousPredictionCount}건)`
@@ -233,12 +208,6 @@ export default function AnalysisPopupMenu({
: "border-transparent bg-background text-foreground hover:border-border hover:bg-accent hover:text-primary" : "border-transparent bg-background text-foreground hover:border-border hover:bg-accent hover:text-primary"
}`} }`}
onClick={() => { onClick={() => {
if (item.key === "gisEditPopup" && onToggleGisEdit) {
setActiveKey(item.key);
onToggleGisEdit();
return;
}
setActiveKey(item.key); setActiveKey(item.key);
if (item.key === "infoSearch") { if (item.key === "infoSearch") {
@@ -127,7 +127,7 @@ const normalizeControlNode = (
...node, ...node,
lyrSn: nodeId, lyrSn: nodeId,
id: nodeId, id: nodeId,
// DB에 저장된 과거 명칭과 무관하게 조사관리 표준 화면 명칭으로 표시한다. // DB에 저장된 과거 명칭과 무관하게 조사사업 표준 화면 명칭으로 표시한다.
lyrNm: fallbackName, lyrNm: fallbackName,
upperLyrSn: upperId, upperLyrSn: upperId,
upId: upperId, upId: upperId,
@@ -87,7 +87,7 @@ export const useExmnResultMap = (options: Options) => {
[gisApp, mapRef], [gisApp, mapRef],
); );
/** 지역 검색값 없이 전체를 요청하고, 선택 진입 context는 위치 이동 범위 조회에만 사용한다. */ /** 일반 진입은 전체, 조사관리 지도보기는 선택 사업·처리분구만 조회해 겹친 타 사업 선택을 방지한다. */
const loadLayer = useCallback( const loadLayer = useCallback(
async (job: ExmnMapJobType, filters: ExmnMapFilters) => { async (job: ExmnMapJobType, filters: ExmnMapFilters) => {
const map = mapRef.current; const map = mapRef.current;
@@ -109,7 +109,9 @@ export const useExmnResultMap = (options: Options) => {
const response = await getExmnMapLayer( const response = await getExmnMapLayer(
job, job,
filters, filters,
{}, entry.exmnBizId && entry.trtdstId
? { exmnBizId: entry.exmnBizId, trtdstId: entry.trtdstId }
: {},
controller.signal, controller.signal,
); );
if (!active()) return; if (!active()) return;
@@ -5,7 +5,7 @@
"use client"; "use client";
import { SelectBox } from "@common/business_components/ui"; 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 { normalizeFldngHstryYearRange } from "@/entities/flood-history/lib/flood-history-utils";
import LayerSettingCard from "../layer-setting/layer-setting-card"; import LayerSettingCard from "../layer-setting/layer-setting-card";
@@ -26,10 +26,10 @@ export default function FldngHstryLayerSettingPanel({
onClose, onClose,
}: Props) { }: Props) {
const normalizedRange = normalizeFldngHstryYearRange(yearRange); const normalizedRange = normalizeFldngHstryYearRange(yearRange);
const currentYear = new Date().getFullYear(); // 제공 자료의 최대 연도부터 최소 연도까지 선택 목록을 구성한다.
const yearOptions = Array.from( const yearOptions = Array.from(
{ length: currentYear - FLDNG_HSTRY_MIN_YEAR + 1 }, { length: FLDNG_HSTRY_MAX_YEAR - FLDNG_HSTRY_MIN_YEAR + 1 },
(_, index) => String(currentYear - index), (_, index) => String(FLDNG_HSTRY_MAX_YEAR - index),
); );
const startYearOptions = yearOptions const startYearOptions = yearOptions
.filter((year) => Number(year) <= Number(normalizedRange.endYear)) .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 useStore from "@/entities/reference-data/model/reference-data-store";
import LoadingOverlay from "@/components/ui/loading-bar"; import LoadingOverlay from "@/components/ui/loading-bar";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; 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 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 { 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"; 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"> {/* 기존 행 클릭 안내에 인수테스트 요구(자료 원천 표시) 출처 문구를 글머리표로 더한다. */}
행을 클릭하면 메인 지도에서 해당 침수흔적을 표시합니다. <PopupFooterNote
</div> items={[
"행을 클릭하면 메인 지도에서 해당 침수흔적을 표시합니다.",
"출처 : 행정안전부 재난보험과(2002년~2025년)",
]}
/>
</StandardPopupLayout> </StandardPopupLayout>
); );
} }
@@ -11,6 +11,7 @@ import { getData } from "@/shared/api/sewerage-api";
import LoadingOverlay from "@/components/ui/loading-bar"; import LoadingOverlay from "@/components/ui/loading-bar";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; 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 PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import useStore from "@/entities/reference-data/model/reference-data-store"; import useStore from "@/entities/reference-data/model/reference-data-store";
import { import {
@@ -226,7 +227,12 @@ export default function FloodDngrMapPopupPage() {
const response = await getFloodDngrMapJson( const response = await getFloodDngrMapJson(
`/sewerage-service/api/v1/fldng/floodDngrMap/group/extent?${params.toString()}`, `/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 = [ const extent = [
payload?.minX, payload?.minX,
payload?.minY, payload?.minY,
@@ -237,7 +243,7 @@ export default function FloodDngrMapPopupPage() {
extent.length !== 4 || extent.length !== 4 ||
extent.some((value) => !Number.isFinite(value)) extent.some((value) => !Number.isFinite(value))
) { ) {
throw new Error("해당 도시침수지도 피처의 위치 정보가 없습니다."); throw new Error("도시침수지도 위치 응답의 좌표가 올바르지 않습니다.");
} }
window.opener?.postMessage( window.opener?.postMessage(
{ {
@@ -253,9 +259,7 @@ export default function FloodDngrMapPopupPage() {
} catch (error) { } catch (error) {
console.error("도시침수지도 위치 이동 중 오류:", error); console.error("도시침수지도 위치 이동 중 오류:", error);
const message = const message =
error instanceof Error && error.message.includes("404") error instanceof Error
? "해당 도시침수지도 데이터에 위치 정보가 없어 이동할 수 없습니다."
: error instanceof Error
? error.message ? error.message
: "도시침수지도 위치 이동 중 오류가 발생했습니다."; : "도시침수지도 위치 이동 중 오류가 발생했습니다.";
setErrorMessage(message); setErrorMessage(message);
@@ -395,6 +399,11 @@ export default function FloodDngrMapPopupPage() {
onRowClick={(row) => void handleRowClick(row)} onRowClick={(row) => void handleRowClick(row)}
rowId={(row, index) => `${row.sggCd}-${row.sbmrgFrqExpln}-${index}`} rowId={(row, index) => `${row.sggCd}-${row.sbmrgFrqExpln}-${index}`}
/> />
{/* 인수테스트 요구: 팝업 자료의 원천을 목록 아래에 표시한다. */}
<PopupFooterNote
items={["출처 : 한강홍수통제소, 행정구역별 도시침수지도(2025.09)"]}
/>
</StandardPopupLayout> </StandardPopupLayout>
); );
} }
@@ -34,7 +34,7 @@ import type {
export const useGisEdit = (treeData: any[]) => { export const useGisEdit = (treeData: any[]) => {
const editableLayers = useMemo(() => collectEditableLayers(treeData), [treeData]); const editableLayers = useMemo(() => collectEditableLayers(treeData), [treeData]);
// GIS 편집 모드 on/off는 우측 분석 메뉴의 GIS 편집 버튼과 직접 연결된다. // GIS 편집 모드 on/off는 지도 우측 GIS 편집 아이콘과 직접 연결된다.
const [isEditMode, setIsEditMode] = useState(false); const [isEditMode, setIsEditMode] = useState(false);
// 현재 선택 레이어를 기준으로 허용 도구와 저장 payload의 layerId를 결정한다. // 현재 선택 레이어를 기준으로 허용 도구와 저장 payload의 layerId를 결정한다.
const [selectedLayerId, setSelectedLayerId] = useState("tb_ms_swage_ppl"); 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>
);
}
@@ -17,6 +17,7 @@ import { useUserStore } from "@/shared/lib/use-user-store";
import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup"; import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup";
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions"; import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
import PipeAnalysisSummaryTable from "./pipe-analysis-summary-table"; 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 PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import { import {
PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS, PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS,
@@ -522,6 +523,13 @@ export default function PipeAnalysisPopupPage() {
</Button> </Button>
} }
/> />
{/* 인수테스트 요구: 관로분석의 기반 자료(원천)를 목록 아래에 표시한다. */}
<PopupFooterNote
items={[
"국토교통부로부터 제공받은 GIS 관로정보(2025년)를 기반으로 분석하고 있음",
]}
/>
</StandardPopupLayout> </StandardPopupLayout>
); );
} }
@@ -10,6 +10,7 @@ import { StandardPopupLayout } from "@common/business_components";
import { getRawJson } from "@/shared/api/sewerage-api"; import { getRawJson } from "@/shared/api/sewerage-api";
import LoadingOverlay from "@/components/ui/loading-bar"; import LoadingOverlay from "@/components/ui/loading-bar";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; 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 PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
import { useAlertStore } from "@/shared/lib"; import { useAlertStore } from "@/shared/lib";
@@ -309,6 +310,11 @@ export default function SbmrgDngrMapPopupPage() {
].join(":") ].join(":")
} }
/> />
{/* 인수테스트 요구: 팝업 자료의 원천을 목록 아래에 표시한다. */}
<PopupFooterNote
items={["출처 : 하수도정비 중점관리지역 20개소 과업 산출물"]}
/>
</StandardPopupLayout> </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"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@common/components"; import { Button } from "@common/components";
import { VerticalTable } from "@/components/ui/vertical-table"; import { VerticalTable } from "@/components/ui/vertical-table";
import { getCmsData } from "@/shared/api/external-service-api"; import { getCmsData } from "@/shared/api/external-service-api";
import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkhole-types"; import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkhole-types";
import { useSinkholeCommonCodes } from "./use-sinkhole-common-codes"; import { useSinkholeCommonCodes } from "./use-sinkhole-common-codes";
import { downloadSinkholeDetailExcel } from "./sinkhole-detail-excel";
type Props = { type Props = {
detail: SinkholeDetail; detail: SinkholeDetail;
@@ -81,6 +82,9 @@ export default function SinkholeDetailPanel({
}: Props) { }: Props) {
// 상세의 원천 복구상태 값은 유지하고 CMS에 대응하는 코드명만 표시한다. // 상세의 원천 복구상태 값은 유지하고 CMS에 대응하는 코드명만 표시한다.
const { restoreStatusLabels } = useSinkholeCommonCodes(); const { restoreStatusLabels } = useSinkholeCommonCodes();
// 파일 생성 중 버튼 표시와 같은 이벤트 루프의 중복 클릭을 함께 제어한다.
const [isDownloading, setIsDownloading] = useState(false);
const downloadInProgressRef = useRef(false);
const [pipeUseCodeMap, setPipeUseCodeMap] = useState<Map<string, string>>( const [pipeUseCodeMap, setPipeUseCodeMap] = useState<Map<string, string>>(
new Map(), new Map(),
); );
@@ -127,8 +131,7 @@ export default function SinkholeDetailPanel({
return { average, oldest }; return { average, oldest };
}, [nearbyPipes]); }, [nearbyPipes]);
const summaryTableData = useMemo( const summaryTableData = useMemo(() => {
() => {
const restoreStatus = String(detail.restoreStatus ?? "").trim(); const restoreStatus = String(detail.restoreStatus ?? "").trim();
return [ return [
{ {
@@ -145,9 +148,7 @@ export default function SinkholeDetailPanel({
restoreCompletedYmd: display(detail.restoreCompletedYmd), restoreCompletedYmd: display(detail.restoreCompletedYmd),
}, },
]; ];
}, }, [detail, restoreStatusLabels]);
[detail, restoreStatusLabels],
);
const summaryHeadList = [ const summaryHeadList = [
{ id: "acdntNo", title: "사고번호" }, { id: "acdntNo", title: "사고번호" },
@@ -161,6 +162,35 @@ export default function SinkholeDetailPanel({
{ id: "restoreCompletedYmd", title: "복구완료일자" }, { 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 ( return (
<div <div
id="popup" id="popup"
@@ -198,9 +228,20 @@ export default function SinkholeDetailPanel({
<section className="pb-[1.2rem]"> <section className="pb-[1.2rem]">
<div className="card-header !block px-[1.2rem]"> <div className="card-header !block px-[1.2rem]">
<h3 className="mx-3 whitespace-nowrap text-2xl font-medium text-label"> <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> </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 ? ( {canShowNearby ? (
<div className="mx-3 mt-2 whitespace-nowrap text-left text-[0.95rem] leading-5 text-muted-foreground"> <div className="mx-3 mt-2 whitespace-nowrap text-left text-[0.95rem] leading-5 text-muted-foreground">
<span>반경 {radiusMeters}m · </span> <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 useStore from "@/entities/reference-data/model/reference-data-store";
import LoadingOverlay from "@/components/ui/loading-bar"; import LoadingOverlay from "@/components/ui/loading-bar";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
import PopupSearchPanel, { import PopupSearchPanel, {
PopupSearchActions, PopupSearchActions,
PopupSearchField, PopupSearchField,
@@ -514,6 +515,13 @@ export default function SinkholePopupPage() {
</Button> </Button>
} }
/> />
{/* 인수테스트 요구: 지반침하 정보의 연계 원천과 연계 주기를 목록 아래에 표시한다. */}
<PopupFooterNote
items={[
"출처 : 지하안전정보시스템 지반침하사고 정보 연계 표출(연계 주기 : 매 시간 연계)",
]}
/>
</StandardPopupLayout> </StandardPopupLayout>
); );
} }
@@ -13,7 +13,19 @@ import type {
export type WaterRiskResultQuery = { export type WaterRiskResultQuery = {
startDate: string; startDate: string;
endDate: string; endDate: string;
/** 현재 탭 분석기준시간(정시) 정확 일치 조건. 분석시각 기간과 함께 보내지 않는다. */
analysisBaseAt?: 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; ctpvCd?: string;
sggCd?: string; sggCd?: string;
areaId?: string; areaId?: string;
@@ -54,6 +66,14 @@ export const fetchWaterRiskResults = async (
}); });
if (query.ctpvCd) params.set("ctpvCd", query.ctpvCd); if (query.ctpvCd) params.set("ctpvCd", query.ctpvCd);
if (query.analysisBaseAt) params.set("analysisBaseAt", query.analysisBaseAt); 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.sggCd) params.set("sggCd", query.sggCd);
if (query.areaId) params.set("areaId", query.areaId); if (query.areaId) params.set("areaId", query.areaId);
if (query.riskStatus) params.set("riskStatus", query.riskStatus); if (query.riskStatus) params.set("riskStatus", query.riskStatus);
@@ -78,15 +98,12 @@ export const fetchWaterRiskResults = async (
export const fetchWaterRiskMapView = async ( export const fetchWaterRiskMapView = async (
row: WaterRiskResultRow, row: WaterRiskResultRow,
): Promise<WaterRiskMapView> => { ): Promise<WaterRiskMapView> => {
if (!row.analysisDate || !row.ctpvCd || !row.sggCd) { // 날짜·지역이 아니라 누른 행의 SWMM 실행 ID로 조회해야 같은 날 같은 지역 결과가 여러 건이어도 같은 결과를 그린다.
throw new Error("지도 조회에 필요한 분석일·행정구역 정보가 없습니다."); if (!row.executionId) {
throw new Error("지도 조회에 필요한 SWMM 실행 ID가 없습니다.");
} }
const params = new URLSearchParams({
ctpvCd: row.ctpvCd,
sggCd: row.sggCd,
});
const response = await getData( 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 { return {
...response, ...response,
@@ -1,4 +1,4 @@
/** 백엔드의 양 끝 날짜 포함 최대 366일 계약을 캘린더와 조회 검증에서 함께 사용한다. */ /** 백엔드의 양 끝 날짜 포함 최대 366일 계약을 기본값과 조회 검증에서 함께 사용한다. */
export const WATER_RISK_MAX_SEARCH_DAYS = 366; export const WATER_RISK_MAX_SEARCH_DAYS = 366;
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
@@ -24,31 +24,82 @@ export const getWaterRiskMinStartDate = (
.slice(0, 10); .slice(0, 10);
}; };
/** 달력 제한을 우회한 직접 입력도 API 요청 전에 검증한다. */ /**
export const validateWaterRiskDateRange = ( * 시간 단위 검색 입력(`datetime-local`, `step=3600`)의 값 형식 `yyyy-MM-ddTHH:00`이다.
startDate: string, * 분은 항상 00이며, 첫 번째 그룹은 날짜(yyyy-MM-dd)다.
endDate: string, */
today: string, 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 => { ): string => {
if (!startDate || !endDate) return "조회 시작일과 종료일을 입력해야 합니다."; if (!from && !to) {
const start = parseDate(startDate); return required ? `${label} 시작과 종료를 입력해야 합니다.` : "";
const end = parseDate(endDate); }
if (start === null || end === null) if (!from || !to) {
return "올바른 조회 날짜를 입력해 주세요."; return required
if (endDate > today) return "조회 종료일은 오늘보다 늦을 수 없습니다."; ? `${label} 시작과 종료를 입력해야 합니다.`
if (start > end) return "조회 시작일은 종료일보다 늦을 수 없습니다."; : `${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) { if ((end - start) / DAY_MS + 1 > WATER_RISK_MAX_SEARCH_DAYS) {
return "강우 발생기간은 종료일 기준 최대 1년(366일)까지 조회할 수 있습니다."; return `${label}은 최대 1년(366일)까지 조회할 수 있습니다.`;
} }
return ""; return "";
}; };
/** 종료일 변경으로 기존 시작일이 허용 범위를 벗어난 경우 최근 1년 시작일로 맞춘다. */ /** 과거이력 강우시각(강우 시작 일시) 기간을 검증한다. 필수 조건이다. */
export const adjustWaterRiskStartDate = ( export const validateWaterRiskRainStartedPeriod = (
startDate: string, from: string,
endDate: string, to: string,
): string => { currentHourValue: string,
const min = getWaterRiskMinStartDate(endDate); ) =>
if (!min) return startDate; validateHourPeriod(from, to, currentHourValue, {
return !startDate || startDate < min || startDate > endDate ? min : startDate; label: "강우시각",
}; required: true,
});
/** 과거이력 분석시각 기간을 검증한다. 선택 조건이며 모두 비우면 적용하지 않는다. */
export const validateWaterRiskAnalysisPeriod = (
from: string,
to: string,
currentHourValue: string,
) =>
validateHourPeriod(from, to, currentHourValue, {
label: "분석시각",
required: false,
});
@@ -12,6 +12,7 @@ import {
type WindowPopupContentProps, type WindowPopupContentProps,
} from "@/features/window-popup/model/types"; } from "@/features/window-popup/model/types";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; 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 PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import { import {
StandardPopupLayout, StandardPopupLayout,
@@ -21,9 +22,12 @@ import {
import type { UiTableSort } from "@common/business_components/ui/table"; import type { UiTableSort } from "@common/business_components/ui/table";
import { Button } from "@common/components"; import { Button } from "@common/components";
import { import {
adjustWaterRiskStartDate, formatWaterRiskHourValue,
getWaterRiskMinStartDate, getWaterRiskMinStartDate,
validateWaterRiskDateRange, toWaterRiskApiDateTime,
toWaterRiskHourValue,
validateWaterRiskAnalysisPeriod,
validateWaterRiskRainStartedPeriod,
} from "./water-risk-date-range"; } from "./water-risk-date-range";
import { import {
@@ -34,25 +38,42 @@ import {
import { import {
buildWaterRiskStatusLabel, buildWaterRiskStatusLabel,
EMPTY_WATER_RISK_SESSION_SNAPSHOT, EMPTY_WATER_RISK_SESSION_SNAPSHOT,
isWaterRiskDanger,
type WaterRiskAreaOption, type WaterRiskAreaOption,
type WaterRiskResultPage, type WaterRiskResultPage,
type WaterRiskResultRow, type WaterRiskResultRow,
type WaterRiskSessionSnapshot, type WaterRiskSessionSnapshot,
type WaterRiskStatus,
} from "./water-risk-types"; } from "./water-risk-types";
import LoadingOverlay from "@/components/ui/loading-bar"; import LoadingOverlay from "@/components/ui/loading-bar";
type WaterRiskResultMode = "current" | "history"; type WaterRiskResultMode = "current" | "history";
/**
* 과거이력 검색조건이다. 시각 값은 모두 시간 단위 입력값(yyyy-MM-ddTHH:00)이며
* 시작·종료 정시를 모두 포함해 조회한다. 현재 탭은 중점관리지역만 이 값을 쓴다.
*/
type ResultFilters = { type ResultFilters = {
startDate: string;
endDate: string;
ctpvCd: string; ctpvCd: string;
sggCd: string; sggCd: string;
areaId: 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 WATER_RISK_PAGE_SIZE = 10;
const EMPTY_RESULT_PAGE: WaterRiskResultPage = { const EMPTY_RESULT_PAGE: WaterRiskResultPage = {
items: [], items: [],
@@ -69,6 +90,8 @@ type WaterRiskTableRow = Record<string, unknown> & {
sidoName: string; sidoName: string;
sggName: string; sggName: string;
areaName: string; areaName: string;
/** 대표실행 분석기준시각(excn.anls_crtr_dt)을 yyyy.MM.dd HH:mm으로 표시한 값 */
analysisBaseAt: string;
rainStartedAt: string; rainStartedAt: string;
currentAccumRainQty: string; currentAccumRainQty: string;
forecastRainQty: string; forecastRainQty: string;
@@ -81,53 +104,56 @@ const WATER_RISK_RESULT_HEADLIST: UiHeadProps[] = [
{ {
id: "rowNo", id: "rowNo",
title: "순번", title: "순번",
widthPercent: 6, widthPercent: 5,
sortable: true, sortable: true,
sortKey: "rowNumber", sortKey: "rowNumber",
}, },
{ {
id: "sidoName", id: "sidoName",
title: "시도", title: "시도",
widthPercent: 8, widthPercent: 7,
sortable: true, sortable: true,
sortKey: "ctpvName", sortKey: "ctpvName",
}, },
{ id: "sggName", title: "시군구", widthPercent: 8, sortable: true }, { id: "sggName", title: "시군구", widthPercent: 7, sortable: true },
{ id: "areaName", title: "중점관리지역", widthPercent: 14, sortable: true }, { id: "areaName", title: "중점관리지역", widthPercent: 12, sortable: true },
// 대표실행 분석기준시각. 컬럼 id가 백엔드 정렬 키(analysisBaseAt)와 같다.
{ id: "analysisBaseAt", title: "분석시각", widthPercent: 11, sortable: true },
{ {
id: "rainStartedAt", id: "rainStartedAt",
title: "강우시작시간", title: "강우시작시각",
widthPercent: 13, widthPercent: 11,
sortable: true, sortable: true,
}, },
{ {
id: "currentAccumRainQty", id: "currentAccumRainQty",
title: "누적강우량(mm)", title: "누적강우량(mm)",
widthPercent: 11, widthPercent: 9,
align: "right", align: "right",
sortable: true, sortable: true,
}, },
{ {
id: "forecastRainQty", id: "forecastRainQty",
title: "예측강우량(mm)", title: "예측강우량(mm)",
widthPercent: 11, widthPercent: 9,
align: "right", align: "right",
sortable: true, sortable: true,
}, },
{ {
id: "maxFloodingAt", id: "maxFloodingAt",
title: "최대 침수 예상시점", title: "최대 침수 예상시점",
widthPercent: 13, widthPercent: 11,
sortable: true, sortable: true,
}, },
{ {
id: "maxFloodingFlow", id: "maxFloodingFlow",
title: "최대 침수유량(m³/s)", title: "최대 침수유량(m³/s)",
widthPercent: 10, widthPercent: 9,
align: "right", align: "right",
sortable: true, sortable: true,
}, },
{ id: "riskStatus", title: "위험 여부", widthPercent: 7 }, // "위험(매핑관로없음)" 문구가 들어가도록 기존보다 넓게 둔다.
{ id: "riskStatus", title: "위험 여부", widthPercent: 9 },
]; ];
const formatOptionalNumber = ( const formatOptionalNumber = (
@@ -155,17 +181,20 @@ const formatAnalysisBaseAt = (value?: string | null) => {
return `${date.replaceAll("-", ".")} ${time.slice(0, 5)}`.trim(); return `${date.replaceAll("-", ".")} ${time.slice(0, 5)}`.trim();
}; };
/** 현재 위험 조회와 검색 초기화에 사용하는 최근 1년의 기본 조회조건이다. */ /** 최초 진입과 검색 초기화에 사용하는 기본 조회조건이다. 강우시각은 최근 1년이다. */
const buildDefaultResultFilters = (): ResultFilters => { const buildDefaultResultFilters = (): ResultFilters => {
const endDate = new Date(); const now = new Date();
const endDateValue = formatLocalDate(endDate);
return { return {
startDate: getWaterRiskMinStartDate(endDateValue)!,
endDate: endDateValue,
ctpvCd: "ALL", ctpvCd: "ALL",
sggCd: "ALL", sggCd: "ALL",
areaId: "ALL", areaId: "ALL",
riskStatus: "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); window.opener.postMessage({ type, payload }, window.location.origin);
}; };
/** 위험 여부에 맞는 기존 팝업 badge 스타일을 반환한다. */ /** 위험 여부에 맞는 기존 팝업 badge 스타일을 반환한다. 위험(매핑관로없음)도 위험 배지를 쓴다. */
const getRiskBadgeClassName = (riskStatus: "DANGER" | "NORMAL") => const getRiskBadgeClassName = (riskStatus: WaterRiskStatus) =>
riskStatus === "DANGER" ? "sn-popup-badge-danger" : "sn-popup-badge-normal"; isWaterRiskDanger(riskStatus) ? "sn-popup-badge-danger" : "sn-popup-badge-normal";
/** 동일 날짜·지역에 여러 강우이벤트가 있어도 실행별 행 키가 겹치지 않게 식별한다. */ /**
* 목록 행을 대표 SWMM 실행 ID로 식별한다. 행 하나가 실행 하나이므로 같은 날·지역 행도 겹치지 않는다.
* 실행 ID가 없는 응답(구 BE)에서는 기존 날짜·시각·지역 조합으로 대체한다.
*/
const buildResultRowKey = (row: WaterRiskResultRow) => const buildResultRowKey = (row: WaterRiskResultRow) =>
row.executionId ||
[ [
row.analysisDate ?? "", row.analysisDate ?? "",
row.rainStartedAt ?? "", row.rainStartedAt ?? "",
@@ -195,10 +228,10 @@ const buildResultRowKey = (row: WaterRiskResultRow) =>
/** 실제 통수위험 API를 조회하고 선택 결과를 부모 지도에 전달하는 팝업이다. */ /** 실제 통수위험 API를 조회하고 선택 결과를 부모 지도에 전달하는 팝업이다. */
export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
// 최초 진입과 초기화 모두 오늘을 종료일로 하는 최근 1년을 사용한다. // 최초 진입과 초기화 모두 현재 정시를 종료로 하는 최근 1년 강우시각을 사용한다.
const initialFilters = useMemo(() => buildDefaultResultFilters(), []); const initialFilters = useMemo(() => buildDefaultResultFilters(), []);
// 과거분석은 오늘까지 선택 가능하며, 시작일의 범위는 선택 종료일에 따라 달라진다. // 과거이력 시각 입력은 현재 정시까지 고를 수 있다. 렌더링마다 다시 계산해 정시가 바뀌면 따라간다.
const today = formatLocalDate(new Date()); const currentHourValue = formatWaterRiskHourValue(new Date());
const [resultMode, setResultMode] = useState<WaterRiskResultMode>("current"); const [resultMode, setResultMode] = useState<WaterRiskResultMode>("current");
const [draftResultFilters, setDraftResultFilters] = const [draftResultFilters, setDraftResultFilters] =
useState<ResultFilters>(initialFilters); useState<ResultFilters>(initialFilters);
@@ -241,15 +274,38 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
let active = true; let active = true;
setIsLoading(true); setIsLoading(true);
setErrorMessage(""); setErrorMessage("");
// 과거 이력의 고정 기본기간이 현재 진행 중인 강우 조회를 제한하지 않게 분리한다. const isHistory = resultMode === "history";
const dateFilters = resultMode === "current" // 현재 탭은 진행 중 강우를 오늘까지 최근 1년 강우시작일로 찾고, 과거이력의 적용 기간이 이를 제한하지 않게 분리한다.
? buildDefaultResultFilters() // 과거이력은 적용 시 검증을 통과한 강우시각 기간을 보내며, 강우시작일도 그 날짜로 맞춘다.
: appliedResultFilters; 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({ fetchWaterRiskResults({
startDate: dateFilters.startDate, startDate,
endDate: dateFilters.endDate, endDate,
analysisBaseAt: analysisBaseAt:
resultMode === "current" ? currentAnalysisBaseAt : undefined, resultMode === "current" ? currentAnalysisBaseAt : undefined,
analysisBaseFrom: analysisBaseFrom || undefined,
analysisBaseTo: analysisBaseTo || undefined,
rainStartedFrom: rainStartedFrom || undefined,
rainStartedTo: rainStartedTo || undefined,
ctpvCd: ctpvCd:
resultMode === "current" || appliedResultFilters.ctpvCd === "ALL" resultMode === "current" || appliedResultFilters.ctpvCd === "ALL"
? undefined ? undefined
@@ -313,7 +369,12 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
const nextSnapshot = (event.data.payload ?? const nextSnapshot = (event.data.payload ??
EMPTY_WATER_RISK_SESSION_SNAPSHOT) as WaterRiskSessionSnapshot; EMPTY_WATER_RISK_SESSION_SNAPSHOT) as WaterRiskSessionSnapshot;
if (nextSnapshot.selectedAreaId) { if (nextSnapshot.selectedAreaId) {
const matchingRow = resultPage.items.find( // 지도에 표시 중인 실행 ID가 있으면 그 행만 선택한다. 같은 지역 첫 행으로 바꾸면 다른 결과가 선택돼 보인다.
const matchingRow = nextSnapshot.selectedExecutionId
? resultPage.items.find(
(row) => row.executionId === nextSnapshot.selectedExecutionId,
)
: resultPage.items.find(
(row) => row.areaId === nextSnapshot.selectedAreaId, (row) => row.areaId === nextSnapshot.selectedAreaId,
); );
if (matchingRow) setSelectedResultKey(buildResultRowKey(matchingRow)); if (matchingRow) setSelectedResultKey(buildResultRowKey(matchingRow));
@@ -418,6 +479,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
sidoName: row.sidoName || "-", sidoName: row.sidoName || "-",
sggName: row.sggName || "-", sggName: row.sggName || "-",
areaName: row.areaName, areaName: row.areaName,
analysisBaseAt: formatAnalysisBaseAt(row.analysisBaseAt),
rainStartedAt: formatAnalysisBaseAt(row.rainStartedAt), rainStartedAt: formatAnalysisBaseAt(row.rainStartedAt),
currentAccumRainQty: formatOptionalNumber(row.currentAccumRainQty, 1), currentAccumRainQty: formatOptionalNumber(row.currentAccumRainQty, 1),
forecastRainQty: formatOptionalNumber(row.forecastRainQty, 1), forecastRainQty: formatOptionalNumber(row.forecastRainQty, 1),
@@ -432,11 +494,25 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
/** 검증된 검색조건을 적용하고 첫 페이지를 새로 조회한다. */ /** 검증된 검색조건을 적용하고 첫 페이지를 새로 조회한다. */
const handleApplyResultFilters = () => { const handleApplyResultFilters = () => {
if (resultMode === "history") { if (resultMode === "history") {
const dateError = validateWaterRiskDateRange( const nowHourValue = formatWaterRiskHourValue(new Date());
draftResultFilters.startDate, draftResultFilters.endDate, today, // 분석시각 기간은 선택 조건이다. 입력한 경우에만 시작·종료 짝, 순서, 기간, 현재 정시 이하를 확인한다.
const analysisPeriodError = validateWaterRiskAnalysisPeriod(
draftResultFilters.analysisFrom,
draftResultFilters.analysisTo,
nowHourValue,
); );
if (dateError) { if (analysisPeriodError) {
setErrorMessage(dateError); setErrorMessage(analysisPeriodError);
return;
}
// 강우시각 기간은 필수 조건이다. 시작·종료 짝, 순서, 최대 366일, 현재 정시 이하를 확인한다.
const rainStartedPeriodError = validateWaterRiskRainStartedPeriod(
draftResultFilters.rainStartedFrom,
draftResultFilters.rainStartedTo,
nowHourValue,
);
if (rainStartedPeriodError) {
setErrorMessage(rainStartedPeriodError);
return; return;
} }
} }
@@ -496,6 +572,44 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
} }
}; };
/**
* 침수예측현황과 같은 형태(라벨 + 시작 `~` 종료 `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 ( return (
<StandardPopupLayout title={windowPopupDefinitions.flowRiskPopup.title}> <StandardPopupLayout title={windowPopupDefinitions.flowRiskPopup.title}>
<section <section
@@ -529,45 +643,9 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
</div> </div>
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3"> <PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
{/* 과거이력 검색조건 순서: 시도 → 시군구 → 중점관리지역 → 분석시각 → 강우시각 → 위험상태 */}
{resultMode === "history" ? ( {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"> <label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label"> <div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
시도 시도
@@ -652,6 +730,16 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
</label> </label>
{resultMode === "history" ? ( {resultMode === "history" ? (
<>
{/* 분석시각(선택)·강우시각(필수): 시간 단위로만 고르며 시작·종료 정시를 모두 포함해 조회한다. */}
{renderHourRange("분석시각", {
fromKey: "analysisFrom",
toKey: "analysisTo",
})}
{renderHourRange("강우시각", {
fromKey: "rainStartedFrom",
toKey: "rainStartedTo",
})}
<label className="flex shrink-0 items-center gap-2"> <label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label"> <div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
위험상태 위험상태
@@ -670,9 +758,11 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
> >
<option value="ALL">전체</option> <option value="ALL">전체</option>
<option value="DANGER">위험</option> <option value="DANGER">위험</option>
<option value="DANGER_UNMAPPED">위험(매핑관로없음)</option>
<option value="NORMAL">정상</option> <option value="NORMAL">정상</option>
</select> </select>
</label> </label>
</>
) : null} ) : null}
<div className="flex min-w-fit flex-1 items-center justify-end gap-2"> <div className="flex min-w-fit flex-1 items-center justify-end gap-2">
@@ -722,25 +812,38 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
renderCell={(row, headId) => renderCell={(row, headId) =>
headId === "riskStatus" ? ( headId === "riskStatus" ? (
<span <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)} {buildWaterRiskStatusLabel(row.riskStatus)}
</span> </span>
) : undefined ) : undefined
} }
scrollMode="horizontal" scrollMode="horizontal"
minTableWidth="124rem" // 분석시각 컬럼 추가로 기존 124rem보다 넓힌다.
minTableWidth="136rem"
emptyMessage="조회된 결과가 없습니다." emptyMessage="조회된 결과가 없습니다."
sort={resultSort} sort={resultSort}
onSortChange={handleSortChange} 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> </section>
</StandardPopupLayout> </StandardPopupLayout>
@@ -1,7 +1,13 @@
/** 통수위험 분석 화면에서 사용하는 타입·상수와 표시값 변환을 정의한다. */ /** 통수위험 분석 화면에서 사용하는 타입·상수와 표시값 변환을 정의한다. */
"use client"; "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"; export type WaterRiskCalculationMethod = "SWMM";
/** 대표실행에서 capacity가 가장 큰 관로의 실제 SWMM 결과이다. */ /** 대표실행에서 capacity가 가장 큰 관로의 실제 SWMM 결과이다. */
@@ -14,6 +20,9 @@ export type WaterRiskPipeSummary = {
/** 강우이벤트 대표실행 한 건을 팝업 목록에 표시하는 모델이다. */ /** 강우이벤트 대표실행 한 건을 팝업 목록에 표시하는 모델이다. */
export type WaterRiskResultRow = { export type WaterRiskResultRow = {
/** 대표 SWMM 실행 ID(UUID). 목록 행 식별과 지도 조회(executions/{executionId}/mapView) 키다. */
executionId: string;
/** 강우시작일(yyyy-MM-dd). 분석시각(analysisBaseAt)과 다른 값이다. */
analysisDate?: string; analysisDate?: string;
analysisBaseAt?: string | null; analysisBaseAt?: string | null;
rainStartedAt?: string | null; rainStartedAt?: string | null;
@@ -36,14 +45,25 @@ export type WaterRiskResultRow = {
/** 부모 지도와 별도 팝업 사이에서 공유하는 최소 선택 상태이다. */ /** 부모 지도와 별도 팝업 사이에서 공유하는 최소 선택 상태이다. */
export type WaterRiskSessionSnapshot = { export type WaterRiskSessionSnapshot = {
selectedAreaId: string | null; selectedAreaId: string | null;
/** 지도에 표시 중인 SWMM 실행 ID. 같은 지역 행이 여러 개여도 팝업이 정확한 행을 다시 선택하게 한다. */
selectedExecutionId: string | null;
}; };
export const EMPTY_WATER_RISK_SESSION_SNAPSHOT: WaterRiskSessionSnapshot = { export const EMPTY_WATER_RISK_SESSION_SNAPSHOT: WaterRiskSessionSnapshot = {
selectedAreaId: null, 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 = { export type WaterRiskAreaOption = {
areaId: string; areaId: string;
@@ -70,7 +90,14 @@ export type WaterRiskGeoJsonGeometry = {
/** 선택한 대표실행의 실제 중점관리지역 및 관로 지도 결과이다. */ /** 선택한 대표실행의 실제 중점관리지역 및 관로 지도 결과이다. */
export type WaterRiskMapView = { export type WaterRiskMapView = {
/** 지도에 표시한 SWMM 실행 ID. 요청한 목록 행의 executionId와 같다. */
executionId?: string;
/** 강우시작일(yyyy-MM-dd) */
analysisDate: string; analysisDate: string;
/** 분석기준시각 */
analysisBaseAt?: string | null;
/** 강우시작시각 */
rainStartedAt?: string | null;
areaId: string; areaId: string;
areaName: string; areaName: string;
ctpvCd: string; ctpvCd: string;
@@ -104,7 +104,7 @@ export const getRightPanelFeatureDisplayLabel = (
? "관로조사" ? "관로조사"
: jobType === "MNHL" : jobType === "MNHL"
? "맨홀조사" ? "맨홀조사"
: "조사관리"; : "조사사업";
return `${surveyLabel}(${managementNo})`; return `${surveyLabel}(${managementNo})`;
} }
@@ -101,7 +101,8 @@ export function useWaterRiskLayer({
[selectedWaterRiskApiResult], [selectedWaterRiskApiResult],
); );
// 지도카드는 실제 GIS geometry까지 결합된 mapView 위험상태를 목록 상태보다 우선한다. // 지도카드는 mapView 위험상태를 우선하고, mapView 응답 전에는 목록 상태를 쓴다.
// 2026-10-20부터 mapView 위험상태는 목록과 같은 대표결과 판정값이므로 두 값이 다르지 않다.
const selectedWaterRiskDisplayStatus = useMemo< const selectedWaterRiskDisplayStatus = useMemo<
WaterRiskResultRow["riskStatus"] WaterRiskResultRow["riskStatus"]
>( >(
@@ -127,7 +128,7 @@ export function useWaterRiskLayer({
const [pixelX = 0, pixelY = 0] = const [pixelX = 0, pixelY = 0] =
map.getPixelFromCoordinate?.(waterRiskInfoAnchor) ?? []; map.getPixelFromCoordinate?.(waterRiskInfoAnchor) ?? [];
const cardWidth = 244; const cardWidth = 244;
const cardHeight = 180; const cardHeight = 190;
const left = pixelX + 22; const left = pixelX + 22;
const top = pixelY - cardHeight / 2; const top = pixelY - cardHeight / 2;
@@ -148,6 +149,12 @@ export function useWaterRiskLayer({
setWaterRiskInfoCardStyle({ left, top }); setWaterRiskInfoCardStyle({ left, top });
}, [waterRiskInfoAnchor]); }, [waterRiskInfoAnchor]);
/** 정보 카드만 닫고 지도 결과·범례는 유지하며, 다음 결과 선택 시 다시 표시한다. */
const dismissWaterRiskInfoCard = useCallback(() => {
setWaterRiskInfoAnchor(null);
setWaterRiskInfoCardStyle(null);
}, []);
const clearWaterRiskPopupCloseWatcher = useCallback(() => { const clearWaterRiskPopupCloseWatcher = useCallback(() => {
if (waterRiskPopupCloseWatcherRef.current !== null) { if (waterRiskPopupCloseWatcherRef.current !== null) {
window.clearInterval(waterRiskPopupCloseWatcherRef.current); window.clearInterval(waterRiskPopupCloseWatcherRef.current);
@@ -413,11 +420,20 @@ export function useWaterRiskLayer({
| undefined; | undefined;
const mapView = data.payload?.mapView as WaterRiskMapView | undefined; const mapView = data.payload?.mapView as WaterRiskMapView | undefined;
if (!resultRow || !mapView || mapView.areaId !== areaId) return; if (!resultRow || !mapView || mapView.areaId !== areaId) return;
// 지도 응답이 누른 행과 다른 실행이면 표시하지 않는다(실행 ID 기준 조회의 안전장치).
if (
mapView.executionId &&
resultRow.executionId &&
mapView.executionId !== resultRow.executionId
) {
return;
}
setSelectedWaterRiskApiResult(resultRow); setSelectedWaterRiskApiResult(resultRow);
setSelectedWaterRiskMapView(mapView); setSelectedWaterRiskMapView(mapView);
setWaterRiskSession((current) => ({ setWaterRiskSession((current) => ({
...current, ...current,
selectedAreaId: areaId, selectedAreaId: areaId,
selectedExecutionId: resultRow.executionId || null,
})); }));
onSelectResult(); onSelectResult();
return; return;
@@ -470,6 +486,7 @@ export function useWaterRiskLayer({
waterRiskInfoCardStyle, waterRiskInfoCardStyle,
selectedWaterRiskMapView, selectedWaterRiskMapView,
selectedWaterRiskDisplayStatus, selectedWaterRiskDisplayStatus,
dismissWaterRiskInfoCard,
watchPopup, watchPopup,
}; };
} }
@@ -4,19 +4,21 @@ import CircleStyle from "ol/style/Circle.js";
import Stroke from "ol/style/Stroke.js"; import Stroke from "ol/style/Stroke.js";
import Style from "ol/style/Style.js"; import Style from "ol/style/Style.js";
import Text from "ol/style/Text.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 = ( /** 위험·위험(매핑관로없음)은 빨강, 정상은 파랑 반투명으로 중점관리지역 면을 채운다(화면설계서 21쪽). */
riskStatus: "DANGER" | "NORMAL", export const buildWaterRiskStatusFillColor = (riskStatus: WaterRiskStatus) =>
) => isWaterRiskDanger(riskStatus)
riskStatus === "DANGER"
? "rgba(198, 40, 40, 0.24)" ? "rgba(198, 40, 40, 0.24)"
: "rgba(21, 101, 192, 0.2)"; : "rgba(21, 101, 192, 0.2)";
export const buildWaterRiskStatusStrokeColor = ( /** 중점관리지역 경계선·라벨 배경색. 위험 계열 판정은 면 색과 같다. */
riskStatus: "DANGER" | "NORMAL", export const buildWaterRiskStatusStrokeColor = (riskStatus: WaterRiskStatus) =>
) => isWaterRiskDanger(riskStatus)
riskStatus === "DANGER"
? "rgba(198, 40, 40, 0.92)" ? "rgba(198, 40, 40, 0.92)"
: "rgba(21, 101, 192, 0.92)"; : "rgba(21, 101, 192, 0.92)";
@@ -31,9 +33,9 @@ export const buildWaterRiskCapacityColor = (capacityRatio: number) => {
export const createWaterRiskAreaStyle = (feature: any) => { export const createWaterRiskAreaStyle = (feature: any) => {
const areaName = String(feature.get("areaName") ?? ""); const areaName = String(feature.get("areaName") ?? "");
const riskStatus = String(feature.get("riskStatus") ?? "NORMAL") as const riskStatus = String(
| "DANGER" feature.get("riskStatus") ?? "NORMAL",
| "NORMAL"; ) as WaterRiskStatus;
return new Style({ return new Style({
fill: new Fill({ color: buildWaterRiskStatusFillColor(riskStatus) }), fill: new Fill({ color: buildWaterRiskStatusFillColor(riskStatus) }),
stroke: new Stroke({ 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"; 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] = [ export const SOUTH_KOREA_VIEW_EXTENT: [number, number, number, number] = [
600000, 1300000, 1500000, 2500000, 600000, 1300000, 1500000, 2500000,
]; ];
@@ -1,6 +1,6 @@
"use client"; "use client";
/** 지도 route에만 적용하는 기존 전역 컨트롤 CSS. 선택자와 스타일 값은 유지한다. */ /** 지도 route의 기존 배치를 유지하고 SN 컨트롤의 쌓임 순서를 맞춘다. */
export default function MapControlStyles() { export default function MapControlStyles() {
return ( return (
<style jsx global>{` <style jsx global>{`
@@ -8,6 +8,7 @@ export default function MapControlStyles() {
top: 16px; top: 16px;
right: 120px; right: 120px;
bottom: auto; bottom: auto;
z-index: 45;
} }
.sn-map-control-layout .map-type-tabs, .sn-map-control-layout .map-type-tabs,
@@ -28,6 +29,7 @@ export default function MapControlStyles() {
top: 16px; top: 16px;
right: 16px; right: 16px;
bottom: auto; bottom: auto;
z-index: 45;
} }
.sn-map-control-layout .tool-bar-vector-icons ul li div { .sn-map-control-layout .tool-bar-vector-icons ul li div {
@@ -63,7 +63,7 @@ import {
normalizeRainObservationLayerInteraction, normalizeRainObservationLayerInteraction,
getFlowDirectionMinZoom, getFlowDirectionMinZoom,
} from "@/features/map-layers"; } from "@/features/map-layers";
import { ListTree } from "lucide-react"; import { ListTree, X } from "lucide-react";
import { import {
Fragment, Fragment,
useCallback, useCallback,
@@ -182,7 +182,12 @@ import {
WATER_DEPTH_LAYER_TYPES, WATER_DEPTH_LAYER_TYPES,
extractWaterDepthLegendItems, extractWaterDepthLegendItems,
} from "../water-depth/water-depth"; } 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( const AnalysisPopupMenu = dynamic(
() => import("../components/analysis-menu/analysis-popup-menu"), () => import("../components/analysis-menu/analysis-popup-menu"),
@@ -296,6 +301,7 @@ const MapPageClient = () => {
waterRiskInfoCardStyle, waterRiskInfoCardStyle,
selectedWaterRiskMapView, selectedWaterRiskMapView,
selectedWaterRiskDisplayStatus, selectedWaterRiskDisplayStatus,
dismissWaterRiskInfoCard,
watchPopup: watchWaterRiskPopup, watchPopup: watchWaterRiskPopup,
} = useWaterRiskLayer({ } = useWaterRiskLayer({
gisMaps, gisMaps,
@@ -3126,20 +3132,31 @@ const MapPageClient = () => {
// 위험/정상 배지는 통수위험 상태를 즉시 구분하는 업무 의미색이므로 고정색을 유지한다. // 위험/정상 배지는 통수위험 상태를 즉시 구분하는 업무 의미색이므로 고정색을 유지한다.
<div <div
style={waterRiskInfoCardStyle} 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 className="bg-secondary px-[9px] py-[7px]">
<div> <div className="flex items-center justify-between">
<div className="text-[10px] font-semibold text-muted-foreground"> <div className="text-[10px] font-semibold text-muted-foreground">
중점관리지역 중점관리지역
</div> </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} {selectedWaterRiskResultRow.areaName}
</div> </div>
</div>
<span <span
className={`inline-flex min-w-[48px] items-center justify-center rounded-full px-[7px] py-[5px] text-[10px] font-bold ${ className={`inline-flex min-w-[48px] shrink-0 items-center justify-center rounded-full px-[7px] py-[5px] text-[10px] font-bold ${
selectedWaterRiskDisplayStatus === "DANGER" // 위험(매핑관로없음)도 위험과 같은 빨간 배지로 표시하고 문구로 구분한다.
isWaterRiskDanger(selectedWaterRiskDisplayStatus)
? "bg-[#ff1c15] text-white" ? "bg-[#ff1c15] text-white"
: "bg-[#e8f6f4] text-[#0f766e]" : "bg-[#e8f6f4] text-[#0f766e]"
}`} }`}
@@ -3147,6 +3164,7 @@ const MapPageClient = () => {
{buildWaterRiskStatusLabel(selectedWaterRiskDisplayStatus)} {buildWaterRiskStatusLabel(selectedWaterRiskDisplayStatus)}
</span> </span>
</div> </div>
</div>
<div className="bg-background px-[9px] py-[6px] text-[9px] text-muted-foreground"> <div className="bg-background px-[9px] py-[6px] text-[9px] text-muted-foreground">
<div>GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면</div> <div>GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면</div>
<div> <div>
@@ -3197,7 +3215,7 @@ const MapPageClient = () => {
) : null} ) : null}
{selectedWaterRiskMapView ? ( {selectedWaterRiskMapView ? (
// Capacity와 Flooding 색상은 SWMM 결과의 업무 의미색이므로 테마와 무관하게 유지한다. // 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="mb-1 font-bold">Capacity</div>
<div className="space-y-1 font-semibold"> <div className="space-y-1 font-semibold">
{[ {[
@@ -3236,10 +3254,13 @@ const MapPageClient = () => {
initialActiveKey={initialAnalysisPopupKey} initialActiveKey={initialAnalysisPopupKey}
onOpenPopup={openAnalysisPopup} onOpenPopup={openAnalysisPopup}
onOpenInfoSearch={() => setIsSearchModalOpen(true)} onOpenInfoSearch={() => setIsSearchModalOpen(true)}
onToggleGisEdit={handleToggleGisEdit}
isGisEditActive={gisEdit.isEditMode}
isInfoSearchActive={isSearchModalOpen} isInfoSearchActive={isSearchModalOpen}
/> />
<GisToolButtons
isGisEditActive={gisEdit.isEditMode}
onToggleGisEdit={handleToggleGisEdit}
onOpenGisManagement={() => openAnalysisPopup("gisManagementPopup")}
/>
{isGisEditSessionInlineVisible ? ( {isGisEditSessionInlineVisible ? (
<div <div
data-testid="gis-edit-session-inline" data-testid="gis-edit-session-inline"
@@ -3335,6 +3356,7 @@ const MapPageClient = () => {
defaultZoom={7.5} defaultZoom={7.5}
defaultLonLat={[129, 36.3]} defaultLonLat={[129, 36.3]}
viewExtent={SOUTH_KOREA_VIEW_EXTENT} viewExtent={SOUTH_KOREA_VIEW_EXTENT}
viewShowFullExtent={true}
/> />
{isAgedPipeListMinimized && ( {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"> <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">
@@ -10,6 +10,14 @@
- 확인됨: 공통 컴포넌트 수정은 `sn`에 실제로 필요한 범위로 제한한다. 근거: `apps/sn/AGENTS.md` - 확인됨: 공통 컴포넌트 수정은 `sn`에 실제로 필요한 범위로 제한한다. 근거: `apps/sn/AGENTS.md`
- 추정: 여러 화면에서 반복되는 `sn` 전용 로직은 먼저 `apps/sn` 내부 공통화로 해결하고, 여러 업무 화면에서 공유되는 비즈니스 UI일 때만 `business_components`를 검토하는 것이 안전하다. 근거: `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 제한과 대량 자료의 서버 생성 기준은 별도로 확인한다. 공통 유틸 사용만으로 데이터 수집 범위가 보장되지는 않는다.
## 작업카드에 남길 항목 ## 작업카드에 남길 항목
- 확인 필요: 신규 또는 수정 컴포넌트 경로 - 확인 필요: 신규 또는 수정 컴포넌트 경로
- 확인 필요: 호출하는 화면 경로 - 확인 필요: 호출하는 화면 경로
@@ -275,3 +275,8 @@
- 레이어 API 단위 테스트는 실제 서비스가 호출하는 Repository 메서드와 동일한 메서드를 모킹해야 한다. - 레이어 API 단위 테스트는 실제 서비스가 호출하는 Repository 메서드와 동일한 메서드를 모킹해야 한다.
- 전체 빌드가 장시간 걸리는 환경에서는 변경 파일 정적 검사 후 백엔드 대상 테스트와 로그인 브라우저 회귀를 분리한다. - 전체 빌드가 장시간 걸리는 환경에서는 변경 파일 정적 검사 후 백엔드 대상 테스트와 로그인 브라우저 회귀를 분리한다.
## 2026-10-19 맨홀 PDF GIS 번호 검증
- 188001 테스트 PDF 실제 판독·쪽 추출, PPL/MNHL 첨부 분리, 미첨부·없는 번호·중복 시설·다중/비연속 번호를 검증한다.
- 실제 서버에서는 맨홀 첨부 저장 → 상류맨홀 지도 선택 → 맨홀 보고서 1쪽 표시와 관로 회귀를 확인한다. 자동검증/실브라우저 여부는 통합검증결과에 구분한다.
@@ -678,3 +678,44 @@
- 검증: SN 전체 타입검사 exit0, 숫자/소수/삭제 및 거부 문자열 Node 검증 통과, git diff --check 통과. 실브라우저 입력 검증은 미수행. - 검증: SN 전체 타입검사 exit0, 숫자/소수/삭제 및 거부 문자열 Node 검증 통과, git diff --check 통과. 실브라우저 입력 검증은 미수행.
- 수정 소스: `frontend/apps/sn/src/features/exmn/ui/exmn-business-register-popup-page.tsx`. API/백엔드 코드·저장 계약 변경 없음. 기존 GIS 작업은 변경하지 않았다. - 수정 소스: `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을 하지 않는다.
@@ -15,3 +15,14 @@
- 순번은 전체 건수 기준 역순으로 계산해 로컬 3002에서 1페이지 18~9, 2페이지 8~1 표출을 확인했다. - 순번은 전체 건수 기준 역순으로 계산해 로컬 3002에서 1페이지 18~9, 2페이지 8~1 표출을 확인했다.
- 원본 근거는 요구사항정의서 `SFR-011-001`, 화면설계서 8~9번 `SN_MN010102`이며 순번·제목 건수는 원본 필수항목이 아닌 공통 그리드 표시 규칙이다. - 원본 근거는 요구사항정의서 `SFR-011-001`, 화면설계서 8~9번 `SN_MN010102`이며 순번·제목 건수는 원본 필수항목이 아닌 공통 그리드 표시 규칙이다.
- API, 조회조건, 연도 그룹 헤더와 Excel 다운로드 형식은 변경하지 않았다. - 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,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 처리를 되돌린다. - 롤백: `waterRiskClient.ts`를 제거하고 popup의 실제 조회/부모 메시지 변경 및 `map/page.tsx` 통수위험 mapView 처리를 되돌린다.
- 자동화 도구가 새 창 popup을 부모 탭과 동시에 제어하는 데 제한이 있어, popup의 실제 mapView 요청과 부모 메시지 처리 코드를 각각 확인했다. 최종 사용자 회귀 시 부모 지도에서 popup을 열고 한 행을 선택하는 시나리오를 한 번 더 수행할 수 있다. - 자동화 도구가 새 창 popup을 부모 탭과 동시에 제어하는 데 제한이 있어, popup의 실제 mapView 요청과 부모 메시지 처리 코드를 각각 확인했다. 최종 사용자 회귀 시 부모 지도에서 popup을 열고 한 행을 선택하는 시나리오를 한 번 더 수행할 수 있다.
- 현재누적강우량은 백엔드 원천 미확보로 `-`이며, 예측강우 격자 overlay는 이번 합리식 결과 범위에 포함하지 않는다. - 현재누적강우량은 백엔드 원천 미확보로 `-`이며, 예측강우 격자 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`를 브라우저에서 생성한다. - `fldng-predc-prst-excel.ts`가 화면 열 순서의 단일 시트 `침수예측현황.xlsx`를 브라우저에서 생성한다.
- 서버 `/prst/excel` Blob 호출과 `getBlobData`, `fileDownload` 사용을 제거했다. - 서버 `/prst/excel` Blob 호출과 `getBlobData`, `fileDownload` 사용을 제거했다.
- 독립 TypeScript `--noEmit` 검증을 통과했다. - 독립 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,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년으로 설정되는 문제"(지도 레이어 침수흔적도 연도 기본값)는 이번 범위(하단 문구)가 아니라 수정하지 않았다.
@@ -1004,3 +1004,44 @@
- 검증: SN 전체 타입검사 exit0, 숫자/소수/삭제 및 거부 문자열 Node 검증 통과, git diff --check 통과. 실브라우저 입력 검증은 미수행. - 검증: SN 전체 타입검사 exit0, 숫자/소수/삭제 및 거부 문자열 Node 검증 통과, git diff --check 통과. 실브라우저 입력 검증은 미수행.
- 수정 소스: `frontend/apps/sn/src/features/exmn/ui/exmn-business-register-popup-page.tsx`. API/백엔드 코드·저장 계약 변경 없음. 기존 GIS 작업은 변경하지 않았다. - 수정 소스: `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을 하지 않는다.
@@ -52,7 +52,7 @@
| 행 선택 지도 이동 | 기존 그룹 범위 이동 유지 | | 행 선택 지도 이동 | 기존 그룹 범위 이동 유지 |
| 일반 지도 통신 | 빈도 부모 3개 TileWMS | | 일반 지도 통신 | 빈도 부모 3개 TileWMS |
| 빈도 제한 | 부모 `flterSql`을 WMS `CQL_FILTER`로 전달 | | 빈도 제한 | 부모 `flterSql`을 WMS `CQL_FILTER`로 전달 |
| 침수심도 스타일 | 자식 5건 기반 동적 SLD | | 침수심도 스타일 | GeoServer 레이어 기본 스타일(2026-10-16부터, 11절). 이전에는 자식 5건 기반 동적 SLD |
| 범례 | 숨겨진 자식 설정의 기존 색상·명칭 유지 | | 범례 | 숨겨진 자식 설정의 기존 색상·명칭 유지 |
| 100년 빈도 | 화면 및 WMS 전환 대상에서 제외 | | 100년 빈도 | 화면 및 WMS 전환 대상에서 제외 |
| 기존 다른 레이어 | 기존 WFS 경로 유지 | | 기존 다른 레이어 | 기존 WFS 경로 유지 |
@@ -88,3 +88,16 @@
- 운영에서 행 선택 후 그룹 extent API가 `ST_Envelope` 스키마 해석 오류로 실패해 지도 이동이 중단된 원인을 백엔드 SQL에서 보완했다. - 운영에서 행 선택 후 그룹 extent API가 `ST_Envelope` 스키마 해석 오류로 실패해 지도 이동이 중단된 원인을 백엔드 SQL에서 보완했다.
- 기존 시군구·침수빈도 그룹 범위 이동 계약과 화면 동작은 유지한다. - 기존 시군구·침수빈도 그룹 범위 이동 계약과 화면 동작은 유지한다.
- 재배포 후 목록 행 클릭, 지도 중심 이동, 선택 빈도 WMS 표출을 회귀 확인한다. - 재배포 후 목록 행 클릭, 지도 중심 이동, 선택 빈도 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_NAME,
FLDNG_HSTRY_LAYER_TYPE, FLDNG_HSTRY_LAYER_TYPE,
FLDNG_HSTRY_MIN_YEAR, FLDNG_HSTRY_MIN_YEAR,
FLDNG_HSTRY_MAX_YEAR,
FLDNG_HSTRY_PREVIEW_LAYER_TYPE, FLDNG_HSTRY_PREVIEW_LAYER_TYPE,
FLDNG_HSTRY_ROOT_NAME, FLDNG_HSTRY_ROOT_NAME,
type FldngHstryApiItem, type FldngHstryApiItem,
@@ -50,11 +51,10 @@ export const formatFldngHstryDateTime = (
return [normalizedDate, normalizedTime].filter(Boolean).join(" "); return [normalizedDate, normalizedTime].filter(Boolean).join(" ");
}; };
export const buildFldngHstryDefaultYearRange = ( /** 침수흔적도 초기 조회 범위를 제공 자료의 연도 범위로 설정한다. */
currentYear = new Date().getFullYear(), export const buildFldngHstryDefaultYearRange = (): FldngHstryYearRange => ({
): FldngHstryYearRange => ({
startYear: String(FLDNG_HSTRY_MIN_YEAR), startYear: String(FLDNG_HSTRY_MIN_YEAR),
endYear: String(currentYear), endYear: String(FLDNG_HSTRY_MAX_YEAR),
}); });
export const normalizeFldngHstryYearRange = ( 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_PAGE_SIZE = 10;
export const FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE = 2000; export const FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE = 2000;
export const FLDNG_HSTRY_MIN_YEAR = 2002; export const FLDNG_HSTRY_MIN_YEAR = 2002;
/** 침수흔적도 연도 선택 목록과 초기 조회 범위의 최대 연도다. */
export const FLDNG_HSTRY_MAX_YEAR = 2025;
export type FldngHstryDetailCacheValue = { export type FldngHstryDetailCacheValue = {
rainAnalysis: Record<string, unknown>; rainAnalysis: Record<string, unknown>;
@@ -1,3 +1,4 @@
import { DREDGING_EXTERNAL_MODE } from "../model/disclosure";
/** 준설 조회·등록·수정·삭제와 최근접 관로 HTTP 계약을 담당한다. */ /** 준설 조회·등록·수정·삭제와 최근접 관로 HTTP 계약을 담당한다. */
import { import {
deleteData, deleteData,
@@ -110,12 +111,13 @@ export const fetchDredgingRecords = async (params: {
/** 등록된 준설사진의 공통 첨부파일 참조를 조회한다. */ /** 등록된 준설사진의 공통 첨부파일 참조를 조회한다. */
export const fetchDredgingPhotos = async ( export const fetchDredgingPhotos = async (
dredgingJobSn: string | number, dredgingJobSn: string | number,
pipeFid: string, pipeFid: string | null,
targetSn?: number,
): Promise<DredgingPhoto[]> => { ): Promise<DredgingPhoto[]> => {
if (!isDredgingBackendEnabled) return []; if (!isDredgingBackendEnabled) return [];
try { try {
const body = await getRawJson( 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); return toDredgingPhotos(body);
} catch (error) { } catch (error) {
@@ -137,18 +139,18 @@ export const updateDredgingRecord = async (
try { try {
await putData(`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}`, { await putData(`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}`, {
dredgingDate: update.dredgingDate, dredgingDate: update.dredgingDate,
pipeFid: update.originalPipeFid, targetSn: update.targetSn,
registrationMode: update.registrationMode, registrationMode: update.registrationMode,
location: { location: {
source: update.locationSource, source: update.locationSource,
longitude: update.location.longitude, longitude: update.location.longitude,
latitude: update.location.latitude, latitude: update.location.latitude,
}, },
expectedPipe: { expectedPipe: update.pipe && !DREDGING_EXTERNAL_MODE ? {
fid: update.pipe.fid, fid: update.pipe.fid,
mngInstCd: update.pipe.mngInstCd, mngInstCd: update.pipe.mngInstCd,
fcltMngNo: update.pipe.fcltMngNo, fcltMngNo: update.pipe.fcltMngNo,
}, } : null,
retainedPhotoSns: update.retainedPhotoSns, retainedPhotoSns: update.retainedPhotoSns,
newPhotos: update.newPhotos.map((photo) => { newPhotos: update.newPhotos.map((photo) => {
const attachment = attachments.get(photo.key); const attachment = attachments.get(photo.key);
@@ -242,7 +244,7 @@ const mockPipeForLocations = (locations: LocationRequest[]): PipeMatch => {
export const findNearestPipeMatches = async ( export const findNearestPipeMatches = async (
locations: LocationRequest[], locations: LocationRequest[],
): Promise<Map<string, PipeMatch>> => { ): Promise<Map<string, PipeMatch>> => {
if (locations.length === 0) return new Map(); if (DREDGING_EXTERNAL_MODE || locations.length === 0) return new Map();
if (!isDredgingBackendEnabled) { if (!isDredgingBackendEnabled) {
const mockPipe = mockPipeForLocations(locations); const mockPipe = mockPipeForLocations(locations);
@@ -55,7 +55,7 @@ export const toNearestPipeMatches = (body: any): Map<string, PipeMatch> => {
for (const item of items ?? []) { for (const item of items ?? []) {
if (!item.selected) { if (!item.selected) {
failureMessages.push(nearestPipeFailureMessage(item)); if (item.failureCode !== "PIPE_NOT_FOUND") failureMessages.push(nearestPipeFailureMessage(item));
continue; continue;
} }
matches.set(item.locationKey, { 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 상태와 분리한다. */ /** 준설 목록·집계 표의 컬럼·표시 변환. 서버 조회/React 상태와 분리한다. */
import type { DredgingRecord } from "./types"; import type { DredgingRecord } from "./types";
import { MS_SWAGE_PPL_CODES } from "@/entities/facility/model/facility-code-fallbacks"; 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, sourcePath: window.location.pathname,
id: record.id, id: record.id,
dredgingJobSn: record.dredgingJobSn, dredgingJobSn: record.dredgingJobSn,
targetSn: record.targetSn,
mappingStatus: record.mappingStatus,
pipeFid: record.pipeFid, pipeFid: record.pipeFid,
mngInstCd: record.mngInstCd, mngInstCd: record.mngInstCd,
ctpv: record.ctpv, ctpv: record.ctpv,
@@ -140,7 +143,8 @@ export type TableHeadDefinition = {
align?: "left" | "center" | "right"; align?: "left" | "center" | "right";
}; };
export const municipalityHeadlist: TableHeadDefinition[] = [ const fullMunicipalityHeadlist: TableHeadDefinition[] = [
{ id: "mappingStatusLabel", title: "관로 지정" },
{ id: "rowNumber", title: "순번" }, { id: "rowNumber", title: "순번" },
{ id: "ctpv", title: "시도" }, { id: "ctpv", title: "시도" },
{ id: "sgg", title: "시군구" }, { id: "sgg", title: "시군구" },
@@ -156,6 +160,10 @@ export const municipalityHeadlist: TableHeadDefinition[] = [
{ id: "registeredDate", title: "등록일자" }, { 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[] = [ export const diameterHeadlist: TableHeadDefinition[] = [
{ id: "ctpv", title: "시도" }, { id: "ctpv", title: "시도" },
{ id: "totalLengthKm", title: "관로연장(km)", align: "right" }, { id: "totalLengthKm", title: "관로연장(km)", align: "right" },
@@ -202,33 +210,33 @@ export const buildDiameterRows = (records: DredgingRecord[]): TableRow[] =>
roundKm( roundKm(
items items
.filter(predicate) .filter(predicate)
.reduce((sum, record) => sum + record.pipeLengthM, 0), .reduce((sum, record) => sum + (record.pipeLengthM ?? 0), 0),
); );
return { return {
ctpv, ctpv,
totalLengthKm: roundKm( 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( diameter600: sumLength(
(record) => record.pipeDiameterMm > 450 && record.pipeDiameterMm <= 600, (record) => (record.pipeDiameterMm ?? 0) > 450 && (record.pipeDiameterMm ?? 0) <= 600,
), ),
diameter700: sumLength( diameter700: sumLength(
(record) => record.pipeDiameterMm > 600 && record.pipeDiameterMm <= 700, (record) => (record.pipeDiameterMm ?? 0) > 600 && (record.pipeDiameterMm ?? 0) <= 700,
), ),
diameter900: sumLength( diameter900: sumLength(
(record) => record.pipeDiameterMm > 700 && record.pipeDiameterMm <= 900, (record) => (record.pipeDiameterMm ?? 0) > 700 && (record.pipeDiameterMm ?? 0) <= 900,
), ),
diameter1000: sumLength( diameter1000: sumLength(
(record) => (record) =>
record.pipeDiameterMm > 900 && record.pipeDiameterMm <= 1000, (record.pipeDiameterMm ?? 0) > 900 && (record.pipeDiameterMm ?? 0) <= 1000,
), ),
diameter1200: sumLength( diameter1200: sumLength(
(record) => (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), recordIds: items.map((record) => record.id),
}; };
}); });
@@ -239,7 +247,7 @@ export const buildElapsedYearRows = (records: DredgingRecord[]): TableRow[] =>
roundKm( roundKm(
items items
.filter(predicate) .filter(predicate)
.reduce((sum, record) => sum + record.pipeLengthM, 0), .reduce((sum, record) => sum + (record.pipeLengthM ?? 0), 0),
); );
const elapsedYears = (record: DredgingRecord) => const elapsedYears = (record: DredgingRecord) =>
record.installationYear === null record.installationYear === null
@@ -249,7 +257,7 @@ export const buildElapsedYearRows = (records: DredgingRecord[]): TableRow[] =>
return { return {
ctpv, ctpv,
totalLengthKm: roundKm( totalLengthKm: roundKm(
items.reduce((sum, record) => sum + record.pipeLengthM, 0), items.reduce((sum, record) => sum + (record.pipeLengthM ?? 0), 0),
), ),
under10: sumLength((record) => { under10: sumLength((record) => {
const elapsed = elapsedYears(record); const elapsed = elapsedYears(record);
@@ -19,11 +19,11 @@ const buildRegistrationBody = (
longitude: group.location.longitude, longitude: group.location.longitude,
latitude: group.location.latitude, latitude: group.location.latitude,
}, },
expectedPipe: { expectedPipe: group.pipe ? {
fid: group.pipe.fid, fid: group.pipe.fid,
mngInstCd: group.pipe.mngInstCd, mngInstCd: group.pipe.mngInstCd,
fcltMngNo: group.pipe.fcltMngNo, fcltMngNo: group.pipe.fcltMngNo,
}, } : null,
photos: group.photos.map((photo) => { photos: group.photos.map((photo) => {
const attachment = attachments.get(photo.key); const attachment = attachments.get(photo.key);
if (!attachment && requireAttachments) { if (!attachment && requireAttachments) {
+18 -14
View File
@@ -59,32 +59,35 @@ export type DredgingDraftGroup = {
export type DredgingRecord = { export type DredgingRecord = {
id: string; id: string;
dredgingJobSn: number | string; dredgingJobSn: number | string;
pipeFid: string; targetSn?: number;
mngInstCd: string; mappingStatus?: "AUTO" | "UNMAPPED";
pipeFid: string | null;
mngInstCd: string | null;
ctpv: string; ctpv: string;
sgg: string; sgg: string;
treatmentZone: string; treatmentZone: string | null;
fcltMngNo: string; fcltMngNo: string | null;
pipeDiameterMm: number; pipeDiameterMm: number | null;
pipeLengthM: number; pipeLengthM: number | null;
pipeMaterial: string; pipeMaterial: string | null;
installationYear: number | null; installationYear: number | null;
dredgingDate: string; dredgingDate: string;
photoCount: number; photoCount: number;
registeredDate: string; registeredDate: string;
longitude: number; longitude: number;
latitude: number; latitude: number;
snappedLongitude: number; snappedLongitude: number | null;
snappedLatitude: number; snappedLatitude: number | null;
registrationMode: DredgingRegistrationMode; registrationMode: DredgingRegistrationMode;
registrationSource: DredgingLocationSource; registrationSource: DredgingLocationSource;
matchDistanceMeters: number; matchDistanceMeters: number | null;
isMock: boolean; isMock: boolean;
}; };
export type DredgingPhoto = { export type DredgingPhoto = {
targetSn?: number;
photoSn: number; photoSn: number;
pipeFid: string; pipeFid?: string | null;
atflCd: string; atflCd: string;
atflId: string; atflId: string;
originalFileName: string; originalFileName: string;
@@ -97,18 +100,19 @@ export type DredgingSaveGroup = {
dredgingDate: string; dredgingDate: string;
location: GeoPoint; location: GeoPoint;
locationSource: DredgingLocationSource; locationSource: DredgingLocationSource;
pipe: PipeMatch; pipe: PipeMatch | null;
photos: DredgingPhotoDraft[]; photos: DredgingPhotoDraft[];
}; };
/** 상세 기반 수정 팝업에서 선택한 한 관로의 위치·사진을 함께 갱신한다. */ /** 상세 기반 수정 팝업에서 선택한 한 관로의 위치·사진을 함께 갱신한다. */
export type DredgingRecordUpdate = { export type DredgingRecordUpdate = {
dredgingDate: string; dredgingDate: string;
originalPipeFid: string; originalPipeFid: string | null;
targetSn: number;
registrationMode: DredgingRegistrationMode; registrationMode: DredgingRegistrationMode;
location: GeoPoint; location: GeoPoint;
locationSource: DredgingLocationSource; locationSource: DredgingLocationSource;
pipe: PipeMatch; pipe: PipeMatch | null;
retainedPhotoSns: number[]; retainedPhotoSns: number[];
newPhotos: DredgingPhotoDraft[]; newPhotos: DredgingPhotoDraft[];
}; };
@@ -1,4 +1,6 @@
"use client"; "use client";
import { DredgingRegistrationModeSelector } from "./dredging-registration-mode-selector";
import { DREDGING_EXTERNAL_MODE } from "../model/disclosure";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { useConfirm } from "@frontend-opensource/use-react-hooks";
@@ -134,25 +136,25 @@ const DredgingDetailPopupPage = ({
const [statusMessage, setStatusMessage] = useState(""); const [statusMessage, setStatusMessage] = useState("");
const uploadedAttachmentsRef = useRef(new Map<string, AttachmentReference>()); 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, fid: record.pipeFid,
mngInstCd: record.mngInstCd, mngInstCd: record.mngInstCd ?? "",
fcltMngNo: record.fcltMngNo, fcltMngNo: record.fcltMngNo ?? "",
pipeDiameterMm: record.pipeDiameterMm, pipeDiameterMm: record.pipeDiameterMm ?? 0,
pipeLengthM: record.pipeLengthM, pipeLengthM: record.pipeLengthM ?? 0,
pipeMaterial: record.pipeMaterial, pipeMaterial: record.pipeMaterial ?? "",
installationYear: record.installationYear, installationYear: record.installationYear,
swtrmtZoneFid: null, swtrmtZoneFid: null,
swtrmtZoneNm: record.treatmentZone, swtrmtZoneNm: record.treatmentZone ?? "",
distanceMeters: record.matchDistanceMeters, distanceMeters: record.matchDistanceMeters ?? 0,
snappedLongitude: record.snappedLongitude || record.longitude, snappedLongitude: record.snappedLongitude || record.longitude,
snappedLatitude: record.snappedLatitude || record.latitude, snappedLatitude: record.snappedLatitude || record.latitude,
isMock: record.isMock, isMock: record.isMock,
}), }),
[record], [record],
); );
const [matchedPipe, setMatchedPipe] = useState<PipeMatch>(initialMatchedPipe); const [matchedPipe, setMatchedPipe] = useState<PipeMatch | null>(initialMatchedPipe);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -168,6 +170,7 @@ const DredgingDetailPopupPage = ({
const items = await fetchDredgingPhotos( const items = await fetchDredgingPhotos(
record.dredgingJobSn, record.dredgingJobSn,
record.pipeFid, record.pipeFid,
record.targetSn,
); );
const previews = await Promise.all( const previews = await Promise.all(
items.map(async (photo): Promise<DredgingPhotoPreview> => { items.map(async (photo): Promise<DredgingPhotoPreview> => {
@@ -214,7 +217,7 @@ const DredgingDetailPopupPage = ({
cancelled = true; cancelled = true;
createdUrls.forEach((url) => URL.revokeObjectURL(url)); createdUrls.forEach((url) => URL.revokeObjectURL(url));
}; };
}, [record.dredgingJobSn, record.pipeFid]); }, [record.dredgingJobSn, record.pipeFid, record.targetSn]);
useEffect( useEffect(
() => () => { () => () => {
@@ -258,19 +261,22 @@ const DredgingDetailPopupPage = ({
} }
}; };
const pipeInfo = [ const pipeInfo = DREDGING_EXTERNAL_MODE ? [
["시도", record.ctpv || "확인불가"], ["시군구", record.sgg || "확인불가"],
["위치등록", locationSourceLabels[locationSource] || locationSource],
] : [
["시도", record.ctpv || "확인불가"], ["시도", record.ctpv || "확인불가"],
["시군구", record.sgg || "확인불가"], ["시군구", record.sgg || "확인불가"],
[ [
"처리구역", "처리구역",
matchedPipe.swtrmtZoneNm || record.treatmentZone || "확인불가", matchedPipe?.swtrmtZoneNm || record.treatmentZone || "확인불가",
], ],
["관경", `${matchedPipe.pipeDiameterMm.toLocaleString()} mm`], ["관경", matchedPipe ? `${matchedPipe.pipeDiameterMm.toLocaleString()} mm` : "미산정"],
["관로연장", `${matchedPipe.pipeLengthM.toLocaleString()} m`], ["관로연장", matchedPipe ? `${matchedPipe.pipeLengthM.toLocaleString()} m` : "미산정"],
["관재질", resolvePipeMaterialName(matchedPipe.pipeMaterial)], ["관재질", matchedPipe ? resolvePipeMaterialName(matchedPipe.pipeMaterial) : "미확인"],
[ [
"매설년도", "매설년도",
matchedPipe.installationYear matchedPipe?.installationYear
? `${matchedPipe.installationYear}년` ? `${matchedPipe.installationYear}년`
: "확인불가", : "확인불가",
], ],
@@ -280,7 +286,7 @@ const DredgingDetailPopupPage = ({
{ length: Math.ceil(pipeInfo.length / 2) }, { length: Math.ceil(pipeInfo.length / 2) },
(_, index) => pipeInfo.slice(index * 2, index * 2 + 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 totalPhotoCount = photos.length + newPhotos.length;
const handleNewFiles = async (files: File[] | null) => { const handleNewFiles = async (files: File[] | null) => {
@@ -407,17 +413,12 @@ const DredgingDetailPopupPage = ({
{ locationKey, ...locationDraft }, { locationKey, ...locationDraft },
]); ]);
const nextPipe = matches.get(locationKey); const nextPipe = matches.get(locationKey);
if (!nextPipe) {
throw new Error(
"50m 이내에 관로가 없습니다. 지도에서 준설위치를 다시 확인해주세요.",
);
}
setLocation(locationDraft); setLocation(locationDraft);
setLocationSource("MANUAL"); setLocationSource("MANUAL");
setMatchedPipe(nextPipe); setMatchedPipe(nextPipe ?? null);
setLocationDraft(null); setLocationDraft(null);
setIsEditingLocation(false); setIsEditingLocation(false);
setStatusMessage("변경한 위치와 최근접 관로를 확인했습니다."); setStatusMessage(DREDGING_EXTERNAL_MODE ? "변경한 준설위치를 적용했습니다." : nextPipe ? "변경한 위치와 최근접 관로를 확인했습니다." : "관로 미지정으로 저장할 수 있습니다.");
} catch (matchFailure) { } catch (matchFailure) {
setMatchError( setMatchError(
matchFailure instanceof Error matchFailure instanceof Error
@@ -449,6 +450,8 @@ const DredgingDetailPopupPage = ({
alert("선택한 지도 위치를 먼저 적용해주세요."); alert("선택한 지도 위치를 먼저 적용해주세요.");
return; return;
} }
if (!record.targetSn) { alert("화면을 새로고침해 준설대상번호를 확인해주세요."); return; }
if (!DREDGING_EXTERNAL_MODE && !matchedPipe && !(await confirm("관로 미지정 상태로 수정 내용을 저장하시겠습니까?"))) return;
setIsSaving(true); setIsSaving(true);
try { try {
setStatusMessage( setStatusMessage(
@@ -466,6 +469,7 @@ const DredgingDetailPopupPage = ({
{ {
dredgingDate, dredgingDate,
originalPipeFid: record.pipeFid, originalPipeFid: record.pipeFid,
targetSn: record.targetSn,
registrationMode, registrationMode,
location, location,
locationSource: 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]"> <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"> <section className="card card-border !m-0 shrink-0 overflow-hidden !p-0">
<div className="card-header !m-0"> <div className="card-header !m-0">
<h2 className="text-2xl font-medium text-label">관로 정보</h2> <h2 className="text-2xl font-medium text-label">{DREDGING_EXTERNAL_MODE ? "준설 정보" : "관로 정보"}</h2>
{!DREDGING_EXTERNAL_MODE && (
<span className="truncate text-base text-muted-foreground"> <span className="truncate text-base text-muted-foreground">
관로번호 {matchedPipe.fcltMngNo || "확인불가"} {`관로번호 ${matchedPipe?.fcltMngNo || "관로 미지정"}`}
</span> </span>
)}
</div> </div>
<div className="p-3"> <div className="p-3">
<Table variant="secondary"> <Table variant="secondary">
@@ -558,7 +564,7 @@ const DredgingDetailPopupPage = ({
dredgingDate dredgingDate
)} )}
</TableCell> </TableCell>
<TableHead>자동매핑</TableHead> <TableHead>{DREDGING_EXTERNAL_MODE ? "위치" : "자동매핑"}</TableHead>
<TableCell <TableCell
className="truncate border px-4" className="truncate border px-4"
title={automaticMappingValue} 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" className="card card-border !m-0 flex !min-h-[40rem] flex-1 flex-col overflow-hidden !p-0"
data-testid="dredging-detail-photo-panel" 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> <h2 className="text-2xl font-medium text-label">준설 사진</h2>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2"> <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{editable ? ( {editable ? (
<> <DredgingRegistrationModeSelector
<Button value={registrationMode}
type="button" labels={registrationModeLabels}
size="sm" onChange={(mode) => void changeRegistrationMode(mode)}
color={registrationMode === "PHOTO" ? "primary" : "white"} testIdPrefix="dredging-edit"
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>
</>
) : null} ) : null}
<span className="text-base text-muted-foreground"> <span className="text-base text-muted-foreground">
{totalPhotoCount}/5장 {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="card-header !m-0 flex-wrap gap-x-2 gap-y-1 !border-b-0">
<div className="min-w-0"> <div className="min-w-0">
<h2 className="text-2xl font-medium text-label"> <h2 className="text-2xl font-medium text-label">
위치 및 관로 {DREDGING_EXTERNAL_MODE ? "준설 위치" : "위치 및 관로"}
</h2> </h2>
<p <p
className={`mt-1 truncate text-sm ${matchError ? "text-rose-700" : "text-muted-foreground"}`} className={`mt-1 truncate text-sm ${matchError ? "text-rose-700" : "text-muted-foreground"}`}
> >
자동 연결 관로 · {matchedPipe.fcltMngNo} ·{" "} {DREDGING_EXTERNAL_MODE ? "배경지도에서 실제 준설 위치를 지정해주세요." :
{matchError ?? `${matchedPipe.distanceMeters.toFixed(2)}m`} matchError ?? (matchedPipe ? `자동 연결 관로 · ${matchedPipe.fcltMngNo} · ${matchedPipe.distanceMeters.toFixed(2)}m` : "관로 미지정")}
</p> </p>
</div> </div>
<div className="flex shrink-0 gap-2"> <div className="flex shrink-0 gap-2">
@@ -883,7 +871,7 @@ const DredgingDetailPopupPage = ({
data-testid="dredging-edit-status" data-testid="dredging-edit-status"
> >
{statusMessage || {statusMessage ||
"준설일자·사진·위치와 자동 연결 관로를 확인해주세요."} (DREDGING_EXTERNAL_MODE ? "준설일자·사진·위치를 확인해주세요." : "준설일자·사진·위치와 자동 연결 관로를 확인해주세요.")}
</p> </p>
</div> </div>
</section> </section>
@@ -914,8 +902,8 @@ const DredgingDetailPopupPage = ({
isEditingLocation isEditingLocation
? "지도에서 변경할 준설위치를 클릭한 뒤 선택 위치 적용을 눌러주세요." ? "지도에서 변경할 준설위치를 클릭한 뒤 선택 위치 적용을 눌러주세요."
: editable : editable
? "현재 준설위치와 자동 연결된 관로를 표시하고 있습니다." ? (DREDGING_EXTERNAL_MODE ? "현재 준설위치를 표시하고 있습니다." : "현재 준설위치와 자동 연결된 관로를 표시하고 있습니다.")
: "준설위치와 자동 연결된 관로를 표시하고 있습니다." : (DREDGING_EXTERNAL_MODE ? "준설위치를 표시하고 있습니다." : "준설위치와 자동 연결된 관로를 표시하고 있습니다.")
} }
height="h-full min-h-0" height="h-full min-h-0"
/> />
@@ -6,6 +6,7 @@
* - 지자체별/관경별/경과년도별 조회와 XLSX 다운로드 제공 * - 지자체별/관경별/경과년도별 조회와 XLSX 다운로드 제공
*/ */
"use client"; "use client";
import { DREDGING_EXTERNAL_MODE } from "../model/disclosure";
import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-code-catalog"; import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-code-catalog";
import { useDredgingListData } from "../lib/use-dredging-list-data"; import { useDredgingListData } from "../lib/use-dredging-list-data";
import { import {
@@ -187,8 +188,8 @@ const Page = () => {
return filteredRecords.map((record, index) => ({ return filteredRecords.map((record, index) => ({
rowNumber: index + 1, rowNumber: index + 1,
...record, ...record,
pipeLengthM: Number(record.pipeLengthM.toFixed(2)), pipeLengthM: Number((record.pipeLengthM ?? 0).toFixed(2)),
pipeMaterial: resolvePipeMaterialName(record.pipeMaterial, materialNames), pipeMaterial: resolvePipeMaterialName(record.pipeMaterial ?? "", materialNames),
installationYear: record.installationYear ?? "확인불가", installationYear: record.installationYear ?? "확인불가",
elapsedYears: elapsedYears:
record.installationYear === null record.installationYear === null
@@ -372,27 +373,28 @@ const Page = () => {
const addedRecords = savedGroups.map<DredgingRecord>((group, index) => ({ const addedRecords = savedGroups.map<DredgingRecord>((group, index) => ({
id: `${localJobSn}-${index + 1}`, id: `${localJobSn}-${index + 1}`,
dredgingJobSn: localJobSn, dredgingJobSn: localJobSn,
pipeFid: group.pipe.fid, targetSn: index + 1,
mngInstCd: group.pipe.mngInstCd, pipeFid: group.pipe?.fid ?? null,
mngInstCd: group.pipe?.mngInstCd ?? null,
ctpv: ctpvName || "경기도", ctpv: ctpvName || "경기도",
sgg: sggName || "양평군", sgg: sggName || "양평군",
treatmentZone: group.pipe.swtrmtZoneNm || "확인불가", treatmentZone: group.pipe?.swtrmtZoneNm || "확인불가",
fcltMngNo: group.pipe.fcltMngNo, fcltMngNo: group.pipe?.fcltMngNo ?? null,
pipeDiameterMm: group.pipe.pipeDiameterMm, pipeDiameterMm: group.pipe?.pipeDiameterMm ?? null,
pipeLengthM: group.pipe.pipeLengthM, pipeLengthM: group.pipe?.pipeLengthM ?? null,
pipeMaterial: group.pipe.pipeMaterial, pipeMaterial: group.pipe?.pipeMaterial ?? null,
installationYear: group.pipe.installationYear, installationYear: group.pipe?.installationYear ?? null,
dredgingDate: group.dredgingDate, dredgingDate: group.dredgingDate,
photoCount: group.photos.length, photoCount: group.photos.length,
registeredDate, registeredDate,
longitude: group.location.longitude, longitude: group.location.longitude,
latitude: group.location.latitude, latitude: group.location.latitude,
snappedLongitude: group.pipe.snappedLongitude, snappedLongitude: group.pipe?.snappedLongitude ?? null,
snappedLatitude: group.pipe.snappedLatitude, snappedLatitude: group.pipe?.snappedLatitude ?? null,
registrationMode: group.registrationMode, registrationMode: group.registrationMode,
registrationSource: group.locationSource, registrationSource: group.locationSource,
matchDistanceMeters: group.pipe.distanceMeters, matchDistanceMeters: group.pipe?.distanceMeters ?? null,
isMock: group.pipe.isMock || !isDredgingBackendEnabled, isMock: Boolean(group.pipe?.isMock) || !isDredgingBackendEnabled,
})); }));
prependDemoRecords(addedRecords); prependDemoRecords(addedRecords);
setCurrentPage(1); setCurrentPage(1);
@@ -536,8 +538,8 @@ const Page = () => {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="MUNICIPALITY">지자체별</SelectItem> <SelectItem value="MUNICIPALITY">지자체별</SelectItem>
<SelectItem value="DIAMETER">관경별</SelectItem> {!DREDGING_EXTERNAL_MODE && (<SelectItem value="DIAMETER">관경별</SelectItem>)}
<SelectItem value="ELAPSED_YEAR">경과년도별</SelectItem> {!DREDGING_EXTERNAL_MODE && (<SelectItem value="ELAPSED_YEAR">경과년도별</SelectItem>)}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -1,4 +1,5 @@
"use client"; "use client";
import { DREDGING_EXTERNAL_MODE } from "../model/disclosure";
import Feature from "ol/Feature.js"; import Feature from "ol/Feature.js";
import type { FeatureLike } from "ol/Feature.js"; import type { FeatureLike } from "ol/Feature.js";
@@ -265,6 +266,12 @@ const LocationInteraction = ({
previousValueRef.current = value; previousValueRef.current = value;
}, [gisMap, interactive, value?.latitude, value?.longitude]); }, [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 = (() => { const pipeStatusText = (() => {
if (pipeLayerSettings.length === 0) if (pipeLayerSettings.length === 0)
return "관망도 레이어 설정을 불러오는 중입니다."; return "관망도 레이어 설정을 불러오는 중입니다.";
@@ -329,6 +336,7 @@ const DredgingLocationMap = ({
); );
useEffect(() => { useEffect(() => {
if (DREDGING_EXTERNAL_MODE) return; // 외부에서는 관망 레이어 설정과 도형을 요청하지 않는다.
let cancelled = false; let cancelled = false;
const load = async () => { const load = async () => {
try { try {
@@ -370,8 +378,8 @@ const DredgingLocationMap = ({
value={value} value={value}
onChange={onChange} onChange={onChange}
interactive={interactive} interactive={interactive}
matchedPipe={matchedPipe} matchedPipe={DREDGING_EXTERNAL_MODE ? null : matchedPipe}
pipeLayerSettings={pipeLayerSettings} pipeLayerSettings={DREDGING_EXTERNAL_MODE ? [] : pipeLayerSettings}
/> />
} }
defaultZoom={value ? 17 : 7.5} 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 dynamic from "next/dynamic";
import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { useConfirm } from "@frontend-opensource/use-react-hooks";
@@ -196,7 +198,9 @@ const DredgingRegisterModal = ({
try { try {
const matches = await findNearestPipeMatches(locations); const matches = await findNearestPipeMatches(locations);
if (matches.size !== locations.length) { 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()); const matchedPipes = Array.from(matches.values());
@@ -453,18 +457,15 @@ const DredgingRegisterModal = ({
if (!location) { if (!location) {
throw new Error(`${label}: 지도에서 준설위치를 등록해주세요.`); throw new Error(`${label}: 지도에서 준설위치를 등록해주세요.`);
} }
if (!group.matchedPipe) { if (group.matchError?.startsWith("사진 위치가 서로 다른 관로")) throw new Error(group.matchError);
throw new Error( if (group.isMatching) throw new Error(`${label}: 위치 확인이 끝난 뒤 저장해주세요.`);
`${label}: ${group.matchError ?? "최근접 관로 자동 매핑을 완료해주세요."}`,
);
}
saveGroups.push({ saveGroups.push({
localId: group.id, localId: group.id,
registrationMode: group.registrationMode, registrationMode: group.registrationMode,
dredgingDate: representativeDredgingDate, dredgingDate: representativeDredgingDate,
location, location,
locationSource: group.manualLocation ? "MANUAL" : "EXIF", locationSource: group.manualLocation ? "MANUAL" : "EXIF",
pipe: group.matchedPipe, pipe: DREDGING_EXTERNAL_MODE ? null : group.matchedPipe,
photos: group.photos, photos: group.photos,
}); });
} }
@@ -476,6 +477,8 @@ const DredgingRegisterModal = ({
if (savingRef.current) return; if (savingRef.current) return;
try { try {
const saveGroups = validateAndBuildSaveGroups(); const saveGroups = validateAndBuildSaveGroups();
if (!DREDGING_EXTERNAL_MODE && saveGroups.some(group => !group.pipe)
&& !(await confirm("관로가 지정되지 않은 대상이 있습니다. 사진과 위치를 저장하시겠습니까?"))) return;
savingRef.current = true; savingRef.current = true;
setIsSaving(true); setIsSaving(true);
setStatusMessage( setStatusMessage(
@@ -491,7 +494,7 @@ const DredgingRegisterModal = ({
uploadedAttachmentsRef.current = attachments; uploadedAttachmentsRef.current = attachments;
setStatusMessage( setStatusMessage(
isDredgingBackendEnabled isDredgingBackendEnabled
? "준설 결과와 관로 매핑을 저장하고 있습니다." ? (DREDGING_EXTERNAL_MODE ? "준설 결과를 저장하고 있습니다." : "준설 결과와 관로 매핑을 저장하고 있습니다.")
: "준설 결과 등록을 완료했습니다.", : "준설 결과 등록을 완료했습니다.",
); );
const session = const session =
@@ -1,4 +1,7 @@
"use client"; "use client";
import { DredgingRegistrationModeSelector } from "./dredging-registration-mode-selector";
import { DREDGING_EXTERNAL_MODE } from "../model/disclosure";
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { FileUpload } from "@common/business_components/ui/file-upload"; import { FileUpload } from "@common/business_components/ui/file-upload";
import { 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"> <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> <strong className="text-foreground">등록 안내</strong>
<span>관로별 사진은 최대 5장까지 등록할 수 있습니다.</span> <span>{DREDGING_EXTERNAL_MODE ? "등록 건별" : "관로별"} 사진은 최대 5장까지 등록할 수 있습니다.</span>
<span> <span>
모든 등록 건은 하나의 준설작업이며 동일한 준설일자를 사용합니다. 모든 등록 건은 하나의 준설작업이며 동일한 준설일자를 사용합니다.
</span> </span>
@@ -114,7 +117,7 @@ export const DredgingRegistrationBody = ({
<div className="card-header !m-0"> <div className="card-header !m-0">
<h3 className="text-2xl font-medium text-label">등록 정보</h3> <h3 className="text-2xl font-medium text-label">등록 정보</h3>
<Button type="button" size="sm" onClick={addGroup}> <Button type="button" size="sm" onClick={addGroup}>
관로 추가 {DREDGING_EXTERNAL_MODE ? "등록 건 추가" : "관로 추가"}
</Button> </Button>
</div> </div>
<div className="p-3"> <div className="p-3">
@@ -151,7 +154,7 @@ export const DredgingRegistrationBody = ({
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableHead>등록 관로</TableHead> <TableHead>{DREDGING_EXTERNAL_MODE ? "등록 대상" : "등록 관로"}</TableHead>
<TableCell className="border px-4"> <TableCell className="border px-4">
<div <div
className="flex flex-wrap items-center gap-2" className="flex flex-wrap items-center gap-2"
@@ -237,38 +240,12 @@ export const DredgingRegistrationBody = ({
촬영일과 GPS 정보를 확인합니다. 촬영일과 GPS 정보를 확인합니다.
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2"> <DredgingRegistrationModeSelector
<Button value={activeGroup.registrationMode}
type="button" labels={registrationModeLabels}
size="sm" onChange={(mode) => void changeRegistrationMode(activeGroup.id, mode)}
color={ testIdPrefix="dredging-register"
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>
</div> </div>
<div className="flex min-h-0 flex-1 flex-col p-3"> <div className="flex min-h-0 flex-1 flex-col p-3">
{activeGroup.registrationMode === "PHOTO" ? ( {activeGroup.registrationMode === "PHOTO" ? (
@@ -379,7 +356,7 @@ export const DredgingRegistrationBody = ({
<section className="card card-border !m-0 shrink-0 overflow-hidden !p-0"> <section className="card card-border !m-0 shrink-0 overflow-hidden !p-0">
<div className="card-header !m-0 flex-wrap gap-2"> <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"> <div className="flex shrink-0 flex-wrap justify-end gap-1">
{isEditingActiveLocation ? ( {isEditingActiveLocation ? (
<> <>
@@ -446,8 +423,9 @@ export const DredgingRegistrationBody = ({
</span> </span>
</TableCell> </TableCell>
</TableRow> </TableRow>
{!DREDGING_EXTERNAL_MODE && (
<TableRow> <TableRow>
<TableHead data-required>자동 연결 관로</TableHead> <TableHead>자동 연결 관로</TableHead>
<TableCell className="border px-4"> <TableCell className="border px-4">
<strong className="mr-3 text-foreground"> <strong className="mr-3 text-foreground">
{activeGroup.isMatching {activeGroup.isMatching
@@ -473,7 +451,7 @@ export const DredgingRegistrationBody = ({
"위치를 지정하면 50m 이내 최근접 관로를 자동으로 연결합니다."} "위치를 지정하면 50m 이내 최근접 관로를 자동으로 연결합니다."}
</span> </span>
</TableCell> </TableCell>
</TableRow> </TableRow>)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
@@ -484,7 +462,7 @@ export const DredgingRegistrationBody = ({
data-testid="dredging-registration-status" data-testid="dredging-registration-status"
> >
{statusMessage || {statusMessage ||
"사진·준설일자·위치와 자동 연결 관로를 확인해주세요."} (DREDGING_EXTERNAL_MODE ? "사진·준설일자·준설위치를 확인해주세요." : "사진·준설일자·위치를 확인해주세요. 관로 미지정 상태도 저장할 수 있습니다.")}
</div> </div>
</aside> </aside>
@@ -504,7 +482,7 @@ export const DredgingRegistrationBody = ({
statusText={ statusText={
isEditingActiveLocation isEditingActiveLocation
? hasUnappliedLocationDraft ? hasUnappliedLocationDraft
? "새 위치를 선택했습니다. 관로 다시 찾기를 눌러 적용해주세요." ? (DREDGING_EXTERNAL_MODE ? "새 위치를 선택했습니다. 선택 위치 적용을 눌러주세요." : "새 위치를 선택했습니다. 관로 다시 찾기를 눌러 적용해주세요.")
: "지도에서 새 준설위치를 클릭해주세요." : "지도에서 새 준설위치를 클릭해주세요."
: activeResolvedLocation : 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 ( export const downloadExmnPipeReport = async (
exmnBizId: string, exmnBizId: string,
pipeNumber: string, pipeNumber: string,
jobType: "PPL" | "MNHL" = "PPL",
): Promise<Blob> => ): Promise<Blob> =>
getBlobData( 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 ( export const getExmnPipeReportAvailability = async (
exmnBizId: string, exmnBizId: string,
pipeNumber: string, pipeNumber: string,
jobType: "PPL" | "MNHL" = "PPL",
): Promise<ExmnPipeReportAvailability> => ): Promise<ExmnPipeReportAvailability> =>
getData( 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>; ) as Promise<ExmnPipeReportAvailability>;
export const updateExmnAttachments = async ( export const updateExmnAttachments = async (
@@ -9,12 +9,12 @@ export const getExmnTemplatePrerequisiteMessage = (
detail: Pick<ExmnBusinessDetail, "districts">, detail: Pick<ExmnBusinessDetail, "districts">,
): string | null => { ): string | null => {
if (!detail.districts?.length) { if (!detail.districts?.length) {
return "조사관리에서 해당 사업 행을 선택하고 ‘수정’을 눌러 처리분구를 등록한 뒤 ‘조사양식 받기’를 실행해 주세요. 이후 결과등록을 다시 진행해 주세요."; return "조사사업에서 해당 사업 행을 선택하고 ‘수정’을 눌러 처리분구를 등록한 뒤 ‘조사양식 받기’를 실행해 주세요. 이후 결과등록을 다시 진행해 주세요.";
} }
if (detail.districts.some(district => if (detail.districts.some(district =>
!district.pplExmnSheetNm?.trim() || !district.mnhlExmnSheetNm?.trim(), !district.pplExmnSheetNm?.trim() || !district.mnhlExmnSheetNm?.trim(),
)) { )) {
return "조사관리에서 해당 사업 행 선택 → ‘수정’ → ‘조사양식 받기’를 먼저 실행한 후 결과등록을 다시 진행해 주세요."; return "조사사업에서 해당 사업 행 선택 → ‘수정’ → ‘조사양식 받기’를 먼저 실행한 후 결과등록을 다시 진행해 주세요.";
} }
return null; return null;
}; };
@@ -287,7 +287,7 @@ export function ExmnRegistrationPage() {
if (!popup) { if (!popup) {
setNotice({ setNotice({
type: "error", type: "error",
text: "사업등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.", text: "조사사업 등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.",
}); });
} }
}; };
@@ -385,7 +385,7 @@ export function ExmnRegistrationPage() {
if (refreshed) if (refreshed)
setNotice({ setNotice({
type: "success", type: "success",
text: "조사결과 최종 저장과 PPL/MNHL 집계를 완료했습니다.", text: "조사결과 저장을 완료했습니다.",
}); });
}); });
} }
@@ -660,7 +660,7 @@ export function ExmnRegistrationPage() {
<div className="mt-4 min-w-0"> <div className="mt-4 min-w-0">
<StandardDataTable <StandardDataTable
table={table} table={table}
title="조사 목록" title="조사사업 목록"
className={EXMN_TABLE_HEADER_CLASS} className={EXMN_TABLE_HEADER_CLASS}
scrollMode="horizontal" scrollMode="horizontal"
minTableWidth="110rem" minTableWidth="110rem"
@@ -673,7 +673,7 @@ export function ExmnRegistrationPage() {
disabled={isLoading} disabled={isLoading}
onClick={openBusinessRegister} onClick={openBusinessRegister}
> >
사업등록 조사사업등록
</Button> </Button>
<Button <Button
type="button" type="button"
@@ -71,7 +71,7 @@ const VALIDATION_CHECK_GUIDES: Partial<
}, },
MNHL_EXMN_PDF: { MNHL_EXMN_PDF: {
checkedItems: ["PDF 파일 형식", "조사결과 Excel 구조 및 저장 가능 여부"], checkedItems: ["PDF 파일 형식", "조사결과 Excel 구조 및 저장 가능 여부"],
pendingNote: "PDF 내부 맨홀번호 대조는 검토 기준 협의 후 적용 예정입니다.", pendingNote: "맨홀보고서의 맨홀번호에는 실제 GIS 맨홀번호를 입력해주세요. 보고서 열람 시 번호를 대조하며, 업로드 검토에는 PDF 내부 번호 대조가 포함되지 않습니다.",
}, },
}; };
@@ -179,6 +179,9 @@ export function ExmnResultRegisterPopupPage({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [notice, setNotice] = useState<Notice>(null); const [notice, setNotice] = useState<Notice>(null);
/** 맨홀 PDF 하나만 선택한 경우 기존 조사자료의 첨부 교체로 처리한다. */
const isManholeOnly = Boolean(files.MNHL_EXMN_PDF) && Object.keys(files).length === 1;
/** popup layout 상위 html/body에서 생기는 문서 스크롤바를 팝업이 열린 동안 잠근다. */ /** popup layout 상위 html/body에서 생기는 문서 스크롤바를 팝업이 열린 동안 잠근다. */
useEffect(() => { useEffect(() => {
const previousHtmlOverflow = document.documentElement.style.overflow; const previousHtmlOverflow = document.documentElement.style.overflow;
@@ -310,7 +313,9 @@ export function ExmnResultRegisterPopupPage({
const fileMap = Object.fromEntries( const fileMap = Object.fromEntries(
Object.entries(files).map(([type, selected]) => [type, selected?.file]), Object.entries(files).map(([type, selected]) => [type, selected?.file]),
) as Partial<Record<ExmnFileType, 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) { if (validationMessage) {
setFileValidationStatuses((current) => ({ setFileValidationStatuses((current) => ({
...current, ...current,
@@ -373,22 +378,22 @@ export function ExmnResultRegisterPopupPage({
/** 정상 검토된 조사결과 Excel을 저장하고 부모 화면에 완료 메시지를 보낸 뒤 팝업을 닫는다. */ /** 정상 검토된 조사결과 Excel을 저장하고 부모 화면에 완료 메시지를 보낸 뒤 팝업을 닫는다. */
const handleCommit = async () => { const handleCommit = async () => {
const excelValidation = validationResults.EXMN_EXCEL; const excelValidation = validationResults.EXMN_EXCEL;
if ( if (!canCommit) return;
!excelValidation?.valid ||
fileValidationStatuses.EXMN_EXCEL !== "success" ||
fileValidationStatuses.PPL_EXMN_PDF !== "success"
)
return;
setIsLoading(true); setIsLoading(true);
setNotice(null); setNotice(null);
try { try {
if (!(await ensureTemplateReady())) return; if (!isManholeOnly && !(await ensureTemplateReady())) return;
// 로딩 화면이 저장 확인창을 가리지 않도록 사전 확인과 업로드 상태를 나눈다. // 로딩 화면이 저장 확인창을 가리지 않도록 사전 확인과 업로드 상태를 나눈다.
setIsLoading(false); setIsLoading(false);
if (!(await confirm("조사결과를 저장하시겠습니까?"))) return; if (!(await confirm("조사결과를 저장하시겠습니까?"))) return;
setIsLoading(true); 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 uploadAndLinkAttachments();
await commitExmnImport( if (!isManholeOnly) await commitExmnImport(
exmnBizId, exmnBizId,
buildImportFormData(files, excelValidation), buildImportFormData(files, excelValidation),
); );
@@ -418,6 +423,11 @@ export function ExmnResultRegisterPopupPage({
(fileType) => fileValidationStatuses[fileType], (fileType) => fileValidationStatuses[fileType],
); );
const excelValidation = validationResults.EXMN_EXCEL; 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 ( return (
<StandardPopupLayout <StandardPopupLayout
@@ -429,10 +439,7 @@ export function ExmnResultRegisterPopupPage({
type="button" type="button"
color="primary" color="primary"
disabled={ disabled={
isLoading || isLoading || !canCommit
!excelValidation?.valid ||
fileValidationStatuses.EXMN_EXCEL !== "success" ||
fileValidationStatuses.PPL_EXMN_PDF !== "success"
} }
onClick={() => void handleCommit()} onClick={() => void handleCommit()}
> >
@@ -614,7 +621,9 @@ export function ExmnResultRegisterPopupPage({
const status = fileValidationStatuses[fileType]!; const status = fileValidationStatuses[fileType]!;
const result = validationResults[fileType]; const result = validationResults[fileType];
const messages = fileValidationMessages[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 = const countText =
fileType === "EXMN_EXCEL" fileType === "EXMN_EXCEL"
? `관로 ${result?.summary.pplCount ?? 0}건 · 맨홀 ${result?.summary.mnhlCount ?? 0}건` ? `관로 ${result?.summary.pplCount ?? 0}건 · 맨홀 ${result?.summary.mnhlCount ?? 0}건`
@@ -43,7 +43,7 @@ const formatValue = (value: unknown) => {
: String(value); : String(value);
}; };
/** 조사관리 표를 CS 목록 규격과 페이지 연속 오름차순 순번으로 연결한다. */ /** 조사사업 표를 CS 목록 규격과 페이지 연속 오름차순 순번으로 연결한다. */
export function ExmnStandardDataTable<T extends Record<string, unknown>>({ export function ExmnStandardDataTable<T extends Record<string, unknown>>({
rows, rows,
headlist, headlist,
@@ -52,21 +52,22 @@ export default function ExmnSurveyResultSection({
const alert = useAlertStore((state) => state.setMessage); const alert = useAlertStore((state) => state.setMessage);
const exmnBizId = readFeatureProperty(featureData, ["exmnBizId"]); const exmnBizId = readFeatureProperty(featureData, ["exmnBizId"]);
const jobType = readFeatureProperty(featureData, ["jobType"]); const jobType = readFeatureProperty(featureData, ["jobType"]);
const pipeNumber = readFeatureProperty(featureData, [ // 맨홀은 조사 관로번호가 아니라 지도 원천의 실제 시설관리번호로 보고서를 조회한다.
"gisManagementNo", const pipeNumber = readFeatureProperty(featureData, jobType === "MNHL"
"fclt_mng_no", ? ["fclt_mng_no", "fcltMngNo"]
"fcltMngNo", : ["gisManagementNo", "fclt_mng_no", "fcltMngNo"]);
]);
const reportAvailable = readBooleanFeatureProperty(featureData, [ const reportAvailable = readBooleanFeatureProperty(featureData, [
"reportAvailable", "reportAvailable",
]); ]);
/** 지도는 상류맨홀 위치를 유지하고 맨홀 보고서는 실제 GIS 맨홀번호로 연결한다. */
const reportLabel = jobType === "MNHL" ? "맨홀" : "관로";
const canOpenPipeReport = const canOpenPipeReport =
jobType === "PPL" && Boolean(exmnBizId && pipeNumber); (jobType === "PPL" || jobType === "MNHL") && Boolean(exmnBizId && pipeNumber);
const openPipeReport = async () => { const openPipeReport = async () => {
if (!canOpenPipeReport) return; if (!canOpenPipeReport) return;
if (!reportAvailable) { if (!reportAvailable) {
alert("등록된 관로 조사보고서가 없습니다."); alert(`등록된 ${reportLabel} 조사보고서가 없습니다.`);
return; return;
} }
@@ -74,11 +75,12 @@ export default function ExmnSurveyResultSection({
const availability = await getExmnPipeReportAvailability( const availability = await getExmnPipeReportAvailability(
exmnBizId, exmnBizId,
pipeNumber, pipeNumber,
jobType === "MNHL" ? "MNHL" : "PPL",
); );
if (!availability.available) { if (!availability.available) {
alert( alert(
availability.message?.trim() || availability.message?.trim() ||
`선택한 관로번호의 조사보고서가 등록된 PDF에 없습니다: ${pipeNumber}`, `선택한 GIS 번호의 조사보고서가 등록된 PDF에 없습니다: ${pipeNumber}`,
); );
return; return;
} }
@@ -86,6 +88,7 @@ export default function ExmnSurveyResultSection({
const reportWindow = openWindowPopup("exmnPipeReport", { const reportWindow = openWindowPopup("exmnPipeReport", {
exmnBizId, exmnBizId,
pipeNumber, pipeNumber,
jobType,
reportNo: readFeatureProperty(featureData, ["reportNo"]), reportNo: readFeatureProperty(featureData, ["reportNo"]),
}); });
if (!reportWindow) { if (!reportWindow) {
@@ -97,7 +100,7 @@ export default function ExmnSurveyResultSection({
alert( alert(
error instanceof Error && error.message error instanceof Error && error.message
? error.message ? error.message
: "관로 조사보고서 열람 가능 여부를 확인하지 못했습니다.", : `${reportLabel} 조사보고서 열람 가능 여부를 확인하지 못했습니다.`,
); );
} }
}; };
@@ -107,6 +110,7 @@ export default function ExmnSurveyResultSection({
["사업차수", readFeatureProperty(featureData, ["bizCycl"])], ["사업차수", readFeatureProperty(featureData, ["bizCycl"])],
["처리분구", readFeatureProperty(featureData, ["trtdstNm"])], ["처리분구", readFeatureProperty(featureData, ["trtdstNm"])],
["조사유형", jobType === "MNHL" ? "맨홀조사" : "관로조사"], ["조사유형", jobType === "MNHL" ? "맨홀조사" : "관로조사"],
[jobType === "MNHL" ? "GIS 맨홀번호" : "GIS 관로번호", pipeNumber],
["보고서번호", readFeatureProperty(featureData, ["reportNo"])], ["보고서번호", readFeatureProperty(featureData, ["reportNo"])],
["구조적결함 등급", readFeatureProperty(featureData, ["structuralGrade"])], ["구조적결함 등급", readFeatureProperty(featureData, ["structuralGrade"])],
["운영적결함 등급", readFeatureProperty(featureData, ["operationalGrade"])], ["운영적결함 등급", readFeatureProperty(featureData, ["operationalGrade"])],
@@ -116,7 +120,7 @@ export default function ExmnSurveyResultSection({
<section className="border-b border-border bg-background"> <section className="border-b border-border bg-background">
<div className="card-header flex items-center justify-between"> <div className="card-header flex items-center justify-between">
<h3 className="mx-3 text-2xl font-medium text-label">조사 결과</h3> <h3 className="mx-3 text-2xl font-medium text-label">조사 결과</h3>
{jobType === "PPL" && ( {(jobType === "PPL" || jobType === "MNHL") && (
<Button <Button
onClick={openPipeReport} onClick={openPipeReport}
disabled={!canOpenPipeReport} disabled={!canOpenPipeReport}
@@ -130,7 +130,7 @@ const toSavedTableRows = (
const getErrorMessage = (error: unknown, fallback: string) => const getErrorMessage = (error: unknown, fallback: string) =>
error instanceof Error && error.message.trim() ? error.message : fallback; error instanceof Error && error.message.trim() ? error.message : fallback;
/** 사업등록 후보로 사용할 처리분구 기준정보를 직접 관리하는 FE-0 화면이다. */ /** 조사사업 등록 후보로 사용할 처리분구 기준정보를 직접 관리하는 FE-0 화면이다. */
export function TrtdstManagementPage() { export function TrtdstManagementPage() {
const { ctpv, sgg } = useStore(); const { ctpv, sgg } = useStore();
const { confirm } = useConfirm(); const { confirm } = useConfirm();
@@ -766,7 +766,7 @@ const FeatureClickEventPopup = ({
<div className="min-w-0"> <div className="min-w-0">
{surveyMode && ( {surveyMode && (
<h3 className="break-words text-2xl font-medium text-primary-foreground"> <h3 className="break-words text-2xl font-medium text-primary-foreground">
조사관리 <br /> 조사사업 <br />
관로번호 :{" "} 관로번호 :{" "}
{featureData?.get?.("gisManagementNo") ?? {featureData?.get?.("gisManagementNo") ??
featureData?.values_?.gisManagementNo ?? featureData?.values_?.gisManagementNo ??
@@ -81,7 +81,7 @@ const MsSwagePpl = ({
const requestFcltMngNo = String( const requestFcltMngNo = String(
featureData?.fclt_mng_no ?? featureData?.fcltMngNo ?? "", featureData?.fclt_mng_no ?? featureData?.fcltMngNo ?? "",
).trim(); ).trim();
// 조사관리 전용 탭은 기본·입력정보와 조사결과만 표시하고 일반 시설 유지관리 이력은 숨긴다. // 조사사업 전용 탭은 기본·입력정보와 조사결과만 표시하고 일반 시설 유지관리 이력은 숨긴다.
const showOperationSections = !surveyResultContent; const showOperationSections = !surveyResultContent;
// 마운트 및 업데이트 시 API 호출 // 마운트 및 업데이트 시 API 호출
@@ -17,7 +17,7 @@ const HEADERS: Record<string, string> = {
ctpvNm: "시도", ctpvNm: "시도",
sggNm: "시군구", sggNm: "시군구",
analysisDateTime: "분석시각", analysisDateTime: "분석시각",
rainBeginningDateTime: "강우 시작시각", rainBeginningDateTime: "강우시작시각",
observedAccumulatedRainfall: "관측 누적강수량(mm)", observedAccumulatedRainfall: "관측 누적강수량(mm)",
predictionHour: "예측시간", predictionHour: "예측시간",
predictedAccumulatedRainfall: "예측 누적강수량(mm)", predictedAccumulatedRainfall: "예측 누적강수량(mm)",
@@ -7,7 +7,7 @@ import Overlay from "ol/Overlay.js";
import GisApp from "@common/business_components/ui/map/lib/gis.core"; 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 { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
import { getFloodRiskGradeClassName } from "./flood-risk-grade"; import { getFloodRiskGradeClassName } from "./flood-risk-grade";
@@ -251,11 +251,11 @@ export const useFloodPredictionMap = ({
removeFloodPredictionOverlay(); removeFloodPredictionOverlay();
try { 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)}`, `/sewerage-service/api/v1/fldng/predc/prst/gridMap?sggCd=${encodeURIComponent(sggCd)}&analysisDateTime=${encodeURIComponent(analysisDateTime)}`,
); );
if (!navigation?.isCurrent()) return; if (!navigation?.isCurrent()) return;
const payload = response?.data ?? response;
const row = const row =
payload && typeof payload === "object" && !Array.isArray(payload) payload && typeof payload === "object" && !Array.isArray(payload)
? (payload as FloodPredictionOverlayRow) ? (payload as FloodPredictionOverlayRow)
@@ -14,6 +14,7 @@ import LoadingOverlay from "@/components/ui/loading-bar";
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions"; import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; 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 PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import { useAlertStore } from "@/shared/lib"; import { useAlertStore } from "@/shared/lib";
import useStore from "@/entities/reference-data/model/reference-data-store"; import useStore from "@/entities/reference-data/model/reference-data-store";
@@ -135,6 +136,16 @@ const buildInitialFilters = (): Filters => ({
const apiDate = (value: string) => value.replace(/[^0-9]/g, "").slice(0, 12); 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) => const displayDate = (value: string) =>
/^\d{12}$/.test(value) /^\d{12}$/.test(value)
? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)} ${value.slice(8, 10)}:${value.slice(10, 12)}` ? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)} ${value.slice(8, 10)}:${value.slice(10, 12)}`
@@ -184,7 +195,7 @@ const validateFilters = (filters: Filters) => {
filters.rainBgngTo && filters.rainBgngTo &&
filters.rainBgngFrom > filters.rainBgngTo filters.rainBgngFrom > filters.rainBgngTo
) { ) {
return "강우 시작시각의 시작은 종료보다 늦을 수 없습니다."; return "강우시각의 시작은 종료보다 늦을 수 없습니다.";
} }
if ( if (
filters.analysisFrom && filters.analysisFrom &&
@@ -367,6 +378,11 @@ export default function FldngPredcPrstPopupContent() {
const extentData = await getData( const extentData = await getData(
`/sewerage-service/api/v1/fldng/floodDngrMap/group/extent?${params.toString()}`, `/sewerage-service/api/v1/fldng/floodDngrMap/group/extent?${params.toString()}`,
); );
// null·생략된 data는 정상적인 위치 부재이며 부모 지도 이동·강조를 요청하지 않는다.
if (extentData == null) {
alert("해당 도시침수지도가 없습니다.");
return;
}
const extent = [ const extent = [
extentData?.minX, extentData?.minX,
extentData?.minY, extentData?.minY,
@@ -374,7 +390,7 @@ export default function FldngPredcPrstPopupContent() {
extentData?.maxY, extentData?.maxY,
].map(Number); ].map(Number);
if (extent.some((value) => !Number.isFinite(value))) { if (extent.some((value) => !Number.isFinite(value))) {
throw new Error("도시침수지도 위치 정보가 없습니다."); throw new Error("도시침수지도 위치 응답의 좌표가 올바르지 않습니다.");
} }
window.opener.postMessage( window.opener.postMessage(
{ {
@@ -472,6 +488,11 @@ export default function FldngPredcPrstPopupContent() {
} }
}; };
/**
* 분석시각·강우시각 범위를 시간 단위로만 고르게 그린다.
* `step=3600`이라 값의 분이 00이면 브라우저가 분 칸을 고정해 바꿀 수 없고, 분이 들어온 경우는 저장 전에 00으로 내린다.
* 침수예측 자료의 분석·강우시작 시각은 정시 단위이므로 시간 단위 조건으로 빠지는 자료는 없다.
*/
const renderDateTimeRange = ( const renderDateTimeRange = (
label: string, label: string,
fromKey: "rainBgngFrom" | "analysisFrom", fromKey: "rainBgngFrom" | "analysisFrom",
@@ -484,27 +505,31 @@ export default function FldngPredcPrstPopupContent() {
<input <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" 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" type="datetime-local"
step={3600}
value={filters[fromKey]} value={filters[fromKey]}
aria-label={`${label} 시작`} aria-label={`${label} 시작`}
onChange={(event) => onChange={(event) => {
const nextValue = toHourInputValue(event.target.value);
setFilters((current) => ({ setFilters((current) => ({
...current, ...current,
[fromKey]: event.target.value, [fromKey]: nextValue,
})) }));
} }}
/> />
<span className="text-xl font-medium text-label">~</span> <span className="text-xl font-medium text-label">~</span>
<input <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" 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" type="datetime-local"
step={3600}
value={filters[toKey]} value={filters[toKey]}
aria-label={`${label} 종료`} aria-label={`${label} 종료`}
onChange={(event) => onChange={(event) => {
const nextValue = toHourInputValue(event.target.value);
setFilters((current) => ({ setFilters((current) => ({
...current, ...current,
[toKey]: event.target.value, [toKey]: nextValue,
})) }));
} }}
/> />
</label> </label>
); );
@@ -662,17 +687,22 @@ export default function FldngPredcPrstPopupContent() {
} }
/> />
<div className="space-y-1 px-1 text-xl text-muted-foreground"> {/* 판정 기준·행 선택 안내에 인수테스트 요구(자료 원천·분석 주기 표시) 문구를 글머리표로 더한다. */}
<p> <PopupFooterNote
items={[
<>
30년빈도 이상인 경우{" "} 30년빈도 이상인 경우{" "}
<span className="font-semibold text-[#dc2626]">위험</span>, 10년빈도 <span className="font-semibold text-[#dc2626]">위험</span>,
이상인 경우{" "} 10년빈도 이상인 경우{" "}
<span className="font-semibold text-[#f97316]">경계</span>, 2년빈도 <span className="font-semibold text-[#f97316]">경계</span>,
이상인 경우{" "} 2년빈도 이상인 경우{" "}
<span className="font-semibold text-[#ca8a04]">주의</span> <span className="font-semibold text-[#ca8a04]">주의</span>
</p> </>,
<p>행을 선택하면 해당 시군구·빈도의 도시침수지도로 이동합니다.</p> "행을 선택하면 해당 시군구·빈도의 도시침수지도로 이동합니다.",
</div> "분석 실행 시기 : 침수예측은 관측강우가 10mm이상으로 강우가 발생시 1시간 주기로 분석 자동실행",
"분석 방법 : 기상청 관측강우와 향후 6시간 예측강우를 누적하여 지속시간별 빈도별 확률강우량(출처 : map.wamis.go.kr)과 근접한 강우량을 찾아 해당되는 빈도의 도시침수지도(출처 : 한강홍수통제소)를 표출",
]}
/>
<PrbbtRnflDialog <PrbbtRnflDialog
open={probabilityRow !== null} open={probabilityRow !== null}
onOpenChange={handleProbabilityOpenChange} onOpenChange={handleProbabilityOpenChange}
@@ -39,7 +39,7 @@ export const windowPopupDefinitions = {
title: "준설결과 수정", title: "준설결과 수정",
width: 1280, width: 1280,
height: 900, height: 900,
requiredParams: ["dredgingJobSn", "pipeFid", "longitude", "latitude"], requiredParams: ["dredgingJobSn", "targetSn", "longitude", "latitude"],
}, },
exmnPipeReport: { exmnPipeReport: {
key: "exmnPipeReport", key: "exmnPipeReport",
@@ -23,14 +23,16 @@ export const buildDredgingPopupRecord = (
): DredgingRecord => ({ ): DredgingRecord => ({
id: getParam(searchParams.id), id: getParam(searchParams.id),
dredgingJobSn: getParam(searchParams.dredgingJobSn), 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), mngInstCd: getParam(searchParams.mngInstCd),
ctpv: getParam(searchParams.ctpv), ctpv: getParam(searchParams.ctpv),
sgg: getParam(searchParams.sgg), sgg: getParam(searchParams.sgg),
treatmentZone: getParam(searchParams.treatmentZone), treatmentZone: getParam(searchParams.treatmentZone),
fcltMngNo: getParam(searchParams.fcltMngNo), fcltMngNo: getParam(searchParams.fcltMngNo),
pipeDiameterMm: getNumber(searchParams.pipeDiameterMm), pipeDiameterMm: getNullableNumber(searchParams.pipeDiameterMm),
pipeLengthM: getNumber(searchParams.pipeLengthM), pipeLengthM: getNullableNumber(searchParams.pipeLengthM),
pipeMaterial: getParam(searchParams.pipeMaterial), pipeMaterial: getParam(searchParams.pipeMaterial),
installationYear: getNullableNumber(searchParams.installationYear), installationYear: getNullableNumber(searchParams.installationYear),
dredgingDate: getParam(searchParams.dredgingDate), dredgingDate: getParam(searchParams.dredgingDate),
@@ -44,6 +46,6 @@ export const buildDredgingPopupRecord = (
getParam(searchParams.registrationMode) === "MANUAL" ? "MANUAL" : "PHOTO", getParam(searchParams.registrationMode) === "MANUAL" ? "MANUAL" : "PHOTO",
registrationSource: registrationSource:
getParam(searchParams.registrationSource) === "MANUAL" ? "MANUAL" : "EXIF", getParam(searchParams.registrationSource) === "MANUAL" ? "MANUAL" : "EXIF",
matchDistanceMeters: getNumber(searchParams.matchDistanceMeters), matchDistanceMeters: getNullableNumber(searchParams.matchDistanceMeters),
isMock: getParam(searchParams.isMock) === "true", isMock: getParam(searchParams.isMock) === "true",
}); });
@@ -14,7 +14,7 @@ const getParam = (value: string | string[] | undefined) =>
/** 공통 Blob API 오류 문자열에서 백엔드의 사용자 안내 메시지만 추출한다. */ /** 공통 Blob API 오류 문자열에서 백엔드의 사용자 안내 메시지만 추출한다. */
const getReportErrorMessage = (error: unknown) => { const getReportErrorMessage = (error: unknown) => {
if (!(error instanceof Error)) { if (!(error instanceof Error)) {
return "관로 조사보고서를 불러오지 못했습니다."; return "조사보고서를 불러오지 못했습니다.";
} }
const backendMessage = error.message.match( const backendMessage = error.message.match(
@@ -35,6 +35,9 @@ export default function ExmnPipeReportPopupContent({
}: WindowPopupContentProps) { }: WindowPopupContentProps) {
const exmnBizId = getParam(searchParams.exmnBizId); const exmnBizId = getParam(searchParams.exmnBizId);
const pipeNumber = getParam(searchParams.pipeNumber); const pipeNumber = getParam(searchParams.pipeNumber);
/** 조사유형별 첨부 API와 표시 제목을 선택한다. */
const jobType = getParam(searchParams.jobType) === "MNHL" ? "MNHL" : "PPL";
const reportLabel = jobType === "MNHL" ? "맨홀" : "관로";
const reportNo = getParam(searchParams.reportNo); const reportNo = getParam(searchParams.reportNo);
const [reportUrl, setReportUrl] = useState(""); const [reportUrl, setReportUrl] = useState("");
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
@@ -46,7 +49,7 @@ export default function ExmnPipeReportPopupContent({
setReportUrl(""); setReportUrl("");
setErrorMessage(""); setErrorMessage("");
void downloadExmnPipeReport(exmnBizId, pipeNumber) void downloadExmnPipeReport(exmnBizId, pipeNumber, jobType)
.then((blob) => { .then((blob) => {
if (!active) return; if (!active) return;
@@ -59,7 +62,7 @@ export default function ExmnPipeReportPopupContent({
}) })
.catch((error) => { .catch((error) => {
if (!active) return; if (!active) return;
console.warn("관로 조사보고서 조회 실패:", error); console.warn("조사보고서 조회 실패:", error);
setErrorMessage(getReportErrorMessage(error)); setErrorMessage(getReportErrorMessage(error));
}); });
@@ -67,16 +70,16 @@ export default function ExmnPipeReportPopupContent({
active = false; active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl); if (objectUrl) URL.revokeObjectURL(objectUrl);
}; };
}, [exmnBizId, pipeNumber]); }, [exmnBizId, pipeNumber, jobType]);
return ( return (
<StandardPopupLayout <StandardPopupLayout
title="관로 조사보고서" title={`${reportLabel} 조사보고서`}
showDefaultCancelAction={false} showDefaultCancelAction={false}
actions={ actions={
<> <>
<span className="text-[1.05rem] text-label"> <span className="text-[1.05rem] text-label">
관로번호 {pipeNumber} {jobType === "MNHL" ? "GIS 번호" : "관로번호"} {pipeNumber}
{reportNo ? ` · 보고서번호 ${reportNo}` : ""} {reportNo ? ` · 보고서번호 ${reportNo}` : ""}
</span> </span>
<Button type="button" color="white" onClick={() => window.close()}> <Button type="button" color="white" onClick={() => window.close()}>
@@ -93,7 +96,7 @@ export default function ExmnPipeReportPopupContent({
</div> </div>
) : reportUrl ? ( ) : reportUrl ? (
<iframe <iframe
title={`${pipeNumber} 관로 조사보고서`} title={`${pipeNumber} ${reportLabel} 조사보고서`}
src={reportUrl} src={reportUrl}
className="h-full w-full border border-border bg-background" className="h-full w-full border border-border bg-background"
/> />
@@ -0,0 +1,35 @@
"use client";
import type { ReactNode } from "react";
import { cn } from "@common/components";
type PopupFooterNoteProps = {
/** 글머리표 한 줄씩 표시할 안내·자료 출처 문구. 강조가 필요한 줄은 JSX로 넘긴다. */
items: ReactNode[];
className?: string;
};
/**
* WindowPopup 목록 아래에 판정 기준·자료 출처·분석 주기 안내를 글머리표 목록으로 표시한다.
* 인수테스트 요구(2026-10-16 "팝업화면 자료의 원천 표시 및 자료 업데이트 주기 표시")에 따라
* 침수예측현황 하단 안내와 같은 위치·글꼴로 모든 대상 팝업이 이 컴포넌트를 쓴다.
*/
export default function PopupFooterNote({ items, className }: PopupFooterNoteProps) {
return (
<ul
data-popup-footer-note
className={cn(
"shrink-0 list-disc space-y-1 pl-[2.4rem] pr-1 text-xl text-muted-foreground",
className,
)}
>
{items.map((item, index) => (
// 문구 목록은 화면마다 고정 순서라 순번을 키로 쓴다.
<li key={index}>{item}</li>
))}
</ul>
);
}
export type { PopupFooterNoteProps };
+1 -1
View File
@@ -1,7 +1,7 @@
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "SN"; export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "SN";
export const PORT = process.env.NEXT_PUBLIC_PORT || "3000"; export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
export const PROXY_HOST = process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`; export const PROXY_HOST = process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
export const SERVER_API_URL = process.env.SERVER_API_URL||'http://dev.onasys.co.kr:8000'; export const SERVER_API_URL = process.env.SERVER_API_URL||'https://apigateway.onasys.co.kr';
export const AUTH_LOGIN_REDIRECT_URL = export const AUTH_LOGIN_REDIRECT_URL =
process.env.NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL || "/auth/login"; process.env.NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL || "/auth/login";
@@ -0,0 +1,90 @@
/** 서로 다른 구조의 시트를 하나의 브라우저 다운로드 파일로 생성한다. */
import type { WorkBook } from "xlsx";
export type ExcelCellValue = string | number | boolean | null;
export type ExcelSheetSpec = {
name: string;
rows: ExcelCellValue[][];
columnWidths?: number[];
autoFilterRange?: string;
mergeRanges?: string[];
};
export type ExcelWorkbookOptions = {
fileName: string;
sheets: ExcelSheetSpec[];
};
/** 금지 문자·길이·대소문자 중복을 처리해 유효한 시트명을 만든다. */
const uniqueSheetName = (name: string, used: Set<string>) => {
const base =
name
.replace(/[\\/?*[\]:\u0000-\u001f]/g, "_")
.replace(/^'+|'+$/g, "")
.trim()
.slice(0, 31) || "Sheet";
let result = base;
let index = 2;
while (used.has(result.toLowerCase())) {
const suffix = `_${index++}`;
result = `${base.slice(0, 31 - suffix.length)}${suffix}`;
}
used.add(result.toLowerCase());
return result;
};
/** 명시적인 셀 배열을 사용해 문자열을 수식으로 해석하지 않고 시트를 구성한다. */
export const buildExcelWorkbook = async (
sheets: ExcelSheetSpec[],
): Promise<WorkBook> => {
if (sheets.length === 0) throw new Error("다운로드할 시트가 없습니다.");
const XLSX = await import("xlsx");
const workbook = XLSX.utils.book_new();
const usedNames = new Set<string>();
for (const sheet of sheets) {
const worksheet = XLSX.utils.aoa_to_sheet(sheet.rows);
worksheet["!cols"] = sheet.columnWidths?.map((wch) => ({ wch }));
if (sheet.autoFilterRange)
worksheet["!autofilter"] = { ref: sheet.autoFilterRange };
if (sheet.mergeRanges)
worksheet["!merges"] = sheet.mergeRanges.map(XLSX.utils.decode_range);
// 긴 주소·원인과 명시적인 개행이 들어간 셀을 읽기 좋게 표시한다.
sheet.rows.forEach((row, r) =>
row.forEach((value, c) => {
const cell = worksheet[XLSX.utils.encode_cell({ r, c })];
if (cell && typeof value === "string")
cell.s = { alignment: { wrapText: true, vertical: "top" } };
}),
);
XLSX.utils.book_append_sheet(
workbook,
worksheet,
uniqueSheetName(sheet.name, usedNames),
);
}
return workbook;
};
/** 업무별 조회와 값 변환이 끝난 시트들을 순서대로 XLSX 파일에 저장한다. */
export const downloadExcelWorkbook = async ({
fileName,
sheets,
}: ExcelWorkbookOptions) => {
if (typeof window === "undefined")
throw new Error("엑셀 다운로드는 브라우저에서만 사용할 수 있습니다.");
const workbook = await buildExcelWorkbook(sheets);
const normalized =
fileName
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
.replace(/[. ]+$/g, "")
.trim() || "download";
const XLSX = await import("xlsx");
XLSX.writeFile(
workbook,
/\.xlsx$/i.test(normalized) ? normalized : `${normalized}.xlsx`,
{
bookType: "xlsx",
compression: true,
cellStyles: true,
},
);
};
@@ -84,6 +84,8 @@ function Gis({
defaultLonLat = [127.4875, 37.4916], defaultLonLat = [127.4875, 37.4916],
viewExtent, viewExtent,
viewMinZoom, viewMinZoom,
// 지정 범위 전체까지 축소를 허용한다. 기본값은 기존 타 앱의 범위 제한을 유지한다.
viewShowFullExtent = false,
showZoomLevel = false, showZoomLevel = false,
toolBarIconSize, toolBarIconSize,
zoomGuide, zoomGuide,
@@ -176,6 +178,7 @@ function Gis({
minZoom: viewMinZoom ?? gisCore.defaults.minZoom, minZoom: viewMinZoom ?? gisCore.defaults.minZoom,
maxZoom: gisCore.defaults.maxZoom, maxZoom: gisCore.defaults.maxZoom,
extent: viewExtent, extent: viewExtent,
showFullExtent: viewShowFullExtent,
}), }),
}); });
} else { } else {
@@ -21,11 +21,6 @@ import Tile from 'ol/layer/Tile.js';
import TileWMS from 'ol/source/TileWMS.js'; import TileWMS from 'ol/source/TileWMS.js';
import gisCore from './gis.core.js'; import gisCore from './gis.core.js';
import { showBusinessUserMessage } from '../../../lib/user-message'; import { showBusinessUserMessage } from '../../../lib/user-message';
import {
buildFloodDngrSldBody,
FLOOD_DNGR_MAP_LAYER_TYPE,
getFloodDngrSldSettings,
} from './gis.wms-sld.js';
/** /**
* GisApp.layer * GisApp.layer
@@ -195,19 +190,12 @@ GisOlLayer.prototype = {
params['FORMAT'] = 'image/png'; params['FORMAT'] = 'image/png';
params['TRANSPARENT'] = true; params['TRANSPARENT'] = true;
params['TILED'] = true; params['TILED'] = true;
// 빈 값이면 GeoServer 레이어의 기본 스타일(Default Style)로 그린다.
// 도시침수지도(tb_ms_flood_dngr_map)의 침수심도 색상도 GeoServer에 등록한 기본 스타일을 사용하며,
// 긴 SLD_BODY를 URL에 싣지 않아 운영 프록시의 헤더 크기 초과(502)를 피한다.
params['STYLES'] = ''; params['STYLES'] = '';
if (filter) params['CQL_FILTER'] = filter; if (filter) params['CQL_FILTER'] = filter;
if (lyrTyNm === FLOOD_DNGR_MAP_LAYER_TYPE) {
const sldSettings = getFloodDngrSldSettings(GisApp.layerCode, options?.id);
const sldBody = buildFloodDngrSldBody(lyrTyNm, sldSettings);
if (!sldBody) {
console.warn('[GisApp] 도시침수지도 WMS SLD 생성에 필요한 침수심도 설정 5건이 유효하지 않습니다.', options);
return null;
}
params['SLD_BODY'] = sldBody;
}
const wmsLayer = new Tile({ const wmsLayer = new Tile({
...layerOptions, ...layerOptions,
id: layerOptions?.id, id: layerOptions?.id,
@@ -1,103 +0,0 @@
const FLOOD_DNGR_MAP_LAYER_TYPE = 'tb_ms_flood_dngr_map';
const FLOOD_DEPTH_ATTRIBUTE = 'sbmrg_dpthl_cd';
const FLOOD_DEPTH_CODES = ['N330', 'N331', 'N332', 'N333', 'N334'];
const escapeXml = (value) => String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
const toHex = (value) => Math.round(value).toString(16).padStart(2, '0');
const parseCssColor = (value) => {
const normalized = String(value ?? '').trim();
const rgbaMatch = normalized.match(/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*(0(?:\.\d+)?|1(?:\.0+)?))?\s*\)$/i);
if (rgbaMatch) {
const red = Number(rgbaMatch[1]);
const green = Number(rgbaMatch[2]);
const blue = Number(rgbaMatch[3]);
const opacity = rgbaMatch[4] == null ? 1 : Number(rgbaMatch[4]);
if ([red, green, blue].some((channel) => channel < 0 || channel > 255) || opacity < 0 || opacity > 1) {
return null;
}
return {
hex: `#${toHex(red)}${toHex(green)}${toHex(blue)}`,
opacity,
};
}
const hexMatch = normalized.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (!hexMatch) return null;
const rawHex = hexMatch[1].length === 3
? hexMatch[1].split('').map((character) => character + character).join('')
: hexMatch[1];
return {
hex: `#${rawHex.toLowerCase()}`,
opacity: 1,
};
};
const getFloodDepthCode = (filterSql) => {
const match = String(filterSql ?? '').match(/\bsbmrg_dpthl_cd\s*=\s*'([^']+)'/i);
const depthCode = match?.[1]?.trim();
return FLOOD_DEPTH_CODES.includes(depthCode) ? depthCode : null;
};
const getLineWidth = (value) => {
const lineWidth = Number(value);
return Number.isFinite(lineWidth) && lineWidth >= 0 && lineWidth <= 20 ? lineWidth : null;
};
/**
* 빈도 부모 레이어 바로 아래에 있는 침수심도 설정만 선별한다.
* 부모는 WMS 요청을 담당하고, 반환된 자식 5건은 SLD 규칙의 필터와 색상 원본으로 사용한다.
*/
export const getFloodDngrSldSettings = (layerCode, parentLayerId) => Object.values(layerCode ?? {})
.filter((layerSetting) => (
String(layerSetting?.upperLyrSn ?? '') === String(parentLayerId ?? '')
&& layerSetting?.lyrTyNm === FLOOD_DNGR_MAP_LAYER_TYPE
))
.sort((left, right) => Number(left?.sortOrdr ?? 0) - Number(right?.sortOrdr ?? 0));
/**
* DB의 침수심도 필터와 RGBA 스타일을 GeoServer가 해석할 수 있는 SLD 1.0 XML로 변환한다.
* 다섯 침수심도 코드나 색상값 중 하나라도 유효하지 않으면 잘못된 기본 스타일 표출을 막기 위해 null을 반환한다.
*/
export const buildFloodDngrSldBody = (layerName, layerSettings) => {
if (!Array.isArray(layerSettings) || layerSettings.length !== FLOOD_DEPTH_CODES.length) return null;
const settingsByDepthCode = new Map();
for (const layerSetting of layerSettings) {
const depthCode = getFloodDepthCode(layerSetting?.flterSql);
if (depthCode && !settingsByDepthCode.has(depthCode)) {
settingsByDepthCode.set(depthCode, layerSetting);
}
}
if (settingsByDepthCode.size !== FLOOD_DEPTH_CODES.length) return null;
const rules = [];
for (const depthCode of FLOOD_DEPTH_CODES) {
const layerSetting = settingsByDepthCode.get(depthCode);
const fillColor = parseCssColor(layerSetting?.polgnColor);
const strokeColor = parseCssColor(layerSetting?.lnColor);
const lineWidth = getLineWidth(layerSetting?.lnWdth);
if (!fillColor || !strokeColor || lineWidth == null) return null;
rules.push(
`<sld:Rule><sld:Name>${escapeXml(depthCode)}</sld:Name><sld:Title>${escapeXml(layerSetting?.lyrNm || depthCode)}</sld:Title>`
+ `<ogc:Filter><ogc:PropertyIsEqualTo><ogc:PropertyName>${FLOOD_DEPTH_ATTRIBUTE}</ogc:PropertyName><ogc:Literal>${escapeXml(depthCode)}</ogc:Literal></ogc:PropertyIsEqualTo></ogc:Filter>`
+ `<sld:PolygonSymbolizer><sld:Fill><sld:CssParameter name="fill">${fillColor.hex}</sld:CssParameter><sld:CssParameter name="fill-opacity">${fillColor.opacity}</sld:CssParameter></sld:Fill>`
+ `<sld:Stroke><sld:CssParameter name="stroke">${strokeColor.hex}</sld:CssParameter><sld:CssParameter name="stroke-opacity">${strokeColor.opacity}</sld:CssParameter><sld:CssParameter name="stroke-width">${lineWidth}</sld:CssParameter></sld:Stroke></sld:PolygonSymbolizer></sld:Rule>`
);
}
return `<?xml version="1.0" encoding="UTF-8"?>`
+ `<sld:StyledLayerDescriptor version="1.0.0" xmlns="http://www.opengis.net/sld" xmlns:sld="http://www.opengis.net/sld" xmlns:ogc="http://www.opengis.net/ogc">`
+ `<sld:NamedLayer><sld:Name>${escapeXml(layerName)}</sld:Name><sld:UserStyle><sld:FeatureTypeStyle>${rules.join('')}</sld:FeatureTypeStyle></sld:UserStyle></sld:NamedLayer>`
+ `</sld:StyledLayerDescriptor>`;
};
export { FLOOD_DNGR_MAP_LAYER_TYPE };