Files
sam-manage/resources/views/documents/edit.blade.php
권혁성 df762d6cf4 feat:문서 데이터 입력 UI 구현 (Phase 2.2)
- 섹션별 동적 검사 테이블 렌더링 (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>
2026-01-31 04:43:12 +09:00

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