feat:검사 기준서 동적화 + 소스 테이블 통합 검색
- 동적 필드/연결 모델 추가 (SectionField, Link, LinkValue, Preset) - 통합 검색 API (SourceTableSearchController) - items/processes/lots/users - 템플릿 편집 UI: 소스 테이블 드롭다운 + datalist 검색/선택 - 문서 작성/인쇄/상세 뷰: getFieldValue() 기반 동적 렌더링 - DocumentTemplateApiController: source_table 기반 저장/복제 - DocumentController: sectionFields/links eager loading 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,10 @@
|
||||
use App\Models\DocumentTemplateApprovalLine;
|
||||
use App\Models\DocumentTemplateBasicField;
|
||||
use App\Models\DocumentTemplateColumn;
|
||||
use App\Models\DocumentTemplateLink;
|
||||
use App\Models\DocumentTemplateLinkValue;
|
||||
use App\Models\DocumentTemplateSection;
|
||||
use App\Models\DocumentTemplateSectionField;
|
||||
use App\Models\DocumentTemplateSectionItem;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -71,6 +74,8 @@ public function show(int $id): JsonResponse
|
||||
'basicFields',
|
||||
'sections.items',
|
||||
'columns',
|
||||
'sectionFields',
|
||||
'links.linkValues',
|
||||
])->findOrFail($id);
|
||||
|
||||
return response()->json([
|
||||
@@ -103,6 +108,8 @@ public function store(Request $request): JsonResponse
|
||||
'basic_fields' => 'nullable|array',
|
||||
'sections' => 'nullable|array',
|
||||
'columns' => 'nullable|array',
|
||||
'section_fields' => 'nullable|array',
|
||||
'template_links' => 'nullable|array',
|
||||
]);
|
||||
|
||||
try {
|
||||
@@ -132,7 +139,7 @@ public function store(Request $request): JsonResponse
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '문서양식이 생성되었습니다.',
|
||||
'data' => $template->load(['approvalLines', 'basicFields', 'sections.items', 'columns']),
|
||||
'data' => $template->load(['approvalLines', 'basicFields', 'sections.items', 'columns', 'sectionFields', 'links.linkValues']),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
@@ -170,6 +177,8 @@ public function update(Request $request, int $id): JsonResponse
|
||||
'basic_fields' => 'nullable|array',
|
||||
'sections' => 'nullable|array',
|
||||
'columns' => 'nullable|array',
|
||||
'section_fields' => 'nullable|array',
|
||||
'template_links' => 'nullable|array',
|
||||
]);
|
||||
|
||||
try {
|
||||
@@ -198,7 +207,7 @@ public function update(Request $request, int $id): JsonResponse
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '문서양식이 수정되었습니다.',
|
||||
'data' => $template->fresh(['approvalLines', 'basicFields', 'sections.items', 'columns']),
|
||||
'data' => $template->fresh(['approvalLines', 'basicFields', 'sections.items', 'columns', 'sectionFields', 'links.linkValues']),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
@@ -262,6 +271,8 @@ public function forceDestroy(int $id): JsonResponse
|
||||
$section->delete();
|
||||
});
|
||||
$template->columns()->delete();
|
||||
$template->sectionFields()->delete();
|
||||
$template->links()->delete(); // cascade로 linkValues도 삭제
|
||||
$template->forceDelete();
|
||||
|
||||
return response()->json([
|
||||
@@ -321,6 +332,8 @@ public function duplicate(Request $request, int $id): JsonResponse
|
||||
'basicFields',
|
||||
'sections.items',
|
||||
'columns',
|
||||
'sectionFields',
|
||||
'links.linkValues',
|
||||
])->findOrFail($id);
|
||||
|
||||
$newName = $request->input('name', $source->name.' (복사)');
|
||||
@@ -404,6 +417,45 @@ public function duplicate(Request $request, int $id): JsonResponse
|
||||
]);
|
||||
}
|
||||
|
||||
// 검사 기준서 동적 필드 복제
|
||||
foreach ($source->sectionFields as $field) {
|
||||
DocumentTemplateSectionField::create([
|
||||
'template_id' => $newTemplate->id,
|
||||
'field_key' => $field->field_key,
|
||||
'label' => $field->label,
|
||||
'field_type' => $field->field_type,
|
||||
'options' => $field->options,
|
||||
'width' => $field->width,
|
||||
'is_required' => $field->is_required,
|
||||
'sort_order' => $field->sort_order,
|
||||
]);
|
||||
}
|
||||
|
||||
// 외부 키 매핑 복제
|
||||
foreach ($source->links as $link) {
|
||||
$newLink = DocumentTemplateLink::create([
|
||||
'template_id' => $newTemplate->id,
|
||||
'link_key' => $link->link_key,
|
||||
'label' => $link->label,
|
||||
'link_type' => $link->link_type,
|
||||
'source_table' => $link->source_table,
|
||||
'search_params' => $link->search_params,
|
||||
'display_fields' => $link->display_fields,
|
||||
'is_required' => $link->is_required,
|
||||
'sort_order' => $link->sort_order,
|
||||
]);
|
||||
|
||||
foreach ($link->linkValues as $value) {
|
||||
DocumentTemplateLinkValue::create([
|
||||
'template_id' => $newTemplate->id,
|
||||
'link_id' => $newLink->id,
|
||||
'linkable_id' => $value->linkable_id,
|
||||
'sort_order' => $value->sort_order,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
return response()->json([
|
||||
@@ -476,6 +528,9 @@ private function saveRelations(DocumentTemplate $template, array $data, bool $de
|
||||
// sections는 cascade로 items도 함께 삭제됨
|
||||
$template->sections()->delete();
|
||||
$template->columns()->delete();
|
||||
$template->sectionFields()->delete();
|
||||
// links는 cascade로 linkValues도 함께 삭제됨
|
||||
$template->links()->delete();
|
||||
}
|
||||
|
||||
// 결재라인
|
||||
@@ -551,5 +606,51 @@ private function saveRelations(DocumentTemplate $template, array $data, bool $de
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 검사 기준서 동적 필드 정의
|
||||
if (! empty($data['section_fields'])) {
|
||||
foreach ($data['section_fields'] as $index => $field) {
|
||||
DocumentTemplateSectionField::create([
|
||||
'template_id' => $template->id,
|
||||
'field_key' => $field['field_key'] ?? '',
|
||||
'label' => $field['label'] ?? '',
|
||||
'field_type' => $field['field_type'] ?? 'text',
|
||||
'options' => $field['options'] ?? null,
|
||||
'width' => $field['width'] ?? '100px',
|
||||
'is_required' => $field['is_required'] ?? false,
|
||||
'sort_order' => $index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 외부 키 매핑 + 연결 값
|
||||
if (! empty($data['template_links'])) {
|
||||
foreach ($data['template_links'] as $index => $link) {
|
||||
$newLink = DocumentTemplateLink::create([
|
||||
'template_id' => $template->id,
|
||||
'link_key' => $link['link_key'] ?? '',
|
||||
'label' => $link['label'] ?? '',
|
||||
'link_type' => $link['link_type'] ?? 'single',
|
||||
'source_table' => $link['source_table'] ?? '',
|
||||
'search_params' => $link['search_params'] ?? null,
|
||||
'display_fields' => $link['display_fields'] ?? null,
|
||||
'is_required' => $link['is_required'] ?? false,
|
||||
'sort_order' => $index,
|
||||
]);
|
||||
|
||||
// 연결 값 저장
|
||||
if (! empty($link['values'])) {
|
||||
foreach ($link['values'] as $vIndex => $value) {
|
||||
DocumentTemplateLinkValue::create([
|
||||
'template_id' => $template->id,
|
||||
'link_id' => $newLink->id,
|
||||
'linkable_id' => $value['linkable_id'] ?? $value['id'] ?? $value,
|
||||
'sort_order' => $vIndex,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
171
app/Http/Controllers/Api/Admin/SourceTableSearchController.php
Normal file
171
app/Http/Controllers/Api/Admin/SourceTableSearchController.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SourceTableSearchController extends Controller
|
||||
{
|
||||
/**
|
||||
* 소스 테이블별 검색 설정
|
||||
* model: Eloquent 모델 클래스 (없으면 DB 쿼리빌더 사용)
|
||||
* search_columns: 검색 대상 컬럼
|
||||
* select: 반환할 컬럼
|
||||
* title_field: 표시 제목 필드
|
||||
* subtitle_field: 표시 부제목 필드
|
||||
* has_tenant: tenant_id 필터 여부
|
||||
* has_active: is_active 필터 여부
|
||||
*/
|
||||
private function getTableConfig(): array
|
||||
{
|
||||
return [
|
||||
'items' => [
|
||||
'model' => \App\Models\Items\Item::class,
|
||||
'search_columns' => ['name', 'code'],
|
||||
'select' => ['id', 'code', 'name', 'item_type', 'unit'],
|
||||
'title_field' => 'name',
|
||||
'subtitle_field' => 'code',
|
||||
'has_tenant' => true,
|
||||
'has_active' => true,
|
||||
'order_by' => 'name',
|
||||
],
|
||||
'processes' => [
|
||||
'model' => \App\Models\Process::class,
|
||||
'search_columns' => ['process_name', 'process_code'],
|
||||
'select' => ['id', 'process_code', 'process_name'],
|
||||
'title_field' => 'process_name',
|
||||
'subtitle_field' => 'process_code',
|
||||
'has_tenant' => true,
|
||||
'has_active' => true,
|
||||
'order_by' => 'process_name',
|
||||
],
|
||||
'lots' => [
|
||||
'table' => 'lots',
|
||||
'search_columns' => ['lot_number', 'specification'],
|
||||
'select' => ['id', 'lot_number', 'item_id', 'specification'],
|
||||
'title_field' => 'lot_number',
|
||||
'subtitle_field' => 'specification',
|
||||
'has_tenant' => true,
|
||||
'has_active' => false,
|
||||
'order_by' => 'lot_number',
|
||||
],
|
||||
'users' => [
|
||||
'model' => \App\Models\User::class,
|
||||
'search_columns' => ['name', 'email'],
|
||||
'select' => ['id', 'name', 'email'],
|
||||
'title_field' => 'name',
|
||||
'subtitle_field' => 'email',
|
||||
'has_tenant' => false,
|
||||
'has_active' => false,
|
||||
'order_by' => 'name',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스 테이블 통합 검색
|
||||
* GET /api/admin/source-tables/{table}/search?q=xxx&item_type=RM,SM&ids=1,2,3
|
||||
*/
|
||||
public function search(Request $request, string $table): JsonResponse
|
||||
{
|
||||
$config = $this->getTableConfig()[$table] ?? null;
|
||||
|
||||
if (! $config) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => "지원하지 않는 테이블: {$table}",
|
||||
], 400);
|
||||
}
|
||||
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$query = $request->input('q', '');
|
||||
|
||||
// Eloquent 모델 또는 DB 쿼리빌더
|
||||
if (isset($config['model'])) {
|
||||
$builder = $config['model']::query();
|
||||
} else {
|
||||
$builder = DB::table($config['table'])->whereNull('deleted_at');
|
||||
}
|
||||
|
||||
// 테넌트 필터
|
||||
if ($config['has_tenant'] && $tenantId) {
|
||||
$builder->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
// 활성 필터
|
||||
if ($config['has_active']) {
|
||||
$builder->where('is_active', true);
|
||||
}
|
||||
|
||||
// 검색어 필터
|
||||
if ($query) {
|
||||
$builder->where(function ($q) use ($query, $config) {
|
||||
foreach ($config['search_columns'] as $col) {
|
||||
$q->orWhere($col, 'like', "%{$query}%");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 추가 파라미터 필터 (item_type 등)
|
||||
if ($request->has('item_type')) {
|
||||
$builder->whereIn('item_type', explode(',', $request->input('item_type')));
|
||||
}
|
||||
|
||||
// ID 목록으로 조회
|
||||
if ($request->has('ids')) {
|
||||
$builder->whereIn('id', explode(',', $request->input('ids')));
|
||||
}
|
||||
|
||||
$results = $builder
|
||||
->orderBy($config['order_by'])
|
||||
->limit(30)
|
||||
->get($config['select']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $results,
|
||||
'meta' => [
|
||||
'title_field' => $config['title_field'],
|
||||
'subtitle_field' => $config['subtitle_field'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용 가능한 소스 테이블 목록
|
||||
* GET /api/admin/source-tables
|
||||
*/
|
||||
public function tables(): JsonResponse
|
||||
{
|
||||
$config = $this->getTableConfig();
|
||||
|
||||
$tables = collect($config)->map(function ($cfg, $key) {
|
||||
return [
|
||||
'key' => $key,
|
||||
'title_field' => $cfg['title_field'],
|
||||
'subtitle_field' => $cfg['subtitle_field'],
|
||||
];
|
||||
})->values();
|
||||
|
||||
// system_field_definitions에서 라벨 가져오기
|
||||
$labels = DB::table('system_field_definitions')
|
||||
->select('source_table', 'source_table_label')
|
||||
->groupBy('source_table', 'source_table_label')
|
||||
->pluck('source_table_label', 'source_table')
|
||||
->toArray();
|
||||
|
||||
$tables = $tables->map(function ($t) use ($labels) {
|
||||
$t['label'] = $labels[$t['key']] ?? ucfirst($t['key']);
|
||||
|
||||
return $t;
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $tables,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ public function create(Request $request): View|Response
|
||||
|
||||
// 선택된 템플릿
|
||||
$template = $templateId
|
||||
? DocumentTemplate::with(['approvalLines', 'basicFields', 'sections.items', 'columns'])->find($templateId)
|
||||
? DocumentTemplate::with(['approvalLines', 'basicFields', 'sections.items', 'columns', 'sectionFields', 'links.linkValues'])->find($templateId)
|
||||
: null;
|
||||
|
||||
return view('documents.edit', [
|
||||
@@ -82,6 +82,8 @@ public function edit(int $id): View|Response
|
||||
'template.basicFields',
|
||||
'template.sections.items',
|
||||
'template.columns',
|
||||
'template.sectionFields',
|
||||
'template.links.linkValues',
|
||||
'approvals.user',
|
||||
'data',
|
||||
'attachments.file',
|
||||
@@ -115,6 +117,8 @@ public function print(int $id): View
|
||||
'template.basicFields',
|
||||
'template.sections.items',
|
||||
'template.columns',
|
||||
'template.sectionFields',
|
||||
'template.links.linkValues',
|
||||
'approvals.user',
|
||||
'data',
|
||||
'creator',
|
||||
@@ -137,6 +141,8 @@ public function show(int $id): View
|
||||
'template.basicFields',
|
||||
'template.sections.items',
|
||||
'template.columns',
|
||||
'template.sectionFields',
|
||||
'template.links.linkValues',
|
||||
'approvals.user',
|
||||
'data',
|
||||
'attachments.file',
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\DocumentTemplate;
|
||||
use App\Models\DocumentTemplateFieldPreset;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DocumentTemplateController extends Controller
|
||||
@@ -31,6 +33,7 @@ public function create(): View
|
||||
'isCreate' => true,
|
||||
'categories' => $this->getCategories(),
|
||||
'tenant' => $this->getCurrentTenant(),
|
||||
'presets' => DocumentTemplateFieldPreset::orderBy('sort_order')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -44,6 +47,8 @@ public function edit(int $id): View
|
||||
'basicFields',
|
||||
'sections.items',
|
||||
'columns',
|
||||
'sectionFields',
|
||||
'links.linkValues',
|
||||
])->findOrFail($id);
|
||||
|
||||
// JavaScript용 데이터 변환
|
||||
@@ -55,6 +60,7 @@ public function edit(int $id): View
|
||||
'isCreate' => false,
|
||||
'categories' => $this->getCategories(),
|
||||
'tenant' => $this->getCurrentTenant(),
|
||||
'presets' => DocumentTemplateFieldPreset::orderBy('sort_order')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -162,6 +168,79 @@ private function prepareTemplateData(DocumentTemplate $template): array
|
||||
'sub_labels' => $c->sub_labels,
|
||||
];
|
||||
})->toArray(),
|
||||
'section_fields' => $template->sectionFields->map(function ($f) {
|
||||
return [
|
||||
'id' => $f->id,
|
||||
'field_key' => $f->field_key,
|
||||
'label' => $f->label,
|
||||
'field_type' => $f->field_type,
|
||||
'options' => $f->options,
|
||||
'width' => $f->width,
|
||||
'is_required' => $f->is_required,
|
||||
];
|
||||
})->toArray(),
|
||||
'template_links' => $template->links->map(function ($l) {
|
||||
$values = $l->linkValues->map(function ($v) use ($l) {
|
||||
$displayText = $this->resolveDisplayText($l->source_table, $v->linkable_id, $l->display_fields);
|
||||
|
||||
return [
|
||||
'id' => $v->id,
|
||||
'linkable_id' => $v->linkable_id,
|
||||
'display_text' => $displayText,
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
return [
|
||||
'id' => $l->id,
|
||||
'link_key' => $l->link_key,
|
||||
'label' => $l->label,
|
||||
'link_type' => $l->link_type,
|
||||
'source_table' => $l->source_table,
|
||||
'search_params' => $l->search_params,
|
||||
'display_fields' => $l->display_fields,
|
||||
'is_required' => $l->is_required,
|
||||
'values' => $values,
|
||||
];
|
||||
})->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스 테이블에서 레코드의 표시 텍스트 조회
|
||||
*/
|
||||
private function resolveDisplayText(?string $sourceTable, int $linkableId, ?array $displayFields): string
|
||||
{
|
||||
if (! $sourceTable || ! $linkableId) {
|
||||
return "ID: {$linkableId}";
|
||||
}
|
||||
|
||||
$titleField = $displayFields['title'] ?? 'name';
|
||||
$subtitleField = $displayFields['subtitle'] ?? null;
|
||||
|
||||
// 모델 매핑
|
||||
$modelMap = [
|
||||
'items' => \App\Models\Items\Item::class,
|
||||
'processes' => \App\Models\Process::class,
|
||||
'users' => \App\Models\User::class,
|
||||
];
|
||||
|
||||
try {
|
||||
if (isset($modelMap[$sourceTable])) {
|
||||
$record = $modelMap[$sourceTable]::find($linkableId);
|
||||
} else {
|
||||
$record = DB::table($sourceTable)->find($linkableId);
|
||||
}
|
||||
|
||||
if (! $record) {
|
||||
return "ID: {$linkableId}";
|
||||
}
|
||||
|
||||
$title = is_object($record) ? ($record->$titleField ?? '') : ($record->$titleField ?? '');
|
||||
$subtitle = $subtitleField ? (is_object($record) ? ($record->$subtitleField ?? '') : ($record->$subtitleField ?? '')) : '';
|
||||
|
||||
return $title . ($subtitle ? " ({$subtitle})" : '');
|
||||
} catch (\Exception $e) {
|
||||
return "ID: {$linkableId}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user