Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user