diff --git a/app/Http/Controllers/DocumentController.php b/app/Http/Controllers/DocumentController.php index 6ab267cf..d8d34d23 100644 --- a/app/Http/Controllers/DocumentController.php +++ b/app/Http/Controllers/DocumentController.php @@ -6,6 +6,7 @@ use App\Models\Documents\DocumentData; use App\Models\DocumentTemplate; use App\Models\Items\Item; +use App\Services\BlockRendererService; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\DB; @@ -80,6 +81,7 @@ public function create(Request $request): View|Response 'templates' => $templates, 'isCreate' => true, 'linkedItemSpecs' => $template ? $this->getLinkedItemSpecs($template) : [], + 'blockHtml' => $template ? $this->renderBlockHtml($template, null) : '', ]); } @@ -123,6 +125,7 @@ public function edit(int $id): View|Response 'templates' => $templates, 'isCreate' => false, 'linkedItemSpecs' => $this->getLinkedItemSpecs($document->template), + 'blockHtml' => $this->renderBlockHtml($document->template, $document), ]); } @@ -165,6 +168,7 @@ public function print(int $id): View return view('documents.print', [ 'document' => $document, 'workOrderItems' => $workOrderItems, + 'blockHtml' => $this->renderBlockHtml($document->template, $document, 'print'), ]); } @@ -274,6 +278,7 @@ public function show(int $id): View 'salesOrder' => $salesOrder, 'materialInputLots' => $materialInputLots, 'itemLotMap' => $itemLotMap ?? collect(), + 'blockHtml' => $this->renderBlockHtml($document->template, $document, 'view'), ]); } @@ -416,6 +421,30 @@ private function buildInspectionResolveMap(object $workOrder, $workOrderItems, ? ]; } + /** + * 블록 빌더 서식의 HTML 렌더링 + */ + private function renderBlockHtml(DocumentTemplate $template, ?Document $document, string $mode = 'edit'): string + { + if (! $template->isBlockBuilder() || empty($template->schema)) { + return ''; + } + + $schema = $template->schema; + + // document_data에서 field_key => field_value 맵 생성 + $data = []; + if ($document && $document->data) { + foreach ($document->data as $d) { + $data[$d->field_key] = $d->field_value; + } + } + + $renderer = new BlockRendererService; + + return $renderer->render($schema, $mode, $data); + } + /** * 템플릿에 연결된 품목들의 규격 정보 (thickness, width, length) 조회 */ diff --git a/app/Services/BlockRendererService.php b/app/Services/BlockRendererService.php index 9a4e6896..7925be9b 100644 --- a/app/Services/BlockRendererService.php +++ b/app/Services/BlockRendererService.php @@ -2,13 +2,28 @@ namespace App\Services; +/** + * 블록 스키마를 HTML로 렌더링하는 서비스 + * + * 3가지 모드 지원: + * - view: 읽기 전용 (문서 상세 조회) + * - edit: 입력 폼 (문서 생성/수정) + * - print: 인쇄/미리보기 전용 + */ class BlockRendererService { + protected string $mode = 'view'; // view | edit | print + /** - * JSON 블록 트리를 HTML로 렌더링 + * JSON 블록 스키마를 HTML로 렌더링 + * + * @param array $schema 블록 스키마 (blocks, page) + * @param string $mode 렌더링 모드 (view|edit|print) + * @param array $data 바인딩 데이터 (field_key => value) */ - public function render(array $schema, array $data = []): string + public function render(array $schema, string $mode = 'view', array $data = []): string { + $this->mode = $mode; $blocks = $schema['blocks'] ?? []; $page = $schema['page'] ?? []; @@ -30,27 +45,76 @@ public function renderBlock(array $block, array $data = []): string { $type = $block['type'] ?? ''; $props = $block['props'] ?? []; + $blockId = $block['id'] ?? ''; return match ($type) { 'heading' => $this->renderHeading($props, $data), 'paragraph' => $this->renderParagraph($props, $data), - 'table' => $this->renderTable($props, $data), + 'table' => $this->renderTable($props, $blockId, $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), + 'text_field' => $this->renderTextField($props, $blockId, $data), + 'number_field' => $this->renderNumberField($props, $blockId, $data), + 'date_field' => $this->renderDateField($props, $blockId, $data), + 'select_field' => $this->renderSelectField($props, $blockId, $data), + 'checkbox_field' => $this->renderCheckboxField($props, $blockId, $data), + 'textarea_field' => $this->renderTextareaField($props, $blockId, $data), + 'signature_field' => $this->renderSignatureField($props, $blockId, $data), default => "", }; } /** - * 데이터 바인딩 치환 + * 블록 스키마에서 폼 필드의 binding 목록 추출 + * edit.blade.php에서 JavaScript 데이터 수집 시 사용 + */ + public function extractBindings(array $schema): array + { + $bindings = []; + $formTypes = ['text_field', 'number_field', 'date_field', 'select_field', 'checkbox_field', 'textarea_field', 'signature_field']; + + foreach ($schema['blocks'] ?? [] as $block) { + $type = $block['type'] ?? ''; + if (in_array($type, $formTypes)) { + $binding = $block['props']['binding'] ?? ''; + $blockId = $block['id'] ?? ''; + $fieldKey = $binding ?: 'block_'.$blockId; + $bindings[] = [ + 'block_id' => $blockId, + 'type' => $type, + 'field_key' => $fieldKey, + 'label' => $block['props']['label'] ?? '', + 'required' => ! empty($block['props']['required']), + ]; + } + // columns 내부 블록도 탐색 + if ($type === 'columns') { + foreach ($block['props']['children'] ?? [] as $children) { + foreach ($children as $child) { + $childType = $child['type'] ?? ''; + if (in_array($childType, $formTypes)) { + $binding = $child['props']['binding'] ?? ''; + $childId = $child['id'] ?? ''; + $fieldKey = $binding ?: 'block_'.$childId; + $bindings[] = [ + 'block_id' => $childId, + 'type' => $childType, + 'field_key' => $fieldKey, + 'label' => $child['props']['label'] ?? '', + 'required' => ! empty($child['props']['required']), + ]; + } + } + } + } + } + + return $bindings; + } + + /** + * 데이터 바인딩 치환 (변수 해석) */ protected function resolveBinding(string $text, array $data): string { @@ -60,19 +124,56 @@ protected function resolveBinding(string $text, array $data): string if ($key === 'today') { return now()->format('Y-m-d'); } + if ($key === 'now') { + return now()->format('Y-m-d H:i'); + } return data_get($data, $key, $matches[0]); }, $text); } - // ===== 렌더링 메서드 ===== + /** + * 폼 필드의 field_key 결정: binding이 있으면 binding, 없으면 block_{id} + */ + protected function getFieldKey(array $props, string $blockId): string + { + $binding = $props['binding'] ?? ''; + + return $binding !== '' ? $binding : 'block_'.$blockId; + } + + /** + * EAV 데이터에서 값 조회 + */ + protected function getFieldValue(string $fieldKey, array $data, string $default = ''): string + { + return (string) ($data[$fieldKey] ?? $default); + } + + protected function isEditMode(): bool + { + return $this->mode === 'edit'; + } + + protected function isViewMode(): bool + { + return $this->mode === 'view'; + } + + protected function isPrintMode(): bool + { + return $this->mode === 'print'; + } + + // ===== 레이아웃 블록 렌더링 ===== protected function renderPageOpen(array $page): string { $size = $page['size'] ?? 'A4'; $orientation = $page['orientation'] ?? 'portrait'; + $printStyle = $this->isPrintMode() ? 'max-width: 210mm; margin: 0 auto; padding: 20mm 15mm;' : ''; - return '
'; + return '
'; } protected function renderPageClose(): string @@ -101,7 +202,7 @@ protected function renderParagraph(array $props, array $data): string return "

{$text}

"; } - protected function renderTable(array $props, array $data): string + protected function renderTable(array $props, string $blockId, array $data): string { $headers = $props['headers'] ?? []; $rows = $props['rows'] ?? []; @@ -118,11 +219,23 @@ protected function renderTable(array $props, array $data): string } $html .= ''; - foreach ($rows as $row) { + foreach ($rows as $rIdx => $row) { $html .= ''; - foreach ($row as $cell) { - $value = $this->resolveBinding($cell ?? '', $data); - $html .= ''.e($value).''; + foreach ($row as $cIdx => $cell) { + $cellKey = "tbl_{$blockId}_r{$rIdx}_c{$cIdx}"; + $savedValue = $this->getFieldValue($cellKey, $data, ''); + $cellText = $cell ?? ''; + + if ($this->isEditMode()) { + $displayValue = $savedValue !== '' ? $savedValue : $cellText; + $html .= ''; + $html .= ''; + $html .= ''; + } else { + $displayValue = $savedValue !== '' ? $savedValue : $cellText; + $value = $this->resolveBinding($displayValue, $data); + $html .= ''.e($value).''; + } } $html .= ''; } @@ -135,7 +248,6 @@ protected function renderColumns(array $props, array $data): string { $count = intval($props['count'] ?? 2); $children = $props['children'] ?? []; - $width = round(100 / $count, 2); $html = '
'; @@ -167,69 +279,188 @@ protected function renderSpacer(array $props): string return "
"; } - protected function renderTextField(array $props, array $data): string + // ===== 폼 필드 블록 렌더링 ===== + + protected function renderTextField(array $props, string $blockId, array $data): string { $label = e($props['label'] ?? ''); - $binding = $props['binding'] ?? ''; - $value = $binding ? e($this->resolveBinding($binding, $data)) : ''; - $required = ! empty($props['required']) ? ' *' : ''; + $fieldKey = $this->getFieldKey($props, $blockId); + $value = $this->getFieldValue($fieldKey, $data); + $required = ! empty($props['required']); + $requiredMark = $required ? ' *' : ''; + $placeholder = e($props['placeholder'] ?? ''); - return "
{$value}
"; + if ($this->isEditMode()) { + $requiredAttr = $required ? 'required' : ''; + + return '
' + .'' + .'' + .'
'; + } + + // view / print + $displayValue = $value !== '' ? e($value) : '-'; + + return '
'.$displayValue.'
'; } - protected function renderNumberField(array $props, array $data): string + protected function renderNumberField(array $props, string $blockId, array $data): string { $label = e($props['label'] ?? ''); + $fieldKey = $this->getFieldKey($props, $blockId); + $value = $this->getFieldValue($fieldKey, $data); $unit = e($props['unit'] ?? ''); $unitDisplay = $unit ? " ({$unit})" : ''; + $min = $props['min'] ?? null; + $max = $props['max'] ?? null; + $decimal = intval($props['decimal'] ?? 0); - return "
"; + if ($this->isEditMode()) { + $attrs = ''; + if ($min !== null) { + $attrs .= ' min="'.e($min).'"'; + } + if ($max !== null) { + $attrs .= ' max="'.e($max).'"'; + } + if ($decimal > 0) { + $attrs .= ' step="'.e(1 / pow(10, $decimal)).'"'; + } + + return '
' + .'' + .'' + .'
'; + } + + $displayValue = $value !== '' ? e($value).$unitDisplay : '-'; + + return '
'.$displayValue.'
'; } - protected function renderDateField(array $props, array $data): string + protected function renderDateField(array $props, string $blockId, array $data): string { $label = e($props['label'] ?? ''); - $binding = $props['binding'] ?? ''; - $value = $binding ? e($this->resolveBinding($binding, $data)) : ''; + $fieldKey = $this->getFieldKey($props, $blockId); + $value = $this->getFieldValue($fieldKey, $data); - return "
{$value}
"; + if ($this->isEditMode()) { + $defaultValue = $value !== '' ? $value : ($this->resolveBinding($props['default'] ?? '', $data) ?: ''); + + return '
' + .'' + .'' + .'
'; + } + + $displayValue = $value !== '' ? e($value) : '-'; + + return '
'.$displayValue.'
'; } - protected function renderSelectField(array $props, array $data): string + protected function renderSelectField(array $props, string $blockId, array $data): string { $label = e($props['label'] ?? ''); + $fieldKey = $this->getFieldKey($props, $blockId); + $value = $this->getFieldValue($fieldKey, $data); $options = $props['options'] ?? []; - return "
".e(implode(' / ', $options)).'
'; + if ($this->isEditMode()) { + $html = '
' + .'' + .'
'; + + return $html; + } + + $displayValue = $value !== '' ? e($value) : '-'; + + return '
'.$displayValue.'
'; } - protected function renderCheckboxField(array $props, array $data): string + protected function renderCheckboxField(array $props, string $blockId, array $data): string { $label = e($props['label'] ?? ''); + $fieldKey = $this->getFieldKey($props, $blockId); + $savedValues = array_filter(explode(',', $this->getFieldValue($fieldKey, $data))); $options = $props['options'] ?? []; - $html = "
"; + if ($this->isEditMode()) { + $html = '
' + .'' + .'
'; + foreach ($options as $idx => $opt) { + $checked = in_array($opt, $savedValues) ? ' checked' : ''; + $html .= ''; + } + $html .= '
'; + + return $html; + } + + // view / print + $html = '
'; foreach ($options as $opt) { - $html .= ''; + $checked = in_array($opt, $savedValues) ? '☑' : '☐'; + $html .= ''.$checked.' '.e($opt).''; } $html .= '
'; return $html; } - protected function renderTextareaField(array $props, array $data): string + protected function renderTextareaField(array $props, string $blockId, array $data): string { $label = e($props['label'] ?? ''); + $fieldKey = $this->getFieldKey($props, $blockId); + $value = $this->getFieldValue($fieldKey, $data); $rows = max(intval($props['rows'] ?? 3), 1); + + if ($this->isEditMode()) { + return '
' + .'' + .'' + .'
'; + } + + $displayValue = $value !== '' ? nl2br(e($value)) : '-'; $height = $rows * 24; - return "
"; + return '
'.$displayValue.'
'; } - protected function renderSignatureField(array $props, array $data): string + protected function renderSignatureField(array $props, string $blockId, array $data): string { $label = e($props['label'] ?? '서명'); + $fieldKey = $this->getFieldKey($props, $blockId); + $value = $this->getFieldValue($fieldKey, $data); - return "
서명 영역
"; + if ($this->isEditMode()) { + return '
' + .'' + .'
' + .'클릭하여 서명' + .'
'; + } + + if ($value) { + return '
서명
'; + } + + return '
서명 영역
'; } } diff --git a/resources/views/documents/edit.blade.php b/resources/views/documents/edit.blade.php index ea865df0..a6e9dfff 100644 --- a/resources/views/documents/edit.blade.php +++ b/resources/views/documents/edit.blade.php @@ -70,7 +70,17 @@ class="w-full rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:rin
- {{-- 기본 필드 --}} + {{-- 블록 빌더 서식: 블록 렌더러로 폼 생성 --}} + @if($template->isBlockBuilder() && !empty($template->schema)) +
+

{{ $template->title ?? $template->name }}

+
+ {!! $blockHtml !!} +
+
+ @else + + {{-- 기본 필드 (레거시 서식) --}} @if($template->basicFields && $template->basicFields->count() > 0)

{{ $template->title ?? '문서 정보' }}

@@ -787,6 +797,8 @@ class="w-full rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:rin @endforeach @endif + @endif {{-- end: 블록 빌더 vs 레거시 분기 --}} + {{-- 버튼 --}}
{ - const fieldKey = el.dataset.fieldKey; - const sectionId = el.dataset.sectionId ? parseInt(el.dataset.sectionId) : null; - const columnId = el.dataset.columnId ? parseInt(el.dataset.columnId) : null; - const rowIndex = el.dataset.rowIndex ? parseInt(el.dataset.rowIndex) : 0; - - let fieldValue; - if (el.type === 'checkbox') { - fieldValue = el.checked ? el.value : ''; - } else if (el.tagName === 'SELECT') { - fieldValue = el.value; - } else { - fieldValue = el.value; - } - - data.data.push({ - field_key: fieldKey, - field_value: fieldValue, - section_id: sectionId, - column_id: columnId, - row_index: rowIndex, + // 블록 빌더 데이터 수집 + const blockContainer = document.getElementById('blockFormContainer'); + if (blockContainer) { + // 체크박스 그룹 처리: 같은 field_key의 체크박스 값을 콤마로 합침 + const checkboxGroups = {}; + blockContainer.querySelectorAll('[data-checkbox-group]').forEach(el => { + const group = el.dataset.checkboxGroup; + if (!checkboxGroups[group]) checkboxGroups[group] = []; + if (el.checked) checkboxGroups[group].push(el.value); }); - }); + Object.entries(checkboxGroups).forEach(([key, values]) => { + data.data.push({ field_key: key, field_value: values.join(',') }); + }); + + // 일반 필드 수집 (체크박스 그룹 제외) + blockContainer.querySelectorAll('[data-field-key]:not([data-checkbox-group])').forEach(el => { + const fieldKey = el.dataset.fieldKey; + let fieldValue; + if (el.type === 'checkbox') { + fieldValue = el.checked ? el.value : ''; + } else if (el.tagName === 'SELECT') { + fieldValue = el.value; + } else { + fieldValue = el.value; + } + data.data.push({ field_key: fieldKey, field_value: fieldValue }); + }); + } else { + // 레거시 서식: 섹션 테이블 데이터 수집 (data-field-key 기반) + form.querySelectorAll('[data-field-key]').forEach(el => { + const fieldKey = el.dataset.fieldKey; + const sectionId = el.dataset.sectionId ? parseInt(el.dataset.sectionId) : null; + const columnId = el.dataset.columnId ? parseInt(el.dataset.columnId) : null; + const rowIndex = el.dataset.rowIndex ? parseInt(el.dataset.rowIndex) : 0; + + let fieldValue; + if (el.type === 'checkbox') { + fieldValue = el.checked ? el.value : ''; + } else if (el.tagName === 'SELECT') { + fieldValue = el.value; + } else { + fieldValue = el.value; + } + + data.data.push({ + field_key: fieldKey, + field_value: fieldValue, + section_id: sectionId, + column_id: columnId, + row_index: rowIndex, + }); + }); + } const isCreate = {{ $isCreate ? 'true' : 'false' }}; const url = isCreate ? '/api/admin/documents' : '/api/admin/documents/{{ $document?->id }}'; diff --git a/resources/views/documents/show.blade.php b/resources/views/documents/show.blade.php index e9802de4..fff0ed0b 100644 --- a/resources/views/documents/show.blade.php +++ b/resources/views/documents/show.blade.php @@ -107,7 +107,15 @@ class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg transiti
- {{-- 기본 필드 데이터 --}} + {{-- 블록 빌더 서식: 블록 렌더러로 조회 --}} + @if($document->template?->isBlockBuilder() && !empty($document->template->schema)) +
+

{{ $document->template->title ?? $document->template->name }}

+
{!! $blockHtml !!}
+
+ @else + + {{-- 기본 필드 데이터 (레거시 서식) --}} @if($document->template?->basicFields && $document->template->basicFields->count() > 0)

{{ $document->template->title ?? '문서 정보' }}

@@ -767,6 +775,8 @@ class="px-2 py-2 text-center text-xs font-medium text-gray-600 uppercase border @endforeach @endif + @endif {{-- end: 블록 빌더 vs 레거시 분기 --}} + {{-- 첨부파일 --}} @if($document->attachments && $document->attachments->count() > 0)