diff --git a/apps/sn/app/(map-layout)/map/components/common/table.ts b/apps/sn/app/(map-layout)/map/components/common/table.ts index 81e14a83f..6818b45e4 100644 --- a/apps/sn/app/(map-layout)/map/components/common/table.ts +++ b/apps/sn/app/(map-layout)/map/components/common/table.ts @@ -1,7 +1,9 @@ // 하수관로 export const MsSwagePplCol = [ { id: 'fclt_mng_no', title: '시설관리번호' }, + { id: 'mngInstNm', title: '관리기관' }, { id: 'inst_mng_no', title: '기관관리번호' }, + { id: 'flowDirectionBasis', title: '유하방향 근거' }, { id: 'instl_ymd', title: '설치일자' }, { id: 'elapsedYears', title: '경과년도' }, { id: 'mapsht_no', title: '도엽번호' }, @@ -33,6 +35,7 @@ export const MsSwagePplCol2 = [ { id: 'rowNumber', title: '일련\n번호' }, { id: 'fcltMngNo', title: '시설\n관리번호' }, { id: 'instMngNo', title: '기관\n관리번호' }, + { id: 'flowDirectionBasis', title: '유하방향\n근거' }, { id: 'ctpvNm', title: '시도' }, { id: 'sggNm', title: '시군구' }, { id: "swtrmt_zone_nm", title: "처리구역" }, diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx index 83bd8564f..01e462338 100644 --- a/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx @@ -6,10 +6,9 @@ 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"; +import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from "../common/table"; type Props = { change: GisEditChange; @@ -18,83 +17,146 @@ type Props = { onSave: (changeId: string, draft: GisEditPipeBasicInfo) => void; }; +type MngInstOption = { + mngInstCd: string; + lwrInstNm: string; +}; + +const EMPTY_SELECT_OPTION = { value: "", label: "선택" }; + +const PIPE_BASIC_INFO_FIELDS = [ + { id: "mngInstCd", sourceId: "mng_inst_cd", title: "관리기관", type: "select", required: true }, + { id: "fcltMngNo", sourceId: "fclt_mng_no", title: "시설관리번호", type: "input", required: true }, + { id: "instMngNo", sourceId: "inst_mng_no", title: "기관관리번호", type: "input", required: true }, + { id: "instlYmd", sourceId: "instl_ymd", title: "설치일자", type: "date" }, + { id: "mapshtNo", sourceId: "mapsht_no", title: "도엽번호", type: "input" }, + { id: "pdmtVl", sourceId: "pdmt_vl", title: "관경(mm)", type: "input" }, + { id: "prlgVl", sourceId: "prlg_vl", title: "연장(m)", type: "input" }, + { + id: "usgCd", + sourceId: "usg_cd", + title: "용도", + type: "select", + required: true, + options: [ + EMPTY_SELECT_OPTION, + ...MS_SWAGE_PPL_USE_CODES.map((item) => ({ value: item.val.trim(), label: item.text })), + ], + }, + { + id: "mqltCd", + sourceId: "mqlt_cd", + title: "재질", + type: "select", + options: [ + EMPTY_SELECT_OPTION, + ...MS_SWAGE_PPL_CODES.map((item) => ({ value: item.val.trim(), label: item.text })), + ], + }, + { id: "avgGrdn", sourceId: "avg_grdn", title: "평균구배(m/m)", type: "input" }, + { id: "drtwMltvArea", sourceId: "drtw_mltv_area", title: "오수배수면적(m²)", type: "input" }, + { id: "exclnMltvArea", sourceId: "excln_mltv_area", title: "우수배수면적(m²)", type: "input" }, + { id: "tmptDpth", sourceId: "tmpt_dpth", title: "시점깊이(m)", type: "input" }, + { id: "tmptMhlMngNo", sourceId: "tmpt_mhl_mng_no", title: "시점맨홀관리번호", type: "input" }, + { id: "scendDpth", sourceId: "scend_dpth", title: "종점깊이(m)", type: "input" }, + { id: "scendMhlMngNo", sourceId: "scend_mhl_mng_no", title: "종점맨홀관리번호", type: "input" }, + { id: "trflnCnt", sourceId: "trfln_cnt", title: "차선수", type: "input" }, + { id: "vrtcLen", sourceId: "vrtc_len", title: "세로길이(m)", type: "input" }, + { id: "wdthLen", sourceId: "wdth_len", title: "가로길이(m)", type: "input" }, +]; + const PIPE_INPUT_INFO_FIELDS = [ - { id: "instlMbdyCd", title: "설치주체", type: "select", options: ["", "BTL", "재정사업"] }, + { id: "instlMbdyCd", title: "설치주체", type: "select", options: [EMPTY_SELECT_OPTION, "BTL", "재정사업"] }, { id: "swtrmtZoneNm", title: "처리구역", type: "input" }, { id: "mltvZoneNm", title: "배수구역", type: "input" }, { id: "mltvDtdvNm", title: "배수분구", type: "input" }, - { id: "lineCd", title: "지선/간선", type: "select", options: ["", "지선", "간선"] }, - { id: "floCd", title: "흐름방식", type: "select", options: ["", "자연유하", "압송"] }, - { id: "layCd", title: "매설위치", type: "select", options: ["", "도로", "하상"] }, + { id: "lineCd", title: "지선/간선", type: "select", options: [EMPTY_SELECT_OPTION, "지선", "간선"] }, + { id: "floCd", title: "흐름방식", type: "select", options: [EMPTY_SELECT_OPTION, "자연유하", "압송"] }, + { id: "layCd", title: "매설위치", type: "select", options: [EMPTY_SELECT_OPTION, "도로", "하상"] }, ]; -const PIPE_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)], - }; - } +const PIPE_VISIBLE_BASIC_FIELD_IDS = PIPE_BASIC_INFO_FIELDS.map((field) => field.id); +const PIPE_VISIBLE_INPUT_FIELD_IDS = PIPE_INPUT_INFO_FIELDS.map((field) => field.id); - 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 toEditableText = (value: unknown) => { + const text = String(value ?? "").trim(); + return text === "-" ? "" : text; }; -const buildInitialInputDraft = (change: GisEditChange, baseValues: Record) => ({ - 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 ?? ""), +const toDateInputValue = (value: unknown) => { + const text = toEditableText(value).replaceAll(".", "").replaceAll("-", ""); + if (!/^\d{8}$/.test(text)) return ""; + return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`; +}; + +const toDbYmdValue = (value: unknown) => toEditableText(value).replaceAll("-", ""); + +const getObjectValue = (values: Record, field: { id: string; sourceId?: string }) => + values[field.id] ?? (field.sourceId ? values[field.sourceId] : undefined); + +const pickFields = (values: Record, fieldIds: string[]) => + fieldIds.reduce>((acc, fieldId) => { + if (Object.prototype.hasOwnProperty.call(values, fieldId)) { + acc[fieldId] = values[fieldId]; + } + return acc; + }, {}); + +const normalizeBasicFormValues = (values: Record) => { + const nextValues = pickFields(values, PIPE_VISIBLE_BASIC_FIELD_IDS); + if (Object.prototype.hasOwnProperty.call(nextValues, "instlYmd")) { + nextValues.instlYmd = toDateInputValue(nextValues.instlYmd); + } + return nextValues; +}; + +const buildBaseFeatureValues = (featureData: any) => { + const resolveUsageCode = (value: unknown) => { + const raw = String(value ?? "").trim(); + const matched = MS_SWAGE_PPL_USE_CODES.find((item) => item.val === raw || item.text === raw); + return matched?.val.trim() ?? raw; + }; + + const resolveMaterialCode = (value: unknown) => { + const raw = String(value ?? "").trim(); + const matched = MS_SWAGE_PPL_CODES.find((item) => item.val === raw || item.text === raw); + return matched?.val.trim() ?? raw; + }; + + const sourceValues = featureData?.values_ ?? featureData ?? {}; + return PIPE_BASIC_INFO_FIELDS.reduce>((acc, field) => { + const rawValue = getObjectValue(sourceValues, field); + if (field.id === "usgCd") { + acc[field.id] = resolveUsageCode(rawValue); + return acc; + } + if (field.id === "mqltCd") { + acc[field.id] = resolveMaterialCode(rawValue); + return acc; + } + if (field.id === "instlYmd") { + acc[field.id] = toDateInputValue(rawValue); + return acc; + } + acc[field.id] = toEditableText(rawValue); + return acc; + }, {}); +}; + +const getPipeUsageLabel = (value: unknown) => { + const raw = String(value ?? "").trim(); + if (!raw) return ""; + return MS_SWAGE_PPL_USE_CODES.find((item) => item.val.trim() === raw || item.text === raw)?.text ?? raw; +}; + +const buildInitialInputDraft = (change: GisEditChange) => ({ + instlMbdyCd: toEditableText(change.basicInfo?.instlMbdyCd ?? ""), + swtrmtZoneNm: toEditableText(change.basicInfo?.swtrmtZoneNm ?? ""), + mltvZoneNm: toEditableText(change.basicInfo?.mltvZoneNm ?? ""), + mltvDtdvNm: toEditableText(change.basicInfo?.mltvDtdvNm ?? ""), + lineCd: toEditableText(change.basicInfo?.lineCd ?? ""), + floCd: toEditableText(change.basicInfo?.floCd ?? ""), + layCd: toEditableText(change.basicInfo?.layCd ?? ""), }); export default function GisEditPipeSessionPopup({ @@ -107,22 +169,67 @@ export default function GisEditPipeSessionPopup({ const baseFeatureValues = useMemo(() => buildBaseFeatureValues(featureData), [featureData]); const [basicInfoTableData, setBasicInfoTableData] = useState>({}); const [inputInfoTableData, setInputInfoTableData] = useState>( - buildInitialInputDraft(change, baseFeatureValues), + buildInitialInputDraft(change), + ); + const [mngInstOptions, setMngInstOptions] = useState([]); + + useEffect(() => { + let isMounted = true; + + const fetchMngInstOptions = async () => { + try { + const result = await getData("v1/fcltppl/mngInsts"); + if (!isMounted) return; + setMngInstOptions(Array.isArray(result) ? result : []); + } catch (error) { + console.error("관리기관 목록 조회 실패:", error); + if (isMounted) setMngInstOptions([]); + } + }; + + fetchMngInstOptions(); + return () => { + isMounted = false; + }; + }, []); + + const mngInstSelectOptions = useMemo( + () => { + const sortedOptions = [...mngInstOptions].sort((a, b) => + String(a.lwrInstNm ?? a.mngInstCd ?? "").localeCompare( + String(b.lwrInstNm ?? b.mngInstCd ?? ""), + "ko", + ), + ); + return [ + EMPTY_SELECT_OPTION, + ...sortedOptions.map((option) => ({ + value: String(option.mngInstCd ?? "").trim(), + label: option.lwrInstNm || String(option.mngInstCd ?? "").trim(), + })), + ]; + }, + [mngInstOptions], + ); + + const basicInfoFields = useMemo( + () => + PIPE_BASIC_INFO_FIELDS.map((field) => + field.id === "mngInstCd" + ? { ...field, options: mngInstSelectOptions } + : field, + ), + [mngInstSelectOptions], ); useEffect(() => { const initialBasicValues: Record = { ...baseFeatureValues, - ...(change.basicInfo ?? {}), + ...normalizeBasicFormValues(change.basicInfo ?? {}), }; - if (isNewPipe) { - delete initialBasicValues.instl_ymd; - delete initialBasicValues.elapsedYears; - } - setBasicInfoTableData(initialBasicValues); - setInputInfoTableData(buildInitialInputDraft(change, baseFeatureValues)); + setInputInfoTableData(buildInitialInputDraft(change)); }, [baseFeatureValues, change.changeId, isNewPipe]); useEffect(() => { @@ -140,10 +247,8 @@ export default function GisEditPipeSessionPopup({ setInputInfoTableData((previous) => ({ ...previous, - ...result, - ...(change.basicInfo ?? {}), - fcltMngNo, - mngInstCd, + ...pickFields(result, PIPE_VISIBLE_INPUT_FIELD_IDS), + ...pickFields(change.basicInfo ?? {}, PIPE_VISIBLE_INPUT_FIELD_IDS), })); } catch (error) { console.error("GIS 편집 관로 입력정보 조회 실패:", error); @@ -156,20 +261,20 @@ export default function GisEditPipeSessionPopup({ }; }, [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, nextInputInfoTableData: Record, - ): GisEditPipeBasicInfo => ({ - ...nextBasicInfoTableData, - ...nextInputInfoTableData, - fcltMngNo: String(nextInputInfoTableData.fcltMngNo ?? change.featureRef?.fcltMngNo ?? change.objectId ?? ""), - mngInstCd: String(nextInputInfoTableData.mngInstCd ?? change.featureRef?.mngInstCd ?? ""), - }); + ): GisEditPipeBasicInfo => { + const basicDraft = pickFields(nextBasicInfoTableData, PIPE_VISIBLE_BASIC_FIELD_IDS); + const inputDraft = pickFields(nextInputInfoTableData, PIPE_VISIBLE_INPUT_FIELD_IDS); + if (basicDraft.instlYmd) { + basicDraft.instlYmd = toDbYmdValue(basicDraft.instlYmd); + } + return { + ...basicDraft, + ...inputDraft, + }; + }; const handleBasicInfoChange = (nextFormData: Record) => { setBasicInfoTableData(nextFormData); @@ -181,14 +286,18 @@ export default function GisEditPipeSessionPopup({ onSave(change.changeId, buildMergedDraft(basicInfoTableData, nextFormData)); }; + const basicInfoTableRows = useMemo(() => [basicInfoTableData], [basicInfoTableData]); + const inputInfoTableRows = useMemo(() => [inputInfoTableData], [inputInfoTableData]); + const displayManagementNo = toEditableText(change.featureRef?.fcltMngNo ?? change.objectId ?? ""); + return ( + {isSaving ? "저장 중" : "저장"} + -
- {statusMessage} + +
- {activeTool === "MOVE" ? ( -
- 이동 대상 선택 후 파란 미리보기 객체를 드래그해 위치를 조정하세요. -
- ) : null} - {activeTool === "VERTEX_EDIT" ? ( -
- 편집 대상 선택 후 주황색 꼭지점 handle을 드래그해 형상을 조정하세요. -
- ) : null} ); } diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts b/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts index dd2bfd3ed..4c627942c 100644 --- a/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts @@ -15,16 +15,33 @@ export type GisEditFeatureRef = { }; 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; + [key: string]: string | null | undefined; + fcltMngNo?: string | null; + mngInstCd?: string | null; + instMngNo?: string | null; + instlYmd?: string | null; + mapshtNo?: string | null; + pdmtVl?: string | null; + prlgVl?: string | null; + usgCd?: string | null; + mqltCd?: string | null; + avgGrdn?: string | null; + drtwMltvArea?: string | null; + exclnMltvArea?: string | null; + tmptDpth?: string | null; + tmptMhlMngNo?: string | null; + scendDpth?: string | null; + scendMhlMngNo?: string | null; + trflnCnt?: string | null; + vrtcLen?: string | null; + wdthLen?: string | null; + instlMbdyCd?: string | null; + swtrmtZoneNm?: string | null; + mltvZoneNm?: string | null; + mltvDtdvNm?: string | null; + lineCd?: string | null; + floCd?: string | null; + layCd?: string | null; }; export type GisEditLayerInfo = { @@ -43,12 +60,16 @@ export type GisEditChange = { layerId: string; layerName: string; objectId?: string; + fid?: string; + baseEditSn?: string; featureRef?: GisEditFeatureRef; objectLabel: string; beforeGeometry?: Record | null; afterGeometry?: Record | null; + featureProperties?: Record | null; basicInfo?: GisEditPipeBasicInfo | null; basicInfoSavedAt?: string | null; + attributeOnly?: boolean; summary: string; createdAt: string; source: "MAP_SELECTION" | "MAP_CENTER" | "MAP_DRAW"; @@ -58,7 +79,7 @@ export type GisEditSavePayload = { screenId: string; layerId: string; layerName: string; - saveTargetType: "VERSION"; + saveTargetType: "VERSION" | "FINAL"; view: { srs: string; bbox: string | null; @@ -68,8 +89,10 @@ export type GisEditSavePayload = { editType: GisEditTool; objectId?: string; clientTempId?: string; + fid?: string; + baseEditSn?: string; featureRef?: GisEditFeatureRef; - basicInfo?: GisEditPipeBasicInfo | null; + attributes?: GisEditPipeBasicInfo | null; beforeGeometry?: Record | null; afterGeometry?: Record | null; geometry?: Record | null; diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts b/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts index 75de19699..47e91c0b9 100644 --- a/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts @@ -19,6 +19,12 @@ type GisEditInteractionRequest = { editType: "MOVE" | "VERTEX_EDIT"; }; +const isActionableChange = (change: GisEditChange) => + !change.attributeOnly || Boolean(change.basicInfoSavedAt); + +const isGeometryChange = (change: GisEditChange) => + !change.attributeOnly && change.editType !== "ADD"; + const buildPreviewFocusMessage = ( change: Pick, ) => { @@ -113,10 +119,57 @@ 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"]); +const GIS_EDIT_PIPE_ATTRIBUTE_FIELD_IDS = [ + "mngInstCd", + "fcltMngNo", + "instMngNo", + "instlYmd", + "mapshtNo", + "pdmtVl", + "prlgVl", + "usgCd", + "mqltCd", + "avgGrdn", + "drtwMltvArea", + "exclnMltvArea", + "tmptDpth", + "tmptMhlMngNo", + "scendDpth", + "scendMhlMngNo", + "trflnCnt", + "vrtcLen", + "wdthLen", + "instlMbdyCd", + "swtrmtZoneNm", + "mltvZoneNm", + "mltvDtdvNm", + "lineCd", + "floCd", + "layCd", +] as const; + +const buildPipeSaveAttributes = (basicInfo: GisEditPipeBasicInfo | null | undefined) => { + if (!basicInfo) return undefined; + + return GIS_EDIT_PIPE_ATTRIBUTE_FIELD_IDS.reduce((acc, fieldId) => { + if (!Object.prototype.hasOwnProperty.call(basicInfo, fieldId)) return acc; + + const value = basicInfo[fieldId]; + const normalizedValue = fieldId === "instlYmd" ? String(value ?? "").replaceAll("-", "") : value; + acc[fieldId] = + typeof normalizedValue === "string" && normalizedValue.trim() === "" + ? null + : normalizedValue; + return acc; + }, {}); +}; export const EMPTY_GIS_EDIT_PIPE_BASIC_INFO: GisEditPipeBasicInfo = { - fcltMngNo: "", mngInstCd: "", + fcltMngNo: "", + instMngNo: "", + instlYmd: "", + usgCd: "", instlMbdyCd: "", swtrmtZoneNm: "", mltvZoneNm: "", @@ -321,15 +374,52 @@ const getFeatureLayerId = (featureData: any) => { return normalizeLayerIdCandidate(featureData?.lyrTypeNm ?? featureData?.lyrTyNm ?? ""); }; +const stripLayerPrefix = (value: unknown) => { + const raw = String(value ?? "").trim(); + if (!raw) return ""; + + const withoutNamespace = raw.includes(":") ? raw.split(":").pop() ?? raw : raw; + if (withoutNamespace.startsWith("tb_ms_swage_ppl.")) { + return withoutNamespace.slice("tb_ms_swage_ppl.".length); + } + return withoutNamespace; +}; + +const getFeatureFid = (featureData: any) => + stripLayerPrefix( + featureData?.get?.("fid") ?? + featureData?.values_?.fid ?? + featureData?.fid ?? + featureData?.getId?.() ?? + featureData?.id_ ?? + "", + ); + const getFeatureObjectId = (featureData: any) => String( - featureData?.get?.("objectId") ?? - featureData?.values_?.fclt_mng_no ?? - featureData?.values_?.swtrmt_fclt_cd ?? + getFeatureFid(featureData) || + String( + featureData?.get?.("objectId") ?? + featureData?.values_?.fclt_mng_no ?? + featureData?.values_?.swtrmt_fclt_cd ?? + featureData?.fcltMngNo ?? + featureData?.fid ?? + "", + ), + ).trim(); + +const getFeatureObjectLabel = (featureData: any) => { + const values = featureData?.values_ ?? {}; + return String( + featureData?.get?.("fclt_mng_no") ?? + values?.fclt_mng_no ?? featureData?.fcltMngNo ?? - featureData?.fid ?? + featureData?.get?.("objectId") ?? + values?.swtrmt_fclt_cd ?? + getFeatureFid(featureData) ?? "", ).trim(); +}; const getFeatureRef = (featureData: any): GisEditFeatureRef | undefined => { const mapValues = featureData?.values_ ?? {}; @@ -346,12 +436,7 @@ const getFeatureRef = (featureData: any): GisEditFeatureRef | undefined => { featureData?.mngInstCd ?? "", ).trim() || undefined, - fid: String( - featureData?.getId?.() ?? - featureData?.id_ ?? - featureData?.fid ?? - "", - ).trim() || undefined, + fid: getFeatureFid(featureData) || undefined, }; if (!featureRef.fcltMngNo && !featureRef.mngInstCd && !featureRef.fid) { @@ -361,6 +446,32 @@ const getFeatureRef = (featureData: any): GisEditFeatureRef | undefined => { return featureRef; }; +// 선택한 원본 관로의 속성 snapshot을 보관해 preview 재선택 시 상세정보가 비지 않게 한다. +const getFeatureProperties = (featureData: any): Record | null => { + const values = featureData?.values_ ?? featureData?.getProperties?.() ?? featureData; + if (!values || typeof values !== "object") return null; + + const { geometry, ...properties } = values; + return properties; +}; + +const normalizePipeGeometry = ( + layerId: string, + geometry: Record | null, +): Record | null => { + if (layerId !== "tb_ms_swage_ppl" || !geometry) return geometry; + if (geometry.type !== "LineString") return geometry; + + const coordinates = geometry.coordinates; + if (!Array.isArray(coordinates)) return geometry; + + return { + ...geometry, + type: "MultiLineString", + coordinates: [coordinates], + }; +}; + const createChangeId = (prefix: string, sequence: number) => { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return `${prefix}-${crypto.randomUUID()}`; @@ -373,15 +484,15 @@ export const useGisEdit = (treeData: any[]) => { // GIS 편집 모드 on/off는 우측 분석 메뉴의 GIS 편집 버튼과 직접 연결된다. const [isEditMode, setIsEditMode] = useState(false); // 현재 선택 레이어를 기준으로 허용 도구와 저장 payload의 layerId를 결정한다. - const [selectedLayerId, setSelectedLayerId] = useState(""); + const [selectedLayerId, setSelectedLayerId] = useState("tb_ms_swage_ppl"); // 1차 구현에서는 activeTool과 지도 선택 이벤트를 조합해 변경분을 로컬 세션에 누적한다. const [activeTool, setActiveTool] = useState(null); - // 저장 전까지의 변경분은 FE 로컬 편집 세션으로만 유지하고 BE API 연결 전 payload 검토 근거로 사용한다. + // 저장 전까지의 변경분은 FE 로컬 편집 세션으로만 유지한다. const [changes, setChanges] = useState([]); const [statusMessage, setStatusMessage] = useState( - "GIS편집 버튼을 켜면 레이어를 선택하고 편집 도구를 사용할 수 있습니다.", + "GIS편집 버튼을 켜면 하수관로 편집 도구를 사용할 수 있습니다.", ); - // 실제 저장 대신 마지막으로 조립한 payload를 세션 패널에서 미리보기로 노출한다. + // 이전 구현 호환용 상태다. 저장 버튼은 payload 미리보기를 노출하지 않고 즉시 API 호출 흐름으로 넘어간다. const [lastSavedPayload, setLastSavedPayload] = useState(null); const [pendingInteraction, setPendingInteraction] = useState(null); const [focusedChangeId, setFocusedChangeId] = useState(null); @@ -394,7 +505,11 @@ export const useGisEdit = (treeData: any[]) => { ); const canUseTools = Boolean(selectedLayer); - const canSave = changes.length > 0 && Boolean(selectedLayer); + const actionableChanges = useMemo( + () => changes.filter(isActionableChange), + [changes], + ); + const canSave = actionableChanges.length > 0 && Boolean(selectedLayer); const appendChange = useCallback((change: GisEditChange) => { setLastSavedPayload(null); @@ -407,11 +522,7 @@ export const useGisEdit = (treeData: any[]) => { if (!targetChange) return; setFocusedChangeId(changeId); - if (targetChange.editType === "MOVE" || targetChange.editType === "VERTEX_EDIT") { - setPendingInteraction({ changeId, editType: targetChange.editType }); - } else { - setPendingInteraction(null); - } + setPendingInteraction(null); setStatusMessage(buildPreviewFocusMessage(targetChange)); }, [changes]); @@ -422,12 +533,26 @@ export const useGisEdit = (treeData: any[]) => { ) => changes.find( (change) => + !change.attributeOnly && change.editType === editType && change.layerId === layerId && String(change.objectId ?? "") === objectId, ) ?? null, [changes]); + // 같은 원본 관로에 대한 삭제/이동/꼭지점편집 변경분은 하나만 유지해 도구 전환 시 세션 상태가 섞이지 않게 한다. + const findExistingGeometryChange = useCallback(( + layerId: string, + objectId: string, + ) => + changes.find( + (change) => + isGeometryChange(change) && + change.layerId === layerId && + String(change.objectId ?? "") === objectId, + ) ?? null, + [changes]); + // 편집 모드를 끄더라도 작업 중 세션을 잃지 않도록 변경분은 유지하고 도구 상태만 정리한다. const toggleEditMode = useCallback(() => { setIsEditMode((previous) => { @@ -438,7 +563,7 @@ export const useGisEdit = (treeData: any[]) => { latestSelectionRef.current = null; setStatusMessage( next - ? "편집 대상 레이어를 선택한 뒤 지도에서 편집 작업을 진행하세요." + ? "하수관로 편집 도구를 선택한 뒤 지도에서 편집 작업을 진행하세요." : "GIS 편집 모드를 종료했습니다. 편집 세션은 유지됩니다.", ); return next; @@ -510,6 +635,13 @@ export const useGisEdit = (treeData: any[]) => { ); }, [appendChange, selectedLayer]); + const deactivateTool = useCallback(() => { + setActiveTool(null); + setPendingInteraction(null); + latestSelectionRef.current = null; + setStatusMessage("편집 도구 선택을 해제했습니다. 추가, 이동, 꼭지점편집, 삭제 모드가 모두 꺼졌습니다."); + }, []); + const registerAddGeometry = useCallback((geometry: Record | null) => { if (!selectedLayer || !geometry) { setStatusMessage("추가 geometry를 확인하지 못했습니다."); @@ -519,6 +651,7 @@ export const useGisEdit = (treeData: any[]) => { changeSequenceRef.current += 1; const clientTempId = `NEW-${changeSequenceRef.current}`; const changeId = createChangeId(`ADD-${selectedLayer.layerId}`, changeSequenceRef.current); + const nextGeometry = normalizePipeGeometry(selectedLayer.layerId, geometry); appendChange({ changeId, @@ -529,12 +662,11 @@ export const useGisEdit = (treeData: any[]) => { featureRef: undefined, objectLabel: clientTempId, beforeGeometry: null, - afterGeometry: geometry, + afterGeometry: nextGeometry, basicInfo: selectedLayer.layerId === "tb_ms_swage_ppl" ? { ...EMPTY_GIS_EDIT_PIPE_BASIC_INFO, - fcltMngNo: clientTempId, } : null, basicInfoSavedAt: null, @@ -557,6 +689,87 @@ export const useGisEdit = (treeData: any[]) => { const previewChange = changes.find((change) => change.changeId === previewChangeId); if (!previewChange) return { handled: true }; + if (previewChange.editType === "ADD" && activeTool === "DELETE") { + setLastSavedPayload(null); + setChanges((previous) => previous.filter((change) => change.changeId !== previewChangeId)); + setFocusedChangeId((current) => (current === previewChangeId ? null : current)); + setPendingInteraction((current) => (current?.changeId === previewChangeId ? null : current)); + latestSelectionRef.current = null; + setStatusMessage(`${previewChange.layerName} 신규 객체를 편집 세션에서 삭제했습니다.`); + return { + handled: true, + isPreviewSelection: true, + editType: "DELETE", + }; + } + + if ( + previewChange.editType !== "ADD" && + previewChange.editType !== "DELETE" && + activeTool === "DELETE" + ) { + setLastSavedPayload(null); + setChanges((previous) => + previous.map((change) => + change.changeId === previewChangeId + ? { + ...change, + editType: "DELETE", + afterGeometry: null, + summary: `${change.layerName} ${change.objectLabel} 객체를 임시 삭제 대상으로 표시했습니다.`, + } + : change, + ), + ); + setFocusedChangeId(previewChangeId); + setPendingInteraction(null); + latestSelectionRef.current = null; + setStatusMessage(`${previewChange.layerName} ${previewChange.objectLabel} 객체를 임시 삭제 대상으로 표시했습니다.`); + return { + handled: true, + isPreviewSelection: true, + changeId: previewChangeId, + editType: "DELETE", + fid: previewChange.fid ?? previewChange.featureRef?.fid ?? previewChange.objectId, + }; + } + + if ( + previewChange.editType === "DELETE" && + (activeTool === "MOVE" || activeTool === "VERTEX_EDIT") + ) { + setFocusedChangeId(previewChangeId); + setPendingInteraction(null); + setStatusMessage("이미 삭제 예정인 관로입니다. 이동 또는 꼭지점편집이 필요하면 마지막 변경 취소 후 다시 선택하세요."); + return { + handled: true, + isPreviewSelection: true, + changeId: previewChangeId, + editType: activeTool, + fid: previewChange.fid ?? previewChange.featureRef?.fid ?? previewChange.objectId, + }; + } + + if ( + previewChange.editType !== "DELETE" && + (activeTool === "MOVE" || activeTool === "VERTEX_EDIT") + ) { + setFocusedChangeId(previewChangeId); + setPendingInteraction({ changeId: previewChangeId, editType: activeTool }); + const targetLabel = previewChange.editType === "ADD" ? "신규 객체" : previewChange.objectLabel; + setStatusMessage( + activeTool === "MOVE" + ? `${previewChange.layerName} ${targetLabel}를 드래그해 위치를 조정하세요.` + : `${previewChange.layerName} ${targetLabel}의 꼭지점을 드래그해 형상을 조정하세요.`, + ); + return { + handled: true, + isPreviewSelection: true, + changeId: previewChangeId, + editType: activeTool, + }; + } + focusChange(previewChangeId); return { handled: true, @@ -585,6 +798,47 @@ export const useGisEdit = (treeData: any[]) => { return { handled: true }; } + const existingGeometryChange = findExistingGeometryChange(selectedLayer.layerId, objectId); + if (existingGeometryChange?.editType === "DELETE" && (activeTool === "MOVE" || activeTool === "VERTEX_EDIT")) { + focusChange(existingGeometryChange.changeId); + setPendingInteraction(null); + setStatusMessage("이미 삭제 예정인 관로입니다. 이동 또는 꼭지점편집이 필요하면 마지막 변경 취소 후 다시 선택하세요."); + return { + handled: true, + isPreviewSelection: false, + changeId: existingGeometryChange.changeId, + editType: "DELETE", + fid: existingGeometryChange.fid ?? existingGeometryChange.featureRef?.fid ?? objectId, + }; + } + + if (existingGeometryChange && existingGeometryChange.editType !== "DELETE" && activeTool === "DELETE") { + setLastSavedPayload(null); + setChanges((previous) => + previous.map((change) => + change.changeId === existingGeometryChange.changeId + ? { + ...change, + editType: "DELETE", + afterGeometry: null, + summary: `${change.layerName} ${change.objectLabel} 객체를 임시 삭제 대상으로 표시했습니다.`, + } + : change, + ), + ); + setFocusedChangeId(existingGeometryChange.changeId); + setPendingInteraction(null); + latestSelectionRef.current = null; + setStatusMessage(`${existingGeometryChange.layerName} ${existingGeometryChange.objectLabel} 객체를 임시 삭제 대상으로 표시했습니다.`); + return { + handled: true, + isPreviewSelection: false, + changeId: existingGeometryChange.changeId, + editType: "DELETE", + fid: existingGeometryChange.fid ?? existingGeometryChange.featureRef?.fid ?? objectId, + }; + } + const selectionSignature = `${activeTool}:${selectedLayer.layerId}:${objectId}`; const now = Date.now(); if ( @@ -598,30 +852,36 @@ export const useGisEdit = (treeData: any[]) => { const existingChange = findExistingObjectChange(activeTool, selectedLayer.layerId, objectId); if (existingChange) { focusChange(existingChange.changeId); + if (activeTool === "MOVE" || activeTool === "VERTEX_EDIT") { + setPendingInteraction({ changeId: existingChange.changeId, editType: activeTool }); + } return { handled: true, isPreviewSelection: false, changeId: existingChange.changeId, editType: existingChange.editType, + fid: existingChange.fid ?? existingChange.featureRef?.fid ?? objectId, }; } const geometryObject = toGeometryObject(featureData); const geometryType = normalizeGeometryType(geometryObject?.type); const featureRef = getFeatureRef(featureData); + const objectLabel = getFeatureObjectLabel(featureData) || objectId; + const normalizedGeometryObject = normalizePipeGeometry(selectedLayer.layerId, geometryObject); const nextGeometry = activeTool === "MOVE" - ? geometryObject + ? normalizedGeometryObject : activeTool === "VERTEX_EDIT" - ? geometryObject + ? normalizedGeometryObject : activeTool === "DELETE" ? null - : geometryObject; + : normalizedGeometryObject; const summaryMap: Record = { - MOVE: `${selectedLayer.layerName} ${objectId} 객체를 선택했습니다. 지도에서 드래그해 위치를 조정하세요.`, - VERTEX_EDIT: `${selectedLayer.layerName} ${objectId} 객체를 선택했습니다. 주황색 실꼭지점을 드래그하거나 흰색 midpoint를 끌어 새 꼭지점을 추가하세요.`, - DELETE: `${selectedLayer.layerName} ${objectId} 객체를 임시 삭제 대상으로 표시했습니다.`, - ADD: `${selectedLayer.layerName} ${objectId} 객체를 추가했습니다.`, + MOVE: `${selectedLayer.layerName} ${objectLabel} 객체를 선택했습니다. 지도에서 드래그해 위치를 조정하세요.`, + VERTEX_EDIT: `${selectedLayer.layerName} ${objectLabel} 객체를 선택했습니다. 주황색 실꼭지점을 드래그하거나 흰색 midpoint를 끌어 새 꼭지점을 추가하세요.`, + DELETE: `${selectedLayer.layerName} ${objectLabel} 객체를 임시 삭제 대상으로 표시했습니다.`, + ADD: `${selectedLayer.layerName} ${objectLabel} 객체를 추가했습니다.`, }; changeSequenceRef.current += 1; const changeId = createChangeId(`${activeTool}-${objectId}`, changeSequenceRef.current); @@ -632,15 +892,17 @@ export const useGisEdit = (treeData: any[]) => { layerId: selectedLayer.layerId, layerName: selectedLayer.layerName, objectId, + fid: featureRef?.fid ?? objectId, featureRef, - objectLabel: objectId, - beforeGeometry: geometryObject, + objectLabel, + beforeGeometry: normalizedGeometryObject, afterGeometry: nextGeometry, + featureProperties: getFeatureProperties(featureData), basicInfo: null, basicInfoSavedAt: null, summary: activeTool === "VERTEX_EDIT" && geometryType === "Point" - ? `${selectedLayer.layerName} ${objectId}는 점형 객체여서 꼭지점편집 대신 geometry 검토 대상으로 기록했습니다.` + ? `${selectedLayer.layerName} ${objectLabel}는 점형 객체여서 꼭지점편집 대신 geometry 검토 대상으로 기록했습니다.` : summaryMap[activeTool], createdAt: new Date().toISOString(), source: "MAP_SELECTION", @@ -656,12 +918,85 @@ export const useGisEdit = (treeData: any[]) => { } else { setPendingInteraction(null); } - return { handled: true, isPreviewSelection: false, changeId, editType: activeTool }; - }, [activeTool, appendChange, findExistingObjectChange, focusChange, isEditMode, selectedLayer]); + return { handled: true, isPreviewSelection: false, changeId, editType: activeTool, fid: featureRef?.fid ?? objectId }; + }, [activeTool, appendChange, findExistingGeometryChange, findExistingObjectChange, focusChange, isEditMode, selectedLayer]); + + const registerPipeDetailSelection = useCallback((featureData: any) => { + if (!isEditMode || !selectedLayer) return { handled: false }; + if (selectedLayer.layerId !== "tb_ms_swage_ppl") return { handled: false }; + + const featureLayerId = getFeatureLayerId(featureData); + if (featureLayerId !== selectedLayer.layerId) { + setStatusMessage("하수관로를 선택해주세요."); + return { handled: true }; + } + + const objectId = getFeatureObjectId(featureData); + if (!objectId) { + setStatusMessage("선택 객체의 관리번호를 찾을 수 없어 상세정보 수정 화면을 열 수 없습니다."); + return { handled: true }; + } + + const existingChange = + changes.find( + (change) => + change.layerId === selectedLayer.layerId && + change.editType !== "DELETE" && + String(change.objectId ?? "") === objectId, + ) ?? null; + if (existingChange) { + focusChange(existingChange.changeId); + setPendingInteraction(null); + setStatusMessage(`${selectedLayer.layerName} ${objectId} 상세정보를 수정할 수 있습니다.`); + return { + handled: true, + isPreviewSelection: false, + changeId: existingChange.changeId, + editType: existingChange.editType as GisEditTool, + fid: existingChange.fid ?? existingChange.featureRef?.fid ?? objectId, + }; + } + + const geometryObject = toGeometryObject(featureData); + const normalizedGeometryObject = normalizePipeGeometry(selectedLayer.layerId, geometryObject); + const featureRef = getFeatureRef(featureData); + const objectLabel = getFeatureObjectLabel(featureData) || objectId; + changeSequenceRef.current += 1; + const changeId = createChangeId(`DETAIL-${objectId}`, changeSequenceRef.current); + + appendChange({ + changeId, + editType: "VERTEX_EDIT" as const, + layerId: selectedLayer.layerId, + layerName: selectedLayer.layerName, + objectId, + fid: featureRef?.fid ?? objectId, + featureRef, + objectLabel, + beforeGeometry: normalizedGeometryObject, + afterGeometry: normalizedGeometryObject, + featureProperties: getFeatureProperties(featureData), + basicInfo: null, + basicInfoSavedAt: null, + attributeOnly: true, + summary: `${selectedLayer.layerName} ${objectLabel} 상세정보 수정 대상으로 선택했습니다.`, + createdAt: new Date().toISOString(), + source: "MAP_SELECTION", + }); + setFocusedChangeId(changeId); + setPendingInteraction(null); + setStatusMessage(`${selectedLayer.layerName} ${objectLabel} 상세정보를 수정할 수 있습니다.`); + return { + handled: true, + isPreviewSelection: false, + changeId, + editType: "VERTEX_EDIT", + fid: featureRef?.fid ?? objectId, + }; + }, [appendChange, changes, focusChange, isEditMode, selectedLayer]); - // 저장 API가 붙기 전까지는 실제 전송 대신 최종 payload를 조립해 검증 패널에 남긴다. const save = useCallback((map: any) => { - if (!selectedLayer || changes.length === 0) { + if (!selectedLayer || actionableChanges.length === 0) { setStatusMessage("저장할 편집 변경분이 없습니다."); return null; } @@ -670,28 +1005,28 @@ export const useGisEdit = (treeData: any[]) => { screenId: SCREEN_ID, layerId: selectedLayer.layerId, layerName: selectedLayer.layerName, - saveTargetType: "VERSION", + saveTargetType: "FINAL", view: { srs: FLOW_DIRECTION_SRS, bbox: buildBbox(map), }, - changes: changes.map((change) => ({ + changes: actionableChanges.map((change) => ({ changeId: change.changeId, editType: change.editType, objectId: change.editType === "ADD" ? undefined : change.objectId, clientTempId: change.editType === "ADD" ? change.objectId : undefined, + fid: change.editType === "ADD" ? undefined : change.fid ?? change.featureRef?.fid ?? change.objectId, + baseEditSn: change.editType === "ADD" ? undefined : change.baseEditSn, featureRef: change.featureRef, - basicInfo: change.basicInfo ?? undefined, + attributes: buildPipeSaveAttributes(change.basicInfo), beforeGeometry: change.beforeGeometry, afterGeometry: change.editType === "DELETE" ? null : change.afterGeometry, geometry: change.editType === "ADD" ? change.afterGeometry : undefined, })), }; - setLastSavedPayload(payload); - setStatusMessage("관로 기본정보와 geometry를 함께 담은 최종 저장 payload를 생성했습니다. 현재는 FE 세션 검토 단계이며, 실제 DB 반영은 백엔드 저장 API가 필요합니다."); return payload; - }, [changes, selectedLayer]); + }, [actionableChanges, selectedLayer]); const removeLastChange = useCallback(() => { setLastSavedPayload(null); @@ -721,10 +1056,22 @@ export const useGisEdit = (treeData: any[]) => { setStatusMessage("편집 세션을 초기화했습니다."); }, []); + const discardSession = useCallback(() => { + setIsEditMode(false); + setLastSavedPayload(null); + setChanges([]); + setActiveTool(null); + setPendingInteraction(null); + setFocusedChangeId(null); + latestSelectionRef.current = null; + setStatusMessage("하수관로 편집을 종료하고 세션 변경분을 초기화했습니다."); + }, []); + const updateChangeGeometry = useCallback(( changeId: string, afterGeometry: Record | null, summary: string, + options?: { keepVertexEditActive?: boolean }, ) => { setLastSavedPayload(null); setChanges((previous) => @@ -739,6 +1086,14 @@ export const useGisEdit = (treeData: any[]) => { ), ); setFocusedChangeId(changeId); + if (options?.keepVertexEditActive) { + setActiveTool("VERTEX_EDIT"); + setPendingInteraction({ changeId, editType: "VERTEX_EDIT" }); + } else { + setActiveTool(null); + setPendingInteraction(null); + } + latestSelectionRef.current = null; setStatusMessage(summary); }, []); @@ -767,6 +1122,20 @@ export const useGisEdit = (treeData: any[]) => { setStatusMessage("관로 기본정보를 편집 세션에 저장했습니다. 최종 GIS 저장 전까지는 DB에 반영되지 않습니다."); }, []); + const updateChangeBaseEditSn = useCallback((changeId: string, baseEditSn: string) => { + setLastSavedPayload(null); + setChanges((previous) => + previous.map((change) => + change.changeId === changeId + ? { + ...change, + baseEditSn, + } + : change, + ), + ); + }, []); + const clearPendingInteraction = useCallback(() => { setPendingInteraction(null); }, []); @@ -789,18 +1158,22 @@ export const useGisEdit = (treeData: any[]) => { pendingInteraction, focusedChangeId, pendingApiMessage: - "현재 구현은 FE 로컬 편집 세션, 관로 기본정보 draft, 저장 payload 미리보기까지 포함합니다. BE 저장 API(`/api/v1/map/gisEditSessions/save`) 연결 전까지는 실제 저장 대신 payload를 검토합니다.", - canUndo: changes.length > 0, + "현재 구현은 FE 로컬 편집 세션, 관로 기본정보 draft, BE 저장 API(`/api/v1/map/gisEditSessions/save`) 최종본 저장까지 포함합니다.", + canUndo: actionableChanges.length > 0, toggleEditMode, selectLayer, activateTool, + deactivateTool, registerAddGeometry, registerFeatureSelection, + registerPipeDetailSelection, save, removeLastChange, clearChanges, + discardSession, updateChangeGeometry, updateChangeBasicInfo, + updateChangeBaseEditSn, clearPendingInteraction, focusChange, announceStatus, diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx index 36cbb46f6..49c0d168c 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx @@ -61,6 +61,11 @@ const linkedFacilityHighlightStyle = new Style({ zIndex: 9999, }); +type MngInstOption = { + mngInstCd: string; + lwrInstNm: string; +}; + const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData: any }) => { const { gisMap } = useGisContext() as unknown as { checkedLayerIds: React.MutableRefObject; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; }; const [layerNm, setLayerNm] = useState(""); // 레이어 이름 상태 @@ -80,6 +85,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const [chmbrYdnts, setChmbrYdnts] = useState(''); // 로딩 상태 const linkedFacilityFeatureRef = useRef(null); const linkedFacilityOriginalStyleRef = useRef(null); + const [mngInstOptions, setMngInstOptions] = useState([]); // 🎯 관로관리번호 추출 const pplMngNo = featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || ""; @@ -87,6 +93,10 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const featureId = typeof featureData.getId === 'function' ? featureData.getId() : featureData.fid; + const selectedLayerTypeName = featureData?.values_ + ? featureData?.id_?.split('.')?.[0] + : featureData?.lyrTypeNm; + const isPipeFeature = selectedLayerTypeName === 'tb_ms_swage_ppl'; // 🎯 데이터 전처리 메모이제이션 const processedData = useMemo(() => { @@ -98,6 +108,81 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, }; }, [featureData?.values_]); + useEffect(() => { + let isMounted = true; + + if (isPipeFeature) { + setMngInstOptions([]); + return () => { + isMounted = false; + }; + } + + const fetchMngInstOptions = async () => { + try { + const result = await getData("v1/fcltppl/mngInsts"); + if (!isMounted) return; + const sortedOptions = Array.isArray(result) + ? [...result].sort((a, b) => + String(a?.lwrInstNm ?? a?.mngInstCd ?? "").localeCompare( + String(b?.lwrInstNm ?? b?.mngInstCd ?? ""), + "ko", + ), + ) + : []; + setMngInstOptions(sortedOptions); + } catch (error) { + console.error("관리기관 목록 조회 실패:", error); + if (isMounted) setMngInstOptions([]); + } + }; + + fetchMngInstOptions(); + return () => { + isMounted = false; + }; + }, [isPipeFeature]); + + const mngInstNameMap = useMemo(() => { + const map = new Map(); + mngInstOptions.forEach((option) => { + const code = String(option.mngInstCd ?? "").trim(); + if (!code) return; + map.set(code, option.lwrInstNm || code); + }); + return map; + }, [mngInstOptions]); + + const withMngInstName = (data: any) => { + if (!data || typeof data !== "object") return data; + const mngInstCd = String(data.mng_inst_cd ?? data.mngInstCd ?? "").trim(); + if (!mngInstCd) return data; + return { + ...data, + mngInstNm: mngInstNameMap.get(mngInstCd) ?? data.mngInstNm ?? data.mng_inst_nm ?? mngInstCd, + }; + }; + + const hasDetailValue = (value: unknown) => { + if (value === null || value === undefined) return false; + if (typeof value === "string") return value.trim() !== ""; + return true; + }; + + const mergeDetailData = (baseData: any, detailData: any) => { + if (!detailData || typeof detailData !== "object") return baseData; + + const meaningfulDetailData = Object.entries(detailData).reduce>((acc, [key, value]) => { + if (hasDetailValue(value)) acc[key] = value; + return acc; + }, {}); + + return { + ...(baseData ?? {}), + ...meaningfulDetailData, + }; + }; + const clearLinkedFacilityHighlight = () => { if (linkedFacilityFeatureRef.current) { linkedFacilityFeatureRef.current.setStyle(linkedFacilityOriginalStyleRef.current ?? undefined); @@ -306,6 +391,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, try { const selectedLayerName = getSelectedLayers(); // 레이어 이름 가져오기 setLayerNm(selectedLayerName); // 상태 업데이트 + const isPipeDetailLayer = selectedLayerName === '하수관로'; let data; if (featureData['ol_uid']) data = featureData.values_; // Gis 레이어 클릭 이벤트일 때 @@ -334,7 +420,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, } // 🎯 기본 데이터 포맷팅 - const formattedData = formatDisplayData(data); + const formattedData = withMngInstName(formatDisplayData(data)); if (selectedLayerName === '하수관로') { const installYmd = data.instl_ymd ?? data.instlYmd; formattedData.elapsedYears = getElapsedYearsDisplayText(installYmd); @@ -344,18 +430,22 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, } } - setFetchData(formattedData); - setTableData(formattedData); + if (!isPipeDetailLayer) { + setFetchData(formattedData); + setTableData(formattedData); + } switch (selectedLayerName) { case '하수관로': setButtonChk(true) // ✅ codeMapper 사용하여 API 결과도 변환 - setTableData(codeMapper({ - layerName: selectedLayerName, - data: { ...formattedData } - })); + if (!isPipeDetailLayer) { + setTableData(withMngInstName(codeMapper({ + layerName: selectedLayerName, + data: { ...formattedData } + }))); + } break; case '하수맨홀': setButtonChk(true) @@ -422,12 +512,14 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const result = await getData(apiEndpoint); if (result) { - const processedApiData = codeMapper({ + const mergedData = isPipeDetailLayer ? result : mergeDetailData(formattedData, result); + const processedApiData = withMngInstName(codeMapper({ layerName: selectedLayerName, - data: { ...result, ...tableData } - }); + data: mergedData + })); setFetchData(processedApiData); + setTableData(processedApiData); } } catch (apiError) { console.error('API 데이터 로딩 중 오류:', apiError); @@ -439,7 +531,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, }; fetchData(); - }, [featureId, processedData]); + }, [featureId, processedData, mngInstNameMap]); useEffect(() => { const layerTypeNm = featureData?.values_ diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 93563fc20..67a3f3497 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -46,7 +46,7 @@ import GisApp from "@common/business_components/ui/map/lib/gis.core"; import { ProcSearchBar } from "@common/business_components/ui/map/searchBar"; import { Button } from "@common/components"; -import { getData, getRawJson } from "../../components/lib/api"; +import { getData, getRawJson, postRawJson } from "../../components/lib/api"; import useSearchModalStore from "@/store/useSearchModalStore"; import useStore from "@/store/useStore"; @@ -286,27 +286,33 @@ const createGisEditPreviewStyle = (feature: any) => { ? "rgba(245, 158, 11, 0.92)" : editType === "MOVE" ? layerPointColor || "rgba(37, 99, 235, 0.92)" - : layerPointColor || "rgba(22, 163, 74, 0.92)"; + : editType === "ADD" + ? "rgba(15, 23, 42, 0.96)" + : layerPointColor || "rgba(22, 163, 74, 0.92)"; const lineColor = editType === "DELETE" ? "rgba(220, 38, 38, 0.9)" : editType === "VERTEX_EDIT" ? "rgba(245, 158, 11, 0.95)" : editType === "MOVE" ? layerLineColor || "rgba(37, 99, 235, 0.95)" - : layerLineColor || "rgba(22, 163, 74, 0.95)"; + : editType === "ADD" + ? "rgba(15, 23, 42, 0.96)" + : layerLineColor || "rgba(22, 163, 74, 0.95)"; const fillColor = editType === "DELETE" ? "rgba(220, 38, 38, 0.18)" : editType === "VERTEX_EDIT" ? "rgba(245, 158, 11, 0.2)" : editType === "MOVE" ? layerFillColor || "rgba(37, 99, 235, 0.18)" - : layerFillColor || "rgba(22, 163, 74, 0.18)"; + : editType === "ADD" + ? "rgba(15, 23, 42, 0.2)" + : layerFillColor || "rgba(22, 163, 74, 0.18)"; const highlightColor = isDragging ? "rgba(14, 116, 144, 0.98)" : isInteractionTarget ? "rgba(15, 23, 42, 0.9)" : lineColor; - const emphasisWidth = isDragging ? 7 : isInteractionTarget ? 6 : 4; + const emphasisWidth = isDragging ? 7 : isInteractionTarget ? 6 : editType === "ADD" ? 6 : 4; const textStyle = previewLabel ? new Text({ text: previewLabel, @@ -365,7 +371,24 @@ const createGisEditPreviewStyle = (feature: any) => { return polygonStyles; } - const lineStyles: Style[] = [ + const lineStyles: Style[] = editType === "ADD" + ? [ + new Style({ + stroke: new Stroke({ + color: "rgba(255, 255, 255, 0.98)", + width: emphasisWidth + 4, + }), + zIndex: 10000, + }), + new Style({ + stroke: new Stroke({ + color: highlightColor, + width: emphasisWidth, + }), + zIndex: 10003, + }), + ] + : [ new Style({ stroke: new Stroke({ color: highlightColor, @@ -540,6 +563,11 @@ const createWaterRiskPipeStyle = (feature: any) => { }; const getSelectedFeatureLayerTypeName = (data: any) => { + const previewLayerId = String(data?.get?.("layerId") ?? "").trim(); + if (String(data?.get?.("changeId") ?? "").trim() && previewLayerId) { + return previewLayerId; + } + if (typeof data?.getId === "function") { return String(data.getId() ?? "").split(".")[0]; } @@ -551,6 +579,85 @@ const getSelectedFeatureLayerTypeName = (data: any) => { return String(data?.lyrTypeNm ?? data?.values_?.lyrTypeNm ?? "").trim(); }; +const normalizeGisEditFid = (value: unknown) => { + const raw = String(value ?? "").trim(); + if (!raw) return ""; + const withoutNamespace = raw.includes(":") ? raw.split(":").pop() ?? raw : raw; + return withoutNamespace.startsWith("tb_ms_swage_ppl.") + ? withoutNamespace.slice("tb_ms_swage_ppl.".length) + : withoutNamespace; +}; + +const getGisEditFeatureFid = (data: any) => + normalizeGisEditFid( + data?.get?.("fid") ?? + data?.values_?.fid ?? + data?.fid ?? + data?.getId?.() ?? + data?.id_ ?? + "", + ); + +const isGisEditPreviewFeatureData = (data: any) => + Boolean(String(data?.get?.("changeId") ?? data?.changeId ?? "").trim()); + +const highlightGisEditSelectedFeature = (featureData: any) => { + if ( + !featureData || + typeof featureData.getGeometry !== "function" || + typeof featureData.setStyle !== "function" + ) { + return; + } + + const select = GisApp.Module.select as any; + select?.selectFeature?.(featureData, true); +}; + +const isVisibleGisEditSessionChange = (change: GisEditSessionSnapshot["changes"][number]) => + !change.attributeOnly || Boolean(change.basicInfoSavedAt); + +const getLatestNumericEditSn = (histories: any[]) => + histories.reduce((latest, history) => { + const editSn = String(history?.editSn ?? "").trim(); + if (!/^\d+$/.test(editSn)) return latest; + const value = Number(editSn); + return latest === null || value > latest ? value : latest; + }, null); + +const getGisEditAttributeText = (attributes: Record | null | undefined, keys: string[]) => { + for (const key of keys) { + const value = String(attributes?.[key] ?? "").trim(); + if (value) return value; + } + return ""; +}; + +const buildGisEditHiddenPipeFilter = (fids: string[]) => { + const normalizedFids = Array.from( + new Set( + fids + .map((fid) => normalizeGisEditFid(fid)) + .filter((fid) => /^\d+$/.test(fid)), + ), + ); + if (normalizedFids.length === 0) return null; + return `fid NOT IN (${normalizedFids.join(",")})`; +}; + +const findInvalidPipeRequiredChange = (changes: Array<{ changeId: string; editType: string; clientTempId?: string; attributes?: Record | null }>) => + changes.find((change) => { + const attributes = change.attributes ?? {}; + const hasAttributePayload = Object.keys(attributes).length > 0; + if (change.editType !== "ADD" && !hasAttributePayload) return false; + + const mngInstCd = getGisEditAttributeText(attributes, ["mngInstCd"]); + const fcltMngNo = getGisEditAttributeText(attributes, ["fcltMngNo"]); + const instMngNo = getGisEditAttributeText(attributes, ["instMngNo"]); + const usgCd = getGisEditAttributeText(attributes, ["usgCd"]); + return !mngInstCd || !fcltMngNo || /^NEW-/i.test(fcltMngNo) || !instMngNo || /^NEW-/i.test(instMngNo) || !usgCd; + }); + const formatWaterRiskPipeField = (value: unknown, suffix: string) => { const normalized = String(value ?? "").trim(); if (!normalized) return "-"; @@ -1424,6 +1531,7 @@ const Map = () => { const [agedPipeCurrentPage, setAgedPipeCurrentPage] = useState(1); const [isAgedPipeListLoading, setIsAgedPipeListLoading] = useState(false); const [agedPipeDownloadProgressText, setAgedPipeDownloadProgressText] = useState(""); + const [isGisEditSaving, setIsGisEditSaving] = useState(false); const { ctpv, sgg } = useStore(); // GIS 편집은 map/page.tsx 내부 기존 지도 상태를 재사용하되, 세부 상태와 payload 조립은 별도 hook으로 분리한다. const gisEdit = useGisEdit(treeData); @@ -1431,6 +1539,7 @@ const Map = () => { const agedPipePopupWindowRef = useRef(null); const pipeAnalysisPopupWindowRef = useRef(null); const gisEditSessionPopupWindowRef = useRef(null); + const gisEditSessionPopupCloseWatcherRef = useRef(null); const [gisEditDetailChangeId, setGisEditDetailChangeId] = useState(null); const gisEditDetailChangeRef = useRef(null); const gisEditDetailFeatureDataRef = useRef(null); @@ -1496,6 +1605,8 @@ const Map = () => { const flowDirectionFailureAlertedRef = useRef(false); const gisEditPreviewLayerRef = useRef(null); const gisEditPreviewSourceRef = useRef(null); + const gisEditOriginalLayerFilterRef = useRef>(new globalThis.Map()); + const gisEditOriginalFeatureStyleRef = useRef>(new globalThis.Map()); const gisEditVertexLayerRef = useRef | null>(null); const gisEditVertexSourceRef = useRef(null); const waterRiskAreaLayerRef = useRef | null>(null); @@ -1504,7 +1615,6 @@ const Map = () => { const waterRiskGridSourceRef = useRef(null); const waterRiskPipeLayerRef = useRef | null>(null); const waterRiskPipeSourceRef = useRef(null); - const gisEditDrawSourceRef = useRef(null); const gisEditDrawRef = useRef(null); const gisEditTranslateRef = useRef(null); const gisEditModifyRef = useRef(null); @@ -1513,22 +1623,26 @@ const Map = () => { ); const [waterRiskInfoAnchor, setWaterRiskInfoAnchor] = useState<[number, number] | null>(null); const [waterRiskInfoCardStyle, setWaterRiskInfoCardStyle] = useState | null>(null); + const gisEditVisibleSessionChanges = useMemo( + () => gisEdit.changes.filter(isVisibleGisEditSessionChange), + [gisEdit.changes], + ); const gisEditSessionSnapshot = useMemo(() => ({ isEditMode: gisEdit.isEditMode, selectedLayerName: gisEdit.selectedLayer?.layerName ?? "", activeTool: gisEdit.activeTool, - changes: gisEdit.changes, + changes: gisEditVisibleSessionChanges, lastSavedPayload: gisEdit.lastSavedPayload, canUndo: gisEdit.canUndo, focusedChangeId: gisEdit.focusedChangeId, }), [ gisEdit.activeTool, gisEdit.canUndo, - gisEdit.changes, gisEdit.focusedChangeId, gisEdit.isEditMode, gisEdit.lastSavedPayload, gisEdit.selectedLayer, + gisEditVisibleSessionChanges, ]); const gisEditDetailChange = useMemo( () => gisEdit.changes.find((change) => change.changeId === gisEditDetailChangeId) ?? null, @@ -1536,33 +1650,32 @@ const Map = () => { ); const gisEditDetailFeatureData = useMemo(() => { if (!gisEditDetailChange) return null; - if (featureData) return featureData; + if (featureData && !isGisEditPreviewFeatureData(featureData)) return featureData; if (gisEditDetailChange.layerId !== "tb_ms_swage_ppl") return null; + const savedFeatureProperties = gisEditDetailChange.featureProperties ?? {}; return { + ...savedFeatureProperties, 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: "", + fcltMngNo: + gisEditDetailChange.featureRef?.fcltMngNo ?? + savedFeatureProperties["fclt_mng_no"] ?? + gisEditDetailChange.objectId ?? + "", + mngInstCd: + gisEditDetailChange.featureRef?.mngInstCd ?? + savedFeatureProperties["mng_inst_cd"] ?? + "", + fclt_mng_no: + gisEditDetailChange.featureRef?.fcltMngNo ?? + savedFeatureProperties["fclt_mng_no"] ?? + gisEditDetailChange.objectId ?? + "", + mng_inst_cd: + gisEditDetailChange.featureRef?.mngInstCd ?? + savedFeatureProperties["mng_inst_cd"] ?? + "", }; }, [featureData, gisEditDetailChange]); gisEditDetailChangeRef.current = gisEditDetailChange; @@ -1588,6 +1701,26 @@ const Map = () => { ); }, [gisEditSessionSnapshot]); + const closeGisEditSessionPopup = useCallback(() => { + if (gisEditSessionPopupCloseWatcherRef.current) { + window.clearInterval(gisEditSessionPopupCloseWatcherRef.current); + gisEditSessionPopupCloseWatcherRef.current = null; + } + + const popup = gisEditSessionPopupWindowRef.current; + if (popup && !popup.closed) { + popup.close(); + } + gisEditSessionPopupWindowRef.current = null; + }, []); + + useEffect(() => () => { + if (gisEditSessionPopupCloseWatcherRef.current) { + window.clearInterval(gisEditSessionPopupCloseWatcherRef.current); + gisEditSessionPopupCloseWatcherRef.current = null; + } + }, []); + const openGisEditPipeDetail = useCallback((changeId: string | null) => { if (!changeId) { setGisEditDetailChangeId(null); @@ -1596,6 +1729,39 @@ const Map = () => { setGisEditDetailChangeId(changeId); }, []); + const focusGisEditChangeOnMap = useCallback((changeId: string) => { + const map = gisMaps.current; + const change = gisEdit.changes.find((item) => item.changeId === changeId); + const geometryObject = change?.editType === "DELETE" ? change?.beforeGeometry : change?.afterGeometry; + if (!map || !geometryObject) return; + + try { + const geometry = new GeoJSON().readGeometry(geometryObject, { + dataProjection: FLOW_DIRECTION_SRS, + featureProjection: GisApp.TO_PROJ, + }); + const extent = geometry?.getExtent?.(); + if ( + !extent || + extent.length < 4 || + extent.some((value: number) => !Number.isFinite(value)) + ) { + return; + } + + const view = map.getView?.(); + const currentZoom = Number(view?.getZoom?.()); + const maxZoom = Number.isFinite(currentZoom) && currentZoom > 18 ? currentZoom : 18; + view?.fit?.(extent, { + duration: 350, + padding: [80, 420, 80, 80], + maxZoom, + }); + } catch (error) { + console.warn("GIS edit change focus failed:", error); + } + }, [gisEdit.changes]); + const syncWaterRiskPopup = useCallback(() => { const popup = waterRiskPopupWindowRef.current; if (!popup || popup.closed) { @@ -1710,7 +1876,7 @@ const Map = () => { }, []); useEffect(() => { - const shouldKeepPopupOpen = gisEdit.isEditMode || gisEdit.changes.length > 0; + const shouldKeepPopupOpen = gisEdit.isEditMode || gisEditVisibleSessionChanges.length > 0; if (shouldKeepPopupOpen) return; const popup = gisEditSessionPopupWindowRef.current; @@ -1721,7 +1887,7 @@ const Map = () => { popup.close(); gisEditSessionPopupWindowRef.current = null; - }, [gisEdit.changes.length, gisEdit.isEditMode]); + }, [gisEdit.isEditMode, gisEditVisibleSessionChanges.length]); useEffect(() => { return () => { @@ -1748,9 +1914,9 @@ const Map = () => { }, [gisEdit.isEditMode, gisEditDetailChange, gisEditDetailChangeId]); useEffect(() => { - if (!(gisEdit.isEditMode || gisEdit.changes.length > 0)) return; + if (!(gisEdit.isEditMode || gisEditVisibleSessionChanges.length > 0)) return; syncGisEditSessionPopup(); - }, [gisEdit.changes.length, gisEdit.isEditMode, syncGisEditSessionPopup]); + }, [gisEdit.isEditMode, gisEditVisibleSessionChanges.length, syncGisEditSessionPopup]); useEffect(() => { const handleGisEditSessionPopupMessage = (event: MessageEvent) => { @@ -1766,7 +1932,11 @@ const Map = () => { const changeId = String(data.payload?.changeId ?? "").trim(); if (changeId) { gisEdit.focusChange(changeId); + focusGisEditChangeOnMap(changeId); + setFeatureData(null); openGisEditPipeDetail(changeId); + setIsOpen(true); + setIsSearchModalOpen(false); } return; } @@ -1785,7 +1955,7 @@ const Map = () => { return () => { window.removeEventListener("message", handleGisEditSessionPopupMessage); }; - }, [gisEdit, openGisEditPipeDetail, syncGisEditSessionPopup]); + }, [focusGisEditChangeOnMap, gisEdit, openGisEditPipeDetail, syncGisEditSessionPopup]); const fetchData = async () => { try { @@ -1816,6 +1986,13 @@ const Map = () => { return waterRiskAreaSourceRef.current; } + const layers = map.getLayers?.().getArray?.() ?? []; + layers.forEach((existingLayer: any) => { + if (existingLayer?.get?.("id") === GIS_EDIT_PREVIEW_LAYER_ID) { + map.removeLayer(existingLayer); + } + }); + const source = new VectorSource(); const layer = new VectorLayer({ source, @@ -2445,21 +2622,75 @@ const Map = () => { return; } - // 편집 모드 + activeTool 조합일 때는 상세 팝업 대신 편집 세션 누적을 우선한다. - const editSelection = gisEdit.registerFeatureSelection(data); + // 편집 도구가 켜져 있을 때는 상세 팝업보다 도구별 세션 처리를 우선한다. + // ADD는 Draw interaction만 geometry를 받아야 하므로 기존 관로 선택 이벤트는 무시한다. + let editSelection: any = gisEdit.registerFeatureSelection(data); + if ( + !editSelection.handled && + gisEdit.isEditMode && + !gisEdit.activeTool && + clickedLayerTypeName === "tb_ms_swage_ppl" + ) { + editSelection = gisEdit.registerPipeDetailSelection(data); + } if (editSelection.handled) { + const closeGisEditDetail = () => { + const select = GisApp.Module.select as any; + select?.resetSelectedFeature?.(); + setFeatureData(null); + setGisEditDetailChangeId(null); + setIsOpen(false); + setIsSearchModalOpen(false); + }; + const loadLatestEditSn = (changeId: string) => { + const fid = editSelection.isPreviewSelection === true ? "" : getGisEditFeatureFid(data); + if (!fid) return; + getRawJson(`/sewerage-service/api/v1/map/gisEdit/swagePpl/histories?fid=${encodeURIComponent(fid)}`) + .then((historyResponse) => { + const latestEditSn = getLatestNumericEditSn(historyResponse?.histories ?? []); + if (latestEditSn !== null) { + gisEdit.updateChangeBaseEditSn(changeId, String(latestEditSn)); + } + }) + .catch((error) => { + console.error("GIS 편집 관로 이력 조회 실패:", error); + gisEdit.announceStatus("관로 이력 정보를 확인하지 못했습니다. 저장 시 충돌 검증이 제한될 수 있습니다."); + }); + }; + + if (gisEdit.activeTool === "ADD" || gisEdit.activeTool === "DELETE") { + closeGisEditDetail(); + return; + } + + const isGeometryEditTargetSelection = + editSelection.isPreviewSelection !== true && + (gisEdit.activeTool === "MOVE" || gisEdit.activeTool === "VERTEX_EDIT"); + + if (isGeometryEditTargetSelection) { + if (typeof editSelection.changeId === "string" && editSelection.changeId) { + loadLatestEditSn(String(editSelection.changeId)); + } + closeGisEditDetail(); + return; + } + + const canOpenPreviewPipeDetail = + clickedLayerTypeName === "tb_ms_swage_ppl" && + editSelection.isPreviewSelection === true && + editSelection.editType !== "DELETE"; const shouldOpenPipeDetail = clickedLayerTypeName === "tb_ms_swage_ppl" && typeof editSelection.changeId === "string" && editSelection.changeId && - editSelection.isPreviewSelection !== true; + (editSelection.isPreviewSelection !== true || canOpenPreviewPipeDetail); - if (typeof editSelection.changeId === "string" && editSelection.changeId) { - openGisEditPipeDetail(editSelection.changeId); - } if (shouldOpenPipeDetail) { - const select = GisApp.Module.select as any; - select?.resetSelectedFeature?.(); + openGisEditPipeDetail(String(editSelection.changeId)); + loadLatestEditSn(String(editSelection.changeId)); + if (editSelection.isPreviewSelection !== true && !isGeometryEditTargetSelection) { + highlightGisEditSelectedFeature(data); + } setFeatureData(data); window.setTimeout(() => { setGisEditDetailChangeId(String(editSelection.changeId)); @@ -2471,11 +2702,7 @@ const Map = () => { // 편집 대상으로 넘긴 뒤에는 기존 상세 팝업/원본 선택 강조를 정리해 // 실제 편집 대상인 preview feature가 시각적으로 가려지지 않게 한다. - const select = GisApp.Module.select as any; - select?.resetSelectedFeature?.(); - setFeatureData(null); - setIsOpen(false); - setIsSearchModalOpen(false); + closeGisEditDetail(); return; } @@ -2967,6 +3194,18 @@ const Map = () => { if (!popup) return null; gisEditSessionPopupWindowRef.current = popup; + if (gisEditSessionPopupCloseWatcherRef.current) { + window.clearInterval(gisEditSessionPopupCloseWatcherRef.current); + gisEditSessionPopupCloseWatcherRef.current = null; + } + gisEditSessionPopupCloseWatcherRef.current = window.setInterval(() => { + if (!popup.closed) return; + if (gisEditSessionPopupCloseWatcherRef.current) { + window.clearInterval(gisEditSessionPopupCloseWatcherRef.current); + gisEditSessionPopupCloseWatcherRef.current = null; + } + gisEditSessionPopupWindowRef.current = null; + }, 300); window.focus?.(); return popup; }, []); @@ -2976,12 +3215,18 @@ const Map = () => { if (!map) return null; if (gisEditPreviewLayerRef.current && gisEditPreviewSourceRef.current) { + const layers = map.getLayers?.().getArray?.() ?? []; + if (!layers.includes(gisEditPreviewLayerRef.current)) { + map.addLayer(gisEditPreviewLayerRef.current); + } + gisEditPreviewLayerRef.current.setZIndex?.(10001); return gisEditPreviewSourceRef.current; } const source = new VectorSource(); const layer = new VectorLayer({ source, + style: createGisEditPreviewStyle, zIndex: 10001, updateWhileAnimating: true, updateWhileInteracting: true, @@ -2990,12 +3235,10 @@ const Map = () => { layer.set("id", GIS_EDIT_PREVIEW_LAYER_ID); layer.set("lyrNm", "GIS 편집 미리보기"); layer.set("lyrTyNm", GIS_EDIT_PREVIEW_LAYER_ID); - // MOVE/VERTEX_EDIT는 preview 객체를 실제 상호작용 대상으로 써야 하므로 클릭 가능 상태를 유지한다. - // 이전에 N으로 바꿨다가 점/선/면 시설물 전반에서 이동 드래그가 끊기는 회귀가 발생했다. - layer.set("clickPsblYn", "Y"); + // Preview selection is handled by map.forEachFeatureAtPixel; keep it out of the common Select styler. + layer.set("clickPsblYn", "N"); + layer.setVisible?.(true); - const core = GisApp.Module.core as any; - core?.deleteLayer?.(GIS_EDIT_PREVIEW_LAYER_ID); map.addLayer(layer); gisEditPreviewLayerRef.current = layer; gisEditPreviewSourceRef.current = source; @@ -3007,6 +3250,85 @@ const Map = () => { setGisEditPreviewCursor(""); }, [setGisEditPreviewCursor]); + const restoreGisEditOriginalPipeMask = useCallback(() => { + gisEditOriginalLayerFilterRef.current.forEach((originalFilter, layer) => { + const source = layer?.getSource?.(); + const params = { ...(source?.getParams?.() ?? {}) }; + if (originalFilter) { + params.CQL_FILTER = originalFilter; + } else { + delete params.CQL_FILTER; + } + params._t = Date.now(); + source?.updateParams?.(params); + source?.refresh?.(); + source?.changed?.(); + layer?.changed?.(); + }); + gisEditOriginalLayerFilterRef.current.clear(); + + gisEditOriginalFeatureStyleRef.current.forEach((originalStyle, feature) => { + feature?.setStyle?.(originalStyle); + feature?.changed?.(); + }); + gisEditOriginalFeatureStyleRef.current.clear(); + gisMaps.current?.renderSync?.(); + }, []); + + const applyGisEditOriginalPipeMask = useCallback((hiddenFids: string[]) => { + const hiddenFidSet = new Set( + hiddenFids + .map((fid) => normalizeGisEditFid(fid)) + .filter(Boolean), + ); + + if (hiddenFidSet.size === 0) { + restoreGisEditOriginalPipeMask(); + return; + } + + const map = gisMaps.current; + if (!map) return; + + const filter = buildGisEditHiddenPipeFilter(Array.from(hiddenFidSet)); + map.getAllLayers?.().forEach((layer: any) => { + const layerTypeName = String(layer?.get?.("lyrTyNm") ?? "").trim(); + if (layerTypeName !== "tb_ms_swage_ppl") return; + + const source = layer.getSource?.(); + if (source?.updateParams && filter) { + const params = { ...(source.getParams?.() ?? {}) }; + if (!gisEditOriginalLayerFilterRef.current.has(layer)) { + gisEditOriginalLayerFilterRef.current.set(layer, String(params.CQL_FILTER ?? "").trim() || null); + } + const originalFilter = gisEditOriginalLayerFilterRef.current.get(layer); + params.CQL_FILTER = originalFilter ? `(${originalFilter}) AND (${filter})` : filter; + params._t = Date.now(); + source.updateParams(params); + source.refresh?.(); + source.changed?.(); + layer.changed?.(); + } + + source?.getFeatures?.().forEach((feature: any) => { + const fid = getGisEditFeatureFid(feature); + const shouldHide = hiddenFidSet.has(fid); + if (shouldHide && !gisEditOriginalFeatureStyleRef.current.has(feature)) { + gisEditOriginalFeatureStyleRef.current.set(feature, feature.getStyle?.()); + feature.setStyle?.(() => []); + feature.changed?.(); + } + if (!shouldHide && gisEditOriginalFeatureStyleRef.current.has(feature)) { + feature.setStyle?.(gisEditOriginalFeatureStyleRef.current.get(feature)); + gisEditOriginalFeatureStyleRef.current.delete(feature); + feature.changed?.(); + } + }); + }); + + gisMaps.current?.renderSync?.(); + }, [restoreGisEditOriginalPipeMask]); + const ensureGisEditVertexLayer = useCallback(() => { const map = gisMaps.current; if (!map) return null; @@ -3040,15 +3362,6 @@ const Map = () => { gisEditVertexSourceRef.current?.clear(); }, []); - const ensureGisEditDrawSource = useCallback(() => { - if (gisEditDrawSourceRef.current) { - return gisEditDrawSourceRef.current; - } - - gisEditDrawSourceRef.current = new VectorSource(); - return gisEditDrawSourceRef.current; - }, []); - const removeGisEditDrawInteraction = useCallback(() => { const map = gisMaps.current; if (!map) return; @@ -3057,7 +3370,6 @@ const Map = () => { map.removeInteraction(gisEditDrawRef.current); gisEditDrawRef.current = null; } - gisEditDrawSourceRef.current?.clear(); }, []); const removeGisEditInteractions = useCallback(() => { @@ -3083,6 +3395,30 @@ const Map = () => { setGisEditPreviewCursor(""); }, [clearGisEditVertexLayer, setGisEditPreviewCursor]); + const refreshGisEditTargetLayers = useCallback((layerTypeNames: string[], focusFid?: string | null) => { + const refreshTargetNames = new Set( + (layerTypeNames.length > 0 ? layerTypeNames : ["tb_ms_swage_ppl"]) + .map((layerTypeName) => String(layerTypeName ?? "").trim()) + .filter(Boolean), + ); + const map = gisMaps.current; + map?.getAllLayers?.().forEach((layer: any) => { + const layerTypeName = String(layer?.get?.("lyrTyNm") ?? "").trim(); + if (!refreshTargetNames.has(layerTypeName)) return; + + const source = layer.getSource?.(); + source?.updateParams?.({ _t: Date.now() }); + source?.refresh?.(); + source?.changed?.(); + layer.changed?.(); + }); + + if (focusFid) { + const core = GisApp.Module.layer as any; + core?.showSearchResultsFromGeoJSON?.("tb_ms_swage_ppl", focusFid); + } + }, []); + // GIS 편집은 map 화면의 로컬 편집 모드를 토글하면서 세션 상태는 별도 popup 창에 동기화한다. const handleToggleGisEdit = useCallback(() => { if (!gisEdit.isEditMode) { @@ -3090,35 +3426,129 @@ const Map = () => { if (!popup) { alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); } + } else if (!gisEditSessionPopupWindowRef.current || gisEditSessionPopupWindowRef.current.closed) { + const popup = openGisEditSessionPopup(); + if (!popup) { + alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); + } + syncGisEditSessionPopup(); + return; } gisEdit.toggleEditMode(); setIsOpen(false); - }, [gisEdit, openGisEditSessionPopup]); + }, [gisEdit, openGisEditSessionPopup, syncGisEditSessionPopup]); + + const handleOpenGisEditSessionPopup = useCallback(() => { + const popup = openGisEditSessionPopup(); + if (!popup) { + alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); + return; + } + syncGisEditSessionPopup(); + }, [openGisEditSessionPopup, syncGisEditSessionPopup]); // 툴바 버튼은 현재 지도 중심 또는 다음 피처 선택을 기준으로 로컬 변경분을 만든다. const handleSelectGisEditTool = useCallback((tool: "ADD" | "MOVE" | "VERTEX_EDIT" | "DELETE") => { + if (gisEdit.activeTool === tool) { + gisEdit.deactivateTool(); + removeGisEditDrawInteraction(); + removeGisEditInteractions(); + return; + } + + removeGisEditDrawInteraction(); + removeGisEditInteractions(); gisEdit.activateTool(tool, gisMaps.current); setIsOpen(false); - }, [gisEdit]); + }, [gisEdit, removeGisEditDrawInteraction, removeGisEditInteractions]); - // 1차 구현은 저장 API 호출 대신 payload 미리보기만 만든다. - const handleSaveGisEdit = useCallback(() => { - gisEdit.save(gisMaps.current); - }, [gisEdit]); + const handleCloseGisEdit = useCallback(() => { + removeGisEditInteractions(); + removeGisEditDrawInteraction(); + clearGisEditPreviewLayer(); + clearGisEditVertexLayer(); + closeGisEditSessionPopup(); + gisEdit.discardSession(); + setGisEditDetailChangeId(null); + setFeatureData(null); + setIsOpen(false); + }, [ + clearGisEditPreviewLayer, + clearGisEditVertexLayer, + closeGisEditSessionPopup, + gisEdit, + removeGisEditDrawInteraction, + removeGisEditInteractions, + ]); + + const handleSaveGisEdit = useCallback(async () => { + if (isGisEditSaving) return; + + const payload = gisEdit.save(gisMaps.current); + if (!payload) return; + + const invalidRequiredChange = findInvalidPipeRequiredChange(payload.changes); + if (invalidRequiredChange) { + const message = "관리기관코드, 시설관리번호, 기관관리번호, 용도는 필수 입력 항목입니다."; + alert(message); + gisEdit.announceStatus(message); + setGisEditDetailChangeId(invalidRequiredChange.changeId); + setIsOpen(true); + return; + } + + setIsGisEditSaving(true); + gisEdit.announceStatus("관로 편집 변경분을 저장 중입니다."); + try { + const response = await postRawJson("/sewerage-service/api/v1/map/gisEditSessions/save", payload); + const results = Array.isArray(response?.results) ? response.results : []; + const refreshLayerTypeNames = Array.isArray(response?.refreshLayerTypeNames) + ? response.refreshLayerTypeNames + : results.flatMap((result: any) => result?.refreshLayerTypeNames ?? []); + const focusFid = results.find((result: any) => result?.fid && result?.editType !== "DELETE")?.fid ?? null; + + removeGisEditInteractions(); + removeGisEditDrawInteraction(); + clearGisEditPreviewLayer(); + clearGisEditVertexLayer(); + refreshGisEditTargetLayers(refreshLayerTypeNames, focusFid); + gisEdit.clearChanges(); + setGisEditDetailChangeId(null); + setIsOpen(false); + closeGisEditSessionPopup(); + gisEdit.announceStatus(response?.message ?? "하수관로 편집 저장이 완료되었습니다."); + } catch (error) { + console.error("GIS 편집 저장 실패:", error); + const apiError = error as Error & { body?: any }; + gisEdit.announceStatus(`하수관로 편집 저장 실패: ${apiError.body?.message ?? apiError.message}`); + } finally { + setIsGisEditSaving(false); + } + }, [ + clearGisEditPreviewLayer, + clearGisEditVertexLayer, + closeGisEditSessionPopup, + gisEdit, + isGisEditSaving, + refreshGisEditTargetLayers, + removeGisEditDrawInteraction, + removeGisEditInteractions, + ]); const handleApplyGisEditPipeBasicInfo = useCallback((changeId: string, basicInfo: GisEditPipeBasicInfo) => { gisEdit.updateChangeBasicInfo(changeId, basicInfo); - }, [gisEdit]); + }, [gisEdit.updateChangeBasicInfo]); 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; + if (!gisEdit.activeTool) return true; - const layerTypeName = getSelectedFeatureLayerTypeName(feature); - if (layerTypeName === "tb_ms_swage_ppl") return true; - - gisEdit.announceStatus("하수관로를 선택해주세요."); + if ( + gisEdit.selectedLayer?.layerId === "tb_ms_swage_ppl" && + getSelectedFeatureLayerTypeName(feature) !== "tb_ms_swage_ppl" + ) { + gisEdit.announceStatus("하수관로를 선택해주세요."); + } return false; }, [gisEdit]); @@ -3157,8 +3587,104 @@ const Map = () => { if (!mapReady || !gisMaps.current || !gisEdit.isEditMode) return; const map = gisMaps.current; + const clickKey = map.on("singleclick", (event: any) => { + const previewFeature = map.forEachFeatureAtPixel( + event.pixel, + (feature: any) => String(feature?.get?.("changeId") ?? "").trim() ? feature : null, + { hitTolerance: 12 }, + ); + // 편집 도구가 켜진 동안에는 공통 Select를 우회하므로 원본 WFS 피처도 여기서 직접 판정한다. + const editableSourceFeature = previewFeature + ? null + : gisEdit.activeTool && gisEdit.activeTool !== "ADD" + ? map.forEachFeatureAtPixel( + event.pixel, + (feature: any) => { + if (String(feature?.get?.("changeId") ?? "").trim()) return null; + return getSelectedFeatureLayerTypeName(feature) === gisEdit.selectedLayer?.layerId + ? feature + : null; + }, + { hitTolerance: 12 }, + ) + : null; + const selectedFeature = previewFeature ?? editableSourceFeature; + if (!selectedFeature) return; - if (gisEdit.activeTool !== "ADD" || !gisEdit.selectedLayer) { + const editSelection = gisEdit.registerFeatureSelection(selectedFeature); + if (!editSelection.handled) return; + + event.preventDefault?.(); + + if (editSelection.editType === "DELETE") { + const select = GisApp.Module.select as any; + select?.resetSelectedFeature?.(); + setGisEditDetailChangeId(null); + setFeatureData(null); + setIsOpen(false); + setIsSearchModalOpen(false); + return; + } + + if (gisEdit.activeTool === "MOVE" || gisEdit.activeTool === "VERTEX_EDIT") { + setGisEditDetailChangeId(null); + setFeatureData(null); + setIsOpen(false); + setIsSearchModalOpen(false); + return; + } + + const selectedChangeId = + typeof editSelection.changeId === "string" ? editSelection.changeId : ""; + const selectedChange = gisEdit.changes.find((change) => change.changeId === selectedChangeId); + const canOpenPipeDetail = + selectedChangeId && + editSelection.editType !== "DELETE"; + + if (canOpenPipeDetail) { + openGisEditPipeDetail(selectedChangeId); + setFeatureData( + selectedChange?.editType === "ADD" + ? { + lyrTypeNm: "tb_ms_swage_ppl", + fcltMngNo: "", + mngInstCd: "", + fclt_mng_no: "", + inst_mng_no: "", + } + : null, + ); + window.setTimeout(() => { + setGisEditDetailChangeId(selectedChangeId); + setIsOpen(true); + }, 0); + setIsSearchModalOpen(false); + } + }); + + return () => { + unByKey(clickKey); + }; + }, [ + gisEdit.activeTool, + gisEdit.changes, + gisEdit.isEditMode, + gisEdit.registerFeatureSelection, + gisEdit.selectedLayer, + mapReady, + openGisEditPipeDetail, + ]); + + const gisEditSelectedLayerId = gisEdit.selectedLayer?.layerId ?? ""; + const gisEditSelectedLayerGeometryType = gisEdit.selectedLayer?.geometryType ?? null; + const gisEditSelectedLayerPreviewColors = gisEdit.selectedLayer?.previewColors ?? null; + + useEffect(() => { + if (!mapReady || !gisMaps.current || !gisEdit.isEditMode) return; + + const map = gisMaps.current; + + if (gisEdit.activeTool !== "ADD" || !gisEditSelectedLayerId || !gisEditSelectedLayerGeometryType) { removeGisEditDrawInteraction(); return; } @@ -3166,11 +3692,12 @@ const Map = () => { removeGisEditInteractions(); removeGisEditDrawInteraction(); - const drawSource = ensureGisEditDrawSource(); + const drawSource = ensureGisEditPreviewLayer(); + if (!drawSource) return; const drawType = - gisEdit.selectedLayer.geometryType === "Point" + gisEditSelectedLayerGeometryType === "Point" ? "Point" - : gisEdit.selectedLayer.geometryType === "Polygon" + : gisEditSelectedLayerGeometryType === "Polygon" ? "Polygon" : "LineString"; @@ -3190,7 +3717,38 @@ const Map = () => { dataProjection: FLOW_DIRECTION_SRS, }) as Record; const changeId = gisEdit.registerAddGeometry(geometryObject); - if (changeId && gisEdit.selectedLayer?.layerId === "tb_ms_swage_ppl") { + if (changeId) { + event.feature.setId(`gis-edit:${changeId}`); + event.feature.set("fid", changeId); + event.feature.set("editType", "ADD"); + event.feature.set("objectId", changeId); + event.feature.set("layerId", gisEditSelectedLayerId); + event.feature.set("changeId", changeId); + event.feature.set("layerPointColor", gisEditSelectedLayerPreviewColors?.pointColor ?? ""); + event.feature.set("layerLineColor", gisEditSelectedLayerPreviewColors?.lineColor ?? ""); + event.feature.set("layerFillColor", gisEditSelectedLayerPreviewColors?.fillColor ?? ""); + event.feature.set("isInteractionTarget", false); + event.feature.set("isDragging", false); + event.feature.set("previewLabel", "신규 추가"); + event.feature.setStyle(createGisEditPreviewStyle(event.feature)); + gisMaps.current?.renderSync?.(); + window.setTimeout(() => { + const source = ensureGisEditPreviewLayer(); + if (!source) return; + if (!source.getFeatureById?.(`gis-edit:${changeId}`)) { + source.addFeature(event.feature); + } + event.feature.changed?.(); + source.changed?.(); + gisEditPreviewLayerRef.current?.changed?.(); + gisMaps.current?.renderSync?.(); + }, 0); + } else { + window.setTimeout(() => { + drawSource.removeFeature(event.feature); + }, 0); + } + if (changeId && gisEditSelectedLayerId === "tb_ms_swage_ppl") { openGisEditPipeDetail(changeId); setFeatureData({ lyrTypeNm: "tb_ms_swage_ppl", @@ -3198,33 +3756,22 @@ const Map = () => { 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); + setIsSearchModalOpen(false); + window.setTimeout(() => { + gisEdit.deactivateTool(); + removeGisEditDrawInteraction(); + }, 0); + gisEdit.announceStatus("하수관로 신규 객체를 지도에 추가했습니다. 우측 상세정보를 입력한 뒤 저장하세요."); } } catch (error) { console.error("GIS 편집 ADD geometry 변환 실패:", error); gisEdit.announceStatus("신규 geometry를 추가하지 못했습니다. 다시 시도해 주세요."); } finally { - drawSource.clear(); setGisEditPreviewCursor("crosshair"); } }); @@ -3236,10 +3783,17 @@ const Map = () => { removeGisEditDrawInteraction(); }; }, [ - ensureGisEditDrawSource, - gisEdit, - openGisEditPipeDetail, + ensureGisEditPreviewLayer, + gisEdit.activeTool, + gisEdit.announceStatus, + gisEdit.deactivateTool, + gisEdit.isEditMode, + gisEdit.registerAddGeometry, + gisEditSelectedLayerGeometryType, + gisEditSelectedLayerId, + gisEditSelectedLayerPreviewColors, mapReady, + openGisEditPipeDetail, removeGisEditDrawInteraction, removeGisEditInteractions, setGisEditPreviewCursor, @@ -3301,33 +3855,49 @@ const Map = () => { const nextChangeIdSet = new Set(); gisEdit.changes.forEach((change) => { + if (change.attributeOnly) return; + const geometry = change.editType === "DELETE" ? change.beforeGeometry : change.afterGeometry; if (!geometry) return; + const previewEditType = + gisEdit.pendingInteraction?.changeId === change.changeId + ? gisEdit.pendingInteraction.editType + : change.editType; const previewLabel = - gisEdit.focusedChangeId === change.changeId && change.editType === "MOVE" + gisEdit.focusedChangeId === change.changeId && previewEditType === "MOVE" ? "드래그 이동" - : gisEdit.focusedChangeId === change.changeId && change.editType === "VERTEX_EDIT" + : gisEdit.focusedChangeId === change.changeId && previewEditType === "VERTEX_EDIT" ? "꼭지점 편집" - : change.editType === "DELETE" + : previewEditType === "DELETE" ? "삭제 예정" - : change.editType === "MOVE" + : previewEditType === "MOVE" ? "이동 예정" - : change.editType === "VERTEX_EDIT" + : previewEditType === "VERTEX_EDIT" ? "편집 예정" : "신규 추가"; let feature = existingFeatureMap.get(change.changeId); const previewColors = layerPreviewColorMap.get(change.layerId); + const originalProperties = change.featureProperties ?? {}; + const previewObjectId = change.objectId ?? change.objectLabel; + const previewFid = + originalProperties["fid"] ?? + change.fid ?? + change.featureRef?.fid ?? + previewObjectId; if (!feature) { feature = geoJson.readFeature( { type: "Feature", geometry, properties: { - editType: change.editType, - objectId: change.objectId ?? change.objectLabel, + ...originalProperties, + fid: previewFid, + editType: previewEditType, + objectId: previewObjectId, layerId: change.layerId, + changeId: change.changeId, }, }, { @@ -3346,8 +3916,13 @@ const Map = () => { ); } - feature.set("editType", change.editType); - feature.set("objectId", change.objectId ?? change.objectLabel); + Object.entries(originalProperties).forEach(([key, value]) => { + if (key === "geometry") return; + feature.set(key, value); + }); + feature.set("fid", previewFid); + feature.set("editType", previewEditType); + feature.set("objectId", previewObjectId); feature.set("layerId", change.layerId); feature.set("changeId", change.changeId); feature.set("layerPointColor", previewColors?.pointColor ?? ""); @@ -3365,6 +3940,9 @@ const Map = () => { source.removeFeature(feature); } }); + source.changed?.(); + gisEditPreviewLayerRef.current?.changed?.(); + gisMaps.current?.renderSync?.(); }, [ clearGisEditPreviewLayer, @@ -3373,9 +3951,52 @@ const Map = () => { gisEdit.editableLayers, gisEdit.focusedChangeId, gisEdit.isEditMode, + gisEdit.pendingInteraction, mapReady, ]); + useEffect(() => { + if (!mapReady || !gisEdit.isEditMode || gisEdit.changes.length === 0) return; + ensureGisEditPreviewLayer(); + gisEditPreviewSourceRef.current?.changed?.(); + gisEditPreviewLayerRef.current?.changed?.(); + gisMaps.current?.renderSync?.(); + }, [ + ensureGisEditPreviewLayer, + gisEdit.changes.length, + gisEdit.isEditMode, + mapReady, + ]); + + const gisEditMaskedPipeFidKey = useMemo(() => { + const fids = gisEdit.changes + .filter((change) => change.layerId === "tb_ms_swage_ppl") + .filter((change) => !change.attributeOnly) + .filter((change) => change.editType !== "ADD" && change.editType !== "DELETE") + .map((change) => normalizeGisEditFid(change.fid ?? change.featureRef?.fid ?? change.objectId)) + .filter((fid) => /^\d+$/.test(fid)); + return Array.from(new Set(fids)).sort().join(","); + }, [gisEdit.changes]); + + useEffect(() => { + if (!mapReady || !gisEdit.isEditMode) { + restoreGisEditOriginalPipeMask(); + return; + } + + applyGisEditOriginalPipeMask(gisEditMaskedPipeFidKey ? gisEditMaskedPipeFidKey.split(",") : []); + }, [ + applyGisEditOriginalPipeMask, + gisEdit.isEditMode, + gisEditMaskedPipeFidKey, + mapReady, + restoreGisEditOriginalPipeMask, + ]); + + useEffect(() => () => { + restoreGisEditOriginalPipeMask(); + }, [restoreGisEditOriginalPipeMask]); + useEffect(() => { if (!mapReady || !gisMaps.current || !gisEdit.isEditMode || !gisEdit.pendingInteraction) return; @@ -3583,6 +4204,7 @@ const Map = () => { gisEdit.pendingInteraction!.changeId, nextGeometry, `${layerName} ${objectId} 객체 꼭지점을 지도에서 조정했습니다.`, + { keepVertexEditActive: true }, ); targetFeature.changed?.(); source.changed?.(); @@ -3597,7 +4219,6 @@ const Map = () => { }; } - gisEdit.clearPendingInteraction(); return cleanupPointerMove; }, [ clearGisEditVertexLayer, @@ -3619,6 +4240,19 @@ const Map = () => { } }, [gisEdit.activeTool, gisEdit.isEditMode, removeGisEditInteractions]); + useEffect(() => { + const select = GisApp.Module.select as any; + if (!select?.setActive) return; + + if (gisEdit.isEditMode && gisEdit.activeTool) { + select.resetSelectedFeature?.(); + select.setActive(false); + return; + } + + select.setActive(true); + }, [gisEdit.activeTool, gisEdit.isEditMode, mapReady]); + useEffect(() => { if (!gisEdit.isEditMode || gisEdit.activeTool !== "ADD") { removeGisEditDrawInteraction(); @@ -3760,14 +4394,15 @@ const Map = () => { /> diff --git a/apps/sn/app/(with-base-layout)/oper/_components/SearchDatePicker.tsx b/apps/sn/app/(with-base-layout)/oper/_components/SearchDatePicker.tsx new file mode 100644 index 000000000..f31f52ae7 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/_components/SearchDatePicker.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { CalendarIcon } from "lucide-react"; +import { useState, type ComponentProps } from "react"; + +import { Button, Calendar, Popover, PopoverContent, PopoverTrigger } from "@common/components/ui"; + +type CalendarProps = ComponentProps; + +type SearchDatePickerProps = { + selected?: Date; + onSelect: (date: Date | undefined) => void; + placeholder?: string; + className?: string; + disabled?: CalendarProps["disabled"]; + defaultMonth?: CalendarProps["defaultMonth"]; +}; + +const formatDateLabel = (date: Date) => { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, "0"); + const day = `${date.getDate()}`.padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +export function SearchDatePicker({ + selected, + onSelect, + placeholder = "날짜 선택", + className, + disabled, + defaultMonth, +}: SearchDatePickerProps) { + // 검색조건 달력은 날짜 선택과 동시에 Popover를 닫아야 하므로 open 상태를 화면 전용으로 제어한다. + const [open, setOpen] = useState(false); + + const handleSelect = (date: Date | undefined) => { + onSelect(date); + if (date) { + setOpen(false); + } + }; + + return ( + + + + + + + + + ); +} diff --git a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx index 3df6a1541..464e372d5 100644 --- a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx @@ -23,7 +23,7 @@ import { formatDate, handleDownload } from '@/components/lib/utils' import LoadingOverlay from '@/components/ui/LoadingBar' import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui' import ListDiv from '@common/business_components/ui/list_div' -import { Button, DatePicker, Input, Label } from '@common/components/ui' +import { Button, Input, Label } from '@common/components/ui' import { useEffect, useState } from 'react' import { tableHeadlist } from '..' import { useQuery } from "@tanstack/react-query"; @@ -31,6 +31,7 @@ import { ENDPOINT } from "@common/components/config"; import { getUserInfo } from "@common/components/api"; import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; +import { SearchDatePicker } from '../_components/SearchDatePicker'; const Page = () => { @@ -176,20 +177,16 @@ const Page = () => {
- { if (date) setFromDate(date) }} - modifiers={{ - disabled: { after: new Date() } - }} + disabled={{ after: new Date() }} /> ~ - { if (date) setToDate(date) }} - modifiers={{ - disabled: { after: new Date() } - }} + disabled={{ after: new Date() }} />
diff --git a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx index 86d5d4853..6fb88829c 100644 --- a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx @@ -23,7 +23,7 @@ import { formatDate, handleDownload } from '@/components/lib/utils' import LoadingOverlay from '@/components/ui/LoadingBar' import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui' import ListDiv from '@common/business_components/ui/list_div' -import { Button, DatePicker, Input, Label } from '@common/components/ui' +import { Button, Input, Label } from '@common/components/ui' import { useEffect, useState } from 'react' import { tableHeadlistOldPp } from '..' import { useQuery } from "@tanstack/react-query"; @@ -31,6 +31,7 @@ import { ENDPOINT } from "@common/components/config"; import { getUserInfo } from "@common/components/api"; import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; +import { SearchDatePicker } from '../_components/SearchDatePicker'; const Page = () => { @@ -174,19 +175,15 @@ const Page = () => {
- { if (date) setFromDate(date) }} - modifiers={{ - disabled: { after: new Date() } - }} /> + disabled={{ after: new Date() }} /> ~ - { if (date) setToDate(date) }} - modifiers={{ - disabled: { after: new Date() } - }} /> + disabled={{ after: new Date() }} />
=> { } }; +export const postRawJson = async (url: string, data: object): Promise => { + const res = await fetch(resolveApiUrl(url), { + method: "POST", + headers: withSiteIdHeaders({ + "Content-Type": "application/json", + }), + body: JSON.stringify(data), + }); + if (res.status === 401) { + handleUnauthorizedResponse(); + } + + const text = await res.text(); + const body = text && text.trim() ? JSON.parse(text) : null; + if (!res.ok) { + const message = + body?.message ?? + `Failed to post data: ${res.status} ${res.statusText}`; + const error = new Error(message) as Error & { status?: number; body?: any }; + error.status = res.status; + error.body = body; + throw error; + } + return body; +}; + const parseDownloadFileName = (contentDisposition: string | null) => { if (!contentDisposition) return undefined; diff --git a/apps/sn/app/components/ui/verticalTable.tsx b/apps/sn/app/components/ui/verticalTable.tsx index 1ed617243..a03641e1e 100644 --- a/apps/sn/app/components/ui/verticalTable.tsx +++ b/apps/sn/app/components/ui/verticalTable.tsx @@ -45,17 +45,37 @@ type UiTableProps = { onFormDataChange?: (formData: { [key: string]: string | number | boolean }) => void; // 추가 }; +type UiSelectOption = + | string + | { + value?: string | number; + label?: string; + val?: string | number; + text?: string; + hidden?: boolean; + }; + type UiHeadProps = { id: string; title: string; align?: string; type?: string; - options?: string[]; + options?: UiSelectOption[]; placeholder?: string; isNumber?: boolean; length?: number; + required?: boolean; }; +const getSelectOptionValue = (option: UiSelectOption) => + typeof option === "string" ? option : String(option.value ?? option.val ?? ""); + +const getSelectOptionLabel = (option: UiSelectOption) => + typeof option === "string" ? option : String(option.label ?? option.text ?? getSelectOptionValue(option)); + +const isSelectOptionHidden = (option: UiSelectOption) => + typeof option === "string" ? false : Boolean(option.hidden); + export function VerticalTable({ headName, tableName1, @@ -153,6 +173,7 @@ export function VerticalTable({ {/* 컬럼 제목 */} {head.title} + {head.required ? * : null} {/* 데이터 값 또는 입력 필드 */} @@ -160,21 +181,28 @@ export function VerticalTable({ head.type === "select" ? ( - ) : head.type === "input" ? ( + ) : head.type === "input" || head.type === "date" ? (
{ if (head.isNumber) { @@ -241,8 +269,9 @@ export function VerticalTable({ {/* 컬럼 제목 */} - {head.title} - + {head.title} + {head.required ? * : null} + {/* 데이터 값 또는 입력 필드 */} {children && children[head.id] ? children[head.id] : isEditable2 ? ( @@ -253,17 +282,24 @@ export function VerticalTable({ onChange={(e) => handleInputChange(head.id, e.target.value)} className="w-full border border-gray-300 rounded-sm px-2 py-1 text-xl border-radius-0" > - {head.options?.map((option) => ( - - ))} + {head.options?.map((option, optionIndex) => { + const optionValue = getSelectOptionValue(option); + return ( + + ); + })} - ) : head.type === "input" ? ( + ) : head.type === "input" || head.type === "date" ? (
{ if (head.isNumber) { @@ -333,4 +369,4 @@ export function VerticalTable({ ); -} \ No newline at end of file +} diff --git a/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md index 3caacaa1d..90d15c331 100644 --- a/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md @@ -142,6 +142,308 @@ - 비하수관로 클릭 시 줌 이동 완전 차단 - `ADD` 미리보기와 `MOVE`, `VERTEX_EDIT` interaction 회귀 여부 +### 2.11 2026-05-22 최종본/이력 DB 저장 통합 검증 + +- 검증 환경 + - 화면: `http://localhost:3002/map` + - 프론트 프록시 대상: `http://localhost:8883/sewerage-service` + - 백엔드: `sewerage-service` local profile, port `8883` + - DB: `sm_bs_ms.tb_ms_swage_ppl`, `sm_bs_ms.tb_ms_edit_swage_ppl` +- 발견 문제 + - 브라우저에서 `ADD` 저장 요청은 정상 전송됐지만 최초 검증 시 `500 / E999`가 반환됐다. + - DB 직접 확인 결과, 최종본 `tb_ms_swage_ppl`의 다음 fid는 `4132694`였지만 이력 테이블에는 이미 `fid=4132694, edit_sn=0` 이력이 존재했다. + - 기존 BE 채번이 최종본 `MAX(fid)+1`만 사용해 삭제 이력이 남은 fid를 재사용했고, 이력 테이블 복합키 `tb_ms_edit_swage_ppl_pk(fid, edit_sn)`와 충돌했다. +- 조치 내용 + - 신규 fid 채번 기준을 최종본과 이력 테이블을 합친 `MAX(fid::bigint)+1`로 변경했다. + - 브라우저 저장 성공 후 원본 레이어 refresh가 호출되도록 기존 저장 성공 처리 흐름을 유지했다. + - 신규 관로의 시설관리번호와 관로관리번호가 비어 있거나 `NEW-*` 임시값이면 저장 API를 호출하지 않고 상세 입력창을 열도록 FE 검증을 추가했다. +- 브라우저/API 검증 결과 + - `ADD`: 지도에서 신규 관로를 그리고 `저장` 버튼 클릭, 응답 `200`, `editCd=C`, `editSn=0`, 신규 `fid=4132695` + - `MOVE`: 같은 브라우저 세션에서 신규 fid 기준 수정 저장, 응답 `200`, `editCd=U`, `editSn=1` + - `DELETE`: 같은 브라우저 세션에서 신규 fid 기준 삭제 저장, 응답 `200`, `editCd=D`, `editSn=2` + - 번호 미입력 신규 저장: 저장 요청 `0건`, 상태문구 `신규 하수관로 저장 전 시설관리번호와 관로관리번호를 입력해주세요.` 노출 확인 +- DB 검증 결과 + - 삭제 후 `tb_ms_swage_ppl`의 테스트 fid 최종본 건수: `0` + - 삭제 전 검증 시 `tb_ms_edit_swage_ppl`의 테스트 fid 이력: `0:C,1:U,2:D` + - 검증 완료 후 테스트 fid `4132695`의 최종본/이력 흔적은 정리했다. + - 중간 자동화 중 잘못 선택된 기존 관로 `fid=2280012`는 직전 이력 기준으로 geometry를 원복했고, 테스트로 생성된 `edit_sn=1` 이력은 삭제했다. +- 정적 검증 + - BE: `.\gradlew.bat compileJava test --tests iipbs.sewerage.service.map.SwagePplEditServiceTest` 성공 + - FE: `pnpm --filter sn exec tsc --noEmit --pretty false`는 저장소 기존 오류로 실패했으며, 이번 GIS 편집 변경 파일 관련 신규 오류는 확인되지 않았다. +- 남은 확인 사항 + - 자동화 좌표 클릭으로 `MOVE` 대상을 고를 때, 새 관로와 기존 관로가 겹치면 기존 관로가 선택될 수 있다. 실제 QA에서는 신규 관로를 기존 관로와 겹치지 않는 위치에 생성하거나 fid 기반 재선택 보조가 필요하다. + - 저장 전 신규 관로의 시설관리번호/관로관리번호 입력 편의성은 별도 화면 검증에서 추가 확인이 필요하다. + +### 2.12 2026-05-26 하수관망도 GIS 편집 UI/기능 상세 재검증 + +#### 2.12.1 검증 목적 + +- 하수관망도 화면의 `GIS 편집` 기능을 실제 사용자 조작 흐름 기준으로 재검증한다. +- `추가`, `이동`, `꼭지점편집`, `삭제`, `저장 검증`, `편집 세션` 흐름을 함께 확인한다. +- 사용자가 제기한 "기능이 이상한 것 같다"는 관찰을 UI/기능/계약/검증 가능성 관점으로 분해한다. +- OpenLayers, ArcGIS, QGIS의 일반적인 GIS 편집 UX와 비교해 개선 방향을 정리한다. + +#### 2.12.2 검증 환경 + +- 작업 루트: `D:\workspace` +- 프론트엔드 앱: `D:\workspace\IIPBS-FRONTEND\frontend\apps\sn` +- 화면 URL: `http://localhost:3002/map` +- 백엔드 URL: `http://localhost:8883/sewerage-service` +- 기준 화면: 하수관망도 +- 기준 편집 대상 레이어: `tb_ms_swage_ppl` +- 기준 브라우저 검증 방식: 로컬 개발 서버에 로그인 후 실제 지도 화면에서 버튼 클릭, 좌표 클릭, 드래그, 더블클릭 수행 +- 병행 QA: `qa` 서브에이전트로 백엔드 테스트, API 검증, 인증 차단 여부 확인 + +#### 2.12.3 검증 전제와 환경 제약 + +- `sn` Next 개발 서버가 `3002` 포트에서 이미 실행 중이었다. +- `AGENTS.md` 기준에 따라 개발 서버가 실행 중인 상태에서는 `next build`, `pnpm --filter sn run build`, `pnpm turbo run build --filter=sn`을 실행하지 않았다. +- 로컬 브라우저 직접 검증은 개발 로그인 계정으로 진행했다. +- QA 서브에이전트는 별도 세션에서 `/auth/login` 인증 차단에 걸려 화면 직접 검증을 수행하지 못했다. +- 따라서 본 섹션의 UI 직접 조작 결과는 메인 브라우저 검증 결과를 기준으로 하고, 서브에이전트 결과는 API/BE/정적 검증 보조 증적으로 사용한다. + +#### 2.12.4 브라우저 진입 및 초기 로딩 상태 + +- `/map` 직접 접근 시 로그인 페이지로 이동하는 것을 확인했다. +- 개발 로그인 후 하수관망도 화면에 진입했다. +- 지도, 좌측 시설 트리, 우측 분석 메뉴, 하수관망도 레이어 표시는 확인됐다. +- `GIS 편집` 버튼 클릭 시 지도 상단 중앙에 편집 툴바가 표시됐다. +- 툴바 구성은 `추가`, `이동`, `꼭지점편집`, `삭제`, `저장`으로 확인됐다. +- 편집 진입 직후 `저장` 버튼은 비활성 상태였다. +- 상태 문구는 `하수관로 편집 도구를 선택한 뒤 지도에서 편집 작업을 진행하세요.`로 표시됐다. +- 지도 초기 로딩 중 다음 오류가 관찰됐다. + - `BIZ_TY_CD 데이터 로드 실패` + - `BIZ_SE_CD 데이터 로드 실패` + - 레이어 설정 조회 실패, `500 Internal Server Error` + - 메뉴 wrapper 계열 `AxiosError` +- 위 오류는 GIS 편집 단독 결함으로 단정하지 않았지만, 편집 QA 중 화면 신뢰도와 재현성을 낮추는 환경/연동 리스크로 기록한다. + +#### 2.12.5 `추가` 검증 결과 + +| 항목 | 내용 | +| --- | --- | +| 재현 절차 | `GIS 편집` 진입 후 `추가` 선택, 지도 위 여러 지점 클릭, 마지막 지점 더블클릭 | +| 기대 결과 | 신규 하수관로 선형 sketch가 생성되고, 지도 위에서 선택 가능한 preview가 명확히 표시되며, 상세정보 입력 패널과 저장 가능 상태가 연결된다. | +| 실제 결과 | 신규 변경분 `NEW-1`이 생성되고 `저장` 버튼이 활성화됐다. 우측 상세정보 패널이 열렸고 기본정보 입력 영역이 표시됐다. | +| 통과 항목 | 도구 활성화, 선형 입력 완료, 신규 변경분 생성, 상세 패널 오픈, 저장 버튼 활성화 | +| 문제 항목 | 신규 선형이 지도 위에서 충분히 강조되지 않았고, 우측 상세정보 패널이 지도 영역을 크게 가려 생성 위치 확인이 어렵다. | +| 판정 | 부분 통과 | + +- `추가` 도구의 기본 기능은 동작한다. +- 다만 사용자는 "내가 방금 추가한 관로가 어디에 생겼는지"를 지도에서 즉시 확인하기 어렵다. +- 신규 객체는 고대비 선형, 라벨, 선택 테두리, 꼭지점 표시 중 최소 하나 이상의 강한 피드백이 필요하다. +- 상세정보 패널이 우측 지도 영역을 크게 차지하므로 추가 직후 선형 위치와 패널 입력을 동시에 확인하기 어렵다. + +#### 2.12.6 `이동` 검증 결과 + +| 항목 | 내용 | +| --- | --- | +| 재현 절차 | 신규 관로 추가 후 `이동` 선택, 신규 관로가 생성된 것으로 예상되는 위치에서 클릭 및 드래그 | +| 기대 결과 | 이동 대상이 선택되고, 이동용 preview 또는 선택 테두리가 표시되며, 드래그 시 객체 geometry만 이동한다. | +| 실제 결과 | 이동 도구 안내 문구는 표시됐지만, 대상 선택 상태와 드래그 핸들이 명확하지 않았다. 드래그 시 객체 이동보다 지도 pan처럼 보이는 동작이 관찰됐다. | +| 통과 항목 | 도구 활성화, 상태 문구 표시 | +| 문제 항목 | 선택 대상 식별 불명확, 이동 preview 식별 불명확, 드래그 중 지도 이동과 객체 이동이 구분되지 않음 | +| 판정 | 불안정 | + +- `이동`은 GIS 편집에서 사용자가 가장 직관적으로 기대하는 기능 중 하나지만, 현재 화면에서는 "어떤 객체가 선택됐고 무엇을 잡고 움직여야 하는지"가 명확하지 않다. +- 편집 대상이 선택되면 지도 pan보다 객체 이동 interaction이 우선되어야 한다. +- 선택 후 이동 가능한 상태에서는 cursor, 선택 테두리, preview 색상, 상태 문구가 함께 바뀌어야 한다. + +#### 2.12.7 `꼭지점편집` 검증 결과 + +| 항목 | 내용 | +| --- | --- | +| 재현 절차 | `꼭지점편집` 선택 후 신규 관로 또는 기존 관로로 보이는 위치를 클릭하고 vertex handle 표시 및 드래그 가능 여부 확인 | +| 기대 결과 | 실꼭지점은 주황색 handle로, 새 점 삽입 후보 midpoint는 흰색 handle로 안정적으로 표시된다. 꼭지점을 드래그하면 선형 geometry가 즉시 갱신된다. | +| 실제 결과 | 안내 문구는 표시됐지만 실제 주황색 꼭지점과 흰색 midpoint handle을 안정적으로 확인하지 못했다. 꼭지점 선택/드래그 가능 상태도 사용자 관점에서 확정하기 어려웠다. | +| 통과 항목 | 도구 활성화, 상태 문구 표시 | +| 문제 항목 | vertex handle 표시 불명확, midpoint 삽입 후보 확인 불가, 드래그 가능 대상 식별 불가 | +| 판정 | 불량 | + +- `꼭지점편집`은 현재 가장 큰 기능 리스크다. +- 기존 문서에서도 `VERTEX_EDIT`는 QGIS 기준 사용자 경험 재정렬과 실조작 수동 재검증이 필요한 항목으로 남아 있었다. +- 이번 재검증에서도 사용자가 기대하는 "꼭지점이 보이고 잡아서 움직인다"는 핵심 피드백이 충분하지 않았다. +- 선형 편집 도구는 실꼭지점, 선택 꼭지점, hover segment, midpoint 삽입 후보를 시각적으로 구분해야 한다. + +#### 2.12.8 `삭제` 검증 결과 + +| 항목 | 내용 | +| --- | --- | +| 재현 절차 | `삭제` 선택 후 신규 관로 또는 기존 관로로 보이는 위치 클릭 | +| 기대 결과 | 삭제 대상이 선택되고, 즉시 최종 삭제하지 않더라도 pending-delete 스타일이 표시되며, 세션 변경목록에 삭제 변경분이 추가된다. | +| 실제 결과 | 삭제 도구 안내 문구는 표시됐지만, 어떤 객체가 삭제 대상으로 선택됐는지 명확한 지도 피드백을 확인하지 못했다. | +| 통과 항목 | 도구 활성화, 상태 문구 표시 | +| 문제 항목 | 삭제 대상 선택 피드백 부족, pending-delete 스타일 확인 어려움, 되돌리기 흐름 노출 부족 | +| 판정 | 불안정 | + +- 삭제는 저장 전 pending 상태가 반드시 명확해야 한다. +- 현재처럼 선택 피드백이 약하면 사용자는 기존 관로를 잘못 삭제 대상으로 올렸는지 확인하기 어렵다. +- 삭제 대상은 빨간 점선, 반투명 처리, `삭제 예정` 라벨, 세션 목록 강조가 함께 필요하다. + +#### 2.12.9 저장 검증 결과 + +| 항목 | 내용 | +| --- | --- | +| 재현 절차 | 신규 관로 `NEW-1` 생성 후 필수값을 충분히 입력하지 않은 상태에서 `저장` 클릭 | +| 기대 결과 | 저장 API 호출 전 필수값 누락을 차단하고, 누락 필드와 입력 위치를 정확히 안내한다. | +| 실제 결과 | 저장 API 호출 전 차단됐고, 상세정보 패널이 열리며 상태 문구가 표시됐다. | +| 통과 항목 | 신규 관로 필수값 누락 시 저장 API 호출 차단 | +| 문제 항목 | 상태 문구와 화면 필드명이 일치하지 않음 | +| 판정 | 부분 통과 | + +- 표시 문구: `신규 하수관로 저장 전 시설관리번호와 관로관리번호를 입력해주세요.` +- 화면 필드명: `시설관리번호`, `기관관리번호` +- `관로관리번호`와 `기관관리번호`가 서로 다른 의미라면 화면에 두 필드를 모두 분명히 노출해야 한다. +- 같은 의미라면 필드명과 검증 문구를 통일해야 한다. +- 이 불일치는 저장 실패 원인을 사용자가 잘못 이해하게 만들 수 있으므로 UI 문구 결함으로 분류한다. + +#### 2.12.10 편집 세션 및 팝업 검증 결과 + +| 항목 | 내용 | +| --- | --- | +| 기대 결과 | GIS 편집 진입 시 편집 세션 목록, 마지막 변경 취소, 세션 초기화, 항목 focus가 사용자가 확인 가능한 위치에 표시된다. | +| 실제 결과 | 메인 지도 툴바는 표시됐지만, 세션 팝업 또는 변경목록이 사용자에게 명확히 노출되는지 확인이 어려웠다. | +| 판정 | 불안정 | + +- 기존 구현 기록상 GIS 편집 세션은 popup 기반으로 전환되어 있다. +- 실제 사용자는 브라우저 팝업 차단, 포커스 이동, 별도 창 위치 문제로 세션 상태를 놓칠 수 있다. +- 편집 기능은 저장 전 변경목록 확인이 중요하므로, 팝업이 열리지 않거나 가려져도 메인 화면 안에서 최소 변경 건수와 마지막 변경 요약은 확인 가능해야 한다. + +#### 2.12.11 서브에이전트 QA 결과 요약 + +- 코드 변경 없이 읽기, 브라우저, API, 테스트 중심으로 검증했다. +- BE `.\gradlew.bat compileJava`: 통과 +- BE `.\gradlew.bat test`: 통과, `BUILD SUCCESSFUL` +- FE 빌드: 미실행 + - `sn` 개발 서버가 `3002`에서 실행 중이므로 사용자 확인 없이 빌드하지 않았다. +- FE `pnpm --filter sn exec tsc --noEmit --pretty false`: 실패 + - 기존 공통/앱 TypeScript 오류 다수 확인 + - 이번 GIS 편집 단독 신규 오류로 단정하지 않음 +- 서브에이전트 브라우저 검증: `/auth/login` 인증 차단으로 GIS 화면 직접 검증 불가 +- API 검증: + - `GET /sewerage-service/api/v1/map/gisEdit/swagePpl/meta?lyrGrp=SN_NETWORK`: 통과 + - 편집 대상: `tb_ms_swage_ppl` + - 지원 편집: `ADD`, `MOVE`, `VERTEX_EDIT`, `DELETE` + - 저장 테이블: `sm_bs_ms.tb_ms_swage_ppl` + - 이력 테이블: `sm_bs_ms.tb_ms_edit_swage_ppl` + - 빈 변경분 저장 요청: `400 GIS_EDIT_EMPTY_CHANGES` 정상 + - 비관로 레이어 저장 요청: `400 GIS_EDIT_PIPE_ONLY` 정상 + - `GET /gisEdit/swagePpl/histories?fid=1`: `200 OK` +- 서브에이전트 증적: + - `D:\workspace\qa-evidence\sfr-012-003-auth-blocker.png` + - `D:\workspace\qa-evidence\sfr-012-003-session-popup-direct.png` + +#### 2.12.12 외부 GIS 편집 UX 비교 + +| 비교 대상 | 참고 기준 | 현재 화면과의 차이 | +| --- | --- | --- | +| OpenLayers Draw | `Draw` interaction은 sketch 시작, 진행, 완료 이벤트를 별도 interaction으로 관리한다. | 현재 `추가`는 완료 후 신규 sketch의 시각 강조가 약하다. | +| OpenLayers Modify | `Modify` interaction은 vertex 조작을 명확한 handle 기반으로 제공한다. | 현재 `꼭지점편집`은 실꼭지점과 midpoint handle이 안정적으로 보이지 않는다. | +| OpenLayers Translate | `Translate` interaction은 선택 feature 이동을 지도 pan과 분리해 처리한다. | 현재 `이동`은 드래그 시 객체 이동과 지도 pan이 사용자에게 구분되지 않는다. | +| ArcGIS Map Viewer / Web Editor | 편집 대상 선택, vertex 편집, 저장 전 확인 흐름을 사용자에게 명확히 제공한다. | 현재 세션 변경목록과 pending 상태 노출이 약하다. | +| QGIS Vertex Tool | 실꼭지점, segment, midpoint 삽입 후보를 시각적으로 구분한다. | 현재 `VERTEX_EDIT`는 QGIS 수준의 조작 피드백에 도달하지 못했다. | + +참고 링크: + +- OpenLayers Draw: https://openlayers.org/en/latest/apidoc/module-ol_interaction_Draw-Draw.html +- OpenLayers Modify: https://openlayers.org/en/latest/apidoc/module-ol_interaction_Modify-Modify.html +- OpenLayers Translate: https://openlayers.org/en/latest/apidoc/module-ol_interaction_Translate-Translate.html +- ArcGIS Online Map Viewer editing: https://doc.arcgis.com/en/arcgis-online/manage-data/edit-features-mv.htm +- ArcGIS Web Editor vertex editing: https://doc.arcgis.com/en/web-editor/latest/create-and-edit/edit-vertices.htm +- QGIS geometry editing: https://docs.qgis.org/latest/en/docs/user_manual/working_with_vector/editing_geometry_attributes.html + +#### 2.12.13 결함 및 개선 백로그 + +| 우선순위 | 구분 | 내용 | 개선 방향 | +| --- | --- | --- | --- | +| P1 | 기능/UX | `이동`, `꼭지점편집`, `삭제`에서 편집 대상 선택 피드백이 불충분하다. | 선택 feature를 고대비 overlay로 표시하고, 선택/drag 가능 상태를 cursor와 상태 문구로 함께 제공한다. | +| P1 | 기능 | `꼭지점편집`의 vertex handle과 midpoint handle이 안정적으로 확인되지 않는다. | 실꼭지점, 선택 꼭지점, hover segment, midpoint 삽입 후보를 별도 style로 분리한다. | +| P1 | 기능 | `이동` 드래그가 지도 pan과 구분되지 않는다. | 선택 feature drag 중 지도 pan을 일시 제한하고, 이동 preview source를 명확히 분리한다. | +| P1 | 기능/세션 | 세션 변경목록과 pending 상태가 사용자에게 명확하지 않다. | 팝업 실패 fallback 또는 메인 지도 내 축약 세션 패널을 제공한다. | +| P2 | UI | `추가` 후 신규 선형이 지도에서 충분히 강조되지 않는다. | 신규 객체 label, 선택 테두리, vertex 표시, 자동 focus를 보강한다. | +| P2 | UI | 상세정보 패널이 지도 확인을 방해한다. | 패널 폭/위치/접힘 상태를 조정하고, 선택 객체 focus와 겹치지 않게 한다. | +| P2 | 문구 | 저장 검증 문구의 `관로관리번호`와 화면 필드 `기관관리번호`가 불일치한다. | 백엔드 계약 기준으로 필드명을 통일하고 누락 필드를 행 단위로 강조한다. | +| P2 | 연동 | 지도 초기 로딩 중 코드/레이어 설정 조회 오류가 발생한다. | GIS 편집 결함과 분리해 공통 코드/레이어 설정 API 오류를 먼저 정리한다. | +| P3 | QA | QA 서브에이전트 세션에서 인증 차단으로 화면 검증이 막힌다. | QA용 로컬 로그인 절차, 테스트 계정, 토큰 주입 절차를 문서화한다. | + +#### 2.12.14 종합 판정 + +- 백엔드 컴파일, 테스트, 기본 GIS 편집 API 계약은 정상 범위로 확인됐다. +- 프론트엔드 정적 검증은 기존 TypeScript 오류로 실패 상태이며, 이번 검증만으로 GIS 편집 단독 신규 오류라고 단정하지 않는다. +- 실제 사용자 기준의 GIS 편집 UI는 `추가`만 부분 통과이고, `이동`, `꼭지점편집`, `삭제`는 선택 피드백과 조작 피드백이 부족해 개선 필요로 판정한다. +- 특히 `VERTEX_EDIT`는 기존 검증 문서에서 이미 진행 중/리스크로 분류된 항목이며, 이번 재검증에서도 동일하게 핵심 개선 대상으로 확인됐다. +- 다음 개선 순서는 `선택 피드백`, `vertex handle`, `이동 drag와 지도 pan 분리`, `세션 변경목록 노출`, `저장 검증 문구 정리` 순서가 적절하다. + +### 2.13 2026-05-26 Playwright 기반 GIS 편집 실조작 재검증 + +#### 2.13.1 검증 범위와 경로 + +- 검증 방식: Playwright MCP 브라우저 자동화 기반 실제 화면 조작 +- 로그인 경로: `http://localhost:3002/auth/login` +- 로그인 계정: `CLEANWATER` +- 지도 경로: `http://localhost:3002/map` +- 백엔드 경로: `http://localhost:8883/sewerage-service` +- 세션 팝업 경로: `http://localhost:3002/popup/gisEditSessionPopup` +- 테스트 관리번호: + - 1차: `QA-PW-20260526-001`, `clientTempId=NEW-1` + - 2차: `QA-PW-20260526-002`, `clientTempId=NEW-2` +- 최종 정리 상태: 저장 API가 두 차례 모두 `500 E999`로 실패해 `fid/editSn`은 발급되지 않았고, DB 삭제 저장은 수행할 대상이 없었다. 미저장 로컬 세션은 `마지막 변경 취소`로 `총 0건`까지 정리했다. + +#### 2.13.2 기능별 실조작 결과 + +| 기능 | 재현 절차 | 기대 결과 | 실제 결과 | 심각도 | 개선 제안 | +| --- | --- | --- | --- | --- | --- | +| 로그인/진입 | `/auth/login`에서 `CLEANWATER` 로그인 후 `/map` 진입, `GIS 편집` 클릭 | 지도와 편집 툴바, 세션 팝업이 표시된다. | 로그인 성공, `/map` 진입 성공, `GIS 편집` 클릭 시 툴바와 세션 팝업 탭 표시 확인 | 통과 | QA 계정 기반 재현 절차를 검증 문서에 유지한다. | +| 추가 | 빈 위치에 선형 클릭-더블클릭, 상세정보에 `QA-PW-20260526-001/002` 입력, `저장` 클릭 | 신규 관로가 저장되고 응답에서 신규 `fid`, `editSn=0`, `editCd=C`가 확인된다. | preview, 상세 패널, 세션 목록, 저장 버튼 활성화는 확인됐으나 저장 API가 2회 모두 `500 E999`로 실패했다. `fid/editSn` 미발급 | P1 | BE는 `E999/ERROR` 대신 원인 코드와 메시지를 반환해야 한다. FE는 저장 실패 시 사용자가 조치 가능한 필드/원인을 표시해야 한다. | +| 추가 payload | 상세정보의 `시설관리번호`, `기관관리번호`에 테스트 번호 입력 후 세션 payload 확인 | 입력한 관리번호가 저장 payload의 필수 식별 필드에 일관되게 반영된다. | `fclt_mng_no`, `inst_mng_no`에는 값이 들어가지만 `fcltMngNo`, `mngInstCd`는 빈 문자열로 전송됐다. | P1 | 신규 관로 입력값 병합 시 빈 문자열을 유효값으로 우선하지 말고 snake/camel 필드를 동일 값으로 정규화해야 한다. | +| 이동 | 저장 실패 후 남은 `NEW-2` preview 기준 `이동` 선택, 대상 클릭 후 drag | 대상 관로가 선택되고 지도 pan이 아니라 관로 preview가 이동한다. | 상태문구는 이동 모드로 전환됐지만 대상 선택/drag 후 세션 변경이나 화면상 선택 피드백을 확인하지 못했다. | P2 | 미저장 ADD preview도 선택 가능 여부를 명확히 하거나, 지원하지 않는다면 상태문구로 제한을 안내한다. | +| 꼭지점편집 | `꼭지점편집` 선택 후 `NEW-2` preview 클릭 및 vertex drag 시도 | 주황색 실꼭지점과 흰색 midpoint가 보이고 vertex/midpoint 조작이 가능하다. | 상태문구는 vertex 안내로 전환됐지만 주황색/흰색 handle과 실제 drag 반영을 확인하지 못했다. | P2 | 선택 성공/실패 피드백, vertex handle 표시 조건, midpoint 삽입 가능 범위를 화면에서 명확히 표시한다. | +| 삭제 | `삭제` 선택 후 `NEW-2` preview 클릭 | 삭제 예정 스타일, 세션 변경목록, 되돌리기 가능 상태가 표시된다. | 저장된 fid가 없어 최종 삭제 저장은 불가했다. 미저장 preview 클릭 후 삭제 변경 전환은 확인되지 않았고, 세션에는 ADD 1건만 남았다. | P2 | 미저장 ADD 객체 삭제는 세션 목록의 `마지막 변경 취소`와 같은 명시적 UI로 안내하거나 지도 클릭 삭제를 지원한다. | +| 취소/롤백 | 세션 팝업에서 `마지막 변경 취소` 클릭 | 마지막 변경분이 제거되고 저장할 변경분이 없어야 한다. | `총 0건`, `저장할 편집 변경분이 없습니다.` 확인. 지도 상태문구도 `가장 최근 편집 변경분을 세션에서 제거했습니다.`로 표시 | 통과 | 최종 저장 실패 후 사용자가 쉽게 정리할 수 있도록 이 동작을 메인 툴바에도 노출하는 방안을 검토한다. | + +#### 2.13.3 네트워크/API 응답 + +| 순번 | 요청 | 테스트 관리번호 | 응답 | 기록 | +| --- | --- | --- | --- | --- | +| 147 | `POST http://localhost:3002/sewerage-service/api/v1/map/gisEditSessions/save` | `QA-PW-20260526-001` | `500 Internal Server Error`, body `{"message":"ERROR","status":500,"code":"E999"}` | `clientTempId=NEW-1`, `fid/editSn` 미발급 | +| 149 | `POST http://localhost:3002/sewerage-service/api/v1/map/gisEditSessions/save` | `QA-PW-20260526-002` | `500 Internal Server Error`, body `{"message":"ERROR","status":500,"code":"E999"}` | `clientTempId=NEW-2`, `fid/editSn` 미발급 | + +- 2차 저장 요청 주요 payload: + - `layerId`: `tb_ms_swage_ppl` + - `saveTargetType`: `FINAL` + - `view.srs`: `EPSG:5179` + - `editType`: `ADD` + - `geometry.type`: `LineString` + - `coordinates`: `[[1068626.8294507712,1774978.3276935548],[1068741.356641528,1774930.6080307395],[1068855.8838322847,1774978.3276935548]]` + - `attributes.fclt_mng_no`: `QA-PW-20260526-002` + - `attributes.inst_mng_no`: `QA-PW-20260526-002` + - `attributes.fcltMngNo`: 빈 문자열 + - `attributes.mngInstCd`: 빈 문자열 + +#### 2.13.4 콘솔 오류 + +- `GET http://localhost:3002/server/cms-service/api/v1/menu-roles/SN` -> `401 Unauthorized` +- `POST http://localhost:3002/sewerage-service/api/v1/map/gisEditSessions/save` -> `500 Internal Server Error` +- `GIS 편집 저장 실패: Error: ERROR` +- `AxiosError` + +#### 2.13.5 화면 캡처 + +- `D:\workspace\qa-evidence\sfr-012-003-login-map-loaded.png` +- `D:\workspace\qa-evidence\sfr-012-003-add-before-save.png` +- `D:\workspace\qa-evidence\sfr-012-003-session-add-failed-payload.png` +- `D:\workspace\qa-evidence\sfr-012-003-add-retry-before-save.png` +- `D:\workspace\qa-evidence\sfr-012-003-move-unsaved-no-selection.png` +- `D:\workspace\qa-evidence\sfr-012-003-vertex-unsaved-no-handles.png` +- `D:\workspace\qa-evidence\sfr-012-003-session-undo-clean.png` + +#### 2.13.6 현재 판정 + +- `ADD`는 화면 조작, 상세정보 입력, 세션 반영, 저장 버튼 활성화까지는 통과했지만 최종 저장 API가 `500 E999`로 실패해 통합 검증은 실패로 판정한다. +- `MOVE`, `VERTEX_EDIT`, `DELETE`는 저장된 테스트 fid가 없어 최종본 기준 검증을 수행하지 못했다. 미저장 preview 기준으로는 선택/drag/삭제 피드백이 명확하지 않아 보류 및 UX 결함으로 분류한다. +- 테스트 관리번호 `QA-PW-20260526-001/002`는 저장 실패로 `fid/editSn`이 생성되지 않았다. 최종본 삭제 저장 정리 대상은 없었고, FE 로컬 세션은 `총 0건`으로 정리했다. +- `next build`, `pnpm --filter sn run build`, `pnpm turbo run build --filter=sn`은 실행하지 않았다. `sn` dev server가 `3002`에서 실행 중이므로 AGENTS.md 기준에 따라 빌드 검증은 제외했다. + ## 3. 현재 검증 상태 ### 3.1 통과 @@ -150,34 +452,47 @@ - 레이어별 도구 활성 규칙 - 세션 `undo`, `reset` - `MOVE`의 기본 preview drag +- 하수관로 `ADD/MOVE/DELETE` 최종본/이력 DB 저장 ### 3.2 진행 중 - `ADD` geometry family별 직접 캡처 +- 2026-05-26 Playwright 재검증 기준 `ADD` 최종 저장 API `500 E999` 원인 분석 및 재검증 - `VERTEX_EDIT`의 QGIS 기준 사용자 경험 재정렬 - `MOVE`, `VERTEX_EDIT` 실조작 수동 재검증 +- 2026-05-26 재검증에서 확인된 `이동`, `꼭지점편집`, `삭제` 선택 피드백/조작 피드백 개선 ### 3.3 보류 -- 실제 저장 API 연결 이후 통합 저장 검증 -- 원본 보호와 편집본 참조 갱신 검증 +- 저장 성공 후 GeoServer/GWC 캐시 무효화 운영 방식 검증 +- `ADD` 저장 실패로 인한 신규 fid 기반 `MOVE/VERTEX_EDIT/DELETE/삭제 저장 정리` 재검증 +- QA 서브에이전트 또는 독립 검증자가 사용할 수 있는 로컬 로그인/인증 절차 문서화 ## 4. 보류/리스크 +- 2026-05-26 Playwright 재검증에서 신규 관로 저장 API가 `500 E999`로 실패해 최종본 기준 편집 시나리오가 차단됐다. +- 신규 관로 상세정보 저장 payload에서 `fclt_mng_no`/`inst_mng_no`는 입력값이 들어가지만 `fcltMngNo`/`mngInstCd`는 빈 문자열로 남아 필수 식별자 정합성 리스크가 있다. - dev 서버가 켜져 있으면 `next build`를 함께 돌리기 어렵다. - `next lint`는 환경 이슈로 안정적인 회귀 수단이 되지 못하고 있다. - 지도 캔버스 기반 drag/vertex 테스트는 수동 브라우저 검증 의존도가 높다. - `VERTEX_EDIT`는 하수관로 `LineString`/`MultiLineString` 처리와 vertex marker UX를 다시 고정해야 한다. - 현재 저장소의 기존 TypeScript 오류 때문에 변경분만 분리한 정적 통과 여부를 보장하기 어렵다. +- 기존 관로와 겹친 위치에서 자동 클릭 기반 `MOVE/DELETE` 검증을 수행하면 의도하지 않은 기존 관로가 선택될 수 있다. +- `GIS 편집 세션`이 popup에 의존하면 팝업 차단, 포커스 이동, 별도 창 위치 문제로 사용자가 변경목록을 놓칠 수 있다. +- 저장 검증 메시지와 화면 필드명이 불일치하면 신규 관로 저장 실패 원인을 사용자가 잘못 이해할 수 있다. +- 지도 초기 로딩 오류가 남아 있으면 GIS 편집 결함과 공통 지도/코드/레이어 설정 오류를 분리하기 어렵다. ## 4-1. 관측 증적 요약 - 자동으로 확인된 항목 + - `CLEANWATER` 계정 로그인 후 `/map` 진입 - `GIS편집` 모드 진입/종료 + - 신규 관로 `ADD` preview 생성, 상세정보 입력, 세션 목록 반영 - 레이어별 도구 활성 규칙 - 세션 `undo`, `reset` - `MOVE`의 기본 preview drag 준비 구조 - 수동 또는 후속 확인이 필요한 항목 + - `ADD` 저장 API `500 E999` 원인 확인 - 실제 canvas drag 정확도 - `VERTEX_EDIT`의 vertex marker 노출과 꼭지점 직접 이동 - 저장 API 연결 후 응답 처리와 원본 보호 @@ -197,6 +512,7 @@ - `환경 차단` - dev 서버 실행 중 build 병행이 어렵고, lint도 안정적 회귀 수단이 아니다. - `기능 결함 또는 미완료` + - `ADD` 최종 저장이 `500 E999`로 실패해 신규 fid 기반 후속 검증이 차단된다. - `VERTEX_EDIT`는 아직 요구사항 기준으로 다시 고정이 필요하다. ## 5. 흡수한 기존 문서 diff --git a/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts b/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts index dbb90794a..a59c2f28d 100644 --- a/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts +++ b/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts @@ -17,17 +17,51 @@ const buildQueryString = (params: OpenWindowPopupParams = {}) => { return queryString ? `?${queryString}` : ""; }; -const buildPopupFeatures = (width: number, height: number) => { +const clamp = (value: number, min: number, max: number) => { + if (max < min) return min; + return Math.min(Math.max(value, min), max); +}; + +const buildPopupFeatures = ( + width: number, + height: number, + placement: "center" | "browser-left" = "center", +) => { const screenLeft = window.screenX ?? window.screenLeft ?? 0; const screenTop = window.screenY ?? window.screenTop ?? 0; const outerWidth = window.outerWidth || window.screen.width; const outerHeight = window.outerHeight || window.screen.height; - const left = Math.max(0, screenLeft + Math.round((outerWidth - width) / 2)); - const top = Math.max(0, screenTop + Math.round((outerHeight - height) / 2)); + const screen = window.screen as Screen & { + availLeft?: number; + availTop?: number; + }; + const availableLeft = screen.availLeft ?? 0; + const availableTop = screen.availTop ?? 0; + const availableWidth = window.screen.availWidth || outerWidth; + const availableHeight = window.screen.availHeight || outerHeight; + const safeWidth = Math.min(width, Math.max(360, availableWidth - 48)); + const safeHeight = Math.min(height, Math.max(420, availableHeight - 72)); + const minLeft = availableLeft; + const minTop = availableTop + 24; + const maxLeft = availableLeft + availableWidth - safeWidth - 16; + const maxTop = availableTop + availableHeight - safeHeight - 24; + const left = + placement === "browser-left" + ? clamp(screenLeft, minLeft, maxLeft) + : clamp( + screenLeft + Math.round((outerWidth - safeWidth) / 2), + availableLeft + 16, + maxLeft, + ); + const top = clamp( + screenTop + Math.round((outerHeight - safeHeight) / 2), + minTop, + maxTop, + ); return [ - `width=${width}`, - `height=${height}`, + `width=${safeWidth}`, + `height=${safeHeight}`, `left=${left}`, `top=${top}`, "resizable=yes", @@ -48,7 +82,11 @@ export const openWindowPopup = ( const definition = windowPopupDefinitions[popupKey]; const popupUrl = `/popup/${popupKey}${buildQueryString(params)}`; - const popupFeatures = buildPopupFeatures(definition.width, definition.height); + const popupFeatures = buildPopupFeatures( + definition.width, + definition.height, + "placement" in definition ? definition.placement : undefined, + ); const popupWindow = window.open( popupUrl, `sn-popup-${popupKey}`, diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts index ff6c1207e..a354aa7cb 100644 --- a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -105,9 +105,10 @@ export const windowPopupDefinitions = { }, gisEditSessionPopup: { key: "gisEditSessionPopup", - title: "GIS 편집 세션", - width: 520, - height: 820, + title: "편집 세션", + width: 460, + height: 640, + placement: "browser-left", }, gisManagementPopup: { key: "gisManagementPopup", diff --git a/apps/sn/src/features/windowPopup/model/types.ts b/apps/sn/src/features/windowPopup/model/types.ts index 220988c12..904a9bc2f 100644 --- a/apps/sn/src/features/windowPopup/model/types.ts +++ b/apps/sn/src/features/windowPopup/model/types.ts @@ -14,6 +14,7 @@ export type WindowPopupDefinition = { title: string; width: number; height: number; + placement?: "center" | "browser-left"; requiredParams?: string[]; }; diff --git a/packages/common/business_components/src/ui/map/lib/gis.layer.js b/packages/common/business_components/src/ui/map/lib/gis.layer.js index 314b93338..97d3eb6db 100644 --- a/packages/common/business_components/src/ui/map/lib/gis.layer.js +++ b/packages/common/business_components/src/ui/map/lib/gis.layer.js @@ -742,10 +742,13 @@ GisOlLayer.prototype = { const extent = this.combineExtents(geometries.map(g => g?.getExtent())); let map = GisApp.Module.core.map; + const targetZoom = 16; + const currentZoom = Number(map.getView().getZoom()); + const maxZoom = Number.isFinite(currentZoom) && currentZoom > targetZoom ? currentZoom : targetZoom; map.getView().fit(extent, { duration: 300, padding: [50, 50, 50, 50], - maxZoom: 16 + maxZoom: maxZoom }); } } else { diff --git a/packages/common/business_components/src/ui/map/lib/gis.select.js b/packages/common/business_components/src/ui/map/lib/gis.select.js index a1dab024e..e05edb9af 100644 --- a/packages/common/business_components/src/ui/map/lib/gis.select.js +++ b/packages/common/business_components/src/ui/map/lib/gis.select.js @@ -132,13 +132,15 @@ GisOlSelect.prototype = { const selectedLayer = e.target?.getLayer(feature) || feature.getLayer?.(); const clckZoom = Number(selectedLayer?.get('clckZoom')); const targetZoom = Number.isFinite(clckZoom) && clckZoom > 0 ? clckZoom : 16; + const currentZoom = Number(map.getView().getZoom()); + const maxZoom = Number.isFinite(currentZoom) && currentZoom > targetZoom ? currentZoom : targetZoom; const geometry = feature.getGeometry(); if (geometry) { const extent = geometry.getExtent(); map.getView().fit(extent, { duration: 300, padding: [50, 50, 50, 50], - maxZoom: targetZoom + maxZoom: maxZoom }); } } diff --git a/packages/common/business_components/src/ui/map/lib/gis.style.js b/packages/common/business_components/src/ui/map/lib/gis.style.js index 4ad75c386..767244e73 100644 --- a/packages/common/business_components/src/ui/map/lib/gis.style.js +++ b/packages/common/business_components/src/ui/map/lib/gis.style.js @@ -343,12 +343,13 @@ GisOlStyle.prototype = { //레이어 스타일 반환 getLayerStyle: function (layer, options) { let gobjTy = layer.get('gobjTy'); - if (!gobjTy) return; + if (!gobjTy || typeof this[gobjTy] !== 'function') return; let style = this[gobjTy](layer, options); return style; }, getLayerStyleFunction: function (layer, options) { let gobjTy = layer.get('gobjTy'); + if (!gobjTy || typeof this[gobjTy] !== 'function') return; let styleFunction = function (feature, resolution) { return this[gobjTy](layer, options, feature, resolution); }.bind(this);