425 lines
17 KiB
PHP
425 lines
17 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', $isCreate ? '양식 디자이너 - 새 양식' : '양식 디자이너 - ' . $template->name)
|
|
|
|
@push('styles')
|
|
<style>
|
|
.block-builder { height: calc(100vh - 120px); }
|
|
.block-palette { width: 220px; }
|
|
.block-properties { width: 300px; }
|
|
.block-canvas { flex: 1; }
|
|
|
|
/* 블록 아이템 */
|
|
.block-item { transition: all 0.15s ease; }
|
|
.block-item:hover { box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5); }
|
|
.block-item.selected { box-shadow: 0 0 0 2px rgb(59, 130, 246); background-color: rgba(59, 130, 246, 0.03); }
|
|
|
|
/* 드래그 고스트 */
|
|
.sortable-ghost { opacity: 0.4; background: #dbeafe !important; }
|
|
.sortable-drag { box-shadow: 0 8px 25px rgba(0,0,0,0.15); }
|
|
|
|
/* 팔레트 드래그 아이템 */
|
|
.palette-item { cursor: grab; }
|
|
.palette-item:active { cursor: grabbing; }
|
|
|
|
/* 블록 핸들 */
|
|
.block-handle { cursor: grab; opacity: 0.3; transition: opacity 0.15s; }
|
|
.block-item:hover .block-handle { opacity: 1; }
|
|
|
|
/* 빈 캔버스 */
|
|
.canvas-empty { border: 2px dashed #d1d5db; min-height: 400px; }
|
|
.canvas-empty.sortable-drag-over { border-color: #3b82f6; background: #eff6ff; }
|
|
|
|
/* 속성 패널 입력 */
|
|
.prop-input { font-size: 0.8125rem; }
|
|
|
|
/* 테이블 블록 에디터 */
|
|
.table-cell-input { border: 1px solid transparent; padding: 2px 4px; min-width: 40px; }
|
|
.table-cell-input:focus { border-color: #3b82f6; outline: none; background: #fff; }
|
|
|
|
/* columns 블록 */
|
|
.column-drop-zone { min-height: 60px; border: 1px dashed #d1d5db; border-radius: 4px; }
|
|
.column-drop-zone.sortable-drag-over { border-color: #3b82f6; background: #eff6ff; }
|
|
</style>
|
|
@endpush
|
|
|
|
@section('content')
|
|
<div x-data="blockEditor({{ json_encode($initialSchema) }}, {{ $templateId }})"
|
|
x-init="init()"
|
|
@keydown.ctrl.z.prevent="undo()"
|
|
@keydown.ctrl.shift.z.prevent="redo()"
|
|
@keydown.meta.z.prevent="undo()"
|
|
@keydown.meta.shift.z.prevent="redo()"
|
|
@keydown.ctrl.s.prevent="save()"
|
|
@keydown.meta.s.prevent="save()"
|
|
class="block-builder flex flex-col">
|
|
|
|
<!-- 상단 툴바 -->
|
|
<div class="flex items-center justify-between bg-white border-b border-gray-200 px-4 py-2 shrink-0">
|
|
<div class="flex items-center gap-3">
|
|
<a href="{{ route('document-templates.index') }}" class="text-gray-500 hover:text-gray-700">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
|
</svg>
|
|
</a>
|
|
<div>
|
|
<input type="text" x-model="templateName" placeholder="양식 이름"
|
|
class="text-lg font-bold border-0 border-b border-transparent hover:border-gray-300 focus:border-blue-500 focus:ring-0 px-1 py-0 bg-transparent">
|
|
<span x-show="isDirty" class="text-xs text-orange-500 ml-2">저장 안됨</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<!-- Undo/Redo -->
|
|
<div class="flex items-center border border-gray-200 rounded-lg overflow-hidden">
|
|
<button @click="undo()" :disabled="historyIndex <= 0"
|
|
class="px-2 py-1.5 text-gray-600 hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed" title="되돌리기 (Ctrl+Z)">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a5 5 0 015 5v2M3 10l4-4m-4 4l4 4"/>
|
|
</svg>
|
|
</button>
|
|
<button @click="redo()" :disabled="historyIndex >= history.length - 1"
|
|
class="px-2 py-1.5 text-gray-600 hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed border-l border-gray-200" title="다시 실행 (Ctrl+Shift+Z)">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 10H11a5 5 0 00-5 5v2m15-7l-4-4m4 4l-4 4"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 페이지 설정 -->
|
|
<div class="flex items-center gap-1 border border-gray-200 rounded-lg px-2 py-1">
|
|
<select x-model="pageConfig.size" @change="markDirty()" class="text-xs border-0 focus:ring-0 bg-transparent py-0">
|
|
<option value="A4">A4</option>
|
|
<option value="A3">A3</option>
|
|
<option value="B5">B5</option>
|
|
<option value="Letter">Letter</option>
|
|
</select>
|
|
<select x-model="pageConfig.orientation" @change="markDirty()" class="text-xs border-0 focus:ring-0 bg-transparent py-0">
|
|
<option value="portrait">세로</option>
|
|
<option value="landscape">가로</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- 분류 -->
|
|
<select x-model="category" @change="markDirty()" class="text-xs border border-gray-200 rounded-lg px-2 py-1.5 focus:ring-1 focus:ring-blue-500">
|
|
<option value="">분류 없음</option>
|
|
@foreach($categories as $cat)
|
|
<option value="{{ $cat['code'] }}">{{ $cat['name'] }}</option>
|
|
@endforeach
|
|
</select>
|
|
|
|
<!-- 저장 -->
|
|
<button @click="save()" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-1.5 rounded-lg text-sm font-medium transition flex items-center gap-1.5">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
|
</svg>
|
|
저장
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 메인 영역 (3패널) -->
|
|
<div class="flex flex-1 overflow-hidden">
|
|
|
|
@include('document-templates.partials.block-palette')
|
|
|
|
@include('document-templates.partials.block-canvas')
|
|
|
|
@include('document-templates.partials.block-properties')
|
|
|
|
</div>
|
|
</div>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script>
|
|
function blockEditor(initialSchema, templateId) {
|
|
return {
|
|
// ===== 상태 =====
|
|
blocks: initialSchema?.blocks ?? [],
|
|
selectedBlockId: null,
|
|
history: [],
|
|
historyIndex: -1,
|
|
isDirty: false,
|
|
saving: false,
|
|
templateId: templateId,
|
|
templateName: initialSchema?._name ?? '새 문서양식',
|
|
category: initialSchema?._category ?? '',
|
|
pageConfig: initialSchema?.page ?? {
|
|
size: 'A4',
|
|
orientation: 'portrait',
|
|
margin: [20, 15, 20, 15]
|
|
},
|
|
|
|
// ===== Block 타입 레지스트리 =====
|
|
blockTypes: {
|
|
heading: { icon: 'H', label: '제목', category: 'basic', defaultProps: { level: 2, text: '제목을 입력하세요', align: 'left' } },
|
|
paragraph: { icon: 'T', label: '텍스트', category: 'basic', defaultProps: { text: '텍스트를 입력하세요', align: 'left' } },
|
|
table: { icon: '⊞', label: '테이블', category: 'basic', defaultProps: { headers: ['항목', '내용'], rows: [['', '']], showHeader: true } },
|
|
columns: { icon: '▥', label: '다단', category: 'basic', defaultProps: { count: 2, children: [[], []] } },
|
|
divider: { icon: '─', label: '구분선', category: 'basic', defaultProps: { style: 'solid' } },
|
|
spacer: { icon: '↕', label: '여백', category: 'basic', defaultProps: { height: 20 } },
|
|
text_field: { icon: '▤', label: '텍스트 입력', category: 'form', defaultProps: { label: '필드명', placeholder: '', required: false, binding: '' } },
|
|
number_field: { icon: '#', label: '숫자 입력', category: 'form', defaultProps: { label: '숫자', min: null, max: null, unit: '', decimal: 0 } },
|
|
date_field: { icon: '📅', label: '날짜', category: 'form', defaultProps: { label: '날짜', format: 'Y-m-d' } },
|
|
select_field: { icon: '▾', label: '선택', category: 'form', defaultProps: { label: '선택', options: ['옵션1', '옵션2'], multiple: false } },
|
|
checkbox_field:{ icon: '☑', label: '체크박스', category: 'form', defaultProps: { label: '체크', options: ['항목1'] } },
|
|
textarea_field:{ icon: '¶', label: '장문 입력', category: 'form', defaultProps: { label: '내용', rows: 3 } },
|
|
signature_field:{ icon: '✍', label: '서명란', category: 'form', defaultProps: { label: '서명', signer: '' } },
|
|
},
|
|
|
|
// ===== 초기화 =====
|
|
init() {
|
|
// blocks에 id가 없는 경우 생성
|
|
this.blocks = this.blocks.map(b => ({
|
|
...b,
|
|
id: b.id || this.generateId()
|
|
}));
|
|
this.pushHistory();
|
|
this.$nextTick(() => this.initSortable());
|
|
},
|
|
|
|
// ===== ID 생성 =====
|
|
generateId() {
|
|
return 'b_' + Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 5);
|
|
},
|
|
|
|
// ===== Block CRUD =====
|
|
addBlock(type, afterIndex = null) {
|
|
const def = this.blockTypes[type];
|
|
if (!def) return;
|
|
|
|
const newBlock = {
|
|
id: this.generateId(),
|
|
type: type,
|
|
props: JSON.parse(JSON.stringify(def.defaultProps))
|
|
};
|
|
|
|
if (afterIndex !== null && afterIndex >= 0) {
|
|
this.blocks.splice(afterIndex + 1, 0, newBlock);
|
|
} else {
|
|
this.blocks.push(newBlock);
|
|
}
|
|
|
|
this.selectedBlockId = newBlock.id;
|
|
this.pushHistory();
|
|
this.markDirty();
|
|
this.$nextTick(() => this.reinitSortable());
|
|
},
|
|
|
|
removeBlock(id) {
|
|
const idx = this.blocks.findIndex(b => b.id === id);
|
|
if (idx === -1) return;
|
|
this.blocks.splice(idx, 1);
|
|
if (this.selectedBlockId === id) {
|
|
this.selectedBlockId = null;
|
|
}
|
|
this.pushHistory();
|
|
this.markDirty();
|
|
},
|
|
|
|
moveBlock(oldIndex, newIndex) {
|
|
if (oldIndex === newIndex) return;
|
|
const [moved] = this.blocks.splice(oldIndex, 1);
|
|
this.blocks.splice(newIndex, 0, moved);
|
|
this.pushHistory();
|
|
this.markDirty();
|
|
},
|
|
|
|
duplicateBlock(id) {
|
|
const idx = this.blocks.findIndex(b => b.id === id);
|
|
if (idx === -1) return;
|
|
const original = this.blocks[idx];
|
|
const clone = JSON.parse(JSON.stringify(original));
|
|
clone.id = this.generateId();
|
|
// columns 블록 내 자식도 id 재생성
|
|
if (clone.props.children) {
|
|
clone.props.children = clone.props.children.map(col =>
|
|
col.map(child => ({ ...child, id: this.generateId() }))
|
|
);
|
|
}
|
|
this.blocks.splice(idx + 1, 0, clone);
|
|
this.selectedBlockId = clone.id;
|
|
this.pushHistory();
|
|
this.markDirty();
|
|
this.$nextTick(() => this.reinitSortable());
|
|
},
|
|
|
|
selectBlock(id) {
|
|
this.selectedBlockId = this.selectedBlockId === id ? null : id;
|
|
},
|
|
|
|
// ===== 선택된 블록 =====
|
|
getSelectedBlock() {
|
|
if (!this.selectedBlockId) return null;
|
|
return this.blocks.find(b => b.id === this.selectedBlockId) || null;
|
|
},
|
|
|
|
updateBlockProp(key, value) {
|
|
const block = this.getSelectedBlock();
|
|
if (!block) return;
|
|
block.props[key] = value;
|
|
this.markDirty();
|
|
},
|
|
|
|
// ===== Undo/Redo =====
|
|
pushHistory() {
|
|
// 현재 위치 이후의 히스토리 삭제
|
|
this.history = this.history.slice(0, this.historyIndex + 1);
|
|
this.history.push(JSON.stringify(this.blocks));
|
|
this.historyIndex = this.history.length - 1;
|
|
// 최대 50단계
|
|
if (this.history.length > 50) {
|
|
this.history.shift();
|
|
this.historyIndex--;
|
|
}
|
|
},
|
|
|
|
undo() {
|
|
if (this.historyIndex <= 0) return;
|
|
this.historyIndex--;
|
|
this.blocks = JSON.parse(this.history[this.historyIndex]);
|
|
this.markDirty();
|
|
this.$nextTick(() => this.reinitSortable());
|
|
},
|
|
|
|
redo() {
|
|
if (this.historyIndex >= this.history.length - 1) return;
|
|
this.historyIndex++;
|
|
this.blocks = JSON.parse(this.history[this.historyIndex]);
|
|
this.markDirty();
|
|
this.$nextTick(() => this.reinitSortable());
|
|
},
|
|
|
|
// ===== Dirty 상태 =====
|
|
markDirty() {
|
|
this.isDirty = true;
|
|
},
|
|
|
|
// ===== 테이블 블록 헬퍼 =====
|
|
addTableRow(block) {
|
|
const colCount = block.props.headers.length;
|
|
block.props.rows.push(new Array(colCount).fill(''));
|
|
this.pushHistory();
|
|
this.markDirty();
|
|
},
|
|
|
|
removeTableRow(block, rowIdx) {
|
|
if (block.props.rows.length <= 1) return;
|
|
block.props.rows.splice(rowIdx, 1);
|
|
this.pushHistory();
|
|
this.markDirty();
|
|
},
|
|
|
|
addTableColumn(block) {
|
|
block.props.headers.push('새 열');
|
|
block.props.rows.forEach(row => row.push(''));
|
|
this.pushHistory();
|
|
this.markDirty();
|
|
},
|
|
|
|
removeTableColumn(block, colIdx) {
|
|
if (block.props.headers.length <= 1) return;
|
|
block.props.headers.splice(colIdx, 1);
|
|
block.props.rows.forEach(row => row.splice(colIdx, 1));
|
|
this.pushHistory();
|
|
this.markDirty();
|
|
},
|
|
|
|
// ===== 저장 =====
|
|
async save() {
|
|
if (this.saving) return;
|
|
this.saving = true;
|
|
|
|
const schema = {
|
|
version: '1.0',
|
|
page: this.pageConfig,
|
|
blocks: this.blocks
|
|
};
|
|
|
|
const payload = {
|
|
name: this.templateName,
|
|
category: this.category,
|
|
builder_type: 'block',
|
|
schema: schema,
|
|
page_config: this.pageConfig,
|
|
is_active: true,
|
|
};
|
|
|
|
try {
|
|
const isCreate = !this.templateId;
|
|
const url = isCreate
|
|
? '/api/admin/document-templates'
|
|
: '/api/admin/document-templates/' + this.templateId;
|
|
const method = isCreate ? 'POST' : 'PUT';
|
|
|
|
const response = await fetch(url, {
|
|
method: method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
this.isDirty = false;
|
|
if (isCreate && result.data?.id) {
|
|
this.templateId = result.data.id;
|
|
// URL 업데이트 (새로 생성된 경우)
|
|
window.history.replaceState({}, '',
|
|
'/document-templates/' + this.templateId + '/block-edit');
|
|
}
|
|
if (typeof showToast === 'function') {
|
|
showToast('저장되었습니다.', 'success');
|
|
}
|
|
} else {
|
|
if (typeof showToast === 'function') {
|
|
showToast(result.message || '저장에 실패했습니다.', 'error');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Save error:', e);
|
|
if (typeof showToast === 'function') {
|
|
showToast('저장 중 오류가 발생했습니다.', 'error');
|
|
}
|
|
} finally {
|
|
this.saving = false;
|
|
}
|
|
},
|
|
|
|
// ===== SortableJS =====
|
|
sortableInstance: null,
|
|
|
|
initSortable() {
|
|
const canvas = this.$refs.canvas;
|
|
if (!canvas) return;
|
|
|
|
this.sortableInstance = new Sortable(canvas, {
|
|
animation: 150,
|
|
handle: '.block-handle',
|
|
ghostClass: 'sortable-ghost',
|
|
dragClass: 'sortable-drag',
|
|
onEnd: (evt) => {
|
|
this.moveBlock(evt.oldIndex, evt.newIndex);
|
|
},
|
|
});
|
|
},
|
|
|
|
reinitSortable() {
|
|
if (this.sortableInstance) {
|
|
this.sortableInstance.destroy();
|
|
}
|
|
this.initSortable();
|
|
},
|
|
|
|
// ===== 팔레트 드래그 추가 =====
|
|
handlePaletteDrop(type) {
|
|
this.addBlock(type);
|
|
},
|
|
};
|
|
}
|
|
</script>
|
|
@endpush
|