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>
This commit is contained in:
2026-01-31 04:43:12 +09:00
parent 7635373a45
commit df762d6cf4
2 changed files with 258 additions and 29 deletions

View File

@@ -87,6 +87,9 @@ public function store(Request $request): JsonResponse
'data' => 'nullable|array',
'data.*.field_key' => 'required|string',
'data.*.field_value' => 'nullable|string',
'data.*.section_id' => 'nullable|integer',
'data.*.column_id' => 'nullable|integer',
'data.*.row_index' => 'nullable|integer',
]);
try {
@@ -121,18 +124,8 @@ public function store(Request $request): JsonResponse
}
}
// 문서 데이터 저장
if ($request->filled('data')) {
foreach ($request->data as $item) {
if (! empty($item['field_value'])) {
DocumentData::create([
'document_id' => $document->id,
'field_key' => $item['field_key'],
'field_value' => $item['field_value'],
]);
}
}
}
// 문서 데이터 저장 (기본필드 + 섹션 테이블 데이터)
$this->saveDocumentData($document, $request->input('data', []));
DB::commit();
@@ -174,6 +167,9 @@ public function update(int $id, Request $request): JsonResponse
'data' => 'nullable|array',
'data.*.field_key' => 'required|string',
'data.*.field_value' => 'nullable|string',
'data.*.section_id' => 'nullable|integer',
'data.*.column_id' => 'nullable|integer',
'data.*.row_index' => 'nullable|integer',
]);
$document->update([
@@ -181,21 +177,10 @@ public function update(int $id, Request $request): JsonResponse
'updated_by' => $userId,
]);
// 문서 데이터 업데이트
// 문서 데이터 업데이트 (기존 삭제 후 재저장)
if ($request->has('data')) {
// 기존 데이터 삭제
$document->data()->delete();
// 새 데이터 저장
foreach ($request->data as $item) {
if (! empty($item['field_value'])) {
DocumentData::create([
'document_id' => $document->id,
'field_key' => $item['field_key'],
'field_value' => $item['field_value'],
]);
}
}
$this->saveDocumentData($document, $request->input('data', []));
}
return response()->json([
@@ -230,6 +215,28 @@ public function destroy(int $id): JsonResponse
]);
}
/**
* 문서 데이터 저장 (기본필드 + 섹션 테이블 데이터)
*/
private function saveDocumentData(Document $document, array $dataItems): void
{
foreach ($dataItems as $item) {
if (empty($item['field_key'])) {
continue;
}
// 빈 값도 저장 (섹션 데이터 편집 시 빈값으로 클리어 가능)
DocumentData::create([
'document_id' => $document->id,
'section_id' => $item['section_id'] ?? null,
'column_id' => $item['column_id'] ?? null,
'row_index' => $item['row_index'] ?? 0,
'field_key' => $item['field_key'],
'field_value' => $item['field_value'] ?? '',
]);
}
}
/**
* 문서 번호 생성
* 형식: {카테고리prefix}-{YYMMDD}-{순번}

View File

@@ -108,15 +108,206 @@ class="w-full rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:rin
</div>
@endif
{{-- 섹션 (테이블 형태 - Phase 2.2에서 구현) --}}
{{-- 섹션 (검사 데이터 테이블) --}}
@if($template->sections && $template->sections->count() > 0)
@foreach($template->sections as $section)
@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
<p class="text-sm text-gray-400 italic">검사 데이터 테이블은 Phase 2.2에서 구현됩니다.</p>
{{-- 검사 데이터 테이블 --}}
@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
@@ -153,7 +344,7 @@ class="px-6 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-
data: []
};
// data 필드 수집
// 기본 필드 수집 (data[field_key])
for (const [key, value] of formData.entries()) {
if (key.startsWith('data[') && key.endsWith(']')) {
const fieldKey = key.slice(5, -1);
@@ -164,6 +355,37 @@ class="px-6 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-
}
}
// 섹션 테이블 데이터 수집 (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';