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 뱃지 표시
This commit is contained in:
김보곤
2026-02-28 19:31:57 +09:00
parent cf5b62ba06
commit 97bdc5fbb3
11 changed files with 1484 additions and 7 deletions

View File

@@ -127,6 +127,7 @@ public function store(Request $request): JsonResponse
$validated = $request->validate([
'name' => 'required|string|max:100',
'category' => 'nullable|string|max:50',
'builder_type' => 'nullable|string|in:legacy,block',
'title' => 'nullable|string|max:200',
'company_name' => 'nullable|string|max:100',
'company_address' => 'nullable|string|max:255',
@@ -134,6 +135,8 @@ public function store(Request $request): JsonResponse
'footer_remark_label' => 'nullable|string|max:50',
'footer_judgement_label' => 'nullable|string|max:50',
'footer_judgement_options' => 'nullable|array',
'schema' => 'nullable|array',
'page_config' => 'nullable|array',
'is_active' => 'boolean',
'linked_item_ids' => 'nullable|array',
'linked_item_ids.*' => 'integer',
@@ -162,6 +165,7 @@ public function store(Request $request): JsonResponse
'tenant_id' => session('selected_tenant_id'),
'name' => $validated['name'],
'category' => $validated['category'] ?? null,
'builder_type' => $validated['builder_type'] ?? 'legacy',
'title' => $validated['title'] ?? null,
'company_name' => $validated['company_name'] ?? '경동기업',
'company_address' => $validated['company_address'] ?? null,
@@ -169,6 +173,8 @@ public function store(Request $request): JsonResponse
'footer_remark_label' => $validated['footer_remark_label'] ?? '부적합 내용',
'footer_judgement_label' => $validated['footer_judgement_label'] ?? '종합판정',
'footer_judgement_options' => $validated['footer_judgement_options'] ?? ['적합', '부적합'],
'schema' => $validated['schema'] ?? null,
'page_config' => $validated['page_config'] ?? null,
'is_active' => $validated['is_active'] ?? true,
'linked_item_ids' => $validated['linked_item_ids'] ?? null,
'linked_process_id' => $validated['linked_process_id'] ?? null,
@@ -204,6 +210,7 @@ public function update(Request $request, int $id): JsonResponse
$validated = $request->validate([
'name' => 'required|string|max:100',
'category' => 'nullable|string|max:50',
'builder_type' => 'nullable|string|in:legacy,block',
'title' => 'nullable|string|max:200',
'company_name' => 'nullable|string|max:100',
'company_address' => 'nullable|string|max:255',
@@ -211,6 +218,8 @@ public function update(Request $request, int $id): JsonResponse
'footer_remark_label' => 'nullable|string|max:50',
'footer_judgement_label' => 'nullable|string|max:50',
'footer_judgement_options' => 'nullable|array',
'schema' => 'nullable|array',
'page_config' => 'nullable|array',
'is_active' => 'boolean',
'linked_item_ids' => 'nullable|array',
'linked_item_ids.*' => 'integer',
@@ -235,7 +244,7 @@ public function update(Request $request, int $id): JsonResponse
try {
DB::beginTransaction();
$template->update([
$updateData = [
'name' => $validated['name'],
'category' => $validated['category'] ?? null,
'title' => $validated['title'] ?? null,
@@ -248,7 +257,20 @@ public function update(Request $request, int $id): JsonResponse
'is_active' => $validated['is_active'] ?? true,
'linked_item_ids' => $validated['linked_item_ids'] ?? null,
'linked_process_id' => $validated['linked_process_id'] ?? null,
]);
];
// 블록 빌더 전용 필드
if (isset($validated['builder_type'])) {
$updateData['builder_type'] = $validated['builder_type'];
}
if (array_key_exists('schema', $validated)) {
$updateData['schema'] = $validated['schema'];
}
if (array_key_exists('page_config', $validated)) {
$updateData['page_config'] = $validated['page_config'];
}
$template->update($updateData);
// 관계 데이터 저장 (기존 데이터 삭제 후 재생성)
$this->saveRelations($template, $validated, true);
@@ -396,6 +418,7 @@ public function duplicate(Request $request, int $id): JsonResponse
'tenant_id' => $source->tenant_id,
'name' => $newName,
'category' => $source->category,
'builder_type' => $source->builder_type ?? 'legacy',
'title' => $source->title,
'company_name' => $source->company_name,
'company_address' => $source->company_address,
@@ -403,6 +426,8 @@ public function duplicate(Request $request, int $id): JsonResponse
'footer_remark_label' => $source->footer_remark_label,
'footer_judgement_label' => $source->footer_judgement_label,
'footer_judgement_options' => $source->footer_judgement_options,
'schema' => $source->schema,
'page_config' => $source->page_config,
'is_active' => false,
'linked_item_ids' => null, // 연결품목은 복사하지 않음 (중복 방지)
'linked_process_id' => null,

View File

@@ -58,6 +58,11 @@ public function edit(int $id): View
'links.linkValues',
])->findOrFail($id);
// 블록 빌더 타입이면 block-editor로 리다이렉트
if ($template->isBlockBuilder()) {
return $this->blockEdit($id);
}
// JavaScript용 데이터 변환
$templateData = $this->prepareTemplateData($template);
@@ -72,6 +77,56 @@ public function edit(int $id): View
]);
}
/**
* 블록 빌더 - 새 양식 생성
*/
public function blockCreate(Request $request): View
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('document-templates.block-create'));
}
return view('document-templates.block-editor', [
'template' => null,
'templateId' => 0,
'isCreate' => true,
'categories' => $this->getCategories(),
'initialSchema' => [
'_name' => '새 문서양식',
'_category' => '',
'version' => '1.0',
'page' => ['size' => 'A4', 'orientation' => 'portrait', 'margin' => [20, 15, 20, 15]],
'blocks' => [],
],
]);
}
/**
* 블록 빌더 - 양식 수정
*/
public function blockEdit(int $id): View
{
$template = DocumentTemplate::findOrFail($id);
$schema = $template->schema ?? [
'version' => '1.0',
'page' => $template->page_config ?? ['size' => 'A4', 'orientation' => 'portrait', 'margin' => [20, 15, 20, 15]],
'blocks' => [],
];
// 뷰에서 사용할 메타 정보 주입
$schema['_name'] = $template->name;
$schema['_category'] = $template->category ?? '';
return view('document-templates.block-editor', [
'template' => $template,
'templateId' => $template->id,
'isCreate' => false,
'categories' => $this->getCategories(),
'initialSchema' => $schema,
]);
}
/**
* 현재 선택된 테넌트 조회
*/

View File

@@ -16,6 +16,7 @@ class DocumentTemplate extends Model
'tenant_id',
'name',
'category',
'builder_type',
'title',
'company_name',
'company_address',
@@ -23,6 +24,8 @@ class DocumentTemplate extends Model
'footer_remark_label',
'footer_judgement_label',
'footer_judgement_options',
'schema',
'page_config',
'is_active',
'linked_item_ids',
'linked_process_id',
@@ -30,10 +33,28 @@ class DocumentTemplate extends Model
protected $casts = [
'footer_judgement_options' => 'array',
'schema' => 'array',
'page_config' => 'array',
'linked_item_ids' => 'array',
'is_active' => 'boolean',
];
/**
* 블록 빌더 타입 여부
*/
public function isBlockBuilder(): bool
{
return $this->builder_type === 'block';
}
/**
* 레거시 빌더 타입 여부
*/
public function isLegacyBuilder(): bool
{
return $this->builder_type !== 'block';
}
/**
* 결재라인
*/

View File

@@ -0,0 +1,235 @@
<?php
namespace App\Services;
class BlockRendererService
{
/**
* JSON 블록 트리를 HTML로 렌더링
*/
public function render(array $schema, array $data = []): string
{
$blocks = $schema['blocks'] ?? [];
$page = $schema['page'] ?? [];
$html = $this->renderPageOpen($page);
foreach ($blocks as $block) {
$html .= $this->renderBlock($block, $data);
}
$html .= $this->renderPageClose();
return $html;
}
/**
* 개별 블록 렌더링
*/
public function renderBlock(array $block, array $data = []): string
{
$type = $block['type'] ?? '';
$props = $block['props'] ?? [];
return match ($type) {
'heading' => $this->renderHeading($props, $data),
'paragraph' => $this->renderParagraph($props, $data),
'table' => $this->renderTable($props, $data),
'columns' => $this->renderColumns($props, $data),
'divider' => $this->renderDivider($props),
'spacer' => $this->renderSpacer($props),
'text_field' => $this->renderTextField($props, $data),
'number_field' => $this->renderNumberField($props, $data),
'date_field' => $this->renderDateField($props, $data),
'select_field' => $this->renderSelectField($props, $data),
'checkbox_field' => $this->renderCheckboxField($props, $data),
'textarea_field' => $this->renderTextareaField($props, $data),
'signature_field' => $this->renderSignatureField($props, $data),
default => "<!-- Unknown block type: {$type} -->",
};
}
/**
* 데이터 바인딩 치환
*/
protected function resolveBinding(string $text, array $data): string
{
return preg_replace_callback('/\{\{(.+?)\}\}/', function ($matches) use ($data) {
$key = trim($matches[1]);
if ($key === 'today') {
return now()->format('Y-m-d');
}
return data_get($data, $key, $matches[0]);
}, $text);
}
// ===== 렌더링 메서드 =====
protected function renderPageOpen(array $page): string
{
$size = $page['size'] ?? 'A4';
$orientation = $page['orientation'] ?? 'portrait';
return '<div class="block-document" data-page-size="'.e($size).'" data-orientation="'.e($orientation).'" style="font-family: \'Noto Sans KR\', sans-serif; max-width: 210mm; margin: 0 auto; padding: 20mm 15mm;">';
}
protected function renderPageClose(): string
{
return '</div>';
}
protected function renderHeading(array $props, array $data): string
{
$level = min(max(intval($props['level'] ?? 2), 1), 6);
$text = e($this->resolveBinding($props['text'] ?? '', $data));
$align = e($props['align'] ?? 'left');
$sizes = [1 => '1.5em', 2 => '1.25em', 3 => '1.1em', 4 => '1em', 5 => '0.9em', 6 => '0.85em'];
$size = $sizes[$level];
return "<h{$level} style=\"text-align:{$align}; font-size:{$size}; font-weight:bold; margin:0.5em 0;\">{$text}</h{$level}>";
}
protected function renderParagraph(array $props, array $data): string
{
$text = e($this->resolveBinding($props['text'] ?? '', $data));
$text = nl2br($text);
$align = e($props['align'] ?? 'left');
return "<p style=\"text-align:{$align}; margin:0.3em 0; font-size:0.9em; line-height:1.6;\">{$text}</p>";
}
protected function renderTable(array $props, array $data): string
{
$headers = $props['headers'] ?? [];
$rows = $props['rows'] ?? [];
$showHeader = $props['showHeader'] ?? true;
$html = '<table style="width:100%; border-collapse:collapse; border:1px solid #333; margin:0.5em 0; font-size:0.85em;">';
if ($showHeader && ! empty($headers)) {
$html .= '<thead><tr>';
foreach ($headers as $header) {
$html .= '<th style="border:1px solid #333; padding:4px 8px; background:#f5f5f5; font-weight:bold; text-align:center;">'.e($header).'</th>';
}
$html .= '</tr></thead>';
}
$html .= '<tbody>';
foreach ($rows as $row) {
$html .= '<tr>';
foreach ($row as $cell) {
$value = $this->resolveBinding($cell ?? '', $data);
$html .= '<td style="border:1px solid #333; padding:4px 8px;">'.e($value).'</td>';
}
$html .= '</tr>';
}
$html .= '</tbody></table>';
return $html;
}
protected function renderColumns(array $props, array $data): string
{
$count = intval($props['count'] ?? 2);
$children = $props['children'] ?? [];
$width = round(100 / $count, 2);
$html = '<div style="display:flex; gap:10px; margin:0.5em 0;">';
for ($i = 0; $i < $count; $i++) {
$html .= '<div style="flex:1;">';
$colChildren = $children[$i] ?? [];
foreach ($colChildren as $child) {
$html .= $this->renderBlock($child, $data);
}
$html .= '</div>';
}
$html .= '</div>';
return $html;
}
protected function renderDivider(array $props): string
{
$style = ($props['style'] ?? 'solid') === 'dashed' ? 'dashed' : 'solid';
return "<hr style=\"border:none; border-top:1px {$style} #ccc; margin:0.8em 0;\">";
}
protected function renderSpacer(array $props): string
{
$height = max(intval($props['height'] ?? 20), 0);
return "<div style=\"height:{$height}px;\"></div>";
}
protected function renderTextField(array $props, array $data): string
{
$label = e($props['label'] ?? '');
$binding = $props['binding'] ?? '';
$value = $binding ? e($this->resolveBinding($binding, $data)) : '';
$required = ! empty($props['required']) ? ' <span style="color:red;">*</span>' : '';
return "<div style=\"margin:0.3em 0;\"><label style=\"font-size:0.8em; font-weight:bold; display:block; margin-bottom:2px;\">{$label}{$required}</label><div style=\"border:1px solid #ccc; padding:4px 8px; min-height:24px; font-size:0.9em;\">{$value}</div></div>";
}
protected function renderNumberField(array $props, array $data): string
{
$label = e($props['label'] ?? '');
$unit = e($props['unit'] ?? '');
$unitDisplay = $unit ? " ({$unit})" : '';
return "<div style=\"margin:0.3em 0;\"><label style=\"font-size:0.8em; font-weight:bold; display:block; margin-bottom:2px;\">{$label}{$unitDisplay}</label><div style=\"border:1px solid #ccc; padding:4px 8px; min-height:24px; font-size:0.9em;\"></div></div>";
}
protected function renderDateField(array $props, array $data): string
{
$label = e($props['label'] ?? '');
$binding = $props['binding'] ?? '';
$value = $binding ? e($this->resolveBinding($binding, $data)) : '';
return "<div style=\"margin:0.3em 0;\"><label style=\"font-size:0.8em; font-weight:bold; display:block; margin-bottom:2px;\">{$label}</label><div style=\"border:1px solid #ccc; padding:4px 8px; min-height:24px; font-size:0.9em;\">{$value}</div></div>";
}
protected function renderSelectField(array $props, array $data): string
{
$label = e($props['label'] ?? '');
$options = $props['options'] ?? [];
return "<div style=\"margin:0.3em 0;\"><label style=\"font-size:0.8em; font-weight:bold; display:block; margin-bottom:2px;\">{$label}</label><div style=\"border:1px solid #ccc; padding:4px 8px; min-height:24px; font-size:0.9em;\">".e(implode(' / ', $options)).'</div></div>';
}
protected function renderCheckboxField(array $props, array $data): string
{
$label = e($props['label'] ?? '');
$options = $props['options'] ?? [];
$html = "<div style=\"margin:0.3em 0;\"><label style=\"font-size:0.8em; font-weight:bold; display:block; margin-bottom:2px;\">{$label}</label><div style=\"display:flex; gap:12px; flex-wrap:wrap;\">";
foreach ($options as $opt) {
$html .= '<label style="font-size:0.85em; display:flex; align-items:center; gap:4px;"><input type="checkbox" disabled> '.e($opt).'</label>';
}
$html .= '</div></div>';
return $html;
}
protected function renderTextareaField(array $props, array $data): string
{
$label = e($props['label'] ?? '');
$rows = max(intval($props['rows'] ?? 3), 1);
$height = $rows * 24;
return "<div style=\"margin:0.3em 0;\"><label style=\"font-size:0.8em; font-weight:bold; display:block; margin-bottom:2px;\">{$label}</label><div style=\"border:1px solid #ccc; padding:4px 8px; min-height:{$height}px; font-size:0.9em;\"></div></div>";
}
protected function renderSignatureField(array $props, array $data): string
{
$label = e($props['label'] ?? '서명');
return "<div style=\"margin:0.5em 0;\"><label style=\"font-size:0.8em; font-weight:bold; display:block; margin-bottom:2px;\">{$label}</label><div style=\"border:2px dashed #ccc; height:60px; display:flex; align-items:center; justify-content:center; font-size:0.8em; color:#999;\">서명 영역</div></div>";
}
}

View File

@@ -0,0 +1,424 @@
@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

View File

@@ -19,6 +19,13 @@ class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition
</svg>
양식
</a>
<a href="{{ route('document-templates.block-create') }}"
class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2">
<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="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
</svg>
블록 빌더
</a>
</div>
</div>

View File

@@ -0,0 +1,254 @@
{{-- 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>

View File

@@ -0,0 +1,35 @@
{{-- Block Palette (좌측 사이드바) --}}
<div class="block-palette bg-gray-50 border-r border-gray-200 overflow-y-auto shrink-0 p-3">
<div class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">기본</div>
<div class="space-y-1 mb-4">
<template x-for="(def, type) in blockTypes" :key="type">
<button x-show="def.category === 'basic'"
@click="addBlock(type)"
class="palette-item w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-gray-700 hover:bg-white hover:shadow-sm transition border border-transparent hover:border-gray-200">
<span class="w-6 h-6 flex items-center justify-center bg-blue-100 text-blue-700 rounded text-xs font-bold shrink-0"
x-text="def.icon"></span>
<span x-text="def.label"></span>
</button>
</template>
</div>
<div class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2"> 필드</div>
<div class="space-y-1 mb-4">
<template x-for="(def, type) in blockTypes" :key="type + '_form'">
<button x-show="def.category === 'form'"
@click="addBlock(type)"
class="palette-item w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-gray-700 hover:bg-white hover:shadow-sm transition border border-transparent hover:border-gray-200">
<span class="w-6 h-6 flex items-center justify-center bg-green-100 text-green-700 rounded text-xs font-bold shrink-0"
x-text="def.icon"></span>
<span x-text="def.label"></span>
</button>
</template>
</div>
{{-- 블록 카운트 --}}
<div class="mt-4 pt-3 border-t border-gray-200">
<div class="text-xs text-gray-400">
블록 <span x-text="blocks.length" class="font-medium text-gray-600"></span>
</div>
</div>
</div>

View File

@@ -0,0 +1,414 @@
{{-- Block Properties Panel (우측 사이드바) --}}
<div class="block-properties bg-white border-l border-gray-200 overflow-y-auto shrink-0">
{{-- 미선택 상태 --}}
<div x-show="!getSelectedBlock()" class="flex flex-col items-center justify-center h-full text-gray-400 p-6">
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
<p class="text-sm">블록을 선택하면</p>
<p class="text-sm">속성을 편집할 있습니다</p>
</div>
{{-- 선택된 블록 속성 편집 --}}
<div x-show="getSelectedBlock()" class="p-3">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-700">
<span x-text="blockTypes[getSelectedBlock()?.type]?.label || ''"></span> 속성
</h3>
<button @click="selectedBlockId = null" class="text-gray-400 hover:text-gray-600">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{{-- ===== Heading 속성 ===== --}}
<template x-if="getSelectedBlock()?.type === 'heading'">
<div class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">텍스트</label>
<input type="text" :value="getSelectedBlock()?.props.text"
@input="updateBlockProp('text', $event.target.value); pushHistory()"
class="prop-input w-full border border-gray-200 rounded-md px-2 py-1.5 focus:ring-1 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">레벨</label>
<select :value="getSelectedBlock()?.props.level"
@change="updateBlockProp('level', parseInt($event.target.value)); pushHistory()"
class="prop-input w-full border border-gray-200 rounded-md px-2 py-1.5 focus:ring-1 focus:ring-blue-500">
<option value="1">H1 가장 제목</option>
<option value="2">H2 제목</option>
<option value="3">H3 중간 제목</option>
<option value="4">H4 작은 제목</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">정렬</label>
<div class="flex border border-gray-200 rounded-md overflow-hidden">
<button @click="updateBlockProp('align', 'left'); pushHistory()"
:class="getSelectedBlock()?.props.align === 'left' ? 'bg-blue-50 text-blue-700' : 'text-gray-500 hover:bg-gray-50'"
class="flex-1 py-1.5 text-xs font-medium">왼쪽</button>
<button @click="updateBlockProp('align', 'center'); pushHistory()"
:class="getSelectedBlock()?.props.align === 'center' ? 'bg-blue-50 text-blue-700' : 'text-gray-500 hover:bg-gray-50'"
class="flex-1 py-1.5 text-xs font-medium border-l border-gray-200">가운데</button>
<button @click="updateBlockProp('align', 'right'); pushHistory()"
:class="getSelectedBlock()?.props.align === 'right' ? 'bg-blue-50 text-blue-700' : 'text-gray-500 hover:bg-gray-50'"
class="flex-1 py-1.5 text-xs font-medium border-l border-gray-200">오른쪽</button>
</div>
</div>
</div>
</template>
{{-- ===== Paragraph 속성 ===== --}}
<template x-if="getSelectedBlock()?.type === 'paragraph'">
<div class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">텍스트</label>
<textarea :value="getSelectedBlock()?.props.text"
@input="updateBlockProp('text', $event.target.value); pushHistory()"
rows="4"
class="prop-input w-full border border-gray-200 rounded-md px-2 py-1.5 focus:ring-1 focus:ring-blue-500 focus:border-blue-500"></textarea>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">정렬</label>
<div class="flex border border-gray-200 rounded-md overflow-hidden">
<button @click="updateBlockProp('align', 'left'); pushHistory()"
:class="getSelectedBlock()?.props.align === 'left' ? 'bg-blue-50 text-blue-700' : 'text-gray-500 hover:bg-gray-50'"
class="flex-1 py-1.5 text-xs font-medium">왼쪽</button>
<button @click="updateBlockProp('align', 'center'); pushHistory()"
:class="getSelectedBlock()?.props.align === 'center' ? 'bg-blue-50 text-blue-700' : 'text-gray-500 hover:bg-gray-50'"
class="flex-1 py-1.5 text-xs font-medium border-l border-gray-200">가운데</button>
<button @click="updateBlockProp('align', 'right'); pushHistory()"
:class="getSelectedBlock()?.props.align === 'right' ? 'bg-blue-50 text-blue-700' : 'text-gray-500 hover:bg-gray-50'"
class="flex-1 py-1.5 text-xs font-medium border-l border-gray-200">오른쪽</button>
</div>
</div>
</div>
</template>
{{-- ===== Table 속성 ===== --}}
<template x-if="getSelectedBlock()?.type === 'table'">
<div class="space-y-3">
<div>
<label class="flex items-center gap-2 text-xs font-medium text-gray-500">
<input type="checkbox" :checked="getSelectedBlock()?.props.showHeader !== false"
@change="updateBlockProp('showHeader', $event.target.checked); pushHistory()"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
헤더 표시
</label>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">헤더</label>
<template x-for="(header, hIdx) in getSelectedBlock()?.props.headers" :key="'ph_' + hIdx">
<div class="flex items-center gap-1 mb-1">
<input type="text" :value="header"
@input="let b = getSelectedBlock(); b.props.headers[hIdx] = $event.target.value; markDirty()"
@change="pushHistory()"
class="prop-input flex-1 border border-gray-200 rounded px-2 py-1 focus:ring-1 focus:ring-blue-500">
<button @click="removeTableColumn(getSelectedBlock(), hIdx)"
class="text-gray-400 hover:text-red-500 shrink-0" 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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</template>
<button @click="addTableColumn(getSelectedBlock())"
class="text-xs text-blue-600 hover:text-blue-800 mt-1">+ 추가</button>
</div>
<div>
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-medium text-gray-500">데이터 </label>
<span class="text-xs text-gray-400" x-text="getSelectedBlock()?.props.rows?.length + '행'"></span>
</div>
<template x-for="(row, rIdx) in getSelectedBlock()?.props.rows" :key="'pr_' + rIdx">
<div class="flex items-center gap-1 mb-1">
<span class="text-[10px] text-gray-400 shrink-0 w-4" x-text="rIdx + 1"></span>
<template x-for="(cell, cIdx) in row" :key="'pc_' + rIdx + '_' + cIdx">
<input type="text" :value="cell"
@input="let b = getSelectedBlock(); b.props.rows[rIdx][cIdx] = $event.target.value; markDirty()"
@change="pushHistory()"
class="prop-input flex-1 border border-gray-200 rounded px-1.5 py-0.5 focus:ring-1 focus:ring-blue-500"
style="min-width: 0;">
</template>
<button @click="removeTableRow(getSelectedBlock(), rIdx)"
class="text-gray-400 hover:text-red-500 shrink-0">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</template>
<button @click="addTableRow(getSelectedBlock())"
class="text-xs text-blue-600 hover:text-blue-800 mt-1">+ 추가</button>
</div>
</div>
</template>
{{-- ===== Columns 속성 ===== --}}
<template x-if="getSelectedBlock()?.type === 'columns'">
<div class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1"> </label>
<select :value="getSelectedBlock()?.props.count"
@change="
let b = getSelectedBlock();
let newCount = parseInt($event.target.value);
let old = b.props.children || [];
while (old.length < newCount) old.push([]);
b.props.children = old.slice(0, newCount);
b.props.count = newCount;
pushHistory();
markDirty();
"
class="prop-input w-full border border-gray-200 rounded-md px-2 py-1.5 focus:ring-1 focus:ring-blue-500">
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</select>
</div>
<p class="text-xs text-gray-400">
Phase 2에서 내부에 블록을 추가하는 기능이 구현됩니다.
</p>
</div>
</template>
{{-- ===== Divider 속성 ===== --}}
<template x-if="getSelectedBlock()?.type === 'divider'">
<div class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">스타일</label>
<select :value="getSelectedBlock()?.props.style"
@change="updateBlockProp('style', $event.target.value); pushHistory()"
class="prop-input w-full border border-gray-200 rounded-md px-2 py-1.5 focus:ring-1 focus:ring-blue-500">
<option value="solid">실선</option>
<option value="dashed">점선</option>
</select>
</div>
</div>
</template>
{{-- ===== Spacer 속성 ===== --}}
<template x-if="getSelectedBlock()?.type === 'spacer'">
<div class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">높이 (px)</label>
<input type="number" :value="getSelectedBlock()?.props.height"
@input="updateBlockProp('height', parseInt($event.target.value) || 20); pushHistory()"
min="5" max="200" step="5"
class="prop-input w-full border border-gray-200 rounded-md px-2 py-1.5 focus:ring-1 focus:ring-blue-500">
</div>
</div>
</template>
{{-- ===== Text Field 속성 ===== --}}
<template x-if="getSelectedBlock()?.type === 'text_field'">
<div class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">라벨</label>
<input type="text" :value="getSelectedBlock()?.props.label"
@input="updateBlockProp('label', $event.target.value); pushHistory()"
class="prop-input w-full border border-gray-200 rounded-md px-2 py-1.5 focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">플레이스홀더</label>
<input type="text" :value="getSelectedBlock()?.props.placeholder"
@input="updateBlockProp('placeholder', $event.target.value); pushHistory()"
class="prop-input w-full border border-gray-200 rounded-md px-2 py-1.5 focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">데이터 바인딩</label>
<input type="text" :value="getSelectedBlock()?.props.binding"
@input="updateBlockProp('binding', $event.target.value); pushHistory()"
placeholder="예: item.name, lot.number"
class="prop-input w-full border border-gray-200 rounded-md px-2 py-1.5 focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label class="flex items-center gap-2 text-xs font-medium text-gray-500">
<input type="checkbox" :checked="getSelectedBlock()?.props.required"
@change="updateBlockProp('required', $event.target.checked); pushHistory()"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
필수 입력
</label>
</div>
</div>
</template>
{{-- ===== Number Field 속성 ===== --}}
<template x-if="getSelectedBlock()?.type === 'number_field'">
<div class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">라벨</label>
<input type="text" :value="getSelectedBlock()?.props.label"
@input="updateBlockProp('label', $event.target.value); pushHistory()"
class="prop-input w-full border border-gray-200 rounded-md px-2 py-1.5 focus:ring-1 focus:ring-blue-500">
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">최소값</label>
<input type="number" :value="getSelectedBlock()?.props.min"
@input="updateBlockProp('min', $event.target.value ? Number($event.target.value) : null); pushHistory()"
class="prop-input w-full border border-gray-200 rounded-md px-2 py-1.5 focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">최대값</label>
<input type="number" :value="getSelectedBlock()?.props.max"
@input="updateBlockProp('max', $event.target.value ? Number($event.target.value) : null); pushHistory()"
class="prop-input w-full border border-gray-200 rounded-md px-2 py-1.5 focus:ring-1 focus:ring-blue-500">
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">단위</label>
<input type="text" :value="getSelectedBlock()?.props.unit"
@input="updateBlockProp('unit', $event.target.value); pushHistory()"
placeholder="mm, kg"
class="prop-input w-full border border-gray-200 rounded-md px-2 py-1.5 focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">소수점</label>
<input type="number" :value="getSelectedBlock()?.props.decimal"
@input="updateBlockProp('decimal', parseInt($event.target.value) || 0); pushHistory()"
min="0" max="6"
class="prop-input w-full border border-gray-200 rounded-md px-2 py-1.5 focus:ring-1 focus:ring-blue-500">
</div>
</div>
</div>
</template>
{{-- ===== Date Field 속성 ===== --}}
<template x-if="getSelectedBlock()?.type === 'date_field'">
<div class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">라벨</label>
<input type="text" :value="getSelectedBlock()?.props.label"
@input="updateBlockProp('label', $event.target.value); pushHistory()"
class="prop-input w-full border border-gray-200 rounded-md px-2 py-1.5 focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">바인딩</label>
<input type="text" :value="getSelectedBlock()?.props.binding"
@input="updateBlockProp('binding', $event.target.value); pushHistory()"
placeholder="예: {{today}}"
class="prop-input w-full border border-gray-200 rounded-md px-2 py-1.5 focus:ring-1 focus:ring-blue-500">
</div>
</div>
</template>
{{-- ===== Select Field 속성 ===== --}}
<template x-if="getSelectedBlock()?.type === 'select_field'">
<div class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">라벨</label>
<input type="text" :value="getSelectedBlock()?.props.label"
@input="updateBlockProp('label', $event.target.value); pushHistory()"
class="prop-input w-full border border-gray-200 rounded-md px-2 py-1.5 focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">옵션</label>
<template x-for="(opt, oi) in getSelectedBlock()?.props.options" :key="'so_' + oi">
<div class="flex items-center gap-1 mb-1">
<input type="text" :value="opt"
@input="let b = getSelectedBlock(); b.props.options[oi] = $event.target.value; markDirty()"
@change="pushHistory()"
class="prop-input flex-1 border border-gray-200 rounded px-2 py-1 focus:ring-1 focus:ring-blue-500">
<button @click="let b = getSelectedBlock(); b.props.options.splice(oi, 1); pushHistory(); markDirty()"
class="text-gray-400 hover:text-red-500 shrink-0">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</template>
<button @click="let b = getSelectedBlock(); b.props.options.push('새 옵션'); pushHistory(); markDirty()"
class="text-xs text-blue-600 hover:text-blue-800 mt-1">+ 옵션 추가</button>
</div>
<div>
<label class="flex items-center gap-2 text-xs font-medium text-gray-500">
<input type="checkbox" :checked="getSelectedBlock()?.props.multiple"
@change="updateBlockProp('multiple', $event.target.checked); pushHistory()"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
다중 선택
</label>
</div>
</div>
</template>
{{-- ===== Checkbox Field 속성 ===== --}}
<template x-if="getSelectedBlock()?.type === 'checkbox_field'">
<div class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">라벨</label>
<input type="text" :value="getSelectedBlock()?.props.label"
@input="updateBlockProp('label', $event.target.value); pushHistory()"
class="prop-input w-full border border-gray-200 rounded-md px-2 py-1.5 focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">항목</label>
<template x-for="(opt, oi) in getSelectedBlock()?.props.options" :key="'co_' + oi">
<div class="flex items-center gap-1 mb-1">
<input type="text" :value="opt"
@input="let b = getSelectedBlock(); b.props.options[oi] = $event.target.value; markDirty()"
@change="pushHistory()"
class="prop-input flex-1 border border-gray-200 rounded px-2 py-1 focus:ring-1 focus:ring-blue-500">
<button @click="let b = getSelectedBlock(); b.props.options.splice(oi, 1); pushHistory(); markDirty()"
class="text-gray-400 hover:text-red-500 shrink-0">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</template>
<button @click="let b = getSelectedBlock(); b.props.options.push('새 항목'); pushHistory(); markDirty()"
class="text-xs text-blue-600 hover:text-blue-800 mt-1">+ 항목 추가</button>
</div>
</div>
</template>
{{-- ===== Textarea Field 속성 ===== --}}
<template x-if="getSelectedBlock()?.type === 'textarea_field'">
<div class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">라벨</label>
<input type="text" :value="getSelectedBlock()?.props.label"
@input="updateBlockProp('label', $event.target.value); pushHistory()"
class="prop-input w-full border border-gray-200 rounded-md px-2 py-1.5 focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1"> </label>
<input type="number" :value="getSelectedBlock()?.props.rows"
@input="updateBlockProp('rows', parseInt($event.target.value) || 3); pushHistory()"
min="1" max="20"
class="prop-input w-full border border-gray-200 rounded-md px-2 py-1.5 focus:ring-1 focus:ring-blue-500">
</div>
</div>
</template>
{{-- ===== Signature Field 속성 ===== --}}
<template x-if="getSelectedBlock()?.type === 'signature_field'">
<div class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">라벨</label>
<input type="text" :value="getSelectedBlock()?.props.label"
@input="updateBlockProp('label', $event.target.value); pushHistory()"
class="prop-input w-full border border-gray-200 rounded-md px-2 py-1.5 focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">서명자</label>
<input type="text" :value="getSelectedBlock()?.props.signer"
@input="updateBlockProp('signer', $event.target.value); pushHistory()"
placeholder="예: inspector, manager"
class="prop-input w-full border border-gray-200 rounded-md px-2 py-1.5 focus:ring-1 focus:ring-blue-500">
</div>
</div>
</template>
{{-- ===== 공통: 블록 ID (디버그) ===== --}}
<div class="mt-6 pt-3 border-t border-gray-100">
<div class="text-[10px] text-gray-300">
ID: <span x-text="getSelectedBlock()?.id"></span>
</div>
</div>
</div>
</div>

View File

@@ -22,10 +22,15 @@
{{ $template->id }}
</td>
<td class="px-4 py-3 whitespace-nowrap">
<a href="{{ route('document-templates.edit', $template->id) }}"
class="text-blue-600 hover:text-blue-800 font-medium">
{{ $template->name }}
</a>
<div class="flex items-center gap-1.5">
@if($template->builder_type === 'block')
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-indigo-100 text-indigo-700">블록</span>
@endif
<a href="{{ $template->builder_type === 'block' ? route('document-templates.block-edit', $template->id) : route('document-templates.edit', $template->id) }}"
class="text-blue-600 hover:text-blue-800 font-medium">
{{ $template->name }}
</a>
</div>
</td>
<td class="px-4 py-3 whitespace-nowrap">
@if($template->category)
@@ -110,7 +115,7 @@ class="text-red-700 hover:text-red-900 transition text-sm font-medium"
영구삭제
</button>
@else
<a href="{{ route('document-templates.edit', $template->id) }}"
<a href="{{ $template->builder_type === 'block' ? route('document-templates.block-edit', $template->id) : route('document-templates.edit', $template->id) }}"
class="text-gray-600 hover:text-blue-600 transition"
title="편집">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -411,7 +411,9 @@
Route::prefix('document-templates')->name('document-templates.')->group(function () {
Route::get('/', [DocumentTemplateController::class, 'index'])->name('index');
Route::get('/create', [DocumentTemplateController::class, 'create'])->name('create');
Route::get('/block-create', [DocumentTemplateController::class, 'blockCreate'])->name('block-create');
Route::get('/{id}/edit', [DocumentTemplateController::class, 'edit'])->name('edit');
Route::get('/{id}/block-edit', [DocumentTemplateController::class, 'blockEdit'])->name('block-edit');
});
// 문서 관리