Compare commits

...
Author SHA1 Message Date
kiminchan 83a3810301 시설물 레이어 ui 변경 작업 2026-06-12 13:26:35 +09:00
kiminchan c6309bf14d 지반침하정보 총 결과 건수 표시 2026-06-11 11:36:13 +09:00
kiminchan c10a2ae1c8 지반침하정보 아이콘 표출 오류 수정 2026-06-10 18:18:53 +09:00
kiminchan 3dd09d9fc7 하수관로 상세정보 조회속도 개선 2026-06-10 17:53:48 +09:00
kiminchan 46f05dbfa0 지도 최소 줌레벨 및 경계 개선 2026-06-10 16:12:18 +09:00
kiminchan 823d1d7812 지반침하 연도 조건과 팝업 조회조건 동기화 정리 2026-06-09 16:42:15 +09:00
kiminchan a274b5a261 지반침하 팝업 조회조건, 다운로드, 지도표출 방식 정리 2026-06-09 15:26:09 +09:00
kiminchan 8737d1eb0a gis 편집 스냅핑, 꼭지점 제거 기능 구현 2026-06-08 17:40:10 +09:00
kiminchan a123588e32 지반침하정보 지도, 상세, 설정, 목록 기능 구현 2026-06-05 16:30:58 +09:00
kiminchan cc91225b01 관로 분석 재질별, 경과년도&재질별 조회 오류 해결 2026-06-04 15:54:32 +09:00
kiminchan af8db9457f 노후관로 팝업 위치 수정 2026-06-04 15:16:53 +09:00
kiminchan 5860cf3a22 Merge remote-tracking branch 'origin/main' into iipbs/sn/dev_kic 2026-06-04 13:08:00 +09:00
kiminchan 9d6cc405a9 하수관망도 - 처리구역분석 상세정보 매설년도-> 경과년도 변경 2026-06-02 16:29:07 +09:00
kiminchan 1c37591bab 노후관로 경과년도 표시 조건 보완 2026-06-02 15:44:36 +09:00
mind-one001 c7c69ea5a7 gis 편집 2026-06-01 15:49:50 +09:00
mind-one001 cdaaa34569 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-05-21 15:57:02 +09:00
mind-one001 17637146be 유하방향 줌레벨 표시 추가 2026-05-21 15:56:33 +09:00
kiminchan b4033071a1 하수관로 편집 상세정보 편집 추가, 하수관망도 업로드 시설 선택 보완 2026-05-21 13:57:16 +09:00
kiminchan 0c66a8f3b7 통수위험분석 프로토타입 구현 2026-05-19 13:39:25 +09:00
kiminchan ef7bae98d4 하수관망도 업로드 구현 2026-05-18 10:23:33 +09:00
kiminchan d2a1f5e48b 정보검색, 관로분석(관재질) 팝업 테이블 가로 스크롤 추가 2026-05-15 15:34:25 +09:00
kiminchan 69b717f517 Merge remote-tracking branch 'origin/iipbs/dm/dev' into iipbs/sn/dev_kic 2026-05-15 15:06:46 +09:00
kiminchan 6d0461eaef 하수관로 편집 기능 구현 2026-05-15 15:03:00 +09:00
mind-one001 be72a594d7 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev
# Conflicts:
#	apps/sn/app/(map-layout)/map/page.tsx
2026-05-13 17:28:21 +09:00
mind-one001 8cc4f58076 Merge branch 'iipbs/sn/dev2' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev2 2026-05-13 17:17:38 +09:00
mind-one001 3f07739756 유하방향 기능 구현 2026-05-13 16:33:34 +09:00
78 changed files with 15036 additions and 299 deletions
@@ -7,12 +7,14 @@ type Props = {
filters: AgedPipeFilters;
onFiltersChange: (filters: AgedPipeFilters) => void;
onApply: (filters?: AgedPipeFilters) => void;
compact?: boolean;
};
export default function AgedPipeLayerSettingPanel({
filters,
onFiltersChange,
onApply,
compact = false,
}: Props) {
const [isCollapsed, setIsCollapsed] = useState(false);
const [elapsedYearsText, setElapsedYearsText] = useState(String(filters.elapsedYears ?? ""));
@@ -30,8 +32,48 @@ export default function AgedPipeLayerSettingPanel({
onApply(nextFilters);
};
if (compact) {
return (
<div className="w-full rounded-[6px] border border-[#dbe7f5] bg-white px-3 py-2 shadow-[0_2px_8px_rgba(15,23,42,0.05)]">
<div className="flex min-h-[32px] items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-[1.15rem] font-medium text-slate-700">
<span className="h-2 w-2 shrink-0 rounded-full bg-[#243b93]" />
<label htmlFor="aged-pipe-elapsed-years" className="whitespace-nowrap">노후관로 경과년도(년)</label>
</div>
<div className="flex shrink-0 items-center gap-1">
<input
id="aged-pipe-elapsed-years"
className="h-[22px] w-14 border border-[#ccd6df] px-1 text-center text-[1.2rem]"
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={elapsedYearsText}
onFocus={(event) => event.currentTarget.select()}
onChange={(event) => {
const digits = event.target.value.replace(/[^0-9]/g, "");
setElapsedYearsText(digits);
}}
onKeyDown={(event) => {
if (event.key === "Enter") commitElapsedYears();
}}
/>
<button
type="button"
className="grid h-[20px] w-[20px] place-items-center rounded-full bg-[#31a83b] text-[1.15rem] font-bold leading-none text-white"
onClick={commitElapsedYears}
title="적용"
>
✓
</button>
</div>
</div>
</div>
);
}
return (
<div className="overflow-hidden rounded-sm border border-red-100 bg-red-50/35">
<div className="w-full overflow-hidden rounded-sm border border-red-100 bg-red-50/35">
<div className="relative flex min-h-[30px] items-center justify-center border-b border-red-100 bg-white px-8">
<button
type="button"
@@ -47,7 +89,7 @@ export default function AgedPipeLayerSettingPanel({
{!isCollapsed && (
<div className="px-2 py-2">
<div className="flex min-h-[30px] items-center gap-1 whitespace-nowrap text-[1.2rem] text-[#333]">
<label htmlFor="aged-pipe-elapsed-years">경과년도(년)</label>
<label htmlFor="aged-pipe-elapsed-years">노후관로 경과년도(년)</label>
<input
id="aged-pipe-elapsed-years"
className="h-[22px] w-14 border border-[#ccd6df] px-1 text-center text-[1.2rem]"
@@ -196,8 +196,16 @@ export default function AgedPipePopupPage() {
window.close();
};
const getCellClassName = (_row: AgedPipeRow, headId: string) => {
if (headId === "mqltCd") {
return "break-all whitespace-normal";
}
return "whitespace-normal";
};
return (
<main className="flex h-screen flex-col overflow-hidden bg-white">
<main className="flex h-screen w-full max-w-full min-w-0 flex-col overflow-x-hidden overflow-y-hidden bg-white">
<LoadingOverlay isLoading={isLoading} />
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
<h1 className="text-[1.45rem] font-semibold text-white">노후관로 현황</h1>
@@ -220,9 +228,9 @@ export default function AgedPipePopupPage() {
</div>
) : null}
<section className="overflow-y-auto p-4">
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[160px_320px_320px_auto] md:items-end md:justify-between">
<label className="block md:max-w-[160px]">
<section className="flex min-h-0 flex-1 flex-col overflow-x-hidden px-4 pb-3 pt-3">
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-3 md:grid-cols-[140px_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-end">
<label className="block min-w-0">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">경과년도</div>
<Input
className="h-10 !text-[1.1rem]"
@@ -244,7 +252,7 @@ export default function AgedPipePopupPage() {
/>
</label>
<label className="block md:max-w-[320px]">
<label className="block min-w-0">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
@@ -266,7 +274,7 @@ export default function AgedPipePopupPage() {
</select>
</label>
<label className="block md:max-w-[320px]">
<label className="block min-w-0">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
@@ -282,7 +290,7 @@ export default function AgedPipePopupPage() {
</select>
</label>
<div className="flex flex-wrap justify-end gap-2 md:pr-3">
<div className="flex flex-wrap items-end justify-end gap-2">
<Button
type="button"
size="sm"
@@ -304,19 +312,65 @@ export default function AgedPipePopupPage() {
</div>
</div>
<UiTable<AgedPipeRow>
headName={<>노후관로 조회 결과 총 {total.toLocaleString()}건</>}
tableData={rows}
headlist={AGED_PIPE_LIST_HEADLIST}
pageSize={10}
total={total}
cellClick={(index) => handleRowClick(index)}
onPageChange={(page) => void handleSearch(page)}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
/>
<div className="aged-pipe-popup-table min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto pt-2">
<UiTable<AgedPipeRow>
headName={<>노후관로 조회 결과 총 {total.toLocaleString()}건</>}
tableData={rows}
headlist={AGED_PIPE_LIST_HEADLIST}
pageSize={10}
total={total}
cellClick={(index) => handleRowClick(index)}
onPageChange={(page) => void handleSearch(page)}
cellClassName={getCellClassName}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
isHideCount={true}
className="!m-0 overflow-x-hidden"
/>
</div>
</section>
<style jsx global>{`
.aged-pipe-popup-table .card-header {
margin-bottom: 0.6rem;
}
.aged-pipe-popup-table .card-header h3 {
font-size: 1.7rem;
line-height: 1.25;
}
.aged-pipe-popup-table .card {
overflow-x: hidden;
}
.aged-pipe-popup-table .table-ui {
width: 100%;
max-width: 100%;
table-layout: fixed;
}
.aged-pipe-popup-table .table-ui th {
padding: 0.8rem 0.45rem;
font-size: 1.1rem;
line-height: 1.2;
word-break: keep-all;
}
.aged-pipe-popup-table .table-ui td {
padding: 0.85rem 0.4rem;
font-size: 1.1rem;
line-height: 1.25;
overflow-wrap: anywhere;
word-break: break-word;
}
.aged-pipe-popup-table .table-ui td:nth-child(2),
.aged-pipe-popup-table .table-ui td:nth-child(3),
.aged-pipe-popup-table .table-ui td:nth-child(8) {
word-break: keep-all;
}
`}</style>
</main>
);
}
@@ -59,9 +59,9 @@ export const AGED_PIPE_LIST_HEADLIST = [
{ id: "instlYmd", title: "설치일자", widthPercent: 12 },
{ id: "pdmtVl", title: "관경(mm)", widthPercent: 10 },
{ id: "prlgVl", title: "연장(m)", widthPercent: 10 },
{ id: "usgCd", title: "용도", widthPercent: 14 },
{ id: "usgCd", title: "용도", widthPercent: 12 },
{ id: "mqltCd", title: "재질", widthPercent: 18 },
{ id: "elapsedYears", title: "경과년도", widthPercent: 12, align: "right" },
{ id: "elapsedYears", title: "경과년도", widthPercent: 8, align: "right" },
];
export const AGED_PIPE_EXCEL_FIELDS = [
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import {
ChartColumnBig,
ChevronLeft,
@@ -16,8 +16,11 @@ import {
import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
type Props = {
onOpenPopup: (popupKey: WindowPopupKey) => void;
onOpenPopup: (popupKey: WindowPopupKey) => Window | null;
onOpenInfoSearch: () => void;
onToggleGisEdit?: () => void;
isGisEditActive?: boolean;
isInfoSearchActive?: boolean;
};
const ANALYSIS_POPUP_ITEMS: Array<{
@@ -34,10 +37,29 @@ const ANALYSIS_POPUP_ITEMS: Array<{
{ key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert },
];
export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Props) {
export default function AnalysisPopupMenu({
onOpenPopup,
onOpenInfoSearch,
onToggleGisEdit,
isGisEditActive = false,
isInfoSearchActive = false,
}: Props) {
const [isOpen, setIsOpen] = useState(true);
const [activeKey, setActiveKey] = useState<WindowPopupKey | "infoSearch" | null>(null);
// GIS 편집 모드가 꺼지면 메뉴 강조 상태도 함께 정리해 사용자가 현재 모드를 오해하지 않게 한다.
useEffect(() => {
if (!isGisEditActive && activeKey === "gisEditPopup") {
setActiveKey(null);
}
}, [activeKey, isGisEditActive]);
useEffect(() => {
if (!isInfoSearchActive && activeKey === "infoSearch") {
setActiveKey(null);
}
}, [activeKey, isInfoSearchActive]);
return (
<div className="absolute right-[0.55rem] top-[20px] z-[45]">
<div className="relative">
@@ -53,7 +75,9 @@ export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Pro
<div className="flex flex-col gap-1 p-[6px]">
{ANALYSIS_POPUP_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = activeKey === item.key;
const isActive = item.key === "gisEditPopup"
? isGisEditActive
: activeKey === item.key;
return (
<button
@@ -65,6 +89,12 @@ export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Pro
: "border-transparent bg-white text-[#374151] hover:border-[#cfe5ff] hover:bg-[#eef6ff] hover:text-[#1e3a5f]"
}`}
onClick={() => {
if (item.key === "gisEditPopup" && onToggleGisEdit) {
setActiveKey(item.key);
onToggleGisEdit();
return;
}
setActiveKey(item.key);
if (item.key === "infoSearch") {
@@ -72,7 +102,19 @@ export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Pro
return;
}
onOpenPopup(item.key);
const popupWindow = onOpenPopup(item.key);
if (!popupWindow) {
setActiveKey(null);
return;
}
const closeWatcher = window.setInterval(() => {
if (!popupWindow.closed) return;
window.clearInterval(closeWatcher);
setActiveKey((currentKey) =>
currentKey === item.key ? null : currentKey,
);
}, 300);
}}
>
<Icon
@@ -1,7 +1,9 @@
// 하수관로
export const MsSwagePplCol = [
{ id: 'fclt_mng_no', title: '시설관리번호' },
{ id: 'mngInstNm', title: '관리기관' },
{ id: 'inst_mng_no', title: '기관관리번호' },
{ id: 'flowDirectionBasis', title: '유하방향 근거' },
{ id: 'instl_ymd', title: '설치일자' },
{ id: 'elapsedYears', title: '경과년도' },
{ id: 'mapsht_no', title: '도엽번호' },
@@ -33,6 +35,7 @@ export const MsSwagePplCol2 = [
{ id: 'rowNumber', title: '일련\n번호' },
{ id: 'fcltMngNo', title: '시설\n관리번호' },
{ id: 'instMngNo', title: '기관\n관리번호' },
{ id: 'flowDirectionBasis', title: '유하방향\n근거' },
{ id: 'ctpvNm', title: '시도' },
{ id: 'sggNm', title: '시군구' },
{ id: "swtrmt_zone_nm", title: "처리구역" },
@@ -0,0 +1,340 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@common/components/ui";
import { getData } from "@/components/lib/api";
import { VerticalTable } from "@/components/ui/verticalTable";
import type { GisEditChange, GisEditPipeBasicInfo } from "./gisEditTypes";
import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from "../common/table";
type Props = {
change: GisEditChange;
featureData: any;
onClose: () => void;
onSave: (changeId: string, draft: GisEditPipeBasicInfo) => void;
};
type MngInstOption = {
mngInstCd: string;
lwrInstNm: string;
};
const EMPTY_SELECT_OPTION = { value: "", label: "선택" };
const PIPE_BASIC_INFO_FIELDS = [
{ id: "mngInstCd", sourceId: "mng_inst_cd", title: "관리기관", type: "select", required: true },
{ id: "fcltMngNo", sourceId: "fclt_mng_no", title: "시설관리번호", type: "input", required: true },
{ id: "instMngNo", sourceId: "inst_mng_no", title: "기관관리번호", type: "input", required: true },
{ id: "instlYmd", sourceId: "instl_ymd", title: "설치일자", type: "date" },
{ id: "mapshtNo", sourceId: "mapsht_no", title: "도엽번호", type: "input" },
{ id: "pdmtVl", sourceId: "pdmt_vl", title: "관경(mm)", type: "input" },
{ id: "prlgVl", sourceId: "prlg_vl", title: "연장(m)", type: "input" },
{
id: "usgCd",
sourceId: "usg_cd",
title: "용도",
type: "select",
required: true,
options: [
EMPTY_SELECT_OPTION,
...MS_SWAGE_PPL_USE_CODES.map((item) => ({ value: item.val.trim(), label: item.text })),
],
},
{
id: "mqltCd",
sourceId: "mqlt_cd",
title: "재질",
type: "select",
options: [
EMPTY_SELECT_OPTION,
...MS_SWAGE_PPL_CODES.map((item) => ({ value: item.val.trim(), label: item.text })),
],
},
{ id: "avgGrdn", sourceId: "avg_grdn", title: "평균구배(m/m)", type: "input" },
{ id: "drtwMltvArea", sourceId: "drtw_mltv_area", title: "오수배수면적(m²)", type: "input" },
{ id: "exclnMltvArea", sourceId: "excln_mltv_area", title: "우수배수면적(m²)", type: "input" },
{ id: "tmptDpth", sourceId: "tmpt_dpth", title: "시점깊이(m)", type: "input" },
{ id: "tmptMhlMngNo", sourceId: "tmpt_mhl_mng_no", title: "시점맨홀관리번호", type: "input" },
{ id: "scendDpth", sourceId: "scend_dpth", title: "종점깊이(m)", type: "input" },
{ id: "scendMhlMngNo", sourceId: "scend_mhl_mng_no", title: "종점맨홀관리번호", type: "input" },
{ id: "trflnCnt", sourceId: "trfln_cnt", title: "차선수", type: "input" },
{ id: "vrtcLen", sourceId: "vrtc_len", title: "세로길이(m)", type: "input" },
{ id: "wdthLen", sourceId: "wdth_len", title: "가로길이(m)", type: "input" },
];
const PIPE_INPUT_INFO_FIELDS = [
{ id: "instlMbdyCd", title: "설치주체", type: "select", options: [EMPTY_SELECT_OPTION, "BTL", "재정사업"] },
{ id: "swtrmtZoneNm", title: "처리구역", type: "input" },
{ id: "mltvZoneNm", title: "배수구역", type: "input" },
{ id: "mltvDtdvNm", title: "배수분구", type: "input" },
{ id: "lineCd", title: "지선/간선", type: "select", options: [EMPTY_SELECT_OPTION, "지선", "간선"] },
{ id: "floCd", title: "흐름방식", type: "select", options: [EMPTY_SELECT_OPTION, "자연유하", "압송"] },
{ id: "layCd", title: "매설위치", type: "select", options: [EMPTY_SELECT_OPTION, "도로", "하상"] },
];
const PIPE_VISIBLE_BASIC_FIELD_IDS = PIPE_BASIC_INFO_FIELDS.map((field) => field.id);
const PIPE_VISIBLE_INPUT_FIELD_IDS = PIPE_INPUT_INFO_FIELDS.map((field) => field.id);
const toEditableText = (value: unknown) => {
const text = String(value ?? "").trim();
return text === "-" ? "" : text;
};
const toDateInputValue = (value: unknown) => {
const text = toEditableText(value).replaceAll(".", "").replaceAll("-", "");
if (!/^\d{8}$/.test(text)) return "";
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
};
const toDbYmdValue = (value: unknown) => toEditableText(value).replaceAll("-", "");
const getObjectValue = (values: Record<string, any>, field: { id: string; sourceId?: string }) =>
values[field.id] ?? (field.sourceId ? values[field.sourceId] : undefined);
const pickFields = (values: Record<string, any>, fieldIds: string[]) =>
fieldIds.reduce<Record<string, any>>((acc, fieldId) => {
if (Object.prototype.hasOwnProperty.call(values, fieldId)) {
acc[fieldId] = values[fieldId];
}
return acc;
}, {});
const normalizeBasicFormValues = (values: Record<string, any>) => {
const nextValues = pickFields(values, PIPE_VISIBLE_BASIC_FIELD_IDS);
if (Object.prototype.hasOwnProperty.call(nextValues, "instlYmd")) {
nextValues.instlYmd = toDateInputValue(nextValues.instlYmd);
}
return nextValues;
};
const buildBaseFeatureValues = (featureData: any) => {
const resolveUsageCode = (value: unknown) => {
const raw = String(value ?? "").trim();
const matched = MS_SWAGE_PPL_USE_CODES.find((item) => item.val === raw || item.text === raw);
return matched?.val.trim() ?? raw;
};
const resolveMaterialCode = (value: unknown) => {
const raw = String(value ?? "").trim();
const matched = MS_SWAGE_PPL_CODES.find((item) => item.val === raw || item.text === raw);
return matched?.val.trim() ?? raw;
};
const sourceValues = featureData?.values_ ?? featureData ?? {};
return PIPE_BASIC_INFO_FIELDS.reduce<Record<string, any>>((acc, field) => {
const rawValue = getObjectValue(sourceValues, field);
if (field.id === "usgCd") {
acc[field.id] = resolveUsageCode(rawValue);
return acc;
}
if (field.id === "mqltCd") {
acc[field.id] = resolveMaterialCode(rawValue);
return acc;
}
if (field.id === "instlYmd") {
acc[field.id] = toDateInputValue(rawValue);
return acc;
}
acc[field.id] = toEditableText(rawValue);
return acc;
}, {});
};
const getPipeUsageLabel = (value: unknown) => {
const raw = String(value ?? "").trim();
if (!raw) return "";
return MS_SWAGE_PPL_USE_CODES.find((item) => item.val.trim() === raw || item.text === raw)?.text ?? raw;
};
const buildInitialInputDraft = (change: GisEditChange) => ({
instlMbdyCd: toEditableText(change.basicInfo?.instlMbdyCd ?? ""),
swtrmtZoneNm: toEditableText(change.basicInfo?.swtrmtZoneNm ?? ""),
mltvZoneNm: toEditableText(change.basicInfo?.mltvZoneNm ?? ""),
mltvDtdvNm: toEditableText(change.basicInfo?.mltvDtdvNm ?? ""),
lineCd: toEditableText(change.basicInfo?.lineCd ?? ""),
floCd: toEditableText(change.basicInfo?.floCd ?? ""),
layCd: toEditableText(change.basicInfo?.layCd ?? ""),
});
export default function GisEditPipeSessionPopup({
change,
featureData,
onClose,
onSave,
}: Props) {
const isNewPipe = change.editType === "ADD";
const baseFeatureValues = useMemo(() => buildBaseFeatureValues(featureData), [featureData]);
const [basicInfoTableData, setBasicInfoTableData] = useState<Record<string, any>>({});
const [inputInfoTableData, setInputInfoTableData] = useState<Record<string, any>>(
buildInitialInputDraft(change),
);
const [mngInstOptions, setMngInstOptions] = useState<MngInstOption[]>([]);
useEffect(() => {
let isMounted = true;
const fetchMngInstOptions = async () => {
try {
const result = await getData("v1/fcltppl/mngInsts");
if (!isMounted) return;
setMngInstOptions(Array.isArray(result) ? result : []);
} catch (error) {
console.error("관리기관 목록 조회 실패:", error);
if (isMounted) setMngInstOptions([]);
}
};
fetchMngInstOptions();
return () => {
isMounted = false;
};
}, []);
const mngInstSelectOptions = useMemo(
() => {
const sortedOptions = [...mngInstOptions].sort((a, b) =>
String(a.lwrInstNm ?? a.mngInstCd ?? "").localeCompare(
String(b.lwrInstNm ?? b.mngInstCd ?? ""),
"ko",
),
);
return [
EMPTY_SELECT_OPTION,
...sortedOptions.map((option) => ({
value: String(option.mngInstCd ?? "").trim(),
label: option.lwrInstNm || String(option.mngInstCd ?? "").trim(),
})),
];
},
[mngInstOptions],
);
const basicInfoFields = useMemo(
() =>
PIPE_BASIC_INFO_FIELDS.map((field) =>
field.id === "mngInstCd"
? { ...field, options: mngInstSelectOptions }
: field,
),
[mngInstSelectOptions],
);
useEffect(() => {
const initialBasicValues: Record<string, any> = {
...baseFeatureValues,
...normalizeBasicFormValues(change.basicInfo ?? {}),
};
setBasicInfoTableData(initialBasicValues);
setInputInfoTableData(buildInitialInputDraft(change));
}, [baseFeatureValues, change.changeId, isNewPipe]);
useEffect(() => {
if (isNewPipe) return;
const fcltMngNo = String(change.featureRef?.fcltMngNo ?? "").trim();
const mngInstCd = String(change.featureRef?.mngInstCd ?? "").trim();
if (!fcltMngNo || !mngInstCd) return;
if (/^NEW-/i.test(fcltMngNo) || /^NEW-/i.test(mngInstCd)) return;
let isMounted = true;
const fetchPipeInputInfo = async () => {
try {
const safeFcltMngNo = encodeURIComponent(fcltMngNo);
const safeMngInstCd = encodeURIComponent(mngInstCd);
const result = await getData(`v1/fcltppl/${safeFcltMngNo}/${safeMngInstCd}`);
if (!isMounted || !result) return;
setInputInfoTableData((previous) => ({
...previous,
...pickFields(result, PIPE_VISIBLE_INPUT_FIELD_IDS),
...pickFields(change.basicInfo ?? {}, PIPE_VISIBLE_INPUT_FIELD_IDS),
}));
} catch (error) {
console.error("GIS 편집 관로 입력정보 조회 실패:", error);
}
};
fetchPipeInputInfo();
return () => {
isMounted = false;
};
}, [change.changeId, change.featureRef?.fcltMngNo, change.featureRef?.mngInstCd, isNewPipe]);
const buildMergedDraft = (
nextBasicInfoTableData: Record<string, any>,
nextInputInfoTableData: Record<string, any>,
): GisEditPipeBasicInfo => {
const basicDraft = pickFields(nextBasicInfoTableData, PIPE_VISIBLE_BASIC_FIELD_IDS);
const inputDraft = pickFields(nextInputInfoTableData, PIPE_VISIBLE_INPUT_FIELD_IDS);
if (basicDraft.instlYmd) {
basicDraft.instlYmd = toDbYmdValue(basicDraft.instlYmd);
}
return {
...basicDraft,
...inputDraft,
};
};
const handleBasicInfoChange = (nextFormData: Record<string, any>) => {
setBasicInfoTableData(nextFormData);
onSave(change.changeId, buildMergedDraft(nextFormData, inputInfoTableData));
};
const handleInputInfoChange = (nextFormData: Record<string, any>) => {
setInputInfoTableData(nextFormData);
onSave(change.changeId, buildMergedDraft(basicInfoTableData, nextFormData));
};
const basicInfoTableRows = useMemo(() => [basicInfoTableData], [basicInfoTableData]);
const inputInfoTableRows = useMemo(() => [inputInfoTableData], [inputInfoTableData]);
const displayManagementNo = toEditableText(change.featureRef?.fcltMngNo ?? change.objectId ?? "");
return (
<div id="popup" className="absolute top-0 right-0 w-[350px] h-[calc(100vh-5rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-[50]">
<div className="card-header bg-primary flex items-center justify-between relative p-4">
<div className="flex items-center space-x-4 flex-1">
<h3 className="text-2xl font-medium text-white text-label whitespace-nowrap">
하수관로
{basicInfoTableData?.usgCd ? `(${getPipeUsageLabel(basicInfoTableData.usgCd)})` : ""}
{" "}정보 <br />관리번호 : {displayManagementNo || "신규"}
</h3>
</div>
<Button
onClick={onClose}
className="flex items-center space-x-4 text-white hover:text-gray-300 focus:outline-none"
size="xs"
>
✕
</Button>
</div>
<div className="overflow-y-auto overflow-x-hidden flex-1">
<VerticalTable
headName="기본정보"
tableData={basicInfoTableRows}
headlist={basicInfoFields}
pageSize={20}
total={0}
cellClick={undefined}
onPageChange={() => {}}
isEditable={true}
onFormDataChange={handleBasicInfoChange}
/>
<VerticalTable
headName="입력정보"
tableData={inputInfoTableRows}
headlist={PIPE_INPUT_INFO_FIELDS}
pageSize={20}
total={0}
cellClick={undefined}
onPageChange={() => {}}
isEditable={true}
onFormDataChange={handleInputInfoChange}
/>
</div>
</div>
);
}
@@ -0,0 +1,131 @@
"use client";
import type { GisEditSessionSnapshot, GisEditTool } from "./gisEditTypes";
type Props = GisEditSessionSnapshot & {
onRemoveLastChange: () => void;
onClearChanges: () => void;
onFocusChange: (changeId: string) => void;
className?: string;
};
const TOOL_LABELS: Record<GisEditTool, string> = {
ADD: "추가",
MOVE: "이동",
VERTEX_EDIT: "꼭지점편집",
DELETE: "삭제",
};
const buildChangeItemClassName = (isFocused: boolean) =>
`block w-full rounded border px-3 py-3 text-left text-[12px] transition ${
isFocused
? "border-[#5a9bd5] bg-[#eef7ff] shadow-[inset_0_0_0_1px_rgba(90,155,213,0.2)]"
: "border-[#d8dee8] bg-[#fbfcfe] hover:border-[#bfd2e5] hover:bg-white"
}`;
const getChangeLabel = (change: GisEditSessionSnapshot["changes"][number]) =>
change.attributeOnly ? "상세정보수정" : TOOL_LABELS[change.editType];
type ChangeListProps = {
changes: GisEditSessionSnapshot["changes"];
focusedChangeId: string | null;
onFocusChange: (changeId: string) => void;
};
const ChangeList = ({ changes, focusedChangeId, onFocusChange }: ChangeListProps) => (
<>
{changes.map((change, index) => (
<button
key={change.changeId}
type="button"
className={buildChangeItemClassName(focusedChangeId === change.changeId)}
onClick={() => onFocusChange(change.changeId)}
>
<div className="flex items-center justify-between gap-3">
<div className="font-semibold text-[#173f67]">
{index + 1}. {getChangeLabel(change)} / {change.objectLabel}
</div>
<div className="flex items-center gap-1.5">
{change.basicInfoSavedAt ? (
<span className="rounded-full border border-[#a9d9bd] bg-white px-2 py-0.5 text-[10px] font-semibold text-[#16834f]">
기본정보 세션저장
</span>
) : null}
{focusedChangeId === change.changeId ? (
<span className="rounded-full border border-[#b8d3ef] bg-white px-2 py-0.5 text-[10px] font-semibold text-[#176db8]">
지도 미리보기 선택
</span>
) : null}
</div>
</div>
<div className="mt-1 text-[#526173]">{change.summary}</div>
</button>
))}
</>
);
export default function GisEditSessionPanel({
isEditMode,
selectedLayerName,
activeTool,
changes,
canUndo,
focusedChangeId,
onRemoveLastChange,
onClearChanges,
onFocusChange,
className = "",
}: Props) {
return (
<div
className={`rounded-lg border border-[#d8dee8] bg-[rgba(255,255,255,0.98)] p-4 text-sm text-[#304255] shadow-[0_10px_26px_rgba(23,42,70,0.14)] ${className}`.trim()}
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-[12px] text-[#667485]">
레이어: {selectedLayerName || "미선택"} / 모드: {isEditMode ? (activeTool ? TOOL_LABELS[activeTool] : "대기") : "편집 모드 종료"}
</div>
</div>
<div className="mb-3 flex flex-wrap items-center gap-2">
<button
type="button"
className="rounded border border-[#d8dee8] bg-white px-3 py-1.5 text-[12px] font-semibold text-[#304255] transition hover:bg-[#f8fafc] disabled:cursor-not-allowed disabled:text-[#9aa8b7]"
disabled={!canUndo}
onClick={onRemoveLastChange}
>
마지막 변경 취소
</button>
<button
type="button"
className="rounded border border-[#f3d0d0] bg-white px-3 py-1.5 text-[12px] font-semibold text-[#b42318] transition hover:bg-[#fff5f5] disabled:cursor-not-allowed disabled:text-[#d0a5a5]"
disabled={!canUndo}
onClick={onClearChanges}
>
초기화
</button>
<div className="ml-auto rounded-full border border-[#d8dee8] bg-white px-2.5 py-1 text-[11px] font-semibold text-[#526173]">
총 {changes.length}건
</div>
</div>
<div className="space-y-2">
{changes.length === 0 ? (
<div className="rounded border border-dashed border-[#d8dee8] bg-[#fbfcfe] px-3 py-4 text-[12px] text-[#667485]">
저장할 편집 변경분이 없습니다.
</div>
) : (
<div className="max-h-[calc(100vh-190px)] overflow-y-auto pr-1">
<div className="space-y-2">
<ChangeList
changes={changes}
focusedChangeId={focusedChangeId}
onFocusChange={onFocusChange}
/>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,171 @@
"use client";
import {
Check,
Move,
PenTool,
Plus,
Trash2,
X,
} from "lucide-react";
import { Button } from "@common/components/ui";
import type { GisEditTool } from "./gisEditTypes";
type Props = {
isEditMode: boolean;
activeTool: GisEditTool | null;
isSnapEnabled: boolean;
canUseTools: boolean;
canSave: boolean;
isSaving?: boolean;
sessionChangeCount?: number;
statusMessage: string;
allowedTools?: GisEditTool[];
onOpenSession?: () => void;
onClose?: () => void;
onToggleSnap: () => void;
onSelectTool: (tool: GisEditTool) => void;
onSave: () => void;
};
const TOOL_BUTTONS: Array<{
tool: GisEditTool;
label: string;
icon: typeof Plus;
}> = [
{ tool: "ADD", label: "추가", icon: Plus },
{ tool: "MOVE", label: "이동", icon: Move },
{ tool: "VERTEX_EDIT", label: "꼭지점편집", icon: PenTool },
{ tool: "DELETE", label: "삭제", icon: Trash2 },
];
export default function GisEditToolbar({
isEditMode,
activeTool,
isSnapEnabled,
canUseTools,
canSave,
isSaving = false,
sessionChangeCount = 0,
statusMessage: _statusMessage,
allowedTools = [],
onOpenSession,
onClose,
onToggleSnap,
onSelectTool,
onSave,
}: Props) {
if (!isEditMode) return null;
return (
<div className="absolute left-1/2 top-4 z-[60] flex w-[min(860px,calc(100%-2rem))] -translate-x-1/2 flex-col">
<div className="card card-border flex !bg-white py-[1px]">
<div className="flex w-full items-center gap-2 overflow-hidden">
<div className="flex w-[120px] shrink-0 items-center justify-start">
<span className="whitespace-nowrap text-[1.2rem] font-bold leading-5 text-[#123b62]">하수관로 편집</span>
</div>
<div className="flex min-w-0 flex-1 justify-center">
<div className="flex items-center gap-1">
{TOOL_BUTTONS.map(({ tool, label, icon: Icon }) => {
const isActive = activeTool === tool;
const isToolEnabled =
canUseTools && allowedTools.includes(tool);
if (tool === "VERTEX_EDIT") {
return (
<div key="vertex-edit-with-snap" className="flex shrink-0 items-center">
<Button
type="button"
color={activeTool === "VERTEX_EDIT" ? "primary" : "white"}
size="sm"
className="mx-0.5 mt-1 min-w-[98px] px-2"
disabled={!isToolEnabled}
onClick={() => onSelectTool("VERTEX_EDIT")}
>
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<Icon className="h-4 w-4" />
<span>{label}</span>
</span>
</Button>
<Button
type="button"
color={isSnapEnabled ? "primary" : "white"}
size="sm"
className="mx-0.5 mt-1 min-w-[82px] px-2"
disabled={!canUseTools}
onClick={onToggleSnap}
title="이동과 꼭지점편집에 스냅핑 적용"
>
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<Check className="h-4 w-4" />
<span>스냅핑</span>
</span>
</Button>
</div>
);
}
return (
<Button
key={tool}
type="button"
color={isActive ? "primary" : "white"}
size="sm"
className="mx-0.5 mt-1 min-w-[74px] shrink-0 px-2"
disabled={!isToolEnabled}
onClick={() => onSelectTool(tool)}
>
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<Icon className="h-4 w-4" />
<span>{label}</span>
</span>
</Button>
);
})}
</div>
</div>
<div className="flex w-[248px] shrink-0 items-center justify-end gap-1">
<Button
type="button"
color="white"
size="sm"
className="mt-1 min-w-[98px] shrink-0 px-2"
onClick={onOpenSession}
title="GIS 편집 세션 보기"
>
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<Check className="h-4 w-4" />
<span>편집 세션 {sessionChangeCount}</span>
</span>
</Button>
<Button
type="button"
color="primary"
size="sm"
className="mt-1 min-w-[58px] shrink-0 px-2"
disabled={!canSave || isSaving}
onClick={onSave}
>
{isSaving ? "저장 중" : "저장"}
</Button>
<Button
type="button"
color="white"
size="sm"
className="mt-1 min-w-[58px] shrink-0 px-2"
onClick={onClose}
title="하수관로 편집 닫기"
>
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<X className="h-4 w-4" />
<span>닫기</span>
</span>
</Button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,111 @@
export type GisEditTool = "ADD" | "MOVE" | "VERTEX_EDIT" | "DELETE";
export type GisEditGeometryType = "Point" | "LineString" | "Polygon";
export type GisEditPreviewColors = {
pointColor: string;
lineColor: string;
fillColor: string;
};
export type GisEditFeatureRef = {
fcltMngNo?: string;
mngInstCd?: string;
fid?: string;
};
export type GisEditPipeBasicInfo = {
[key: string]: string | null | undefined;
fcltMngNo?: string | null;
mngInstCd?: string | null;
instMngNo?: string | null;
instlYmd?: string | null;
mapshtNo?: string | null;
pdmtVl?: string | null;
prlgVl?: string | null;
usgCd?: string | null;
mqltCd?: string | null;
avgGrdn?: string | null;
drtwMltvArea?: string | null;
exclnMltvArea?: string | null;
tmptDpth?: string | null;
tmptMhlMngNo?: string | null;
scendDpth?: string | null;
scendMhlMngNo?: string | null;
trflnCnt?: string | null;
vrtcLen?: string | null;
wdthLen?: string | null;
instlMbdyCd?: string | null;
swtrmtZoneNm?: string | null;
mltvZoneNm?: string | null;
mltvDtdvNm?: string | null;
lineCd?: string | null;
floCd?: string | null;
layCd?: string | null;
};
export type GisEditLayerInfo = {
layerId: string;
layerName: string;
geometryType: GisEditGeometryType;
editableYn: "Y" | "N";
allowedTools: GisEditTool[];
previewColors: GisEditPreviewColors;
disabledReason?: string;
};
export type GisEditChange = {
changeId: string;
editType: GisEditTool;
layerId: string;
layerName: string;
objectId?: string;
fid?: string;
baseEditSn?: string;
featureRef?: GisEditFeatureRef;
objectLabel: string;
beforeGeometry?: Record<string, unknown> | null;
afterGeometry?: Record<string, unknown> | null;
featureProperties?: Record<string, unknown> | null;
basicInfo?: GisEditPipeBasicInfo | null;
basicInfoSavedAt?: string | null;
attributeOnly?: boolean;
summary: string;
createdAt: string;
source: "MAP_SELECTION" | "MAP_CENTER" | "MAP_DRAW";
};
export type GisEditSavePayload = {
screenId: string;
layerId: string;
layerName: string;
saveTargetType: "VERSION" | "FINAL";
view: {
srs: string;
bbox: string | null;
};
changes: Array<{
changeId: string;
editType: GisEditTool;
objectId?: string;
clientTempId?: string;
fid?: string;
baseEditSn?: string;
featureRef?: GisEditFeatureRef;
attributes?: GisEditPipeBasicInfo | null;
beforeGeometry?: Record<string, unknown> | null;
afterGeometry?: Record<string, unknown> | null;
geometry?: Record<string, unknown> | null;
}>;
};
export type GisEditSessionSnapshot = {
isEditMode: boolean;
selectedLayerName: string;
activeTool: GisEditTool | null;
isSnapEnabled: boolean;
changes: GisEditChange[];
lastSavedPayload: GisEditSavePayload | null;
canUndo: boolean;
focusedChangeId: string | null;
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,415 @@
"use client";
import type { ChangeEvent } from "react";
import { useMemo, useRef, useState } from "react";
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
import { Button, Input, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
type UploadFacilityType = {
facilityTypeCode: string;
facilityTypeName: string;
allowedExtensions: string[];
maxFileSizeMb: number;
};
type UploadHistoryItem = {
uploadId: string;
facilityTypeCode: string;
facilityTypeName: string;
fileName: string;
registeredAt: string;
registeredBy: string;
};
const UPLOAD_FACILITY_TYPES: UploadFacilityType[] = [
{
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
allowedExtensions: [".zip", ".shp", ".geojson", ".json", ".dxf"],
maxFileSizeMb: 200,
},
];
const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [
{
uploadId: "GUP-MOCK-20260504-0001",
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
fileName: "asan_pipe_20260504.zip",
registeredAt: "2026-05-04",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260418-0004",
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
fileName: "asan_pipe_20260418.zip",
registeredAt: "2026-04-18",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260409-0007",
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
fileName: "asan_pipe_20260409.zip",
registeredAt: "2026-04-09",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260331-0010",
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
fileName: "asan_pipe_20260331.zip",
registeredAt: "2026-03-31",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260322-0013",
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
fileName: "asan_pipe_20260322.zip",
registeredAt: "2026-03-22",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260313-0016",
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
fileName: "asan_pipe_20260313.zip",
registeredAt: "2026-03-13",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260304-0019",
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
fileName: "asan_pipe_20260304.zip",
registeredAt: "2026-03-04",
registeredBy: "admin",
},
];
const getFileExtension = (fileName: string) => {
const lastDotIndex = fileName.lastIndexOf(".");
if (lastDotIndex < 0) return "";
return fileName.slice(lastDotIndex).toLowerCase();
};
const buildTodayDate = () => new Date().toISOString().slice(0, 10);
const GIS_UPLOAD_PAGE_SIZE = 10;
export default function GisManagementPopupPage() {
// 브라우저 파일 picker는 제어형 input보다 ref 제어가 안전하다.
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [selectedFacilityTypeCode, setSelectedFacilityTypeCode] = useState("");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploadHistory, setUploadHistory] = useState<UploadHistoryItem[]>(
INITIAL_UPLOAD_HISTORY,
);
const [selectedUploadId, setSelectedUploadId] = useState<string>(
INITIAL_UPLOAD_HISTORY[0]?.uploadId ?? "",
);
const [currentPage, setCurrentPage] = useState(1);
const selectedFacility = useMemo(
() =>
UPLOAD_FACILITY_TYPES.find(
(facilityType) =>
facilityType.facilityTypeCode === selectedFacilityTypeCode,
) ?? null,
[selectedFacilityTypeCode],
);
const selectedHistory = useMemo(
() =>
uploadHistory.find((history) => history.uploadId === selectedUploadId) ??
null,
[selectedUploadId, uploadHistory],
);
const pagedUploadHistory = useMemo(() => {
const startIndex = (currentPage - 1) * GIS_UPLOAD_PAGE_SIZE;
return uploadHistory.slice(startIndex, startIndex + GIS_UPLOAD_PAGE_SIZE);
}, [currentPage, uploadHistory]);
const resetSelectedFile = () => {
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleFacilityTypeChange = (
event: ChangeEvent<HTMLSelectElement>,
) => {
setSelectedFacilityTypeCode(event.target.value);
resetSelectedFile();
};
const handleOpenFilePicker = () => {
if (!selectedFacility) {
window.alert("시설구분을 먼저 선택하세요.");
return;
}
fileInputRef.current?.click();
};
const handleFileSelected = (event: ChangeEvent<HTMLInputElement>) => {
const nextFile = event.target.files?.[0] ?? null;
if (!selectedFacility) {
resetSelectedFile();
window.alert("시설구분을 먼저 선택한 뒤 파일을 선택하세요.");
return;
}
if (!nextFile) {
resetSelectedFile();
return;
}
const fileExtension = getFileExtension(nextFile.name);
const isAllowedExtension = selectedFacility.allowedExtensions.includes(
fileExtension,
);
if (!isAllowedExtension) {
resetSelectedFile();
window.alert(
`업로드 가능한 GIS 파일 형식이 아닙니다. 허용 형식: ${selectedFacility.allowedExtensions.join(", ")}`,
);
return;
}
const maxFileSizeInBytes = selectedFacility.maxFileSizeMb * 1024 * 1024;
if (nextFile.size > maxFileSizeInBytes) {
resetSelectedFile();
window.alert(
`파일 크기가 제한을 초과했습니다. 최대 ${selectedFacility.maxFileSizeMb}MB까지 업로드할 수 있습니다.`,
);
return;
}
setSelectedFile(nextFile);
};
const handleUpload = () => {
if (!selectedFacility) {
window.alert("시설구분을 선택하세요.");
return;
}
if (!selectedFile) {
window.alert("업로드할 파일을 선택하세요.");
return;
}
const isDuplicateFile = uploadHistory.some(
(history) =>
history.facilityTypeCode === selectedFacility.facilityTypeCode &&
history.fileName.toLowerCase() === selectedFile.name.toLowerCase(),
);
if (isDuplicateFile) {
window.alert("동일 시설구분에 같은 파일명이 존재합니다.");
return;
}
const nextUploadHistory: UploadHistoryItem = {
uploadId: `GUP-MOCK-${Date.now()}`,
facilityTypeCode: selectedFacility.facilityTypeCode,
facilityTypeName: selectedFacility.facilityTypeName,
fileName: selectedFile.name,
registeredAt: buildTodayDate(),
registeredBy: "admin",
};
setUploadHistory((currentHistory) => [
nextUploadHistory,
...currentHistory,
]);
setCurrentPage(1);
setSelectedUploadId(nextUploadHistory.uploadId);
window.alert("GIS 업로드가 완료되었습니다.");
resetSelectedFile();
};
const handleDownload = () => {
if (!selectedHistory) {
window.alert("다운로드할 파일을 선택하세요.");
return;
}
window.alert(`${selectedHistory.fileName} 다운로드를 요청했습니다.`);
};
return (
<main className="flex h-screen items-start justify-center bg-[linear-gradient(180deg,#f7f9fc_0%,#eef4fa_100%)] px-[12px] py-[12px] text-[#202a36]">
<section className="w-full max-w-[920px] overflow-hidden rounded-[12px] border border-[#d8e3ef] bg-[rgba(255,255,255,0.96)] shadow-[0_16px_36px_rgba(31,59,87,0.10)] backdrop-blur-[2px]">
<header className="flex h-[44px] items-center border-b border-[#e2ebf5] bg-[linear-gradient(180deg,#ffffff_0%,#f3f8fd_100%)] px-[18px]">
<h1 className="text-[1.35rem] font-semibold tracking-[-0.01em] text-[#1f3b57]">
GIS 관리
</h1>
</header>
<div className="px-[16px] pb-[16px] pt-[10px]">
<div className="mb-[10px] mt-[4px] text-[1.05rem] font-medium tracking-[-0.01em] text-slate-700">
GIS 업로드
</div>
<div className="mb-[8px] flex items-center justify-between gap-[12px] px-[2px]">
<div className="flex min-w-0 flex-1 items-center gap-[10px]">
<select
className="h-[32px] w-[110px] shrink-0 rounded-[6px] border border-[#cad7e6] bg-[linear-gradient(180deg,#ffffff_0%,#f8fbff_100%)] px-[10px] text-[1.02rem] font-medium text-slate-700 outline-none transition focus:border-[#8bbef0] focus:shadow-[0_0_0_2px_rgba(47,126,196,0.12)]"
value={selectedFacilityTypeCode}
onChange={handleFacilityTypeChange}
aria-label="시설구분"
>
<option value="">시설구분</option>
{UPLOAD_FACILITY_TYPES.map((facilityType) => (
<option
key={facilityType.facilityTypeCode}
value={facilityType.facilityTypeCode}
>
{facilityType.facilityTypeName}
</option>
))}
</select>
<Input
value={selectedFile?.name ?? ""}
placeholder="선택된 파일 없음"
readOnly
aria-label="선택 파일명"
className="h-[32px] min-w-0 flex-1 rounded-[6px] border-[#cad7e6] bg-[linear-gradient(180deg,#ffffff_0%,#f8fbff_100%)] px-[10px] py-0 !text-[1.02rem] font-medium text-slate-700 placeholder:text-[#9aa8b7] shadow-[inset_0_1px_0_rgba(255,255,255,0.7)] focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
<div className="flex shrink-0 items-center gap-[10px]">
<Button
type="button"
size="sm"
className="h-[32px] min-w-[56px] rounded-sm bg-blue-600 px-0 py-2 text-sm font-medium text-white hover:bg-blue-700"
onClick={handleOpenFilePicker}
>
찾기
</Button>
<Button
type="button"
size="sm"
className="h-[32px] min-w-[64px] rounded-sm bg-blue-600 px-0 py-2 text-sm font-medium text-white hover:bg-blue-700"
onClick={handleUpload}
>
업로드
</Button>
</div>
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept={
selectedFacility?.allowedExtensions.join(",") ??
".zip,.shp,.geojson,.json,.dxf"
}
onChange={handleFileSelected}
/>
<div className="mb-[8px] flex items-center justify-between px-[2px]">
<div className="text-[1.05rem] font-medium tracking-[-0.01em] text-slate-700">
GIS 업로드 현황
</div>
<Button
type="button"
size="sm"
color="green"
className="h-[32px] min-w-[82px] rounded-sm px-[10px] py-2 text-sm font-medium text-white"
onClick={handleDownload}
>
다운로드
</Button>
</div>
<div className="overflow-hidden rounded-[10px] border border-[#d6e1ee] bg-[linear-gradient(180deg,#ffffff_0%,#f9fbfe_100%)] shadow-[0_6px_16px_rgba(31,59,87,0.06),inset_0_1px_0_rgba(255,255,255,0.78)]">
<Table variant="secondary" className="text-[1.02rem]">
<TableHeader className="!static bg-transparent">
<TableRow className="border-0">
<TableHead className="h-[32px] border border-[#d7e3ef] bg-[#eaf3fb] px-[12px] py-[5px] text-left text-[1.0rem] font-medium text-[#647784]">
no
</TableHead>
<TableHead className="h-[32px] border border-[#d7e3ef] bg-[#eaf3fb] px-[12px] py-[5px] text-left text-[1.0rem] font-medium text-[#647784]">
시설구분
</TableHead>
<TableHead className="h-[32px] border border-[#d7e3ef] bg-[#eaf3fb] px-[12px] py-[5px] text-left text-[1.0rem] font-medium text-[#647784]">
파일명
</TableHead>
<TableHead className="h-[32px] border border-[#d7e3ef] bg-[#eaf3fb] px-[12px] py-[5px] text-left text-[1.0rem] font-medium text-[#647784]">
등록일자
</TableHead>
<TableHead className="h-[32px] border border-[#d7e3ef] bg-[#eaf3fb] px-[12px] py-[5px] text-left text-[1.0rem] font-medium text-[#647784]">
등록자
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedUploadHistory.map((history, index) => {
const isSelected = history.uploadId === selectedUploadId;
const backgroundColor = isSelected
? "#eef6ff"
: index % 2 === 0
? "#ffffff"
: "#f9fbfd";
const rowNumber =
(currentPage - 1) * GIS_UPLOAD_PAGE_SIZE + index + 1;
return (
<TableRow
key={history.uploadId}
className="border-0 cursor-pointer"
onClick={() => setSelectedUploadId(history.uploadId)}
style={{ backgroundColor }}
>
<TableCell className="h-[40px] border border-[#dde7f0] px-[12px] py-[8px] text-[1.0rem] font-normal text-[#43566b]">
{rowNumber}
</TableCell>
<TableCell className="h-[40px] border border-[#dde7f0] px-[12px] py-[8px] text-[1.0rem] font-normal text-[#43566b]">
{history.facilityTypeName}
</TableCell>
<TableCell className="h-[40px] border border-[#dde7f0] px-[12px] py-[8px] text-[1.0rem] font-normal text-[#43566b]">
{history.fileName}
</TableCell>
<TableCell className="h-[40px] border border-[#dde7f0] px-[12px] py-[8px] text-[1.0rem] font-normal text-[#43566b]">
{history.registeredAt}
</TableCell>
<TableCell className="h-[40px] border border-[#dde7f0] px-[12px] py-[8px] text-[1.0rem] font-normal text-[#43566b]">
{history.registeredBy}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{uploadHistory.length > GIS_UPLOAD_PAGE_SIZE ? (
<div className="mt-[10px] flex justify-center">
<UiPageNations
total={uploadHistory.length}
cur={currentPage}
pageSize={GIS_UPLOAD_PAGE_SIZE}
onPageChange={setCurrentPage}
/>
</div>
) : null}
</div>
</section>
</main>
);
}
@@ -42,6 +42,9 @@ const PIPE_ANALYSIS_DETAIL_EXPORT_KEYS = [
"materialName",
] as const;
const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
error instanceof Error && error.message.trim() ? error.message : fallbackMessage;
export default function PipeAnalysisDetailPopupPage() {
const searchParams = useSearchParams();
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
@@ -118,7 +121,7 @@ export default function PipeAnalysisDetailPopupPage() {
setCurrentPage(page);
} catch (error) {
console.error("관로현황 조회 중 오류:", error);
alert("관로현황 조회 중 오류가 발생했습니다.");
alert(resolveErrorMessage(error, "관로현황 조회 중 오류가 발생했습니다."));
setRows([]);
setTotal(0);
} finally {
@@ -188,7 +191,7 @@ export default function PipeAnalysisDetailPopupPage() {
});
} catch (error) {
console.error("관로현황 다운로드 중 오류:", error);
alert("관로현황 다운로드 중 오류가 발생했습니다.");
alert(resolveErrorMessage(error, "관로현황 다운로드 중 오류가 발생했습니다."));
} finally {
setIsLoading(false);
}
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, Table, TableBody, TableCell, TableHeader, TableHead, TableRow } from "@common/components";
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
import { getData } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
@@ -32,6 +33,7 @@ const SUMMARY_FILE_PREFIX: Record<string, string> = {
MATERIAL: "관로분석_관재질별",
YEAR_MATERIAL: "관로분석_경과년도별_관재질별",
};
const PIPE_ANALYSIS_SUMMARY_PAGE_SIZE = 10;
const toExcelColumnName = (index: number) => {
let current = index;
@@ -57,6 +59,9 @@ const formatGridNumber = (value: unknown) => {
});
};
const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
error instanceof Error && error.message.trim() ? error.message : fallbackMessage;
export default function PipeAnalysisPopupPage() {
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const userInfo = useUserStore((state) => state.userInfo);
@@ -70,6 +75,7 @@ export default function PipeAnalysisPopupPage() {
const [materialColumns, setMaterialColumns] = useState<PipeAnalysisMaterialColumn[]>([]);
const [rows, setRows] = useState<PipeAnalysisSummaryViewRow[]>([]);
const [zoneOptions, setZoneOptions] = useState<Array<{ value: string; label: string }>>([]);
const [summaryCurrentPage, setSummaryCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const initializedRef = useRef(false);
@@ -138,10 +144,11 @@ export default function PipeAnalysisPopupPage() {
const scopedFilters = applyScopeToSummaryFilters(nextFilters, scope);
setDraftFilters(scopedFilters);
setAppliedFilters(scopedFilters);
setSummaryCurrentPage(1);
await fetchSummary(scopedFilters);
} catch (error) {
console.error("관로분석 집계 조회 중 오류:", error);
alert("관로분석 집계 조회 중 오류가 발생했습니다.");
alert(resolveErrorMessage(error, "관로분석 집계 조회 중 오류가 발생했습니다."));
setRows([]);
setMaterialColumns([]);
} finally {
@@ -216,7 +223,7 @@ export default function PipeAnalysisPopupPage() {
return exportRow;
});
await downloadExcelWithKoreanHeaders({
data: exportRows as JSON[],
data: exportRows as unknown as JSON[],
fileName: `${SUMMARY_FILE_PREFIX[appliedFilters.analysisType]}_${buildPipeAnalysisFileDate()}`,
sheetNameArray: ["관로분석 현황"],
headerRange,
@@ -225,7 +232,7 @@ export default function PipeAnalysisPopupPage() {
});
} catch (error) {
console.error("관로분석 집계 다운로드 중 오류:", error);
alert("관로분석 집계 다운로드 중 오류가 발생했습니다.");
alert(resolveErrorMessage(error, "관로분석 집계 다운로드 중 오류가 발생했습니다."));
}
};
@@ -280,6 +287,25 @@ export default function PipeAnalysisPopupPage() {
);
const useHorizontalScrollTable = appliedFilters.analysisType !== "YEAR";
const summaryPageOffset = (summaryCurrentPage - 1) * PIPE_ANALYSIS_SUMMARY_PAGE_SIZE;
const pagedRows = useMemo(
() =>
appliedFilters.analysisType === "YEAR_MATERIAL"
? rows
: rows.slice(summaryPageOffset, summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE),
[appliedFilters.analysisType, rows, summaryPageOffset],
);
const visibleYearMaterialGroups = useMemo(
() =>
yearMaterialGroups.slice(
summaryPageOffset,
summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE,
),
[summaryPageOffset, yearMaterialGroups],
);
const summaryTotalCount = appliedFilters.analysisType === "YEAR_MATERIAL"
? yearMaterialGroups.length
: rows.length;
const sggOptions = useMemo(
() =>
@@ -410,11 +436,8 @@ export default function PipeAnalysisPopupPage() {
{appliedFilters.analysisType === "YEAR_MATERIAL" ? (
<>
<div className="card-header">
<h3 className="hidden">
寃쎄낵?꾨룄&?ъ쭏 愿€濡쒗쁽?? 珥?{yearMaterialGroups.length.toLocaleString("ko-KR")}嫄?
</h3>
<h3 className="text-2xl font-medium text-label">
{resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 관로현황 총 {yearMaterialGroups.length.toLocaleString("ko-KR")}건
{resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 관로현황 {summaryTotalCount.toLocaleString("ko-KR")}건
</h3>
</div>
<div className="card !p-0r !m-[1.2rem] overflow-x-auto">
@@ -434,8 +457,8 @@ export default function PipeAnalysisPopupPage() {
</TableRow>
</TableHeader>
<TableBody className="text-center">
{yearMaterialGroups.length > 0 ? (
yearMaterialGroups.flatMap((group) =>
{visibleYearMaterialGroups.length > 0 ? (
visibleYearMaterialGroups.flatMap((group) =>
group.rows.map((yearRow, rowIndex) => (
<TableRow key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}`}>
{rowIndex === 0 ? (
@@ -485,13 +508,23 @@ export default function PipeAnalysisPopupPage() {
) : (
<TableRow>
<TableCell colSpan={headList.length} className="h-24 border border-border text-center text-[1.3rem] text-gray-500">
議고쉶??寃곌낵媛€ ?놁뒿?덈떎.
조회 결과가 없습니다.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{yearMaterialGroups.length > PIPE_ANALYSIS_SUMMARY_PAGE_SIZE ? (
<div className="flex justify-center">
<UiPageNations
total={yearMaterialGroups.length}
cur={summaryCurrentPage}
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
onPageChange={setSummaryCurrentPage}
/>
</div>
) : null}
</>
) : null}
@@ -499,21 +532,26 @@ export default function PipeAnalysisPopupPage() {
className={appliedFilters.analysisType === "YEAR_MATERIAL"
? "hidden"
: useHorizontalScrollTable
? "[&>div.relative.w-full]:overflow-x-auto [&>div.relative.w-full]:overflow-y-visible"
? "overflow-x-auto overflow-y-visible [&_table]:min-w-full [&_table]:w-max"
: ""}
headName={
appliedFilters.analysisType === "YEAR_MATERIAL"
? "경과년도&재질 관로현황"
: "관로분석현황"
? `경과년도&재질 관로현황 ${summaryTotalCount.toLocaleString("ko-KR")}건`
: `관로분석현황 ${summaryTotalCount.toLocaleString("ko-KR")}건`
}
tableData={rows}
tableData={pagedRows}
headlist={headList}
pageSize={Math.max(rows.length, 1)}
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
total={rows.length}
cellClick={(index, columnId) => handleCellClick(index, columnId)}
onPageChange={() => undefined}
curPage={1}
cellClick={(index, columnId) =>
handleCellClick(
typeof index === "number" ? index + summaryPageOffset : index,
columnId,
)}
onPageChange={setSummaryCurrentPage}
curPage={summaryCurrentPage}
widthFixed={!useHorizontalScrollTable}
isHideCount={true}
cellClassName={(row, columnId) =>
!row.__isNoZone && row.__cellFilters[columnId]
? "cursor-pointer font-semibold text-blue-700 underline underline-offset-2"
@@ -46,6 +46,8 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
};
const { BIZ_SE_CD, BIZ_TY_CD } = useStore();
const requestMngInstCd = String(featureData?.mng_inst_cd ?? featureData?.mngInstCd ?? "").trim();
const requestFcltMngNo = String(featureData?.fclt_mng_no ?? featureData?.fcltMngNo ?? "").trim();
// 마운트 및 업데이트 시 API 호출
@@ -53,9 +55,18 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
if (featureData) {
setFormData(featureData);
}
}, [featureData]);
useEffect(() => {
if (!requestMngInstCd || !requestFcltMngNo) {
setInspectionData([]);
setCleaningData([]);
setRepairData([]);
return;
}
const fetchAll = async () => {
const operationData = await getData(`v1/bizInfo/bizByFclt?mngInstCd=${featureData?.mng_inst_cd}&fcltMngNo=${featureData?.fclt_mng_no}&page=0&size=1`);
const operationData = await getData(`v1/bizInfo/bizByFclt?mngInstCd=${requestMngInstCd}&fcltMngNo=${requestFcltMngNo}&page=0&size=1`);
let inspectionArr: operationDataType[] = [];
let cleaningArr: operationDataType[] = [];
@@ -104,8 +115,13 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
setCleaningData(cleaningArr);
setRepairData(repairArr);
};
fetchAll();
}, [featureData]);
fetchAll().catch((error) => {
console.error("운영 이력 조회 실패:", error);
setInspectionData([]);
setCleaningData([]);
setRepairData([]);
});
}, [BIZ_SE_CD, BIZ_TY_CD, requestFcltMngNo, requestMngInstCd]);
// 폼 데이터가 변경될 때 호출될 핸들러
const handleFormDataChange = (newFormData: any) => {
@@ -160,4 +176,4 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
)
}
export default MsSwagePpl
export default MsSwagePpl
@@ -33,9 +33,9 @@ const metricGroups: {
title: string;
field: keyof SwtrmtPrcsZoneAnalysisMetric;
}[] = [
{ title: "평균매설년도(년)", field: "avgInstlYear" },
{ title: "최장매설년도(년)", field: "oldestInstlYear" },
{ title: "최신매설년도(년)", field: "latestInstlYear" },
{ title: "평균경과년도(년)", field: "avgInstlYear" },
{ title: "최장경과년도(년)", field: "oldestInstlYear" },
{ title: "최신경과년도(년)", field: "latestInstlYear" },
];
const MetricVerticalGrid = ({
@@ -61,6 +61,11 @@ const linkedFacilityHighlightStyle = new Style({
zIndex: 9999,
});
type MngInstOption = {
mngInstCd: string;
lwrInstNm: string;
};
const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData: any }) => {
const { gisMap } = useGisContext() as unknown as { checkedLayerIds: React.MutableRefObject<number[]>; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; };
const [layerNm, setLayerNm] = useState<string>(""); // 레이어 이름 상태
@@ -80,6 +85,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
const [chmbrYdnts, setChmbrYdnts] = useState<string>(''); // 로딩 상태
const linkedFacilityFeatureRef = useRef<any>(null);
const linkedFacilityOriginalStyleRef = useRef<any>(null);
const [mngInstOptions, setMngInstOptions] = useState<MngInstOption[]>([]);
// 🎯 관로관리번호 추출
const pplMngNo = featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || "";
@@ -87,6 +93,10 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
const featureId = typeof featureData.getId === 'function'
? featureData.getId()
: featureData.fid;
const selectedLayerTypeName = featureData?.values_
? featureData?.id_?.split('.')?.[0]
: featureData?.lyrTypeNm;
const isPipeFeature = selectedLayerTypeName === 'tb_ms_swage_ppl';
// 🎯 데이터 전처리 메모이제이션
const processedData = useMemo(() => {
@@ -98,6 +108,90 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
};
}, [featureData?.values_]);
useEffect(() => {
let isMounted = true;
if (isPipeFeature) {
setMngInstOptions([]);
return () => {
isMounted = false;
};
}
const fetchMngInstOptions = async () => {
try {
const result = await getData("v1/fcltppl/mngInsts");
if (!isMounted) return;
const sortedOptions = Array.isArray(result)
? [...result].sort((a, b) =>
String(a?.lwrInstNm ?? a?.mngInstCd ?? "").localeCompare(
String(b?.lwrInstNm ?? b?.mngInstCd ?? ""),
"ko",
),
)
: [];
setMngInstOptions(sortedOptions);
} catch (error) {
console.error("관리기관 목록 조회 실패:", error);
if (isMounted) setMngInstOptions([]);
}
};
fetchMngInstOptions();
return () => {
isMounted = false;
};
}, [isPipeFeature]);
const mngInstNameMap = useMemo(() => {
const map = new Map<string, string>();
mngInstOptions.forEach((option) => {
const code = String(option.mngInstCd ?? "").trim();
if (!code) return;
map.set(code, option.lwrInstNm || code);
});
return map;
}, [mngInstOptions]);
const withMngInstName = (data: any) => {
if (!data || typeof data !== "object") return data;
const mngInstCd = String(data.mng_inst_cd ?? data.mngInstCd ?? "").trim();
if (!mngInstCd) return data;
return {
...data,
mngInstNm: mngInstNameMap.get(mngInstCd) ?? data.mngInstNm ?? data.mng_inst_nm ?? mngInstCd,
};
};
const hasDetailValue = (value: unknown) => {
if (value === null || value === undefined) return false;
if (typeof value === "string") return value.trim() !== "";
return true;
};
const isValidDetailIdentifier = (value: unknown) => {
if (typeof value !== "string") return false;
const normalizedValue = value.trim();
if (!normalizedValue) return false;
if (normalizedValue.toLowerCase() === "null") return false;
if (normalizedValue.toLowerCase() === "undefined") return false;
return true;
};
const mergeDetailData = (baseData: any, detailData: any) => {
if (!detailData || typeof detailData !== "object") return baseData;
const meaningfulDetailData = Object.entries(detailData).reduce<Record<string, unknown>>((acc, [key, value]) => {
if (hasDetailValue(value)) acc[key] = value;
return acc;
}, {});
return {
...(baseData ?? {}),
...meaningfulDetailData,
};
};
const clearLinkedFacilityHighlight = () => {
if (linkedFacilityFeatureRef.current) {
linkedFacilityFeatureRef.current.setStyle(linkedFacilityOriginalStyleRef.current ?? undefined);
@@ -334,7 +428,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
}
// 🎯 기본 데이터 포맷팅
const formattedData = formatDisplayData(data);
const formattedData = withMngInstName(formatDisplayData(data));
if (selectedLayerName === '하수관로') {
const installYmd = data.instl_ymd ?? data.instlYmd;
formattedData.elapsedYears = getElapsedYearsDisplayText(installYmd);
@@ -352,10 +446,14 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
setButtonChk(true)
// ✅ codeMapper 사용하여 API 결과도 변환
setTableData(codeMapper({
layerName: selectedLayerName,
data: { ...formattedData }
}));
{
const initialPipeData = withMngInstName(codeMapper({
layerName: selectedLayerName,
data: { ...formattedData }
}));
setFetchData(initialPipeData);
setTableData(initialPipeData);
}
break;
case '하수맨홀':
setButtonChk(true)
@@ -409,28 +507,32 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
// 자체입력정보 API 엔드포인트 선택
let apiEndpoint = '';
const fcltMngNo = featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || null;
const mngInstCd = featureData?.values_?.mng_inst_cd || featureData?.mngInstCd || null;
const fcltMngNo = String(featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || "").trim();
const mngInstCd = String(featureData?.values_?.mng_inst_cd || featureData?.mngInstCd || "").trim();
const canRequestDetail = isValidDetailIdentifier(fcltMngNo) && isValidDetailIdentifier(mngInstCd);
if (selectedLayerName === '하수관로') apiEndpoint = `v1/fcltppl/${fcltMngNo}/${mngInstCd}`;
else if (selectedLayerName === '우수토실') apiEndpoint = `v1/fcltstofcbr/${fcltMngNo}/${mngInstCd}`;
else if (selectedLayerName === '하수펌프장') apiEndpoint = `v1/fcltpump/${fcltMngNo}/${mngInstCd}`;
else if (selectedLayerName === '하수맨홀') apiEndpoint = `v1/fcltmhl/${fcltMngNo}/${mngInstCd}`;
// API에서 상세 데이터 가져오기
if (apiEndpoint) {
if (apiEndpoint && canRequestDetail) {
try {
const result = await getData(apiEndpoint);
if (result) {
const processedApiData = codeMapper({
const mergedData = mergeDetailData(formattedData, result);
const processedApiData = withMngInstName(codeMapper({
layerName: selectedLayerName,
data: { ...result, ...tableData }
});
data: mergedData
}));
setFetchData(processedApiData);
setTableData(processedApiData);
}
} catch (apiError) {
console.error('API 데이터 로딩 중 오류:', apiError);
void apiError;
console.warn("Pipe detail fallback kept initial feature data.");
}
}
} catch (error) {
@@ -439,7 +541,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
};
fetchData();
}, [featureId, processedData]);
}, [featureId, processedData, mngInstNameMap]);
useEffect(() => {
const layerTypeNm = featureData?.values_
@@ -0,0 +1,167 @@
"use client";
import { useMemo } from "react";
import { Button } from "@common/components";
import { VerticalTable } from "@/components/ui/verticalTable";
import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkholeTypes";
type Props = {
detail: SinkholeDetail;
nearbyPipes: SinkholeNearbyPipe[];
radiusMeters: number;
onClose: () => void;
onSelectPipe: (pipe: SinkholeNearbyPipe) => void;
};
const display = (value: unknown) => {
const normalized = String(value ?? "").trim();
return normalized ? normalized : "-";
};
const displayNumber = (value: unknown, suffix = "") => {
if (value === null || value === undefined || value === "") return "-";
const normalized = Number(value);
if (Number.isNaN(normalized)) return display(value);
return `${normalized.toLocaleString()}${suffix}`;
};
export default function SinkholeDetailPanel({
detail,
nearbyPipes,
radiusMeters,
onClose,
onSelectPipe,
}: Props) {
const canShowNearby = String(detail.coordStatus ?? "").trim() === "SUCCESS";
const summaryTableData = useMemo(
() => [
{
acdntNo: display(detail.acdntNo),
ocrnYmd: display(detail.ocrnYmd),
fullAddress: display(detail.fullAddress || detail.daddr),
sizeWidth: display(detail.sizeWidth),
sizeLength: display(detail.sizeLength),
sizeDepth: display(detail.sizeDepth),
detailCause: display(detail.detailCause),
restoreStatus: display(detail.restoreStatus),
restoreCompletedYmd: display(detail.restoreCompletedYmd),
},
],
[detail],
);
const summaryHeadList = [
{ id: "acdntNo", title: "사고번호" },
{ id: "ocrnYmd", title: "발생일자" },
{ id: "fullAddress", title: "주소" },
{ id: "sizeWidth", title: "발생규모폭" },
{ id: "sizeLength", title: "발생규모연장" },
{ id: "sizeDepth", title: "발생규모깊이" },
{ id: "detailCause", title: "상세발생원인" },
{ id: "restoreStatus", title: "복구상태" },
{ id: "restoreCompletedYmd", title: "복구완료일자" },
];
return (
<div
id="popup"
className="absolute top-0 right-0 z-[50] flex h-[calc(100vh-5rem)] w-[350px] flex-col overflow-hidden border-r border-gray-300 bg-white transition-all duration-300"
>
<div className="card-header relative flex items-center justify-between bg-primary p-4">
<div className="flex-1">
<h3 className="text-2xl font-medium text-white">
지반침하정보
<br />
사고번호 : {display(detail.acdntNo)}
</h3>
</div>
<Button
onClick={onClose}
className="flex items-center space-x-4 text-white hover:text-gray-300 focus:outline-none"
size="xs"
>
✕
</Button>
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden">
<VerticalTable
headName="발생정보"
tableData={summaryTableData}
headlist={summaryHeadList}
pageSize={20}
total={0}
cellClick={undefined}
onPageChange={function (): void {
throw new Error("Function not implemented.");
}}
/>
<section className="pb-[1.2rem]">
<div className="card-header flex items-center justify-between px-[1.2rem]">
<h3 className="text-2xl mx-3 font-medium text-label">
침하발생 반경내 관로정보
</h3>
<div className="mr-3 text-[1rem] text-slate-500">반경 {radiusMeters}m · {nearbyPipes.length}건</div>
</div>
{!canShowNearby ? (
<div className="card !m-[1.2rem] !mt-0 rounded-sm border border-amber-200 bg-amber-50 px-3 py-3 text-[1.05rem] text-amber-700">
좌표가 없어 반경 내 하수관로를 조회할 수 없습니다.
{detail.geocodeErrorMessage ? (
<div className="mt-2 break-words text-[0.98rem] text-amber-800">
사유: {display(detail.geocodeErrorMessage)}
</div>
) : null}
</div>
) : nearbyPipes.length === 0 ? (
<div className="card !m-[1.2rem] !mt-0 rounded-sm border border-slate-200 bg-slate-50 px-3 py-3 text-[1.05rem] text-slate-600">
반경 내 하수관로 정보가 없습니다.
</div>
) : (
<div className="card !mx-[1.2rem] !mb-[1.2rem] !mt-0 overflow-hidden">
<table className="w-full table-fixed border-collapse">
<colgroup>
<col className="w-[60px]" />
<col />
<col className="w-[88px]" />
</colgroup>
<thead>
<tr>
<th className="border border-gray-300 bg-blue-100 px-2 py-2 text-center text-[1rem] font-bold text-slate-900 whitespace-nowrap">
NO
</th>
<th className="border border-gray-300 bg-blue-100 px-2 py-2 text-center text-[1rem] font-bold text-slate-900 whitespace-nowrap">
관로 ID
</th>
<th className="border border-gray-300 bg-blue-100 px-2 py-2 text-center text-[1rem] font-bold text-slate-900 whitespace-nowrap">
거리(m)
</th>
</tr>
</thead>
<tbody>
{nearbyPipes.map((pipe, index) => (
<tr
key={`${pipe.fid}-${index}`}
className="cursor-pointer hover:bg-blue-50"
onClick={() => onSelectPipe(pipe)}
>
<td className="border border-gray-300 px-2 py-2 text-center text-[1rem] text-slate-900">
{index + 1}
</td>
<td className="border border-gray-300 px-2 py-2 text-left text-[1rem] font-semibold text-[#1d4ed8] underline underline-offset-2">
{display(pipe.pipeId)}
</td>
<td className="border border-gray-300 px-2 py-2 text-right text-[1rem] text-slate-900">
{displayNumber(pipe.distanceMeters)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
</div>
);
}
@@ -0,0 +1,129 @@
"use client";
import { useEffect, useState } from "react";
import type { SinkholeSyncSummary } from "./sinkholeTypes";
type Props = {
radiusMeters: number;
onApplyRadius: (radiusMeters: number) => void;
lastSyncSummary?: SinkholeSyncSummary | null;
isAdmin?: boolean;
compact?: boolean;
};
export default function SinkholeLayerSettingPanel({
radiusMeters,
onApplyRadius,
lastSyncSummary,
isAdmin = false,
compact = false,
}: Props) {
const [isCollapsed, setIsCollapsed] = useState(false);
const [radiusText, setRadiusText] = useState(String(radiusMeters));
useEffect(() => {
setRadiusText(String(radiusMeters));
}, [radiusMeters]);
const commitRadius = () => {
const nextValue = Number(radiusText.replace(/[^0-9]/g, ""));
if (!Number.isFinite(nextValue) || nextValue <= 0) {
alert("반경은 1m 이상 입력해야 합니다.");
return;
}
onApplyRadius(nextValue);
};
if (compact) {
return (
<div className="w-full rounded-[6px] border border-[#dbe7f5] bg-white px-3 py-2 shadow-[0_2px_8px_rgba(15,23,42,0.05)]">
<div className="flex min-h-[32px] items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-[1.15rem] font-medium text-slate-700">
<span className="h-2 w-2 shrink-0 rounded-full bg-[#243b93]" />
<label htmlFor="sinkhole-radius" className="whitespace-nowrap">지반침하반경(m)</label>
</div>
<div className="flex shrink-0 items-center gap-1">
<input
id="sinkhole-radius"
className="h-[22px] w-14 border border-[#ccd6df] px-1 text-center text-[1.2rem]"
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={radiusText}
onFocus={(event) => event.currentTarget.select()}
onChange={(event) => setRadiusText(event.target.value.replace(/[^0-9]/g, ""))}
onKeyDown={(event) => {
if (event.key === "Enter") commitRadius();
}}
/>
<button
type="button"
className="grid h-[20px] w-[20px] place-items-center rounded-full bg-[#31a83b] text-[1.15rem] font-bold leading-none text-white"
onClick={commitRadius}
title="적용"
>
✓
</button>
</div>
</div>
</div>
);
}
return (
<div className="w-full overflow-hidden rounded-sm border border-[#f0d7b0] bg-[#fff8ef]">
<div className="relative flex min-h-[30px] items-center justify-center border-b border-[#f2dfc3] bg-white px-8">
<button
type="button"
className="absolute left-2 top-1/2 h-[18px] w-[18px] -translate-y-1/2 bg-transparent text-[1.2rem] leading-none text-[#555]"
onClick={() => setIsCollapsed((value) => !value)}
title={isCollapsed ? "펼치기" : "접기"}
>
{isCollapsed ? "›" : "⌄"}
</button>
<span className="text-[1.2rem] font-semibold text-[#d97706]">지반침하정보</span>
</div>
{!isCollapsed && (
<div className="px-2 py-2">
<div className="flex min-h-[30px] items-center gap-1 whitespace-nowrap text-[1.2rem] text-[#333]">
<label htmlFor="sinkhole-radius">지반침하반경(m)</label>
<input
id="sinkhole-radius"
className="h-[22px] w-14 border border-[#ccd6df] px-1 text-center text-[1.2rem]"
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={radiusText}
onFocus={(event) => event.currentTarget.select()}
onChange={(event) => setRadiusText(event.target.value.replace(/[^0-9]/g, ""))}
onKeyDown={(event) => {
if (event.key === "Enter") commitRadius();
}}
/>
<button
type="button"
className="grid h-[22px] w-[22px] place-items-center rounded-full bg-[#31a83b] text-[1.4rem] font-bold leading-none text-white"
onClick={commitRadius}
title="적용"
>
✓
</button>
</div>
{isAdmin && lastSyncSummary ? (
<div className="mt-2 rounded-sm border border-[#f2dfc3] bg-white px-2 py-2 text-[1rem] text-[#7c5b2f]">
<div className="font-semibold">최근 동기화</div>
<div className="mt-1">완료시각: {lastSyncSummary.finishedAt || "-"}</div>
<div className="mt-1">
성공 {lastSyncSummary.successCount}건 / 실패 {lastSyncSummary.failedCount}건
</div>
</div>
) : null}
</div>
)}
</div>
);
}
@@ -0,0 +1,421 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Button, Input } from "@common/components";
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import { getRawJson } from "@/components/lib/api";
import useStore from "@/store/useStore";
import LoadingOverlay from "@/components/ui/LoadingBar";
import type { SelectDataType } from "@common/business_components";
import {
buildSinkholeDefaultFilters,
buildSinkholeListQueryString,
formatDateInputToYmd,
formatYmdToDateInput,
normalizeSinkholeFilters,
SINKHOLE_COORD_STATUS_OPTIONS,
SINKHOLE_DOWNLOAD_EXCEL_HEADERS,
SINKHOLE_FILTER_SYNC_MESSAGE,
SINKHOLE_MIN_SEARCH_YMD,
SINKHOLE_RESTORE_STATUS_OPTIONS,
SINKHOLE_ROW_SELECT_MESSAGE,
type SinkholeFilters,
type SinkholeListItem,
} from "./sinkholeTypes";
const PAGE_SIZE = 20;
const DOWNLOAD_BATCH_SIZE = 2000;
type SinkholeTableRow = SinkholeListItem & {
rowNo: number;
sizeWidthDisplay: string;
sizeLengthDisplay: string;
sizeDepthDisplay: string;
detailCauseDisplay: string;
restoreCompletedYmdDisplay: string;
coordStatusDisplay: string;
};
const HEADLIST = [
{ id: "rowNo", title: "NO", widthPercent: 6 },
{ id: "ocrnYmd", title: "발생일자", widthPercent: 11 },
{ id: "fullAddress", title: "주소", widthPercent: 22 },
{ id: "sizeWidthDisplay", title: "발생규모폭", widthPercent: 10 },
{ id: "sizeLengthDisplay", title: "발생규모연장", widthPercent: 10 },
{ id: "sizeDepthDisplay", title: "발생규모깊이", widthPercent: 10 },
{ id: "detailCauseDisplay", title: "상세발생원인", widthPercent: 12 },
{ id: "restoreStatus", title: "복구상태", widthPercent: 9 },
{ id: "coordStatusDisplay", title: "좌표보정상태", widthPercent: 10 },
{ id: "restoreCompletedYmdDisplay", title: "복구완료일자", widthPercent: 10 },
] as const;
const toDisplay = (value: unknown) => {
const normalized = String(value ?? "").trim();
return normalized ? normalized : "-";
};
const toCoordStatusDisplay = (value: unknown) => {
const normalized = String(value ?? "").trim().toUpperCase();
if (normalized === "SUCCESS") return "성공";
if (normalized === "NO_ADDRESS") return "주소없음";
if (normalized === "FAILED") return "실패";
return normalized || "-";
};
const toTableRows = (items: SinkholeListItem[], page: number, pageSize = PAGE_SIZE) =>
items.map((item, index) => ({
...item,
rowNo: page * pageSize + index + 1,
sizeWidthDisplay: toDisplay(item.sizeWidth),
sizeLengthDisplay: toDisplay(item.sizeLength),
sizeDepthDisplay: toDisplay(item.sizeDepth),
detailCauseDisplay: toDisplay(item.detailCause),
coordStatusDisplay: toCoordStatusDisplay(item.coordStatus),
restoreCompletedYmdDisplay: toDisplay(item.restoreCompletedYmd),
}));
const buildDownloadRows = (rows: SinkholeTableRow[]) =>
rows.map((row) => ({
rowNo: row.rowNo,
ocrnYmd: row.ocrnYmd || "-",
fullAddress: row.fullAddress || "-",
sizeWidth: row.sizeWidthDisplay,
sizeLength: row.sizeLengthDisplay,
sizeDepth: row.sizeDepthDisplay,
detailCause: row.detailCauseDisplay,
restoreStatus: row.restoreStatus || "-",
restoreCompletedYmd: row.restoreCompletedYmdDisplay,
}));
const unwrapPayload = <T,>(response: any): T =>
(response?.data ?? response) as T;
export default function SinkholePopupPage() {
const searchParams = useSearchParams();
const hasInitialFilterParams =
searchParams.has("startYmd") ||
searchParams.has("endYmd") ||
searchParams.has("ctpvCd") ||
searchParams.has("sggCd") ||
searchParams.has("restoreStatus") ||
searchParams.has("coordStatus");
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [filters, setFilters] = useState<SinkholeFilters>(() => {
const seeded = normalizeSinkholeFilters(
{
startYmd: searchParams.get("startYmd") ?? "",
endYmd: searchParams.get("endYmd") ?? "",
ctpvCd: searchParams.get("ctpvCd") ?? "0",
sggCd: searchParams.get("sggCd") ?? "0",
restoreStatus: searchParams.get("restoreStatus") ?? "0",
coordStatus: searchParams.get("coordStatus") ?? "0",
},
undefined,
);
if (!hasInitialFilterParams) {
return normalizeSinkholeFilters(buildSinkholeDefaultFilters(), undefined);
}
return seeded;
});
const [appliedFilters, setAppliedFilters] = useState<SinkholeFilters>(() => {
const seeded = normalizeSinkholeFilters(
{
startYmd: searchParams.get("startYmd") ?? "",
endYmd: searchParams.get("endYmd") ?? "",
ctpvCd: searchParams.get("ctpvCd") ?? "0",
sggCd: searchParams.get("sggCd") ?? "0",
restoreStatus: searchParams.get("restoreStatus") ?? "0",
coordStatus: searchParams.get("coordStatus") ?? "0",
},
undefined,
);
if (!hasInitialFilterParams) {
return normalizeSinkholeFilters(buildSinkholeDefaultFilters(), undefined);
}
return seeded;
});
const [rows, setRows] = useState<SinkholeTableRow[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
const sggOptions = useMemo(
() =>
filters.ctpvCd === "0"
? sgg
: sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))),
[filters.ctpvCd, sgg],
);
useEffect(() => {
if (ctpv.length === 0) fetchCtpv();
if (sgg.length === 0) fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
const fetchRows = useCallback(
async (
page: number,
nextFilters: SinkholeFilters,
pageSize = PAGE_SIZE,
options?: {
includeCoordStatus?: boolean;
},
) => {
const queryString = buildSinkholeListQueryString(
nextFilters,
ctpv as SelectDataType[],
sgg as SelectDataType[],
page - 1,
pageSize,
options,
);
const response = await getRawJson(`/sewerage-service/api/v1/sinkholes?${queryString}`);
const payload = unwrapPayload<{ items?: SinkholeListItem[]; total?: number }>(response);
const items = Array.isArray(payload?.items) ? payload.items : [];
return {
rows: toTableRows(items, page - 1, pageSize),
items,
total: Number(payload?.total ?? 0),
};
},
[ctpv, sgg],
);
const loadRows = useCallback(
async (page = 1, nextFilters = filters) => {
setIsLoading(true);
try {
const normalized = normalizeSinkholeFilters(nextFilters);
const response = await fetchRows(page, normalized);
setRows(response.rows);
setTotal(response.total);
setCurrentPage(page);
setAppliedFilters(normalized);
window.opener?.postMessage(
{
type: SINKHOLE_FILTER_SYNC_MESSAGE,
filters: normalized,
},
window.location.origin,
);
} catch (error) {
console.error("지반침하 목록 조회 중 오류:", error);
setRows([]);
setTotal(0);
alert(error instanceof Error ? error.message : "지반침하 목록 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
},
[fetchRows, filters],
);
useEffect(() => {
void loadRows(1, filters);
// 최초 로딩은 한 번만 수행한다.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSearch = async (page = 1) => {
const normalized = normalizeSinkholeFilters(filters);
setFilters(normalized);
await loadRows(page, normalized);
};
const handleRowClick = (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!row) return;
setSelectedIndex(index);
window.opener?.postMessage(
{
type: SINKHOLE_ROW_SELECT_MESSAGE,
row,
},
window.location.origin,
);
};
const handleDownload = async () => {
if (total === 0) {
alert("다운로드할 데이터가 없습니다.");
return;
}
setIsLoading(true);
try {
const normalized = normalizeSinkholeFilters(appliedFilters);
const downloadItems: SinkholeListItem[] = [];
const totalPages = Math.max(1, Math.ceil(total / DOWNLOAD_BATCH_SIZE));
for (let page = 1; page <= totalPages; page += 1) {
const response = await fetchRows(page, normalized, DOWNLOAD_BATCH_SIZE, {
includeCoordStatus: false,
});
downloadItems.push(...response.items);
}
if (downloadItems.length === 0) {
alert("다운로드할 데이터가 없습니다.");
return;
}
const downloadRows = toTableRows(downloadItems, 0, 1);
await downloadExcelWithKoreanHeaders({
data: buildDownloadRows(downloadRows) as unknown as JSON[],
fileName: `지반침하정보_${new Date().toISOString().slice(0, 10)}.xlsx`,
sheetNameArray: ["지반침하정보"],
headerRange: "A1:I1",
headerMapping: SINKHOLE_DOWNLOAD_EXCEL_HEADERS,
});
} catch (error) {
console.error("지반침하 다운로드 중 오류:", error);
alert(error instanceof Error ? error.message : "지반침하 다운로드 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
return (
<main className="flex h-screen flex-col overflow-hidden bg-white">
<LoadingOverlay isLoading={isLoading} />
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
<h1 className="text-[1.45rem] font-semibold text-white">지반침하발생현황</h1>
<div />
</header>
<section className="overflow-y-auto p-4">
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[170px_170px_220px_220px_180px_180px_auto] md:items-end md:justify-between">
<label className="block md:max-w-[170px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">발생기간(시작)</div>
<Input
className="h-10 !text-[1.1rem]"
type="date"
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
value={formatYmdToDateInput(filters.startYmd)}
onChange={(event) => setFilters({ ...filters, startYmd: formatDateInputToYmd(event.target.value) })}
/>
</label>
<label className="block md:max-w-[170px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">발생기간(종료)</div>
<Input
className="h-10 !text-[1.1rem]"
type="date"
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
value={formatYmdToDateInput(filters.endYmd)}
onChange={(event) => setFilters({ ...filters, endYmd: formatDateInputToYmd(event.target.value) })}
/>
</label>
<label className="block md:max-w-[220px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.ctpvCd}
onChange={(event) =>
setFilters({
...filters,
ctpvCd: event.target.value,
sggCd: "0",
})
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="block md:max-w-[220px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.sggCd}
onChange={(event) => setFilters({ ...filters, sggCd: event.target.value })}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="block md:max-w-[180px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">복구상태</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.restoreStatus}
onChange={(event) => setFilters({ ...filters, restoreStatus: event.target.value })}
>
{SINKHOLE_RESTORE_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block md:max-w-[180px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">좌표보정상태</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.coordStatus}
onChange={(event) => setFilters({ ...filters, coordStatus: event.target.value })}
>
{SINKHOLE_COORD_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<div className="flex flex-wrap justify-end gap-2 md:pr-3">
<Button
type="button"
size="sm"
color="primary"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
size="sm"
color="green"
className="h-10 px-5 !text-[1.1rem]"
onClick={handleDownload}
>
다운로드
</Button>
</div>
</div>
<UiTable<SinkholeTableRow>
headName={<>지반침하 조회 결과 총 {total.toLocaleString()}건</>}
tableData={rows}
headlist={HEADLIST as any}
pageSize={PAGE_SIZE}
total={total}
cellClick={(index) => handleRowClick(index)}
onPageChange={(page) => void handleSearch(page)}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
isHideCount={true}
/>
</section>
</main>
);
}
@@ -0,0 +1,478 @@
"use client";
import type { SelectDataType } from "@common/business_components";
import type { IUserInfo } from "@/shared/store";
export const SINKHOLE_CONTROL_NODE_ID = "SN_SINKHOLE";
export const SINKHOLE_LAYER_TYPE = "tb_sn_sinkhole_info";
export const SINKHOLE_LAYER_NAME = "지반침하정보";
export const SINKHOLE_ALL_YEAR_NODE_ID = "SN_SINKHOLE_YEAR_ALL";
export const SINKHOLE_YEAR_NODE_PREFIX = "SN_SINKHOLE_YEAR_";
export const SINKHOLE_POPUP_KEY = "sinkholePopup";
export const SINKHOLE_ROW_SELECT_MESSAGE = "SN_SINKHOLE_ROW_SELECT";
export const SINKHOLE_FILTER_SYNC_MESSAGE = "SN_SINKHOLE_FILTER_SYNC";
export const SINKHOLE_SYNC_COMPLETED_MESSAGE = "SN_SINKHOLE_SYNC_COMPLETED";
export const SINKHOLE_DEFAULT_RADIUS = 100;
export const SINKHOLE_LAYER_ID = "SN_SINKHOLE_LAYER";
export const SINKHOLE_SELECTED_LAYER_ID = "SN_SINKHOLE_SELECTED_LAYER";
export const SINKHOLE_MIN_SEARCH_YMD = "20160101";
export const SINKHOLE_YEAR_ICON_ORANGE = "SN_SINKHOLE_SPOT_ORANGE.png";
export const SINKHOLE_YEAR_ICON_BLUE = "SN_SINKHOLE_SPOT_BLUE.png";
export type SinkholeFilters = {
startYmd: string;
endYmd: string;
ctpvCd: string;
sggCd: string;
restoreStatus: string;
coordStatus: string;
};
export type SinkholeListItem = {
acdntNo: string;
ctpvNm?: string;
sggNm?: string;
emdNm?: string;
fullAddress: string;
ocrnYmd: string;
restoreStatus?: string;
restoreCompletedYmd?: string;
coordStatus?: string;
geocodeStatus?: string;
geocodeSource?: string;
geocodeErrorMessage?: string;
latitude?: number | null;
longitude?: number | null;
x?: number | null;
y?: number | null;
sizeWidth?: number | string | null;
sizeLength?: number | string | null;
sizeDepth?: number | string | null;
detailCause?: string | null;
deathCount?: number | null;
injuryCount?: number | null;
vehicleDamageCount?: number | null;
};
export type SinkholeDetail = SinkholeListItem & {
daddr?: string;
};
export type SinkholeNearbyPipe = {
pipeId: string;
fid: string;
lyrTypeNm: string;
pipeTypeCd?: string;
pipeTypeName?: string;
diameter?: number | string | null;
length?: number | string | null;
distanceMeters?: number | string | null;
};
export type SinkholeSyncSummary = {
syncId: string;
startedAt: string;
finishedAt: string;
sourceCount: number;
successCount: number;
failedCount: number;
reGeocodedCount: number;
message: string;
};
export type SinkholeScope = {
ctpvCd: string;
sggCd: string;
ctpvLocked: boolean;
sggLocked: boolean;
};
const toYmd = (date: Date) =>
`${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, "0")}${String(date.getDate()).padStart(2, "0")}`;
export const buildSinkholeDefaultFilters = (scope?: SinkholeScope): SinkholeFilters => {
const today = new Date();
const oneYearAgo = new Date(today);
oneYearAgo.setFullYear(today.getFullYear() - 1);
return {
startYmd: toYmd(oneYearAgo),
endYmd: toYmd(today),
ctpvCd: scope?.ctpvCd || "0",
sggCd: scope?.sggCd || "0",
restoreStatus: "0",
coordStatus: "0",
};
};
export const buildSinkholeTreeBaseFilters = (scope?: SinkholeScope): SinkholeFilters => ({
startYmd: "",
endYmd: "",
ctpvCd: scope?.ctpvCd || "0",
sggCd: scope?.sggCd || "0",
restoreStatus: "0",
coordStatus: "0",
});
export const buildSinkholeScope = (userInfo: IUserInfo | null): SinkholeScope => {
const admin = userInfo?.adminRoleYn === "Y";
if (admin) {
return {
ctpvCd: "0",
sggCd: "0",
ctpvLocked: false,
sggLocked: false,
};
}
const digits = String(userInfo?.laeSeCd ?? "").replace(/[^0-9]/g, "");
if (digits.length >= 5) {
return {
ctpvCd: digits.slice(0, 2),
sggCd: digits.slice(0, 5),
ctpvLocked: true,
sggLocked: true,
};
}
if (digits.length >= 2) {
return {
ctpvCd: digits.slice(0, 2),
sggCd: "0",
ctpvLocked: true,
sggLocked: false,
};
}
return {
ctpvCd: "0",
sggCd: "0",
ctpvLocked: false,
sggLocked: false,
};
};
export const normalizeSinkholeFilters = (
filters: SinkholeFilters,
scope?: SinkholeScope,
): SinkholeFilters => {
const next = {
startYmd: String(filters.startYmd ?? "").replace(/[^0-9]/g, "").slice(0, 8),
endYmd: String(filters.endYmd ?? "").replace(/[^0-9]/g, "").slice(0, 8),
ctpvCd: String(filters.ctpvCd ?? "0") || "0",
sggCd: String(filters.sggCd ?? "0") || "0",
restoreStatus: String(filters.restoreStatus ?? "0") || "0",
coordStatus: String(filters.coordStatus ?? "0") || "0",
};
if (scope?.ctpvCd && scope.ctpvCd !== "0") {
next.ctpvCd = scope.ctpvCd;
}
if (scope?.sggCd && scope.sggCd !== "0") {
next.sggCd = scope.sggCd;
} else if (next.ctpvCd !== "0" && next.sggCd !== "0" && !next.sggCd.startsWith(next.ctpvCd)) {
next.sggCd = "0";
}
return next;
};
export const formatYmdToDateInput = (value?: string) => {
const normalized = String(value ?? "").replace(/[^0-9]/g, "");
if (normalized.length !== 8) return "";
return `${normalized.slice(0, 4)}-${normalized.slice(4, 6)}-${normalized.slice(6, 8)}`;
};
export const formatDateInputToYmd = (value?: string) => String(value ?? "").replace(/[^0-9]/g, "").slice(0, 8);
export const resolveRegionNameByCode = (
code: string,
options: SelectDataType[],
length: number,
) => {
const normalized = String(code ?? "").trim();
if (!normalized || normalized === "0") return "";
const key = normalized.slice(0, length);
return options.find((item) => String(item.val).trim() === key)?.text ?? "";
};
export const buildSinkholeListQueryString = (
filters: SinkholeFilters,
ctpvOptions: SelectDataType[],
sggOptions: SelectDataType[],
page = 0,
size = 20,
options?: {
includeCoordStatus?: boolean;
},
) => {
const params = new URLSearchParams({
page: String(page),
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);
}
if (options?.includeCoordStatus === false) {
params.set("includeCoordStatus", "false");
}
return params.toString();
};
export const isSinkholeLayerNode = (node: any) => {
const data = node?.data ?? node;
const nodeId = String(node?.id ?? data?.lyrSn ?? "");
const layerName = String(data?.lyrNm ?? data?.name ?? "").trim();
const layerTypeName = String(data?.lyrTyNm ?? data?.lyrTypeNm ?? data?.lyr_type_nm ?? "").trim();
return (
nodeId === SINKHOLE_CONTROL_NODE_ID ||
nodeId === SINKHOLE_ALL_YEAR_NODE_ID ||
nodeId.startsWith(SINKHOLE_YEAR_NODE_PREFIX) ||
layerName === SINKHOLE_LAYER_NAME ||
layerTypeName === SINKHOLE_LAYER_TYPE
);
};
export const isSinkholeRootNode = (node: any) => {
const data = node?.data ?? node;
const nodeId = String(node?.id ?? data?.lyrSn ?? "");
const layerName = String(data?.lyrNm ?? data?.name ?? "").trim();
return nodeId === SINKHOLE_CONTROL_NODE_ID || layerName === SINKHOLE_LAYER_NAME;
};
export const isSinkholeYearNode = (node: any) => {
const data = node?.data ?? node;
const nodeId = String(node?.id ?? data?.lyrSn ?? "");
const nodeText = String(node?.text ?? data?.lyrNm ?? data?.name ?? "").trim();
return (
nodeId === SINKHOLE_ALL_YEAR_NODE_ID ||
nodeId.startsWith(SINKHOLE_YEAR_NODE_PREFIX) ||
nodeText === "전체" ||
/^\d{4}년$/.test(nodeText)
);
};
const resolveSinkholeYearToken = (node: any) => {
const data = node?.data ?? node;
const explicitYear = String(data?.sinkholeYear ?? "").trim();
if (explicitYear) {
return explicitYear === "ALL" || /^\d{4}$/.test(explicitYear) ? explicitYear : "";
}
const nodeText = String(node?.text ?? data?.lyrNm ?? data?.name ?? "").trim();
if (nodeText === "전체") {
return "ALL";
}
const yearMatch = nodeText.match(/^(\d{4})년$/);
return yearMatch?.[1] ?? "";
};
export const resolveSinkholeLegendIconName = (node: any) => {
const sinkholeYear = resolveSinkholeYearToken(node);
if (sinkholeYear === "ALL") {
return SINKHOLE_YEAR_ICON_BLUE;
}
if (/^\d{4}$/.test(sinkholeYear)) {
return SINKHOLE_YEAR_ICON_ORANGE;
}
return null;
};
export const buildSinkholeYearNodes = (currentYear = new Date().getFullYear()) => {
return [
{
lyrSn: `${SINKHOLE_YEAR_NODE_PREFIX}${currentYear}`,
id: `${SINKHOLE_YEAR_NODE_PREFIX}${currentYear}`,
lyrNm: `${currentYear}년`,
upperLyrSn: SINKHOLE_CONTROL_NODE_ID,
upId: SINKHOLE_CONTROL_NODE_ID,
sortOrdr: "1",
drawOrdr: null,
flterSql: null,
gobjTy: "",
lyrTyNm: SINKHOLE_LAYER_TYPE,
sinkholeYear: String(currentYear),
iconNm: SINKHOLE_YEAR_ICON_ORANGE,
useYn: "Y",
minViewSize: null,
iconSize: null,
lblAtrbNm: null,
fontNm: null,
fontIntnColor: null,
fontExtnColor: null,
fontExtnWdth: null,
lnWdth: null,
lnColor: null,
polgnColor: null,
initEprssYn: "Y",
clickPsblYn: "N",
exprsYn: "Y",
cmncNm: "all",
srvcNm: "api",
actlLyrYn: "N",
clckZoom: null,
children: null,
},
{
lyrSn: SINKHOLE_ALL_YEAR_NODE_ID,
id: SINKHOLE_ALL_YEAR_NODE_ID,
lyrNm: "전체",
upperLyrSn: SINKHOLE_CONTROL_NODE_ID,
upId: SINKHOLE_CONTROL_NODE_ID,
sortOrdr: "99",
drawOrdr: null,
flterSql: null,
gobjTy: "",
lyrTyNm: SINKHOLE_LAYER_TYPE,
sinkholeYear: "ALL",
iconNm: SINKHOLE_YEAR_ICON_BLUE,
useYn: "Y",
minViewSize: null,
iconSize: null,
lblAtrbNm: null,
fontNm: null,
fontIntnColor: null,
fontExtnColor: null,
fontExtnWdth: null,
lnWdth: null,
lnColor: null,
polgnColor: null,
initEprssYn: "Y",
clickPsblYn: "N",
exprsYn: "Y",
cmncNm: "all",
srvcNm: "api",
actlLyrYn: "N",
clckZoom: null,
children: null,
},
];
};
export const applySinkholeNodeFilters = (filters: SinkholeFilters, node: any) => {
const data = node?.data ?? node;
const sinkholeYear = resolveSinkholeYearToken(node);
const filterSql = String(data?.flterSql ?? data?.filterSql ?? "").trim();
if (sinkholeYear === "ALL") {
return {
...filters,
startYmd: "",
endYmd: "",
};
}
const rangeMatch = filterSql.match(
/ocrn_ymd\s*>=\s*'(\d{8})'.*ocrn_ymd\s*<=\s*'(\d{8})'/i,
);
if (rangeMatch) {
return {
...filters,
startYmd: rangeMatch[1] ?? "",
endYmd: rangeMatch[2] ?? "",
};
}
if (/^\d{4}$/.test(sinkholeYear)) {
return {
...filters,
startYmd: `${sinkholeYear}0101`,
endYmd: `${sinkholeYear}1231`,
};
}
return filters;
};
export const applySinkholeYearFilters = (filters: SinkholeFilters, checkedNodesData: any[]) => {
const selectedYears = checkedNodesData
.map((node) => resolveSinkholeYearToken(node))
.filter(Boolean);
if (selectedYears.includes("ALL")) {
return filters;
}
if (selectedYears.length === 0) {
return filters;
}
const numericYears = selectedYears
.map((year) => Number(year))
.filter((year) => Number.isFinite(year))
.sort((left, right) => left - right);
if (numericYears.length === 0) {
return filters;
}
const yearStart = `${numericYears[0]}0101`;
const yearEnd = `${numericYears[numericYears.length - 1]}1231`;
const nextStart = filters.startYmd ? (filters.startYmd > yearStart ? filters.startYmd : yearStart) : yearStart;
const nextEnd = filters.endYmd ? (filters.endYmd < yearEnd ? filters.endYmd : yearEnd) : yearEnd;
if (nextStart > nextEnd) {
return {
...filters,
startYmd: "99991231",
endYmd: "99991231",
};
}
return {
...filters,
startYmd: nextStart,
endYmd: nextEnd,
};
};
export const SINKHOLE_RESTORE_STATUS_OPTIONS = [
{ value: "0", label: "전체" },
{ value: "복구완료", label: "복구완료" },
{ value: "복구중", label: "복구중" },
{ value: "미복구", label: "미복구" },
] as const;
export const SINKHOLE_COORD_STATUS_OPTIONS = [
{ value: "0", label: "전체" },
{ value: "SUCCESS", label: "성공" },
{ value: "FAILED", label: "실패" },
] as const;
export const SINKHOLE_EXCEL_HEADERS: Record<string, string> = {
rowNo: "NO",
ocrnYmd: "발생일자",
fullAddress: "주소",
sizeWidth: "발생규모폭",
sizeLength: "발생규모연장",
sizeDepth: "발생규모깊이",
detailCause: "상세발생원인",
restoreStatus: "복구상태",
coordStatus: "좌표보정상태",
restoreCompletedYmd: "복구완료일자",
};
export const SINKHOLE_DOWNLOAD_EXCEL_HEADERS: Record<string, string> = {
rowNo: "NO",
ocrnYmd: "발생일자",
fullAddress: "주소",
sizeWidth: "발생규모폭",
sizeLength: "발생규모연장",
sizeDepth: "발생규모깊이",
detailCause: "상세발생원인",
restoreStatus: "복구상태",
restoreCompletedYmd: "복구완료일자",
};
@@ -0,0 +1,876 @@
"use client";
import { Fragment, useEffect, useMemo, useState } from "react";
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
import { Button } from "@common/components";
import {
WINDOW_POPUP_MESSAGE_TYPE,
type WindowPopupContentProps,
} from "@/features/windowPopup/model/types";
import {
buildWaterRiskMethodLabel,
buildWaterRiskSettingAreaName,
buildWaterRiskStatusLabel,
EMPTY_WATER_RISK_SESSION_SNAPSHOT,
WATER_RISK_RESULT_ROWS,
WATER_RISK_SETTING_ROWS,
WATER_RISK_SGG_OPTIONS_BY_SIDO,
WATER_RISK_SIDO_OPTIONS,
type WaterRiskResultRow,
type WaterRiskSessionSnapshot,
type WaterRiskSettingRow,
type WaterRiskStagedPipe,
} from "./waterRiskTypes";
type WaterRiskView = "result" | "setting";
type ResultFilters = {
analysisDate: string;
analysisTime: string;
sidoName: string;
sggName: string;
areaId: string;
riskStatus: "ALL" | "DANGER" | "NORMAL";
};
type SettingFilters = {
sidoName: string;
sggName: string;
areaId: string;
};
const DEFAULT_RESULT_FILTERS: ResultFilters = {
analysisDate: "2026-05-18",
analysisTime: "09:00",
sidoName: "전체",
sggName: "전체",
areaId: "ALL",
riskStatus: "ALL",
};
const DEFAULT_SETTING_FILTERS: SettingFilters = {
sidoName: "전체",
sggName: "전체",
areaId: "ALL",
};
const TOOLBAR_INPUT_CLASS_NAME =
"h-[30px] rounded-none border border-[#8f98a3] bg-white px-2 text-[12px] text-[#1f2937] outline-none";
const TOOLBAR_BUTTON_CLASS_NAME =
"h-[30px] min-w-[44px] rounded-none border border-[#7c5a20] bg-[#ef8d2f] px-3 text-[12px] font-semibold text-white hover:bg-[#df7f24]";
const SECONDARY_BUTTON_CLASS_NAME =
"h-[28px] rounded-none border border-[#8f98a3] bg-white px-3 text-[12px] text-[#334155] hover:bg-[#f8fafc]";
const ACTION_BUTTON_CLASS_NAME =
"h-[28px] rounded-[6px] border border-[#2e77bc] bg-[#3f8fd7] px-3 text-[12px] font-semibold text-white hover:bg-[#337ec1]";
const WATER_RISK_PAGE_SIZE = 10;
const RESULT_TABLE_CONTAINER_CLASS_NAME =
"min-h-0 overflow-hidden rounded-[10px] border border-[#d6e1ee] bg-[linear-gradient(180deg,#ffffff_0%,#f9fbfe_100%)] shadow-[0_6px_16px_rgba(31,59,87,0.06)]";
const SETTING_TABLE_CONTAINER_CLASS_NAME =
"min-h-0 overflow-hidden rounded-[10px] border border-[#d6e1ee] bg-[linear-gradient(180deg,#ffffff_0%,#f9fbfe_100%)] shadow-[0_6px_16px_rgba(31,59,87,0.06)]";
const postMessageToOpener = (type: string, payload?: Record<string, unknown>) => {
if (typeof window === "undefined" || !window.opener) return;
window.opener.postMessage({ type, payload }, window.location.origin);
};
const buildPreviewTimestamp = () => new Date().toISOString();
const buildResultDownloadPreview = (
filters: ResultFilters,
rows: WaterRiskResultRow[],
) => ({
endpoint: "/api/v1/map/wraResults/download",
method: "POST",
generatedAt: buildPreviewTimestamp(),
filters,
resultCount: rows.length,
});
const buildSettingDownloadPreview = (
filters: SettingFilters,
rows: WaterRiskSettingRow[],
) => ({
endpoint: "/api/v1/map/wraOutletPipeSettings/download",
method: "POST",
generatedAt: buildPreviewTimestamp(),
filters,
resultCount: rows.length,
});
const buildSavePreview = (
snapshot: WaterRiskSessionSnapshot,
stagedPipes: WaterRiskStagedPipe[],
) => ({
endpoint: "/api/v1/map/wraOutletPipeSettings/save",
method: "POST",
generatedAt: buildPreviewTimestamp(),
body: {
focusAreaId: snapshot.selectedSettingAreaId,
focusAreaName: snapshot.selectedSettingAreaName,
pipes: stagedPipes.map((pipe, index) => ({
pipeId: pipe.pipeId,
setSeq: index + 1,
})),
},
});
const getRiskBadgeClassName = (riskStatus: "DANGER" | "NORMAL") =>
riskStatus === "DANGER"
? "bg-[#ff1c15] text-white"
: "bg-white text-[#111827]";
export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
const [activeView, setActiveView] = useState<WaterRiskView>("result");
const [snapshot, setSnapshot] = useState<WaterRiskSessionSnapshot>(
EMPTY_WATER_RISK_SESSION_SNAPSHOT,
);
const [draftResultFilters, setDraftResultFilters] = useState<ResultFilters>(
DEFAULT_RESULT_FILTERS,
);
const [appliedResultFilters, setAppliedResultFilters] = useState<ResultFilters>(
DEFAULT_RESULT_FILTERS,
);
const [draftSettingFilters, setDraftSettingFilters] = useState<SettingFilters>(
DEFAULT_SETTING_FILTERS,
);
const [appliedSettingFilters, setAppliedSettingFilters] = useState<SettingFilters>(
DEFAULT_SETTING_FILTERS,
);
const [selectedResultAreaId, setSelectedResultAreaId] = useState<string>(
WATER_RISK_RESULT_ROWS[0]?.areaId ?? "",
);
const [selectedSettingAreaId, setSelectedSettingAreaId] = useState<string>("");
const [resultCurrentPage, setResultCurrentPage] = useState(1);
const [settingCurrentPage, setSettingCurrentPage] = useState(1);
const [noticeMessage, setNoticeMessage] = useState("");
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SYNC) return;
const nextSnapshot = (event.data.payload ??
EMPTY_WATER_RISK_SESSION_SNAPSHOT) as WaterRiskSessionSnapshot;
setSnapshot(nextSnapshot);
if (nextSnapshot.selectedAreaId) {
setSelectedResultAreaId(nextSnapshot.selectedAreaId);
}
if (nextSnapshot.selectedSettingAreaId) {
setSelectedSettingAreaId(nextSnapshot.selectedSettingAreaId);
}
};
window.addEventListener("message", handleMessage);
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_READY);
return () => {
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_STOP_SETTING_EDIT);
window.removeEventListener("message", handleMessage);
};
}, []);
const visibleResultRows = useMemo(() => {
return WATER_RISK_RESULT_ROWS.filter((row) => {
if (
appliedResultFilters.sidoName !== "전체" &&
row.sidoName !== appliedResultFilters.sidoName
) {
return false;
}
if (
appliedResultFilters.sggName !== "전체" &&
row.sggName !== appliedResultFilters.sggName
) {
return false;
}
if (
appliedResultFilters.areaId !== "ALL" &&
row.areaId !== appliedResultFilters.areaId
) {
return false;
}
if (
appliedResultFilters.riskStatus !== "ALL" &&
row.riskStatus !== appliedResultFilters.riskStatus
) {
return false;
}
return true;
});
}, [appliedResultFilters]);
const visibleSettingRows = useMemo(() => {
return WATER_RISK_SETTING_ROWS.filter((row) => {
if (
appliedSettingFilters.sidoName !== "전체" &&
row.sidoName !== appliedSettingFilters.sidoName
) {
return false;
}
if (
appliedSettingFilters.sggName !== "전체" &&
row.sggName !== appliedSettingFilters.sggName
) {
return false;
}
if (
appliedSettingFilters.areaId !== "ALL" &&
row.areaId !== appliedSettingFilters.areaId
) {
return false;
}
return true;
});
}, [appliedSettingFilters]);
const maxPipeCount = useMemo(
() =>
visibleResultRows.reduce(
(maxCount, row) => Math.max(maxCount, row.pipeSummaries.length),
0,
),
[visibleResultRows],
);
const resultAreaOptions = useMemo(
() =>
WATER_RISK_RESULT_ROWS.map((row) => ({
areaId: row.areaId,
areaName: row.areaName,
})),
[],
);
const settingAreaOptions = useMemo(() => {
const areaMap = new Map<string, { areaId: string; areaName: string }>();
WATER_RISK_SETTING_ROWS.forEach((row) => {
areaMap.set(row.areaId, {
areaId: row.areaId,
areaName: row.areaName,
});
});
return Array.from(areaMap.values());
}, []);
const resultSggOptions =
WATER_RISK_SGG_OPTIONS_BY_SIDO[draftResultFilters.sidoName] ??
WATER_RISK_SGG_OPTIONS_BY_SIDO["전체"] ??
[];
const settingSggOptions =
WATER_RISK_SGG_OPTIONS_BY_SIDO[draftSettingFilters.sidoName] ??
WATER_RISK_SGG_OPTIONS_BY_SIDO["전체"] ??
[];
const currentSettingAreaName =
snapshot.selectedSettingAreaName ||
buildWaterRiskSettingAreaName(selectedSettingAreaId || snapshot.selectedSettingAreaId);
const pagedResultRows = useMemo(() => {
const startIndex = (resultCurrentPage - 1) * WATER_RISK_PAGE_SIZE;
return visibleResultRows.slice(startIndex, startIndex + WATER_RISK_PAGE_SIZE);
}, [resultCurrentPage, visibleResultRows]);
const pagedSettingRows = useMemo(() => {
const startIndex = (settingCurrentPage - 1) * WATER_RISK_PAGE_SIZE;
return visibleSettingRows.slice(startIndex, startIndex + WATER_RISK_PAGE_SIZE);
}, [settingCurrentPage, visibleSettingRows]);
const showSettingEditor = snapshot.isSettingEditMode || snapshot.stagedPipes.length > 0;
const resultPipeGroupCount = Math.max(1, maxPipeCount);
const resultTableMinWidth = 560 + resultPipeGroupCount * 180;
const settingTableMinWidth = 900;
useEffect(() => {
if (typeof window === "undefined" || !window.resizeTo) return;
const popupWidth =
activeView === "result"
? Math.min(1320, Math.max(860, 620 + resultPipeGroupCount * 180))
: showSettingEditor
? 1180
: 980;
const popupHeight = activeView === "setting" && showSettingEditor ? 900 : 720;
window.resizeTo(popupWidth, popupHeight);
}, [activeView, resultPipeGroupCount, showSettingEditor]);
const handleApplyResultFilters = () => {
if (!draftResultFilters.analysisDate || !draftResultFilters.analysisTime) {
setNoticeMessage("기준 날짜와 시간을 입력해야 조회할 수 있습니다.");
return;
}
setResultCurrentPage(1);
setAppliedResultFilters(draftResultFilters);
setNoticeMessage("통수위험 분석 결과를 갱신했습니다.");
};
const handleOpenSettingView = () => {
setActiveView("setting");
setNoticeMessage("");
};
const handleReturnToResultView = () => {
setActiveView("result");
setNoticeMessage("");
};
const handleSelectResultRow = (row: WaterRiskResultRow) => {
setSelectedResultAreaId(row.areaId);
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT, {
areaId: row.areaId,
});
setNoticeMessage(`${row.areaName}을 지도에서 강조했습니다.`);
};
const handlePreviewResultDownload = () => {
const preview = buildResultDownloadPreview(appliedResultFilters, visibleResultRows);
console.info("WaterRisk result download preview", preview);
setNoticeMessage("결과 다운로드 preview를 생성했습니다. 콘솔을 확인하세요.");
};
const handleApplySettingFilters = () => {
setSettingCurrentPage(1);
setAppliedSettingFilters(draftSettingFilters);
setNoticeMessage("설정 현황 목록을 갱신했습니다.");
};
const handleStopSettingEdit = () => {
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_STOP_SETTING_EDIT);
setNoticeMessage("통수위험 설정 편집 모드를 종료했습니다.");
};
const handleRemoveStagedPipe = (pipeId: string) => {
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_REMOVE_STAGED_PIPE, {
pipeId,
});
};
const handleMoveStagedPipe = (pipeId: string, direction: "UP" | "DOWN") => {
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_REORDER_STAGED_PIPE, {
pipeId,
direction,
});
};
const handleClearStagedPipes = () => {
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_CLEAR_STAGED_PIPES);
};
const handlePreviewSave = () => {
if (!snapshot.selectedSettingAreaId || snapshot.stagedPipes.length === 0) {
setNoticeMessage("저장할 유출관로가 없습니다.");
return;
}
const preview = buildSavePreview(snapshot, snapshot.stagedPipes);
console.info("WaterRisk save preview", preview);
setNoticeMessage("설정 저장 payload preview를 생성했습니다. 콘솔을 확인하세요.");
};
const handleSelectSettingArea = (areaId: string) => {
setSelectedSettingAreaId(areaId);
const targetAreaName =
settingAreaOptions.find((item) => item.areaId === areaId)?.areaName ??
buildWaterRiskSettingAreaName(areaId);
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_START_SETTING_EDIT, {
areaId,
areaName: targetAreaName,
});
setNoticeMessage(`${targetAreaName} 설정 편집 대상을 선택했습니다.`);
};
return (
<main className="flex h-screen flex-col overflow-hidden bg-[linear-gradient(180deg,#f7f9fc_0%,#eef4fa_100%)] text-[#202a36]">
<header className="flex h-[46px] items-center border-b border-[#e2ebf5] bg-[linear-gradient(180deg,#ffffff_0%,#f3f8fd_100%)] px-[18px]">
{activeView === "setting" ? (
<button
type="button"
className="mr-3 text-[12px] font-semibold text-[#51657a] hover:text-[#1f2937]"
onClick={handleReturnToResultView}
>
← 결과조회
</button>
) : null}
<h1 className="text-[1.1rem] font-semibold tracking-[-0.01em] text-[#1f3b57]">
{activeView === "result" ? "통수위험분석" : "통수위험분석 설정"}
</h1>
</header>
<section className="flex-1 overflow-hidden px-[12px] pb-[12px] pt-[10px]">
{activeView === "result" ? (
<section className="flex h-full min-h-0 flex-col gap-[8px]">
<div className="overflow-x-auto">
<div className="flex min-w-max items-center gap-[12px] rounded-[10px] border border-[#d6e1ee] bg-[linear-gradient(180deg,#ffffff_0%,#f9fbfe_100%)] px-[10px] py-[8px] shadow-[0_6px_16px_rgba(31,59,87,0.06)]">
<div className="flex flex-1 items-center gap-[4px]">
<input
type="date"
value={draftResultFilters.analysisDate}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
analysisDate: event.target.value,
}))
}
aria-label="년월일"
className={`${TOOLBAR_INPUT_CLASS_NAME} w-[120px]`}
/>
<input
type="time"
value={draftResultFilters.analysisTime}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
analysisTime: event.target.value,
}))
}
aria-label="시간"
className={`${TOOLBAR_INPUT_CLASS_NAME} w-[100px]`}
/>
<select
value={draftResultFilters.sidoName}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
sidoName: event.target.value,
sggName: "전체",
}))
}
aria-label="시도"
className={`${TOOLBAR_INPUT_CLASS_NAME} w-[94px]`}
>
{WATER_RISK_SIDO_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
<select
value={draftResultFilters.sggName}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
sggName: event.target.value,
}))
}
aria-label="시군구"
className={`${TOOLBAR_INPUT_CLASS_NAME} w-[94px]`}
>
{resultSggOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
<select
value={draftResultFilters.areaId}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
areaId: event.target.value,
}))
}
aria-label="중점관리지역"
className={`${TOOLBAR_INPUT_CLASS_NAME} w-[120px]`}
>
<option value="ALL">중점관리지역</option>
{resultAreaOptions.map((option) => (
<option key={option.areaId} value={option.areaId}>
{option.areaName}
</option>
))}
</select>
<select
value={draftResultFilters.riskStatus}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
riskStatus: event.target.value as ResultFilters["riskStatus"],
}))
}
aria-label="위험상태"
className={`${TOOLBAR_INPUT_CLASS_NAME} w-[74px]`}
>
<option value="ALL">위험</option>
<option value="DANGER">위험</option>
<option value="NORMAL">정상</option>
</select>
</div>
<div className="ml-auto flex items-center gap-[4px]">
<Button
type="button"
size="sm"
className="h-[30px] min-w-[48px] rounded-[4px] bg-[#2f7ec4] px-[10px] text-[12px] font-semibold text-white hover:bg-[#266fad]"
onClick={handleApplyResultFilters}
>
조회
</Button>
<Button
type="button"
size="sm"
className="h-[30px] min-w-[48px] rounded-[4px] bg-[#ef8d2f] px-[10px] text-[12px] font-semibold text-white hover:bg-[#df7f24]"
onClick={handleOpenSettingView}
>
설정
</Button>
</div>
</div>
</div>
<div className={RESULT_TABLE_CONTAINER_CLASS_NAME}>
<div className="overflow-x-auto overflow-y-hidden">
<table
className="border-collapse text-[12px]"
style={{ minWidth: `${resultTableMinWidth}px`, width: "100%" }}
>
<thead>
<tr className="bg-[#eaf3fb] text-[#647784]">
<th rowSpan={2} className="border border-[#d1dae2] px-2 py-2">no</th>
<th rowSpan={2} className="border border-[#d1dae2] px-2 py-2">시도</th>
<th rowSpan={2} className="border border-[#d1dae2] px-2 py-2">시군구</th>
<th rowSpan={2} className="border border-[#d1dae2] px-2 py-2">중점관리지역</th>
<th rowSpan={2} className="border border-[#d1dae2] px-2 py-2">예측강우량(mm)</th>
{Array.from({ length: maxPipeCount }).map((_, index) => (
<th
key={`pipe-group-${index + 1}`}
colSpan={2}
className="border border-[#d1dae2] px-2 py-2"
>
관로 ID {index + 1}
</th>
))}
<th rowSpan={2} className="border border-[#d1dae2] px-2 py-2">통수위험</th>
</tr>
<tr className="bg-[#eaf3fb] text-[#647784]">
{Array.from({ length: maxPipeCount }).map((_, index) => (
<Fragment key={`pipe-sub-${index + 1}`}>
<th className="border border-[#d1dae2] px-2 py-1">유출유량(㎥/hr)</th>
<th className="border border-[#d1dae2] px-2 py-1">통수량(㎥/hr)</th>
</Fragment>
))}
</tr>
</thead>
<tbody>
{pagedResultRows.map((row, index) => {
const isSelected = row.areaId === selectedResultAreaId;
const rowNumber =
(resultCurrentPage - 1) * WATER_RISK_PAGE_SIZE + index + 1;
return (
<tr
key={row.areaId}
className={`cursor-pointer ${isSelected ? "bg-[#eef6ff]" : "bg-white"}`}
onClick={() => handleSelectResultRow(row)}
>
<td className="border border-[#dbe4ec] px-2 py-2 text-center">{rowNumber}</td>
<td className="border border-[#dbe4ec] px-2 py-2">{row.sidoName}</td>
<td className="border border-[#dbe4ec] px-2 py-2">{row.sggName}</td>
<td className="border border-[#dbe4ec] px-2 py-2 font-semibold">{row.areaName}</td>
<td className="border border-[#dbe4ec] px-2 py-2 text-right">{row.forecastRainQty.toFixed(1)}</td>
{Array.from({ length: maxPipeCount }).map((_, pipeIndex) => {
const pipe = row.pipeSummaries[pipeIndex];
return (
<Fragment key={`${row.areaId}-pipe-${pipeIndex}`}>
<td className="border border-[#dbe4ec] px-2 py-2 text-right">
{pipe ? pipe.runoffFlowQty.toFixed(1) : ""}
</td>
<td className="border border-[#dbe4ec] px-2 py-2 text-right">
{pipe ? pipe.conveyQty.toFixed(1) : ""}
</td>
</Fragment>
);
})}
<td className="border border-[#dbe4ec] px-2 py-2 text-center">
<span className={`inline-flex min-w-[56px] items-center justify-center px-2 py-1 text-[12px] font-semibold ${getRiskBadgeClassName(row.riskStatus)}`}>
{buildWaterRiskStatusLabel(row.riskStatus)}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{visibleResultRows.length > WATER_RISK_PAGE_SIZE ? (
<div className="flex justify-center">
<UiPageNations
total={visibleResultRows.length}
cur={resultCurrentPage}
pageSize={WATER_RISK_PAGE_SIZE}
onPageChange={setResultCurrentPage}
/>
</div>
) : null}
</section>
) : (
<section className="flex h-full min-h-0 flex-col gap-2">
<div className="overflow-x-auto">
<div className="flex min-w-max items-center gap-[12px] rounded-[10px] border border-[#d6e1ee] bg-[linear-gradient(180deg,#ffffff_0%,#f9fbfe_100%)] px-[8px] py-[8px] shadow-[0_6px_16px_rgba(31,59,87,0.06)]">
<div className="flex flex-1 items-center gap-[4px]">
<select
value={draftSettingFilters.sidoName}
onChange={(event) =>
setDraftSettingFilters((current) => ({
...current,
sidoName: event.target.value,
sggName: "전체",
}))
}
aria-label="시도"
className={`${TOOLBAR_INPUT_CLASS_NAME} w-[94px]`}
>
{WATER_RISK_SIDO_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
<select
value={draftSettingFilters.sggName}
onChange={(event) =>
setDraftSettingFilters((current) => ({
...current,
sggName: event.target.value,
}))
}
aria-label="시군구"
className={`${TOOLBAR_INPUT_CLASS_NAME} w-[94px]`}
>
{settingSggOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
<select
value={draftSettingFilters.areaId}
onChange={(event) =>
setDraftSettingFilters((current) => ({
...current,
areaId: event.target.value,
}))
}
aria-label="중점관리지역"
className={`${TOOLBAR_INPUT_CLASS_NAME} w-[120px]`}
>
<option value="ALL">중점관리지역</option>
{settingAreaOptions.map((option) => (
<option key={option.areaId} value={option.areaId}>
{option.areaName}
</option>
))}
</select>
</div>
<div className="ml-auto flex items-center gap-[4px]">
<Button
type="button"
size="sm"
className="h-[30px] min-w-[48px] rounded-[4px] bg-[#2f7ec4] px-[10px] text-[12px] font-semibold text-white hover:bg-[#266fad]"
onClick={handleApplySettingFilters}
>
조회
</Button>
</div>
</div>
</div>
<div
className={`${SETTING_TABLE_CONTAINER_CLASS_NAME} ${showSettingEditor ? "flex-[0_0_44%]" : ""}`}
>
<div className="overflow-x-auto overflow-y-hidden">
<table
className="border-collapse text-[12px]"
style={{ minWidth: `${settingTableMinWidth}px`, width: "100%" }}
>
<thead className="bg-[#eaf3fb] text-[#647784]">
<tr>
<th className="border border-[#d1dae2] px-2 py-2">시도</th>
<th className="border border-[#d1dae2] px-2 py-2">시군구</th>
<th className="border border-[#d1dae2] px-2 py-2">중점관리지역</th>
<th className="border border-[#d1dae2] px-2 py-2">유출관로ID</th>
<th className="border border-[#d1dae2] px-2 py-2">관경</th>
<th className="border border-[#d1dae2] px-2 py-2">연장</th>
<th className="border border-[#d1dae2] px-2 py-2">재질</th>
<th className="border border-[#d1dae2] px-2 py-2">설정순번</th>
<th className="border border-[#d1dae2] px-2 py-2">설정완료여부</th>
</tr>
</thead>
<tbody>
{pagedSettingRows.map((row, index) => {
const isSelected =
row.areaId === (selectedSettingAreaId || snapshot.selectedSettingAreaId);
return (
<tr
key={`${row.areaId}-${row.pipeId}`}
className={`cursor-pointer ${isSelected ? "bg-[#eef6ff]" : "bg-white"}`}
onClick={() => handleSelectSettingArea(row.areaId)}
>
<td className="border border-[#dbe4ec] px-2 py-2">{row.sidoName}</td>
<td className="border border-[#dbe4ec] px-2 py-2">{row.sggName}</td>
<td className="border border-[#dbe4ec] px-2 py-2 font-semibold">{row.areaName}</td>
<td className="border border-[#dbe4ec] px-2 py-2">{row.pipeId}</td>
<td className="border border-[#dbe4ec] px-2 py-2">{row.pipeDia}</td>
<td className="border border-[#dbe4ec] px-2 py-2">{row.pipeLen}</td>
<td className="border border-[#dbe4ec] px-2 py-2">{row.pipeMaterial}</td>
<td className="border border-[#dbe4ec] px-2 py-2 text-center">{row.setSeq}</td>
<td className="border border-[#dbe4ec] px-2 py-2 text-center">
{row.setCompleteYn === "Y" ? "완료" : "미완료"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{visibleSettingRows.length > WATER_RISK_PAGE_SIZE ? (
<div className="flex justify-center">
<UiPageNations
total={visibleSettingRows.length}
cur={settingCurrentPage}
pageSize={WATER_RISK_PAGE_SIZE}
onPageChange={setSettingCurrentPage}
/>
</div>
) : null}
{showSettingEditor ? (
<div className="min-h-0 flex-1 overflow-hidden rounded-[10px] border border-[#d6e1ee] bg-[linear-gradient(180deg,#ffffff_0%,#f9fbfe_100%)] shadow-[0_6px_16px_rgba(31,59,87,0.06)]">
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center justify-between border-b border-[#e2ebf5] px-3 py-2">
<div className="text-[12px] font-semibold text-[#1f3b57]">
설정 편집 대상: {currentSettingAreaName || "선택 전"}
</div>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
color="white"
className="h-[28px] rounded-[4px] border border-[#cad7e6] bg-white px-[10px] text-[12px] text-[#43566b] hover:bg-[#f8fbff]"
onClick={handleStopSettingEdit}
>
편집 종료
</Button>
<Button
type="button"
size="sm"
color="white"
className="h-[28px] rounded-[4px] border border-[#cad7e6] bg-white px-[10px] text-[12px] text-[#43566b] hover:bg-[#f8fbff]"
onClick={handleClearStagedPipes}
disabled={snapshot.stagedPipes.length === 0}
>
목록 초기화
</Button>
<Button
type="button"
size="sm"
className={ACTION_BUTTON_CLASS_NAME}
onClick={handlePreviewSave}
>
저장 preview
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<table className="w-full border-collapse text-[12px]">
<thead className="bg-[#e1ebf5] text-[#24364d]">
<tr>
<th className="border border-[#dbe4ec] px-2 py-2">순번</th>
<th className="border border-[#dbe4ec] px-2 py-2">유출관로ID</th>
<th className="border border-[#dbe4ec] px-2 py-2">관경</th>
<th className="border border-[#dbe4ec] px-2 py-2">연장</th>
<th className="border border-[#dbe4ec] px-2 py-2">재질</th>
<th className="border border-[#dbe4ec] px-2 py-2">선택원천</th>
<th className="border border-[#dbe4ec] px-2 py-2">조작</th>
</tr>
</thead>
<tbody>
{snapshot.stagedPipes.length === 0 ? (
<tr>
<td
colSpan={7}
className="border border-[#dbe4ec] px-3 py-6 text-center text-[#64748b]"
>
staged 유출관로가 없습니다. 지도에서 하수관로를 선택하세요.
</td>
</tr>
) : (
snapshot.stagedPipes.map((pipe, index) => (
<tr
key={pipe.pipeId}
className="bg-white"
>
<td className="border border-[#dbe4ec] px-2 py-2 text-center">{index + 1}</td>
<td className="border border-[#dbe4ec] px-2 py-2 font-semibold">{pipe.pipeId}</td>
<td className="border border-[#dbe4ec] px-2 py-2">{pipe.pipeDia}</td>
<td className="border border-[#dbe4ec] px-2 py-2">{pipe.pipeLen}</td>
<td className="border border-[#dbe4ec] px-2 py-2">{pipe.pipeMaterial}</td>
<td className="border border-[#dbe4ec] px-2 py-2">{pipe.source}</td>
<td className="border border-[#dbe4ec] px-2 py-2">
<div className="flex items-center gap-1">
<Button
type="button"
size="sm"
color="white"
className="h-[28px] rounded-[4px] border border-[#cad7e6] bg-white px-[10px] text-[12px] text-[#43566b] hover:bg-[#f8fbff]"
onClick={() => handleMoveStagedPipe(pipe.pipeId, "UP")}
disabled={index === 0}
>
위
</Button>
<Button
type="button"
size="sm"
color="white"
className="h-[28px] rounded-[4px] border border-[#cad7e6] bg-white px-[10px] text-[12px] text-[#43566b] hover:bg-[#f8fbff]"
onClick={() => handleMoveStagedPipe(pipe.pipeId, "DOWN")}
disabled={index === snapshot.stagedPipes.length - 1}
>
아래
</Button>
<Button
type="button"
size="sm"
color="white"
className="h-[28px] rounded-none border border-[#e7b7b7] bg-white px-3 text-[12px] text-[#c62828] hover:bg-[#fff8f8]"
onClick={() => handleRemoveStagedPipe(pipe.pipeId)}
>
삭제
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
) : null}
</section>
)}
</section>
</main>
);
}
@@ -0,0 +1,936 @@
"use client";
export type WaterRiskStatus = "DANGER" | "NORMAL";
export type WaterRiskCalculationMethod = "METHOD_1" | "METHOD_2";
export type WaterRiskTab = "result" | "setting";
export type WaterRiskPipeSummary = {
pipeId: string;
runoffFlowQty: number;
conveyQty: number;
};
export type WaterRiskResultRow = {
areaId: string;
areaName: string;
sidoName: string;
sggName: string;
forecastRainQty: number;
riskStatus: WaterRiskStatus;
calculationMethod: WaterRiskCalculationMethod;
pipeSummaries: WaterRiskPipeSummary[];
};
export type WaterRiskSettingRow = {
areaId: string;
areaName: string;
sidoName: string;
sggName: string;
pipeId: string;
pipeDia: string;
pipeLen: string;
pipeMaterial: string;
setSeq: number;
setCompleteYn: "Y" | "N";
};
export type WaterRiskStagedPipe = {
pipeId: string;
pipeDia: string;
pipeLen: string;
pipeMaterial: string;
setSeq: number;
source: "MAP" | "MOCK";
};
export type WaterRiskGridCell = {
gridId: string;
rainQty: number;
colorLevel: "LEVEL_1" | "LEVEL_2" | "LEVEL_3" | "LEVEL_4";
polygonCoordinates: number[][][];
};
export type WaterRiskAreaMapData = {
areaId: string;
areaName: string;
polygonCoordinates: number[][][];
gridCells: WaterRiskGridCell[];
};
export type WaterRiskMapPipe = {
pipeId: string;
displayLabel: string;
lineCoordinates: number[][];
};
export type WaterRiskSessionSnapshot = {
selectedAreaId: string | null;
selectedSettingAreaId: string | null;
selectedSettingAreaName: string;
isSettingEditMode: boolean;
stagedPipes: WaterRiskStagedPipe[];
statusMessage: string;
};
export const EMPTY_WATER_RISK_SESSION_SNAPSHOT: WaterRiskSessionSnapshot = {
selectedAreaId: null,
selectedSettingAreaId: null,
selectedSettingAreaName: "",
isSettingEditMode: false,
stagedPipes: [],
statusMessage: "",
};
const buildRectanglePolygon = (
centerX: number,
centerY: number,
halfWidth: number,
halfHeight: number,
) => {
return [[
[centerX - halfWidth, centerY - halfHeight],
[centerX + halfWidth, centerY - halfHeight],
[centerX + halfWidth, centerY + halfHeight],
[centerX - halfWidth, centerY + halfHeight],
[centerX - halfWidth, centerY - halfHeight],
]];
};
const buildGridCells = (
centerX: number,
centerY: number,
values: number[],
): WaterRiskGridCell[] => {
const cellHalfSize = 180;
const offsets = [
[-190, 190],
[190, 190],
[-190, -190],
[190, -190],
] as const;
return offsets.map(([offsetX, offsetY], index) => {
const rainQty = values[index] ?? values[values.length - 1] ?? 0;
const colorLevel = rainQty >= 40
? "LEVEL_4"
: rainQty >= 30
? "LEVEL_3"
: rainQty >= 20
? "LEVEL_2"
: "LEVEL_1";
return {
gridId: `GRID-${index + 1}`,
rainQty,
colorLevel,
polygonCoordinates: buildRectanglePolygon(
centerX + offsetX,
centerY + offsetY,
cellHalfSize,
cellHalfSize,
),
};
});
};
export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [
{
areaId: "AREA-001",
areaName: "A 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
forecastRainQty: 42.0,
riskStatus: "DANGER",
calculationMethod: "METHOD_1",
pipeSummaries: [
{ pipeId: "PIPE-10284", runoffFlowQty: 18.5, conveyQty: 15.2 },
{ pipeId: "PIPE-10291", runoffFlowQty: 11.3, conveyQty: 12.8 },
],
},
{
areaId: "AREA-002",
areaName: "B 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
forecastRainQty: 31.4,
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
{ pipeId: "PIPE-45103", runoffFlowQty: 9.8, conveyQty: 14.7 },
],
},
{
areaId: "AREA-003",
areaName: "C 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
forecastRainQty: 37.8,
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
{ pipeId: "PIPE-60014", runoffFlowQty: 16.1, conveyQty: 12.9 },
{ pipeId: "PIPE-60018", runoffFlowQty: 10.2, conveyQty: 11.1 },
{ pipeId: "PIPE-60021", runoffFlowQty: 8.7, conveyQty: 9.5 },
],
},
{
areaId: "AREA-004",
areaName: "D 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
forecastRainQty: 24.3,
riskStatus: "NORMAL",
calculationMethod: "METHOD_2",
pipeSummaries: [
{ pipeId: "PIPE-72010", runoffFlowQty: 7.2, conveyQty: 11.4 },
{ pipeId: "PIPE-72018", runoffFlowQty: 6.1, conveyQty: 9.8 },
],
},
{
areaId: "AREA-005",
areaName: "E 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
forecastRainQty: 28.6,
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
{ pipeId: "PIPE-83014", runoffFlowQty: 8.3, conveyQty: 12.1 },
],
},
{
areaId: "AREA-006",
areaName: "F 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
forecastRainQty: 45.1,
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
{ pipeId: "PIPE-83018", runoffFlowQty: 17.4, conveyQty: 12.3 },
{ pipeId: "PIPE-83019", runoffFlowQty: 13.6, conveyQty: 10.1 },
],
},
{
areaId: "AREA-007",
areaName: "G 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
forecastRainQty: 33.2,
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
{ pipeId: "PIPE-91004", runoffFlowQty: 9.7, conveyQty: 12.5 },
],
},
{
areaId: "AREA-008",
areaName: "H 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
forecastRainQty: 39.4,
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
{ pipeId: "PIPE-91010", runoffFlowQty: 15.5, conveyQty: 11.6 },
{ pipeId: "PIPE-91012", runoffFlowQty: 12.4, conveyQty: 10.8 },
{ pipeId: "PIPE-91014", runoffFlowQty: 9.2, conveyQty: 9.0 },
],
},
{
areaId: "AREA-009",
areaName: "I 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
forecastRainQty: 26.1,
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
{ pipeId: "PIPE-99001", runoffFlowQty: 6.7, conveyQty: 10.4 },
],
},
{
areaId: "AREA-010",
areaName: "J 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
forecastRainQty: 41.8,
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
{ pipeId: "PIPE-99007", runoffFlowQty: 14.8, conveyQty: 10.9 },
{ pipeId: "PIPE-99009", runoffFlowQty: 10.1, conveyQty: 9.7 },
],
},
{
areaId: "AREA-011",
areaName: "K 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
forecastRainQty: 29.7,
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
{ pipeId: "PIPE-11001", runoffFlowQty: 8.8, conveyQty: 12.5 },
{ pipeId: "PIPE-11002", runoffFlowQty: 7.9, conveyQty: 11.4 },
],
},
{
areaId: "AREA-012",
areaName: "L 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
forecastRainQty: 43.6,
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
{ pipeId: "PIPE-12001", runoffFlowQty: 16.7, conveyQty: 12.2 },
],
},
{
areaId: "AREA-013",
areaName: "M 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
forecastRainQty: 35.2,
riskStatus: "DANGER",
calculationMethod: "METHOD_1",
pipeSummaries: [
{ pipeId: "PIPE-13001", runoffFlowQty: 14.4, conveyQty: 11.6 },
{ pipeId: "PIPE-13002", runoffFlowQty: 10.5, conveyQty: 10.9 },
{ pipeId: "PIPE-13003", runoffFlowQty: 8.6, conveyQty: 9.3 },
],
},
{
areaId: "AREA-014",
areaName: "N 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
forecastRainQty: 21.9,
riskStatus: "NORMAL",
calculationMethod: "METHOD_2",
pipeSummaries: [
{ pipeId: "PIPE-14001", runoffFlowQty: 5.8, conveyQty: 8.7 },
],
},
{
areaId: "AREA-015",
areaName: "O 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
forecastRainQty: 47.3,
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
{ pipeId: "PIPE-15001", runoffFlowQty: 18.2, conveyQty: 13.4 },
{ pipeId: "PIPE-15002", runoffFlowQty: 12.7, conveyQty: 10.8 },
],
},
{
areaId: "AREA-016",
areaName: "P 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
forecastRainQty: 27.5,
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
{ pipeId: "PIPE-16001", runoffFlowQty: 7.1, conveyQty: 10.3 },
{ pipeId: "PIPE-16002", runoffFlowQty: 6.2, conveyQty: 9.8 },
],
},
{
areaId: "AREA-017",
areaName: "Q 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
forecastRainQty: 32.8,
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
{ pipeId: "PIPE-17001", runoffFlowQty: 9.9, conveyQty: 11.7 },
],
},
{
areaId: "AREA-018",
areaName: "R 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
forecastRainQty: 44.9,
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
{ pipeId: "PIPE-18001", runoffFlowQty: 17.1, conveyQty: 12.4 },
{ pipeId: "PIPE-18002", runoffFlowQty: 13.4, conveyQty: 10.6 },
{ pipeId: "PIPE-18003", runoffFlowQty: 9.8, conveyQty: 9.1 },
],
},
{
areaId: "AREA-019",
areaName: "S 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
forecastRainQty: 23.4,
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
{ pipeId: "PIPE-19001", runoffFlowQty: 6.4, conveyQty: 9.6 },
],
},
{
areaId: "AREA-020",
areaName: "T 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
forecastRainQty: 40.7,
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
{ pipeId: "PIPE-20001", runoffFlowQty: 15.2, conveyQty: 11.4 },
{ pipeId: "PIPE-20002", runoffFlowQty: 11.9, conveyQty: 10.1 },
],
},
];
export const WATER_RISK_SETTING_ROWS: WaterRiskSettingRow[] = [
{
areaId: "AREA-001",
areaName: "A 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
pipeId: "PIPE-10284",
pipeDia: "900 mm",
pipeLen: "186 m",
pipeMaterial: "콘크리트",
setSeq: 1,
setCompleteYn: "Y",
},
{
areaId: "AREA-001",
areaName: "A 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
pipeId: "PIPE-10291",
pipeDia: "700 mm",
pipeLen: "142 m",
pipeMaterial: "흄관",
setSeq: 2,
setCompleteYn: "Y",
},
{
areaId: "AREA-002",
areaName: "B 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
pipeId: "PIPE-45103",
pipeDia: "600 mm",
pipeLen: "98 m",
pipeMaterial: "PE",
setSeq: 1,
setCompleteYn: "Y",
},
{
areaId: "AREA-003",
areaName: "C 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
pipeId: "PIPE-60014",
pipeDia: "800 mm",
pipeLen: "154 m",
pipeMaterial: "강관",
setSeq: 1,
setCompleteYn: "Y",
},
{
areaId: "AREA-003",
areaName: "C 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
pipeId: "PIPE-60018",
pipeDia: "700 mm",
pipeLen: "119 m",
pipeMaterial: "흄관",
setSeq: 2,
setCompleteYn: "Y",
},
{
areaId: "AREA-004",
areaName: "D 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
pipeId: "PIPE-72010",
pipeDia: "500 mm",
pipeLen: "101 m",
pipeMaterial: "PVC",
setSeq: 1,
setCompleteYn: "N",
},
{
areaId: "AREA-005",
areaName: "E 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
pipeId: "PIPE-83014",
pipeDia: "650 mm",
pipeLen: "133 m",
pipeMaterial: "PE",
setSeq: 1,
setCompleteYn: "Y",
},
{
areaId: "AREA-006",
areaName: "F 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
pipeId: "PIPE-83018",
pipeDia: "900 mm",
pipeLen: "176 m",
pipeMaterial: "흄관",
setSeq: 1,
setCompleteYn: "Y",
},
{
areaId: "AREA-006",
areaName: "F 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
pipeId: "PIPE-83019",
pipeDia: "800 mm",
pipeLen: "167 m",
pipeMaterial: "콘크리트",
setSeq: 2,
setCompleteYn: "Y",
},
{
areaId: "AREA-007",
areaName: "G 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
pipeId: "PIPE-91004",
pipeDia: "700 mm",
pipeLen: "111 m",
pipeMaterial: "PVC",
setSeq: 1,
setCompleteYn: "Y",
},
{
areaId: "AREA-008",
areaName: "H 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
pipeId: "PIPE-91010",
pipeDia: "1000 mm",
pipeLen: "201 m",
pipeMaterial: "강관",
setSeq: 1,
setCompleteYn: "Y",
},
{
areaId: "AREA-008",
areaName: "H 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
pipeId: "PIPE-91012",
pipeDia: "900 mm",
pipeLen: "184 m",
pipeMaterial: "흄관",
setSeq: 2,
setCompleteYn: "Y",
},
{
areaId: "AREA-008",
areaName: "H 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
pipeId: "PIPE-91014",
pipeDia: "800 mm",
pipeLen: "159 m",
pipeMaterial: "콘크리트",
setSeq: 3,
setCompleteYn: "Y",
},
{
areaId: "AREA-009",
areaName: "I 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
pipeId: "PIPE-99001",
pipeDia: "600 mm",
pipeLen: "94 m",
pipeMaterial: "PE",
setSeq: 1,
setCompleteYn: "N",
},
{
areaId: "AREA-010",
areaName: "J 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
pipeId: "PIPE-99007",
pipeDia: "850 mm",
pipeLen: "143 m",
pipeMaterial: "흄관",
setSeq: 1,
setCompleteYn: "Y",
},
{
areaId: "AREA-010",
areaName: "J 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
pipeId: "PIPE-99009",
pipeDia: "750 mm",
pipeLen: "128 m",
pipeMaterial: "강관",
setSeq: 2,
setCompleteYn: "Y",
},
{
areaId: "AREA-011",
areaName: "K 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
pipeId: "PIPE-11001",
pipeDia: "700 mm",
pipeLen: "121 m",
pipeMaterial: "콘크리트",
setSeq: 1,
setCompleteYn: "Y",
},
{
areaId: "AREA-012",
areaName: "L 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
pipeId: "PIPE-12001",
pipeDia: "820 mm",
pipeLen: "147 m",
pipeMaterial: "강관",
setSeq: 1,
setCompleteYn: "N",
},
{
areaId: "AREA-013",
areaName: "M 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
pipeId: "PIPE-13001",
pipeDia: "900 mm",
pipeLen: "163 m",
pipeMaterial: "흄관",
setSeq: 1,
setCompleteYn: "Y",
},
{
areaId: "AREA-013",
areaName: "M 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
pipeId: "PIPE-13002",
pipeDia: "800 mm",
pipeLen: "152 m",
pipeMaterial: "콘크리트",
setSeq: 2,
setCompleteYn: "Y",
},
{
areaId: "AREA-014",
areaName: "N 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
pipeId: "PIPE-14001",
pipeDia: "500 mm",
pipeLen: "88 m",
pipeMaterial: "PVC",
setSeq: 1,
setCompleteYn: "Y",
},
{
areaId: "AREA-015",
areaName: "O 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
pipeId: "PIPE-15001",
pipeDia: "980 mm",
pipeLen: "194 m",
pipeMaterial: "강관",
setSeq: 1,
setCompleteYn: "Y",
},
];
export const WATER_RISK_MAP_AREAS: WaterRiskAreaMapData[] = [
{
areaId: "AREA-001",
areaName: "A 중점관리지역",
polygonCoordinates: buildRectanglePolygon(955300, 1948770, 360, 240),
gridCells: buildGridCells(955300, 1948770, [38.2, 42.0, 31.5, 28.4]),
},
{
areaId: "AREA-002",
areaName: "B 중점관리지역",
polygonCoordinates: buildRectanglePolygon(954480, 1948350, 320, 210),
gridCells: buildGridCells(954480, 1948350, [22.4, 24.1, 20.5, 18.9]),
},
{
areaId: "AREA-003",
areaName: "C 중점관리지역",
polygonCoordinates: buildRectanglePolygon(956260, 1948220, 390, 250),
gridCells: buildGridCells(956260, 1948220, [35.1, 37.8, 32.4, 29.6]),
},
{
areaId: "AREA-004",
areaName: "D 중점관리지역",
polygonCoordinates: buildRectanglePolygon(957020, 1948660, 300, 210),
gridCells: buildGridCells(957020, 1948660, [19.3, 24.3, 21.7, 17.8]),
},
{
areaId: "AREA-005",
areaName: "E 중점관리지역",
polygonCoordinates: buildRectanglePolygon(954900, 1949020, 290, 200),
gridCells: buildGridCells(954900, 1949020, [24.8, 28.6, 22.5, 20.7]),
},
{
areaId: "AREA-006",
areaName: "F 중점관리지역",
polygonCoordinates: buildRectanglePolygon(955980, 1949040, 330, 220),
gridCells: buildGridCells(955980, 1949040, [41.7, 45.1, 36.2, 31.5]),
},
{
areaId: "AREA-007",
areaName: "G 중점관리지역",
polygonCoordinates: buildRectanglePolygon(956760, 1947990, 280, 190),
gridCells: buildGridCells(956760, 1947990, [27.4, 33.2, 24.2, 21.1]),
},
{
areaId: "AREA-008",
areaName: "H 중점관리지역",
polygonCoordinates: buildRectanglePolygon(957420, 1948090, 340, 230),
gridCells: buildGridCells(957420, 1948090, [34.7, 39.4, 32.8, 28.6]),
},
{
areaId: "AREA-009",
areaName: "I 중점관리지역",
polygonCoordinates: buildRectanglePolygon(954020, 1947900, 260, 180),
gridCells: buildGridCells(954020, 1947900, [18.4, 26.1, 17.6, 15.9]),
},
{
areaId: "AREA-010",
areaName: "J 중점관리지역",
polygonCoordinates: buildRectanglePolygon(957980, 1948900, 300, 210),
gridCells: buildGridCells(957980, 1948900, [37.9, 41.8, 33.8, 29.4]),
},
];
export const WATER_RISK_MAP_PIPES_BY_AREA: Record<string, WaterRiskMapPipe[]> = {
"AREA-001": [
{
pipeId: "PIPE-10284",
displayLabel: "관로 ID 1",
lineCoordinates: [
[955020, 1948450],
[955210, 1948570],
[955320, 1948690],
],
},
{
pipeId: "PIPE-10291",
displayLabel: "관로 ID 2",
lineCoordinates: [
[955350, 1948820],
[955470, 1948680],
[955560, 1948550],
],
},
],
"AREA-002": [
{
pipeId: "PIPE-45103",
displayLabel: "관로 ID 1",
lineCoordinates: [
[954230, 1948500],
[954360, 1948420],
[954520, 1948340],
],
},
],
"AREA-003": [
{
pipeId: "PIPE-60014",
displayLabel: "관로 ID 1",
lineCoordinates: [
[956000, 1948420],
[956110, 1948320],
[956220, 1948210],
],
},
{
pipeId: "PIPE-60018",
displayLabel: "관로 ID 2",
lineCoordinates: [
[956180, 1948480],
[956270, 1948360],
[956410, 1948240],
],
},
{
pipeId: "PIPE-60021",
displayLabel: "관로 ID 3",
lineCoordinates: [
[956420, 1948420],
[956520, 1948340],
[956650, 1948240],
],
},
],
"AREA-004": [
{
pipeId: "PIPE-72010",
displayLabel: "관로 ID 1",
lineCoordinates: [
[956770, 1948790],
[956900, 1948720],
[957050, 1948650],
],
},
{
pipeId: "PIPE-72018",
displayLabel: "관로 ID 2",
lineCoordinates: [
[956980, 1948840],
[957090, 1948740],
[957210, 1948640],
],
},
],
"AREA-005": [
{
pipeId: "PIPE-83014",
displayLabel: "관로 ID 1",
lineCoordinates: [
[954710, 1949160],
[954840, 1949080],
[954980, 1949000],
],
},
],
"AREA-006": [
{
pipeId: "PIPE-83018",
displayLabel: "관로 ID 1",
lineCoordinates: [
[955760, 1949180],
[955910, 1949100],
[956030, 1949020],
],
},
{
pipeId: "PIPE-83019",
displayLabel: "관로 ID 2",
lineCoordinates: [
[955980, 1949240],
[956120, 1949140],
[956240, 1949040],
],
},
],
"AREA-007": [
{
pipeId: "PIPE-91004",
displayLabel: "관로 ID 1",
lineCoordinates: [
[956520, 1948120],
[956660, 1948050],
[956820, 1947980],
],
},
],
"AREA-008": [
{
pipeId: "PIPE-91010",
displayLabel: "관로 ID 1",
lineCoordinates: [
[957150, 1948220],
[957300, 1948160],
[957470, 1948100],
],
},
{
pipeId: "PIPE-91012",
displayLabel: "관로 ID 2",
lineCoordinates: [
[957350, 1948290],
[957480, 1948200],
[957610, 1948110],
],
},
{
pipeId: "PIPE-91014",
displayLabel: "관로 ID 3",
lineCoordinates: [
[957540, 1948340],
[957670, 1948220],
[957790, 1948100],
],
},
],
"AREA-009": [
{
pipeId: "PIPE-99001",
displayLabel: "관로 ID 1",
lineCoordinates: [
[953810, 1948010],
[953940, 1947960],
[954080, 1947910],
],
},
],
"AREA-010": [
{
pipeId: "PIPE-99007",
displayLabel: "관로 ID 1",
lineCoordinates: [
[957750, 1949040],
[957900, 1948970],
[958040, 1948900],
],
},
{
pipeId: "PIPE-99009",
displayLabel: "관로 ID 2",
lineCoordinates: [
[957980, 1949120],
[958120, 1949010],
[958260, 1948900],
],
},
],
};
export const WATER_RISK_SIDO_OPTIONS = ["전체", "서울특별시", "부산광역시"];
export const WATER_RISK_SGG_OPTIONS_BY_SIDO: Record<string, string[]> = {
전체: ["전체", "강남구", "해운대구"],
서울특별시: ["전체", "강남구"],
부산광역시: ["전체", "해운대구"],
};
export const WATER_RISK_PIPE_LAYER_TYPE = "tb_ms_swage_ppl";
export const WATER_RISK_COLOR_LABELS: Record<WaterRiskGridCell["colorLevel"], string> = {
LEVEL_1: "0.0~20.0 미만",
LEVEL_2: "20.0~30.0 미만",
LEVEL_3: "30.0~40.0 미만",
LEVEL_4: "40.0 이상",
};
export const buildWaterRiskStatusLabel = (status: WaterRiskStatus) =>
status === "DANGER" ? "위험" : "정상";
export const buildWaterRiskMethodLabel = (method: WaterRiskCalculationMethod) =>
method === "METHOD_1" ? "방법 1" : "방법 2";
export const buildWaterRiskSettingAreaName = (areaId: string | null) => {
if (!areaId) return "";
return WATER_RISK_RESULT_ROWS.find((row) => row.areaId === areaId)?.areaName ?? "";
};
File diff suppressed because it is too large Load Diff
@@ -113,7 +113,7 @@ const SearchModal: React.FC<SearchModalProps> = ({ renderMode = "launcher" }) =>
});
if (!popupWindow) {
alert('?앹뾽??李⑤떒?섏뿀?듬땲?? 釉뚮씪?곗? ?앹뾽 ?덉슜 ?ㅼ젙???뺤씤?댁<?몄슂.');
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
}
};
@@ -183,9 +183,9 @@ const SearchModal: React.FC<SearchModalProps> = ({ renderMode = "launcher" }) =>
{/* 📋 결과 리스트 */}
{/* 기본정보 탭 */}
{activeTab === 'basic' && (
<div className="h-[79dvh] w-full rounded-lg border mt-4 overflow-x-scroll">
<div className="mt-4 h-[79dvh] w-full overflow-x-hidden overflow-y-auto rounded-lg border">
<LoadingOverlay isLoading={isLoading} />
<UiTable className='w-[120%]'
<UiTable className='overflow-x-auto [&_table]:min-w-full [&_table]:w-max'
headName={(() => {
const label = BASIC_TYPES.find((type) => type.val === basicType)?.text
return (<>{label} 조회 결과</>);
@@ -20,6 +20,14 @@ export const WATER_DEPTH_LAYER_TYPES: string[] = [
'tb_ms_sbmrg_dngr_map' // 침수위험지도
];
export const WATER_DEPTH_LEGEND_ITEMS = [
{ color: "rgb(255, 255, 127)", label: "0.0~0.5m 미만" },
{ color: "rgb(191, 255, 0)", label: "0.5~1.0m 미만" },
{ color: "rgb(0, 255, 255)", label: "1.0~2.0m 미만" },
{ color: "rgb(191, 127, 255)", label: "2.0~5.0m 미만" },
{ color: "rgb(255, 0, 127)", label: "5.0m 이상" },
] as const;
interface WaterDepthProps {
onClose?: () => void;
isPlaying: boolean;
@@ -70,13 +78,15 @@ export function WaterDepth({
</div>
</div>
<div className="w-[140px] mx-auto flex flex-col items-center">
<div className="bg-blue-900 text-white text-2xl text-center py-1 mb-2 rounded w-full">침 수 심</div>
<div className="bg-blue-900 text-white text-2xl text-center py-1 mb-2 rounded w-full">침수심 범례</div>
<div className="space-y-1 w-full">
<LegendItem color="rgb(255, 255, 127)" label="0.0~0.5m 미만" /> {/* 코드: N330 */}
<LegendItem color="rgb(191, 255, 0)" label="0.5~1.0m 미만" /> {/* 코드: N331 */}
<LegendItem color="rgb(0, 255, 255)" label="1.0~2.0m 미만" /> {/* 코드: N332 */}
<LegendItem color="rgb(191, 127, 255)" label="2.0~5.0m 미만" /> {/* 코드: N333 */}
<LegendItem color="rgb(255, 0, 127)" label="5.0m 이상" /> {/* 코드: N334 */}
{WATER_DEPTH_LEGEND_ITEMS.map((item, index) => (
<LegendItem
key={`${item.label}-${index}`}
color={item.color}
label={item.label}
/>
))}
</div>
<div className="border-t-2 border-blue-900 mt-1 mx-auto w-full" />
</div>
@@ -95,4 +105,4 @@ function LegendItem({ color, label }: { color: string; label: string }) {
<span className="text-xl text-gray-900 font-bold">{label}</span>
</div>
);
}
}
+1
View File
@@ -0,0 +1 @@
export { default } from "../map/page";
@@ -0,0 +1,73 @@
"use client";
import { CalendarIcon } from "lucide-react";
import { useState, type ComponentProps } from "react";
import { Button, Calendar, Popover, PopoverContent, PopoverTrigger } from "@common/components/ui";
type CalendarProps = ComponentProps<typeof Calendar>;
type SearchDatePickerProps = {
selected?: Date;
onSelect: (date: Date | undefined) => void;
placeholder?: string;
className?: string;
disabled?: CalendarProps["disabled"];
defaultMonth?: CalendarProps["defaultMonth"];
};
const formatDateLabel = (date: Date) => {
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, "0");
const day = `${date.getDate()}`.padStart(2, "0");
return `${year}-${month}-${day}`;
};
export function SearchDatePicker({
selected,
onSelect,
placeholder = "날짜 선택",
className,
disabled,
defaultMonth,
}: SearchDatePickerProps) {
// 검색조건 달력은 날짜 선택과 동시에 Popover를 닫아야 하므로 open 상태를 화면 전용으로 제어한다.
const [open, setOpen] = useState(false);
const handleSelect = (date: Date | undefined) => {
onSelect(date);
if (date) {
setOpen(false);
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={[
"h-[3rem] w-[12rem] justify-start border-input !bg-background px-4 py-2 text-left text-[1.3rem] font-normal text-muted-foreground hover:!bg-accent",
className,
]
.filter(Boolean)
.join(" ")}
disabled={disabled === true}
>
<CalendarIcon className="!size-[1.6rem]" />
{selected ? formatDateLabel(selected) : <span>{placeholder}</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
classNames={{ cell: "rounded-md" }}
mode="single"
selected={selected}
onSelect={handleSelect}
disabled={disabled}
defaultMonth={defaultMonth}
/>
</PopoverContent>
</Popover>
);
}
@@ -23,7 +23,7 @@ import { formatDate, handleDownload } from '@/components/lib/utils'
import LoadingOverlay from '@/components/ui/LoadingBar'
import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui'
import ListDiv from '@common/business_components/ui/list_div'
import { Button, DatePicker, Input, Label } from '@common/components/ui'
import { Button, Input, Label } from '@common/components/ui'
import { useEffect, useState } from 'react'
import { tableHeadlist } from '..'
import { useQuery } from "@tanstack/react-query";
@@ -31,6 +31,7 @@ import { ENDPOINT } from "@common/components/config";
import { getUserInfo } from "@common/components/api";
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
import { SearchDatePicker } from '../_components/SearchDatePicker';
const Page = () => {
@@ -176,20 +177,16 @@ const Page = () => {
<div className='flex items-center gap-5'>
<div className='flex items-center gap-2'>
<Label className="w-20 text-xl text-gray-600">등록기간</Label>
<DatePicker mode={'single'}
<SearchDatePicker
selected={fromDate}
onSelect={(date: Date | undefined) => { if (date) setFromDate(date) }}
modifiers={{
disabled: { after: new Date() }
}}
disabled={{ after: new Date() }}
/>
<span>~</span>
<DatePicker mode={'single'}
<SearchDatePicker
selected={toDate}
onSelect={(date: Date | undefined) => { if (date) setToDate(date) }}
modifiers={{
disabled: { after: new Date() }
}}
disabled={{ after: new Date() }}
/>
</div>
<div className='flex items-center'>
@@ -23,7 +23,7 @@ import { formatDate, handleDownload } from '@/components/lib/utils'
import LoadingOverlay from '@/components/ui/LoadingBar'
import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui'
import ListDiv from '@common/business_components/ui/list_div'
import { Button, DatePicker, Input, Label } from '@common/components/ui'
import { Button, Input, Label } from '@common/components/ui'
import { useEffect, useState } from 'react'
import { tableHeadlistOldPp } from '..'
import { useQuery } from "@tanstack/react-query";
@@ -31,6 +31,7 @@ import { ENDPOINT } from "@common/components/config";
import { getUserInfo } from "@common/components/api";
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
import { SearchDatePicker } from '../_components/SearchDatePicker';
const Page = () => {
@@ -174,19 +175,15 @@ const Page = () => {
<div className='flex items-center gap-4'>
<div className='flex items-center space-x-2'>
<Label className="w-20 text-xl text-gray-600">등록기간</Label>
<DatePicker mode={'single'}
<SearchDatePicker
selected={fromDate}
onSelect={(date: Date | undefined) => { if (date) setFromDate(date) }}
modifiers={{
disabled: { after: new Date() }
}} />
disabled={{ after: new Date() }} />
<span>~</span>
<DatePicker mode={'single'}
<SearchDatePicker
selected={toDate}
onSelect={(date: Date | undefined) => { if (date) setToDate(date) }}
modifiers={{
disabled: { after: new Date() }
}} />
disabled={{ after: new Date() }} />
<div className='flex items-center'>
<Label className="w-20 text-xl text-gray-600">등록자</Label>
<Input
+64 -1
View File
@@ -29,6 +29,28 @@ const withSiteIdHeaders = (headers: HeadersInit = {}) => ({
const resolveApiUrl = (url: string) =>
url.startsWith('/') ? `${url}` : `${BASE_URL}/${url}`;
const buildRequestError = (
fallbackMessage: string,
status?: number,
body?: unknown,
) => {
const bodyMessage =
typeof body === "object" && body !== null
? (
(body as { message?: unknown }).message
?? (body as { error?: { message?: unknown } }).error?.message
)
: undefined;
const message =
typeof bodyMessage === "string" && bodyMessage.trim()
? bodyMessage
: fallbackMessage;
const error = new Error(message) as Error & { status?: number; body?: unknown };
error.status = status;
error.body = body;
return error;
};
const handleUnauthorizedResponse = () => {
if (typeof window === "undefined") return;
@@ -79,7 +101,22 @@ export const getData = async (url: string): Promise<any> => {
throw new Error('Invalid JSON response from server');
}
}
throw new Error('Failed to fetch data');
const text = await res.text().catch(() => "");
let body: unknown = null;
if (text.trim()) {
try {
body = JSON.parse(text);
} catch {
body = text;
}
}
throw buildRequestError(
`Failed to fetch data: ${res.status} ${res.statusText}`,
res.status,
body,
);
};
export const postData = async (url: string, data: object): Promise<any> => {
@@ -101,6 +138,32 @@ export const postData = async (url: string, data: object): Promise<any> => {
}
};
export const postRawJson = async (url: string, data: object): Promise<any> => {
const res = await fetch(resolveApiUrl(url), {
method: "POST",
headers: withSiteIdHeaders({
"Content-Type": "application/json",
}),
body: JSON.stringify(data),
});
if (res.status === 401) {
handleUnauthorizedResponse();
}
const text = await res.text();
const body = text && text.trim() ? JSON.parse(text) : null;
if (!res.ok) {
const message =
body?.message ??
`Failed to post data: ${res.status} ${res.statusText}`;
const error = new Error(message) as Error & { status?: number; body?: any };
error.status = res.status;
error.body = body;
throw error;
}
return body;
};
const parseDownloadFileName = (contentDisposition: string | null) => {
if (!contentDisposition) return undefined;
+55 -19
View File
@@ -45,17 +45,37 @@ type UiTableProps = {
onFormDataChange?: (formData: { [key: string]: string | number | boolean }) => void; // 추가
};
type UiSelectOption =
| string
| {
value?: string | number;
label?: string;
val?: string | number;
text?: string;
hidden?: boolean;
};
type UiHeadProps = {
id: string;
title: string;
align?: string;
type?: string;
options?: string[];
options?: UiSelectOption[];
placeholder?: string;
isNumber?: boolean;
length?: number;
required?: boolean;
};
const getSelectOptionValue = (option: UiSelectOption) =>
typeof option === "string" ? option : String(option.value ?? option.val ?? "");
const getSelectOptionLabel = (option: UiSelectOption) =>
typeof option === "string" ? option : String(option.label ?? option.text ?? getSelectOptionValue(option));
const isSelectOptionHidden = (option: UiSelectOption) =>
typeof option === "string" ? false : Boolean(option.hidden);
export function VerticalTable({
headName,
tableName1,
@@ -153,6 +173,7 @@ export function VerticalTable({
{/* 컬럼 제목 */}
<TableCell className={`font-bold bg-blue-100 text-xs w-[120px] whitespace-nowrap`}>
{head.title}
{head.required ? <span className="ml-1 text-red-600">*</span> : null}
</TableCell>
{/* 데이터 값 또는 입력 필드 */}
<TableCell className="text-xs">
@@ -160,21 +181,28 @@ export function VerticalTable({
head.type === "select" ? (
<select
id={head.id}
value={String(formData[head.id]) ?? "-"}
value={formData[head.id] !== undefined ? String(formData[head.id]) : ""}
onChange={(e) => handleInputChange(head.id, e.target.value)}
className="w-full border border-gray-300 rounded-sm px-2 py-1 text-xl border-radius-0"
>
{head.options?.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
{head.options?.map((option, optionIndex) => {
const optionValue = getSelectOptionValue(option);
return (
<option
key={`${head.id}-${optionValue}-${optionIndex}`}
value={optionValue}
hidden={isSelectOptionHidden(option)}
>
{getSelectOptionLabel(option)}
</option>
);
})}
</select>
) : head.type === "input" ? (
) : head.type === "input" || head.type === "date" ? (
<div className="w-full">
<input
id={head.id}
type="text"
type={head.type === "date" ? "date" : "text"}
value={String(formData[head.id] ?? "")}
onChange={(e) => {
if (head.isNumber) {
@@ -241,8 +269,9 @@ export function VerticalTable({
<TableRow key={head.id}>
{/* 컬럼 제목 */}
<TableCell className={`font-bold bg-blue-100 text-xs w-[120px] whitespace-nowrap`}>
{head.title}
</TableCell>
{head.title}
{head.required ? <span className="ml-1 text-red-600">*</span> : null}
</TableCell>
{/* 데이터 값 또는 입력 필드 */}
<TableCell className="text-xs">
{children && children[head.id] ? children[head.id] : isEditable2 ? (
@@ -253,17 +282,24 @@ export function VerticalTable({
onChange={(e) => handleInputChange(head.id, e.target.value)}
className="w-full border border-gray-300 rounded-sm px-2 py-1 text-xl border-radius-0"
>
{head.options?.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
{head.options?.map((option, optionIndex) => {
const optionValue = getSelectOptionValue(option);
return (
<option
key={`${head.id}-${optionValue}-${optionIndex}`}
value={optionValue}
hidden={isSelectOptionHidden(option)}
>
{getSelectOptionLabel(option)}
</option>
);
})}
</select>
) : head.type === "input" ? (
) : head.type === "input" || head.type === "date" ? (
<div className="w-full">
<input
id={head.id}
type={head.isNumber ? "number" : "text"}
type={head.type === "date" ? "date" : head.isNumber ? "number" : "text"}
value={formData[head.id] !== undefined ? String(formData[head.id]) : ""}
onChange={(e) => {
if (head.isNumber) {
@@ -333,4 +369,4 @@ export function VerticalTable({
<Toaster position="top-center"></Toaster>
</>
);
}
}
@@ -31,6 +31,22 @@ const agedPipeHighlightStyle = new Style({
const PIPE_LAYER_TYPE = "tb_ms_swage_ppl";
const EmptySearchView = () => null;
const normalizeTreeRowSpacing = (data: any[]) => {
if (!Array.isArray(data)) return data;
return data.map((item) => {
if (item?.lyrNm !== "지반침하정보") {
return item;
}
return {
...item,
iconNm: null,
gobjTy: "",
};
});
};
const DESIGN_PREVIEW_FEATURE = {
fid: "preview-aged-pipe",
lyrTypeNm: PIPE_LAYER_TYPE,
@@ -103,7 +119,7 @@ const Map = () => {
const fetchData = async () => {
try {
const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK");
setTreeData(data);
setTreeData(normalizeTreeRowSpacing(data));
} catch (error) {
console.error("노후관로 레이어 설정 조회 중 오류:", error);
throw new Error("Invalid JSON response from server");
@@ -8,6 +8,20 @@
- 확인됨: Zustand에서 서버 기준 데이터를 조회해 보관하는 경우에는 로딩, 오류, 중복 호출 방지, 재조회 조건을 함께 정의한다.
- 확인됨: 같은 의미의 기준 데이터 store를 두 계열 이상 유지하지 않는다. 기존 store가 겹치면 조회 출처, 데이터 shape, 필터링 규칙을 비교하고 하나의 기준으로 정리한다.
- 확인됨: React Query를 사용하는 Provider는 QueryClient 인스턴스가 렌더마다 새로 생성되지 않도록 안정적으로 유지한다.
- 확인됨: 지도형 편집 기능은 조회 상태, 상호작용 상태, 편집 세션 상태, 저장 상태를 한 덩어리로 섞지 않고 분리해 기록한다. 근거: `015-하네스엔지니어링정책.md`
## 지도 상호작용 상태 기록 항목
- 확인 필요: `isEditMode`처럼 편집 모드 on/off 상태가 분리되어 있는가
- 확인 필요: `selectedLayer`, `activeTool`, `selectedFeatureId`처럼 현재 조작 대상을 식별하는 상태가 있는가
- 확인 필요: `pendingInteraction`, `focusedChangeId`처럼 도구 전환과 지도 interaction 정리를 위한 중간 상태가 있는가
- 확인 필요: `changes[]`, `canUndo`, `canSave`처럼 편집 세션 누적 상태가 별도로 드러나는가
- 확인 필요: `savePending`, `saveError`, `statusMessage`처럼 저장 상태와 사용자 피드백이 분리되어 있는가
- 확인 필요: preview feature, vertex handle, 임시 삭제 스타일 같은 지도 시각 표식이 어떤 상태와 연결되는지 문서에서 추적 가능한가
## 지도 상호작용 상태 전이 기록 기준
- 확인됨: 도구 전환 시 이전 interaction 정리 조건을 함께 적는다.
- 확인됨: 선택 없음, 레이어 미선택, 객체 미선택, 저장 가능, 저장 불가 같은 경계 상태를 표나 목록으로 남긴다.
- 확인됨: canvas 조작 결과를 직접 읽기 어려우면 상태 메시지, 변경 목록, preview label 같은 간접 표식을 함께 둔다.
## 화면 흐름 기록 항목
- 확인 필요: 초기 진입 시 호출되는 API
@@ -19,6 +33,7 @@
- 확인 필요: 로딩, 오류, 중복 호출 방지, 재조회 조건
- 확인 필요: 팝업, 모달, 지도, 파일, 엑셀 흐름
- 확인 필요: 에러 메시지 표시 위치
- 확인 필요: 지도 상호작용 관측 포인트와 자동검증 가능한 화면 표식
## 주의 사항
- 확인됨: 요구사항 근거 없는 기능 추가는 금지한다. 근거: `apps/sn/AGENTS.md`
+10 -2
View File
@@ -1,5 +1,10 @@
# 검증 기준
## 공통 원칙
- 확인됨: 하네스 친화 검증의 공통 기준은 `FE_APP_DOCS_ROOT/기준문서/015-하네스엔지니어링정책.md`를 우선 참조한다.
- 확인됨: 기능별 자동검증 선행조건, 관측 포인트, 자동/수동 검증 경계는 각 기능의 작업카드와 통합검증계획에 남긴다.
- 확인됨: 인증 차단, 환경 차단, 데이터 차단, 자동화 한계는 기능 결함과 분리해 기록한다.
## 기본 빌드 검증
- 확인됨: `sn`만 수정한 경우 아래 명령으로 로컬 빌드를 확인한다. 근거: `apps/sn/AGENTS.md`
@@ -28,7 +33,10 @@ pnpm turbo run build --filter="...[origin/main]"
## 브라우저 검증
- 확인됨: 백엔드 API 응답과 실제 브라우저 화면 동작을 함께 확인하는 것을 기본으로 한다. 근거: `apps/sn/AGENTS.md`
- 확인 필요: 각 기능별 URL, 로그인 계정, 권한, 테스트 데이터는 작업카드에서 확정한다. 근거: 실행 환경 확인 필요
- 확인됨: 버튼 상태, 상태 메시지, 변경 목록, 네트워크 payload처럼 자동으로 관측 가능한 항목을 먼저 고정한다. 근거: `015-하네스엔지니어링정책.md`
- 확인됨: 지도 canvas drag, vertex 이동처럼 자동화가 불안정한 항목은 수동 검증으로 전환할 수 있으나, 전환 사유를 남겨야 한다. 근거: `015-하네스엔지니어링정책.md`
## 검증 기록
- 확인됨: 검증 결과는 `보고/YYYY-MM-DD/결과보고.md`에 기록한다. 근거: 이 하네스 운영 문서
- 확인됨: 외부 범위 오류는 `보고/YYYY-MM-DD/이슈.md`에 기록한다. 근거: 이 하네스 운영 문서
- 확인됨: 기능 검증 계획은 기능별 `통합검증계획` 문서에 누적한다. 근거: `014-문서관리정책.md`
- 확인됨: 기능 검증 결과는 기능별 `통합검증결과` 문서에 누적한다. 근거: `014-문서관리정책.md`
- 확인됨: 외부 범위 오류나 공통 차단 이슈는 기능 검증결과에 요약하고, 여러 채팅방에 영향을 주는 경우 공통 결정로그 또는 공통 작업내용 문서에 함께 남긴다. 근거: `015-하네스엔지니어링정책.md`
@@ -15,11 +15,25 @@
- 폼과 조회 조건은 화면 컴포넌트 내부에서 뒤섞지 말고 역할 단위로 분리한다.
- 지도, 표, 팝업이 함께 있는 화면은 사용자 흐름 기준으로 이벤트 책임을 나눈다.
## 3-1. 지도 상호작용 설계 원칙
- 편집 도구별 책임은 `선택`, `추가`, `이동`, `꼭지점편집`, `삭제`, `저장`처럼 사용자 행위 단위로 구분한다.
- 같은 지도 feature를 재사용하더라도 `MOVE`와 `VERTEX_EDIT`처럼 interaction lifecycle이 다른 도구는 상태 전이와 정리 조건을 분리한다.
- 기존 상세 팝업, 레이어 선택, 지도 클릭 이벤트와 편집 도구가 충돌하면 우선순위 규칙을 설계 문서에 남긴다.
- 지도 상호작용 결과는 canvas 안에서만 끝나지 않게 상태 메시지, 변경 목록, preview label, 버튼 활성 상태 중 하나 이상으로 바깥에서 관측 가능해야 한다.
- `map/page.tsx` 같은 대형 화면 파일에 책임이 몰려 있으면, 코드 리팩터링 전에도 문서에서 상태 경계와 컴포넌트 분리 목표를 먼저 고정한다.
## 4. API 연동 원칙
- 프론트는 실제 백엔드 DTO, API 명세, 에러 응답을 기준으로 맞춘다.
- 필드명 해석이 애매하면 프론트에서 임의 변환 규칙을 늘리기보다 계약을 먼저 확인한다.
- 목록/상세/다운로드 API는 요청 파라미터와 응답 구조를 문서에 남긴다.
## 4-1. 하네스 친화 설계 원칙
- 자동검증 선행조건, 테스트 데이터, 인증 요구사항은 설계 또는 작업카드에서 명시한다.
- 브라우저 자동화로 안정적으로 확인할 수 있는 관측 포인트를 설계 단계에서 먼저 정한다.
- 자동화가 어려운 canvas drag, vertex 이동은 수동 검증으로 전환할 수 있지만, 전환 사유와 대체 관측 포인트를 함께 둔다.
- 인증 차단, 환경 차단, 데이터 차단, 자동화 한계는 기능 결함과 분리해 설계와 검증 문서에 반영한다.
## 5. 공통 문서 참조
- 공통 용어는 `COMMON_DOCS_ROOT/기준문서/003-공통용어사전.md`를 우선 참조한다.
- 공통 환경/빌드 이슈는 `COMMON_DOCS_ROOT/기준문서/004-환경빌드문제대응.md`를 함께 본다.
- 하네스 친화 검증 기준은 `FE_APP_DOCS_ROOT/기준문서/015-하네스엔지니어링정책.md`를 함께 참조한다.
@@ -0,0 +1,155 @@
# 014. 문서관리정책
## 1. 목적
- `sn` 앱과 연동 기능 문서를 기능 단위 통합 문서 방식으로 관리한다.
- 라운드별 개별 md가 계속 늘어나는 문제를 막고, 나중에 한 기능의 전체 맥락을 빠르게 읽을 수 있게 한다.
## 2. 기본 원칙
- 기능마다 아래 문서만 유지한다.
- 설계 문서 1개
- 작업내용 문서 1개
- 작업검증 계획 문서 1개
- 작업검증 결과 문서 1개
- 세부 이력은 새 파일을 만들지 않고 기존 문서 내부 헤더로 누적한다.
- 라운드별 `002`, `003`, `004` 식 후속 파일 추가를 기본 금지한다.
- 예외적으로 별도 문서가 꼭 필요하면, 왜 기존 통합 문서 헤더로 해결되지 않는지 먼저 기록한다.
- 하네스 정보도 별도 기능 md를 늘리기보다 기존 4문서에 배치한다.
- 하네스 정보 배치 기준은 `FE_APP_DOCS_ROOT/기준문서/015-하네스엔지니어링정책.md`를 따른다.
## 3. 경로별 생성 규칙
### 3.1 공통 설계 문서
- 경로
- `COMMON_DOCS_ROOT/작업계획/<기능폴더>/`
- 파일
- `001-de-<기능명>-설계.md`
- 규칙
- 설계는 기능당 1개만 유지한다.
- 후속 설계 변경, 상태 재검토, QGIS 반영, API 계약 보완도 기존 설계 문서에 새 헤더로 누적한다.
### 3.2 `sn` 작업내용 문서
- 경로
- `FE_APP_DOCS_ROOT/작업내용/<기능폴더>/`
- 파일
- `001-fe-<기능명>-통합구현기록.md`
- 규칙
- 구현 이력, 라운드별 변경, 결정사항, 리스크, BE 공유사항을 한 문서에 누적한다.
- 기존 `001`, `002`, `003` 개별 구현 문서는 통합 문서에 흡수 후 정리한다.
### 3.3 `sn` 작업검증 문서
- 경로
- `FE_APP_DOCS_ROOT/작업검증/<기능폴더>/`
- 파일
- `001-qa-<기능명>-통합검증계획.md`
- `002-qa-<기능명>-통합검증결과.md`
- 규칙
- 검증 계획은 `통합검증계획` 1개에만 누적한다.
- 검증 결과는 `통합검증결과` 1개에만 누적한다.
- 회귀 검증, 후속 검증, 도구별 검증도 새 파일이 아니라 각 문서 내부 헤더로 구분한다.
## 4. 문서 내부 헤더 규칙
### 4.1 설계 문서
- 권장 섹션
- 목적
- 입력 근거
- 현재 확인 상태
- 기능 범위
- 사용자 흐름
- 화면/UI 계약
- API 계약
- 검증 기준
- 남은 리스크
- 하네스 정보 배치
- 도구 상태 전이표
- 지도 상호작용 관측 포인트
- 자동검증 가능 범위와 수동 검증 경계
- 후속 반영은 아래 형식 중 하나로 누적한다.
- `## 2-1. 외부 참조 기준`
- `### 6.6 도구별 세부 계약`
- `### 6.8 지도 상호작용 관측 포인트`
- `### 7.5 도구 상태 전이표`
- `## YYYY-MM-DD 설계 변경 메모`
### 4.2 통합 구현 기록
- 권장 섹션
- 문서 목적
- 입력 근거
- 공통 계약
- 구현 라운드 요약
- 기능별 현재 상태
- BE 공유 사항
- 남은 리스크
- 흡수한 기존 문서
- 하네스 정보 배치
- 자동화 차단 요인
- 관측 가능성 확보를 위해 추가한 상태/메시지/표식
- 하네스 친화화 부채
- 라운드별 구현은 `### 4.x` 식으로 누적한다.
### 4.3 통합 검증 계획
- 권장 섹션
- 문서 목적
- 검증 범위
- 검증 기준
- 라운드별 검증 계획
- 다음 검증 순서
- 흡수한 기존 문서
- 하네스 정보 배치
- 브라우저 검증 선행조건
- 관측 포인트 표
- 자동검증 항목과 수동검증 항목 구분
- 차단 사유 분류 기준
### 4.4 통합 검증 결과
- 권장 섹션
- 문서 목적
- 수행한 검증
- 라운드별 검증 결과
- 현재 검증 상태
- 보류/리스크
- 흡수한 기존 문서
- 하네스 정보 배치
- 실제 관측 증적 요약
- 차단 사유 분류
- 자동화 실패와 기능 실패 구분 결과
## 5. 기존 개별 문서 흡수 규칙
- 기존 개별 문서를 유지할 필요가 없으면 다음 순서로 정리한다.
1. 통합 문서에 핵심 내용을 요약/흡수
2. 통합 문서 `흡수한 기존 문서` 섹션에 파일명을 기록
3. 기존 개별 문서를 정리한다
- 흡수 시 원래 문서의 모든 문장을 그대로 복사하기보다, 결정사항과 검증 결과를 구조화해서 재작성한다.
## 6. 새 기능 시작 시 문서 생성 순서
1. 공통 설계 문서 1개 생성
2. `sn` 작업카드 1개 생성
3. `sn` 통합 구현 기록 1개 생성
4. `sn` 통합 검증 계획 1개 생성
5. `sn` 통합 검증 결과 1개 생성
6. 이후부터는 기존 5개 문서만 계속 갱신
## 7. 금지 사항
- 구현 라운드마다 `002-fe-...`, `003-fe-...`, `004-qa-...`를 계속 추가하지 않는다.
- 같은 기능에서 `후속검증계획.md`, `후속검증결과.md`, `MOVE실드래그검증계획.md`, `VERTEX_EDIT실조작검증결과.md` 같은 파생 검증 파일을 반복 증식시키지 않는다.
- `상태재검토`, `후속정리`, `보완메모`를 별도 파일로 분리하지 않는다.
## 8. 예외 처리
- 사용자 요청으로 개별 문서 분리가 명시된 경우
- 외부 전달용 산출물이라 통합 문서 내부 헤더로 대체할 수 없는 경우
- 법적/감사/계약상 독립 파일 보관이 필요한 경우
예외 상황에서도 먼저 통합 문서에 “왜 별도 파일이 필요한지”를 기록한다.
@@ -0,0 +1,126 @@
# 015. 하네스엔지니어링정책
## 1. 목적
- `sn` 앱 기능을 브라우저 자동검증, 수동 회귀 검증, FE-BE 연동 관찰이 가능한 구조로 문서화하는 기준을 통일한다.
- 구현 코드 구조를 바로 바꾸기 전에도, 문서 단계에서 관측 포인트와 차단 요인을 먼저 드러내어 하네스 친화성을 높인다.
- 기능별 문서에 하네스 정보가 중복되거나 누락되지 않도록 공통 배치 기준을 제공한다.
## 2. 적용 범위
- `FE_APP_DOCS_ROOT/기준문서/**`
- `FE_APP_DOCS_ROOT/작업계획/**`
- `FE_APP_DOCS_ROOT/작업내용/**`
- `FE_APP_DOCS_ROOT/작업검증/**`
- `COMMON_DOCS_ROOT/작업계획/**` 중 `sn` 화면/상호작용 계약을 담는 설계 문서
## 3. 기본 원칙
- 하네스 정보는 기능별로 새 md를 늘리기보다 기존 4문서 체계 안에 배치한다.
- 자동검증 가능 여부와 무관하게, 사용자가 실제로 무엇을 보고 성공/실패를 판단하는지 먼저 문서화한다.
- 브라우저 자동화 한계, 인증 차단, 외부 시스템 장애는 기능 결함과 분리해 기록한다.
- 지도 상호작용 기능은 도구 상태, 선택 상태, 세션 상태, 네트워크 상태를 따로 관측할 수 있어야 한다.
- 코드 구조가 과밀해도 문서에서는 책임 경계, 상태 전이, 관측 포인트를 먼저 고정한다.
## 4. 자동검증 선행조건
- 접속 URL
- 로그인 경로와 권한
- 필수 쿠키 또는 토큰 존재 여부
- 서버 기동 상태와 대상 API base URL
- 테스트용 레이어, 객체 ID, BBOX, 샘플 데이터
- 브라우저 자동화 가능 범위와 수동 검증 전환 조건
인증 또는 외부 연동 문제로 위 조건이 충족되지 않으면, 이를 기능 실패로 단정하지 말고 `환경 차단`, `인증 차단`, `데이터 차단`, `자동화 한계` 중 하나로 분류한다.
## 5. 관측 포인트 표준
### 5.1 화면 관측
- 버튼 활성/비활성
- 툴바 노출/숨김
- 상태 메시지 텍스트
- 선택 레이어명
- 변경 목록 행 수
- 오류 또는 안내 메시지
### 5.2 지도 상호작용 관측
- 현재 활성 도구
- 선택 객체 존재 여부
- preview feature 존재 여부
- vertex marker 또는 modify handle 노출 여부
- drag 또는 modify 종료 후 geometry 변경 여부
- 삭제 대상 임시 스타일 적용 여부
### 5.3 네트워크 관측
- 요청 URL, method, query/body
- 응답 status
- 편집 세션 payload shape
- 인증 실패 또는 권한 실패 응답
### 5.4 세션 관측
- 로컬 변경분 누적 여부
- undo/reset 가능 여부
- save 가능 여부
- save pending/save error 상태
## 6. 자동검증과 수동검증 경계
- DOM, 텍스트, 버튼 상태, 네트워크 호출, payload 구조는 자동검증 우선 대상으로 본다.
- canvas 기반 drag, vertex 이동, 지도 좌표 조작은 자동화가 불안정하면 수동 검증으로 전환할 수 있다.
- 수동 전환 시에는 이유를 `자동화 한계`로 명시하고, 어떤 관측 포인트를 사람 손으로 확인했는지 남긴다.
## 7. 인증·세션 기준
- 인증 장애는 기능 결함과 분리해 기록한다.
- 로컬 브라우저 검증이 인증 때문에 막히면 `COMMON_DOCS_ROOT/작업내용/공통-로그인/001-de-공통-로그인-로컬임시로그인-검토계획.md`를 우선 참조한다.
- 기능 문서에는 해당 기능이 요구하는 최소 권한, 로그인 여부, 세션 유지 시간, 재로그인 필요 조건만 요약한다.
## 8. 기능 문서 배치 규칙
### 8.1 설계 문서
- 도구 상태 전이표
- 지도 상호작용 관측 포인트
- 자동검증 가능 범위와 수동 검증 경계
### 8.2 작업카드
- 브라우저 검증 선행조건
- 테스트 데이터와 샘플 진입 조건
- 인증 차단 시 대체 경로
### 8.3 통합 구현 기록
- 현재 자동화 차단 요인
- 하네스 친화화 부채
- 관측 가능성 확보를 위해 추가된 상태/메시지/표식
### 8.4 통합 검증 계획
- 관측 포인트 표
- 자동검증 항목과 수동검증 항목 구분
- 차단 사유 분류 기준
### 8.5 통합 검증 결과
- 실제 관측 증적 요약
- 차단 사유 분류
- 자동화 실패와 기능 실패의 구분 결과
## 9. 차단 사유 분류
- `기능 결함`: 화면 동작, 상태 전이, payload, 응답 처리 자체가 기대와 다름
- `인증 차단`: 로그인, 토큰, 권한 부족으로 기능 진입 불가
- `환경 차단`: 서버 미기동, 잘못된 env, CORS, 프록시, 네트워크 장애
- `데이터 차단`: 테스트 데이터, 레이어 메타, 객체 샘플 부족
- `자동화 한계`: 브라우저 자동화 도구로 canvas drag, vertex 조작이 안정적으로 재현되지 않음
## 10. 금지 사항
- 같은 주제라고 해서 `브라우저자동검증기준.md`, `인증검증기준.md`, `상태머신.md`를 기능마다 별도 생성하지 않는다.
- 관측 포인트 없이 `수동 확인 필요`만 적고 끝내지 않는다.
- 인증 실패, 환경 실패, 자동화 한계를 모두 기능 결함으로 뭉뚱그려 기록하지 않는다.
@@ -0,0 +1,72 @@
# SFR-010-010 지반침하정보 통합 검증 계획
## 1. 문서 목적
- `SFR-010-010 지반침하정보`의 검증 계획을 기능당 1개 문서로 누적 관리한다.
- 이번 턴은 설계 단계이므로 구현 전 검증 시나리오와 차단 조건을 먼저 기록한다.
- 승인 후 구현이 시작되면 이 문서에 라운드별 검증 계획을 계속 추가한다.
## 2. 검증 기준 요약
- 레이어 트리에서 `지반침하정보` 체크/해제가 동작해야 한다.
- 목록 아이콘 클릭 시 지반침하 목록 팝업이 열려야 한다.
- 목록 조회조건 검증이 동작해야 한다.
- 날짜 누락 차단
- `2016-01-01` 이전 날짜 차단
- 목록 행 클릭 또는 지도 포인트 클릭 시 우측 상세 패널이 열려야 한다.
- 반경 적용 시 지도 원과 반경 내 관로 목록이 함께 갱신돼야 한다.
- 좌표 없음/좌표 변환 실패/반경 내 관로 없음 케이스가 서로 다른 안내로 분기돼야 한다.
- 관리자만 동기화 버튼과 관련 운영 정보를 볼 수 있어야 한다.
## 3. 라운드별 검증 계획
### 3.1 설계 단계 사전 계획
- FE 화면 구조 검증
- 레이어 트리 행 구성
- 반경 패널 위치
- 목록 팝업 열림 방식
- 우측 상세 패널 레이아웃
- BE 계약 검증
- geometry/list/detail/nearby/sync/download endpoint 일치 여부
- 원천 부재 필드 nullable 처리 여부
- 관리자 권한 분리 여부
- DB 영향 검증
- IL 원천 조회
- 반경 내 관로 조회 대상
- 좌표 캐시 영속화 필요 여부
### 3.2 구현 후 예정 검증
- 지반침하 레이어 체크/해제 시 지도 포인트 표출 여부
- 목록 팝업 조회 / 결과 없음 / 다운로드 검증
- 목록 행 클릭 후 지도 중심 이동과 상세 패널 오픈
- 지도 포인트 클릭 후 상세 패널 오픈
- 반경 값 변경 후 재조회
- 좌표 없는 사고건 상세 표시
- 관리자 동기화 성공/실패 검증
## 4. 브라우저 검증 선행조건
- `/map` 진입 가능한 로그인 상태
- FE dev 서버와 BE API 동시 기동
- IL 원천 데이터와 관로 데이터 접근 가능
- 관리자/일반 사용자 계정 각각 1개 이상
## 5. 차단 사유 분류 기준
- `기능 결함`
- 화면 상태 전이 또는 API 응답 처리 오류
- `인증 차단`
- 로그인/권한 부족
- `환경 차단`
- 서버 미기동, 프록시, 네트워크 문제
- `데이터 차단`
- 원천 데이터 또는 샘플 사고건 부족
- `계약 미확정`
- 원천 부재 필드 처리, 캐시 정책, 팝업 방식 미확정
## 6. 현재 보류 메모
- 이번 턴은 설계 단계이므로 실제 브라우저/API 실행 검증은 수행하지 않는다.
- 구현 승인 전에는 이 문서의 시나리오와 설계 문서의 계약 일치 여부만 점검 대상으로 둔다.
@@ -0,0 +1,133 @@
# SFR-010-010 지반침하정보 통합 검증 결과
## 1. 문서 목적
- `SFR-010-010 지반침하정보`의 검증 결과를 기능당 1개 문서로 누적 관리한다.
- 이번 턴은 설계 단계이므로 실행 결과 대신 사전 점검 결과와 미실행 사유를 기록한다.
- 승인 후 구현과 실행 검증이 시작되면 이 문서에 라운드별 결과를 누적한다.
## 2. 현재 수행 결과
### 2.1 설계 자료 점검
- 요구사항명세서, 시나리오, HTML, PNG를 읽고 화면 흐름과 API 초안을 정리했다.
- 현재 백엔드 작업트리에서 아래 선반영 범위를 확인했다.
- `GET /api/v1/sinkholes/geometry`
- IL datasource 분리
- 주소 기반 지오코딩 서비스
- 현재 프론트 코드에서는 `지반침하정보` 실구현이 없고, 실제 구현은 승인 후 착수해야 함을 확인했다.
### 2.2 확인된 설계 리스크
- 화면에 필요한 상세 필드 일부가 현재 원천 테이블에 없다.
- 좌표 캐시가 현재 메모리 캐시라 최근 동기화 일시/상태 유지 요구를 그대로 만족하지 못한다.
- 목록 팝업을 별도 popup 창으로 열지, `/map` 내부 모달로 둘지 구현 기준이 아직 확정되지 않았다.
### 2.3 2026-06-05 런타임 원인 확인
- 프론트에서 `GET /api/v1/sinkholes/geometry` 호출 시 500이 재현됐다.
- 같은 시점에 로컬 `GET /api/v1/sinkholes`, `GET /api/v1/sinkholes/{acdntNo}`를 직접 확인한 결과 `geocodeErrorMessage`에 아래 예외가 내려왔다.
- `Unrecognized field "adresTy" ... SinkholeNgiiResponse`
- 따라서 이번 런타임 장애의 1차 원인은 프론트 fetch 자체가 아니라 `reuse-service` 지오코딩 응답 필드 확장을 백엔드가 무시하지 못한 점으로 판정했다.
- `./gradlew compileJava --no-daemon`는 시도했으나 로컬 점유 중인 `spring-configuration-metadata.json` 때문에 `Failed to clean up stale outputs`로 중단돼, 재기동 후 최종 확인이 필요하다.
### 2.4 2026-06-09 연도/전체 노드 코드 경로 확인
- 현재 프론트 지반침하 연도 필터 로직을 다시 확인한 결과, `tb_sn_lyr_stng`에서 내려오는 `2026년`, `전체` 자식 노드는 `sinkholeYear` 값이 없어 기존 코드가 연도 토큰을 읽지 못하고 있었다.
- 그래서 화면에 연도 노드가 보여도 초기 표출과 팝업 필터 동기화에서는 최근 3개월 기본 조건이 남아 있을 수 있는 상태로 판정했다.
- 이번 수정으로 `sinkholeYear`가 없더라도 노드 텍스트 `2026년`, `전체`를 각각 `2026`, `ALL`로 해석하도록 보강했다.
- 레이어 설정 초기 로딩도 지반침하 트리 보정 유틸을 실제로 거치도록 연결해, DB 자식 노드 유무와 상관없이 같은 정리 경로를 타게 했다.
### 2.5 2026-06-09 발생기간 조회 시작일 2016년 확장
- 현재 팝업은 빈 조회조건일 때 최근 3개월을 기본값으로 쓰고 있어, 사용자가 장기 이력을 보려 해도 시작일을 다시 넓혀야 하는 상태였다.
- 이번 수정으로 프론트 기본 조회조건을 `2016-01-01 ~ 오늘`로 바꾸고, 날짜 입력 `min`도 `2016-01-01`로 고정했다.
- 백엔드도 같은 날 `최대 1년` 검증을 제거하고 `2016-01-01` 이전 날짜만 차단하도록 바꿨다.
### 2.6 2026-06-09 다운로드 전체 결과 기준 코드 경로 확인
- 현재 지반침하 다운로드는 화면에 보이는 현재 페이지 `rows`만 엑셀로 내려가고 있어, 페이지네이션 이후 데이터는 누락되는 상태였다.
- 이번 수정으로 다운로드 시 현재 조회조건을 유지한 채 `/api/v1/sinkholes`를 최대 2000건 단위로 재호출해 전체 결과를 합친 뒤 엑셀을 생성하도록 변경했다.
- 아직 브라우저에서 2페이지 이상 결과를 실제로 내려받아 행 수를 대조하는 수동 검증은 수행하지 못했다.
### 2.7 2026-06-09 다운로드 병목 재검토
- 백엔드 목록 API는 각 행마다 `SinkholeCoordinateResolver.resolve()`를 호출하고, 원천 TM 좌표가 없으면 외부 지오코딩을 타므로 대량 다운로드에서 이 경로가 지연의 핵심 병목이 될 수 있다고 판단했다.
- 그래서 동일 주소의 실패 지오코딩도 30분 단기 캐시에 저장하고, 프론트 다운로드 배치도 2000건으로 올려 1600건 규모에서는 목록 API 1회 호출에 가깝게 줄였다.
- 아직 실제 운영/개발 데이터 1600건 조건에서 전후 다운로드 시간을 계측하지는 못했다.
### 2.8 2026-06-09 좌표 실패 건 상세 fallback 코드 경로 확인
- 좌표 실패 건 상세 클릭 시 `GET /api/v1/sinkholes/{acdntNo}`가 generic 500으로 실패하는 사례가 확인됐다.
- 이번 수정으로 목록 행 클릭 경로에서는 상세 API 실패 시 목록 row 데이터를 fallback 상세로 사용하고, 인접관로 조회 실패도 별도 warning으로만 남기도록 바꿨다.
- 추가로 row 자체가 이미 좌표 실패 상태이고 x/y도 없으면, 상세 API를 아예 생략하고 즉시 fallback 상세 패널을 열도록 바꿨다.
- 아직 실제 브라우저에서 실패 좌표 row 클릭 후 우측 패널이 fallback 데이터로 열리는지 재검증은 수행하지 못했다.
### 2.9 2026-06-09 목록/다운로드 조회조건 변경 코드 경로 확인
- 기본 발생기간은 `2016-01-01` 고정이 아니라 다시 `현재 기준 최근 1년`으로 되돌렸다.
- 목록 조회영역에 `좌표보정상태` 조건을 추가했고, 목록 API query string도 해당 값을 함께 보내도록 바꿨다.
- 다운로드는 `includeCoordStatus=false`를 붙여 `좌표보정상태` 열 없이 현재 조회조건 전체 결과를 다시 받아 엑셀을 만들도록 변경했다.
### 2.10 2026-06-09 조회옵션/헤더범위 마감 확인
- `좌표보정상태` 옵션에서 `주소없음`은 제거되고 `전체 / 성공 / 실패`만 남는 코드 경로를 확인했다.
- 지반침하 다운로드는 `headerRange=A1:I1`로 수정돼 첫 데이터 행이 헤더 스타일을 먹지 않도록 정리된 것을 확인했다.
### 2.11 2026-06-09 팝업 활성 중 필터 우선순위 코드 경로 확인
- 평소 지도 레이어 조회는 트리 연도 체크 상태를 기준으로 하고, 팝업 sync 메시지를 받은 이후에만 팝업 필터가 우선 적용되도록 `isSinkholePopupFilterActive` 분기가 추가된 것을 확인했다.
- 팝업 close watcher와 레이어 해제 경로에서도 popup-active 상태를 해제하도록 맞춰, 팝업 종료 후 다시 트리 기준으로 복귀하는 코드 경로를 확인했다.
### 2.12 2026-06-09 기능 테스트 최종 확인
- 사용자 확인 기준으로 아래 주요 흐름이 기대대로 동작함을 최종 확인했다.
- 지반침하 트리 기본 체크 상태 유지
- `2026년` / `전체` 상위조건과 팝업 조회조건의 결합
- 팝업 `목록보기` 오픈 시 지도 즉시 동기화
- 팝업 닫힘 시 트리 기준 복귀
- 팝업 열린 상태에서 레이어 재체크 시 조건 유지
- 다운로드 첫 데이터 행 헤더 스타일 오염 제거
- 현재 대화 범위에서는 기능 테스트 성공으로 판정한다.
### 2.13 2026-06-11 목록 총 건수 헤더 표시 확인
- 검증 대상
- `FE_APP_ROOT/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx`
- 확인 내용
- 지반침하 팝업 목록 테이블이 `total` 상태값을 이미 보유하고 있는지 코드 경로를 다시 확인했다.
- 테이블 헤더를 `지반침하 조회 결과 총 {total.toLocaleString()}건`으로 지정하고 `isHideCount={true}`를 적용해, 노후관로와 같은 문구 형식으로 총 건수가 노출되도록 보강했다.
- 사용자 확인 결과 반경이 정상 적용되었고, 요구한 총 건수 문구가 지반침하 팝업 상단에 표시되는 것을 확인했다.
- 검증 결과
- 사용자 확인 완료
- 추가 브라우저 재현 캡처와 빌드 재실행은 이번 라운드에서 수행하지 않음
## 3. 미실행 항목
- FE 브라우저 검증
- BE API 호출 검증
- 반경 내 관로 거리 계산 검증
- 관리자 권한 분기 검증
- 다운로드 파일 검증
- FE 빌드 검증
- BE compile/test 최종 검증
미실행 사유:
- 작성자 승인 전 구현 금지 지시
- 이번 턴 범위가 설계 및 문서 초안 작성까지로 제한됨
- 이번 유지보수 라운드에서는 코드 경로 점검과 최소 수정까지만 수행했고, 개발 서버 실행 여부를 확인하지 못해 FE 빌드를 별도로 돌리지 않았다.
## 4. 현재 상태 판정
- 설계 검토: 완료
- 코드 구현: 완료
- 기능 테스트: 성공
- 브라우저/API 정밀 재검증: 일부 미실행
## 5. 후속 실행 조건
- 작성자 구현 승인
- FE/BE 코드 반영 완료
- 로그인 가능한 FE/BE 실행 환경 준비
- 관리자/일반 사용자 테스트 계정 준비
@@ -0,0 +1,27 @@
# SFR-011-002 관로분석 FE 통합검증결과
## 1. 검증 목적
- 관로분석 프론트 변경이 오류 전달 구조를 깨지 않고 유지되는지 확인한다.
## 2. 수행한 검증
- 코드 리뷰로 `PipeAnalysisPopupPage`, `PipeAnalysisDetailPopupPage`, `getData`의 예외 흐름을 확인했다.
- `pnpm --filter sn exec tsc --noEmit`를 실행했다.
## 3. 검증 결과
- `PipeAnalysisPopupPage.tsx`의 엑셀 다운로드 캐스팅 경고는 수정 후 해소했다.
- 집계/상세 팝업은 이제 `Error.message`가 존재하면 공통 경고문 대신 해당 메시지를 노출한다.
- 관로분석 집계 헤더의 `총 N건` 문구가 제거됐다.
- 집계 팝업은 10개 단위 페이지 렌더링으로 바뀌어 한 화면에 그리는 행 수를 제한한다.
- 전체 TypeScript 검증은 저장소 기존 오류로 실패했다.
## 4. 기존 오류 메모
- `app/(map-layout)/map/components/common/index.ts` 타입 불일치
- `app/html/(with-base-layout)/layout.tsx` import 누락
- `packages/common/business_components/src/ui/chart/*` implicit any
## 5. 미수행 검증
- 로컬 브라우저에서 실제 관로분석 팝업을 열어 세 분석구분을 재조회하는 수동 검증은 이번 턴에서 수행하지 못했다.
## 6. 판단
- 프론트 수정 범위 내에서 신규 타입 오류를 만들지는 않았다.
- 실제 장애 해소 여부의 최종 판정은 백엔드 반영본과 함께 브라우저 재조회 확인이 필요하다.
@@ -0,0 +1,93 @@
# SFR-011-007 통수위험분석 통합 검증 계획
## 1. 문서 목적
- `SFR-011-007 통수위험분석`의 FE 검증 계획을 기능당 1개 문서로 누적 관리한다.
- 현재 문서는 FE-only 프로토타입 구현 완료 기준의 검증 범위와 후속 통합 검증 포인트를 정의한다.
## 2. 검증 기준 요약
- `통수위험` 아이콘으로 기능 진입 가능
- 결과 조회 popup 열림/닫힘 동작
- 날짜/시간 필수값 검증
- 위험상태 필터 동작
- 관로 1건, 2건, 3건 동적 컬럼 렌더링
- 결과 행 선택 시 지도 강조 및 상세 popup 갱신
- 예측강우 sample grid overlay 노출
- 결과/설정 목록 페이지네이션 노출
- 설정 현황 조회와 staged 설정 편집 진입
- 유출관로 중복 선택 차단
- 최소 1건, 최대 3건 저장 규칙
- 저장 실패 시 staged 목록 유지
## 3. 라운드별 검증 계획
### 3.1 설계 승인 직후 FE-only 검증
- popup 진입/전환/닫기
- 조회조건 유효성
- 결과 그리드 렌더링
- 위험/정상 스타일 전환
- 예측강우 sample overlay 가시성
- 설정 현황 목록과 staged 유출관로 편집
- 저장 payload preview
- 상세정보 카드의 표 구조, 위치, 강조 규칙
### 3.2 BE 연동 후 통합 검증
- 실제 조회조건 전달
- 실제 geometry와 격자 overlay 정합성
- 설정 저장/재조회
- 결과/설정 다운로드
- 권한 범위 제어
## 4. 현재 검증 범위
### 4.1 진행 전제
- FE-only 프로토타입 코드는 구현 완료 상태다.
- 자동 브라우저 검증은 환경 제약으로 제한적이므로, 현재는 코드 점검과 사용자 피드백 기반 수동 검증 포인트를 우선 기록한다.
### 4.2 자동 또는 반자동 관측 포인트
| 구분 | 관측 포인트 | 기대 결과 |
| --- | --- | --- |
| 진입 | `통수위험` 버튼 | popup 열림 |
| 결과 | 날짜/시간 누락 | 조회 차단 메시지 |
| 결과 | 위험상태 필터 | 결과 건수와 목록 반영 |
| 결과 | 동적 관로 컬럼 | 1~3건 반복 렌더링 |
| 결과 | 페이지네이션 | 결과 2페이지, 설정 3페이지 이상 노출 |
| 지도 | 선택 지역 | 중심 이동, 강조 색상 반영 |
| 지도 | 예측강우 격자 | sample overlay 표시 |
| 지도 | 상세정보 카드 | 표 구조, 숫자 강조, 중점관리지역 오른쪽 anchor 동작 확인 |
| 설정 | 설정 현황 전환 | 탭 또는 화면 전환 성공 |
| 설정 | 유출관로 선택 | staged 목록 누적 |
| 설정 | 4번째 관로 선택 | 차단 메시지 |
| 저장 | payload preview | focusAreaId, pipes, setSeq 구조 확인 |
## 5. 차단 사유 분류 기준
- `기능 결함`
- 기대한 상태 전이나 렌더링이 깨짐
- `인증 차단`
- 로그인 또는 권한 문제로 진입 불가
- `환경 차단`
- dev 서버, API, 지도 리소스 미기동
- `데이터 차단`
- 지역 목록, 샘플 결과, geometry 부족
- `자동화 한계`
- 지도 canvas overlay 체감 확인이 도구상 제한적
## 6. 다음 검증 순서
1. 결과 조회 popup 열림/닫힘
2. 조회조건 필수값/초기화
3. 동적 관로 컬럼 렌더링
4. 결과/설정 목록 페이지네이션
5. 결과 행 선택과 지도 강조
6. 예측강우 sample overlay
7. 상세정보 카드 레이아웃과 중점관리지역 오른쪽 anchor 규칙
8. 설정 현황 목록 조회
9. staged 유출관로 1~3건 제어
10. 저장 payload preview
11. BE 연동 후 실제 저장/다운로드
@@ -0,0 +1,116 @@
# SFR-011-007 통수위험분석 통합 검증 결과
## 1. 문서 목적
- `SFR-011-007 통수위험분석`의 FE 검증 결과를 기능당 1개 문서로 누적 관리한다.
- 현재 문서는 설계 초안 점검부터 FE-only 프로토타입 코드 점검, 수동 피드백 반영 상태까지의 결과를 기록한다.
## 2. 수행한 검증 요약
### 2.1 2026-05-18 설계 단계 점검
- 수행 범위
- 작업지시 문서 해석
- 템플릿 문서 구조 확인
- 공통/FE 문서 세트 정의
- 예상 수정 파일, FE-only 범위, BE 필요 범위 분리
- 미수행 범위
- FE 코드 구현
- 브라우저 검증
- API 응답 확인
### 2.2 2026-05-18 FE-only 1차 구현 점검
- 코드 기준 확인
- `flowRiskPopup` registry가 실제 `WaterRiskPopupContent`로 연결됐다.
- `WaterRiskPopupPage.tsx`에서 결과 조회/설정 현황/staged 편집/preview UI가 구현됐다.
- `map/page.tsx`에서 popup sync, 선택 지역 강조, sample rain grid overlay, 설정 편집 중 하수관로 선택 인터셉트가 구현됐다.
- 타입체크
- `NODE_OPTIONS=--max-old-space-size=4096 pnpm exec tsc --noEmit --incremental false --pretty false`
- 결과: 이번 변경분 관련 신규 오류는 제거됐다.
- 잔여 오류: 저장소 기존 오류만 남음
- `components/common/index.ts` 타입 불일치
- `PipeAnalysisPopupPage.tsx`의 `JSON[]` 캐스팅 경고
- `app/html/**` 기존 타입/모듈 오류
- `packages/common/business_components` chart implicit any
- 미수행
- 실제 브라우저 동작 검증
- 실제 API 연동 검증
### 2.3 2026-05-19 FE 프로토타입 UI 보완 점검
- 코드 기준 확인
- 결과 조회/설정 현황 mock 데이터가 확대되어 페이지네이션이 실제로 노출되는 상태로 바뀌었다.
- 결과/설정 toolbar 버튼 정렬, 헤더 컬러, 본문 행 배경이 프로토타입 기준으로 정리됐다.
- 상세정보 카드는 표 구조, compact spacing, 유출유량 초과 강조 등 사용자 피드백 기반 보완이 반영됐다.
- 수동 피드백 반영
- 페이지네이션과 상세정보 카드 UI는 사용자 확인 기준으로 여러 차례 보정됐다.
- 상세정보 카드의 지도 기준 고정 규칙은 현재도 추가 브라우저 확인이 필요한 항목으로 남아 있다.
- 미수행
- 자동 브라우저 캡처 기반 검증
- 실제 API 응답/저장/다운로드 검증
### 2.4 2026-05-19 FE 추가 검증 실행
- 로컬 응답 확인
- `http://localhost:3002/popup/flowRiskPopup`
- 결과: HTTP 200 응답 확인
- 타입체크
- 실행 위치: `frontend/apps/sn`
- 실행 명령: `.\node_modules\.bin\tsc.cmd --noEmit --incremental false --pretty false`
- 결과: 이번 통수위험분석 변경으로 새로 유입된 타입 오류 1건(`buildWaterRiskInfoAnchor`)을 수정했고, 재실행 후 남은 오류는 저장소 기존 오류만 확인됐다.
- 잔여 기존 오류
- `app/(map-layout)/map/components/common/index.ts` props 타입 불일치
- `app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx` `JSON[]` 캐스팅 경고
- `app/html/(map-layout)/map/components/common/index.ts` props 타입 불일치
- `app/html/(with-base-layout)/layout.tsx` 모듈 경로 오류
- `packages/common/business_components/src/ui/chart/*.tsx` implicit any
- 미수행
- 지도 상호작용을 포함한 실제 브라우저 자동 검증
- 실제 API 연동 검증
### 2.5 2026-05-19 상세정보 카드 상호작용 보정
- 코드 기준 확인
- 상세정보 카드는 선택된 중점관리지역 geometry를 기준으로 오른쪽 anchor를 잡는 방식으로 재조정됐다.
- 지도 렌더링 중에는 같은 anchor를 기준으로 카드 위치를 갱신하는 구조로 정리됐다.
- 수동 피드백 반영
- 사용자 기준으로 “화면 고정”이 아니라 “중점관리지역 오른쪽에 붙은 상태로 이동”해야 한다는 요구를 반영했다.
- 미수행
- 실제 브라우저 drag/zoom 상황에서 최종 체감 확인
## 3. 현재 검증 상태
### 3.1 통과
- 문서 구조가 `SFR-012-003` 템플릿 기준과 맞는지 확인
- 공통 설계서, 공유기록, FE 작업카드, 구현기록, 검증 계획/결과 문서 세트 정의 완료
- 구현 승인 전 코드 미수정 원칙 반영
- FE-only 프로토타입 범위의 결과 조회/설정 현황/지도 강조/staged 편집 코드 반영 완료
- 로컬 `flowRiskPopup` 경로의 HTTP 200 응답 확인
- 이번 통수위험분석 변경으로 인한 신규 타입 오류 제거
### 3.2 미실행
- 실제 브라우저에서의 `통수위험` 아이콘 진입
- 실제 popup 렌더링 재확인
- 실제 지도 drag/zoom 상태에서 상세정보 카드 anchor 규칙 재확인
- 실제 API 연동 검증
### 3.3 보류
- 실제 BE 연동 검증
- 다운로드 파일 검증
- 권한 범위 검증
## 4. 보류/리스크
- 상세정보 카드 anchor 규칙은 FE 프로토타입 단계에서 반복 조정된 항목이라 운영 규칙 확정 전 재검토가 필요하다.
- 동적 관로 컬럼과 지도 overlay는 실제 API 응답 구조 확정 전까지 mock 기준으로만 검토 가능하다.
- 실제 다운로드/저장 후처리와 파일 결과는 아직 확인되지 않았다.
## 5. 다음 검증 착수 조건
- dev 서버 또는 로컬 미리보기 진입 가능
- mock 데이터 또는 초기 API 응답 구조 준비
- 인증 가능한 브라우저 환경에서 지도 조작 수동 검증 가능
@@ -0,0 +1,57 @@
# SFR-011 노후관로관리 통합 검증 결과
## 1. 문서 목적
- `SFR-011 노후관로관리` 유지보수 라운드의 검증 결과를 기록한다.
- 이번 검증은 팝업 위치/폭 유지보수와 좁은 폭 레이아웃 보정 중심이다.
## 2. 현재 검증 상태
### 2.1 수행 여부
- 정적 코드 검토와 타입 검사 시도를 수행했다.
- 자동 브라우저 검증은 수행하지 못했다.
### 2.2 수행 결과
- 정적 구현 확인
- `agedPipePopup` 최종 크기가 `850 x 600`으로 조정됐다.
- `agedPipePopup`이 `browser-left + alignTop + offsetTop: 200` 기준 배치를 사용하도록 설정됐다.
- 조회/다운로드 버튼이 `시군구`와 같은 수평선 오른쪽으로 정렬되도록 반영됐다.
- 필터 영역 하단 여백이 축소되도록 반영됐다.
- `UiTable` 외곽 마진 제거와 중복 총건수 숨김이 반영됐다.
- 표 영역이 현재 팝업 높이 기준에서 하단 공백이 과도하지 않도록 조정됐다.
- 로컬 라우트 응답 확인
- `http://127.0.0.1:3002/popup/agedPipePopup?elapsedYears=30&ctpvCd=0&sggCd=0` 응답 `200`
- `http://127.0.0.1:3002/map` 응답 `200`
- 정적 타입 검사
- 실행 명령: `pnpm --filter sn exec tsc --noEmit --pretty false`
- 결과: 실패
- 확인 내용
- 저장소 기존 타입 오류가 다수 존재한다.
- 출력된 기존 오류 경로
- `app/(map-layout)/map/components/common/index.ts`
- `app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx`
- `app/html/(map-layout)/map/components/common/index.ts`
- `app/html/(with-base-layout)/layout.tsx`
- `packages/common/business_components/src/ui/chart/*`
- 명령 말미에 `Command "tsc" not found`가 함께 출력돼 현재 실행 환경도 안정적이지 않다.
### 2.3 미수행 항목
- `localhost:3002/map` 실제 브라우저에서 `노후관로 > 목록보기` 팝업 위치 육안 확인
- 팝업 최소화 후 재오픈 체감 확인
- 실제 최종 크기(`850 x 600`) 상태에서 행 선택, 페이지 이동, 다운로드 버튼 상호작용 확인
## 3. 현재 리스크
- 자동 브라우저 캡처용 Playwright 의존성이 현재 세션에 없어 화면 증적을 남기지 못했다.
- 팝업 위치가 사용자 의도와 완전히 일치하는지는 수동 검증 전까지 확정할 수 없다.
- 저장소 기존 타입 오류 때문에 이번 변경의 정적 게이트 통과 여부를 분리 보장하기 어렵다.
## 4. 후속 검증 권장 항목
- `http://127.0.0.1:3002/map`에서 실제 팝업 열기
- 사용자 스크린샷 기준 위치 체감 확인
- 최종 크기(`850 x 600`) 상태에서 표 하단 공백 체감 확인
- 필요 시 문서만 현재 운영값 기준으로 재동기화
@@ -0,0 +1,66 @@
# SFR-012-002 하수관망도 업로드 통합 검증 계획
## 1. 문서 목적
- `SFR-012-002 하수관망도 업로드`의 검증 계획을 기능당 1개 문서로 누적 관리한다.
- 현재는 구현 전 단계이므로 FE-only 검증 범위와 BE 연동 후 검증 범위를 먼저 분리한다.
## 2. 검증 기준 요약
- `GIS 관리` popup이 정상적으로 열린다.
- 하수관로만 시설구분 대상으로 노출된다.
- 파일 선택 시 파일명이 표시된다.
- 업로드 성공 시 현황 목록이 즉시 갱신된다.
- 다운로드는 선택 행이 있을 때만 가능 상태가 된다.
- 원본 분리 저장 안내가 popup에서 유지된다.
## 3. FE-only 검증 계획
### 3.1 popup 진입
- 우측 기능 버튼 `GIS 관리` 클릭
- popup open 여부
- popup 제목, 업로드 영역, 현황 그리드 노출 여부
### 3.2 업로드 폼
- 하수관로 단일 시설구분이 기본 표시되는지 확인
- 하수맨홀, 하수처리시설 등 다른 시설구분이 노출되지 않는지 확인
- 파일 미선택 상태에서 `업로드` 클릭 차단
- 파일 선택 후 파일명 input 반영
### 3.3 업로드 현황
- mock 업로드 성공 시 목록 상단 반영
- 행 선택 시 선택 강조 및 상태 메시지 반영
- 다운로드 버튼 선택 상태 확인
- 항목 10건 초과 시 기존 공용 페이지네이션이 노출되는지 확인
- 첫 화면 기준 5행 수준이 자연스럽게 보이는지 확인
- `찾기`, `업로드`, `다운로드` 버튼 색과 간격이 화면에 맞는지 확인
### 3.4 요청 미리보기
- popup을 닫으면 분석도구 메뉴 active가 해제되는지 확인
## 4. BE 연동 후 검증 계획
- 허용 확장자 검증
- 최대 파일 크기 검증
- 시설구분 매핑 검증
- 중복 파일 검증
- 업로드 실패 시 원본 보호
- 다운로드 권한/존재 검증
## 5. 차단 사유 분류 기준
- `기능 결함`
- `인증 차단`
- `환경 차단`
- `데이터 차단`
- `자동화 한계`
## 6. 현재 상태
- 설계 완료
- FE-only mock 구현 완료
- 브라우저 정밀 조작 검증은 일부 미실행
@@ -0,0 +1,63 @@
# SFR-012-002 하수관망도 업로드 통합 검증 결과
## 1. 문서 목적
- `SFR-012-002 하수관망도 업로드`의 검증 결과를 기능당 1개 문서로 누적 관리한다.
- 현재 문서는 구현 전 단계의 초기 상태를 기록한다.
## 2. 현재 검증 상태
### 2.1 수행 여부
- FE-only mock 구현 기준으로 정적/라우트 검증을 일부 수행했다.
### 2.2 사유
- 실제 백엔드 업로드/다운로드 연결이 아직 없으므로, 이번 결과는 popup 렌더링과 mock 흐름 중심이다.
### 2.3 수행 결과
- 정적 구현 확인
- `gisManagementPopup` placeholder가 실제 popup page wrapper 구조로 교체됨
- 시설구분 선택, 파일 선택, mock 업로드 이력, 다운로드 대상 선택 UI 확인
- 업로드 현황 10건 단위 페이지네이션 연결 확인
- popup 종료 시 분석도구 active 해제 로직 연결 확인
- `찾기`, `업로드` 버튼 `조회` 계열 색 반영
- `다운로드` 버튼 `green` 계열 색 반영
- 업로드 row 버튼 그룹 정렬/간격 조정 반영
- 로컬 라우트 응답 확인
- `http://127.0.0.1:3002/popup/gisManagementPopup` 응답 `200`
- `http://127.0.0.1:3002/map` 응답 `200`
- 응답 HTML 문자열 확인
- `GIS 업로드`
- `GIS 업로드 현황`
- `시설구분`
- `다운로드`
- 정적 타입 검사
- `pnpm --filter sn exec tsc --noEmit --pretty false --incremental false`
- 결과: 실패
- 사유: 기존 저장소 타입 오류
- `app/(map-layout)/map/components/common/index.ts`
- `app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx`
- `app/html/(map-layout)/map/components/common/index.ts`
- `app/html/(with-base-layout)/layout.tsx`
- `packages/common/business_components/src/ui/chart/*`
- 이번 `GisManagementPopupContent.tsx` 관련 신규 오류는 출력되지 않았다.
## 3. 후속 검증 예정 항목
- `GIS 관리` popup open
- 시설구분/파일 선택 유효성
- mock 업로드 이력 갱신
- 다운로드 대상 선택
- 10건 초과 페이지 전환
- popup 종료 후 active 해제
- 버튼 색/간격 최종 육안 확인
- BE 연동 후 실제 업로드/다운로드 검증
## 4. 현재 리스크
- 서버 계약이 확정되기 전까지 FE 검증 항목 일부는 mock 기준에 머문다.
- 실제 파일 선택, 업로드 버튼 클릭, 행 선택 상호작용은 브라우저 수동 확인이 한 번 더 필요하다.
- 저장소 기존 타입 오류 때문에 이번 기능만 분리한 정적 통과 여부를 보장하기 어렵다.
- 시설구분 목록은 아직 실제 시설물 레이어 조회 결과와 자동 동기화되지 않는다.
@@ -0,0 +1,171 @@
# SFR-012-003 하수관로편집 통합 검증 계획
## 1. 문서 목적
- `SFR-012-003 하수관로편집`의 검증 계획을 기능당 1개 문서로 누적 관리한다.
- 기존 `001`, `003`, `005`, `007`, `009`, `011` 계획 문서를 이 문서로 흡수한다.
- 이후 라운드부터는 새 검증 계획 파일을 추가하지 않고 이 문서 내부 헤더로만 구분한다.
## 2. 검증 기준 요약
- 레이어 선택 전 편집 도구 비활성
- `ADD`, `MOVE`, `VERTEX_EDIT`, `DELETE` 변경분은 저장 전까지 로컬 세션 유지
- `MOVE`
- preview drag 후 geometry 반영
- `VERTEX_EDIT`
- vertex marker 표시
- 양 끝점 이동 가능
- geometry 반영
- 저장 시 원본 직접 수정 금지
## 3. 라운드별 검증 계획 요약
### 3.1 초기/후속 검증
- `GIS 편집` 버튼 진입/종료, 메뉴 강조 잔류 재확인
- 기본 툴바 노출과 상태 메시지 확인
### 3.2 세션도구 확장 검증
- 7개 편집 레이어 노출 확인
- 레이어별 도구 활성 규칙 확인
- `undo`, `reset` 확인
### 3.3 실제지도조작 확장 검증
- `MOVE`, `VERTEX_EDIT`, `DELETE`가 실제 지도 조작 구조를 받을 수 있는지 코드/브라우저 기준으로 점검
- 자동 브라우저 drag는 한계가 있어 수동 검증 필요로 기록
### 3.4 `MOVE`
- preview drag 구조와 세션 반영 흐름은 상대적으로 안정적
- 유지 기준으로 본다
### 3.5 `VERTEX_EDIT`
- 가장 많은 변동이 있었고 현재도 재검증 필요
- 핵심 검증 기준
- 선택 직후 vertex marker 표시
- 양 끝점 이동 가능
- 이동 후 geometry가 실제로 변경
### 3.6 2026-05-15 세션 유지 / `MOVE` / `VERTEX_EDIT` 회귀 검증
- 사용자 피드백 기준으로 아래 항목을 우선 재검증한다.
- `ADD`, `MOVE`, `VERTEX_EDIT` 후 세션 기록과 지도 preview가 바로 사라지지 않는지
- 신규 추가 preview가 레이어별로 다른 색상으로 보여 구분 가능한지
- 편집 세션 기본 패널이 최근 5건 정도 높이만 보이고, 아래 이력은 패널 내부 스크롤로 바로 확인 가능한지
- `MOVE` 대상 선택 후 preview drag가 실제로 반응하는지
- `VERTEX_EDIT` 대상 선택 후 모든 실꼭지점 marker와 midpoint handle이 드러나는지
- `VERTEX_EDIT` 양 끝점 drag가 실제로 반응하는지
- `VERTEX_EDIT` midpoint drag 후 새 vertex가 삽입되는지
- `VERTEX_EDIT` 드래그 후 geometry 변경이 세션 요약과 preview에 반영되는지
- `하수처리시설`이 point 기준으로 `VERTEX_EDIT` 비대상으로 처리되는지
### 3.7 2026-05-15 QGIS 유사 `VERTEX_EDIT` 재구현 검증
- QGIS 비교 기준으로 아래 항목을 별도 확인한다.
- 실꼭지점은 항상 보이되, midpoint는 모든 segment에 상시 표시되지 않고 hover segment 기준으로만 나타나는지
- endpoint와 중간 vertex가 동일한 주황색 handle 규칙으로 이동되는지
- midpoint drag가 새 vertex 삽입으로 반영되는지
- feature 선택이 유지된 상태에서 연속 꼭지점 편집이 가능한지
- `MOVE` 도구 회귀 없이 preview drag가 기존처럼 유지되는지
### 3.8 2026-05-15 편집 세션 팝업 전환 및 도구 아이콘 검증
- 편집 세션 고정 패널이 더 이상 지도 위에 남지 않는지
- GIS 편집 진입 시 `GIS 편집 세션` window popup이 열리는지
- 팝업에서 변경 목록, 마지막 변경 취소, 세션 초기화, 항목 focus가 메인 지도와 동기화되는지
- 세션 안내 문구 영역이 제거됐는지
- `추가`, `이동`, `꼭지점편집`, `삭제` 버튼에 `lucide-react` 아이콘이 표시되는지
### 3.9 2026-05-21 관로 기본정보 세션 편집 검증
- 편집 대상 레이어가 `하수관로`일 때만 편집 상세정보창이 열리는지
- `MOVE`, `VERTEX_EDIT`, `ADD` change 선택 후 상세정보창 상단 `기본정보` 테이블에서 7개 필드가 직접 수정 가능한지
- 신규 추가일 때 `경과년도`, `설치일자 확인불가` 같은 조회 전용 표식이 노출되지 않는지
- 상세정보창 저장이 즉시 DB 저장이 아니라 `basicInfo` 세션 저장으로만 처리되는지
- 입력 중 스크롤이 상단으로 점프하지 않고 현재 편집 위치를 유지하는지
- `GIS 편집 세션` 목록에 `기본정보 세션저장` 표식이 나타나는지
- 최종 저장 payload에 `featureRef`, `basicInfo`, `beforeGeometry`, `afterGeometry`가 함께 들어가는지
- `DELETE` change나 관로 외 레이어에서는 상세정보 세션 편집창이 열리지 않는지
- 편집 세션 모드에서는 `보수 및 갱신`, `청소 및 준설`, `점검 및 진단` 섹션이 숨겨지는지
- `MOVE`, `VERTEX_EDIT` 중 비하수관로 클릭 시 선택/줌 이동 없이 차단되고 상태문구만 바뀌는지
- `MOVE`, `VERTEX_EDIT`에서 하수관로 선택 즉시 편집 상세정보창이 열리는지
## 4. 현재 검증 범위
### 4.1 진행 중
- `ADD` geometry family별 직접 캡처
- `VERTEX_EDIT`의 QGIS 기준 사용자 경험 재정렬
### 4.2 이후 범위
- 실제 저장 API 연결 이후 통합 저장 검증
- 원본 보호와 편집본 참조 갱신 검증
## 5. 현재 테스트 메모
- dev 서버가 켜져 있으면 `next build`는 수행하지 않는다.
- `next lint`는 환경 이슈로 안정적인 회귀 수단이 되지 못하고 있다.
- 지도 캔버스 기반 drag/vertex 테스트는 수동 브라우저 검증이 핵심이다.
## 5-1. 브라우저 검증 선행조건
- `/map` 진입 가능한 로그인 상태 또는 로컬 임시 로그인 경로가 준비되어 있어야 한다.
- FE dev 서버와 관련 BE API가 모두 응답해야 한다.
- 편집 대상 레이어와 샘플 객체를 재현 가능한 테스트 데이터로 확보해야 한다.
- 인증 또는 외부 연동 문제로 진입이 막히면 `기능 결함`이 아니라 `인증 차단` 또는 `환경 차단`으로 분류한다.
- 로컬 인증 우회 필요 시 `COMMON_DOCS_ROOT/작업내용/공통-로그인/001-de-공통-로그인-로컬임시로그인-검토계획.md`를 참조한다.
## 5-2. 브라우저 자동검증 관측 포인트
| 구분 | 관측 포인트 | 기대 결과 | 검증 방식 |
| --- | --- | --- | --- |
| 공통 | `GIS편집` 버튼 | 편집 모드 진입 시 강조, 종료 시 해제 | 자동 |
| 공통 | 편집 툴바 | 편집 모드에서만 노출 | 자동 |
| 공통 | 레이어 선택 | 미선택 시 도구 비활성, 선택 시 활성 | 자동 |
| 세션 | 변경 목록 | 조작 후 항목 누적 | 자동 |
| 세션 | `undo`, `reset` | 상태 복구 또는 초기화 | 자동 |
| `ADD` | 상태 메시지/신규 항목 | geometry family별 신규 추가 반영 | 자동 중심 |
| `MOVE` | preview label, 목록 요약 | drag 후 geometry 변경 반영 | 자동+수동 |
| `VERTEX_EDIT` | vertex marker 또는 modify handle | 선택 직후 노출, 이동 후 geometry 변경 | 수동 중심 |
| `DELETE` | 임시 삭제 스타일 | 저장 전까지 유지 | 자동+수동 |
| `SAVE` | payload/응답 처리 | 성공 또는 실패 메시지와 세션 상태 반영 | 자동 |
## 5-3. 차단 사유 분류 기준
- `기능 결함`: 기대한 상태 전이 또는 응답 처리가 깨짐
- `인증 차단`: 로그인, 토큰, 권한 문제로 진입 또는 호출이 막힘
- `환경 차단`: 서버 미기동, 프록시/네트워크 문제
- `데이터 차단`: 레이어 메타, 샘플 객체, 테스트 BBOX 부족
- `자동화 한계`: canvas drag, vertex 조작을 도구가 안정적으로 재현하지 못함
## 6. 다음 검증 순서
1. `ADD`
- 점형 클릭 추가
- 선형 연속 클릭 추가
- 면형 폐합 추가
- 편집 모드 종료 후에도 세션/preview 유지 확인
2. `MOVE`
- preview drag 회귀 없는지 확인
- drag 종료 후 요약/geometry 반영 확인
3. `VERTEX_EDIT`
- 실꼭지점 marker 표시
- hover segment midpoint 표시
- 양 끝점 이동
- 중간 vertex 이동
- 드래그 후 geometry/요약 반영 확인
4. `DELETE`
- 임시 삭제 스타일 유지
## 7. 흡수한 기존 문서
- `001-qa-SFR-012-003-하수관로편집-검증계획.md`
- `003-qa-SFR-012-003-하수관로편집-후속검증계획.md`
- `005-qa-SFR-012-003-하수관로편집-세션도구확장검증계획.md`
- `007-qa-SFR-012-003-하수관로편집-실제지도조작검증계획.md`
- `009-qa-SFR-012-003-하수관로편집-MOVE실드래그검증계획.md`
- `011-qa-SFR-012-003-하수관로편집-VERTEX_EDIT실조작검증계획.md`
@@ -0,0 +1,599 @@
# SFR-012-003 하수관로편집 통합 검증 결과
## 1. 문서 목적
- `SFR-012-003 하수관로편집`의 검증 결과를 기능당 1개 문서로 누적 관리한다.
- 기존 `002`, `004`, `006`, `008`, `010`, `012` 결과 문서를 이 문서로 흡수한다.
- 이후 라운드부터는 새 검증 결과 파일을 추가하지 않고 이 문서 내부 헤더로만 구분한다.
## 2. 수행한 검증 요약
### 2.1 초기/후속 검증
- `GIS 편집` 모드 진입/종료
- 메뉴 강조 잔류 재확인
### 2.2 세션도구 확장 검증
- 7개 편집 레이어 노출
- 도구 활성 규칙
- `undo`, `reset`
### 2.3 실제지도조작 확장 검증
- `MOVE`, `VERTEX_EDIT`, `DELETE` 구조 점검
- 자동 브라우저 drag 한계 확인
### 2.4 `MOVE`
- preview drag 구조와 세션 반영 흐름은 상대적으로 안정적
### 2.5 `VERTEX_EDIT`
- 여러 차례 보강했지만 현재는 재검증 필요 상태
### 2.6 2026-05-15 세션 유지 / 상호작용 회귀 대응 점검
- 코드 기준 점검
- `GIS편집` 모드 종료 후에도 변경분이 있으면 세션 패널과 지도 preview를 유지하도록 수정했다.
- 신규 추가 preview는 레이어별 색상을 우선 사용하도록 보강했다.
- 편집 세션 기본 패널은 최근 5건 정도 높이만 보이도록 고정하고, 아래 이력은 패널 내부 스크롤로 바로 확인하도록 바꿨다.
- 추가 조정으로 세션 패널 높이를 더 줄이고, 하단 안내 문구는 제거했다.
- preview layer는 상호작용 중 redraw되도록 `updateWhileInteracting` 보강이 들어갔다.
- `MOVE`, `VERTEX_EDIT` interaction effect는 `gisEdit` 전체 객체 의존 대신 실제 pending interaction 중심으로 좁혔다.
- 원본 피처 선택 시 layerId 정규화를 추가해 namespace 또는 feature id 형식 차이로 편집 진입이 막히지 않게 했다.
- `하수처리시설(tb_il_swage_prcs_fclt)`은 point 기준으로 정정해 `VERTEX_EDIT` 대상에서 빠지도록 맞췄다.
- 정적 검증
- `node node_modules/typescript/bin/tsc --noEmit --incremental false --pretty false`
- 결과: 기존 저장소 오류로 실패
- 확인된 기존 오류 예시
- `components/common/index.ts` 타입 불일치
- `PipeAnalysisPopupPage.tsx` 타입 변환 경고
- 공통 chart 컴포넌트 implicit any
- 로컬 라우트 응답 확인
- `http://127.0.0.1:3002/map` 응답 200
- `http://127.0.0.1:3002/auth/login` 응답 200
- 자동 브라우저 검증
- Playwright headless 실행 시 `spawn EPERM`으로 실패해 실제 canvas drag 자동검증은 수행하지 못했다.
- 따라서 `MOVE`, `VERTEX_EDIT`의 실조작 확인은 수동 브라우저 재검증이 남아 있다.
### 2.7 2026-05-15 QGIS 유사 `VERTEX_EDIT` 재구현 점검
- 코드 기준 점검
- `VERTEX_EDIT`는 custom vertex handle layer를 유지하되, 실꼭지점 상시 표시와 hover segment midpoint 1건 표시 구조로 단순화했다.
- midpoint 후보는 `LineString`, `MultiLineString`, `Polygon`, `MultiPolygon` 모두 계산 가능하도록 분리했다.
- vertex 근처에서는 midpoint를 숨기고, segment 근처에서만 midpoint를 띄우도록 pixel tolerance를 적용했다.
- endpoint와 일반 vertex는 같은 drag 규칙을 사용하고, midpoint drag는 새 좌표 삽입으로 처리한다.
- `MOVE` preview layer의 `clickPsblYn="Y"` 기준은 유지했고, vertex layer는 별도 레이어로 계속 분리했다.
- 정적 검증
- `pnpm exec tsc --noEmit --incremental false --pretty false`
- 결과: 이번 변경분 관련 신규 타입 오류는 정리했고, 저장소 기존 오류만 남았다.
- 남은 기존 오류 예시
- `components/common/index.ts` 타입 불일치
- `PipeAnalysisPopupPage.tsx`의 `JSON[]` 캐스팅 경고
- 공통 chart 컴포넌트 implicit any
- 로컬 환경 확인
- `http://127.0.0.1:3002/map` 응답 200
- `http://127.0.0.1:3002/auth/login` 응답 200
- `http://127.0.0.1:3002/popup/gisEditSessionPopup` 응답 200
- 현재 세션에서는 브라우저 전용 자동화 도구를 확보하지 못해 canvas 기반 midpoint hover/drag 실조작은 수동 재검증 필요로 남긴다.
### 2.8 2026-05-15 편집 세션 팝업 전환 및 도구 아이콘 점검
- 코드 기준 점검
- 지도 좌하단 고정 세션 패널 렌더링은 제거했다.
- `GIS 편집 세션` popup definition, registry, popup content를 추가해 기존 window popup 체계에 맞췄다.
- 메인 `/map`은 GIS 편집 진입 시 세션 팝업을 열고, postMessage로 세션 snapshot을 동기화하도록 바꿨다.
- 팝업에서 `마지막 변경 취소`, `세션 초기화`, `항목 focus`를 메인 지도에 다시 전달하도록 연결했다.
- 세션 패널의 안내 문구 영역은 제거했다.
- `추가`, `이동`, `꼭지점편집`, `삭제` 버튼은 `lucide-react` 아이콘을 사용하도록 바꿨다.
- 정적 검증
- `pnpm exec tsc --noEmit --incremental false --pretty false`
- 결과: 여전히 저장소 기존 오류로 실패
- 기존 오류 범주
- 기존 공통 타입 불일치
- 기존 chart implicit any
- 수동 재검증 필요 항목
- 브라우저 팝업 차단 여부
- 세션 팝업과 메인 지도 간 메시지 동기화
- 도구 아이콘 실제 렌더링
### 2.9 2026-05-21 관로 기본정보 세션 편집 점검
- 코드 기준 점검
- `GisEditChange`에 `featureRef`, `basicInfo`, `basicInfoSavedAt`가 추가돼 geometry change와 관로 기본정보 draft를 함께 담을 수 있게 했다.
- 하수관로 change를 선택하면 편집 전용 상세정보창을 열고, 기존 `GET /api/v1/fcltppl/{fcltMngNo}/{mngInstCd}`로 기본정보를 읽어 와 세션 저장할 수 있게 했다.
- 상세정보창 저장은 기존 즉시 `POST /api/v1/fcltppl`이 아니라 `updateChangeBasicInfo`로 change draft만 갱신한다.
- 최종 저장 payload는 `changeId`, `featureRef`, `basicInfo`, `beforeGeometry`, `afterGeometry`를 함께 조립하도록 바뀌었다.
- 정적 검증
- `node node_modules/typescript/bin/tsc --noEmit --incremental false --pretty false`
- 결과: 기존 저장소 오류로 실패
- 확인된 기존 오류
- `app/(map-layout)/map/components/common/index.ts` 타입 불일치
- `PipeAnalysisPopupPage.tsx`의 `JSON[]` 캐스팅 경고
- html 라우트 경로 import 누락
- 공통 chart 컴포넌트 implicit any
- 이번 변경 파일 관련 신규 오류는 별도 출력되지 않았다.
- 수동 재검증 필요 항목
- 편집 모드에서 실제 하수관로 선택 시 상세정보창이 예상대로 열리는지
- 기본정보 저장 후 세션 목록 뱃지가 정상 반영되는지
- 최종 payload 미리보기에 관로 기본정보와 geometry가 함께 담기는지
- 백엔드 geometry 저장 API 부재 상태에서 사용자가 최종 저장 한계를 어떻게 인지할지
### 2.10 2026-05-21 자동저장 루프/선택 제한 보강 점검
- 코드 기준 점검
- 입력값 변경을 `useEffect` 자동 저장으로 처리하던 구조를 제거하고, 실제 `input/select` 변경 이벤트에서만 세션 draft를 갱신하도록 바꿨다.
- 편집 팝업 wrapper를 안정화해 입력 중 remount로 인한 스크롤 상단 점프 가능성을 줄였다.
- 공통 `GisSelect`에 `canSelectFeature` 가드를 연결해 `MOVE`, `VERTEX_EDIT` + 하수관로 레이어 조합일 때 비하수관로 선택을 선택 단계에서 차단하도록 확장했다.
- 비하수관로 차단 시 지도 줌 이동이나 상세정보 전환 없이 상태문구 `하수관로를 선택해주세요.`만 남기도록 정리했다.
- `MOVE`, `VERTEX_EDIT`의 하수관로 선택 시 상세정보창 오픈은 preview 준비 완료 대기 대신 `setTimeout(0)` 기준 즉시 오픈으로 단순화했다.
- 정적 검증
- `node node_modules/typescript/bin/tsc --noEmit --incremental false --pretty false`
- 결과: 기존 저장소 오류로 실패
- 확인된 기존 오류
- `app/(map-layout)/map/components/common/index.ts` 타입 불일치
- `PipeAnalysisPopupPage.tsx`의 `JSON[]` 캐스팅 경고
- html 라우트 경로 import 누락
- 공통 chart 컴포넌트 implicit any
- 수동 재검증 필요 항목
- 하수관로 편집 팝업의 입력 중 스크롤 위치 유지
- `MOVE`, `VERTEX_EDIT`에서 하수관로 선택 즉시 팝업 오픈
- 비하수관로 클릭 시 줌 이동 완전 차단
- `ADD` 미리보기와 `MOVE`, `VERTEX_EDIT` interaction 회귀 여부
### 2.11 2026-05-22 최종본/이력 DB 저장 통합 검증
- 검증 환경
- 화면: `http://localhost:3002/map`
- 프론트 프록시 대상: `http://localhost:8883/sewerage-service`
- 백엔드: `sewerage-service` local profile, port `8883`
- DB: `sm_bs_ms.tb_ms_swage_ppl`, `sm_bs_ms.tb_ms_edit_swage_ppl`
- 발견 문제
- 브라우저에서 `ADD` 저장 요청은 정상 전송됐지만 최초 검증 시 `500 / E999`가 반환됐다.
- DB 직접 확인 결과, 최종본 `tb_ms_swage_ppl`의 다음 fid는 `4132694`였지만 이력 테이블에는 이미 `fid=4132694, edit_sn=0` 이력이 존재했다.
- 기존 BE 채번이 최종본 `MAX(fid)+1`만 사용해 삭제 이력이 남은 fid를 재사용했고, 이력 테이블 복합키 `tb_ms_edit_swage_ppl_pk(fid, edit_sn)`와 충돌했다.
- 조치 내용
- 신규 fid 채번 기준을 최종본과 이력 테이블을 합친 `MAX(fid::bigint)+1`로 변경했다.
- 브라우저 저장 성공 후 원본 레이어 refresh가 호출되도록 기존 저장 성공 처리 흐름을 유지했다.
- 신규 관로의 시설관리번호와 관로관리번호가 비어 있거나 `NEW-*` 임시값이면 저장 API를 호출하지 않고 상세 입력창을 열도록 FE 검증을 추가했다.
- 브라우저/API 검증 결과
- `ADD`: 지도에서 신규 관로를 그리고 `저장` 버튼 클릭, 응답 `200`, `editCd=C`, `editSn=0`, 신규 `fid=4132695`
- `MOVE`: 같은 브라우저 세션에서 신규 fid 기준 수정 저장, 응답 `200`, `editCd=U`, `editSn=1`
- `DELETE`: 같은 브라우저 세션에서 신규 fid 기준 삭제 저장, 응답 `200`, `editCd=D`, `editSn=2`
- 번호 미입력 신규 저장: 저장 요청 `0건`, 상태문구 `신규 하수관로 저장 전 시설관리번호와 관로관리번호를 입력해주세요.` 노출 확인
- DB 검증 결과
- 삭제 후 `tb_ms_swage_ppl`의 테스트 fid 최종본 건수: `0`
- 삭제 전 검증 시 `tb_ms_edit_swage_ppl`의 테스트 fid 이력: `0:C,1:U,2:D`
- 검증 완료 후 테스트 fid `4132695`의 최종본/이력 흔적은 정리했다.
- 중간 자동화 중 잘못 선택된 기존 관로 `fid=2280012`는 직전 이력 기준으로 geometry를 원복했고, 테스트로 생성된 `edit_sn=1` 이력은 삭제했다.
- 정적 검증
- BE: `.\gradlew.bat compileJava test --tests iipbs.sewerage.service.map.SwagePplEditServiceTest` 성공
- FE: `pnpm --filter sn exec tsc --noEmit --pretty false`는 저장소 기존 오류로 실패했으며, 이번 GIS 편집 변경 파일 관련 신규 오류는 확인되지 않았다.
- 남은 확인 사항
- 자동화 좌표 클릭으로 `MOVE` 대상을 고를 때, 새 관로와 기존 관로가 겹치면 기존 관로가 선택될 수 있다. 실제 QA에서는 신규 관로를 기존 관로와 겹치지 않는 위치에 생성하거나 fid 기반 재선택 보조가 필요하다.
- 저장 전 신규 관로의 시설관리번호/관로관리번호 입력 편의성은 별도 화면 검증에서 추가 확인이 필요하다.
### 2.12 2026-05-26 하수관망도 GIS 편집 UI/기능 상세 재검증
#### 2.12.1 검증 목적
- 하수관망도 화면의 `GIS 편집` 기능을 실제 사용자 조작 흐름 기준으로 재검증한다.
- `추가`, `이동`, `꼭지점편집`, `삭제`, `저장 검증`, `편집 세션` 흐름을 함께 확인한다.
- 사용자가 제기한 "기능이 이상한 것 같다"는 관찰을 UI/기능/계약/검증 가능성 관점으로 분해한다.
- OpenLayers, ArcGIS, QGIS의 일반적인 GIS 편집 UX와 비교해 개선 방향을 정리한다.
#### 2.12.2 검증 환경
- 작업 루트: `D:\workspace`
- 프론트엔드 앱: `D:\workspace\IIPBS-FRONTEND\frontend\apps\sn`
- 화면 URL: `http://localhost:3002/map`
- 백엔드 URL: `http://localhost:8883/sewerage-service`
- 기준 화면: 하수관망도
- 기준 편집 대상 레이어: `tb_ms_swage_ppl`
- 기준 브라우저 검증 방식: 로컬 개발 서버에 로그인 후 실제 지도 화면에서 버튼 클릭, 좌표 클릭, 드래그, 더블클릭 수행
- 병행 QA: `qa` 서브에이전트로 백엔드 테스트, API 검증, 인증 차단 여부 확인
#### 2.12.3 검증 전제와 환경 제약
- `sn` Next 개발 서버가 `3002` 포트에서 이미 실행 중이었다.
- `AGENTS.md` 기준에 따라 개발 서버가 실행 중인 상태에서는 `next build`, `pnpm --filter sn run build`, `pnpm turbo run build --filter=sn`을 실행하지 않았다.
- 로컬 브라우저 직접 검증은 개발 로그인 계정으로 진행했다.
- QA 서브에이전트는 별도 세션에서 `/auth/login` 인증 차단에 걸려 화면 직접 검증을 수행하지 못했다.
- 따라서 본 섹션의 UI 직접 조작 결과는 메인 브라우저 검증 결과를 기준으로 하고, 서브에이전트 결과는 API/BE/정적 검증 보조 증적으로 사용한다.
#### 2.12.4 브라우저 진입 및 초기 로딩 상태
- `/map` 직접 접근 시 로그인 페이지로 이동하는 것을 확인했다.
- 개발 로그인 후 하수관망도 화면에 진입했다.
- 지도, 좌측 시설 트리, 우측 분석 메뉴, 하수관망도 레이어 표시는 확인됐다.
- `GIS 편집` 버튼 클릭 시 지도 상단 중앙에 편집 툴바가 표시됐다.
- 툴바 구성은 `추가`, `이동`, `꼭지점편집`, `삭제`, `저장`으로 확인됐다.
- 편집 진입 직후 `저장` 버튼은 비활성 상태였다.
- 상태 문구는 `하수관로 편집 도구를 선택한 뒤 지도에서 편집 작업을 진행하세요.`로 표시됐다.
- 지도 초기 로딩 중 다음 오류가 관찰됐다.
- `BIZ_TY_CD 데이터 로드 실패`
- `BIZ_SE_CD 데이터 로드 실패`
- 레이어 설정 조회 실패, `500 Internal Server Error`
- 메뉴 wrapper 계열 `AxiosError`
- 위 오류는 GIS 편집 단독 결함으로 단정하지 않았지만, 편집 QA 중 화면 신뢰도와 재현성을 낮추는 환경/연동 리스크로 기록한다.
#### 2.12.5 `추가` 검증 결과
| 항목 | 내용 |
| --- | --- |
| 재현 절차 | `GIS 편집` 진입 후 `추가` 선택, 지도 위 여러 지점 클릭, 마지막 지점 더블클릭 |
| 기대 결과 | 신규 하수관로 선형 sketch가 생성되고, 지도 위에서 선택 가능한 preview가 명확히 표시되며, 상세정보 입력 패널과 저장 가능 상태가 연결된다. |
| 실제 결과 | 신규 변경분 `NEW-1`이 생성되고 `저장` 버튼이 활성화됐다. 우측 상세정보 패널이 열렸고 기본정보 입력 영역이 표시됐다. |
| 통과 항목 | 도구 활성화, 선형 입력 완료, 신규 변경분 생성, 상세 패널 오픈, 저장 버튼 활성화 |
| 문제 항목 | 신규 선형이 지도 위에서 충분히 강조되지 않았고, 우측 상세정보 패널이 지도 영역을 크게 가려 생성 위치 확인이 어렵다. |
| 판정 | 부분 통과 |
- `추가` 도구의 기본 기능은 동작한다.
- 다만 사용자는 "내가 방금 추가한 관로가 어디에 생겼는지"를 지도에서 즉시 확인하기 어렵다.
- 신규 객체는 고대비 선형, 라벨, 선택 테두리, 꼭지점 표시 중 최소 하나 이상의 강한 피드백이 필요하다.
- 상세정보 패널이 우측 지도 영역을 크게 차지하므로 추가 직후 선형 위치와 패널 입력을 동시에 확인하기 어렵다.
#### 2.12.6 `이동` 검증 결과
| 항목 | 내용 |
| --- | --- |
| 재현 절차 | 신규 관로 추가 후 `이동` 선택, 신규 관로가 생성된 것으로 예상되는 위치에서 클릭 및 드래그 |
| 기대 결과 | 이동 대상이 선택되고, 이동용 preview 또는 선택 테두리가 표시되며, 드래그 시 객체 geometry만 이동한다. |
| 실제 결과 | 이동 도구 안내 문구는 표시됐지만, 대상 선택 상태와 드래그 핸들이 명확하지 않았다. 드래그 시 객체 이동보다 지도 pan처럼 보이는 동작이 관찰됐다. |
| 통과 항목 | 도구 활성화, 상태 문구 표시 |
| 문제 항목 | 선택 대상 식별 불명확, 이동 preview 식별 불명확, 드래그 중 지도 이동과 객체 이동이 구분되지 않음 |
| 판정 | 불안정 |
- `이동`은 GIS 편집에서 사용자가 가장 직관적으로 기대하는 기능 중 하나지만, 현재 화면에서는 "어떤 객체가 선택됐고 무엇을 잡고 움직여야 하는지"가 명확하지 않다.
- 편집 대상이 선택되면 지도 pan보다 객체 이동 interaction이 우선되어야 한다.
- 선택 후 이동 가능한 상태에서는 cursor, 선택 테두리, preview 색상, 상태 문구가 함께 바뀌어야 한다.
#### 2.12.7 `꼭지점편집` 검증 결과
| 항목 | 내용 |
| --- | --- |
| 재현 절차 | `꼭지점편집` 선택 후 신규 관로 또는 기존 관로로 보이는 위치를 클릭하고 vertex handle 표시 및 드래그 가능 여부 확인 |
| 기대 결과 | 실꼭지점은 주황색 handle로, 새 점 삽입 후보 midpoint는 흰색 handle로 안정적으로 표시된다. 꼭지점을 드래그하면 선형 geometry가 즉시 갱신된다. |
| 실제 결과 | 안내 문구는 표시됐지만 실제 주황색 꼭지점과 흰색 midpoint handle을 안정적으로 확인하지 못했다. 꼭지점 선택/드래그 가능 상태도 사용자 관점에서 확정하기 어려웠다. |
| 통과 항목 | 도구 활성화, 상태 문구 표시 |
| 문제 항목 | vertex handle 표시 불명확, midpoint 삽입 후보 확인 불가, 드래그 가능 대상 식별 불가 |
| 판정 | 불량 |
- `꼭지점편집`은 현재 가장 큰 기능 리스크다.
- 기존 문서에서도 `VERTEX_EDIT`는 QGIS 기준 사용자 경험 재정렬과 실조작 수동 재검증이 필요한 항목으로 남아 있었다.
- 이번 재검증에서도 사용자가 기대하는 "꼭지점이 보이고 잡아서 움직인다"는 핵심 피드백이 충분하지 않았다.
- 선형 편집 도구는 실꼭지점, 선택 꼭지점, hover segment, midpoint 삽입 후보를 시각적으로 구분해야 한다.
#### 2.12.8 `삭제` 검증 결과
| 항목 | 내용 |
| --- | --- |
| 재현 절차 | `삭제` 선택 후 신규 관로 또는 기존 관로로 보이는 위치 클릭 |
| 기대 결과 | 삭제 대상이 선택되고, 즉시 최종 삭제하지 않더라도 pending-delete 스타일이 표시되며, 세션 변경목록에 삭제 변경분이 추가된다. |
| 실제 결과 | 삭제 도구 안내 문구는 표시됐지만, 어떤 객체가 삭제 대상으로 선택됐는지 명확한 지도 피드백을 확인하지 못했다. |
| 통과 항목 | 도구 활성화, 상태 문구 표시 |
| 문제 항목 | 삭제 대상 선택 피드백 부족, pending-delete 스타일 확인 어려움, 되돌리기 흐름 노출 부족 |
| 판정 | 불안정 |
- 삭제는 저장 전 pending 상태가 반드시 명확해야 한다.
- 현재처럼 선택 피드백이 약하면 사용자는 기존 관로를 잘못 삭제 대상으로 올렸는지 확인하기 어렵다.
- 삭제 대상은 빨간 점선, 반투명 처리, `삭제 예정` 라벨, 세션 목록 강조가 함께 필요하다.
#### 2.12.9 저장 검증 결과
| 항목 | 내용 |
| --- | --- |
| 재현 절차 | 신규 관로 `NEW-1` 생성 후 필수값을 충분히 입력하지 않은 상태에서 `저장` 클릭 |
| 기대 결과 | 저장 API 호출 전 필수값 누락을 차단하고, 누락 필드와 입력 위치를 정확히 안내한다. |
| 실제 결과 | 저장 API 호출 전 차단됐고, 상세정보 패널이 열리며 상태 문구가 표시됐다. |
| 통과 항목 | 신규 관로 필수값 누락 시 저장 API 호출 차단 |
| 문제 항목 | 상태 문구와 화면 필드명이 일치하지 않음 |
| 판정 | 부분 통과 |
- 표시 문구: `신규 하수관로 저장 전 시설관리번호와 관로관리번호를 입력해주세요.`
- 화면 필드명: `시설관리번호`, `기관관리번호`
- `관로관리번호`와 `기관관리번호`가 서로 다른 의미라면 화면에 두 필드를 모두 분명히 노출해야 한다.
- 같은 의미라면 필드명과 검증 문구를 통일해야 한다.
- 이 불일치는 저장 실패 원인을 사용자가 잘못 이해하게 만들 수 있으므로 UI 문구 결함으로 분류한다.
#### 2.12.10 편집 세션 및 팝업 검증 결과
| 항목 | 내용 |
| --- | --- |
| 기대 결과 | GIS 편집 진입 시 편집 세션 목록, 마지막 변경 취소, 세션 초기화, 항목 focus가 사용자가 확인 가능한 위치에 표시된다. |
| 실제 결과 | 메인 지도 툴바는 표시됐지만, 세션 팝업 또는 변경목록이 사용자에게 명확히 노출되는지 확인이 어려웠다. |
| 판정 | 불안정 |
- 기존 구현 기록상 GIS 편집 세션은 popup 기반으로 전환되어 있다.
- 실제 사용자는 브라우저 팝업 차단, 포커스 이동, 별도 창 위치 문제로 세션 상태를 놓칠 수 있다.
- 편집 기능은 저장 전 변경목록 확인이 중요하므로, 팝업이 열리지 않거나 가려져도 메인 화면 안에서 최소 변경 건수와 마지막 변경 요약은 확인 가능해야 한다.
#### 2.12.11 서브에이전트 QA 결과 요약
- 코드 변경 없이 읽기, 브라우저, API, 테스트 중심으로 검증했다.
- BE `.\gradlew.bat compileJava`: 통과
- BE `.\gradlew.bat test`: 통과, `BUILD SUCCESSFUL`
- FE 빌드: 미실행
- `sn` 개발 서버가 `3002`에서 실행 중이므로 사용자 확인 없이 빌드하지 않았다.
- FE `pnpm --filter sn exec tsc --noEmit --pretty false`: 실패
- 기존 공통/앱 TypeScript 오류 다수 확인
- 이번 GIS 편집 단독 신규 오류로 단정하지 않음
- 서브에이전트 브라우저 검증: `/auth/login` 인증 차단으로 GIS 화면 직접 검증 불가
- API 검증:
- `GET /sewerage-service/api/v1/map/gisEdit/swagePpl/meta?lyrGrp=SN_NETWORK`: 통과
- 편집 대상: `tb_ms_swage_ppl`
- 지원 편집: `ADD`, `MOVE`, `VERTEX_EDIT`, `DELETE`
- 저장 테이블: `sm_bs_ms.tb_ms_swage_ppl`
- 이력 테이블: `sm_bs_ms.tb_ms_edit_swage_ppl`
- 빈 변경분 저장 요청: `400 GIS_EDIT_EMPTY_CHANGES` 정상
- 비관로 레이어 저장 요청: `400 GIS_EDIT_PIPE_ONLY` 정상
- `GET /gisEdit/swagePpl/histories?fid=1`: `200 OK`
- 서브에이전트 증적:
- `D:\workspace\qa-evidence\sfr-012-003-auth-blocker.png`
- `D:\workspace\qa-evidence\sfr-012-003-session-popup-direct.png`
#### 2.12.12 외부 GIS 편집 UX 비교
| 비교 대상 | 참고 기준 | 현재 화면과의 차이 |
| --- | --- | --- |
| OpenLayers Draw | `Draw` interaction은 sketch 시작, 진행, 완료 이벤트를 별도 interaction으로 관리한다. | 현재 `추가`는 완료 후 신규 sketch의 시각 강조가 약하다. |
| OpenLayers Modify | `Modify` interaction은 vertex 조작을 명확한 handle 기반으로 제공한다. | 현재 `꼭지점편집`은 실꼭지점과 midpoint handle이 안정적으로 보이지 않는다. |
| OpenLayers Translate | `Translate` interaction은 선택 feature 이동을 지도 pan과 분리해 처리한다. | 현재 `이동`은 드래그 시 객체 이동과 지도 pan이 사용자에게 구분되지 않는다. |
| ArcGIS Map Viewer / Web Editor | 편집 대상 선택, vertex 편집, 저장 전 확인 흐름을 사용자에게 명확히 제공한다. | 현재 세션 변경목록과 pending 상태 노출이 약하다. |
| QGIS Vertex Tool | 실꼭지점, segment, midpoint 삽입 후보를 시각적으로 구분한다. | 현재 `VERTEX_EDIT`는 QGIS 수준의 조작 피드백에 도달하지 못했다. |
참고 링크:
- OpenLayers Draw: https://openlayers.org/en/latest/apidoc/module-ol_interaction_Draw-Draw.html
- OpenLayers Modify: https://openlayers.org/en/latest/apidoc/module-ol_interaction_Modify-Modify.html
- OpenLayers Translate: https://openlayers.org/en/latest/apidoc/module-ol_interaction_Translate-Translate.html
- ArcGIS Online Map Viewer editing: https://doc.arcgis.com/en/arcgis-online/manage-data/edit-features-mv.htm
- ArcGIS Web Editor vertex editing: https://doc.arcgis.com/en/web-editor/latest/create-and-edit/edit-vertices.htm
- QGIS geometry editing: https://docs.qgis.org/latest/en/docs/user_manual/working_with_vector/editing_geometry_attributes.html
#### 2.12.13 결함 및 개선 백로그
| 우선순위 | 구분 | 내용 | 개선 방향 |
| --- | --- | --- | --- |
| P1 | 기능/UX | `이동`, `꼭지점편집`, `삭제`에서 편집 대상 선택 피드백이 불충분하다. | 선택 feature를 고대비 overlay로 표시하고, 선택/drag 가능 상태를 cursor와 상태 문구로 함께 제공한다. |
| P1 | 기능 | `꼭지점편집`의 vertex handle과 midpoint handle이 안정적으로 확인되지 않는다. | 실꼭지점, 선택 꼭지점, hover segment, midpoint 삽입 후보를 별도 style로 분리한다. |
| P1 | 기능 | `이동` 드래그가 지도 pan과 구분되지 않는다. | 선택 feature drag 중 지도 pan을 일시 제한하고, 이동 preview source를 명확히 분리한다. |
| P1 | 기능/세션 | 세션 변경목록과 pending 상태가 사용자에게 명확하지 않다. | 팝업 실패 fallback 또는 메인 지도 내 축약 세션 패널을 제공한다. |
| P2 | UI | `추가` 후 신규 선형이 지도에서 충분히 강조되지 않는다. | 신규 객체 label, 선택 테두리, vertex 표시, 자동 focus를 보강한다. |
| P2 | UI | 상세정보 패널이 지도 확인을 방해한다. | 패널 폭/위치/접힘 상태를 조정하고, 선택 객체 focus와 겹치지 않게 한다. |
| P2 | 문구 | 저장 검증 문구의 `관로관리번호`와 화면 필드 `기관관리번호`가 불일치한다. | 백엔드 계약 기준으로 필드명을 통일하고 누락 필드를 행 단위로 강조한다. |
| P2 | 연동 | 지도 초기 로딩 중 코드/레이어 설정 조회 오류가 발생한다. | GIS 편집 결함과 분리해 공통 코드/레이어 설정 API 오류를 먼저 정리한다. |
| P3 | QA | QA 서브에이전트 세션에서 인증 차단으로 화면 검증이 막힌다. | QA용 로컬 로그인 절차, 테스트 계정, 토큰 주입 절차를 문서화한다. |
#### 2.12.14 종합 판정
- 백엔드 컴파일, 테스트, 기본 GIS 편집 API 계약은 정상 범위로 확인됐다.
- 프론트엔드 정적 검증은 기존 TypeScript 오류로 실패 상태이며, 이번 검증만으로 GIS 편집 단독 신규 오류라고 단정하지 않는다.
- 실제 사용자 기준의 GIS 편집 UI는 `추가`만 부분 통과이고, `이동`, `꼭지점편집`, `삭제`는 선택 피드백과 조작 피드백이 부족해 개선 필요로 판정한다.
- 특히 `VERTEX_EDIT`는 기존 검증 문서에서 이미 진행 중/리스크로 분류된 항목이며, 이번 재검증에서도 동일하게 핵심 개선 대상으로 확인됐다.
- 다음 개선 순서는 `선택 피드백`, `vertex handle`, `이동 drag와 지도 pan 분리`, `세션 변경목록 노출`, `저장 검증 문구 정리` 순서가 적절하다.
### 2.13 2026-05-26 Playwright 기반 GIS 편집 실조작 재검증
#### 2.13.1 검증 범위와 경로
- 검증 방식: Playwright MCP 브라우저 자동화 기반 실제 화면 조작
- 로그인 경로: `http://localhost:3002/auth/login`
- 로그인 계정: `CLEANWATER`
- 지도 경로: `http://localhost:3002/map`
- 백엔드 경로: `http://localhost:8883/sewerage-service`
- 세션 팝업 경로: `http://localhost:3002/popup/gisEditSessionPopup`
- 테스트 관리번호:
- 1차: `QA-PW-20260526-001`, `clientTempId=NEW-1`
- 2차: `QA-PW-20260526-002`, `clientTempId=NEW-2`
- 최종 정리 상태: 저장 API가 두 차례 모두 `500 E999`로 실패해 `fid/editSn`은 발급되지 않았고, DB 삭제 저장은 수행할 대상이 없었다. 미저장 로컬 세션은 `마지막 변경 취소`로 `총 0건`까지 정리했다.
#### 2.13.2 기능별 실조작 결과
| 기능 | 재현 절차 | 기대 결과 | 실제 결과 | 심각도 | 개선 제안 |
| --- | --- | --- | --- | --- | --- |
| 로그인/진입 | `/auth/login`에서 `CLEANWATER` 로그인 후 `/map` 진입, `GIS 편집` 클릭 | 지도와 편집 툴바, 세션 팝업이 표시된다. | 로그인 성공, `/map` 진입 성공, `GIS 편집` 클릭 시 툴바와 세션 팝업 탭 표시 확인 | 통과 | QA 계정 기반 재현 절차를 검증 문서에 유지한다. |
| 추가 | 빈 위치에 선형 클릭-더블클릭, 상세정보에 `QA-PW-20260526-001/002` 입력, `저장` 클릭 | 신규 관로가 저장되고 응답에서 신규 `fid`, `editSn=0`, `editCd=C`가 확인된다. | preview, 상세 패널, 세션 목록, 저장 버튼 활성화는 확인됐으나 저장 API가 2회 모두 `500 E999`로 실패했다. `fid/editSn` 미발급 | P1 | BE는 `E999/ERROR` 대신 원인 코드와 메시지를 반환해야 한다. FE는 저장 실패 시 사용자가 조치 가능한 필드/원인을 표시해야 한다. |
| 추가 payload | 상세정보의 `시설관리번호`, `기관관리번호`에 테스트 번호 입력 후 세션 payload 확인 | 입력한 관리번호가 저장 payload의 필수 식별 필드에 일관되게 반영된다. | `fclt_mng_no`, `inst_mng_no`에는 값이 들어가지만 `fcltMngNo`, `mngInstCd`는 빈 문자열로 전송됐다. | P1 | 신규 관로 입력값 병합 시 빈 문자열을 유효값으로 우선하지 말고 snake/camel 필드를 동일 값으로 정규화해야 한다. |
| 이동 | 저장 실패 후 남은 `NEW-2` preview 기준 `이동` 선택, 대상 클릭 후 drag | 대상 관로가 선택되고 지도 pan이 아니라 관로 preview가 이동한다. | 상태문구는 이동 모드로 전환됐지만 대상 선택/drag 후 세션 변경이나 화면상 선택 피드백을 확인하지 못했다. | P2 | 미저장 ADD preview도 선택 가능 여부를 명확히 하거나, 지원하지 않는다면 상태문구로 제한을 안내한다. |
| 꼭지점편집 | `꼭지점편집` 선택 후 `NEW-2` preview 클릭 및 vertex drag 시도 | 주황색 실꼭지점과 흰색 midpoint가 보이고 vertex/midpoint 조작이 가능하다. | 상태문구는 vertex 안내로 전환됐지만 주황색/흰색 handle과 실제 drag 반영을 확인하지 못했다. | P2 | 선택 성공/실패 피드백, vertex handle 표시 조건, midpoint 삽입 가능 범위를 화면에서 명확히 표시한다. |
| 삭제 | `삭제` 선택 후 `NEW-2` preview 클릭 | 삭제 예정 스타일, 세션 변경목록, 되돌리기 가능 상태가 표시된다. | 저장된 fid가 없어 최종 삭제 저장은 불가했다. 미저장 preview 클릭 후 삭제 변경 전환은 확인되지 않았고, 세션에는 ADD 1건만 남았다. | P2 | 미저장 ADD 객체 삭제는 세션 목록의 `마지막 변경 취소`와 같은 명시적 UI로 안내하거나 지도 클릭 삭제를 지원한다. |
| 취소/롤백 | 세션 팝업에서 `마지막 변경 취소` 클릭 | 마지막 변경분이 제거되고 저장할 변경분이 없어야 한다. | `총 0건`, `저장할 편집 변경분이 없습니다.` 확인. 지도 상태문구도 `가장 최근 편집 변경분을 세션에서 제거했습니다.`로 표시 | 통과 | 최종 저장 실패 후 사용자가 쉽게 정리할 수 있도록 이 동작을 메인 툴바에도 노출하는 방안을 검토한다. |
#### 2.13.3 네트워크/API 응답
| 순번 | 요청 | 테스트 관리번호 | 응답 | 기록 |
| --- | --- | --- | --- | --- |
| 147 | `POST http://localhost:3002/sewerage-service/api/v1/map/gisEditSessions/save` | `QA-PW-20260526-001` | `500 Internal Server Error`, body `{"message":"ERROR","status":500,"code":"E999"}` | `clientTempId=NEW-1`, `fid/editSn` 미발급 |
| 149 | `POST http://localhost:3002/sewerage-service/api/v1/map/gisEditSessions/save` | `QA-PW-20260526-002` | `500 Internal Server Error`, body `{"message":"ERROR","status":500,"code":"E999"}` | `clientTempId=NEW-2`, `fid/editSn` 미발급 |
- 2차 저장 요청 주요 payload:
- `layerId`: `tb_ms_swage_ppl`
- `saveTargetType`: `FINAL`
- `view.srs`: `EPSG:5179`
- `editType`: `ADD`
- `geometry.type`: `LineString`
- `coordinates`: `[[1068626.8294507712,1774978.3276935548],[1068741.356641528,1774930.6080307395],[1068855.8838322847,1774978.3276935548]]`
- `attributes.fclt_mng_no`: `QA-PW-20260526-002`
- `attributes.inst_mng_no`: `QA-PW-20260526-002`
- `attributes.fcltMngNo`: 빈 문자열
- `attributes.mngInstCd`: 빈 문자열
#### 2.13.4 콘솔 오류
- `GET http://localhost:3002/server/cms-service/api/v1/menu-roles/SN` -> `401 Unauthorized`
- `POST http://localhost:3002/sewerage-service/api/v1/map/gisEditSessions/save` -> `500 Internal Server Error`
- `GIS 편집 저장 실패: Error: ERROR`
- `AxiosError`
#### 2.13.5 화면 캡처
- `D:\workspace\qa-evidence\sfr-012-003-login-map-loaded.png`
- `D:\workspace\qa-evidence\sfr-012-003-add-before-save.png`
- `D:\workspace\qa-evidence\sfr-012-003-session-add-failed-payload.png`
- `D:\workspace\qa-evidence\sfr-012-003-add-retry-before-save.png`
- `D:\workspace\qa-evidence\sfr-012-003-move-unsaved-no-selection.png`
- `D:\workspace\qa-evidence\sfr-012-003-vertex-unsaved-no-handles.png`
- `D:\workspace\qa-evidence\sfr-012-003-session-undo-clean.png`
#### 2.13.6 현재 판정
- `ADD`는 화면 조작, 상세정보 입력, 세션 반영, 저장 버튼 활성화까지는 통과했지만 최종 저장 API가 `500 E999`로 실패해 통합 검증은 실패로 판정한다.
- `MOVE`, `VERTEX_EDIT`, `DELETE`는 저장된 테스트 fid가 없어 최종본 기준 검증을 수행하지 못했다. 미저장 preview 기준으로는 선택/drag/삭제 피드백이 명확하지 않아 보류 및 UX 결함으로 분류한다.
- 테스트 관리번호 `QA-PW-20260526-001/002`는 저장 실패로 `fid/editSn`이 생성되지 않았다. 최종본 삭제 저장 정리 대상은 없었고, FE 로컬 세션은 `총 0건`으로 정리했다.
- `next build`, `pnpm --filter sn run build`, `pnpm turbo run build --filter=sn`은 실행하지 않았다. `sn` dev server가 `3002`에서 실행 중이므로 AGENTS.md 기준에 따라 빌드 검증은 제외했다.
### 2.14 2026-06-08 스냅핑 토글 / Delete 키 꼭지점 삭제 코드 반영 점검
- 코드 기준 점검
- `GisEditToolbar`에 `스냅핑` on/off 버튼이 추가됐고, 편집 세션 팝업 헤더에도 `스냅핑 ON/OFF` 상태가 보이도록 반영했다.
- `useGisEdit`는 `isSnapEnabled` 상태를 관리하고, `MOVE`, `VERTEX_EDIT` 안내 문구에 스냅핑 상태를 함께 반영한다.
- 초기 반영 직후 `ADD`에는 스냅핑이 연결되지 않아 사용자가 스냅핑 ON 상태로 신규 관로를 그려도 기존 관로에 붙지 않는 문제가 있었다.
- 후속 수정으로 `page.tsx`의 `ADD`, `MOVE`, `VERTEX_EDIT` interaction 진입 시 현재 보이는 하수관로 원본/preview feature를 묶은 `Snap` interaction을 함께 추가한다.
- `VERTEX_EDIT` 중 vertex handle을 클릭하면 선택 상태가 유지되고, 키보드 `Delete`로 해당 꼭지점을 삭제한 뒤 `updateChangeGeometry(..., { keepVertexEditActive: true })` 흐름으로 세션/preview를 다시 갱신한다.
- midpoint는 클릭 시 안내 문구만 내리고 Delete 삭제 대상에서는 제외한다.
- 꼭지점 삭제 유틸은 선형 최소 2점, 폴리곤 최소 3점을 보장하도록 방어 로직이 들어갔다.
- 정적 검증
- 실행 명령
- `node apps/sn/node_modules/typescript/bin/tsc -p apps/sn/tsconfig.json --noEmit --pretty false --ignoreDeprecations 5.0`
- 결과
- 이번 변경분 관련 신규 타입 오류는 정리했다.
- 아래 기존 저장소 오류는 그대로 남아 있다.
- `app/(map-layout)/map/components/common/index.ts` 타입 불일치
- `app/(map-layout)/map/page.tsx` 기존 null 가능성 경고 1건
- `app/html/(with-base-layout)/layout.tsx` import 누락
- 공통 chart 컴포넌트 implicit any
- 수동 재검증 필요 항목
- 스냅핑 `ON/OFF`에 따라 `ADD`, `MOVE`, `VERTEX_EDIT` 체감이 실제로 달라지는지
- 주황색 실꼭지점 클릭 후 `Delete` 입력이 정상 동작하는지
- 2점만 남은 선형에서 Delete가 차단되는지
- 겹친 handle 묶음에서 Delete가 기대한 범위만 제거하는지
### 2.15 2026-06-08 QGIS 기준 1차 스냅 재구현 점검
- 코드 기준 점검
- 기존 “현재 지도에 이미 올라온 feature를 임시 수집”하던 방식 대신, 현재 화면 BBOX 기준 `tb_ms_swage_ppl` WFS를 직접 다시 읽어오는 전용 pipe snap source를 추가했다.
- `ADD`는 `Draw + Snap(source)` 구조로 다시 연결했고, `VERTEX_EDIT`도 custom vertex handle 위에 같은 pipe snap source를 사용하도록 조정했다.
- 후속 보정으로 `MOVE`도 같은 pipe snap source를 우선 사용하도록 확장했다.
- `VERTEX_EDIT`에서 기존 관로를 편집하는 경우에는 자기 자신 fid를 snap source에서 제외해 원본 자기 geometry에 다시 달라붙는 현상을 줄이도록 보강했다.
- `moveend` 시 pipe snap source를 재조회해 현재 보이는 원본 하수관로를 기준으로 스냅 상태를 갱신한다.
- 정적 검증
- 실행 명령
- `node apps/sn/node_modules/typescript/bin/tsc -p apps/sn/tsconfig.json --noEmit --pretty false --ignoreDeprecations 5.0`
- 결과
- 이번 1차 재구현으로 인한 신규 타입 오류는 정리했다.
- 남은 오류는 기존 저장소 오류와 기존 `page.tsx` null 가능성 1건이다.
- 수동 재검증 필요 항목
- 기존 오수관 끝점/선분 근처에서 `ADD` 시작점과 끝점이 실제로 붙는지
- 기존 오수관 꼭지점 근처에서 `VERTEX_EDIT` 주황색 실꼭지점이 실제로 붙는지
- 기존 오수관 끝점/선분 근처에서 `MOVE` 결과 geometry가 실제로 붙는지
- 지도 이동 후 다른 위치에서도 snap source가 새로 갱신되어 계속 붙는지
### 2.16 2026-06-08 MOVE grip 기준 재보정 및 입력정보 조회 오류 점검
- 코드 기준 점검
- `MOVE`는 이제 관로 선택 순간 클릭 위치에서 가장 가까운 관로 꼭지점 meta를 `move grip`으로 기억한다.
- 드래그 시작/중/종료 시 원본 geometry snapshot을 다시 만들고, `move grip`이 하수관로 원본 snap source에 붙도록 전체 geometry를 재정렬한다.
- 따라서 `MOVE`도 `ADD`, `VERTEX_EDIT`처럼 “사용자가 조작하는 한 점이 스냅 기준”이 되도록 맞췄다.
- 관로 상세 팝업의 입력정보 조회는 `fcltMngNo`, `mngInstCd`를 `encodeURIComponent(...)` 처리하고, `NEW-*` 임시값 조회를 차단하도록 보강했다.
- 정적 검증
- 실행 명령
- `node apps/sn/node_modules/typescript/bin/tsc -p apps/sn/tsconfig.json --noEmit --pretty false --ignoreDeprecations 5.0`
- 결과
- 이번 보정으로 인한 신규 타입 오류는 정리했다.
- 저장소 기존 오류만 남아 있다.
- 수동 재검증 필요 항목
- 관로 끝점 근처를 잡고 `MOVE`할 때 grip 기준으로 자연스럽게 붙는지
- 관로 중간을 잡고 `MOVE`했을 때도 가장 가까운 관로 꼭지점을 기준으로 결과가 일관적인지
- 이동 중 관로 상세 팝업이 열려도 `fetchPipeInputInfo` 500이 재발하지 않는지
## 3. 현재 검증 상태
### 3.1 통과
- `GIS 편집` 모드 진입/종료
- 레이어별 도구 활성 규칙
- 세션 `undo`, `reset`
- `MOVE`의 기본 preview drag
- `스냅핑` 토글 상태의 툴바/세션 스냅샷 반영
- `VERTEX_EDIT` 실꼭지점 선택 후 `Delete` 키 처리 코드 경로
- `ADD`, `VERTEX_EDIT`의 하수관로 전용 WFS snap source 연결
- `MOVE`의 grip vertex 기준 snap 보정 코드 경로
- 하수관로 `ADD/MOVE/DELETE` 최종본/이력 DB 저장
### 3.2 진행 중
- `ADD` geometry family별 직접 캡처
- 2026-05-26 Playwright 재검증 기준 `ADD` 최종 저장 API `500 E999` 원인 분석 및 재검증
- `VERTEX_EDIT`의 QGIS 기준 사용자 경험 재정렬
- `ADD`, `VERTEX_EDIT`의 실제 QGIS 체감 스냅 수동 재검증
- `MOVE`, `VERTEX_EDIT` 실조작 수동 재검증
- `MOVE` grip 기준 snap 체감 수동 재검증
- `스냅핑 ON/OFF` 체감과 실제 snap 대상 범위 수동 재검증
- `Delete` 꼭지점 삭제 후 남은 선형 연결 상태 수동 재검증
- 2026-05-26 재검증에서 확인된 `이동`, `꼭지점편집`, `삭제` 선택 피드백/조작 피드백 개선
### 3.3 보류
- 저장 성공 후 GeoServer/GWC 캐시 무효화 운영 방식 검증
- `ADD` 저장 실패로 인한 신규 fid 기반 `MOVE/VERTEX_EDIT/DELETE/삭제 저장 정리` 재검증
- QA 서브에이전트 또는 독립 검증자가 사용할 수 있는 로컬 로그인/인증 절차 문서화
## 4. 보류/리스크
- 2026-05-26 Playwright 재검증에서 신규 관로 저장 API가 `500 E999`로 실패해 최종본 기준 편집 시나리오가 차단됐다.
- 신규 관로 상세정보 저장 payload에서 `fclt_mng_no`/`inst_mng_no`는 입력값이 들어가지만 `fcltMngNo`/`mngInstCd`는 빈 문자열로 남아 필수 식별자 정합성 리스크가 있다.
- dev 서버가 켜져 있으면 `next build`를 함께 돌리기 어렵다.
- `next lint`는 환경 이슈로 안정적인 회귀 수단이 되지 못하고 있다.
- 지도 캔버스 기반 drag/vertex 테스트는 수동 브라우저 검증 의존도가 높다.
- `VERTEX_EDIT`는 하수관로 `LineString`/`MultiLineString` 처리와 vertex marker UX를 다시 고정해야 한다.
- 현재 저장소의 기존 TypeScript 오류 때문에 변경분만 분리한 정적 통과 여부를 보장하기 어렵다.
- 기존 관로와 겹친 위치에서 자동 클릭 기반 `MOVE/DELETE` 검증을 수행하면 의도하지 않은 기존 관로가 선택될 수 있다.
- `GIS 편집 세션`이 popup에 의존하면 팝업 차단, 포커스 이동, 별도 창 위치 문제로 사용자가 변경목록을 놓칠 수 있다.
- 저장 검증 메시지와 화면 필드명이 불일치하면 신규 관로 저장 실패 원인을 사용자가 잘못 이해할 수 있다.
- 지도 초기 로딩 오류가 남아 있으면 GIS 편집 결함과 공통 지도/코드/레이어 설정 오류를 분리하기 어렵다.
- 스냅핑은 현재 source에 로드된 하수관로 원본/preview만 대상으로 하므로, 화면 밖 객체나 미로딩 객체까지는 맞추지 못할 수 있다.
- 겹친 handle을 하나의 묶음으로 보여주는 현재 구조상, 같은 좌표를 공유하는 raw vertex는 Delete 시 함께 제거될 수 있다.
## 4-1. 관측 증적 요약
- 자동으로 확인된 항목
- `CLEANWATER` 계정 로그인 후 `/map` 진입
- `GIS편집` 모드 진입/종료
- 신규 관로 `ADD` preview 생성, 상세정보 입력, 세션 목록 반영
- 레이어별 도구 활성 규칙
- 세션 `undo`, `reset`
- `MOVE`의 기본 preview drag 준비 구조
- 수동 또는 후속 확인이 필요한 항목
- `ADD` 저장 API `500 E999` 원인 확인
- 실제 canvas drag 정확도
- `VERTEX_EDIT`의 vertex marker 노출과 꼭지점 직접 이동
- 저장 API 연결 후 응답 처리와 원본 보호
- 간접 관측 표식
- 상태 메시지
- 변경 목록
- preview label
- 임시 삭제 스타일
## 4-2. 차단 사유 분류
- `자동화 한계`
- 지도 캔버스 기반 drag/vertex 조작은 자동 브라우저 재현 안정성이 낮다.
- 이번 세션에서는 Playwright 또는 브라우저 플러그인 기반 canvas 자동화 경로를 확보하지 못했다.
- `인증 차단`
- 실제 브라우저 통합 검증은 로그인/세션 준비 여부에 따라 막힐 수 있다.
- `환경 차단`
- dev 서버 실행 중 build 병행이 어렵고, lint도 안정적 회귀 수단이 아니다.
- `기능 결함 또는 미완료`
- `ADD` 최종 저장이 `500 E999`로 실패해 신규 fid 기반 후속 검증이 차단된다.
- `VERTEX_EDIT`는 아직 요구사항 기준으로 다시 고정이 필요하다.
## 5. 흡수한 기존 문서
- `002-qa-SFR-012-003-하수관로편집-검증결과.md`
- `004-qa-SFR-012-003-하수관로편집-후속검증결과.md`
- `006-qa-SFR-012-003-하수관로편집-세션도구확장검증결과.md`
- `008-qa-SFR-012-003-하수관로편집-실제지도조작검증결과.md`
- `010-qa-SFR-012-003-하수관로편집-MOVE실드래그검증결과.md`
- `012-qa-SFR-012-003-하수관로편집-VERTEX_EDIT실조작검증결과.md`
@@ -0,0 +1,193 @@
# SFR-010-010 지반침하정보 작업카드
## 1. 작업 개요
- 작업 ID: SFR-010-010-FE
- 작업명: 하수관망도 지반침하정보 프론트엔드 구현 계획
- 상태: 설계
- 작성일: 2026-06-02
- 담당 역할: FE
- 관련 역할: DE, BE, QA
- 담당 범위: `apps/sn` 지도 화면에서 지반침하 레이어 체크/해제, 목록 팝업, 우측 상세 패널, 반경 적용, 관리자 동기화 버튼 노출 제어, BE API 연동 준비
- 근거 문서:
- `COMMON_DOCS_ROOT/작업계획/SFR-010-010 지반침하정보/001-de-SFR-010-010-지반침하정보-설계.md`
- `COMMON_DOCS_ROOT/작업계획/SFR-010-010 지반침하정보/지반침하정보_요구사항명세서_v1_1.docx`
- `COMMON_DOCS_ROOT/작업계획/SFR-010-010 지반침하정보/지반침하정보-화면구성시나리오_v1_1.md`
- `COMMON_DOCS_ROOT/작업계획/SFR-010-010 지반침하정보/지반침하정보_업무검토시안_v1_1.html`
- `COMMON_DOCS_ROOT/작업계획/SFR-010-010 지반침하정보/지반침하정보_화면설계서.png`
## 1-1. 공통 동기화 확인
- 공통 작업현황판 확인 여부: 확인
- 파일점유현황 확인 여부: 확인
- 결정로그 확인 여부: 확인
- 기존 진행 중 작업과의 관계:
- `map/page.tsx`는 노후관로, 유하방향표출, GIS편집 이력이 있어 충돌 주의가 필요하다.
- FE 문서 루트 전체는 문서 재정리 흔적이 있으므로 기존 경로/파일명은 유지한다.
## 1-2. 최신 해석 메모
- 현재 `apps/sn`에는 `지반침하정보` 실제 기능 구현이 없고, HTML 샘플 수준 흔적만 확인됐다.
- 목록 팝업은 `노후관로` popup 패턴을, 우측 상세 패널은 `FeatureClickEventPopup`의 세로 패널 패턴을 우선 재사용한다.
- 좌표 보정과 반경 내 관로 탐색은 프론트에서 계산하지 않고 `sewerage-service` 응답 기준으로만 처리한다.
## 2. 작업 목적
- 하수관망도 레이어 트리에서 `지반침하정보`를 실제 기능으로 연결한다.
- 사용자는 목록 팝업에서 사고 이력을 조회하고, 행 클릭 또는 지도 포인트 클릭으로 상세정보와 반경 내 하수관로를 확인할 수 있어야 한다.
- 관리자만 수동 동기화 버튼을 볼 수 있어야 하고, 일반 사용자는 조회/다운로드 중심 UX만 가져야 한다.
- 실제 구현 전 문서 단계에서 FE 상태관리, 재사용 컴포넌트, API 연결 지점, 검증 포인트를 먼저 고정한다.
## 3. 수정 대상 파일
| 구분 | 파일 경로 | 수정 여부 | 근거 |
| --- | --- | --- | --- |
| 메인 화면 | `FE_APP_ROOT/app/(map-layout)/map/page.tsx` | 예정 | 레이어 트리, 지도 포인트, 반경 원, 상세 패널 상태 연결 |
| 신규 컴포넌트 | `FE_APP_ROOT/app/(map-layout)/map/components/sinkhole/*` | 예정 | 목록 팝업, 상세 패널, 반경 패널, API 훅 분리 |
| 팝업 정의 | `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts` | 예정 | 목록 팝업을 기존 popup 체계로 열 경우 필요 |
| 팝업 등록 | `FE_APP_ROOT/src/features/windowPopup/registry.tsx` | 예정 | popup key와 화면 등록 |
| 팝업 UI | `FE_APP_ROOT/src/features/windowPopup/ui/*` | 예정 | `지반침하발생현황` popup content |
| 공통 비즈니스 컴포넌트 | `FE_BC_ROOT/**` | 기본 제외 | 현재 계획은 `apps/sn` 내부 재사용 우선 |
## 3-1. 쓰기 범위와 충돌 주의
| 구분 | 경로 | 담당 역할 | 처리 기준 |
| --- | --- | --- | --- |
| 쓰기 허용 | `apps/sn/**` | FE | 신규 지도 기능과 문서 작성 |
| 쓰기 허용 | `packages/common/business_components/**` | FE | 꼭 필요한 경우만 최소 범위 수정 |
| 쓰기 금지 | `packages/common/business_components` 외 공통 패키지 | FE | 원인과 영향 범위만 기록 |
| 쓰기 금지 | 타 앱 `apps/*` | FE | 보고만 수행 |
- 충돌 주의 파일
- `FE_APP_ROOT/app/(map-layout)/map/page.tsx`
- `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts`
- `FE_APP_ROOT/src/features/windowPopup/registry.tsx`
## 4. 화면 흐름
- 진입 경로: `하수관망도 > 지반침하정보`
- 사용자 조작
- 레이어 트리에서 `지반침하정보` 체크
- 목록 아이콘 클릭
- 목록 팝업에서 조건 입력 후 조회
- 좌표보정상태 조건 선택
- 목록 행 클릭 또는 지도 포인트 클릭
- 좌측 하단 반경 입력 후 적용
- 관리자일 경우 동기화 클릭
- 정상 흐름
- 지도에 지반침하 포인트 표시
- 우측 상세 패널 노출
- 반경 원과 반경 내 관로 목록 갱신
- 목록 다운로드 가능
- 오류 흐름
- 날짜 미입력
- `2016-01-01` 이전 날짜 입력
- 좌표 없음
- 상세조회 실패
- 반경 내 관로 없음
- 동기화 실패
## 5. API 연동
| 기능 | Method | URL | 요청 | 응답 | 확인 상태 |
| --- | --- | --- | --- | --- | --- |
| 지도 포인트 조회 | `GET` | `/api/v1/sinkholes/geometry` | 기간/행정구역/limit | GeoJSON `FeatureCollection` | 현재 BE 선반영 |
| 목록 조회 | `GET` | `/api/v1/sinkholes` | 기간, 시도, 시군구, 복구상태, 좌표보정상태, page, size | `items[]`, `total` | BE 신규 필요 |
| 상세 조회 | `GET` | `/api/v1/sinkholes/{acdntNo}` | 사고번호 | 상세 DTO | BE 신규 필요 |
| 반경 내 관로 조회 | `GET` | `/api/v1/sinkholes/{acdntNo}/nearbyPipes` | 사고번호, 반경 | 관로 목록 | BE 신규 필요 |
| 동기화 | `POST` | `/api/v1/sinkholes/sync` | 관리자 요청 body | 처리 결과 | BE 신규 필요 |
| 다운로드 | `GET` | `/api/v1/sinkholes` | 현재 조회조건 + `includeCoordStatus=false` | FE xlsx 생성 | 목록 재조회 기반 |
- FE 메모
- 좌표 보정과 반경 내 관로 거리 계산은 프론트에서 하지 않는다.
- 목록 행의 좌표를 바로 믿지 않고 상세 API 응답으로 다시 기준을 맞춘다.
- 화면에 필요한 일부 필드는 원천 부재 가능성이 있으므로 `null -> "-"` 렌더링 정책이 필요하다.
## 6. 영향 범위
- `apps/sn` 화면 영향
- 레이어 트리
- 지도 포인트/반경 원
- 중앙 목록 팝업
- 우측 상세 패널
- 좌측 하단 반경 패널
- 공통 패키지 영향
- 현재 계획상 없음
- 지도 포인트 레이어/클러스터 공통 확장이 꼭 필요하면 별도 승인 후 검토
- 백엔드 API 영향
- 지반침하 신규 목록/상세/동기화/다운로드 계약
## 6-1. 멀티에이전트 작업 분리
- DE 산출물
- 공통 설계 문서
- API/DTO/DB 영향 초안
- FE 산출물
- 지도 상태관리 구조
- 목록 팝업 / 상세 패널 설계
- API 호출 연결 계획
- BE 산출물
- 지반침하 API 세트 확장
- 좌표 캐시 / 동기화 구조
- 반경 내 관로 조회
- QA 산출물
- 정상/예외/권한/환경 검증 시나리오
## 7. 검증 방법
- 빌드 명령
- 개발 서버 미실행 확인 후 `pnpm --filter sn run build`
- 필요 시 `pnpm turbo run build --filter=sn`
- 브라우저 URL
- `/map`
- 사용자 조작
- 지반침하 레이어 체크/해제
- 목록 아이콘 클릭
- 조건 조회
- 목록 행 클릭
- 지도 포인트 클릭
- 반경 변경
- 관리자 동기화
- 기대 결과
- 목록/상세/반경/다운로드가 요구사항과 일치
- 좌표 없음/조회 결과 없음/반경 내 관로 없음이 각각 다른 안내로 표시
## 7-1. 하네스 관측 포인트
- 레이어 체크박스 상태
- 목록 팝업 열림 여부
- 우측 상세 패널 노출 여부
- 반경 값과 적용 메시지
- 지도 중심 이동 여부
- 포인트와 반경 원 노출 여부
- 관리자 버튼 노출 여부
## 8. 롤백 방법
- `지반침하정보` 관련 신규 컴포넌트 제거
- 레이어 트리와 팝업 등록 분기 제거
- `map/page.tsx`의 지반침하 상태관리 제거
- 문서는 설계 단계 기준으로 유지하고 코드만 롤백
## 9. 예상 위험
- `map/page.tsx`에 또 하나의 지도 도메인 로직이 들어가면서 책임이 더 커질 수 있다.
- 목록 팝업을 `window popup`으로 할지 인라인 모달로 할지 구현 방식이 달라질 수 있다.
- 원천 데이터에 없는 상세 필드를 FE가 어떻게 보여줄지 합의가 필요하다.
- 좌표 없는 사고건을 지도와 목록에서 다르게 다뤄야 해 예외 분기가 많다.
## 10. 완료 기준
- `지반침하정보` 체크 시 포인트 표출 가능
- 목록 아이콘 클릭 시 조회 팝업 오픈 가능
- 목록 조회/행 클릭/지도 포인트 클릭 흐름이 연결됨
- 반경 적용 시 반경 원과 반경 내 관로 목록 재조회 가능
- 관리자 전용 동기화 버튼이 권한 기준으로 분기됨
- BE 계약 문서와 FE 작업카드가 같은 필드 기준을 사용함
## 11. 구현 전 승인 필요 항목
- 화면 원본이 없는 필드의 FE 표시 방식
- 목록 팝업을 기존 popup 창으로 열지, `/map` 내부 모달로 열지
- 최근 동기화 일시 위치와 노출 범위
- 좌표 캐시를 메모리 캐시로만 둘지 DB 영속화까지 포함할지
@@ -0,0 +1,175 @@
# SFR-011-007 통수위험분석 작업카드
## 1. 작업 개요
- 작업 ID: `SFR-011-007-FE`
- 작업명: 통수위험분석 프론트엔드 구현 계획
- 상태: 완료
- 작성일: 2026-05-18
- 담당 역할: FE
- 관련 역할: DE, BE, QA
- 담당 범위: `apps/sn` 지도 화면에서 통수위험 진입, 결과 조회 popup, 지도보기, 설정 현황, staged 유출관로 선택, 저장 payload 준비까지
- 근거 문서
- `COMMON_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/001-de-SFR-011-007-통수위험분석-설계.md`
- `COMMON_DOCS_ROOT/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합공유기록.md`
- `COMMON_DOCS_ROOT/작업지시/SFR-011-007-통수위험분석/통수위험분석_요구사항명세서_v1_2.docx`
- `COMMON_DOCS_ROOT/작업지시/SFR-011-007-통수위험분석/통수위험분석_화면_v1.1.html`
- `COMMON_DOCS_ROOT/작업지시/SFR-011-007-통수위험분석/통수위험분석_통수위험분석결과 조회_화면설계서.png`
- `COMMON_DOCS_ROOT/작업지시/SFR-011-007-통수위험분석/통수위험분석_통수위험분석결과 지도보기_화면설계서.png`
- `COMMON_DOCS_ROOT/작업지시/SFR-011-007-통수위험분석/통합위험분석_화면구성시나리오_v1.1.md`
## 1-1. 공통 동기화 확인
- 공통 작업현황판 확인 여부: 확인
- 파일점유현황 확인 여부: 확인
- 결정로그 확인 여부: 확인
- 기존 진행 중 작업과의 관계: `map/page.tsx`, `AnalysisPopupMenu.tsx`, `windowPopup/**`는 최근 지도형 기능 작업 이력이 있어 충돌 주의가 필요하다.
## 2. 작업 목적
- 기존 하수관망도 지도 화면에 `통수위험` 기능을 추가해 사용자가 결과 조회, 지도보기, 설정 현황 조회, 유출관로 설정 편집을 한 흐름 안에서 수행할 수 있게 한다.
- 프론트는 백엔드 연결 전 단계에서 popup UI, 동적 관로 컬럼, mock 지도 강조, staged 설정 목록, 저장 payload preview까지 먼저 구현한다.
- 실제 산정 로직, geometry 데이터, 다운로드 파일, 저장 API는 BE 범위로 분리하고 FE는 그 계약을 맞출 준비 상태까지 수행한다.
## 2-1. 최근 진행 갱신
- 2026-05-18
- `통수위험` 메뉴의 placeholder popup을 실제 `WaterRiskPopupContent`로 교체했다.
- popup 안에서 결과 조회, 설정 현황, staged 유출관로 목록, 결과/설정 다운로드 preview, 저장 payload preview가 동작하도록 FE-only mock 흐름을 연결했다.
- `map/page.tsx`에는 popup과 동기화되는 선택 지역 강조, 예측강우 sample grid overlay, 설정 편집 모드, 지도 하수관로 클릭 기반 staged 추가 흐름을 연결했다.
- 실제 API 호출은 아직 붙이지 않았고, BE 신규 API 계약과 mock 상태를 기준으로만 동작한다.
- 2026-05-19
- 결과 조회/설정 현황 mock 데이터를 확대해 페이지네이션이 실제로 보이도록 조정했다.
- 결과 조회와 설정 현황 toolbar 버튼을 우측 정렬로 정리하고, 본문 행 배경과 헤더 컬러를 관로분석 계열 톤으로 맞췄다.
- 지도 상세정보 카드는 프로토타입 표 구조, 위험/정상 배지, 유출유량 초과 강조, compact spacing을 기준으로 재정리했다.
- 지도 상세정보 카드는 선택 중점관리지역의 오른쪽에 붙어 움직이는 프로토타입 기준으로 다시 조정했다.
- 지도 강조와 상세정보 카드는 계속 실험이 반복된 항목이라, 최종 운영 규칙은 BE 연동 및 수동 브라우저 검증 단계에서 한 번 더 확정한다.
## 3. 수정 대상 파일
| 구분 | 파일 경로 | 수정 여부 | 근거 |
| --- | --- | --- | --- |
| 화면 | `FE_APP_ROOT/app/(map-layout)/map/page.tsx` | 완료 | 지도 이동, 강조 overlay, 상태 연결, 상세정보 카드 프로토타입 |
| 우측 메뉴 | `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx` | 완료 | `통수위험` 아이콘 진입점 추가 |
| popup 정의 | `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts` | 완료 | 신규 popup 정의 |
| popup 등록 | `FE_APP_ROOT/src/features/windowPopup/registry.tsx` | 완료 | popup content 연결 |
| 신규 컴포넌트 | `FE_APP_ROOT/app/(map-layout)/map/components/waterRisk/*` | 완료 | 결과/설정 UI, mock 데이터, 타입 분리 |
| popup content | `FE_APP_ROOT/src/features/windowPopup/ui/WaterRiskPopupContent.tsx` | 완료 | window popup 본문 연결 |
| 공통 비즈니스 컴포넌트 | `FE_BC_ROOT/**` | 기본 제외 | 초기 구현은 `apps/sn` 내부 해결 우선 |
## 4. 화면 흐름
- 진입 경로
- 하수관망도 지도 화면 우측 기능 아이콘 `통수위험`
- 정상 흐름
- 결과 조회 popup 열기
- 조회조건 입력
- 결과 목록 표시
- 결과 행 선택 후 지도 강조/상세정보 표시
- `설정`으로 화면 전환
- 설정 현황 조회
- 중점관리지역 선택 후 유출관로 staged 편집
- 저장 payload 생성
- 오류 흐름
- 날짜/시간 누락
- 결과 없음
- 중복 유출관로 선택
- 3건 초과 선택
- 저장 실패 시 staged 목록 유지
## 5. API 연동
| 기능 | Method | URL | 요청 | 응답 | 확인 상태 |
| --- | --- | --- | --- | --- | --- |
| 결과 조회 | `GET` | `/api/v1/map/wraResults` | 분석일시, 지역, 위험상태 | 요약 + 결과 목록 + 관로 반복형 데이터 | BE 신규 필요 |
| 지도 상세 | `GET` | `/api/v1/map/wraResults/{focusAreaId}/mapView` | 중점관리지역 식별값 | polygon + rain grid + 상세 popup 데이터 | BE 신규 필요 |
| 설정 현황 조회 | `GET` | `/api/v1/map/wraOutletPipeSettings` | 지역 조건 | 설정 목록 | BE 신규 필요 |
| 설정 저장 | `POST` | `/api/v1/map/wraOutletPipeSettings/save` | 중점관리지역 + 유출관로 1~3건 + 순번 | 저장 결과 + 최신 상태 | BE 신규 필요 |
| 결과 다운로드 | `POST` | `/api/v1/map/wraResults/download` | 현재 조회조건 | 파일 또는 파일 토큰 | BE 신규 필요 |
| 설정 다운로드 | `POST` | `/api/v1/map/wraOutletPipeSettings/download` | 현재 조회조건 | 파일 또는 파일 토큰 | BE 신규 필요 |
- FE 메모
- 1차 구현은 실제 네트워크 대신 payload preview 또는 mock service 기준으로 상태를 먼저 맞춘다.
- 동적 관로 그룹은 `pipeSummaries[]` 반복 렌더링 기준으로 구현한다.
## 6. 영향 범위
- `apps/sn` 영향
- 지도 메인 화면
- 우측 분석 기능 메뉴
- popup registry와 popup content
- 지도 선택/강조/overlay 상태
- 지도 상세정보 카드와 표형 mock UI
- `business_components` 영향
- 현재 계획상 없음
- overlay 또는 popup wrapper 요구가 기존 패턴으로 해결되지 않을 때만 추가 검토
- 백엔드 영향
- 결과 조회/상세/설정/저장/다운로드 API
- 권한과 지역 조건 cascade
## 7. 구현 범위 분리
### 7.1 FE 단독 구현 범위
- popup 레이아웃
- 조회/설정 폼
- mock 결과/설정 데이터
- 동적 관로 그룹 컬럼
- 위험/정상 스타일 변경
- 예측강우 sample overlay
- staged 유출관로 목록
- 저장/다운로드 payload preview
### 7.2 BE 필요 범위
- 실제 분석 결과 수치
- geometry와 grid 데이터
- 저장/다운로드
- 권한 검증
- 중복/유효성 서버 검증
## 8. 검증 방법
- 브라우저 기준
- `통수위험` 아이콘 클릭
- 결과 popup 열림
- 날짜/시간 필수값 검증
- 위험상태 필터 동작
- 1건/2건/3건 동적 관로 그룹 표시
- 페이지네이션 노출과 행 수 변화
- 결과 행 선택 후 지도 강조
- 설정 화면 전환
- 유출관로 staged 목록 추가/삭제/순번 변경
- 저장 payload preview 확인
- 정적 확인
- FE-only 프로토타입 코드는 완료됐고, 실제 브라우저 검증/BE 연동은 후속으로 진행한다.
## 9. 현재 결정 상태
- popup 구조는 결과 조회와 설정 현황을 같은 popup 내부 전환 구조로 유지한다.
- 격자 overlay는 FE 1차 범위에 sample overlay로 포함했다.
- 다운로드는 실제 파일 처리 없이 preview까지를 FE 프로토타입 범위로 본다.
- staged 설정 편집 화면은 popup 내부 + 지도 연동 구조를 유지한다.
## 10. 롤백 방법
- `통수위험` 아이콘 진입점 제거
- waterRisk 전용 popup/hook/overlay 제거
- 기존 지도 기본 기능만 남기기
## 11. 예상 위험
- 동적 관로 그룹 헤더/컬럼 렌더링이 테이블 공통 컴포넌트와 맞물리며 복잡해질 수 있다.
- 지도 강조와 예측강우 격자 overlay가 동시에 올라가면 렌더 성능 이슈가 생길 수 있다.
- 설정 편집 모드에서 기존 피처 클릭 상세와 충돌 가능성이 있다.
- 상세정보 카드의 지도 기준 고정 규칙은 사용자 피드백에 따라 다시 조정될 수 있다.
## 12. 완료 기준
- `통수위험` 진입과 popup UI가 동작한다.
- 결과/설정 화면 전환이 가능하다.
- 동적 관로 그룹 렌더링이 1~3건 모두 자연스럽다.
- 지도 강조/상세정보/mock 격자 표시가 가능하다.
- staged 설정 목록과 저장 payload preview가 준비된다.
- FE-only 프로토타입 기준 구현이 완료되고, BE 연동 및 실제 통합 검증 후속 범위가 문서로 정리된다.
- 지도 상세정보 카드 위치 규칙은 현재 프로토타입 기준 동작으로 마감하고, 운영 기준 확정은 후속 검증으로 넘긴다.
@@ -0,0 +1,175 @@
# SFR-012-002 하수관망도 업로드 작업카드
## 1. 작업 개요
- 작업 ID: SFR-012-002-FE
- 작업명: 하수관망도 업로드 프론트엔드 구현 계획
- 상태: 완료
- 작성일: 2026-05-15
- 담당 역할: FE
- 관련 역할: DE, BE, QA
- 담당 범위: `GIS 관리` popup UI, 시설구분 선택, 파일 찾기, mock 업로드/다운로드 흐름, 업로드 payload 준비 상태까지
- 운영 제약: `GIS 관리`는 하수관로만 업로드 대상으로 사용하고, 다른 GIS 테이블은 FE 시설구분 목록에서 제외
- 구현 제외: 실제 서버 업로드/다운로드 연결, 백엔드 저장 로직, DB 반영
- 근거 문서
- `COMMON_DOCS_ROOT/작업계획/SFR-012-002-하수관망도 업로드/001-de-SFR-012-002-하수관망도 업로드-설계.md`
- `COMMON_DOCS_ROOT/작업지시/SFR-012-002-하수관망도 업로드/하수관망도업로드_요구사항명세서_v1_1.docx`
- `COMMON_DOCS_ROOT/작업지시/SFR-012-002-하수관망도 업로드/하수관망도업로드_시나리오_v1.0.md`
- `COMMON_DOCS_ROOT/작업지시/SFR-012-002-하수관망도 업로드/하수관망도업로드_화면_v1_1.html`
- `COMMON_DOCS_ROOT/작업지시/SFR-012-002-하수관망도 업로드/하수관망도업로두_화면설계서_v1.0.png`
## 1-1. 공통 동기화 확인
- 공통 작업현황판 확인 여부: 확인
- 파일점유현황 확인 여부: 확인
- 결정로그 확인 여부: 확인
- 기존 진행 중 작업과의 관계
- `SFR-012-003`에서 `GIS 편집`은 지도 로컬 모드로 구현됐다.
- 이번 `SFR-012-002`는 같은 우측 기능 버튼 그룹을 사용하지만 popup 업무 흐름으로 분리해야 한다.
## 1-2. 최근 설계 갱신
- 2026-05-15
- `SFR-012-003`의 문서 구조를 기준으로 `SFR-012-002` canonical 문서 세트를 신규 작성한다.
- 이번 기능의 FE 핵심은 `map/page.tsx` 직접 수정 확대보다 `gisManagementPopup` placeholder를 실제 popup content로 교체하는 데 있다.
- 승인 전에는 코드 수정 없이 설계, 작업카드, 구현기록, 검증 문서 초안까지만 진행한다.
- 2026-05-15 구현 반영
- `gisManagementPopup` placeholder를 실제 업로드 관리 popup content로 교체했다.
- 시설구분 선택, 파일 찾기, mock 업로드 이력 추가, 다운로드 요청 미리보기, 업로드/다운로드 payload 미리보기까지 FE-only 범위를 반영했다.
- 실제 multipart 업로드와 다운로드 바이너리 처리, 서버 이력 재조회는 후속 BE 연동 단계로 남긴다.
- 2026-05-18 구현 마감 정리
- popup 구현 구조를 `windowPopup` wrapper + 실제 popup page 컴포넌트 분리 방식으로 재구성했다.
- `GIS 관리` popup 디자인을 분석도구 계열의 타이포와 색 축에 맞춰 재정렬했다.
- 업로드 현황은 10건 단위 페이징을 붙이고, 팝업을 닫으면 분석도구 메뉴의 active 상태도 같이 해제되도록 보완했다.
- `찾기`, `업로드` 버튼은 상황판/검색영역 `조회` 버튼 계열 색으로 정리했고, `다운로드` 버튼은 운영정보 `파일 다운로드`와 같은 `green` 계열로 정리했다.
- 업로드 row는 좌측 입력 그룹과 우측 버튼 그룹을 분리해 버튼 위치와 간격을 조정했다.
## 2. 작업 목적
- 기존 지도 화면의 `GIS 관리` 기능을 실제 업로드 관리 popup으로 구체화한다.
- 사용자가 하수관로 GIS 파일을 찾은 뒤 업로드 이력과 다운로드 대상을 관리할 수 있는 FE 화면 흐름을 마련한다.
- 실제 백엔드 연결 전에도 UI, 상태, mock 데이터, 업로드 요청 payload 구조를 검토할 수 있도록 준비한다.
## 3. 예상 수정 대상 파일
| 구분 | 파일 경로 | 수정 여부 | 근거 |
| --- | --- | --- | --- |
| popup registry | `FE_APP_ROOT/src/features/windowPopup/registry.tsx` | 예정 | `gisManagementPopup` placeholder를 실제 content로 교체 |
| popup definition | `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts` | 가능성 있음 | popup 크기 조정 필요 시 |
| 신규 popup content | `FE_APP_ROOT/src/features/windowPopup/ui/GisManagementPopupContent.tsx` | 예정 | 업로드 관리 popup 본문 |
| 신규 보조 컴포넌트 | `FE_APP_ROOT/src/features/windowPopup/ui/gisManagement/*` | 가능성 있음 | 폼/그리드/상태영역 분리 시 |
| 지도 메인 화면 | `FE_APP_ROOT/app/(map-layout)/map/page.tsx` | 가능성 낮음 | popup open 이후 동기화가 필요할 때만 최소 수정 |
| 우측 기능 버튼 | `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx` | 기본 제외 | 버튼 key와 open 흐름이 이미 존재 |
## 3-1. 이번 라운드 실제 수정 파일
- `FE_APP_ROOT/src/features/windowPopup/ui/GisManagementPopupContent.tsx`
- `FE_APP_ROOT/src/features/windowPopup/registry.tsx`
- `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts`
- `FE_APP_ROOT/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx`
- `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx`
- `FE_APP_ROOT/app/(map-layout)/map/page.tsx`
## 4. 화면 흐름
- 진입 경로
- 하수관망도 지도 화면
- 우측 기능 버튼 `GIS 관리`
- 사용자 조작
- `GIS 관리` 클릭
- 시설구분 선택
- `찾기` 클릭 후 파일 선택
- `업로드` 클릭
- 업로드 현황 행 선택
- `다운로드` 클릭
- 정상 흐름
- popup 열림
- 시설구분 선택 전 `찾기`/`업로드` 안내
- 파일 선택 후 파일명 표시
- 업로드 후 현황 목록 갱신
- 다운로드 대상 선택 후 다운로드 요청 가능 상태
- 오류 흐름
- 시설구분 미선택
- 파일 미선택
- 미지원 확장자
- 업로드 실패
- 다운로드 대상 미선택
## 5. FE 단독 구현 범위
- popup UI 렌더링
- 시설구분 select 상태
- 브라우저 파일 선택 상태
- mock 업로드 이력 목록
- 업로드 시 mock 행 추가
- 다운로드 대상 선택 상태
- 상태 메시지 및 토스트
- 업로드 payload 직렬화 준비
## 6. 백엔드 필요 범위
- 업로드 가능 시설구분/허용 확장자/최대 파일 크기 메타 API
- 업로드 현황 조회 API
- GIS 파일 업로드 API
- GIS 파일 다운로드 API
- 원본 분리 저장 및 최신 참조 갱신
## 7. API 연동 초안
| 기능 | Method | URL | 요청 | 응답 | 확인 상태 |
| --- | --- | --- | --- | --- | --- |
| 시설구분 메타 조회 | `GET` | `/api/v1/map/gisUploadFacilityTypes` | `screenId` | 시설구분, 허용 확장자, 업로드 가능 여부 | BE 신규 필요 |
| 업로드 현황 조회 | `GET` | `/api/v1/map/gisUploadHistories` | `screenId`, `facilityTypeCode` | 업로드 이력 목록 | BE 신규 필요 |
| GIS 업로드 | `POST` | `/api/v1/map/gisUploads` | `multipart/form-data` | 업로드 결과, 참조 갱신 정보 | BE 신규 필요 |
| GIS 다운로드 | `GET` | `/api/v1/map/gisUploads/{uploadId}/download` | `uploadId` | 파일 다운로드 | BE 신규 필요 |
## 8. 영향 범위
- `apps/sn` 화면 영향
- 지도 화면 우측 기능 버튼의 `GIS 관리` popup 실제 동작
- popup 내부 폼/그리드/상태 메시지
- 지도 기능 영향
- 지도 이동, 확대/축소, 레이어 트리 기존 동작은 유지돼야 한다.
- 공통 비즈니스 컴포넌트 영향
- 현재 계획상 없음
- 타 앱 영향 가능성
- 없음
## 9. 검증 방법
- 브라우저
- `GIS 관리` popup 열림
- 시설구분 미선택 시 차단
- 파일 선택 후 파일명 노출
- mock 업로드 후 목록 갱신
- 다운로드 대상 선택 상태
- popup 종료 시 분석도구 메뉴 active 해제
- 10건 단위 페이징과 첫 화면 5행 수준 밀도 확인
- `찾기`, `업로드`, `다운로드` 버튼 색/간격 확인
- 정적 확인
- payload preview 또는 직렬화 결과 점검
- 이번 라운드 수행 결과
- `http://127.0.0.1:3002/popup/gisManagementPopup` 응답 `200`
- popup 응답 HTML에서 `GIS 업로드`, `GIS 업로드 현황`, `시설구분`, `다운로드` 문자열 확인
- `pnpm --filter sn exec tsc --noEmit --pretty false --incremental false` 실행 시 기존 저장소 타입 오류로 실패했으며, 이번 popup 파일 관련 신규 오류는 출력되지 않았다.
- 보류 항목
- 실제 multipart 업로드 응답
- 실제 다운로드 파일 수신
## 10. 롤백 방법
- `gisManagementPopup`을 placeholder content로 되돌린다.
- 신규 popup content와 관련 mock 상태를 제거한다.
## 11. 예상 위험
- popup 구현 위치가 `src/features/windowPopup/**`라 지도 화면 코드와 책임 경계를 명확히 잡지 않으면 유지보수가 어려워질 수 있다.
- 파일 업로드는 브라우저 객체와 서버 multipart 계약이 어긋나기 쉬워 FE-only 구현이 오래 임시 상태로 남을 수 있다.
- 업로드 후 실제 지도 레이어 refresh 시점은 서버 계약 없이는 임의로 확정하기 어렵다.
- 시설구분 목록은 아직 실제 `시설물 레이어`나 업로드 메타 API를 조회하지 않고 mock 값으로 유지된다.
## 12. 완료 기준
- FE 범위의 `GIS 관리` popup UI, mock 업로드 흐름, 페이지네이션, active 상태 해제가 반영됐을 것
- 예상 수정 파일, 화면 흐름, BE 필요 범위가 문서로 정리됐을 것
- 실제 업로드/다운로드 API 연동 전까지 FE와 BE 경계가 분리돼 있을 것
@@ -0,0 +1,238 @@
# SFR-012-003 하수관로편집 작업카드
## 1. 작업 개요
- 작업 ID: SFR-012-003-FE
- 작업명: 하수관망도 GIS편집 프론트엔드 구현 계획
- 상태: 진행
- 작성일: 2026-05-14
- 담당 역할: FE
- 관련 역할: DE, BE, QA
- 담당 범위: `apps/sn` 지도형 화면에서 GIS편집 진입, 레이어 선택, 추가/이동/꼭지점편집/삭제, 관로 기본정보 세션 편집, 저장 payload 준비까지 구현
- 근거 문서:
- `COMMON_DOCS_ROOT/작업계획/SFR-012-003-하수관로편집/001-de-SFR-012-003-하수관로편집-설계.md`
- `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_요구사항명세서_v1_1.docx`
- `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_화면시나리오_v1_1.md`
- `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_화면_v1_1.html`
- `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_화면설계서_v1_1.png`
## 1-2. 최근 진행 갱신
- 2026-06-08
- `MOVE`와 `꼭지점편집` 사이에 `스냅핑` on/off 버튼을 추가하고, 편집 세션/상태 스냅샷에도 `ON/OFF`가 보이도록 연결했다.
- 기존 임시 스냅 수집 방식으로는 원본 오수관에 안정적으로 붙지 않는다고 판단해, 1차 재구현 범위를 `ADD`, `VERTEX_EDIT`로 줄였다.
- `ADD`, `VERTEX_EDIT`는 현재 화면 BBOX 기준으로 읽어온 `tb_ms_swage_ppl` 원본 WFS geometry를 전용 snap source로 사용하도록 재구성했다.
- 후속 보정으로 `MOVE`도 같은 원본 하수관로 snap source를 사용하도록 넓혔고, 이동 기준점은 사용자가 처음 집은 관로 꼭지점(grip vertex)으로 다시 고정했다.
- `MOVE` 중 관로 입력정보 조회에서 `500`이 발생하는 케이스를 줄이기 위해 상세 팝업의 path segment를 인코딩하고 `NEW-*` 임시값 조회를 차단했다.
- `VERTEX_EDIT` 중 주황색 실꼭지점을 클릭해 선택한 뒤 키보드 `Delete`로 삭제할 수 있게 했고, 하수관로 최소 2점 유지 규칙을 넣었다.
- 흰색 midpoint는 새 점 삽입 전용으로 유지하고, Delete 삭제 대상에서는 제외했다.
- 수동 브라우저 재검증은 아직 남아 있고, 정적 타입체크는 저장소 기존 오류와 별개로 이번 변경분 신규 오류는 정리했다.
- 2026-05-21
- 실제 구현 코드를 다시 점검한 결과, 현재 GIS편집은 geometry 세션만 관리하고 관로 기본정보 세션은 없는 상태로 정리했다.
- `tb_ms_swage_ppl` geometry와 `tb_sn_swage_ppl` 기본정보 저장 구조가 분리돼 있어, 최종 저장 payload는 `featureRef + basicInfo + geometryPatch` 기준으로 재설계했다.
- 편집 모드에서 하수관로 change를 선택하면 기본정보만 세션 저장할 수 있는 편집 상세정보창을 추가하고, 최종 저장은 geometry와 기본정보를 함께 조립하는 방향으로 구현 중이다.
- 하수관로 편집 팝업의 입력정보 저장버튼은 제거하고, 입력 변경 시 세션 draft가 바로 반영되도록 수정했다.
- 편집 팝업 입력 시 스크롤 상단 점프와 `Maximum update depth exceeded` 에러 원인을 자동저장 effect 루프로 보고 제거했다.
- `MOVE`, `VERTEX_EDIT` 중에는 하수관로만 선택 가능하게 차단하고, 하수관로 선택 즉시 편집 상세정보창이 열리도록 보강했다.
- 2026-05-14
- 7개 편집 레이어 확장, 레이어별 허용 도구 규칙, 로컬 세션 undo/reset, `MOVE`/`VERTEX_EDIT`/`DELETE` 임시 변경분 누적까지 반영했다.
- 이번 라운드에서 `MOVE`를 preview feature 기반 실제 drag 흐름으로 올리고, 지도 위 preview 강조와 재선택 UX를 보강했다.
- `VERTEX_EDIT` 구현은 QGIS 기준 사용자 경험과 어긋나는 구간이 확인되어 재설계가 필요하다.
- 재구현 기준은 `ADD=geometry family별 직접 캡처`, `MOVE=현 방식 유지`, `VERTEX_EDIT=양 끝점 포함 vertex marker 조작`으로 다시 고정한다.
- 다음 핵심 잔여 범위는 설계 문서 기준 재정렬 후 `ADD`, `VERTEX_EDIT`, `DELETE`를 순서대로 안정화하는 것이다.
## 1-3. 브라우저 검증 선행조건
- 접속 URL: `sn` 지도 메인 화면 진입이 가능해야 한다.
- 로그인/권한: `/map` 진입과 지도 API 호출이 가능한 계정 또는 로컬 임시 로그인 경로가 준비되어야 한다.
- 서버 조건: FE dev 서버, BE API, 프록시 또는 gateway 경로가 응답해야 한다.
- 데이터 조건: 편집 대상 레이어, 샘플 객체, 테스트 BBOX를 확보해야 한다.
- 인증 차단 시 대체 경로: `COMMON_DOCS_ROOT/작업내용/공통-로그인/001-de-공통-로그인-로컬임시로그인-검토계획.md` 기준으로 로컬 로그인 가능 여부를 먼저 확인한다.
- 자동화 한계 예상 항목: canvas drag, vertex 직접 조작은 수동 검증 전환 가능 항목으로 본다.
## 1-1. 공통 동기화 확인
- 공통 작업현황판 확인 여부: 확인
- 파일점유현황 확인 여부: 확인
- 결정로그 확인 여부: 확인
- 기존 진행 중 작업과의 관계: 지도 메인 화면 `map/page.tsx`는 최근 유하방향표출 작업 이력이 있어 동일 파일 충돌 주의가 필요하다.
## 2. 작업 목적
- 기존 하수관망도 지도 화면에 `GIS편집` 기능을 추가해 사용자가 편집 대상 레이어를 고른 뒤 객체 추가, 이동, 꼭지점편집, 삭제, 저장까지 한 흐름에서 수행할 수 있게 한다.
- 프론트는 저장 전까지 편집 변경분을 로컬 세션으로 관리하고, 저장 시 BE가 제공할 편집 저장 API에 맞춰 payload를 전달한다.
- 백엔드 개발자가 바로 이어받을 수 있도록 필요한 API, 화면 상태, 예외 흐름을 FE 기준으로 구체화한다.
- 실제 API 구현은 범위 밖이며, 백엔드 공유용 요청 사항은 `작업내용` 문서로 분리해 관리한다.
- 추가 운영 제약:
- `GIS편집`, `GIS관리`는 하수관로만 대상으로 제한한다.
- 다른 GIS 테이블은 타회사 소유 범위로 보고 FE에서 선택/업로드 대상으로 노출하지 않는다.
## 3. 수정 대상 파일
| 구분 | 파일 경로 | 수정 여부 | 근거 |
| --- | --- | --- | --- |
| 화면 | `FE_APP_ROOT/app/(map-layout)/map/page.tsx` | 예정 | 지도 메인 화면에서 우측 기능 버튼, 편집 모드 상태, OpenLayers interaction 연결 필요 |
| 컴포넌트 | `FE_APP_ROOT/app/(map-layout)/map/components/gisEdit/*` | 예정 | 툴바, 세션 패널, 편집 훅 분리 후보 |
| 팝업/선택 | `FE_APP_ROOT/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx` | 가능성 있음 | 편집 모드 중 기존 상세 팝업 충돌 차단 필요 |
| API 호출 | `FE_APP_ROOT/app/(map-layout)/map/page.tsx` 또는 `components/gisEdit/*` | 부분 완료 | 현재는 저장 payload 미리보기까지 구현, 실제 API 호출은 후속 |
| 공통 비즈니스 컴포넌트 | `FE_BC_ROOT/**` | 기본 제외 | 초기 구현은 `apps/sn` 내부 해결 우선 |
## 3-1. 쓰기 범위와 충돌 주의
| 구분 | 경로 | 담당 역할 | 처리 기준 |
| --- | --- | --- | --- |
| 쓰기 허용 | `apps/sn/**` | FE | 지도 화면, 편집 상태, API 호출 작업 |
| 쓰기 허용 | `packages/common/business_components/**` | FE | 꼭 필요한 경우에만 최소 범위 수정 |
| 쓰기 금지 | `packages/common/business_components` 외 공통 패키지 | FE | 문제 원인과 영향 범위만 기록 |
| 쓰기 금지 | 타 앱 `apps/*` | FE | 문제 원인과 영향 범위만 기록 |
- 충돌 주의 파일:
- `FE_APP_ROOT/app/(map-layout)/map/page.tsx`
- `FE_APP_ROOT/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx`
- 인수인계 메모:
- `map/page.tsx`는 이미 레이어 트리, 검색, 유하방향표출까지 담당 중이라 편집 기능을 그대로 누적하면 과도하게 비대해질 수 있다.
- 가능하면 `components/gisEdit` 하위로 상태/뷰를 나눠야 한다.
- 파일점유 등록 여부: 공통 파일점유현황에 계획 작업 반영
## 4. 화면 흐름
- 진입 경로: `관망도편집 > 하수관망도 편집`의 지도형 메인 화면
- 사용자 조작:
- 우측 `GIS편집` 클릭
- 편집 대상 레이어 선택
- `추가`, `이동`, `꼭지점편집`, `삭제` 중 선택
- 저장 클릭
- 정상 흐름:
- 편집 모드 진입
- 레이어 선택 전 도구 비활성
- 레이어 선택 후 도구 활성
- `추가`는 geometry family별로 사용자가 지도에서 직접 형상을 캡처
- `이동`은 preview drag로 위치 조정
- `꼭지점편집`은 선택 객체의 vertex marker를 노출하고 양 끝점을 포함해 이동
- 변경분이 로컬 편집 세션에 누적
- 하수관로 편집은 기존 상세정보창 구조를 재사용하고, `입력정보` 저장은 세션 저장으로 처리
- 저장 시 원본과 분리된 편집본 저장 요청
- 빈 결과 흐름:
- 변경분이 없으면 저장 비활성 또는 안내 메시지 표시
- 오류 흐름:
- 편집 대상 미선택
- 미지원 레이어 선택
- 객체 미선택
- geometry 오류
- 저장 실패 시 임시 변경분 유지
## 5. API 연동
| 기능 | Method | URL | 요청 | 응답 | 확인 상태 |
| --- | --- | --- | --- | --- | --- |
| 기존 레이어 트리 조회 | `GET` | `/api/v1/map/lyrStngs/SN_NETWORK` | 기존 지도 레이어 그룹 | 레이어 트리 데이터 | 기존 사용 중 |
| 편집 가능 레이어 메타 조회 | `GET` | `/api/v1/map/gisEditLayers` | `lyrGrp`, `screenId` | 편집 가능 레이어, geometry 타입, 허용 도구 | BE 신규 필요 |
| 관로 기본정보 조회 | `GET` | `/api/v1/fcltppl/{fcltMngNo}/{mngInstCd}` | 관로 식별자 | `PplDto` 기본정보 7개 필드 | 기존 사용 중 |
| 편집 저장 | `POST` | `/api/v1/map/gisEditSessions/save` | 레이어 정보, view, `changes[]` | `sessionId`, `editFileId`, `versionNo`, `savedCount` | BE 신규 필요 |
- FE 메모:
- 저장 전까지 변경분은 FE 로컬 상태로 보관한다.
- 관로 상세정보창의 저장은 즉시 API 호출이 아니라 change별 세션 저장으로 해석한다.
- 현재 구현은 실제 최종 저장 호출 대신 payload 미리보기만 생성한다.
- BE 요청 사항은 `FE_APP_DOCS_ROOT/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-프론트공유API요청.md`에 별도 정리한다.
## 6. 영향 범위
- `apps/sn` 화면 영향:
- 지도 메인 화면 상단 툴바
- 우측 기능 버튼
- 편집 세션 패널
- 기존 피처 클릭 상세와의 충돌 제어
- `business_components` 영향:
- 초기 계획상 없음
- `Gis` 공통 확장이 꼭 필요해지면 별도 승인/영향분석 후 검토
- 백엔드 API 영향:
- 편집 가능 레이어 메타 API
- 저장 API
- 원본 분리 저장 및 참조 갱신 로직
- 다른 앱 영향 가능성:
- 현재 계획상 없음
- `apps/sn` 내부 구현 우선
## 6-1. 멀티에이전트 작업 분리
- FE 담당 산출물:
- 편집 모드 토글
- 툴바 및 세션 패널
- OpenLayers 편집 interaction
- 저장 payload 조립
- BE 담당 산출물:
- 편집 가능 레이어 메타 조회
- 저장 API
- 원본 분리 저장, 편집본 참조 갱신
- QA 담당 산출물:
- 편집 대상 미선택/객체 미선택/geometry 오류/저장 실패 검증
- 저장 후 원본 보호 검증
- 병렬 진행 가능 항목:
- FE 로컬 상태/툴바 UI 설계
- BE API 명세 확정
- 순차 진행 필요 항목:
- 저장 payload 최종 필드 확정 후 FE-BE 연동 검증
## 6-2. 채팅방 간 충돌 방지
- 같은 기능을 다른 채팅방에서 진행 중인지: 현재 공통 작업현황판에는 신규 기능 구현 행이 아직 없다. 이번 문서 작성으로 최초 등록한다.
- 겹치는 파일 또는 폴더:
- `map/page.tsx`
- `featureClickEventPopup.tsx`
- `FE_APP_DOCS_ROOT/작업계획/**`는 문서 재정리 흔적이 있어 경로 유지 주의
- 점유 충돌 시 처리 방식:
- 문서와 실제 코드 작업을 분리하고, 코드 착수 전에 파일점유현황을 다시 갱신한다.
## 7. 검증 방법
- 빌드 명령:
- 개발 서버 미실행 확인 후 `pnpm --filter sn run build`
- 필요 시 `pnpm turbo run build --filter=sn`
- 브라우저 URL:
- 기본 `FE_APP_ROOT/app/(map-layout)/map/page.tsx` 진입 화면
- 사용자 조작:
- `GIS편집` 클릭
- 레이어 선택 전/후 도구 활성 상태 확인
- `추가`, `이동`, `꼭지점편집`, `삭제` 각각 1회 수행
- 저장 클릭
- 기대 결과:
- 레이어 선택 전 편집 도구 비활성
- 변경분 누적 시 저장 활성
- 저장 실패 시 임시 변경분 유지
- 저장 성공 시 원본 보존 메시지 표시
- 네트워크 응답 확인 기준:
- `gisEditLayers` 응답에 editable 여부와 도구 목록 포함
- `gisEditSessions/save` 요청에 `changes[]`가 변경 유형별로 정확히 담김
- 저장 성공 응답에 편집본 식별자와 버전 정보 포함
## 7-1. 하네스 관측 포인트
- 화면 관측
- `GIS편집` 버튼 강조 여부
- 툴바 노출 여부
- 선택 레이어명
- 상태 메시지
- 변경 목록 행 수
- 지도 상호작용 관측
- `ADD` 완료 후 신규 항목 생성
- `MOVE` drag 종료 후 preview label과 목록 요약 변화
- `VERTEX_EDIT` 시 vertex marker 또는 modify handle 노출
- `DELETE` 시 삭제 예정 스타일 유지
- 세션 관측
- `undo`, `reset`, `canSave`
- 저장 실패 시 임시 변경분 유지
- 차단 사유 분류
- 기능 결함
- 인증 차단
- 환경 차단
- 데이터 차단
- 자동화 한계
## 8. 롤백 방법
- `GIS편집` 토글과 편집 툴바 UI 제거
- `components/gisEdit/*` 신규 파일 제거
- 편집 저장 API 호출 분기 제거
- 기존 지도 조회/레이어 트리/피처 상세 흐름만 남기기
## 9. 예상 위험
- `map/page.tsx`가 이미 크기와 책임이 커서 기능 누적 시 회귀 위험이 높다.
- 점형/선형 객체의 편집 interaction이 달라 공통 훅 설계가 어색해질 수 있다.
- 편집 가능 레이어/geometry 타입/저장 대상이 확정되지 않으면 FE 구현이 임의 가정을 하게 될 수 있다.
- 편집 모드 중 기존 피처 클릭 상세 팝업이 뜨면 객체 선택/이동과 충돌할 수 있다.
## 10. 완료 기준
- `GIS편집` 버튼으로 편집 모드 on/off 가능
- 레이어 선택 전 편집 도구 비활성
- 선택 레이어 기준으로 `추가`, `이동`, `꼭지점편집`, `삭제` 최소 동작 가능
- 변경분이 로컬 편집 세션에 누적되고 목록으로 확인 가능
- 저장 payload가 설계 문서 계약과 맞게 구성됨
- BE 공유용 API 요청 문서가 별도 정리됨
- 브라우저에서 정상/예외 시나리오를 확인할 수 있음
- 결과 보고 시 작업 목적, 수정 대상 파일, 영향 범위, 검증 방법, 롤백, 리스크를 정리할 수 있음
@@ -0,0 +1,253 @@
# SFR-010-010 지반침하정보 통합 구현 기록
## 1. 문서 목적
- `SFR-010-010 지반침하정보`의 FE 구현 이력, 설계 결정, 남은 리스크를 기능당 1개 문서로 누적 관리한다.
- 이번 턴은 구현 전 단계이므로 설계 검토 결과와 승인 필요 항목만 먼저 기록한다.
- 승인 후에는 새 파일을 추가하지 않고 이 문서 내부 헤더로만 구현 라운드를 누적한다.
## 2. 입력 근거
- `COMMON_DOCS_ROOT/작업계획/SFR-010-010 지반침하정보/001-de-SFR-010-010-지반침하정보-설계.md`
- `COMMON_DOCS_ROOT/작업계획/SFR-010-010 지반침하정보/지반침하정보-화면구성시나리오_v1_1.md`
- `FE_APP_DOCS_ROOT/작업계획/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-작업카드.md`
## 3. 공통 계약
- 프론트는 `reuse-service`를 직접 호출하지 않고 `sewerage-service`만 호출한다.
- 지도 포인트 좌표, 반경 내 관로 거리 계산, 좌표보정상태 판단은 백엔드 응답을 기준으로 처리한다.
- 목록 행 클릭 후 상세 패널은 목록행 데이터가 아니라 상세 API 응답으로 다시 맞춘다.
- 원천에 없는 상세 필드는 FE에서 `null -> "-"` 렌더링을 기본안으로 두되 승인 전까지 확정하지 않는다.
- 목록 팝업은 기존 `노후관로` 팝업 패턴, 우측 상세는 `FeatureClickEventPopup` 패턴 재사용을 우선한다.
## 4. 라운드별 기록
### 4.1 2026-06-02 설계 착수
- 작업지시 폴더의 요구사항명세서, 시나리오, HTML, PNG를 읽고 기능 범위를 재해석했다.
- 백엔드 현재 작업트리에서 아래 선반영 코드를 확인했다.
- `GET /api/v1/sinkholes/geometry`
- IL datasource
- 주소 기반 지오코딩 서비스
- 프론트 현재 코드에서는 `지반침하정보` 실제 기능 구현은 없고, HTML 샘플 및 레이어 명칭 흔적만 확인됐다.
- 이번 기능의 FE 핵심 축을 아래로 정리했다.
- 레이어 체크/해제
- 목록 팝업
- 우측 상세 패널
- 반경 적용 UX
- 관리자 전용 동기화 버튼 노출 제어
- 아직 구현하지 않은 항목
- 실제 코드 수정
- 브라우저 검증
- API 연결
### 4.2 2026-06-05 좌표 응답 정합화
- 프론트 목록 팝업에 `좌표보정상태` 컬럼을 추가해 지오코딩 성공/실패를 운영 화면에서 바로 볼 수 있게 했다.
- 상세 패널에서 좌표가 없어 반경 내 관로를 조회하지 못하는 경우 `geocodeErrorMessage`를 함께 노출하도록 보강했다.
- 같은 시점의 로컬 백엔드 응답을 확인한 결과, `reuse-service` 지오코딩 응답의 추가 필드를 무시하지 못해 `Unrecognized field "adresTy"` 예외가 나고 있었음을 확인했다.
- 즉 프론트 좌표 표시 이상은 지도 상태관리 자체보다 백엔드 좌표 응답 실패가 선행 원인이었다.
### 4.3 2026-06-05 구현 흐름 메모
- 지반침하 원천 데이터에는 사고 정보는 있는데 지도에 바로 표시할 좌표가 없는 경우가 있어서, 프론트는 백엔드가 좌표보정상태까지 붙여 내려주는 구조를 전제로 구현했다.
예시데이터
- 사고번호: `20260024`
- 주소: `인천광역시 서구 백석동 74-11`
- 발생일자: `20260409`
- 복구상태: `복구중`
- `ocrn_txcrd`, `ocrn_tycrd`: 비어 있음
프론트에서 본 처리 흐름
- 목록 API에서 지반침하사고 1건을 받음
- 백엔드가 원천 좌표 또는 주소 API 결과를 기준으로 `x`, `y`, `coordStatus`를 같이 내려줌
- 프론트는 `coordStatus`가 성공이면 지도 포인트를 그림
- 실패면 목록에서 `좌표보정상태 = 실패`로 보여주고 상세에서 실패 사유를 보여줌
### 4.4 2026-06-05 상세 패널 유지보수
- 지반침하 상세를 열었을 때 중심점과 반경 원은 그려지지만 줌이 그대로여서 사용자가 위치를 바로 확인하기 어렵다고 판단했다.
- 그래서 상세 조회 성공 시점과 반경 재적용 시점 모두 반경 원 extent 기준으로 `fit` 하도록 보강했다.
- `침하발생 반경내 관로정보` 표는 기존 단순 HTML 테이블에서 공용 `Table` 스타일로 바꿔 발생정보 카드와 더 비슷한 시각 톤을 맞췄다.
- 관로 ID 행을 클릭하면 `GisApp.Module.layer.showSearchResultsFromGeoJSON`로 `tb_ms_swage_ppl`의 `fid`를 넘겨 지도 강조를 요청하도록 연결해, 반경 내 관로를 상세 패널에서 바로 따라갈 수 있게 했다.
### 4.5 2026-06-05 관로 강조와 상세 복귀 유지보수
- 관로 ID 클릭 시 현재 줌레벨은 유지한 채 해당 관로만 지도에 하이라이트하고, 관로 상세정보 창을 함께 열도록 수정했다.
- 관로 상세정보 창의 닫기 버튼을 누르면 선택된 관로 하이라이트를 해제하고, 직전에 보던 지반침하 상세 패널이 다시 보이도록 복귀 흐름을 추가했다.
### 4.6 2026-06-05 레이어설정 기준 지반침하 트리 재정리
- 초기에는 프론트에서 `지반침하정보` 루트와 연도 노드를 임의로 만들었지만, 실제 `tb_sn_lyr_stng`에는 `지반침하정보` 루트만 있고 자식 노드가 없어서 도시침수지도와 같은 토글 구조로 보이지 않았다.
- 그래서 프론트는 실제 루트 row를 부모 토글로 사용하고, DB 자식 노드가 없을 때만 `현재년도`, `전체` 2개를 fallback으로 붙이도록 정리했다.
- 연도 노드 클릭 시에는 노드명 하드코딩 대신 `filter_sql`을 우선 읽어 목록 조회 조건을 만들고, `filter_sql`이 없을 때만 `sinkholeYear` 값을 fallback으로 사용하도록 수정했다.
### 4.7 2026-06-09 연도/전체 노드 해석 유지보수
- `tb_sn_lyr_stng`에 `2026년`, `전체` 자식 노드를 실제로 넣은 뒤에는 프론트 fallback 노드가 아니라 DB 노드를 주로 타게 됐는데, 기존 `applySinkholeYearFilters`는 `sinkholeYear` 커스텀 필드만 읽고 있어 DB 자식 노드 선택을 해석하지 못하고 있었다.
- 그 결과 화면에는 `2026년`, `전체`가 보여도 초기 레이어 표출과 팝업 동기화에서는 기본 최근 3개월 조건이 남아 있을 수 있는 드리프트가 생겼다.
- 이번 유지보수에서 프론트는 `sinkholeYear`가 없는 경우에도 노드 텍스트 `2026년`, `전체`를 각각 `2026`, `ALL` 토큰으로 해석하도록 보강했다.
- 또한 `page.tsx`의 레이어 설정 초기 로딩에 지반침하 루트/자식 보정 유틸을 다시 연결해, DB 자식 노드가 있을 때와 없을 때 모두 같은 정리 경로를 거치도록 맞췄다.
### 4.8 2026-06-09 발생기간 조회 시작일 2016년 확장
- 지반침하발생현황 팝업은 빈 조회조건으로 열릴 때 최근 3개월을 기본값으로 넣고 있었는데, 운영 확인용 장기 이력 조회에서는 시작일을 2016년까지 바로 내릴 수 있어야 한다는 요청이 생겼다.
- 프론트 기본 조회조건은 `2016-01-01 ~ 오늘`로 바꾸고, 날짜 입력 `min`도 `2016-01-01`로 고정했다.
- 이 변경만으로는 서버가 `최대 1년` 검증에서 막히므로, 백엔드 검증 규칙도 같은 기준으로 함께 맞춰야 한다고 정리했다.
### 4.9 2026-06-09 다운로드 전체 결과 기준 재조회 유지보수
- 기존 다운로드 버튼은 현재 테이블에 렌더된 `rows`만 엑셀로 내보내고 있어, 사용자가 2페이지 이후 결과를 조회하지 않아도 “전체 조회 결과 다운로드”를 기대하는 UX와 어긋나고 있었다.
- 이번 수정으로 다운로드는 현재 조회조건을 그대로 유지한 채 `/api/v1/sinkholes`를 200건 단위로 다시 호출해 전체 결과를 합친 뒤 엑셀을 생성하도록 바꿨다.
- 엑셀 `NO` 컬럼도 현재 페이지 기준이 아니라 전체 결과 기준 1번부터 연속 번호가 매겨지도록 정리했다.
### 4.10 2026-06-09 다운로드 체감속도 재검토
- 지반침하 다운로드는 기능적으로 전체 결과 기준으로 맞췄지만, 1600건 수준에서도 체감이 길다면 단순 페이지 수보다 백엔드 좌표보정 경로가 병목일 가능성이 높다고 판단했다.
- 프론트 재조회 배치 크기는 2000으로 올려 1600건 정도는 1회 목록 호출로 내려받게 조정했다.
- 이와 별개로 동일 주소의 지오코딩 실패도 30분 동안은 캐시되도록 백엔드 정책을 바꿔, 같은 다운로드를 반복할 때 외부 지오코딩을 매번 다시 치지 않게 정리했다.
### 4.11 2026-06-09 좌표 실패 건 상세 fallback 표시
- 좌표변환 실패 건을 목록에서 클릭했을 때 상세 API가 generic 500으로 실패하면, 사용자는 사고 상세 자체를 전혀 열 수 없게 되는 문제가 있었다.
- 하지만 목록 행에는 사고번호, 발생일자, 주소, 복구상태, 좌표보정상태, 지오코딩 실패 사유가 이미 있으므로, 이 경우에는 목록 데이터를 우선 상세 패널 fallback 값으로 사용해도 UX가 성립한다고 판단했다.
- 그래서 목록 행 클릭 경로에서는 상세 API 실패 시 row 데이터를 `SinkholeDetail` 형태로 보강해 우측 패널을 열고, 반경 내 관로 조회가 실패하더라도 상세 자체는 계속 보여주도록 방어 로직을 추가했다.
- 이후 추가 정리로, 목록 row에 좌표가 없고 `coordStatus/geocodeStatus`가 이미 실패 상태인 경우에는 상세 API를 아예 호출하지 않고 곧바로 fallback 상세 패널을 여는 쪽으로 단순화했다.
### 4.12 2026-06-09 연도 노드 지도범례 아이콘 표시
- 지반침하 `2026년`, `전체` 노드는 체크박스만 보이고 지도 범례 느낌이 약해, 하수처리시설처럼 노드 앞에 작은 아이콘을 두는 쪽이 더 직관적이라고 판단했다.
- `2026년`은 주황 원형, `전체`는 파랑 원형 SVG 아이콘을 `public/images/map/layericon/` 아래에 추가했고, DB 자식 노드와 FE fallback 노드 모두 같은 규칙으로 아이콘을 붙이도록 맞췄다.
- 공용 트리 렌더러는 기존에 `SN_SINKHOLE*` 전체를 아이콘 숨김 대상으로 보고 있었기 때문에, 지반침하 루트만 예외로 두고 연도 노드는 아이콘이 보이도록 분기 범위를 조정했다.
- 추가 확인 결과 공용 트리의 `makeLegendSrc()`가 `gobjTy`를 먼저 검사해 `iconNm`만 있는 노드를 바로 탈락시키고 있었고, 이 때문에 `icon_nm`을 넣어도 화면에 변화가 없었다.
- 그래서 공용 트리 렌더 순서를 `iconNm 우선 -> 없으면 gobjTy 기반 범례 생성`으로 바꿔, DB `icon_nm`과 FE 주입 `iconNm` 모두 실제로 보이게 맞췄다.
### 4.13 2026-06-09 연도 노드 색과 지도 포인트 색상 일치
- 연도 노드 아이콘이 보여도 아이콘 중앙의 흰 점 때문에 지도 포인트와 시각 톤이 달라 보여, SVG를 단색 원형 + 진한 외곽선 형태로 다시 정리했다.
- 지도 포인트도 같은 색 규칙을 쓰도록 `createSinkholePointStyle`을 연도 기반 variant 구조로 바꿨다.
- `전체` 선택 시에는 `2026년` 건만 주황, 나머지 연도는 파랑으로 보이고, 선택 상세 포인트와 반경 원도 같은 색 계열을 따르도록 맞췄다.
### 4.14 2026-06-09 포인트/아이콘 시각 농도 완화
- 실제 화면 확인 피드백 기준으로 지반침하 포인트가 너무 진해서 바탕지도를 가리고 있었고, 연도 노드 아이콘도 가운데가 빈 점처럼 보여 지도 포인트와 다르게 느껴졌다.
- 그래서 연도 아이콘 SVG는 외곽선과 중심 흰 점을 모두 제거한 꽉 찬 단색 점으로 단순화했다.
- 지도 포인트는 주황/파랑 모두 fill alpha와 stroke alpha를 낮추고 반경 원 투명도도 줄여, 기존보다 바탕지도가 더 잘 보이도록 완화했다.
### 4.15 2026-06-09 포인트 농도 재보정
- 완화 이후 실제 체감이 지나치게 옅어져 포인트 존재감이 약해졌기 때문에, 일반 포인트 fill/stroke alpha와 반지름을 한 단계 다시 올렸다.
- 목표는 “예전 지반침하정보처럼 자연스럽게 보이되 지도 바탕을 과도하게 가리지 않는 중간 농도”로 두었다.
### 4.17 2026-06-09 포인트 투명도 미세 상향
- 추가 피드백 기준으로는 아직도 포인트가 너무 연하게 느껴졌기 때문에, 일반 포인트 fill/stroke와 반경 원 alpha를 현재 값에서 절반 정도 더 보이는 수준으로 한 번 더 올렸다.
- 선택 포인트는 이미 충분히 강조되고 있어 선택 색은 유지하고, 일반 점과 반경 원의 가시성만 미세 조정했다.
### 4.18 2026-06-09 목록/다운로드 조회조건 정리
- 추가 요구사항으로 지반침하 목록에 `좌표보정상태` 조회조건을 넣고, 다운로드는 `좌표보정상태` 열 자체를 제외한 채 현재 조회조건 전체 결과를 받아야 한다는 기준이 확정됐다.
- 프론트는 기본 발생기간을 다시 `현재 기준 최근 1년`으로 되돌렸고, 팝업 조회영역에 `좌표보정상태` select를 추가했다.
- 다운로드는 현재 조회조건을 유지하되 `includeCoordStatus=false`로 목록 API를 재조회해, 좌표보정상태 컬럼과 좌표해석 비용을 함께 줄이는 방향으로 정리했다.
### 4.19 2026-06-09 팝업 닫힘 시 기본 최근 1년 복귀
- 기존에는 지반침하 팝업에서 마지막으로 조회한 조건이 `appliedSinkholeFilters`에 남아, 팝업을 닫아도 지도 레이어가 그 조건을 계속 유지하고 있었다.
- 추가 협의 기준으로는 팝업을 닫으면 다시 기본 최근 1년 조건으로 복귀하는 쪽이 더 자연스럽다고 보고, 팝업 close watcher를 붙였다.
- 이제 팝업이 닫히면 지도 지반침하 레이어 조회조건도 `buildSinkholeDefaultFilters()` 기준 최근 1년으로 즉시 되돌아간다.
### 4.20 2026-06-09 초기 지도 표출 기준을 팝업 조건으로 단일화
- 기존에는 `appliedSinkholeFilters`와 별도로 `2026년`/`전체` 연도 자식 노드 체크 상태가 `applySinkholeYearFilters()`로 다시 날짜조건을 덮어쓰고 있어, 초기 로드 시 최근 1년 기본조건과 충돌했다.
- 그래서 지도 레이어 조회와 팝업 초기 전달에는 연도 노드 덮어쓰기를 제거하고, 팝업 조회조건 자체를 유일한 필터 기준으로 정리했다.
- 결과적으로 초기 로드, 팝업 조회 후 지도 반영, 팝업 닫힘 후 기본 최근 1년 복귀가 같은 필터 흐름으로 맞춰졌다.
### 4.22 2026-06-09 팝업 활성 중 필터 우선순위 재정리
- 추가 확인 결과 사용자가 원하는 동작은 “평소에는 트리 연도 체크 상태 기준, 팝업이 열려 조회를 수행한 동안만 팝업 조건 우선, 팝업을 닫으면 다시 트리 기준 복귀”였다.
- 그래서 지반침하 레이어 조회에 `isSinkholePopupFilterActive` 상태를 추가하고, 팝업 sync 메시지를 받았을 때만 팝업 필터를 우선 사용하도록 조정했다.
- 팝업이 닫히거나 지반침하 레이어 자체를 끄면 popup-active 상태를 해제하고, 기본 최근 1년 필터를 기준으로 다시 트리 연도 조건이 적용되도록 정리했다.
### 4.23 2026-06-09 목록보기 오픈 즉시 지도 동기화
- 추가 요구사항으로 `목록보기` 버튼을 눌러 팝업이 열리는 순간에도, 팝업이 들고 뜨는 초기 조회조건이 즉시 지도에 반영돼야 한다는 기준을 확인했다.
- 그래서 `openSinkholePopupWindow()`에서 팝업 오픈 직후 `isSinkholePopupFilterActive=true`, `appliedSinkholeFilters=effectiveFilters`를 바로 반영하고 selection version도 증가시키도록 연결했다.
- 이로써 팝업 내부 첫 조회 응답을 기다리지 않아도 지도와 팝업의 초기 조건이 먼저 맞춰진다.
### 4.24 2026-06-09 트리 상위조건 + 팝업 하위조건 결합
- 추가 정리 결과 최종 UX는 `2026년`, `전체`를 상위 큰 조건으로 두고, 팝업 조회조건은 그 범위 안에서만 한 번 더 좁히는 구조가 가장 자연스럽다고 판단했다.
- 그래서 popup-active가 아닐 때는 `buildSinkholeTreeBaseFilters()`에 트리 연도 조건만 적용하고, popup-active일 때만 `팝업 조회조건 ∩ 트리 연도조건`을 계산하도록 로직을 바꿨다.
- 이로써 `2026년`만 체크 시 다른 연도는 나오지 않고, `전체` 체크 시에는 팝업 조건이 전체 범위 안에서만 적용되며, 팝업 종료 후에는 다시 트리 기준으로 복귀한다.
### 4.25 2026-06-09 팝업 열린 상태에서 레이어 재체크 시 필터 유지
- 추가 확인 결과 팝업이 열린 상태에서 지반침하 레이어를 껐다가 다시 켜면, 레이어 해제 분기에서 popup-active 상태와 `appliedSinkholeFilters`를 같이 초기화하고 있어 팝업 조건이 사라지고 있었다.
- 이건 팝업 close 시점과 레이어 토글 시점을 혼동한 것이므로, 레이어 해제 시에는 popup-active와 팝업 필터를 건드리지 않고 레이어/상세만 닫도록 수정했다.
- 이제 팝업이 열린 상태에서 레이어를 다시 켜면 방금 보던 팝업 조건이 그대로 유지된 채 지도에 재적용된다.
### 4.26 2026-06-09 기능 테스트 성공 반영
- 최종 사용자 확인 기준으로 현재 대화에서 다뤘던 지반침하 팝업/지도/다운로드 연동 이슈는 모두 기대 동작으로 수렴했다.
- 따라서 본 문서는 이번 라운드 기능 구현이 사용자 기능 테스트 기준 성공 상태임을 기준점으로 남긴다.
### 4.16 2026-06-09 연도 노드 아이콘 외곽선 재조정
- 연도 노드 아이콘은 단색 점만 두었을 때 경계가 약해 보여, 사용자가 보여준 예시처럼 `색 원 + 흰 외곽선` 형태가 더 읽기 쉽다고 판단했다.
- 그래서 `2026년` 주황 아이콘과 `전체` 파랑 아이콘 모두 흰 외곽선을 다시 추가해 트리 안에서 더 또렷하게 보이도록 맞췄다.
### 4.21 2026-06-09 조회옵션/다운로드 마감 정리
- `좌표보정상태` 조회조건은 최종적으로 `전체 / 성공 / 실패`만 남기고 `주소없음` 옵션은 제거했다.
- 다운로드 엑셀은 `좌표보정상태` 열을 제외한 9개 컬럼만 내리도록 유지했고, 헤더 범위도 `A1:I1`로 맞춰 첫 데이터 행이 헤더색을 먹지 않게 수정했다.
### 4.27 2026-06-10 개발서버 SVG 아이콘 400 유지보수
- 로컬에서는 보였지만 개발서버에서는 `SN_SINKHOLE_SPOT_ORANGE.svg`, `SN_SINKHOLE_SPOT_BLUE.svg` 요청이 각각 `400 Bad Request`로 떨어지는 현상을 확인했다.
- 경로 생성은 기존 `Layer_Select_BTN.png`와 같은 `/images/map/layericon/` 규칙을 타고 있었고, 개발서버에서만 신규 `svg` 2건만 실패했으므로 이번 이슈는 트리 로직보다 개발서버의 정적 SVG 처리 차이로 판단했다.
- `layericon` 폴더의 기존 운영 자산이 대부분 `png` 기반인 점도 고려해, 연도 노드 아이콘 자산을 `png`로 추가하고 `sinkholeTypes.ts` 상수도 `.png`를 바라보도록 바꿨다.
- 이렇게 하면 DB 자식 노드와 FE fallback 노드 모두 기존 정적 아이콘 자산 체계와 같은 형식을 사용하므로, 서버 설정 차이 없이 같은 경로 규칙으로 안정적으로 로드할 수 있다.
### 4.28 2026-06-11 목록 총 건수 헤더 보강
- 지반침하 목록 팝업은 기존에도 `/api/v1/sinkholes` 응답의 `total` 값을 상태로 보관하고 있었지만, `UiTable`의 `headName`을 비워 둔 상태라 노후관로처럼 상단에 “조회 결과 총 n건” 문구가 노출되지 않았다.
- 이번 유지보수에서는 `SinkholePopupPage.tsx`의 목록 테이블 헤더를 `지반침하 조회 결과 총 {total.toLocaleString()}건`으로 지정하고, `isHideCount={true}`를 함께 적용해 `UiTable` 기본 건수 문구와 중복되지 않도록 정리했다.
- 결과적으로 노후관로 팝업과 같은 위치/형식으로 총 건수가 표시되며, 별도 API 변경 없이 기존 `total` 응답을 그대로 재사용한다.
### 4.29 2026-06-11 시설물 트리 하단 설정영역 분리
- 기존에는 `지반침하정보`의 반경 설정 패널이 트리 노드 바로 아래 인라인으로 붙어 있었는데, 생활안전지도처럼 “레이어를 사용할 때만 하단 설정영역에 따로 모아 보여주는 구조” 요청이 추가됐다.
- 이번 유지보수에서는 패널 디자인과 입력/적용 동작은 그대로 두고, `map/page.tsx`에서 인라인 노출 분기를 제거한 뒤 `Gis`의 `layerSettingView` 하단 영역으로 이동시켰다.
- 따라서 `지반침하정보`를 체크하지 않으면 설정 패널이 보이지 않고, 연도/전체 노드가 활성화된 동안에만 트리 하단 별도 영역에 나타난다.
- 후속 보정으로 하단 영역 자체에 `선택 레이어 설정` 헤더와 선택된 레이어명 표시를 추가하고, 세로 공간이 부족할 때는 내부 스크롤이 생기도록 정리했다.
- `노후관로 설정`과 패널 폭이 달라 보이지 않도록 `지반침하정보` 설정 패널 폭도 하단 영역 기준 `w-full`로 통일했다.
- 추가 보정으로 별도 상단 타이틀 줄은 제거하고, 대신 시설물 레이어 목록 영역 자체를 얇은 직사각형 테두리로 감싸서 위 레이어 영역과 아래 설정영역이 자연스럽게 구분되도록 정리했다.
- 추가 점검으로 `선택 레이어 설정` 노출 기준을 연도 자식 노드 탐지 대신 실제 `isSinkholeChecked` 상태로 바꿔, 지반침하 레이어 체크 시 하단 설정영역 반영이 누락되지 않도록 보정했다.
- 공용 트리 접기 버튼이 세로 스크롤바 위를 덮어 보이는 문제를 줄이기 위해, 버튼을 패널 안쪽이 아니라 우측 바깥 경계 쪽으로 이동시켜 핸들처럼 보이도록 조정했다.
- 후속 조정으로 버튼이 아예 보이지 않는 문제를 해결하기 위해, 접기/펼치기 버튼을 패널 밖 가운데에 붙는 단일 토글 핸들 구조로 재배치했다.
- 추가 수정으로 토글 버튼이 `overflow-hidden` 패널 박스에 같이 들어가 잘리던 구조를 분리해, 버튼을 패널 박스 바깥 형제 위치로 옮겨 실제 화면에서 보이도록 보정했다.
- 최종 보정으로 토글 버튼 시각 스타일은 분석도구 패널에서 사용 중인 `36x20` 핸들 디자인과 동일하게 맞추고, 위치는 패널 바깥 중앙을 유지하도록 정리했다.
- 추가 구조 보정으로 시설물 레이어 직사각형 박스는 화면에 항상 보이는 고정 영역으로 두고, 실제 롤업/스크롤은 그 내부에서만 일어나도록 바꿨다. 토글 핸들도 이제 패널 전체가 아니라 이 직사각형 박스 기준 중앙에 붙는다.
- 후속 위치 보정으로 토글 핸들이 박스 바깥으로 너무 멀리 빠지지 않도록 `left-full` 배치를 제거하고, 레이어 직사각형 박스 오른쪽 경계에 걸치는 `right` 음수 오프셋 기준으로 다시 맞췄다.
- 후속 가시성 보정으로 토글 핸들 크기를 키우고 파란 계열 대비와 그림자를 강화해, 배경 지도와 겹쳐도 버튼이 바로 보이도록 조정했다.
- 추가 구조 보정으로 생활안전지도처럼 `레이어 박스 내부 스크롤`과 `패널 전체 스크롤`을 분리했다. 시설물 레이어 박스는 고정 높이와 고정 테두리를 유지하고, 선택 레이어 설정이 많아질 때는 패널 전체 스크롤로 아래 내용을 보도록 정리했다.
- `침수위험지도(중점관리지역)`, `도시침수지도(정보제공포털)` 선택 시 `침수심 범례`는 토글 제어가 아니라 선택 레이어 설정 안에서 실제 범례 항목을 그대로 보여주는 정보 카드로 정리했다.
- 스크롤바 폭은 줄이되 색상은 브라우저 기본색으로 되돌려, 이전보다 얇지만 과한 커스텀 색은 보이지 않도록 맞췄다.
- 후속 정리로 선택 레이어 설정 안의 침수심 범례 카드는 실제 범례 본문만 남기고, 지도 우측 하단에 별도로 뜨던 기존 범례 박스는 제거했다.
- 추가 구조 보정으로 토글 핸들을 패널 이동 영역과 완전히 분리된 형제로 빼고, 열림 시에는 레이어 박스 옆에, 닫힘 시에는 화면 왼쪽 가장자리에 남도록 바꿔 다시 열기 버튼이 사라지지 않게 정리했다.
## 5. 승인 전 보류 사항
- 목록 팝업을 `window popup`으로 열지 `/map` 내부 중앙 모달로 열지 확정 필요
- 최근 동기화 일시를 관리자에게 어디에 보여줄지 확정 필요
- 원천 부재 필드를 FE에서 `-`로 고정해도 되는지 승인 필요
- 좌표 캐시를 메모리만 쓸지 DB 영속화까지 포함할지 BE 승인 필요
## 6. 남은 리스크
- `map/page.tsx`는 기존 기능이 많아 무리하게 누적하면 회귀 위험이 크다.
- 원천 데이터에 없는 컬럼을 화면은 요구하고 있어, API/FE 표시 정책이 늦게 확정되면 구현이 되돌아갈 수 있다.
- 좌표 없는 사고건과 좌표 실패 사고건은 UI와 QA 시나리오가 달라 예외 흐름이 많다.
@@ -0,0 +1,37 @@
# SFR-011-002 관로분석 FE 통합구현기록
## 1. 문서 목적
- 관로분석 팝업 유지보수 과정에서 프론트에 반영한 오류 처리 변경을 기록한다.
## 2. 유지보수 배경
- `경과년도별 조회`는 정상 동작하지만 `관재질별 조회`, `경과년도 & 관재질별 조회`는 집계 조회 실패 시점에 프론트가 `Failed to fetch data`만 표시해 실제 원인 파악이 어려웠다.
## 3. 수정 파일
- `FE_APP_ROOT/app/components/lib/api/index.ts`
- `FE_APP_ROOT/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx`
- `FE_APP_ROOT/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx`
## 4. 구현 내용
- `getData`
- 비정상 응답 본문을 읽어 JSON 또는 text로 보존한다.
- 응답 body의 `message` 또는 중첩 error message가 있으면 그 값을 `Error.message`로 사용한다.
- fallback에는 HTTP status와 statusText를 포함한다.
- `PipeAnalysisPopupPage`
- 집계 조회/다운로드 실패 시 실제 `Error.message`를 우선 alert에 노출한다.
- 엑셀 다운로드 data 캐스팅을 `unknown as JSON[]`로 조정했다.
- 집계 헤더의 `총 N건` 문구를 제거했다.
- 집계 결과는 10개 단위 클라이언트 페이징으로 잘라 렌더링한다.
- `경과년도&재질`은 rowSpan 구조를 유지하기 위해 처리구역 10묶음 기준으로 페이지를 나눈다.
- 모든 분석구분 헤더에 현재 집계 건수를 `N건` 형식으로 노출한다.
- `PipeAnalysisDetailPopupPage`
- 상세 조회/다운로드 실패 시 실제 `Error.message`를 우선 alert에 노출한다.
## 5. 영향 범위
- 관로분석 팝업 집계 조회
- 관로분석 상세 팝업 조회
- 동일 `getData` 유틸을 사용하는 다른 화면의 오류 메시지 표현
- 재질별/경과년도&재질별 요약 렌더링 성능
## 6. 리스크 및 메모
- `getData` 공통 유틸 수정이라 다른 화면에서도 이제 서버 메시지가 더 직접적으로 노출된다.
- 서버 메시지 문구가 사용자 친화적이지 않은 API는 추후 별도 화면 단에서 다듬을 수 있다.
@@ -0,0 +1,161 @@
# SFR-011-007 통수위험분석 통합 구현 기록
## 1. 문서 목적
- `SFR-011-007 통수위험분석`의 FE 구현 이력과 남은 리스크를 기능당 1개 문서로 누적 관리한다.
- 현재 문서는 설계 초안부터 FE-only 프로토타입 구현까지의 누적 이력을 기록하며, 이후 실제 BE 연동과 통합 검증 결과를 계속 추가한다.
## 2. 입력 근거
- `COMMON_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/001-de-SFR-011-007-통수위험분석-설계.md`
- `COMMON_DOCS_ROOT/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합공유기록.md`
- `FE_APP_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-작업카드.md`
## 3. 공통 계약
- 작성자 승인 전에는 코드 수정 없이 문서 설계까지만 진행한다.
- FE 1차 구현은 실제 백엔드 연결 없이도 검토 가능한 범위를 우선한다.
- 결과 목록의 관로 정보는 1~3건 반복형 렌더링으로 구현하고, 고정 3세트 컬럼 가정은 두지 않는다.
- 설정 편집은 중점관리지역 1건 기준 staged 유출관로 1~3건을 FE 로컬 상태에 유지한다.
- 저장 실패 시 staged 목록과 지도 선택 상태는 유지한다.
## 4. 구현 라운드 요약
### 4.1 2026-05-18 설계/초안 라운드
- 수행 내용
- 작업지시 DOCX, HTML, PNG, 시나리오를 기준으로 기능 범위를 재정리했다.
- `SFR-012-003` 문서 구조를 기준으로 공통 설계서, 공통 공유기록, FE 작업카드, FE 구현기록, QA 계획/결과 문서 세트를 정의했다.
- 결과 조회, 지도보기, 설정 현황, 설정 편집/저장 흐름을 FE 단독 범위와 BE 필요 범위로 분리했다.
- 예상 수정 파일과 승인 필요 항목을 정리했다.
- 아직 하지 않은 내용
- FE 코드 수정
- popup 등록
- mock 데이터 연결
- 지도 overlay 구현
- staged 설정 상태 구현
### 4.2 2026-05-18 FE-only 1차 구현
- 반영 파일
- `FE_APP_ROOT/app/(map-layout)/map/page.tsx`
- `FE_APP_ROOT/app/(map-layout)/map/components/waterRisk/waterRiskTypes.ts`
- `FE_APP_ROOT/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx`
- `FE_APP_ROOT/src/features/windowPopup/model/types.ts`
- `FE_APP_ROOT/src/features/windowPopup/registry.tsx`
- `FE_APP_ROOT/src/features/windowPopup/ui/WaterRiskPopupContent.tsx`
- 구현 내용
- `통수위험` popup registry 연결
- 결과 조회/설정 현황 2탭 popup UI
- 관로 1~3건 동적 컬럼 렌더링
- 결과 행 선택 시 main map 강조와 sample rain grid overlay 동기화
- 설정 편집 모드 시작/종료, 지도 하수관로 클릭 기반 staged 목록 추가
- staged 목록 순번 조정/삭제/초기화
- 결과 다운로드 preview, 설정 다운로드 preview, 설정 저장 payload preview
- 아직 mock 기준인 항목
- 실제 통수위험 결과 API
- 실제 중점관리지역 geometry/예측강우 grid 연계
- 실제 설정 저장/재조회
- 실제 파일 다운로드
### 4.3 2026-05-19 FE 프로토타입 마감 정리
- 반영 파일
- `FE_APP_ROOT/app/(map-layout)/map/page.tsx`
- `FE_APP_ROOT/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx`
- `FE_APP_ROOT/app/(map-layout)/map/components/waterRisk/waterRiskTypes.ts`
- 구현 내용
- 결과 조회/설정 현황 더미데이터를 확대해 페이지네이션이 실제로 보이도록 정리했다.
- 결과 조회와 설정 현황 toolbar 버튼 정렬, 본문 배경, 헤더 컬러를 기존 지도형 기능과 유사한 톤으로 맞췄다.
- 지도 상세정보 카드를 compact 표 구조로 재구성하고, 유출유량이 통수량을 초과하는 경우 강조 표시가 가능하게 정리했다.
- 지도 상세정보 카드 위치는 여러 차례 사용자 피드백을 반영해 프로토타입 기준 위치 조정이 가능한 상태까지 정리했다.
- 아직 mock 또는 후속 검증이 필요한 항목
- 상세정보 카드의 최종 고정/앵커 규칙 확정
- 실제 API 응답 구조에 맞춘 결과/상세/설정 데이터 치환
- 실제 다운로드 및 저장 성공/실패 후처리
### 4.4 2026-05-19 FE 추가 보정
- 반영 파일
- `FE_APP_ROOT/app/(map-layout)/map/page.tsx`
- 구현 내용
- 상세정보 카드 위치 규칙은 사용자 피드백 기준으로 반복 보정되었고, 현재는 직접 추가 조정이 가능한 상태로 유지한다.
- 상세정보 카드 표 구조, 헤더/본문 spacing, 유출유량 초과 강조, 페이지네이션 노출 상태를 사용자 피드백에 맞춰 세부 조정했다.
- 확인 메모
- UI 세부 보정 과정에서 타입 오류 1건(`buildWaterRiskInfoAnchor`)이 발생했으나 바로 수정했다.
### 4.5 2026-05-19 상세정보 카드 위치 규칙 보정
- 반영 파일
- `FE_APP_ROOT/app/(map-layout)/map/page.tsx`
- 구현 내용
- 상세정보 카드는 선택된 중점관리지역의 오른쪽에 붙어 보이는 프로토타입 기준으로 재조정했다.
- 카드 anchor 좌표는 선택 대상 중점관리지역 geometry 기준으로 유지하고, 지도 렌더링 중에는 같은 anchor 기준으로 픽셀 위치를 다시 계산하도록 보정했다.
- 화면 고정형 팝업이 아니라 지도 대상 옆에서 같이 움직이는 정보 카드 방향으로 정리했다.
- 남은 메모
- 실제 운영에서 요구되는 카드 고정 규칙과 사용성은 브라우저 수동 검증에서 한 번 더 확정할 필요가 있다.
## 5. 기능별 현재 상태
### 5.1 결과 조회
- 설계 완료
- FE-only 프로토타입 구현 완료
- 핵심 구현 메모
- 날짜/시간/지역/위험상태 필터
- 결과 목록
- 관로 1~3건 동적 컬럼
- 페이지네이션 및 mock 행 렌더링
### 5.2 지도보기
- 설계 완료
- FE-only 프로토타입 구현 완료
- 핵심 구현 메모
- 결과 행 선택 기반 지도 이동
- 위험/정상 polygon 스타일
- 예측강우 sample grid overlay
- 상세정보 카드
### 5.3 설정 현황
- 설계 완료
- FE-only 프로토타입 구현 완료
- 핵심 구현 메모
- 결과 popup에서 같은 흐름으로 전환
- 설정 목록 조회
- 설정 목록 다운로드 조건 유지
- 페이지네이션 및 mock 설정 행 렌더링
### 5.4 설정 편집/저장
- 설계 완료
- FE-only 프로토타입 구현 완료
- 핵심 구현 메모
- 지도 유출관로 선택
- staged 목록 1~3건
- 순번 조정
- 저장 payload preview
## 6. 예상 구현 순서
1. `통수위험` 아이콘과 popup 진입점 연결
2. 결과 조회/설정 현황 화면 구조 구현
3. mock 결과 데이터와 동적 관로 컬럼 렌더링
4. 결과 행 선택 기반 지도 강조와 sample grid overlay
5. staged 유출관로 편집 상태와 저장 payload preview
6. BE 계약 확정 후 실제 API 연결
## 7. 남은 리스크
- popup 1개 탭 구조와 popup 2개 분리 구조 중 하나를 먼저 확정해야 구현 방향이 흔들리지 않는다.
- 격자 overlay는 mock 단계와 실제 데이터 단계의 렌더 방식 차이가 클 수 있다.
- 설정 편집 모드에서 기존 지도 클릭 기능과 충돌 가능성이 있다.
- 상세정보 카드 위치 규칙은 FE 프로토타입 단계에서 반복 조정된 항목이라 운영 규칙 확정 전 재검토가 필요하다.
- 지도 drag/zoom 중 상세정보 카드의 체감 동작은 실제 사용자 손 기준으로 재확인이 필요하다.
## 8. 다음 라운드 착수 조건
- 실제 API 응답 구조 확정
- BE 저장/다운로드 계약 확정
- 인증 가능한 브라우저 환경에서 수동 검증 가능 상태 확보
@@ -0,0 +1,98 @@
# SFR-011 노후관로관리 통합 구현 기록
## 1. 문서 목적
- `SFR-011 노후관로관리`의 FE 구현 이력과 유지보수 보정 사항을 기능당 1개 문서로 누적 관리한다.
- 이번 라운드는 신규 기능 추가가 아니라, 기존 `노후관로` 목록 팝업의 위치와 폭을 유지보수하는 목적이다.
## 2. 입력 근거
- `COMMON_DOCS_ROOT/작업계획/SFR-011-노후관로관리/001-DE-P2-노후관로관리-설계초안.md`
- 사용자 유지보수 요청
- 하수관망도 `노후관로` 목록보기 팝업 가로 사이즈 축소
- 지도와 같이 볼 수 있도록 팝업 위치 좌측 상단 쪽으로 이동
- 폭 축소 후 컴포넌트가 자연스럽게 재배치되도록 보정
## 3. 현재 라운드 구현 요약
### 3.1 2026-06-04 팝업 위치/폭 유지보수
- `agedPipePopup` 최종 크기를 `850 x 600`으로 조정했다.
- 팝업 공통 열기 유틸에 위치 미세조정 옵션을 추가해 `노후관로` 팝업이 `placement: "browser-left"`, `alignTop: true`, `offsetTop: 200` 기준으로 열리도록 정리했다.
- 상단 조회 조건 영역은 `경과년도 / 시도 / 시군구 / 조회 / 다운로드`가 한 줄로 보이도록 맞췄다.
- 현재 팝업 높이에서 표 아래 공백이 과도하지 않도록 높이 기준을 재조정했다.
### 3.2 2026-06-04 후속 미세조정
- 사용 피드백 기준으로 팝업 위치는 현재 좌측 기준 + 세로 오프셋 `200px` 배치를 유지했다.
- 조회/다운로드 버튼을 `시군구`와 같은 수평선에서 우측 정렬되도록 다시 조정했다.
- 필터 영역 하단 여백을 줄여 버튼 아래 빈 공간이 남지 않게 보정했다.
- `UiTable`에 전달하는 외곽 마진을 제거하고, 중복 총 건수 문구를 숨겨 팝업 내부 가로/세로 스크롤 발생 가능성을 낮췄다.
- 표 영역은 현재 팝업 폭과 높이 안에서 보이도록 유지하는 방향으로 정리했다.
### 3.3 2026-06-11 시설물 트리 하단 설정영역 분리
- 기존 `노후관로 설정` 패널은 시설물 트리 안에서 `노후관로` 노드 바로 아래에 인라인으로 열렸지만, 지반침하정보와 동일하게 “레이어 사용 시 하단 설정영역에 추가되는 방식”으로 맞춰 달라는 요청이 들어왔다.
- 이번 유지보수에서는 `노후관로 설정` 패널 컴포넌트 자체는 유지하고, `map/page.tsx`에서 `isAgedPipeChecked`일 때만 `Gis` 하단 `layerSettingView` 영역에 렌더되도록 위치만 조정했다.
- 체크 해제 시에는 설정 패널도 같이 사라지고, 목록보기/필터 적용/지도 표시 흐름은 기존과 동일하게 유지한다.
- 후속 보정으로 하단 영역은 `선택 레이어 설정` 묶음으로 감싸고, 선택된 설정이 없으면 섹션 자체가 사라지도록 해 “선택된 게 있을 때만 뜨는 느낌”을 명확히 했다.
- 같은 영역 안에서 `지반침하정보`와 `노후관로 설정` 패널이 서로 다른 폭으로 보이지 않도록 두 패널 모두 하단 영역 기준으로 동일 폭을 쓰게 맞췄다.
- 추가 보정으로 별도 상단 구분 타이틀은 제거하고, 시설물 레이어 목록을 얇은 사각형 프레임으로 감싸 위 레이어 영역과 아래 설정영역의 구획만 남기도록 정리했다.
- 스크롤바가 생길 때 레이어명이 2줄로 꺾이는 현상을 줄이기 위해, 공용 트리 스크롤 영역에 `scrollbar-gutter: stable`을 적용하고 레이어명은 한 줄 `truncate` 기준으로 보정했다.
- 공용 트리 접기 버튼이 스크롤바를 가려 보이는 배치도 함께 조정해, 버튼이 패널 외곽 핸들처럼 보이도록 옮겼다.
- 후속 조정으로 버튼이 잘려 보이지 않는 문제를 해결하기 위해, 접기/펼치기 버튼을 패널 밖 가운데에 붙는 단일 토글 핸들 구조로 재배치했다.
- 추가 수정으로 토글 버튼이 패널의 `overflow-hidden` 범위에 포함돼 실제로는 잘려 보이지 않던 구조를 분리해, 패널 바깥 형제 위치에서 항상 보이도록 보정했다.
- 최종 보정으로 토글 시각 스타일은 분석도구 패널과 같은 `36x20` 핸들 디자인을 재사용하고, 위치는 패널 바깥 중앙으로 유지하도록 정리했다.
- 추가 구조 보정으로 시설물 레이어 박스는 항상 윤곽이 유지되게 하고, 스크롤은 그 박스 내부에서만 동작하도록 바꿨다. 토글 핸들 위치도 박스 기준 중앙으로 다시 정리했다.
- 후속 위치 보정으로 토글 핸들이 박스 오른쪽 경계에 자연스럽게 걸치도록 다시 맞춰, 지도 영역 쪽으로 과하게 밀려나 보이던 배치를 줄였다.
- 후속 가시성 보정으로 토글 핸들 크기, 파란 계열 대비, 그림자를 함께 올려 지도 배경 위에서도 더 눈에 띄도록 정리했다.
- 추가 구조 보정으로 패널은 `바깥 전체 스크롤`, 시설물 레이어 박스는 `고정 높이 + 내부 스크롤`로 분리해 생활안전지도와 비슷한 사용 흐름으로 정리했다.
- `침수심 범례`는 선택 레이어 설정 안에서 별도 토글을 두지 않고, 관련 침수 레이어가 선택된 경우 실제 범례 항목을 그대로 확인하는 정보 카드로 반영했다.
- 스크롤바는 폭만 얇게 유지하고 색상은 기본값으로 되돌려, 생활안전지도처럼 덜 눈에 띄는 톤으로 정리했다.
- 후속 정리로 선택 레이어 설정에는 침수심 범례 본문만 남기고, 지도 우측 하단에 따로 뜨던 기존 범례 박스는 제거했다.
- 추가 구조 보정으로 토글 핸들을 패널 본체와 분리된 고정 핸들로 바꿔, 닫았을 때도 화면 왼쪽 가장자리에 다시 열기 버튼이 남도록 정리했다.
## 4. 실제 수정 파일
- `FE_APP_ROOT/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx`
- `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts`
- `FE_APP_ROOT/src/features/windowPopup/model/types.ts`
- `FE_APP_ROOT/src/features/windowPopup/lib/openWindowPopup.ts`
## 5. 구현 상세
### 5.1 팝업 정의
- `agedPipePopup`
- `width: 850`
- `height: 600`
- `placement: "browser-left"`
- `alignTop: true`
- `offsetLeft: 0`
- `offsetTop: 200`
### 5.2 공통 팝업 유틸
- 기존 `center`, `browser-left` 배치 외에, 개별 팝업이 좌표를 미세 조정할 수 있도록 아래 속성을 허용했다.
- `alignTop`
- `offsetLeft`
- `offsetTop`
- 이번 라운드에서는 `agedPipePopup`만 이 옵션을 사용한다.
### 5.3 팝업 본문 레이아웃
- 최상위 컨테이너에 `min-w-0`, `min-h-0` 기준을 추가했다.
- 조회조건 영역을 `시군구`와 버튼이 같은 줄에서 정렬되도록 재배치했다.
- 표는 현재 팝업 높이 안에서 하단 공백이 과도하지 않게 보이도록 구성했다.
## 6. 영향 범위
- `노후관로` 목록 팝업 초기 오픈 위치
- `노후관로` 목록 팝업 초기 오픈 폭
- 현재 팝업 높이에서의 조건영역/표 레이아웃
- 다른 팝업은 기본 동작을 유지하며, 이번 위치 미세조정 옵션은 사용하지 않는다.
## 7. 남은 리스크
- 사용자 스크린샷 기준의 정확한 위치 체감은 실제 브라우저 수동 확인이 필요하다.
- 최소화 후 다시 열 때 사용하는 메인 화면의 미니 카드 위치는 이번 라운드 범위에 포함하지 않았다.
@@ -0,0 +1,116 @@
# SFR-012-002 하수관망도 업로드 통합 구현 기록
## 1. 문서 목적
- `SFR-012-002 하수관망도 업로드`의 FE 구현 이력, 결정사항, BE 연동 경계, 남은 리스크를 기능당 1개 문서로 누적 관리한다.
- 구현 전 단계에서는 설계 기준과 착수 조건을 먼저 기록한다.
- 이후 라운드부터는 새 파일을 추가하지 않고 이 문서 내부 헤더만 추가한다.
## 2. 입력 근거
- `COMMON_DOCS_ROOT/작업계획/SFR-012-002-하수관망도 업로드/001-de-SFR-012-002-하수관망도 업로드-설계.md`
- `FE_APP_DOCS_ROOT/작업계획/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-작업카드.md`
## 3. 공통 계약
- `GIS 관리`는 지도 화면 내부 로컬 편집 모드가 아니라 `window popup` 업무 흐름으로 구현한다.
- 업로드 대상 GIS는 원본과 분리된 업로드본으로 저장된다는 업무 규칙을 FE 문구와 payload 구조에 모두 반영한다.
- 승인 전에는 코드 구현을 시작하지 않는다.
- FE는 승인 후에도 실제 업로드/다운로드 API 연결 전까지는 mock 이력과 payload 준비 상태까지만 구현한다.
## 4. 라운드 요약
### 4.1 2026-05-15 설계 착수
- 작업지시의 요구사항 명세서, 시나리오, HTML, PNG를 읽고 `SFR-012-003`과 비교했다.
- `SFR-012-003`이 지도 위 직접 편집 중심이었다면, `SFR-012-002`는 popup 기반 업로드 관리라는 점을 핵심 차이로 확정했다.
- canonical 문서 구조는 그대로 재사용하되, 이번 기능의 구현 후보를 `src/features/windowPopup/**` 중심으로 재설계했다.
### 4.2 2026-05-15 FE-only popup 구현
- `gisManagementPopup` placeholder를 실제 popup content로 교체했다.
- 구현 범위
- 시설구분 select
- 파일 찾기 input
- 파일 확장자/크기 1차 검증
- mock 업로드 이력 갱신
- 다운로드 대상 선택
- 업로드/다운로드 요청 미리보기
- 구현 원칙
- 실제 API 호출 없이도 백엔드 계약과 payload 구조를 검토할 수 있어야 한다.
- 업로드 성공 시 같은 시설구분의 기존 최신 참조 행은 `이력` 상태로 내리고, 신규 행을 `최신 참조`로 올리는 mock 규칙을 둔다.
- 미지원 시설구분은 select에서 비활성 처리해 예외 흐름을 먼저 노출한다.
- 실제 수정 파일
- `FE_APP_ROOT/src/features/windowPopup/ui/GisManagementPopupContent.tsx`
- `FE_APP_ROOT/src/features/windowPopup/registry.tsx`
- `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts`
### 4.3 2026-05-18 popup 구조/스타일 재정리
- `windowPopup` 내부에서 직접 화면을 모두 그리던 방식을 걷어내고, `GisManagementPopupContent`는 얇은 wrapper로 두고 실제 화면은 `GisManagementPopupPage`로 분리했다.
- 분석도구 popup과 비슷한 타이포/버튼/테이블 톤으로 재정리했다.
- 팝업 초기 폭과 높이를 조정하고, 내부 컨텐츠는 창 크기에 맞춰 유동적으로 늘어나도록 변경했다.
- 업로드 현황은 10건 단위 페이징을 적용했고, 기본적으로 5행 수준이 편하게 보이도록 높이를 조정했다.
- `GIS 관리` window popup을 닫으면 분석도구 메뉴 active가 같이 해제되도록 보완했다.
- `찾기`, `업로드` 버튼은 상황판/검색영역 `조회` 버튼 계열 색으로 통일했다.
- `다운로드` 버튼은 운영정보 `파일 다운로드`와 같은 `green` 계열 버튼으로 분리했다.
- 업로드 row는 좌측 입력 그룹과 우측 버튼 그룹을 분리 정렬하고, 버튼 간격을 별도로 조정했다.
### 4.4 현재 FE 구현 범위 확정
- 구현 완료 범위
- `GIS 관리` popup 진입
- 하수관로 단일 시설구분 표시/사용
- 파일 찾기
- 확장자/크기 1차 검증
- mock 업로드 이력 누적
- 업로드 현황 10건 페이징
- 다운로드 대상 선택
- popup close 시 분석도구 active 해제
- 버튼 색/정렬/간격 정리
- 미완료 범위
- 실제 업로드 API 호출
- 실제 다운로드 바이너리 응답
- 업로드 성공 후 서버 이력 재조회
- 실제 시설물 레이어 기반 시설구분 동기화
### 4.5 2026-05-21 업로드 대상 하수관로 단일화
- 운영 제약 기준으로 `GIS 관리` 업로드 대상은 하수관로만 사용한다.
- 이에 따라 `UPLOAD_FACILITY_TYPES` mock 상수에서 하수맨홀, 하수처리시설 항목을 제거했다.
- 업로드 현황 mock 목록도 하수관로 이력만 남기고 정리했다.
- FE 문서와 공통 공유 기록에서도 다른 GIS 테이블은 타회사 소유 범위라 업로드 대상에서 제외한다는 근거를 함께 남긴다.
- 현재 구조는 `/map` 진입 시 전체 레이어 트리를 읽더라도 `GIS 관리` popup 소비 단계에서는 하수관로만 노출하는 방식으로 유지한다.
## 5. BE 공유 초안
### 5.1 FE가 기대하는 메타 정보
- 업로드 가능 시설구분 목록
- 시설구분별 허용 확장자
- 시설구분별 최대 파일 크기
- 시설구분별 업로드 가능 여부
### 5.2 FE가 기대하는 업로드 응답 정보
- `uploadId`
- `facilityTypeCode`
- `facilityTypeName`
- `fileName`
- `registeredAt`
- `registeredBy`
- `activeYn`
- 최신 참조 갱신 여부
### 5.3 FE가 기대하는 다운로드 조건
- 선택 업로드 ID 기준 다운로드
- 권한 없음 / 파일 없음 / 다운로드 실패 메시지 구분
## 6. 남은 리스크
- popup UI를 어느 수준까지 기존 공용 폼 패턴으로 맞출지 구현 직전에 다시 확인이 필요하다.
- 업로드 후 지도 레이어를 즉시 새 업로드본 기준으로 refresh할지 여부는 서버 계약 없이는 확정하기 어렵다.
- 실제 upload history 스키마가 설계 초안과 달라질 수 있다.
- 현재 업로드/다운로드는 요청 미리보기와 mock 목록 갱신만 수행하므로, 실제 파일 바이너리 흐름은 백엔드 구현 후 재검증이 필요하다.
- 시설구분 목록은 현재 `UPLOAD_FACILITY_TYPES` mock 상수에 고정돼 있어 실제 좌측 `시설물 레이어`와 다를 수 있다.
@@ -0,0 +1,327 @@
# SFR-012-003 하수관로편집 통합 구현 기록
## 1. 문서 목적
- `SFR-012-003 하수관로편집`의 FE 구현 이력, 결정사항, 남은 리스크를 기능당 1개 문서로 누적 관리한다.
- 기존 `001~006` 구현 문서를 이 문서로 흡수한다.
- 이후 라운드부터는 새 파일을 추가하지 않고 이 문서 내부 헤더로만 구분한다.
## 2. 입력 근거
- `COMMON_DOCS_ROOT/작업계획/SFR-012-003-하수관로편집/001-de-SFR-012-003-하수관로편집-설계.md`
- `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_화면시나리오_v1_1.md`
- `FE_APP_DOCS_ROOT/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md`
## 3. 공통 계약
- 편집 결과는 원본 GIS를 직접 수정하지 않고 FE 로컬 세션에 우선 누적한다.
- `ADD`, `MOVE`, `VERTEX_EDIT`, `DELETE`는 동시에 활성화하지 않는다.
- `ADD`
- geometry family별 직접 캡처 방식으로 구현한다.
- `POINT`는 클릭 1회, `LINE`은 연속 클릭 + 종료, `POLYGON`은 연속 클릭 + 폐합 종료를 따른다.
- `MOVE`
- preview feature drag 기반으로 유지한다.
- `VERTEX_EDIT`
- QGIS vertex tool 기준으로 vertex marker를 노출하고, 양 끝점 포함 vertex 이동이 가능해야 한다.
- `DELETE`
- 원본을 즉시 지우지 않고 임시 삭제 상태로만 유지한다.
## 3-1. 편집도구 아이콘 기준
현재 구현과 문서 표기는 화면 시안 기준 편집도구 아이콘 의미를 따른다.
| 도구 | 아이콘 | 메모 |
| --- | --- | --- |
| 추가 | <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"></path><path d="M5 12h14"></path><path d="M5 19h14"></path></svg> | geometry family별 직접 캡처 대상 |
| 이동 | <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M12 2l-3 3M12 2l3 3M12 22l-3-3M12 22l3-3"></path><path d="M2 12h20M2 12l3-3M2 12l3 3M22 12l-3-3M22 12l-3 3"></path></svg> | preview drag 유지 기준 |
| 꼭지점편집 | <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 17l6-10 8 8"></path><circle cx="5" cy="17" r="2"></circle><circle cx="11" cy="7" r="2"></circle><circle cx="19" cy="15" r="2"></circle></svg> | 양 끝점 포함 vertex 조정 목표 |
| 삭제 | <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"></path><path d="M10 11v6M14 11v6"></path><path d="M6 7l1 14h10l1-14"></path><path d="M9 7V4h6v3"></path></svg> | 임시 삭제 상태 유지 |
| 저장 | <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3h12l2 2v16H5z"></path><path d="M8 3v6h8V3"></path><path d="M8 17h8"></path></svg> | 원본 분리 저장 계약 |
## 4. 구현 라운드 요약
### 4.1 초기 범위 정리
- FE 로컬 세션, 툴바, 저장 payload 미리보기 구조를 준비했다.
- 백엔드 공유용 편집 메타/저장 API 초안을 정리했다.
### 4.2 레이어/도구 확장
- 편집 후보 레이어를 7종으로 확장했다.
- 레이어별 허용 도구 활성 규칙을 추가했다.
- 세션 `undo`, `reset`을 추가했다.
### 4.3 `MOVE`
- preview feature 기반 `Translate` interaction을 연결했다.
- 같은 객체 재선택 시 중복 change 누적을 줄이는 focus 흐름을 추가했다.
- 현재 `MOVE`는 유지 기준으로 본다.
### 4.4 `VERTEX_EDIT`
- 여러 차례 interaction 보강을 시도했으나, 요구사항과 다른 구현 흔적이 남았다.
- 현재 핵심 재정렬 기준:
- vertex marker가 보일 것
- 양 끝점 이동 가능
- `MOVE`와 서로 회귀를 일으키지 않을 것
### 4.5 현재 라운드 `ADD` 재구현 시작
- 기존 지도 중심 임시 생성 방식을 폐기했다.
- `ADD` 도구 선택 시 geometry type별 `Draw` interaction을 연결하는 방향으로 재구현 중이다.
### 4.6 2026-05-15 세션 유지 및 상호작용 회귀 대응
- 사용자 피드백 기준 이슈
- `ADD`, `MOVE`, `VERTEX_EDIT` 변경분이 화면에서 바로 사라져 세션 추적이 어렵다.
- `MOVE` 선택 후 드래그가 실제로 움직이지 않는다.
- `VERTEX_EDIT`는 주황색 점이 기대보다 빈약하게 보이고 드래그 편집이 체감되지 않는다.
- 이번 라운드 반영
- `GIS편집` 모드를 꺼도 변경분이 있으면 세션 패널과 지도 preview를 유지하도록 조정했다.
- preview layer를 `updateWhileInteracting=true`로 바꾸고, interaction start/end 시 `renderSync`와 source 변경 반영을 보강했다.
- 원본 피처 선택 시 layerId 비교가 namespace/feature id 형식 차이로 깨지지 않도록 layerId 정규화를 추가했다.
- interaction effect가 `gisEdit` 전체 객체 변경에 과민하게 반응하지 않도록 의존성을 좁혀 `MOVE`, `VERTEX_EDIT` 재연결 흐름을 안정화했다.
- 세션 패널은 편집 모드 종료 후에도 변경분이 남아 있으면 계속 노출되도록 바꿨다.
- 신규 추가 preview는 레이어별 색상을 우선 사용하도록 보강해 같은 화면에서 여러 시설을 편집해도 사용자가 구분할 수 있게 했다.
- `하수처리시설(tb_il_swage_prcs_fclt)`은 실제 레이어 설정 기준에 맞춰 `Point`로 정정하고 `VERTEX_EDIT` 대상에서 제외했다.
### 4.7 2026-05-15 편집 세션 패널 축소/확장 UX 보강
- 편집 세션이 길어질 때 지도를 과도하게 가리는 문제가 있어 기본 패널은 최근 5건만 표시하도록 줄였다.
- 기본 패널에는 `총 N건` 배지와 `전체 보기` 버튼을 추가했다.
- `전체 보기`는 드래그 가능한 팝업으로 분리해 사용자가 지도 위에서 위치를 옮기며 확인할 수 있게 했다.
- 편집 모드를 종료한 뒤에도 세션이 남아 있으면 compact 패널과 전체 보기 팝업에서 계속 확인할 수 있다.
### 4.8 2026-05-15 편집 세션 패널 단순화
- 사용자 피드백에 따라 `전체 보기` 팝업은 제거했다.
- 대신 기본 패널 높이를 최근 5건 정도만 보이는 크기로 고정하고, 패널 내부 스크롤로 아래 이력을 바로 확인할 수 있게 바꿨다.
- 결과적으로 지도 맥락은 유지하면서도 추가 팝업 조작 없이 세션 전체를 훑을 수 있는 구조로 단순화했다.
- 후속 조정으로 패널 높이를 한 번 더 줄이고, 하단 안내 문구 영역도 제거해 화면 점유를 더 낮췄다.
### 4.9 2026-05-15 QGIS Vertex Tool 기준 재설계 착수
- QGIS 공식 문서 기준 확인 결과, Vertex Tool은 “모든 실꼭지점 표시”, “끝점 이동”, “segment 중간 가상점 추가”, “selection persistent”를 기본으로 제공한다.
- 기존 구현은 OpenLayers `Modify` 기본 overlay 위주여서 marker 표시와 endpoint drag 체감이 QGIS와 크게 달랐다.
- 이번 라운드부터 `VERTEX_EDIT`는 아래 구조로 재구성한다.
- preview geometry와 분리된 vertex handle 전용 레이어
- 실꼭지점 handle
- segment 중간 가상 handle
- handle drag 후 geometry 재조립 및 세션 반영
### 4.10 2026-05-15 `VERTEX_EDIT` QGIS 유사화 재구현
- 이번 라운드 결정
- `ADD`, `MOVE`는 기준선으로 유지하고 `VERTEX_EDIT`만 재구현 대상으로 제한했다.
- OpenLayers `Modify` 기본 overlay로 되돌리지 않고, 기존 custom handle layer 방향을 유지하되 실험 코드를 단순화했다.
- “모든 midpoint 상시 표시” 방식은 버리고 “실꼭지점 상시 + hover segment midpoint 1건” 구조로 바꿨다.
- 코드 반영
- 선택 preview feature의 실제 vertex만 항상 렌더링하도록 handle 생성 함수를 분리했다.
- `LineString`, `MultiLineString`, `Polygon`, `MultiPolygon`에서 segment 후보를 계산하는 midpoint candidate 수집 유틸을 추가했다.
- pointer move 기준으로 가장 가까운 segment 1건만 midpoint handle을 노출하고, vertex 근처에서는 midpoint를 숨기도록 pixel tolerance 로직을 넣었다.
- midpoint drag 시 해당 segment 사이에 새 좌표를 삽입하고, vertex drag 시 기존 좌표만 이동하도록 geometry 재조립 흐름을 유지했다.
- interaction 시작/종료 중 midpoint hover가 섞여 드래그가 흔들리지 않도록 dragging flag와 pointer move cleanup을 분리했다.
- `MOVE` preview layer의 `clickPsblYn="Y"` 기준은 그대로 유지했고, vertex layer는 계속 별도 레이어로 유지했다.
- 사용자 체감 개선 목표
- 어떤 점이 실꼭지점인지 즉시 보인다.
- midpoint는 필요한 segment 근처에서만 나타나 화면이 덜 산개된다.
- endpoint도 일반 vertex와 같은 방식으로 조작된다.
### 4.11 2026-05-15 편집 세션창 팝업 전환 및 도구 아이콘 정리
- 사용자 추가 요청 기준 반영
- 편집 세션창의 “현재 구현 ... 검토합니다” 안내 영역을 제거했다.
- 지도 좌하단 고정 세션 패널은 제거하고, 기존 `window popup` 체계의 `GIS 편집 세션` 팝업으로 전환했다.
- 팝업 기본 크기는 세션 목록과 payload 미리보기를 함께 볼 수 있게 `520 x 820`으로 잡았다.
- `추가`, `이동`, `꼭지점편집`, `삭제` 버튼은 분석도구와 같은 `lucide-react` 아이콘을 붙였다.
- 구현 방식
- 메인 `/map` 화면은 GIS 편집 모드 진입 시 세션 팝업을 열고, postMessage로 세션 snapshot을 동기화한다.
- 팝업에서 `마지막 변경 취소`, `세션 초기화`, `항목 focus`를 누르면 다시 메인 창으로 메시지를 보내 실제 편집 세션을 제어한다.
- 기존 `gisEditPopup` placeholder는 유지하고, 세션 전용 팝업은 `gisEditSessionPopup`으로 별도 등록했다.
### 4.12 2026-05-15 중복 endpoint handle 통합
- 사용자 피드백 기준 이슈
- 관로 시점/종점 주변에 같은 위치의 주황색 꼭지점이 2개씩 겹쳐 보이는 경우가 있었다.
- 이번 라운드 반영
- vertex handle 생성 시 raw vertex를 그대로 전부 노출하지 않고, 같은 좌표를 공유하는 vertex들은 화면에서 handle 1개로 묶었다.
- 단순 표시만 합친 것이 아니라, 묶인 handle을 드래그하면 같은 좌표를 공유하던 raw vertex들을 함께 이동시키도록 처리했다.
- 내부 `MultiLineString`/분리 part 구조는 유지하고, 표시와 드래그 동작만 통합했다.
### 4.13 2026-05-15 외부 제출용 단일 산출물 생성
- 사용자 요청으로 오늘 작업 내역, 설계 변경, 검증 계획, 검증 결과를 묶은 제출용 단일 `md` 파일을 별도 생성한다.
- 이는 통합 문서 체계 예외 산출물이며, 기능 운영 문서는 기존 canonical 4문서 체계를 계속 기준으로 유지한다.
### 4.14 2026-05-21 관로 기본정보 세션 편집 결합
- 실제 구현 재점검 결과
- `useGisEdit`는 geometry change 세션만 관리하고 있었다.
- 관로 상세정보 저장은 기존 `FeatureClickEventPopup` 내부 `POST /api/v1/fcltppl` 즉시 저장 구조였다.
- 편집 모드에서는 피처 클릭이 상세 팝업 대신 session change 누적으로 우선 처리돼, 이동/꼭지점편집 중 기존 상세정보를 보며 수정하는 흐름이 없었다.
- 이번 라운드 반영
- `GisEditChange`에 `featureRef`, `basicInfo`, `basicInfoSavedAt`를 추가했다.
- 기존 상세정보 구현 파일은 직접 수정하지 않는 방향으로 정리하고, GIS 편집 전용 하수관로 팝업을 별도 컴포넌트로 분리했다.
- 신규 추가는 같은 상세정보 형태의 편집 팝업을 빈 값으로 열고, 기존 관로 이동/꼭지점편집은 기존 값이 채워진 상태로 연다.
- 편집 전용 팝업은 `기본정보` editable 테이블과 `입력정보` editable 테이블만 표시하고, `보수 및 갱신`, `청소 및 준설`, `점검 및 진단` 섹션은 렌더링하지 않는다.
- 관로 기본정보는 기존 `GET /api/v1/fcltppl/{fcltMngNo}/{mngInstCd}`로 읽어 오되, 저장은 즉시 API 호출이 아니라 change draft 갱신으로만 처리한다.
- 최종 저장 payload는 `featureRef + basicInfo + geometryPatch` 구조로 다시 조립하도록 바꿨다.
- 남긴 판단
- 현재 백엔드에는 geometry batch save API가 없어 FE는 payload 조립과 저장 지점 정렬까지 수행한다.
- 실제 DB 반영은 `tb_ms_swage_ppl` geometry와 `tb_sn_swage_ppl` 기본정보 저장 구조를 함께 처리하는 백엔드 계약이 추가로 필요하다.
### 4.15 2026-05-21 편집 팝업 자동저장 루프 및 선택 제한 보강
- 사용자 피드백 기준 이슈
- 편집 팝업 입력 시 스크롤이 상단으로 튀고, 일부 입력에서는 `Maximum update depth exceeded` 런타임 에러가 발생했다.
- `MOVE`, `VERTEX_EDIT` 중 하수관로 외 객체를 클릭하면 지도 기본 선택 흐름이 타면서 줌 이동이 발생했다.
- `MOVE`, `VERTEX_EDIT`에서 하수관로를 선택해도 편집 상세정보창이 항상 즉시 뜨지 않았다.
- 원인 정리
- 편집 팝업 draft를 `useEffect`에서 자동 저장하도록 구성하면서 `팝업 입력 -> parent change 갱신 -> 팝업 재초기화 -> 재저장` 루프가 생겼다.
- `detailView` wrapper가 부모 렌더마다 재생성되어 스크롤/포커스가 흔들릴 여지가 있었다.
- `MOVE`, `VERTEX_EDIT`의 상세정보창 오픈을 preview 준비 완료 시점에 의존시켜 선택 직후 오픈이 누락되는 경우가 있었다.
- 이번 라운드 반영
- 자동 세션 저장은 렌더 기반 effect에서 제거하고, 실제 `input/select` 변경 이벤트 시점에만 draft를 세션에 반영하도록 바꿨다.
- `detailView` wrapper를 안정화해 편집 팝업이 입력 중 불필요하게 remount되지 않도록 조정했다.
- `MOVE`, `VERTEX_EDIT`에서 하수관로를 선택하면 `setTimeout(0)` 기준으로 상세정보창을 즉시 열도록 단순화했다.
- 공통 `GisSelect`에 `canSelectFeature` 가드를 연결해, 하수관로 편집 중에는 비하수관로 피처를 아예 선택 단계에서 차단하도록 확장했다.
- 차단된 경우에는 지도 줌 이동이나 상세정보 전환 없이 상태문구 `하수관로를 선택해주세요.`만 남기도록 정리했다.
### 4.16 2026-06-08 스냅핑 토글 및 Delete 키 꼭지점 삭제
- 사용자 요구사항 기준
- `이동`과 `꼭지점편집` 사이에 QGIS 유사 `스냅핑` on/off 토글을 두고 실제 프론트 interaction에서 사용 가능해야 한다.
- `꼭지점편집` 중 주황색 실꼭지점을 클릭한 뒤 키보드 `Delete`를 누르면 해당 꼭지점을 삭제하고, 남은 꼭지점끼리 다시 연결돼야 한다.
- 이번 라운드 반영
- `useGisEdit`에 `isSnapEnabled` 상태와 `toggleSnapEnabled`를 추가하고, 툴바와 세션 팝업 스냅샷에 함께 노출했다.
- `GisEditToolbar`에 `꼭지점편집` 오른쪽 `스냅핑` 버튼을 추가했다.
- 처음 구현은 `MOVE`, `VERTEX_EDIT`에만 스냅을 붙여 `ADD`에서는 스냅핑이 동작하지 않는 문제가 있었다.
- 후속 보정으로 `ADD`, `MOVE`, `VERTEX_EDIT` 모두 현재 보이는 `tb_ms_swage_ppl` 원본 feature와 다른 preview feature를 수집해 OpenLayers `Snap` interaction을 함께 부착하도록 수정했다.
- `VERTEX_EDIT` handle feature에 선택 키를 부여해, 사용자가 주황색 실꼭지점을 클릭하면 선택 상태를 유지하도록 보강했다.
- 전역 `keydown`에서 `Delete`를 감지하되 `input`, `textarea`, `select`, contentEditable 포커스 중에는 무시하도록 방어했다.
- `Delete` 대상은 주황색 실꼭지점만 허용하고, 흰색 midpoint는 “새 점 삽입 전용”으로 유지했다.
- `LineString`, `MultiLineString`, `Polygon`, `MultiPolygon` 기준 삭제 유틸을 추가했지만, 현재 운영 범위상 핵심 대상은 하수관로 선형이다.
- 선형 최소 꼭지점 규칙을 넣어 하수관로가 2점 미만이 되는 삭제는 차단했다.
- 남긴 판단
- 현재 스냅 대상은 지도 source에 로드된 하수관로 원본/preview만 사용하므로 화면 밖 또는 비로딩 객체까지 보장하지는 않는다.
- 겹쳐 있는 handle은 기존 구현처럼 하나의 묶음으로 표현되므로, 같은 좌표를 공유하는 raw vertex는 Delete 시 함께 제거될 수 있다.
### 4.17 2026-06-08 QGIS 기준 1차 스냅 재구현
- 사용자 재피드백 기준 이슈
- 스냅핑 `ON` 상태에서도 `ADD`가 기존 오수관에 붙지 않았다.
- `MOVE`, `VERTEX_EDIT`도 원본 하수관로를 기준으로 붙는 체감이 약했다.
- 원인 재정리
- 기존 구현은 “지도에 이미 feature로 올라와 있는 객체”를 즉석에서 수집해 snap 대상으로 쓰는 구조였다.
- 하수관로 원본이 WMS로만 보이는 경우에는 브라우저 메모리에 snap 기준 feature가 충분히 없을 수 있어, 사용자는 관로가 보여도 실제로는 스냅 기준 geometry가 없는 상태가 생긴다.
- 이번 1차 재구현 반영
- 범위를 `ADD`, `VERTEX_EDIT` 우선으로 줄여 시작했지만, 후속 보정으로 `MOVE`도 같은 원본 하수관로 snap source를 사용하도록 확장했다.
- 현재 화면 extent를 기준으로 `tb_ms_swage_ppl` WFS를 직접 다시 조회해 전용 `pipe snap source`를 만든다.
- `ADD`는 `Draw + Snap(source)` 구조로, `VERTEX_EDIT`는 custom vertex handle + `Snap(source)` 구조로 다시 연결했다.
- `MOVE`도 preview 수집 기반 fallback보다 원본 하수관로 WFS snap source를 우선 사용하도록 보강했다.
- `VERTEX_EDIT`에서 기존 관로를 편집하는 경우 자기 자신 원본 fid는 snap source에서 제외해 자기 geometry에 다시 달라붙는 문제를 줄였다.
- 지도 `moveend` 시 snap source를 다시 조회해 현재 보이는 하수관로 기준을 유지하도록 보강했다.
- 현재 한계
- 이번 1차는 `MOVE`를 아직 QGIS 기준으로 다시 고정하지 않았다.
- 수동 브라우저 검증 전이라 실제 체감과 시각 피드백은 아직 최종 확정 상태가 아니다.
### 4.18 2026-06-08 MOVE 기준점 재정렬 및 입력정보 조회 방어
- 사용자 재피드백 기준 이슈
- `ADD`, `VERTEX_EDIT`는 비교적 잘 붙는데 `MOVE`는 붙는 위치와 안 붙는 위치가 섞여 보였다.
- 이동 중 관로 상세 팝업이 열릴 때 `fetchPipeInputInfo`에서 `500`이 간헐적으로 발생했다.
- 원인 정리
- 기존 `MOVE`는 드래그 후 커서 좌표나 임의의 가장 가까운 꼭지점 기준으로 보정해, 사용자가 실제로 잡아 끈 관로 끝점과 snap 기준점이 달라질 수 있었다.
- 관로 입력정보 조회는 `fcltMngNo`, `mngInstCd`를 path에 그대로 넣고 있어 특수문자/임시값 케이스에 취약했다.
- 이번 라운드 반영
- `MOVE`는 관로 선택 순간 클릭 위치에서 가장 가까운 관로 꼭지점 meta를 `move grip`으로 저장한다.
- 드래그 시작/중/종료 모두 원본 geometry snapshot을 기준으로 다시 계산하고, 선택된 `move grip`이 원본 하수관로 snap source에 붙도록 전체 geometry를 재정렬한다.
- 즉 `MOVE`도 `ADD`, `VERTEX_EDIT`처럼 “명확한 한 점을 기준으로 붙는다”는 QGIS 관점에 더 가깝게 재해석했다.
- `GisEditPipeSessionPopup`의 입력정보 조회는 path segment를 `encodeURIComponent(...)` 처리하고, `NEW-*` 임시값은 조회하지 않도록 방어했다.
- 남긴 판단
- `MOVE` 체감이 여전히 어색하면 다음 단계는 grip point를 지도 위에 직접 시각화해 사용자가 “지금 어떤 점을 잡고 이동 중인지”를 보이게 해야 한다.
## 5. 기능별 현재 상태
### 5.1 `ADD`
- 요구사항
- 사용자가 지도 위에서 직접 점/선/면을 캡처해야 한다.
- 이전 상태
- 지도 중심 기준 임시 geometry 자동 생성
- 현재 상태
- `Draw` interaction 기반으로 전환 중
- 남은 과제
- 선형/면형 종료 UX
- 캡처 취소 UX
- 세션 summary와 preview label 정교화
- 신규 추가 색상은 레이어 메타 우선, 없으면 FE fallback 색으로 처리
### 5.2 `MOVE`
- preview drag 방식으로 동작 확인
- 유지 기준
- 선택 객체 1건 기준 preview 생성
- drag 종료 후 세션 geometry 갱신
- 2026-05-15 보강
- preview layer redraw와 interaction 재바인딩 타이밍을 안정화했다.
- 실제 브라우저 수동 drag 재확인은 아직 남아 있다.
### 5.3 `VERTEX_EDIT`
- 요구사항
- 선택 객체의 vertex marker가 표시되어야 함
- 양 끝점 이동 가능
- 중간 vertex 이동 가능
- 현재 리스크
- 하수관로 geometry가 `LineString`/`MultiLineString`으로 섞여 들어온다.
- interaction 구현이 흔들리면 `MOVE`까지 회귀할 수 있다.
- 다음 기준
- 하수관로를 기준으로 QGIS형 vertex handle 흐름부터 고정
- 2026-05-15 보강
- preview geometry와 별도인 vertex handle 레이어를 유지하되, 실꼭지점만 상시 표시하고 midpoint는 hover segment에 대해서만 노출하도록 재구현했다.
- endpoint drag, midpoint drag 후 vertex 삽입, geometry 재조립 흐름을 하나의 custom handle 규칙으로 통일했다.
- `하수처리시설`은 point라서 `VERTEX_EDIT` 대상이 아니라는 기준을 코드와 문서에 함께 맞췄다.
### 5.4 `DELETE`
- 임시 삭제 세션 흐름은 존재
- 삭제 대상 시각 구분 강화는 후속
## 6. BE 공유 사항
- `GET /api/v1/map/gisEditLayers`
- geometry type/family
- editable 여부
- allowed tools
- `POST /api/v1/map/gisEditSessions/save`
- 변경 유형별 geometry payload
- 원본 분리 저장
## 7. 남은 리스크
- `map/page.tsx`에 interaction 책임이 많이 몰려 있어 회귀 위험이 높다.
- `VERTEX_EDIT`는 하수관로 기준 단순화가 먼저 필요하다.
- `ADD`가 QGIS capture 경험과 완전히 같아지려면 종료/취소 UX를 더 다듬어야 한다.
- canvas 기반 `MOVE`, `VERTEX_EDIT`는 자동화 브라우저 검증이 제한적이라 실제 드래그 체감은 수동 확인이 남아 있다.
## 8. 흡수한 기존 문서
- `001-fe-SFR-012-003-하수관로편집-프론트공유API요청.md`
- `002-fe-SFR-012-003-하수관로편집-GIS편집상태재검토.md`
- `003-fe-SFR-012-003-하수관로편집-세션도구확장.md`
- `004-fe-SFR-012-003-하수관로편집-실제지도조작확장.md`
- `005-fe-SFR-012-003-하수관로편집-MOVE실드래그구현.md`
- `006-fe-SFR-012-003-하수관로편집-VERTEX_EDIT실조작구현.md`
## 2026-06-10 관로 상세 조회 성능 개선
- 작업 목적
- 하수관로 클릭 후 상세 팝업이 API 응답 전까지 비어 보이던 체감을 줄이고, 잘못된 상세 호출을 막는다.
- 프론트 변경
- `featureClickEventPopup.tsx`에서 지도 클릭 feature 값으로 하수관로 기본정보를 먼저 렌더링하고, 상세 API 응답이 오면 기존 병합 흐름으로 덮어쓴다.
- 상세 식별값이 비어 있거나 `null`/`undefined` 문자열인 경우 `fcltppl` 상세 호출을 건너뛴다.
- `MsSwagePpl.tsx`의 운영이력 조회는 snake/camel 식별값을 모두 허용하고, 식별값이 없는 경우 호출하지 않도록 가드했다.
- 기대 효과
- 관로 클릭 직후 팝업 기본정보가 즉시 표시된다.
- `/api/v1/fcltppl/null/null` 같은 불필요 호출을 줄여 상세 체감 지연과 백엔드 부하를 함께 낮춘다.
+1
View File
@@ -28,6 +28,7 @@
"js-cookie": "^3.0.5",
"moment": "^2.30.1",
"next": "15.3.8",
"lucide-react": "^0.474.0",
"react-chartjs-2": "^5.3.0",
"react-hot-toast": "^2.5.2",
"react-dnd": "^16.0.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 379 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<circle cx="8" cy="8" r="4.5" fill="#2563eb" stroke="#ffffff" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<circle cx="8" cy="8" r="4.5" fill="#f59e0b" stroke="#ffffff" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 174 B

@@ -3,7 +3,7 @@ import {
windowPopupDefinitions,
type WindowPopupKey,
} from "../model/popupDefinitions";
import type { OpenWindowPopupParams } from "../model/types";
import type { OpenWindowPopupParams, WindowPopupDefinition } from "../model/types";
const buildQueryString = (params: OpenWindowPopupParams = {}) => {
const searchParams = new URLSearchParams();
@@ -17,17 +17,59 @@ const buildQueryString = (params: OpenWindowPopupParams = {}) => {
return queryString ? `?${queryString}` : "";
};
const buildPopupFeatures = (width: number, height: number) => {
const clamp = (value: number, min: number, max: number) => {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
};
const buildPopupFeatures = ({
width,
height,
placement = "center",
alignTop = false,
offsetLeft = 0,
offsetTop = 0,
}: Pick<
WindowPopupDefinition,
"width" | "height" | "placement" | "alignTop" | "offsetLeft" | "offsetTop"
>) => {
const screenLeft = window.screenX ?? window.screenLeft ?? 0;
const screenTop = window.screenY ?? window.screenTop ?? 0;
const outerWidth = window.outerWidth || window.screen.width;
const outerHeight = window.outerHeight || window.screen.height;
const left = Math.max(0, screenLeft + Math.round((outerWidth - width) / 2));
const top = Math.max(0, screenTop + Math.round((outerHeight - height) / 2));
const screen = window.screen as Screen & {
availLeft?: number;
availTop?: number;
};
const availableLeft = screen.availLeft ?? 0;
const availableTop = screen.availTop ?? 0;
const availableWidth = window.screen.availWidth || outerWidth;
const availableHeight = window.screen.availHeight || outerHeight;
const safeWidth = Math.min(width, Math.max(360, availableWidth - 48));
const safeHeight = Math.min(height, Math.max(420, availableHeight - 72));
const minLeft = availableLeft;
const minTop = availableTop + 24;
const maxLeft = availableLeft + availableWidth - safeWidth - 16;
const maxTop = availableTop + availableHeight - safeHeight - 24;
const left =
placement === "browser-left"
? clamp(screenLeft + offsetLeft, minLeft, maxLeft)
: clamp(
screenLeft + Math.round((outerWidth - safeWidth) / 2) + offsetLeft,
availableLeft + 16,
maxLeft,
);
const top = clamp(
alignTop
? screenTop + 24 + offsetTop
: screenTop + Math.round((outerHeight - safeHeight) / 2) + offsetTop,
minTop,
maxTop,
);
return [
`width=${width}`,
`height=${height}`,
`width=${safeWidth}`,
`height=${safeHeight}`,
`left=${left}`,
`top=${top}`,
"resizable=yes",
@@ -48,7 +90,7 @@ export const openWindowPopup = (
const definition = windowPopupDefinitions[popupKey];
const popupUrl = `/popup/${popupKey}${buildQueryString(params)}`;
const popupFeatures = buildPopupFeatures(definition.width, definition.height);
const popupFeatures = buildPopupFeatures(definition);
const popupWindow = window.open(
popupUrl,
`sn-popup-${popupKey}`,
@@ -70,8 +70,18 @@ export const windowPopupDefinitions = {
agedPipePopup: {
key: "agedPipePopup",
title: "노후 하수관로 관리 ",
width: 1180,
height: 760,
width: 850,
height: 600,
placement: "browser-left",
alignTop: true,
offsetLeft: 0,
offsetTop: 200,
},
sinkholePopup: {
key: "sinkholePopup",
title: "지반침하발생현황",
width: 1380,
height: 820,
},
pipeAnalysisPopup: {
key: "pipeAnalysisPopup",
@@ -103,16 +113,23 @@ export const windowPopupDefinitions = {
width: 1080,
height: 760,
},
gisEditSessionPopup: {
key: "gisEditSessionPopup",
title: "편집 세션",
width: 460,
height: 640,
placement: "browser-left",
},
gisManagementPopup: {
key: "gisManagementPopup",
title: "GIS 관리",
width: 1080,
height: 760,
width: 860,
height: 700,
},
flowRiskPopup: {
key: "flowRiskPopup",
title: "통수 위험",
width: 1080,
width: 920,
height: 760,
},
} as const satisfies Record<string, WindowPopupDefinition>;
@@ -14,6 +14,10 @@ export type WindowPopupDefinition = {
title: string;
width: number;
height: number;
placement?: "center" | "browser-left";
alignTop?: boolean;
offsetLeft?: number;
offsetTop?: number;
requiredParams?: string[];
};
@@ -31,6 +35,19 @@ export const WINDOW_POPUP_MESSAGE_TYPE = {
FLOOD_PRE_UPLOAD_COMPLETED: "sn:popup:floodPreUploadCompleted",
MAP_SEARCH_SELECT_FEATURE: "sn:popup:mapSearchSelectFeature",
MAP_SEARCH_SHOW_MULTIPLE: "sn:popup:mapSearchShowMultiple",
GIS_EDIT_SESSION_READY: "sn:popup:gisEditSessionReady",
GIS_EDIT_SESSION_SYNC: "sn:popup:gisEditSessionSync",
GIS_EDIT_SESSION_FOCUS_CHANGE: "sn:popup:gisEditSessionFocusChange",
GIS_EDIT_SESSION_REMOVE_LAST_CHANGE: "sn:popup:gisEditSessionRemoveLastChange",
GIS_EDIT_SESSION_CLEAR_CHANGES: "sn:popup:gisEditSessionClearChanges",
WATER_RISK_READY: "sn:popup:waterRiskReady",
WATER_RISK_SYNC: "sn:popup:waterRiskSync",
WATER_RISK_SELECT_RESULT: "sn:popup:waterRiskSelectResult",
WATER_RISK_START_SETTING_EDIT: "sn:popup:waterRiskStartSettingEdit",
WATER_RISK_STOP_SETTING_EDIT: "sn:popup:waterRiskStopSettingEdit",
WATER_RISK_REMOVE_STAGED_PIPE: "sn:popup:waterRiskRemoveStagedPipe",
WATER_RISK_REORDER_STAGED_PIPE: "sn:popup:waterRiskReorderStagedPipe",
WATER_RISK_CLEAR_STAGED_PIPES: "sn:popup:waterRiskClearStagedPipes",
} as const;
export type WindowPopupMessageType =
+13 -11
View File
@@ -10,11 +10,15 @@ import RdiiChartPopupContent from "./ui/RdiiChartPopupContent";
import ReportFilesPopupContent from "./ui/ReportFilesPopupContent";
import ReportTabPopupContent from "./ui/ReportTabPopupContent";
import SearchModalPopupContent from "./ui/SearchModalPopupContent";
import SinkholePopupContent from "./ui/SinkholePopupContent";
import SscDetailPopupContent from "./ui/SscDetailPopupContent";
import type { WindowPopupContentProps } from "./model/types";
import FeaturePlaceholderPopupContent from "./ui/FeaturePlaceholderPopupContent";
import GisEditSessionPopupContent from "./ui/GisEditSessionPopupContent";
import GisManagementPopupContent from "./ui/GisManagementPopupContent";
import PipeAnalysisPopupContent from "./ui/PipeAnalysisPopupContent";
import PipeAnalysisDetailPopupContent from "./ui/PipeAnalysisDetailPopupContent";
import WaterRiskPopupContent from "./ui/WaterRiskPopupContent";
const FloodPredictionMapPopupContent = (_props: WindowPopupContentProps) => (
<FeaturePlaceholderPopupContent
@@ -32,16 +36,6 @@ const GisEditPopupContent = (_props: WindowPopupContentProps) => (
<FeaturePlaceholderPopupContent title={windowPopupDefinitions.gisEditPopup.title} />
);
const GisManagementPopupContent = (_props: WindowPopupContentProps) => (
<FeaturePlaceholderPopupContent
title={windowPopupDefinitions.gisManagementPopup.title}
/>
);
const FlowRiskPopupContent = (_props: WindowPopupContentProps) => (
<FeaturePlaceholderPopupContent title={windowPopupDefinitions.flowRiskPopup.title} />
);
export const windowPopupRegistry = {
oldPpUpload: {
...windowPopupDefinitions.oldPpUpload,
@@ -87,6 +81,10 @@ export const windowPopupRegistry = {
...windowPopupDefinitions.agedPipePopup,
component: AgedPipePopupContent,
},
sinkholePopup: {
...windowPopupDefinitions.sinkholePopup,
component: SinkholePopupContent,
},
pipeAnalysisPopup: {
...windowPopupDefinitions.pipeAnalysisPopup,
component: PipeAnalysisPopupContent,
@@ -107,12 +105,16 @@ export const windowPopupRegistry = {
...windowPopupDefinitions.gisEditPopup,
component: GisEditPopupContent,
},
gisEditSessionPopup: {
...windowPopupDefinitions.gisEditSessionPopup,
component: GisEditSessionPopupContent,
},
gisManagementPopup: {
...windowPopupDefinitions.gisManagementPopup,
component: GisManagementPopupContent,
},
flowRiskPopup: {
...windowPopupDefinitions.flowRiskPopup,
component: FlowRiskPopupContent,
component: WaterRiskPopupContent,
},
} as const satisfies Record<WindowPopupKey, WindowPopupRegistryItem>;
@@ -0,0 +1,75 @@
"use client";
import { useEffect, useState } from "react";
import GisEditSessionPanel from "@/app/(map-layout)/map/components/gisEdit/GisEditSessionPanel";
import type { GisEditSessionSnapshot } from "@/app/(map-layout)/map/components/gisEdit/gisEditTypes";
import {
WINDOW_POPUP_MESSAGE_TYPE,
type WindowPopupContentProps,
} from "../model/types";
import { windowPopupDefinitions } from "../model/popupDefinitions";
const EMPTY_SNAPSHOT: GisEditSessionSnapshot = {
isEditMode: false,
selectedLayerName: "",
activeTool: null,
isSnapEnabled: false,
changes: [],
lastSavedPayload: null,
canUndo: false,
focusedChangeId: null,
};
const postMessageToOpener = (type: string, payload?: Record<string, unknown>) => {
if (typeof window === "undefined" || !window.opener) return;
window.opener.postMessage({ type, payload }, window.location.origin);
};
export default function GisEditSessionPopupContent(_props: WindowPopupContentProps) {
const [snapshot, setSnapshot] = useState<GisEditSessionSnapshot>(EMPTY_SNAPSHOT);
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
const data = event.data;
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_SYNC) {
setSnapshot((data.payload ?? EMPTY_SNAPSHOT) as GisEditSessionSnapshot);
}
};
window.addEventListener("message", handleMessage);
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_READY);
return () => {
window.removeEventListener("message", handleMessage);
};
}, []);
return (
<main className="flex h-screen flex-col overflow-hidden bg-[#f6f8fb]">
<header className="flex min-h-[48px] items-center border-b border-[#dbe2e6] bg-primary px-5 py-3">
<h1 className="text-[1.2rem] font-semibold text-white">
{windowPopupDefinitions.gisEditSessionPopup.title}
</h1>
</header>
<section className="flex-1 overflow-hidden p-3">
<GisEditSessionPanel
{...snapshot}
className="h-full shadow-none"
onRemoveLastChange={() => {
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_REMOVE_LAST_CHANGE);
}}
onClearChanges={() => {
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_CLEAR_CHANGES);
}}
onFocusChange={(changeId) => {
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_FOCUS_CHANGE, {
changeId,
});
}}
/>
</section>
</main>
);
}
@@ -0,0 +1,10 @@
import { Suspense } from "react";
import GisManagementPopupPage from "@/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage";
export default function GisManagementPopupContent() {
return (
<Suspense fallback={null}>
<GisManagementPopupPage />
</Suspense>
);
}
@@ -0,0 +1,10 @@
import { Suspense } from "react";
import SinkholePopupPage from "@/app/(map-layout)/map/components/sinkhole/SinkholePopupPage";
export default function SinkholePopupContent() {
return (
<Suspense fallback={null}>
<SinkholePopupPage />
</Suspense>
);
}
@@ -0,0 +1,12 @@
import { Suspense } from "react";
import WaterRiskPopupPage from "@/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage";
import type { WindowPopupContentProps } from "../model/types";
export default function WaterRiskPopupContent(props: WindowPopupContentProps) {
return (
<Suspense fallback={null}>
<WaterRiskPopupPage {...props} />
</Suspense>
);
}
+44
View File
@@ -0,0 +1,44 @@
declare module "lucide-react" {
import type { ComponentType, SVGProps } from "react";
export type LucideProps = SVGProps<SVGSVGElement> & {
size?: string | number;
color?: string;
strokeWidth?: string | number;
absoluteStrokeWidth?: boolean;
};
type LucideIcon = ComponentType<LucideProps>;
export const ChartColumnBig: LucideIcon;
export const ArrowLeft: LucideIcon;
export const ArrowRight: LucideIcon;
export const CalendarIcon: LucideIcon;
export const Check: LucideIcon;
export const CheckIcon: LucideIcon;
export const ChevronDown: LucideIcon;
export const ChevronDownIcon: LucideIcon;
export const ChevronLeft: LucideIcon;
export const ChevronLeftIcon: LucideIcon;
export const ChevronRight: LucideIcon;
export const ChevronRightIcon: LucideIcon;
export const ChevronUp: LucideIcon;
export const Circle: LucideIcon;
export const CloudUpload: LucideIcon;
export const History: LucideIcon;
export const Move: LucideIcon;
export const MoreHorizontal: LucideIcon;
export const PanelsTopLeft: LucideIcon;
export const PanelLeft: LucideIcon;
export const Paperclip: LucideIcon;
export const PenTool: LucideIcon;
export const PencilRuler: LucideIcon;
export const Plus: LucideIcon;
export const Search: LucideIcon;
export const Settings2: LucideIcon;
export const Trash2: LucideIcon;
export const TriangleAlert: LucideIcon;
export const Waves: LucideIcon;
export const X: LucideIcon;
export const XIcon: LucideIcon;
}
+1
View File
@@ -12,6 +12,7 @@
"module": "esnext",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"ignoreDeprecations": "6.0",
"strictNullChecks": true,
"resolveJsonModule": true,
"isolatedModules": true,
@@ -49,12 +49,14 @@ function Gis({
searchView: SearchView,
detailView: DetailView,
onFeatureSelect,
canSelectFeature,
setIsSearchModalOpen,
onCheckedLayersChange,
searchBar: SearchBar,
waterDepth: WaterDepth = null,
layerSettingView: LayerSettingView = null,
inlineLayerSettingView,
treeSectionClassName = "",
layerIconVisible,
isPlaying = false,
setIsPlaying = () => { },
@@ -111,7 +113,7 @@ function Gis({
(data) => {
onFeatureSelect?.(data); // 선택된 피쳐 데이터 콜백 호출
},
[mapInstanceRef],
[onFeatureSelect],
);
// 지도 생성 및 초기화
@@ -315,6 +317,7 @@ function Gis({
onProgressChange={handleProgressChange}
layerSettingView={LayerSettingView}
inlineLayerSettingView={inlineLayerSettingView}
treeSectionClassName={treeSectionClassName}
toggleButtonPosition={treeTogglePosition}
/>
@@ -354,7 +357,7 @@ function Gis({
<GisZoomLevel zoomGuide={zoomGuide} showZoomScaleInfo={showZoomScaleInfo} />
)}
<GisMapToggle />
<GisSelect onFeatureSelect={handleFeatureSelect} />
<GisSelect onFeatureSelect={handleFeatureSelect} canSelectFeature={canSelectFeature} />
</div>
</div>
@@ -1,25 +1,43 @@
import React, { useContext, useEffect } from "react";
import React, { useContext, useEffect, useRef } from "react";
import { GisContext } from "./Gis";
import GisApp from "./lib/gis.core";
import GisOlSelect from "./lib/gis.select";
export default React.memo(function GisSelect({ onFeatureSelect }) {
export default React.memo(function GisSelect({ onFeatureSelect, canSelectFeature }) {
const { gisMap } = useContext(GisContext);
const onFeatureSelectRef = useRef(onFeatureSelect);
const canSelectFeatureRef = useRef(canSelectFeature);
useEffect(() => {
onFeatureSelectRef.current = onFeatureSelect;
}, [onFeatureSelect]);
useEffect(() => {
canSelectFeatureRef.current = canSelectFeature;
}, [canSelectFeature]);
useEffect(() => {
if (!gisMap) return; // 아직 초기화 안 됐으면 아무것도 안 함
// 이미 select가 있으면 재생성하지 않음
const select = new GisOlSelect(gisMap);
GisApp.Module.select = select;
// 피처 선택 시 콜백 등록
select.getSelectedInfo(function (features) {
if (features?.[0]) {
onFeatureSelect(features[0]);
const isSelectable = canSelectFeatureRef.current?.(features[0]);
if (isSelectable === false) {
select.resetSelectedFeature?.();
return;
}
onFeatureSelectRef.current?.(features[0]);
}
});
}, [gisMap, onFeatureSelect]);
return () => {
select.removeInteration?.();
};
}, [gisMap]);
// const select = new GisOlSelect(gisMap);
// GisApp.Module.select = select;
@@ -46,6 +46,7 @@ export default React.memo(function GisTree({
layerSettingView: LayerSettingView = null,
layerIconVisible,
inlineLayerSettingView,
treeSectionClassName = "",
toggleButtonPosition = "center",
}) {
const { gisCore, treeData } = useGisContext();
@@ -442,10 +443,11 @@ export default React.memo(function GisTree({
* 레이어 정보로부터 범례 이미지 경로 또는 동적 생성 이미지를 반환합니다.
*/
const makeLegendSrc = (layerinfo) => {
if (!layerinfo.gobjTy) return null;
if (layerinfo.iconNm) {
return GisApp.CONTEXT_LAYER_PATH + layerinfo.iconNm;
} else {
}
if (!layerinfo.gobjTy) return null;
else {
return makeLegendImage(layerinfo).src;
}
};
@@ -512,36 +514,45 @@ export default React.memo(function GisTree({
if (!treeData || treeData.length === 0) return null;
const toggleButtonPositionClass =
toggleButtonPosition === "top" ? "top-[5.5rem]" : "top-1/2 -translate-y-1/2";
toggleButtonPosition === "top" ? "top-4" : "top-1/2 -translate-y-1/2";
const thinScrollbarClass =
"[scrollbar-width:thin] [&::-webkit-scrollbar]:w-[4px]";
return (
<>
{/* 사이드 패널 */}
<div
className={`absolute left-0 top-0 z-40 flex h-full w-[250px] flex-col overflow-hidden border-r border-gray-300 bg-white transition-all duration-300 ${visible
? "translate-x-0 opacity-100"
: "pointer-events-none -translate-x-full opacity-0"
className={`absolute left-0 top-0 z-40 h-full transition-transform duration-300 ${visible
? "translate-x-0"
: "-translate-x-[250px]"
}`}
>
<div
className={`grow overflow-y-auto border-none p-2 pt-0 shadow-none ${activeTab === "layers" ? "block" : "hidden"
}`}
>
<h3 className="mx-2 my-4 text-3xl font-bold text-gray-600 text-center">시설물</h3>
<hr className="mb-4" />
{/* DnD Tree */}
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
<Tree
tree={nodes}
rootId={null}
initialOpen={expanded}
onOpenChange={setExpanded}
render={(node, { depth, isOpen, onToggle }) => {
<div className="flex h-full w-[250px] flex-col overflow-hidden border-r border-gray-300 bg-white">
<div
className={`grow overflow-y-auto border-none p-2 pt-0 shadow-none ${thinScrollbarClass} ${activeTab === "layers" ? "block" : "hidden"
}`}
>
<h3 className="mx-2 my-4 text-3xl font-bold text-gray-600 text-center">시설물</h3>
<hr className="mb-4" />
<div className="relative h-[58vh] min-h-[280px] max-h-[58vh]">
<div className={`h-full overflow-hidden ${treeSectionClassName}`}>
<div className={`h-full overflow-y-auto pr-[2px] [scrollbar-gutter:stable] ${thinScrollbarClass}`}>
{/* DnD Tree */}
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
<Tree
tree={nodes}
rootId={null}
initialOpen={expanded}
onOpenChange={setExpanded}
render={(node, { depth, isOpen, onToggle }) => {
const isVisible = node.data.exprsYn === "Y";
const hasVisibleChildren = nodes.some(
(child) =>
child.parent === node.id && child.data.exprsYn === "Y"
);
const isSinkholeRootTreeNode =
String(node.id) === "SN_SINKHOLE" ||
String(node?.data?.lyrNm ?? "").trim() === "지반침하정보";
const showLayerActionButton = layerIconVisible?.(node);
const showLayerActionIcon = depth > 0 && imageVisible;
@@ -550,7 +561,7 @@ export default React.memo(function GisTree({
return (
<div style={{ display: isVisible ? "block" : "none" }}>
<div
className="flex items-center gap-2 rounded px-2 py-1 hover:bg-gray-100"
className="flex min-w-0 items-center gap-2 rounded px-2 py-1 hover:bg-gray-100"
style={{
marginInlineStart: depth * 16,
}}
@@ -563,7 +574,7 @@ export default React.memo(function GisTree({
title={isOpen ? "닫기" : "열기"}
/>
) : (
<span style={{ display: "inline-block", width: 16 }} />
(isSinkholeRootTreeNode ? null : <span style={{ display: "inline-block", width: 16 }} />)
)}
<input
type="checkbox"
@@ -571,8 +582,11 @@ export default React.memo(function GisTree({
onChange={() => handleCheck(node.id)}
className="cursor-pointer"
/>
{node.icon && <span>{node.icon}</span>}
<span className="text-[1.3rem] font-medium text-label">
{!isSinkholeRootTreeNode && node.icon && <span>{node.icon}</span>}
<span
className="min-w-0 flex-1 truncate whitespace-nowrap text-[1.3rem] font-medium text-label"
title={String(node.text ?? "")}
>
{node.text}
</span>
{showLayerActionButton && (
@@ -617,35 +631,34 @@ export default React.memo(function GisTree({
)}
</div>
);
}}
sort={(a, b) => {
const aOrder = parseInt(a.data?.sortOrdr ?? "0");
const bOrder = parseInt(b.data?.sortOrdr ?? "0");
return aOrder - bOrder;
}}
/>
</DndProvider>
</div>
{LayerSettingView && (
<div className="shrink-0 bg-white px-2 pb-3">
<LayerSettingView />
}}
sort={(a, b) => {
const aOrder = parseInt(a.data?.sortOrdr ?? "0");
const bOrder = parseInt(b.data?.sortOrdr ?? "0");
return aOrder - bOrder;
}}
/>
</DndProvider>
</div>
</div>
</div>
{LayerSettingView && (
<div className="bg-white px-2 pb-3 pt-3">
<LayerSettingView />
</div>
)}
</div>
)}
<button
onClick={() => setVisible(false)}
className={`absolute right-[-3px] z-50 transform rounded-l-none rounded-r border border-l border-gray-300 bg-white px-1 py-2 shadow-sm ${toggleButtonPositionClass}`}
>
<ChevronLeft size={16} />
</button>
</div>
</div>
{!visible && (
<button
onClick={() => setVisible(true)}
className={`absolute left-[-3px] z-50 transform rounded-r border border-r border-gray-300 bg-white px-1 py-2 shadow-sm ${toggleButtonPositionClass}`}
>
<ChevronRight size={16} />
</button>
)}
<button
type="button"
onClick={() => setVisible((prev) => !prev)}
className={`absolute z-50 flex h-[44px] w-[24px] items-center justify-center rounded-[6px] border border-[#bfd0e3] bg-[rgba(255,255,255,0.98)] text-[#2c6fb7] shadow-[0_10px_22px_rgba(15,23,42,0.22)] transition-all duration-300 hover:bg-[#eef6ff] hover:text-[#1e88e5] ${toggleButtonPositionClass} ${visible ? "left-[250px]" : "left-0"}`}
title={visible ? "레이어 패널 닫기" : "레이어 패널 열기"}
aria-label={visible ? "레이어 패널 닫기" : "레이어 패널 열기"}
>
{visible ? <ChevronLeft className="h-5 w-5" strokeWidth={2.6} /> : <ChevronRight className="h-5 w-5" strokeWidth={2.6} />}
</button>
</>
);
});
@@ -742,10 +742,13 @@ GisOlLayer.prototype = {
const extent = this.combineExtents(geometries.map(g => g?.getExtent()));
let map = GisApp.Module.core.map;
const targetZoom = 16;
const currentZoom = Number(map.getView().getZoom());
const maxZoom = Number.isFinite(currentZoom) && currentZoom > targetZoom ? currentZoom : targetZoom;
map.getView().fit(extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 16
maxZoom: maxZoom
});
}
} else {
@@ -132,13 +132,15 @@ GisOlSelect.prototype = {
const selectedLayer = e.target?.getLayer(feature) || feature.getLayer?.();
const clckZoom = Number(selectedLayer?.get('clckZoom'));
const targetZoom = Number.isFinite(clckZoom) && clckZoom > 0 ? clckZoom : 16;
const currentZoom = Number(map.getView().getZoom());
const maxZoom = Number.isFinite(currentZoom) && currentZoom > targetZoom ? currentZoom : targetZoom;
const geometry = feature.getGeometry();
if (geometry) {
const extent = geometry.getExtent();
map.getView().fit(extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: targetZoom
maxZoom: maxZoom
});
}
}
@@ -343,12 +343,13 @@ GisOlStyle.prototype = {
//레이어 스타일 반환
getLayerStyle: function (layer, options) {
let gobjTy = layer.get('gobjTy');
if (!gobjTy) return;
if (!gobjTy || typeof this[gobjTy] !== 'function') return;
let style = this[gobjTy](layer, options);
return style;
},
getLayerStyleFunction: function (layer, options) {
let gobjTy = layer.get('gobjTy');
if (!gobjTy || typeof this[gobjTy] !== 'function') return;
let styleFunction = function (feature, resolution) {
return this[gobjTy](layer, options, feature, resolution);
}.bind(this);