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>";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user