- 섹션별 동적 검사 테이블 렌더링 (complex/select/check/measurement/text) - 정적 컬럼 자동 매핑 (NO, 검사항목, 검사기준, 검사방식, 검사주기) - complex 컬럼 서브 라벨 행 (측정치 n1/n2/n3) - 종합판정 + 비고 Footer 영역 - JS 폼 데이터 수집 (기본필드 + 섹션 테이블 데이터 + 체크박스) - saveDocumentData() 공통 메서드 (section_id/column_id/row_index EAV 저장) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
422 lines
24 KiB
PHP
422 lines
24 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', $isCreate ? '새 문서 작성' : '문서 수정')
|
|
|
|
@section('content')
|
|
<div class="max-w-7xl mx-auto">
|
|
<!-- 헤더 -->
|
|
<div class="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-4 mb-6">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-gray-800">{{ $isCreate ? '새 문서 작성' : '문서 수정' }}</h1>
|
|
<p class="text-sm text-gray-500 mt-1 hidden sm:block">
|
|
@if($document)
|
|
{{ $document->document_no }} - {{ $document->title }}
|
|
@else
|
|
템플릿을 선택하여 문서를 작성합니다.
|
|
@endif
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<a href="{{ route('documents.index') }}"
|
|
class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg transition">
|
|
목록
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- 템플릿 선택 (생성 시) --}}
|
|
@if($isCreate && !$template)
|
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">템플릿 선택</h2>
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
@forelse($templates as $tpl)
|
|
<a href="{{ route('documents.create', ['template_id' => $tpl->id]) }}"
|
|
class="block p-4 border border-gray-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors">
|
|
<h3 class="font-medium text-gray-900">{{ $tpl->name }}</h3>
|
|
<p class="text-sm text-gray-500 mt-1">{{ $tpl->category }}</p>
|
|
</a>
|
|
@empty
|
|
<p class="text-gray-500 col-span-3">사용 가능한 템플릿이 없습니다.</p>
|
|
@endforelse
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- 문서 폼 --}}
|
|
@if($template)
|
|
<form id="documentForm" class="space-y-6">
|
|
@csrf
|
|
<input type="hidden" name="template_id" value="{{ $template->id }}">
|
|
|
|
{{-- 기본 정보 --}}
|
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">기본 정보</h2>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">템플릿</label>
|
|
<input type="text" value="{{ $template->name }}" disabled
|
|
class="w-full rounded-lg border-gray-300 bg-gray-50 text-sm">
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
|
제목 <span class="text-red-500">*</span>
|
|
</label>
|
|
<input type="text" name="title" value="{{ $document->title ?? '' }}" required
|
|
class="w-full rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500"
|
|
placeholder="문서 제목을 입력하세요">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- 기본 필드 --}}
|
|
@if($template->basicFields && $template->basicFields->count() > 0)
|
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">{{ $template->title ?? '문서 정보' }}</h2>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
@foreach($template->basicFields as $field)
|
|
@php
|
|
$fieldKey = \Illuminate\Support\Str::slug($field->label, '_');
|
|
$savedValue = $document?->data->where('field_key', $fieldKey)->first()?->field_value ?? $field->default_value ?? '';
|
|
@endphp
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
|
{{ $field->label }}
|
|
</label>
|
|
@if($field->field_type === 'textarea')
|
|
<textarea name="data[{{ $fieldKey }}]"
|
|
class="w-full rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500"
|
|
rows="3">{{ $savedValue }}</textarea>
|
|
@elseif($field->field_type === 'date')
|
|
<input type="date" name="data[{{ $fieldKey }}]"
|
|
value="{{ $savedValue }}"
|
|
class="w-full rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500">
|
|
@elseif($field->field_type === 'number')
|
|
<input type="number" name="data[{{ $fieldKey }}]"
|
|
value="{{ $savedValue }}"
|
|
class="w-full rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500">
|
|
@else
|
|
<input type="text" name="data[{{ $fieldKey }}]"
|
|
value="{{ $savedValue }}"
|
|
class="w-full rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500">
|
|
@endif
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- 섹션 (검사 데이터 테이블) --}}
|
|
@if($template->sections && $template->sections->count() > 0)
|
|
@foreach($template->sections as $sectionIndex => $section)
|
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">{{ $section->title }}</h2>
|
|
@if($section->image_path)
|
|
<img src="{{ asset('storage/' . $section->image_path) }}" alt="{{ $section->title }}" class="max-w-full h-auto mb-4 rounded">
|
|
@endif
|
|
|
|
{{-- 검사 데이터 테이블 --}}
|
|
@if($section->items->count() > 0 && $template->columns->count() > 0)
|
|
<div class="overflow-x-auto mt-4">
|
|
<table class="min-w-full border border-gray-300 text-sm" data-section-id="{{ $section->id }}">
|
|
{{-- 테이블 헤더 --}}
|
|
<thead class="bg-gray-50">
|
|
<tr>
|
|
@foreach($template->columns as $col)
|
|
@if($col->column_type === 'complex' && $col->sub_labels)
|
|
<th colspan="{{ count($col->sub_labels) }}"
|
|
class="px-2 py-2 text-center text-xs font-medium text-gray-600 uppercase border border-gray-300"
|
|
style="min-width: {{ $col->width }}">
|
|
{{ $col->label }}
|
|
</th>
|
|
@else
|
|
<th rowspan="{{ $template->columns->contains(fn($c) => $c->column_type === 'complex' && $c->sub_labels) ? 2 : 1 }}"
|
|
class="px-2 py-2 text-center text-xs font-medium text-gray-600 uppercase border border-gray-300"
|
|
style="min-width: {{ $col->width }}">
|
|
{{ $col->label }}
|
|
</th>
|
|
@endif
|
|
@endforeach
|
|
</tr>
|
|
{{-- 서브 라벨 행 (complex 컬럼이 있을 때만) --}}
|
|
@if($template->columns->contains(fn($c) => $c->column_type === 'complex' && $c->sub_labels))
|
|
<tr>
|
|
@foreach($template->columns as $col)
|
|
@if($col->column_type === 'complex' && $col->sub_labels)
|
|
@foreach($col->sub_labels as $subLabel)
|
|
<th class="px-2 py-1 text-center text-xs font-medium text-gray-500 border border-gray-300 bg-gray-50">
|
|
{{ $subLabel }}
|
|
</th>
|
|
@endforeach
|
|
@endif
|
|
@endforeach
|
|
</tr>
|
|
@endif
|
|
</thead>
|
|
{{-- 테이블 바디 --}}
|
|
<tbody>
|
|
@foreach($section->items as $rowIndex => $item)
|
|
<tr class="hover:bg-blue-50" data-row-index="{{ $rowIndex }}">
|
|
@foreach($template->columns as $col)
|
|
@php
|
|
$colSlug = \Illuminate\Support\Str::slug($col->label, '_');
|
|
@endphp
|
|
|
|
@if($col->column_type === 'complex' && $col->sub_labels)
|
|
{{-- complex: 서브 라벨별 입력 필드 --}}
|
|
@foreach($col->sub_labels as $subIndex => $subLabel)
|
|
@php
|
|
$fieldKey = "s{$section->id}_r{$rowIndex}_c{$col->id}_sub{$subIndex}";
|
|
$savedVal = $document?->data->where('field_key', $fieldKey)->first()?->field_value ?? '';
|
|
@endphp
|
|
<td class="px-1 py-1 border border-gray-300 text-center">
|
|
<input type="text"
|
|
name="section_data[{{ $fieldKey }}]"
|
|
value="{{ $savedVal }}"
|
|
data-section-id="{{ $section->id }}"
|
|
data-column-id="{{ $col->id }}"
|
|
data-row-index="{{ $rowIndex }}"
|
|
class="w-full text-center text-sm border-0 focus:ring-1 focus:ring-blue-400 rounded py-1"
|
|
placeholder="{{ $subLabel }}">
|
|
</td>
|
|
@endforeach
|
|
@elseif($col->column_type === 'select')
|
|
{{-- select: 판정 드롭다운 --}}
|
|
@php
|
|
$fieldKey = "s{$section->id}_r{$rowIndex}_c{$col->id}";
|
|
$savedVal = $document?->data->where('field_key', $fieldKey)->first()?->field_value ?? '';
|
|
$options = $template->footer_judgement_options ?? ['적합', '부적합'];
|
|
@endphp
|
|
<td class="px-1 py-1 border border-gray-300 text-center">
|
|
<select name="section_data[{{ $fieldKey }}]"
|
|
data-section-id="{{ $section->id }}"
|
|
data-column-id="{{ $col->id }}"
|
|
data-row-index="{{ $rowIndex }}"
|
|
class="w-full text-center text-sm border-0 focus:ring-1 focus:ring-blue-400 rounded py-1">
|
|
<option value="">-</option>
|
|
@foreach($options as $opt)
|
|
<option value="{{ $opt }}" {{ $savedVal === $opt ? 'selected' : '' }}>{{ $opt }}</option>
|
|
@endforeach
|
|
</select>
|
|
</td>
|
|
@elseif($col->column_type === 'check')
|
|
{{-- check: 체크박스 --}}
|
|
@php
|
|
$fieldKey = "s{$section->id}_r{$rowIndex}_c{$col->id}";
|
|
$savedVal = $document?->data->where('field_key', $fieldKey)->first()?->field_value ?? '';
|
|
@endphp
|
|
<td class="px-1 py-1 border border-gray-300 text-center">
|
|
<input type="checkbox"
|
|
name="section_data[{{ $fieldKey }}]"
|
|
value="OK"
|
|
{{ $savedVal === 'OK' ? 'checked' : '' }}
|
|
data-section-id="{{ $section->id }}"
|
|
data-column-id="{{ $col->id }}"
|
|
data-row-index="{{ $rowIndex }}"
|
|
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
|
</td>
|
|
@elseif($col->column_type === 'measurement')
|
|
{{-- measurement: 수치 입력 --}}
|
|
@php
|
|
$fieldKey = "s{$section->id}_r{$rowIndex}_c{$col->id}";
|
|
$savedVal = $document?->data->where('field_key', $fieldKey)->first()?->field_value ?? '';
|
|
@endphp
|
|
<td class="px-1 py-1 border border-gray-300 text-center">
|
|
<input type="number" step="any"
|
|
name="section_data[{{ $fieldKey }}]"
|
|
value="{{ $savedVal }}"
|
|
data-section-id="{{ $section->id }}"
|
|
data-column-id="{{ $col->id }}"
|
|
data-row-index="{{ $rowIndex }}"
|
|
class="w-full text-center text-sm border-0 focus:ring-1 focus:ring-blue-400 rounded py-1">
|
|
</td>
|
|
@else
|
|
{{-- text: 정적 데이터 (항목정보) 또는 텍스트 입력 --}}
|
|
@php
|
|
// 정적 컬럼 매핑: NO, 검사항목, 검사기준, 검사방식, 검사주기
|
|
$staticValue = match(true) {
|
|
str_contains(strtolower($col->label), 'no') && strlen($col->label) <= 4 => $rowIndex + 1,
|
|
in_array($col->label, ['검사항목', '항목']) => $item->item,
|
|
in_array($col->label, ['검사기준', '기준']) => $item->standard,
|
|
in_array($col->label, ['검사방식', '방식', '검사방법']) => $item->method,
|
|
in_array($col->label, ['검사주기', '주기']) => $item->frequency,
|
|
in_array($col->label, ['규격', '적용규격', '관련규정']) => $item->regulation,
|
|
in_array($col->label, ['분류', '카테고리']) => $item->category,
|
|
default => null,
|
|
};
|
|
@endphp
|
|
@if($staticValue !== null)
|
|
<td class="px-2 py-2 border border-gray-300 text-sm text-gray-700 {{ is_numeric($staticValue) ? 'text-center' : '' }}">
|
|
{{ $staticValue }}
|
|
</td>
|
|
@else
|
|
@php
|
|
$fieldKey = "s{$section->id}_r{$rowIndex}_c{$col->id}";
|
|
$savedVal = $document?->data->where('field_key', $fieldKey)->first()?->field_value ?? '';
|
|
@endphp
|
|
<td class="px-1 py-1 border border-gray-300 text-center">
|
|
<input type="text"
|
|
name="section_data[{{ $fieldKey }}]"
|
|
value="{{ $savedVal }}"
|
|
data-section-id="{{ $section->id }}"
|
|
data-column-id="{{ $col->id }}"
|
|
data-row-index="{{ $rowIndex }}"
|
|
class="w-full text-center text-sm border-0 focus:ring-1 focus:ring-blue-400 rounded py-1">
|
|
</td>
|
|
@endif
|
|
@endif
|
|
@endforeach
|
|
</tr>
|
|
@endforeach
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- 종합판정 / 비고 --}}
|
|
@if($loop->last && $template->footer_judgement_label)
|
|
<div class="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4 pt-4 border-t border-gray-200">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
|
{{ $template->footer_remark_label ?? '비고' }}
|
|
</label>
|
|
@php
|
|
$remarkKey = 'footer_remark';
|
|
$remarkVal = $document?->data->where('field_key', $remarkKey)->first()?->field_value ?? '';
|
|
@endphp
|
|
<textarea name="data[{{ $remarkKey }}]" rows="3"
|
|
class="w-full rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500">{{ $remarkVal }}</textarea>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
|
{{ $template->footer_judgement_label ?? '종합판정' }}
|
|
</label>
|
|
@php
|
|
$judgementKey = 'footer_judgement';
|
|
$judgementVal = $document?->data->where('field_key', $judgementKey)->first()?->field_value ?? '';
|
|
$judgementOptions = $template->footer_judgement_options ?? ['적합', '부적합'];
|
|
@endphp
|
|
<select name="data[{{ $judgementKey }}]"
|
|
class="w-full rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500">
|
|
<option value="">선택하세요</option>
|
|
@foreach($judgementOptions as $opt)
|
|
<option value="{{ $opt }}" {{ $judgementVal === $opt ? 'selected' : '' }}>{{ $opt }}</option>
|
|
@endforeach
|
|
</select>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endforeach
|
|
@endif
|
|
|
|
{{-- 버튼 --}}
|
|
<div class="flex justify-end gap-3">
|
|
<a href="{{ route('documents.index') }}"
|
|
class="px-6 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-200 transition-colors">
|
|
취소
|
|
</a>
|
|
<button type="submit"
|
|
class="px-6 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors">
|
|
{{ $isCreate ? '저장' : '수정' }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
@endif
|
|
</div>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const form = document.getElementById('documentForm');
|
|
if (!form) return;
|
|
|
|
form.addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
|
|
const formData = new FormData(form);
|
|
const data = {
|
|
template_id: formData.get('template_id'),
|
|
title: formData.get('title'),
|
|
data: []
|
|
};
|
|
|
|
// 기본 필드 수집 (data[field_key])
|
|
for (const [key, value] of formData.entries()) {
|
|
if (key.startsWith('data[') && key.endsWith(']')) {
|
|
const fieldKey = key.slice(5, -1);
|
|
data.data.push({
|
|
field_key: fieldKey,
|
|
field_value: value
|
|
});
|
|
}
|
|
}
|
|
|
|
// 섹션 테이블 데이터 수집 (section_data[field_key])
|
|
for (const [key, value] of formData.entries()) {
|
|
if (key.startsWith('section_data[') && key.endsWith(']')) {
|
|
const fieldKey = key.slice(13, -1);
|
|
const el = form.querySelector(`[name="${key}"]`);
|
|
// 체크박스는 체크 안된 경우 skip
|
|
if (el && el.type === 'checkbox' && !el.checked) continue;
|
|
data.data.push({
|
|
field_key: fieldKey,
|
|
field_value: value,
|
|
section_id: el?.dataset?.sectionId ? parseInt(el.dataset.sectionId) : null,
|
|
column_id: el?.dataset?.columnId ? parseInt(el.dataset.columnId) : null,
|
|
row_index: el?.dataset?.rowIndex ? parseInt(el.dataset.rowIndex) : 0,
|
|
});
|
|
}
|
|
}
|
|
|
|
// 체크 안된 체크박스도 빈 값으로 전송
|
|
form.querySelectorAll('input[type="checkbox"][name^="section_data["]').forEach(cb => {
|
|
if (!cb.checked) {
|
|
const fieldKey = cb.name.slice(13, -1);
|
|
data.data.push({
|
|
field_key: fieldKey,
|
|
field_value: '',
|
|
section_id: cb.dataset?.sectionId ? parseInt(cb.dataset.sectionId) : null,
|
|
column_id: cb.dataset?.columnId ? parseInt(cb.dataset.columnId) : null,
|
|
row_index: cb.dataset?.rowIndex ? parseInt(cb.dataset.rowIndex) : 0,
|
|
});
|
|
}
|
|
});
|
|
|
|
const isCreate = {{ $isCreate ? 'true' : 'false' }};
|
|
const url = isCreate ? '/api/admin/documents' : '/api/admin/documents/{{ $document?->id }}';
|
|
const method = isCreate ? 'POST' : 'PATCH';
|
|
|
|
fetch(url, {
|
|
method: method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
|
},
|
|
body: JSON.stringify(data)
|
|
})
|
|
.then(response => response.json())
|
|
.then(result => {
|
|
if (result.success) {
|
|
showToast(isCreate ? '문서가 저장되었습니다.' : '문서가 수정되었습니다.', 'success');
|
|
if (isCreate && result.data?.id) {
|
|
window.location.href = '/documents/' + result.data.id + '/edit';
|
|
} else {
|
|
window.location.href = '/documents';
|
|
}
|
|
} else {
|
|
showToast(result.message || '오류가 발생했습니다.', 'error');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
showToast('오류가 발생했습니다.', 'error');
|
|
});
|
|
});
|
|
});
|
|
</script>
|
|
@endpush |