Files
sam-manage/resources/views/document-templates/block-editor.blade.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