feat: [planning-design] 메뉴 트리 편집 모달 UI 추가

- JSON prompt 방식 → 트리구조 모달 UI로 개선
- 상위/하위 메뉴 추가, 삭제, 이름 편집 지원
- 드래그 앤 드롭으로 메뉴 순서 변경 가능
- 접기/펼치기 토글 지원
This commit is contained in:
김보곤
2026-03-07 23:07:17 +09:00
parent 708cef2ec7
commit 0622fc2a34

View File

@@ -621,6 +621,57 @@
}
.sb-desc-remove:hover { color: #ef4444; background: #fef2f2; }
/* Menu Tree Editor Modal */
.sb-menu-modal-overlay {
position: fixed; inset: 0; z-index: 9999; background: rgba(0,0,0,0.4);
display: flex; align-items: center; justify-content: center;
}
.sb-menu-modal {
background: #fff; border-radius: 12px; width: 480px; max-height: 80vh;
display: flex; flex-direction: column; box-shadow: 0 25px 50px rgba(0,0,0,0.15);
}
.sb-menu-modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; border-bottom: 1px solid #e2e8f0;
}
.sb-menu-modal-header h3 { font-size: 15px; font-weight: 700; color: #1e293b; margin: 0; }
.sb-menu-modal-body { flex: 1; overflow-y: auto; padding: 16px 20px; }
.sb-menu-modal-footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 12px 20px; border-top: 1px solid #e2e8f0;
}
.sb-mt-item {
display: flex; align-items: center; gap: 6px; padding: 5px 0;
border-bottom: 1px solid #f8fafc;
}
.sb-mt-item:hover { background: #f8fafc; border-radius: 6px; }
.sb-mt-drag {
cursor: grab; color: #cbd5e1; flex-shrink: 0; font-size: 14px; padding: 0 2px;
user-select: none;
}
.sb-mt-drag:active { cursor: grabbing; }
.sb-mt-icon { flex-shrink: 0; width: 18px; text-align: center; color: #94a3b8; font-size: 11px; cursor: pointer; }
.sb-mt-icon:hover { color: #475569; }
.sb-mt-name {
flex: 1; border: none; outline: none; font-size: 13px; padding: 4px 6px;
border-radius: 4px; color: #334155; background: transparent;
}
.sb-mt-name:focus { background: #f1f5f9; }
.sb-mt-actions { display: flex; gap: 2px; flex-shrink: 0; }
.sb-mt-btn {
width: 24px; height: 24px; border: none; background: transparent;
color: #94a3b8; cursor: pointer; border-radius: 4px; font-size: 13px;
display: flex; align-items: center; justify-content: center;
}
.sb-mt-btn:hover { background: #f1f5f9; color: #475569; }
.sb-mt-btn.danger:hover { background: #fef2f2; color: #ef4444; }
.sb-mt-children { padding-left: 24px; border-left: 2px solid #e2e8f0; margin-left: 10px; }
.sb-mt-add-root {
display: flex; align-items: center; gap: 6px; padding: 10px 0 4px;
font-size: 12px; color: var(--pc-indigo); cursor: pointer; font-weight: 500;
}
.sb-mt-add-root:hover { color: #4f46e5; }
/* Phase Swimlane (Timeline View) */
.pc-swimlane {
position: absolute; top: 0;
@@ -1233,6 +1284,61 @@
</div>{{-- /Main Content Area --}}
</div>
{{-- Menu Tree Editor Modal --}}
<template x-if="sbMenuEditorOpen">
<div class="sb-menu-modal-overlay" @click.self="sbMenuEditorOpen = false" @keydown.escape.window="sbMenuEditorOpen = false">
<div class="sb-menu-modal" @click.stop>
<div class="sb-menu-modal-header">
<h3>ERP 메뉴 트리 편집</h3>
<button class="sb-mt-btn" @click="sbMenuEditorOpen = false" style="width:28px;height:28px;font-size:16px;">×</button>
</div>
<div class="sb-menu-modal-body">
<template x-for="(menu, mi) in sbMenuDraft" :key="mi">
<div>
<div class="sb-mt-item" draggable="true"
@dragstart="sbMenuDragStart('root', mi, $event)"
@dragover.prevent="sbMenuDragOver('root', mi, $event)"
@drop="sbMenuDrop('root', mi, $event)"
@dragend="sbMenuDragEnd()">
<span class="sb-mt-drag" title="드래그하여 순서 변경"></span>
<span class="sb-mt-icon" @click="sbMenuToggle(mi)" x-text="(menu.children && menu.children.length) ? (menu._open !== false ? '▾' : '▸') : '·'"></span>
<input type="text" class="sb-mt-name" x-model="menu.name" placeholder="메뉴명 입력" style="font-weight:600;">
<div class="sb-mt-actions">
<button class="sb-mt-btn" @click="sbMenuAddChild(mi)" title="하위 메뉴 추가">+</button>
<button class="sb-mt-btn danger" @click="sbMenuRemove(mi)" title="삭제">×</button>
</div>
</div>
<div class="sb-mt-children" x-show="menu._open !== false">
<template x-for="(child, ci) in (menu.children || [])" :key="ci">
<div class="sb-mt-item" draggable="true"
@dragstart="sbMenuDragStart('child', mi + '-' + ci, $event)"
@dragover.prevent="sbMenuDragOver('child', mi + '-' + ci, $event)"
@drop="sbMenuDrop('child', mi + '-' + ci, $event)"
@dragend="sbMenuDragEnd()">
<span class="sb-mt-drag" title="드래그하여 순서 변경"></span>
<span class="sb-mt-icon" style="color:#cbd5e1;"></span>
<input type="text" class="sb-mt-name" x-model="child.name" placeholder="하위 메뉴명">
<div class="sb-mt-actions">
<button class="sb-mt-btn danger" @click="menu.children.splice(ci, 1)" title="삭제">×</button>
</div>
</div>
</template>
<div class="sb-mt-add-root" style="padding:4px 0 2px; font-size:11px;" @click="sbMenuAddChild(mi)">+ 하위 메뉴 추가</div>
</div>
</div>
</template>
<div class="sb-mt-add-root" @click="sbMenuDraft.push({ name: '', children: [], _open: true })">+ 상위 메뉴 추가</div>
</div>
<div class="sb-menu-modal-footer">
<button style="padding:6px 16px; border:1px solid #e2e8f0; border-radius:6px; font-size:12px; cursor:pointer; background:#fff; color:#64748b;"
@click="sbMenuEditorOpen = false">취소</button>
<button style="padding:6px 16px; border:none; border-radius:6px; font-size:12px; cursor:pointer; background:var(--pc-indigo); color:#fff; font-weight:600;"
@click="sbMenuApply()">적용</button>
</div>
</div>
</div>
</template>
{{-- Node Detail Modal --}}
<template x-if="modalNode">
<div class="pc-modal-overlay" @click.self="closeNodeModal()" @keydown.escape.window="closeNodeModal()">
@@ -1394,6 +1500,9 @@ function planningCanvas() {
_connTick: 0,
// Storyboard
sbMenuEditorOpen: false,
sbMenuDraft: [],
_sbMenuDrag: null,
sb: {
docInfo: { projectName: '', unitTask: '', version: 'D1.0' },
menuTree: [
@@ -2138,17 +2247,85 @@ function planningCanvas() {
},
sbEditMenu() {
const json = JSON.stringify(this.sb.menuTree, null, 2);
const input = prompt('메뉴 트리 JSON을 편집하세요:\n(취소하면 변경 없음)', json);
if (input === null) return;
try {
this.sb.menuTree = JSON.parse(input);
this.autoSave();
} catch (err) {
alert('JSON 형식이 올바르지 않습니다.');
// deep copy menuTree → draft (+ _open 플래그 추가)
this.sbMenuDraft = JSON.parse(JSON.stringify(this.sb.menuTree)).map(m => {
m.children = m.children || [];
m._open = true;
return m;
});
this.sbMenuEditorOpen = true;
},
sbMenuApply() {
// _open 플래그 제거 후 적용
this.sb.menuTree = this.sbMenuDraft.map(m => {
const { _open, ...rest } = m;
rest.children = (rest.children || []).map(c => ({ name: c.name }));
return rest;
}).filter(m => m.name.trim() !== '');
this.sbMenuEditorOpen = false;
this.autoSave();
},
sbMenuToggle(mi) {
this.sbMenuDraft[mi]._open = this.sbMenuDraft[mi]._open === false ? true : false;
},
sbMenuAddChild(mi) {
if (!this.sbMenuDraft[mi].children) this.sbMenuDraft[mi].children = [];
this.sbMenuDraft[mi].children.push({ name: '' });
this.sbMenuDraft[mi]._open = true;
},
sbMenuRemove(mi) {
if (this.sbMenuDraft[mi].children?.length > 0 && !confirm('하위 메뉴도 함께 삭제됩니다. 계속하시겠습니까?')) return;
this.sbMenuDraft.splice(mi, 1);
},
sbMenuDragStart(level, key, e) {
this._sbMenuDrag = { level, key };
e.dataTransfer.effectAllowed = 'move';
},
sbMenuDragOver(level, key, e) {
if (!this._sbMenuDrag) return;
e.dataTransfer.dropEffect = 'move';
},
sbMenuDrop(level, key, e) {
if (!this._sbMenuDrag) return;
const src = this._sbMenuDrag;
this._sbMenuDrag = null;
if (src.level !== level) return;
if (level === 'root') {
const fromIdx = parseInt(src.key);
const toIdx = parseInt(key);
if (fromIdx === toIdx) return;
const item = this.sbMenuDraft.splice(fromIdx, 1)[0];
this.sbMenuDraft.splice(toIdx, 0, item);
} else {
// child: key format = "parentIdx-childIdx"
const [spi, sci] = src.key.split('-').map(Number);
const [dpi, dci] = key.split('-').map(Number);
if (spi === dpi && sci === dci) return;
if (spi === dpi) {
// 같은 부모 내 이동
const children = this.sbMenuDraft[spi].children;
const item = children.splice(sci, 1)[0];
children.splice(dci, 0, item);
} else {
// 다른 부모로 이동
const item = this.sbMenuDraft[spi].children.splice(sci, 1)[0];
this.sbMenuDraft[dpi].children.splice(dci, 0, item);
}
}
},
sbMenuDragEnd() {
this._sbMenuDrag = null;
},
sbExportHtml() {
let html = '<!DOCTYPE html><html><head><meta charset="utf-8"><title>' +
(this.sb.docInfo.projectName || 'Storyboard') + '</title>' +