Merge branch 'iipbs/sn/dev'
This commit is contained in:
+2
-1
@@ -1,6 +1,7 @@
|
||||
# turbo
|
||||
.turbo/
|
||||
.vscode/**/*
|
||||
**/.vscode/
|
||||
*.code-workspace
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
Vendored
-88
@@ -1,88 +0,0 @@
|
||||
{
|
||||
// IntelliSense를 사용하여 가능한 특성에 대해 알아보세요.
|
||||
// 기존 특성에 대한 설명을 보려면 가리킵니다.
|
||||
// 자세한 내용을 보려면 https://go.microsoft.com/fwlink/?linkid=830387을(를) 방문하세요.
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "dev:as 디버그",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev:as"
|
||||
],
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"sourceMaps": true,
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--inspect"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "dev:cs 디버그",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev:cs"
|
||||
],
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"sourceMaps": true,
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--inspect"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "attach",
|
||||
"name": "dev:cs 서버 연결 (attach)",
|
||||
"port": 9229,
|
||||
"restart": true,
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"sourceMaps": true,
|
||||
"resolveSourceMapLocations": [
|
||||
"${workspaceFolder}/apps/cs/**",
|
||||
"!**/node_modules/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"name": "dev:cs 클라이언트 디버그 (Chrome)",
|
||||
"url": "http://localhost:3000",
|
||||
"webRoot": "${workspaceFolder}/apps/cs",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "dev:st 디버그",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev:st"
|
||||
],
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"sourceMaps": true,
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--inspect"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
-5
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"pnpm": true
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -11,7 +11,7 @@ NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
|
||||
#NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr/openapi/Gettile.do?apiKey=04trYP9_xwLAfALjwZ-B8g&Service=WMTS&Request=GetTile&Version=1.0.0&Format=image/png&layer=korean_map&style=korean&tilematrixset=EPSG:5179&TileMatrix={z}&TileCol={x}&TileRow={y}
|
||||
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
|
||||
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
|
||||
SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
SERVER_API_URL = https://apigateway.onasys.co.kr
|
||||
SEWERAGE_API_URL = http://localhost:8883
|
||||
# SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
|
||||
DC_URL = https://apigateway.apps.rsonesoft.com
|
||||
|
||||
@@ -6,7 +6,7 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
|
||||
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
|
||||
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11302
|
||||
NEXT_PUBLIC_SUB_DOMAIN = http://con.mind-one.co.kr:13000
|
||||
SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
SERVER_API_URL = https://apigateway.onasys.co.kr
|
||||
NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver
|
||||
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
|
||||
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
|
||||
|
||||
@@ -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은 적용조건 전체를 사용하고 대량 원시 자료는 서버 생성으로 분리한다. 화면 페이징/페이징 없음 예외는 사유와 검증 범위를 메뉴 > 화면명별로 기록한다.
|
||||
|
||||
1. 모든 변경은 원본 요구사항정의서와 화면설계서, 2차 요구사항, 기능 명세 또는 승인된 작업 문서를 근거로 한다.
|
||||
|
||||
+3
-2
@@ -66,7 +66,8 @@ export const normalizeAgedPipeStatusByCtpvFilters = (
|
||||
filters: Partial<AgedPipeStatusByCtpvFilters>,
|
||||
): AgedPipeStatusByCtpvFilters => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
let startYear = Math.trunc(Number(filters.startYear ?? currentYear - 2));
|
||||
// 최초 진입·초기화는 당해연도 1년 조회로 시작하며 직접 선택한 범위는 유지한다.
|
||||
let startYear = Math.trunc(Number(filters.startYear ?? currentYear));
|
||||
let endYear = Math.trunc(Number(filters.endYear ?? currentYear));
|
||||
let elapsedYears = Math.trunc(
|
||||
Number(
|
||||
@@ -74,7 +75,7 @@ export const normalizeAgedPipeStatusByCtpvFilters = (
|
||||
),
|
||||
);
|
||||
|
||||
if (!Number.isFinite(startYear)) startYear = currentYear - 2;
|
||||
if (!Number.isFinite(startYear)) startYear = currentYear;
|
||||
if (!Number.isFinite(endYear)) endYear = currentYear;
|
||||
if (
|
||||
!Number.isFinite(elapsedYears) ||
|
||||
|
||||
@@ -10,9 +10,7 @@ import {
|
||||
ChartColumnBig,
|
||||
ChevronUp,
|
||||
PanelsTopLeft,
|
||||
PencilRuler,
|
||||
Search,
|
||||
Settings2,
|
||||
TriangleAlert,
|
||||
Waves,
|
||||
} from "lucide-react";
|
||||
@@ -28,8 +26,6 @@ import { getRawJson } from "@/shared/api/sewerage-api";
|
||||
type Props = {
|
||||
onOpenPopup: (popupKey: WindowPopupKey) => Window | null;
|
||||
onOpenInfoSearch: () => void;
|
||||
onToggleGisEdit?: () => void;
|
||||
isGisEditActive?: boolean;
|
||||
isInfoSearchActive?: boolean;
|
||||
initialActiveKey?: WindowPopupKey | null;
|
||||
};
|
||||
@@ -89,16 +85,6 @@ const ANALYSIS_POPUP_ITEMS: Array<{
|
||||
label: windowPopupDefinitions.agedPipeStatusByCtpvPopup.title,
|
||||
icon: ChartColumnBig,
|
||||
},
|
||||
{
|
||||
key: "gisEditPopup",
|
||||
label: windowPopupDefinitions.gisEditPopup.title,
|
||||
icon: PencilRuler,
|
||||
},
|
||||
{
|
||||
key: "gisManagementPopup",
|
||||
label: windowPopupDefinitions.gisManagementPopup.title,
|
||||
icon: Settings2,
|
||||
},
|
||||
{
|
||||
key: "flowRiskPopup",
|
||||
label: windowPopupDefinitions.flowRiskPopup.title,
|
||||
@@ -119,8 +105,6 @@ const ANALYSIS_POPUP_ITEMS: Array<{
|
||||
export default function AnalysisPopupMenu({
|
||||
onOpenPopup,
|
||||
onOpenInfoSearch,
|
||||
onToggleGisEdit,
|
||||
isGisEditActive = false,
|
||||
isInfoSearchActive = false,
|
||||
initialActiveKey = null,
|
||||
}: Props) {
|
||||
@@ -155,12 +139,6 @@ export default function AnalysisPopupMenu({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGisEditActive && activeKey === "gisEditPopup") {
|
||||
setActiveKey(null);
|
||||
}
|
||||
}, [activeKey, isGisEditActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInfoSearchActive && activeKey === "infoSearch") {
|
||||
setActiveKey(null);
|
||||
@@ -214,10 +192,7 @@ export default function AnalysisPopupMenu({
|
||||
<div className="flex flex-col gap-[3px] p-[6px]">
|
||||
{ANALYSIS_POPUP_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive =
|
||||
item.key === "gisEditPopup"
|
||||
? isGisEditActive
|
||||
: activeKey === item.key;
|
||||
const isActive = activeKey === item.key;
|
||||
const label =
|
||||
item.key === "fldngPredcPrst"
|
||||
? `침수예측현황(${seriousPredictionCount}건)`
|
||||
@@ -233,12 +208,6 @@ export default function AnalysisPopupMenu({
|
||||
: "border-transparent bg-background text-foreground hover:border-border hover:bg-accent hover:text-primary"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (item.key === "gisEditPopup" && onToggleGisEdit) {
|
||||
setActiveKey(item.key);
|
||||
onToggleGisEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveKey(item.key);
|
||||
|
||||
if (item.key === "infoSearch") {
|
||||
|
||||
@@ -127,7 +127,7 @@ const normalizeControlNode = (
|
||||
...node,
|
||||
lyrSn: nodeId,
|
||||
id: nodeId,
|
||||
// DB에 저장된 과거 명칭과 무관하게 조사관리 표준 화면 명칭으로 표시한다.
|
||||
// DB에 저장된 과거 명칭과 무관하게 조사사업 표준 화면 명칭으로 표시한다.
|
||||
lyrNm: fallbackName,
|
||||
upperLyrSn: upperId,
|
||||
upId: upperId,
|
||||
|
||||
@@ -87,7 +87,7 @@ export const useExmnResultMap = (options: Options) => {
|
||||
[gisApp, mapRef],
|
||||
);
|
||||
|
||||
/** 지역 검색값 없이 전체를 요청하고, 선택 진입 context는 위치 이동 범위 조회에만 사용한다. */
|
||||
/** 일반 진입은 전체, 조사관리 지도보기는 선택 사업·처리분구만 조회해 겹친 타 사업 선택을 방지한다. */
|
||||
const loadLayer = useCallback(
|
||||
async (job: ExmnMapJobType, filters: ExmnMapFilters) => {
|
||||
const map = mapRef.current;
|
||||
@@ -109,7 +109,9 @@ export const useExmnResultMap = (options: Options) => {
|
||||
const response = await getExmnMapLayer(
|
||||
job,
|
||||
filters,
|
||||
{},
|
||||
entry.exmnBizId && entry.trtdstId
|
||||
? { exmnBizId: entry.exmnBizId, trtdstId: entry.trtdstId }
|
||||
: {},
|
||||
controller.signal,
|
||||
);
|
||||
if (!active()) return;
|
||||
|
||||
+4
-4
@@ -5,7 +5,7 @@
|
||||
"use client";
|
||||
|
||||
import { SelectBox } from "@common/business_components/ui";
|
||||
import { FLDNG_HSTRY_MIN_YEAR, type FldngHstryYearRange } from "@/entities/flood-history/model/flood-history-types";
|
||||
import { FLDNG_HSTRY_MIN_YEAR, FLDNG_HSTRY_MAX_YEAR, type FldngHstryYearRange } from "@/entities/flood-history/model/flood-history-types";
|
||||
import { normalizeFldngHstryYearRange } from "@/entities/flood-history/lib/flood-history-utils";
|
||||
import LayerSettingCard from "../layer-setting/layer-setting-card";
|
||||
|
||||
@@ -26,10 +26,10 @@ export default function FldngHstryLayerSettingPanel({
|
||||
onClose,
|
||||
}: Props) {
|
||||
const normalizedRange = normalizeFldngHstryYearRange(yearRange);
|
||||
const currentYear = new Date().getFullYear();
|
||||
// 제공 자료의 최대 연도부터 최소 연도까지 선택 목록을 구성한다.
|
||||
const yearOptions = Array.from(
|
||||
{ length: currentYear - FLDNG_HSTRY_MIN_YEAR + 1 },
|
||||
(_, index) => String(currentYear - index),
|
||||
{ length: FLDNG_HSTRY_MAX_YEAR - FLDNG_HSTRY_MIN_YEAR + 1 },
|
||||
(_, index) => String(FLDNG_HSTRY_MAX_YEAR - index),
|
||||
);
|
||||
const startYearOptions = yearOptions
|
||||
.filter((year) => Number(year) <= Number(normalizedRange.endYear))
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useAlertStore } from "@/shared/lib";
|
||||
import useStore from "@/entities/reference-data/model/reference-data-store";
|
||||
import LoadingOverlay from "@/components/ui/loading-bar";
|
||||
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
|
||||
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
|
||||
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
||||
import { FLDNG_HSTRY_LAYER_TYPE, FLDNG_HSTRY_DEFAULT_FILTERS, FLDNG_HSTRY_LIST_HEADLIST, FLDNG_HSTRY_PAGE_SIZE, FLDNG_HSTRY_ROW_SELECT_MESSAGE, type FldngHstryApiItem, type FldngHstryFilters, type FldngHstryTableRow } from "@/entities/flood-history/model/flood-history-types";
|
||||
import { attachFldngHstryGeometry, buildFldngHstryListQueryString, buildFldngHstryYearOptions, normalizeFldngHstryFilters, toFldngHstryTableRows } from "@/entities/flood-history/lib/flood-history-utils";
|
||||
@@ -421,9 +422,13 @@ export default function FldngHstryPopupPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="rounded-sm bg-secondary px-4 py-3 text-[1.2rem] text-muted-foreground">
|
||||
행을 클릭하면 메인 지도에서 해당 침수흔적을 표시합니다.
|
||||
</div>
|
||||
{/* 기존 행 클릭 안내에 인수테스트 요구(자료 원천 표시) 출처 문구를 글머리표로 더한다. */}
|
||||
<PopupFooterNote
|
||||
items={[
|
||||
"행을 클릭하면 메인 지도에서 해당 침수흔적을 표시합니다.",
|
||||
"출처 : 행정안전부 재난보험과(2002년~2025년)",
|
||||
]}
|
||||
/>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
+14
-5
@@ -11,6 +11,7 @@ import { getData } from "@/shared/api/sewerage-api";
|
||||
import LoadingOverlay from "@/components/ui/loading-bar";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
|
||||
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
|
||||
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
|
||||
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
||||
import useStore from "@/entities/reference-data/model/reference-data-store";
|
||||
import {
|
||||
@@ -226,7 +227,12 @@ export default function FloodDngrMapPopupPage() {
|
||||
const response = await getFloodDngrMapJson(
|
||||
`/sewerage-service/api/v1/fldng/floodDngrMap/group/extent?${params.toString()}`,
|
||||
);
|
||||
const payload = response as FloodDngrMapExtent;
|
||||
const payload = response as FloodDngrMapExtent | null | undefined;
|
||||
// 성공 응답의 null·생략된 data는 위치 부재이다. 좌표 접근과 지도 메시지를 중단한다.
|
||||
if (payload == null) {
|
||||
setErrorMessage("위치 정보가 없습니다");
|
||||
return;
|
||||
}
|
||||
const extent = [
|
||||
payload?.minX,
|
||||
payload?.minY,
|
||||
@@ -237,7 +243,7 @@ export default function FloodDngrMapPopupPage() {
|
||||
extent.length !== 4 ||
|
||||
extent.some((value) => !Number.isFinite(value))
|
||||
) {
|
||||
throw new Error("해당 도시침수지도 피처의 위치 정보가 없습니다.");
|
||||
throw new Error("도시침수지도 위치 응답의 좌표가 올바르지 않습니다.");
|
||||
}
|
||||
window.opener?.postMessage(
|
||||
{
|
||||
@@ -253,9 +259,7 @@ export default function FloodDngrMapPopupPage() {
|
||||
} catch (error) {
|
||||
console.error("도시침수지도 위치 이동 중 오류:", error);
|
||||
const message =
|
||||
error instanceof Error && error.message.includes("404")
|
||||
? "해당 도시침수지도 데이터에 위치 정보가 없어 이동할 수 없습니다."
|
||||
: error instanceof Error
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "도시침수지도 위치 이동 중 오류가 발생했습니다.";
|
||||
setErrorMessage(message);
|
||||
@@ -395,6 +399,11 @@ export default function FloodDngrMapPopupPage() {
|
||||
onRowClick={(row) => void handleRowClick(row)}
|
||||
rowId={(row, index) => `${row.sggCd}-${row.sbmrgFrqExpln}-${index}`}
|
||||
/>
|
||||
|
||||
{/* 인수테스트 요구: 팝업 자료의 원천을 목록 아래에 표시한다. */}
|
||||
<PopupFooterNote
|
||||
items={["출처 : 한강홍수통제소, 행정구역별 도시침수지도(2025.09)"]}
|
||||
/>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ import type {
|
||||
|
||||
export const useGisEdit = (treeData: any[]) => {
|
||||
const editableLayers = useMemo(() => collectEditableLayers(treeData), [treeData]);
|
||||
// GIS 편집 모드 on/off는 우측 분석 메뉴의 GIS 편집 버튼과 직접 연결된다.
|
||||
// GIS 편집 모드 on/off는 지도 우측 GIS 편집 아이콘과 직접 연결된다.
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
// 현재 선택 레이어를 기준으로 허용 도구와 저장 payload의 layerId를 결정한다.
|
||||
const [selectedLayerId, setSelectedLayerId] = useState("tb_ms_swage_ppl");
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { PencilRuler, Settings2 } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
isGisEditActive: boolean;
|
||||
onToggleGisEdit: () => void;
|
||||
onOpenGisManagement: () => Window | null;
|
||||
};
|
||||
|
||||
/** 기존 지도 도구와 같은 크기로 GIS 버튼의 기본·활성·키보드 포커스를 표시한다. */
|
||||
const getButtonClass = (isActive: boolean) =>
|
||||
`flex h-[40px] w-[40px] items-center justify-center p-0 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary ${
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-background text-label hover:bg-primary hover:text-primary-foreground"
|
||||
}`;
|
||||
|
||||
/** 분석도구 메뉴와 독립적으로 GIS 편집 모드와 관리 팝업의 진입 버튼을 제공한다. */
|
||||
export default function GisToolButtons({
|
||||
isGisEditActive,
|
||||
onToggleGisEdit,
|
||||
onOpenGisManagement,
|
||||
}: Props) {
|
||||
// 관리 팝업이 닫히면 버튼의 활성 표시도 해제한다.
|
||||
const [isGisManagementActive, setIsGisManagementActive] = useState(false);
|
||||
// 렌더링과 무관한 감지 타이머를 보관해 재클릭·화면 이탈 시 중복 실행을 방지한다.
|
||||
const closeWatcherRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (closeWatcherRef.current !== null) {
|
||||
window.clearInterval(closeWatcherRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 기존 팝업 실행 함수를 호출하고 차단·닫힘 상태를 버튼에 반영한다. */
|
||||
const handleOpenGisManagement = () => {
|
||||
if (closeWatcherRef.current !== null) {
|
||||
window.clearInterval(closeWatcherRef.current);
|
||||
closeWatcherRef.current = null;
|
||||
}
|
||||
const popup = onOpenGisManagement();
|
||||
setIsGisManagementActive(Boolean(popup && !popup.closed));
|
||||
if (!popup || popup.closed) return;
|
||||
|
||||
closeWatcherRef.current = window.setInterval(() => {
|
||||
if (!popup.closed) return;
|
||||
if (closeWatcherRef.current !== null) {
|
||||
window.clearInterval(closeWatcherRef.current);
|
||||
closeWatcherRef.current = null;
|
||||
}
|
||||
setIsGisManagementActive(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="GIS 도구"
|
||||
className="absolute right-[16px] top-[222.67px] z-[45] flex w-[40px] flex-col overflow-hidden rounded-[3px] bg-background shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title="GIS 편집"
|
||||
aria-label="GIS 편집"
|
||||
aria-pressed={isGisEditActive}
|
||||
onClick={onToggleGisEdit}
|
||||
className={`${getButtonClass(isGisEditActive)} border-b border-[#eeeeee]`}
|
||||
>
|
||||
<PencilRuler className="h-[20px] w-[20px]" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="GIS 관리"
|
||||
aria-label="GIS 관리"
|
||||
aria-haspopup="dialog"
|
||||
className={getButtonClass(isGisManagementActive)}
|
||||
onClick={handleOpenGisManagement}
|
||||
>
|
||||
<Settings2 className="h-[20px] w-[20px]" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { useUserStore } from "@/shared/lib/use-user-store";
|
||||
import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup";
|
||||
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
|
||||
import PipeAnalysisSummaryTable from "./pipe-analysis-summary-table";
|
||||
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
|
||||
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
||||
import {
|
||||
PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS,
|
||||
@@ -522,6 +523,13 @@ export default function PipeAnalysisPopupPage() {
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 인수테스트 요구: 관로분석의 기반 자료(원천)를 목록 아래에 표시한다. */}
|
||||
<PopupFooterNote
|
||||
items={[
|
||||
"국토교통부로부터 제공받은 GIS 관로정보(2025년)를 기반으로 분석하고 있음",
|
||||
]}
|
||||
/>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { StandardPopupLayout } from "@common/business_components";
|
||||
import { getRawJson } from "@/shared/api/sewerage-api";
|
||||
import LoadingOverlay from "@/components/ui/loading-bar";
|
||||
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
|
||||
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
|
||||
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
@@ -309,6 +310,11 @@ export default function SbmrgDngrMapPopupPage() {
|
||||
].join(":")
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 인수테스트 요구: 팝업 자료의 원천을 목록 아래에 표시한다. */}
|
||||
<PopupFooterNote
|
||||
items={["출처 : 하수도정비 중점관리지역 20개소 과업 산출물"]}
|
||||
/>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/** 지반침하 상세의 표시값과 다운로드 시점 관로 조회 결과를 두 시트로 저장한다. */
|
||||
import { getRawJson } from "@/shared/api/sewerage-api";
|
||||
import {
|
||||
downloadExcelWorkbook,
|
||||
type ExcelCellValue,
|
||||
type ExcelSheetSpec,
|
||||
} from "@/shared/util/download-excel-workbook";
|
||||
import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkhole-types";
|
||||
|
||||
type DownloadOptions = {
|
||||
detail: SinkholeDetail;
|
||||
radiusMeters: number;
|
||||
summaryEntries: Array<[string, string]>;
|
||||
getPipeUseName: (pipe: SinkholeNearbyPipe) => string;
|
||||
};
|
||||
|
||||
/** 식별자는 문자열을 유지하고 결측 숫자는 0으로 바꾸지 않는다. */
|
||||
const numberCell = (value: unknown): ExcelCellValue => {
|
||||
if (value == null || String(value).trim() === "") return "-";
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : String(value);
|
||||
};
|
||||
|
||||
/** 화면과 같은 순서의 발생정보 및 반경 내 관로 목록 시트 명세를 만든다. */
|
||||
export const buildSinkholeDetailSheets = (
|
||||
options: DownloadOptions,
|
||||
pipes: SinkholeNearbyPipe[],
|
||||
noCoordinates: boolean,
|
||||
): ExcelSheetSpec[] => {
|
||||
const pipeRows: ExcelCellValue[][] = pipes.map((pipe, index) => [
|
||||
index + 1,
|
||||
String(pipe.pipeId ?? "").trim() || "-",
|
||||
options.getPipeUseName(pipe),
|
||||
numberCell(pipe.elapsedYears),
|
||||
numberCell(pipe.distanceMeters),
|
||||
]);
|
||||
if (pipeRows.length === 0)
|
||||
pipeRows.push([
|
||||
noCoordinates
|
||||
? "좌표가 없어 반경 내 하수관로를 조회할 수 없습니다."
|
||||
: "반경 내 하수관로 정보가 없습니다.",
|
||||
]);
|
||||
return [
|
||||
{
|
||||
name: "지반침하정보",
|
||||
rows: [["항목", "내용"], ...options.summaryEntries],
|
||||
columnWidths: [22, 75],
|
||||
},
|
||||
{
|
||||
name: "하수관로목록",
|
||||
rows: [
|
||||
["사고번호", String(options.detail.acdntNo)],
|
||||
["조회 반경(m)", options.radiusMeters],
|
||||
["조회 기준", "기존 반경 조회 기준, 최대 200건 (가까운 거리순)"],
|
||||
[],
|
||||
["NO", "관로 ID", "용도", "경과년도(년)", "거리(m)"],
|
||||
...pipeRows,
|
||||
],
|
||||
columnWidths: [12, 36, 24, 20, 18],
|
||||
autoFilterRange: `A5:E${5 + pipes.length}`,
|
||||
mergeRanges: ["B3:E3", ...(pipes.length === 0 ? ["A6:E6"] : [])],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
/** 기존 반경 API를 다시 조회하여 실패를 빈 목록으로 내보내지 않는다. */
|
||||
export const downloadSinkholeDetailExcel = async (options: DownloadOptions) => {
|
||||
// 비동기 재조회 동안 호출자의 상세·반경·표시 행이 변경되어도 클릭 시점을 유지한다.
|
||||
const snapshot: DownloadOptions = {
|
||||
...options,
|
||||
detail: { ...options.detail },
|
||||
summaryEntries: options.summaryEntries.map(([title, value]) => [
|
||||
title,
|
||||
value,
|
||||
]),
|
||||
};
|
||||
const { detail, radiusMeters } = snapshot;
|
||||
const noCoordinates = String(detail.coordStatus ?? "").trim() !== "SUCCESS";
|
||||
let pipes: SinkholeNearbyPipe[] = [];
|
||||
if (!noCoordinates) {
|
||||
const hasPoint =
|
||||
detail.x != null &&
|
||||
detail.y != null &&
|
||||
String(detail.x).trim() !== "" &&
|
||||
String(detail.y).trim() !== "" &&
|
||||
Number.isFinite(Number(detail.x)) &&
|
||||
Number.isFinite(Number(detail.y));
|
||||
const params = new URLSearchParams({ radiusMeters: String(radiusMeters) });
|
||||
if (hasPoint) {
|
||||
params.set("x", String(detail.x));
|
||||
params.set("y", String(detail.y));
|
||||
}
|
||||
const path = hasPoint
|
||||
? "nearbyPipesByPoint"
|
||||
: `${encodeURIComponent(detail.acdntNo)}/nearbyPipes`;
|
||||
const raw = await getRawJson(
|
||||
`/sewerage-service/api/v1/sinkholes/${path}?${params}`,
|
||||
);
|
||||
const response = raw?.data ?? raw;
|
||||
if (response?.coordStatus !== "SUCCESS")
|
||||
throw new Error(
|
||||
"관로 조회 좌표를 확인할 수 없습니다. 상세정보를 다시 조회해 주세요.",
|
||||
);
|
||||
if (
|
||||
!Array.isArray(response?.items) ||
|
||||
response.items.some(
|
||||
(item: unknown) =>
|
||||
!item ||
|
||||
typeof item !== "object" ||
|
||||
Array.isArray(item) ||
|
||||
!("pipeId" in item) ||
|
||||
!("fid" in item),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
"관로 조회 응답이 올바르지 않습니다. 다시 시도해 주세요.",
|
||||
);
|
||||
pipes = response.items;
|
||||
}
|
||||
const now = new Date();
|
||||
const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||
await downloadExcelWorkbook({
|
||||
fileName: `지반침하정보_${detail.acdntNo}_${date}.xlsx`,
|
||||
sheets: buildSinkholeDetailSheets(snapshot, pipes, noCoordinates),
|
||||
});
|
||||
};
|
||||
@@ -4,12 +4,13 @@
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@common/components";
|
||||
import { VerticalTable } from "@/components/ui/vertical-table";
|
||||
import { getCmsData } from "@/shared/api/external-service-api";
|
||||
import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkhole-types";
|
||||
import { useSinkholeCommonCodes } from "./use-sinkhole-common-codes";
|
||||
import { downloadSinkholeDetailExcel } from "./sinkhole-detail-excel";
|
||||
|
||||
type Props = {
|
||||
detail: SinkholeDetail;
|
||||
@@ -81,6 +82,9 @@ export default function SinkholeDetailPanel({
|
||||
}: Props) {
|
||||
// 상세의 원천 복구상태 값은 유지하고 CMS에 대응하는 코드명만 표시한다.
|
||||
const { restoreStatusLabels } = useSinkholeCommonCodes();
|
||||
// 파일 생성 중 버튼 표시와 같은 이벤트 루프의 중복 클릭을 함께 제어한다.
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const downloadInProgressRef = useRef(false);
|
||||
const [pipeUseCodeMap, setPipeUseCodeMap] = useState<Map<string, string>>(
|
||||
new Map(),
|
||||
);
|
||||
@@ -127,8 +131,7 @@ export default function SinkholeDetailPanel({
|
||||
return { average, oldest };
|
||||
}, [nearbyPipes]);
|
||||
|
||||
const summaryTableData = useMemo(
|
||||
() => {
|
||||
const summaryTableData = useMemo(() => {
|
||||
const restoreStatus = String(detail.restoreStatus ?? "").trim();
|
||||
return [
|
||||
{
|
||||
@@ -145,9 +148,7 @@ export default function SinkholeDetailPanel({
|
||||
restoreCompletedYmd: display(detail.restoreCompletedYmd),
|
||||
},
|
||||
];
|
||||
},
|
||||
[detail, restoreStatusLabels],
|
||||
);
|
||||
}, [detail, restoreStatusLabels]);
|
||||
|
||||
const summaryHeadList = [
|
||||
{ id: "acdntNo", title: "사고번호" },
|
||||
@@ -161,6 +162,35 @@ export default function SinkholeDetailPanel({
|
||||
{ id: "restoreCompletedYmd", title: "복구완료일자" },
|
||||
];
|
||||
|
||||
/** 클릭 시의 상세·반경·표시값을 고정해 탭 전환 중에도 한 사고만 내보낸다. */
|
||||
const handleDownload = async () => {
|
||||
if (downloadInProgressRef.current) return;
|
||||
downloadInProgressRef.current = true;
|
||||
setIsDownloading(true);
|
||||
const summary = summaryTableData[0] as Record<string, string>;
|
||||
try {
|
||||
await downloadSinkholeDetailExcel({
|
||||
detail: { ...detail },
|
||||
radiusMeters,
|
||||
summaryEntries: summaryHeadList.map(({ id, title }) => [
|
||||
title,
|
||||
summary[id] ?? "-",
|
||||
]),
|
||||
getPipeUseName,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("지반침하 상세 엑셀 다운로드 실패:", error);
|
||||
alert(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "엑셀 다운로드 중 오류가 발생했습니다.",
|
||||
);
|
||||
} finally {
|
||||
downloadInProgressRef.current = false;
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id="popup"
|
||||
@@ -198,9 +228,20 @@ export default function SinkholeDetailPanel({
|
||||
|
||||
<section className="pb-[1.2rem]">
|
||||
<div className="card-header !block px-[1.2rem]">
|
||||
<h3 className="mx-3 whitespace-nowrap text-2xl font-medium text-label">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="ml-3 min-w-0 flex-1 text-2xl font-medium text-label">
|
||||
침하발생 반경내 관로정보
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
className="hover:bg-primary/90 mr-3 shrink-0 bg-primary text-primary-foreground"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
aria-busy={isDownloading}
|
||||
>
|
||||
{isDownloading ? "다운로드 중..." : "엑셀다운로드"}
|
||||
</Button>
|
||||
</div>
|
||||
{canShowNearby ? (
|
||||
<div className="mx-3 mt-2 whitespace-nowrap text-left text-[0.95rem] leading-5 text-muted-foreground">
|
||||
<span>반경 {radiusMeters}m · </span>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
import useStore from "@/entities/reference-data/model/reference-data-store";
|
||||
import LoadingOverlay from "@/components/ui/loading-bar";
|
||||
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
|
||||
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
|
||||
import PopupSearchPanel, {
|
||||
PopupSearchActions,
|
||||
PopupSearchField,
|
||||
@@ -514,6 +515,13 @@ export default function SinkholePopupPage() {
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 인수테스트 요구: 지반침하 정보의 연계 원천과 연계 주기를 목록 아래에 표시한다. */}
|
||||
<PopupFooterNote
|
||||
items={[
|
||||
"출처 : 지하안전정보시스템 지반침하사고 정보 연계 표출(연계 주기 : 매 시간 연계)",
|
||||
]}
|
||||
/>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,19 @@ import type {
|
||||
export type WaterRiskResultQuery = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
/** 현재 탭 분석기준시간(정시) 정확 일치 조건. 분석시각 기간과 함께 보내지 않는다. */
|
||||
analysisBaseAt?: string;
|
||||
/** 과거이력 분석시각 기간 시작(yyyy-MM-ddTHH:00:00, 포함). 종료와 함께 보낸다. */
|
||||
analysisBaseFrom?: string;
|
||||
/** 과거이력 분석시각 기간 종료(yyyy-MM-ddTHH:00:00, 포함). 시작과 함께 보낸다. */
|
||||
analysisBaseTo?: string;
|
||||
/**
|
||||
* 과거이력 강우시각(강우 시작 일시) 기간 시작(yyyy-MM-ddTHH:00:00, 포함). 종료와 함께 보낸다.
|
||||
* 보내면 백엔드가 startDate·endDate보다 이 기간을 우선한다.
|
||||
*/
|
||||
rainStartedFrom?: string;
|
||||
/** 과거이력 강우시각(강우 시작 일시) 기간 종료(yyyy-MM-ddTHH:00:00, 포함). 시작과 함께 보낸다. */
|
||||
rainStartedTo?: string;
|
||||
ctpvCd?: string;
|
||||
sggCd?: string;
|
||||
areaId?: string;
|
||||
@@ -54,6 +66,14 @@ export const fetchWaterRiskResults = async (
|
||||
});
|
||||
if (query.ctpvCd) params.set("ctpvCd", query.ctpvCd);
|
||||
if (query.analysisBaseAt) params.set("analysisBaseAt", query.analysisBaseAt);
|
||||
if (query.analysisBaseFrom && query.analysisBaseTo) {
|
||||
params.set("analysisBaseFrom", query.analysisBaseFrom);
|
||||
params.set("analysisBaseTo", query.analysisBaseTo);
|
||||
}
|
||||
if (query.rainStartedFrom && query.rainStartedTo) {
|
||||
params.set("rainStartedFrom", query.rainStartedFrom);
|
||||
params.set("rainStartedTo", query.rainStartedTo);
|
||||
}
|
||||
if (query.sggCd) params.set("sggCd", query.sggCd);
|
||||
if (query.areaId) params.set("areaId", query.areaId);
|
||||
if (query.riskStatus) params.set("riskStatus", query.riskStatus);
|
||||
@@ -78,15 +98,12 @@ export const fetchWaterRiskResults = async (
|
||||
export const fetchWaterRiskMapView = async (
|
||||
row: WaterRiskResultRow,
|
||||
): Promise<WaterRiskMapView> => {
|
||||
if (!row.analysisDate || !row.ctpvCd || !row.sggCd) {
|
||||
throw new Error("지도 조회에 필요한 분석일·행정구역 정보가 없습니다.");
|
||||
// 날짜·지역이 아니라 누른 행의 SWMM 실행 ID로 조회해야 같은 날 같은 지역 결과가 여러 건이어도 같은 결과를 그린다.
|
||||
if (!row.executionId) {
|
||||
throw new Error("지도 조회에 필요한 SWMM 실행 ID가 없습니다.");
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
ctpvCd: row.ctpvCd,
|
||||
sggCd: row.sggCd,
|
||||
});
|
||||
const response = await getData(
|
||||
`v1/waterRisk/results/${encodeURIComponent(row.analysisDate)}/areas/${encodeURIComponent(row.areaId)}/mapView?${params.toString()}`,
|
||||
`v1/waterRisk/executions/${encodeURIComponent(row.executionId)}/mapView`,
|
||||
);
|
||||
return {
|
||||
...response,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** 백엔드의 양 끝 날짜 포함 최대 366일 계약을 캘린더와 조회 검증에서 함께 사용한다. */
|
||||
/** 백엔드의 양 끝 날짜 포함 최대 366일 계약을 기본값과 조회 검증에서 함께 사용한다. */
|
||||
export const WATER_RISK_MAX_SEARCH_DAYS = 366;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -24,31 +24,82 @@ export const getWaterRiskMinStartDate = (
|
||||
.slice(0, 10);
|
||||
};
|
||||
|
||||
/** 달력 제한을 우회한 직접 입력도 API 요청 전에 검증한다. */
|
||||
export const validateWaterRiskDateRange = (
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
today: string,
|
||||
/**
|
||||
* 시간 단위 검색 입력(`datetime-local`, `step=3600`)의 값 형식 `yyyy-MM-ddTHH:00`이다.
|
||||
* 분은 항상 00이며, 첫 번째 그룹은 날짜(yyyy-MM-dd)다.
|
||||
*/
|
||||
const HOUR_VALUE_PATTERN = /^(\d{4}-\d{2}-\d{2})T\d{2}:00$/;
|
||||
|
||||
/**
|
||||
* `datetime-local` 입력값의 분을 00으로 맞춘다.
|
||||
* 브라우저는 값의 분이 00이면 분 칸을 고정하지만, 빈 값에서 처음 입력하거나 직접 입력해 분이 들어온 경우는
|
||||
* 스스로 고치지 않으므로 저장 전에 시간 단위로 내린다. 형식이 아니면(빈 값 포함) 그대로 둔다.
|
||||
*/
|
||||
export const toWaterRiskHourValue = (value: string) =>
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)
|
||||
? `${value.slice(0, 13)}:00`
|
||||
: value;
|
||||
|
||||
/** 로컬 시각을 시간 단위 입력값(yyyy-MM-ddTHH:00)으로 내린다. 기본 종료값과 미래 시각 제한에 쓴다. */
|
||||
export const formatWaterRiskHourValue = (date: Date) => {
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:00`;
|
||||
};
|
||||
|
||||
/** 시간 단위 입력값을 API 정시 일시(yyyy-MM-ddTHH:00:00)로 바꾼다. 빈 값은 빈 문자열이다. */
|
||||
export const toWaterRiskApiDateTime = (value: string) =>
|
||||
value ? `${value}:00` : "";
|
||||
|
||||
/**
|
||||
* 시간 단위 기간을 백엔드 계약(시작·종료 함께, 정시, 시작 ≤ 종료, 날짜 기준 최대 366일)과
|
||||
* 화면 조건(종료 ≤ 현재 정시)으로 검증한다. 오류가 없으면 빈 문자열을 반환한다.
|
||||
* `required`가 아니면 시작·종료를 모두 비웠을 때 조건 없이 조회한다.
|
||||
*/
|
||||
const validateHourPeriod = (
|
||||
from: string,
|
||||
to: string,
|
||||
currentHourValue: string,
|
||||
{ label, required }: { label: string; required: boolean },
|
||||
): string => {
|
||||
if (!startDate || !endDate) return "조회 시작일과 종료일을 입력해야 합니다.";
|
||||
const start = parseDate(startDate);
|
||||
const end = parseDate(endDate);
|
||||
if (start === null || end === null)
|
||||
return "올바른 조회 날짜를 입력해 주세요.";
|
||||
if (endDate > today) return "조회 종료일은 오늘보다 늦을 수 없습니다.";
|
||||
if (start > end) return "조회 시작일은 종료일보다 늦을 수 없습니다.";
|
||||
if (!from && !to) {
|
||||
return required ? `${label} 시작과 종료를 입력해야 합니다.` : "";
|
||||
}
|
||||
if (!from || !to) {
|
||||
return required
|
||||
? `${label} 시작과 종료를 입력해야 합니다.`
|
||||
: `${label}은 시작과 종료를 모두 입력하거나 모두 비워야 합니다.`;
|
||||
}
|
||||
const fromMatch = HOUR_VALUE_PATTERN.exec(from);
|
||||
const toMatch = HOUR_VALUE_PATTERN.exec(to);
|
||||
const start = fromMatch ? parseDate(fromMatch[1]!) : null;
|
||||
const end = toMatch ? parseDate(toMatch[1]!) : null;
|
||||
if (start === null || end === null) return `올바른 ${label}을 입력해 주세요.`;
|
||||
if (to > currentHourValue) return `${label} 종료는 현재 시각보다 늦을 수 없습니다.`;
|
||||
if (from > to) return `${label} 시작은 종료보다 늦을 수 없습니다.`;
|
||||
if ((end - start) / DAY_MS + 1 > WATER_RISK_MAX_SEARCH_DAYS) {
|
||||
return "강우 발생기간은 종료일 기준 최대 1년(366일)까지 조회할 수 있습니다.";
|
||||
return `${label}은 최대 1년(366일)까지 조회할 수 있습니다.`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
/** 종료일 변경으로 기존 시작일이 허용 범위를 벗어난 경우 최근 1년 시작일로 맞춘다. */
|
||||
export const adjustWaterRiskStartDate = (
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): string => {
|
||||
const min = getWaterRiskMinStartDate(endDate);
|
||||
if (!min) return startDate;
|
||||
return !startDate || startDate < min || startDate > endDate ? min : startDate;
|
||||
};
|
||||
/** 과거이력 강우시각(강우 시작 일시) 기간을 검증한다. 필수 조건이다. */
|
||||
export const validateWaterRiskRainStartedPeriod = (
|
||||
from: string,
|
||||
to: string,
|
||||
currentHourValue: string,
|
||||
) =>
|
||||
validateHourPeriod(from, to, currentHourValue, {
|
||||
label: "강우시각",
|
||||
required: true,
|
||||
});
|
||||
|
||||
/** 과거이력 분석시각 기간을 검증한다. 선택 조건이며 모두 비우면 적용하지 않는다. */
|
||||
export const validateWaterRiskAnalysisPeriod = (
|
||||
from: string,
|
||||
to: string,
|
||||
currentHourValue: string,
|
||||
) =>
|
||||
validateHourPeriod(from, to, currentHourValue, {
|
||||
label: "분석시각",
|
||||
required: false,
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type WindowPopupContentProps,
|
||||
} from "@/features/window-popup/model/types";
|
||||
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
|
||||
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
|
||||
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
||||
import {
|
||||
StandardPopupLayout,
|
||||
@@ -21,9 +22,12 @@ import {
|
||||
import type { UiTableSort } from "@common/business_components/ui/table";
|
||||
import { Button } from "@common/components";
|
||||
import {
|
||||
adjustWaterRiskStartDate,
|
||||
formatWaterRiskHourValue,
|
||||
getWaterRiskMinStartDate,
|
||||
validateWaterRiskDateRange,
|
||||
toWaterRiskApiDateTime,
|
||||
toWaterRiskHourValue,
|
||||
validateWaterRiskAnalysisPeriod,
|
||||
validateWaterRiskRainStartedPeriod,
|
||||
} from "./water-risk-date-range";
|
||||
|
||||
import {
|
||||
@@ -34,25 +38,42 @@ import {
|
||||
import {
|
||||
buildWaterRiskStatusLabel,
|
||||
EMPTY_WATER_RISK_SESSION_SNAPSHOT,
|
||||
isWaterRiskDanger,
|
||||
type WaterRiskAreaOption,
|
||||
type WaterRiskResultPage,
|
||||
type WaterRiskResultRow,
|
||||
type WaterRiskSessionSnapshot,
|
||||
type WaterRiskStatus,
|
||||
} from "./water-risk-types";
|
||||
|
||||
import LoadingOverlay from "@/components/ui/loading-bar";
|
||||
|
||||
type WaterRiskResultMode = "current" | "history";
|
||||
|
||||
/**
|
||||
* 과거이력 검색조건이다. 시각 값은 모두 시간 단위 입력값(yyyy-MM-ddTHH:00)이며
|
||||
* 시작·종료 정시를 모두 포함해 조회한다. 현재 탭은 중점관리지역만 이 값을 쓴다.
|
||||
*/
|
||||
type ResultFilters = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
ctpvCd: string;
|
||||
sggCd: string;
|
||||
areaId: string;
|
||||
riskStatus: "ALL" | "DANGER" | "NORMAL";
|
||||
riskStatus: "ALL" | WaterRiskStatus;
|
||||
/** 과거이력 분석시각 기간 시작. 비우면 분석시각 조건을 적용하지 않는다. */
|
||||
analysisFrom: string;
|
||||
/** 과거이력 분석시각 기간 종료. 시작과 함께 입력한다. */
|
||||
analysisTo: string;
|
||||
/** 과거이력 강우시각(강우 시작 일시) 기간 시작. 필수이며 기본은 최근 1년 시작일 00시다. */
|
||||
rainStartedFrom: string;
|
||||
/** 과거이력 강우시각(강우 시작 일시) 기간 종료. 필수이며 기본은 현재 정시다. */
|
||||
rainStartedTo: string;
|
||||
};
|
||||
|
||||
/** 과거이력 시각 범위 입력 키. 분석시각·강우시각 입력을 같은 모양으로 그릴 때 쓴다. */
|
||||
type HourRangeKeys =
|
||||
| { fromKey: "analysisFrom"; toKey: "analysisTo" }
|
||||
| { fromKey: "rainStartedFrom"; toKey: "rainStartedTo" };
|
||||
|
||||
const WATER_RISK_PAGE_SIZE = 10;
|
||||
const EMPTY_RESULT_PAGE: WaterRiskResultPage = {
|
||||
items: [],
|
||||
@@ -69,6 +90,8 @@ type WaterRiskTableRow = Record<string, unknown> & {
|
||||
sidoName: string;
|
||||
sggName: string;
|
||||
areaName: string;
|
||||
/** 대표실행 분석기준시각(excn.anls_crtr_dt)을 yyyy.MM.dd HH:mm으로 표시한 값 */
|
||||
analysisBaseAt: string;
|
||||
rainStartedAt: string;
|
||||
currentAccumRainQty: string;
|
||||
forecastRainQty: string;
|
||||
@@ -81,53 +104,56 @@ const WATER_RISK_RESULT_HEADLIST: UiHeadProps[] = [
|
||||
{
|
||||
id: "rowNo",
|
||||
title: "순번",
|
||||
widthPercent: 6,
|
||||
widthPercent: 5,
|
||||
sortable: true,
|
||||
sortKey: "rowNumber",
|
||||
},
|
||||
{
|
||||
id: "sidoName",
|
||||
title: "시도",
|
||||
widthPercent: 8,
|
||||
widthPercent: 7,
|
||||
sortable: true,
|
||||
sortKey: "ctpvName",
|
||||
},
|
||||
{ id: "sggName", title: "시군구", widthPercent: 8, sortable: true },
|
||||
{ id: "areaName", title: "중점관리지역", widthPercent: 14, sortable: true },
|
||||
{ id: "sggName", title: "시군구", widthPercent: 7, sortable: true },
|
||||
{ id: "areaName", title: "중점관리지역", widthPercent: 12, sortable: true },
|
||||
// 대표실행 분석기준시각. 컬럼 id가 백엔드 정렬 키(analysisBaseAt)와 같다.
|
||||
{ id: "analysisBaseAt", title: "분석시각", widthPercent: 11, sortable: true },
|
||||
{
|
||||
id: "rainStartedAt",
|
||||
title: "강우시작시간",
|
||||
widthPercent: 13,
|
||||
title: "강우시작시각",
|
||||
widthPercent: 11,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
id: "currentAccumRainQty",
|
||||
title: "누적강우량(mm)",
|
||||
widthPercent: 11,
|
||||
widthPercent: 9,
|
||||
align: "right",
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
id: "forecastRainQty",
|
||||
title: "예측강우량(mm)",
|
||||
widthPercent: 11,
|
||||
widthPercent: 9,
|
||||
align: "right",
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
id: "maxFloodingAt",
|
||||
title: "최대 침수 예상시점",
|
||||
widthPercent: 13,
|
||||
widthPercent: 11,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
id: "maxFloodingFlow",
|
||||
title: "최대 침수유량(m³/s)",
|
||||
widthPercent: 10,
|
||||
widthPercent: 9,
|
||||
align: "right",
|
||||
sortable: true,
|
||||
},
|
||||
{ id: "riskStatus", title: "위험 여부", widthPercent: 7 },
|
||||
// "위험(매핑관로없음)" 문구가 들어가도록 기존보다 넓게 둔다.
|
||||
{ id: "riskStatus", title: "위험 여부", widthPercent: 9 },
|
||||
];
|
||||
|
||||
const formatOptionalNumber = (
|
||||
@@ -155,17 +181,20 @@ const formatAnalysisBaseAt = (value?: string | null) => {
|
||||
return `${date.replaceAll("-", ".")} ${time.slice(0, 5)}`.trim();
|
||||
};
|
||||
|
||||
/** 현재 위험 조회와 검색 초기화에 사용하는 최근 1년의 기본 조회조건이다. */
|
||||
/** 최초 진입과 검색 초기화에 사용하는 기본 조회조건이다. 강우시각은 최근 1년이다. */
|
||||
const buildDefaultResultFilters = (): ResultFilters => {
|
||||
const endDate = new Date();
|
||||
const endDateValue = formatLocalDate(endDate);
|
||||
const now = new Date();
|
||||
return {
|
||||
startDate: getWaterRiskMinStartDate(endDateValue)!,
|
||||
endDate: endDateValue,
|
||||
ctpvCd: "ALL",
|
||||
sggCd: "ALL",
|
||||
areaId: "ALL",
|
||||
riskStatus: "ALL",
|
||||
// 분석시각 기간은 선택 조건이므로 기본·초기화 상태는 비워 둔다.
|
||||
analysisFrom: "",
|
||||
analysisTo: "",
|
||||
// 강우시각은 필수 조건이다. 365일 전 00시 ~ 현재 정시(종료일 포함 최대 366일)를 기본으로 한다.
|
||||
rainStartedFrom: `${getWaterRiskMinStartDate(formatLocalDate(now))!}T00:00`,
|
||||
rainStartedTo: formatWaterRiskHourValue(now),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -178,12 +207,16 @@ const postMessageToOpener = (
|
||||
window.opener.postMessage({ type, payload }, window.location.origin);
|
||||
};
|
||||
|
||||
/** 위험 여부에 맞는 기존 팝업 badge 스타일을 반환한다. */
|
||||
const getRiskBadgeClassName = (riskStatus: "DANGER" | "NORMAL") =>
|
||||
riskStatus === "DANGER" ? "sn-popup-badge-danger" : "sn-popup-badge-normal";
|
||||
/** 위험 여부에 맞는 기존 팝업 badge 스타일을 반환한다. 위험(매핑관로없음)도 위험 배지를 쓴다. */
|
||||
const getRiskBadgeClassName = (riskStatus: WaterRiskStatus) =>
|
||||
isWaterRiskDanger(riskStatus) ? "sn-popup-badge-danger" : "sn-popup-badge-normal";
|
||||
|
||||
/** 동일 날짜·지역에 여러 강우이벤트가 있어도 실행별 행 키가 겹치지 않게 식별한다. */
|
||||
/**
|
||||
* 목록 행을 대표 SWMM 실행 ID로 식별한다. 행 하나가 실행 하나이므로 같은 날·지역 행도 겹치지 않는다.
|
||||
* 실행 ID가 없는 응답(구 BE)에서는 기존 날짜·시각·지역 조합으로 대체한다.
|
||||
*/
|
||||
const buildResultRowKey = (row: WaterRiskResultRow) =>
|
||||
row.executionId ||
|
||||
[
|
||||
row.analysisDate ?? "",
|
||||
row.rainStartedAt ?? "",
|
||||
@@ -195,10 +228,10 @@ const buildResultRowKey = (row: WaterRiskResultRow) =>
|
||||
|
||||
/** 실제 통수위험 API를 조회하고 선택 결과를 부모 지도에 전달하는 팝업이다. */
|
||||
export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
// 최초 진입과 초기화 모두 오늘을 종료일로 하는 최근 1년을 사용한다.
|
||||
// 최초 진입과 초기화 모두 현재 정시를 종료로 하는 최근 1년 강우시각을 사용한다.
|
||||
const initialFilters = useMemo(() => buildDefaultResultFilters(), []);
|
||||
// 과거분석은 오늘까지 선택 가능하며, 시작일의 범위는 선택 종료일에 따라 달라진다.
|
||||
const today = formatLocalDate(new Date());
|
||||
// 과거이력 시각 입력은 현재 정시까지 고를 수 있다. 렌더링마다 다시 계산해 정시가 바뀌면 따라간다.
|
||||
const currentHourValue = formatWaterRiskHourValue(new Date());
|
||||
const [resultMode, setResultMode] = useState<WaterRiskResultMode>("current");
|
||||
const [draftResultFilters, setDraftResultFilters] =
|
||||
useState<ResultFilters>(initialFilters);
|
||||
@@ -241,15 +274,38 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
let active = true;
|
||||
setIsLoading(true);
|
||||
setErrorMessage("");
|
||||
// 과거 이력의 고정 기본기간이 현재 진행 중인 강우 조회를 제한하지 않게 분리한다.
|
||||
const dateFilters = resultMode === "current"
|
||||
? buildDefaultResultFilters()
|
||||
: appliedResultFilters;
|
||||
const isHistory = resultMode === "history";
|
||||
// 현재 탭은 진행 중 강우를 오늘까지 최근 1년 강우시작일로 찾고, 과거이력의 적용 기간이 이를 제한하지 않게 분리한다.
|
||||
// 과거이력은 적용 시 검증을 통과한 강우시각 기간을 보내며, 강우시작일도 그 날짜로 맞춘다.
|
||||
const requestToday = formatLocalDate(new Date());
|
||||
const startDate = isHistory
|
||||
? appliedResultFilters.rainStartedFrom.slice(0, 10)
|
||||
: getWaterRiskMinStartDate(requestToday)!;
|
||||
const endDate = isHistory
|
||||
? appliedResultFilters.rainStartedTo.slice(0, 10)
|
||||
: requestToday;
|
||||
// 과거이력의 분석시각 기간은 적용 시 검증을 통과한 값이며, 비어 있으면 조건 없이 조회한다.
|
||||
const analysisBaseFrom = isHistory
|
||||
? toWaterRiskApiDateTime(appliedResultFilters.analysisFrom)
|
||||
: "";
|
||||
const analysisBaseTo = isHistory
|
||||
? toWaterRiskApiDateTime(appliedResultFilters.analysisTo)
|
||||
: "";
|
||||
const rainStartedFrom = isHistory
|
||||
? toWaterRiskApiDateTime(appliedResultFilters.rainStartedFrom)
|
||||
: "";
|
||||
const rainStartedTo = isHistory
|
||||
? toWaterRiskApiDateTime(appliedResultFilters.rainStartedTo)
|
||||
: "";
|
||||
fetchWaterRiskResults({
|
||||
startDate: dateFilters.startDate,
|
||||
endDate: dateFilters.endDate,
|
||||
startDate,
|
||||
endDate,
|
||||
analysisBaseAt:
|
||||
resultMode === "current" ? currentAnalysisBaseAt : undefined,
|
||||
analysisBaseFrom: analysisBaseFrom || undefined,
|
||||
analysisBaseTo: analysisBaseTo || undefined,
|
||||
rainStartedFrom: rainStartedFrom || undefined,
|
||||
rainStartedTo: rainStartedTo || undefined,
|
||||
ctpvCd:
|
||||
resultMode === "current" || appliedResultFilters.ctpvCd === "ALL"
|
||||
? undefined
|
||||
@@ -313,7 +369,12 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
const nextSnapshot = (event.data.payload ??
|
||||
EMPTY_WATER_RISK_SESSION_SNAPSHOT) as WaterRiskSessionSnapshot;
|
||||
if (nextSnapshot.selectedAreaId) {
|
||||
const matchingRow = resultPage.items.find(
|
||||
// 지도에 표시 중인 실행 ID가 있으면 그 행만 선택한다. 같은 지역 첫 행으로 바꾸면 다른 결과가 선택돼 보인다.
|
||||
const matchingRow = nextSnapshot.selectedExecutionId
|
||||
? resultPage.items.find(
|
||||
(row) => row.executionId === nextSnapshot.selectedExecutionId,
|
||||
)
|
||||
: resultPage.items.find(
|
||||
(row) => row.areaId === nextSnapshot.selectedAreaId,
|
||||
);
|
||||
if (matchingRow) setSelectedResultKey(buildResultRowKey(matchingRow));
|
||||
@@ -418,6 +479,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
sidoName: row.sidoName || "-",
|
||||
sggName: row.sggName || "-",
|
||||
areaName: row.areaName,
|
||||
analysisBaseAt: formatAnalysisBaseAt(row.analysisBaseAt),
|
||||
rainStartedAt: formatAnalysisBaseAt(row.rainStartedAt),
|
||||
currentAccumRainQty: formatOptionalNumber(row.currentAccumRainQty, 1),
|
||||
forecastRainQty: formatOptionalNumber(row.forecastRainQty, 1),
|
||||
@@ -432,11 +494,25 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
/** 검증된 검색조건을 적용하고 첫 페이지를 새로 조회한다. */
|
||||
const handleApplyResultFilters = () => {
|
||||
if (resultMode === "history") {
|
||||
const dateError = validateWaterRiskDateRange(
|
||||
draftResultFilters.startDate, draftResultFilters.endDate, today,
|
||||
const nowHourValue = formatWaterRiskHourValue(new Date());
|
||||
// 분석시각 기간은 선택 조건이다. 입력한 경우에만 시작·종료 짝, 순서, 기간, 현재 정시 이하를 확인한다.
|
||||
const analysisPeriodError = validateWaterRiskAnalysisPeriod(
|
||||
draftResultFilters.analysisFrom,
|
||||
draftResultFilters.analysisTo,
|
||||
nowHourValue,
|
||||
);
|
||||
if (dateError) {
|
||||
setErrorMessage(dateError);
|
||||
if (analysisPeriodError) {
|
||||
setErrorMessage(analysisPeriodError);
|
||||
return;
|
||||
}
|
||||
// 강우시각 기간은 필수 조건이다. 시작·종료 짝, 순서, 최대 366일, 현재 정시 이하를 확인한다.
|
||||
const rainStartedPeriodError = validateWaterRiskRainStartedPeriod(
|
||||
draftResultFilters.rainStartedFrom,
|
||||
draftResultFilters.rainStartedTo,
|
||||
nowHourValue,
|
||||
);
|
||||
if (rainStartedPeriodError) {
|
||||
setErrorMessage(rainStartedPeriodError);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<StandardPopupLayout title={windowPopupDefinitions.flowRiskPopup.title}>
|
||||
<section
|
||||
@@ -529,45 +643,9 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
</div>
|
||||
|
||||
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
|
||||
{/* 과거이력 검색조건 순서: 시도 → 시군구 → 중점관리지역 → 분석시각 → 강우시각 → 위험상태 */}
|
||||
{resultMode === "history" ? (
|
||||
<>
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
강우 발생기간
|
||||
</div>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={draftResultFilters.startDate}
|
||||
min={getWaterRiskMinStartDate(draftResultFilters.endDate)}
|
||||
max={draftResultFilters.endDate || today}
|
||||
onChange={(event) =>
|
||||
setDraftResultFilters((current) => ({
|
||||
...current,
|
||||
startDate: event.target.value,
|
||||
}))
|
||||
}
|
||||
aria-label="조회 시작일"
|
||||
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<span className="text-xl font-medium text-label">~</span>
|
||||
<input
|
||||
type="date"
|
||||
value={draftResultFilters.endDate}
|
||||
max={today}
|
||||
onChange={(event) =>
|
||||
setDraftResultFilters((current) => ({
|
||||
...current,
|
||||
startDate: adjustWaterRiskStartDate(current.startDate, event.target.value),
|
||||
endDate: event.target.value,
|
||||
}))
|
||||
}
|
||||
aria-label="조회 종료일"
|
||||
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
시도
|
||||
@@ -652,6 +730,16 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
</label>
|
||||
|
||||
{resultMode === "history" ? (
|
||||
<>
|
||||
{/* 분석시각(선택)·강우시각(필수): 시간 단위로만 고르며 시작·종료 정시를 모두 포함해 조회한다. */}
|
||||
{renderHourRange("분석시각", {
|
||||
fromKey: "analysisFrom",
|
||||
toKey: "analysisTo",
|
||||
})}
|
||||
{renderHourRange("강우시각", {
|
||||
fromKey: "rainStartedFrom",
|
||||
toKey: "rainStartedTo",
|
||||
})}
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
위험상태
|
||||
@@ -670,9 +758,11 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
>
|
||||
<option value="ALL">전체</option>
|
||||
<option value="DANGER">위험</option>
|
||||
<option value="DANGER_UNMAPPED">위험(매핑관로없음)</option>
|
||||
<option value="NORMAL">정상</option>
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
|
||||
@@ -722,25 +812,38 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
renderCell={(row, headId) =>
|
||||
headId === "riskStatus" ? (
|
||||
<span
|
||||
className={`sn-popup-badge min-w-[5.6rem] ${getRiskBadgeClassName(row.riskStatus)}`}
|
||||
className={`sn-popup-badge min-w-[5.6rem] whitespace-nowrap ${getRiskBadgeClassName(row.riskStatus)}`}
|
||||
>
|
||||
{buildWaterRiskStatusLabel(row.riskStatus)}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="124rem"
|
||||
// 분석시각 컬럼 추가로 기존 124rem보다 넓힌다.
|
||||
minTableWidth="136rem"
|
||||
emptyMessage="조회된 결과가 없습니다."
|
||||
sort={resultSort}
|
||||
onSortChange={handleSortChange}
|
||||
actions={
|
||||
<span className="text-xl font-normal text-muted-foreground">
|
||||
SWMM 시뮬레이션 기반 통수위험 분석 결과 (GIS 매핑 관로 중
|
||||
Capacity 0.8 이상이 1개라도 있으면{" "}
|
||||
<span className="font-bold text-[#ff1c15]">위험</span>, 없으면{" "}
|
||||
<span className="font-bold text-[#1677ff]">정상</span>)
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{/*
|
||||
인수테스트 요구: 판정 기준을 목록 머리글 오른쪽에서 침수예측현황과 같은 하단 위치로 옮기고,
|
||||
분석 원천·실행 주기·침수 항목 설명을 글머리표로 함께 표시한다.
|
||||
*/}
|
||||
<PopupFooterNote
|
||||
items={[
|
||||
<>
|
||||
분석 결과 관로의 Capacity(통수능) 값이 0.8 이상이 1개라도 있으면{" "}
|
||||
<span className="font-bold text-[#ff1c15]">위험</span>, 매핑되지
|
||||
않은 관로만 0.8 이상이면{" "}
|
||||
<span className="font-bold text-[#ff1c15]">위험(매핑관로없음)</span>,
|
||||
없으면 <span className="font-bold text-[#1677ff]">정상</span>
|
||||
</>,
|
||||
"통수능이 위험에 해당하지만 지도화면에 해당 관로를 매핑할 수 없을 때, 빨간색 관로는 보이지 않을 수 있습니다.",
|
||||
"분석 실행 시기 : 통수위험은 관측강우가 0mm이상으로 강우가 발생시 1시간 주기로 분석 자동실행",
|
||||
"분석 방법 : 기상청 향후 6시간 예측강우를 20개소 중점관리지역의 관망모델(SWMM모델)에 연동하여 관로 통수량을 분석하여 통수위험여부를 분석",
|
||||
"최대 침수예상시점과 최대침수유량은 맨홀 월류량이 있을때 그 시점과 값이 나타나는것입니다.",
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
</StandardPopupLayout>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
/** 통수위험 분석 화면에서 사용하는 타입·상수와 표시값 변환을 정의한다. */
|
||||
"use client";
|
||||
|
||||
export type WaterRiskStatus = "DANGER" | "NORMAL";
|
||||
/**
|
||||
* 백엔드 위험상태 코드이다.
|
||||
* DANGER: 유효 GIS 매핑 관로 중 Capacity 기준 이상 관로가 있음.
|
||||
* DANGER_UNMAPPED: 매핑 위험 관로는 없고, 유효 GIS 매핑이 없는 관로만 기준 이상임.
|
||||
* NORMAL: 기준 이상 관로가 없음.
|
||||
*/
|
||||
export type WaterRiskStatus = "DANGER" | "DANGER_UNMAPPED" | "NORMAL";
|
||||
export type WaterRiskCalculationMethod = "SWMM";
|
||||
|
||||
/** 대표실행에서 capacity가 가장 큰 관로의 실제 SWMM 결과이다. */
|
||||
@@ -14,6 +20,9 @@ export type WaterRiskPipeSummary = {
|
||||
|
||||
/** 강우이벤트 대표실행 한 건을 팝업 목록에 표시하는 모델이다. */
|
||||
export type WaterRiskResultRow = {
|
||||
/** 대표 SWMM 실행 ID(UUID). 목록 행 식별과 지도 조회(executions/{executionId}/mapView) 키다. */
|
||||
executionId: string;
|
||||
/** 강우시작일(yyyy-MM-dd). 분석시각(analysisBaseAt)과 다른 값이다. */
|
||||
analysisDate?: string;
|
||||
analysisBaseAt?: string | null;
|
||||
rainStartedAt?: string | null;
|
||||
@@ -36,14 +45,25 @@ export type WaterRiskResultRow = {
|
||||
/** 부모 지도와 별도 팝업 사이에서 공유하는 최소 선택 상태이다. */
|
||||
export type WaterRiskSessionSnapshot = {
|
||||
selectedAreaId: string | null;
|
||||
/** 지도에 표시 중인 SWMM 실행 ID. 같은 지역 행이 여러 개여도 팝업이 정확한 행을 다시 선택하게 한다. */
|
||||
selectedExecutionId: string | null;
|
||||
};
|
||||
|
||||
export const EMPTY_WATER_RISK_SESSION_SNAPSHOT: WaterRiskSessionSnapshot = {
|
||||
selectedAreaId: null,
|
||||
selectedExecutionId: null,
|
||||
};
|
||||
|
||||
export const buildWaterRiskStatusLabel = (status: WaterRiskStatus) =>
|
||||
status === "DANGER" ? "위험" : "정상";
|
||||
/** 위험상태 코드를 목록 배지·지도 라벨·정보카드에 쓰는 화면 문구로 바꾼다. */
|
||||
export const buildWaterRiskStatusLabel = (status: WaterRiskStatus) => {
|
||||
if (status === "DANGER") return "위험";
|
||||
if (status === "DANGER_UNMAPPED") return "위험(매핑관로없음)";
|
||||
return "정상";
|
||||
};
|
||||
|
||||
/** 배지·지도 색을 위험 계열(빨강)로 표시할 상태인지 판별한다. 매핑관로없음도 위험 색을 쓴다. */
|
||||
export const isWaterRiskDanger = (status: WaterRiskStatus | null | undefined) =>
|
||||
status === "DANGER" || status === "DANGER_UNMAPPED";
|
||||
|
||||
export type WaterRiskAreaOption = {
|
||||
areaId: string;
|
||||
@@ -70,7 +90,14 @@ export type WaterRiskGeoJsonGeometry = {
|
||||
|
||||
/** 선택한 대표실행의 실제 중점관리지역 및 관로 지도 결과이다. */
|
||||
export type WaterRiskMapView = {
|
||||
/** 지도에 표시한 SWMM 실행 ID. 요청한 목록 행의 executionId와 같다. */
|
||||
executionId?: string;
|
||||
/** 강우시작일(yyyy-MM-dd) */
|
||||
analysisDate: string;
|
||||
/** 분석기준시각 */
|
||||
analysisBaseAt?: string | null;
|
||||
/** 강우시작시각 */
|
||||
rainStartedAt?: string | null;
|
||||
areaId: string;
|
||||
areaName: string;
|
||||
ctpvCd: string;
|
||||
|
||||
@@ -104,7 +104,7 @@ export const getRightPanelFeatureDisplayLabel = (
|
||||
? "관로조사"
|
||||
: jobType === "MNHL"
|
||||
? "맨홀조사"
|
||||
: "조사관리";
|
||||
: "조사사업";
|
||||
return `${surveyLabel}(${managementNo})`;
|
||||
}
|
||||
|
||||
|
||||
@@ -101,7 +101,8 @@ export function useWaterRiskLayer({
|
||||
[selectedWaterRiskApiResult],
|
||||
);
|
||||
|
||||
// 지도카드는 실제 GIS geometry까지 결합된 mapView 위험상태를 목록 상태보다 우선한다.
|
||||
// 지도카드는 mapView 위험상태를 우선하고, mapView 응답 전에는 목록 상태를 쓴다.
|
||||
// 2026-10-20부터 mapView 위험상태는 목록과 같은 대표결과 판정값이므로 두 값이 다르지 않다.
|
||||
const selectedWaterRiskDisplayStatus = useMemo<
|
||||
WaterRiskResultRow["riskStatus"]
|
||||
>(
|
||||
@@ -127,7 +128,7 @@ export function useWaterRiskLayer({
|
||||
const [pixelX = 0, pixelY = 0] =
|
||||
map.getPixelFromCoordinate?.(waterRiskInfoAnchor) ?? [];
|
||||
const cardWidth = 244;
|
||||
const cardHeight = 180;
|
||||
const cardHeight = 190;
|
||||
const left = pixelX + 22;
|
||||
const top = pixelY - cardHeight / 2;
|
||||
|
||||
@@ -148,6 +149,12 @@ export function useWaterRiskLayer({
|
||||
setWaterRiskInfoCardStyle({ left, top });
|
||||
}, [waterRiskInfoAnchor]);
|
||||
|
||||
/** 정보 카드만 닫고 지도 결과·범례는 유지하며, 다음 결과 선택 시 다시 표시한다. */
|
||||
const dismissWaterRiskInfoCard = useCallback(() => {
|
||||
setWaterRiskInfoAnchor(null);
|
||||
setWaterRiskInfoCardStyle(null);
|
||||
}, []);
|
||||
|
||||
const clearWaterRiskPopupCloseWatcher = useCallback(() => {
|
||||
if (waterRiskPopupCloseWatcherRef.current !== null) {
|
||||
window.clearInterval(waterRiskPopupCloseWatcherRef.current);
|
||||
@@ -413,11 +420,20 @@ export function useWaterRiskLayer({
|
||||
| undefined;
|
||||
const mapView = data.payload?.mapView as WaterRiskMapView | undefined;
|
||||
if (!resultRow || !mapView || mapView.areaId !== areaId) return;
|
||||
// 지도 응답이 누른 행과 다른 실행이면 표시하지 않는다(실행 ID 기준 조회의 안전장치).
|
||||
if (
|
||||
mapView.executionId &&
|
||||
resultRow.executionId &&
|
||||
mapView.executionId !== resultRow.executionId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setSelectedWaterRiskApiResult(resultRow);
|
||||
setSelectedWaterRiskMapView(mapView);
|
||||
setWaterRiskSession((current) => ({
|
||||
...current,
|
||||
selectedAreaId: areaId,
|
||||
selectedExecutionId: resultRow.executionId || null,
|
||||
}));
|
||||
onSelectResult();
|
||||
return;
|
||||
@@ -470,6 +486,7 @@ export function useWaterRiskLayer({
|
||||
waterRiskInfoCardStyle,
|
||||
selectedWaterRiskMapView,
|
||||
selectedWaterRiskDisplayStatus,
|
||||
dismissWaterRiskInfoCard,
|
||||
watchPopup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,19 +4,21 @@ import CircleStyle from "ol/style/Circle.js";
|
||||
import Stroke from "ol/style/Stroke.js";
|
||||
import Style from "ol/style/Style.js";
|
||||
import Text from "ol/style/Text.js";
|
||||
import { buildWaterRiskStatusLabel } from "@/app/(map-layout)/map/components/water-risk/water-risk-types";
|
||||
import {
|
||||
buildWaterRiskStatusLabel,
|
||||
isWaterRiskDanger,
|
||||
type WaterRiskStatus,
|
||||
} from "@/app/(map-layout)/map/components/water-risk/water-risk-types";
|
||||
|
||||
export const buildWaterRiskStatusFillColor = (
|
||||
riskStatus: "DANGER" | "NORMAL",
|
||||
) =>
|
||||
riskStatus === "DANGER"
|
||||
/** 위험·위험(매핑관로없음)은 빨강, 정상은 파랑 반투명으로 중점관리지역 면을 채운다(화면설계서 21쪽). */
|
||||
export const buildWaterRiskStatusFillColor = (riskStatus: WaterRiskStatus) =>
|
||||
isWaterRiskDanger(riskStatus)
|
||||
? "rgba(198, 40, 40, 0.24)"
|
||||
: "rgba(21, 101, 192, 0.2)";
|
||||
|
||||
export const buildWaterRiskStatusStrokeColor = (
|
||||
riskStatus: "DANGER" | "NORMAL",
|
||||
) =>
|
||||
riskStatus === "DANGER"
|
||||
/** 중점관리지역 경계선·라벨 배경색. 위험 계열 판정은 면 색과 같다. */
|
||||
export const buildWaterRiskStatusStrokeColor = (riskStatus: WaterRiskStatus) =>
|
||||
isWaterRiskDanger(riskStatus)
|
||||
? "rgba(198, 40, 40, 0.92)"
|
||||
: "rgba(21, 101, 192, 0.92)";
|
||||
|
||||
@@ -31,9 +33,9 @@ export const buildWaterRiskCapacityColor = (capacityRatio: number) => {
|
||||
|
||||
export const createWaterRiskAreaStyle = (feature: any) => {
|
||||
const areaName = String(feature.get("areaName") ?? "");
|
||||
const riskStatus = String(feature.get("riskStatus") ?? "NORMAL") as
|
||||
| "DANGER"
|
||||
| "NORMAL";
|
||||
const riskStatus = String(
|
||||
feature.get("riskStatus") ?? "NORMAL",
|
||||
) as WaterRiskStatus;
|
||||
return new Style({
|
||||
fill: new Fill({ color: buildWaterRiskStatusFillColor(riskStatus) }),
|
||||
stroke: new Stroke({
|
||||
|
||||
@@ -25,6 +25,8 @@ export const EXMN_PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH";
|
||||
// 지반침하 지도 포인트와 같은 파란 계열의 기존 레이어 트리 심볼 자산을 재사용한다.
|
||||
export const SINKHOLE_TREE_ICON_NAME = "SN_SINKHOLE_SPOT_BLUE.png";
|
||||
|
||||
// EPSG:5179의 남한·도서 주변 여유 범위 [minX, minY, maxX, maxY].
|
||||
// 화면 비율 때문에 전체 범위가 잘리지 않도록 Gis의 viewShowFullExtent와 함께 사용한다.
|
||||
export const SOUTH_KOREA_VIEW_EXTENT: [number, number, number, number] = [
|
||||
600000, 1300000, 1500000, 2500000,
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
/** 지도 route에만 적용하는 기존 전역 컨트롤 CSS. 선택자와 스타일 값은 유지한다. */
|
||||
/** 지도 route의 기존 배치를 유지하고 SN 컨트롤의 쌓임 순서를 맞춘다. */
|
||||
export default function MapControlStyles() {
|
||||
return (
|
||||
<style jsx global>{`
|
||||
@@ -8,6 +8,7 @@ export default function MapControlStyles() {
|
||||
top: 16px;
|
||||
right: 120px;
|
||||
bottom: auto;
|
||||
z-index: 45;
|
||||
}
|
||||
|
||||
.sn-map-control-layout .map-type-tabs,
|
||||
@@ -28,6 +29,7 @@ export default function MapControlStyles() {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
bottom: auto;
|
||||
z-index: 45;
|
||||
}
|
||||
|
||||
.sn-map-control-layout .tool-bar-vector-icons ul li div {
|
||||
|
||||
@@ -63,7 +63,7 @@ import {
|
||||
normalizeRainObservationLayerInteraction,
|
||||
getFlowDirectionMinZoom,
|
||||
} from "@/features/map-layers";
|
||||
import { ListTree } from "lucide-react";
|
||||
import { ListTree, X } from "lucide-react";
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
@@ -182,7 +182,12 @@ import {
|
||||
WATER_DEPTH_LAYER_TYPES,
|
||||
extractWaterDepthLegendItems,
|
||||
} from "../water-depth/water-depth";
|
||||
import { buildWaterRiskStatusLabel } from "../components/water-risk/water-risk-types";
|
||||
import {
|
||||
buildWaterRiskStatusLabel,
|
||||
isWaterRiskDanger,
|
||||
} from "../components/water-risk/water-risk-types";
|
||||
|
||||
import GisToolButtons from "../components/gis-tools/gis-tool-buttons";
|
||||
|
||||
const AnalysisPopupMenu = dynamic(
|
||||
() => import("../components/analysis-menu/analysis-popup-menu"),
|
||||
@@ -296,6 +301,7 @@ const MapPageClient = () => {
|
||||
waterRiskInfoCardStyle,
|
||||
selectedWaterRiskMapView,
|
||||
selectedWaterRiskDisplayStatus,
|
||||
dismissWaterRiskInfoCard,
|
||||
watchPopup: watchWaterRiskPopup,
|
||||
} = useWaterRiskLayer({
|
||||
gisMaps,
|
||||
@@ -3126,20 +3132,31 @@ const MapPageClient = () => {
|
||||
// 위험/정상 배지는 통수위험 상태를 즉시 구분하는 업무 의미색이므로 고정색을 유지한다.
|
||||
<div
|
||||
style={waterRiskInfoCardStyle}
|
||||
className="absolute z-[61] w-[244px] overflow-hidden rounded-[12px] border border-border bg-background shadow-blur"
|
||||
className="absolute z-[44] w-[244px] overflow-hidden rounded-[12px] border border-border bg-background shadow-blur"
|
||||
>
|
||||
<div className="flex items-center justify-between bg-secondary px-[9px] py-[7px]">
|
||||
<div>
|
||||
<div className="bg-secondary px-[9px] py-[7px]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[10px] font-semibold text-muted-foreground">
|
||||
중점관리지역
|
||||
</div>
|
||||
<div className="mt-[2px] text-[13px] font-bold text-foreground">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-[20px] w-[20px] items-center justify-center rounded-[3px] text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
onClick={dismissWaterRiskInfoCard}
|
||||
aria-label="통수위험 정보 카드 닫기"
|
||||
title="닫기"
|
||||
>
|
||||
<X className="h-[14px] w-[14px]" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-[2px] flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 text-[13px] font-bold text-foreground">
|
||||
{selectedWaterRiskResultRow.areaName}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex min-w-[48px] items-center justify-center rounded-full px-[7px] py-[5px] text-[10px] font-bold ${
|
||||
selectedWaterRiskDisplayStatus === "DANGER"
|
||||
className={`inline-flex min-w-[48px] shrink-0 items-center justify-center rounded-full px-[7px] py-[5px] text-[10px] font-bold ${
|
||||
// 위험(매핑관로없음)도 위험과 같은 빨간 배지로 표시하고 문구로 구분한다.
|
||||
isWaterRiskDanger(selectedWaterRiskDisplayStatus)
|
||||
? "bg-[#ff1c15] text-white"
|
||||
: "bg-[#e8f6f4] text-[#0f766e]"
|
||||
}`}
|
||||
@@ -3147,6 +3164,7 @@ const MapPageClient = () => {
|
||||
{buildWaterRiskStatusLabel(selectedWaterRiskDisplayStatus)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-background px-[9px] py-[6px] text-[9px] text-muted-foreground">
|
||||
<div>GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면</div>
|
||||
<div>
|
||||
@@ -3197,7 +3215,7 @@ const MapPageClient = () => {
|
||||
) : null}
|
||||
{selectedWaterRiskMapView ? (
|
||||
// Capacity와 Flooding 색상은 SWMM 결과의 업무 의미색이므로 테마와 무관하게 유지한다.
|
||||
<div className="absolute bottom-4 left-[270px] z-[61] rounded-md border border-border bg-background px-3 py-2 text-[10px] text-foreground shadow-md">
|
||||
<div className="absolute bottom-4 left-[270px] z-[44] rounded-md border border-border bg-background px-3 py-2 text-[10px] text-foreground shadow-md">
|
||||
<div className="mb-1 font-bold">Capacity</div>
|
||||
<div className="space-y-1 font-semibold">
|
||||
{[
|
||||
@@ -3236,10 +3254,13 @@ const MapPageClient = () => {
|
||||
initialActiveKey={initialAnalysisPopupKey}
|
||||
onOpenPopup={openAnalysisPopup}
|
||||
onOpenInfoSearch={() => setIsSearchModalOpen(true)}
|
||||
onToggleGisEdit={handleToggleGisEdit}
|
||||
isGisEditActive={gisEdit.isEditMode}
|
||||
isInfoSearchActive={isSearchModalOpen}
|
||||
/>
|
||||
<GisToolButtons
|
||||
isGisEditActive={gisEdit.isEditMode}
|
||||
onToggleGisEdit={handleToggleGisEdit}
|
||||
onOpenGisManagement={() => openAnalysisPopup("gisManagementPopup")}
|
||||
/>
|
||||
{isGisEditSessionInlineVisible ? (
|
||||
<div
|
||||
data-testid="gis-edit-session-inline"
|
||||
@@ -3335,6 +3356,7 @@ const MapPageClient = () => {
|
||||
defaultZoom={7.5}
|
||||
defaultLonLat={[129, 36.3]}
|
||||
viewExtent={SOUTH_KOREA_VIEW_EXTENT}
|
||||
viewShowFullExtent={true}
|
||||
/>
|
||||
{isAgedPipeListMinimized && (
|
||||
<div className="fixed left-[480px] top-[68px] z-[90] flex min-w-[250px] items-center gap-3 rounded-sm border border-red-200 bg-background px-3 py-2 shadow-[0_8px_22px_rgba(15,23,42,0.22)] backdrop-blur">
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
- 확인됨: 공통 컴포넌트 수정은 `sn`에 실제로 필요한 범위로 제한한다. 근거: `apps/sn/AGENTS.md`
|
||||
- 추정: 여러 화면에서 반복되는 `sn` 전용 로직은 먼저 `apps/sn` 내부 공통화로 해결하고, 여러 업무 화면에서 공유되는 비즈니스 UI일 때만 `business_components`를 검토하는 것이 안전하다. 근거: `apps/sn/AGENTS.md`
|
||||
|
||||
## 공통 유틸 재사용 기준
|
||||
|
||||
- `sn` 프론트엔드의 엑셀 다운로드 기능을 신규 개발하거나 수정할 때는 `apps/sn/src/shared/util/download-excel-workbook.ts`의 재사용을 먼저 검토한다. 근거: 사용자 지시(2026-10-22).
|
||||
- 업무별 데이터 조회·가공·한글 항목 매핑은 각 기능에서 담당하고, 워크북 생성·시트 구성·파일 저장은 공통 유틸을 우선 사용한다. 시트 생성은 `buildExcelWorkbook`, 브라우저 다운로드는 `downloadExcelWorkbook`을 검토한다.
|
||||
- 기존 다운로드 기능을 관련 없는 작업에서 일괄 전환하지 않는다. 변경 대상 기능의 요구사항과 기존 동작을 확인한 뒤 적용한다.
|
||||
- 공통 유틸로 요구사항을 충족하기 어려우면 그 사유와 대안을 작업계획에 기록한다. 특수 서식이나 기존 템플릿 등 요구사항에 맞춰 적용 여부를 판단한다.
|
||||
- 다운로드 대상 데이터의 범위·전체 건수·API 제한과 대량 자료의 서버 생성 기준은 별도로 확인한다. 공통 유틸 사용만으로 데이터 수집 범위가 보장되지는 않는다.
|
||||
|
||||
## 작업카드에 남길 항목
|
||||
- 확인 필요: 신규 또는 수정 컴포넌트 경로
|
||||
- 확인 필요: 호출하는 화면 경로
|
||||
|
||||
@@ -275,3 +275,8 @@
|
||||
|
||||
- 레이어 API 단위 테스트는 실제 서비스가 호출하는 Repository 메서드와 동일한 메서드를 모킹해야 한다.
|
||||
- 전체 빌드가 장시간 걸리는 환경에서는 변경 파일 정적 검사 후 백엔드 대상 테스트와 로그인 브라우저 회귀를 분리한다.
|
||||
|
||||
|
||||
## 2026-10-19 맨홀 PDF GIS 번호 검증
|
||||
- 188001 테스트 PDF 실제 판독·쪽 추출, PPL/MNHL 첨부 분리, 미첨부·없는 번호·중복 시설·다중/비연속 번호를 검증한다.
|
||||
- 실제 서버에서는 맨홀 첨부 저장 → 상류맨홀 지도 선택 → 맨홀 보고서 1쪽 표시와 관로 회귀를 확인한다. 자동검증/실브라우저 여부는 통합검증결과에 구분한다.
|
||||
|
||||
@@ -678,3 +678,44 @@
|
||||
- 검증: SN 전체 타입검사 exit0, 숫자/소수/삭제 및 거부 문자열 Node 검증 통과, git diff --check 통과. 실브라우저 입력 검증은 미수행.
|
||||
- 수정 소스: `frontend/apps/sn/src/features/exmn/ui/exmn-business-register-popup-page.tsx`. API/백엔드 코드·저장 계약 변경 없음. 기존 GIS 작업은 변경하지 않았다.
|
||||
- 롤백: 해당 소스의 이번 필수 표시/입력 제한만 복원한다.
|
||||
|
||||
|
||||
## 2026-10-19 맨홀 조사보고서 GIS 번호 연결 (maintenance)
|
||||
|
||||
- 목적: 별도 맨홀 식별체계 확정 전 맨홀 PDF의 `맨홀번호`에 조사시설 GIS 번호(테스트 188001)를 넣고 지도에서 해당 보고서를 열람한다. 지도 도형은 기존 상류맨홀 매칭을 유지한다.
|
||||
- 근거: 요구사항정의서 `3. (기능)요구사항정의 및 추적표` SFR-010-006/007, 화면설계서 실제 42쪽의 식별기준 미확정 메모 및 43쪽 `SN_MN040101` 조사결과 등록. 이번 사용자 지시로 임시 GIS 번호 연결 기준을 확정했다.
|
||||
- BE: `ExmnManholeReportService.java` 신규, `ExmnPipePdfValidator.java` 표제별 불변 판독기, `ExmnBusinessController.java`의 `GET /api/v1/exmnBusinesses/{exmnBizId}/manholeReports/{pipeNumber}` 및 `/availability`, `ExmnFacilityRepository.java`의 조사유형별 첨부 존재 여부 분기.
|
||||
- 맨홀 첨부 `mnhlExmnAtchFileId`와 활성 MNHL의 `gisManagementNo`만 사용한다. 선택 번호가 없거나 활성 시설이 중복되면 임의 보고서를 반환하지 않는다. 매 요청 현재 PDF를 판독하므로 이전 관로 쪽 캐시를 사용하지 않는다. DB 스키마 및 업무 데이터 변경 없음.
|
||||
- FE: `exmn-business-api.ts`, `exmn-survey-result-section.tsx`, `exmn-pipe-report-popup-content.tsx`, `exmn-result-register-popup-page.tsx`. MNHL에서도 보고서 버튼을 표시하고 jobType에 따라 API·제목·번호 안내를 선택한다. PPL 기본 호출은 유지한다.
|
||||
- 업로드 검토의 PDF 내부 번호 대조는 이번 범위에 추가하지 않았다. 번호 입력 기준과 열람 시 대조한다는 안내를 결과등록 화면에 표시한다.
|
||||
- 산출물: 작업루트 `output/pdf/맨홀_현황조사_보고서_188001.pdf`. 사용자 원본 1쪽의 맨홀번호만 188001로 변경했으며 보고서번호·노선명 등 예시값과 사진은 원본을 유지했다. 실제 납품 조사자료가 아닌 기능 테스트용이다.
|
||||
- 검증: BE compileJava --offline 성공, SN 전체 typecheck 성공, 양 저장소 diff --check 성공. 임시 JUnit 5건 성공(실제 PDF 번호/쪽 추출, PPL 표제 분리, 다중/비연속 번호 거부·연속 쪽, MNHL 첨부 선택/DB 캐시 미사용/미첨부, 없는 번호/중복 시설). PDF 재열람·텍스트 및 렌더링 육안 검증 완료.
|
||||
- 미검증: 로컬 3002/8883 실행 포트가 없으므로 실제 서버 업로드·저장·지도 선택·브라우저 팝업은 수행하지 않았다. 188001 MNHL 조사시설 및 상류맨홀 GIS 도형의 실제 존재를 확인한 것으로 간주하지 않는다.
|
||||
- 수동 절차: 변경 BE/FE 실행 → 188001을 가진 MNHL 조사자료의 사업에서 결과등록 → CCTV 조사결과(맨홀)에 테스트 PDF 등록·저장 → 맨홀조사 레이어의 해당 상류맨홀 선택 → 조사보고서 보기 → 제목 맨홀 조사보고서/GIS 번호188001/1쪽 확인. 관로 보고서와 미첨부·없는 번호도 회귀 확인한다.
|
||||
- 롤백: 위 소스의 이번 변경과 신규 ExmnManholeReportService를 함께 복원한다. DB 롤백은 없다. 실제 첨부 등록을 수행한 경우에는 별도로 이전 첨부를 복원한다.
|
||||
- 남은 리스크: 번호는 확정 맨홀번호가 아닌 조사시설 GIS 번호의 임시 계약이며, 스캔 PDF는 판독 불가하다. 맨홀 PDF는 availability/열람 때 각각 판독하므로 대형 파일 성능은 후속 검증 대상이다. 테스트 소스·원시 결과·렌더링 임시 이미지는 요약 후 정리하고 최종 PDF는 보존한다.
|
||||
|
||||
|
||||
## 2026-10-19 후속: 기존 사업 맨홀 PDF 단독 교체
|
||||
- 원인: 보고서 열람을 추가했지만 결과등록 화면은 모든 검토에 Excel을 요구하고 저장에 Excel·PPL 검토 성공을 요구했다.
|
||||
- 변경: 맨홀 PDF 하나만 선택하면 기존 활성 MNHL 조사시설의 GIS 번호로 PDF 번호를 대조한다. 기존 조사자료 없음·불일치·중복·잘못된 PDF는 차단한다. 선택 PDF의 일부 시설 포함은 허용한다.
|
||||
- 저장 직전 다시 검토한 뒤 선택한 MNHL 첨부만 기존 attachments API로 연결한다. Excel imports/commit과 양식 생성 확인은 호출하지 않아 기존 시설·집계·관로첨부를 유지한다. Excel을 선택한 신규/전체 결과 저장 흐름은 유지한다.
|
||||
- 수정: BE ExmnBusinessController(선택 mnhlFile 입력 및 Excel 없는 MNHL 검토 분기), ExmnManholeReportService(validateReplacement), FE exmn-result-register-popup-page(검토·저장 조건과 검토안내).
|
||||
- 검증: BE compileJava, SN typecheck, 임시 JUnit 4건(기존번호 정상·무쓰기, 기존결과 없음, 번호 불일치/중복, 누락/손상 PDF) 통과. 원본 요구사항·지도 기준은 앞선 절과 동일하다. DB 스키마 변경 없음.
|
||||
- 롤백: 이번 단독 교체 분기만 복원한다. 앞선 맨홀 보고서 열람 구현은 유지할 수 있다.
|
||||
|
||||
- 실제 브라우저 확인: 기존 테스트 사업 `6fe818cb-0a7a-4379-969c-150150155f91`에서 Excel/PPL 미선택·188001 맨홀 PDF만 선택 → 검토 완료 → 저장 → 부모 사업목록 성공 안내 확인. 등록한 맨홀 PDF를 보고서 팝업에서 GIS 번호188001/1쪽으로 실제 표시했다. 지도 클릭 경로 자체는 이번 후속에서 재실행하지 않았다.
|
||||
- 검토 중 기존8883 서버의 닫힌 Spring 컨텍스트로 일반 API도500 발생. 사용자 명시 승인 후 동일 실행인수·local 프로필·포트8883·context /sewerage-service로 재시작하여 정상화했다. 교체 서버 PID24204는 사용자가 이어서 테스트하도록 유지한다. 종료명령: Stop-Process -Id 24204. 실행중 필요한 classpath 사본과 stdout/stderr는 작업루트 .codex-tmp/manhole-backend-classpath.jar 및 manhole-backend.out.log/err.log에 유지한다(테스트용 임시 서버가 아닌 승인된 사용자 서버 교체).
|
||||
- 부모 성공문구도 첨부 교체에 맞게 `조사결과 저장을 완료했습니다.`로 보정했다(exmn-registration-page.tsx). 실제 첨부는 변경했으며 기존 시설·집계는 Excel commit을 호출하지 않아 유지한다. 되돌릴 경우 이전 맨홀 첨부를 재등록한다.
|
||||
|
||||
|
||||
## 2026-10-19 최종 정정: 실제 GIS 맨홀번호와 선택 사업 기준
|
||||
- 사용자 확정: PDF 맨홀번호는 조사자료 관로번호가 아닌 실제 GIS 맨홀번호이다. 앞선 gisManagementNo 기준 설명·구현은 이 절로 대체한다.
|
||||
- 원인: 지도보기 진입은 테스트 사업이었지만 전체 사업 레이어의 겹친 시설이 선택됐다. 실제 화면은 오류 확인 테스트_수정/조사관로188003/시설관리번호188001이었다. 사용자 선택 실수가 아닌 지도 범위와 번호 계약 불일치이다.
|
||||
- FE use-exmn-result-map.ts: 조사관리에서 사업·처리분구를 지정해 진입하면 해당 범위만 표출·선택한다. 일반 지도 진입은 기존 전체 범위를 유지한다. 기존 2026-10-01 전체표출 정책의 조사관리 진입 부분을 이 결정으로 대체한다.
|
||||
- FE exmn-survey-result-section.tsx: MNHL 보고서 키는 fclt_mng_no/fcltMngNo, PPL은 기존 gisManagementNo. 조사 결과 표에 GIS 번호를 명시한다. 결과등록 안내도 실제 GIS 맨홀번호로 정정했다.
|
||||
- BE ExmnManholeReportService: 기존 ExmnMapLayerService의 동일 지역·상류맨홀 매칭 결과 fclt_mng_no 집합으로 업로드 검토·열람 대상을 확인한다. 조사 관로번호를 맨홀번호로 대체하지 않는다. 같은 실제 맨홀에 여러 조사관로가 연결되면 번호 집합으로 처리한다. 스키마·조사시설·원천 GIS 변경 없음.
|
||||
- 검증: 실제 GIS188001/조사관로188003 분리, PDF 검토·열람 성공, 조사관로 번호 직접 요청 거부 및 GIS 미연결 거부를 묶은 JUnit1건 통과. BE 컴파일·FE 타입검사·diff --check 통과. 임시 테스트 소스와 결과 정리 완료.
|
||||
- 실브라우저: 테스트 사업→초이처리분구→지도3개 표시→실제188001 클릭→사업명 테스트/GIS 맨홀번호188001 확인→조사보고서 보기→1쪽 PDF와188001 표시 성공. 사용자도 최종 정상동작 확인했다.
|
||||
- 실행 서버: 기존 재시작 승인 범위에서 코드 반영 후8883 PID27788로 유지한다(이전PID24204 종료). classpath/실행로그는 기존 .codex-tmp/manhole-backend-* 경로 유지. 종료명령 Stop-Process -Id 27788.
|
||||
- 롤백: 위 FE3개 소스와 ExmnManholeReportService의 이번 변경을 함께 복원한다. DB 롤백 없음. 잔여 리스크는 GIS 미연결 자료 열람 불가 및 대형 사업의 지도 매칭·PDF 판독 비용이며, 임의 위치/다른 사업 fallback을 하지 않는다.
|
||||
|
||||
@@ -15,3 +15,14 @@
|
||||
- 순번은 전체 건수 기준 역순으로 계산해 로컬 3002에서 1페이지 18~9, 2페이지 8~1 표출을 확인했다.
|
||||
- 원본 근거는 요구사항정의서 `SFR-011-001`, 화면설계서 8~9번 `SN_MN010102`이며 순번·제목 건수는 원본 필수항목이 아닌 공통 그리드 표시 규칙이다.
|
||||
- API, 조회조건, 연도 그룹 헤더와 Excel 다운로드 형식은 변경하지 않았다.
|
||||
|
||||
## 4. 2026-10-08 기본 검색기간 단년 변경
|
||||
|
||||
- 사용자 요청으로 디지털하수관로의 시도별 노후관로현황 기본 검색기간을 최근 3년에서 당해연도 1년으로 변경했다. 최초 진입·초기화·잘못된 연도값의 기본 처리가 동일한 정규화 함수를 사용한다.
|
||||
- 원본 근거: 요구사항정의서 `3. (기능)요구사항정의 및 추적표` 87행 `SFR-011-001`, 화면설계서 슬라이드 9 `SN_MN010102`. 화면설계서의 시작·종료 연도 예시는 모두 2026년이다.
|
||||
- 수정: `aged-pipe-status-by-ctpv-utils.ts`의 기본 시작연도와 비정상 시작연도 fallback만 당해연도로 변경. 사용자가 선택한 최대 3년 범위, 경과년도 기본 30년, API 계약·DB·지도 기능·Excel 적용조건 처리는 유지한다.
|
||||
- 로컬 3002 실제 브라우저: 최초 진입 `2026~2026` → 2026년 단일 그룹/10행, 직접 `2024~2026` 선택 후 조회 → 3개 연도 그룹, 초기화 → `2026~2026` 복귀 확인. 해당 API 요청 모두 HTTP 200.
|
||||
- 기본값·초기화 입력·비정상 연도·명시적 3년 유지·4년 제한 등 6개 확인과 `git diff --check` 통과. 사용자 개발 서버가 실행 중이므로 빌드/서버 재시작은 하지 않았다. 일회성 테스트 파일은 생성하지 않았다.
|
||||
- 시도 집계 목록의 기존 클라이언트 페이징 예외(18개 고정 집계)는 변경하지 않았다. 지도 호출 영향 없음. Excel은 동일한 마지막 적용조건을 사용하는 기존 코드 유지, 실제 다운로드 재검증은 이번 범위에서 수행하지 않았다.
|
||||
- 배포는 사용자가 수행한다. 원복은 기본 시작연도 및 fallback을 `currentYear - 2`로 되돌린다. 단년 조건의 성능 통과 여부는 배포 후 재시험 전까지 미확정이다.
|
||||
- 후속 결과서: 시스템명은 `디지털하수관로`, 표시 메뉴명에는 `SN01` 등 시험 ID를 붙이지 않는다. 사용자가 수정한 HWP를 기준으로 추가 편집하고, 실제 캡처는 마우스 커서가 보이지 않게 준비한다. 기존 측정 원본은 변경하지 않는다.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# 지반침하 상세정보·하수관로 목록 엑셀 다운로드 작업계획
|
||||
|
||||
- 작성일: 2026-10-22
|
||||
- 요구사항: SFR-010-010
|
||||
- 상태: 사용자 승인 후 구현 완료. 타입·XLSX 검증 완료, 실제 브라우저 클릭 검증은 미완료.
|
||||
- 작업 목적: 지도 우측 지반침하 상세정보에서 발생정보와 해당 반경 내 하수관로 목록을 하나의 XLSX 파일로 내려받는다.
|
||||
- 범위: FE만 수정한다. 공통 유틸은 사용자 지정 경로인 `apps/sn/src/shared/util`에 둔다.
|
||||
|
||||
## 1. 요구사항 및 원본 확인
|
||||
|
||||
| 근거 | 직접 확인한 내용 |
|
||||
| --- | --- |
|
||||
| 사용자 요청 | 우측 상세정보에 엑셀 다운로드 추가. 상세 상단을 첫 시트, 하수관로 목록을 둘째 시트로 구성. 다른 화면을 참조해 공통화하고 승인 후 구현 |
|
||||
| `COMMON_DOCS_ROOT/기준문서/요구사항정의서.xlsx` | `3. (기능)요구사항정의 및 추적표` 시트 82행, `SFR-010-010`, 싱크홀 발생 정보 지도 서비스. 지반침하 용어 변경, 연계 정보 지도 표출, 기본 반경 100m 및 사용자 입력 반경, 좌표 없는 자료의 목록 표출 근거 확인 |
|
||||
| `COMMON_DOCS_ROOT/기준문서/화면설계서.pptx` | 슬라이드 7, 화면 ID `SN_MN010101`, 하수관망도 > 지반침하정보. 발생정보 상세, 반경 내 관로 추출, 관로 행 클릭 강조, 발생이력 목록 다운로드 확인 |
|
||||
| 기존 작업카드 | 같은 폴더의 `001-fe-SFR-010-010-지반침하정보-작업카드.md` 확인. 현재 경로와 실제 구현을 기준으로 추가 계획 작성 |
|
||||
|
||||
원본에는 우측 상세정보를 두 시트로 내려받는 구체적 구성이 없으므로 이번 사용자 요청을 추가 요구사항으로 삼는다. 원본의 피해 인원 항목을 새로 추가하지 않고 현재 상세 상단에 표시되는 9개 항목을 내보낸다.
|
||||
|
||||
원본 파일 정보: 화면설계서 10,736,400바이트 / 수정일 2026-08-13, 요구사항정의서 124,187바이트 / 수정일 2026-08-06.
|
||||
|
||||
## 2. 현재 구현과 참조 결과
|
||||
|
||||
| 파일 | 확인 결과 / 활용 방향 |
|
||||
| --- | --- |
|
||||
| `app/(map-layout)/map/components/sinkhole/sinkhole-detail-panel.tsx` | `detail`, `nearbyPipes`, `radiusMeters`를 받아 발생정보 9개 항목과 관로 5개 열을 표시한다. 다운로드 버튼은 없다. 복구상태와 관로 용도는 공통코드 표시명 사용 |
|
||||
| `app/(map-layout)/map/components/sinkhole/sinkhole-popup-page.tsx` | 기존 지반침하 목록 다운로드는 적용조건 전체를 페이지 순회하여 수집하고 한글 헤더, 로딩 및 오류 안내를 적용한다. 해당 목록 기능은 유지 |
|
||||
| `src/shared/lib/download-excel.ts` | `xlsx` 동적 import, 한글 헤더, 열 너비, 시트명 정리, 압축 저장 참조. `sheetNameArray`는 현재 첫 이름만 사용하며 동일 구조 데이터의 대량 분할용이므로 이종 데이터 두 시트를 직접 표현하기 어렵다 |
|
||||
| `app/(map-layout)/map/components/aged-pipe-status-by-ctpv/aged-pipe-status-by-ctpv-export.ts` | `aoa_to_sheet`, 병합, 열 너비, 자동필터, `book_append_sheet`, XLSX 저장 방식 참조 |
|
||||
| `app/(map-layout)/map/ui/map-page-client.tsx` | 활성 지반침하 상세 탭의 상세와 관로 배열을 패널에 전달한다. 기존 조회 실패도 빈 배열이 되므로 관로 0건과 조회 실패를 현재 props만으로 구분할 수 없다 |
|
||||
|
||||
백엔드는 읽기 전용으로 현행 제약만 확인했다. `SinkholeQueryService.getNearbyPipes`, `getNearbyPipesByPoint` 모두 관로 조회 상한이 200건이다. 호출 인자로 전체 건수나 페이지를 요청할 수 없다.
|
||||
|
||||
## 3. 결과 파일과 UI 제안
|
||||
|
||||
- 버튼: 사용자 최종 후속 요청에 따라 하수관로 목록의 `침하발생 반경내 관로정보` 제목과 같은 헤더 행의 오른쪽에 배치한다. 하수처리구역 상세의 `보수이력`과 동일한 Button 기본 크기와 색상·여백 클래스를 사용한다. 좁은 패널에서는 제목 줄바꿈을 허용하고 버튼 크기를 유지한다.
|
||||
- 파일명: `지반침하정보_사고번호_YYYY-MM-DD.xlsx`. 파일명에 쓸 수 없는 문자는 정리하고 날짜는 사용자 로컬 날짜를 사용한다.
|
||||
- 두 시트의 순서는 고정한다.
|
||||
|
||||
| 순서 / 시트명 | 구성 |
|
||||
| --- | --- |
|
||||
| 1 / `지반침하정보` | `항목`, `내용`의 세로 표. 사고번호, 발생일자, 주소, 발생규모폭, 발생규모연장, 발생규모깊이, 상세발생원인, 복구상태, 복구완료일자 순으로 현재 상세와 동일한 표시값 사용 |
|
||||
| 2 / `하수관로목록` | 상단에 사고번호와 조회 반경(m)을 표시하고, `NO`, `관로 ID`, `용도`, `경과년도(년)`, `거리(m)` 열로 관로 목록 구성. 조회 순서를 유지하고 자동필터는 목록 헤더부터 적용 |
|
||||
|
||||
- 첫 시트는 현재 `summaryTableData`와 `summaryHeadList`를 재사용해 화면과 엑셀의 값·항목 순서가 달라지지 않게 한다.
|
||||
- 관로 용도는 현재 화면의 공통코드명 변환 함수를 재사용한다. 코드명 미확보 시 화면과 동일하게 원래 코드를 사용한다.
|
||||
- 관로 ID와 사고번호는 문자열로 보존한다. 숫자형 경과년도·거리는 숫자로 저장하며 헤더에 단위를 표기한다. 결측값은 `-`로 처리하고 0은 유지한다.
|
||||
- 관로가 정상 조회되어 0건이면 둘째 시트에 헤더와 `반경 내 하수관로 정보가 없습니다.` 안내를 넣어 두 시트를 모두 생성한다.
|
||||
- 좌표가 없는 상세도 첫 시트는 다운로드할 수 있게 하고, 둘째 시트에는 좌표 부재로 관로를 조회할 수 없다는 안내를 넣는다.
|
||||
- 다운로드 중에는 버튼을 비활성화하고 진행 상태를 표시한다. 실패하면 안내 후 버튼을 복구한다.
|
||||
|
||||
## 4. 공통화 설계
|
||||
|
||||
### 신규 공통 유틸
|
||||
|
||||
경로: `apps/sn/src/shared/util/download-excel-workbook.ts`
|
||||
|
||||
`downloadExcelWorkbook({ fileName, sheets })` 형태로 서로 다른 구조의 시트를 순서대로 받는다. 각 시트는 시트명, 명시적 셀 행 배열, 열 너비, 선택적인 자동필터 범위·병합 범위를 전달한다. 셀 값은 문자열·숫자·불리언·null로 제한하고 업무 데이터나 API 호출은 유틸에 넣지 않는다.
|
||||
|
||||
공통 책임은 XLSX 동적 import, 워크북/시트 생성, 시트명 금지문자·31자 제한·중복 이름 정리, 열 너비 설정, 자동필터 및 병합 적용, 파일 확장자·파일명 정리, 브라우저 저장이다. 문자열을 수식으로 전환하지 않는다. 빈 데이터에서도 전달된 헤더·안내 행을 저장할 수 있어야 한다.
|
||||
|
||||
기존 `src/shared/lib/download-excel.ts`의 공개 인터페이스와 호출부는 이번에 변경하지 않는다. 두 함수의 목적이 달라 기존 목록 다운로드 전체를 이관하는 작업은 제외하고 새 기능에서 공통 유틸을 사용한다. 다른 화면도 이종 시트가 필요할 때 같은 유틸을 사용할 수 있다.
|
||||
|
||||
### 지반침하 전용 변환
|
||||
|
||||
경로: `app/(map-layout)/map/components/sinkhole/sinkhole-detail-excel.ts`
|
||||
|
||||
화면에서 전달한 발생정보 표시값, 관로 표시값, 사고번호, 반경을 XLSX 시트 명세로 변환한다. 지반침하 항목명과 빈 목록·좌표 부재 안내는 이 파일에서 관리하여 공통 유틸과 분리한다.
|
||||
|
||||
## 5. 데이터 범위와 오류 처리
|
||||
|
||||
- 이번 다운로드 대상은 선택한 사고의 현재 적용 반경에 대해 기존 API가 제공하는 관로 목록이다. 전체 사고 목록이나 전체 하수관로를 내보내는 기능이 아니다.
|
||||
- 기존 관로 API가 최대 200건을 반환하므로 FE만으로 200건 초과 전체를 보장할 수 없다. 둘째 시트에 `기존 반경 조회 기준, 최대 200건`을 표시한다. 200건 초과 전체 추출은 백엔드 계약 보완이 필요한 별도 범위다.
|
||||
- 기존 패널 props의 빈 배열은 조회 실패와 0건을 구분하지 못한다. 이를 숨기지 않도록 다운로드 클릭 시 기존 조회 API로 선택 사고·반경의 관로 목록을 다시 확인한다. 새 API는 만들지 않는다.
|
||||
- 유효한 상세 x/y가 있으면 기존 `nearbyPipesByPoint`, 없으면 좌표 조회 가능 상태를 확인한 뒤 기존 사고번호별 `nearbyPipes`를 사용한다. 좌표 부재 상태는 조회를 생략하고 해당 사유를 시트에 남긴다.
|
||||
- 요청 실패·비정상 응답은 0건으로 바꾸지 않고 다운로드를 중단해 안내한다. 정상 응답의 빈 `items`만 0건으로 처리한다.
|
||||
- 클릭 시 활성 상세, 반경, 코드명 정보를 고정하여 비동기 요청 도중 다른 사고로 이동해도 서로 다른 사고 정보가 섞이지 않게 한다.
|
||||
- 재조회 중 데이터가 변경될 수 있으므로 두 번째 시트는 다운로드 시점의 같은 사고·반경 조회 결과다. 지도·화면의 상태를 다운로드 때문에 갱신하지 않는다.
|
||||
|
||||
## 6. 수정 예정 파일
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
| --- | --- |
|
||||
| `src/shared/util/download-excel-workbook.ts` | 신규. 업무에 독립적인 다중 시트 XLSX 생성/저장 |
|
||||
| `app/(map-layout)/map/components/sinkhole/sinkhole-detail-excel.ts` | 신규. 발생정보/관로 두 시트 구성 및 기존 API 재조회 처리를 담당. API 호출은 공통 유틸에 포함하지 않음 |
|
||||
| `app/(map-layout)/map/components/sinkhole/sinkhole-detail-panel.tsx` | 버튼, 진행/오류 처리, 기존 표시값 전달 |
|
||||
| 이 작업계획 문서 | 승인 및 구현 후 검증 결과 기록 |
|
||||
|
||||
백엔드, 의존성, 공용 패키지 및 다른 페이지의 코드는 변경하지 않는다. 기존에 수정 중인 파일은 덮어쓰지 않는다. 메인 지도 상태 변경은 현재 계획에서 제외한다.
|
||||
|
||||
## 7. 구현 절차
|
||||
|
||||
1. 공통 워크북 유틸을 작성한다. 업무 데이터와 분리해 서로 다른 행 구조를 가진 시트를 하나의 파일로 생성할 수 있게 한다.
|
||||
2. 지반침하 전용 변환·조회 함수를 작성한다. 클릭 시 사고와 반경을 고정하고 기존 API 응답을 검증한 뒤 발생정보와 관로 시트 명세를 만든다.
|
||||
3. 우측 상세에 다운로드 버튼을 연결한다. 현재 표시값을 재사용하고 진행 중 중복 실행과 오류 안내를 처리한다.
|
||||
4. 아래 검증을 수행하고 결과를 기록한다. 파일 자체와 실제 다운로드 흐름이 요구사항에 맞는지 확인한 뒤 완료 보고한다.
|
||||
|
||||
## 8. 검증 및 완료 기준
|
||||
|
||||
- `git diff --check`, `pnpm --filter sn run typecheck`로 변경 오류를 확인한다.
|
||||
- 사용자의 개발 서버가 실행 중이면 종료·재시작 및 `next build`를 하지 않는다. 실제 앱 포트와 실행 상태를 확인한 뒤 가능한 환경에서 UI를 검증한다.
|
||||
- 실제 XLSX 파일을 다시 읽어 시트가 정확히 두 개이고 순서·이름·9개 발생정보 항목·관로 5개 열·행 수·값이 맞는지 확인한다.
|
||||
- 정상 관로 여러 건, 정상 0건, 좌표 없음, API 실패, 비정상 응답, 코드명/원코드 대체, 결측값과 0, 숫자형 ID의 문자열 보존, 중복 클릭을 확인한다.
|
||||
- 반경 변경 후 다운로드와 활성 사고 탭 전환 중 다운로드에서 사고·반경이 섞이지 않는지 확인한다.
|
||||
- 한글·긴 주소·발생원인의 열 너비와 줄바꿈, 필터 위치, 파일명과 날짜를 확인한다. XLSX 라이브러리에서 지원하지 않는 장식 스타일을 완료 조건으로 약속하지 않는다.
|
||||
- 기존 관로 행 클릭·지도 강조·닫기·다른 상세 탭과 기존 지반침하 목록 다운로드가 유지되는지 확인한다.
|
||||
- 검증용 파일/스크립트는 결과 기록 후 정리하며 사용자 원본과 다른 작업자의 파일은 건드리지 않는다.
|
||||
|
||||
## 9. 영향 범위·롤백·잔여 제약
|
||||
|
||||
- 영향: 지반침하 우측 상세의 다운로드 기능과 신규 FE 공통 유틸에 한정한다. 기존 조회·지도·목록 다운로드 계약은 유지한다.
|
||||
- 롤백: 패널의 이번 버튼/핸들러/import 변경만 되돌리고 신규 전용 함수와 신규 공통 유틸을 제거한다. 기존 타 작업 변경은 보존한다.
|
||||
- 잔여 제약: 최대 200건 API 상한, 다운로드 시점 재조회와 기존 화면 사이의 데이터 갱신 차이, 대량 XLSX 생성의 브라우저 메모리 사용. 전체 관로 추출이나 서버 생성 방식은 별도 요청/승인 범위다.
|
||||
|
||||
## 10. 승인 및 구현 결과
|
||||
|
||||
- 사용자 승인: 이 작업계획대로 코드 수정 요청을 받아 구현했다.
|
||||
- 신규 공통 유틸 `src/shared/util/download-excel-workbook.ts`: `buildExcelWorkbook`으로 시트 생성과 다운로드를 분리하고 `downloadExcelWorkbook`으로 브라우저 저장을 제공한다. 동적 XLSX 로딩, 시트 순서, 이름 중복/금지문자/길이 정리, 파일명 정리, 열 너비·필터·병합을 지원한다.
|
||||
- 신규 `sinkhole-detail-excel.ts`: 발생정보 9개 항목과 관로 5개 열을 두 시트로 구성한다. API 재조회 실패와 비정상 응답은 다운로드를 중단하고, 정상 0건·좌표 부재는 별도 안내를 내보낸다. 사고·반경·표시 행은 재조회 전에 복사해 고정한다.
|
||||
- 수정 `sinkhole-detail-panel.tsx`: 하수관로 목록 제목 오른쪽에 버튼을 배치했다. 사용자 후속 요청에 따라 하수처리구역 상세의 보수이력 버튼과 같은 기본 크기·색상·여백을 적용했다. 진행 상태를 state로 표시하고 ref로 즉시 중복 실행을 막으며 오류 후 상태를 복구한다.
|
||||
- 기존 목록 다운로드, 메인 지도, 백엔드, 의존성 및 타 페이지는 수정하지 않았다. 공통 유틸 우선 검토 기준문서 추가는 별도 제안 단계이므로 이번 구현 범위에 포함하지 않았다.
|
||||
|
||||
### 검증 결과
|
||||
|
||||
| 항목 | 결과 / 한계 |
|
||||
| --- | --- |
|
||||
| `pnpm --filter sn run typecheck` | 통과 |
|
||||
| `git diff --check` | 통과 |
|
||||
| XLSX 실제 직렬화 후 재읽기 | 두 시트 이름·순서, 첫 시트 9개 항목, 둘째 시트 5개 열, 필터 범위, 관로 순서 확인 |
|
||||
| 데이터 형식 | 문자열 관로 ID `00123`의 앞자리 0 보존, 용도명/원코드 대체, 경과년도·거리의 숫자 0과 결측 `-` 확인 |
|
||||
| 예외 / 빈 목록 | 정상 0건 안내, 좌표 부재 시 API 요청 생략 및 안내, 요청 실패·비정상 응답 시 저장 중단 확인 |
|
||||
| 비동기 데이터 고정 | 조회 중 호출자의 사고번호·반경·요약 행을 바꿔도 파일에는 클릭 시 데이터 유지 확인 |
|
||||
| 공통 유틸 | 금지문자, 대소문자 중복, 31자 제한, 빈 시트, 수식처럼 보이는 문자열의 일반 문자열 저장 확인 |
|
||||
| API 분기 / 파일명 | 좌표 기준 및 사고번호 기준 요청, 사고번호 URL 인코딩, 파일명 금지문자 정리 확인 |
|
||||
| 개발 서버 | 기존 3002 포트 서버 유지, `/map` HTTP 200 확인. 빌드·재시작하지 않음 |
|
||||
| 브라우저 / 실제 API | 연결된 브라우저가 없어 실제 버튼 클릭·다운로드·지도 회귀 및 실제 API 응답 검증은 미완료. 위 데이터/예외 검증은 요청·저장 함수를 대체한 메모리 기반 검증이며 XLSX 직렬화/재읽기는 실제 라이브러리 사용 |
|
||||
| 시각 서식 | 열 너비·병합 설정 확인. 줄바꿈 스타일은 라이브러리에 전달하되 무료 SheetJS의 지원 및 Excel 실제 화면은 미검증 |
|
||||
|
||||
검증 코드는 셸 표준입력으로 실행했고 디스크에 테스트 파일·XLSX를 남기지 않았다. 롤백과 최대 200건 제약은 9절을 따른다.
|
||||
@@ -41,3 +41,41 @@
|
||||
- 롤백: `waterRiskClient.ts`를 제거하고 popup의 실제 조회/부모 메시지 변경 및 `map/page.tsx` 통수위험 mapView 처리를 되돌린다.
|
||||
- 자동화 도구가 새 창 popup을 부모 탭과 동시에 제어하는 데 제한이 있어, popup의 실제 mapView 요청과 부모 메시지 처리 코드를 각각 확인했다. 최종 사용자 회귀 시 부모 지도에서 popup을 열고 한 행을 선택하는 시나리오를 한 번 더 수행할 수 있다.
|
||||
- 현재누적강우량은 백엔드 원천 미확보로 `-`이며, 예측강우 격자 overlay는 이번 합리식 결과 범위에 포함하지 않는다.
|
||||
|
||||
## 5. 2026-10-20 위험(매핑관로없음)·분석시각 컬럼·분석시각 기간 검색
|
||||
|
||||
- 근거: `COMMON_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/004-de-...-매핑관로없음위험-분석시각-작업계획.md` 1~8 승인. 원본 화면설계서 19·21쪽(SN_MN010201·010202), 요구사항 SFR-011-007.
|
||||
- `components/water-risk/water-risk-types.ts`: `WaterRiskStatus`에 `DANGER_UNMAPPED` 추가, 라벨 `위험(매핑관로없음)`, 위험 색 판별 `isWaterRiskDanger()`.
|
||||
- `components/water-risk/water-risk-popup-page.tsx`
|
||||
- 목록: `중점관리지역` 다음 `분석시각` 컬럼(정렬 가능, 컬럼 id = BE 정렬 키 `analysisBaseAt`), 컬럼 폭 재배분, 최소 폭 124rem → 136rem, 위험 배지 줄바꿈 방지.
|
||||
- 과거이력 검색패널: `분석시각` 기간(시작 날짜·시 ~ 종료 날짜·시, 선택). 기본·초기화는 빈 값이며 적용 시 `validateWaterRiskAnalysisPeriod`로 검증한다. 위험상태 필터에 `위험(매핑관로없음)` 추가. 하단 판정 안내 문구 갱신.
|
||||
- 현재 탭은 기존처럼 현재 정시 `analysisBaseAt`만 보낸다.
|
||||
- `components/water-risk/water-risk-client.ts`: `analysisBaseFrom`·`analysisBaseTo`를 둘 다 있을 때만 쿼리스트링에 넣는다.
|
||||
- `components/water-risk/water-risk-date-range.ts`: `buildWaterRiskAnalysisDateTime`, `validateWaterRiskAnalysisPeriod`(짝 입력, 시작 ≤ 종료, 종료 ≤ 현재 정시, 366일) 추가.
|
||||
- `lib/water-risk-styles.ts`, `ui/map-page-client.tsx`: 지도 구역 색·정보카드 배지를 `isWaterRiskDanger()`로 판정해 위험(매핑관로없음)도 빨강으로 표시한다.
|
||||
- 검증: `git diff --check` 통과, `tsc --noEmit`(sn 전체) 통과, 변경 6개 파일 ESLint 문제 수가 수정 전(HEAD)과 동일(기존 `map-page-client.tsx` 46 errors 등은 baseline). 개발 서버가 꺼져 있어 화면 확인은 미실행, `next build` 미실행.
|
||||
- 롤백: 위 6개 파일을 되돌린다.
|
||||
- 2026-10-20 추가: BE mapView 위험상태가 목록과 같은 대표결과 판정값으로 바뀌어, `lib/use-water-risk-layer.ts`의 정보카드 상태 우선순위 주석만 갱신했다(동작 변경 없음).
|
||||
|
||||
## 6. 2026-10-21 지도 조회 키를 SWMM 실행 ID로 변경
|
||||
|
||||
- 근거: `COMMON_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/004-de-...-작업계획.md` 15절 9~13 승인.
|
||||
- `components/water-risk/water-risk-types.ts`: `WaterRiskResultRow.executionId`, `WaterRiskMapView.executionId·analysisBaseAt·rainStartedAt`, `WaterRiskSessionSnapshot.selectedExecutionId` 추가.
|
||||
- `components/water-risk/water-risk-client.ts`: 지도 조회를 `v1/waterRisk/executions/{executionId}/mapView`로 변경(날짜·지역 경로 미사용).
|
||||
- `components/water-risk/water-risk-popup-page.tsx`: 행 식별 키를 `executionId`로 변경(없으면 기존 조합), 부모 동기화 메시지에 `selectedExecutionId`가 있으면 그 행만 선택 표시.
|
||||
- `lib/use-water-risk-layer.ts`: 지도 응답과 행의 실행 ID가 다르면 무시, 세션에 `selectedExecutionId` 저장.
|
||||
- 검증: `tsc --noEmit` 통과, 변경 4개 파일 ESLint 문제 수 수정 전과 동일, `git diff --check` 통과. 화면 확인은 BE 재기동 후 필요(현재 BE는 이전 코드라 새 지도 API 없음).
|
||||
- 롤백: 위 4개 파일 복원. BE의 구 지도 API가 유지되는 동안은 FE만 되돌려도 기존 동작으로 복귀한다.
|
||||
|
||||
## 7. 2026-10-21 과거이력 검색조건 순서·명칭·시간 단위 입력
|
||||
|
||||
- 근거: `COMMON_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/004-de-...-작업계획.md` 16절 결정 14~17 승인. 원본 화면설계서 19쪽 SN_MN010201, 요구사항 SFR-011-007.
|
||||
- `components/water-risk/water-risk-popup-page.tsx`
|
||||
- 과거이력 검색조건 순서를 시도 → 시군구 → 중점관리지역 → 분석시각 → 강우시각 → 위험상태로 바꿨다(현재 탭은 중점관리지역만).
|
||||
- 명칭: 검색란 `강우 발생기간` → `강우시각`, 목록 컬럼 `강우시작시간` → `강우시작시각`(컬럼 id·정렬 키 `rainStartedAt` 유지).
|
||||
- 분석시각·강우시각 입력을 침수예측현황과 같은 `datetime-local` 시작 `~` 종료(`w-[17rem]`)로 바꾸고 `step={3600}`을 줬다. 분은 00으로 고정되고, 분이 들어오면 저장 전에 00으로 내린다. 현재 정시 이후는 `max`로 막는다.
|
||||
- 검색조건 상태를 `rainStartedFrom`·`rainStartedTo`(필수, 기본 365일 전 00시 ~ 현재 정시), `analysisFrom`·`analysisTo`(선택)로 정리했다. 조회 시 `yyyy-MM-ddTHH:00:00`으로 보내고, `startDate`·`endDate`는 강우시각 날짜로 맞춘다. 현재 탭은 기존처럼 최근 1년 날짜와 현재 정시 `analysisBaseAt`만 보낸다.
|
||||
- `components/water-risk/water-risk-date-range.ts`: 시간 단위 값 변환(`toWaterRiskHourValue`·`formatWaterRiskHourValue`·`toWaterRiskApiDateTime`)과 기간 검증(`validateWaterRiskRainStartedPeriod` 필수, `validateWaterRiskAnalysisPeriod` 선택: 짝 입력·정시·시작 ≤ 종료·종료 ≤ 현재 정시·366일)으로 정리. 오류 문구는 `강우시각…`/`분석시각…`.
|
||||
- `components/water-risk/water-risk-client.ts`: `rainStartedFrom`·`rainStartedTo`를 둘 다 있을 때만 전달.
|
||||
- 검증: `tsc --noEmit`(sn 전체) 통과, 변경 3개 파일 ESLint 0건(수정 전과 같음), `git diff --check` 통과. `step=3600` 입력의 분 고정은 앱 내장 브라우저(Chromium 152)에서 따로 확인했다. 로그인이 필요해 실제 팝업 화면은 미확인, `next build` 미실행.
|
||||
- 롤백: 위 3개 파일을 되돌린다. BE의 새 파라미터는 선택값이라 FE만 되돌려도 동작한다.
|
||||
|
||||
@@ -77,3 +77,53 @@
|
||||
- `fldng-predc-prst-excel.ts`가 화면 열 순서의 단일 시트 `침수예측현황.xlsx`를 브라우저에서 생성한다.
|
||||
- 서버 `/prst/excel` Blob 호출과 `getBlobData`, `fileDownload` 사용을 제거했다.
|
||||
- 독립 TypeScript `--noEmit` 검증을 통과했다.
|
||||
|
||||
## 2026-10-21 강우 명칭·시간 단위 입력 통일 계획 (2026-10-21 승인·구현)
|
||||
|
||||
### 배경
|
||||
|
||||
- 사용자 지시(2026-10-21)
|
||||
- 강우 관련 명칭: 검색란 `강우시각`, 목록 `강우시작시각`. 침수예측현황도 바꿔야 하면 이 문서에 추가한 뒤 승인 후 수정한다.
|
||||
- 검색란의 분석시각·강우시각은 `datetime-local`(`2026-10-21 오후 03:00` 형태)로 두되 **시간까지만 선택**한다. 분은 `00`으로 고정하고 수정할 수 없다. 침수예측현황도 같이 수정한다.
|
||||
- 통수위험 쪽 계획: `COMMON_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/004-de-SFR-011-007-통수위험분석-매핑관로없음위험-분석시각-작업계획.md` 16절.
|
||||
|
||||
### 현재 명칭
|
||||
|
||||
| 위치 | 현재 | 기준과 일치 |
|
||||
| --- | --- | --- |
|
||||
| 검색란 라벨 (`ui/fldng-predc-prst-popup-content.tsx`) | `강우시각` | 예 |
|
||||
| 목록 컬럼 (`ui/fldng-predc-prst-popup-content.tsx`) | `강우시작시각` | 예 |
|
||||
| 지도 정보 (`lib/use-flood-prediction-map.ts`) | `강우시작시각` | 예 |
|
||||
| 검색 오류 문구 (`ui/fldng-predc-prst-popup-content.tsx` `validateFilters`) | `강우 시작시각의 시작은 종료보다 늦을 수 없습니다.` | 아니오(검색란 이름은 `강우시각`) |
|
||||
| Excel 머리글 (`lib/fldng-predc-prst-excel.ts` `HEADERS.rainBeginningDateTime`) | `강우 시작시각` | 아니오(목록은 붙여 쓴 `강우시작시각`) |
|
||||
|
||||
- 화면에 보이는 라벨과 목록 컬럼 이름은 이미 기준과 같다. 이름은 문구 2곳만 맞춘다.
|
||||
|
||||
### 시간 단위 입력 결론
|
||||
|
||||
- 현재 분석시각·강우시각 입력(`renderDateTimeRange`)은 `datetime-local`이라 분까지 고를 수 있다.
|
||||
- `step={3600}`(1시간)을 주면 값의 분이 `00`일 때 브라우저가 분 칸을 고정해 바꿀 수 없다. 빈 값에서 처음 입력할 때처럼 분이 들어오는 경우는 `onChange`에서 분을 `00`으로 맞춘다. 입력 폭(`w-[17rem]`)과 표시 형태는 그대로다.
|
||||
- API는 지금처럼 `yyyyMMddHHmm`을 보내며 분만 항상 `00`이 된다. BE는 문자열 시작 이상·종료 이하로 비교하므로(`FldngPredcPrstRepository.addRange`) **BE 변경은 없다.**
|
||||
- 개발 DB `tb_sn_fldng_predc_anls` 208건의 분석시각(`obsrvn_tm`)·강우시작시각(`rain_bgng_tm`)이 모두 정시(분 `00`)라서 시간 단위 조건으로 빠지는 자료는 없다.
|
||||
|
||||
### 수정 계획
|
||||
|
||||
| 번호 | 파일 | 변경 |
|
||||
| --- | --- | --- |
|
||||
| 1 | `src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx` | 오류 문구 `강우 시작시각의 시작은 종료보다 늦을 수 없습니다.` → `강우시각의 시작은 종료보다 늦을 수 없습니다.` |
|
||||
| 2 | `src/features/fldng-predc-prst/lib/fldng-predc-prst-excel.ts` | Excel 머리글 `강우 시작시각` → `강우시작시각` |
|
||||
| 3 | `src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx` | `renderDateTimeRange`의 시작·종료 입력 4개(분석시각·강우시각)에 `step={3600}` 추가, `onChange`에서 분을 `00`으로 맞춰 저장 |
|
||||
|
||||
- 바꾸지 않는 것: API 파라미터·정렬 키, 기본값(빈 값), 분석도구 메뉴의 침수예측 건수 조회 범위(`model/analysis-time-range.ts`, 입력란과 무관).
|
||||
|
||||
### 검증·영향·롤백
|
||||
|
||||
- 검증: 분석시각·강우시각 입력에서 분 칸이 `00`으로 고정되는지, 빈 값에서 새로 입력해도 분이 `00`이 되는지 확인. 시작 > 종료로 강우시각을 넣어 바뀐 오류 문구 확인, 조회 요청의 시각 파라미터 끝 두 자리가 `00`인지 확인, Excel 다운로드 머리글 확인. `tsc --noEmit`·`git diff --check`.
|
||||
- 영향: 침수예측현황 팝업의 검색 입력 4개, 오류 문구, Excel 머리글 1칸. API·DB·지도 영향 없음.
|
||||
- 롤백: 위 2개 파일을 되돌린다.
|
||||
|
||||
### 구현 결과 (2026-10-21)
|
||||
|
||||
- 계획 1~3을 그대로 반영했다. `ui/fldng-predc-prst-popup-content.tsx`: 오류 문구 `강우시각의 시작은 종료보다 늦을 수 없습니다.`, 분석시각·강우시각 입력 4개에 `step={3600}`과 분 00 고정(`toHourInputValue`). `lib/fldng-predc-prst-excel.ts`: Excel 머리글 `강우시작시각`.
|
||||
- 검증: `tsc --noEmit`(sn 전체) 통과, `git diff --check` 통과. ESLint는 수정 전과 같은 1건(기존 미사용 import `createPredictionAnalysisTimeRange`, 이번 범위 밖). 로그인이 필요해 실제 팝업 화면과 Excel 다운로드는 미확인.
|
||||
- BE·API·DB 변경 없음.
|
||||
|
||||
@@ -0,0 +1,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 통과. 실브라우저 입력 검증은 미수행.
|
||||
- 수정 소스: `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 |
|
||||
| 빈도 제한 | 부모 `flterSql`을 WMS `CQL_FILTER`로 전달 |
|
||||
| 침수심도 스타일 | 자식 5건 기반 동적 SLD |
|
||||
| 침수심도 스타일 | GeoServer 레이어 기본 스타일(2026-10-16부터, 11절). 이전에는 자식 5건 기반 동적 SLD |
|
||||
| 범례 | 숨겨진 자식 설정의 기존 색상·명칭 유지 |
|
||||
| 100년 빈도 | 화면 및 WMS 전환 대상에서 제외 |
|
||||
| 기존 다른 레이어 | 기존 WFS 경로 유지 |
|
||||
@@ -88,3 +88,16 @@
|
||||
- 운영에서 행 선택 후 그룹 extent API가 `ST_Envelope` 스키마 해석 오류로 실패해 지도 이동이 중단된 원인을 백엔드 SQL에서 보완했다.
|
||||
- 기존 시군구·침수빈도 그룹 범위 이동 계약과 화면 동작은 유지한다.
|
||||
- 재배포 후 목록 행 클릭, 지도 중심 이동, 선택 빈도 WMS 표출을 회귀 확인한다.
|
||||
|
||||
## 11. 2026-10-16 WMS SLD_BODY 제거
|
||||
|
||||
- 근거: `COMMON_DOCS_ROOT/작업계획/SN-도시침수지도-정보제공포털-목록보기/003-de-SN-도시침수지도-WMS502-SLD_BODY제거-작업계획.md` (SFR-011-008·009, 화면설계서 24·28쪽 SN_MN010205·SN_MN010206)
|
||||
- 운영에서 `SLD_BODY`가 포함된 WMS 요청이 nginx 502로 실패해, 침수심도 스타일을 GeoServer 레이어 기본 스타일로 옮기고 요청에서 `SLD_BODY`를 뺐다.
|
||||
- `gis.layer.js`
|
||||
- `getWmsLayer()`에서 도시침수지도 `SLD_BODY` 생성 분기와 `gis.wms-sld.js` import를 제거했다.
|
||||
- `STYLES=''`(레이어 기본 스타일), 빈도 `CQL_FILTER`, `drawOrdr`, `minViewSize`는 그대로다.
|
||||
- `gis.wms-sld.js`: 삭제했다(사용처가 `gis.layer.js` 하나뿐).
|
||||
- 범례(`water-depth.tsx`)는 기존처럼 숨겨진 자식 5건의 DB 색상을 쓴다. GeoServer 기본 스타일이 제공 SLD(`tb_ms_flood_dngr_map_depth.sld`)로 교체되어야 지도 색과 범례가 일치한다.
|
||||
- 검증: 남은 참조 없음, `git diff --check`·ESLint 통과, 개발 GeoServer 동일 파라미터 요청 HTTP 200 PNG.
|
||||
- 미수행: 사용자 개발 서버 실행 중이라 `business_components`·`sn` 빌드 보류, 로그인 필요로 브라우저 Network 확인 보류.
|
||||
- 롤백: `gis.layer.js`의 SLD 분기와 `gis.wms-sld.js`를 복원한다.
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FLDNG_HSTRY_LAYER_NAME,
|
||||
FLDNG_HSTRY_LAYER_TYPE,
|
||||
FLDNG_HSTRY_MIN_YEAR,
|
||||
FLDNG_HSTRY_MAX_YEAR,
|
||||
FLDNG_HSTRY_PREVIEW_LAYER_TYPE,
|
||||
FLDNG_HSTRY_ROOT_NAME,
|
||||
type FldngHstryApiItem,
|
||||
@@ -50,11 +51,10 @@ export const formatFldngHstryDateTime = (
|
||||
return [normalizedDate, normalizedTime].filter(Boolean).join(" ");
|
||||
};
|
||||
|
||||
export const buildFldngHstryDefaultYearRange = (
|
||||
currentYear = new Date().getFullYear(),
|
||||
): FldngHstryYearRange => ({
|
||||
/** 침수흔적도 초기 조회 범위를 제공 자료의 연도 범위로 설정한다. */
|
||||
export const buildFldngHstryDefaultYearRange = (): FldngHstryYearRange => ({
|
||||
startYear: String(FLDNG_HSTRY_MIN_YEAR),
|
||||
endYear: String(currentYear),
|
||||
endYear: String(FLDNG_HSTRY_MAX_YEAR),
|
||||
});
|
||||
|
||||
export const normalizeFldngHstryYearRange = (
|
||||
|
||||
@@ -14,6 +14,8 @@ export const FLDNG_HSTRY_DEPTH_LAYER_ID = "SN_FLOOD_HISTORY_DEPTH";
|
||||
export const FLDNG_HSTRY_PAGE_SIZE = 10;
|
||||
export const FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE = 2000;
|
||||
export const FLDNG_HSTRY_MIN_YEAR = 2002;
|
||||
/** 침수흔적도 연도 선택 목록과 초기 조회 범위의 최대 연도다. */
|
||||
export const FLDNG_HSTRY_MAX_YEAR = 2025;
|
||||
|
||||
export type FldngHstryDetailCacheValue = {
|
||||
rainAnalysis: Record<string, unknown>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DREDGING_EXTERNAL_MODE } from "../model/disclosure";
|
||||
/** 준설 조회·등록·수정·삭제와 최근접 관로 HTTP 계약을 담당한다. */
|
||||
import {
|
||||
deleteData,
|
||||
@@ -110,12 +111,13 @@ export const fetchDredgingRecords = async (params: {
|
||||
/** 등록된 준설사진의 공통 첨부파일 참조를 조회한다. */
|
||||
export const fetchDredgingPhotos = async (
|
||||
dredgingJobSn: string | number,
|
||||
pipeFid: string,
|
||||
pipeFid: string | null,
|
||||
targetSn?: number,
|
||||
): Promise<DredgingPhoto[]> => {
|
||||
if (!isDredgingBackendEnabled) return [];
|
||||
try {
|
||||
const body = await getRawJson(
|
||||
`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}/photos?fid=${encodeURIComponent(pipeFid)}`,
|
||||
`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}/photos?${targetSn ? `targetSn=${targetSn}` : `fid=${encodeURIComponent(pipeFid ?? "")}`}`,
|
||||
);
|
||||
return toDredgingPhotos(body);
|
||||
} catch (error) {
|
||||
@@ -137,18 +139,18 @@ export const updateDredgingRecord = async (
|
||||
try {
|
||||
await putData(`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}`, {
|
||||
dredgingDate: update.dredgingDate,
|
||||
pipeFid: update.originalPipeFid,
|
||||
targetSn: update.targetSn,
|
||||
registrationMode: update.registrationMode,
|
||||
location: {
|
||||
source: update.locationSource,
|
||||
longitude: update.location.longitude,
|
||||
latitude: update.location.latitude,
|
||||
},
|
||||
expectedPipe: {
|
||||
expectedPipe: update.pipe && !DREDGING_EXTERNAL_MODE ? {
|
||||
fid: update.pipe.fid,
|
||||
mngInstCd: update.pipe.mngInstCd,
|
||||
fcltMngNo: update.pipe.fcltMngNo,
|
||||
},
|
||||
} : null,
|
||||
retainedPhotoSns: update.retainedPhotoSns,
|
||||
newPhotos: update.newPhotos.map((photo) => {
|
||||
const attachment = attachments.get(photo.key);
|
||||
@@ -242,7 +244,7 @@ const mockPipeForLocations = (locations: LocationRequest[]): PipeMatch => {
|
||||
export const findNearestPipeMatches = async (
|
||||
locations: LocationRequest[],
|
||||
): Promise<Map<string, PipeMatch>> => {
|
||||
if (locations.length === 0) return new Map();
|
||||
if (DREDGING_EXTERNAL_MODE || locations.length === 0) return new Map();
|
||||
|
||||
if (!isDredgingBackendEnabled) {
|
||||
const mockPipe = mockPipeForLocations(locations);
|
||||
|
||||
@@ -55,7 +55,7 @@ export const toNearestPipeMatches = (body: any): Map<string, PipeMatch> => {
|
||||
|
||||
for (const item of items ?? []) {
|
||||
if (!item.selected) {
|
||||
failureMessages.push(nearestPipeFailureMessage(item));
|
||||
if (item.failureCode !== "PIPE_NOT_FOUND") failureMessages.push(nearestPipeFailureMessage(item));
|
||||
continue;
|
||||
}
|
||||
matches.set(item.locationKey, {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** 외부용 앱은 관망 정보를 요청하거나 화면에 표시하지 않는다. */
|
||||
export const DREDGING_EXTERNAL_MODE = false;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DREDGING_EXTERNAL_MODE } from "./disclosure";
|
||||
/** 준설 목록·집계 표의 컬럼·표시 변환. 서버 조회/React 상태와 분리한다. */
|
||||
import type { DredgingRecord } from "./types";
|
||||
import { MS_SWAGE_PPL_CODES } from "@/entities/facility/model/facility-code-fallbacks";
|
||||
@@ -103,6 +104,8 @@ export const buildDredgingPopupParams = (record: DredgingRecord) => ({
|
||||
sourcePath: window.location.pathname,
|
||||
id: record.id,
|
||||
dredgingJobSn: record.dredgingJobSn,
|
||||
targetSn: record.targetSn,
|
||||
mappingStatus: record.mappingStatus,
|
||||
pipeFid: record.pipeFid,
|
||||
mngInstCd: record.mngInstCd,
|
||||
ctpv: record.ctpv,
|
||||
@@ -140,7 +143,8 @@ export type TableHeadDefinition = {
|
||||
align?: "left" | "center" | "right";
|
||||
};
|
||||
|
||||
export const municipalityHeadlist: TableHeadDefinition[] = [
|
||||
const fullMunicipalityHeadlist: TableHeadDefinition[] = [
|
||||
{ id: "mappingStatusLabel", title: "관로 지정" },
|
||||
{ id: "rowNumber", title: "순번" },
|
||||
{ id: "ctpv", title: "시도" },
|
||||
{ id: "sgg", title: "시군구" },
|
||||
@@ -156,6 +160,10 @@ export const municipalityHeadlist: TableHeadDefinition[] = [
|
||||
{ id: "registeredDate", title: "등록일자" },
|
||||
];
|
||||
|
||||
export const municipalityHeadlist = DREDGING_EXTERNAL_MODE
|
||||
? fullMunicipalityHeadlist.filter(column => ["rowNumber", "ctpv", "sgg", "dredgingDate", "photoCount", "registeredDate"].includes(column.id))
|
||||
: fullMunicipalityHeadlist;
|
||||
|
||||
export const diameterHeadlist: TableHeadDefinition[] = [
|
||||
{ id: "ctpv", title: "시도" },
|
||||
{ id: "totalLengthKm", title: "관로연장(km)", align: "right" },
|
||||
@@ -202,33 +210,33 @@ export const buildDiameterRows = (records: DredgingRecord[]): TableRow[] =>
|
||||
roundKm(
|
||||
items
|
||||
.filter(predicate)
|
||||
.reduce((sum, record) => sum + record.pipeLengthM, 0),
|
||||
.reduce((sum, record) => sum + (record.pipeLengthM ?? 0), 0),
|
||||
);
|
||||
|
||||
return {
|
||||
ctpv,
|
||||
totalLengthKm: roundKm(
|
||||
items.reduce((sum, record) => sum + record.pipeLengthM, 0),
|
||||
items.reduce((sum, record) => sum + (record.pipeLengthM ?? 0), 0),
|
||||
),
|
||||
diameter450: sumLength((record) => record.pipeDiameterMm <= 450),
|
||||
diameter450: sumLength((record) => (record.pipeDiameterMm ?? 0) <= 450),
|
||||
diameter600: sumLength(
|
||||
(record) => record.pipeDiameterMm > 450 && record.pipeDiameterMm <= 600,
|
||||
(record) => (record.pipeDiameterMm ?? 0) > 450 && (record.pipeDiameterMm ?? 0) <= 600,
|
||||
),
|
||||
diameter700: sumLength(
|
||||
(record) => record.pipeDiameterMm > 600 && record.pipeDiameterMm <= 700,
|
||||
(record) => (record.pipeDiameterMm ?? 0) > 600 && (record.pipeDiameterMm ?? 0) <= 700,
|
||||
),
|
||||
diameter900: sumLength(
|
||||
(record) => record.pipeDiameterMm > 700 && record.pipeDiameterMm <= 900,
|
||||
(record) => (record.pipeDiameterMm ?? 0) > 700 && (record.pipeDiameterMm ?? 0) <= 900,
|
||||
),
|
||||
diameter1000: sumLength(
|
||||
(record) =>
|
||||
record.pipeDiameterMm > 900 && record.pipeDiameterMm <= 1000,
|
||||
(record.pipeDiameterMm ?? 0) > 900 && (record.pipeDiameterMm ?? 0) <= 1000,
|
||||
),
|
||||
diameter1200: sumLength(
|
||||
(record) =>
|
||||
record.pipeDiameterMm > 1000 && record.pipeDiameterMm <= 1200,
|
||||
(record.pipeDiameterMm ?? 0) > 1000 && (record.pipeDiameterMm ?? 0) <= 1200,
|
||||
),
|
||||
diameter1500: sumLength((record) => record.pipeDiameterMm >= 1500),
|
||||
diameter1500: sumLength((record) => (record.pipeDiameterMm ?? 0) >= 1500),
|
||||
recordIds: items.map((record) => record.id),
|
||||
};
|
||||
});
|
||||
@@ -239,7 +247,7 @@ export const buildElapsedYearRows = (records: DredgingRecord[]): TableRow[] =>
|
||||
roundKm(
|
||||
items
|
||||
.filter(predicate)
|
||||
.reduce((sum, record) => sum + record.pipeLengthM, 0),
|
||||
.reduce((sum, record) => sum + (record.pipeLengthM ?? 0), 0),
|
||||
);
|
||||
const elapsedYears = (record: DredgingRecord) =>
|
||||
record.installationYear === null
|
||||
@@ -249,7 +257,7 @@ export const buildElapsedYearRows = (records: DredgingRecord[]): TableRow[] =>
|
||||
return {
|
||||
ctpv,
|
||||
totalLengthKm: roundKm(
|
||||
items.reduce((sum, record) => sum + record.pipeLengthM, 0),
|
||||
items.reduce((sum, record) => sum + (record.pipeLengthM ?? 0), 0),
|
||||
),
|
||||
under10: sumLength((record) => {
|
||||
const elapsed = elapsedYears(record);
|
||||
|
||||
@@ -19,11 +19,11 @@ const buildRegistrationBody = (
|
||||
longitude: group.location.longitude,
|
||||
latitude: group.location.latitude,
|
||||
},
|
||||
expectedPipe: {
|
||||
expectedPipe: group.pipe ? {
|
||||
fid: group.pipe.fid,
|
||||
mngInstCd: group.pipe.mngInstCd,
|
||||
fcltMngNo: group.pipe.fcltMngNo,
|
||||
},
|
||||
} : null,
|
||||
photos: group.photos.map((photo) => {
|
||||
const attachment = attachments.get(photo.key);
|
||||
if (!attachment && requireAttachments) {
|
||||
|
||||
@@ -59,32 +59,35 @@ export type DredgingDraftGroup = {
|
||||
export type DredgingRecord = {
|
||||
id: string;
|
||||
dredgingJobSn: number | string;
|
||||
pipeFid: string;
|
||||
mngInstCd: string;
|
||||
targetSn?: number;
|
||||
mappingStatus?: "AUTO" | "UNMAPPED";
|
||||
pipeFid: string | null;
|
||||
mngInstCd: string | null;
|
||||
ctpv: string;
|
||||
sgg: string;
|
||||
treatmentZone: string;
|
||||
fcltMngNo: string;
|
||||
pipeDiameterMm: number;
|
||||
pipeLengthM: number;
|
||||
pipeMaterial: string;
|
||||
treatmentZone: string | null;
|
||||
fcltMngNo: string | null;
|
||||
pipeDiameterMm: number | null;
|
||||
pipeLengthM: number | null;
|
||||
pipeMaterial: string | null;
|
||||
installationYear: number | null;
|
||||
dredgingDate: string;
|
||||
photoCount: number;
|
||||
registeredDate: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
snappedLongitude: number;
|
||||
snappedLatitude: number;
|
||||
snappedLongitude: number | null;
|
||||
snappedLatitude: number | null;
|
||||
registrationMode: DredgingRegistrationMode;
|
||||
registrationSource: DredgingLocationSource;
|
||||
matchDistanceMeters: number;
|
||||
matchDistanceMeters: number | null;
|
||||
isMock: boolean;
|
||||
};
|
||||
|
||||
export type DredgingPhoto = {
|
||||
targetSn?: number;
|
||||
photoSn: number;
|
||||
pipeFid: string;
|
||||
pipeFid?: string | null;
|
||||
atflCd: string;
|
||||
atflId: string;
|
||||
originalFileName: string;
|
||||
@@ -97,18 +100,19 @@ export type DredgingSaveGroup = {
|
||||
dredgingDate: string;
|
||||
location: GeoPoint;
|
||||
locationSource: DredgingLocationSource;
|
||||
pipe: PipeMatch;
|
||||
pipe: PipeMatch | null;
|
||||
photos: DredgingPhotoDraft[];
|
||||
};
|
||||
|
||||
/** 상세 기반 수정 팝업에서 선택한 한 관로의 위치·사진을 함께 갱신한다. */
|
||||
export type DredgingRecordUpdate = {
|
||||
dredgingDate: string;
|
||||
originalPipeFid: string;
|
||||
originalPipeFid: string | null;
|
||||
targetSn: number;
|
||||
registrationMode: DredgingRegistrationMode;
|
||||
location: GeoPoint;
|
||||
locationSource: DredgingLocationSource;
|
||||
pipe: PipeMatch;
|
||||
pipe: PipeMatch | null;
|
||||
retainedPhotoSns: number[];
|
||||
newPhotos: DredgingPhotoDraft[];
|
||||
};
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"use client";
|
||||
import { DredgingRegistrationModeSelector } from "./dredging-registration-mode-selector";
|
||||
import { DREDGING_EXTERNAL_MODE } from "../model/disclosure";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
@@ -134,25 +136,25 @@ const DredgingDetailPopupPage = ({
|
||||
const [statusMessage, setStatusMessage] = useState("");
|
||||
const uploadedAttachmentsRef = useRef(new Map<string, AttachmentReference>());
|
||||
|
||||
const initialMatchedPipe = useMemo<PipeMatch>(
|
||||
() => ({
|
||||
const initialMatchedPipe = useMemo<PipeMatch | null>(
|
||||
() => !record.pipeFid || DREDGING_EXTERNAL_MODE ? null : ({
|
||||
fid: record.pipeFid,
|
||||
mngInstCd: record.mngInstCd,
|
||||
fcltMngNo: record.fcltMngNo,
|
||||
pipeDiameterMm: record.pipeDiameterMm,
|
||||
pipeLengthM: record.pipeLengthM,
|
||||
pipeMaterial: record.pipeMaterial,
|
||||
mngInstCd: record.mngInstCd ?? "",
|
||||
fcltMngNo: record.fcltMngNo ?? "",
|
||||
pipeDiameterMm: record.pipeDiameterMm ?? 0,
|
||||
pipeLengthM: record.pipeLengthM ?? 0,
|
||||
pipeMaterial: record.pipeMaterial ?? "",
|
||||
installationYear: record.installationYear,
|
||||
swtrmtZoneFid: null,
|
||||
swtrmtZoneNm: record.treatmentZone,
|
||||
distanceMeters: record.matchDistanceMeters,
|
||||
swtrmtZoneNm: record.treatmentZone ?? "",
|
||||
distanceMeters: record.matchDistanceMeters ?? 0,
|
||||
snappedLongitude: record.snappedLongitude || record.longitude,
|
||||
snappedLatitude: record.snappedLatitude || record.latitude,
|
||||
isMock: record.isMock,
|
||||
}),
|
||||
[record],
|
||||
);
|
||||
const [matchedPipe, setMatchedPipe] = useState<PipeMatch>(initialMatchedPipe);
|
||||
const [matchedPipe, setMatchedPipe] = useState<PipeMatch | null>(initialMatchedPipe);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -168,6 +170,7 @@ const DredgingDetailPopupPage = ({
|
||||
const items = await fetchDredgingPhotos(
|
||||
record.dredgingJobSn,
|
||||
record.pipeFid,
|
||||
record.targetSn,
|
||||
);
|
||||
const previews = await Promise.all(
|
||||
items.map(async (photo): Promise<DredgingPhotoPreview> => {
|
||||
@@ -214,7 +217,7 @@ const DredgingDetailPopupPage = ({
|
||||
cancelled = true;
|
||||
createdUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [record.dredgingJobSn, record.pipeFid]);
|
||||
}, [record.dredgingJobSn, record.pipeFid, record.targetSn]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -258,19 +261,22 @@ const DredgingDetailPopupPage = ({
|
||||
}
|
||||
};
|
||||
|
||||
const pipeInfo = [
|
||||
const pipeInfo = DREDGING_EXTERNAL_MODE ? [
|
||||
["시도", record.ctpv || "확인불가"], ["시군구", record.sgg || "확인불가"],
|
||||
["위치등록", locationSourceLabels[locationSource] || locationSource],
|
||||
] : [
|
||||
["시도", record.ctpv || "확인불가"],
|
||||
["시군구", record.sgg || "확인불가"],
|
||||
[
|
||||
"처리구역",
|
||||
matchedPipe.swtrmtZoneNm || record.treatmentZone || "확인불가",
|
||||
matchedPipe?.swtrmtZoneNm || record.treatmentZone || "확인불가",
|
||||
],
|
||||
["관경", `${matchedPipe.pipeDiameterMm.toLocaleString()} mm`],
|
||||
["관로연장", `${matchedPipe.pipeLengthM.toLocaleString()} m`],
|
||||
["관재질", resolvePipeMaterialName(matchedPipe.pipeMaterial)],
|
||||
["관경", matchedPipe ? `${matchedPipe.pipeDiameterMm.toLocaleString()} mm` : "미산정"],
|
||||
["관로연장", matchedPipe ? `${matchedPipe.pipeLengthM.toLocaleString()} m` : "미산정"],
|
||||
["관재질", matchedPipe ? resolvePipeMaterialName(matchedPipe.pipeMaterial) : "미확인"],
|
||||
[
|
||||
"매설년도",
|
||||
matchedPipe.installationYear
|
||||
matchedPipe?.installationYear
|
||||
? `${matchedPipe.installationYear}년`
|
||||
: "확인불가",
|
||||
],
|
||||
@@ -280,7 +286,7 @@ const DredgingDetailPopupPage = ({
|
||||
{ length: Math.ceil(pipeInfo.length / 2) },
|
||||
(_, index) => pipeInfo.slice(index * 2, index * 2 + 2),
|
||||
);
|
||||
const automaticMappingValue = `${matchedPipe.distanceMeters.toFixed(2)} m`;
|
||||
const automaticMappingValue = DREDGING_EXTERNAL_MODE ? "위치 등록" : matchedPipe ? `${matchedPipe.distanceMeters.toFixed(2)} m` : "관로 미지정";
|
||||
const totalPhotoCount = photos.length + newPhotos.length;
|
||||
|
||||
const handleNewFiles = async (files: File[] | null) => {
|
||||
@@ -407,17 +413,12 @@ const DredgingDetailPopupPage = ({
|
||||
{ locationKey, ...locationDraft },
|
||||
]);
|
||||
const nextPipe = matches.get(locationKey);
|
||||
if (!nextPipe) {
|
||||
throw new Error(
|
||||
"50m 이내에 관로가 없습니다. 지도에서 준설위치를 다시 확인해주세요.",
|
||||
);
|
||||
}
|
||||
setLocation(locationDraft);
|
||||
setLocationSource("MANUAL");
|
||||
setMatchedPipe(nextPipe);
|
||||
setMatchedPipe(nextPipe ?? null);
|
||||
setLocationDraft(null);
|
||||
setIsEditingLocation(false);
|
||||
setStatusMessage("변경한 위치와 최근접 관로를 확인했습니다.");
|
||||
setStatusMessage(DREDGING_EXTERNAL_MODE ? "변경한 준설위치를 적용했습니다." : nextPipe ? "변경한 위치와 최근접 관로를 확인했습니다." : "관로 미지정으로 저장할 수 있습니다.");
|
||||
} catch (matchFailure) {
|
||||
setMatchError(
|
||||
matchFailure instanceof Error
|
||||
@@ -449,6 +450,8 @@ const DredgingDetailPopupPage = ({
|
||||
alert("선택한 지도 위치를 먼저 적용해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!record.targetSn) { alert("화면을 새로고침해 준설대상번호를 확인해주세요."); return; }
|
||||
if (!DREDGING_EXTERNAL_MODE && !matchedPipe && !(await confirm("관로 미지정 상태로 수정 내용을 저장하시겠습니까?"))) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
setStatusMessage(
|
||||
@@ -466,6 +469,7 @@ const DredgingDetailPopupPage = ({
|
||||
{
|
||||
dredgingDate,
|
||||
originalPipeFid: record.pipeFid,
|
||||
targetSn: record.targetSn,
|
||||
registrationMode,
|
||||
location,
|
||||
locationSource:
|
||||
@@ -524,10 +528,12 @@ const DredgingDetailPopupPage = ({
|
||||
<aside className="flex min-h-0 min-w-0 flex-col gap-3 overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable]">
|
||||
<section className="card card-border !m-0 shrink-0 overflow-hidden !p-0">
|
||||
<div className="card-header !m-0">
|
||||
<h2 className="text-2xl font-medium text-label">관로 정보</h2>
|
||||
<h2 className="text-2xl font-medium text-label">{DREDGING_EXTERNAL_MODE ? "준설 정보" : "관로 정보"}</h2>
|
||||
{!DREDGING_EXTERNAL_MODE && (
|
||||
<span className="truncate text-base text-muted-foreground">
|
||||
관로번호 {matchedPipe.fcltMngNo || "확인불가"}
|
||||
{`관로번호 ${matchedPipe?.fcltMngNo || "관로 미지정"}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<Table variant="secondary">
|
||||
@@ -558,7 +564,7 @@ const DredgingDetailPopupPage = ({
|
||||
dredgingDate
|
||||
)}
|
||||
</TableCell>
|
||||
<TableHead>자동매핑</TableHead>
|
||||
<TableHead>{DREDGING_EXTERNAL_MODE ? "위치" : "자동매핑"}</TableHead>
|
||||
<TableCell
|
||||
className="truncate border px-4"
|
||||
title={automaticMappingValue}
|
||||
@@ -604,34 +610,16 @@ const DredgingDetailPopupPage = ({
|
||||
className="card card-border !m-0 flex !min-h-[40rem] flex-1 flex-col overflow-hidden !p-0"
|
||||
data-testid="dredging-detail-photo-panel"
|
||||
>
|
||||
<div className="card-header !m-0 shrink-0 gap-2">
|
||||
<div className="card-header !m-0 shrink-0 flex-wrap gap-2">
|
||||
<h2 className="text-2xl font-medium text-label">준설 사진</h2>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{editable ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color={registrationMode === "PHOTO" ? "primary" : "white"}
|
||||
aria-pressed={registrationMode === "PHOTO"}
|
||||
onClick={() => void changeRegistrationMode("PHOTO")}
|
||||
data-testid="dredging-edit-photo-mode"
|
||||
>
|
||||
{registrationModeLabels.PHOTO}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color={
|
||||
registrationMode === "MANUAL" ? "primary" : "white"
|
||||
}
|
||||
aria-pressed={registrationMode === "MANUAL"}
|
||||
onClick={() => void changeRegistrationMode("MANUAL")}
|
||||
data-testid="dredging-edit-manual-mode"
|
||||
>
|
||||
{registrationModeLabels.MANUAL}
|
||||
</Button>
|
||||
</>
|
||||
<DredgingRegistrationModeSelector
|
||||
value={registrationMode}
|
||||
labels={registrationModeLabels}
|
||||
onChange={(mode) => void changeRegistrationMode(mode)}
|
||||
testIdPrefix="dredging-edit"
|
||||
/>
|
||||
) : null}
|
||||
<span className="text-base text-muted-foreground">
|
||||
{totalPhotoCount}/5장
|
||||
@@ -838,13 +826,13 @@ const DredgingDetailPopupPage = ({
|
||||
<div className="card-header !m-0 flex-wrap gap-x-2 gap-y-1 !border-b-0">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-2xl font-medium text-label">
|
||||
위치 및 관로
|
||||
{DREDGING_EXTERNAL_MODE ? "준설 위치" : "위치 및 관로"}
|
||||
</h2>
|
||||
<p
|
||||
className={`mt-1 truncate text-sm ${matchError ? "text-rose-700" : "text-muted-foreground"}`}
|
||||
>
|
||||
자동 연결 관로 · {matchedPipe.fcltMngNo} ·{" "}
|
||||
{matchError ?? `${matchedPipe.distanceMeters.toFixed(2)}m`}
|
||||
{DREDGING_EXTERNAL_MODE ? "배경지도에서 실제 준설 위치를 지정해주세요." :
|
||||
matchError ?? (matchedPipe ? `자동 연결 관로 · ${matchedPipe.fcltMngNo} · ${matchedPipe.distanceMeters.toFixed(2)}m` : "관로 미지정")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
@@ -883,7 +871,7 @@ const DredgingDetailPopupPage = ({
|
||||
data-testid="dredging-edit-status"
|
||||
>
|
||||
{statusMessage ||
|
||||
"준설일자·사진·위치와 자동 연결 관로를 확인해주세요."}
|
||||
(DREDGING_EXTERNAL_MODE ? "준설일자·사진·위치를 확인해주세요." : "준설일자·사진·위치와 자동 연결 관로를 확인해주세요.")}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -914,8 +902,8 @@ const DredgingDetailPopupPage = ({
|
||||
isEditingLocation
|
||||
? "지도에서 변경할 준설위치를 클릭한 뒤 선택 위치 적용을 눌러주세요."
|
||||
: editable
|
||||
? "현재 준설위치와 자동 연결된 관로를 표시하고 있습니다."
|
||||
: "준설위치와 자동 연결된 관로를 표시하고 있습니다."
|
||||
? (DREDGING_EXTERNAL_MODE ? "현재 준설위치를 표시하고 있습니다." : "현재 준설위치와 자동 연결된 관로를 표시하고 있습니다.")
|
||||
: (DREDGING_EXTERNAL_MODE ? "준설위치를 표시하고 있습니다." : "준설위치와 자동 연결된 관로를 표시하고 있습니다.")
|
||||
}
|
||||
height="h-full min-h-0"
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* - 지자체별/관경별/경과년도별 조회와 XLSX 다운로드 제공
|
||||
*/
|
||||
"use client";
|
||||
import { DREDGING_EXTERNAL_MODE } from "../model/disclosure";
|
||||
import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-code-catalog";
|
||||
import { useDredgingListData } from "../lib/use-dredging-list-data";
|
||||
import {
|
||||
@@ -187,8 +188,8 @@ const Page = () => {
|
||||
return filteredRecords.map((record, index) => ({
|
||||
rowNumber: index + 1,
|
||||
...record,
|
||||
pipeLengthM: Number(record.pipeLengthM.toFixed(2)),
|
||||
pipeMaterial: resolvePipeMaterialName(record.pipeMaterial, materialNames),
|
||||
pipeLengthM: Number((record.pipeLengthM ?? 0).toFixed(2)),
|
||||
pipeMaterial: resolvePipeMaterialName(record.pipeMaterial ?? "", materialNames),
|
||||
installationYear: record.installationYear ?? "확인불가",
|
||||
elapsedYears:
|
||||
record.installationYear === null
|
||||
@@ -372,27 +373,28 @@ const Page = () => {
|
||||
const addedRecords = savedGroups.map<DredgingRecord>((group, index) => ({
|
||||
id: `${localJobSn}-${index + 1}`,
|
||||
dredgingJobSn: localJobSn,
|
||||
pipeFid: group.pipe.fid,
|
||||
mngInstCd: group.pipe.mngInstCd,
|
||||
targetSn: index + 1,
|
||||
pipeFid: group.pipe?.fid ?? null,
|
||||
mngInstCd: group.pipe?.mngInstCd ?? null,
|
||||
ctpv: ctpvName || "경기도",
|
||||
sgg: sggName || "양평군",
|
||||
treatmentZone: group.pipe.swtrmtZoneNm || "확인불가",
|
||||
fcltMngNo: group.pipe.fcltMngNo,
|
||||
pipeDiameterMm: group.pipe.pipeDiameterMm,
|
||||
pipeLengthM: group.pipe.pipeLengthM,
|
||||
pipeMaterial: group.pipe.pipeMaterial,
|
||||
installationYear: group.pipe.installationYear,
|
||||
treatmentZone: group.pipe?.swtrmtZoneNm || "확인불가",
|
||||
fcltMngNo: group.pipe?.fcltMngNo ?? null,
|
||||
pipeDiameterMm: group.pipe?.pipeDiameterMm ?? null,
|
||||
pipeLengthM: group.pipe?.pipeLengthM ?? null,
|
||||
pipeMaterial: group.pipe?.pipeMaterial ?? null,
|
||||
installationYear: group.pipe?.installationYear ?? null,
|
||||
dredgingDate: group.dredgingDate,
|
||||
photoCount: group.photos.length,
|
||||
registeredDate,
|
||||
longitude: group.location.longitude,
|
||||
latitude: group.location.latitude,
|
||||
snappedLongitude: group.pipe.snappedLongitude,
|
||||
snappedLatitude: group.pipe.snappedLatitude,
|
||||
snappedLongitude: group.pipe?.snappedLongitude ?? null,
|
||||
snappedLatitude: group.pipe?.snappedLatitude ?? null,
|
||||
registrationMode: group.registrationMode,
|
||||
registrationSource: group.locationSource,
|
||||
matchDistanceMeters: group.pipe.distanceMeters,
|
||||
isMock: group.pipe.isMock || !isDredgingBackendEnabled,
|
||||
matchDistanceMeters: group.pipe?.distanceMeters ?? null,
|
||||
isMock: Boolean(group.pipe?.isMock) || !isDredgingBackendEnabled,
|
||||
}));
|
||||
prependDemoRecords(addedRecords);
|
||||
setCurrentPage(1);
|
||||
@@ -536,8 +538,8 @@ const Page = () => {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MUNICIPALITY">지자체별</SelectItem>
|
||||
<SelectItem value="DIAMETER">관경별</SelectItem>
|
||||
<SelectItem value="ELAPSED_YEAR">경과년도별</SelectItem>
|
||||
{!DREDGING_EXTERNAL_MODE && (<SelectItem value="DIAMETER">관경별</SelectItem>)}
|
||||
{!DREDGING_EXTERNAL_MODE && (<SelectItem value="ELAPSED_YEAR">경과년도별</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { DREDGING_EXTERNAL_MODE } from "../model/disclosure";
|
||||
|
||||
import Feature from "ol/Feature.js";
|
||||
import type { FeatureLike } from "ol/Feature.js";
|
||||
@@ -265,6 +266,12 @@ const LocationInteraction = ({
|
||||
previousValueRef.current = value;
|
||||
}, [gisMap, interactive, value?.latitude, value?.longitude]);
|
||||
|
||||
if (DREDGING_EXTERNAL_MODE) {
|
||||
return <div className="rounded-sm border border-border bg-background px-2 py-1.5 text-xs text-label shadow">
|
||||
{interactive ? "지도를 클릭해 준설위치를 선택하세요." : "등록된 준설위치를 확인합니다."}
|
||||
</div>;
|
||||
}
|
||||
|
||||
const pipeStatusText = (() => {
|
||||
if (pipeLayerSettings.length === 0)
|
||||
return "관망도 레이어 설정을 불러오는 중입니다.";
|
||||
@@ -329,6 +336,7 @@ const DredgingLocationMap = ({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (DREDGING_EXTERNAL_MODE) return; // 외부에서는 관망 레이어 설정과 도형을 요청하지 않는다.
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
@@ -370,8 +378,8 @@ const DredgingLocationMap = ({
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
interactive={interactive}
|
||||
matchedPipe={matchedPipe}
|
||||
pipeLayerSettings={pipeLayerSettings}
|
||||
matchedPipe={DREDGING_EXTERNAL_MODE ? null : matchedPipe}
|
||||
pipeLayerSettings={DREDGING_EXTERNAL_MODE ? [] : pipeLayerSettings}
|
||||
/>
|
||||
}
|
||||
defaultZoom={value ? 17 : 7.5}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
import { DREDGING_EXTERNAL_MODE } from "../model/disclosure";
|
||||
/**
|
||||
* 화면명: 준설실적 등록
|
||||
* 준설 사진을 등록하고 촬영정보·위치·인접 관로를 확인하여 준설실적을 저장하는 화면이다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
@@ -196,7 +198,9 @@ const DredgingRegisterModal = ({
|
||||
try {
|
||||
const matches = await findNearestPipeMatches(locations);
|
||||
if (matches.size !== locations.length) {
|
||||
throw new Error("탐색거리 안에서 관로를 찾지 못한 위치가 있습니다.");
|
||||
commitGroups(current => current.map(item => item.id === group.id
|
||||
? { ...item, matchedPipe: null, isMatching: false, matchError: DREDGING_EXTERNAL_MODE ? null : "관로 미지정으로 저장할 수 있습니다." } : item));
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedPipes = Array.from(matches.values());
|
||||
@@ -453,18 +457,15 @@ const DredgingRegisterModal = ({
|
||||
if (!location) {
|
||||
throw new Error(`${label}: 지도에서 준설위치를 등록해주세요.`);
|
||||
}
|
||||
if (!group.matchedPipe) {
|
||||
throw new Error(
|
||||
`${label}: ${group.matchError ?? "최근접 관로 자동 매핑을 완료해주세요."}`,
|
||||
);
|
||||
}
|
||||
if (group.matchError?.startsWith("사진 위치가 서로 다른 관로")) throw new Error(group.matchError);
|
||||
if (group.isMatching) throw new Error(`${label}: 위치 확인이 끝난 뒤 저장해주세요.`);
|
||||
saveGroups.push({
|
||||
localId: group.id,
|
||||
registrationMode: group.registrationMode,
|
||||
dredgingDate: representativeDredgingDate,
|
||||
location,
|
||||
locationSource: group.manualLocation ? "MANUAL" : "EXIF",
|
||||
pipe: group.matchedPipe,
|
||||
pipe: DREDGING_EXTERNAL_MODE ? null : group.matchedPipe,
|
||||
photos: group.photos,
|
||||
});
|
||||
}
|
||||
@@ -476,6 +477,8 @@ const DredgingRegisterModal = ({
|
||||
if (savingRef.current) return;
|
||||
try {
|
||||
const saveGroups = validateAndBuildSaveGroups();
|
||||
if (!DREDGING_EXTERNAL_MODE && saveGroups.some(group => !group.pipe)
|
||||
&& !(await confirm("관로가 지정되지 않은 대상이 있습니다. 사진과 위치를 저장하시겠습니까?"))) return;
|
||||
savingRef.current = true;
|
||||
setIsSaving(true);
|
||||
setStatusMessage(
|
||||
@@ -491,7 +494,7 @@ const DredgingRegisterModal = ({
|
||||
uploadedAttachmentsRef.current = attachments;
|
||||
setStatusMessage(
|
||||
isDredgingBackendEnabled
|
||||
? "준설 결과와 관로 매핑을 저장하고 있습니다."
|
||||
? (DREDGING_EXTERNAL_MODE ? "준설 결과를 저장하고 있습니다." : "준설 결과와 관로 매핑을 저장하고 있습니다.")
|
||||
: "준설 결과 등록을 완료했습니다.",
|
||||
);
|
||||
const session =
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { DredgingRegistrationModeSelector } from "./dredging-registration-mode-selector";
|
||||
import { DREDGING_EXTERNAL_MODE } from "../model/disclosure";
|
||||
import type { ComponentType } from "react";
|
||||
import { FileUpload } from "@common/business_components/ui/file-upload";
|
||||
import {
|
||||
@@ -96,7 +99,7 @@ export const DredgingRegistrationBody = ({
|
||||
>
|
||||
<div className="bg-secondary/40 mb-3 flex shrink-0 flex-wrap items-center gap-x-4 gap-y-1 rounded-sm border border-border px-4 py-2 text-sm text-label">
|
||||
<strong className="text-foreground">등록 안내</strong>
|
||||
<span>관로별 사진은 최대 5장까지 등록할 수 있습니다.</span>
|
||||
<span>{DREDGING_EXTERNAL_MODE ? "등록 건별" : "관로별"} 사진은 최대 5장까지 등록할 수 있습니다.</span>
|
||||
<span>
|
||||
모든 등록 건은 하나의 준설작업이며 동일한 준설일자를 사용합니다.
|
||||
</span>
|
||||
@@ -114,7 +117,7 @@ export const DredgingRegistrationBody = ({
|
||||
<div className="card-header !m-0">
|
||||
<h3 className="text-2xl font-medium text-label">등록 정보</h3>
|
||||
<Button type="button" size="sm" onClick={addGroup}>
|
||||
관로 추가
|
||||
{DREDGING_EXTERNAL_MODE ? "등록 건 추가" : "관로 추가"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
@@ -151,7 +154,7 @@ export const DredgingRegistrationBody = ({
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>등록 관로</TableHead>
|
||||
<TableHead>{DREDGING_EXTERNAL_MODE ? "등록 대상" : "등록 관로"}</TableHead>
|
||||
<TableCell className="border px-4">
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
@@ -237,38 +240,12 @@ export const DredgingRegistrationBody = ({
|
||||
촬영일과 GPS 정보를 확인합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color={
|
||||
activeGroup.registrationMode === "PHOTO"
|
||||
? "primary"
|
||||
: "white"
|
||||
}
|
||||
aria-pressed={activeGroup.registrationMode === "PHOTO"}
|
||||
onClick={() =>
|
||||
void changeRegistrationMode(activeGroup.id, "PHOTO")
|
||||
}
|
||||
>
|
||||
{registrationModeLabels.PHOTO}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color={
|
||||
activeGroup.registrationMode === "MANUAL"
|
||||
? "primary"
|
||||
: "white"
|
||||
}
|
||||
aria-pressed={activeGroup.registrationMode === "MANUAL"}
|
||||
onClick={() =>
|
||||
void changeRegistrationMode(activeGroup.id, "MANUAL")
|
||||
}
|
||||
>
|
||||
{registrationModeLabels.MANUAL}
|
||||
</Button>
|
||||
</div>
|
||||
<DredgingRegistrationModeSelector
|
||||
value={activeGroup.registrationMode}
|
||||
labels={registrationModeLabels}
|
||||
onChange={(mode) => void changeRegistrationMode(activeGroup.id, mode)}
|
||||
testIdPrefix="dredging-register"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col p-3">
|
||||
{activeGroup.registrationMode === "PHOTO" ? (
|
||||
@@ -379,7 +356,7 @@ export const DredgingRegistrationBody = ({
|
||||
|
||||
<section className="card card-border !m-0 shrink-0 overflow-hidden !p-0">
|
||||
<div className="card-header !m-0 flex-wrap gap-2">
|
||||
<h3 className="text-2xl font-medium text-label">위치 및 관로</h3>
|
||||
<h3 className="text-2xl font-medium text-label">{DREDGING_EXTERNAL_MODE ? "준설 위치" : "위치 및 관로"}</h3>
|
||||
<div className="flex shrink-0 flex-wrap justify-end gap-1">
|
||||
{isEditingActiveLocation ? (
|
||||
<>
|
||||
@@ -446,8 +423,9 @@ export const DredgingRegistrationBody = ({
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{!DREDGING_EXTERNAL_MODE && (
|
||||
<TableRow>
|
||||
<TableHead data-required>자동 연결 관로</TableHead>
|
||||
<TableHead>자동 연결 관로</TableHead>
|
||||
<TableCell className="border px-4">
|
||||
<strong className="mr-3 text-foreground">
|
||||
{activeGroup.isMatching
|
||||
@@ -473,7 +451,7 @@ export const DredgingRegistrationBody = ({
|
||||
"위치를 지정하면 50m 이내 최근접 관로를 자동으로 연결합니다."}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableRow>)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -484,7 +462,7 @@ export const DredgingRegistrationBody = ({
|
||||
data-testid="dredging-registration-status"
|
||||
>
|
||||
{statusMessage ||
|
||||
"사진·준설일자·위치와 자동 연결 관로를 확인해주세요."}
|
||||
(DREDGING_EXTERNAL_MODE ? "사진·준설일자·준설위치를 확인해주세요." : "사진·준설일자·위치를 확인해주세요. 관로 미지정 상태도 저장할 수 있습니다.")}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -504,7 +482,7 @@ export const DredgingRegistrationBody = ({
|
||||
statusText={
|
||||
isEditingActiveLocation
|
||||
? hasUnappliedLocationDraft
|
||||
? "새 위치를 선택했습니다. 관로 다시 찾기를 눌러 적용해주세요."
|
||||
? (DREDGING_EXTERNAL_MODE ? "새 위치를 선택했습니다. 선택 위치 적용을 눌러주세요." : "새 위치를 선택했습니다. 관로 다시 찾기를 눌러 적용해주세요.")
|
||||
: "지도에서 새 준설위치를 클릭해주세요."
|
||||
: activeResolvedLocation
|
||||
? "등록된 준설위치를 표시하고 있습니다."
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import type { DredgingRegistrationMode } from "../model/types";
|
||||
|
||||
type Props = {
|
||||
value: DredgingRegistrationMode;
|
||||
labels: Record<string, string>;
|
||||
onChange: (mode: DredgingRegistrationMode) => void;
|
||||
testIdPrefix: string;
|
||||
};
|
||||
|
||||
/** 업로드 실행 버튼과 구분되는 등록 방식 선택. 기존 전환 처리는 호출 화면이 담당한다. */
|
||||
export const DredgingRegistrationModeSelector = ({
|
||||
value, labels, onChange, testIdPrefix,
|
||||
}: Props) => {
|
||||
const name = useId();
|
||||
|
||||
return (
|
||||
<fieldset className="m-0 flex flex-wrap items-center gap-x-4 gap-y-2 border-0 p-0">
|
||||
<legend className="sr-only">등록 방식</legend>
|
||||
{(["PHOTO", "MANUAL"] as const).map((mode) => (
|
||||
<label key={mode} className="inline-flex cursor-pointer items-center gap-2 py-1 text-sm text-label">
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
value={mode}
|
||||
checked={value === mode}
|
||||
onChange={() => onChange(mode)}
|
||||
className="h-4 w-4 shrink-0 cursor-pointer accent-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||
data-testid={`${testIdPrefix}-${mode.toLowerCase()}-mode`}
|
||||
/>
|
||||
<span className={value === mode ? "font-medium text-foreground" : ""}>{labels[mode]}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
@@ -341,9 +341,10 @@ export const downloadExmnBusinessReport = async (
|
||||
export const downloadExmnPipeReport = async (
|
||||
exmnBizId: string,
|
||||
pipeNumber: string,
|
||||
jobType: "PPL" | "MNHL" = "PPL",
|
||||
): Promise<Blob> =>
|
||||
getBlobData(
|
||||
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/pipeReports/${encodeURIComponent(pipeNumber)}`,
|
||||
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/${jobType === "MNHL" ? "manholeReports" : "pipeReports"}/${encodeURIComponent(pipeNumber)}`,
|
||||
);
|
||||
|
||||
/** 사업 파일별 첨부서비스 키 쌍을 조사사업에 연결한다. */
|
||||
@@ -351,9 +352,10 @@ export const downloadExmnPipeReport = async (
|
||||
export const getExmnPipeReportAvailability = async (
|
||||
exmnBizId: string,
|
||||
pipeNumber: string,
|
||||
jobType: "PPL" | "MNHL" = "PPL",
|
||||
): Promise<ExmnPipeReportAvailability> =>
|
||||
getData(
|
||||
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/pipeReports/${encodeURIComponent(pipeNumber)}/availability`,
|
||||
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/${jobType === "MNHL" ? "manholeReports" : "pipeReports"}/${encodeURIComponent(pipeNumber)}/availability`,
|
||||
) as Promise<ExmnPipeReportAvailability>;
|
||||
|
||||
export const updateExmnAttachments = async (
|
||||
|
||||
@@ -9,12 +9,12 @@ export const getExmnTemplatePrerequisiteMessage = (
|
||||
detail: Pick<ExmnBusinessDetail, "districts">,
|
||||
): string | null => {
|
||||
if (!detail.districts?.length) {
|
||||
return "조사관리에서 해당 사업 행을 선택하고 ‘수정’을 눌러 처리분구를 등록한 뒤 ‘조사양식 받기’를 실행해 주세요. 이후 결과등록을 다시 진행해 주세요.";
|
||||
return "조사사업에서 해당 사업 행을 선택하고 ‘수정’을 눌러 처리분구를 등록한 뒤 ‘조사양식 받기’를 실행해 주세요. 이후 결과등록을 다시 진행해 주세요.";
|
||||
}
|
||||
if (detail.districts.some(district =>
|
||||
!district.pplExmnSheetNm?.trim() || !district.mnhlExmnSheetNm?.trim(),
|
||||
)) {
|
||||
return "조사관리에서 해당 사업 행 선택 → ‘수정’ → ‘조사양식 받기’를 먼저 실행한 후 결과등록을 다시 진행해 주세요.";
|
||||
return "조사사업에서 해당 사업 행 선택 → ‘수정’ → ‘조사양식 받기’를 먼저 실행한 후 결과등록을 다시 진행해 주세요.";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -287,7 +287,7 @@ export function ExmnRegistrationPage() {
|
||||
if (!popup) {
|
||||
setNotice({
|
||||
type: "error",
|
||||
text: "사업등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.",
|
||||
text: "조사사업 등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -385,7 +385,7 @@ export function ExmnRegistrationPage() {
|
||||
if (refreshed)
|
||||
setNotice({
|
||||
type: "success",
|
||||
text: "조사결과 최종 저장과 PPL/MNHL 집계를 완료했습니다.",
|
||||
text: "조사결과 저장을 완료했습니다.",
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -660,7 +660,7 @@ export function ExmnRegistrationPage() {
|
||||
<div className="mt-4 min-w-0">
|
||||
<StandardDataTable
|
||||
table={table}
|
||||
title="조사 목록"
|
||||
title="조사사업 목록"
|
||||
className={EXMN_TABLE_HEADER_CLASS}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="110rem"
|
||||
@@ -673,7 +673,7 @@ export function ExmnRegistrationPage() {
|
||||
disabled={isLoading}
|
||||
onClick={openBusinessRegister}
|
||||
>
|
||||
사업등록
|
||||
조사사업등록
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -71,7 +71,7 @@ const VALIDATION_CHECK_GUIDES: Partial<
|
||||
},
|
||||
MNHL_EXMN_PDF: {
|
||||
checkedItems: ["PDF 파일 형식", "조사결과 Excel 구조 및 저장 가능 여부"],
|
||||
pendingNote: "PDF 내부 맨홀번호 대조는 검토 기준 협의 후 적용 예정입니다.",
|
||||
pendingNote: "맨홀보고서의 맨홀번호에는 실제 GIS 맨홀번호를 입력해주세요. 보고서 열람 시 번호를 대조하며, 업로드 검토에는 PDF 내부 번호 대조가 포함되지 않습니다.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -179,6 +179,9 @@ export function ExmnResultRegisterPopupPage({
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
|
||||
/** 맨홀 PDF 하나만 선택한 경우 기존 조사자료의 첨부 교체로 처리한다. */
|
||||
const isManholeOnly = Boolean(files.MNHL_EXMN_PDF) && Object.keys(files).length === 1;
|
||||
|
||||
/** popup layout 상위 html/body에서 생기는 문서 스크롤바를 팝업이 열린 동안 잠근다. */
|
||||
useEffect(() => {
|
||||
const previousHtmlOverflow = document.documentElement.style.overflow;
|
||||
@@ -310,7 +313,9 @@ export function ExmnResultRegisterPopupPage({
|
||||
const fileMap = Object.fromEntries(
|
||||
Object.entries(files).map(([type, selected]) => [type, selected?.file]),
|
||||
) as Partial<Record<ExmnFileType, File>>;
|
||||
const validationMessage = validateExmnFiles(fileMap);
|
||||
const validationMessage = isManholeOnly
|
||||
? validateExmnFile("MNHL_EXMN_PDF", files.MNHL_EXMN_PDF!.file)
|
||||
: validateExmnFiles(fileMap);
|
||||
if (validationMessage) {
|
||||
setFileValidationStatuses((current) => ({
|
||||
...current,
|
||||
@@ -373,22 +378,22 @@ export function ExmnResultRegisterPopupPage({
|
||||
/** 정상 검토된 조사결과 Excel을 저장하고 부모 화면에 완료 메시지를 보낸 뒤 팝업을 닫는다. */
|
||||
const handleCommit = async () => {
|
||||
const excelValidation = validationResults.EXMN_EXCEL;
|
||||
if (
|
||||
!excelValidation?.valid ||
|
||||
fileValidationStatuses.EXMN_EXCEL !== "success" ||
|
||||
fileValidationStatuses.PPL_EXMN_PDF !== "success"
|
||||
)
|
||||
return;
|
||||
if (!canCommit) return;
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
if (!(await ensureTemplateReady())) return;
|
||||
if (!isManholeOnly && !(await ensureTemplateReady())) return;
|
||||
// 로딩 화면이 저장 확인창을 가리지 않도록 사전 확인과 업로드 상태를 나눈다.
|
||||
setIsLoading(false);
|
||||
if (!(await confirm("조사결과를 저장하시겠습니까?"))) return;
|
||||
setIsLoading(true);
|
||||
if (isManholeOnly) {
|
||||
// 검토 이후 기존 조사자료가 변경됐을 수 있어 첨부 업로드 직전에 다시 대조한다.
|
||||
const result = await validateExmnImport(exmnBizId, buildImportFormData(files, null, "MNHL_EXMN_PDF"));
|
||||
if (!result.valid) throw new Error(result.errors.map((item) => item.message).join("\n"));
|
||||
}
|
||||
await uploadAndLinkAttachments();
|
||||
await commitExmnImport(
|
||||
if (!isManholeOnly) await commitExmnImport(
|
||||
exmnBizId,
|
||||
buildImportFormData(files, excelValidation),
|
||||
);
|
||||
@@ -418,6 +423,11 @@ export function ExmnResultRegisterPopupPage({
|
||||
(fileType) => fileValidationStatuses[fileType],
|
||||
);
|
||||
const excelValidation = validationResults.EXMN_EXCEL;
|
||||
/** 단독 첨부 교체와 신규 Excel 결과 저장의 필수 검토 조건을 분리한다. */
|
||||
const canCommit = isManholeOnly
|
||||
? fileValidationStatuses.MNHL_EXMN_PDF === "success" && Boolean(validationResults.MNHL_EXMN_PDF?.valid)
|
||||
: Boolean(excelValidation?.valid) && fileValidationStatuses.EXMN_EXCEL === "success"
|
||||
&& fileValidationStatuses.PPL_EXMN_PDF === "success";
|
||||
|
||||
return (
|
||||
<StandardPopupLayout
|
||||
@@ -429,10 +439,7 @@ export function ExmnResultRegisterPopupPage({
|
||||
type="button"
|
||||
color="primary"
|
||||
disabled={
|
||||
isLoading ||
|
||||
!excelValidation?.valid ||
|
||||
fileValidationStatuses.EXMN_EXCEL !== "success" ||
|
||||
fileValidationStatuses.PPL_EXMN_PDF !== "success"
|
||||
isLoading || !canCommit
|
||||
}
|
||||
onClick={() => void handleCommit()}
|
||||
>
|
||||
@@ -614,7 +621,9 @@ export function ExmnResultRegisterPopupPage({
|
||||
const status = fileValidationStatuses[fileType]!;
|
||||
const result = validationResults[fileType];
|
||||
const messages = fileValidationMessages[fileType] ?? [];
|
||||
const guide = VALIDATION_CHECK_GUIDES[fileType];
|
||||
const guide = fileType === "MNHL_EXMN_PDF" && isManholeOnly
|
||||
? { checkedItems: ["PDF 맨홀번호 판독", "기존 조사자료에 연결된 실제 GIS 맨홀번호와 대조"], pendingNote: "저장 시 기존 조사자료는 유지하고 맨홀 보고서만 교체합니다." }
|
||||
: VALIDATION_CHECK_GUIDES[fileType];
|
||||
const countText =
|
||||
fileType === "EXMN_EXCEL"
|
||||
? `관로 ${result?.summary.pplCount ?? 0}건 · 맨홀 ${result?.summary.mnhlCount ?? 0}건`
|
||||
|
||||
@@ -43,7 +43,7 @@ const formatValue = (value: unknown) => {
|
||||
: String(value);
|
||||
};
|
||||
|
||||
/** 조사관리 표를 CS 목록 규격과 페이지 연속 오름차순 순번으로 연결한다. */
|
||||
/** 조사사업 표를 CS 목록 규격과 페이지 연속 오름차순 순번으로 연결한다. */
|
||||
export function ExmnStandardDataTable<T extends Record<string, unknown>>({
|
||||
rows,
|
||||
headlist,
|
||||
|
||||
@@ -52,21 +52,22 @@ export default function ExmnSurveyResultSection({
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const exmnBizId = readFeatureProperty(featureData, ["exmnBizId"]);
|
||||
const jobType = readFeatureProperty(featureData, ["jobType"]);
|
||||
const pipeNumber = readFeatureProperty(featureData, [
|
||||
"gisManagementNo",
|
||||
"fclt_mng_no",
|
||||
"fcltMngNo",
|
||||
]);
|
||||
// 맨홀은 조사 관로번호가 아니라 지도 원천의 실제 시설관리번호로 보고서를 조회한다.
|
||||
const pipeNumber = readFeatureProperty(featureData, jobType === "MNHL"
|
||||
? ["fclt_mng_no", "fcltMngNo"]
|
||||
: ["gisManagementNo", "fclt_mng_no", "fcltMngNo"]);
|
||||
const reportAvailable = readBooleanFeatureProperty(featureData, [
|
||||
"reportAvailable",
|
||||
]);
|
||||
/** 지도는 상류맨홀 위치를 유지하고 맨홀 보고서는 실제 GIS 맨홀번호로 연결한다. */
|
||||
const reportLabel = jobType === "MNHL" ? "맨홀" : "관로";
|
||||
const canOpenPipeReport =
|
||||
jobType === "PPL" && Boolean(exmnBizId && pipeNumber);
|
||||
(jobType === "PPL" || jobType === "MNHL") && Boolean(exmnBizId && pipeNumber);
|
||||
|
||||
const openPipeReport = async () => {
|
||||
if (!canOpenPipeReport) return;
|
||||
if (!reportAvailable) {
|
||||
alert("등록된 관로 조사보고서가 없습니다.");
|
||||
alert(`등록된 ${reportLabel} 조사보고서가 없습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -74,11 +75,12 @@ export default function ExmnSurveyResultSection({
|
||||
const availability = await getExmnPipeReportAvailability(
|
||||
exmnBizId,
|
||||
pipeNumber,
|
||||
jobType === "MNHL" ? "MNHL" : "PPL",
|
||||
);
|
||||
if (!availability.available) {
|
||||
alert(
|
||||
availability.message?.trim() ||
|
||||
`선택한 관로번호의 조사보고서가 등록된 PDF에 없습니다: ${pipeNumber}`,
|
||||
`선택한 GIS 번호의 조사보고서가 등록된 PDF에 없습니다: ${pipeNumber}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -86,6 +88,7 @@ export default function ExmnSurveyResultSection({
|
||||
const reportWindow = openWindowPopup("exmnPipeReport", {
|
||||
exmnBizId,
|
||||
pipeNumber,
|
||||
jobType,
|
||||
reportNo: readFeatureProperty(featureData, ["reportNo"]),
|
||||
});
|
||||
if (!reportWindow) {
|
||||
@@ -97,7 +100,7 @@ export default function ExmnSurveyResultSection({
|
||||
alert(
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: "관로 조사보고서 열람 가능 여부를 확인하지 못했습니다.",
|
||||
: `${reportLabel} 조사보고서 열람 가능 여부를 확인하지 못했습니다.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -107,6 +110,7 @@ export default function ExmnSurveyResultSection({
|
||||
["사업차수", readFeatureProperty(featureData, ["bizCycl"])],
|
||||
["처리분구", readFeatureProperty(featureData, ["trtdstNm"])],
|
||||
["조사유형", jobType === "MNHL" ? "맨홀조사" : "관로조사"],
|
||||
[jobType === "MNHL" ? "GIS 맨홀번호" : "GIS 관로번호", pipeNumber],
|
||||
["보고서번호", readFeatureProperty(featureData, ["reportNo"])],
|
||||
["구조적결함 등급", readFeatureProperty(featureData, ["structuralGrade"])],
|
||||
["운영적결함 등급", readFeatureProperty(featureData, ["operationalGrade"])],
|
||||
@@ -116,7 +120,7 @@ export default function ExmnSurveyResultSection({
|
||||
<section className="border-b border-border bg-background">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="mx-3 text-2xl font-medium text-label">조사 결과</h3>
|
||||
{jobType === "PPL" && (
|
||||
{(jobType === "PPL" || jobType === "MNHL") && (
|
||||
<Button
|
||||
onClick={openPipeReport}
|
||||
disabled={!canOpenPipeReport}
|
||||
|
||||
@@ -130,7 +130,7 @@ const toSavedTableRows = (
|
||||
const getErrorMessage = (error: unknown, fallback: string) =>
|
||||
error instanceof Error && error.message.trim() ? error.message : fallback;
|
||||
|
||||
/** 사업등록 후보로 사용할 처리분구 기준정보를 직접 관리하는 FE-0 화면이다. */
|
||||
/** 조사사업 등록 후보로 사용할 처리분구 기준정보를 직접 관리하는 FE-0 화면이다. */
|
||||
export function TrtdstManagementPage() {
|
||||
const { ctpv, sgg } = useStore();
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
@@ -766,7 +766,7 @@ const FeatureClickEventPopup = ({
|
||||
<div className="min-w-0">
|
||||
{surveyMode && (
|
||||
<h3 className="break-words text-2xl font-medium text-primary-foreground">
|
||||
조사관리 <br />
|
||||
조사사업 <br />
|
||||
관로번호 :{" "}
|
||||
{featureData?.get?.("gisManagementNo") ??
|
||||
featureData?.values_?.gisManagementNo ??
|
||||
|
||||
@@ -81,7 +81,7 @@ const MsSwagePpl = ({
|
||||
const requestFcltMngNo = String(
|
||||
featureData?.fclt_mng_no ?? featureData?.fcltMngNo ?? "",
|
||||
).trim();
|
||||
// 조사관리 전용 탭은 기본·입력정보와 조사결과만 표시하고 일반 시설 유지관리 이력은 숨긴다.
|
||||
// 조사사업 전용 탭은 기본·입력정보와 조사결과만 표시하고 일반 시설 유지관리 이력은 숨긴다.
|
||||
const showOperationSections = !surveyResultContent;
|
||||
|
||||
// 마운트 및 업데이트 시 API 호출
|
||||
|
||||
@@ -17,7 +17,7 @@ const HEADERS: Record<string, string> = {
|
||||
ctpvNm: "시도",
|
||||
sggNm: "시군구",
|
||||
analysisDateTime: "분석시각",
|
||||
rainBeginningDateTime: "강우 시작시각",
|
||||
rainBeginningDateTime: "강우시작시각",
|
||||
observedAccumulatedRainfall: "관측 누적강수량(mm)",
|
||||
predictionHour: "예측시간",
|
||||
predictedAccumulatedRainfall: "예측 누적강수량(mm)",
|
||||
|
||||
@@ -7,7 +7,7 @@ import Overlay from "ol/Overlay.js";
|
||||
|
||||
import GisApp from "@common/business_components/ui/map/lib/gis.core";
|
||||
|
||||
import { getRawJson } from "@/shared/api/sewerage-api";
|
||||
import { getData } from "@/shared/api/sewerage-api";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
|
||||
|
||||
import { getFloodRiskGradeClassName } from "./flood-risk-grade";
|
||||
@@ -251,11 +251,11 @@ export const useFloodPredictionMap = ({
|
||||
removeFloodPredictionOverlay();
|
||||
|
||||
try {
|
||||
const response = await getRawJson(
|
||||
// 공통 응답의 data만 읽어 null·생략된 값을 빈 오버레이 결과로 처리한다.
|
||||
const payload = await getData(
|
||||
`/sewerage-service/api/v1/fldng/predc/prst/gridMap?sggCd=${encodeURIComponent(sggCd)}&analysisDateTime=${encodeURIComponent(analysisDateTime)}`,
|
||||
);
|
||||
if (!navigation?.isCurrent()) return;
|
||||
const payload = response?.data ?? response;
|
||||
const row =
|
||||
payload && typeof payload === "object" && !Array.isArray(payload)
|
||||
? (payload as FloodPredictionOverlayRow)
|
||||
|
||||
@@ -14,6 +14,7 @@ import LoadingOverlay from "@/components/ui/loading-bar";
|
||||
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
|
||||
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
|
||||
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
|
||||
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import useStore from "@/entities/reference-data/model/reference-data-store";
|
||||
@@ -135,6 +136,16 @@ const buildInitialFilters = (): Filters => ({
|
||||
|
||||
const apiDate = (value: string) => value.replace(/[^0-9]/g, "").slice(0, 12);
|
||||
|
||||
/**
|
||||
* 검색 시각 입력(`datetime-local`, `step=3600`)의 분을 00으로 맞춘다.
|
||||
* 브라우저는 값의 분이 00이면 분 칸을 고정하지만, 빈 값에서 처음 입력하거나 직접 입력해 분이 들어온 경우는
|
||||
* 스스로 고치지 않으므로 저장 전에 시간 단위로 내린다. 형식이 아니면(빈 값 포함) 그대로 둔다.
|
||||
*/
|
||||
const toHourInputValue = (value: string) =>
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)
|
||||
? `${value.slice(0, 13)}:00`
|
||||
: value;
|
||||
|
||||
const displayDate = (value: string) =>
|
||||
/^\d{12}$/.test(value)
|
||||
? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)} ${value.slice(8, 10)}:${value.slice(10, 12)}`
|
||||
@@ -184,7 +195,7 @@ const validateFilters = (filters: Filters) => {
|
||||
filters.rainBgngTo &&
|
||||
filters.rainBgngFrom > filters.rainBgngTo
|
||||
) {
|
||||
return "강우 시작시각의 시작은 종료보다 늦을 수 없습니다.";
|
||||
return "강우시각의 시작은 종료보다 늦을 수 없습니다.";
|
||||
}
|
||||
if (
|
||||
filters.analysisFrom &&
|
||||
@@ -367,6 +378,11 @@ export default function FldngPredcPrstPopupContent() {
|
||||
const extentData = await getData(
|
||||
`/sewerage-service/api/v1/fldng/floodDngrMap/group/extent?${params.toString()}`,
|
||||
);
|
||||
// null·생략된 data는 정상적인 위치 부재이며 부모 지도 이동·강조를 요청하지 않는다.
|
||||
if (extentData == null) {
|
||||
alert("해당 도시침수지도가 없습니다.");
|
||||
return;
|
||||
}
|
||||
const extent = [
|
||||
extentData?.minX,
|
||||
extentData?.minY,
|
||||
@@ -374,7 +390,7 @@ export default function FldngPredcPrstPopupContent() {
|
||||
extentData?.maxY,
|
||||
].map(Number);
|
||||
if (extent.some((value) => !Number.isFinite(value))) {
|
||||
throw new Error("도시침수지도 위치 정보가 없습니다.");
|
||||
throw new Error("도시침수지도 위치 응답의 좌표가 올바르지 않습니다.");
|
||||
}
|
||||
window.opener.postMessage(
|
||||
{
|
||||
@@ -472,6 +488,11 @@ export default function FldngPredcPrstPopupContent() {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 분석시각·강우시각 범위를 시간 단위로만 고르게 그린다.
|
||||
* `step=3600`이라 값의 분이 00이면 브라우저가 분 칸을 고정해 바꿀 수 없고, 분이 들어온 경우는 저장 전에 00으로 내린다.
|
||||
* 침수예측 자료의 분석·강우시작 시각은 정시 단위이므로 시간 단위 조건으로 빠지는 자료는 없다.
|
||||
*/
|
||||
const renderDateTimeRange = (
|
||||
label: string,
|
||||
fromKey: "rainBgngFrom" | "analysisFrom",
|
||||
@@ -484,27 +505,31 @@ export default function FldngPredcPrstPopupContent() {
|
||||
<input
|
||||
className="h-[3.2rem] w-[17rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
type="datetime-local"
|
||||
step={3600}
|
||||
value={filters[fromKey]}
|
||||
aria-label={`${label} 시작`}
|
||||
onChange={(event) =>
|
||||
onChange={(event) => {
|
||||
const nextValue = toHourInputValue(event.target.value);
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
[fromKey]: event.target.value,
|
||||
}))
|
||||
}
|
||||
[fromKey]: nextValue,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<span className="text-xl font-medium text-label">~</span>
|
||||
<input
|
||||
className="h-[3.2rem] w-[17rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
type="datetime-local"
|
||||
step={3600}
|
||||
value={filters[toKey]}
|
||||
aria-label={`${label} 종료`}
|
||||
onChange={(event) =>
|
||||
onChange={(event) => {
|
||||
const nextValue = toHourInputValue(event.target.value);
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
[toKey]: event.target.value,
|
||||
}))
|
||||
}
|
||||
[toKey]: nextValue,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
@@ -662,17 +687,22 @@ export default function FldngPredcPrstPopupContent() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-1 px-1 text-xl text-muted-foreground">
|
||||
<p>
|
||||
{/* 판정 기준·행 선택 안내에 인수테스트 요구(자료 원천·분석 주기 표시) 문구를 글머리표로 더한다. */}
|
||||
<PopupFooterNote
|
||||
items={[
|
||||
<>
|
||||
30년빈도 이상인 경우{" "}
|
||||
<span className="font-semibold text-[#dc2626]">위험</span>, 10년빈도
|
||||
이상인 경우{" "}
|
||||
<span className="font-semibold text-[#f97316]">경계</span>, 2년빈도
|
||||
이상인 경우{" "}
|
||||
<span className="font-semibold text-[#dc2626]">위험</span>,
|
||||
10년빈도 이상인 경우{" "}
|
||||
<span className="font-semibold text-[#f97316]">경계</span>,
|
||||
2년빈도 이상인 경우{" "}
|
||||
<span className="font-semibold text-[#ca8a04]">주의</span>
|
||||
</p>
|
||||
<p>행을 선택하면 해당 시군구·빈도의 도시침수지도로 이동합니다.</p>
|
||||
</div>
|
||||
</>,
|
||||
"행을 선택하면 해당 시군구·빈도의 도시침수지도로 이동합니다.",
|
||||
"분석 실행 시기 : 침수예측은 관측강우가 10mm이상으로 강우가 발생시 1시간 주기로 분석 자동실행",
|
||||
"분석 방법 : 기상청 관측강우와 향후 6시간 예측강우를 누적하여 지속시간별 빈도별 확률강우량(출처 : map.wamis.go.kr)과 근접한 강우량을 찾아 해당되는 빈도의 도시침수지도(출처 : 한강홍수통제소)를 표출",
|
||||
]}
|
||||
/>
|
||||
<PrbbtRnflDialog
|
||||
open={probabilityRow !== null}
|
||||
onOpenChange={handleProbabilityOpenChange}
|
||||
|
||||
@@ -39,7 +39,7 @@ export const windowPopupDefinitions = {
|
||||
title: "준설결과 수정",
|
||||
width: 1280,
|
||||
height: 900,
|
||||
requiredParams: ["dredgingJobSn", "pipeFid", "longitude", "latitude"],
|
||||
requiredParams: ["dredgingJobSn", "targetSn", "longitude", "latitude"],
|
||||
},
|
||||
exmnPipeReport: {
|
||||
key: "exmnPipeReport",
|
||||
|
||||
@@ -23,14 +23,16 @@ export const buildDredgingPopupRecord = (
|
||||
): DredgingRecord => ({
|
||||
id: getParam(searchParams.id),
|
||||
dredgingJobSn: getParam(searchParams.dredgingJobSn),
|
||||
pipeFid: getParam(searchParams.pipeFid),
|
||||
targetSn: getNumber(searchParams.targetSn),
|
||||
mappingStatus: getParam(searchParams.mappingStatus) === "UNMAPPED" ? "UNMAPPED" : "AUTO",
|
||||
pipeFid: getParam(searchParams.pipeFid) || null,
|
||||
mngInstCd: getParam(searchParams.mngInstCd),
|
||||
ctpv: getParam(searchParams.ctpv),
|
||||
sgg: getParam(searchParams.sgg),
|
||||
treatmentZone: getParam(searchParams.treatmentZone),
|
||||
fcltMngNo: getParam(searchParams.fcltMngNo),
|
||||
pipeDiameterMm: getNumber(searchParams.pipeDiameterMm),
|
||||
pipeLengthM: getNumber(searchParams.pipeLengthM),
|
||||
pipeDiameterMm: getNullableNumber(searchParams.pipeDiameterMm),
|
||||
pipeLengthM: getNullableNumber(searchParams.pipeLengthM),
|
||||
pipeMaterial: getParam(searchParams.pipeMaterial),
|
||||
installationYear: getNullableNumber(searchParams.installationYear),
|
||||
dredgingDate: getParam(searchParams.dredgingDate),
|
||||
@@ -44,6 +46,6 @@ export const buildDredgingPopupRecord = (
|
||||
getParam(searchParams.registrationMode) === "MANUAL" ? "MANUAL" : "PHOTO",
|
||||
registrationSource:
|
||||
getParam(searchParams.registrationSource) === "MANUAL" ? "MANUAL" : "EXIF",
|
||||
matchDistanceMeters: getNumber(searchParams.matchDistanceMeters),
|
||||
matchDistanceMeters: getNullableNumber(searchParams.matchDistanceMeters),
|
||||
isMock: getParam(searchParams.isMock) === "true",
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ const getParam = (value: string | string[] | undefined) =>
|
||||
/** 공통 Blob API 오류 문자열에서 백엔드의 사용자 안내 메시지만 추출한다. */
|
||||
const getReportErrorMessage = (error: unknown) => {
|
||||
if (!(error instanceof Error)) {
|
||||
return "관로 조사보고서를 불러오지 못했습니다.";
|
||||
return "조사보고서를 불러오지 못했습니다.";
|
||||
}
|
||||
|
||||
const backendMessage = error.message.match(
|
||||
@@ -35,6 +35,9 @@ export default function ExmnPipeReportPopupContent({
|
||||
}: WindowPopupContentProps) {
|
||||
const exmnBizId = getParam(searchParams.exmnBizId);
|
||||
const pipeNumber = getParam(searchParams.pipeNumber);
|
||||
/** 조사유형별 첨부 API와 표시 제목을 선택한다. */
|
||||
const jobType = getParam(searchParams.jobType) === "MNHL" ? "MNHL" : "PPL";
|
||||
const reportLabel = jobType === "MNHL" ? "맨홀" : "관로";
|
||||
const reportNo = getParam(searchParams.reportNo);
|
||||
const [reportUrl, setReportUrl] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
@@ -46,7 +49,7 @@ export default function ExmnPipeReportPopupContent({
|
||||
setReportUrl("");
|
||||
setErrorMessage("");
|
||||
|
||||
void downloadExmnPipeReport(exmnBizId, pipeNumber)
|
||||
void downloadExmnPipeReport(exmnBizId, pipeNumber, jobType)
|
||||
.then((blob) => {
|
||||
if (!active) return;
|
||||
|
||||
@@ -59,7 +62,7 @@ export default function ExmnPipeReportPopupContent({
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!active) return;
|
||||
console.warn("관로 조사보고서 조회 실패:", error);
|
||||
console.warn("조사보고서 조회 실패:", error);
|
||||
setErrorMessage(getReportErrorMessage(error));
|
||||
});
|
||||
|
||||
@@ -67,16 +70,16 @@ export default function ExmnPipeReportPopupContent({
|
||||
active = false;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [exmnBizId, pipeNumber]);
|
||||
}, [exmnBizId, pipeNumber, jobType]);
|
||||
|
||||
return (
|
||||
<StandardPopupLayout
|
||||
title="관로 조사보고서"
|
||||
title={`${reportLabel} 조사보고서`}
|
||||
showDefaultCancelAction={false}
|
||||
actions={
|
||||
<>
|
||||
<span className="text-[1.05rem] text-label">
|
||||
관로번호 {pipeNumber}
|
||||
{jobType === "MNHL" ? "GIS 번호" : "관로번호"} {pipeNumber}
|
||||
{reportNo ? ` · 보고서번호 ${reportNo}` : ""}
|
||||
</span>
|
||||
<Button type="button" color="white" onClick={() => window.close()}>
|
||||
@@ -93,7 +96,7 @@ export default function ExmnPipeReportPopupContent({
|
||||
</div>
|
||||
) : reportUrl ? (
|
||||
<iframe
|
||||
title={`${pipeNumber} 관로 조사보고서`}
|
||||
title={`${pipeNumber} ${reportLabel} 조사보고서`}
|
||||
src={reportUrl}
|
||||
className="h-full w-full border border-border bg-background"
|
||||
/>
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "SN";
|
||||
export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
|
||||
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 =
|
||||
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],
|
||||
viewExtent,
|
||||
viewMinZoom,
|
||||
// 지정 범위 전체까지 축소를 허용한다. 기본값은 기존 타 앱의 범위 제한을 유지한다.
|
||||
viewShowFullExtent = false,
|
||||
showZoomLevel = false,
|
||||
toolBarIconSize,
|
||||
zoomGuide,
|
||||
@@ -176,6 +178,7 @@ function Gis({
|
||||
minZoom: viewMinZoom ?? gisCore.defaults.minZoom,
|
||||
maxZoom: gisCore.defaults.maxZoom,
|
||||
extent: viewExtent,
|
||||
showFullExtent: viewShowFullExtent,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -21,11 +21,6 @@ import Tile from 'ol/layer/Tile.js';
|
||||
import TileWMS from 'ol/source/TileWMS.js';
|
||||
import gisCore from './gis.core.js';
|
||||
import { showBusinessUserMessage } from '../../../lib/user-message';
|
||||
import {
|
||||
buildFloodDngrSldBody,
|
||||
FLOOD_DNGR_MAP_LAYER_TYPE,
|
||||
getFloodDngrSldSettings,
|
||||
} from './gis.wms-sld.js';
|
||||
|
||||
/**
|
||||
* GisApp.layer
|
||||
@@ -195,19 +190,12 @@ GisOlLayer.prototype = {
|
||||
params['FORMAT'] = 'image/png';
|
||||
params['TRANSPARENT'] = true;
|
||||
params['TILED'] = true;
|
||||
// 빈 값이면 GeoServer 레이어의 기본 스타일(Default Style)로 그린다.
|
||||
// 도시침수지도(tb_ms_flood_dngr_map)의 침수심도 색상도 GeoServer에 등록한 기본 스타일을 사용하며,
|
||||
// 긴 SLD_BODY를 URL에 싣지 않아 운영 프록시의 헤더 크기 초과(502)를 피한다.
|
||||
params['STYLES'] = '';
|
||||
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({
|
||||
...layerOptions,
|
||||
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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
|
||||
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 };
|
||||
Reference in New Issue
Block a user