feat: [planning-design] 스토리보드 블록 템플릿 시스템 추가
- 기본 프리셋 9종: 검색+목록, 상세폼, CRUD, 대시보드, 결재폼, 탭 레이아웃, 팝업/모달, 로그인, 빈 페이지 - 내 템플릿 저장/삽입/삭제 (localStorage 영구 보관) - 템플릿 검색 필터, 프리셋/커스텀 탭 분리 - 현재 페이지 블록을 한 번에 템플릿으로 저장하여 재활용
This commit is contained in:
@@ -736,6 +736,74 @@
|
||||
}
|
||||
.sb-blk-empty-area:hover { border-color: var(--pc-indigo); background: #fafafe; }
|
||||
|
||||
/* Template Dropdown */
|
||||
.sb-tpl-dropdown {
|
||||
position: relative; display: inline-block;
|
||||
}
|
||||
.sb-tpl-trigger {
|
||||
padding: 3px 10px; border: 1px solid #e2e8f0; border-radius: 5px;
|
||||
font-size: 10px; cursor: pointer; background: #fff; color: #475569;
|
||||
display: flex; align-items: center; gap: 4px; white-space: nowrap;
|
||||
transition: all .12s;
|
||||
}
|
||||
.sb-tpl-trigger:hover { border-color: var(--pc-indigo); color: var(--pc-indigo); }
|
||||
.sb-tpl-panel {
|
||||
position: absolute; top: 100%; right: 0; margin-top: 4px; z-index: 100;
|
||||
width: 360px; max-height: 480px; background: #fff;
|
||||
border: 1px solid #e2e8f0; border-radius: 10px;
|
||||
box-shadow: 0 12px 32px rgba(0,0,0,0.12); display: flex; flex-direction: column;
|
||||
}
|
||||
.sb-tpl-tabs {
|
||||
display: flex; border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.sb-tpl-tab {
|
||||
flex: 1; padding: 8px; font-size: 11px; font-weight: 600; text-align: center;
|
||||
cursor: pointer; color: #94a3b8; border-bottom: 2px solid transparent;
|
||||
transition: all .12s;
|
||||
}
|
||||
.sb-tpl-tab:hover { color: #475569; }
|
||||
.sb-tpl-tab.active { color: var(--pc-indigo); border-bottom-color: var(--pc-indigo); }
|
||||
.sb-tpl-search {
|
||||
margin: 8px 10px 4px; padding: 6px 10px; border: 1px solid #e2e8f0;
|
||||
border-radius: 6px; font-size: 11px; outline: none; width: calc(100% - 20px);
|
||||
}
|
||||
.sb-tpl-search:focus { border-color: var(--pc-indigo); }
|
||||
.sb-tpl-list { flex: 1; overflow-y: auto; padding: 4px 6px 8px; }
|
||||
.sb-tpl-item {
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 10px;
|
||||
border-radius: 6px; cursor: pointer; transition: all .1s;
|
||||
}
|
||||
.sb-tpl-item:hover { background: #f1f5f9; }
|
||||
.sb-tpl-item-icon {
|
||||
width: 32px; height: 32px; border-radius: 6px; background: #f1f5f9;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sb-tpl-item-info { flex: 1; min-width: 0; }
|
||||
.sb-tpl-item-name { font-size: 12px; font-weight: 600; color: #1e293b; }
|
||||
.sb-tpl-item-desc { font-size: 10px; color: #94a3b8; margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sb-tpl-item-actions { display: flex; gap: 2px; flex-shrink: 0; }
|
||||
.sb-tpl-item-btn {
|
||||
width: 22px; height: 22px; border: none; background: transparent; color: #cbd5e1;
|
||||
border-radius: 4px; cursor: pointer; font-size: 12px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.sb-tpl-item-btn:hover { background: #fee2e2; color: #ef4444; }
|
||||
.sb-tpl-save-bar {
|
||||
padding: 8px 10px; border-top: 1px solid #e2e8f0; display: flex; gap: 6px; align-items: center;
|
||||
}
|
||||
.sb-tpl-save-input {
|
||||
flex: 1; padding: 5px 8px; border: 1px solid #e2e8f0; border-radius: 5px;
|
||||
font-size: 11px; outline: none;
|
||||
}
|
||||
.sb-tpl-save-input:focus { border-color: var(--pc-indigo); }
|
||||
.sb-tpl-save-btn {
|
||||
padding: 5px 12px; border: none; border-radius: 5px; font-size: 11px;
|
||||
font-weight: 600; cursor: pointer; background: var(--pc-indigo); color: #fff;
|
||||
}
|
||||
.sb-tpl-save-btn:hover { opacity: 0.9; }
|
||||
.sb-tpl-empty { text-align: center; padding: 20px; color: #cbd5e1; font-size: 11px; }
|
||||
|
||||
/* Menu Tree Editor Modal */
|
||||
.sb-menu-modal-overlay {
|
||||
position: fixed; inset: 0; z-index: 9999; background: rgba(0,0,0,0.4);
|
||||
@@ -1358,6 +1426,62 @@
|
||||
<button class="sb-block-toolbar-btn" @click="sbAddBlock('image')">🖼 이미지</button>
|
||||
<button class="sb-block-toolbar-btn" @click="sbAddBlock('divider')">― 구분선</button>
|
||||
<input type="file" accept="image/*" x-ref="sbBlockImageInput" style="display:none;" @change="sbBlockUploadImage($event)">
|
||||
<div class="sb-block-toolbar-sep"></div>
|
||||
{{-- Template Dropdown --}}
|
||||
<div class="sb-tpl-dropdown" @click.outside="sbTplOpen = false">
|
||||
<button class="sb-tpl-trigger" @click="sbTplOpen = !sbTplOpen">📋 템플릿 <span style="font-size:8px;">▾</span></button>
|
||||
<div class="sb-tpl-panel" x-show="sbTplOpen" x-cloak x-transition>
|
||||
<div class="sb-tpl-tabs">
|
||||
<div class="sb-tpl-tab" :class="{ active: sbTplTab === 'preset' }" @click="sbTplTab = 'preset'">기본 템플릿</div>
|
||||
<div class="sb-tpl-tab" :class="{ active: sbTplTab === 'custom' }" @click="sbTplTab = 'custom'">내 템플릿</div>
|
||||
</div>
|
||||
<input class="sb-tpl-search" type="text" placeholder="템플릿 검색..." x-model="sbTplSearch">
|
||||
<div class="sb-tpl-list">
|
||||
{{-- Preset Templates --}}
|
||||
<template x-if="sbTplTab === 'preset'">
|
||||
<div>
|
||||
<template x-for="tpl in sbFilteredPresets" :key="tpl.name">
|
||||
<div class="sb-tpl-item" @click="sbInsertTemplate(tpl); sbTplOpen = false;">
|
||||
<div class="sb-tpl-item-icon" x-text="tpl.icon"></div>
|
||||
<div class="sb-tpl-item-info">
|
||||
<div class="sb-tpl-item-name" x-text="tpl.name"></div>
|
||||
<div class="sb-tpl-item-desc" x-text="tpl.desc"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="sb-tpl-empty" x-show="sbFilteredPresets.length === 0">검색 결과가 없습니다</div>
|
||||
</div>
|
||||
</template>
|
||||
{{-- Custom Templates --}}
|
||||
<template x-if="sbTplTab === 'custom'">
|
||||
<div>
|
||||
<template x-for="(tpl, ti) in sbFilteredCustoms" :key="tpl.name">
|
||||
<div class="sb-tpl-item">
|
||||
<div class="sb-tpl-item-icon">📄</div>
|
||||
<div class="sb-tpl-item-info" @click="sbInsertTemplate(tpl); sbTplOpen = false;">
|
||||
<div class="sb-tpl-item-name" x-text="tpl.name"></div>
|
||||
<div class="sb-tpl-item-desc" x-text="tpl.blocks.length + '개 블록'"></div>
|
||||
</div>
|
||||
<div class="sb-tpl-item-actions">
|
||||
<button class="sb-tpl-item-btn" @click.stop="sbDeleteCustomTemplate(ti)" title="삭제">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="sb-tpl-empty" x-show="sbFilteredCustoms.length === 0 && !sbTplSearch">
|
||||
저장된 템플릿이 없습니다<br>
|
||||
<span style="font-size:10px;">현재 페이지 블록을 아래에서 저장하세요</span>
|
||||
</div>
|
||||
<div class="sb-tpl-empty" x-show="sbFilteredCustoms.length === 0 && sbTplSearch">검색 결과가 없습니다</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{{-- Save current blocks as template --}}
|
||||
<div class="sb-tpl-save-bar">
|
||||
<input class="sb-tpl-save-input" type="text" placeholder="현재 블록을 템플릿으로 저장..." x-model="sbTplSaveName" @keydown.enter="sbSaveAsTemplate()">
|
||||
<button class="sb-tpl-save-btn" @click="sbSaveAsTemplate()" :disabled="!sbTplSaveName.trim()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Block Editor Area --}}
|
||||
@@ -1818,6 +1942,11 @@ function planningCanvas() {
|
||||
sbSelectedBlock: null,
|
||||
sbBlockImageTarget: null,
|
||||
_sbBlockDragIdx: null,
|
||||
sbTplOpen: false,
|
||||
sbTplTab: 'preset',
|
||||
sbTplSearch: '',
|
||||
sbTplSaveName: '',
|
||||
sbCustomTemplates: [],
|
||||
sb: {
|
||||
docInfo: { projectName: '', unitTask: '', version: 'D1.0' },
|
||||
menuTree: [
|
||||
@@ -1920,6 +2049,149 @@ function planningCanvas() {
|
||||
return page.blocks;
|
||||
},
|
||||
|
||||
get sbPresetTemplates() {
|
||||
return [
|
||||
{ name: '검색 + 목록 화면', icon: '🔍', desc: '검색조건 + 데이터 테이블', blocks: [
|
||||
{ type: 'heading2', content: '검색 조건' },
|
||||
{ type: 'input', label: '검색어', placeholder: '품명, 품번 등을 입력하세요' },
|
||||
{ type: 'badges', items: [
|
||||
{ text: '검색', color: '#4338ca', textColor: '#fff' },
|
||||
{ text: '초기화', color: '#f1f5f9', textColor: '#475569' },
|
||||
]},
|
||||
{ type: 'divider', content: '' },
|
||||
{ type: 'heading2', content: '목록' },
|
||||
{ type: 'table', cols: ['No', '항목명', '상태', '담당자', '등록일', '비고'], rows: [
|
||||
['1', '샘플 항목 A', '진행중', '홍길동', '2026-03-01', ''],
|
||||
['2', '샘플 항목 B', '대기', '김철수', '2026-03-02', ''],
|
||||
['3', '', '', '', '', ''],
|
||||
]},
|
||||
{ type: 'badges', items: [
|
||||
{ text: '총 3건', color: '#e0e7ff', textColor: '#4338ca' },
|
||||
]},
|
||||
]},
|
||||
{ name: '상세 정보 폼', icon: '📝', desc: '라벨+입력 필드 그룹', blocks: [
|
||||
{ type: 'heading', content: '상세 정보' },
|
||||
{ type: 'input', label: '품명', placeholder: '품명을 입력하세요' },
|
||||
{ type: 'input', label: '품번', placeholder: '품번을 입력하세요' },
|
||||
{ type: 'select', label: '분류', placeholder: '분류를 선택하세요' },
|
||||
{ type: 'input', label: '규격', placeholder: '규격 정보' },
|
||||
{ type: 'text', content: '' },
|
||||
{ type: 'divider', content: '' },
|
||||
{ type: 'badges', items: [
|
||||
{ text: '저장', color: '#4338ca', textColor: '#fff' },
|
||||
{ text: '취소', color: '#f1f5f9', textColor: '#475569' },
|
||||
]},
|
||||
]},
|
||||
{ name: 'CRUD 화면', icon: '⚙️', desc: '제목 + 검색 + 테이블 + 버튼 조합', blocks: [
|
||||
{ type: 'heading', content: '관리 화면 제목' },
|
||||
{ type: 'text', content: '화면 설명 텍스트를 입력하세요.' },
|
||||
{ type: 'divider', content: '' },
|
||||
{ type: 'heading2', content: '검색 조건' },
|
||||
{ type: 'input', label: '검색어', placeholder: '검색어 입력' },
|
||||
{ type: 'select', label: '상태', placeholder: '전체' },
|
||||
{ type: 'badges', items: [
|
||||
{ text: '검색', color: '#4338ca', textColor: '#fff' },
|
||||
{ text: '초기화', color: '#f1f5f9', textColor: '#475569' },
|
||||
{ text: '+ 신규등록', color: '#10b981', textColor: '#fff' },
|
||||
{ text: '엑셀 다운로드', color: '#f1f5f9', textColor: '#475569' },
|
||||
]},
|
||||
{ type: 'divider', content: '' },
|
||||
{ type: 'table', cols: ['선택', 'No', '항목명', '분류', '상태', '담당자', '수정일'], rows: [
|
||||
['☐', '1', '', '', '', '', ''],
|
||||
['☐', '2', '', '', '', '', ''],
|
||||
]},
|
||||
{ type: 'badges', items: [
|
||||
{ text: '선택 삭제', color: '#fef2f2', textColor: '#ef4444' },
|
||||
{ text: '일괄 수정', color: '#e0e7ff', textColor: '#4338ca' },
|
||||
]},
|
||||
]},
|
||||
{ name: '대시보드 카드', icon: '📊', desc: '통계 카드 + 요약 테이블', blocks: [
|
||||
{ type: 'heading', content: '대시보드' },
|
||||
{ type: 'card', title: '총 주문건', content: '1,234건 (+12.5% ↑)' },
|
||||
{ type: 'card', title: '매출 합계', content: '₩85,600,000' },
|
||||
{ type: 'card', title: '미처리 건수', content: '23건' },
|
||||
{ type: 'divider', content: '' },
|
||||
{ type: 'heading2', content: '최근 현황' },
|
||||
{ type: 'table', cols: ['구분', '이번 달', '전월', '증감률'], rows: [
|
||||
['수주', '450건', '380건', '+18.4%'],
|
||||
['출하', '420건', '395건', '+6.3%'],
|
||||
['반품', '12건', '18건', '-33.3%'],
|
||||
]},
|
||||
]},
|
||||
{ name: '결재/승인 폼', icon: '✅', desc: '결재라인 + 체크리스트 + 버튼', blocks: [
|
||||
{ type: 'heading', content: '결재 요청서' },
|
||||
{ type: 'table', cols: ['구분', '기안자', '검토', '승인'], rows: [
|
||||
['직급', '사원', '과장', '부장'],
|
||||
['성명', '', '', ''],
|
||||
['상태', '기안', '대기', '대기'],
|
||||
]},
|
||||
{ type: 'divider', content: '' },
|
||||
{ type: 'heading2', content: '요청 내용' },
|
||||
{ type: 'text', content: '결재 요청 상세 내용을 입력하세요.' },
|
||||
{ type: 'divider', content: '' },
|
||||
{ type: 'todo', items: [
|
||||
{ text: '첨부파일 확인', checked: false },
|
||||
{ text: '금액 확인', checked: false },
|
||||
{ text: '기안 내용 검토', checked: false },
|
||||
]},
|
||||
{ type: 'badges', items: [
|
||||
{ text: '결재 요청', color: '#4338ca', textColor: '#fff' },
|
||||
{ text: '임시 저장', color: '#f1f5f9', textColor: '#475569' },
|
||||
]},
|
||||
]},
|
||||
{ name: '탭 레이아웃', icon: '📑', desc: '탭 메뉴 + 콘텐츠 영역', blocks: [
|
||||
{ type: 'heading', content: '화면 제목' },
|
||||
{ type: 'badges', items: [
|
||||
{ text: '기본정보', color: '#4338ca', textColor: '#fff' },
|
||||
{ text: 'BOM', color: '#f1f5f9', textColor: '#475569' },
|
||||
{ text: '이력', color: '#f1f5f9', textColor: '#475569' },
|
||||
{ text: '첨부파일', color: '#f1f5f9', textColor: '#475569' },
|
||||
]},
|
||||
{ type: 'divider', content: '' },
|
||||
{ type: 'callout', icon: 'ℹ️', content: '선택한 탭의 콘텐츠가 여기에 표시됩니다.' },
|
||||
{ type: 'text', content: '' },
|
||||
]},
|
||||
{ name: '팝업/모달', icon: '💬', desc: '모달 다이얼로그 UI', blocks: [
|
||||
{ type: 'card', title: '모달 제목', content: '' },
|
||||
{ type: 'text', content: '모달 본문 내용을 입력하세요.' },
|
||||
{ type: 'input', label: '입력 항목', placeholder: '값을 입력하세요' },
|
||||
{ type: 'divider', content: '' },
|
||||
{ type: 'badges', items: [
|
||||
{ text: '확인', color: '#4338ca', textColor: '#fff' },
|
||||
{ text: '취소', color: '#f1f5f9', textColor: '#475569' },
|
||||
]},
|
||||
]},
|
||||
{ name: '로그인 화면', icon: '🔐', desc: '로그인 폼 UI', blocks: [
|
||||
{ type: 'heading', content: '로그인' },
|
||||
{ type: 'text', content: '시스템에 접속하려면 로그인하세요.' },
|
||||
{ type: 'input', label: '아이디', placeholder: 'user@company.com' },
|
||||
{ type: 'input', label: '비밀번호', placeholder: '••••••••' },
|
||||
{ type: 'todo', items: [
|
||||
{ text: '자동 로그인', checked: false },
|
||||
]},
|
||||
{ type: 'button', content: '로그인', color: '#4338ca' },
|
||||
{ type: 'text', content: '비밀번호를 잊으셨나요?' },
|
||||
]},
|
||||
{ name: '빈 페이지 (기본 구조)', icon: '📄', desc: '제목 + 설명 + 구분선', blocks: [
|
||||
{ type: 'heading', content: '' },
|
||||
{ type: 'text', content: '' },
|
||||
{ type: 'divider', content: '' },
|
||||
]},
|
||||
];
|
||||
},
|
||||
|
||||
get sbFilteredPresets() {
|
||||
const q = this.sbTplSearch.toLowerCase().trim();
|
||||
if (!q) return this.sbPresetTemplates;
|
||||
return this.sbPresetTemplates.filter(t => t.name.toLowerCase().includes(q) || t.desc.toLowerCase().includes(q));
|
||||
},
|
||||
|
||||
get sbFilteredCustoms() {
|
||||
const q = this.sbTplSearch.toLowerCase().trim();
|
||||
if (!q) return this.sbCustomTemplates;
|
||||
return this.sbCustomTemplates.filter(t => t.name.toLowerCase().includes(q));
|
||||
},
|
||||
|
||||
get allPaletteItems() {
|
||||
return [
|
||||
...this.paletteItems.planning,
|
||||
@@ -1970,6 +2242,7 @@ function planningCanvas() {
|
||||
this.newProject();
|
||||
}
|
||||
this.sbInitPages();
|
||||
this.sbCustomTemplates = JSON.parse(localStorage.getItem('sb_custom_templates') || '[]');
|
||||
},
|
||||
|
||||
// ===== Project Management =====
|
||||
@@ -2688,6 +2961,42 @@ function planningCanvas() {
|
||||
this.sbBlockImageTarget = null;
|
||||
},
|
||||
|
||||
// ===== Template System =====
|
||||
sbInsertTemplate(tpl) {
|
||||
const page = this.sbCurrentPage;
|
||||
if (!page) return;
|
||||
if (!page.blocks) page.blocks = [];
|
||||
const blocks = JSON.parse(JSON.stringify(tpl.blocks));
|
||||
blocks.forEach(blk => {
|
||||
blk.id = 'blk_' + Date.now() + '_' + Math.random().toString(36).slice(2, 5);
|
||||
});
|
||||
page.blocks.push(...blocks);
|
||||
this.autoSave();
|
||||
},
|
||||
|
||||
sbSaveAsTemplate() {
|
||||
const name = this.sbTplSaveName.trim();
|
||||
if (!name) return;
|
||||
const page = this.sbCurrentPage;
|
||||
if (!page || !page.blocks || page.blocks.length === 0) {
|
||||
alert('현재 페이지에 블록이 없습니다.');
|
||||
return;
|
||||
}
|
||||
const blocks = JSON.parse(JSON.stringify(page.blocks));
|
||||
// id 제거 (삽입 시 새로 생성)
|
||||
blocks.forEach(blk => { delete blk.id; });
|
||||
this.sbCustomTemplates.push({ name, blocks });
|
||||
localStorage.setItem('sb_custom_templates', JSON.stringify(this.sbCustomTemplates));
|
||||
this.sbTplSaveName = '';
|
||||
this.sbTplTab = 'custom';
|
||||
},
|
||||
|
||||
sbDeleteCustomTemplate(idx) {
|
||||
if (!confirm('이 템플릿을 삭제하시겠습니까?')) return;
|
||||
this.sbCustomTemplates.splice(idx, 1);
|
||||
localStorage.setItem('sb_custom_templates', JSON.stringify(this.sbCustomTemplates));
|
||||
},
|
||||
|
||||
sbEditMenu() {
|
||||
// deep copy menuTree → draft (+ _open 플래그 추가)
|
||||
this.sbMenuDraft = JSON.parse(JSON.stringify(this.sb.menuTree)).map(m => {
|
||||
|
||||
Reference in New Issue
Block a user