diff --git a/app/Http/Controllers/Api/Admin/DocumentTemplateApiController.php b/app/Http/Controllers/Api/Admin/DocumentTemplateApiController.php index c417e28a..5d3e3dd2 100644 --- a/app/Http/Controllers/Api/Admin/DocumentTemplateApiController.php +++ b/app/Http/Controllers/Api/Admin/DocumentTemplateApiController.php @@ -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, diff --git a/app/Http/Controllers/DocumentTemplateController.php b/app/Http/Controllers/DocumentTemplateController.php index 4c7156d0..44688194 100644 --- a/app/Http/Controllers/DocumentTemplateController.php +++ b/app/Http/Controllers/DocumentTemplateController.php @@ -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, + ]); + } + /** * 현재 선택된 테넌트 조회 */ diff --git a/app/Models/DocumentTemplate.php b/app/Models/DocumentTemplate.php index 56645b60..f23eaddc 100644 --- a/app/Models/DocumentTemplate.php +++ b/app/Models/DocumentTemplate.php @@ -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'; + } + /** * 결재라인 */ diff --git a/app/Services/BlockRendererService.php b/app/Services/BlockRendererService.php new file mode 100644 index 00000000..9a4e6896 --- /dev/null +++ b/app/Services/BlockRendererService.php @@ -0,0 +1,235 @@ +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 => "", + }; + } + + /** + * 데이터 바인딩 치환 + */ + 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 '
'; + } + + protected function renderPageClose(): string + { + return '
'; + } + + 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 "{$text}"; + } + + protected function renderParagraph(array $props, array $data): string + { + $text = e($this->resolveBinding($props['text'] ?? '', $data)); + $text = nl2br($text); + $align = e($props['align'] ?? 'left'); + + return "

{$text}

"; + } + + protected function renderTable(array $props, array $data): string + { + $headers = $props['headers'] ?? []; + $rows = $props['rows'] ?? []; + $showHeader = $props['showHeader'] ?? true; + + $html = ''; + + if ($showHeader && ! empty($headers)) { + $html .= ''; + foreach ($headers as $header) { + $html .= ''; + } + $html .= ''; + } + + $html .= ''; + foreach ($rows as $row) { + $html .= ''; + foreach ($row as $cell) { + $value = $this->resolveBinding($cell ?? '', $data); + $html .= ''; + } + $html .= ''; + } + $html .= '
'.e($header).'
'.e($value).'
'; + + return $html; + } + + protected function renderColumns(array $props, array $data): string + { + $count = intval($props['count'] ?? 2); + $children = $props['children'] ?? []; + $width = round(100 / $count, 2); + + $html = '
'; + + for ($i = 0; $i < $count; $i++) { + $html .= '
'; + $colChildren = $children[$i] ?? []; + foreach ($colChildren as $child) { + $html .= $this->renderBlock($child, $data); + } + $html .= '
'; + } + + $html .= '
'; + + return $html; + } + + protected function renderDivider(array $props): string + { + $style = ($props['style'] ?? 'solid') === 'dashed' ? 'dashed' : 'solid'; + + return "
"; + } + + protected function renderSpacer(array $props): string + { + $height = max(intval($props['height'] ?? 20), 0); + + return "
"; + } + + 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']) ? ' *' : ''; + + return "
{$value}
"; + } + + protected function renderNumberField(array $props, array $data): string + { + $label = e($props['label'] ?? ''); + $unit = e($props['unit'] ?? ''); + $unitDisplay = $unit ? " ({$unit})" : ''; + + return "
"; + } + + protected function renderDateField(array $props, array $data): string + { + $label = e($props['label'] ?? ''); + $binding = $props['binding'] ?? ''; + $value = $binding ? e($this->resolveBinding($binding, $data)) : ''; + + return "
{$value}
"; + } + + protected function renderSelectField(array $props, array $data): string + { + $label = e($props['label'] ?? ''); + $options = $props['options'] ?? []; + + return "
".e(implode(' / ', $options)).'
'; + } + + protected function renderCheckboxField(array $props, array $data): string + { + $label = e($props['label'] ?? ''); + $options = $props['options'] ?? []; + + $html = "
"; + foreach ($options as $opt) { + $html .= ''; + } + $html .= '
'; + + 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 "
"; + } + + protected function renderSignatureField(array $props, array $data): string + { + $label = e($props['label'] ?? '서명'); + + return "
서명 영역
"; + } +} diff --git a/resources/views/document-templates/block-editor.blade.php b/resources/views/document-templates/block-editor.blade.php new file mode 100644 index 00000000..79f26b61 --- /dev/null +++ b/resources/views/document-templates/block-editor.blade.php @@ -0,0 +1,424 @@ +@extends('layouts.app') + +@section('title', $isCreate ? '블록 빌더 - 새 양식' : '블록 빌더 - ' . $template->name) + +@push('styles') + +@endpush + +@section('content') +
+ + +
+
+ + + + + +
+ + 저장 안됨 +
+
+
+ +
+ + +
+ + +
+ + +
+ + + + + + +
+
+ + +
+ + @include('document-templates.partials.block-palette') + + @include('document-templates.partials.block-canvas') + + @include('document-templates.partials.block-properties') + +
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/document-templates/index.blade.php b/resources/views/document-templates/index.blade.php index 7ae3848d..2349069f 100644 --- a/resources/views/document-templates/index.blade.php +++ b/resources/views/document-templates/index.blade.php @@ -19,6 +19,13 @@ class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition 새 양식 + + + + + 블록 빌더 + diff --git a/resources/views/document-templates/partials/block-canvas.blade.php b/resources/views/document-templates/partials/block-canvas.blade.php new file mode 100644 index 00000000..ebaece66 --- /dev/null +++ b/resources/views/document-templates/partials/block-canvas.blade.php @@ -0,0 +1,254 @@ +{{-- Block Canvas (중앙 영역) --}} +
+
+ + {{-- 빈 캔버스 --}} +
+ + + +

좌측 팔레트에서 블록을 추가하세요

+

또는 블록을 클릭하여 시작

+
+ + {{-- 블록 목록 --}} +
+ +
+ + {{-- 하단 블록 추가 버튼 --}} +
+
+ +
+ +
+
+
+
+
diff --git a/resources/views/document-templates/partials/block-palette.blade.php b/resources/views/document-templates/partials/block-palette.blade.php new file mode 100644 index 00000000..fece4c6a --- /dev/null +++ b/resources/views/document-templates/partials/block-palette.blade.php @@ -0,0 +1,35 @@ +{{-- Block Palette (좌측 사이드바) --}} +
+
기본
+
+ +
+ +
폼 필드
+
+ +
+ + {{-- 블록 카운트 --}} +
+
+ 블록 개 +
+
+
diff --git a/resources/views/document-templates/partials/block-properties.blade.php b/resources/views/document-templates/partials/block-properties.blade.php new file mode 100644 index 00000000..9c0507e4 --- /dev/null +++ b/resources/views/document-templates/partials/block-properties.blade.php @@ -0,0 +1,414 @@ +{{-- Block Properties Panel (우측 사이드바) --}} +
+ + {{-- 미선택 상태 --}} +
+ + + +

블록을 선택하면

+

속성을 편집할 수 있습니다

+
+ + {{-- 선택된 블록 속성 편집 --}} +
+
+

+ 속성 +

+ +
+ + {{-- ===== Heading 속성 ===== --}} + + + {{-- ===== Paragraph 속성 ===== --}} + + + {{-- ===== Table 속성 ===== --}} + + + {{-- ===== Columns 속성 ===== --}} + + + {{-- ===== Divider 속성 ===== --}} + + + {{-- ===== Spacer 속성 ===== --}} + + + {{-- ===== Text Field 속성 ===== --}} + + + {{-- ===== Number Field 속성 ===== --}} + + + {{-- ===== Date Field 속성 ===== --}} + + + {{-- ===== Select Field 속성 ===== --}} + + + {{-- ===== Checkbox Field 속성 ===== --}} + + + {{-- ===== Textarea Field 속성 ===== --}} + + + {{-- ===== Signature Field 속성 ===== --}} + + + {{-- ===== 공통: 블록 ID (디버그) ===== --}} +
+
+ ID: +
+
+
+
diff --git a/resources/views/document-templates/partials/table.blade.php b/resources/views/document-templates/partials/table.blade.php index 57e49421..89b6ff90 100644 --- a/resources/views/document-templates/partials/table.blade.php +++ b/resources/views/document-templates/partials/table.blade.php @@ -22,10 +22,15 @@ {{ $template->id }} - - {{ $template->name }} - +
+ @if($template->builder_type === 'block') + 블록 + @endif + + {{ $template->name }} + +
@if($template->category) @@ -110,7 +115,7 @@ class="text-red-700 hover:text-red-900 transition text-sm font-medium" 영구삭제 @else - diff --git a/routes/web.php b/routes/web.php index c7ee3df0..7c863c9c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); }); // 문서 관리