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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 선택된 테넌트 조회
|
||||
*/
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재라인
|
||||
*/
|
||||
|
||||
235
app/Services/BlockRendererService.php
Normal file
235
app/Services/BlockRendererService.php
Normal 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>";
|
||||
}
|
||||
}
|
||||
424
resources/views/document-templates/block-editor.blade.php
Normal file
424
resources/views/document-templates/block-editor.blade.php
Normal 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
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
// 문서 관리
|
||||
|
||||
Reference in New Issue
Block a user