Merge branch 'iipbs/sn/dev'

This commit is contained in:
2026-09-08 22:29:39 +09:00
108 changed files with 2515 additions and 1371 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
NEXT_PUBLIC_NGII_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
# SEWERAGE_API_URL = http://localhost:8883
SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
SEWERAGE_API_URL = http://localhost:8883
# SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
DC_URL = https://apigateway.apps.rsonesoft.com
# NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login
+33
View File
@@ -6,6 +6,39 @@
현재 목표는 리팩토링 전용 작업이 아니라, 하수관로 운영관리 시스템 2차사업 고도화를 수행하는 것이다.
즉, 기존 `sn` 화면과 흐름을 안정적으로 유지하면서 2차 요구사항에 해당하는 화면 개발, 기능 확장, 백엔드 연동 보완을 수행해야 한다.
## 사용자 작업 범위: UI/UX 및 퍼블리싱
현재 사용자와 진행하는 작업의 기본 범위는 UI/UX 및 프론트엔드 퍼블리싱으로 제한한다.
사용자가 별도로 명시하지 않는 한 아래 기준을 모든 후속 작업에 적용한다.
### 허용 범위
- 화면 레이아웃과 시각적 위계 조정
- JSX 마크업, Tailwind CSS, CSS 스타일 수정
- 색상, 여백, 간격, 크기, 타이포그래피, 아이콘, 테두리, 배경 조정
- 반응형 화면, 접근성, 포커스와 시각적 상태 개선
- 표시 전용 컴포넌트 구성과 퍼블리싱 품질 개선
- 기존 화면 내용과 동작을 유지하는 범위의 UI 구조 정리
### 기본 금지 범위
- 백엔드 코드, API 계약, 요청·응답 데이터 구조 변경
- `.env*`, middleware, 프록시, 인증과 권한 설정 변경
- 업무 로직, 상태 모델, 데이터 가공 및 저장 흐름 변경
- 데이터베이스, 서버 프로세스와 배포 설정 변경
- 패키지 설치·업데이트와 의존성 구조 변경
- 사용자 요청 없이 화면의 문구, 데이터 항목, 기능 동작 변경
금지 범위의 변경이 필요해 보이면 직접 수정하지 않고 원인과 필요한 작업만 사용자에게 설명한다.
### UI 검증 안전 기준
- 사용자가 실행한 개발 서버가 있는지 실제 앱 포트까지 먼저 확인한다.
- 개발 서버 실행 중에는 같은 앱의 `next build`를 실행하지 않는다.
- UI 작업의 기본 검증은 실행 중인 개발 서버의 HMR, 브라우저 화면 확인, `git diff --check`로 제한한다.
- 개발 서버 종료, `.next` 정리, 전체 빌드와 프로세스 재시작은 사용자가 명시적으로 승인한 경우에만 수행한다.
- UI 변경 전후에 기존 문구, 데이터, 이벤트와 업무 동작이 유지되는지 확인한다.
---
## 공통 시작 기준
@@ -1,3 +1,4 @@
/** 시설 상세정보 화면의 운영정보 입력 영역을 공통 형식으로 표시한다. */
import {
Table,
TableHeader,
@@ -1,3 +1,7 @@
/**
* 화면명: 노후관로 레이어 설정
* 사업차수와 구조적·운영적 상태등급별 지도 표시 조건을 설정한다.
*/
"use client";
import { useEffect, useState } from "react";
@@ -54,23 +58,18 @@ export default function AgedPipeLayerSettingPanel({
};
const inputClass =
"h-[20px] !rounded border border-input bg-background px-[1px] text-center text-[1.1rem] leading-none text-foreground";
const numberInputClass = inputClass + " w-[40px]";
"h-[26px] !rounded border border-input bg-background px-2 py-0 text-center text-[12px] leading-none text-foreground";
const numberInputClass = inputClass + " min-w-0 flex-1";
const applyButtonClass =
"grid h-[16px] w-[16px] shrink-0 place-items-center rounded-full bg-primary text-[0.85rem] font-bold leading-none text-primary-foreground";
const indicator = (
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
);
"h-[26px] shrink-0 rounded border border-[#A9C5E5] bg-[#EDF4FC] px-2 py-0 text-[12px] font-medium leading-none text-[#2463A6]";
const settingRow = (
<div className="flex min-h-[26px] items-center gap-1 whitespace-nowrap text-[1.15rem] font-medium text-label">
<div className="flex min-w-0 items-center gap-1">
{indicator}
<div className="flex min-h-[26px] flex-col items-stretch gap-1.5 whitespace-nowrap text-[1.15rem] font-medium text-label">
<div className="flex min-w-0 items-center">
<label htmlFor="aged-pipe-elapsed-years" className="shrink-0">
노후관로 경과년도(년)
</label>
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
<div className="flex w-full items-center gap-1">
<input
id="aged-pipe-elapsed-years"
className={numberInputClass}
@@ -95,7 +94,7 @@ export default function AgedPipeLayerSettingPanel({
title="적용"
aria-label="경과년도 적용"
>
<span className="relative left-[1px]">✓</span>
확인
</button>
</div>
</div>
@@ -1,3 +1,7 @@
/**
* 화면명: 노후관로 목록
* 노후관로를 검색하고 목록·엑셀 다운로드·지도 위치 확인 기능을 제공한다.
*/
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -57,7 +61,7 @@ export default function AgedPipePopupPage() {
const sggOptions = useMemo(
() =>
filters.ctpvCd === "0"
? sgg
? []
: sgg.filter((item) =>
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
),
@@ -1,3 +1,4 @@
/** 노후관로 목록의 엑셀 다운로드 데이터와 서식을 생성한다. */
"use client";
import { postBlobData } from "@/components/lib/api";
@@ -1,3 +1,4 @@
/** 노후관로 검색 조건, 표시값 변환 및 지도 연계 유틸을 제공한다. */
"use client";
import type { SelectDataType } from "@common/business_components";
@@ -1,15 +1,28 @@
/**
* 화면명: 시도별 노후관로현황
* 기준연도별 시도 단위 노후관로 현황을 조회하고 엑셀로 내려받는다.
*/
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Input } from "@common/components";
import { StandardPopupLayout } from "@common/business_components";
import {
StandardDataTable,
StandardPopupLayout,
} from "@common/business_components";
import {
getCoreRowModel,
getPaginationRowModel,
useReactTable,
type ColumnDef,
type PaginationState,
} from "@tanstack/react-table";
import { getRawJson } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { useAlertStore } from "@/shared/lib";
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
import {
AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
buildAgedPipeStatusByCtpvHeadList,
buildAgedPipeStatusYearOptions,
getAgedPipeStatusByCtpvValidationMessage,
normalizeAgedPipeStatusByCtpvResponse,
@@ -20,7 +33,6 @@ import {
type AgedPipeStatusByCtpvRow,
} from "./agedPipeStatusByCtpvUtils";
import { downloadAgedPipeStatusByCtpvExcel } from "./agedPipeStatusByCtpvExport";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
// CS 목록과 동일하게 한 페이지에는 최대 10개 시도만 표시한다.
@@ -52,21 +64,48 @@ export default function AgedPipeStatusByCtpvPopupPage() {
const [currentPage, setCurrentPage] = useState(1);
const yearOptions = useMemo(() => buildAgedPipeStatusYearOptions(), []);
const headList = useMemo(
const columns = useMemo<ColumnDef<AgedPipeStatusByCtpvRow>[]>(
() => [
{ id: "rowNumber", title: "순번", widthPercent: 7 },
...buildAgedPipeStatusByCtpvHeadList(years),
{ accessorKey: "rowNumber", header: "순번" },
{ accessorKey: "ctpvNm", header: "시도" },
...years.map((year) => ({
id: `year_${year}`,
header: `${year}년`,
columns: [
{
accessorKey: `year${year}TotalLengthKm`,
header: "전체관로연장(km)",
},
{
accessorKey: `year${year}AgedLengthKm`,
header: "노후관로연장(km)",
},
{
accessorKey: `year${year}AgedRate`,
header: "노후화율(%)",
},
],
})),
],
[years],
);
const pageRows = useMemo(
() =>
rows.slice(
(currentPage - 1) * AGED_PIPE_STATUS_PAGE_SIZE,
currentPage * AGED_PIPE_STATUS_PAGE_SIZE,
),
[currentPage, rows],
);
const pagination: PaginationState = {
pageIndex: currentPage - 1,
pageSize: AGED_PIPE_STATUS_PAGE_SIZE,
};
const table = useReactTable({
data: rows,
columns,
state: { pagination },
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: (updater) => {
const next =
typeof updater === "function" ? updater(pagination) : updater;
setCurrentPage(next.pageIndex + 1);
},
});
const loadRows = useCallback(
async (nextFilters: AgedPipeStatusByCtpvFilters) => {
@@ -249,23 +288,19 @@ export default function AgedPipeStatusByCtpvPopupPage() {
</div>
</PopupSearchPanel>
<MapPopupDataTable
rows={
pageRows as Array<AgedPipeStatusByCtpvRow & Record<string, unknown>>
}
headlist={headList}
<StandardDataTable
table={table}
className="!w-full !max-w-full"
title="시도별 노후관로 목록"
total={rows.length}
page={currentPage}
pageSize={AGED_PIPE_STATUS_PAGE_SIZE}
onPageChange={setCurrentPage}
scrollMode={years.length > 2 ? "horizontal" : "none"}
minTableWidth={years.length > 2 ? "120rem" : "100%"}
scrollMode="horizontal"
minTableWidth={years.length > 0 ? "120rem" : "100%"}
actions={
<Button type="button" onClick={handleDownload}>
엑셀다운로드
</Button>
}
flushOuterMargin
/>
</StandardPopupLayout>
);
@@ -1,32 +1,81 @@
/** 시도별 노후관로 현황의 연도 그룹형 엑셀 데이터를 생성한다. */
"use client";
import { postBlobData } from "@/components/lib/api";
import { fileDownload } from "@/components/lib/utils/fileDownload";
import * as XLSX from "xlsx";
import { getRawJson } from "@/components/lib/api";
import {
getAgedPipeStatusByCtpvValidationMessage,
normalizeAgedPipeStatusByCtpvFilters,
type AgedPipeStatusByCtpvFilters,
type AgedPipeStatusByCtpvResponse,
} from "./agedPipeStatusByCtpvUtils";
/** 시도별 노후관로현황 XLSX 다운로드를 요청한다. */
/** 적용조건의 시도별 노후관로현황 전체를 조회해 연도 그룹 Excel로 생성한다. */
export const downloadAgedPipeStatusByCtpvExcel = async (
filters: AgedPipeStatusByCtpvFilters,
) => {
const validationMessage = getAgedPipeStatusByCtpvValidationMessage(filters);
if (validationMessage) throw new Error(validationMessage);
const normalizedFilters = normalizeAgedPipeStatusByCtpvFilters(filters);
const { blob, fileName } = await postBlobData(
"v1/swagePpl/aged-status-by-ctpv/exportJobs/download",
{
startYear: normalizedFilters.startYear,
endYear: normalizedFilters.endYear,
elapsedYears: normalizedFilters.elapsedYears,
format: "xlsx",
},
const normalized = normalizeAgedPipeStatusByCtpvFilters(filters);
const params = new URLSearchParams({
startYear: String(normalized.startYear),
endYear: String(normalized.endYear),
elapsedYears: String(normalized.elapsedYears),
});
const response: any = await getRawJson(
`v1/swagePpl/aged-status-by-ctpv?${params.toString()}`,
);
const payload = (response?.data ?? response) as AgedPipeStatusByCtpvResponse;
const years = Array.isArray(payload?.years)
? payload.years.map(Number).filter(Number.isFinite)
: [];
const rows = Array.isArray(payload?.rows) ? payload.rows : [];
if (rows.length === 0) throw new Error("다운로드할 데이터가 없습니다.");
const resolvedFileName = fileName || "시도별_노후관로현황.xlsx";
fileDownload(blob, resolvedFileName);
return resolvedFileName;
const headers = ["순번", "시도"];
years.forEach((year) => headers.push(`${year}년`, "", ""));
const subHeaders = ["", ""];
years.forEach(() =>
subHeaders.push("전체관로연장(km)", "노후관로연장(km)", "노후화율(%)"),
);
const data = rows.map((row, rowIndex) => {
const statsByYear = new Map(
(row.yearStats ?? []).map((stat) => [Number(stat.year), stat]),
);
const values: Array<string | number> = [rowIndex + 1, row.ctpvNm || "-"];
years.forEach((year) => {
const stat = statsByYear.get(year);
values.push(
stat ? Number(stat.totalLengthKm ?? 0) : "-",
stat ? Number(stat.agedLengthKm ?? 0) : "-",
stat ? Number(stat.agedRate ?? 0) : "-",
);
});
return values;
});
const worksheet = XLSX.utils.aoa_to_sheet([headers, subHeaders, ...data]);
worksheet["!merges"] = [
{ s: { r: 0, c: 0 }, e: { r: 1, c: 0 } },
{ s: { r: 0, c: 1 }, e: { r: 1, c: 1 } },
...years.map((_, index) => ({
s: { r: 0, c: 2 + index * 3 },
e: { r: 0, c: 4 + index * 3 },
})),
];
worksheet["!cols"] = [
{ wch: 8 },
{ wch: 18 },
...years.flatMap(() => [{ wch: 18 }, { wch: 18 }, { wch: 14 }]),
];
worksheet["!autofilter"] = {
ref: `A2:${XLSX.utils.encode_col(headers.length - 1)}${data.length + 2}`,
};
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "시도별 노후관로현황");
XLSX.writeFile(workbook, "시도별_노후관로현황.xlsx", {
bookType: "xlsx",
compression: true,
cellStyles: true,
});
};
@@ -1,3 +1,4 @@
/** 시도별 노후관로 현황의 조회 연도와 그룹 컬럼을 구성한다. */
"use client";
import type { UiHeadProps } from "@common/business_components/ui";
@@ -65,16 +66,16 @@ export const normalizeAgedPipeStatusByCtpvFilters = (
filters: Partial<AgedPipeStatusByCtpvFilters>,
): AgedPipeStatusByCtpvFilters => {
const currentYear = new Date().getFullYear();
let startYear = Math.trunc(Number(filters.startYear ?? currentYear));
let endYear = Math.trunc(Number(filters.endYear ?? startYear));
let startYear = Math.trunc(Number(filters.startYear ?? currentYear - 2));
let endYear = Math.trunc(Number(filters.endYear ?? currentYear));
let elapsedYears = Math.trunc(
Number(
filters.elapsedYears ?? AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
),
);
if (!Number.isFinite(startYear)) startYear = currentYear;
if (!Number.isFinite(endYear)) endYear = startYear;
if (!Number.isFinite(startYear)) startYear = currentYear - 2;
if (!Number.isFinite(endYear)) endYear = currentYear;
if (
!Number.isFinite(elapsedYears) ||
elapsedYears < 1 ||
@@ -1,9 +1,12 @@
/**
* 화면명: 분석도구 메뉴
* 지도에서 실행할 분석·현황 팝업 메뉴를 펼치고 선택한다.
*/
"use client";
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
import {
ChartColumnBig,
ChevronDown,
ChevronUp,
PanelsTopLeft,
PencilRuler,
@@ -32,10 +35,10 @@ type MenuIconProps = {
isActive: boolean;
};
const InftwtMenuIcon = ({ isActive }: MenuIconProps) => (
const InftwtMenuIcon = () => (
<svg
viewBox="0 0 24 24"
className={`h-[15px] w-[15px] shrink-0 ${isActive ? "text-primary" : "text-label"}`}
className="h-[15px] w-[15px] shrink-0 text-current"
aria-hidden="true"
fill="none"
>
@@ -167,31 +170,44 @@ export default function AnalysisPopupMenu({
}, [initialActiveKey]);
return (
<div className="absolute right-[15px] top-[8px] z-[45]">
<div className="flex w-[154px] flex-col overflow-hidden rounded-[6px] border border-border bg-background shadow-[0_8px_18px_rgba(15,23,42,0.14)]">
<div className="flex h-[36px] shrink-0 items-center justify-between gap-2 border-b border-border bg-secondary pl-[10px] pr-[4px] text-[13px] font-bold text-foreground">
<div className="flex items-center gap-2">
<PanelsTopLeft className="h-4 w-4 text-primary" />
<span>분석도구</span>
<div className="pointer-events-none absolute right-[68px] top-[16px] z-[45] flex flex-col items-end gap-[8px]">
<button
type="button"
className={`pointer-events-auto flex h-[40px] w-[40px] shrink-0 items-center justify-center overflow-hidden rounded-[3px] p-0 shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)] ${
isOpen
? "bg-[#0279FA] text-white"
: "bg-white text-[#666666] hover:bg-[#0279FA] hover:text-white"
}`}
onClick={() => setIsOpen((value) => !value)}
title={isOpen ? "분석 메뉴 숨기기" : "분석도구"}
aria-label={isOpen ? "분석 메뉴 숨기기" : "분석 메뉴 보이기"}
aria-expanded={isOpen}
aria-controls="analysis-popup-menu"
>
<PanelsTopLeft className="h-[20px] w-[20px]" aria-hidden="true" />
</button>
{isOpen ? (
<div
id="analysis-popup-menu"
className="pointer-events-auto flex w-max min-w-[154px] flex-col overflow-hidden rounded-[6px] bg-background shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)]"
>
<div className="flex h-[36px] shrink-0 items-center justify-between gap-2 border-b border-border bg-white pl-[10px] pr-[4px] text-[13px] font-bold text-foreground">
<div className="flex items-center gap-2">
<PanelsTopLeft className="h-4 w-4 text-primary" />
<span>분석도구</span>
</div>
<button
type="button"
className="flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-[4px] text-label hover:bg-accent hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#0279FA]"
onClick={() => setIsOpen(false)}
title="분석 메뉴 숨기기"
aria-label="분석 메뉴 숨기기"
>
<ChevronUp className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<button
type="button"
className="flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-[4px] text-label transition hover:bg-accent hover:text-primary"
onClick={() => setIsOpen((value) => !value)}
title={isOpen ? "분석 메뉴 숨기기" : "분석 메뉴 보이기"}
aria-label={isOpen ? "분석 메뉴 숨기기" : "분석 메뉴 보이기"}
aria-expanded={isOpen}
>
{isOpen ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
</div>
{isOpen ? (
<div className="flex flex-col gap-[3px] p-[6px]">
{ANALYSIS_POPUP_ITEMS.map((item) => {
const Icon = item.icon;
@@ -208,7 +224,7 @@ export default function AnalysisPopupMenu({
<button
key={item.key}
type="button"
className={`relative flex h-[33px] items-center gap-[7px] rounded-[5px] border px-[8px] pl-[10px] text-left text-[12px] font-semibold transition ${
className={`relative flex h-[33px] items-center gap-[7px] rounded-[5px] border px-[8px] pl-[10px] text-left text-[12px] font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#0279FA] ${
isActive
? "border-primary bg-secondary font-bold text-primary before:absolute before:bottom-[7px] before:left-0 before:top-[7px] before:w-[3px] before:rounded-r-[2px] before:bg-primary"
: "border-transparent bg-background text-foreground hover:border-border hover:bg-accent hover:text-primary"
@@ -244,19 +260,17 @@ export default function AnalysisPopupMenu({
>
{item.renderIcon ? item.renderIcon({ isActive }) : null}
{Icon ? (
<Icon
className={`h-[15px] w-[15px] shrink-0 ${
isActive ? "text-primary" : "text-label"
}`}
/>
<Icon className="h-[15px] w-[15px] shrink-0 text-current" />
) : null}
<span className="whitespace-nowrap">{label}</span>
<span className="whitespace-nowrap" title={label}>
{label}
</span>
</button>
);
})}
</div>
) : null}
</div>
</div>
) : null}
</div>
);
}
@@ -1,3 +1,4 @@
/** 지도 시설 상세정보의 코드값을 업무 표시명으로 변환한다. */
import { MS_SWAGE_PPL_USE_CODES, MS_SWAGE_PPL_CODES, MS_SWAGE_MHL_CODES, MS_SWAGE_MHL_USE_CODES, DRTW_SEWAGE_PUMP_USE_CODES } from './table';
interface CodeMapperProps {
@@ -159,4 +160,4 @@ export const reverseCodeMapper = ({ layerName, data }: CodeMapperProps): any =>
};
// 기본 내보내기
export default { codeMapper, reverseCodeMapper };
export default { codeMapper, reverseCodeMapper };
@@ -1,3 +1,4 @@
/** 선택한 지도 레이어 종류에 맞는 시설 상세 컴포넌트를 반환한다. */
import MsSwagePpl from "../popupComponent/MsSwagePpl"; // 하수관로
import sewagePump from "../popupComponent/sewagePump"; // 하수펌프
import smh from "../popupComponent/smh";
@@ -1,3 +1,4 @@
/** 지도 시설 상세 및 목록에서 사용하는 컬럼·코드 정의를 제공한다. */
// 하수관로
export const MsSwagePplCol = [
{ id: 'fclt_mng_no', title: '시설관리번호' },
@@ -1,3 +1,7 @@
/**
* 화면명: 관로조사 레이어 설정
* 사업차수와 조사 종류·상태등급별 지도 표시 조건을 설정한다.
*/
"use client";
import type { ExmnMapFilters, ExmnMapJobType } from "./exmnMapTypes";
@@ -30,7 +34,7 @@ export default function ExmnLayerSettingPanel({
});
};
const selectClass =
"h-[20px] w-[62px] rounded border border-input bg-background px-[1px] text-center text-[1.1rem] leading-none text-foreground outline-none focus:border-primary";
"h-[26px] w-full rounded border border-input bg-background px-2 py-0 text-center text-[12px] leading-none text-foreground outline-none focus:border-primary";
const labelClass = "shrink-0 text-[1.15rem] font-medium text-label";
const title = jobType === "PPL" ? "관로조사" : "맨홀조사";
@@ -41,8 +45,8 @@ export default function ExmnLayerSettingPanel({
onEnabledChange={onEnabledChange}
onClose={onClose}
>
<div className="space-y-[1px]">
<label className="flex min-h-[24px] items-center justify-between ">
<div className="space-y-[6px]">
<label className="flex min-h-[24px] flex-col items-stretch gap-1">
<span className={`flex items-center gap-1 ${labelClass}`}>
<span
className={`h-2 w-2 rounded-full ${jobType === "PPL" ? "bg-[#facc15]" : "bg-[#8b5cf6]"}`}
@@ -59,7 +63,7 @@ export default function ExmnLayerSettingPanel({
onChange={(event) => updateFilter("bizCycl", event.target.value)}
/>
</label>
<label className="flex min-h-[24px] items-center justify-between ">
<label className="flex min-h-[24px] flex-col items-stretch gap-1">
<span className={labelClass}>구조적 상태등급</span>
<select
className={selectClass}
@@ -75,7 +79,7 @@ export default function ExmnLayerSettingPanel({
))}
</select>
</label>
<label className="flex min-h-[24px] items-center justify-between ">
<label className="flex min-h-[24px] flex-col items-stretch gap-1">
<span className={labelClass}>운영적 상태등급</span>
<select
className={selectClass}
@@ -1,3 +1,7 @@
/**
* 화면명: 관로조사 결과
* 선택 시설의 조사 결과와 상세 판정 정보를 표시한다.
*/
"use client";
import {
@@ -1,3 +1,4 @@
/** 관로조사 지도 데이터 조회 API와 응답 변환 함수를 제공한다. */
import { getRawJson } from "../../../../components/lib/api";
import type { ExmnMapFilters, ExmnMapJobType } from "./exmnMapTypes";
@@ -1,3 +1,4 @@
/** 관로조사 지도 화면에서 사용하는 타입·상수와 표시 조건을 정의한다. */
export const EXMN_RESULT_ROOT_NODE_ID = "SN_EXMN_RESULT";
export const EXMN_RESULT_PPL_NODE_ID = "SN_EXMN_RESULT_PPL";
export const EXMN_RESULT_MNHL_NODE_ID = "SN_EXMN_RESULT_MNHL";
@@ -1,11 +1,12 @@
/**
* 화면명: 침수흔적도 상세정보
* 지도에서 선택한 침수흔적의 기본정보·강우정보·피해정보를 표시한다.
*/
"use client";
import { useMemo, useState } from "react";
import {
formatFloodArea,
formatFloodDepth,
type FldngHstryItem,
} from "./fldngHstryTypes";
import type { FldngHstryItem } from "./fldngHstryTypes";
import { formatFldngHstryArea, formatFldngHstryDepth } from "./fldngHstryUtils";
type Props = {
detail: FldngHstryItem;
@@ -50,8 +51,8 @@ export default function FldngHstryDetailPanel({
const yearRows = useMemo(
() =>
[
["침수심", `${detail.floodDepthM.toFixed(2)} m`],
["침수면적", formatFloodArea(detail.floodAreaHa)],
["침수심", `${formatFldngHstryDepth(detail.floodDepthM)} m`],
["침수면적", formatFldngHstryArea(detail.floodAreaHa)],
["침수년도", `${detail.floodYear}년`],
["등급", detail.gradeLabel],
["침수구역", detail.districtName],
@@ -63,7 +64,7 @@ export default function FldngHstryDetailPanel({
() =>
[
["침수재해명", detail.disasterName],
["침수면적", formatFloodArea(detail.floodAreaHa)],
["침수면적", formatFldngHstryArea(detail.floodAreaHa)],
["침수원인상세명", detail.causeDetailName],
["침수시작년월일", detail.startDate],
["침수종료년월일", detail.endDate],
@@ -0,0 +1,397 @@
/**
* 화면명: 침수흔적위선 연계정보
* 우측 정보 화면의 심선·위선 목록, 선택 상태와 화면 생명주기 캐시를 관리한다.
*/
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { RefObject } from "react";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import type Feature from "ol/Feature.js";
import VectorSource from "ol/source/Vector.js";
import { loadFldngHstryDetail } from "./fldngHstryClient";
import {
FLDNG_HSTRY_DEPTH_LAYER_TYPE,
FLDNG_HSTRY_LAYER_TYPE,
type FldngHstryDetailCacheValue,
} from "./fldngHstryTypes";
type FldngHstryGisLayer = {
searchLayer?: { getSource?: () => VectorSource | null } | null;
clearSearchLayer: (option: unknown) => void;
ensureSearchLayer: (option: unknown) => {
set: (key: string, value: unknown) => void;
setSource: (source: VectorSource) => void;
};
showSearchResultsFromGeoJSON: (
layerTypeName: string,
featureId: string,
) => void | Promise<void>;
};
type FldngHstryScrollButtonProps = {
targetRef: RefObject<HTMLDivElement | null>;
direction: -1 | 1;
label: string;
children: string;
};
const FLDNG_HSTRY_SCROLL_CLICK_DISTANCE = 120;
const FLDNG_HSTRY_SCROLL_HOLD_DELAY = 300;
const FLDNG_HSTRY_SCROLL_HOLD_SPEED = 4;
/** 짧게 누르면 한 칸, 길게 누르면 목록을 해당 방향으로 계속 이동한다. */
const FldngHstryScrollButton = ({
targetRef,
direction,
label,
children,
}: FldngHstryScrollButtonProps) => {
const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const animationFrameRef = useRef<number | null>(null);
const pointerClickHandledRef = useRef(false);
const stopContinuousScroll = useCallback(() => {
if (holdTimerRef.current !== null) {
clearTimeout(holdTimerRef.current);
holdTimerRef.current = null;
}
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
}, []);
const scrollOnce = useCallback(() => {
targetRef.current?.scrollBy({
left: direction * FLDNG_HSTRY_SCROLL_CLICK_DISTANCE,
behavior: "smooth",
});
}, [direction, targetRef]);
const startContinuousScroll = useCallback(() => {
const scroll = () => {
const target = targetRef.current;
if (!target) {
stopContinuousScroll();
return;
}
target.scrollLeft += direction * FLDNG_HSTRY_SCROLL_HOLD_SPEED;
animationFrameRef.current = requestAnimationFrame(scroll);
};
animationFrameRef.current = requestAnimationFrame(scroll);
}, [direction, stopContinuousScroll, targetRef]);
useEffect(() => stopContinuousScroll, [stopContinuousScroll]);
return (
<button
type="button"
aria-label={label}
className="shrink-0 select-none px-1 text-lg leading-none text-foreground"
onPointerDown={(event) => {
if (event.button !== 0) return;
stopContinuousScroll();
pointerClickHandledRef.current = true;
event.currentTarget.setPointerCapture(event.pointerId);
scrollOnce();
holdTimerRef.current = setTimeout(
startContinuousScroll,
FLDNG_HSTRY_SCROLL_HOLD_DELAY,
);
}}
onPointerUp={stopContinuousScroll}
onPointerCancel={() => {
stopContinuousScroll();
pointerClickHandledRef.current = false;
}}
onLostPointerCapture={stopContinuousScroll}
onClick={() => {
if (pointerClickHandledRef.current) {
pointerClickHandledRef.current = false;
return;
}
scrollOnce();
}}
>
{children}
</button>
);
};
const cloneFldngHstryFeature = (feature: Feature) => {
const clonedFeature = feature.clone();
const featureId = feature.getId();
if (featureId !== undefined) clonedFeature.setId(featureId);
return clonedFeature;
};
const displayCachedFldngHstryFeatures = (
gisLayer: FldngHstryGisLayer,
layerTypeName: string,
features: Feature[],
) => {
const matched = Object.values(GisApp.layerCode ?? {}).find(
(item: any) => item?.lyrTyNm === layerTypeName,
) as any;
if (!matched || features.length === 0) return;
gisLayer.clearSearchLayer(matched);
const layer = gisLayer.ensureSearchLayer(matched);
layer.set("gobjTy", matched.gobjTy);
const clonedFeatures = features.map(cloneFldngHstryFeature);
layer.setSource(new VectorSource({ features: clonedFeatures }));
(GisApp.Module.select as any)?.selectFeature(clonedFeatures[0], false);
};
const showFldngHstryFeature = async (
cache: Map<string, Promise<Feature[]>>,
gisLayer: FldngHstryGisLayer,
layerTypeName: string,
featureId: string,
) => {
const cacheKey = `${layerTypeName}:${featureId}`;
const cachedRequest = cache.get(cacheKey);
if (cachedRequest) {
displayCachedFldngHstryFeatures(
gisLayer,
layerTypeName,
await cachedRequest,
);
return;
}
await gisLayer.showSearchResultsFromGeoJSON(layerTypeName, featureId);
const source = gisLayer.searchLayer?.getSource?.();
if (!source) return;
const featureRequest = new Promise<Feature[]>((resolve, reject) => {
const resolveFeatures = () => {
const features = source.getFeatures();
if (features.length > 0) {
resolve(features.map(cloneFldngHstryFeature));
} else {
reject(new Error("침수흔적 위치 정보가 없습니다."));
}
};
const rejectFeatures = () =>
reject(new Error("침수흔적 위치 조회에 실패했습니다."));
if (source.getFeatures().length > 0) {
resolveFeatures();
return;
}
source.once("featuresloadend", resolveFeatures);
source.once("featuresloaderror", rejectFeatures);
});
cache.set(cacheKey, featureRequest);
try {
await featureRequest;
} catch (error) {
cache.delete(cacheKey);
console.warn(error);
}
};
export const useFldngHstryFeatureClick = () => {
const fldngHstryDetailCacheRef = useRef(
new Map<string, Promise<FldngHstryDetailCacheValue>>(),
);
const fldngHstryFeatureCacheRef = useRef(
new Map<string, Promise<Feature[]>>(),
);
const [fldngHstryDepthRows, setFldngHstryDepthRows] = useState<
Record<string, unknown>[]
>([]);
const [fldngHstryWtlvRows, setFldngHstryWtlvRows] = useState<
Record<string, unknown>[]
>([]);
const [selectedFldngHstryDepthFid, setSelectedFldngHstryDepthFid] = useState<
string | null
>(null);
const [selectedFldngHstryWtlvFid, setSelectedFldngHstryWtlvFid] = useState<
string | null
>(null);
const fldngHstryDepthYearListRef = useRef<HTMLDivElement>(null);
const fldngHstryWtlvListRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const detailCache = fldngHstryDetailCacheRef.current;
const featureCache = fldngHstryFeatureCacheRef.current;
return () => {
detailCache.clear();
featureCache.clear();
};
}, []);
const resetFldngHstryFeatureClick = useCallback(() => {
setFldngHstryDepthRows([]);
setFldngHstryWtlvRows([]);
setSelectedFldngHstryDepthFid(null);
setSelectedFldngHstryWtlvFid(null);
}, []);
const loadFldngHstryFeatureClickDetail = useCallback(
async (fldngHstryFid: string) => {
const cacheKey = `${FLDNG_HSTRY_LAYER_TYPE}:${fldngHstryFid}`;
let request = fldngHstryDetailCacheRef.current.get(cacheKey);
if (!request) {
request = loadFldngHstryDetail(fldngHstryFid);
fldngHstryDetailCacheRef.current.set(cacheKey, request);
}
const detail = await request;
if (!detail.complete) fldngHstryDetailCacheRef.current.delete(cacheKey);
setFldngHstryDepthRows(detail.fldngHstryDepthRows);
setFldngHstryWtlvRows(detail.fldngHstryWtlvRows);
setSelectedFldngHstryDepthFid(null);
setSelectedFldngHstryWtlvFid(null);
return detail;
},
[],
);
const headName = useMemo(
() => (
<div className="flex w-full min-w-0 flex-col items-start gap-2">
<span>기본정보</span>
{fldngHstryDepthRows.length > 0 && (
<div className="flex w-full items-center gap-1">
<span className="w-9 shrink-0 text-[1.1rem] font-semibold">
심선
</span>
<FldngHstryScrollButton
targetRef={fldngHstryDepthYearListRef}
direction={-1}
label="이전 연도"
>
‹
</FldngHstryScrollButton>
<div
ref={fldngHstryDepthYearListRef}
className="flex min-w-0 flex-1 gap-2 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{[...fldngHstryDepthRows]
.map((row, index) => ({
row,
index,
year: String(
row.fldng_yr ?? row.fldngYr ?? row.yr ?? "",
).trim(),
}))
.filter(({ year }) => Boolean(year))
.sort((left, right) => {
const yearOrder = left.year.localeCompare(
right.year,
undefined,
{ numeric: true },
);
return yearOrder !== 0 ? yearOrder : left.index - right.index;
})
.map(({ row, index, year }) => {
const depthFid = String(
row.fldng_dpth_map_sn ?? row.fid ?? "",
).trim();
return (
<button
key={`${depthFid || "row"}-${index}`}
type="button"
onClick={async () => {
if (!depthFid) return;
setSelectedFldngHstryDepthFid(depthFid);
setSelectedFldngHstryWtlvFid(null);
await showFldngHstryFeature(
fldngHstryFeatureCacheRef.current,
GisApp.Module.layer as any,
FLDNG_HSTRY_DEPTH_LAYER_TYPE,
`${FLDNG_HSTRY_DEPTH_LAYER_TYPE}.${depthFid.replace(new RegExp(`^${FLDNG_HSTRY_DEPTH_LAYER_TYPE}\\.`), "")}`,
);
}}
className={`shrink-0 rounded border px-3 py-1 text-[1.2rem] font-medium leading-5 ${selectedFldngHstryDepthFid === depthFid ? "border-blue-600 bg-white text-blue-700" : "border-border bg-muted text-muted-foreground"}`}
>
{year}년
</button>
);
})}
</div>
<FldngHstryScrollButton
targetRef={fldngHstryDepthYearListRef}
direction={1}
label="다음 연도"
>
›
</FldngHstryScrollButton>
</div>
)}
{fldngHstryWtlvRows.length > 0 && (
<div className="flex w-full items-center gap-1">
<span className="w-9 shrink-0 text-[1.1rem] font-semibold">
위선
</span>
<FldngHstryScrollButton
targetRef={fldngHstryWtlvListRef}
direction={-1}
label="이전 위선"
>
‹
</FldngHstryScrollButton>
<div
ref={fldngHstryWtlvListRef}
className="flex min-w-0 flex-1 gap-2 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{fldngHstryWtlvRows.map((row, index) => {
const waterLevelFid = String(row.fid ?? "").trim();
const disasterName = String(
row.fldng_dstr_nm ?? row.fldngDstrNm ?? "",
).trim();
return (
<button
key={`${waterLevelFid || "wtlv"}-${index}`}
type="button"
onClick={async () => {
if (!waterLevelFid) return;
setSelectedFldngHstryWtlvFid(waterLevelFid);
setSelectedFldngHstryDepthFid(null);
await showFldngHstryFeature(
fldngHstryFeatureCacheRef.current,
GisApp.Module.layer as any,
FLDNG_HSTRY_LAYER_TYPE,
`${FLDNG_HSTRY_LAYER_TYPE}.${waterLevelFid.replace(new RegExp(`^${FLDNG_HSTRY_LAYER_TYPE}\\.`), "")}`,
);
}}
className={`shrink-0 rounded border px-3 py-1 text-[1.2rem] font-medium leading-5 ${selectedFldngHstryWtlvFid === waterLevelFid ? "border-blue-600 bg-white text-blue-700" : "border-border bg-muted text-muted-foreground"}`}
>
{disasterName || "재해명 없음"}
</button>
);
})}
</div>
<FldngHstryScrollButton
targetRef={fldngHstryWtlvListRef}
direction={1}
label="다음 위선"
>
›
</FldngHstryScrollButton>
</div>
)}
</div>
),
[
fldngHstryDepthRows,
fldngHstryWtlvRows,
selectedFldngHstryDepthFid,
selectedFldngHstryWtlvFid,
],
);
return {
hasFldngHstryLinkedFeatures:
fldngHstryDepthRows.length > 0 || fldngHstryWtlvRows.length > 0,
fldngHstryFeatureClickHeadName: headName,
loadFldngHstryFeatureClickDetail,
resetFldngHstryFeatureClick,
};
};
@@ -1,16 +1,21 @@
/**
* 화면명: 침수흔적도 레이어 설정
* 침수흔적위선·심선의 표시 여부와 조회 연도 범위를 설정한다.
*/
"use client";
import { SelectBox } from "@common/business_components/ui";
import {
FLOOD_HISTORY_MIN_YEAR,
normalizeFloodHistoryYearRange,
type FloodHistoryYearRange,
FLDNG_HSTRY_MIN_YEAR,
type FldngHstryYearRange,
} from "./fldngHstryTypes";
import { normalizeFldngHstryYearRange } from "./fldngHstryUtils";
import LayerSettingCard from "../layerSetting/LayerSettingCard";
type Props = {
yearRange: FloodHistoryYearRange;
yearRange: FldngHstryYearRange;
enabled: boolean;
onYearRangeChange: (yearRange: FloodHistoryYearRange) => void;
onYearRangeChange: (yearRange: FldngHstryYearRange) => void;
onEnabledChange: (enabled: boolean) => void;
onClose: () => void;
};
@@ -23,10 +28,10 @@ export default function FldngHstryLayerSettingPanel({
onEnabledChange,
onClose,
}: Props) {
const normalizedRange = normalizeFloodHistoryYearRange(yearRange);
const normalizedRange = normalizeFldngHstryYearRange(yearRange);
const currentYear = new Date().getFullYear();
const yearOptions = Array.from(
{ length: currentYear - FLOOD_HISTORY_MIN_YEAR + 1 },
{ length: currentYear - FLDNG_HSTRY_MIN_YEAR + 1 },
(_, index) => String(currentYear - index),
);
const startYearOptions = yearOptions
@@ -36,11 +41,11 @@ export default function FldngHstryLayerSettingPanel({
.filter((year) => Number(year) >= Number(normalizedRange.startYear))
.map((year) => ({ val: year, text: year }));
const yearSelectClass =
"h-[20px] w-[62px] !rounded border border-input bg-background px-[1px] text-center text-[1.1rem] leading-none text-foreground";
"!h-[26px] w-full !rounded border border-input bg-background px-2 py-0 text-center text-[12px] leading-none text-foreground";
const changeYear = (key: keyof FloodHistoryYearRange, value: string) => {
const changeYear = (key: keyof FldngHstryYearRange, value: string) => {
onYearRangeChange(
normalizeFloodHistoryYearRange({
normalizeFldngHstryYearRange({
...normalizedRange,
[key]: value,
}),
@@ -48,41 +53,16 @@ export default function FldngHstryLayerSettingPanel({
};
return (
<div className="w-full overflow-visible rounded-[6px] border border-border bg-background shadow-blur">
<div className="flex min-h-[28px] items-center justify-between border-b border-border px-2">
<span className="text-[1.15rem] font-semibold text-label">
침수흔적도
</span>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label="침수년도 필터 적용 여부"
title={enabled ? "침수년도 필터 적용 중" : "침수년도 필터 미적용"}
className={`relative h-[16px] w-[28px] rounded-full border transition-colors ${enabled ? "border-primary bg-primary" : "border-[#94a3b8] bg-[#cbd5e1]"}`}
onClick={() => onEnabledChange(!enabled)}
>
<span
className={`absolute top-1/2 h-[12px] w-[12px] -translate-y-1/2 rounded-full bg-white shadow-sm transition-[left] ${enabled ? "left-[14px]" : "left-[2px]"}`}
/>
</button>
<button
type="button"
aria-label="침수년도 설정 닫기"
title="닫기"
className="grid h-[20px] w-[20px] place-items-center bg-transparent text-[1.3rem] leading-none text-muted-foreground hover:text-foreground"
onClick={onClose}
>
×
</button>
</div>
</div>
<div className="flex min-h-[30px] items-center gap-1.5 px-2 py-1 text-[1.15rem] font-medium text-label">
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
<LayerSettingCard
title="침수흔적도"
enabled={enabled}
onEnabledChange={onEnabledChange}
onClose={onClose}
>
<div className="flex flex-col gap-1.5 font-medium text-label">
<span className="shrink-0">침수년도</span>
<div className="ml-auto flex shrink-0 items-center gap-1.5">
<div className="w-[62px]">
<div className="flex w-full items-center gap-1.5">
<div className="min-w-0 flex-1">
<SelectBox
className="w-full"
selectClass={yearSelectClass}
@@ -97,7 +77,7 @@ export default function FldngHstryLayerSettingPanel({
<span className="px-[1px] text-[0.95rem] font-normal text-muted-foreground">
~
</span>
<div className="w-[62px]">
<div className="min-w-0 flex-1">
<SelectBox
className="w-full"
selectClass={yearSelectClass}
@@ -111,6 +91,6 @@ export default function FldngHstryLayerSettingPanel({
</div>
</div>
</div>
</div>
</LayerSettingCard>
);
}
@@ -1,5 +1,6 @@
/**
* 침수발생이력 popup
* 화면명: 침수발생이력 목록
* 침수발생이력을 검색하고 엑셀 다운로드 및 지도 위치 확인 기능을 제공한다.
*/
"use client";
@@ -16,22 +17,24 @@ import LoadingOverlay from "@/components/ui/LoadingBar";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import {
FLOOD_HISTORY_LAYER_TYPE,
FLDNG_HSTRY_LAYER_TYPE,
FLDNG_HSTRY_DEFAULT_FILTERS,
FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE,
FLDNG_HSTRY_DOWNLOAD_HEADERS,
FLDNG_HSTRY_LIST_HEADLIST,
FLDNG_HSTRY_PAGE_SIZE,
FLDNG_HSTRY_ROW_SELECT_MESSAGE,
type FldngHstryApiItem,
type FldngHstryFilters,
type FldngHstryTableRow,
} from "./fldngHstryTypes";
import {
attachFldngHstryGeometry,
buildFldngHstryListQueryString,
buildFldngHstryYearOptions,
normalizeFldngHstryFilters,
toFldngHstryTableRows,
type FldngHstryApiItem,
type FldngHstryFilters,
type FldngHstryTableRow,
} from "./fldngHstryTypes";
} from "./fldngHstryUtils";
const unwrapPayload = <T,>(response: any): T =>
(response?.data ?? response) as T;
@@ -49,8 +52,8 @@ const resolveOptionText = (
const resolveFldngHstryMapFeatureFid = (row: FldngHstryTableRow) => {
const fid = String(row.fid ?? "").trim();
if (fid.startsWith(`${FLOOD_HISTORY_LAYER_TYPE}.`)) return fid;
if (fid) return `${FLOOD_HISTORY_LAYER_TYPE}.${fid}`;
if (fid.startsWith(`${FLDNG_HSTRY_LAYER_TYPE}.`)) return fid;
if (fid) return `${FLDNG_HSTRY_LAYER_TYPE}.${fid}`;
return "";
};
@@ -122,11 +125,10 @@ export default function FldngHstryPopupPage() {
const fldngYearOptions = useMemo(() => buildFldngHstryYearOptions(), []);
const sggOptions = useMemo(
() =>
filters.ctpvCd === "0"
? sgg
? []
: sgg.filter((item) =>
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
),
@@ -156,11 +158,15 @@ export default function FldngHstryPopupPage() {
...hydrated,
ctpvNm:
resolveOptionText(hydrated.ctpvCd ?? "", ctpv as any, 2) ||
String((hydrated as any).ctpvNm ?? (hydrated as any).ctpv_nm ?? "").trim() ||
String(
(hydrated as any).ctpvNm ?? (hydrated as any).ctpv_nm ?? "",
).trim() ||
String(hydrated.ctpvCd ?? "").trim(),
sggNm:
resolveOptionText(hydrated.sggCd ?? "", sgg as any, 5) ||
String((hydrated as any).sggNm ?? (hydrated as any).sgg_nm ?? "").trim() ||
String(
(hydrated as any).sggNm ?? (hydrated as any).sgg_nm ?? "",
).trim() ||
String(hydrated.sggCd ?? "").trim(),
};
});
@@ -232,7 +238,7 @@ export default function FldngHstryPopupPage() {
{
type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE,
payload: {
layerNm: FLOOD_HISTORY_LAYER_TYPE,
layerNm: FLDNG_HSTRY_LAYER_TYPE,
fid: mapFeatureFid,
},
},
@@ -261,8 +267,7 @@ export default function FldngHstryPopupPage() {
try {
const response = await fetchAllPages({
batchSize: FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE,
fetchPage: (page, size) =>
fetchRows(page + 1, appliedFilters, size),
fetchPage: (page, size) => fetchRows(page + 1, appliedFilters, size),
getItems: (page) => page.rows,
getTotal: (page) => page.total,
});
@@ -361,7 +366,9 @@ export default function FldngHstryPopupPage() {
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">강우시작일</span>
<span className="whitespace-nowrap text-[1.3rem] text-label">
강우시작일
</span>
<Input
className="w-[11rem]"
type="date"
@@ -369,13 +376,32 @@ export default function FldngHstryPopupPage() {
min={filters.floodYear ? `${filters.floodYear}-01-01` : undefined}
max={filters.floodYear ? `${filters.floodYear}-12-31` : undefined}
disabled={!filters.floodYear}
onChange={(event) => setFilters((current) => ({ ...current, rainStartDate: event.target.value }))}
onChange={(event) =>
setFilters((current) => ({
...current,
rainStartDate: event.target.value,
}))
}
/>
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">침수지구명</span>
<Input type="text" value={filters.districtName} onChange={(event) => setFilters((current) => ({ ...current, districtName: event.target.value }))} onKeyDown={(event) => { if (event.key === "Enter") void handleSearch(1); }} />
<span className="whitespace-nowrap text-[1.3rem] text-label">
침수지구명
</span>
<Input
type="text"
value={filters.districtName}
onChange={(event) =>
setFilters((current) => ({
...current,
districtName: event.target.value,
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") void handleSearch(1);
}}
/>
</label>
<label className="flex items-center gap-2">
@@ -0,0 +1,61 @@
/** 침수흔적 강우분석과 연결된 심선·위선 목록 조회를 담당한다. */
import { getData } from "@/components/lib/api";
import type { FldngHstryDetailCacheValue } from "./fldngHstryTypes";
const normalizeFldngHstryRainAnalysisPayload = (data: any) => {
if (!data || typeof data !== "object") return {};
return {
rain_bgng_tm: data.rainBgngTm ?? data.rain_bgng_tm ?? null,
rainBgngTm: data.rainBgngTm ?? data.rain_bgng_tm ?? null,
rain_end_tm: data.rainEndTm ?? data.rain_end_tm ?? null,
rainEndTm: data.rainEndTm ?? data.rain_end_tm ?? null,
rain_tot: data.rainTot ?? data.rain_tot ?? null,
rainTot: data.rainTot ?? data.rain_tot ?? null,
rain_str: data.rainStr ?? data.rain_str ?? null,
rainStr: data.rainStr ?? data.rain_str ?? null,
};
};
export const loadFldngHstryDetail = async (
fldngHstryFid: string,
): Promise<FldngHstryDetailCacheValue> => {
const [rainResult, depthResult] = await Promise.allSettled([
getData(`v1/fldng/hstry/rain-anls/${encodeURIComponent(fldngHstryFid)}`),
getData(`v1/fldng/hstry/depth-map/${encodeURIComponent(fldngHstryFid)}`),
]);
if (rainResult.status === "rejected") {
console.warn("침수강우분석 조회 실패:", rainResult.reason);
}
if (depthResult.status === "rejected") {
console.warn("침수깊이지도 조회 실패:", depthResult.reason);
}
const depthPayload =
depthResult.status === "fulfilled"
? (depthResult.value?.data ?? depthResult.value)
: null;
const depthItems = Array.isArray(depthPayload)
? depthPayload
: depthPayload?.depthMaps;
const waterLevelItems = Array.isArray(depthPayload?.waterLevelMaps)
? depthPayload.waterLevelMaps
: [];
return {
rainAnalysis:
rainResult.status === "fulfilled"
? normalizeFldngHstryRainAnalysisPayload(rainResult.value)
: {},
fldngHstryDepthRows: Array.isArray(depthItems)
? depthItems.filter((row) => row && typeof row === "object")
: [],
fldngHstryWtlvRows: waterLevelItems.filter(
(row: unknown) => row && typeof row === "object",
),
complete:
rainResult.status === "fulfilled" && depthResult.status === "fulfilled",
};
};
@@ -1,62 +1,27 @@
"use client";
/** 침수흔적 화면에서 사용하는 타입·상수와 화면 설정값을 정의한다. */
import type { UiHeadProps } from "@common/business_components/ui";
export const FLOOD_HISTORY_POPUP_KEY = "fldngHstryPopup";
export const FLDNG_HSTRY_POPUP_KEY = "fldngHstryPopup";
export const FLDNG_HSTRY_ROW_SELECT_MESSAGE = "SN_FLOOD_HISTORY_ROW_SELECT";
export const FLOOD_HISTORY_LAYER_TYPE = "tb_ms_fldng_wtlv_map";
export const FLOOD_HISTORY_DEPTH_LAYER_TYPE = "tb_ms_fldng_dpth_map";
export const FLOOD_HISTORY_PREVIEW_LAYER_TYPE = "tb_sn_flood_trace_mock";
export const FLOOD_HISTORY_LAYER_NAME = "침수흔적위선";
export const FLOOD_HISTORY_DEPTH_LAYER_NAME = "침수흔적심선";
export const FLOOD_HISTORY_ROOT_NAME = "침수흔적도";
export const FLOOD_HISTORY_AREA_LAYER_ID = "SN_FLOOD_HISTORY_AREA";
export const FLOOD_HISTORY_DEPTH_LAYER_ID = "SN_FLOOD_HISTORY_DEPTH";
export const FLDNG_HSTRY_LAYER_TYPE = "tb_ms_fldng_wtlv_map";
export const FLDNG_HSTRY_DEPTH_LAYER_TYPE = "tb_ms_fldng_dpth_map";
export const FLDNG_HSTRY_PREVIEW_LAYER_TYPE = "tb_sn_flood_trace_mock";
export const FLDNG_HSTRY_LAYER_NAME = "침수흔적위선";
export const FLDNG_HSTRY_DEPTH_LAYER_NAME = "침수흔적심선";
export const FLDNG_HSTRY_ROOT_NAME = "침수흔적도";
export const FLDNG_HSTRY_AREA_LAYER_ID = "SN_FLOOD_HISTORY_AREA";
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 FLOOD_HISTORY_MIN_YEAR = 2002;
export const FLDNG_HSTRY_MIN_YEAR = 2002;
/** 지도 침수흔적위선·심선이 공통으로 사용하는 침수년도 범위다. */
export type FloodHistoryYearRange = {
startYear: string;
endYear: string;
export type FldngHstryDetailCacheValue = {
rainAnalysis: Record<string, unknown>;
fldngHstryDepthRows: Record<string, unknown>[];
fldngHstryWtlvRows: Record<string, unknown>[];
complete: boolean;
};
/** 침수년도 설정의 초기 범위는 데이터 최소연도부터 현재년도까지다. */
export const buildFloodHistoryDefaultYearRange = (
currentYear = new Date().getFullYear(),
): FloodHistoryYearRange => ({
startYear: String(FLOOD_HISTORY_MIN_YEAR),
endYear: String(currentYear),
});
/** 연도 설정값을 4자리 숫자로 정규화하고 유효하지 않으면 기본 범위를 사용한다. */
export const normalizeFloodHistoryYearRange = (
range?: Partial<FloodHistoryYearRange> | null,
fallback: FloodHistoryYearRange = buildFloodHistoryDefaultYearRange(),
): FloodHistoryYearRange => {
const normalizeYear = (value: unknown) =>
String(value ?? "")
.replace(/[^0-9]/g, "")
.slice(0, 4);
const startYear = normalizeYear(range?.startYear);
const endYear = normalizeYear(range?.endYear);
return {
startYear: /^\d{4}$/.test(startYear) ? startYear : fallback.startYear,
endYear: /^\d{4}$/.test(endYear) ? endYear : fallback.endYear,
};
};
/** 위선·심선 WFS에 동일하게 결합할 침수년도 CQL 조건을 생성한다. */
export const buildFloodHistoryYearCql = (
range: FloodHistoryYearRange,
enabled: boolean,
) => {
if (!enabled) return "";
const normalized = normalizeFloodHistoryYearRange(range);
return `(fldng_yr >= '${normalized.startYear}' AND fldng_yr <= '${normalized.endYear}')`;
};
export type FldngHstryYearRange = { startYear: string; endYear: string };
export type FldngHstryFilters = {
ctpvCd: string;
sggCd: string;
@@ -65,47 +30,44 @@ export type FldngHstryFilters = {
rainStartDate: string;
districtName: string;
};
export type FldngHstryDepthZone = {
zoneId: string;
depthLabel: string;
color: string;
ring: [number, number][];
};
export type FldngHstryApiItem = {
fid: string; // FID
fldngMapFid?: string; // 침수흔적도지도 FID
fldngWtlvMapSn?: number | null; // 침수흔적도지도 일련번호
taskClCd?: string; // 업무 구분 코드
fldngDstrNm?: string; // 침수재해명
exmnYr?: string; // 조사년도
avgFldngWtlv?: number | null; // 평균침수위
fldngArea?: number | null; // 침수면적
dtlOcrnCsCn?: string; // 상세발생원인내용
stdgCd?: string; // 표준지역코드
ctpvCd?: string; // 시도코드
sggCd?: string; // 시군구코드
dataSn?: number | null; // 데이터 일련번호
bgngYmd?: string; // 시작일자
endYmd?: string; // 종료일자
fldngBgngHm?: string; // 침수시작시분
fldngEndHm?: string; // 침수종료시분
fldngCsDtlCd?: string; // 침수원인상세코드
fldngDstrctNm?: string; // 침수지구명
damAmt?: number | null; // 피해금액
fldngHr?: number | null; // 침수시간
fldngYr?: string; // 침수년도
cstrnFbctnYn?: string; // 공사여부
atchFileSn?: number | null; // 첨부파일 일련번호
confArea?: number | null; // 확정면적
confLen?: number | null; // 확정연장
rainBgngTm?: string | null; // 강우시작일시
rainEndTm?: string | null; // 강우종료일시
rainTot?: number | null; // 총강우량
rainStr?: number | null; // 강우강도
fid: string;
fldngMapFid?: string;
fldngWtlvMapSn?: number | null;
taskClCd?: string;
fldngDstrNm?: string;
exmnYr?: string;
avgFldngWtlv?: number | null;
fldngArea?: number | null;
dtlOcrnCsCn?: string;
stdgCd?: string;
ctpvCd?: string;
sggCd?: string;
dataSn?: number | null;
bgngYmd?: string;
endYmd?: string;
fldngBgngHm?: string;
fldngEndHm?: string;
fldngCsDtlCd?: string;
fldngDstrctNm?: string;
damAmt?: number | null;
fldngHr?: number | null;
fldngYr?: string;
cstrnFbctnYn?: string;
atchFileSn?: number | null;
confArea?: number | null;
confLen?: number | null;
rainBgngTm?: string | null;
rainEndTm?: string | null;
rainTot?: number | null;
rainStr?: number | null;
};
export type FldngHstryItem = FldngHstryApiItem & {
historyId: string;
ctpvNm: string;
@@ -135,7 +97,6 @@ export type FldngHstryItem = FldngHstryApiItem & {
areaRing: [number, number][];
depthZones: FldngHstryDepthZone[];
};
export type FldngHstryTableRow = FldngHstryItem & {
rowNo: number;
floodYearDisplay: string;
@@ -158,7 +119,6 @@ export const FLDNG_HSTRY_DEFAULT_FILTERS: FldngHstryFilters = {
rainStartDate: "",
districtName: "",
};
export const FLDNG_HSTRY_DEPTH_LEGEND_ITEMS = [
{ color: "#fff27a", label: "0.0~0.5m" },
{ color: "#c2f13d", label: "0.5~1.0m" },
@@ -166,7 +126,6 @@ export const FLDNG_HSTRY_DEPTH_LEGEND_ITEMS = [
{ color: "#8f8bff", label: "2.0~5.0m" },
{ color: "#f2528b", label: "5.0m 이상" },
] as const;
export const FLDNG_HSTRY_LIST_HEADLIST: UiHeadProps[] = [
{ id: "rowNo", title: "순번", widthPercent: 3 },
{ id: "floodYearDisplay", title: "침수\n년도", widthPercent: 4 },
@@ -180,11 +139,10 @@ export const FLDNG_HSTRY_LIST_HEADLIST: UiHeadProps[] = [
{ id: "floodHourDisplay", title: "침수시간", widthPercent: 7 },
{ id: "districtName", title: "침수지구명", widthPercent: 10 },
{ id: "avgFloodDepthDisplay", title: "평균\n침수위", widthPercent: 7 },
{ id: "floodAreaDisplay", title: "침수\n면적\(ha)", widthPercent: 7 },
{ id: "floodAreaDisplay", title: "침수\n면적(ha)", widthPercent: 7 },
{ id: "exmnYearDisplay", title: "조사년도", widthPercent: 6 },
{ id: "damageAmountDisplay", title: "피해금액", widthPercent: 9 },
];
export const FLDNG_HSTRY_DOWNLOAD_HEADERS: Record<string, string> = {
rowNo: "순번",
floodYearDisplay: "침수년도",
@@ -204,373 +162,3 @@ export const FLDNG_HSTRY_DOWNLOAD_HEADERS: Record<string, string> = {
exmnYearDisplay: "조사년도",
damageAmountDisplay: "피해금액",
};
const toRadian = (degree: number) => (degree * Math.PI) / 180;
const createIrregularRing = ({
centerX,
centerY,
radiusX,
radiusY,
scales,
rotationDeg = 0,
}: {
centerX: number;
centerY: number;
radiusX: number;
radiusY: number;
scales: number[];
rotationDeg?: number;
}): [number, number][] => {
const rotation = toRadian(rotationDeg);
const points = scales.map((scale, index) => {
const angle = rotation + (Math.PI * 2 * index) / scales.length;
const jitterX = radiusX * scale * Math.cos(angle);
const jitterY = radiusY * scale * Math.sin(angle);
return [centerX + jitterX, centerY + jitterY] as [number, number];
});
return points.length > 0 ? [...points, points[0]!] : [];
};
const toNumericSeed = (value: string) =>
Array.from(value).reduce(
(sum, char, index) => sum + char.charCodeAt(0) * (index + 1),
0,
);
const buildGeometrySeed = (fid: string) => {
const seed = toNumericSeed(fid);
return {
centerX: 1050000 + (seed % 120000),
centerY: 1680000 + (seed % 150000),
width: 14000 + (seed % 9000),
height: 11000 + (seed % 7000),
};
};
const createFldngHstryDepthZones = (
historyId: string,
centerX: number,
centerY: number,
): FldngHstryDepthZone[] => {
return [
{
zoneId: `${historyId}-depth-1`,
depthLabel: "0.0~0.5m",
color: "#fff27a",
ring: createIrregularRing({
centerX: centerX - 2600,
centerY: centerY - 1200,
radiusX: 2900,
radiusY: 2300,
rotationDeg: -18,
scales: [1.02, 0.78, 0.9, 1.08, 0.86, 0.72, 0.94, 1.04],
}),
},
{
zoneId: `${historyId}-depth-2`,
depthLabel: "0.5~1.0m",
color: "#c2f13d",
ring: createIrregularRing({
centerX: centerX + 1800,
centerY: centerY - 600,
radiusX: 3500,
radiusY: 2800,
rotationDeg: 12,
scales: [1.04, 0.88, 1.02, 0.8, 0.92, 1.08, 0.84, 0.95],
}),
},
{
zoneId: `${historyId}-depth-3`,
depthLabel: "1.0~2.0m",
color: "#67d8ff",
ring: createIrregularRing({
centerX,
centerY: centerY + 1800,
radiusX: 4700,
radiusY: 3900,
rotationDeg: 8,
scales: [1.12, 0.86, 0.94, 1.06, 0.82, 0.97, 1.04, 0.9, 1.08, 0.84],
}),
},
{
zoneId: `${historyId}-depth-4`,
depthLabel: "2.0~5.0m",
color: "#8f8bff",
ring: createIrregularRing({
centerX: centerX + 3200,
centerY: centerY + 3200,
radiusX: 2600,
radiusY: 2000,
rotationDeg: 26,
scales: [1.05, 0.82, 1.08, 0.88, 0.96, 0.78, 1.02, 0.9],
}),
},
{
zoneId: `${historyId}-depth-5`,
depthLabel: "5.0m 이상",
color: "#f2528b",
ring: createIrregularRing({
centerX: centerX + 400,
centerY: centerY - 3600,
radiusX: 1200,
radiusY: 1050,
rotationDeg: -10,
scales: [1.0, 0.8, 1.06, 0.84, 0.94, 0.76, 0.98],
}),
},
];
};
const normalizeDate = (value?: string | null) => {
const source = String(value ?? "").trim();
if (!/^\d{8}$/.test(source)) return "";
return `${source.slice(0, 4)}-${source.slice(4, 6)}-${source.slice(6, 8)}`;
};
const normalizeHm = (value?: string | null) => {
const digits = String(value ?? "").replace(/[^0-9]/g, "");
if (!digits) return "";
if (digits.length === 1) return `0${digits}:00`;
if (digits.length === 2) return `${digits}:00`;
if (digits.length === 3)
return `0${digits.slice(0, 1)}:${digits.slice(1, 3)}`;
return `${digits.slice(0, 2)}:${digits.slice(2, 4)}`;
};
const normalizeDateTime = (value?: string | null) => {
const source = String(value ?? "").trim();
if (!source) return "";
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(source)) return source;
const date = new Date(source);
if (Number.isNaN(date.getTime()))
return source.slice(0, 16).replace("T", " ");
const yyyy = date.getFullYear();
const mm = `${date.getMonth() + 1}`.padStart(2, "0");
const dd = `${date.getDate()}`.padStart(2, "0");
const hh = `${date.getHours()}`.padStart(2, "0");
const mi = `${date.getMinutes()}`.padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
};
const formatNumber = (value?: number | null, digits = 1, suffix = "") => {
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric)) return `0${suffix}`;
return `${numeric.toFixed(digits)}${suffix}`;
};
const formatInteger = (value?: number | null, suffix = "") => {
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric)) return `0${suffix}`;
return `${numeric.toLocaleString("ko-KR")}${suffix}`;
};
const formatOptionalNumber = (
value?: number | null,
digits = 1,
suffix = "",
) => {
if (value === null || value === undefined) return "-";
const numeric = Number(value);
if (!Number.isFinite(numeric)) return "-";
return `${numeric.toFixed(digits)}${suffix}`;
};
const buildCauseDetailName = (item: FldngHstryApiItem) => {
const direct = String(item.dtlOcrnCsCn ?? "").trim();
if (direct) return direct;
const code = String(item.fldngCsDtlCd ?? "").trim();
return code ? `원인코드 ${code}` : "-";
};
const buildGradeLabel = (avgFloodDepthM: number) => {
if (avgFloodDepthM >= 2) return "3등급";
if (avgFloodDepthM >= 1) return "2등급";
return "1등급";
};
export const normalizeFldngHstryFilters = (
filters: FldngHstryFilters,
): FldngHstryFilters => ({
ctpvCd: String(filters.ctpvCd ?? "0") || "0",
sggCd: String(filters.sggCd ?? "0") || "0",
floodYear: String(filters.floodYear ?? "")
.replace(/[^0-9]/g, "")
.slice(0, 4),
disasterName: String(filters.disasterName ?? "").trim(),
rainStartDate: String(filters.rainStartDate ?? "").trim(),
districtName: String(filters.districtName ?? "").trim(),
});
export const buildFldngHstryYearOptions = () => {
const currentYear = new Date().getFullYear();
return Array.from({ length: 30 }, (_, index) => String(currentYear - index));
};
export const buildFldngHstryListQueryString = (
filters: FldngHstryFilters,
page: number,
size: number,
) => {
const normalized = normalizeFldngHstryFilters(filters);
const params = new URLSearchParams({
page: String(page - 1),
size: String(size),
});
if (normalized.ctpvCd !== "0")
params.set("ctpvCd", normalized.ctpvCd.slice(0, 2));
if (normalized.sggCd !== "0")
params.set("sggCd", normalized.sggCd.slice(0, 5));
if (normalized.floodYear) params.set("fldngYr", normalized.floodYear);
if (normalized.disasterName)
params.set("fldngDstrNm", normalized.disasterName);
if (normalized.rainStartDate)
params.set("rainBgngTm", normalized.rainStartDate);
if (normalized.districtName)
params.set("fldngDstrctNm", normalized.districtName);
return params.toString();
};
export const formatFloodArea = (value: number) =>
Number(value ?? 0).toLocaleString("ko-KR", {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
export const formatFloodDepth = (value: number) => value.toFixed(2);
export const attachFldngHstryGeometry = (
item: FldngHstryApiItem,
): FldngHstryItem => {
const historyId = String(item.fid ?? "");
const geometrySeed = buildGeometrySeed(historyId);
const startDate = normalizeDate(item.bgngYmd);
const endDate = normalizeDate(item.endYmd);
const startTime = normalizeHm(item.fldngBgngHm);
const endTime = normalizeHm(item.fldngEndHm);
const avgFloodDepthM = Number(item.avgFldngWtlv ?? 0);
const floodAreaHa = Number(item.fldngArea ?? 0);
return {
...item,
historyId,
ctpvNm: "",
sggNm: "",
floodYear: String(item.fldngYr ?? "").trim(),
exmnYear: String(item.exmnYr ?? "").trim(),
disasterName: String(item.fldngDstrNm ?? "").trim(),
districtName: String(item.fldngDstrctNm ?? "").trim(),
floodAreaHa,
avgFloodDepthM,
floodDepthM: avgFloodDepthM,
gradeLabel: buildGradeLabel(avgFloodDepthM),
startDate,
endDate,
startTime,
endTime,
floodHour: formatNumber(Number(item.fldngHr ?? 0), 1),
causeDetailCode: String(item.fldngCsDtlCd ?? "").trim(),
causeDetailName: buildCauseDetailName(item),
damageAmount: formatInteger(Number(item.damAmt ?? 0)),
rainStartDateTime: normalizeDateTime(item.rainBgngTm),
rainEndDateTime: normalizeDateTime(item.rainEndTm),
rainStartTime: normalizeDateTime(item.rainBgngTm).slice(11, 16),
rainEndTime: normalizeDateTime(item.rainEndTm).slice(11, 16),
totalRainfallMm: Number(item.rainTot ?? 0),
rainfallIntensityMmHr: Number(item.rainStr ?? 0),
areaRing: createIrregularRing({
centerX: geometrySeed.centerX,
centerY: geometrySeed.centerY,
radiusX: geometrySeed.width / 2,
radiusY: geometrySeed.height / 2,
rotationDeg: 9,
scales: [1.08, 0.9, 1.04, 0.82, 0.98, 1.12, 0.88, 0.96, 1.03, 0.86],
}),
depthZones: createFldngHstryDepthZones(
historyId,
geometrySeed.centerX,
geometrySeed.centerY,
),
};
};
export const toFldngHstryTableRows = (
items: FldngHstryItem[],
page: number,
pageSize = FLDNG_HSTRY_PAGE_SIZE,
): FldngHstryTableRow[] =>
items.map((item, index) => ({
...item,
rowNo: (page - 1) * pageSize + index + 1,
floodYearDisplay: item.floodYear || "-",
floodHourDisplay: item.floodHour || "-",
avgFloodDepthDisplay: formatFloodDepth(item.avgFloodDepthM),
floodAreaDisplay: formatFloodArea(item.floodAreaHa),
exmnYearDisplay: item.exmnYear || "-",
damageAmountDisplay: item.damageAmount || "-",
rainBgngTmDisplay: item.rainStartDateTime || "-",
rainEndTmDisplay: item.rainEndDateTime || "-",
rainTotDisplay: formatOptionalNumber(item.rainTot, 1),
rainStrDisplay: formatOptionalNumber(item.rainStr, 1),
}));
const resolveFldngHstryNodeName = (node: any) =>
String(
node?.text ??
node?.data?.lyrNm ??
node?.data?.name ??
node?.lyrNm ??
node?.name ??
"",
).trim();
const resolveFldngHstryLayerType = (node: any) =>
String(
node?.data?.lyrTyNm ??
node?.data?.lyrTypeNm ??
node?.lyrTyNm ??
node?.lyrTypeNm ??
"",
).trim();
export const isFldngHstryNode = (node: any) => {
const name = resolveFldngHstryNodeName(node);
const layerType = resolveFldngHstryLayerType(node);
return (
name === FLOOD_HISTORY_LAYER_NAME ||
name === FLOOD_HISTORY_DEPTH_LAYER_NAME ||
layerType === FLOOD_HISTORY_LAYER_TYPE ||
layerType === FLOOD_HISTORY_DEPTH_LAYER_TYPE ||
layerType === FLOOD_HISTORY_PREVIEW_LAYER_TYPE
);
};
/** 침수흔적위선 체크 상태를 심선과 분리해 판별한다. */
export const isFloodHistoryWaterLevelNode = (node: any) => {
const name = resolveFldngHstryNodeName(node);
const layerType = resolveFldngHstryLayerType(node);
return (
name === FLOOD_HISTORY_LAYER_NAME || layerType === FLOOD_HISTORY_LAYER_TYPE
);
};
/** 침수흔적심선 체크 상태를 위선과 분리해 판별한다. */
export const isFloodHistoryDepthNode = (node: any) => {
const name = resolveFldngHstryNodeName(node);
const layerType = resolveFldngHstryLayerType(node);
return (
name === FLOOD_HISTORY_DEPTH_LAYER_NAME ||
layerType === FLOOD_HISTORY_DEPTH_LAYER_TYPE
);
};
export const isFldngHstryRootNode = (node: any) =>
resolveFldngHstryNodeName(node) === FLOOD_HISTORY_ROOT_NAME;
export const isFloodHistoryNode = isFldngHstryNode;
export const FLOOD_HISTORY_ROW_SELECT_MESSAGE = FLDNG_HSTRY_ROW_SELECT_MESSAGE;
export const FLOOD_HISTORY_PAGE_SIZE = FLDNG_HSTRY_PAGE_SIZE;
export const FLOOD_HISTORY_DOWNLOAD_BATCH_SIZE =
FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE;
@@ -0,0 +1,440 @@
/** 침수흔적 검색조건·표시값·geometry·레이어 판별 변환을 제공한다. */
import { appendApiSearchParams } from "@/shared/util/util";
import {
FLDNG_HSTRY_PAGE_SIZE,
FLDNG_HSTRY_DEPTH_LAYER_NAME,
FLDNG_HSTRY_DEPTH_LAYER_TYPE,
FLDNG_HSTRY_LAYER_NAME,
FLDNG_HSTRY_LAYER_TYPE,
FLDNG_HSTRY_MIN_YEAR,
FLDNG_HSTRY_PREVIEW_LAYER_TYPE,
FLDNG_HSTRY_ROOT_NAME,
type FldngHstryApiItem,
type FldngHstryDepthZone,
type FldngHstryFilters,
type FldngHstryItem,
type FldngHstryTableRow,
type FldngHstryYearRange,
} from "./fldngHstryTypes";
export const normalizeFldngHstryWtlvDisplayId = (value: unknown) => {
const normalized = String(value ?? "").trim();
const prefix = `${FLDNG_HSTRY_LAYER_TYPE}.`;
return normalized.startsWith(prefix)
? normalized.slice(prefix.length)
: normalized;
};
export const normalizeFldngHstryWtlvApiFid = (value: unknown) => {
const normalized = String(value ?? "").trim();
return normalized ? normalizeFldngHstryWtlvDisplayId(normalized) : "";
};
export const formatFldngHstryDateTime = (
dateValue: unknown,
timeValue: unknown,
) => {
const rawDate = String(dateValue ?? "").trim();
const rawTime = String(timeValue ?? "").trim();
const normalizedDate = /^\d{8}$/.test(rawDate)
? `${rawDate.slice(0, 4)}-${rawDate.slice(4, 6)}-${rawDate.slice(6, 8)}`
: rawDate;
const timeDigits = rawTime.replace(/[^0-9]/g, "");
const normalizedTime = !timeDigits
? ""
: timeDigits.length <= 2
? `${timeDigits.padStart(2, "0")}:00`
: `${timeDigits.padStart(4, "0").slice(0, 2)}:${timeDigits.padStart(4, "0").slice(2, 4)}`;
return [normalizedDate, normalizedTime].filter(Boolean).join(" ");
};
export const buildFldngHstryDefaultYearRange = (
currentYear = new Date().getFullYear(),
): FldngHstryYearRange => ({
startYear: String(FLDNG_HSTRY_MIN_YEAR),
endYear: String(currentYear),
});
export const normalizeFldngHstryYearRange = (
range?: Partial<FldngHstryYearRange> | null,
fallback: FldngHstryYearRange = buildFldngHstryDefaultYearRange(),
): FldngHstryYearRange => {
const normalizeYear = (value: unknown) =>
String(value ?? "")
.replace(/[^0-9]/g, "")
.slice(0, 4);
const startYear = normalizeYear(range?.startYear);
const endYear = normalizeYear(range?.endYear);
return {
startYear: /^\d{4}$/.test(startYear) ? startYear : fallback.startYear,
endYear: /^\d{4}$/.test(endYear) ? endYear : fallback.endYear,
};
};
export const buildFldngHstryYearCql = (
range: FldngHstryYearRange,
enabled: boolean,
) => {
if (!enabled) return "";
const normalized = normalizeFldngHstryYearRange(range);
return `(fldng_yr >= '${normalized.startYear}' AND fldng_yr <= '${normalized.endYear}')`;
};
const toRadian = (degree: number) => (degree * Math.PI) / 180;
const createIrregularRing = ({
centerX,
centerY,
radiusX,
radiusY,
scales,
rotationDeg = 0,
}: {
centerX: number;
centerY: number;
radiusX: number;
radiusY: number;
scales: number[];
rotationDeg?: number;
}): [number, number][] => {
const rotation = toRadian(rotationDeg);
const points = scales.map((scale, index) => {
const angle = rotation + (Math.PI * 2 * index) / scales.length;
return [
centerX + radiusX * scale * Math.cos(angle),
centerY + radiusY * scale * Math.sin(angle),
] as [number, number];
});
return points.length > 0 ? [...points, points[0]!] : [];
};
const toNumericSeed = (value: string) =>
Array.from(value).reduce(
(sum, char, index) => sum + char.charCodeAt(0) * (index + 1),
0,
);
const buildGeometrySeed = (fid: string) => {
const seed = toNumericSeed(fid);
return {
centerX: 1050000 + (seed % 120000),
centerY: 1680000 + (seed % 150000),
width: 14000 + (seed % 9000),
height: 11000 + (seed % 7000),
};
};
const createFldngHstryDepthZones = (
historyId: string,
centerX: number,
centerY: number,
): FldngHstryDepthZone[] => [
{
zoneId: `${historyId}-depth-1`,
depthLabel: "0.0~0.5m",
color: "#fff27a",
ring: createIrregularRing({
centerX: centerX - 2600,
centerY: centerY - 1200,
radiusX: 2900,
radiusY: 2300,
rotationDeg: -18,
scales: [1.02, 0.78, 0.9, 1.08, 0.86, 0.72, 0.94, 1.04],
}),
},
{
zoneId: `${historyId}-depth-2`,
depthLabel: "0.5~1.0m",
color: "#c2f13d",
ring: createIrregularRing({
centerX: centerX + 1800,
centerY: centerY - 600,
radiusX: 3500,
radiusY: 2800,
rotationDeg: 12,
scales: [1.04, 0.88, 1.02, 0.8, 0.92, 1.08, 0.84, 0.95],
}),
},
{
zoneId: `${historyId}-depth-3`,
depthLabel: "1.0~2.0m",
color: "#67d8ff",
ring: createIrregularRing({
centerX,
centerY: centerY + 1800,
radiusX: 4700,
radiusY: 3900,
rotationDeg: 8,
scales: [1.12, 0.86, 0.94, 1.06, 0.82, 0.97, 1.04, 0.9, 1.08, 0.84],
}),
},
{
zoneId: `${historyId}-depth-4`,
depthLabel: "2.0~5.0m",
color: "#8f8bff",
ring: createIrregularRing({
centerX: centerX + 3200,
centerY: centerY + 3200,
radiusX: 2600,
radiusY: 2000,
rotationDeg: 26,
scales: [1.05, 0.82, 1.08, 0.88, 0.96, 0.78, 1.02, 0.9],
}),
},
{
zoneId: `${historyId}-depth-5`,
depthLabel: "5.0m 이상",
color: "#f2528b",
ring: createIrregularRing({
centerX: centerX + 400,
centerY: centerY - 3600,
radiusX: 1200,
radiusY: 1050,
rotationDeg: -10,
scales: [1, 0.8, 1.06, 0.84, 0.94, 0.76, 0.98],
}),
},
];
const normalizeDate = (value?: string | null) => {
const source = String(value ?? "").trim();
return /^\d{8}$/.test(source)
? `${source.slice(0, 4)}-${source.slice(4, 6)}-${source.slice(6, 8)}`
: "";
};
const normalizeHm = (value?: string | null) => {
const digits = String(value ?? "").replace(/[^0-9]/g, "");
if (!digits) return "";
if (digits.length === 1) return `0${digits}:00`;
if (digits.length === 2) return `${digits}:00`;
if (digits.length === 3)
return `0${digits.slice(0, 1)}:${digits.slice(1, 3)}`;
return `${digits.slice(0, 2)}:${digits.slice(2, 4)}`;
};
const normalizeDateTime = (value?: string | null) => {
const source = String(value ?? "").trim();
if (!source) return "";
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(source)) return source;
const date = new Date(source);
if (Number.isNaN(date.getTime()))
return source.slice(0, 16).replace("T", " ");
const yyyy = date.getFullYear();
const mm = `${date.getMonth() + 1}`.padStart(2, "0");
const dd = `${date.getDate()}`.padStart(2, "0");
const hh = `${date.getHours()}`.padStart(2, "0");
const mi = `${date.getMinutes()}`.padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
};
const formatNumber = (value?: number | null, digits = 1, suffix = "") => {
const numeric = Number(value ?? 0);
return Number.isFinite(numeric)
? `${numeric.toFixed(digits)}${suffix}`
: `0${suffix}`;
};
const formatInteger = (value?: number | null, suffix = "") => {
const numeric = Number(value ?? 0);
return Number.isFinite(numeric)
? `${numeric.toLocaleString("ko-KR")}${suffix}`
: `0${suffix}`;
};
const formatOptionalNumber = (
value?: number | null,
digits = 1,
suffix = "",
) => {
if (value === null || value === undefined) return "-";
const numeric = Number(value);
return Number.isFinite(numeric) ? `${numeric.toFixed(digits)}${suffix}` : "-";
};
const buildCauseDetailName = (item: FldngHstryApiItem) => {
const direct = String(item.dtlOcrnCsCn ?? "").trim();
if (direct) return direct;
const code = String(item.fldngCsDtlCd ?? "").trim();
return code ? `원인코드 ${code}` : "-";
};
const buildGradeLabel = (avgFloodDepthM: number) => {
if (avgFloodDepthM >= 2) return "3등급";
if (avgFloodDepthM >= 1) return "2등급";
return "1등급";
};
export const normalizeFldngHstryFilters = (
filters: FldngHstryFilters,
): FldngHstryFilters => ({
ctpvCd: String(filters.ctpvCd ?? "0") || "0",
sggCd: String(filters.sggCd ?? "0") || "0",
floodYear: String(filters.floodYear ?? "")
.replace(/[^0-9]/g, "")
.slice(0, 4),
disasterName: String(filters.disasterName ?? "").trim(),
rainStartDate: String(filters.rainStartDate ?? "").trim(),
districtName: String(filters.districtName ?? "").trim(),
});
export const buildFldngHstryYearOptions = () => {
const currentYear = new Date().getFullYear();
return Array.from({ length: 30 }, (_, index) => String(currentYear - index));
};
export const buildFldngHstryListQueryString = (
filters: FldngHstryFilters,
page: number,
size: number,
) => {
const normalized = normalizeFldngHstryFilters(filters);
const params = new URLSearchParams({
page: String(page - 1),
size: String(size),
});
appendApiSearchParams(params, {
ctpvCd: normalized.ctpvCd === "0" ? "0" : normalized.ctpvCd.slice(0, 2),
sggCd: normalized.sggCd === "0" ? "0" : normalized.sggCd.slice(0, 5),
fldngYr: normalized.floodYear,
fldngDstrNm: normalized.disasterName,
rainBgngTm: normalized.rainStartDate,
fldngDstrctNm: normalized.districtName,
});
return params.toString();
};
export const formatFldngHstryArea = (value: number) =>
Number(value ?? 0).toLocaleString("ko-KR", {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
export const formatFldngHstryDepth = (value: number) => value.toFixed(2);
export const attachFldngHstryGeometry = (
item: FldngHstryApiItem,
): FldngHstryItem => {
const historyId = String(item.fid ?? "");
const geometrySeed = buildGeometrySeed(historyId);
const startDate = normalizeDate(item.bgngYmd);
const endDate = normalizeDate(item.endYmd);
const startTime = normalizeHm(item.fldngBgngHm);
const endTime = normalizeHm(item.fldngEndHm);
const avgFloodDepthM = Number(item.avgFldngWtlv ?? 0);
const floodAreaHa = Number(item.fldngArea ?? 0);
return {
...item,
historyId,
ctpvNm: "",
sggNm: "",
floodYear: String(item.fldngYr ?? "").trim(),
exmnYear: String(item.exmnYr ?? "").trim(),
disasterName: String(item.fldngDstrNm ?? "").trim(),
districtName: String(item.fldngDstrctNm ?? "").trim(),
floodAreaHa,
avgFloodDepthM,
floodDepthM: avgFloodDepthM,
gradeLabel: buildGradeLabel(avgFloodDepthM),
startDate,
endDate,
startTime,
endTime,
floodHour: formatNumber(Number(item.fldngHr ?? 0), 1),
causeDetailCode: String(item.fldngCsDtlCd ?? "").trim(),
causeDetailName: buildCauseDetailName(item),
damageAmount: formatInteger(Number(item.damAmt ?? 0)),
rainStartDateTime: normalizeDateTime(item.rainBgngTm),
rainEndDateTime: normalizeDateTime(item.rainEndTm),
rainStartTime: normalizeDateTime(item.rainBgngTm).slice(11, 16),
rainEndTime: normalizeDateTime(item.rainEndTm).slice(11, 16),
totalRainfallMm: Number(item.rainTot ?? 0),
rainfallIntensityMmHr: Number(item.rainStr ?? 0),
areaRing: createIrregularRing({
centerX: geometrySeed.centerX,
centerY: geometrySeed.centerY,
radiusX: geometrySeed.width / 2,
radiusY: geometrySeed.height / 2,
rotationDeg: 9,
scales: [1.08, 0.9, 1.04, 0.82, 0.98, 1.12, 0.88, 0.96, 1.03, 0.86],
}),
depthZones: createFldngHstryDepthZones(
historyId,
geometrySeed.centerX,
geometrySeed.centerY,
),
};
};
export const toFldngHstryTableRows = (
items: FldngHstryItem[],
page: number,
pageSize = FLDNG_HSTRY_PAGE_SIZE,
): FldngHstryTableRow[] =>
items.map((item, index) => ({
...item,
rowNo: (page - 1) * pageSize + index + 1,
floodYearDisplay: item.floodYear || "-",
floodHourDisplay: item.floodHour || "-",
avgFloodDepthDisplay: formatFldngHstryDepth(item.avgFloodDepthM),
floodAreaDisplay: formatFldngHstryArea(item.floodAreaHa),
exmnYearDisplay: item.exmnYear || "-",
damageAmountDisplay: item.damageAmount || "-",
rainBgngTmDisplay: item.rainStartDateTime || "-",
rainEndTmDisplay: item.rainEndDateTime || "-",
rainTotDisplay: formatOptionalNumber(item.rainTot, 1),
rainStrDisplay: formatOptionalNumber(item.rainStr, 1),
}));
const resolveFldngHstryNodeName = (node: any) =>
String(
node?.text ??
node?.data?.lyrNm ??
node?.data?.name ??
node?.lyrNm ??
node?.name ??
"",
).trim();
const resolveFldngHstryLayerType = (node: any) =>
String(
node?.data?.lyrTyNm ??
node?.data?.lyrTypeNm ??
node?.lyrTyNm ??
node?.lyrTypeNm ??
"",
).trim();
export const isFldngHstryNode = (node: any) => {
const name = resolveFldngHstryNodeName(node);
const layerType = resolveFldngHstryLayerType(node);
return (
name === FLDNG_HSTRY_LAYER_NAME ||
name === FLDNG_HSTRY_DEPTH_LAYER_NAME ||
layerType === FLDNG_HSTRY_LAYER_TYPE ||
layerType === FLDNG_HSTRY_DEPTH_LAYER_TYPE ||
layerType === FLDNG_HSTRY_PREVIEW_LAYER_TYPE
);
};
export const isFldngHstryWtlvNode = (node: any) => {
const name = resolveFldngHstryNodeName(node);
const layerType = resolveFldngHstryLayerType(node);
return (
name === FLDNG_HSTRY_LAYER_NAME || layerType === FLDNG_HSTRY_LAYER_TYPE
);
};
export const isFldngHstryDepthNode = (node: any) => {
const name = resolveFldngHstryNodeName(node);
const layerType = resolveFldngHstryLayerType(node);
return (
name === FLDNG_HSTRY_DEPTH_LAYER_NAME ||
layerType === FLDNG_HSTRY_DEPTH_LAYER_TYPE
);
};
export const isFldngHstryRootNode = (node: any) =>
resolveFldngHstryNodeName(node) === FLDNG_HSTRY_ROOT_NAME;
@@ -1,3 +1,7 @@
/**
* 화면명: 도시침수지도 목록
* 빈도별 도시침수지도를 검색하고 목록·엑셀·지도 보기 기능을 제공한다.
*/
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -101,7 +105,7 @@ export default function FloodDngrMapPopupPage() {
availableSggCodes.has(String(item.val).trim().slice(0, 5)),
);
return filters.ctpvCd === "0"
? scopedOptions
? []
: scopedOptions.filter((item) =>
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
);
@@ -390,8 +394,6 @@ export default function FloodDngrMapPopupPage() {
onPageChange={(page) => void loadRows(page, appliedFilters)}
onRowClick={(row) => void handleRowClick(row)}
rowId={(row, index) => `${row.sggCd}-${row.sbmrgFrqExpln}-${index}`}
scrollMode="horizontal"
minTableWidth="120rem"
/>
</StandardPopupLayout>
);
@@ -1,6 +1,8 @@
/** 도시침수지도 화면의 검색 조건, 범례, 컬럼과 응답 변환을 정의한다. */
"use client";
import type { UiHeadProps } from "@common/business_components/ui";
import { appendApiSearchParams } from "@/shared/util/util";
export const FLOOD_DNGR_MAP_POPUP_KEY = "floodDngrMapPopup";
export const FLOOD_DNGR_MAP_LAYER_TYPE = "tb_ms_flood_dngr_map";
@@ -89,16 +91,12 @@ export const buildFloodDngrMapListQueryString = (
page: String(page),
size: String(size),
});
if (filters.ctpvCd && filters.ctpvCd !== "0")
params.set("ctpvCd", filters.ctpvCd.slice(0, 2));
if (filters.sggCd && filters.sggCd !== "0")
params.set("sggCd", filters.sggCd.slice(0, 5));
if (filters.sbmrgDpthlCd && filters.sbmrgDpthlCd !== "0") {
params.set("sbmrgDpthlCd", filters.sbmrgDpthlCd);
}
if (filters.sbmrgFrqExpln && filters.sbmrgFrqExpln !== "0") {
params.set("sbmrgFrqExpln", filters.sbmrgFrqExpln);
}
appendApiSearchParams(params, {
ctpvCd: filters.ctpvCd === "0" ? "0" : filters.ctpvCd.slice(0, 2),
sggCd: filters.sggCd === "0" ? "0" : filters.sggCd.slice(0, 5),
sbmrgDpthlCd: filters.sbmrgDpthlCd,
sbmrgFrqExpln: filters.sbmrgFrqExpln,
});
return params.toString();
};
@@ -1,3 +1,7 @@
/**
* 화면명: 하수관로 편집정보
* 편집 중인 하수관로의 기본정보와 입력정보를 표시하고 수정한다.
*/
"use client";
import { useEffect, useMemo, useState } from "react";
@@ -1,3 +1,7 @@
/**
* 화면명: 하수관로 편집 세션
* 현재 편집 대상과 변경 상태를 표시하고 저장·취소 동작을 제공한다.
*/
"use client";
import { Button } from "@common/components";
@@ -1,3 +1,7 @@
/**
* 화면명: 하수관로 편집 도구
* 관로 추가·이동·꼭지점 편집·삭제 도구를 선택하고 제어한다.
*/
"use client";
import { Check, Move, PenTool, Plus, Trash2, X } from "lucide-react";
@@ -55,7 +59,7 @@ export default function GisEditToolbar({
data-testid="gis-edit-toolbar"
className="absolute bottom-4 left-1/2 z-[60] flex w-[min(980px,calc(100%-2rem))] -translate-x-1/2 flex-col"
>
<div className="card card-border !m-0 flex !bg-background !p-[0.8rem] shadow-[0_8px_24px_rgba(23,42,70,0.18)]">
<div className="card !m-0 flex !border-0 !bg-background !p-[0.8rem] shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)]">
<div className="flex w-full items-center gap-3 overflow-hidden">
<div className="flex w-[168px] shrink-0 flex-col justify-center border-r border-border pr-3">
<span className="whitespace-nowrap text-[1.3rem] font-semibold leading-5 text-label">
@@ -1,3 +1,4 @@
/** 하수관로 GIS 편집에서 사용하는 도구·세션·변경 데이터 타입을 정의한다. */
export type GisEditTool = "ADD" | "MOVE" | "VERTEX_EDIT" | "DELETE";
export type GisEditGeometryType = "Point" | "LineString" | "Polygon";
@@ -1,3 +1,4 @@
/** 하수관로 GIS 편집 도구의 선택 상태와 변경 내역을 관리하는 훅이다. */
"use client";
import { useCallback, useMemo, useRef, useState } from "react";
@@ -1,3 +1,4 @@
/** 하수관로 GIS 편집에 필요한 공통코드를 조회하는 훅이다. */
"use client";
import { useEffect, useState } from "react";
@@ -1,3 +1,7 @@
/**
* 화면명: GIS 관리
* GIS 자료 업로드 이력을 조회하고 등록·검증·반영 기능을 제공한다.
*/
"use client";
import type { ChangeEvent } from "react";
@@ -1,3 +1,7 @@
/**
* 화면명: GIS 업로드 미리보기 지도
* 업로드한 공간정보를 지도에 미리 표시하고 검증 결과를 확인한다.
*/
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -1,3 +1,4 @@
/** GIS 관리 화면에 필요한 공통코드를 조회하고 선택 항목으로 제공한다. */
"use client";
import { useEffect, useState } from "react";
@@ -1,3 +1,7 @@
/**
* 화면명: 침입수 분석결과 목록
* 침입수 분석결과를 검색하고 목록·엑셀·지도 보기 기능을 제공한다.
*/
"use client";
import { useCallback, useEffect, useState } from "react";
@@ -1,3 +1,4 @@
/** 침입수 분석결과 화면의 타입·검색 조건·컬럼과 응답 변환을 정의한다. */
"use client";
export const INFTWT_RESULT_ROW_SELECT_MESSAGE = "SN_INFTWT_RESULT_ROW_SELECT";
@@ -1,5 +1,6 @@
"use client";
/** 침입수 분석결과 선택 시 부모 지도에 결과 레이어를 표시하는 훅이다. */
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
import GeoJSON from "ol/format/GeoJSON.js";
import VectorLayer from "ol/layer/Vector.js";
@@ -1,3 +1,7 @@
/**
* 화면명: 선택 레이어 설정 카드
* 레이어별 설정 제목과 활성화·닫기 영역을 공통 레이아웃으로 제공한다.
*/
"use client";
import type { ReactNode } from "react";
@@ -22,8 +26,16 @@ export default function LayerSettingCard({
}: Props) {
return (
<div className="w-full overflow-visible rounded-[6px] border border-border bg-background shadow-blur">
<div className="flex min-h-[28px] items-center justify-between border-b border-border px-2">
<span className="text-[1.15rem] font-semibold text-label">{title}</span>
<div className="flex min-h-[28px] items-center justify-between border-b border-border pl-[0.8rem] pr-2">
<div className="flex min-w-0 items-center gap-2">
<span
aria-hidden="true"
className="h-[7px] w-[7px] shrink-0 rotate-45 bg-[#7CCE5C]"
/>
<span className="truncate text-[12px] font-semibold text-label">
{title}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
{showToggle ? (
<button
@@ -52,7 +64,9 @@ export default function LayerSettingCard({
</div>
</div>
{children ? (
<div className="px-2 py-1 text-[1.15rem] text-label">{children}</div>
<div className="flex flex-col gap-[6px] p-[0.8rem] text-[1.15rem] text-label">
{children}
</div>
) : null}
</div>
);
@@ -1,3 +1,7 @@
/**
* 화면명: 관로현황 목록
* 관로분석 집계 항목에 해당하는 개별 관로 목록을 표시한다.
*/
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -1,3 +1,7 @@
/**
* 화면명: 관로분석
* 행정구역·처리구역과 분석구분별 관로 집계 결과를 조회한다.
*/
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -388,7 +392,7 @@ export default function PipeAnalysisPopupPage() {
const sggOptions = useMemo(
() =>
draftFilters.ctpvCd === "0"
? sgg
? []
: sgg.filter((item) =>
String(item.val).startsWith(draftFilters.ctpvCd.slice(0, 2)),
),
@@ -1,3 +1,4 @@
/** 관로분석 검색 조건, 집계 컬럼과 표시값 변환 유틸을 제공한다. */
"use client";
import type { SelectDataType } from "@common/business_components";
@@ -1,4 +1,5 @@
/**
* 화면명: 하수관로 정보
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수관로)
*
* 📄 MsSwagePpl
@@ -1,4 +1,5 @@
/**
* 화면명: 우수토실 정보
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (우수토실)
*
* 📄 Ssc
@@ -1,4 +1,5 @@
/**
* 화면명: 하수처리시설 정보
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수처리시설)
*
* 📄 SwtrmtFclt
@@ -1,3 +1,7 @@
/**
* 화면명: 처리구역내 관로분석정보
* 선택한 하수처리구역의 관로 분석 현황과 보수이력 연계 기능을 제공한다.
*/
"use client";
import React, {
@@ -1,3 +1,4 @@
/** 침입수·RDII 분석의 적용연월 선택 상태와 월 목록을 제공한다. */
import { useState, useEffect } from "react";
import moment from "moment";
import { getData } from "@/components/lib/api";
@@ -295,4 +296,4 @@ export const getChartDataRainData = (arr: TRainData[]) => {
lineVal0: 0,
barVal0: roundTo(data.amrf, 2),
}));
};
};
@@ -1,3 +1,7 @@
/**
* 화면명: 침입수 분석
* 침입수 분석 조건 입력, 실행 상태와 분석결과를 표시한다.
*/
"use client";
import React, { useEffect, useMemo, useState } from "react";
@@ -244,7 +248,7 @@ const InfilChart = ({
const renderResultPanel = () => (
<section className="card card-border !m-0 min-w-0 overflow-hidden !p-[1.2rem]">
<StandardFormSection title="침입수 분석결과" columns={2}>
<StandardFormSection title="침입수 분석결과" columns={2} wideLabels>
<StandardFormField label="상수사용량 적용연월" columns={2}>
<div className="flex min-h-[3.2rem] items-center justify-end text-[1.35rem] font-semibold tabular-nums text-foreground">
{selectedAplcnYm
@@ -1,3 +1,7 @@
/**
* 화면명: RDII 분석
* RDII 분석 조건 입력, 실행 상태와 분석결과를 표시한다.
*/
"use client";
import React, { useEffect, useMemo, useState } from "react";
@@ -218,7 +222,7 @@ const RdiiChart = ({
const renderResultPanel = () => (
<section className="card card-border !m-0 min-w-0 overflow-hidden !p-[1.2rem]">
<StandardFormSection title="RDII 분석결과" columns={2}>
<StandardFormSection title="RDII 분석결과" columns={2} wideLabels>
<StandardFormField label="상수사용 적용연월" columns={2}>
<div className="flex min-h-[3.2rem] items-center justify-end text-[1.35rem] font-semibold tabular-nums text-foreground">
{selectedAplcnYm
@@ -1,4 +1,5 @@
/**
* 화면명: 시설 상세정보
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창
*
* 📄 FeatureClickEventPopup
@@ -69,6 +70,12 @@ import { useAlertStore } from "@/shared/lib";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import useStore from "@/store/useStore";
import ExmnSurveyResultSection from "../exmn/ExmnSurveyResultSection";
import {
formatFldngHstryDateTime,
normalizeFldngHstryWtlvApiFid,
normalizeFldngHstryWtlvDisplayId,
} from "../fldngHstry/fldngHstryUtils";
import { useFldngHstryFeatureClick } from "../fldngHstry/FldngHstryFeatureClick";
const linkedFacilityHighlightStyle = new Style({
image: new CircleStyle({
@@ -91,53 +98,6 @@ const linkedFacilityHighlightStyle = new Style({
zIndex: 9999,
});
const normalizeFldngWtlvMapDisplayId = (value: unknown) => {
const normalized = String(value ?? "").trim();
const prefix = "tb_ms_fldng_wtlv_map.";
return normalized.startsWith(prefix)
? normalized.slice(prefix.length)
: normalized;
};
const normalizeFldngWtlvMapApiFid = (value: unknown) => {
const normalized = String(value ?? "").trim();
if (!normalized) return "";
return normalizeFldngWtlvMapDisplayId(normalized);
};
const normalizeRainAnalysisPayload = (data: any) => {
if (!data || typeof data !== "object") return {};
return {
rain_bgng_tm: data.rainBgngTm ?? data.rain_bgng_tm ?? null,
rainBgngTm: data.rainBgngTm ?? data.rain_bgng_tm ?? null,
rain_end_tm: data.rainEndTm ?? data.rain_end_tm ?? null,
rainEndTm: data.rainEndTm ?? data.rain_end_tm ?? null,
rain_tot: data.rainTot ?? data.rain_tot ?? null,
rainTot: data.rainTot ?? data.rain_tot ?? null,
rain_str: data.rainStr ?? data.rain_str ?? null,
rainStr: data.rainStr ?? data.rain_str ?? null,
};
};
// 침수흔적도 침수시작일시, 침수종료일시 한줄로 보이도록
const formatFldngWtlvDateTime = (dateValue: unknown, timeValue: unknown) => {
const rawDate = String(dateValue ?? "").trim();
const rawTime = String(timeValue ?? "").trim();
const normalizedDate = /^\d{8}$/.test(rawDate)
? `${rawDate.slice(0, 4)}-${rawDate.slice(4, 6)}-${rawDate.slice(6, 8)}`
: rawDate;
const timeDigits = rawTime.replace(/[^0-9]/g, "");
const normalizedTime = !timeDigits
? ""
: timeDigits.length <= 2
? `${timeDigits.padStart(2, "0")}:00`
: `${timeDigits.padStart(4, "0").slice(0, 2)}:${timeDigits.padStart(4, "0").slice(2, 4)}`;
return [normalizedDate, normalizedTime].filter(Boolean).join(" ");
};
type MngInstOption = {
mngInstCd: string;
lwrInstNm: string;
@@ -183,20 +143,12 @@ const FeatureClickEventPopup = ({
new Map<string, Promise<any | null>>(),
);
const [mngInstOptions, setMngInstOptions] = useState<MngInstOption[]>([]);
const [floodDepthRows, setFloodDepthRows] = useState<
Record<string, unknown>[]
>([]);
const [floodWtlvRows, setFloodWtlvRows] = useState<Record<string, unknown>[]>(
[],
);
const [selectedFloodDepthFid, setSelectedFloodDepthFid] = useState<
string | null
>(null);
const [selectedFloodWtlvFid, setSelectedFloodWtlvFid] = useState<
string | null
>(null);
const floodDepthYearListRef = useRef<HTMLDivElement>(null);
const floodWtlvListRef = useRef<HTMLDivElement>(null);
const {
hasFldngHstryLinkedFeatures,
fldngHstryFeatureClickHeadName,
loadFldngHstryFeatureClickDetail,
resetFldngHstryFeatureClick,
} = useFldngHstryFeatureClick();
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
// 🎯 관로관리번호 추출
@@ -488,7 +440,7 @@ const FeatureClickEventPopup = ({
result = "침수흔적위선";
setHeadlist(FldngWtlvMapCol);
setLayerSpecificNm(
normalizeFldngWtlvMapDisplayId(
normalizeFldngHstryWtlvDisplayId(
featureData.values_.fid ?? featureId,
),
);
@@ -568,7 +520,7 @@ const FeatureClickEventPopup = ({
case "tb_ms_fldng_wtlv_map": {
result = "침수흔적위선";
setHeadlist(FldngWtlvMapCol2);
setLayerSpecificNm(normalizeFldngWtlvMapDisplayId(featureData.fid));
setLayerSpecificNm(normalizeFldngHstryWtlvDisplayId(featureData.fid));
break;
}
case "tb_ms_fldng_dpth_map": {
@@ -642,7 +594,7 @@ const FeatureClickEventPopup = ({
formatDisplayData(normalizedRegionData),
);
if (selectedLayerName === "침수흔적위선") {
const fldngBgngDtm = formatFldngWtlvDateTime(
const fldngBgngDtm = formatFldngHstryDateTime(
formattedData.bgngYmd ??
formattedData.bgng_ymd ??
data.bgngYmd ??
@@ -652,7 +604,7 @@ const FeatureClickEventPopup = ({
data.fldngBgngHm ??
data.fldng_bgng_hm,
);
const fldngEndDtm = formatFldngWtlvDateTime(
const fldngEndDtm = formatFldngHstryDateTime(
formattedData.endYmd ??
formattedData.end_ymd ??
data.endYmd ??
@@ -855,60 +807,20 @@ const FeatureClickEventPopup = ({
}
if (selectedLayerName === "침수흔적위선" && featureId) {
const depthLookupKey = normalizeFldngWtlvMapApiFid(featureId);
setFloodDepthRows([]);
setFloodWtlvRows([]);
setSelectedFloodDepthFid(null);
setSelectedFloodWtlvFid(null);
try {
const normalizedFloodMapFid =
normalizeFldngWtlvMapApiFid(featureId);
if (!normalizedFloodMapFid) {
throw new Error("침수흔적도 fid가 비어 있습니다.");
}
const rainAnalysis = await getData(
`v1/fldng/hstry/rain-anls/${encodeURIComponent(normalizedFloodMapFid)}`,
);
if (rainAnalysis) {
const mergedData = {
...formattedData,
...normalizeRainAnalysisPayload(rainAnalysis),
};
setFetchData(mergedData);
setTableData(mergedData);
}
} catch (rainError) {
console.warn("침수강우분석 조회 실패:", rainError);
}
try {
if (depthLookupKey) {
const depthRows = await getData(
`v1/fldng/hstry/depth-map/${encodeURIComponent(depthLookupKey)}`,
);
const depthPayload = depthRows?.data ?? depthRows;
const depthItems = Array.isArray(depthPayload)
? depthPayload
: depthPayload?.depthMaps;
const waterLevelItems = Array.isArray(
depthPayload?.waterLevelMaps,
)
? depthPayload.waterLevelMaps
: [];
const normalizedDepthRows = Array.isArray(depthItems)
? depthItems.filter((row) => row && typeof row === "object")
: [];
const normalizedWaterLevelRows = waterLevelItems.filter(
(row: unknown) => row && typeof row === "object",
);
setFloodDepthRows(normalizedDepthRows);
setFloodWtlvRows(normalizedWaterLevelRows);
// 최초에는 연계 항목을 선택하지 않고 버튼 목록만 표시한다.
setSelectedFloodDepthFid(null);
setSelectedFloodWtlvFid(null);
}
} catch (depthError) {
console.warn("침수깊이지도 조회 실패:", depthError);
resetFldngHstryFeatureClick();
const fldngHstryFid = normalizeFldngHstryWtlvApiFid(featureId);
if (!fldngHstryFid) {
throw new Error("침수흔적도 fid가 비어 있습니다.");
}
const fldngHstryDetail =
await loadFldngHstryFeatureClickDetail(fldngHstryFid);
const mergedFldngHstryData = {
...formattedData,
...fldngHstryDetail.rainAnalysis,
};
setFetchData(mergedFldngHstryData);
setTableData(mergedFldngHstryData);
}
} catch (error) {
console.error("데이터 로딩 중 오류:", error);
@@ -1018,13 +930,14 @@ const FeatureClickEventPopup = ({
return (
<div
id="popup"
className="absolute right-0 top-0 z-[50] flex h-[calc(100vh-5rem)] w-[350px] flex-col overflow-hidden border-r border-border bg-background transition-all duration-300"
className="absolute right-0 top-0 z-[50] flex h-full max-h-[calc(100dvh-6rem)] w-[350px] max-w-full flex-col overflow-hidden border-r border-border bg-background transition-all duration-300"
>
<LoadingOverlay isLoading={isLoading} />
<div className="card-header relative flex items-center justify-between bg-primary p-4 pr-12">
<div className="flex flex-1 items-center space-x-4">
{/* 제목·기능 버튼·닫기 버튼의 칸을 분리해 시설 종류나 제목 길이에 관계없이 겹침을 방지한다. */}
<div className="card-header relative !mx-0 !grid shrink-0 grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2 bg-primary p-4">
<div className="min-w-0">
{surveyMode && (
<h3 className="whitespace-nowrap text-2xl font-medium text-primary-foreground">
<h3 className="break-words text-2xl font-medium text-primary-foreground">
조사관리 <br />
관로번호 :{" "}
{featureData?.get?.("gisManagementNo") ??
@@ -1034,16 +947,14 @@ const FeatureClickEventPopup = ({
</h3>
)}
<h3
className={`${surveyMode ? "hidden" : ""} whitespace-nowrap text-2xl font-medium text-primary-foreground`}
className={`${surveyMode ? "hidden" : ""} break-words text-2xl font-medium text-primary-foreground`}
>
{layerNm}
{tableData?.usg_cd && <>({tableData.usg_cd})</>} 정보 <br />
관리번호 : {layerSpecificNm}
</h3>
</div>
<div
className={`flex shrink-0 items-center space-x-3 ${layerNm === "하수관로" ? "mr-8" : ""}`}
>
<div className="flex items-center gap-2">
{/* 하수관로일 때만 Infil/RDII 버튼 표시 */}
{layerNm === "하수관로" && (
<>
@@ -1099,7 +1010,7 @@ const FeatureClickEventPopup = ({
</div>
<Button
onClick={onClose}
className="text-primary-foreground/80 absolute right-2 top-1/2 z-10 h-8 w-8 -translate-y-1/2 p-0 hover:text-primary-foreground focus:outline-none"
className="text-primary-foreground/80 h-8 w-8 shrink-0 p-0 hover:text-primary-foreground focus:outline-none"
size="xs"
aria-label="시설 상세정보 닫기"
title="닫기"
@@ -1107,160 +1018,15 @@ const FeatureClickEventPopup = ({
<span aria-hidden="true">✕</span>
</Button>
</div>
{/* 표 카드를 flex로 압축하지 않고 내용 높이대로 쌓아 본문에서만 스크롤한다. */}
<div
className="flex flex-1 flex-col overflow-y-auto overflow-x-hidden"
className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden"
ref={contentRef}
>
<VerticalTable
headName={
layerNm === "침수흔적위선" &&
(floodDepthRows.length > 0 || floodWtlvRows.length > 0) ? (
<div className="flex w-full min-w-0 flex-col items-start gap-2">
<span>기본정보</span>
{floodDepthRows.length > 0 && (
<div className="flex w-full items-center gap-1">
<span className="w-9 shrink-0 text-[1.1rem] font-semibold">
심선
</span>
<button
type="button"
aria-label="이전 연도"
className="shrink-0 px-1 text-lg leading-none text-foreground"
onClick={() =>
floodDepthYearListRef.current?.scrollBy({
left: -120,
behavior: "smooth",
})
}
>
‹
</button>
<div
ref={floodDepthYearListRef}
className="flex min-w-0 flex-1 gap-2 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{[...floodDepthRows]
.map((row, index) => ({
row,
index,
year: String(
row.fldng_yr ?? row.fldngYr ?? row.yr ?? "",
).trim(),
}))
.filter(({ year }) => Boolean(year))
.sort((left, right) => {
const yearOrder = left.year.localeCompare(
right.year,
undefined,
{ numeric: true },
);
return yearOrder !== 0
? yearOrder
: left.index - right.index;
})
.map(({ row, index, year }) => (
<button
key={`${String(row.fldng_dpth_map_sn ?? row.fid ?? "row")}-${index}`}
type="button"
onClick={async () => {
const depthFid = String(
row.fldng_dpth_map_sn ?? row.fid ?? "",
).trim();
if (depthFid) {
setSelectedFloodDepthFid(depthFid);
setSelectedFloodWtlvFid(null);
const core = GisApp.Module.layer as any;
await core.showSearchResultsFromGeoJSON?.(
"tb_ms_fldng_dpth_map",
`tb_ms_fldng_dpth_map.${depthFid.replace(/^tb_ms_fldng_dpth_map\./, "")}`,
);
}
}}
className={`shrink-0 rounded border px-3 py-1 text-[1.2rem] font-medium leading-5 ${selectedFloodDepthFid === String(row.fldng_dpth_map_sn ?? row.fid ?? "").trim() ? "border-blue-600 bg-white text-blue-700" : "border-border bg-muted text-muted-foreground"}`}
>
{year}년
</button>
))}
</div>
<button
type="button"
aria-label="다음 연도"
className="shrink-0 px-1 text-lg leading-none text-foreground"
onClick={() =>
floodDepthYearListRef.current?.scrollBy({
left: 120,
behavior: "smooth",
})
}
>
›
</button>
</div>
)}
{floodWtlvRows.length > 0 && (
<div className="flex w-full items-center gap-1">
<span className="w-9 shrink-0 text-[1.1rem] font-semibold">
위선
</span>
<button
type="button"
aria-label="이전 위선"
className="shrink-0 px-1 text-lg leading-none text-foreground"
onClick={() =>
floodWtlvListRef.current?.scrollBy({
left: -120,
behavior: "smooth",
})
}
>
‹
</button>
<div
ref={floodWtlvListRef}
className="flex min-w-0 flex-1 gap-2 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{floodWtlvRows.map((row, index) => {
const waterLevelFid = String(row.fid ?? "").trim();
const disasterName = String(
row.fldng_dstr_nm ?? row.fldngDstrNm ?? "",
).trim();
return (
<button
key={`${waterLevelFid || "wtlv"}-${index}`}
type="button"
onClick={async () => {
if (!waterLevelFid) return;
setSelectedFloodWtlvFid(waterLevelFid);
setSelectedFloodDepthFid(null);
const core = GisApp.Module.layer as any;
await core.showSearchResultsFromGeoJSON?.(
"tb_ms_fldng_wtlv_map",
`tb_ms_fldng_wtlv_map.${waterLevelFid.replace(/^tb_ms_fldng_wtlv_map\./, "")}`,
);
}}
className={`shrink-0 rounded border px-3 py-1 text-[1.2rem] font-medium leading-5 ${selectedFloodWtlvFid === waterLevelFid ? "border-blue-600 bg-white text-blue-700" : "border-border bg-muted text-muted-foreground"}`}
>
{disasterName || "재해명 없음"}
</button>
);
})}
</div>
<button
type="button"
aria-label="다음 위선"
className="shrink-0 px-1 text-lg leading-none text-foreground"
onClick={() =>
floodWtlvListRef.current?.scrollBy({
left: 120,
behavior: "smooth",
})
}
>
›
</button>
</div>
)}
</div>
layerNm === "침수흔적위선" && hasFldngHstryLinkedFeatures ? (
fldngHstryFeatureClickHeadName
) : layerNm === "하수관로" && tableData?.elapsedYears ? (
<>
기본정보{" "}
@@ -1,4 +1,5 @@
/**
* 화면명: 하수펌프장 정보
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수펌프장)
*
* 📄 sewagePump
@@ -1,4 +1,5 @@
/**
* 화면명: 하수맨홀 정보
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수맨홀)
*
* 📄 Smh
@@ -1,4 +1,5 @@
/**
* 화면명: 빗물펌프 정보
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (빗물펌프)
*
* 📄 StormWaterPump
@@ -34,4 +35,4 @@ const StormWaterPump = ({ featureData }: { featureData: Record<string, any> }) =
)
}
export default StormWaterPump
export default StormWaterPump
@@ -1,3 +1,4 @@
/** 처리구역 관로분석정보와 보수이력을 조회·다운로드하는 API를 제공한다. */
import { getBlobDataWithHeaders, getData } from "@/components/lib/api";
export type SwtrmtPrcsZoneAnalysisMetric = {
@@ -1,3 +1,7 @@
/**
* 화면명: RDII 분석결과 목록
* RDII 분석결과를 검색하고 목록·엑셀·지도 보기 기능을 제공한다.
*/
"use client";
import { useCallback, useEffect, useState } from "react";
@@ -1,3 +1,4 @@
/** RDII 분석결과 화면의 타입·검색 조건·컬럼과 응답 변환을 정의한다. */
"use client";
export const RDII_RESULT_ROW_SELECT_MESSAGE = "SN_RDII_RESULT_ROW_SELECT";
@@ -44,4 +45,4 @@ export type RdiiResultMapViewResponse = {
pipeGeometry: Record<string, unknown>;
pipes: RdiiResultMapViewPipe[];
pipeCount: number;
};
};
@@ -1,3 +1,4 @@
/** RDII 분석결과 선택 시 부모 지도에 결과 레이어를 표시하는 훅이다. */
"use client";
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
@@ -1,3 +1,7 @@
/**
* 화면명: 보수이력 목록
* 선택 시설의 보수이력을 조회하고 목록으로 표시한다.
*/
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -1,3 +1,7 @@
/**
* 화면명: 침수위험지도 목록
* 침수위험지도를 검색하고 목록·엑셀·지도 보기 기능을 제공한다.
*/
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -85,7 +89,7 @@ export default function SbmrgDngrMapPopupPage() {
availableSggCodes.has(String(item.val).trim().slice(0, 5)),
);
return filters.ctpvCd === "0"
? scopedOptions
? []
: scopedOptions.filter((item) =>
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
);
@@ -1,6 +1,8 @@
/** 침수위험지도 화면의 검색 조건, 컬럼과 응답 변환을 정의한다. */
"use client";
import type { UiHeadProps } from "@common/business_components/ui";
import { appendApiSearchParams } from "@/shared/util/util";
export const SBMRG_DNGR_MAP_POPUP_KEY = "sbmrgDngrMapPopup";
export const SBMRG_DNGR_MAP_LAYER_TYPE = "tb_ms_sbmrg_dngr_map";
@@ -74,13 +76,11 @@ export const buildSbmrgDngrMapListQueryString = (
page: String(page),
size: String(size),
});
if (filters.ctpvCd && filters.ctpvCd !== "0")
params.set("ctpvCd", filters.ctpvCd.slice(0, 2));
if (filters.sggCd && filters.sggCd !== "0")
params.set("sggCd", filters.sggCd.slice(0, 5));
if (filters.floodWtlvFrqVl && filters.floodWtlvFrqVl !== "0") {
params.set("floodWtlvFrqVl", filters.floodWtlvFrqVl);
}
appendApiSearchParams(params, {
ctpvCd: filters.ctpvCd === "0" ? "0" : filters.ctpvCd.slice(0, 2),
sggCd: filters.sggCd === "0" ? "0" : filters.sggCd.slice(0, 5),
floodWtlvFrqVl: filters.floodWtlvFrqVl,
});
return params.toString();
};
@@ -1,3 +1,7 @@
/**
* 화면명: 지반침하 상세정보
* 선택한 지반침하 정보와 주변 관로 조회 결과를 표시한다.
*/
"use client";
import { useEffect, useMemo, useState } from "react";
@@ -1,3 +1,7 @@
/**
* 화면명: 지반침하정보 레이어 설정
* 발생연도와 위험도 등 지도 표시 조건을 설정한다.
*/
"use client";
import { useEffect, useState } from "react";
@@ -101,7 +105,7 @@ export default function SinkholeLayerSettingPanel({
const currentYear = new Date().getFullYear();
const yearSelectClass =
"h-[20px] !w-[58px] !rounded border border-input bg-background px-[1px] text-center text-[1.1rem] leading-none text-foreground";
"!h-[26px] !w-full !rounded border border-input bg-background px-2 py-0 text-center text-[12px] leading-none text-foreground";
const startYearOptions = Array.from(
{ length: currentYear - SINKHOLE_MIN_SEARCH_YEAR + 1 },
(_, index) => String(currentYear - index),
@@ -124,24 +128,19 @@ export default function SinkholeLayerSettingPanel({
.map((year) => ({ val: year, text: year }));
const inputClass =
"h-[20px] !rounded border border-input bg-background px-[1px] text-center text-[1.1rem] leading-none text-foreground";
const numberInputClass = inputClass + " w-[40px]";
"h-[26px] !rounded border border-input bg-background px-2 py-0 text-center text-[12px] leading-none text-foreground";
const numberInputClass = inputClass + " min-w-0 flex-1";
const applyButtonClass =
"grid h-[16px] w-[16px] shrink-0 place-items-center rounded-full bg-primary text-[0.85rem] font-bold leading-none text-primary-foreground";
const indicator = (
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
);
"h-[26px] shrink-0 rounded border border-[#A9C5E5] bg-[#EDF4FC] px-2 py-0 text-[12px] font-medium leading-none text-[#2463A6]";
const settingRows = (
<div className="space-y-1.5 text-[1.15rem] font-medium text-label">
<div className="flex min-h-[26px] items-center gap-1.5 whitespace-nowrap">
<div className="flex min-w-0 items-center gap-1">
{indicator}
<div className="flex min-h-[26px] flex-col items-stretch gap-1.5 whitespace-nowrap">
<div className="flex min-w-0 items-center">
<label htmlFor="sinkhole-radius" className="shrink-0">
지반침하반경(m)
</label>
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
<div className="flex w-full items-center gap-1">
<input
id="sinkhole-radius"
className={numberInputClass}
@@ -165,19 +164,18 @@ export default function SinkholeLayerSettingPanel({
title="적용"
aria-label="반경 적용"
>
<span className="relative left-[1px]">✓</span>
확인
</button>
</div>
</div>
<div className="flex min-h-[26px] items-center gap-1.5 whitespace-nowrap">
<div className="flex min-w-0 items-center gap-1">
{indicator}
<div className="flex min-h-[26px] flex-col items-stretch gap-1.5 whitespace-nowrap">
<div className="flex min-w-0 items-center">
<label htmlFor="sinkhole-start-year" className="shrink-0">
발생년도
</label>
</div>
<div className="ml-auto flex shrink-0 items-center gap-1.5">
<div id="sinkhole-start-year" className="w-[62px]">
<div className="flex w-full items-center gap-1.5">
<div id="sinkhole-start-year" className="min-w-0 flex-1">
<SelectBox
className="w-full"
selectClass={yearSelectClass}
@@ -194,7 +192,7 @@ export default function SinkholeLayerSettingPanel({
<span className="px-[1px] text-[0.95rem] font-normal text-muted-foreground">
~
</span>
<div id="sinkhole-end-year" className="w-[62px]">
<div id="sinkhole-end-year" className="min-w-0 flex-1">
<SelectBox
className="w-full"
selectClass={yearSelectClass}
@@ -215,7 +213,7 @@ export default function SinkholeLayerSettingPanel({
title="적용"
aria-label="연도범위 적용"
>
<span className="relative left-[1px]">✓</span>
확인
</button>
</div>
</div>
@@ -1,3 +1,7 @@
/**
* 화면명: 지반침하 목록
* 지반침하 정보를 검색하고 목록·엑셀·지도 보기 기능을 제공한다.
*/
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -197,7 +201,7 @@ export default function SinkholePopupPage() {
const sggOptions = useMemo(
() =>
filters.ctpvCd === "0"
? sgg
? []
: sgg.filter((item) =>
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
),
@@ -1,7 +1,9 @@
/** 지반침하 화면에서 사용하는 타입·상수·검색 조건을 정의한다. */
"use client";
import type { SelectDataType } from "@common/business_components";
import type { IUserInfo } from "@/shared/store";
import { appendApiSearchParams } from "@/shared/util/util";
export const SINKHOLE_CONTROL_NODE_ID = "SN_SINKHOLE";
export const SINKHOLE_LAYER_TYPE = "tb_sn_sinkhole_info";
@@ -284,19 +286,16 @@ export const buildSinkholeListQueryString = (
size: String(size),
});
if (filters.startYmd) params.set("startYmd", filters.startYmd);
if (filters.endYmd) params.set("endYmd", filters.endYmd);
const ctpvNm = resolveRegionNameByCode(filters.ctpvCd, ctpvOptions, 2);
const sggNm = resolveRegionNameByCode(filters.sggCd, sggOptions, 5);
if (ctpvNm) params.set("ctpvNm", ctpvNm);
if (sggNm) params.set("sggNm", sggNm);
if (filters.restoreStatus && filters.restoreStatus !== "0") {
params.set("restoreStatus", filters.restoreStatus);
}
if (filters.coordStatus && filters.coordStatus !== "0") {
params.set("coordStatus", filters.coordStatus);
}
appendApiSearchParams(params, {
startYmd: filters.startYmd,
endYmd: filters.endYmd,
ctpvNm,
sggNm,
restoreStatus: filters.restoreStatus,
coordStatus: filters.coordStatus,
});
if (options?.includeCoordStatus === false) {
params.set("includeCoordStatus", "false");
}
@@ -1,3 +1,4 @@
/** 지반침하 화면에 필요한 공통코드를 조회하고 선택 항목으로 제공한다. */
"use client";
import { useEffect, useMemo, useState } from "react";
@@ -1,3 +1,7 @@
/**
* 화면명: 통수위험 분석
* 통수위험 분석 결과와 과거 분석이력을 조회하고 지도에 표시한다.
*/
"use client";
import { useEffect, useMemo, useState } from "react";
@@ -366,7 +370,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
areaOptions
.filter(
(option) =>
draftResultFilters.ctpvCd === "ALL" ||
draftResultFilters.ctpvCd !== "ALL" &&
option.ctpvCd === draftResultFilters.ctpvCd,
)
.forEach((option) => {
@@ -382,7 +386,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
() =>
areaOptions.filter(
(option) =>
(draftResultFilters.ctpvCd === "ALL" ||
(draftResultFilters.ctpvCd !== "ALL" &&
option.ctpvCd === draftResultFilters.ctpvCd) &&
(draftResultFilters.sggCd === "ALL" ||
option.sggCd === draftResultFilters.sggCd),
@@ -1,3 +1,4 @@
/** 통수위험 분석 대상과 결과를 조회하는 API 함수를 제공한다. */
import { getData } from "@/app/components/lib/api";
import type { UiTableSort } from "@common/business_components/ui/table";
@@ -1,3 +1,4 @@
/** 통수위험 분석 화면에서 사용하는 타입·상수와 표시값 변환을 정의한다. */
"use client";
export type WaterRiskStatus = "DANGER" | "NORMAL";
+152 -114
View File
@@ -18,6 +18,7 @@
"use client";
import dynamic from "next/dynamic";
import { ListTree } from "lucide-react";
import {
Fragment,
useCallback,
@@ -144,23 +145,25 @@ import {
type SinkholeYearRange,
} from "./components/sinkhole/sinkholeTypes";
import {
FLOOD_HISTORY_AREA_LAYER_ID,
FLOOD_HISTORY_DEPTH_LAYER_NAME,
FLOOD_HISTORY_DEPTH_LAYER_TYPE,
FLOOD_HISTORY_DEPTH_LAYER_ID,
FLOOD_HISTORY_PREVIEW_LAYER_TYPE,
FLOOD_HISTORY_LAYER_TYPE,
FLOOD_HISTORY_LAYER_NAME,
FLOOD_HISTORY_POPUP_KEY,
FLDNG_HSTRY_AREA_LAYER_ID,
FLDNG_HSTRY_DEPTH_LAYER_NAME,
FLDNG_HSTRY_DEPTH_LAYER_TYPE,
FLDNG_HSTRY_DEPTH_LAYER_ID,
FLDNG_HSTRY_PREVIEW_LAYER_TYPE,
FLDNG_HSTRY_LAYER_TYPE,
FLDNG_HSTRY_LAYER_NAME,
FLDNG_HSTRY_POPUP_KEY,
FLDNG_HSTRY_ROW_SELECT_MESSAGE,
buildFloodHistoryDefaultYearRange,
buildFloodHistoryYearCql,
isFloodHistoryDepthNode,
isFloodHistoryWaterLevelNode,
isFldngHstryRootNode,
type FloodHistoryYearRange,
type FldngHstryYearRange,
type FldngHstryItem,
} from "./components/fldngHstry/fldngHstryTypes";
import {
buildFldngHstryDefaultYearRange,
buildFldngHstryYearCql,
isFldngHstryDepthNode,
isFldngHstryWtlvNode,
isFldngHstryRootNode,
} from "./components/fldngHstry/fldngHstryUtils";
import {
SBMRG_DNGR_MAP_POPUP_KEY,
isSbmrgDngrMapRootNode,
@@ -267,7 +270,7 @@ const WATER_RISK_AREA_LAYER_ID = "SN_WATER_RISK_AREA";
const WATER_RISK_PIPE_LAYER_ID = "SN_WATER_RISK_PIPE";
const WATER_RISK_MANHOLE_LAYER_ID = "SN_WATER_RISK_MANHOLE";
const SHORT_TERM_FORECAST_GRID_LAYER_NAME = "초단기예보격자망";
const FLOOD_HISTORY_SELECTION_PADDING: [number, number, number, number] = [
const FLDNG_HSTRY_SELECTION_PADDING: [number, number, number, number] = [
100, 380, 120, 120,
];
const RIGHT_PANEL_TAB_LIMIT = 5;
@@ -1089,7 +1092,7 @@ const getRightPanelFeatureTitle = (data: any) => {
case "tb_ms_swage_prcs_zone":
return "처리구역";
case "tb_ms_fldng_wtlv_map":
return FLOOD_HISTORY_LAYER_NAME;
return FLDNG_HSTRY_LAYER_NAME;
case "tb_ms_fldng_dpth_map":
return "침수흔적심선";
case "om_dt_untrt_swg_rain":
@@ -3034,28 +3037,27 @@ const Map = () => {
useState(true);
const [isFldngHstryChecked, setIsFldngHstryChecked] = useState(false);
// 침수흔적위선과 심선은 하나의 연도 범위를 공유하지만 체크 상태는 독립적으로 관리한다.
const [isFloodWtlvChecked, setIsFloodWtlvChecked] = useState(false);
const [isFloodDepthChecked, setIsFloodDepthChecked] = useState(false);
const [isFldngHstryWtlvChecked, setIsFldngHstryWtlvChecked] = useState(false);
const [isFldngHstryDepthChecked, setIsFldngHstryDepthChecked] =
useState(false);
// 심선은 6개 깊이 등급 중 사용자가 실제 체크한 lyrSn만 필터 레이어로 생성한다.
const [checkedFloodDepthLayerKeys, setCheckedFloodDepthLayerKeys] = useState<
string[]
>([]);
const [floodYearRange, setFloodYearRange] = useState<FloodHistoryYearRange>(
() => buildFloodHistoryDefaultYearRange(),
);
const [isFloodYearFilterEnabled, setIsFloodYearFilterEnabled] =
const [checkedFldngHstryDepthLayerKeys, setCheckedFldngHstryDepthLayerKeys] =
useState<string[]>([]);
const [fldngHstryYearRange, setFldngHstryYearRange] =
useState<FldngHstryYearRange>(() => buildFldngHstryDefaultYearRange());
const [isFldngHstryYearFilterEnabled, setIsFldngHstryYearFilterEnabled] =
useState(true);
const [isFloodYearSettingVisible, setIsFloodYearSettingVisible] =
const [isFldngHstryYearSettingVisible, setIsFldngHstryYearSettingVisible] =
useState(true);
// 닫힌 설정 카드는 레이어가 완전히 해제된 뒤 다시 체크될 때만 자동으로 연다.
const floodLayerCheckedRef = useRef(false);
const fldngHstryLayerCheckedRef = useRef(false);
const sinkholeLayerCheckedRef = useRef(false);
const agedPipeLayerCheckedRef = useRef(false);
const exmnPplLayerCheckedRef = useRef(false);
const exmnMnhlLayerCheckedRef = useRef(false);
const waterDepthLayerCheckedRef = useRef(false);
// 체크 등급 변경과 연도 필터 변경을 구분해 연도 변경 때만 기존 선택 상세를 정리한다.
const floodYearFilterSignatureRef = useRef("");
const fldngHstryYearFilterSignatureRef = useRef("");
const [isExmnPplChecked, setIsExmnPplChecked] = useState(false);
const [isExmnMnhlChecked, setIsExmnMnhlChecked] = useState(false);
const [exmnPplFilters, setExmnPplFilters] = useState<ExmnMapFilters>(() => ({
@@ -5343,7 +5345,7 @@ const Map = () => {
// Select 컬렉션에 다시 넣어도 강조가 렌더링되지 않는다. 탭으로 돌아올
// 때는 FID로 search-layer를 복원한 뒤 로드된 feature를 선택하게 한다.
if (
layerTypeName === FLOOD_HISTORY_LAYER_TYPE ||
layerTypeName === FLDNG_HSTRY_LAYER_TYPE ||
layerTypeName === "tb_ms_fldng_dpth_map"
) {
clearSinkholeFocusedPipeLayer();
@@ -5403,9 +5405,9 @@ const Map = () => {
source,
zIndex: 9993,
});
layer.set("id", FLOOD_HISTORY_AREA_LAYER_ID);
layer.set("lyrNm", FLOOD_HISTORY_LAYER_NAME);
layer.set("lyrTyNm", FLOOD_HISTORY_PREVIEW_LAYER_TYPE);
layer.set("id", FLDNG_HSTRY_AREA_LAYER_ID);
layer.set("lyrNm", FLDNG_HSTRY_LAYER_NAME);
layer.set("lyrTyNm", FLDNG_HSTRY_PREVIEW_LAYER_TYPE);
layer.set("clickPsblYn", "N");
gisMaps.current.addLayer(layer);
fldngHstryAreaLayerRef.current = layer;
@@ -5423,9 +5425,9 @@ const Map = () => {
source,
zIndex: 9992,
});
layer.set("id", FLOOD_HISTORY_DEPTH_LAYER_ID);
layer.set("lyrNm", FLOOD_HISTORY_LAYER_NAME);
layer.set("lyrTyNm", FLOOD_HISTORY_PREVIEW_LAYER_TYPE);
layer.set("id", FLDNG_HSTRY_DEPTH_LAYER_ID);
layer.set("lyrNm", FLDNG_HSTRY_LAYER_NAME);
layer.set("lyrTyNm", FLDNG_HSTRY_PREVIEW_LAYER_TYPE);
layer.set("clickPsblYn", "N");
gisMaps.current.addLayer(layer);
fldngHstryDepthLayerRef.current = layer;
@@ -5479,7 +5481,7 @@ const Map = () => {
const geometry = new Polygon([detail.areaRing]);
gisMaps.current.getView?.().fit?.(geometry.getExtent(), {
duration: 350,
padding: FLOOD_HISTORY_SELECTION_PADDING,
padding: FLDNG_HSTRY_SELECTION_PADDING,
maxZoom: 15,
constrainResolution: true,
nearest: true,
@@ -5507,7 +5509,7 @@ const Map = () => {
);
const openFldngHstryPopupWindow = useCallback(() => {
const popup = openWindowPopup(FLOOD_HISTORY_POPUP_KEY as WindowPopupKey);
const popup = openWindowPopup(FLDNG_HSTRY_POPUP_KEY as WindowPopupKey);
if (!popup) {
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
return null;
@@ -6052,7 +6054,7 @@ const Map = () => {
// 제거되지 않는다. 마지막 탭을 닫는 경우와 다른 일반 feature 탭으로
// 전환되는 경우 모두 잔상으로 남지 않도록 여기서 명시적으로 제거한다.
if (
closingLayerTypeName === FLOOD_HISTORY_LAYER_TYPE ||
closingLayerTypeName === FLDNG_HSTRY_LAYER_TYPE ||
closingLayerTypeName === "tb_ms_fldng_dpth_map"
) {
const layerModule = GisApp.Module.layer as any;
@@ -6477,33 +6479,33 @@ const Map = () => {
agedPipeLayerCheckedRef.current = agedPipeChecked;
waterDepthLayerCheckedRef.current = waterDepthChecked;
setIsAgedPipeChecked(agedPipeChecked);
const floodWtlvChecked = checkedNodesData.some((node) =>
isFloodHistoryWaterLevelNode(node),
const fldngHstryWtlvChecked = checkedNodesData.some((node) =>
isFldngHstryWtlvNode(node),
);
const checkedFloodDepthNodes = checkedNodesData.filter((node) =>
isFloodHistoryDepthNode(node),
const checkedFldngHstryDepthNodes = checkedNodesData.filter((node) =>
isFldngHstryDepthNode(node),
);
const checkedDepthLayerKeys = Array.from(
const checkedFldngHstryDepthLayerKeys = Array.from(
new Set(
checkedFloodDepthNodes
checkedFldngHstryDepthNodes
.map((node) => String(node?.data?.lyrSn ?? node?.id ?? "").trim())
.filter(Boolean),
),
).sort((left, right) => Number(left) - Number(right));
const floodDepthChecked = checkedDepthLayerKeys.length > 0;
const fldngHstryChecked = floodWtlvChecked || floodDepthChecked;
setIsFloodWtlvChecked(floodWtlvChecked);
setIsFloodDepthChecked(floodDepthChecked);
setCheckedFloodDepthLayerKeys((currentKeys) =>
currentKeys.join(",") === checkedDepthLayerKeys.join(",")
const fldngHstryDepthChecked = checkedFldngHstryDepthLayerKeys.length > 0;
const fldngHstryChecked = fldngHstryWtlvChecked || fldngHstryDepthChecked;
setIsFldngHstryWtlvChecked(fldngHstryWtlvChecked);
setIsFldngHstryDepthChecked(fldngHstryDepthChecked);
setCheckedFldngHstryDepthLayerKeys((currentKeys) =>
currentKeys.join(",") === checkedFldngHstryDepthLayerKeys.join(",")
? currentKeys
: checkedDepthLayerKeys,
: checkedFldngHstryDepthLayerKeys,
);
setIsFldngHstryChecked(fldngHstryChecked);
if (fldngHstryChecked && !floodLayerCheckedRef.current) {
setIsFloodYearSettingVisible(true);
if (fldngHstryChecked && !fldngHstryLayerCheckedRef.current) {
setIsFldngHstryYearSettingVisible(true);
}
floodLayerCheckedRef.current = fldngHstryChecked;
fldngHstryLayerCheckedRef.current = fldngHstryChecked;
if (!fldngHstryChecked) {
clearFldngHstrySelection();
}
@@ -6692,18 +6694,18 @@ const Map = () => {
});
}
if (isFldngHstryChecked && isFloodYearSettingVisible) {
if (isFldngHstryChecked && isFldngHstryYearSettingVisible) {
nextItems.push({
key: "fldngHstry",
label: "침수흔적도",
order: resolveSettingOrder("fldngHstry", 9997),
content: (
<FldngHstryLayerSettingPanel
yearRange={floodYearRange}
enabled={isFloodYearFilterEnabled}
onYearRangeChange={setFloodYearRange}
onEnabledChange={setIsFloodYearFilterEnabled}
onClose={() => setIsFloodYearSettingVisible(false)}
yearRange={fldngHstryYearRange}
enabled={isFldngHstryYearFilterEnabled}
onYearRangeChange={setFldngHstryYearRange}
onEnabledChange={setIsFldngHstryYearFilterEnabled}
onClose={() => setIsFldngHstryYearSettingVisible(false)}
/>
),
});
@@ -6764,17 +6766,17 @@ const Map = () => {
showToggle={false}
>
{isWaterDepthLegendEnabled ? (
<div className="space-y-1">
<div className="space-y-[6px]">
{waterDepthLegendItems.map((item) => (
<div key={item.key} className="flex items-center gap-2">
<span
className="inline-block h-4 w-10 shrink-0"
className="inline-block h-6 w-10 shrink-0"
style={{
backgroundColor: item.color,
border: `1px solid ${item.borderColor}`,
}}
/>
<span className="text-[1rem] font-medium text-label">
<span className="text-[11px] font-medium text-label">
{item.label}
</span>
</div>
@@ -6797,7 +6799,7 @@ const Map = () => {
exmnMnhlFilters,
exmnPplFilters,
handleApplyAgedPipeFilters,
floodYearRange,
fldngHstryYearRange,
isAgedPipeChecked,
isAgedPipeSettingEnabled,
isAgedPipeSettingVisible,
@@ -6808,8 +6810,8 @@ const Map = () => {
isExmnPplSettingEnabled,
isExmnPplSettingVisible,
isFldngHstryChecked,
isFloodYearFilterEnabled,
isFloodYearSettingVisible,
isFldngHstryYearFilterEnabled,
isFldngHstryYearSettingVisible,
isSinkholeChecked,
isSinkholeSettingEnabled,
isSinkholeSettingVisible,
@@ -6852,16 +6854,19 @@ const Map = () => {
if (activeLayerSettingItems.length === 0) return null;
return (
<div className="mt-3 flex h-[34vh] max-h-[32rem] min-h-[24rem] flex-col overflow-hidden rounded-sm border border-border bg-background shadow-[0_4px_14px_rgba(15,23,42,0.08)]">
<div className="border-b border-border bg-secondary px-3 py-2">
<span className="text-[1.2rem] font-semibold text-label">
<div className="flex h-[34vh] max-h-[32rem] min-h-[24rem] flex-col overflow-hidden rounded-[6px] border border-border bg-background shadow-[0_4px_14px_rgba(15,23,42,0.08)]">
<div className="flex h-[38px] shrink-0 items-center gap-2 border-b border-border px-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-sm bg-[#EDF8EA] text-[#65B84A]">
<ListTree size={14} strokeWidth={2} />
</span>
<span className="text-[1.4rem] font-bold text-foreground">
선택 레이어 설정
</span>
</div>
<div
ref={restoreActiveLayerSettingScroll}
onScroll={handleActiveLayerSettingScroll}
className="min-h-0 flex-1 overflow-y-scroll px-1 py-2 [scrollbar-gutter:stable] [scrollbar-width:thin] [&::-webkit-scrollbar]:w-[4px]"
className="min-h-0 flex-1 overflow-y-scroll px-2 py-2 [scrollbar-gutter:stable] [scrollbar-width:thin] [&::-webkit-scrollbar]:w-[4px]"
>
<div className="flex flex-col gap-2">
{activeLayerSettingItems.map((item) => (
@@ -6930,60 +6935,60 @@ const Map = () => {
if (!mapReady || !gisMaps.current) return;
const core = GisApp.Module.core as any;
const yearCql = buildFloodHistoryYearCql(
floodYearRange,
isFloodYearFilterEnabled,
const yearCql = buildFldngHstryYearCql(
fldngHstryYearRange,
isFldngHstryYearFilterEnabled,
);
const yearFilterSignature = `${floodYearRange.startYear}:${floodYearRange.endYear}:${isFloodYearFilterEnabled}`;
const yearFilterSignature = `${fldngHstryYearRange.startYear}:${fldngHstryYearRange.endYear}:${isFldngHstryYearFilterEnabled}`;
if (
floodYearFilterSignatureRef.current &&
floodYearFilterSignatureRef.current !== yearFilterSignature
fldngHstryYearFilterSignatureRef.current &&
fldngHstryYearFilterSignatureRef.current !== yearFilterSignature
) {
clearFldngHstrySelection();
}
floodYearFilterSignatureRef.current = yearFilterSignature;
fldngHstryYearFilterSignatureRef.current = yearFilterSignature;
/** 기준 레이어 필터를 보존하면서 선택한 침수년도 조건을 결합한다. */
const resolveFloodFilter = (baseLayerCode: any) => {
const resolveFldngHstryFilter = (baseLayerCode: any) => {
const baseFilter = String(baseLayerCode?.flterSql ?? "").trim();
return [baseFilter && `(${baseFilter})`, yearCql]
.filter(Boolean)
.join(" AND ");
};
type FloodLayerSpec = {
type FldngHstryLayerSpec = {
id: string;
layerType: string;
layerName: string;
baseLayerCode: any;
filterSql: string;
};
const desiredLayers: FloodLayerSpec[] = [];
const desiredLayers: FldngHstryLayerSpec[] = [];
const layerCodes = Object.values(GisApp.layerCode ?? {}) as any[];
if (isFloodWtlvChecked) {
if (isFldngHstryWtlvChecked) {
const baseLayerCode = layerCodes.find(
(item) =>
item?.lyrTyNm === FLOOD_HISTORY_LAYER_TYPE && item?.actlLyrYn === "Y",
item?.lyrTyNm === FLDNG_HSTRY_LAYER_TYPE && item?.actlLyrYn === "Y",
);
if (baseLayerCode) {
desiredLayers.push({
id: FLDNG_HSTRY_WFS_LAYER_ID,
layerType: FLOOD_HISTORY_LAYER_TYPE,
layerName: baseLayerCode?.lyrNm || FLOOD_HISTORY_LAYER_NAME,
layerType: FLDNG_HSTRY_LAYER_TYPE,
layerName: baseLayerCode?.lyrNm || FLDNG_HSTRY_LAYER_NAME,
baseLayerCode,
filterSql: resolveFloodFilter(baseLayerCode),
filterSql: resolveFldngHstryFilter(baseLayerCode),
});
}
}
if (isFloodDepthChecked) {
const selectedDepthLayerKeys = new Set(checkedFloodDepthLayerKeys);
if (isFldngHstryDepthChecked) {
const selectedDepthLayerKeys = new Set(checkedFldngHstryDepthLayerKeys);
layerCodes
.filter((item) => {
const layerKey = String(item?.lyrSn ?? item?.id ?? "").trim();
return (
item?.lyrTyNm === FLOOD_HISTORY_DEPTH_LAYER_TYPE &&
item?.lyrTyNm === FLDNG_HSTRY_DEPTH_LAYER_TYPE &&
item?.actlLyrYn === "Y" &&
selectedDepthLayerKeys.has(layerKey)
);
@@ -6994,25 +6999,28 @@ const Map = () => {
).trim();
desiredLayers.push({
id: `${FLDNG_DEPTH_WFS_LAYER_ID_PREFIX}_${layerKey}`,
layerType: FLOOD_HISTORY_DEPTH_LAYER_TYPE,
layerName: baseLayerCode?.lyrNm || FLOOD_HISTORY_DEPTH_LAYER_NAME,
layerType: FLDNG_HSTRY_DEPTH_LAYER_TYPE,
layerName: baseLayerCode?.lyrNm || FLDNG_HSTRY_DEPTH_LAYER_NAME,
baseLayerCode,
filterSql: resolveFloodFilter(baseLayerCode),
filterSql: resolveFldngHstryFilter(baseLayerCode),
});
});
}
const desiredLayerIds = new Set(desiredLayers.map((item) => item.id));
const mapLayers = gisMaps.current?.getLayers?.()?.getArray?.() ?? [];
const isManagedFloodLayerId = (id: string) =>
const mapLayers = [...(gisMaps.current?.getLayers?.()?.getArray?.() ?? [])];
const isManagedFldngHstryLayerId = (id: string) =>
id === FLDNG_HSTRY_WFS_LAYER_ID ||
id.startsWith(`${FLDNG_DEPTH_WFS_LAYER_ID_PREFIX}_`);
// 체크 해제된 등급만 삭제하고, 계속 체크된 등급은 지도에 그대로 유지한다.
mapLayers.forEach((mapLayer: any) => {
const layerId = String(mapLayer?.get?.("id") ?? "");
if (isManagedFloodLayerId(layerId) && !desiredLayerIds.has(layerId)) {
core?.deleteLayer?.(layerId);
if (
isManagedFldngHstryLayerId(layerId) &&
!desiredLayerIds.has(layerId)
) {
gisMaps.current?.removeLayer?.(mapLayer);
}
});
@@ -7026,7 +7034,9 @@ const Map = () => {
?.find(
(mapLayer: any) => String(mapLayer?.get?.("id") ?? "") === spec.id,
);
if (existingLayer?.get?.("floodFilterSignature") === filterSignature) {
if (
existingLayer?.get?.("fldngHstryFilterSignature") === filterSignature
) {
existingLayer?.setVisible?.(true);
return;
}
@@ -7050,7 +7060,7 @@ const Map = () => {
layer?.set?.("id", spec.id);
layer?.set?.("lyrNm", spec.layerName);
layer?.set?.("lyrTyNm", spec.layerType);
layer?.set?.("floodFilterSignature", filterSignature);
layer?.set?.("fldngHstryFilterSignature", filterSignature);
});
// 전용 필터 레이어가 담당하는 유형의 원본 레이어만 숨겨 중복 표출을 막는다.
@@ -7065,19 +7075,19 @@ const Map = () => {
const layerType = String(mapLayer?.get?.("lyrTyNm") ?? "");
if (
activeFilteredTypes.has(layerType) &&
!isManagedFloodLayerId(layerId)
!isManagedFldngHstryLayerId(layerId)
) {
mapLayer?.setVisible?.(false);
}
});
}, [
checkedFloodDepthLayerKeys,
checkedFldngHstryDepthLayerKeys,
clearFldngHstrySelection,
floodYearRange,
fldngHstryYearRange,
isFldngHstryChecked,
isFloodDepthChecked,
isFloodWtlvChecked,
isFloodYearFilterEnabled,
isFldngHstryDepthChecked,
isFldngHstryWtlvChecked,
isFldngHstryYearFilterEnabled,
mapReady,
]);
@@ -9379,7 +9389,7 @@ const Map = () => {
>
<GisEditSessionPanel
{...gisEditSessionSnapshot}
className="max-h-[calc(50dvh-82px)]"
className="max-h-[calc(50dvh-82px)] !border-0 !shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)]"
variant="inline"
onOpenPopup={handleOpenGisEditSessionPopup}
onRemoveFocusedChange={gisEdit.removeFocusedChange}
@@ -9423,10 +9433,13 @@ const Map = () => {
setIsSearchModalOpen={setIsSearchModalOpen}
onCheckedLayersChange={handleCheckedLayersChange}
onOpenPopup={handleOpenLayerPopup}
searchBarContainerClassName="!top-[16px] ml-[12px]"
alwaysVisibleMapTabs={true}
searchBar={
<ProcSearchBar
gisMaps={gisMaps}
facilityTypes={facilityTypes}
integrated
sidoSearchZoom={
isAgedPipeChecked ? AGED_PIPE_GUIDE_MIN_ZOOM : undefined
}
@@ -9435,15 +9448,17 @@ const Map = () => {
}
waterDepth={undefined}
layerSettingView={ActiveLayerSettingView}
treeSectionClassName="rounded-sm border border-border bg-background px-1 py-2 text-foreground"
treeSectionClassName="px-1 py-2 text-foreground"
detachedTreeToggleButton={true}
showLayerPanelTitle={false}
sectionedTreePanel={true}
isPlaying={isPlaying}
setIsPlaying={setIsPlaying as any}
targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any}
checkInterval={2500}
checkedLayerChangeDelay={0}
showZoomLevel={true}
toolBarIconSize={20}
showZoomScaleInfo={true}
showFloatingSearchButton={false}
treeTogglePosition="center"
@@ -9497,9 +9512,19 @@ const Map = () => {
)}
<style jsx global>{`
.sn-map-control-layout .mapbtn {
top: auto;
right: 15px;
bottom: 439px;
top: 16px;
right: 120px;
bottom: auto;
}
.sn-map-control-layout .map-type-tabs,
.sn-map-control-layout .tool-bar ul,
.sn-map-control-layout .zoom-level-panel,
.sn-map-control-layout .zoom-level-summary {
border: 0;
box-shadow:
0 1px 2px rgba(60, 64, 67, 0.3),
0 1px 3px 1px rgba(60, 64, 67, 0.15);
}
.sn-map-control-layout .custom-toolbar {
@@ -9507,19 +9532,32 @@ const Map = () => {
}
.sn-map-control-layout .tool-bar {
top: auto;
right: 15px;
bottom: 230px;
top: 16px;
right: 16px;
bottom: auto;
}
.sn-map-control-layout .tool-bar-vector-icons ul li div {
display: flex;
align-items: center;
justify-content: center;
background-image: none !important;
color: #666;
line-height: 1;
}
.sn-map-control-layout .tool-bar-vector-icons ul li div:hover {
color: #fff;
}
.sn-map-control-layout .zoom-level-control {
right: 15px;
bottom: 36px;
right: 16px;
bottom: 16px;
}
.sn-map-control-layout .zoom-level-summary {
right: 56px;
bottom: -15px;
bottom: 0;
}
.sn-map-control-layout .ol-scale-line {
@@ -55,7 +55,7 @@ const BasicTab: React.FC = () => {
selectContentClass="z-[101]"
/>
</div>
<div className="min-w-0 flex-[1_1_60rem]">
<div className="contents">
{basicType === "tb_ms_swage_ppl" && <SwagePpl />} {/* 하수관로 */}
{basicType === "tb_il_swage_prcs_fclt" && <SwagePrcsFclt />}{" "}
{/** 하수처리시설 */}
@@ -23,8 +23,8 @@ const FldngWtlvMap: React.FC = () => {
const { handleSearch, handleResetSearch } = useSearchModal();
return (
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
<div className="contents">
<div className="contents">
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
sigungu={sggCd}
@@ -53,8 +53,8 @@ const Stofcbr: React.FC = () => {
const { handleSearch, handleResetSearch } = useSearchModal();
return (
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
<div className="contents">
<div className="contents">
{/* 시도, 시군구 */}
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
@@ -77,8 +77,8 @@ const SwageMhl: React.FC = () => {
const { handleSearch, handleResetSearch } = useSearchModal();
return (
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
<div className="contents">
<div className="contents">
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
sigungu={sggCd}
@@ -78,8 +78,8 @@ const SwagePmstn: React.FC = () => {
const { handleSearch, handleResetSearch } = useSearchModal();
return (
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
<div className="contents">
<div className="contents">
{/* 시도, 시군구 */}
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
@@ -84,8 +84,8 @@ const SwagePpl: React.FC = () => {
const { handleSearch, handleResetSearch } = useSearchModal();
return (
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
<div className="contents">
<div className="contents">
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
sigungu={sggCd}
@@ -81,8 +81,8 @@ const SwagePrcsFclt: React.FC = () => {
const { handleSearch, handleResetSearch } = useSearchModal();
return (
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
<div className="contents">
<div className="contents">
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
sigungu={sggCd}
@@ -34,8 +34,8 @@ const swagePrcsZone: React.FC = () => {
const { handleSearch, handleResetSearch } = useSearchModal();
return (
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
<div className="contents">
<div className="contents">
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
sigungu={sggCd}
@@ -485,6 +485,7 @@ const IrSetting = () => {
key="irsetting-layout-key"
title="분석 조건 및 자료"
columns={2}
wideLabels
>
<StandardFormField label="상수사용량 적용월" required columns={2}>
{/* 연월 선택 라이브러리로 수정 필요 */}
@@ -518,6 +518,7 @@ export default function RTKForm() {
key="form-section"
title="분석 조건 및 자료"
columns={2}
wideLabels
>
<StandardFormField label="RTK계수 출처" required columns={2}>
<Input {...form.register("rtkSource")} className="w-full" />
@@ -14,6 +14,7 @@ import {
TableRow,
} from "@common/components";
import { MS_SWAGE_PPL_CODES } from "@/app/(map-layout)/map/components/common/table";
import { fileDownload } from "@/components/lib/utils/fileDownload";
import { useAlertStore } from "@/shared/lib";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -118,6 +119,8 @@ const DredgingDetailPopupPage = ({
const [matchError, setMatchError] = useState<string | null>(null);
const [isMatching, setIsMatching] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// 원본 다운로드 중 연속 클릭을 막고 선택한 사진의 다운로드 진행 상태를 표시한다.
const [isDownloadingPhoto, setIsDownloadingPhoto] = useState(false);
const [statusMessage, setStatusMessage] = useState("");
const uploadedAttachmentsRef = useRef(new Map<string, AttachmentReference>());
@@ -220,6 +223,31 @@ const DredgingDetailPopupPage = ({
null)
: null;
/** 저장된 사진은 공통 첨부 원본을 받고, 아직 저장하지 않은 신규 사진은 로컬 File을 내려받는다. */
const handlePhotoDownload = async () => {
// 다운로드 중 선택을 바꾸더라도 클릭한 시점의 파일과 파일명을 함께 유지한다.
const existingPhoto = selectedExistingPhoto;
const newPhoto = selectedNewPhoto;
if (isDownloadingPhoto || (!existingPhoto && !newPhoto)) return;
setIsDownloadingPhoto(true);
try {
if (newPhoto) {
fileDownload(newPhoto.file, newPhoto.file.name);
} else if (existingPhoto) {
const original = await fetchDredgingPhotoBlob(existingPhoto.atflId);
fileDownload(
original,
existingPhoto.originalFileName || `준설사진_${existingPhoto.photoSn}`,
);
}
} catch {
alert("사진을 다운로드하지 못했습니다. 잠시 후 다시 시도해주세요.");
} finally {
setIsDownloadingPhoto(false);
}
};
const pipeInfo = [
["시도", record.ctpv || "확인불가"],
["시군구", record.sgg || "확인불가"],
@@ -480,10 +508,10 @@ const DredgingDetailPopupPage = ({
</Button>
</div>
}
bodyClassName="sn-dredging-register block overflow-y-auto"
bodyClassName="sn-dredging-register !overflow-hidden"
>
<div className="grid min-h-0 grid-cols-[minmax(44rem,48rem)_minmax(0,1fr)] gap-3">
<aside className="flex min-h-0 flex-col gap-3 pr-1">
<div className="grid h-full min-h-0 flex-1 grid-cols-[minmax(44rem,48rem)_minmax(0,1fr)] gap-3">
<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>
@@ -561,7 +589,11 @@ const DredgingDetailPopupPage = ({
</div>
</section>
<section className="card card-border !m-0 flex min-h-[36rem] flex-1 flex-col overflow-hidden !p-0">
{/* 사진 수·원본 크기와 무관하게 남은 화면 높이를 사용하며, 작은 창에서만 좌측 정보가 스크롤된다. */}
<section
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">
<h2 className="text-2xl font-medium text-label">준설 사진</h2>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
@@ -598,7 +630,7 @@ const DredgingDetailPopupPage = ({
</div>
{editable && registrationMode === "PHOTO" ? (
<div className="sn-dredging-photo-upload shrink-0 border-b border-border p-3">
<div className="sn-dredging-photo-upload flex h-[5.2rem] shrink-0 items-center border-b border-border p-3">
<FileUpload
files={newPhotos.map((photo) => photo.file)}
setFiles={(files) => void handleNewFiles(files)}
@@ -607,14 +639,18 @@ const DredgingDetailPopupPage = ({
/>
</div>
) : editable ? (
<div className="bg-secondary/30 shrink-0 border-b border-border px-3 py-3 text-center text-sm text-muted-foreground">
<div className="bg-secondary/30 flex h-[5.2rem] shrink-0 items-center justify-center border-b border-border px-3 py-3 text-center text-sm text-muted-foreground">
사진 없이 지도에서 준설위치를 직접 지정합니다.
</div>
) : null}
<div className="shrink-0 border-b border-border p-3">
{/* 목록의 높이와 스크롤바 자리를 예약해 추가·삭제·로딩 상태에도 미리보기 위치를 유지한다. */}
<div
className="h-[12rem] shrink-0 overflow-y-auto overscroll-contain border-b border-border p-3 [scrollbar-gutter:stable]"
data-testid="dredging-detail-photo-list-viewport"
>
{isLoading ? (
<p className="flex h-[8.8rem] items-center justify-center text-sm text-muted-foreground">
<p className="flex h-full items-center justify-center text-sm text-muted-foreground">
등록된 사진을 불러오는 중입니다.
</p>
) : error ? (
@@ -728,7 +764,7 @@ const DredgingDetailPopupPage = ({
})}
</div>
) : (
<p className="flex h-[8.8rem] items-center justify-center text-sm text-muted-foreground">
<p className="flex h-full items-center justify-center text-sm text-muted-foreground">
{editable && registrationMode === "PHOTO"
? "사진을 추가하거나 사진 없이 직접 등록을 선택해주세요."
: "등록된 사진이 없습니다."}
@@ -736,22 +772,41 @@ const DredgingDetailPopupPage = ({
)}
</div>
{/* 사진 미리보기는 밝은 사진의 경계를 보장하는 미디어 캔버스이므로 고정 암색을 유지한다. */}
{/* 사진 미리보기는 밝은 사진의 경계를 보장하는 미디어 캔버스이므로 고정 암색을 유지한다.
이미지를 절대 배치해 원본 고유 크기가 부모 높이를 늘리지 않도록 한다. */}
<div
className="flex min-h-[22rem] flex-1 items-center justify-center overflow-hidden bg-slate-900 p-2"
className="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-slate-900 p-2"
data-testid="dredging-detail-photo-preview"
>
{/* 미리보기 위에 겹쳐 배치해 다운로드 버튼이 고정 사진 영역의 높이를 바꾸지 않게 한다. */}
<div className="absolute right-2 top-2 z-10">
<Button
type="button"
size="sm"
color="white"
disabled={
isLoading ||
isSaving ||
isDownloadingPhoto ||
(!selectedExistingPhoto && !selectedNewPhoto)
}
onClick={() => void handlePhotoDownload()}
data-testid="dredging-photo-download"
>
{isDownloadingPhoto ? "다운로드 중..." : "사진 다운로드"}
</Button>
</div>
{selectedExistingPhoto?.previewUrl ? (
<img
src={selectedExistingPhoto.previewUrl}
alt={selectedExistingPhoto.originalFileName || "준설사진"}
className="h-full w-full object-contain"
className="absolute inset-0 h-full w-full object-contain p-2"
/>
) : selectedNewPhoto ? (
<img
src={selectedNewPhoto.previewUrl}
alt={selectedNewPhoto.file.name}
className="h-full w-full object-contain"
className="absolute inset-0 h-full w-full object-contain p-2"
/>
) : selectedExistingPhoto?.loadError ? (
<p className="px-4 text-center text-sm text-rose-200">
@@ -813,7 +868,7 @@ const DredgingDetailPopupPage = ({
</div>
</div>
<div
className="bg-secondary/30 px-4 py-2 text-sm text-muted-foreground"
className="bg-secondary/30 h-[5.2rem] overflow-y-auto px-4 py-2 text-sm text-muted-foreground"
data-testid="dredging-edit-status"
>
{statusMessage ||
@@ -835,7 +890,7 @@ const DredgingDetailPopupPage = ({
</span>
) : null}
</div>
<div className="min-h-[56rem] flex-1 p-2">
<div className="min-h-0 flex-1 p-2">
<DredgingLocationMap
value={isEditingLocation ? locationDraft : location}
matchedPipe={
@@ -850,7 +905,7 @@ const DredgingDetailPopupPage = ({
? "현재 준설위치와 자동 연결된 관로를 표시하고 있습니다."
: "준설위치와 자동 연결된 관로를 표시하고 있습니다."
}
height="h-full min-h-[56rem]"
height="h-full min-h-0"
/>
</div>
</section>
@@ -1,3 +1,7 @@
/**
* 화면명: 준설실적 등록
* 준설 사진을 등록하고 촬영정보·위치·인접 관로를 확인하여 준설실적을 저장하는 화면이다.
*/
"use client";
import dynamic from "next/dynamic";
@@ -557,7 +561,7 @@ const DredgingRegisterModal = ({
className={
presentation === "popup"
? "sn-dredging-register flex h-full min-h-0 flex-col overflow-hidden"
: "sn-dredging-register"
: "sn-dredging-register flex h-[min(72rem,70dvh)] min-h-0 flex-col overflow-hidden"
}
>
<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">
@@ -575,7 +579,7 @@ const DredgingRegisterModal = ({
groups.findIndex((group) => group.id === activeGroup.id) + 1
}`}
>
<aside className="flex min-h-0 min-w-0 flex-col gap-3 pr-1">
<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">
<h3 className="text-2xl font-medium text-label">등록 정보</h3>
@@ -683,7 +687,11 @@ const DredgingRegisterModal = ({
</div>
</section>
<section className="card card-border !m-0 shrink-0 overflow-hidden !p-0">
{/* 등록 건별 사진 수가 달라도 목록 카드 높이를 고정해 지도와 하단 정보가 밀리지 않게 한다. */}
<section
className="card card-border !m-0 flex h-[28rem] shrink-0 flex-col overflow-hidden !p-0"
data-testid="dredging-register-photo-panel"
>
<div className="card-header !m-0 flex-wrap gap-2">
<div>
<h3 className="text-2xl font-medium text-label">준설 사진</h3>
@@ -724,10 +732,10 @@ const DredgingRegisterModal = ({
</Button>
</div>
</div>
<div className="p-3">
<div className="flex min-h-0 flex-1 flex-col p-3">
{activeGroup.registrationMode === "PHOTO" ? (
<>
<div className="sn-dredging-photo-upload">
<div className="sn-dredging-photo-upload shrink-0">
<FileUpload
files={activeGroup.photos.map((photo) => photo.file)}
setFiles={(files) => {
@@ -739,7 +747,10 @@ const DredgingRegisterModal = ({
</div>
{activeGroup.photos.length > 0 ? (
<div className="mt-2 rounded-sm border border-border">
<div
className="mt-2 min-h-0 flex-1 overflow-y-auto overscroll-contain rounded-sm border border-border [scrollbar-gutter:stable]"
data-testid="dredging-register-photo-list-viewport"
>
<Table className="table-fixed" variant="default">
<TableHeader>
<TableRow>
@@ -749,7 +760,9 @@ const DredgingRegisterModal = ({
</TableHead>
<TableHead className="w-[16%]">위치</TableHead>
<TableHead className="w-[17%]">상태</TableHead>
<TableHead className="w-[8%]">삭제</TableHead>
<TableHead className="w-[8%] whitespace-nowrap px-1">
삭제
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -778,9 +791,14 @@ const DredgingRegisterModal = ({
? "GPS"
: "지정 필요"}
</TableCell>
<TableCell className="px-3">
<TableCell className="overflow-hidden px-3">
<span
className={`whitespace-nowrap rounded-sm px-2 py-1 text-sm font-medium ${
title={
photoStatusLabels[
photo.metadata.status
] || "수동 입력 필요"
}
className={`inline-block max-w-full truncate rounded-sm px-2 py-1 align-middle text-sm font-medium ${
photo.metadata.status === "COMPLETE"
? "bg-emerald-100 text-emerald-800"
: "bg-amber-100 text-amber-800"
@@ -813,14 +831,14 @@ const DredgingRegisterModal = ({
</Table>
</div>
) : (
<div className="bg-secondary/30 mt-2 rounded-sm border border-dashed border-border px-3 py-2 text-center text-sm text-muted-foreground">
<div className="bg-secondary/30 mt-2 flex min-h-0 flex-1 items-center justify-center rounded-sm border border-dashed border-border px-3 py-2 text-center text-sm text-muted-foreground">
준설사진을 선택하면 촬영일과 위치정보를 자동으로
확인합니다.
</div>
)}
</>
) : (
<div className="bg-secondary/30 rounded-sm border border-dashed border-border px-3 py-3 text-center text-sm text-muted-foreground">
<div className="bg-secondary/30 flex min-h-0 flex-1 items-center justify-center rounded-sm border border-dashed border-border px-3 py-3 text-center text-sm text-muted-foreground">
사진 없이 지도에서 준설위치를 직접 지정합니다.
</div>
)}
@@ -946,7 +964,7 @@ const DredgingRegisterModal = ({
위치 선택 지도
</h3>
</div>
<div className="min-h-[52rem] flex-1 p-2">
<div className="min-h-0 flex-1 p-2">
<DredgingLocationMap
value={
isEditingActiveLocation
@@ -967,7 +985,7 @@ const DredgingRegisterModal = ({
: "위치등록 버튼을 누른 뒤 지도에서 준설위치를 선택해주세요."
}
onChange={changeLocationDraft}
height="h-full min-h-[52rem]"
height="h-full min-h-0"
/>
</div>
</section>
@@ -1003,7 +1021,7 @@ const DredgingRegisterModal = ({
</Button>
</div>
}
bodyClassName="block overflow-y-auto"
bodyClassName="!overflow-hidden"
>
{modalBody}
</StandardPopupLayout>
@@ -361,7 +361,7 @@ const Page = () => {
/** 선택한 시도 코드와 앞 두 자리가 같은 시군구만 후보로 제공한다. */
const availableSggOptions = useMemo(() => {
if (ctpvCd === "0") return sggRegionOptions;
if (ctpvCd === "0") return [];
const ctpvPrefix = ctpvCd.slice(0, 2);
return sggRegionOptions.filter((option) =>
String(option.val).startsWith(ctpvPrefix),
+4 -4
View File
@@ -168,16 +168,16 @@ const FileUploadForm = ({
}
return (
<div>
<div className="w-full">
{" "}
{/* 컴포넌트 루트 div */}
<div className="card p-6">
<div className="card w-full p-6">
{" "}
{/* 카드 스타일링 */}
<div className="flex items-center gap-2">
<div className="flex w-full items-center gap-2">
{" "}
{/* FileUploader와 Button을 정렬하는 메인 flex 컨테이너 */}
<div className="mr-4 w-[300px]">
<div className="min-w-0 flex-1">
{" "}
{/* FileUploader의 너비와 오른쪽 여백을 제어하는 컨테이너 */}
<FileUploader
@@ -41,7 +41,7 @@ export default function AgedPipeListModal({
const { ctpv, sgg } = useStore();
const lastClickRef = useRef<{ index: number; clickedAt: number } | null>(null);
const sggOptions = filters.ctpvCd === "0"
? sgg
? []
: sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2)));
const handleRowClick = (index?: number) => {
@@ -34,9 +34,9 @@ const BasicTab: React.FC = () => {
const { basicType, setBasicType } = useSearchModalStore();
return (
<div className="relative border rounded-lg pt-5 px-5 w-full min-h-[10vh]">
<div className="relative flex w-full min-h-[10vh] flex-wrap items-center gap-x-4 gap-y-3 rounded-lg border px-5 pt-5">
{/* 구분 */}
<div className="mb-3 flex items-center justify-between gap-4">
<div className="flex shrink-0 items-center">
<div className="flex items-center">
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">구분</Label>
<SelectBox
@@ -52,7 +52,7 @@ const BasicTab: React.FC = () => {
/>
</div>
</div>
{/* 조회 조건 */}
{/* 조회 조건: 구분 선택과 같은 행에 배치 */}
{basicType === "tb_ms_swage_ppl" && (<SwagePpl />)} {/* 하수관로 */}
{basicType === "tb_il_swage_prcs_fclt" && (<SwagePrcsFclt />)} {/* 하수처리시설 */}
{basicType === "tb_ms_swage_prcs_zone" && (<SwagePrcsZone />)} {/* 하수처리구역 */}
@@ -63,4 +63,4 @@ const BasicTab: React.FC = () => {
);
};
export default React.memo(BasicTab);
export default React.memo(BasicTab);
@@ -0,0 +1,67 @@
# 시설 상세 패널 레이아웃 수정 검증
## 목적 및 원본 근거
- 검증일: 2026-09-04
- 대상: 하수관망도 우측 시설 상세 패널에서 기본정보·입력정보·유지관리 이력 표가 겹치는 현상
- 화면설계서: 36번 슬라이드 `SN_MN010303 / SFR-012-001`, 48번 슬라이드 `SN_MN040107 / SFR-010-004`, 10번 슬라이드 `SN_MN010103`
- 요구사항정의서: `3. (기능)요구사항정의 및 추적표` 96행 `SFR-012-001`, 92행 `SFR-011-006`
- 기존 시설 속성과 추가 현황정보 표시 기능을 유지하는 레이아웃 수정이다. 데이터·API·저장 계약은 변경하지 않는다.
## 원인 및 수정
- 본문의 `display:flex; flex-direction:column` 때문에 내용이 긴 카드가 가용 높이 안으로 압축됐다. 카드 안의 실제 table은 원래 높이를 유지해 다음 카드 위로 넘쳤다.
- 실제 관로 `61582`에서 기본정보 카드 높이는 약 204px인데 표 높이는 약 853px, 입력정보 카드 높이는 약 86px인데 표 높이는 약 362px였다.
- `featureClickEventPopup.tsx` 본문을 일반 블록 흐름으로 복구하고 `min-h-0 min-w-0 flex-1`로 단일 본문 스크롤 영역을 유지했다.
- 상단 헤더는 `shrink-0`으로 고정하고 음수 좌우 마진을 제거했다.
- 패널은 지도 부모 높이를 사용하되 공통 상단 헤더(6rem) 아래 가용 화면 높이를 넘지 않도록 제한했다.
- 침수흔적 심선·위선 선택부, 표 컴포넌트, 입력 폼, 저장 및 닫기 콜백은 변경하지 않았다.
## 실제 브라우저 검증
`localhost:3002/map → 정보검색 → 시설 구분 조회 → 목록의 시설번호 선택` 흐름으로 실행했다.
| 시나리오 | 확인 결과 |
| --- | --- |
| 관로 61582 상세 | 기본정보 → 입력정보 → 보수 및 갱신 → 청소 및 준설 → 점검 및 진단 순서 정상 |
| 관로, 1920×900, 상단·중간·하단 스크롤 | 카드 밖 표 넘침 0건, 인접 영역 겹침 0건, 패널 하단 900px |
| 관로, 1366×600, 상단·중간·하단 스크롤 | 카드 밖 표 넘침 0건, 인접 영역 겹침 0건, 패널 하단 600px |
| 관로 입력값 임시 변경·스크롤 | 입력값 유지 확인 후 원래 값으로 복원; 서버 저장 미실행 |
| 관로 X 닫기·다시 선택 | 닫힘 및 재진입 정상 |
| 맨홀 1101909890 상세 | 기본정보·입력정보 표 넘침 0건 |
| 하수처리구역 41830PB009R 상세 | 표 넘침 및 인접 영역 겹침 0건 |
| 침수흔적위선 관리번호 1 상세 | 기본정보 겹침 0건. 해당 데이터에는 연결 심선/위선 선택 버튼이 없어 버튼 클릭은 검증하지 않음 |
| 지도 브라우저 콘솔 | 검증 중 error 0건 |
화면 크기 변경 시 기존 300ms transition이 끝난 뒤 최종 배치를 측정했다.
캡처:
- `D:/workspace/IIPBS/docs/작업내용/SFR-012-001-시설상세레이아웃/검증/after-pipe-input-history.png`
- `D:/workspace/IIPBS/docs/작업내용/SFR-012-001-시설상세레이아웃/검증/after-pipe-1366x600-bottom.png`
## 정적 검사 및 제한
- `git diff --check`: 통과
- SN 타입 검사: 실패. 기존 HEAD의 `DredgingRegisterModal.tsx:792`에 `statusLabel` 미정의 오류(TS2304)가 존재한다. 이번 수정 파일의 타입 오류는 보고되지 않았다.
- 실행 명령: `tsc -p apps/sn/tsconfig.json --noEmit --ignoreDeprecations 5.0`
- 사용자 개발 서버를 보호하기 위해 Next build 및 서버 종료/재시작은 실행하지 않았다.
- 저장·삭제·업로드 등 DB 변경 검증은 수행하지 않았다.
## 영향 및 롤백
- 변경 코드: `apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx` 한 파일
- 같은 상세 패널을 사용하는 SN 시설 유형에 적용된다. WR·FO·DM 및 공용 패키지는 수정하지 않는다.
- 롤백은 해당 파일의 패널/헤더/본문 레이아웃 클래스와 추가 주석 변경만 되돌리면 된다.
## 추가 검증: 헤더 기능 버튼과 X 겹침
- 하수관로에만 우측 여백을 주고 X는 절대 위치에 놓았던 구조라 맨홀의 `상류관로 보기`와 X가 겹쳤다.
- 헤더를 제목 / 기능 버튼 / 닫기 버튼의 3열 grid로 변경했다. X의 절대 위치와 관로 전용 임의 여백을 제거하고 긴 제목은 자신의 칸 안에서 줄바꿈하도록 했다.
- 맨홀 `1101909890`, 관로 `1102086715`, 하수처리구역 `41830PB009R`을 실제 정보검색에서 선택하여 1920×900 및 1366×600에서 검증했다.
- 6개 조합 모두 영역 겹침 0건, 헤더 외부 버튼 넘침 0건. 각 버튼 중앙 hit test도 해당 버튼을 가리켰다.
- 맨홀 상류관로 버튼과 X, 관로 RDII 버튼과 X 간격은 모두 5px이다.
- `상류관로 보기` 실제 클릭 시 `GET /api/v1/fcltmhl/upperPpls/1101909890` 응답 200. 해당 표본에서는 찾을 수 없다는 기존 공통 메시지가 표시됐다. 메시지 확인 후 X로 상세 패널을 닫았다.
- 헤더 수정은 클릭/저장 콜백을 변경하지 않는다. 앞서 수정한 본문 스크롤 구조도 유지한다.
- 추가 캡처: 같은 검증 폴더의 `after-manhole-header.png`, `after-pipe-header.png`.
- 타입 검사는 동일한 기존 `DredgingRegisterModal.tsx:792 statusLabel` 오류만 보고됐다. `git diff --check`는 통과했다.
@@ -372,6 +372,47 @@ Chrome 확장의 파일 URL 접근 권한이 꺼져 있어 이번 회귀에서 E
- 검증 화면: `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/20260828/03-수정화면-촬영일자-날짜만.png`.
- 사용자 실행 중인 3002 개발서버를 보호하기 위해 Next build는 수행하지 않았다.
## 16. 2026-09-04 사진 영역 높이 고정 검증
- 실제 로컬 등록·수정 팝업 DOM에서 합성 가로/세로/정사각형 사진으로 검증했다. 수정 사진 조회 응답은 테스트용 ID에만 가로챈 응답을 사용했고, 신규 파일은 로컬 선택만 했다. 저장·업로드·DB 변경은 수행하지 않았다.
- 수정 전 1440×900에서 이미지 전환 시 미리보기 높이는 220 → 1872 → 475.5px, 팝업 스크롤 높이는 883 → 2535 → 1139px로 변했다.
| 검증 항목 | 결과 |
| --- | --- |
| 기존 사진 가로·세로·정사각 전환 | 같은 창 크기에서 미리보기 높이·위치 동일 |
| 1920×1080 수정 팝업 | 미리보기 360px 유지, 전체 스크롤 초과 0px |
| 1440×900 수정 팝업 | 미리보기 180px 유지, 전체 스크롤 초과 0px |
| 1366×768 수정 팝업 | 미리보기 최소 180px 유지, 좌측 정보만 스크롤, 전체 스크롤 초과 0px |
| 기존 5장 삭제 후 0장·신규 세로/가로 사진 선택 | 미리보기 높이 유지 |
| 빈 사진 목록·조회 실패 응답 | 예약된 사진 목록·미리보기 높이 유지 |
| 등록 사진 0~5장·등록 건 1/2 전환 | 사진 카드 280px 유지, 지도·팝업 전체 스크롤 초과 0px |
| 긴 사진 목록 | 내부 스크롤로 5번째 사진까지 접근 확인 |
| TypeScript | `tsc --noEmit --incremental false --ignoreDeprecations 5.0` 종료코드 0 |
| Prettier | 변경 두 TSX 파일 포맷 적용 |
- 증빙: `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/20260904/01-photo-preview-fixed.png`, `02-registration-photo-list-fixed.png`.
- 사용자 실행 3002 서버를 종료·재시작하지 않았고 Next build는 생략했다. 낮은 창 높이에서 좌측 정보 스크롤은 정보 잘림을 방지하기 위해 유지한다.
## 17. 2026-09-04 사진 다운로드 검증
| 검증 항목 | 결과 |
| --- | --- |
| 실제 등록 사진 다운로드 | 목록에서 관로 4115732847 선택 후 수정 팝업에서 `test2.jpg` 다운로드 성공 |
| 원본 보존 | 실제 파일 2,219,776바이트. 미리보기용 공통 첨부 원본과 다운로드 파일 SHA-256 일치 |
| 선택 사진 전환 | 합성 파일 1·2를 선택해 각각 해당 파일명으로 다운로드 |
| 한글·공백·확장자 | `준설 사진_원본1.png`, `준설 사진_원본2.png` 유지 |
| 합성 원본 바이트 | 가로·세로 테스트 PNG와 다운로드 파일 SHA-256 일치 |
| 진행 상태 | 다운로드 처리 중 비활성화 및 완료 후 재활성화 |
| 미선택 | 선택한 사진을 화면에서 제거하면 다운로드 버튼 비활성화 |
| 실패·재시도 | 합성 HTTP 500에서 사용자용 안내 표시, HTML 오류 본문 미노출, 재시도 성공 |
| 신규 사진 | 아직 저장하지 않은 `tall.png` 원본 다운로드, 첨부서비스 추가 호출 0건, 원본 해시 일치 |
| 고정 레이아웃 | 1440×900에서 다운로드 전후 미리보기 높이 180px 유지 |
| TypeScript | SN 전체 `tsc --noEmit --incremental false --ignoreDeprecations 5.0` 종료코드 0 |
- 합성 사진 조회·실패 응답은 테스트 ID에만 적용했다. 실제 등록 사진 다운로드는 대체 응답 없이 기존 서버로 검증했다.
- 준설 저장·삭제는 수행하지 않았고 검사에 사용한 다운로드 사본은 정리했다.
- 화면 증빙: `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/20260904/03-photo-download.png` (합성 사진).
- 사용자 실행 3002 서버는 유지했으며 Next build는 실행하지 않았다.
## 16. 2026-09-01 준설 공통코드 적용 빠른 검증
- 실제 CMS 캐시 API 확인
@@ -1,5 +1,10 @@
# SFR-011-001 시도별 노후관로현황 FE 작업카드
## 유지보수 반영 (2026-09-04)
- 목록의 연도별 결과 컬럼을 연도 그룹 헤더로 묶어 표시한다.
- 엑셀 다운로드는 조회 결과를 프론트엔드에서 XLSX로 생성하며, 목록과 동일한 연도 그룹 헤더와 하위 컬럼을 사용한다.
## 1. 작업 목적
- 하수관망도 우측 분석도구에서 `시도별 노후관로현황` 팝업을 열고 기준년도 구간별 시도 집계를 조회한다.
@@ -7,7 +12,7 @@
- `AnalysisPopupMenu`에 진입 버튼 추가
- `windowPopup` registry에 새 popup key 추가
- 전용 popup page 구현
- 기준년도 시작/종료, 경과년도 기본값 30년, CSV 다운로드 연결
- 기준년도 시작/종료, 경과년도 기본값 30년, FE XLSX 다운로드 연결
## 3. 구현 기준
- 기존 `노후관로 현황` popup의 레이아웃 톤과 다운로드 UX를 최대한 재사용한다.
@@ -16,4 +21,4 @@
## 4. 연동 API
- `GET /api/v1/swagePpl/aged-status-by-ctpv`
- `POST /api/v1/swagePpl/aged-status-by-ctpv/exportJobs/download`
- 별도 백엔드 Excel API를 호출하지 않고 조회 응답으로 FE에서 XLSX 생성
@@ -0,0 +1,79 @@
# FE-MAP 레이어 패널 헤더 구현 기록
## 작업 목적
- 사용자 제공 기준 이미지에 맞춰 지도 좌측 패널을 `레이어 관리`와 `선택 레이어 설정` 두 영역으로 구분한다.
- 레이어 트리, 체크 상태, 선택 레이어별 설정 내용과 동작은 변경하지 않는다.
## 적용 기준
- 지도형 화면은 기존 `Gis` 조합을 유지한다.
- 사용자 지정 스타일은 패널 외곽 여백 `4px`, 영역 사이 간격 `6px`, 패널 배경 `#E5E5E5`이다.
- 두 영역은 흰색 카드와 제목 헤더로 구분한다.
## 수정 대상
- `apps/sn/app/(map-layout)/map/page.tsx`
- `packages/common/business_components/src/ui/map/Gis.js`
- `packages/common/business_components/src/ui/map/GisTree.js`
## 변경 내용
- 공통 GIS 패널에 기본값이 비활성인 섹션형 패널 옵션을 추가했다.
- `sn` 관망도에서만 옵션을 활성화해 다른 앱의 기존 GIS 패널에는 영향을 주지 않는다.
- `레이어 관리`와 `선택 레이어 설정`에 구분 아이콘과 고정 높이 헤더를 적용했다.
- 두 카드 모두 헤더와 스크롤 본문을 형제 영역으로 분리해 헤더 아래에서만 콘텐츠가 스크롤되도록 통일했다.
- 두 스크롤바는 카드 본문 오른쪽 끝에 배치하고 동일한 `4px` 두께와 안정적인 스크롤 여백을 적용했다.
- 레이어 트리의 목록 액션은 아이콘 전용 표시를 해제하고 기존 `목록보기` 텍스트 버튼으로 복원했다.
- 선택 레이어 설정 카드 제목 왼쪽에는 `#7CCE5C` 다이아몬드 표식을 공통 적용했다.
- 설정 카드의 필드 제목과 입력 컨트롤은 위아래 배치로 정리했다.
- 카드 제목의 다이아몬드는 `7px × 7px`, 카드 제목은 `13px`, 입력 텍스트는 `12px`로 조정했다.
- 적용용 원형 체크 버튼은 입력과 동일한 `20px` 높이의 `확인` 텍스트 버튼으로 변경하고, 입력과 함께 카드 본문 너비를 채우도록 배치했다.
- `sn` 레이어 관리 행의 마우스 호버 배경은 `#E8F0FD`로 적용했다.
- `확인` 버튼은 배경 `#EDF4FC`, 글자 `#2463A6`, `1px` `#A9C5E5` 테두리로 통일했다.
- 설정 카드 제목은 `12px`, 본문은 `8px` 패딩과 `6px` 간격으로 조정했다.
- 침수심 범례 항목 텍스트는 `11px`로 조정했다.
- 관망도 전용 패널 접기·열기 버튼은 너비를 `20px`로 유지하면서 아이콘을 `32px`로 확대하고 아이콘 색상을 `#666666`으로 적용했다.
- 패널에 맞닿는 버튼의 좌측 위·아래 모서리는 각지게 하고 우측 모서리만 `4px` 둥글게 처리했다.
## 지도 검색바 퍼블리싱
- 실제 관망도에서만 활성화되는 `integrated` 옵션으로 기존 검색 상태와 조회 동작을 유지했다.
- 검색바는 `656px × 44px`, 셀렉트 3개는 각각 `192px`, 조회 버튼은 `80px`로 구성했다.
- 셀렉트 사이 간격과 래퍼 패딩을 제거하고 첫 셀렉트 왼쪽과 조회 버튼 오른쪽에만 `8px` 모서리를 적용했다.
- 첫 번째와 두 번째 셀렉트 오른쪽 내부에 `24px` 높이의 `#D5DCE5` 구분선을 배치했다.
- 셀렉트 글자·화살표·말줄임과 조회 버튼 타이포그래피를 요청 사양에 맞추고 Radix Portal 드롭다운 구조는 유지했다.
- 선택값은 기본 `line-clamp` 표시 영향을 제거하고 `20px` 행간의 한 줄 말줄임으로 고정해 글자 세로 잘림을 방지했다.
- 셀렉트 포커스·열림 라인은 `#2094FA`로 적용하고, 포커스된 영역 옆 구분선은 숨겨 라인이 겹치지 않도록 했다.
- 전체 래퍼의 `8px` 모서리로 셀렉트와 조회 버튼을 함께 클리핑해 우측 라운드를 정확히 맞추고 조회 버튼 배경을 `#2094FA`로 적용했다.
- 셀렉트 포커스·열림 라인은 `2px`로 조정하고, 첫 번째 시도 셀렉트는 왼쪽 위·아래에 검색바와 동일한 `8px` 모서리를 적용했다.
- 세 셀렉트의 아래 방향 화살표는 모두 `32px × 32px`로 확대했다.
- GIS 편집 세션, 검색바, 분석도구의 상단 기준을 `8px`로 통일했다.
- 검색바는 편집 세션 노출 여부와 관계없이 오른쪽 `12px` 위치에 고정해 GIS 편집을 켜고 끌 때 움직이지 않도록 했다.
- 분리형 레이어 패널 토글은 상태별 버튼을 교체하지 않고 하나의 버튼을 유지하며, 패널과 같은 `300ms` 동안 `left` 위치를 이동해 패널 가장자리에 붙은 채 함께 열리고 닫히도록 했다.
- 관망도 섹션형 패널은 열고 닫을 때 투명도 변화를 제거하고 불투명 상태에서 좌우 이동만 적용했다.
- 패널과 토글 버튼 모두 동일한 `transform`, `300ms`, `ease-in-out` 방식으로 정확히 `250px` 이동시켜 애니메이션 중 버튼이 패널에서 떨어지지 않도록 맞췄다.
- 관망도 베이스맵 선택은 숨겨진 `sprite_gis viewer` 메뉴 대신 `일반지도`와 `위성지도`를 처음부터 보여주는 탭으로 변경하고 기존 레이어 전환 함수는 유지했다.
- 분석도구는 기존 지도 버튼과 같은 외형의 아이콘 버튼으로 축소하고, 클릭 시 버튼 아래 `8px` 간격으로 우측 정렬된 기존 메뉴가 열리도록 변경했다.
- 지도 위젯은 지도 가장자리에서 `16px` 떨어뜨리고, 우측 상단에 도구 모음 → 분석도구 → 지도 선택 탭 순서로 `12px` 간격을 두어 상단 정렬했다.
- 우측 상단 지도 선택·분석도구·도구 모음 버튼의 높이는 모두 `40px`로 통일했다.
- 조회 버튼과 지도 유형 활성 탭의 배경은 `#0279FA`로 통일하고, 지도 유형 탭 버튼은 외곽 안쪽에서 `4px` 떨어지도록 배치했다.
- 지도 위 검색바, 지도 선택, 분석도구, 도구 모음, 줌·축척 및 편집 위젯의 외곽 보더를 제거하고 Google Maps 계열의 2중 그림자로 통일했다.
- 분석도구 실행 버튼과 우측 확대·축소·거리측정·면적측정·리셋 아이콘은 버튼 크기를 유지하면서 `20px × 20px` 벡터 아이콘으로 통일했다.
- 분석도구 메뉴가 열린 동안 아이콘 버튼은 `#0279FA` 배경과 흰색 아이콘으로 표시한다.
- 분석도구 메뉴는 화면 진입 시 기본으로 펼치고, 상위 배치 영역은 클릭을 통과시켜 메뉴가 열린 상태에서도 일반지도·위성지도 탭을 조작할 수 있게 했다.
- 선택 레이어 설정의 드롭다운·입력·확인 버튼 높이는 `26px`로 통일하고 수직 패딩을 제거해 가운데 정렬했다.
- 지반침하정보·노후관로 등 공통 설정 카드 제목의 왼쪽 패딩과 본문 전체 패딩은 `0.8rem`으로 조정했다.
- 분석도구 하위 메뉴는 첨부 기준처럼 `15px` 아이콘과 `12px` 텍스트가 한 줄에 놓이는 세로 목록으로 복원하고, 긴 항목에 맞춰 너비가 자동 확장되도록 했다.
- 분석도구 제목은 흰색 콘텐츠 영역 안에 배치하고 하단 구분선과 접기 버튼을 유지했다.
- 분석도구 실행 버튼과 하위 메뉴에는 키보드 탐색 시에만 나타나는 `2px` `#0279FA` `focus-visible` 링을 적용했다.
- 검색바는 복잡한 지도 배경에서도 경계가 분명하도록 `0 2px 4px rgba(60, 64, 67, 0.3), 0 4px 10px rgba(60, 64, 67, 0.18)` 그림자로 강화했다.
- 줌 게이지와 줌레벨·축척 요약 박스는 지도 하단 `16px` 기준으로 아래맞춤 정렬했다.
- 기존 트리 행과 선택 레이어 설정 카드 본문은 유지했다.
## 검증 기준
- 두 카드의 외곽 여백이 상하좌우 `4px`인지 확인한다.
- 두 카드 사이 간격이 `6px`인지 확인한다.
- 패널 바깥 배경이 `#E5E5E5`인지 확인한다.
- 레이어 체크, 펼침/접힘, 설정 카드 입력과 스크롤이 기존과 동일하게 동작하는지 확인한다.
@@ -354,6 +354,27 @@
- EXIF 원본 `capturedAt`, 첨부정보와 백엔드 저장 payload는 변경하지 않고 화면 렌더링만 변경했다.
- 추적 근거는 요구사항 `SFR-010-012`, 화면설계서 슬라이드 53의 `SN_MN040201`이며 세부 시간 숨김은 사용자 지시에 따른 표출 보완이다.
## 22. 2026-09-04 사진 영역 높이 고정
- 수정 팝업의 높이가 제한되지 않은 grid/flex와 일반 흐름의 이미지 때문에 원본 이미지 비율이 사진·지도 영역 높이를 바꾸는 문제를 확인했다.
- 팝업 본문과 좌우 영역을 창 높이에 맞추고 사진은 고정된 미리보기 안에서 `object-contain`으로 비율을 유지한다. 이미지 고유 크기는 부모 크기 계산에 참여하지 않는다.
- 기존·신규 사진 목록은 12rem 높이를 예약하고 목록 내부만 스크롤한다. 로딩·빈 목록·오류 상태도 같은 공간을 사용한다.
- 등록 화면 사진 카드는 28rem 높이로 고정한다. 사진 0~5장, 등록 건 전환, 수동 등록에서도 카드 높이를 유지한다.
- 작은 창에서는 좌측 정보만 내부 스크롤하며 지도와 팝업 전체 높이는 유지한다. 수정 상태 안내 높이도 고정해 마지막 사진 삭제 안내가 미리보기를 밀지 않게 했다.
- 변경 범위는 `DredgingDetailPopupPage.tsx`, `DredgingRegisterModal.tsx`이며 공통 패키지·API·DB·업로드와 저장 처리에는 변경이 없다.
- 추적 근거: 요구사항정의서 sheet3 G84:I85의 `SFR-010-012/013`, 화면설계서 53~55번 슬라이드의 `SN_MN040201/040202`. 사진 높이 고정은 사용자 지시에 따른 UI 보완이다.
- 롤백은 위 두 화면의 레이아웃 클래스 변경을 되돌리는 것으로 가능하며 데이터 이관은 필요하지 않다.
## 23. 2026-09-04 선택 사진 원본 다운로드
- 수정 화면의 사진 미리보기 우측 상단에 `사진 다운로드` 버튼을 추가했다. 기존 고정 사진 영역 위에 겹쳐 배치하므로 영역 높이는 변하지 않는다.
- 저장된 사진은 기존 `fetchDredgingPhotoBlob`을 통해 공통 첨부서비스 `/attach-service/api/v1/download/{atflId}` 원본을 받아 SN `fileDownload` 유틸리티로 저장한다. 서버 원본파일명과 확장자를 유지한다.
- 저장 전 신규 사진은 로컬 `File`을 그대로 내려받으며 공통 첨부 업로드나 준설 저장을 호출하지 않는다.
- 미선택·로딩·저장·다운로드 진행 중에는 버튼을 비활성화한다. 다운로드 중 선택을 바꾸어도 클릭 당시 사진과 파일명을 함께 유지한다.
- 실패 시 공통 안내창에 사용자용 메시지만 표시하며 재시도가 가능하다. 서버 오류 본문은 화면에 출력하지 않는다.
- 변경 범위는 `DredgingDetailPopupPage.tsx`와 기능 문서다. 공통 컴포넌트·유틸리티, 백엔드 API, DB 스키마는 변경하지 않았다.
- 근거: 원본 `SFR-010-012/013`, 화면 `SN_MN040201`의 사진 확인 흐름에 사용자가 요청한 다운로드 동작을 추가했다.
- 롤백은 선택 사진 다운로드 핸들러·진행 상태·버튼만 제거하면 되며 앞선 사진 영역 고정 변경은 유지할 수 있다.
## 22. 2026-09-01 준설 공통코드 표시명 적용
- `DRDGNREGMODE`, `DRDGNLOCSRC`, `DRDGNPHOTOSTTS`를 기존 CMS 캐시 API로 조회하고 브라우저 세션에서 그룹별 응답과 진행 중 요청을 재사용한다.
@@ -717,17 +717,19 @@ export function CtyFldngPrvtnBizListPage() {
/>
</div>
<Button type="submit" className="w-[7.4rem]">
조회
</Button>
<Button
type="button"
color="white"
className="w-[8.8rem]"
onClick={handleReset}
>
초기화
</Button>
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
<Button type="submit" className="w-[7.4rem]">
조회
</Button>
<Button
type="button"
color="white"
className="w-[8.8rem]"
onClick={handleReset}
>
초기화
</Button>
</div>
</form>
</div>
{notice && (
@@ -755,6 +757,12 @@ export function CtyFldngPrvtnBizListPage() {
onRowClick={handleOpenDetail}
actions={
<>
<Button type="button" onClick={handleRegister}>
등록
</Button>
<Button type="button" onClick={() => fileRef.current?.click()}>
업로드
</Button>
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
@@ -764,12 +772,6 @@ export function CtyFldngPrvtnBizListPage() {
>
양식다운로드
</Button>
<Button type="button" onClick={handleRegister}>
등록
</Button>
<Button type="button" onClick={() => fileRef.current?.click()}>
업로드
</Button>
</>
}
/>
@@ -240,7 +240,7 @@ export default function FldngPredcPrstPopupContent() {
const sggOptions = useMemo(
() =>
filters.ctpvCd === "0"
? sgg
? []
: sgg.filter((item) =>
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
),
@@ -40,12 +40,15 @@ export const buildObsltPplRepresentativeSggOptions = (
sggOptions: RegionOption[],
ctpvCd: string,
) => {
// 시도가 전체(0)이면 시군구를 선택할 수 없도록 목록을 비운다.
if (ctpvCd === "0") return [];
const representativeMap = new Map<string, RegionOption>();
sggOptions
.filter((option) => {
const optionCode = String(option.val ?? "").trim();
return ctpvCd === "0" || optionCode.startsWith(ctpvCd.slice(0, 2));
return optionCode.startsWith(ctpvCd.slice(0, 2));
})
.forEach((option) => {
const representativeName = normalizeObsltPplSigunName(option.text);
@@ -619,17 +619,19 @@ export function ObsltPplListPage() {
/>
</div>
<Button type="submit" className="w-[7.4rem]">
조회
</Button>
<Button
type="button"
color="white"
className="w-[8.8rem]"
onClick={handleReset}
>
초기화
</Button>
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
<Button type="submit" className="w-[7.4rem]">
조회
</Button>
<Button
type="button"
color="white"
className="w-[8.8rem]"
onClick={handleReset}
>
초기화
</Button>
</div>
</form>
</div>
@@ -652,15 +654,12 @@ export function ObsltPplListPage() {
onRowClick={handleOpenDetail}
actions={
<>
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
<Button type="button" onClick={handleOpenCfmtnBgt}>
최종확정예산사업
</Button>
<Button type="button" onClick={handleRegister}>
등록
</Button>
<Button type="button" onClick={handleOpenCfmtnBgt}>
최종확정예산사업
</Button>
<input
ref={fileInputRef}
type="file"
@@ -674,6 +673,9 @@ export function ObsltPplListPage() {
>
업로드
</Button>
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
</>
}
/>
@@ -191,7 +191,7 @@ export const windowPopupDefinitions = {
agedPipeStatusByCtpvPopup: {
key: "agedPipeStatusByCtpvPopup",
title: "시도별 노후관로현황",
width: 1160,
width: 1400,
height: 820,
},
fldngHstryPopup: {
+1
View File
@@ -26,6 +26,7 @@ declare module "lucide-react" {
export const Circle: LucideIcon;
export const CloudUpload: LucideIcon;
export const History: LucideIcon;
export const ListTree: LucideIcon;
export const Move: LucideIcon;
export const MoreHorizontal: LucideIcon;
export const PanelsTopLeft: LucideIcon;
@@ -57,6 +57,10 @@ function Gis({
// 외부 업무 흐름에서 특정 시설물 레이어를 기존 체크 상태에 추가하도록 요청한다.
checkedLayerRequest = null,
searchBar: SearchBar,
// 화면별 상단 정렬과 인접 패널 회피를 위한 검색바 위치 전용 클래스다.
searchBarContainerClassName = "",
// sn 관망도처럼 베이스맵 선택지를 항상 노출하는 화면에서만 탭 형태를 사용한다.
alwaysVisibleMapTabs = false,
waterDepth: WaterDepth = null,
layerSettingView: LayerSettingView = null,
inlineLayerSettingView,
@@ -65,6 +69,8 @@ function Gis({
layerLegendIconVisible,
// 목록·액션 아이콘이 지도 레이어 체크를 선행 조건으로 요구하는지 여부다.
layerIconRequiresChecked = true,
// 목록 액션의 기능은 유지하면서 텍스트를 목록 아이콘으로 표시할지 여부다.
layerActionIconOnly = false,
isPlaying = false,
setIsPlaying = () => { },
targetLayerTypes = [],
@@ -79,11 +85,14 @@ function Gis({
viewExtent,
viewMinZoom,
showZoomLevel = false,
toolBarIconSize,
zoomGuide,
showFloatingSearchButton = true,
treeTogglePosition = "center",
detachedTreeToggleButton = false,
showLayerPanelTitle = true,
// sn 관망도처럼 트리와 선택 설정을 카드 두 개로 구분해야 하는 화면에서만 활성화한다.
sectionedTreePanel = false,
showZoomScaleInfo = false,
}) {
const mapRef = useRef();
@@ -322,6 +331,7 @@ function Gis({
layerIconVisible={layerIconVisible}
layerLegendIconVisible={layerLegendIconVisible}
layerIconRequiresChecked={layerIconRequiresChecked}
layerActionIconOnly={layerActionIconOnly}
sinkholeLegendIconVisible={sinkholeLegendIconVisible}
isPlaying={isPlaying}
setIsPlaying={setIsPlaying}
@@ -334,6 +344,7 @@ function Gis({
toggleButtonPosition={treeTogglePosition}
detachedToggleButton={detachedTreeToggleButton}
showLayerPanelTitle={showLayerPanelTitle}
sectionedPanel={sectionedTreePanel}
/>
{typeof WaterDepth === "function" && (
@@ -363,15 +374,15 @@ function Gis({
)}
{/* 시도/시군구 조회 영역 */}
{SearchBar && (
<div className="absolute left-1/2 top-4 z-30 w-max -translate-x-1/2 rounded">
<div className={`absolute left-1/2 top-4 z-30 w-max -translate-x-1/2 rounded ${searchBarContainerClassName}`}>
{SearchBar}
</div>
)}
<GisToolBar />
<GisToolBar iconSize={toolBarIconSize} />
{showZoomLevel && (
<GisZoomLevel zoomGuide={zoomGuide} showZoomScaleInfo={showZoomScaleInfo} />
)}
<GisMapToggle />
<GisMapToggle alwaysVisibleTabs={alwaysVisibleMapTabs} />
<GisSelect
onFeatureSelect={handleFeatureSelect}
onMapBlankClick={onMapBlankClick}
@@ -1,30 +1,67 @@
import React, { useContext, useState } from 'react';
import { GisContext } from './Gis';
import React, { useContext, useState } from "react";
import { GisContext } from "./Gis";
export default React.memo(function MapToggleBtn() {
const { gisCore } = useContext(GisContext);
const [activeMap, setActiveMap] = useState({ label: '일반', type: 'base' }); // 기본 선택값
export default React.memo(function MapToggleBtn({ alwaysVisibleTabs = false }) {
const { gisCore } = useContext(GisContext);
const [activeMap, setActiveMap] = useState({ label: "일반", type: "base" }); // 기본 선택값
const handleSelect = mapObj => {
setActiveMap(mapObj);
gisCore.changeBaseLayer(mapObj.type);
};
const handleSelect = (mapObj) => {
setActiveMap(mapObj);
gisCore.changeBaseLayer(mapObj.type);
};
return (
<div className="mapbtn">
<button className="sprite_gis viewer" title="지도선택">지도 선택</button>
<ul className="choice_map">
<li className={activeMap.type === 'base' ? 'on' : ''}>
<button className="map1" id="base" title="일반지도" onClick={() => handleSelect({ label: '일반', type: 'base' })}>
<div>일반지도</div>
</button>
</li>
<li className={activeMap.type === 'satellite' ? 'on' : ''}>
<button className="map2" id="satelite" title="위성지도" onClick={() => handleSelect({ label: '위성', type: 'satellite' })}>
<div>위성지도</div>
</button>
</li>
</ul>
</div>
);
if (alwaysVisibleTabs) {
return (
<div className="mapbtn mapbtn-tabs">
<div className="map-type-tabs" role="group" aria-label="지도 유형 선택">
<button
type="button"
aria-pressed={activeMap.type === "base"}
className={activeMap.type === "base" ? "on" : ""}
onClick={() => handleSelect({ label: "일반", type: "base" })}
>
일반지도
</button>
<button
type="button"
aria-pressed={activeMap.type === "satellite"}
className={activeMap.type === "satellite" ? "on" : ""}
onClick={() => handleSelect({ label: "위성", type: "satellite" })}
>
위성지도
</button>
</div>
</div>
);
}
return (
<div className="mapbtn">
<button className="sprite_gis viewer" title="지도선택">
지도 선택
</button>
<ul className="choice_map">
<li className={activeMap.type === "base" ? "on" : ""}>
<button
className="map1"
id="base"
title="일반지도"
onClick={() => handleSelect({ label: "일반", type: "base" })}
>
<div>일반지도</div>
</button>
</li>
<li className={activeMap.type === "satellite" ? "on" : ""}>
<button
className="map2"
id="satelite"
title="위성지도"
onClick={() => handleSelect({ label: "위성", type: "satellite" })}
>
<div>위성지도</div>
</button>
</li>
</ul>
</div>
);
});
@@ -1,71 +1,102 @@
import React from 'react';
import GisApp from './lib/gis.core';
import GisOlMeasure from './lib/gis.measure';
import { useContext } from 'react';
import { useGisContext } from './Gis';
export default React.memo(function GisToolBar() {
const { gisMap } = useGisContext();
if (!gisMap) return <></>;
const gisMeasuer = new GisOlMeasure(gisMap);
GisApp.Module.measure = gisMeasuer;
const zoomIn = () => {
if (gisMap.getView().getMaxZoom() > gisMap.getView().getZoom()) {
gisMap.getView().animate({
zoom: gisMap.getView().getZoom() + 1,
duration: 250,
});
}
};
const zoomOut = () => {
if (gisMap.getView().getMaxZoom() > gisMap.getView().getZoom()) {
gisMap.getView().animate({
zoom: gisMap.getView().getZoom() - 1,
duration: 250,
});
}
};
import React from "react";
import { RotateCcw, Ruler, Scan, ZoomIn, ZoomOut } from "lucide-react";
import GisApp from "./lib/gis.core";
import GisOlMeasure from "./lib/gis.measure";
import { useContext } from "react";
import { useGisContext } from "./Gis";
export default React.memo(function GisToolBar({ iconSize }) {
const { gisMap } = useGisContext();
const useVectorIcons = Number.isFinite(iconSize) && iconSize > 0;
if (!gisMap) return <></>;
const gisMeasuer = new GisOlMeasure(gisMap);
GisApp.Module.measure = gisMeasuer;
const zoomIn = () => {
if (gisMap.getView().getMaxZoom() > gisMap.getView().getZoom()) {
gisMap.getView().animate({
zoom: gisMap.getView().getZoom() + 1,
duration: 250,
});
}
};
const zoomOut = () => {
if (gisMap.getView().getMaxZoom() > gisMap.getView().getZoom()) {
gisMap.getView().animate({
zoom: gisMap.getView().getZoom() - 1,
duration: 250,
});
}
};
const measure = type => {
gisMeasuer.addInteraction(type);
};
const measure = (type) => {
gisMeasuer.addInteraction(type);
};
const reset = () => {
gisMeasuer.removeFeature();
};
return (
<>
<div className="custom-toolbar">
<div className="tool-bar">
<h3>toolbar 영역</h3>
<ul>
<li>
<div className="btn_control_zoomin" title="확대" onClick={zoomIn}>
<span>확대</span>
</div>
</li>
<li>
<div className="btn_control_zoomout" title="축소" onClick={zoomOut}>
<span>축소</span>
</div>
</li>
<li>
<div className="btn_control_ruler btn_control_measure" title="거리 측정" onClick={() => measure(GisApp.measure.LineString)}>
<span>거리측정</span>
</div>
</li>
<li>
<div className="btn_control_area btn_control_measure" title="면적 측정" onClick={() => measure(GisApp.measure.Polygon)}>
<span>면적측정</span>
</div>
</li>
<li>
<div className="sprite_gis icon4 btn_select_cancel" title="리셋" onClick={() => reset()}>
<span>리셋</span>
</div>
</li>
</ul>
</div>
</div>
</>
);
const reset = () => {
gisMeasuer.removeFeature();
};
return (
<>
<div className="custom-toolbar">
<div
className={`tool-bar ${useVectorIcons ? "tool-bar-vector-icons" : ""}`}
>
<h3>toolbar 영역</h3>
<ul>
<li>
<div className="btn_control_zoomin" title="확대" onClick={zoomIn}>
{useVectorIcons && (
<ZoomIn size={iconSize} aria-hidden="true" />
)}
<span>확대</span>
</div>
</li>
<li>
<div
className="btn_control_zoomout"
title="축소"
onClick={zoomOut}
>
{useVectorIcons && (
<ZoomOut size={iconSize} aria-hidden="true" />
)}
<span>축소</span>
</div>
</li>
<li>
<div
className="btn_control_ruler btn_control_measure"
title="거리 측정"
onClick={() => measure(GisApp.measure.LineString)}
>
{useVectorIcons && <Ruler size={iconSize} aria-hidden="true" />}
<span>거리측정</span>
</div>
</li>
<li>
<div
className="btn_control_area btn_control_measure"
title="면적 측정"
onClick={() => measure(GisApp.measure.Polygon)}
>
{useVectorIcons && <Scan size={iconSize} aria-hidden="true" />}
<span>면적측정</span>
</div>
</li>
<li>
<div
className="sprite_gis icon4 btn_select_cancel"
title="리셋"
onClick={() => reset()}
>
{useVectorIcons && (
<RotateCcw size={iconSize} aria-hidden="true" />
)}
<span>리셋</span>
</div>
</li>
</ul>
</div>
</div>
</>
);
});

Some files were not shown because too many files have changed in this diff Show More