gis 편집
This commit is contained in:
@@ -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: "처리구역" },
|
||||
|
||||
@@ -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<string, any>) => ({
|
||||
fcltMngNo: String(change.basicInfo?.fcltMngNo ?? change.featureRef?.fcltMngNo ?? baseValues.fclt_mng_no ?? baseValues.fcltMngNo ?? ""),
|
||||
mngInstCd: String(change.basicInfo?.mngInstCd ?? change.featureRef?.mngInstCd ?? baseValues.mng_inst_cd ?? baseValues.mngInstCd ?? ""),
|
||||
instlMbdyCd: String(change.basicInfo?.instlMbdyCd ?? ""),
|
||||
swtrmtZoneNm: String(change.basicInfo?.swtrmtZoneNm ?? ""),
|
||||
mltvZoneNm: String(change.basicInfo?.mltvZoneNm ?? ""),
|
||||
mltvDtdvNm: String(change.basicInfo?.mltvDtdvNm ?? ""),
|
||||
lineCd: String(change.basicInfo?.lineCd ?? ""),
|
||||
floCd: String(change.basicInfo?.floCd ?? ""),
|
||||
layCd: String(change.basicInfo?.layCd ?? ""),
|
||||
const toDateInputValue = (value: unknown) => {
|
||||
const text = toEditableText(value).replaceAll(".", "").replaceAll("-", "");
|
||||
if (!/^\d{8}$/.test(text)) return "";
|
||||
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
|
||||
};
|
||||
|
||||
const toDbYmdValue = (value: unknown) => toEditableText(value).replaceAll("-", "");
|
||||
|
||||
const getObjectValue = (values: Record<string, any>, field: { id: string; sourceId?: string }) =>
|
||||
values[field.id] ?? (field.sourceId ? values[field.sourceId] : undefined);
|
||||
|
||||
const pickFields = (values: Record<string, any>, fieldIds: string[]) =>
|
||||
fieldIds.reduce<Record<string, any>>((acc, fieldId) => {
|
||||
if (Object.prototype.hasOwnProperty.call(values, fieldId)) {
|
||||
acc[fieldId] = values[fieldId];
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const normalizeBasicFormValues = (values: Record<string, any>) => {
|
||||
const nextValues = pickFields(values, PIPE_VISIBLE_BASIC_FIELD_IDS);
|
||||
if (Object.prototype.hasOwnProperty.call(nextValues, "instlYmd")) {
|
||||
nextValues.instlYmd = toDateInputValue(nextValues.instlYmd);
|
||||
}
|
||||
return nextValues;
|
||||
};
|
||||
|
||||
const buildBaseFeatureValues = (featureData: any) => {
|
||||
const resolveUsageCode = (value: unknown) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
const matched = MS_SWAGE_PPL_USE_CODES.find((item) => item.val === raw || item.text === raw);
|
||||
return matched?.val.trim() ?? raw;
|
||||
};
|
||||
|
||||
const resolveMaterialCode = (value: unknown) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
const matched = MS_SWAGE_PPL_CODES.find((item) => item.val === raw || item.text === raw);
|
||||
return matched?.val.trim() ?? raw;
|
||||
};
|
||||
|
||||
const sourceValues = featureData?.values_ ?? featureData ?? {};
|
||||
return PIPE_BASIC_INFO_FIELDS.reduce<Record<string, any>>((acc, field) => {
|
||||
const rawValue = getObjectValue(sourceValues, field);
|
||||
if (field.id === "usgCd") {
|
||||
acc[field.id] = resolveUsageCode(rawValue);
|
||||
return acc;
|
||||
}
|
||||
if (field.id === "mqltCd") {
|
||||
acc[field.id] = resolveMaterialCode(rawValue);
|
||||
return acc;
|
||||
}
|
||||
if (field.id === "instlYmd") {
|
||||
acc[field.id] = toDateInputValue(rawValue);
|
||||
return acc;
|
||||
}
|
||||
acc[field.id] = toEditableText(rawValue);
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const getPipeUsageLabel = (value: unknown) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "";
|
||||
return MS_SWAGE_PPL_USE_CODES.find((item) => item.val.trim() === raw || item.text === raw)?.text ?? raw;
|
||||
};
|
||||
|
||||
const buildInitialInputDraft = (change: GisEditChange) => ({
|
||||
instlMbdyCd: toEditableText(change.basicInfo?.instlMbdyCd ?? ""),
|
||||
swtrmtZoneNm: toEditableText(change.basicInfo?.swtrmtZoneNm ?? ""),
|
||||
mltvZoneNm: toEditableText(change.basicInfo?.mltvZoneNm ?? ""),
|
||||
mltvDtdvNm: toEditableText(change.basicInfo?.mltvDtdvNm ?? ""),
|
||||
lineCd: toEditableText(change.basicInfo?.lineCd ?? ""),
|
||||
floCd: toEditableText(change.basicInfo?.floCd ?? ""),
|
||||
layCd: toEditableText(change.basicInfo?.layCd ?? ""),
|
||||
});
|
||||
|
||||
export default function GisEditPipeSessionPopup({
|
||||
@@ -107,22 +169,67 @@ export default function GisEditPipeSessionPopup({
|
||||
const baseFeatureValues = useMemo(() => buildBaseFeatureValues(featureData), [featureData]);
|
||||
const [basicInfoTableData, setBasicInfoTableData] = useState<Record<string, any>>({});
|
||||
const [inputInfoTableData, setInputInfoTableData] = useState<Record<string, any>>(
|
||||
buildInitialInputDraft(change, baseFeatureValues),
|
||||
buildInitialInputDraft(change),
|
||||
);
|
||||
const [mngInstOptions, setMngInstOptions] = useState<MngInstOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const fetchMngInstOptions = async () => {
|
||||
try {
|
||||
const result = await getData("v1/fcltppl/mngInsts");
|
||||
if (!isMounted) return;
|
||||
setMngInstOptions(Array.isArray(result) ? result : []);
|
||||
} catch (error) {
|
||||
console.error("관리기관 목록 조회 실패:", error);
|
||||
if (isMounted) setMngInstOptions([]);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMngInstOptions();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const mngInstSelectOptions = useMemo(
|
||||
() => {
|
||||
const sortedOptions = [...mngInstOptions].sort((a, b) =>
|
||||
String(a.lwrInstNm ?? a.mngInstCd ?? "").localeCompare(
|
||||
String(b.lwrInstNm ?? b.mngInstCd ?? ""),
|
||||
"ko",
|
||||
),
|
||||
);
|
||||
return [
|
||||
EMPTY_SELECT_OPTION,
|
||||
...sortedOptions.map((option) => ({
|
||||
value: String(option.mngInstCd ?? "").trim(),
|
||||
label: option.lwrInstNm || String(option.mngInstCd ?? "").trim(),
|
||||
})),
|
||||
];
|
||||
},
|
||||
[mngInstOptions],
|
||||
);
|
||||
|
||||
const basicInfoFields = useMemo(
|
||||
() =>
|
||||
PIPE_BASIC_INFO_FIELDS.map((field) =>
|
||||
field.id === "mngInstCd"
|
||||
? { ...field, options: mngInstSelectOptions }
|
||||
: field,
|
||||
),
|
||||
[mngInstSelectOptions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const initialBasicValues: Record<string, any> = {
|
||||
...baseFeatureValues,
|
||||
...(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<string, any>,
|
||||
nextInputInfoTableData: Record<string, any>,
|
||||
): GisEditPipeBasicInfo => ({
|
||||
...nextBasicInfoTableData,
|
||||
...nextInputInfoTableData,
|
||||
fcltMngNo: String(nextInputInfoTableData.fcltMngNo ?? change.featureRef?.fcltMngNo ?? change.objectId ?? ""),
|
||||
mngInstCd: String(nextInputInfoTableData.mngInstCd ?? change.featureRef?.mngInstCd ?? ""),
|
||||
});
|
||||
): GisEditPipeBasicInfo => {
|
||||
const basicDraft = pickFields(nextBasicInfoTableData, PIPE_VISIBLE_BASIC_FIELD_IDS);
|
||||
const inputDraft = pickFields(nextInputInfoTableData, PIPE_VISIBLE_INPUT_FIELD_IDS);
|
||||
if (basicDraft.instlYmd) {
|
||||
basicDraft.instlYmd = toDbYmdValue(basicDraft.instlYmd);
|
||||
}
|
||||
return {
|
||||
...basicDraft,
|
||||
...inputDraft,
|
||||
};
|
||||
};
|
||||
|
||||
const handleBasicInfoChange = (nextFormData: Record<string, any>) => {
|
||||
setBasicInfoTableData(nextFormData);
|
||||
@@ -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 (
|
||||
<div id="popup" className="absolute top-0 right-0 w-[350px] h-[calc(100vh-5rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-[50]">
|
||||
<div className="card-header bg-primary flex items-center justify-between relative p-4">
|
||||
<div className="flex items-center space-x-4 flex-1">
|
||||
<h3 className="text-2xl font-medium text-white text-label whitespace-nowrap">
|
||||
하수관로
|
||||
{basicInfoTableData?.usg_cd ? `(${basicInfoTableData.usg_cd})` : ""}
|
||||
{" "}정보 <br />관리번호 : {change.featureRef?.fcltMngNo ?? change.objectId ?? "-"}
|
||||
{basicInfoTableData?.usgCd ? `(${getPipeUsageLabel(basicInfoTableData.usgCd)})` : ""}
|
||||
{" "}정보 <br />관리번호 : {displayManagementNo || "신규"}
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
@@ -202,8 +311,8 @@ export default function GisEditPipeSessionPopup({
|
||||
<div className="overflow-y-auto overflow-x-hidden flex-1">
|
||||
<VerticalTable
|
||||
headName="기본정보"
|
||||
tableData={[basicInfoTableData]}
|
||||
headlist={editableBasicInfoFields}
|
||||
tableData={basicInfoTableRows}
|
||||
headlist={basicInfoFields}
|
||||
pageSize={20}
|
||||
total={0}
|
||||
cellClick={undefined}
|
||||
@@ -213,7 +322,7 @@ export default function GisEditPipeSessionPopup({
|
||||
/>
|
||||
<VerticalTable
|
||||
headName="입력정보"
|
||||
tableData={[inputInfoTableData]}
|
||||
tableData={inputInfoTableRows}
|
||||
headlist={PIPE_INPUT_INFO_FIELDS}
|
||||
pageSize={20}
|
||||
total={0}
|
||||
|
||||
@@ -23,6 +23,9 @@ const buildChangeItemClassName = (isFocused: boolean) =>
|
||||
: "border-[#d8dee8] bg-[#fbfcfe] hover:border-[#bfd2e5] hover:bg-white"
|
||||
}`;
|
||||
|
||||
const getChangeLabel = (change: GisEditSessionSnapshot["changes"][number]) =>
|
||||
change.attributeOnly ? "상세정보수정" : TOOL_LABELS[change.editType];
|
||||
|
||||
type ChangeListProps = {
|
||||
changes: GisEditSessionSnapshot["changes"];
|
||||
focusedChangeId: string | null;
|
||||
@@ -40,7 +43,7 @@ const ChangeList = ({ changes, focusedChangeId, onFocusChange }: ChangeListProps
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-semibold text-[#173f67]">
|
||||
{index + 1}. {TOOL_LABELS[change.editType]} / {change.objectLabel}
|
||||
{index + 1}. {getChangeLabel(change)} / {change.objectLabel}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{change.basicInfoSavedAt ? (
|
||||
@@ -66,7 +69,6 @@ export default function GisEditSessionPanel({
|
||||
selectedLayerName,
|
||||
activeTool,
|
||||
changes,
|
||||
lastSavedPayload,
|
||||
canUndo,
|
||||
focusedChangeId,
|
||||
onRemoveLastChange,
|
||||
@@ -76,24 +78,12 @@ export default function GisEditSessionPanel({
|
||||
}: Props) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded border border-[#d8dee8] bg-[rgba(255,255,255,0.98)] p-4 text-sm text-[#304255] shadow-[0_10px_26px_rgba(23,42,70,0.14)] ${className}`.trim()}
|
||||
className={`rounded-lg border border-[#d8dee8] bg-[rgba(255,255,255,0.98)] p-4 text-sm text-[#304255] shadow-[0_10px_26px_rgba(23,42,70,0.14)] ${className}`.trim()}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[13px] font-bold text-[#173f67]">편집 세션</div>
|
||||
<div className="mt-1 text-[12px] text-[#667485]">
|
||||
레이어: {selectedLayerName || "미선택"} / 모드: {isEditMode ? (activeTool ? TOOL_LABELS[activeTool] : "대기") : "편집 모드 종료"}
|
||||
</div>
|
||||
<div className="text-[12px] text-[#667485]">
|
||||
레이어: {selectedLayerName || "미선택"} / 모드: {isEditMode ? (activeTool ? TOOL_LABELS[activeTool] : "대기") : "편집 모드 종료"}
|
||||
</div>
|
||||
{isEditMode ? (
|
||||
<div className="rounded-full border border-[#b8d3ef] bg-[#f2f8ff] px-2 py-1 text-[11px] font-semibold text-[#174e82]">
|
||||
FE 로컬 세션
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-full border border-[#f2d18d] bg-[#fff8eb] px-2 py-1 text-[11px] font-semibold text-[#9a5b00]">
|
||||
지도 미리보기 유지
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
@@ -124,7 +114,7 @@ export default function GisEditSessionPanel({
|
||||
저장할 편집 변경분이 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[calc(100vh-220px)] min-h-[640px] overflow-y-auto pr-1">
|
||||
<div className="max-h-[calc(100vh-190px)] overflow-y-auto pr-1">
|
||||
<div className="space-y-2">
|
||||
<ChangeList
|
||||
changes={changes}
|
||||
@@ -136,14 +126,6 @@ export default function GisEditSessionPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lastSavedPayload ? (
|
||||
<div className="mt-4 rounded border border-[#d8dee8] bg-[#0f172a] px-3 py-3 text-[11px] text-[#dbeafe]">
|
||||
<div className="mb-2 font-semibold text-white">저장 payload 미리보기</div>
|
||||
<pre className="max-h-[220px] overflow-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(lastSavedPayload, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Check,
|
||||
Move,
|
||||
PenTool,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@common/components/ui";
|
||||
|
||||
import type { GisEditLayerInfo, GisEditTool } from "./gisEditTypes";
|
||||
import type { GisEditTool } from "./gisEditTypes";
|
||||
|
||||
type Props = {
|
||||
isEditMode: boolean;
|
||||
editableLayers: GisEditLayerInfo[];
|
||||
selectedLayerId: string;
|
||||
selectedLayer: GisEditLayerInfo | null;
|
||||
activeTool: GisEditTool | null;
|
||||
canUseTools: boolean;
|
||||
canSave: boolean;
|
||||
isSaving?: boolean;
|
||||
sessionChangeCount?: number;
|
||||
statusMessage: string;
|
||||
onSelectLayer: (layerId: string) => void;
|
||||
allowedTools?: GisEditTool[];
|
||||
onOpenSession?: () => void;
|
||||
onClose?: () => void;
|
||||
onSelectTool: (tool: GisEditTool) => void;
|
||||
onSave: () => void;
|
||||
};
|
||||
@@ -36,49 +40,38 @@ const TOOL_BUTTONS: Array<{
|
||||
|
||||
export default function GisEditToolbar({
|
||||
isEditMode,
|
||||
editableLayers,
|
||||
selectedLayerId,
|
||||
selectedLayer,
|
||||
activeTool,
|
||||
canUseTools,
|
||||
canSave,
|
||||
statusMessage,
|
||||
onSelectLayer,
|
||||
isSaving = false,
|
||||
sessionChangeCount = 0,
|
||||
statusMessage: _statusMessage,
|
||||
allowedTools = [],
|
||||
onOpenSession,
|
||||
onClose,
|
||||
onSelectTool,
|
||||
onSave,
|
||||
}: Props) {
|
||||
if (!isEditMode) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute left-1/2 top-4 z-[60] flex w-[min(780px,calc(100%-2rem))] -translate-x-1/2 flex-col gap-2">
|
||||
<div className="rounded border border-[#9bbddf] bg-[rgba(255,255,255,0.96)] px-3 py-3 shadow-[0_10px_26px_rgba(23,42,70,0.14)]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="h-9 min-w-[176px] rounded border border-[#9bbddf] bg-[#f8fbff] px-3 text-sm text-[#243548]"
|
||||
value={selectedLayerId}
|
||||
onChange={(event) => onSelectLayer(event.target.value)}
|
||||
>
|
||||
<option value="">레이어 선택</option>
|
||||
{editableLayers.map((layer) => (
|
||||
<option key={layer.layerId} value={layer.layerId} disabled={layer.editableYn !== "Y"}>
|
||||
{layer.layerName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="absolute left-1/2 top-4 z-[60] flex w-[min(860px,calc(100%-2rem))] -translate-x-1/2 flex-col">
|
||||
<div className="card card-border flex !bg-white py-[1px]">
|
||||
<div className="flex w-full flex-wrap items-center gap-2">
|
||||
<div className="mr-1 flex min-w-[116px] items-center">
|
||||
<span className="text-[1.3rem] font-bold leading-5 text-[#123b62]">하수관로 편집</span>
|
||||
</div>
|
||||
{TOOL_BUTTONS.map(({ tool, label, icon: Icon }) => {
|
||||
const isActive = activeTool === tool;
|
||||
const isToolEnabled =
|
||||
canUseTools && Boolean(selectedLayer?.allowedTools.includes(tool));
|
||||
canUseTools && allowedTools.includes(tool);
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
key={tool}
|
||||
type="button"
|
||||
className={`min-w-[92px] rounded border px-3 py-2 text-sm font-semibold transition ${
|
||||
isActive
|
||||
? "border-[#176db8] bg-[#176db8] text-white"
|
||||
: "border-[#9bbddf] bg-[#eef7ff] text-[#0c5fa6]"
|
||||
} disabled:cursor-not-allowed disabled:border-[#d6dde6] disabled:bg-[#f0f2f4] disabled:text-[#9aa8b7]`}
|
||||
color={isActive ? "primary" : "white"}
|
||||
size="sm"
|
||||
className="mx-1 mt-1 min-w-[82px]"
|
||||
disabled={!isToolEnabled}
|
||||
onClick={() => onSelectTool(tool)}
|
||||
>
|
||||
@@ -86,34 +79,50 @@ export default function GisEditToolbar({
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="ml-auto rounded border border-[#a9d9bd] bg-[#e9f8ef] px-4 py-2 text-sm font-semibold text-[#16834f] transition disabled:cursor-not-allowed disabled:border-[#d6dde6] disabled:bg-[#f0f2f4] disabled:text-[#9aa8b7]"
|
||||
disabled={!canSave}
|
||||
color="white"
|
||||
size="sm"
|
||||
className="ml-auto mt-1 min-w-[112px]"
|
||||
onClick={onOpenSession}
|
||||
title="GIS 편집 세션 보기"
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Check className="h-4 w-4" />
|
||||
<span>편집 세션 {sessionChangeCount}</span>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
size="sm"
|
||||
className="mx-1 mt-1 min-w-[64px]"
|
||||
disabled={!canSave || isSaving}
|
||||
onClick={onSave}
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isSaving ? "저장 중" : "저장"}
|
||||
</Button>
|
||||
|
||||
<div className="rounded border border-[#d8dee8] bg-[rgba(255,255,255,0.94)] px-3 py-2 text-sm text-[#304255] shadow-[0_10px_26px_rgba(23,42,70,0.12)]">
|
||||
{statusMessage}
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
size="sm"
|
||||
className="mx-1 mt-1 min-w-[68px]"
|
||||
onClick={onClose}
|
||||
title="하수관로 편집 닫기"
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<X className="h-4 w-4" />
|
||||
<span>닫기</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{activeTool === "MOVE" ? (
|
||||
<div className="self-center rounded-full border border-[#b8d3ef] bg-[rgba(242,248,255,0.96)] px-3 py-1 text-[12px] font-medium text-[#175f9c] shadow-[0_6px_18px_rgba(23,42,70,0.1)]">
|
||||
이동 대상 선택 후 파란 미리보기 객체를 드래그해 위치를 조정하세요.
|
||||
</div>
|
||||
) : null}
|
||||
{activeTool === "VERTEX_EDIT" ? (
|
||||
<div className="self-center rounded-full border border-[#f2d18d] bg-[rgba(255,248,235,0.96)] px-3 py-1 text-[12px] font-medium text-[#9a5b00] shadow-[0_6px_18px_rgba(23,42,70,0.1)]">
|
||||
편집 대상 선택 후 주황색 꼭지점 handle을 드래그해 형상을 조정하세요.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> | null;
|
||||
afterGeometry?: Record<string, unknown> | null;
|
||||
featureProperties?: Record<string, unknown> | null;
|
||||
basicInfo?: GisEditPipeBasicInfo | null;
|
||||
basicInfoSavedAt?: string | null;
|
||||
attributeOnly?: boolean;
|
||||
summary: string;
|
||||
createdAt: string;
|
||||
source: "MAP_SELECTION" | "MAP_CENTER" | "MAP_DRAW";
|
||||
@@ -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<string, unknown> | null;
|
||||
afterGeometry?: Record<string, unknown> | null;
|
||||
geometry?: Record<string, unknown> | null;
|
||||
|
||||
@@ -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<GisEditChange, "editType" | "layerName" | "objectLabel">,
|
||||
) => {
|
||||
@@ -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<GisEditPipeBasicInfo>((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<string, unknown> | 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<string, unknown> | null,
|
||||
): Record<string, unknown> | 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<GisEditTool | null>(null);
|
||||
// 저장 전까지의 변경분은 FE 로컬 편집 세션으로만 유지하고 BE API 연결 전 payload 검토 근거로 사용한다.
|
||||
// 저장 전까지의 변경분은 FE 로컬 편집 세션으로만 유지한다.
|
||||
const [changes, setChanges] = useState<GisEditChange[]>([]);
|
||||
const [statusMessage, setStatusMessage] = useState(
|
||||
"GIS편집 버튼을 켜면 레이어를 선택하고 편집 도구를 사용할 수 있습니다.",
|
||||
"GIS편집 버튼을 켜면 하수관로 편집 도구를 사용할 수 있습니다.",
|
||||
);
|
||||
// 실제 저장 대신 마지막으로 조립한 payload를 세션 패널에서 미리보기로 노출한다.
|
||||
// 이전 구현 호환용 상태다. 저장 버튼은 payload 미리보기를 노출하지 않고 즉시 API 호출 흐름으로 넘어간다.
|
||||
const [lastSavedPayload, setLastSavedPayload] = useState<GisEditSavePayload | null>(null);
|
||||
const [pendingInteraction, setPendingInteraction] = useState<GisEditInteractionRequest | null>(null);
|
||||
const [focusedChangeId, setFocusedChangeId] = useState<string | null>(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<string, unknown> | 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<GisEditTool, string> = {
|
||||
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<string, unknown> | 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,
|
||||
|
||||
+103
-11
@@ -61,6 +61,11 @@ const linkedFacilityHighlightStyle = new Style({
|
||||
zIndex: 9999,
|
||||
});
|
||||
|
||||
type MngInstOption = {
|
||||
mngInstCd: string;
|
||||
lwrInstNm: string;
|
||||
};
|
||||
|
||||
const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData: any }) => {
|
||||
const { gisMap } = useGisContext() as unknown as { checkedLayerIds: React.MutableRefObject<number[]>; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; };
|
||||
const [layerNm, setLayerNm] = useState<string>(""); // 레이어 이름 상태
|
||||
@@ -80,6 +85,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
|
||||
const [chmbrYdnts, setChmbrYdnts] = useState<string>(''); // 로딩 상태
|
||||
const linkedFacilityFeatureRef = useRef<any>(null);
|
||||
const linkedFacilityOriginalStyleRef = useRef<any>(null);
|
||||
const [mngInstOptions, setMngInstOptions] = useState<MngInstOption[]>([]);
|
||||
|
||||
// 🎯 관로관리번호 추출
|
||||
const pplMngNo = featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || "";
|
||||
@@ -87,6 +93,10 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
|
||||
const featureId = typeof featureData.getId === 'function'
|
||||
? featureData.getId()
|
||||
: featureData.fid;
|
||||
const selectedLayerTypeName = featureData?.values_
|
||||
? featureData?.id_?.split('.')?.[0]
|
||||
: featureData?.lyrTypeNm;
|
||||
const isPipeFeature = selectedLayerTypeName === 'tb_ms_swage_ppl';
|
||||
|
||||
// 🎯 데이터 전처리 메모이제이션
|
||||
const processedData = useMemo(() => {
|
||||
@@ -98,6 +108,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<string, string>();
|
||||
mngInstOptions.forEach((option) => {
|
||||
const code = String(option.mngInstCd ?? "").trim();
|
||||
if (!code) return;
|
||||
map.set(code, option.lwrInstNm || code);
|
||||
});
|
||||
return map;
|
||||
}, [mngInstOptions]);
|
||||
|
||||
const withMngInstName = (data: any) => {
|
||||
if (!data || typeof data !== "object") return data;
|
||||
const mngInstCd = String(data.mng_inst_cd ?? data.mngInstCd ?? "").trim();
|
||||
if (!mngInstCd) return data;
|
||||
return {
|
||||
...data,
|
||||
mngInstNm: mngInstNameMap.get(mngInstCd) ?? data.mngInstNm ?? data.mng_inst_nm ?? mngInstCd,
|
||||
};
|
||||
};
|
||||
|
||||
const hasDetailValue = (value: unknown) => {
|
||||
if (value === null || value === undefined) return false;
|
||||
if (typeof value === "string") return value.trim() !== "";
|
||||
return true;
|
||||
};
|
||||
|
||||
const mergeDetailData = (baseData: any, detailData: any) => {
|
||||
if (!detailData || typeof detailData !== "object") return baseData;
|
||||
|
||||
const meaningfulDetailData = Object.entries(detailData).reduce<Record<string, unknown>>((acc, [key, value]) => {
|
||||
if (hasDetailValue(value)) acc[key] = value;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
...(baseData ?? {}),
|
||||
...meaningfulDetailData,
|
||||
};
|
||||
};
|
||||
|
||||
const clearLinkedFacilityHighlight = () => {
|
||||
if (linkedFacilityFeatureRef.current) {
|
||||
linkedFacilityFeatureRef.current.setStyle(linkedFacilityOriginalStyleRef.current ?? undefined);
|
||||
@@ -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_
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import { useState, type ComponentProps } from "react";
|
||||
|
||||
import { Button, Calendar, Popover, PopoverContent, PopoverTrigger } from "@common/components/ui";
|
||||
|
||||
type CalendarProps = ComponentProps<typeof Calendar>;
|
||||
|
||||
type SearchDatePickerProps = {
|
||||
selected?: Date;
|
||||
onSelect: (date: Date | undefined) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: CalendarProps["disabled"];
|
||||
defaultMonth?: CalendarProps["defaultMonth"];
|
||||
};
|
||||
|
||||
const formatDateLabel = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, "0");
|
||||
const day = `${date.getDate()}`.padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
export function SearchDatePicker({
|
||||
selected,
|
||||
onSelect,
|
||||
placeholder = "날짜 선택",
|
||||
className,
|
||||
disabled,
|
||||
defaultMonth,
|
||||
}: SearchDatePickerProps) {
|
||||
// 검색조건 달력은 날짜 선택과 동시에 Popover를 닫아야 하므로 open 상태를 화면 전용으로 제어한다.
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleSelect = (date: Date | undefined) => {
|
||||
onSelect(date);
|
||||
if (date) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={[
|
||||
"h-[3rem] w-[12rem] justify-start border-input !bg-background px-4 py-2 text-left text-[1.3rem] font-normal text-muted-foreground hover:!bg-accent",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
disabled={disabled === true}
|
||||
>
|
||||
<CalendarIcon className="!size-[1.6rem]" />
|
||||
{selected ? formatDateLabel(selected) : <span>{placeholder}</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<Calendar
|
||||
classNames={{ cell: "rounded-md" }}
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={handleSelect}
|
||||
disabled={disabled}
|
||||
defaultMonth={defaultMonth}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import { formatDate, handleDownload } from '@/components/lib/utils'
|
||||
import LoadingOverlay from '@/components/ui/LoadingBar'
|
||||
import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui'
|
||||
import ListDiv from '@common/business_components/ui/list_div'
|
||||
import { Button, DatePicker, Input, Label } from '@common/components/ui'
|
||||
import { Button, Input, Label } from '@common/components/ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { tableHeadlist } from '..'
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -31,6 +31,7 @@ import { ENDPOINT } from "@common/components/config";
|
||||
import { getUserInfo } from "@common/components/api";
|
||||
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
|
||||
import { SearchDatePicker } from '../_components/SearchDatePicker';
|
||||
|
||||
|
||||
const Page = () => {
|
||||
@@ -176,20 +177,16 @@ const Page = () => {
|
||||
<div className='flex items-center gap-5'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label className="w-20 text-xl text-gray-600">등록기간</Label>
|
||||
<DatePicker mode={'single'}
|
||||
<SearchDatePicker
|
||||
selected={fromDate}
|
||||
onSelect={(date: Date | undefined) => { if (date) setFromDate(date) }}
|
||||
modifiers={{
|
||||
disabled: { after: new Date() }
|
||||
}}
|
||||
disabled={{ after: new Date() }}
|
||||
/>
|
||||
<span>~</span>
|
||||
<DatePicker mode={'single'}
|
||||
<SearchDatePicker
|
||||
selected={toDate}
|
||||
onSelect={(date: Date | undefined) => { if (date) setToDate(date) }}
|
||||
modifiers={{
|
||||
disabled: { after: new Date() }
|
||||
}}
|
||||
disabled={{ after: new Date() }}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
|
||||
@@ -23,7 +23,7 @@ import { formatDate, handleDownload } from '@/components/lib/utils'
|
||||
import LoadingOverlay from '@/components/ui/LoadingBar'
|
||||
import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui'
|
||||
import ListDiv from '@common/business_components/ui/list_div'
|
||||
import { Button, DatePicker, Input, Label } from '@common/components/ui'
|
||||
import { Button, Input, Label } from '@common/components/ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { tableHeadlistOldPp } from '..'
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -31,6 +31,7 @@ import { ENDPOINT } from "@common/components/config";
|
||||
import { getUserInfo } from "@common/components/api";
|
||||
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
|
||||
import { SearchDatePicker } from '../_components/SearchDatePicker';
|
||||
|
||||
|
||||
const Page = () => {
|
||||
@@ -174,19 +175,15 @@ const Page = () => {
|
||||
<div className='flex items-center gap-4'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Label className="w-20 text-xl text-gray-600">등록기간</Label>
|
||||
<DatePicker mode={'single'}
|
||||
<SearchDatePicker
|
||||
selected={fromDate}
|
||||
onSelect={(date: Date | undefined) => { if (date) setFromDate(date) }}
|
||||
modifiers={{
|
||||
disabled: { after: new Date() }
|
||||
}} />
|
||||
disabled={{ after: new Date() }} />
|
||||
<span>~</span>
|
||||
<DatePicker mode={'single'}
|
||||
<SearchDatePicker
|
||||
selected={toDate}
|
||||
onSelect={(date: Date | undefined) => { if (date) setToDate(date) }}
|
||||
modifiers={{
|
||||
disabled: { after: new Date() }
|
||||
}} />
|
||||
disabled={{ after: new Date() }} />
|
||||
<div className='flex items-center'>
|
||||
<Label className="w-20 text-xl text-gray-600">등록자</Label>
|
||||
<Input
|
||||
|
||||
@@ -101,6 +101,32 @@ export const postData = async (url: string, data: object): Promise<any> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const postRawJson = async (url: string, data: object): Promise<any> => {
|
||||
const res = await fetch(resolveApiUrl(url), {
|
||||
method: "POST",
|
||||
headers: withSiteIdHeaders({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
handleUnauthorizedResponse();
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
const body = text && text.trim() ? JSON.parse(text) : null;
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
body?.message ??
|
||||
`Failed to post data: ${res.status} ${res.statusText}`;
|
||||
const error = new Error(message) as Error & { status?: number; body?: any };
|
||||
error.status = res.status;
|
||||
error.body = body;
|
||||
throw error;
|
||||
}
|
||||
return body;
|
||||
};
|
||||
|
||||
const parseDownloadFileName = (contentDisposition: string | null) => {
|
||||
if (!contentDisposition) return undefined;
|
||||
|
||||
|
||||
@@ -45,17 +45,37 @@ type UiTableProps = {
|
||||
onFormDataChange?: (formData: { [key: string]: string | number | boolean }) => void; // 추가
|
||||
};
|
||||
|
||||
type UiSelectOption =
|
||||
| string
|
||||
| {
|
||||
value?: string | number;
|
||||
label?: string;
|
||||
val?: string | number;
|
||||
text?: string;
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
type UiHeadProps = {
|
||||
id: string;
|
||||
title: string;
|
||||
align?: string;
|
||||
type?: string;
|
||||
options?: string[];
|
||||
options?: UiSelectOption[];
|
||||
placeholder?: string;
|
||||
isNumber?: boolean;
|
||||
length?: number;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
const getSelectOptionValue = (option: UiSelectOption) =>
|
||||
typeof option === "string" ? option : String(option.value ?? option.val ?? "");
|
||||
|
||||
const getSelectOptionLabel = (option: UiSelectOption) =>
|
||||
typeof option === "string" ? option : String(option.label ?? option.text ?? getSelectOptionValue(option));
|
||||
|
||||
const isSelectOptionHidden = (option: UiSelectOption) =>
|
||||
typeof option === "string" ? false : Boolean(option.hidden);
|
||||
|
||||
export function VerticalTable({
|
||||
headName,
|
||||
tableName1,
|
||||
@@ -153,6 +173,7 @@ export function VerticalTable({
|
||||
{/* 컬럼 제목 */}
|
||||
<TableCell className={`font-bold bg-blue-100 text-xs w-[120px] whitespace-nowrap`}>
|
||||
{head.title}
|
||||
{head.required ? <span className="ml-1 text-red-600">*</span> : null}
|
||||
</TableCell>
|
||||
{/* 데이터 값 또는 입력 필드 */}
|
||||
<TableCell className="text-xs">
|
||||
@@ -160,21 +181,28 @@ export function VerticalTable({
|
||||
head.type === "select" ? (
|
||||
<select
|
||||
id={head.id}
|
||||
value={String(formData[head.id]) ?? "-"}
|
||||
value={formData[head.id] !== undefined ? String(formData[head.id]) : ""}
|
||||
onChange={(e) => handleInputChange(head.id, e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-sm px-2 py-1 text-xl border-radius-0"
|
||||
>
|
||||
{head.options?.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
{head.options?.map((option, optionIndex) => {
|
||||
const optionValue = getSelectOptionValue(option);
|
||||
return (
|
||||
<option
|
||||
key={`${head.id}-${optionValue}-${optionIndex}`}
|
||||
value={optionValue}
|
||||
hidden={isSelectOptionHidden(option)}
|
||||
>
|
||||
{getSelectOptionLabel(option)}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
) : head.type === "input" ? (
|
||||
) : head.type === "input" || head.type === "date" ? (
|
||||
<div className="w-full">
|
||||
<input
|
||||
id={head.id}
|
||||
type="text"
|
||||
type={head.type === "date" ? "date" : "text"}
|
||||
value={String(formData[head.id] ?? "")}
|
||||
onChange={(e) => {
|
||||
if (head.isNumber) {
|
||||
@@ -241,8 +269,9 @@ export function VerticalTable({
|
||||
<TableRow key={head.id}>
|
||||
{/* 컬럼 제목 */}
|
||||
<TableCell className={`font-bold bg-blue-100 text-xs w-[120px] whitespace-nowrap`}>
|
||||
{head.title}
|
||||
</TableCell>
|
||||
{head.title}
|
||||
{head.required ? <span className="ml-1 text-red-600">*</span> : null}
|
||||
</TableCell>
|
||||
{/* 데이터 값 또는 입력 필드 */}
|
||||
<TableCell className="text-xs">
|
||||
{children && children[head.id] ? children[head.id] : isEditable2 ? (
|
||||
@@ -253,17 +282,24 @@ export function VerticalTable({
|
||||
onChange={(e) => handleInputChange(head.id, e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-sm px-2 py-1 text-xl border-radius-0"
|
||||
>
|
||||
{head.options?.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
{head.options?.map((option, optionIndex) => {
|
||||
const optionValue = getSelectOptionValue(option);
|
||||
return (
|
||||
<option
|
||||
key={`${head.id}-${optionValue}-${optionIndex}`}
|
||||
value={optionValue}
|
||||
hidden={isSelectOptionHidden(option)}
|
||||
>
|
||||
{getSelectOptionLabel(option)}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
) : head.type === "input" ? (
|
||||
) : head.type === "input" || head.type === "date" ? (
|
||||
<div className="w-full">
|
||||
<input
|
||||
id={head.id}
|
||||
type={head.isNumber ? "number" : "text"}
|
||||
type={head.type === "date" ? "date" : head.isNumber ? "number" : "text"}
|
||||
value={formData[head.id] !== undefined ? String(formData[head.id]) : ""}
|
||||
onChange={(e) => {
|
||||
if (head.isNumber) {
|
||||
@@ -333,4 +369,4 @@ export function VerticalTable({
|
||||
<Toaster position="top-center"></Toaster>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. 흡수한 기존 문서
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -14,6 +14,7 @@ export type WindowPopupDefinition = {
|
||||
title: string;
|
||||
width: number;
|
||||
height: number;
|
||||
placement?: "center" | "browser-left";
|
||||
requiredParams?: string[];
|
||||
};
|
||||
|
||||
|
||||
@@ -742,10 +742,13 @@ GisOlLayer.prototype = {
|
||||
const extent = this.combineExtents(geometries.map(g => g?.getExtent()));
|
||||
|
||||
let map = GisApp.Module.core.map;
|
||||
const targetZoom = 16;
|
||||
const currentZoom = Number(map.getView().getZoom());
|
||||
const maxZoom = Number.isFinite(currentZoom) && currentZoom > targetZoom ? currentZoom : targetZoom;
|
||||
map.getView().fit(extent, {
|
||||
duration: 300,
|
||||
padding: [50, 50, 50, 50],
|
||||
maxZoom: 16
|
||||
maxZoom: maxZoom
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -132,13 +132,15 @@ GisOlSelect.prototype = {
|
||||
const selectedLayer = e.target?.getLayer(feature) || feature.getLayer?.();
|
||||
const clckZoom = Number(selectedLayer?.get('clckZoom'));
|
||||
const targetZoom = Number.isFinite(clckZoom) && clckZoom > 0 ? clckZoom : 16;
|
||||
const currentZoom = Number(map.getView().getZoom());
|
||||
const maxZoom = Number.isFinite(currentZoom) && currentZoom > targetZoom ? currentZoom : targetZoom;
|
||||
const geometry = feature.getGeometry();
|
||||
if (geometry) {
|
||||
const extent = geometry.getExtent();
|
||||
map.getView().fit(extent, {
|
||||
duration: 300,
|
||||
padding: [50, 50, 50, 50],
|
||||
maxZoom: targetZoom
|
||||
maxZoom: maxZoom
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,12 +343,13 @@ GisOlStyle.prototype = {
|
||||
//레이어 스타일 반환
|
||||
getLayerStyle: function (layer, options) {
|
||||
let gobjTy = layer.get('gobjTy');
|
||||
if (!gobjTy) return;
|
||||
if (!gobjTy || typeof this[gobjTy] !== 'function') return;
|
||||
let style = this[gobjTy](layer, options);
|
||||
return style;
|
||||
},
|
||||
getLayerStyleFunction: function (layer, options) {
|
||||
let gobjTy = layer.get('gobjTy');
|
||||
if (!gobjTy || typeof this[gobjTy] !== 'function') return;
|
||||
let styleFunction = function (feature, resolution) {
|
||||
return this[gobjTy](layer, options, feature, resolution);
|
||||
}.bind(this);
|
||||
|
||||
Reference in New Issue
Block a user