feat: 수입검사 성적서 템플릿 시더 및 미리보기 구현

- InspectionTemplateSeeder: 검사항목 4개(겉모양, 두께, 폭, 길이) 생성
- 템플릿 미리보기를 React 성적서 양식과 동일한 형태로 구현
  - 헤더: 로고, 제목, 결재란
  - 기본정보 테이블 (목업 데이터)
  - 검사항목 테이블: NO, 검사항목, 검사기준, 검사방식, 검사주기, 측정값(n1,n2,n3), 판정
  - 종합판정 영역
- 문서 목록/상세/편집 뷰 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-29 01:06:53 +09:00
parent 1e70d2edbf
commit 6d708cfdda
5 changed files with 276 additions and 160 deletions

View File

@@ -0,0 +1,78 @@
<?php
namespace Database\Seeders;
use App\Models\DocumentTemplate;
use App\Models\DocumentTemplateApprovalLine;
use App\Models\DocumentTemplateBasicField;
use App\Models\DocumentTemplateColumn;
use App\Models\DocumentTemplateSection;
use App\Models\DocumentTemplateSectionItem;
use Illuminate\Database\Seeder;
class InspectionTemplateSeeder extends Seeder
{
/**
* 수입검사 성적서 템플릿 - 검사항목 테이블만
*/
public function run(): void
{
$tenantId = 1;
$this->cleanupExisting($tenantId);
// 템플릿 생성
$template = DocumentTemplate::create([
'tenant_id' => $tenantId,
'name' => '철제품 수입검사 성적서',
'category' => '품질/수입검사',
'title' => '수입검사 성적서',
'is_active' => true,
]);
// 검사항목 섹션
$section = DocumentTemplateSection::create([
'template_id' => $template->id,
'title' => '검사 항목',
'sort_order' => 1,
]);
// 검사항목 (React 모달과 동일)
$items = [
['item' => '겉모양', 'standard' => '외관 이상 없음', 'method' => '육안'],
['item' => '두께', 'standard' => 't 1.0', 'method' => '계측'],
['item' => '폭', 'standard' => 'W 1,000mm', 'method' => '계측'],
['item' => '길이', 'standard' => 'L 2,000mm', 'method' => '계측'],
];
foreach ($items as $i => $item) {
DocumentTemplateSectionItem::create([
'section_id' => $section->id,
'item' => $item['item'],
'standard' => $item['standard'],
'method' => $item['method'],
'sort_order' => $i + 1,
]);
}
$this->command->info("✅ 템플릿 생성 완료 (ID: {$template->id})");
}
private function cleanupExisting(int $tenantId): void
{
$existing = DocumentTemplate::where('tenant_id', $tenantId)
->where('name', '철제품 수입검사 성적서')
->first();
if ($existing) {
DocumentTemplateColumn::where('template_id', $existing->id)->delete();
$sections = DocumentTemplateSection::where('template_id', $existing->id)->get();
foreach ($sections as $section) {
DocumentTemplateSectionItem::where('section_id', $section->id)->delete();
}
DocumentTemplateSection::where('template_id', $existing->id)->delete();
DocumentTemplateBasicField::where('template_id', $existing->id)->delete();
DocumentTemplateApprovalLine::where('template_id', $existing->id)->delete();
$existing->forceDelete();
}
}
}

View File

@@ -588,90 +588,122 @@ function closePreviewModal() {
}
function generatePreviewHtml() {
const title = document.getElementById('title').value || '검사 성적서';
const companyName = '{{ $tenant?->company_name ?? "회사명" }}';
const title = document.getElementById('title').value || '수입검사 성적서';
// 검사항목 행 생성
const renderItems = () => {
if (templateState.sections.length === 0 || templateState.sections[0].items.length === 0) {
return `<tr><td colspan="10" class="text-center py-4 text-gray-400">검사항목이 없습니다.</td></tr>`;
}
return templateState.sections[0].items.map((item, idx) => `
<tr>
<td class="border border-gray-400 px-2 py-1.5 text-center">${idx + 1}</td>
<td class="border border-gray-400 px-2 py-1.5">${escapeHtml(item.item)}</td>
<td class="border border-gray-400 px-2 py-1.5">${escapeHtml(item.standard)}</td>
<td class="border border-gray-400 px-2 py-1.5 text-center">${escapeHtml(item.method)}</td>
<td class="border border-gray-400 px-2 py-1.5 text-center">LOT</td>
<td class="border border-gray-400 px-2 py-1.5 text-center"><input type="radio" name="j${idx}_1"> OK <input type="radio" name="j${idx}_1"> NG</td>
<td class="border border-gray-400 px-2 py-1.5 text-center">-</td>
<td class="border border-gray-400 px-2 py-1.5 text-center">-</td>
<td class="border border-gray-400 px-2 py-1.5 text-center">-</td>
<td class="border border-gray-400 px-2 py-1.5 text-center">-</td>
</tr>
`).join('');
};
// 실제 React 성적서 양식과 동일한 형태
return `
<div class="bg-white p-8 border" style="font-family: 'Malgun Gothic', sans-serif;">
<div class="text-center mb-6">
<h1 class="text-2xl font-bold">${escapeHtml(title)}</h1>
<p class="text-gray-600 mt-1">${escapeHtml(companyName)}</p>
<div class="bg-white p-6" style="font-family: 'Malgun Gothic', sans-serif; font-size: 12px; max-width: 900px; margin: 0 auto;">
<!-- 헤더: 로고 + 제목 + 결재란 -->
<div class="flex justify-between items-start mb-4">
<!-- 로고 -->
<div class="text-center" style="width: 80px;">
<div class="text-2xl font-bold">KD</div>
<div class="text-xs">경동기업</div>
</div>
<!-- 제목 -->
<div class="flex-1 text-center">
<h1 class="text-xl font-bold tracking-widest">${escapeHtml(title)}</h1>
</div>
<!-- 결재란 -->
<div>
<table class="border-collapse text-xs" style="width: 120px;">
<tr>
<td class="border border-gray-400 px-2 py-1 bg-gray-100">담당</td>
<td class="border border-gray-400 px-2 py-1 bg-gray-100">부서장</td>
</tr>
<tr>
<td class="border border-gray-400 px-2 py-1">결재</td>
<td class="border border-gray-400 px-2 py-1">노원호</td>
</tr>
</table>
<div class="text-right text-xs mt-1">참고일자: 2026-01-29</div>
</div>
</div>
${templateState.approval_lines.length > 0 ? `
<div class="flex justify-end mb-6">
<table class="border-collapse border border-gray-400 text-sm">
<tr>
${templateState.approval_lines.map(line => `
<td class="border border-gray-400 px-4 py-1 text-center font-medium bg-gray-100">${escapeHtml(line.name)}</td>
`).join('')}
</tr>
<tr>
${templateState.approval_lines.map(line => `
<td class="border border-gray-400 px-4 py-6 text-center"></td>
`).join('')}
</tr>
</table>
</div>
` : ''}
<!-- 기본 정보 테이블 -->
<table class="w-full border-collapse text-xs mb-4">
<tr>
<td class="border border-gray-400 px-2 py-1.5 bg-gray-100 font-medium" style="width:80px">품 명</td>
<td class="border border-gray-400 px-2 py-1.5" colspan="2">SUS304 스테인리스 판재</td>
<td class="border border-gray-400 px-2 py-1.5 bg-gray-100 font-medium" style="width:100px">납품업체<br>(제조업체)</td>
<td class="border border-gray-400 px-2 py-1.5">(주)대한철강</td>
</tr>
<tr>
<td class="border border-gray-400 px-2 py-1.5 bg-gray-100 font-medium">규 격<br>(두께*너비<br>*길이)</td>
<td class="border border-gray-400 px-2 py-1.5" colspan="2">1000×2000×3T</td>
<td class="border border-gray-400 px-2 py-1.5 bg-gray-100 font-medium">로트번호</td>
<td class="border border-gray-400 px-2 py-1.5">LOT-2026-001</td>
</tr>
<tr>
<td class="border border-gray-400 px-2 py-1.5 bg-gray-100 font-medium">자재번호</td>
<td class="border border-gray-400 px-2 py-1.5" colspan="2">PE02RB</td>
<td class="border border-gray-400 px-2 py-1.5 bg-gray-100 font-medium">검사일자</td>
<td class="border border-gray-400 px-2 py-1.5">01/29</td>
</tr>
<tr>
<td class="border border-gray-400 px-2 py-1.5 bg-gray-100 font-medium">로트크기</td>
<td class="border border-gray-400 px-2 py-1.5">200</td>
<td class="border border-gray-400 px-2 py-1.5 text-center">매</td>
<td class="border border-gray-400 px-2 py-1.5 bg-gray-100 font-medium">검사자</td>
<td class="border border-gray-400 px-2 py-1.5">노원호 <input type="checkbox" checked class="ml-2"></td>
</tr>
</table>
${templateState.sections.map(section => `
<div class="mb-6">
<h3 class="font-bold text-lg mb-2 border-b-2 border-gray-800 pb-1">${escapeHtml(section.title)}</h3> ${section.image_path ? `
<div class="mb-3">
<img src="/storage/${section.image_path}" alt="${escapeHtml(section.title)}" class="max-w-full h-auto border border-gray-300 rounded">
</div>
` : ''}
${section.items.length > 0 ? `
<table class="w-full border-collapse border border-gray-400 text-sm">
<thead>
<tr class="bg-gray-100">
<th class="border border-gray-400 px-2 py-1">구분</th>
<th class="border border-gray-400 px-2 py-1">검사항목</th>
<th class="border border-gray-400 px-2 py-1">검사기준</th>
<th class="border border-gray-400 px-2 py-1">검사방법</th>
<th class="border border-gray-400 px-2 py-1">검사주기</th>
<th class="border border-gray-400 px-2 py-1">관련규정</th>
</tr>
</thead>
<tbody>
${section.items.map(item => `
<tr>
<td class="border border-gray-400 px-2 py-1">${escapeHtml(item.category)}</td>
<td class="border border-gray-400 px-2 py-1">${escapeHtml(item.item)}</td>
<td class="border border-gray-400 px-2 py-1">${escapeHtml(item.standard)}</td>
<td class="border border-gray-400 px-2 py-1">${escapeHtml(item.method)}</td>
<td class="border border-gray-400 px-2 py-1">${escapeHtml(item.frequency)}</td>
<td class="border border-gray-400 px-2 py-1">${escapeHtml(item.regulation)}</td>
</tr>
`).join('')}
</tbody>
</table>
` : '<p class="text-gray-400">항목 없음</p>'}
</div>
`).join('')}
<!-- 검사 항목 테이블 -->
<table class="w-full border-collapse text-xs">
<thead>
<tr>
<th class="border border-gray-400 px-2 py-1.5 bg-gray-100" style="width:30px" rowspan="2">NO</th>
<th class="border border-gray-400 px-2 py-1.5 bg-gray-100" style="width:80px" rowspan="2">검사항목</th>
<th class="border border-gray-400 px-2 py-1.5 bg-gray-100" rowspan="2">검사기준</th>
<th class="border border-gray-400 px-2 py-1.5 bg-gray-100" style="width:60px" rowspan="2">검사방식</th>
<th class="border border-gray-400 px-2 py-1.5 bg-gray-100" style="width:60px" rowspan="2">검사주기</th>
<th class="border border-gray-400 px-2 py-1.5 bg-gray-100" colspan="3">측정값</th>
<th class="border border-gray-400 px-2 py-1.5 bg-gray-100" style="width:50px" rowspan="2">판정<br>(적/부)</th>
</tr>
<tr>
<th class="border border-gray-400 px-1 py-1 bg-gray-100" style="width:50px">n1</th>
<th class="border border-gray-400 px-1 py-1 bg-gray-100" style="width:50px">n2</th>
<th class="border border-gray-400 px-1 py-1 bg-gray-100" style="width:50px">n3</th>
</tr>
</thead>
<tbody>
${renderItems()}
</tbody>
</table>
${templateState.columns.length > 0 ? `
<div class="mb-6">
<h3 class="font-bold text-lg mb-2 border-b-2 border-gray-800 pb-1">검사 데이터</h3>
<table class="w-full border-collapse border border-gray-400 text-sm">
<thead>
<tr class="bg-gray-100">
${templateState.columns.map(col => `
<th class="border border-gray-400 px-2 py-1" style="width: ${col.width}">${escapeHtml(col.label)}</th>
`).join('')}
</tr>
</thead>
<tbody>
<tr>
${templateState.columns.map(() => `
<td class="border border-gray-400 px-2 py-4"></td>
`).join('')}
</tr>
</tbody>
</table>
</div>
` : ''}
<!-- 종합판정 -->
<div class="flex justify-end mt-4">
<table class="border-collapse text-xs">
<tr>
<td class="border border-gray-400 px-4 py-2 bg-gray-100 font-medium">종합판정</td>
</tr>
<tr>
<td class="border border-gray-400 px-4 py-3 text-center text-gray-400">미완료</td>
</tr>
</table>
</div>
</div>
`;
}

View File

@@ -3,12 +3,12 @@
@section('title', $isCreate ? '새 문서 작성' : '문서 수정')
@section('content')
<div class="p-6">
{{-- 헤더 --}}
<div class="flex justify-between items-center mb-6">
<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">
<p class="text-sm text-gray-500 mt-1 hidden sm:block">
@if($document)
{{ $document->document_no }} - {{ $document->title }}
@else
@@ -16,13 +16,12 @@
@endif
</p>
</div>
<a href="{{ route('documents.index') }}"
class="inline-flex items-center px-4 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-200 transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
목록으로
</a>
<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>
{{-- 템플릿 선택 (생성 ) --}}

View File

@@ -3,70 +3,68 @@
@section('title', '문서 관리')
@section('content')
<div class="p-6">
{{-- 헤더 --}}
<div class="flex justify-between items-center mb-6">
<!-- 페이지 헤더 -->
<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">문서 관리</h1>
<p class="text-sm text-gray-500 mt-1">작성된 문서를 관리합니다.</p>
<p class="text-sm text-gray-500 mt-1 hidden sm:block">
작성된 문서를 관리합니다.
</p>
</div>
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
<a href="{{ route('documents.create') }}"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
문서 작성
</a>
</div>
<a href="{{ route('documents.create') }}"
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
문서 작성
</a>
</div>
{{-- 필터 --}}
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
<form id="filterForm" class="grid grid-cols-1 md:grid-cols-4 gap-4">
{{-- 상태 필터 --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">상태</label>
<select name="status" class="w-full rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">전체</option>
<!-- 필터 영역 -->
<x-filter-collapsible id="filterForm">
<form id="filterForm" class="flex flex-wrap gap-2 sm:gap-4">
<input type="hidden" name="per_page" id="perPageInput" value="15">
<input type="hidden" name="page" id="pageInput" value="1">
<!-- 검색 -->
<div class="flex-1 min-w-0 w-full sm:w-auto">
<input type="text"
name="search"
placeholder="문서번호, 제목으로 검색..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- 상태 필터 -->
<div class="w-full sm:w-32">
<select name="status" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">전체 상태</option>
@foreach($statuses as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
{{-- 템플릿 필터 --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">템플릿</label>
<select name="template_id" class="w-full rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">전체</option>
<!-- 템플릿 필터 -->
<div class="w-full sm:w-40">
<select name="template_id" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">전체 템플릿</option>
@foreach($templates as $template)
<option value="{{ $template->id }}">{{ $template->name }}</option>
@endforeach
</select>
</div>
{{-- 검색 --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">검색</label>
<input type="text" name="search" placeholder="문서번호, 제목"
class="w-full rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500">
</div>
{{-- 버튼 --}}
<div class="flex items-end gap-2">
<button type="submit"
class="px-4 py-2 bg-gray-800 text-white text-sm font-medium rounded-lg hover:bg-gray-900 transition-colors">
검색
</button>
<button type="reset"
class="px-4 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-200 transition-colors">
초기화
</button>
</div>
<!-- 검색 버튼 -->
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition w-full sm:w-auto">
검색
</button>
</form>
</div>
</x-filter-collapsible>
{{-- 문서 목록 --}}
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<!-- 문서 목록 테이블 -->
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
@@ -81,17 +79,22 @@ class="px-4 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-lg hover:
</tr>
</thead>
<tbody id="documentList" class="bg-white divide-y divide-gray-200">
{{-- HTMX로 로드 --}}
<!-- 로딩 스피너 -->
<tr id="loadingRow">
<td colspan="7" class="px-6 py-12 text-center">
<div class="flex justify-center items-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
{{-- 페이지네이션 --}}
<!-- 페이지네이션 -->
<div id="pagination" class="px-6 py-4 border-t border-gray-200">
{{-- HTMX로 로드 --}}
</div>
</div>
</div>
@endsection
@push('scripts')
@@ -100,15 +103,13 @@ class="px-4 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-lg hover:
loadDocuments();
// 필터 폼 제출
document.getElementById('filterForm').addEventListener('submit', function(e) {
e.preventDefault();
loadDocuments();
});
// 초기화 버튼
document.getElementById('filterForm').addEventListener('reset', function() {
setTimeout(() => loadDocuments(), 10);
});
const filterForm = document.getElementById('filterForm');
if (filterForm) {
filterForm.addEventListener('submit', function(e) {
e.preventDefault();
loadDocuments();
});
}
});
function loadDocuments(page = 1) {
@@ -117,6 +118,17 @@ function loadDocuments(page = 1) {
const params = new URLSearchParams(formData);
params.set('page', page);
// 로딩 표시
document.getElementById('documentList').innerHTML = `
<tr>
<td colspan="7" class="px-6 py-12 text-center">
<div class="flex justify-center items-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
</td>
</tr>
`;
fetch(`/api/admin/documents?${params.toString()}`, {
headers: {
'Accept': 'application/json',

View File

@@ -3,29 +3,25 @@
@section('title', '문서 상세')
@section('content')
<div class="p-6">
{{-- 헤더 --}}
<div class="flex justify-between items-center mb-6">
<!-- 헤더 -->
<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">문서 상세</h1>
<p class="text-sm text-gray-500 mt-1">{{ $document->document_no }} - {{ $document->title }}</p>
<p class="text-sm text-gray-500 mt-1 hidden sm:block">{{ $document->document_no }} - {{ $document->title }}</p>
</div>
<div class="flex gap-2">
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
@if($document->status === 'DRAFT' || $document->status === 'REJECTED')
<a href="{{ route('documents.edit', $document->id) }}"
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
수정
</a>
@endif
<a href="{{ route('documents.index') }}"
class="inline-flex items-center px-4 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-200 transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
목록으로
class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg transition">
목록
</a>
</div>
</div>
@@ -196,5 +192,4 @@ class="text-sm text-blue-600 hover:text-blue-800">
</div>
</div>
</div>
</div>
@endsection