feat: 문서 양식 관리 및 수입검사 양식 개선

- 문서 양식 API 컨트롤러 및 뷰 개선
- 수입검사 양식 시더 업데이트
- 문서 미리보기 뷰 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 03:27:32 +09:00
parent b1b4070fe4
commit 16fb78fe5e
7 changed files with 155 additions and 41 deletions

View File

@@ -625,6 +625,7 @@ private function saveRelations(DocumentTemplate $template, array $data, bool $de
'frequency_c' => $item['frequency_c'] ?? null,
'frequency' => $item['frequency'] ?? '',
'regulation' => $item['regulation'] ?? '',
'field_values' => $item['field_values'] ?? null,
'sort_order' => $iIndex,
]);
}

View File

@@ -213,7 +213,18 @@ private function prepareTemplateData(DocumentTemplate $template): array
'is_required' => $f->is_required,
];
})->toArray(),
'template_links' => $template->links->map(function ($l) {
'template_links' => $this->buildTemplateLinks($template),
];
}
/**
* template_links 데이터 빌드 (linked_item_ids 레거시 호환)
*/
private function buildTemplateLinks(DocumentTemplate $template): array
{
// 신규 template_links가 있으면 그대로 사용
if ($template->links->isNotEmpty()) {
return $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);
@@ -235,8 +246,35 @@ private function prepareTemplateData(DocumentTemplate $template): array
'is_required' => $l->is_required,
'values' => $values,
];
})->toArray(),
];
})->toArray();
}
// 레거시: linked_item_ids만 있고 template_links가 없는 경우 가상 엔트리 생성
$linkedItemIds = $template->linked_item_ids;
if (! empty($linkedItemIds) && is_array($linkedItemIds)) {
$displayFields = ['title' => 'name', 'subtitle' => 'code'];
$values = collect($linkedItemIds)->map(function ($itemId) use ($displayFields) {
return [
'id' => $itemId,
'linkable_id' => $itemId,
'display_text' => $this->resolveDisplayText('items', $itemId, $displayFields),
];
})->toArray();
return [[
'id' => 'legacy',
'link_key' => 'items',
'label' => '연결 품목',
'link_type' => 'multiple',
'source_table' => 'items',
'search_params' => null,
'display_fields' => $displayFields,
'is_required' => false,
'values' => $values,
]];
}
return [];
}
/**

View File

@@ -12,7 +12,7 @@
class IncomingInspectionTemplateSeeder extends Seeder
{
private int $tenantId = 1;
private int $tenantId = 287;
public function run(): void
{
@@ -97,6 +97,7 @@ private function getTemplateDefinitions(): array
'method' => '공급업체 밀시트',
'frequency' => '입고시',
'regulation' => 'KS D 3528',
'field_values' => ['reference_attribute' => 'thickness'],
],
[
'category' => '도금',
@@ -296,4 +297,4 @@ private function cleanupExisting(string $name): void
DocumentTemplateApprovalLine::where('template_id', $existing->id)->delete();
$existing->forceDelete();
}
}
}

View File

@@ -1136,6 +1136,15 @@ function removeSubLabel(colId, idx) {
}
}
// template_links의 items 값을 linked_item_ids로 동기화
function syncLinkedItemIds() {
const link = getMainLink();
if (link && link.source_table === 'items' && link.values && link.values.length > 0) {
return link.values.map(v => v.linkable_id || v.id).filter(Boolean);
}
return templateState.linked_item_ids;
}
// ===== 저장 =====
function saveTemplate() {
const name = document.getElementById('name').value.trim();
@@ -1156,7 +1165,7 @@ function saveTemplate() {
footer_judgement_label: document.getElementById('footer_judgement_label').value,
footer_judgement_options: templateState.footer_judgement_options.filter(o => o.trim() !== ''),
is_active: document.getElementById('is_active').checked,
linked_item_ids: templateState.linked_item_ids,
linked_item_ids: syncLinkedItemIds(),
linked_process_id: templateState.linked_process_id,
approval_lines: templateState.approval_lines,
basic_fields: templateState.basic_fields,
@@ -1850,7 +1859,7 @@ function updateDynamicField(sectionId, itemId, fieldKey, value) {
// 기존 컬럼에도 업데이트 (하위 호환)
item[fieldKey] = value;
// field_values에도 업데이트
if (!item.field_values) item.field_values = {};
if (!item.field_values || Array.isArray(item.field_values)) item.field_values = {};
item.field_values[fieldKey] = value;
}
@@ -1862,6 +1871,16 @@ function calcTableWidth() {
return Math.max(width, 600);
}
// 참조속성 업데이트
function updateReferenceAttribute(sectionId, itemId, value) {
const section = templateState.sections.find(s => s.id == sectionId);
if (!section) return;
const item = section.items.find(i => i.id == itemId);
if (!item) return;
if (!item.field_values || Array.isArray(item.field_values)) item.field_values = {};
item.field_values.reference_attribute = value || null;
}
function renderDynamicFieldInput(field, sectionId, item) {
const val = getItemFieldValue(item, field.field_key);
const fk = field.field_key;
@@ -1907,12 +1926,23 @@ class="w-full px-2 py-1 border border-gray-200 rounded text-xs">`;
return renderToleranceInput(sid, iid, val || item.tolerance);
case 'text_with_criteria': {
// 상단: 검사기준 텍스트, 하단: 기준범위 min/max
// 상단: 참조속성 + 검사기준 텍스트, 하단: 기준범위 min/max
const c = getItemFieldValue(item, 'standard_criteria') || item.standard_criteria;
const refAttr = (item.field_values && item.field_values.reference_attribute) || '';
return `<div class="flex flex-col gap-1">
<input type="text" value="${escapeHtml(val || '')}" placeholder="검사기준"
onchange="updateDynamicField('${sid}', '${iid}', '${fk}', this.value)"
class="w-full px-2 py-1 border border-gray-200 rounded text-xs">
<div class="flex gap-1">
<select onchange="updateReferenceAttribute('${sid}', '${iid}', this.value)"
class="px-1 py-1 border border-gray-200 rounded text-xs flex-shrink-0" style="width:72px"
title="참조속성">
<option value="" ${!refAttr ? 'selected' : ''}>기준치수</option>
<option value="thickness" ${refAttr === 'thickness' ? 'selected' : ''}>두께</option>
<option value="width" ${refAttr === 'width' ? 'selected' : ''}>너비</option>
<option value="length" ${refAttr === 'length' ? 'selected' : ''}>길이</option>
</select>
<input type="text" value="${escapeHtml(val || '')}" placeholder="검사기준"
onchange="updateDynamicField('${sid}', '${iid}', '${fk}', this.value)"
class="flex-1 min-w-0 px-2 py-1 border border-gray-200 rounded text-xs">
</div>
<div style="display:grid;grid-template-columns:1fr auto 8px 1fr auto;align-items:center;gap:2px">
<input type="number" step="any" value="${c?.min ?? ''}" placeholder="min"
onchange="updateStandardCriteria('${sid}', '${iid}', 'min', this.value)"

View File

@@ -86,6 +86,8 @@ class="bg-white rounded-lg shadow-sm overflow-hidden">
<script>
// 폼 제출 시 HTMX 이벤트 트리거
(function() {
let searchDebounceTimer = null;
function initFilterForm() {
const filterForm = document.getElementById('filterForm');
if (filterForm && !filterForm._initialized) {
@@ -97,6 +99,19 @@ function initFilterForm() {
filterForm._initialized = true;
}
// 검색 input 실시간 필터링 (debounce 300ms)
const searchInput = filterForm?.querySelector('input[name="search"]');
if (searchInput && !searchInput._initialized) {
searchInput.addEventListener('input', function() {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(function() {
handleTrashedFilter();
htmx.trigger('#template-table', 'filterSubmit');
}, 300);
});
searchInput._initialized = true;
}
// 휴지통 필터 처리
const isActiveFilter = document.getElementById('isActiveFilter');
if (isActiveFilter && !isActiveFilter._initialized) {

View File

@@ -25,16 +25,18 @@ class="text-blue-600 hover:text-blue-800 font-medium">
</td>
<td class="px-4 py-3 whitespace-nowrap">
@if($template->category)
@php
$categoryName = $categoryNames[$template->category] ?? $template->category;
@endphp
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
@switch($template->category)
@case('품질') bg-green-100 text-green-800 @break
@case('생산') bg-blue-100 text-blue-800 @break
@case('영업') bg-purple-100 text-purple-800 @break
@case('구매') bg-orange-100 text-orange-800 @break
@default bg-gray-100 text-gray-800
@endswitch
@if(str_contains($categoryName, '수입')) bg-blue-100 text-blue-800
@elseif(str_contains($categoryName, '출하')) bg-green-100 text-green-800
@elseif(str_contains($categoryName, '품질')) bg-purple-100 text-purple-800
@elseif(str_contains($categoryName, '중간')) bg-orange-100 text-orange-800
@else bg-gray-100 text-gray-800
@endif
">
{{ $template->category }}
{{ $categoryName }}
</span>
@else
<span class="text-gray-400">-</span>

View File

@@ -176,54 +176,77 @@ class="px-2 py-2 text-center text-xs font-medium text-gray-600 uppercase border
@endif
</thead>
{{-- 테이블 바디 (읽기 전용) --}}
{{-- React field_key 형식: {item_id}_n{n}, {item_id}_okng_n{n}, {item_id}_result --}}
<tbody>
@foreach($section->items as $rowIndex => $item)
@php
$isOkng = $item->measurement_type === 'checkbox';
@endphp
<tr class="hover:bg-gray-50">
@foreach($document->template->columns as $col)
@if($col->column_type === 'complex' && $col->sub_labels)
{{-- complex: 서브 라벨별 데이터 --}}
{{-- complex: 측정치 (n1, n2, n3...) --}}
@foreach($col->sub_labels as $subIndex => $subLabel)
@php
$fieldKey = "s{$section->id}_r{$rowIndex}_c{$col->id}_sub{$subIndex}";
$n = $subIndex + 1;
$fieldKey = $isOkng
? "{$item->id}_okng_n{$n}"
: "{$item->id}_n{$n}";
$savedVal = $document->data->where('field_key', $fieldKey)->first()?->field_value ?? '';
@endphp
<td class="px-2 py-2 border border-gray-300 text-center text-sm">{{ $savedVal ?: '-' }}</td>
<td class="px-2 py-2 border border-gray-300 text-center text-sm {{ !$isOkng ? 'font-mono' : '' }}">
@if($isOkng && $savedVal)
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium
{{ strtolower($savedVal) === 'ok' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' }}">
{{ strtoupper($savedVal) }}
</span>
@else
{{ $savedVal ?: '-' }}
@endif
</td>
@endforeach
@elseif($col->column_type === 'select')
{{-- select: 판정 결과 --}}
{{-- select: 항목별 판정 {item_id}_result --}}
@php
$fieldKey = "s{$section->id}_r{$rowIndex}_c{$col->id}";
$fieldKey = "{$item->id}_result";
$savedVal = $document->data->where('field_key', $fieldKey)->first()?->field_value ?? '';
@endphp
<td class="px-2 py-2 border border-gray-300 text-center text-sm">
@if($savedVal)
@php
$isPass = in_array(strtolower($savedVal), ['ok', '적합', '합격', 'pass']);
@endphp
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium
{{ $savedVal === '적합' || $savedVal === '합격' || $savedVal === 'OK' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' }}">
{{ $savedVal }}
{{ $isPass ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' }}">
{{ $isPass ? '적합' : '부적합' }}
</span>
@else
-
@endif
</td>
@elseif($col->column_type === 'check')
{{-- check: 체크 결과 --}}
{{-- check: 체크 결과 {item_id}_result --}}
@php
$fieldKey = "s{$section->id}_r{$rowIndex}_c{$col->id}";
$fieldKey = "{$item->id}_result";
$savedVal = $document->data->where('field_key', $fieldKey)->first()?->field_value ?? '';
@endphp
<td class="px-2 py-2 border border-gray-300 text-center text-sm">
@if($savedVal === 'OK')
@if(in_array(strtolower($savedVal), ['ok', 'pass', '적합', '합격']))
<svg class="w-5 h-5 text-green-500 mx-auto" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
@elseif($savedVal)
<svg class="w-5 h-5 text-red-500 mx-auto" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
@else
-
@endif
</td>
@elseif($col->column_type === 'measurement')
{{-- measurement: 수치 데이터 --}}
{{-- measurement: 수치 데이터 {item_id}_n1 --}}
@php
$fieldKey = "s{$section->id}_r{$rowIndex}_c{$col->id}";
$fieldKey = "{$item->id}_n1";
$savedVal = $document->data->where('field_key', $fieldKey)->first()?->field_value ?? '';
@endphp
<td class="px-2 py-2 border border-gray-300 text-center text-sm font-mono">{{ $savedVal ?: '-' }}</td>
@@ -247,7 +270,7 @@ class="px-2 py-2 text-center text-xs font-medium text-gray-600 uppercase border
</td>
@else
@php
$fieldKey = "s{$section->id}_r{$rowIndex}_c{$col->id}";
$fieldKey = "{$item->id}_n1";
$savedVal = $document->data->where('field_key', $fieldKey)->first()?->field_value ?? '';
@endphp
<td class="px-2 py-2 border border-gray-300 text-center text-sm">{{ $savedVal ?: '-' }}</td>
@@ -262,25 +285,29 @@ class="px-2 py-2 text-center text-xs font-medium text-gray-600 uppercase border
@endif
{{-- 종합판정 / 비고 (마지막 섹션에만) --}}
@if($loop->last && $document->template->footer_judgement_label)
@if($loop->last)
@php
// React 형식: overall_result, remark (+ 레거시 호환: footer_judgement, footer_remark)
$remarkVal = $document->data->where('field_key', 'remark')->first()?->field_value
?? $document->data->where('field_key', 'footer_remark')->first()?->field_value
?? '';
$judgementVal = $document->data->where('field_key', 'overall_result')->first()?->field_value
?? $document->data->where('field_key', 'footer_judgement')->first()?->field_value
?? '';
$isPass = in_array(strtolower($judgementVal), ['pass', 'ok', '적합', '합격']);
@endphp
<div class="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4 pt-4 border-t border-gray-200">
<div>
<dt class="text-sm font-medium text-gray-500">{{ $document->template->footer_remark_label ?? '비고' }}</dt>
@php
$remarkVal = $document->data->where('field_key', 'footer_remark')->first()?->field_value ?? '';
@endphp
<dd class="mt-1 text-sm text-gray-900 whitespace-pre-wrap">{{ $remarkVal ?: '-' }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">{{ $document->template->footer_judgement_label ?? '종합판정' }}</dt>
@php
$judgementVal = $document->data->where('field_key', 'footer_judgement')->first()?->field_value ?? '';
@endphp
<dd class="mt-1">
@if($judgementVal)
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
{{ $judgementVal === '적합' || $judgementVal === '합격' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' }}">
{{ $judgementVal }}
{{ $isPass ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' }}">
{{ $isPass ? '적합' : '부적합' }}
</span>
@else
<span class="text-sm text-gray-500">-</span>