Files
sam-manage/resources/views/document-templates/partials/block-canvas.blade.php
김보곤 97bdc5fbb3 feat: [document] 범용 블록 빌더 Phase 1 구현
- block-editor.blade.php: 3패널 UI (Palette + Canvas + Properties)
- Alpine.js blockEditor() 컴포넌트 (CRUD, Undo/Redo, SortableJS)
- 기본 Block 6종: heading, paragraph, table, columns, divider, spacer
- 폼 필드 Block 7종: text, number, date, select, checkbox, textarea, signature
- BlockRendererService: JSON → HTML 렌더링 서비스
- 컨트롤러 분기: builder_type = 'block' → 블록 빌더 뷰
- 라우트 추가: block-create, block-edit
- API store/update에 schema JSON 처리 추가
- index 페이지에 블록 빌더 진입 버튼 추가
- 목록에 builder_type 뱃지 표시
2026-02-28 19:32:16 +09:00

255 lines
17 KiB
PHP

{{-- Block Canvas (중앙 영역) --}}
<div class="block-canvas overflow-y-auto bg-gray-100 p-6">
<div class="max-w-3xl mx-auto bg-white shadow-sm rounded-lg min-h-full">
{{-- 캔버스 --}}
<div x-show="blocks.length === 0" class="canvas-empty flex flex-col items-center justify-center rounded-lg m-4 p-12">
<svg class="w-12 h-12 text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
<p class="text-gray-400 text-sm mb-1">좌측 팔레트에서 블록을 추가하세요</p>
<p class="text-gray-300 text-xs">또는 블록을 클릭하여 시작</p>
</div>
{{-- 블록 목록 --}}
<div x-ref="canvas" class="p-4 space-y-2" x-show="blocks.length > 0">
<template x-for="(block, index) in blocks" :key="block.id">
<div class="block-item group relative rounded-lg border border-gray-200 transition"
:class="{ 'selected': selectedBlockId === block.id }"
@click.stop="selectBlock(block.id)">
{{-- 블록 컨트롤 --}}
<div class="absolute -top-0 left-0 right-0 flex items-center justify-between px-2 py-0.5 opacity-0 group-hover:opacity-100 transition pointer-events-none group-hover:pointer-events-auto"
style="z-index: 10;">
<div class="flex items-center gap-1">
{{-- 드래그 핸들 --}}
<div class="block-handle px-1 py-0.5 text-gray-400 hover:text-gray-600 cursor-grab">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M7 2a2 2 0 10.001 4.001A2 2 0 007 2zm0 6a2 2 0 10.001 4.001A2 2 0 007 8zm0 6a2 2 0 10.001 4.001A2 2 0 007 14zm6-8a2 2 0 10-.001-4.001A2 2 0 0013 6zm0 2a2 2 0 10.001 4.001A2 2 0 0013 8zm0 6a2 2 0 10.001 4.001A2 2 0 0013 14z"/>
</svg>
</div>
{{-- 블록 타입 라벨 --}}
<span class="text-[10px] text-gray-400 bg-gray-50 px-1.5 py-0.5 rounded"
x-text="blockTypes[block.type]?.label || block.type"></span>
</div>
<div class="flex items-center gap-0.5">
{{-- 블록 추가 --}}
<button @click.stop="addBlock(block.type, index - 1)"
class="p-0.5 text-gray-400 hover:text-blue-600 rounded" title="위에 추가">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
</svg>
</button>
{{-- 복제 --}}
<button @click.stop="duplicateBlock(block.id)"
class="p-0.5 text-gray-400 hover:text-blue-600 rounded" title="복제">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
{{-- 삭제 --}}
<button @click.stop="removeBlock(block.id)"
class="p-0.5 text-gray-400 hover:text-red-600 rounded" title="삭제">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
</div>
{{-- 블록 내용 렌더링 --}}
<div class="p-3">
{{-- Heading --}}
<template x-if="block.type === 'heading'">
<div>
<div x-show="block.props.level === 1" class="text-2xl font-bold"
:style="'text-align:' + (block.props.align || 'left')"
x-text="block.props.text || '제목 1'"></div>
<div x-show="block.props.level === 2" class="text-xl font-bold"
:style="'text-align:' + (block.props.align || 'left')"
x-text="block.props.text || '제목 2'"></div>
<div x-show="block.props.level === 3" class="text-lg font-semibold"
:style="'text-align:' + (block.props.align || 'left')"
x-text="block.props.text || '제목 3'"></div>
<div x-show="block.props.level >= 4" class="text-base font-semibold"
:style="'text-align:' + (block.props.align || 'left')"
x-text="block.props.text || '제목 4'"></div>
</div>
</template>
{{-- Paragraph --}}
<template x-if="block.type === 'paragraph'">
<p class="text-sm text-gray-700 whitespace-pre-wrap"
:style="'text-align:' + (block.props.align || 'left')"
x-text="block.props.text || '텍스트를 입력하세요'"></p>
</template>
{{-- Table --}}
<template x-if="block.type === 'table'">
<div class="overflow-x-auto">
<table class="w-full border-collapse border border-gray-300 text-sm">
<thead x-show="block.props.showHeader !== false">
<tr class="bg-gray-50">
<template x-for="(header, hIdx) in block.props.headers" :key="'th_' + hIdx">
<th class="border border-gray-300 px-2 py-1 text-left font-medium text-gray-600"
x-text="header"></th>
</template>
</tr>
</thead>
<tbody>
<template x-for="(row, rIdx) in block.props.rows" :key="'tr_' + rIdx">
<tr>
<template x-for="(cell, cIdx) in row" :key="'td_' + rIdx + '_' + cIdx">
<td class="border border-gray-300 px-2 py-1 text-gray-600"
x-text="cell || '—'"></td>
</template>
</tr>
</template>
</tbody>
</table>
</div>
</template>
{{-- Columns --}}
<template x-if="block.type === 'columns'">
<div class="flex gap-3">
<template x-for="(col, colIdx) in block.props.children" :key="'col_' + colIdx">
<div class="flex-1 column-drop-zone rounded p-2 min-h-[60px]">
<div class="text-xs text-gray-300 text-center" x-show="col.length === 0">
<span x-text="colIdx + 1"></span>
</div>
<template x-for="child in col" :key="child.id">
<div class="text-xs text-gray-500 bg-gray-50 px-2 py-1 rounded mb-1">
<span x-text="blockTypes[child.type]?.label || child.type"></span>:
<span x-text="child.props?.label || child.props?.text || ''"></span>
</div>
</template>
</div>
</template>
</div>
</template>
{{-- Divider --}}
<template x-if="block.type === 'divider'">
<hr class="border-gray-300" :class="{ 'border-dashed': block.props.style === 'dashed' }">
</template>
{{-- Spacer --}}
<template x-if="block.type === 'spacer'">
<div class="flex items-center justify-center text-xs text-gray-300"
:style="'height:' + (block.props.height || 20) + 'px'">
<span x-text="block.props.height || 20"></span>px
</div>
</template>
{{-- Text Field --}}
<template x-if="block.type === 'text_field'">
<div>
<label class="text-xs font-medium text-gray-600 mb-1 block">
<span x-text="block.props.label"></span>
<span x-show="block.props.required" class="text-red-500">*</span>
</label>
<div class="bg-gray-50 border border-gray-200 rounded px-3 py-1.5 text-sm text-gray-400"
x-text="block.props.placeholder || '텍스트 입력'"></div>
</div>
</template>
{{-- Number Field --}}
<template x-if="block.type === 'number_field'">
<div>
<label class="text-xs font-medium text-gray-600 mb-1 block">
<span x-text="block.props.label"></span>
<span x-show="block.props.unit" class="text-gray-400" x-text="'(' + block.props.unit + ')'"></span>
</label>
<div class="bg-gray-50 border border-gray-200 rounded px-3 py-1.5 text-sm text-gray-400">0</div>
</div>
</template>
{{-- Date Field --}}
<template x-if="block.type === 'date_field'">
<div>
<label class="text-xs font-medium text-gray-600 mb-1 block" x-text="block.props.label"></label>
<div class="bg-gray-50 border border-gray-200 rounded px-3 py-1.5 text-sm text-gray-400">YYYY-MM-DD</div>
</div>
</template>
{{-- Select Field --}}
<template x-if="block.type === 'select_field'">
<div>
<label class="text-xs font-medium text-gray-600 mb-1 block" x-text="block.props.label"></label>
<div class="bg-gray-50 border border-gray-200 rounded px-3 py-1.5 text-sm text-gray-400 flex items-center justify-between">
<span>선택하세요</span>
<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="M19 9l-7 7-7-7"/>
</svg>
</div>
</div>
</template>
{{-- Checkbox Field --}}
<template x-if="block.type === 'checkbox_field'">
<div>
<label class="text-xs font-medium text-gray-600 mb-1 block" x-text="block.props.label"></label>
<div class="flex flex-wrap gap-3">
<template x-for="(opt, oi) in block.props.options" :key="'cb_' + oi">
<label class="flex items-center gap-1.5 text-sm text-gray-600">
<input type="checkbox" disabled class="rounded border-gray-300">
<span x-text="opt"></span>
</label>
</template>
</div>
</div>
</template>
{{-- Textarea Field --}}
<template x-if="block.type === 'textarea_field'">
<div>
<label class="text-xs font-medium text-gray-600 mb-1 block" x-text="block.props.label"></label>
<div class="bg-gray-50 border border-gray-200 rounded px-3 py-2 text-sm text-gray-400"
:style="'min-height:' + ((block.props.rows || 3) * 24) + 'px'">
내용을 입력하세요
</div>
</div>
</template>
{{-- Signature Field --}}
<template x-if="block.type === 'signature_field'">
<div>
<label class="text-xs font-medium text-gray-600 mb-1 block" x-text="block.props.label"></label>
<div class="border-2 border-dashed border-gray-200 rounded-lg flex items-center justify-center bg-gray-50" style="height: 80px;">
<span class="text-gray-300 text-sm">서명 영역</span>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
{{-- 하단 블록 추가 버튼 --}}
<div x-show="blocks.length > 0" class="p-4 flex justify-center">
<div x-data="{ showMenu: false }" class="relative">
<button @click="showMenu = !showMenu"
class="flex items-center gap-1.5 px-4 py-2 text-sm text-gray-500 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-600 transition">
<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="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
블록 추가
</button>
<div x-show="showMenu" @click.away="showMenu = false"
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 bg-white shadow-lg rounded-lg border border-gray-200 p-2 w-52 z-20">
<template x-for="(def, type) in blockTypes" :key="type + '_add'">
<button @click="addBlock(type); showMenu = false"
class="w-full flex items-center gap-2 px-2 py-1 rounded text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-700">
<span class="w-5 h-5 flex items-center justify-center text-xs font-bold rounded"
:class="def.category === 'basic' ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'"
x-text="def.icon"></span>
<span x-text="def.label"></span>
</button>
</template>
</div>
</div>
</div>
</div>
</div>