feat: [planning-design] 메뉴 트리 편집 모달 UI 추가
- JSON prompt 방식 → 트리구조 모달 UI로 개선 - 상위/하위 메뉴 추가, 삭제, 이름 편집 지원 - 드래그 앤 드롭으로 메뉴 순서 변경 가능 - 접기/펼치기 토글 지원
This commit is contained in:
@@ -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>' +
|
||||
|
||||
Reference in New Issue
Block a user