Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev

This commit is contained in:
2026-05-21 15:57:02 +09:00
16 changed files with 728 additions and 158 deletions
@@ -0,0 +1,228 @@
"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 { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
import type { GisEditChange, GisEditPipeBasicInfo } from "./gisEditTypes";
import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES, MsSwagePplCol } from "../common/table";
type Props = {
change: GisEditChange;
featureData: any;
onClose: () => void;
onSave: (changeId: string, draft: GisEditPipeBasicInfo) => void;
};
const PIPE_INPUT_INFO_FIELDS = [
{ id: "instlMbdyCd", title: "설치주체", type: "select", options: ["", "BTL", "재정사업"] },
{ id: "swtrmtZoneNm", title: "처리구역", type: "input" },
{ id: "mltvZoneNm", title: "배수구역", type: "input" },
{ id: "mltvDtdvNm", title: "배수분구", type: "input" },
{ id: "lineCd", title: "지선/간선", type: "select", options: ["", "지선", "간선"] },
{ id: "floCd", title: "흐름방식", type: "select", options: ["", "자연유하", "압송"] },
{ id: "layCd", title: "매설위치", type: "select", options: ["", "도로", "하상"] },
];
const PIPE_BASIC_INFO_FIELDS = MsSwagePplCol.map((column) => {
if (column.id === "usg_cd") {
return {
...column,
type: "select",
options: ["", ...MS_SWAGE_PPL_USE_CODES.map((item) => item.text)],
};
}
if (column.id === "mqlt_cd") {
return {
...column,
type: "select",
options: ["", ...MS_SWAGE_PPL_CODES.map((item) => item.text)],
};
}
return {
...column,
type: "input",
};
});
const buildBaseFeatureValues = (featureData: any) => {
const resolveUsageText = (value: unknown) => {
const raw = String(value ?? "").trim();
const matched = MS_SWAGE_PPL_USE_CODES.find((item) => item.val === raw || item.text === raw);
return matched?.text ?? raw;
};
const resolveMaterialText = (value: unknown) => {
const raw = String(value ?? "").trim();
const matched = MS_SWAGE_PPL_CODES.find((item) => item.val === raw || item.text === raw);
return matched?.text ?? raw;
};
if (featureData?.values_) {
const formatted = formatDisplayData(featureData.values_);
return {
...formatted,
usg_cd: resolveUsageText(featureData.values_?.usg_cd ?? formatted.usg_cd),
mqlt_cd: resolveMaterialText(featureData.values_?.mqlt_cd ?? formatted.mqlt_cd),
};
}
if (featureData) {
const formatted = formatDisplayData(featureData);
return {
...formatted,
usg_cd: resolveUsageText(featureData?.usg_cd ?? formatted.usg_cd),
mqlt_cd: resolveMaterialText(featureData?.mqlt_cd ?? formatted.mqlt_cd),
};
}
return {};
};
const buildInitialInputDraft = (change: GisEditChange, baseValues: Record<string, any>) => ({
fcltMngNo: String(change.basicInfo?.fcltMngNo ?? change.featureRef?.fcltMngNo ?? baseValues.fclt_mng_no ?? baseValues.fcltMngNo ?? ""),
mngInstCd: String(change.basicInfo?.mngInstCd ?? change.featureRef?.mngInstCd ?? baseValues.mng_inst_cd ?? baseValues.mngInstCd ?? ""),
instlMbdyCd: String(change.basicInfo?.instlMbdyCd ?? ""),
swtrmtZoneNm: String(change.basicInfo?.swtrmtZoneNm ?? ""),
mltvZoneNm: String(change.basicInfo?.mltvZoneNm ?? ""),
mltvDtdvNm: String(change.basicInfo?.mltvDtdvNm ?? ""),
lineCd: String(change.basicInfo?.lineCd ?? ""),
floCd: String(change.basicInfo?.floCd ?? ""),
layCd: String(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, baseFeatureValues),
);
useEffect(() => {
const initialBasicValues: Record<string, any> = {
...baseFeatureValues,
...(change.basicInfo ?? {}),
};
if (isNewPipe) {
delete initialBasicValues.instl_ymd;
delete initialBasicValues.elapsedYears;
}
setBasicInfoTableData(initialBasicValues);
setInputInfoTableData(buildInitialInputDraft(change, baseFeatureValues));
}, [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;
let isMounted = true;
const fetchPipeInputInfo = async () => {
try {
const result = await getData(`v1/fcltppl/${fcltMngNo}/${mngInstCd}`);
if (!isMounted || !result) return;
setInputInfoTableData((previous) => ({
...previous,
...result,
...(change.basicInfo ?? {}),
fcltMngNo,
mngInstCd,
}));
} catch (error) {
console.error("GIS 편집 관로 입력정보 조회 실패:", error);
}
};
fetchPipeInputInfo();
return () => {
isMounted = false;
};
}, [change.changeId, change.featureRef?.fcltMngNo, change.featureRef?.mngInstCd, isNewPipe]);
const editableBasicInfoFields = useMemo(
() => PIPE_BASIC_INFO_FIELDS.filter((field) => !(isNewPipe && (field.id === "instl_ymd" || field.id === "elapsedYears"))),
[isNewPipe],
);
const buildMergedDraft = (
nextBasicInfoTableData: Record<string, any>,
nextInputInfoTableData: Record<string, any>,
): GisEditPipeBasicInfo => ({
...nextBasicInfoTableData,
...nextInputInfoTableData,
fcltMngNo: String(nextInputInfoTableData.fcltMngNo ?? change.featureRef?.fcltMngNo ?? change.objectId ?? ""),
mngInstCd: String(nextInputInfoTableData.mngInstCd ?? change.featureRef?.mngInstCd ?? ""),
});
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));
};
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?.usg_cd ? `(${basicInfoTableData.usg_cd})` : ""}
{" "}정보 <br />관리번호 : {change.featureRef?.fcltMngNo ?? change.objectId ?? "-"}
</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={[basicInfoTableData]}
headlist={editableBasicInfoFields}
pageSize={20}
total={0}
cellClick={undefined}
onPageChange={() => {}}
isEditable={true}
onFormDataChange={handleBasicInfoChange}
/>
<VerticalTable
headName="입력정보"
tableData={[inputInfoTableData]}
headlist={PIPE_INPUT_INFO_FIELDS}
pageSize={20}
total={0}
cellClick={undefined}
onPageChange={() => {}}
isEditable={true}
onFormDataChange={handleInputInfoChange}
/>
</div>
</div>
);
}
@@ -42,11 +42,18 @@ const ChangeList = ({ changes, focusedChangeId, onFocusChange }: ChangeListProps
<div className="font-semibold text-[#173f67]">
{index + 1}. {TOOL_LABELS[change.editType]} / {change.objectLabel}
</div>
{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 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>
@@ -8,6 +8,25 @@ export type GisEditPreviewColors = {
fillColor: string;
};
export type GisEditFeatureRef = {
fcltMngNo?: string;
mngInstCd?: string;
fid?: string;
};
export type GisEditPipeBasicInfo = {
[key: string]: string | undefined;
fcltMngNo?: string;
mngInstCd?: string;
instlMbdyCd?: string;
swtrmtZoneNm?: string;
mltvZoneNm?: string;
mltvDtdvNm?: string;
lineCd?: string;
floCd?: string;
layCd?: string;
};
export type GisEditLayerInfo = {
layerId: string;
layerName: string;
@@ -24,9 +43,12 @@ export type GisEditChange = {
layerId: string;
layerName: string;
objectId?: string;
featureRef?: GisEditFeatureRef;
objectLabel: string;
beforeGeometry?: Record<string, unknown> | null;
afterGeometry?: Record<string, unknown> | null;
basicInfo?: GisEditPipeBasicInfo | null;
basicInfoSavedAt?: string | null;
summary: string;
createdAt: string;
source: "MAP_SELECTION" | "MAP_CENTER" | "MAP_DRAW";
@@ -42,9 +64,12 @@ export type GisEditSavePayload = {
bbox: string | null;
};
changes: Array<{
changeId: string;
editType: GisEditTool;
objectId?: string;
clientTempId?: string;
featureRef?: GisEditFeatureRef;
basicInfo?: GisEditPipeBasicInfo | null;
beforeGeometry?: Record<string, unknown> | null;
afterGeometry?: Record<string, unknown> | null;
geometry?: Record<string, unknown> | null;
@@ -5,8 +5,10 @@ import GeoJSON from "ol/format/GeoJSON.js";
import type {
GisEditChange,
GisEditFeatureRef,
GisEditGeometryType,
GisEditLayerInfo,
GisEditPipeBasicInfo,
GisEditPreviewColors,
GisEditSavePayload,
GisEditTool,
@@ -110,6 +112,19 @@ const EDITABLE_LAYER_RULES: Record<
const FLOW_DIRECTION_SRS = "EPSG:5179";
const SCREEN_ID = "SN_MN010301";
const GIS_EDIT_ALLOWED_LAYER_IDS = new Set(["tb_ms_swage_ppl"]);
export const EMPTY_GIS_EDIT_PIPE_BASIC_INFO: GisEditPipeBasicInfo = {
fcltMngNo: "",
mngInstCd: "",
instlMbdyCd: "",
swtrmtZoneNm: "",
mltvZoneNm: "",
mltvDtdvNm: "",
lineCd: "",
floCd: "",
layCd: "",
};
const normalizeLayerIdCandidate = (value: unknown) => {
const raw = String(value ?? "").trim();
@@ -211,7 +226,7 @@ const resolvePreviewColors = (
};
};
// FE 1차 구현은 시설 레이어 주요 7종을 기본 편집 후보로 열고, treeData에 없더라도 fallback 후보를 제공한다.
// 현행 운영 기준상 GIS 편집은 하수관로만 다루므로 treeData에서도 해당 레이어만 추린다.
const collectEditableLayers = (treeData: any[]): GisEditLayerInfo[] => {
const results = new Map<string, GisEditLayerInfo>();
@@ -221,7 +236,11 @@ const collectEditableLayers = (treeData: any[]): GisEditLayerInfo[] => {
const layerTypeName = getLayerTypeName(data);
const layerRule = EDITABLE_LAYER_RULES[layerTypeName];
if (layerRule && !results.has(layerTypeName)) {
if (
layerRule &&
GIS_EDIT_ALLOWED_LAYER_IDS.has(layerTypeName) &&
!results.has(layerTypeName)
) {
results.set(layerTypeName, {
layerId: layerTypeName,
layerName: layerRule.layerName,
@@ -245,6 +264,7 @@ const collectEditableLayers = (treeData: any[]): GisEditLayerInfo[] => {
if (results.size === 0) {
Object.entries(EDITABLE_LAYER_RULES).forEach(([layerId, layerRule]) => {
if (!GIS_EDIT_ALLOWED_LAYER_IDS.has(layerId)) return;
results.set(layerId, {
layerId,
layerName: layerRule.layerName,
@@ -311,6 +331,36 @@ const getFeatureObjectId = (featureData: any) =>
"",
).trim();
const getFeatureRef = (featureData: any): GisEditFeatureRef | undefined => {
const mapValues = featureData?.values_ ?? {};
const featureRef: GisEditFeatureRef = {
fcltMngNo: String(
featureData?.get?.("fclt_mng_no") ??
mapValues?.fclt_mng_no ??
featureData?.fcltMngNo ??
"",
).trim() || undefined,
mngInstCd: String(
featureData?.get?.("mng_inst_cd") ??
mapValues?.mng_inst_cd ??
featureData?.mngInstCd ??
"",
).trim() || undefined,
fid: String(
featureData?.getId?.() ??
featureData?.id_ ??
featureData?.fid ??
"",
).trim() || undefined,
};
if (!featureRef.fcltMngNo && !featureRef.mngInstCd && !featureRef.fid) {
return undefined;
}
return featureRef;
};
const createChangeId = (prefix: string, sequence: number) => {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return `${prefix}-${crypto.randomUUID()}`;
@@ -476,9 +526,18 @@ export const useGisEdit = (treeData: any[]) => {
layerId: selectedLayer.layerId,
layerName: selectedLayer.layerName,
objectId: clientTempId,
featureRef: undefined,
objectLabel: clientTempId,
beforeGeometry: null,
afterGeometry: geometry,
basicInfo:
selectedLayer.layerId === "tb_ms_swage_ppl"
? {
...EMPTY_GIS_EDIT_PIPE_BASIC_INFO,
fcltMngNo: clientTempId,
}
: null,
basicInfoSavedAt: null,
summary: `${selectedLayer.layerName} 신규 객체를 지도에서 직접 입력했습니다.`,
createdAt: new Date().toISOString(),
source: "MAP_DRAW",
@@ -501,17 +560,22 @@ export const useGisEdit = (treeData: any[]) => {
focusChange(previewChangeId);
return {
handled: true,
isPreviewSelection: true,
changeId: previewChangeId,
editType: previewChange.editType,
};
}
if (!activeTool || !selectedLayer) return { handled: false };
if (activeTool === "ADD") return { handled: true };
if (activeTool === "ADD") return { handled: true, isPreviewSelection: false };
const featureLayerId = getFeatureLayerId(featureData);
if (featureLayerId !== selectedLayer.layerId) {
setStatusMessage(`현재 선택 레이어는 ${selectedLayer.layerName}입니다. 같은 레이어의 객체를 선택하세요.`);
setStatusMessage(
selectedLayer.layerId === "tb_ms_swage_ppl"
? "하수관로를 선택해주세요."
: `현재 선택 레이어는 ${selectedLayer.layerName}입니다. 같은 레이어의 객체를 선택하세요.`,
);
return { handled: true };
}
@@ -536,6 +600,7 @@ export const useGisEdit = (treeData: any[]) => {
focusChange(existingChange.changeId);
return {
handled: true,
isPreviewSelection: false,
changeId: existingChange.changeId,
editType: existingChange.editType,
};
@@ -543,6 +608,7 @@ export const useGisEdit = (treeData: any[]) => {
const geometryObject = toGeometryObject(featureData);
const geometryType = normalizeGeometryType(geometryObject?.type);
const featureRef = getFeatureRef(featureData);
const nextGeometry =
activeTool === "MOVE"
? geometryObject
@@ -566,9 +632,12 @@ export const useGisEdit = (treeData: any[]) => {
layerId: selectedLayer.layerId,
layerName: selectedLayer.layerName,
objectId,
featureRef,
objectLabel: objectId,
beforeGeometry: geometryObject,
afterGeometry: nextGeometry,
basicInfo: null,
basicInfoSavedAt: null,
summary:
activeTool === "VERTEX_EDIT" && geometryType === "Point"
? `${selectedLayer.layerName} ${objectId}는 점형 객체여서 꼭지점편집 대신 geometry 검토 대상으로 기록했습니다.`
@@ -587,7 +656,7 @@ export const useGisEdit = (treeData: any[]) => {
} else {
setPendingInteraction(null);
}
return { handled: true, changeId, editType: activeTool };
return { handled: true, isPreviewSelection: false, changeId, editType: activeTool };
}, [activeTool, appendChange, findExistingObjectChange, focusChange, isEditMode, selectedLayer]);
// 저장 API가 붙기 전까지는 실제 전송 대신 최종 payload를 조립해 검증 패널에 남긴다.
@@ -607,9 +676,12 @@ export const useGisEdit = (treeData: any[]) => {
bbox: buildBbox(map),
},
changes: changes.map((change) => ({
changeId: change.changeId,
editType: change.editType,
objectId: change.editType === "ADD" ? undefined : change.objectId,
clientTempId: change.editType === "ADD" ? change.objectId : undefined,
featureRef: change.featureRef,
basicInfo: change.basicInfo ?? undefined,
beforeGeometry: change.beforeGeometry,
afterGeometry: change.editType === "DELETE" ? null : change.afterGeometry,
geometry: change.editType === "ADD" ? change.afterGeometry : undefined,
@@ -617,7 +689,7 @@ export const useGisEdit = (treeData: any[]) => {
};
setLastSavedPayload(payload);
setStatusMessage("백엔드 저장 API가 아직 없어 payload 미리보기만 생성했습니다. 이 구조를 기준으로 BE와 연동하면 됩니다.");
setStatusMessage("관로 기본정보와 geometry를 함께 담은 최종 저장 payload를 생성했습니다. 현재는 FE 세션 검토 단계이며, 실제 DB 반영은 백엔드 저장 API가 필요합니다.");
return payload;
}, [changes, selectedLayer]);
@@ -670,6 +742,31 @@ export const useGisEdit = (treeData: any[]) => {
setStatusMessage(summary);
}, []);
const updateChangeBasicInfo = useCallback((
changeId: string,
basicInfo: GisEditPipeBasicInfo,
) => {
setLastSavedPayload(null);
setChanges((previous) =>
previous.map((change) =>
change.changeId === changeId
? {
...change,
featureRef: {
...change.featureRef,
fcltMngNo: basicInfo.fcltMngNo || change.featureRef?.fcltMngNo,
mngInstCd: basicInfo.mngInstCd || change.featureRef?.mngInstCd,
},
basicInfo,
basicInfoSavedAt: new Date().toISOString(),
}
: change,
),
);
setFocusedChangeId(changeId);
setStatusMessage("관로 기본정보를 편집 세션에 저장했습니다. 최종 GIS 저장 전까지는 DB에 반영되지 않습니다.");
}, []);
const clearPendingInteraction = useCallback(() => {
setPendingInteraction(null);
}, []);
@@ -692,7 +789,7 @@ export const useGisEdit = (treeData: any[]) => {
pendingInteraction,
focusedChangeId,
pendingApiMessage:
"현재 구현은 FE 로컬 편집 세션과 저장 payload 미리보기까지 포함합니다. BE 저장 API(`/api/v1/map/gisEditSessions/save`) 연결 전까지는 실제 저장 대신 payload를 검토합니다.",
"현재 구현은 FE 로컬 편집 세션, 관로 기본정보 draft, 저장 payload 미리보기까지 포함합니다. BE 저장 API(`/api/v1/map/gisEditSessions/save`) 연결 전까지는 실제 저장 대신 payload를 검토합니다.",
canUndo: changes.length > 0,
toggleEditMode,
selectLayer,
@@ -703,6 +800,7 @@ export const useGisEdit = (treeData: any[]) => {
removeLastChange,
clearChanges,
updateChangeGeometry,
updateChangeBasicInfo,
clearPendingInteraction,
focusChange,
announceStatus,
@@ -29,18 +29,6 @@ const UPLOAD_FACILITY_TYPES: UploadFacilityType[] = [
allowedExtensions: [".zip", ".shp", ".geojson", ".json", ".dxf"],
maxFileSizeMb: 200,
},
{
facilityTypeCode: "MHL",
facilityTypeName: "하수맨홀",
allowedExtensions: [".zip", ".shp", ".geojson", ".json"],
maxFileSizeMb: 100,
},
{
facilityTypeCode: "SWTRMT_FCLT",
facilityTypeName: "하수처리시설",
allowedExtensions: [".zip", ".shp", ".geojson"],
maxFileSizeMb: 150,
},
];
const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [
@@ -52,22 +40,6 @@ const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [
registeredAt: "2026-05-04",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260430-0002",
facilityTypeCode: "MHL",
facilityTypeName: "하수맨홀",
fileName: "asan_manhole_20260430.zip",
registeredAt: "2026-04-30",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260421-0003",
facilityTypeCode: "SWTRMT_FCLT",
facilityTypeName: "하수처리시설",
fileName: "00000",
registeredAt: "",
registeredBy: "",
},
{
uploadId: "GUP-MOCK-20260418-0004",
facilityTypeCode: "PPL",
@@ -76,22 +48,6 @@ const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [
registeredAt: "2026-04-18",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260415-0005",
facilityTypeCode: "MHL",
facilityTypeName: "하수맨홀",
fileName: "asan_manhole_20260415.zip",
registeredAt: "2026-04-15",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260412-0006",
facilityTypeCode: "SWTRMT_FCLT",
facilityTypeName: "하수처리시설",
fileName: "asan_swtrmt_20260412.zip",
registeredAt: "2026-04-12",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260409-0007",
facilityTypeCode: "PPL",
@@ -100,22 +56,6 @@ const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [
registeredAt: "2026-04-09",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260406-0008",
facilityTypeCode: "MHL",
facilityTypeName: "하수맨홀",
fileName: "asan_manhole_20260406.zip",
registeredAt: "2026-04-06",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260403-0009",
facilityTypeCode: "SWTRMT_FCLT",
facilityTypeName: "하수처리시설",
fileName: "asan_treatment_20260403.zip",
registeredAt: "2026-04-03",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260331-0010",
facilityTypeCode: "PPL",
@@ -124,22 +64,6 @@ const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [
registeredAt: "2026-03-31",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260328-0011",
facilityTypeCode: "MHL",
facilityTypeName: "하수맨홀",
fileName: "asan_manhole_20260328.zip",
registeredAt: "2026-03-28",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260325-0012",
facilityTypeCode: "SWTRMT_FCLT",
facilityTypeName: "하수처리시설",
fileName: "asan_swtrmt_20260325.zip",
registeredAt: "2026-03-25",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260322-0013",
facilityTypeCode: "PPL",
@@ -148,22 +72,6 @@ const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [
registeredAt: "2026-03-22",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260319-0014",
facilityTypeCode: "MHL",
facilityTypeName: "하수맨홀",
fileName: "asan_manhole_20260319.zip",
registeredAt: "2026-03-19",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260316-0015",
facilityTypeCode: "SWTRMT_FCLT",
facilityTypeName: "하수처리시설",
fileName: "asan_treatment_20260316.zip",
registeredAt: "2026-03-16",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260313-0016",
facilityTypeCode: "PPL",
@@ -172,22 +80,6 @@ const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [
registeredAt: "2026-03-13",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260310-0017",
facilityTypeCode: "MHL",
facilityTypeName: "하수맨홀",
fileName: "asan_manhole_20260310.zip",
registeredAt: "2026-03-10",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260307-0018",
facilityTypeCode: "SWTRMT_FCLT",
facilityTypeName: "하수처리시설",
fileName: "asan_swtrmt_20260307.zip",
registeredAt: "2026-03-07",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260304-0019",
facilityTypeCode: "PPL",
@@ -196,22 +88,6 @@ const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [
registeredAt: "2026-03-04",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260301-0020",
facilityTypeCode: "MHL",
facilityTypeName: "하수맨홀",
fileName: "asan_manhole_20260301.zip",
registeredAt: "2026-03-01",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260226-0021",
facilityTypeCode: "SWTRMT_FCLT",
facilityTypeName: "하수처리시설",
fileName: "asan_treatment_20260226.zip",
registeredAt: "2026-02-26",
registeredBy: "admin",
},
];
const getFileExtension = (fileName: string) => {
@@ -160,4 +160,4 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
)
}
export default MsSwagePpl
export default MsSwagePpl
+223 -13
View File
@@ -52,8 +52,9 @@ import useStore from "@/store/useStore";
import AgedPipeLayerSettingPanel from "./components/agedPipe/AgedPipeLayerSettingPanel";
import GisEditToolbar from "./components/gisEdit/GisEditToolbar";
import GisEditPipeSessionPopup from "./components/gisEdit/GisEditPipeSessionPopup";
import { useGisEdit } from "./components/gisEdit/useGisEdit";
import type { GisEditSessionSnapshot } from "./components/gisEdit/gisEditTypes";
import type { GisEditPipeBasicInfo, GisEditSessionSnapshot } from "./components/gisEdit/gisEditTypes";
import {
PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE,
type PipeAnalysisDetailRow,
@@ -626,6 +627,19 @@ const getSquaredPixelDistance = (
return (endX - startX) ** 2 + (endY - startY) ** 2;
};
const GIS_EDIT_VERTEX_CLUSTER_PIXEL_THRESHOLD = 12;
const getAverageCoordinate = (coordinates: number[][]) => {
if (coordinates.length === 0) return [0, 0];
const [sumX, sumY] = coordinates.reduce<[number, number]>(
([accX, accY], [x = 0, y = 0]) => [accX + Number(x), accY + Number(y)],
[0, 0],
);
return [sumX / coordinates.length, sumY / coordinates.length];
};
const getPixelDistanceToSegment = (
map: any,
coordinate: number[],
@@ -752,23 +766,40 @@ const collectVertexHandleCandidates = (geometry: any) => {
return [];
};
const buildVertexHandleFeatures = (geometry: any): Feature[] => {
const groupedCandidates = new globalThis.Map<string, { coordinate: number[]; metaList: VertexHandleMeta[] }>();
const buildVertexHandleFeatures = (geometry: any, map?: any): Feature[] => {
const groupedCandidates: Array<{
coordinate: number[];
metaList: VertexHandleMeta[];
sourceCoordinates: number[][];
}> = [];
collectVertexHandleCandidates(geometry).forEach((candidate: VertexHandleCandidate) => {
const key = buildVertexCoordinateKey(candidate.coordinate);
const existing = groupedCandidates.get(key);
const existing = groupedCandidates.find((group) => {
if (!map) {
return buildVertexCoordinateKey(group.coordinate) === buildVertexCoordinateKey(candidate.coordinate);
}
return (
getSquaredPixelDistance(map, group.coordinate, candidate.coordinate) <=
GIS_EDIT_VERTEX_CLUSTER_PIXEL_THRESHOLD ** 2
);
});
if (existing) {
existing.metaList.push(candidate.meta);
existing.sourceCoordinates.push(candidate.coordinate);
existing.coordinate = getAverageCoordinate(existing.sourceCoordinates);
return;
}
groupedCandidates.set(key, {
groupedCandidates.push({
coordinate: candidate.coordinate,
metaList: [candidate.meta],
sourceCoordinates: [candidate.coordinate],
});
});
return Array.from(groupedCandidates.values()).map(({ coordinate, metaList }: { coordinate: number[]; metaList: VertexHandleMeta[] }) =>
return groupedCandidates.map(({ coordinate, metaList }) =>
createVertexHandleFeature(coordinate, metaList[0]!, metaList),
);
};
@@ -861,7 +892,7 @@ const getNearestVertexPixelDistance = (
geometry: any,
coordinate: number[],
): number => {
return buildVertexHandleFeatures(geometry).reduce((minimumDistance: number, feature: Feature) => {
return buildVertexHandleFeatures(geometry, map).reduce((minimumDistance: number, feature: Feature) => {
const vertexCoordinate = (feature.getGeometry?.() as any)?.getCoordinates?.();
if (!Array.isArray(vertexCoordinate)) return minimumDistance;
return Math.min(
@@ -1400,6 +1431,10 @@ const Map = () => {
const agedPipePopupWindowRef = useRef<Window | null>(null);
const pipeAnalysisPopupWindowRef = useRef<Window | null>(null);
const gisEditSessionPopupWindowRef = useRef<Window | null>(null);
const [gisEditDetailChangeId, setGisEditDetailChangeId] = useState<string | null>(null);
const gisEditDetailChangeRef = useRef<any>(null);
const gisEditDetailFeatureDataRef = useRef<any>(null);
const gisEditIsEditModeRef = useRef(false);
const waterRiskPopupWindowRef = useRef<Window | null>(null);
const waterRiskPopupCloseWatcherRef = useRef<number | null>(null);
// 유하방향 레이어 체크 여부를 React 렌더링 흐름에 반영하기 위한 state이다.
@@ -1495,6 +1530,44 @@ const Map = () => {
gisEdit.lastSavedPayload,
gisEdit.selectedLayer,
]);
const gisEditDetailChange = useMemo(
() => gisEdit.changes.find((change) => change.changeId === gisEditDetailChangeId) ?? null,
[gisEdit.changes, gisEditDetailChangeId],
);
const gisEditDetailFeatureData = useMemo(() => {
if (!gisEditDetailChange) return null;
if (featureData) return featureData;
if (gisEditDetailChange.layerId !== "tb_ms_swage_ppl") return null;
return {
lyrTypeNm: "tb_ms_swage_ppl",
fcltMngNo: gisEditDetailChange.featureRef?.fcltMngNo ?? gisEditDetailChange.objectId ?? "",
mngInstCd: gisEditDetailChange.featureRef?.mngInstCd ?? "",
fclt_mng_no: gisEditDetailChange.featureRef?.fcltMngNo ?? gisEditDetailChange.objectId ?? "",
mng_inst_cd: gisEditDetailChange.featureRef?.mngInstCd ?? "",
inst_mng_no: "",
instl_ymd: "",
mapsht_no: "",
pdmt_vl: "",
prlg_vl: "",
usg_cd: "",
mqlt_cd: "",
avg_grdn: "",
drtw_mltv_area: "",
excln_mltv_area: "",
tmpt_dpth: "",
tmpt_mhl_mng_no: "",
scend_dpth: "",
scend_mhl_mng_no: "",
trfln_cnt: "",
vrtc_len: "",
wdth_len: "",
};
}, [featureData, gisEditDetailChange]);
gisEditDetailChangeRef.current = gisEditDetailChange;
gisEditDetailFeatureDataRef.current = gisEditDetailFeatureData;
gisEditIsEditModeRef.current = gisEdit.isEditMode;
const waterRiskSessionSnapshot = useMemo<WaterRiskSessionSnapshot>(
() => waterRiskSession,
[waterRiskSession],
@@ -1515,6 +1588,14 @@ const Map = () => {
);
}, [gisEditSessionSnapshot]);
const openGisEditPipeDetail = useCallback((changeId: string | null) => {
if (!changeId) {
setGisEditDetailChangeId(null);
return;
}
setGisEditDetailChangeId(changeId);
}, []);
const syncWaterRiskPopup = useCallback(() => {
const popup = waterRiskPopupWindowRef.current;
if (!popup || popup.closed) {
@@ -1652,6 +1733,20 @@ const Map = () => {
};
}, []);
useEffect(() => {
if (!gisEdit.isEditMode) {
setGisEditDetailChangeId(null);
return;
}
if (!gisEditDetailChangeId) return;
if (!gisEditDetailChange) return;
if (gisEditDetailChange.layerId !== "tb_ms_swage_ppl" || gisEditDetailChange.editType === "DELETE") {
setGisEditDetailChangeId(null);
}
}, [gisEdit.isEditMode, gisEditDetailChange, gisEditDetailChangeId]);
useEffect(() => {
if (!(gisEdit.isEditMode || gisEdit.changes.length > 0)) return;
syncGisEditSessionPopup();
@@ -1671,6 +1766,7 @@ const Map = () => {
const changeId = String(data.payload?.changeId ?? "").trim();
if (changeId) {
gisEdit.focusChange(changeId);
openGisEditPipeDetail(changeId);
}
return;
}
@@ -1689,7 +1785,7 @@ const Map = () => {
return () => {
window.removeEventListener("message", handleGisEditSessionPopupMessage);
};
}, [gisEdit, syncGisEditSessionPopup]);
}, [gisEdit, openGisEditPipeDetail, syncGisEditSessionPopup]);
const fetchData = async () => {
try {
@@ -2335,9 +2431,44 @@ const Map = () => {
// SearchModal, GisContent 등에서 호출
const onFeatureSelect = (data: any) => {
const clickedLayerTypeName = getSelectedFeatureLayerTypeName(data);
if (
gisEdit.isEditMode &&
(gisEdit.activeTool === "MOVE" || gisEdit.activeTool === "VERTEX_EDIT") &&
gisEdit.selectedLayer?.layerId === "tb_ms_swage_ppl" &&
clickedLayerTypeName !== "tb_ms_swage_ppl"
) {
gisEdit.announceStatus("하수관로를 선택해주세요.");
setFeatureData(null);
setIsOpen(false);
setIsSearchModalOpen(false);
return;
}
// 편집 모드 + activeTool 조합일 때는 상세 팝업 대신 편집 세션 누적을 우선한다.
const editSelection = gisEdit.registerFeatureSelection(data);
if (editSelection.handled) {
const shouldOpenPipeDetail =
clickedLayerTypeName === "tb_ms_swage_ppl" &&
typeof editSelection.changeId === "string" &&
editSelection.changeId &&
editSelection.isPreviewSelection !== true;
if (typeof editSelection.changeId === "string" && editSelection.changeId) {
openGisEditPipeDetail(editSelection.changeId);
}
if (shouldOpenPipeDetail) {
const select = GisApp.Module.select as any;
select?.resetSelectedFeature?.();
setFeatureData(data);
window.setTimeout(() => {
setGisEditDetailChangeId(String(editSelection.changeId));
setIsOpen(true);
}, 0);
setIsSearchModalOpen(false);
return;
}
// 편집 대상으로 넘긴 뒤에는 기존 상세 팝업/원본 선택 강조를 정리해
// 실제 편집 대상인 preview feature가 시각적으로 가려지지 않게 한다.
const select = GisApp.Module.select as any;
@@ -2356,7 +2487,7 @@ const Map = () => {
setIsOpen(false);
setIsSearchModalOpen(false);
return;
}
}
const nextPipe = buildWaterRiskPipeFromFeature(data);
if (!nextPipe) {
@@ -2975,6 +3106,53 @@ const Map = () => {
gisEdit.save(gisMaps.current);
}, [gisEdit]);
const handleApplyGisEditPipeBasicInfo = useCallback((changeId: string, basicInfo: GisEditPipeBasicInfo) => {
gisEdit.updateChangeBasicInfo(changeId, basicInfo);
}, [gisEdit]);
const canSelectFeatureDuringGisEdit = useCallback((feature: any) => {
if (!gisEdit.isEditMode) return true;
if (gisEdit.activeTool !== "MOVE" && gisEdit.activeTool !== "VERTEX_EDIT") return true;
if (gisEdit.selectedLayer?.layerId !== "tb_ms_swage_ppl") return true;
const layerTypeName = getSelectedFeatureLayerTypeName(feature);
if (layerTypeName === "tb_ms_swage_ppl") return true;
gisEdit.announceStatus("하수관로를 선택해주세요.");
return false;
}, [gisEdit]);
const GisEditAwareDetailView = useCallback((props: any) => {
const currentChange = gisEditDetailChangeRef.current;
const currentFeatureData = gisEditDetailFeatureDataRef.current;
const isEditMode = gisEditIsEditModeRef.current;
if (isEditMode && currentChange && currentChange.layerId === "tb_ms_swage_ppl" && currentChange.editType !== "DELETE") {
return (
<GisEditPipeSessionPopup
change={currentChange}
featureData={currentFeatureData ?? props.featureData}
onClose={() => {
setGisEditDetailChangeId(null);
props.onClose?.();
}}
onSave={handleApplyGisEditPipeBasicInfo}
/>
);
}
return (
<FeatureClickEventPopup
{...props}
featureData={props.featureData}
onClose={() => {
setGisEditDetailChangeId(null);
props.onClose?.();
}}
/>
);
}, [handleApplyGisEditPipeBasicInfo]);
useEffect(() => {
if (!mapReady || !gisMaps.current || !gisEdit.isEditMode) return;
@@ -3011,7 +3189,37 @@ const Map = () => {
featureProjection: GisApp.TO_PROJ,
dataProjection: FLOW_DIRECTION_SRS,
}) as Record<string, unknown>;
gisEdit.registerAddGeometry(geometryObject);
const changeId = gisEdit.registerAddGeometry(geometryObject);
if (changeId && gisEdit.selectedLayer?.layerId === "tb_ms_swage_ppl") {
openGisEditPipeDetail(changeId);
setFeatureData({
lyrTypeNm: "tb_ms_swage_ppl",
fcltMngNo: "",
mngInstCd: "",
fclt_mng_no: "",
inst_mng_no: "",
instl_ymd: "",
mapsht_no: "",
pdmt_vl: "",
prlg_vl: "",
usg_cd: "",
mqlt_cd: "",
avg_grdn: "",
drtw_mltv_area: "",
excln_mltv_area: "",
tmpt_dpth: "",
tmpt_mhl_mng_no: "",
scend_dpth: "",
scend_mhl_mng_no: "",
trfln_cnt: "",
vrtc_len: "",
wdth_len: "",
});
window.setTimeout(() => {
setGisEditDetailChangeId(changeId);
setIsOpen(true);
}, 0);
}
} catch (error) {
console.error("GIS 편집 ADD geometry 변환 실패:", error);
gisEdit.announceStatus("신규 geometry를 추가하지 못했습니다. 다시 시도해 주세요.");
@@ -3030,6 +3238,7 @@ const Map = () => {
}, [
ensureGisEditDrawSource,
gisEdit,
openGisEditPipeDetail,
mapReady,
removeGisEditDrawInteraction,
removeGisEditInteractions,
@@ -3254,7 +3463,7 @@ const Map = () => {
const renderVertexHandles = (hoveredMidpoint: VertexHandleCandidate | null = null) => {
vertexSource.clear();
buildVertexHandleFeatures(targetFeature.getGeometry?.()).forEach((handleFeature: Feature) => {
buildVertexHandleFeatures(targetFeature.getGeometry?.(), map).forEach((handleFeature: Feature) => {
vertexSource.addFeature(handleFeature);
});
if (hoveredMidpoint) {
@@ -3568,7 +3777,8 @@ const Map = () => {
gisMaps={gisMaps}
isMiniMap={false}
searchView={SearchModal}
detailView={FeatureClickEventPopup}
detailView={GisEditAwareDetailView}
canSelectFeature={canSelectFeatureDuringGisEdit}
onFeatureSelect={onFeatureSelect}
featureData={featureData}
setFeatureData={setFeatureData}
@@ -8,7 +8,7 @@
## 2. 검증 기준 요약
- `GIS 관리` popup이 정상적으로 열린다.
- 시설구분 미선택 시 `찾기` 또는 `업로드`가 차단된다.
- 하수관로만 시설구분 대상으로 노출된다.
- 파일 선택 시 파일명이 표시된다.
- 업로드 성공 시 현황 목록이 즉시 갱신된다.
- 다운로드는 선택 행이 있을 때만 가능 상태가 된다.
@@ -24,7 +24,8 @@
### 3.2 업로드 폼
- 시설구분 미선택 상태에서 `찾기` 클릭 차단
- 하수관로 단일 시설구분이 기본 표시되는지 확인
- 하수맨홀, 하수처리시설 등 다른 시설구분이 노출되지 않는지 확인
- 파일 미선택 상태에서 `업로드` 클릭 차단
- 파일 선택 후 파일명 input 반영
@@ -79,6 +79,20 @@
- 세션 안내 문구 영역이 제거됐는지
- `추가`, `이동`, `꼭지점편집`, `삭제` 버튼에 `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 진행 중
@@ -98,6 +98,50 @@
- 세션 팝업과 메인 지도 간 메시지 동기화
- 도구 아이콘 실제 렌더링
### 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 회귀 여부
## 3. 현재 검증 상태
### 3.1 통과
@@ -9,6 +9,7 @@
- 담당 역할: 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`
@@ -46,7 +47,7 @@
## 2. 작업 목적
- 기존 지도 화면의 `GIS 관리` 기능을 실제 업로드 관리 popup으로 구체화한다.
- 사용자가 시설구분을 선택하고 GIS 파일을 찾은 뒤 업로드 이력과 다운로드 대상을 관리할 수 있는 FE 화면 흐름을 마련한다.
- 사용자가 하수관로 GIS 파일을 찾은 뒤 업로드 이력과 다운로드 대상을 관리할 수 있는 FE 화면 흐름을 마련한다.
- 실제 백엔드 연결 전에도 UI, 상태, mock 데이터, 업로드 요청 payload 구조를 검토할 수 있도록 준비한다.
## 3. 예상 수정 대상 파일
@@ -7,7 +7,7 @@
- 작성일: 2026-05-14
- 담당 역할: FE
- 관련 역할: DE, BE, QA
- 담당 범위: `apps/sn` 지도형 화면에서 GIS편집 진입, 레이어 선택, 추가/이동/꼭지점편집/삭제, 저장 payload 준비까지 구현
- 담당 범위: `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`
@@ -16,6 +16,13 @@
- `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_화면설계서_v1_1.png`
## 1-2. 최근 진행 갱신
- 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를 보강했다.
@@ -42,6 +49,9 @@
- 프론트는 저장 전까지 편집 변경분을 로컬 세션으로 관리하고, 저장 시 BE가 제공할 편집 저장 API에 맞춰 payload를 전달한다.
- 백엔드 개발자가 바로 이어받을 수 있도록 필요한 API, 화면 상태, 예외 흐름을 FE 기준으로 구체화한다.
- 실제 API 구현은 범위 밖이며, 백엔드 공유용 요청 사항은 `작업내용` 문서로 분리해 관리한다.
- 추가 운영 제약:
- `GIS편집`, `GIS관리`는 하수관로만 대상으로 제한한다.
- 다른 GIS 테이블은 타회사 소유 범위로 보고 FE에서 선택/업로드 대상으로 노출하지 않는다.
## 3. 수정 대상 파일
| 구분 | 파일 경로 | 수정 여부 | 근거 |
@@ -83,6 +93,7 @@
- `이동`은 preview drag로 위치 조정
- `꼭지점편집`은 선택 객체의 vertex marker를 노출하고 양 끝점을 포함해 이동
- 변경분이 로컬 편집 세션에 누적
- 하수관로 편집은 기존 상세정보창 구조를 재사용하고, `입력정보` 저장은 세션 저장으로 처리
- 저장 시 원본과 분리된 편집본 저장 요청
- 빈 결과 흐름:
- 변경분이 없으면 저장 비활성 또는 안내 메시지 표시
@@ -98,11 +109,13 @@
| --- | --- | --- | --- | --- | --- |
| 기존 레이어 트리 조회 | `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 로컬 상태로 보관한다.
- 현재 구현은 실제 호출 대신 payload 미리보기만 생성한다.
- 관로 상세정보창의 저장은 즉시 API 호출이 아니라 change별 세션 저장으로 해석한다.
- 현재 구현은 실제 최종 저장 호출 대신 payload 미리보기만 생성한다.
- BE 요청 사항은 `FE_APP_DOCS_ROOT/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-프론트공유API요청.md`에 별도 정리한다.
## 6. 영향 범위
@@ -60,7 +60,7 @@
- 구현 완료 범위
- `GIS 관리` popup 진입
- 시설구분 선택
- 하수관로 단일 시설구분 표시/사용
- 파일 찾기
- 확장자/크기 1차 검증
- mock 업로드 이력 누적
@@ -74,6 +74,14 @@
- 업로드 성공 후 서버 이력 재조회
- 실제 시설물 레이어 기반 시설구분 동기화
### 4.5 2026-05-21 업로드 대상 하수관로 단일화
- 운영 제약 기준으로 `GIS 관리` 업로드 대상은 하수관로만 사용한다.
- 이에 따라 `UPLOAD_FACILITY_TYPES` mock 상수에서 하수맨홀, 하수처리시설 항목을 제거했다.
- 업로드 현황 mock 목록도 하수관로 이력만 남기고 정리했다.
- FE 문서와 공통 공유 기록에서도 다른 GIS 테이블은 타회사 소유 범위라 업로드 대상에서 제외한다는 근거를 함께 남긴다.
- 현재 구조는 `/map` 진입 시 전체 레이어 트리를 읽더라도 `GIS 관리` popup 소비 단계에서는 하수관로만 노출하는 방식으로 유지한다.
## 5. BE 공유 초안
### 5.1 FE가 기대하는 메타 정보
@@ -153,6 +153,40 @@
- 사용자 요청으로 오늘 작업 내역, 설계 변경, 검증 계획, 검증 결과를 묶은 제출용 단일 `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` 가드를 연결해, 하수관로 편집 중에는 비하수관로 피처를 아예 선택 단계에서 차단하도록 확장했다.
- 차단된 경우에는 지도 줌 이동이나 상세정보 전환 없이 상태문구 `하수관로를 선택해주세요.`만 남기도록 정리했다.
## 5. 기능별 현재 상태
### 5.1 `ADD`
@@ -49,6 +49,7 @@ function Gis({
searchView: SearchView,
detailView: DetailView,
onFeatureSelect,
canSelectFeature,
setIsSearchModalOpen,
onCheckedLayersChange,
searchBar: SearchBar,
@@ -354,7 +355,7 @@ function Gis({
<GisZoomLevel zoomGuide={zoomGuide} showZoomScaleInfo={showZoomScaleInfo} />
)}
<GisMapToggle />
<GisSelect onFeatureSelect={handleFeatureSelect} />
<GisSelect onFeatureSelect={handleFeatureSelect} canSelectFeature={canSelectFeature} />
</div>
</div>
@@ -3,14 +3,19 @@ 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; // 아직 초기화 안 됐으면 아무것도 안 함
@@ -20,6 +25,11 @@ export default React.memo(function GisSelect({ onFeatureSelect }) {
// 피처 선택 시 콜백 등록
select.getSelectedInfo(function (features) {
if (features?.[0]) {
const isSelectable = canSelectFeatureRef.current?.(features[0]);
if (isSelectable === false) {
select.resetSelectedFeature?.();
return;
}
onFeatureSelectRef.current?.(features[0]);
}
});