- InspectionTemplateSeeder: 검사항목 4개(겉모양, 두께, 폭, 길이) 생성 - 템플릿 미리보기를 React 성적서 양식과 동일한 형태로 구현 - 헤더: 로고, 제목, 결재란 - 기본정보 테이블 (목업 데이터) - 검사항목 테이블: NO, 검사항목, 검사기준, 검사방식, 검사주기, 측정값(n1,n2,n3), 판정 - 종합판정 영역 - 문서 목록/상세/편집 뷰 개선 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
848 lines
44 KiB
PHP
848 lines
44 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">
|
||
검사 성적서, 작업지시서 등의 문서 양식을 설정합니다.
|
||
</p>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<button type="button" onclick="openPreviewModal()" class="bg-gray-600 hover:bg-gray-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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||
</svg>
|
||
미리보기
|
||
</button>
|
||
<a href="{{ route('document-templates.index') }}" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg transition">
|
||
목록
|
||
</a>
|
||
<button type="button" onclick="saveTemplate()" 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="M5 13l4 4L19 7" />
|
||
</svg>
|
||
저장
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 탭 네비게이션 -->
|
||
<div class="mb-6 border-b border-gray-200 bg-white rounded-t-lg">
|
||
<nav class="-mb-px flex">
|
||
<button onclick="switchTab('basic')" id="tab-basic"
|
||
class="tab-btn px-6 py-4 text-sm font-medium border-b-2 border-blue-500 text-blue-600">
|
||
기본정보
|
||
</button>
|
||
<button onclick="switchTab('approval')" id="tab-approval"
|
||
class="tab-btn px-6 py-4 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700">
|
||
결재라인
|
||
</button>
|
||
<button onclick="switchTab('sections')" id="tab-sections"
|
||
class="tab-btn px-6 py-4 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700">
|
||
검사 기준서
|
||
</button>
|
||
<button onclick="switchTab('columns')" id="tab-columns"
|
||
class="tab-btn px-6 py-4 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700">
|
||
테이블 컬럼
|
||
</button>
|
||
</nav>
|
||
</div>
|
||
|
||
<!-- 기본정보 탭 -->
|
||
<div id="content-basic" class="tab-content bg-white rounded-lg shadow-sm p-6">
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<!-- 양식명 -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">양식명 <span class="text-red-500">*</span></label>
|
||
<input type="text" id="name" 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>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">분류</label>
|
||
<input type="text" id="category" list="category-list" placeholder="분류를 입력하거나 선택하세요"
|
||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||
<datalist id="category-list">
|
||
@foreach($categories as $category)
|
||
<option value="{{ $category }}">
|
||
@endforeach
|
||
</datalist>
|
||
</div>
|
||
<!-- 문서 제목 -->
|
||
<div class="md:col-span-2">
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">문서 제목</label>
|
||
<input type="text" id="title" 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="flex items-center gap-2">
|
||
<input type="checkbox" id="is_active" checked class="w-4 h-4 text-blue-600 rounded focus:ring-blue-500">
|
||
<label for="is_active" class="text-sm text-gray-700">활성화</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 결재라인 탭 -->
|
||
<div id="content-approval" class="tab-content bg-white rounded-lg shadow-sm p-6 hidden">
|
||
<div class="flex justify-between items-center mb-4">
|
||
<h3 class="text-lg font-medium text-gray-800">결재라인 설정</h3>
|
||
<button type="button" onclick="addApprovalLine()" class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-lg text-sm flex items-center gap-1">
|
||
<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>
|
||
추가
|
||
</button>
|
||
</div>
|
||
<div id="approval-lines" class="space-y-3">
|
||
<!-- 동적으로 추가됨 -->
|
||
</div>
|
||
<p id="approval-empty" class="text-gray-400 text-center py-8 hidden">결재라인이 없습니다. 추가 버튼을 클릭하세요.</p>
|
||
</div>
|
||
|
||
<!-- 검사 기준서 탭 -->
|
||
<div id="content-sections" class="tab-content bg-white rounded-lg shadow-sm p-6 hidden">
|
||
<div class="flex justify-between items-center mb-4">
|
||
<h3 class="text-lg font-medium text-gray-800">검사 기준서 섹션</h3>
|
||
<button type="button" onclick="addSection()" class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-lg text-sm flex items-center gap-1">
|
||
<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>
|
||
섹션 추가
|
||
</button>
|
||
</div>
|
||
<div id="sections-container" class="space-y-4">
|
||
<!-- 동적으로 추가됨 -->
|
||
</div>
|
||
<p id="sections-empty" class="text-gray-400 text-center py-8 hidden">검사 기준서 섹션이 없습니다. 섹션 추가 버튼을 클릭하세요.</p>
|
||
</div>
|
||
|
||
<!-- 테이블 컬럼 탭 -->
|
||
<div id="content-columns" class="tab-content bg-white rounded-lg shadow-sm p-6 hidden">
|
||
<div class="flex justify-between items-center mb-4">
|
||
<h3 class="text-lg font-medium text-gray-800">검사 데이터 테이블 컬럼</h3>
|
||
<button type="button" onclick="addColumn()" class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-lg text-sm flex items-center gap-1">
|
||
<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>
|
||
컬럼 추가
|
||
</button>
|
||
</div>
|
||
<div id="columns-container" class="space-y-3">
|
||
<!-- 동적으로 추가됨 -->
|
||
</div>
|
||
<p id="columns-empty" class="text-gray-400 text-center py-8 hidden">테이블 컬럼이 없습니다. 컬럼 추가 버튼을 클릭하세요.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 미리보기 모달 -->
|
||
<div id="preview-modal" class="fixed inset-0 z-50 hidden overflow-y-auto">
|
||
<div class="flex items-center justify-center min-h-screen p-4">
|
||
<div class="fixed inset-0 bg-black bg-opacity-50" onclick="closePreviewModal()"></div>
|
||
<div class="relative bg-white rounded-lg shadow-xl max-w-5xl w-full max-h-[90vh] overflow-hidden">
|
||
<div class="flex justify-between items-center p-4 border-b">
|
||
<h3 class="text-lg font-bold text-gray-800">문서 미리보기</h3>
|
||
<button onclick="closePreviewModal()" class="text-gray-400 hover:text-gray-600">
|
||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div id="preview-content" class="p-4 overflow-auto max-h-[calc(90vh-120px)]">
|
||
<!-- 미리보기 내용 -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
@endsection
|
||
|
||
@push('scripts')
|
||
<script>
|
||
// ===== 상태 관리 =====
|
||
const templateState = {
|
||
id: {{ $template->id ?? 'null' }},
|
||
name: '',
|
||
category: '',
|
||
title: '',
|
||
footer_remark_label: '비고',
|
||
footer_judgement_label: '종합판정',
|
||
footer_judgement_options: ['합격', '불합격', '조건부합격'],
|
||
is_active: true,
|
||
approval_lines: [],
|
||
sections: [],
|
||
columns: []
|
||
};
|
||
|
||
// 고유 ID 생성
|
||
let uniqueId = 0;
|
||
function generateId() {
|
||
return `temp_${++uniqueId}`;
|
||
}
|
||
|
||
// ===== 초기화 =====
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
@if($template && isset($templateData))
|
||
// 기존 데이터 로드 (컨트롤러에서 준비된 데이터 사용)
|
||
const loadedData = @json($templateData);
|
||
templateState.name = loadedData.name || '';
|
||
templateState.category = loadedData.category || '';
|
||
templateState.title = loadedData.title || '';
|
||
templateState.footer_remark_label = loadedData.footer_remark_label || '';
|
||
templateState.footer_judgement_label = loadedData.footer_judgement_label || '';
|
||
templateState.footer_judgement_options = loadedData.footer_judgement_options || [];
|
||
templateState.is_active = loadedData.is_active || false;
|
||
templateState.approval_lines = loadedData.approval_lines || [];
|
||
templateState.sections = loadedData.sections || [];
|
||
templateState.columns = loadedData.columns || [];
|
||
@endif
|
||
|
||
// UI 초기화
|
||
initBasicFields();
|
||
renderApprovalLines();
|
||
renderSections();
|
||
renderColumns();
|
||
});
|
||
|
||
// ===== 기본정보 =====
|
||
function initBasicFields() {
|
||
document.getElementById('name').value = templateState.name || '';
|
||
document.getElementById('category').value = templateState.category || '';
|
||
document.getElementById('title').value = templateState.title || '';
|
||
document.getElementById('is_active').checked = templateState.is_active;
|
||
|
||
// 변경 이벤트 바인딩
|
||
['name', 'category', 'title'].forEach(field => {
|
||
document.getElementById(field).addEventListener('input', function() {
|
||
templateState[field] = this.value;
|
||
});
|
||
});
|
||
document.getElementById('is_active').addEventListener('change', function() {
|
||
templateState.is_active = this.checked;
|
||
});
|
||
}
|
||
|
||
// ===== 탭 전환 =====
|
||
function switchTab(tabId) {
|
||
// 모든 탭 버튼 비활성화
|
||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||
btn.classList.remove('border-blue-500', 'text-blue-600');
|
||
btn.classList.add('border-transparent', 'text-gray-500');
|
||
});
|
||
// 선택된 탭 버튼 활성화
|
||
const activeBtn = document.getElementById(`tab-${tabId}`);
|
||
activeBtn.classList.add('border-blue-500', 'text-blue-600');
|
||
activeBtn.classList.remove('border-transparent', 'text-gray-500');
|
||
|
||
// 모든 컨텐츠 숨김
|
||
document.querySelectorAll('.tab-content').forEach(content => {
|
||
content.classList.add('hidden');
|
||
});
|
||
// 선택된 컨텐츠 표시
|
||
document.getElementById(`content-${tabId}`).classList.remove('hidden');
|
||
}
|
||
|
||
// ===== 결재라인 =====
|
||
function addApprovalLine() {
|
||
const line = { id: generateId(), name: '', dept: '', role: '' };
|
||
templateState.approval_lines.push(line);
|
||
renderApprovalLines();
|
||
}
|
||
|
||
function removeApprovalLine(id) {
|
||
templateState.approval_lines = templateState.approval_lines.filter(l => l.id !== id);
|
||
renderApprovalLines();
|
||
}
|
||
|
||
function updateApprovalLine(id, field, value) {
|
||
const line = templateState.approval_lines.find(l => l.id === id);
|
||
if (line) line[field] = value;
|
||
}
|
||
|
||
function renderApprovalLines() {
|
||
const container = document.getElementById('approval-lines');
|
||
const emptyMsg = document.getElementById('approval-empty');
|
||
|
||
if (templateState.approval_lines.length === 0) {
|
||
container.innerHTML = '';
|
||
emptyMsg.classList.remove('hidden');
|
||
return;
|
||
}
|
||
|
||
emptyMsg.classList.add('hidden');
|
||
container.innerHTML = templateState.approval_lines.map((line, idx) => `
|
||
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg cursor-move" data-id="${line.id}">
|
||
<span class="text-gray-400 font-bold" title="드래그하여 순서 변경">⋮⋮</span>
|
||
<span class="text-gray-400 font-mono text-sm w-6">${idx + 1}</span>
|
||
<input type="text" value="${escapeHtml(line.name)}" placeholder="단계명 (작성, 검토, 승인)"
|
||
onchange="updateApprovalLine('${line.id}', 'name', this.value)"
|
||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||
<input type="text" value="${escapeHtml(line.dept)}" placeholder="부서"
|
||
onchange="updateApprovalLine('${line.id}', 'dept', this.value)"
|
||
class="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||
<input type="text" value="${escapeHtml(line.role)}" placeholder="직책/담당자"
|
||
onchange="updateApprovalLine('${line.id}', 'role', this.value)"
|
||
class="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||
<button onclick="removeApprovalLine('${line.id}')" class="text-red-500 hover:text-red-700 p-1">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
// ===== 섹션 관리 =====
|
||
function addSection() {
|
||
const section = { id: generateId(), title: '', image_path: null, items: [] };
|
||
templateState.sections.push(section);
|
||
renderSections();
|
||
}
|
||
|
||
function removeSection(id) {
|
||
templateState.sections = templateState.sections.filter(s => s.id !== id);
|
||
renderSections();
|
||
}
|
||
|
||
function updateSection(id, field, value) {
|
||
const section = templateState.sections.find(s => s.id === id);
|
||
if (section) section[field] = value;
|
||
}
|
||
|
||
function addSectionItem(sectionId) {
|
||
const section = templateState.sections.find(s => s.id === sectionId);
|
||
if (section) {
|
||
section.items.push({
|
||
id: generateId(),
|
||
category: '',
|
||
item: '',
|
||
standard: '',
|
||
method: '',
|
||
frequency: '',
|
||
regulation: ''
|
||
});
|
||
renderSections();
|
||
}
|
||
}
|
||
|
||
function removeSectionItem(sectionId, itemId) {
|
||
const section = templateState.sections.find(s => s.id === sectionId);
|
||
if (section) {
|
||
section.items = section.items.filter(i => i.id !== itemId);
|
||
renderSections();
|
||
}
|
||
}
|
||
|
||
function updateSectionItem(sectionId, itemId, field, value) {
|
||
const section = templateState.sections.find(s => s.id === sectionId);
|
||
if (section) {
|
||
const item = section.items.find(i => i.id === itemId);
|
||
if (item) item[field] = value;
|
||
}
|
||
}
|
||
|
||
function renderSections() {
|
||
const container = document.getElementById('sections-container');
|
||
const emptyMsg = document.getElementById('sections-empty');
|
||
|
||
if (templateState.sections.length === 0) {
|
||
container.innerHTML = '';
|
||
emptyMsg.classList.remove('hidden');
|
||
return;
|
||
}
|
||
|
||
emptyMsg.classList.add('hidden');
|
||
container.innerHTML = templateState.sections.map((section, idx) => `
|
||
<div class="border border-gray-200 rounded-lg overflow-hidden" data-section-id="${section.id}">
|
||
<div class="bg-gray-50 p-4 flex items-center justify-between">
|
||
<div class="flex items-center gap-3 flex-1">
|
||
<span class="text-gray-400 font-bold cursor-move drag-handle" title="드래그하여 순서 변경">⋮⋮</span>
|
||
<input type="text" value="${escapeHtml(section.title)}" placeholder="섹션 제목 (예: 가이드레일)"
|
||
onchange="updateSection('${section.id}', 'title', this.value)"
|
||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm font-medium">
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<label class="text-gray-600 hover:text-blue-600 text-sm flex items-center gap-1 cursor-pointer">
|
||
<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="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||
</svg>
|
||
이미지
|
||
<input type="file" accept="image/*" class="hidden" onchange="uploadSectionImage('${section.id}', this)">
|
||
</label>
|
||
<button onclick="addSectionItem('${section.id}')" class="text-blue-600 hover:text-blue-800 text-sm flex items-center gap-1">
|
||
<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>
|
||
항목 추가
|
||
</button>
|
||
<button onclick="removeSection('${section.id}')" class="text-red-500 hover:text-red-700 p-1">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
${section.image_path ? `
|
||
<div class="px-4 py-2 bg-blue-50 flex items-center justify-between">
|
||
<div class="flex items-center gap-2">
|
||
<img src="/storage/${section.image_path}" alt="섹션 이미지" class="h-16 rounded border border-gray-200">
|
||
<span class="text-xs text-gray-500">이미지 첨부됨</span>
|
||
</div>
|
||
<button onclick="removeSectionImage('${section.id}')" class="text-red-500 hover:text-red-700 text-xs">
|
||
이미지 삭제
|
||
</button>
|
||
</div>
|
||
` : ''}
|
||
<div class="p-4">
|
||
${section.items.length > 0 ? `
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-sm">
|
||
<thead class="bg-gray-100">
|
||
<tr>
|
||
<th class="px-2 py-2 text-left text-xs font-medium text-gray-500 w-24">구분</th>
|
||
<th class="px-2 py-2 text-left text-xs font-medium text-gray-500">검사항목</th>
|
||
<th class="px-2 py-2 text-left text-xs font-medium text-gray-500">검사기준</th>
|
||
<th class="px-2 py-2 text-left text-xs font-medium text-gray-500 w-24">검사방법</th>
|
||
<th class="px-2 py-2 text-left text-xs font-medium text-gray-500 w-24">검사주기</th>
|
||
<th class="px-2 py-2 text-left text-xs font-medium text-gray-500 w-28">관련규정</th>
|
||
<th class="px-2 py-2 w-10"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${section.items.map(item => `
|
||
<tr class="border-t" data-item-id="${item.id}">
|
||
<td class="px-1 py-1">
|
||
<input type="text" value="${escapeHtml(item.category)}" placeholder="구분"
|
||
onchange="updateSectionItem('${section.id}', '${item.id}', 'category', this.value)"
|
||
class="w-full px-2 py-1 border border-gray-200 rounded text-xs">
|
||
</td>
|
||
<td class="px-1 py-1">
|
||
<input type="text" value="${escapeHtml(item.item)}" placeholder="검사항목"
|
||
onchange="updateSectionItem('${section.id}', '${item.id}', 'item', this.value)"
|
||
class="w-full px-2 py-1 border border-gray-200 rounded text-xs">
|
||
</td>
|
||
<td class="px-1 py-1">
|
||
<input type="text" value="${escapeHtml(item.standard)}" placeholder="검사기준"
|
||
onchange="updateSectionItem('${section.id}', '${item.id}', 'standard', this.value)"
|
||
class="w-full px-2 py-1 border border-gray-200 rounded text-xs">
|
||
</td>
|
||
<td class="px-1 py-1">
|
||
<input type="text" value="${escapeHtml(item.method)}" placeholder="방법"
|
||
onchange="updateSectionItem('${section.id}', '${item.id}', 'method', this.value)"
|
||
class="w-full px-2 py-1 border border-gray-200 rounded text-xs">
|
||
</td>
|
||
<td class="px-1 py-1">
|
||
<input type="text" value="${escapeHtml(item.frequency)}" placeholder="주기"
|
||
onchange="updateSectionItem('${section.id}', '${item.id}', 'frequency', this.value)"
|
||
class="w-full px-2 py-1 border border-gray-200 rounded text-xs">
|
||
</td>
|
||
<td class="px-1 py-1">
|
||
<input type="text" value="${escapeHtml(item.regulation)}" placeholder="규정"
|
||
onchange="updateSectionItem('${section.id}', '${item.id}', 'regulation', this.value)"
|
||
class="w-full px-2 py-1 border border-gray-200 rounded text-xs">
|
||
</td>
|
||
<td class="px-1 py-1">
|
||
<button onclick="removeSectionItem('${section.id}', '${item.id}')" class="text-red-400 hover:text-red-600">
|
||
<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="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
` : `
|
||
<p class="text-gray-400 text-center py-4 text-sm">항목이 없습니다. 항목 추가 버튼을 클릭하세요.</p>
|
||
`}
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
// ===== 컬럼 관리 =====
|
||
function addColumn() {
|
||
const column = { id: generateId(), label: '', width: '100px', column_type: 'text', group_name: '', sub_labels: null };
|
||
templateState.columns.push(column);
|
||
renderColumns();
|
||
}
|
||
|
||
function removeColumn(id) {
|
||
templateState.columns = templateState.columns.filter(c => c.id !== id);
|
||
renderColumns();
|
||
}
|
||
|
||
function updateColumn(id, field, value) {
|
||
const column = templateState.columns.find(c => c.id === id);
|
||
if (column) column[field] = value;
|
||
}
|
||
|
||
function renderColumns() {
|
||
const container = document.getElementById('columns-container');
|
||
const emptyMsg = document.getElementById('columns-empty');
|
||
|
||
if (templateState.columns.length === 0) {
|
||
container.innerHTML = '';
|
||
emptyMsg.classList.remove('hidden');
|
||
return;
|
||
}
|
||
|
||
emptyMsg.classList.add('hidden');
|
||
container.innerHTML = templateState.columns.map((col, idx) => `
|
||
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg cursor-move" data-column-id="${col.id}">
|
||
<span class="text-gray-400 font-bold" title="드래그하여 순서 변경">⋮⋮</span>
|
||
<span class="text-gray-400 font-mono text-sm w-6">${idx + 1}</span>
|
||
<input type="text" value="${escapeHtml(col.label)}" placeholder="컬럼명"
|
||
onchange="updateColumn('${col.id}', 'label', this.value)"
|
||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||
<input type="text" value="${escapeHtml(col.width)}" placeholder="너비"
|
||
onchange="updateColumn('${col.id}', 'width', this.value)"
|
||
class="w-24 px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||
<select onchange="updateColumn('${col.id}', 'column_type', this.value)"
|
||
class="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||
<option value="text" ${col.column_type === 'text' ? 'selected' : ''}>텍스트</option>
|
||
<option value="check" ${col.column_type === 'check' ? 'selected' : ''}>체크</option>
|
||
<option value="measurement" ${col.column_type === 'measurement' ? 'selected' : ''}>측정값</option>
|
||
<option value="select" ${col.column_type === 'select' ? 'selected' : ''}>선택</option>
|
||
</select>
|
||
<input type="text" value="${escapeHtml(col.group_name || '')}" placeholder="그룹명"
|
||
onchange="updateColumn('${col.id}', 'group_name', this.value)"
|
||
class="w-28 px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||
<button onclick="removeColumn('${col.id}')" class="text-red-500 hover:text-red-700 p-1">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
// ===== 저장 =====
|
||
function saveTemplate() {
|
||
const name = document.getElementById('name').value.trim();
|
||
if (!name) {
|
||
showToast('양식명은 필수입니다.', 'warning');
|
||
switchTab('basic');
|
||
document.getElementById('name').focus();
|
||
return;
|
||
}
|
||
|
||
const data = {
|
||
name: name,
|
||
category: document.getElementById('category').value,
|
||
title: document.getElementById('title').value,
|
||
is_active: document.getElementById('is_active').checked,
|
||
approval_lines: templateState.approval_lines,
|
||
sections: templateState.sections,
|
||
columns: templateState.columns
|
||
};
|
||
|
||
const url = templateState.id
|
||
? `/api/admin/document-templates/${templateState.id}`
|
||
: '/api/admin/document-templates';
|
||
const method = templateState.id ? 'PUT' : 'POST';
|
||
|
||
fetch(url, {
|
||
method: method,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||
'Accept': 'application/json'
|
||
},
|
||
body: JSON.stringify(data)
|
||
})
|
||
.then(response => response.json())
|
||
.then(result => {
|
||
if (result.success) {
|
||
showToast(result.message || '저장되었습니다.', 'success');
|
||
if (!templateState.id && result.data?.id) {
|
||
// 새로 생성된 경우 편집 페이지로 이동
|
||
window.location.href = `/document-templates/${result.data.id}/edit`;
|
||
}
|
||
} else {
|
||
showToast(result.message || '저장에 실패했습니다.', 'error');
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Save error:', error);
|
||
showToast('저장 중 오류가 발생했습니다.', 'error');
|
||
});
|
||
}
|
||
|
||
// ===== 미리보기 =====
|
||
function openPreviewModal() {
|
||
const content = document.getElementById('preview-content');
|
||
content.innerHTML = generatePreviewHtml();
|
||
document.getElementById('preview-modal').classList.remove('hidden');
|
||
}
|
||
|
||
function closePreviewModal() {
|
||
document.getElementById('preview-modal').classList.add('hidden');
|
||
}
|
||
|
||
function generatePreviewHtml() {
|
||
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-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>
|
||
|
||
<!-- 기본 정보 테이블 -->
|
||
<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>
|
||
|
||
<!-- 검사 항목 테이블 -->
|
||
<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>
|
||
|
||
<!-- 종합판정 -->
|
||
<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>
|
||
`;
|
||
}
|
||
|
||
// ===== 이미지 업로드 =====
|
||
function uploadSectionImage(sectionId, input) {
|
||
const file = input.files[0];
|
||
if (!file) return;
|
||
|
||
const formData = new FormData();
|
||
formData.append('image', file);
|
||
|
||
showToast('이미지 업로드 중...', 'info');
|
||
|
||
fetch('/api/admin/document-templates/upload-image', {
|
||
method: 'POST',
|
||
headers: {
|
||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||
'Accept': 'application/json'
|
||
},
|
||
body: formData
|
||
})
|
||
.then(response => response.json())
|
||
.then(result => {
|
||
if (result.success) {
|
||
const section = templateState.sections.find(s => s.id === sectionId);
|
||
if (section) {
|
||
section.image_path = result.path;
|
||
renderSections();
|
||
showToast('이미지가 업로드되었습니다.', 'success');
|
||
}
|
||
} else {
|
||
showToast(result.message || '이미지 업로드에 실패했습니다.', 'error');
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Upload error:', error);
|
||
showToast('이미지 업로드 중 오류가 발생했습니다.', 'error');
|
||
});
|
||
|
||
input.value = '';
|
||
}
|
||
|
||
function removeSectionImage(sectionId) {
|
||
const section = templateState.sections.find(s => s.id === sectionId);
|
||
if (section) {
|
||
section.image_path = null;
|
||
renderSections();
|
||
}
|
||
}
|
||
|
||
// ===== SortableJS 초기화 =====
|
||
function initSortable() {
|
||
// 섹션 정렬
|
||
const sectionsContainer = document.getElementById('sections-container');
|
||
if (sectionsContainer && typeof Sortable !== 'undefined') {
|
||
new Sortable(sectionsContainer, {
|
||
animation: 150,
|
||
handle: '.drag-handle',
|
||
onEnd: function(evt) {
|
||
const newOrder = [];
|
||
sectionsContainer.querySelectorAll('[data-section-id]').forEach((el, idx) => {
|
||
const sectionId = el.dataset.sectionId;
|
||
const section = templateState.sections.find(s => s.id === sectionId);
|
||
if (section) newOrder.push(section);
|
||
});
|
||
templateState.sections = newOrder;
|
||
}
|
||
});
|
||
}
|
||
|
||
// 결재라인 정렬
|
||
const approvalContainer = document.getElementById('approval-lines');
|
||
if (approvalContainer && typeof Sortable !== 'undefined') {
|
||
new Sortable(approvalContainer, {
|
||
animation: 150,
|
||
onEnd: function(evt) {
|
||
const newOrder = [];
|
||
approvalContainer.querySelectorAll('[data-id]').forEach((el) => {
|
||
const id = el.dataset.id;
|
||
const line = templateState.approval_lines.find(l => l.id === id);
|
||
if (line) newOrder.push(line);
|
||
});
|
||
templateState.approval_lines = newOrder;
|
||
}
|
||
});
|
||
}
|
||
|
||
// 컬럼 정렬
|
||
const columnsContainer = document.getElementById('columns-container');
|
||
if (columnsContainer && typeof Sortable !== 'undefined') {
|
||
new Sortable(columnsContainer, {
|
||
animation: 150,
|
||
onEnd: function(evt) {
|
||
const newOrder = [];
|
||
columnsContainer.querySelectorAll('[data-column-id]').forEach((el) => {
|
||
const id = el.dataset.columnId;
|
||
const col = templateState.columns.find(c => c.id === id);
|
||
if (col) newOrder.push(col);
|
||
});
|
||
templateState.columns = newOrder;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 렌더링 후 SortableJS 재초기화
|
||
const originalRenderApprovalLines = renderApprovalLines;
|
||
renderApprovalLines = function() {
|
||
originalRenderApprovalLines();
|
||
setTimeout(initSortable, 100);
|
||
};
|
||
|
||
const originalRenderSections = renderSections;
|
||
renderSections = function() {
|
||
originalRenderSections();
|
||
setTimeout(initSortable, 100);
|
||
};
|
||
|
||
const originalRenderColumns = renderColumns;
|
||
renderColumns = function() {
|
||
originalRenderColumns();
|
||
setTimeout(initSortable, 100);
|
||
};
|
||
|
||
// 초기 SortableJS 로드
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
setTimeout(initSortable, 500);
|
||
});
|
||
|
||
// ===== 유틸리티 =====
|
||
function escapeHtml(text) {
|
||
if (!text) return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
</script>
|
||
|
||
<!-- SortableJS CDN -->
|
||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||
@endpush |