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 "
";
+ 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 = '
';
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)