index/edit 페이지에 각각 중복 구현되어 있던 미리보기 렌더링 로직을 partials/preview-modal.blade.php로 통합하여 단일 buildDocumentPreviewHtml() 함수로 관리 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1627 lines
83 KiB
PHP
1627 lines
83 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', $isCreate ? '문서양식 등록' : '문서양식 편집')
|
|
|
|
@push('styles')
|
|
<style>
|
|
/* number input 스피너 숨김 */
|
|
input[type=number]::-webkit-inner-spin-button,
|
|
input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
|
|
input[type=number] { -moz-appearance: textfield; }
|
|
</style>
|
|
@endpush
|
|
|
|
@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('basic-fields')" id="tab-basic-fields"
|
|
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">
|
|
<!-- Row 1: 양식명 + 문서 제목 -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-500 mb-1">양식명 <span class="text-red-500">*</span></label>
|
|
<input type="text" id="name" placeholder="예: 최종검사 성적서"
|
|
class="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-500 mb-1">문서 제목</label>
|
|
<input type="text" id="title" placeholder="예: 최종 검사 성적서"
|
|
class="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Row 2: 분류 + 회사명 + 활성화 -->
|
|
<div class="grid grid-cols-1 md:grid-cols-[1fr_1fr_auto] gap-4 mb-4 items-end">
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-500 mb-1">분류</label>
|
|
<select id="category" onchange="onCategoryChange(this.value)"
|
|
class="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
<option value="">-- 선택 --</option>
|
|
<option value="수입검사">수입검사</option>
|
|
<option value="중간검사">중간검사</option>
|
|
<option value="품질검사">품질검사</option>
|
|
@foreach($categories as $cat)
|
|
@if(!in_array($cat, ['수입검사', '중간검사', '품질검사']))
|
|
<option value="{{ $cat }}">{{ $cat }}</option>
|
|
@endif
|
|
@endforeach
|
|
<option value="__custom__">직접 입력...</option>
|
|
</select>
|
|
<input type="text" id="category-custom" placeholder="분류명을 입력하세요"
|
|
class="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 mt-1 hidden"
|
|
oninput="templateState.category = this.value">
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-500 mb-1">회사명</label>
|
|
<input type="text" id="company_name" placeholder="예: 케이디산업"
|
|
class="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
</div>
|
|
<div class="flex items-center gap-2 pb-0.5">
|
|
<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 whitespace-nowrap">활성화</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Row 2.5: 조건부 - 수입검사 품목 / 품질검사 공정 -->
|
|
<div id="linked-items-section" class="mb-4 hidden">
|
|
<label class="block text-xs font-medium text-gray-500 mb-1">연결 품목 (RM, SM)</label>
|
|
<div class="flex items-center gap-2">
|
|
<input type="text" id="item-search-input" placeholder="품목명 또는 코드로 검색..."
|
|
class="flex-1 px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
oninput="searchLinkedItems(this.value)">
|
|
</div>
|
|
<div id="item-search-results" class="border border-gray-200 rounded-lg max-h-40 overflow-y-auto hidden mt-1"></div>
|
|
<div id="linked-items-tags" class="flex flex-wrap gap-2 mt-2"></div>
|
|
</div>
|
|
<div id="linked-process-section" class="mb-4 hidden">
|
|
<label class="block text-xs font-medium text-gray-500 mb-1">연결 공정</label>
|
|
<div class="flex items-center gap-2">
|
|
<input type="text" id="process-search-input" placeholder="공정명 또는 코드로 검색..."
|
|
class="flex-1 px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
oninput="searchProcesses(this.value)">
|
|
</div>
|
|
<div id="process-search-results" class="border border-gray-200 rounded-lg max-h-40 overflow-y-auto hidden mt-1"></div>
|
|
<div id="linked-process-display" class="mt-2"></div>
|
|
</div>
|
|
|
|
<!-- Row 3: Footer - 비고라벨 + 판정라벨 -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-500 mb-1">비고 라벨</label>
|
|
<input type="text" id="footer_remark_label" placeholder="예: 부적합 내용"
|
|
class="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-500 mb-1">종합판정 라벨</label>
|
|
<input type="text" id="footer_judgement_label" placeholder="예: 종합판정"
|
|
class="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Row 4: 종합판정 옵션 -->
|
|
<div class="mb-6">
|
|
<label class="block text-xs font-medium text-gray-500 mb-1">종합판정 옵션</label>
|
|
<div class="flex items-center gap-2">
|
|
<div id="judgement-options-container" class="flex flex-wrap gap-2 flex-1">
|
|
<!-- 동적으로 렌더링 -->
|
|
</div>
|
|
<button type="button" onclick="addJudgementOption()" class="bg-gray-100 hover:bg-gray-200 text-gray-600 px-3 py-1.5 rounded-lg text-xs flex-shrink-0">
|
|
+ 옵션 추가
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 구분선 + 결재라인 -->
|
|
<hr class="my-5 border-gray-200">
|
|
<div class="flex justify-between items-center mb-3">
|
|
<h3 class="text-sm font-semibold text-gray-700">결재라인</h3>
|
|
<button type="button" onclick="addApprovalLine()" class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded-lg text-xs flex items-center gap-1">
|
|
<svg class="w-3.5 h-3.5" 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-2">
|
|
<!-- 동적으로 추가됨 -->
|
|
</div>
|
|
<p id="approval-empty" class="text-gray-400 text-center py-4 text-sm hidden">결재라인이 없습니다. 추가 버튼을 클릭하세요.</p>
|
|
</div>
|
|
|
|
<!-- 기본필드 탭 -->
|
|
<div id="content-basic-fields" class="tab-content bg-white rounded-lg shadow-sm p-6 hidden">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<div>
|
|
<h3 class="text-lg font-medium text-gray-800">기본필드 설정</h3>
|
|
<p class="text-xs text-gray-500 mt-1">문서 상단에 표시되는 필드입니다 (예: 품명, LOT NO, 검사일자)</p>
|
|
</div>
|
|
<button type="button" onclick="addBasicField()" 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="basic-fields-container" class="space-y-3">
|
|
<!-- 동적으로 추가됨 -->
|
|
</div>
|
|
<p id="basic-fields-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>
|
|
|
|
<!-- 미리보기 모달 (공통 partial) -->
|
|
@include('document-templates.partials.preview-modal')
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script>
|
|
// ===== 상태 관리 =====
|
|
const templateState = {
|
|
id: {{ $template->id ?? 'null' }},
|
|
name: '',
|
|
category: '',
|
|
title: '',
|
|
company_name: '',
|
|
footer_remark_label: '부적합 내용',
|
|
footer_judgement_label: '종합판정',
|
|
footer_judgement_options: ['합격', '불합격', '조건부합격'],
|
|
is_active: true,
|
|
linked_item_ids: null,
|
|
linked_process_id: null,
|
|
approval_lines: [],
|
|
basic_fields: [],
|
|
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.company_name = loadedData.company_name || '';
|
|
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.linked_item_ids = loadedData.linked_item_ids || null;
|
|
templateState.linked_process_id = loadedData.linked_process_id || null;
|
|
templateState.approval_lines = loadedData.approval_lines || [];
|
|
templateState.basic_fields = loadedData.basic_fields || [];
|
|
templateState.sections = loadedData.sections || [];
|
|
templateState.columns = loadedData.columns || [];
|
|
@endif
|
|
|
|
// UI 초기화
|
|
initBasicInfo();
|
|
renderJudgementOptions();
|
|
renderApprovalLines();
|
|
renderBasicFields();
|
|
loadInspectionMethods(); // 검사방식 옵션 로드 후 renderSections 자동 호출
|
|
renderColumns();
|
|
});
|
|
|
|
// ===== 연결 데이터 상태 =====
|
|
let linkedItems = []; // 수입검사 연결 품목 [{id, code, name}]
|
|
let linkedProcess = null; // 품질검사 연결 공정 {id, process_code, process_name}
|
|
let itemSearchTimer = null;
|
|
let processSearchTimer = null;
|
|
let userSearchTimer = null;
|
|
|
|
// ===== 기본정보 =====
|
|
function initBasicInfo() {
|
|
document.getElementById('name').value = templateState.name || '';
|
|
document.getElementById('title').value = templateState.title || '';
|
|
document.getElementById('footer_remark_label').value = templateState.footer_remark_label || '';
|
|
document.getElementById('footer_judgement_label').value = templateState.footer_judgement_label || '';
|
|
document.getElementById('is_active').checked = templateState.is_active;
|
|
|
|
// 회사명 자동채움 (생성 시)
|
|
@if($isCreate && $tenant)
|
|
document.getElementById('company_name').value = '{{ $tenant->company_name ?? '' }}';
|
|
templateState.company_name = '{{ $tenant->company_name ?? '' }}';
|
|
@else
|
|
document.getElementById('company_name').value = templateState.company_name || '';
|
|
@endif
|
|
|
|
// 분류 초기값 설정
|
|
const categorySelect = document.getElementById('category');
|
|
const categoryValue = templateState.category || '';
|
|
const predefined = ['수입검사', '중간검사', '품질검사'];
|
|
const existingOptions = Array.from(categorySelect.options).map(o => o.value);
|
|
|
|
if (categoryValue && !existingOptions.includes(categoryValue) && categoryValue !== '__custom__') {
|
|
// 기존 카테고리 목록에 없는 커스텀 값
|
|
categorySelect.value = '__custom__';
|
|
document.getElementById('category-custom').value = categoryValue;
|
|
document.getElementById('category-custom').classList.remove('hidden');
|
|
} else {
|
|
categorySelect.value = categoryValue;
|
|
}
|
|
onCategoryChange(categorySelect.value, true);
|
|
|
|
// 연결 품목 로드 (수입검사)
|
|
if (templateState.linked_item_ids && templateState.linked_item_ids.length > 0) {
|
|
loadLinkedItems(templateState.linked_item_ids);
|
|
}
|
|
// 연결 공정 로드 (품질검사)
|
|
if (templateState.linked_process_id) {
|
|
loadLinkedProcess(templateState.linked_process_id);
|
|
}
|
|
|
|
// 변경 이벤트 바인딩
|
|
['name', 'title', 'company_name', 'footer_remark_label', 'footer_judgement_label'].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 onCategoryChange(value, isInit = false) {
|
|
const customInput = document.getElementById('category-custom');
|
|
const itemsSection = document.getElementById('linked-items-section');
|
|
const processSection = document.getElementById('linked-process-section');
|
|
|
|
// 직접 입력 모드
|
|
if (value === '__custom__') {
|
|
customInput.classList.remove('hidden');
|
|
customInput.focus();
|
|
templateState.category = customInput.value;
|
|
} else {
|
|
customInput.classList.add('hidden');
|
|
templateState.category = value;
|
|
}
|
|
|
|
// 조건부 UI 토글
|
|
itemsSection.classList.toggle('hidden', value !== '수입검사');
|
|
processSection.classList.toggle('hidden', value !== '품질검사');
|
|
|
|
// 분류 변경 시 연결 데이터 초기화 (초기 로드 제외)
|
|
if (!isInit) {
|
|
if (value !== '수입검사') {
|
|
linkedItems = [];
|
|
templateState.linked_item_ids = null;
|
|
renderLinkedItemTags();
|
|
}
|
|
if (value !== '품질검사') {
|
|
linkedProcess = null;
|
|
templateState.linked_process_id = null;
|
|
renderLinkedProcessDisplay();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== 수입검사: 품목 검색 =====
|
|
function searchLinkedItems(query) {
|
|
clearTimeout(itemSearchTimer);
|
|
if (query.length < 1) {
|
|
document.getElementById('item-search-results').classList.add('hidden');
|
|
return;
|
|
}
|
|
itemSearchTimer = setTimeout(() => {
|
|
fetch(`/api/admin/items/search?q=${encodeURIComponent(query)}&item_type=RM,SM`, {
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
|
})
|
|
.then(r => r.json())
|
|
.then(result => {
|
|
const container = document.getElementById('item-search-results');
|
|
if (!result.success || !result.data.length) {
|
|
container.innerHTML = '<div class="px-3 py-2 text-sm text-gray-400">결과 없음</div>';
|
|
container.classList.remove('hidden');
|
|
return;
|
|
}
|
|
// RM, SM 필터링 (서버에서 못할 경우 클라이언트 필터)
|
|
const filtered = result.data.filter(item =>
|
|
['RM', 'SM'].includes(item.item_type) && !linkedItems.find(li => li.id === item.id)
|
|
);
|
|
container.innerHTML = filtered.map(item => `
|
|
<div class="px-3 py-2 text-sm hover:bg-blue-50 cursor-pointer flex justify-between"
|
|
onclick="addLinkedItem(${item.id}, '${escapeHtml(item.code)}', '${escapeHtml(item.name)}')">
|
|
<span>${escapeHtml(item.name)}</span>
|
|
<span class="text-gray-400">${escapeHtml(item.code)} (${item.item_type})</span>
|
|
</div>
|
|
`).join('');
|
|
container.classList.remove('hidden');
|
|
});
|
|
}, 300);
|
|
}
|
|
|
|
function addLinkedItem(id, code, name) {
|
|
if (linkedItems.find(li => li.id === id)) return;
|
|
linkedItems.push({ id, code, name });
|
|
templateState.linked_item_ids = linkedItems.map(li => li.id);
|
|
document.getElementById('item-search-input').value = '';
|
|
document.getElementById('item-search-results').classList.add('hidden');
|
|
renderLinkedItemTags();
|
|
}
|
|
|
|
function removeLinkedItem(id) {
|
|
linkedItems = linkedItems.filter(li => li.id !== id);
|
|
templateState.linked_item_ids = linkedItems.length > 0 ? linkedItems.map(li => li.id) : null;
|
|
renderLinkedItemTags();
|
|
}
|
|
|
|
function renderLinkedItemTags() {
|
|
const container = document.getElementById('linked-items-tags');
|
|
container.innerHTML = linkedItems.map(item => `
|
|
<span class="inline-flex items-center gap-1 bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm">
|
|
${escapeHtml(item.name)} <span class="text-blue-400">(${escapeHtml(item.code)})</span>
|
|
<button onclick="removeLinkedItem(${item.id})" class="text-blue-400 hover:text-blue-600 ml-1">×</button>
|
|
</span>
|
|
`).join('');
|
|
}
|
|
|
|
function loadLinkedItems(ids) {
|
|
// 기존 연결 품목 정보 로드 (ids 파라미터로 한번에 조회)
|
|
fetch(`/api/admin/items/search?ids=${ids.join(',')}`, {
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
|
})
|
|
.then(r => r.json())
|
|
.then(result => {
|
|
if (result.success && result.data.length) {
|
|
result.data.forEach(item => {
|
|
if (!linkedItems.find(li => li.id === item.id)) {
|
|
linkedItems.push({ id: item.id, code: item.code, name: item.name });
|
|
}
|
|
});
|
|
renderLinkedItemTags();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ===== 품질검사: 공정 검색 =====
|
|
function searchProcesses(query) {
|
|
clearTimeout(processSearchTimer);
|
|
if (query.length < 1) {
|
|
document.getElementById('process-search-results').classList.add('hidden');
|
|
return;
|
|
}
|
|
processSearchTimer = setTimeout(() => {
|
|
fetch(`/api/admin/processes/search?q=${encodeURIComponent(query)}`, {
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
|
})
|
|
.then(r => r.json())
|
|
.then(result => {
|
|
const container = document.getElementById('process-search-results');
|
|
if (!result.success || !result.data.length) {
|
|
container.innerHTML = '<div class="px-3 py-2 text-sm text-gray-400">결과 없음</div>';
|
|
container.classList.remove('hidden');
|
|
return;
|
|
}
|
|
container.innerHTML = result.data.map(proc => `
|
|
<div class="px-3 py-2 text-sm hover:bg-blue-50 cursor-pointer flex justify-between"
|
|
onclick="selectLinkedProcess(${proc.id}, '${escapeHtml(proc.process_code)}', '${escapeHtml(proc.process_name)}')">
|
|
<span>${escapeHtml(proc.process_name)}</span>
|
|
<span class="text-gray-400">${escapeHtml(proc.process_code)}</span>
|
|
</div>
|
|
`).join('');
|
|
container.classList.remove('hidden');
|
|
});
|
|
}, 300);
|
|
}
|
|
|
|
function selectLinkedProcess(id, code, name) {
|
|
linkedProcess = { id, process_code: code, process_name: name };
|
|
templateState.linked_process_id = id;
|
|
document.getElementById('process-search-input').value = '';
|
|
document.getElementById('process-search-results').classList.add('hidden');
|
|
renderLinkedProcessDisplay();
|
|
}
|
|
|
|
function removeLinkedProcess() {
|
|
linkedProcess = null;
|
|
templateState.linked_process_id = null;
|
|
renderLinkedProcessDisplay();
|
|
}
|
|
|
|
function renderLinkedProcessDisplay() {
|
|
const container = document.getElementById('linked-process-display');
|
|
if (!linkedProcess) {
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
container.innerHTML = `
|
|
<span class="inline-flex items-center gap-1 bg-green-100 text-green-800 px-3 py-1 rounded-full text-sm">
|
|
${escapeHtml(linkedProcess.process_name)} <span class="text-green-400">(${escapeHtml(linkedProcess.process_code)})</span>
|
|
<button onclick="removeLinkedProcess()" class="text-green-400 hover:text-green-600 ml-1">×</button>
|
|
</span>
|
|
`;
|
|
}
|
|
|
|
function loadLinkedProcess(id) {
|
|
fetch(`/api/admin/processes/search?q=`, {
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
|
})
|
|
.then(r => r.json())
|
|
.then(result => {
|
|
if (result.success && result.data.length) {
|
|
const proc = result.data.find(p => p.id === id);
|
|
if (proc) {
|
|
linkedProcess = { id: proc.id, process_code: proc.process_code, process_name: proc.process_name };
|
|
renderLinkedProcessDisplay();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ===== 결재라인 단계명 변경 핸들러 =====
|
|
function onApprovalNameChange(id, value) {
|
|
const line = templateState.approval_lines.find(l => l.id == id);
|
|
if (!line) return;
|
|
line.name = value;
|
|
if (value === '작성') {
|
|
line.dept = '(작성자)';
|
|
line.role = '(작성자)';
|
|
line.user_id = null;
|
|
line.user_name = null;
|
|
} else {
|
|
if (line.dept === '(작성자)') line.dept = '';
|
|
if (line.role === '(작성자)') line.role = '';
|
|
}
|
|
renderApprovalLines();
|
|
}
|
|
|
|
// ===== 결재라인 사용자 검색 =====
|
|
function searchApprovalUser(lineId, query) {
|
|
clearTimeout(userSearchTimer);
|
|
const resultsContainer = document.getElementById(`user-results-${lineId}`);
|
|
if (query.length < 1) {
|
|
resultsContainer.classList.add('hidden');
|
|
return;
|
|
}
|
|
userSearchTimer = setTimeout(() => {
|
|
fetch(`/api/admin/tenant-users/search?q=${encodeURIComponent(query)}`, {
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
|
})
|
|
.then(r => r.json())
|
|
.then(result => {
|
|
if (!result.success || !result.data.length) {
|
|
resultsContainer.innerHTML = '<div class="px-3 py-2 text-sm text-gray-400">결과 없음</div>';
|
|
resultsContainer.classList.remove('hidden');
|
|
return;
|
|
}
|
|
resultsContainer.innerHTML = result.data.map(user => `
|
|
<div class="px-3 py-2 text-sm hover:bg-blue-50 cursor-pointer"
|
|
onclick="selectApprovalUser('${lineId}', ${user.id}, '${escapeHtml(user.name)}', '${escapeHtml(user.department_name || '')}')">
|
|
<span class="font-medium">${escapeHtml(user.name)}</span>
|
|
${user.department_name ? `<span class="text-gray-400 ml-2">${escapeHtml(user.department_name)}</span>` : ''}
|
|
</div>
|
|
`).join('');
|
|
resultsContainer.classList.remove('hidden');
|
|
});
|
|
}, 300);
|
|
}
|
|
|
|
function selectApprovalUser(lineId, userId, userName, deptName) {
|
|
const line = templateState.approval_lines.find(l => l.id == lineId);
|
|
if (!line) return;
|
|
line.user_id = userId;
|
|
line.user_name = userName;
|
|
if (deptName && !line.dept) line.dept = deptName;
|
|
|
|
// UI 업데이트
|
|
const searchInput = document.getElementById(`user-search-${lineId}`);
|
|
if (searchInput) searchInput.value = userName;
|
|
const hiddenInput = document.getElementById(`user-id-${lineId}`);
|
|
if (hiddenInput) hiddenInput.value = userId;
|
|
const resultsContainer = document.getElementById(`user-results-${lineId}`);
|
|
if (resultsContainer) resultsContainer.classList.add('hidden');
|
|
}
|
|
|
|
// ===== 종합판정 옵션 =====
|
|
function renderJudgementOptions() {
|
|
const container = document.getElementById('judgement-options-container');
|
|
container.innerHTML = templateState.footer_judgement_options.map((opt, idx) => `
|
|
<div class="flex items-center gap-1 bg-gray-100 rounded-lg px-2 py-1">
|
|
<input type="text" value="${escapeHtml(opt)}" placeholder="옵션"
|
|
onchange="updateJudgementOption(${idx}, this.value)"
|
|
class="w-24 px-2 py-1 border border-gray-200 rounded text-sm bg-white">
|
|
<button onclick="removeJudgementOption(${idx})" 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>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function addJudgementOption() {
|
|
templateState.footer_judgement_options.push('');
|
|
renderJudgementOptions();
|
|
// 마지막 input에 포커스
|
|
const inputs = document.querySelectorAll('#judgement-options-container input[type="text"]');
|
|
if (inputs.length) inputs[inputs.length - 1].focus();
|
|
}
|
|
|
|
function updateJudgementOption(idx, value) {
|
|
templateState.footer_judgement_options[idx] = value;
|
|
}
|
|
|
|
function removeJudgementOption(idx) {
|
|
templateState.footer_judgement_options.splice(idx, 1);
|
|
renderJudgementOptions();
|
|
}
|
|
|
|
// ===== 기본필드 (basic_fields) =====
|
|
function addBasicField() {
|
|
templateState.basic_fields.push({
|
|
id: generateId(),
|
|
label: '',
|
|
field_type: 'text',
|
|
default_value: ''
|
|
});
|
|
renderBasicFields();
|
|
}
|
|
|
|
function removeBasicField(id) {
|
|
templateState.basic_fields = templateState.basic_fields.filter(f => f.id !=id);
|
|
renderBasicFields();
|
|
}
|
|
|
|
function updateBasicField(id, field, value) {
|
|
const bf = templateState.basic_fields.find(f => f.id ==id);
|
|
if (bf) bf[field] = value;
|
|
}
|
|
|
|
function renderBasicFields() {
|
|
const container = document.getElementById('basic-fields-container');
|
|
const emptyMsg = document.getElementById('basic-fields-empty');
|
|
|
|
if (templateState.basic_fields.length === 0) {
|
|
container.innerHTML = '';
|
|
emptyMsg.classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
emptyMsg.classList.add('hidden');
|
|
container.innerHTML = templateState.basic_fields.map((field, idx) => `
|
|
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg cursor-move" data-bf-id="${field.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(field.label)}" placeholder="필드명 (예: 품명, LOT NO)"
|
|
onchange="updateBasicField('${field.id}', 'label', this.value)"
|
|
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
|
<select onchange="updateBasicField('${field.id}', 'field_type', this.value)"
|
|
class="w-28 px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
|
<option value="text" ${field.field_type === 'text' ? 'selected' : ''}>텍스트</option>
|
|
<option value="date" ${field.field_type === 'date' ? 'selected' : ''}>날짜</option>
|
|
<option value="number" ${field.field_type === 'number' ? 'selected' : ''}>숫자</option>
|
|
<option value="select" ${field.field_type === 'select' ? 'selected' : ''}>선택</option>
|
|
<option value="item_search" ${field.field_type === 'item_search' ? 'selected' : ''}>품목검색</option>
|
|
</select>
|
|
<input type="text" value="${escapeHtml(field.default_value || '')}" placeholder="기본값"
|
|
onchange="updateBasicField('${field.id}', 'default_value', this.value)"
|
|
class="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
|
<button onclick="removeBasicField('${field.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 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: '(작성자)', user_id: null, user_name: null };
|
|
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) => {
|
|
const isWriter = (line.name === '작성');
|
|
const userDisplay = isWriter
|
|
? '<span class="text-gray-400 text-xs px-2">(작성자)</span>'
|
|
: `<div class="relative">
|
|
<input type="text" id="user-search-${line.id}" placeholder="사용자 검색..."
|
|
value="${line.user_id ? escapeHtml(line.user_name || '') : ''}"
|
|
oninput="searchApprovalUser('${line.id}', this.value)"
|
|
onfocus="searchApprovalUser('${line.id}', this.value)"
|
|
class="w-40 px-2 py-1 border border-gray-300 rounded text-xs">
|
|
<input type="hidden" id="user-id-${line.id}" value="${line.user_id || ''}">
|
|
<div id="user-results-${line.id}" class="absolute z-10 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-40 overflow-y-auto hidden"></div>
|
|
</div>`;
|
|
const deptRoleDisplay = isWriter
|
|
? '<span class="text-gray-400 text-xs px-2">(작성자)</span>'
|
|
: `<input type="text" value="${escapeHtml(line.dept)}" placeholder="부서"
|
|
onchange="updateApprovalLine('${line.id}', 'dept', this.value)"
|
|
class="w-24 px-2 py-1 border border-gray-300 rounded text-xs">
|
|
<input type="text" value="${escapeHtml(line.role)}" placeholder="직책"
|
|
onchange="updateApprovalLine('${line.id}', 'role', this.value)"
|
|
class="w-24 px-2 py-1 border border-gray-300 rounded text-xs">`;
|
|
|
|
return `
|
|
<div class="flex items-center gap-2 px-3 py-2 bg-gray-50 rounded cursor-move" data-id="${line.id}">
|
|
<span class="text-gray-300 text-xs cursor-grab" title="드래그하여 순서 변경">⋮⋮</span>
|
|
<span class="text-gray-400 font-mono text-xs w-4">${idx + 1}</span>
|
|
<select onchange="onApprovalNameChange('${line.id}', this.value)"
|
|
class="w-20 px-2 py-1 border border-gray-300 rounded text-xs">
|
|
<option value="작성" ${line.name === '작성' ? 'selected' : ''}>작성</option>
|
|
<option value="검토" ${line.name === '검토' ? 'selected' : ''}>검토</option>
|
|
<option value="승인" ${line.name === '승인' ? 'selected' : ''}>승인</option>
|
|
<option value="참조" ${line.name === '참조' ? 'selected' : ''}>참조</option>
|
|
</select>
|
|
${deptRoleDisplay}
|
|
${userDisplay}
|
|
<button onclick="removeApprovalLine('${line.id}')" class="text-red-400 hover:text-red-600 p-0.5 ml-auto">
|
|
<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>
|
|
</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;
|
|
}
|
|
|
|
// 검사방식 → 측정유형 자동매핑
|
|
const METHOD_TO_MEASUREMENT = {
|
|
'visual': 'checkbox',
|
|
'check': 'numeric',
|
|
'mill_sheet': 'single_value',
|
|
'certified_agency': 'single_value',
|
|
'substitute_cert': 'substitute',
|
|
'other': 'text'
|
|
};
|
|
|
|
// 측정유형 옵션 정의
|
|
const MEASUREMENT_TYPES = [
|
|
{ code: 'checkbox', name: 'OK/NG 체크' },
|
|
{ code: 'numeric', name: '수치입력(3)' },
|
|
{ code: 'single_value', name: '단일값' },
|
|
{ code: 'substitute', name: '성적서 대체' },
|
|
{ code: 'text', name: '자유입력' }
|
|
];
|
|
|
|
// 검사방식(inspection_method) 옵션 - 페이지 로드 시 API에서 가져옴
|
|
let inspectionMethods = [];
|
|
|
|
function loadInspectionMethods() {
|
|
fetch('/api/admin/common-codes/inspection_method', {
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
|
})
|
|
.then(r => r.json())
|
|
.then(result => {
|
|
if (result.success && result.data) {
|
|
inspectionMethods = result.data;
|
|
} else {
|
|
// fallback 기본값
|
|
inspectionMethods = [
|
|
{ code: 'visual', name: '육안검사' },
|
|
{ code: 'check', name: '체크검사' },
|
|
{ code: 'mill_sheet', name: '공급업체 밀시트' },
|
|
{ code: 'certified_agency', name: '공인시험기관' },
|
|
{ code: 'substitute_cert', name: '공급업체 성적서 대체' },
|
|
{ code: 'other', name: '기타' }
|
|
];
|
|
}
|
|
renderSections();
|
|
})
|
|
.catch(() => {
|
|
inspectionMethods = [
|
|
{ code: 'visual', name: '육안검사' },
|
|
{ code: 'check', name: '체크검사' },
|
|
{ code: 'mill_sheet', name: '공급업체 밀시트' },
|
|
{ code: 'certified_agency', name: '공인시험기관' },
|
|
{ code: 'substitute_cert', name: '공급업체 성적서 대체' },
|
|
{ code: 'other', name: '기타' }
|
|
];
|
|
renderSections();
|
|
});
|
|
}
|
|
|
|
function onMethodChange(sectionId, itemId, value) {
|
|
updateSectionItem(sectionId, itemId, 'method', value);
|
|
// 자동 매핑
|
|
const mapped = METHOD_TO_MEASUREMENT[value] || '';
|
|
updateSectionItem(sectionId, itemId, 'measurement_type', mapped);
|
|
renderSections();
|
|
}
|
|
|
|
function addSectionItem(sectionId) {
|
|
const section = templateState.sections.find(s => s.id ==sectionId);
|
|
if (section) {
|
|
section.items.push({
|
|
id: generateId(),
|
|
category: '',
|
|
item: '',
|
|
standard: '',
|
|
tolerance: null,
|
|
standard_criteria: null,
|
|
method: '',
|
|
measurement_type: '',
|
|
frequency_n: null,
|
|
frequency_c: null,
|
|
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;
|
|
}
|
|
}
|
|
|
|
// ── 공차(Tolerance) 구조화 함수들 ──
|
|
|
|
function updateToleranceProp(sectionId, itemId, prop, value) {
|
|
const section = templateState.sections.find(s => s.id == sectionId);
|
|
if (!section) return;
|
|
const item = section.items.find(i => i.id == itemId);
|
|
if (!item) return;
|
|
|
|
if (prop === 'type') {
|
|
// 타입 변경 시 초기화
|
|
const defaults = {
|
|
symmetric: { type: 'symmetric', value: null },
|
|
asymmetric: { type: 'asymmetric', plus: null, minus: null },
|
|
range: { type: 'range', min: null, max: null },
|
|
limit: { type: 'limit', op: 'lte', value: null },
|
|
};
|
|
item.tolerance = value ? (defaults[value] || null) : null;
|
|
} else {
|
|
if (!item.tolerance) return;
|
|
const numVal = value !== '' ? parseFloat(value) : null;
|
|
if (prop === 'op') {
|
|
item.tolerance.op = value;
|
|
} else {
|
|
item.tolerance[prop] = numVal;
|
|
}
|
|
}
|
|
renderSections();
|
|
}
|
|
|
|
function renderToleranceInput(sectionId, itemId, tol) {
|
|
const tolType = tol?.type || '';
|
|
const selectHtml = `<select onchange="updateToleranceProp('${sectionId}', '${itemId}', 'type', this.value)"
|
|
class="w-full px-1 py-0.5 border border-gray-200 rounded text-xs mb-0.5">
|
|
<option value="" ${!tolType ? 'selected' : ''}>없음</option>
|
|
<option value="symmetric" ${tolType === 'symmetric' ? 'selected' : ''}>± 대칭</option>
|
|
<option value="asymmetric" ${tolType === 'asymmetric' ? 'selected' : ''}>+/- 비대칭</option>
|
|
<option value="range" ${tolType === 'range' ? 'selected' : ''}>~ 범위</option>
|
|
<option value="limit" ${tolType === 'limit' ? 'selected' : ''}>한계값</option>
|
|
</select>`;
|
|
|
|
let fieldsHtml = '';
|
|
if (tolType === 'symmetric') {
|
|
fieldsHtml = `<div class="flex items-center gap-0.5">
|
|
<span class="text-xs text-gray-500">±</span>
|
|
<input type="number" step="any" value="${tol.value ?? ''}" placeholder="0.10"
|
|
onchange="updateToleranceProp('${sectionId}', '${itemId}', 'value', this.value)"
|
|
class="flex-1 min-w-0 px-1 py-0.5 border border-gray-200 rounded text-xs">
|
|
</div>`;
|
|
} else if (tolType === 'asymmetric') {
|
|
fieldsHtml = `<div class="flex items-center gap-0.5">
|
|
<span class="text-xs text-gray-500">+</span>
|
|
<input type="number" step="any" value="${tol.plus ?? ''}" placeholder="0.20"
|
|
onchange="updateToleranceProp('${sectionId}', '${itemId}', 'plus', this.value)"
|
|
class="flex-1 min-w-0 px-1 py-0.5 border border-gray-200 rounded text-xs">
|
|
<span class="text-xs text-gray-500">-</span>
|
|
<input type="number" step="any" value="${tol.minus ?? ''}" placeholder="0.10"
|
|
onchange="updateToleranceProp('${sectionId}', '${itemId}', 'minus', this.value)"
|
|
class="flex-1 min-w-0 px-1 py-0.5 border border-gray-200 rounded text-xs">
|
|
</div>`;
|
|
} else if (tolType === 'range') {
|
|
fieldsHtml = `<div class="flex items-center gap-0.5">
|
|
<input type="number" step="any" value="${tol.min ?? ''}" placeholder="min"
|
|
onchange="updateToleranceProp('${sectionId}', '${itemId}', 'min', this.value)"
|
|
class="flex-1 min-w-0 px-1 py-0.5 border border-gray-200 rounded text-xs">
|
|
<span class="text-xs text-gray-400">~</span>
|
|
<input type="number" step="any" value="${tol.max ?? ''}" placeholder="max"
|
|
onchange="updateToleranceProp('${sectionId}', '${itemId}', 'max', this.value)"
|
|
class="flex-1 min-w-0 px-1 py-0.5 border border-gray-200 rounded text-xs">
|
|
</div>`;
|
|
} else if (tolType === 'limit') {
|
|
fieldsHtml = `<div class="flex items-center gap-0.5">
|
|
<select onchange="updateToleranceProp('${sectionId}', '${itemId}', 'op', this.value)"
|
|
class="px-0.5 py-0.5 border border-gray-200 rounded text-xs" style="width:2.5rem;flex-shrink:0">
|
|
<option value="lte" ${tol.op === 'lte' ? 'selected' : ''}>≤</option>
|
|
<option value="lt" ${tol.op === 'lt' ? 'selected' : ''}><</option>
|
|
<option value="gte" ${tol.op === 'gte' ? 'selected' : ''}>≥</option>
|
|
<option value="gt" ${tol.op === 'gt' ? 'selected' : ''}>></option>
|
|
</select>
|
|
<input type="number" step="any" value="${tol.value ?? ''}" placeholder="0.05"
|
|
onchange="updateToleranceProp('${sectionId}', '${itemId}', 'value', this.value)"
|
|
class="flex-1 min-w-0 px-1 py-0.5 border border-gray-200 rounded text-xs">
|
|
</div>`;
|
|
}
|
|
|
|
return selectHtml + fieldsHtml;
|
|
}
|
|
|
|
function formatTolerance(tol) {
|
|
if (!tol || !tol.type) return '-';
|
|
switch (tol.type) {
|
|
case 'symmetric':
|
|
return tol.value != null ? `\u00B1${tol.value}` : '-';
|
|
case 'asymmetric':
|
|
return (tol.plus != null || tol.minus != null)
|
|
? `+${tol.plus ?? 0} / -${tol.minus ?? 0}` : '-';
|
|
case 'range':
|
|
return (tol.min != null || tol.max != null)
|
|
? `${tol.min ?? ''} ~ ${tol.max ?? ''}` : '-';
|
|
case 'limit': {
|
|
const opSymbol = { lte: '\u2264', lt: '<', gte: '\u2265', gt: '>' };
|
|
return tol.value != null ? `${opSymbol[tol.op] || '\u2264'}${tol.value}` : '-';
|
|
}
|
|
default: return '-';
|
|
}
|
|
}
|
|
|
|
function updateStandardCriteria(sectionId, itemId, field, value) {
|
|
const section = templateState.sections.find(s => s.id == sectionId);
|
|
if (!section) return;
|
|
const item = section.items.find(i => i.id == itemId);
|
|
if (!item) return;
|
|
if (!item.standard_criteria) item.standard_criteria = {};
|
|
if (field === 'min' || field === 'max') {
|
|
item.standard_criteria[field] = value !== '' ? parseFloat(value) : null;
|
|
// 기본 연산자 자동 설정
|
|
if (field === 'min' && !item.standard_criteria.min_op) item.standard_criteria.min_op = 'gte';
|
|
if (field === 'max' && !item.standard_criteria.max_op) item.standard_criteria.max_op = 'lte';
|
|
} else {
|
|
item.standard_criteria[field] = value;
|
|
}
|
|
// min/max 둘 다 비어있으면 null로 초기화
|
|
const c = item.standard_criteria;
|
|
if (c.min == null && c.max == null) {
|
|
item.standard_criteria = null;
|
|
}
|
|
}
|
|
|
|
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="text-sm" style="table-layout:fixed;width:1035px">
|
|
<thead class="bg-gray-100">
|
|
<tr>
|
|
<th class="px-2 py-2 text-left text-xs font-medium text-gray-500" style="width:65px">구분</th>
|
|
<th class="px-2 py-2 text-left text-xs font-medium text-gray-500" style="width:130px">검사항목</th>
|
|
<th class="px-2 py-2 text-left text-xs font-medium text-gray-500" style="width:280px">검사기준</th>
|
|
<th class="px-2 py-2 text-left text-xs font-medium text-gray-500" style="width:120px">공차/범위</th>
|
|
<th class="px-2 py-2 text-left text-xs font-medium text-gray-500" style="width:110px">검사방식</th>
|
|
<th class="px-2 py-2 text-left text-xs font-medium text-gray-500" style="width:100px">측정유형</th>
|
|
<th class="px-2 py-2 text-left text-xs font-medium text-gray-500" style="width:120px">검사주기</th>
|
|
<th class="px-2 py-2 text-left text-xs font-medium text-gray-500" style="width:80px">관련규정</th>
|
|
<th class="px-2 py-2" style="width:30px"></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" style="overflow:hidden">
|
|
<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 mb-1">
|
|
<div style="display:grid;grid-template-columns:1fr auto 8px 1fr auto;align-items:center;gap:2px">
|
|
<input type="number" step="any" value="${item.standard_criteria?.min ?? ''}" placeholder="min"
|
|
onchange="updateStandardCriteria('${section.id}', '${item.id}', 'min', this.value)"
|
|
class="px-1 py-0.5 border border-gray-200 rounded text-xs text-center" style="min-width:0;width:100%">
|
|
<select onchange="updateStandardCriteria('${section.id}', '${item.id}', 'min_op', this.value)"
|
|
class="px-0 py-0.5 border border-gray-200 rounded text-xs">
|
|
<option value="gte" ${(item.standard_criteria?.min_op || 'gte') === 'gte' ? 'selected' : ''}>이상</option>
|
|
<option value="gt" ${item.standard_criteria?.min_op === 'gt' ? 'selected' : ''}>초과</option>
|
|
</select>
|
|
<span class="text-xs text-gray-400 text-center">~</span>
|
|
<input type="number" step="any" value="${item.standard_criteria?.max ?? ''}" placeholder="max"
|
|
onchange="updateStandardCriteria('${section.id}', '${item.id}', 'max', this.value)"
|
|
class="px-1 py-0.5 border border-gray-200 rounded text-xs text-center" style="min-width:0;width:100%">
|
|
<select onchange="updateStandardCriteria('${section.id}', '${item.id}', 'max_op', this.value)"
|
|
class="px-0 py-0.5 border border-gray-200 rounded text-xs">
|
|
<option value="lte" ${(item.standard_criteria?.max_op || 'lte') === 'lte' ? 'selected' : ''}>이하</option>
|
|
<option value="lt" ${item.standard_criteria?.max_op === 'lt' ? 'selected' : ''}>미만</option>
|
|
</select>
|
|
</div>
|
|
</td>
|
|
<td class="px-1 py-1" style="overflow:hidden">
|
|
${renderToleranceInput(section.id, item.id, item.tolerance)}
|
|
</td>
|
|
<td class="px-1 py-1">
|
|
<select onchange="onMethodChange('${section.id}', '${item.id}', this.value)"
|
|
class="w-full px-1 py-1 border border-gray-200 rounded text-xs">
|
|
<option value="">선택</option>
|
|
${inspectionMethods.map(m => `<option value="${m.code}" ${item.method === m.code ? 'selected' : ''}>${escapeHtml(m.name)}</option>`).join('')}
|
|
</select>
|
|
</td>
|
|
<td class="px-1 py-1">
|
|
<select onchange="updateSectionItem('${section.id}', '${item.id}', 'measurement_type', this.value)"
|
|
class="w-full px-1 py-1 border border-gray-200 rounded text-xs">
|
|
<option value="">선택</option>
|
|
${MEASUREMENT_TYPES.map(mt => `<option value="${mt.code}" ${item.measurement_type === mt.code ? 'selected' : ''}>${escapeHtml(mt.name)}</option>`).join('')}
|
|
</select>
|
|
</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 mb-1">
|
|
<div class="flex items-center gap-0.5">
|
|
<span class="text-xs text-gray-500">n=</span>
|
|
<input type="number" value="${item.frequency_n ?? ''}" placeholder="" min="1"
|
|
onchange="updateSectionItem('${section.id}', '${item.id}', 'frequency_n', this.value ? parseInt(this.value) : null)"
|
|
class="w-10 px-1 py-0.5 border border-gray-200 rounded text-xs text-center">
|
|
<span style="width:10px;"></span>
|
|
<span class="text-xs text-gray-500">c=</span>
|
|
<input type="number" value="${item.frequency_c ?? ''}" placeholder="" min="0"
|
|
onchange="updateSectionItem('${section.id}', '${item.id}', 'frequency_c', this.value ? parseInt(this.value) : null)"
|
|
class="w-10 px-1 py-0.5 border border-gray-200 rounded text-xs text-center">
|
|
</div>
|
|
</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="p-3 bg-gray-50 rounded-lg cursor-move" data-column-id="${col.id}">
|
|
<div class="flex items-center gap-3">
|
|
<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="handleColumnTypeChange('${col.id}', 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>
|
|
<option value="complex" ${col.column_type === 'complex' ? '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>
|
|
${col.column_type === 'complex' ? `
|
|
<div class="ml-10 mt-2 p-2 bg-white rounded border border-gray-200">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<span class="text-xs font-medium text-gray-600">하위 라벨:</span>
|
|
<button onclick="addSubLabel('${col.id}')" class="text-blue-600 hover:text-blue-800 text-xs">+ 추가</button>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
${(col.sub_labels || []).map((label, si) => `
|
|
<div class="flex items-center gap-1 bg-blue-50 rounded px-2 py-1">
|
|
<input type="text" value="${escapeHtml(label)}" placeholder="${si + 1}"
|
|
onchange="updateSubLabel('${col.id}', ${si}, this.value)"
|
|
class="w-16 px-1 py-0.5 border border-blue-200 rounded text-xs bg-white text-center">
|
|
<button onclick="removeSubLabel('${col.id}', ${si})" class="text-red-400 hover:text-red-600">
|
|
<svg class="w-3 h-3" 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>
|
|
`).join('')}
|
|
${(!col.sub_labels || col.sub_labels.length === 0) ? '<span class="text-xs text-gray-400">하위 라벨이 없습니다.</span>' : ''}
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// ===== 컬럼 타입 변경 처리 =====
|
|
function handleColumnTypeChange(id, value) {
|
|
const column = templateState.columns.find(c => c.id ==id);
|
|
if (column) {
|
|
column.column_type = value;
|
|
if (value === 'complex' && !column.sub_labels) {
|
|
column.sub_labels = ['1', '2', '3', '4', '5'];
|
|
}
|
|
if (value !== 'complex') {
|
|
column.sub_labels = null;
|
|
}
|
|
renderColumns();
|
|
}
|
|
}
|
|
|
|
function addSubLabel(colId) {
|
|
const column = templateState.columns.find(c => c.id ==colId);
|
|
if (column) {
|
|
if (!column.sub_labels) column.sub_labels = [];
|
|
column.sub_labels.push('');
|
|
renderColumns();
|
|
// 마지막 input에 포커스
|
|
setTimeout(() => {
|
|
const colEl = document.querySelector(`[data-column-id="${colId}"]`);
|
|
if (colEl) {
|
|
const inputs = colEl.querySelectorAll('.bg-blue-50 input');
|
|
if (inputs.length) inputs[inputs.length - 1].focus();
|
|
}
|
|
}, 50);
|
|
}
|
|
}
|
|
|
|
function updateSubLabel(colId, idx, value) {
|
|
const column = templateState.columns.find(c => c.id ==colId);
|
|
if (column && column.sub_labels) {
|
|
column.sub_labels[idx] = value;
|
|
}
|
|
}
|
|
|
|
function removeSubLabel(colId, idx) {
|
|
const column = templateState.columns.find(c => c.id ==colId);
|
|
if (column && column.sub_labels) {
|
|
column.sub_labels.splice(idx, 1);
|
|
renderColumns();
|
|
}
|
|
}
|
|
|
|
// ===== 저장 =====
|
|
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,
|
|
|
|
company_name: document.getElementById('company_name').value,
|
|
footer_remark_label: document.getElementById('footer_remark_label').value,
|
|
footer_judgement_label: document.getElementById('footer_judgement_label').value,
|
|
footer_judgement_options: templateState.footer_judgement_options.filter(o => o.trim() !== ''),
|
|
is_active: document.getElementById('is_active').checked,
|
|
linked_item_ids: templateState.linked_item_ids,
|
|
linked_process_id: templateState.linked_process_id,
|
|
approval_lines: templateState.approval_lines,
|
|
basic_fields: templateState.basic_fields,
|
|
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');
|
|
});
|
|
}
|
|
|
|
// ===== 미리보기 (공통 buildDocumentPreviewHtml 사용) =====
|
|
function openPreviewModal() {
|
|
const getMethodName = (code) => {
|
|
const m = inspectionMethods.find(im => im.code === code);
|
|
return m ? m.name : (code || '-');
|
|
};
|
|
|
|
const data = {
|
|
title: document.getElementById('title').value,
|
|
company_name: document.getElementById('company_name').value,
|
|
footer_remark_label: document.getElementById('footer_remark_label').value,
|
|
footer_judgement_label: document.getElementById('footer_judgement_label').value,
|
|
footer_judgement_options: templateState.footer_judgement_options,
|
|
approval_lines: templateState.approval_lines,
|
|
basic_fields: templateState.basic_fields,
|
|
sections: templateState.sections,
|
|
columns: templateState.columns,
|
|
methodResolver: getMethodName
|
|
};
|
|
|
|
const content = document.getElementById('preview-content');
|
|
content.innerHTML = buildDocumentPreviewHtml(data);
|
|
document.getElementById('preview-modal').classList.remove('hidden');
|
|
}
|
|
|
|
// ===== 이미지 업로드 =====
|
|
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 basicFieldsContainer = document.getElementById('basic-fields-container');
|
|
if (basicFieldsContainer && typeof Sortable !== 'undefined') {
|
|
new Sortable(basicFieldsContainer, {
|
|
animation: 150,
|
|
onEnd: function(evt) {
|
|
const newOrder = [];
|
|
basicFieldsContainer.querySelectorAll('[data-bf-id]').forEach((el) => {
|
|
const id = el.dataset.bfId;
|
|
const field = templateState.basic_fields.find(f => f.id ==id);
|
|
if (field) newOrder.push(field);
|
|
});
|
|
templateState.basic_fields = 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 originalRenderBasicFields = renderBasicFields;
|
|
renderBasicFields = function() {
|
|
originalRenderBasicFields();
|
|
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);
|
|
});
|
|
|
|
// ===== 검색 드롭다운 닫기 =====
|
|
document.addEventListener('click', function(e) {
|
|
// 품목 검색 결과 닫기
|
|
if (!e.target.closest('#linked-items-section')) {
|
|
const ir = document.getElementById('item-search-results');
|
|
if (ir) ir.classList.add('hidden');
|
|
}
|
|
// 공정 검색 결과 닫기
|
|
if (!e.target.closest('#linked-process-section')) {
|
|
const pr = document.getElementById('process-search-results');
|
|
if (pr) pr.classList.add('hidden');
|
|
}
|
|
// 사용자 검색 결과 닫기
|
|
if (!e.target.closest('[id^="user-search-"]') && !e.target.closest('[id^="user-results-"]')) {
|
|
document.querySelectorAll('[id^="user-results-"]').forEach(el => el.classList.add('hidden'));
|
|
}
|
|
});
|
|
|
|
// ===== 유틸리티 =====
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function formatStandard(item) {
|
|
const c = item.standard_criteria;
|
|
if (c && (c.min != null || c.max != null)) {
|
|
const opLabel = { gte: '이상', gt: '초과', lte: '이하', lt: '미만' };
|
|
const parts = [];
|
|
if (c.min != null) parts.push(`${c.min} ${opLabel[c.min_op || 'gte']}`);
|
|
if (c.max != null) parts.push(`${c.max} ${opLabel[c.max_op || 'lte']}`);
|
|
return parts.join(' ~ ');
|
|
}
|
|
let std = item.standard || '-';
|
|
const tolStr = formatTolerance(item.tolerance);
|
|
if (tolStr !== '-') std += ' (' + tolStr + ')';
|
|
return escapeHtml(std);
|
|
}
|
|
|
|
function formatFrequency(item) {
|
|
const parts = [];
|
|
if (item.frequency_n != null && item.frequency_n !== '') {
|
|
let nc = `n=${item.frequency_n}`;
|
|
if (item.frequency_c != null && item.frequency_c !== '') {
|
|
nc += `, c=${item.frequency_c}`;
|
|
}
|
|
parts.push(nc);
|
|
}
|
|
if (item.frequency) {
|
|
parts.push(escapeHtml(item.frequency));
|
|
}
|
|
return parts.length > 0 ? parts.join(' / ') : '-';
|
|
}
|
|
</script>
|
|
|
|
<!-- SortableJS CDN -->
|
|
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
|
@endpush
|