feat:검사 기준서 동적 필드 + 자동 하이라이트 + 미리보기 개선
- 문서 작성 시 연결 품목 규격(두께/너비/길이) 기반 자동 하이라이트 - 미리보기에서 field_values 동적 필드 데이터 정상 표시 - DocumentTemplateController에서 field_values 직렬화 추가 - DocumentController에 linkedItemSpecs 조회 로직 추가 - Item 모델 attributes JSON cast 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,10 +20,17 @@ public function index(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
// 슈퍼관리자 휴지통 조회
|
||||
$showTrashed = $request->filled('trashed') && auth()->user()?->is_super_admin;
|
||||
|
||||
$query = Document::with(['template', 'creator'])
|
||||
->where('tenant_id', $tenantId)
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
if ($showTrashed) {
|
||||
$query->onlyTrashed();
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
@@ -207,14 +214,7 @@ public function destroy(int $id): JsonResponse
|
||||
|
||||
$document = Document::where('tenant_id', $tenantId)->findOrFail($id);
|
||||
|
||||
// 작성중 상태에서만 삭제 가능
|
||||
if ($document->status !== Document::STATUS_DRAFT) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '작성중 상태의 문서만 삭제할 수 있습니다.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$document->update(['deleted_by' => auth()->id()]);
|
||||
$document->delete();
|
||||
|
||||
return response()->json([
|
||||
@@ -223,6 +223,57 @@ public function destroy(int $id): JsonResponse
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 영구삭제 (슈퍼관리자 전용)
|
||||
*/
|
||||
public function forceDestroy(int $id): JsonResponse
|
||||
{
|
||||
if (!auth()->user()?->is_super_admin) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '슈퍼관리자만 영구 삭제할 수 있습니다.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$document = Document::withTrashed()->where('tenant_id', $tenantId)->findOrFail($id);
|
||||
|
||||
// 관련 데이터도 영구삭제
|
||||
$document->data()->delete();
|
||||
$document->approvals()->delete();
|
||||
$document->forceDelete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '문서가 영구 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제된 문서 복원 (슈퍼관리자 전용)
|
||||
*/
|
||||
public function restore(int $id): JsonResponse
|
||||
{
|
||||
if (!auth()->user()?->is_super_admin) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '슈퍼관리자만 복원할 수 있습니다.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$document = Document::onlyTrashed()->where('tenant_id', $tenantId)->findOrFail($id);
|
||||
$document->update(['deleted_by' => null]);
|
||||
$document->restore();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '문서가 복원되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 제출 (DRAFT → PENDING)
|
||||
*/
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
|
||||
use App\Models\Documents\Document;
|
||||
use App\Models\DocumentTemplate;
|
||||
use App\Models\Items\Item;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DocumentController extends Controller
|
||||
@@ -63,6 +65,7 @@ public function create(Request $request): View|Response
|
||||
'template' => $template,
|
||||
'templates' => $templates,
|
||||
'isCreate' => true,
|
||||
'linkedItemSpecs' => $template ? $this->getLinkedItemSpecs($template) : [],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -102,6 +105,7 @@ public function edit(int $id): View|Response
|
||||
'template' => $document->template,
|
||||
'templates' => $templates,
|
||||
'isCreate' => false,
|
||||
'linkedItemSpecs' => $this->getLinkedItemSpecs($document->template),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -154,4 +158,42 @@ public function show(int $id): View
|
||||
'document' => $document,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿에 연결된 품목들의 규격 정보 (thickness, width, length) 조회
|
||||
*/
|
||||
private function getLinkedItemSpecs(DocumentTemplate $template): array
|
||||
{
|
||||
$specs = [];
|
||||
|
||||
if (! $template->relationLoaded('links')) {
|
||||
$template->load('links.linkValues');
|
||||
}
|
||||
|
||||
foreach ($template->links as $link) {
|
||||
if ($link->source_table !== 'items') {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($link->linkValues as $lv) {
|
||||
$item = Item::find($lv->linkable_id);
|
||||
if (! $item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$attrs = $item->attributes ?? [];
|
||||
if (isset($attrs['thickness']) || isset($attrs['width']) || isset($attrs['length'])) {
|
||||
$specs[] = [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'thickness' => $attrs['thickness'] ?? null,
|
||||
'width' => $attrs['width'] ?? null,
|
||||
'length' => $attrs['length'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $specs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,19 +141,22 @@ private function prepareTemplateData(DocumentTemplate $template): array
|
||||
'title' => $s->title,
|
||||
'image_path' => $s->image_path,
|
||||
'items' => $s->items->map(function ($i) {
|
||||
$fv = $i->field_values ?? [];
|
||||
|
||||
return [
|
||||
'id' => $i->id,
|
||||
'category' => $i->category,
|
||||
'item' => $i->item,
|
||||
'standard' => $i->standard,
|
||||
'tolerance' => $i->tolerance,
|
||||
'standard_criteria' => $i->standard_criteria,
|
||||
'method' => $i->method,
|
||||
'measurement_type' => $i->measurement_type,
|
||||
'frequency_n' => $i->frequency_n,
|
||||
'frequency_c' => $i->frequency_c,
|
||||
'frequency' => $i->frequency,
|
||||
'regulation' => $i->regulation,
|
||||
'category' => $fv['category'] ?? $i->category,
|
||||
'item' => $fv['item'] ?? $i->item,
|
||||
'standard' => $fv['standard'] ?? $i->standard,
|
||||
'tolerance' => $fv['tolerance'] ?? $i->tolerance,
|
||||
'standard_criteria' => $fv['standard_criteria'] ?? $i->standard_criteria,
|
||||
'method' => $fv['method'] ?? $i->method,
|
||||
'measurement_type' => $fv['measurement_type'] ?? $i->measurement_type,
|
||||
'frequency_n' => $fv['frequency_n'] ?? $i->frequency_n,
|
||||
'frequency_c' => $fv['frequency_c'] ?? $i->frequency_c,
|
||||
'frequency' => $fv['frequency'] ?? $i->frequency,
|
||||
'regulation' => $fv['regulation'] ?? $i->regulation,
|
||||
'field_values' => $fv,
|
||||
];
|
||||
})->toArray(),
|
||||
];
|
||||
|
||||
@@ -21,5 +21,6 @@ class Item extends Model
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'attributes' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -67,8 +67,8 @@ class="tab-btn px-6 py-4 text-sm font-medium border-b-2 border-transparent text-
|
||||
|
||||
<!-- 기본정보 + 결재라인 탭 -->
|
||||
<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">
|
||||
<!-- Row 1: 양식명 + 문서제목 + 분류 + 회사명 (4:4:1:1) -->
|
||||
<div class="grid gap-3 mb-4 items-end" style="grid-template-columns:4fr 4fr 1fr 1fr">
|
||||
<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="예: 최종검사 성적서"
|
||||
@@ -79,15 +79,11 @@ class="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outlin
|
||||
<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>
|
||||
class="w-full px-2 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>
|
||||
@@ -96,38 +92,59 @@ class="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outlin
|
||||
<option value="{{ $cat }}">{{ $cat }}</option>
|
||||
@endif
|
||||
@endforeach
|
||||
<option value="__custom__">직접 입력...</option>
|
||||
<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"
|
||||
<input type="text" id="category-custom" placeholder="분류명"
|
||||
class="w-full px-2 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 class="flex items-center justify-between mb-1">
|
||||
<label class="text-xs font-medium text-gray-500">회사명</label>
|
||||
<label class="flex items-center gap-1 cursor-pointer">
|
||||
<input type="checkbox" id="is_active" checked class="w-3.5 h-3.5 text-blue-600 rounded focus:ring-blue-500">
|
||||
<span class="text-xs text-gray-500">활성</span>
|
||||
</label>
|
||||
</div>
|
||||
<input type="text" id="company_name" placeholder="케이디산업"
|
||||
class="w-full px-2 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.5: 동적 연결 설정 -->
|
||||
<!-- Row 2: 연결 설정 (소스테이블 1개 + 체크박스 다건 검색) -->
|
||||
<div id="dynamic-links-section" class="mb-4">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<label class="block text-xs font-medium text-gray-500">연결 설정</label>
|
||||
<button type="button" onclick="addTemplateLink()" class="text-blue-600 hover:text-blue-800 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 class="flex items-center gap-3 mb-2">
|
||||
<label class="text-xs font-medium text-gray-500 whitespace-nowrap">연결 설정</label>
|
||||
<select id="link-source-table" onchange="onLinkSourceTableChange(this.value)"
|
||||
class="w-48 px-2 py-1 border border-gray-300 rounded text-xs focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">소스 테이블 선택</option>
|
||||
</select>
|
||||
<span class="text-xs text-gray-400">또는</span>
|
||||
<input type="text" id="link-source-table-custom" placeholder="테이블명 직접 입력"
|
||||
onchange="onLinkSourceTableCustom(this.value)"
|
||||
class="w-36 px-2 py-1 border border-gray-300 rounded text-xs focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<select id="link-type" onchange="onLinkTypeChange(this.value)"
|
||||
class="w-20 px-1 py-1 border border-gray-300 rounded text-xs">
|
||||
<option value="multiple">다중</option>
|
||||
<option value="single">단일</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="template-links-container" class="space-y-3">
|
||||
<!-- 동적으로 렌더링 -->
|
||||
<!-- 검색 + 체크박스 결과 패널 -->
|
||||
<div id="link-search-panel" class="hidden">
|
||||
<div class="flex gap-2 mb-2">
|
||||
<input type="text" id="link-search-input" placeholder="검색어 입력..."
|
||||
oninput="searchLinkValues('__main__', this.value)"
|
||||
class="flex-1 px-2 py-1.5 border border-gray-300 rounded text-xs focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<button type="button" onclick="addCheckedLinkValues()" id="btn-add-checked"
|
||||
class="px-3 py-1.5 bg-blue-600 text-white rounded text-xs hover:bg-blue-700 hidden">
|
||||
선택 추가
|
||||
</button>
|
||||
</div>
|
||||
<!-- 검색 결과 체크박스 리스트 -->
|
||||
<div id="link-search-results" class="border border-gray-200 rounded max-h-48 overflow-y-auto hidden"></div>
|
||||
<!-- 연결된 항목 태그 -->
|
||||
<div class="mt-2 flex flex-wrap gap-1" id="link-value-tags"></div>
|
||||
</div>
|
||||
<p id="template-links-empty" class="text-gray-400 text-center py-2 text-xs hidden">연결 설정이 없습니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: Footer - 비고라벨 + 판정라벨 -->
|
||||
@@ -357,7 +374,7 @@ function initBasicInfo() {
|
||||
onCategoryChange(categorySelect.value, true);
|
||||
|
||||
// 동적 연결 UI 렌더링
|
||||
renderTemplateLinks();
|
||||
populateLinkSourceTable();
|
||||
// 프리셋 select 초기화
|
||||
initPresetSelect();
|
||||
// 필드 설정 렌더링
|
||||
@@ -399,7 +416,7 @@ function onCategoryChange(value, isInit = false) {
|
||||
}
|
||||
|
||||
// 연결 UI 렌더링
|
||||
renderTemplateLinks();
|
||||
populateLinkSourceTable();
|
||||
}
|
||||
|
||||
// ===== 결재라인 단계명 변경 핸들러 =====
|
||||
@@ -807,7 +824,7 @@ function updateToleranceProp(sectionId, itemId, prop, value) {
|
||||
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">
|
||||
class="px-1 py-0.5 border border-gray-200 rounded text-xs mb-0.5" style="width:80px">
|
||||
<option value="" ${!tolType ? 'selected' : ''}>없음</option>
|
||||
<option value="symmetric" ${tolType === 'symmetric' ? 'selected' : ''}>± 대칭</option>
|
||||
<option value="asymmetric" ${tolType === 'asymmetric' ? 'selected' : ''}>+/- 비대칭</option>
|
||||
@@ -821,33 +838,33 @@ class="w-full px-1 py-0.5 border border-gray-200 rounded text-xs mb-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">
|
||||
class="flex-1 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">
|
||||
class="px-1 py-0.5 border border-gray-200 rounded text-xs" style="width:36px">
|
||||
<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">
|
||||
class="px-1 py-0.5 border border-gray-200 rounded text-xs" style="width:36px">
|
||||
</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">
|
||||
class="px-1 py-0.5 border border-gray-200 rounded text-xs" style="width:36px">
|
||||
<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">
|
||||
class="px-1 py-0.5 border border-gray-200 rounded text-xs" style="width:36px">
|
||||
</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">
|
||||
class="py-0.5 border border-gray-200 rounded text-xs" style="width:28px;padding:2px 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>
|
||||
@@ -855,7 +872,7 @@ class="px-0.5 py-0.5 border border-gray-200 rounded text-xs" style="width:2.5rem
|
||||
</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">
|
||||
class="px-1 py-0.5 border border-gray-200 rounded text-xs" style="width:48px">
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -957,11 +974,11 @@ class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm font-medium">
|
||||
<div class="p-4">
|
||||
${section.items.length > 0 ? `
|
||||
<div class="overflow-x-auto">
|
||||
<table class="text-sm" style="table-layout:fixed;width:${calcTableWidth()}px">
|
||||
<table class="w-full text-sm" style="min-width:${calcTableWidth()}px">
|
||||
<thead class="bg-gray-100">
|
||||
<tr>
|
||||
${templateState.section_fields.map(f => `
|
||||
<th class="px-2 py-2 text-left text-xs font-medium text-gray-500" style="width:${f.width || '100px'}">${escapeHtml(f.label)}</th>
|
||||
<th class="px-2 py-2 text-left text-xs font-medium text-gray-500" style="min-width:${f.width || '100px'}">${escapeHtml(f.label)}</th>
|
||||
`).join('')}
|
||||
<th class="px-2 py-2" style="width:30px"></th>
|
||||
</tr>
|
||||
@@ -970,7 +987,7 @@ class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm font-medium">
|
||||
${section.items.map(item => `
|
||||
<tr class="border-t" data-item-id="${item.id}">
|
||||
${templateState.section_fields.map(f => `
|
||||
<td class="px-1 py-1" style="overflow:hidden">
|
||||
<td class="px-1 py-1">
|
||||
${renderDynamicFieldInput(f, section.id, item)}
|
||||
</td>
|
||||
`).join('')}
|
||||
@@ -1151,7 +1168,7 @@ function saveTemplate() {
|
||||
sections: templateState.sections,
|
||||
columns: templateState.columns,
|
||||
section_fields: templateState.section_fields,
|
||||
template_links: templateState.template_links
|
||||
template_links: templateState.template_links.filter(l => l.source_table)
|
||||
};
|
||||
|
||||
const url = templateState.id
|
||||
@@ -1193,6 +1210,22 @@ function openPreviewModal() {
|
||||
return m ? m.name : (code || '-');
|
||||
};
|
||||
|
||||
// field_values를 top-level 프로퍼티로 풀어서 미리보기 호환
|
||||
const normalizedSections = templateState.sections.map(s => ({
|
||||
...s,
|
||||
items: (s.items || []).map(item => {
|
||||
const merged = { ...item };
|
||||
if (item.field_values) {
|
||||
Object.entries(item.field_values).forEach(([k, v]) => {
|
||||
if (v !== null && v !== undefined) {
|
||||
merged[k] = v;
|
||||
}
|
||||
});
|
||||
}
|
||||
return merged;
|
||||
})
|
||||
}));
|
||||
|
||||
const data = {
|
||||
title: document.getElementById('title').value,
|
||||
company_name: document.getElementById('company_name').value,
|
||||
@@ -1201,7 +1234,7 @@ function openPreviewModal() {
|
||||
footer_judgement_options: templateState.footer_judgement_options,
|
||||
approval_lines: templateState.approval_lines,
|
||||
basic_fields: templateState.basic_fields,
|
||||
sections: templateState.sections,
|
||||
sections: normalizedSections,
|
||||
columns: templateState.columns,
|
||||
methodResolver: getMethodName
|
||||
};
|
||||
@@ -1437,7 +1470,7 @@ function applyPresetData(preset) {
|
||||
}));
|
||||
}
|
||||
renderSectionFields();
|
||||
renderTemplateLinks();
|
||||
populateLinkSourceTable();
|
||||
renderSections();
|
||||
}
|
||||
|
||||
@@ -1508,104 +1541,32 @@ class="w-3 h-3">
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ===== 동적 연결 설정 =====
|
||||
function addTemplateLink() {
|
||||
templateState.template_links.push({
|
||||
id: generateId(),
|
||||
link_key: '',
|
||||
label: '',
|
||||
link_type: 'single',
|
||||
source_table: '',
|
||||
search_params: null,
|
||||
display_fields: null,
|
||||
is_required: false,
|
||||
values: []
|
||||
});
|
||||
renderTemplateLinks();
|
||||
}
|
||||
|
||||
function removeTemplateLink(id) {
|
||||
templateState.template_links = templateState.template_links.filter(l => l.id != id);
|
||||
renderTemplateLinks();
|
||||
}
|
||||
|
||||
function updateTemplateLink(id, key, value) {
|
||||
const link = templateState.template_links.find(l => l.id == id);
|
||||
if (link) link[key] = value;
|
||||
}
|
||||
|
||||
function renderTemplateLinks() {
|
||||
const container = document.getElementById('template-links-container');
|
||||
const emptyMsg = document.getElementById('template-links-empty');
|
||||
if (!container) return;
|
||||
|
||||
if (templateState.template_links.length === 0) {
|
||||
container.innerHTML = '';
|
||||
if (emptyMsg) emptyMsg.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
if (emptyMsg) emptyMsg.classList.add('hidden');
|
||||
|
||||
container.innerHTML = templateState.template_links.map((link, idx) => `
|
||||
<div class="p-3 bg-gray-50 rounded-lg border border-gray-200" data-link-id="${link.id}">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<input type="text" value="${escapeHtml(link.link_key)}" placeholder="키 (items, process)"
|
||||
onchange="updateTemplateLink('${link.id}', 'link_key', this.value)"
|
||||
class="w-28 px-2 py-1 border border-gray-300 rounded text-xs">
|
||||
<input type="text" value="${escapeHtml(link.label)}" placeholder="표시명"
|
||||
onchange="updateTemplateLink('${link.id}', 'label', this.value)"
|
||||
class="flex-1 px-2 py-1 border border-gray-300 rounded text-xs">
|
||||
<select onchange="updateTemplateLink('${link.id}', 'link_type', this.value)"
|
||||
class="w-20 px-1 py-1 border border-gray-300 rounded text-xs">
|
||||
<option value="single" ${link.link_type === 'single' ? 'selected' : ''}>단일</option>
|
||||
<option value="multiple" ${link.link_type === 'multiple' ? 'selected' : ''}>다중</option>
|
||||
</select>
|
||||
<button onclick="removeTemplateLink('${link.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>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<select onchange="onSourceTableChange('${link.id}', this.value)"
|
||||
class="w-36 px-1 py-1 border border-gray-300 rounded text-xs source-table-select" data-link-id="${link.id}">
|
||||
<option value="">소스 테이블 선택</option>
|
||||
</select>
|
||||
<span class="text-xs text-gray-400">또는</span>
|
||||
<input type="text" value="${!link.source_table ? '' : ''}" placeholder="직접 입력 (테이블명)"
|
||||
onchange="updateTemplateLink('${link.id}', 'source_table', this.value)"
|
||||
class="flex-1 px-2 py-1 border border-gray-300 rounded text-xs source-table-input" data-link-id="${link.id}"
|
||||
${link.source_table && sourceTableOptions.find(t => t.key === link.source_table) ? 'disabled' : `value="${escapeHtml(link.source_table || '')}"`}>
|
||||
</div>
|
||||
<!-- 연결 값 검색 (datalist 패턴) -->
|
||||
${link.source_table ? `
|
||||
<div class="mt-2">
|
||||
<div class="relative">
|
||||
<input type="text" placeholder="검색하여 연결..." id="link-search-${link.id}"
|
||||
oninput="searchLinkValues('${link.id}', this.value)"
|
||||
class="w-full px-2 py-1 border border-gray-300 rounded text-xs">
|
||||
<div id="link-results-${link.id}" class="absolute z-10 w-full mt-1 bg-white border border-gray-200 rounded shadow-lg max-h-40 overflow-y-auto hidden"></div>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap gap-1" id="link-values-${link.id}">
|
||||
${(link.values || []).map(v => `
|
||||
<span class="inline-flex items-center bg-blue-100 text-blue-700 text-xs px-2 py-0.5 rounded">
|
||||
${escapeHtml(v.display_text || 'ID: ' + (v.linkable_id || v.id))}
|
||||
<button onclick="removeLinkValue('${link.id}', ${v.linkable_id || v.id})" class="ml-1 text-blue-400 hover:text-blue-600">×</button>
|
||||
</span>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// 소스 테이블 드롭다운 옵션 채우기
|
||||
populateSourceTableSelects();
|
||||
}
|
||||
|
||||
// ===== 소스 테이블 관련 함수 =====
|
||||
// ===== 연결 설정 (소스테이블 1개 + 체크박스 다건 검색) =====
|
||||
let sourceTableOptions = [];
|
||||
let linkSearchTimers = {};
|
||||
let linkSearchTimer = null;
|
||||
let checkedItems = new Map(); // id → {id, title, subtitle}
|
||||
|
||||
function getMainLink() {
|
||||
if (templateState.template_links.length === 0) return null;
|
||||
return templateState.template_links[0];
|
||||
}
|
||||
|
||||
function ensureMainLink() {
|
||||
if (templateState.template_links.length === 0) {
|
||||
templateState.template_links.push({
|
||||
id: generateId(),
|
||||
link_key: '',
|
||||
label: '',
|
||||
link_type: 'multiple',
|
||||
source_table: '',
|
||||
search_params: null,
|
||||
display_fields: null,
|
||||
is_required: false,
|
||||
values: []
|
||||
});
|
||||
}
|
||||
return templateState.template_links[0];
|
||||
}
|
||||
|
||||
async function loadSourceTableOptions() {
|
||||
try {
|
||||
@@ -1619,69 +1580,139 @@ class="w-full px-2 py-1 border border-gray-300 rounded text-xs">
|
||||
}
|
||||
}
|
||||
|
||||
function populateSourceTableSelects() {
|
||||
document.querySelectorAll('.source-table-select').forEach(select => {
|
||||
const linkId = select.dataset.linkId;
|
||||
const link = templateState.template_links.find(l => l.id == linkId);
|
||||
const currentValue = link ? link.source_table : '';
|
||||
function populateLinkSourceTable() {
|
||||
const select = document.getElementById('link-source-table');
|
||||
if (!select) return;
|
||||
|
||||
// 기존 옵션 유지하되 동적 옵션 추가
|
||||
let html = '<option value="">소스 테이블 선택</option>';
|
||||
sourceTableOptions.forEach(t => {
|
||||
html += `<option value="${t.key}" ${currentValue === t.key ? 'selected' : ''}>${escapeHtml(t.label || t.key)}</option>`;
|
||||
});
|
||||
select.innerHTML = html;
|
||||
const link = getMainLink();
|
||||
const currentValue = link ? link.source_table : '';
|
||||
|
||||
// 직접 입력 필드 상태 설정
|
||||
const input = select.closest('.flex').querySelector('.source-table-input');
|
||||
if (input) {
|
||||
if (currentValue && sourceTableOptions.find(t => t.key === currentValue)) {
|
||||
input.disabled = true;
|
||||
input.value = '';
|
||||
} else {
|
||||
input.disabled = false;
|
||||
input.value = currentValue || '';
|
||||
}
|
||||
}
|
||||
let html = '<option value="">소스 테이블 선택</option>';
|
||||
sourceTableOptions.forEach(t => {
|
||||
html += `<option value="${t.key}" ${currentValue === t.key ? 'selected' : ''}>${escapeHtml(t.label || t.key)}</option>`;
|
||||
});
|
||||
}
|
||||
select.innerHTML = html;
|
||||
|
||||
function onSourceTableChange(linkId, value) {
|
||||
const link = templateState.template_links.find(l => l.id == linkId);
|
||||
if (!link) return;
|
||||
|
||||
link.source_table = value;
|
||||
link.values = []; // 소스 테이블 변경 시 기존 연결 값 초기화
|
||||
|
||||
// 소스 테이블 메타 정보로 display_fields 자동 설정
|
||||
const tableInfo = sourceTableOptions.find(t => t.key === value);
|
||||
if (tableInfo) {
|
||||
link.display_fields = {
|
||||
title: tableInfo.title_field,
|
||||
subtitle: tableInfo.subtitle_field
|
||||
};
|
||||
// 직접 입력 필드 동기화
|
||||
const customInput = document.getElementById('link-source-table-custom');
|
||||
if (customInput) {
|
||||
const isKnown = currentValue && sourceTableOptions.find(t => t.key === currentValue);
|
||||
if (isKnown) {
|
||||
customInput.value = '';
|
||||
customInput.disabled = true;
|
||||
} else {
|
||||
customInput.disabled = false;
|
||||
customInput.value = currentValue || '';
|
||||
}
|
||||
}
|
||||
|
||||
renderTemplateLinks();
|
||||
// link_type 동기화
|
||||
const typeSelect = document.getElementById('link-type');
|
||||
if (typeSelect && link) {
|
||||
typeSelect.value = link.link_type || 'multiple';
|
||||
}
|
||||
|
||||
// 소스 테이블이 설정되어 있으면 검색 패널 표시 + 태그 렌더
|
||||
if (currentValue) {
|
||||
document.getElementById('link-search-panel')?.classList.remove('hidden');
|
||||
renderLinkValueTags();
|
||||
}
|
||||
}
|
||||
|
||||
function onLinkSourceTableChange(value) {
|
||||
const link = ensureMainLink();
|
||||
const panel = document.getElementById('link-search-panel');
|
||||
const customInput = document.getElementById('link-source-table-custom');
|
||||
|
||||
if (!value) {
|
||||
link.source_table = '';
|
||||
link.values = [];
|
||||
panel?.classList.add('hidden');
|
||||
if (customInput) { customInput.disabled = false; customInput.value = ''; }
|
||||
renderLinkValueTags();
|
||||
return;
|
||||
}
|
||||
|
||||
link.source_table = value;
|
||||
link.values = [];
|
||||
link.link_key = value;
|
||||
if (customInput) { customInput.disabled = true; customInput.value = ''; }
|
||||
|
||||
// 메타 정보 자동 설정
|
||||
const tableInfo = sourceTableOptions.find(t => t.key === value);
|
||||
if (tableInfo) {
|
||||
link.label = tableInfo.label || value;
|
||||
link.display_fields = { title: tableInfo.title_field, subtitle: tableInfo.subtitle_field };
|
||||
}
|
||||
|
||||
panel?.classList.remove('hidden');
|
||||
checkedItems.clear();
|
||||
renderLinkValueTags();
|
||||
|
||||
// 검색 초기화
|
||||
const searchInput = document.getElementById('link-search-input');
|
||||
if (searchInput) searchInput.value = '';
|
||||
document.getElementById('link-search-results')?.classList.add('hidden');
|
||||
}
|
||||
|
||||
function onLinkSourceTableCustom(value) {
|
||||
const link = ensureMainLink();
|
||||
const panel = document.getElementById('link-search-panel');
|
||||
const select = document.getElementById('link-source-table');
|
||||
|
||||
if (!value) {
|
||||
link.source_table = '';
|
||||
link.values = [];
|
||||
panel?.classList.add('hidden');
|
||||
if (select) select.value = '';
|
||||
renderLinkValueTags();
|
||||
return;
|
||||
}
|
||||
|
||||
link.source_table = value;
|
||||
link.values = [];
|
||||
link.link_key = value;
|
||||
link.label = value;
|
||||
if (select) select.value = '';
|
||||
|
||||
panel?.classList.remove('hidden');
|
||||
checkedItems.clear();
|
||||
renderLinkValueTags();
|
||||
|
||||
const searchInput = document.getElementById('link-search-input');
|
||||
if (searchInput) searchInput.value = '';
|
||||
document.getElementById('link-search-results')?.classList.add('hidden');
|
||||
}
|
||||
|
||||
function onLinkTypeChange(value) {
|
||||
const link = ensureMainLink();
|
||||
link.link_type = value;
|
||||
|
||||
// 단일로 변경 시 첫 번째만 유지
|
||||
if (value === 'single' && link.values.length > 1) {
|
||||
link.values = [link.values[0]];
|
||||
renderLinkValueTags();
|
||||
}
|
||||
}
|
||||
|
||||
async function searchLinkValues(linkId, query) {
|
||||
const link = templateState.template_links.find(l => l.id == linkId);
|
||||
const link = getMainLink();
|
||||
if (!link || !link.source_table) return;
|
||||
|
||||
clearTimeout(linkSearchTimers[linkId]);
|
||||
const resultsEl = document.getElementById(`link-results-${linkId}`);
|
||||
clearTimeout(linkSearchTimer);
|
||||
const resultsEl = document.getElementById('link-search-results');
|
||||
if (!resultsEl) return;
|
||||
|
||||
if (query.length < 1) {
|
||||
resultsEl.classList.add('hidden');
|
||||
document.getElementById('btn-add-checked')?.classList.add('hidden');
|
||||
checkedItems.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
linkSearchTimers[linkId] = setTimeout(async () => {
|
||||
linkSearchTimer = setTimeout(async () => {
|
||||
try {
|
||||
let url = `/api/admin/source-tables/${link.source_table}/search?q=${encodeURIComponent(query)}`;
|
||||
// search_params 추가
|
||||
if (link.search_params) {
|
||||
Object.entries(link.search_params).forEach(([k, v]) => {
|
||||
url += `&${k}=${encodeURIComponent(v)}`;
|
||||
@@ -1694,19 +1725,31 @@ function onSourceTableChange(linkId, value) {
|
||||
const subtitleField = json.meta.subtitle_field;
|
||||
const existingIds = (link.values || []).map(v => v.linkable_id || v.id);
|
||||
|
||||
resultsEl.innerHTML = json.data
|
||||
.filter(item => !existingIds.includes(item.id))
|
||||
.map(item => `
|
||||
<div class="px-3 py-2 hover:bg-blue-50 cursor-pointer text-xs border-b border-gray-100"
|
||||
onclick="selectLinkValue('${linkId}', ${item.id}, '${escapeHtml(item[titleField] || '')}', '${escapeHtml(item[subtitleField] || '')}')">
|
||||
<div class="font-medium">${escapeHtml(item[titleField] || '')}</div>
|
||||
${item[subtitleField] ? `<div class="text-gray-400">${escapeHtml(item[subtitleField] || '')}</div>` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
checkedItems.clear();
|
||||
resultsEl.innerHTML = json.data.map(item => {
|
||||
const isLinked = existingIds.includes(item.id);
|
||||
return `
|
||||
<label class="flex items-center gap-2 px-3 py-1.5 hover:bg-blue-50 cursor-pointer text-xs border-b border-gray-100 ${isLinked ? 'bg-gray-50 opacity-60' : ''}">
|
||||
<input type="checkbox" value="${item.id}"
|
||||
data-title="${escapeHtml(item[titleField] || '')}"
|
||||
data-subtitle="${escapeHtml(item[subtitleField] || '')}"
|
||||
onchange="onSearchCheckChange(this)"
|
||||
class="w-3.5 h-3.5 rounded text-blue-600"
|
||||
${isLinked ? 'disabled checked' : ''}>
|
||||
<div class="flex-1">
|
||||
<span class="font-medium">${escapeHtml(item[titleField] || '')}</span>
|
||||
${item[subtitleField] ? `<span class="text-gray-400 ml-1">${escapeHtml(item[subtitleField] || '')}</span>` : ''}
|
||||
</div>
|
||||
${isLinked ? '<span class="text-blue-500 text-[10px]">연결됨</span>' : ''}
|
||||
</label>
|
||||
`;
|
||||
}).join('');
|
||||
resultsEl.classList.remove('hidden');
|
||||
updateAddCheckedButton();
|
||||
} else {
|
||||
resultsEl.innerHTML = '<div class="px-3 py-2 text-xs text-gray-400">검색 결과 없음</div>';
|
||||
resultsEl.classList.remove('hidden');
|
||||
document.getElementById('btn-add-checked')?.classList.add('hidden');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('연결 검색 실패:', e);
|
||||
@@ -1714,58 +1757,85 @@ function onSourceTableChange(linkId, value) {
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function selectLinkValue(linkId, itemId, title, subtitle) {
|
||||
const link = templateState.template_links.find(l => l.id == linkId);
|
||||
if (!link) return;
|
||||
function onSearchCheckChange(checkbox) {
|
||||
const id = parseInt(checkbox.value);
|
||||
if (checkbox.checked) {
|
||||
checkedItems.set(id, {
|
||||
id: id,
|
||||
title: checkbox.dataset.title,
|
||||
subtitle: checkbox.dataset.subtitle
|
||||
});
|
||||
} else {
|
||||
checkedItems.delete(id);
|
||||
}
|
||||
updateAddCheckedButton();
|
||||
}
|
||||
|
||||
function updateAddCheckedButton() {
|
||||
const btn = document.getElementById('btn-add-checked');
|
||||
if (!btn) return;
|
||||
if (checkedItems.size > 0) {
|
||||
btn.classList.remove('hidden');
|
||||
btn.textContent = `선택 추가 (${checkedItems.size})`;
|
||||
} else {
|
||||
btn.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function addCheckedLinkValues() {
|
||||
const link = getMainLink();
|
||||
if (!link) return;
|
||||
if (!link.values) link.values = [];
|
||||
|
||||
// 단일 연결이면 기존 값 교체
|
||||
// 단일이면 마지막 체크만
|
||||
if (link.link_type === 'single') {
|
||||
link.values = [{ linkable_id: itemId, display_text: title + (subtitle ? ` (${subtitle})` : '') }];
|
||||
} else {
|
||||
// 중복 체크
|
||||
if (!link.values.find(v => (v.linkable_id || v.id) === itemId)) {
|
||||
link.values.push({ linkable_id: itemId, display_text: title + (subtitle ? ` (${subtitle})` : '') });
|
||||
const last = Array.from(checkedItems.values()).pop();
|
||||
if (last) {
|
||||
link.values = [{ linkable_id: last.id, display_text: last.title + (last.subtitle ? ` (${last.subtitle})` : '') }];
|
||||
}
|
||||
} else {
|
||||
checkedItems.forEach(item => {
|
||||
if (!link.values.find(v => (v.linkable_id || v.id) === item.id)) {
|
||||
link.values.push({ linkable_id: item.id, display_text: item.title + (item.subtitle ? ` (${item.subtitle})` : '') });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 검색 결과 닫기
|
||||
const resultsEl = document.getElementById(`link-results-${linkId}`);
|
||||
if (resultsEl) resultsEl.classList.add('hidden');
|
||||
const searchInput = document.getElementById(`link-search-${linkId}`);
|
||||
if (searchInput) searchInput.value = '';
|
||||
|
||||
// 값 태그 다시 렌더링
|
||||
renderLinkValueTags(linkId);
|
||||
checkedItems.clear();
|
||||
// 검색 결과 닫고 초기화
|
||||
document.getElementById('link-search-results')?.classList.add('hidden');
|
||||
document.getElementById('btn-add-checked')?.classList.add('hidden');
|
||||
document.getElementById('link-search-input').value = '';
|
||||
renderLinkValueTags();
|
||||
}
|
||||
|
||||
function removeLinkValue(linkId, itemId) {
|
||||
const link = templateState.template_links.find(l => l.id == linkId);
|
||||
function removeLinkValue(itemId) {
|
||||
const link = getMainLink();
|
||||
if (!link) return;
|
||||
|
||||
link.values = (link.values || []).filter(v => (v.linkable_id || v.id) !== itemId);
|
||||
renderLinkValueTags(linkId);
|
||||
renderLinkValueTags();
|
||||
}
|
||||
|
||||
function renderLinkValueTags(linkId) {
|
||||
const link = templateState.template_links.find(l => l.id == linkId);
|
||||
const container = document.getElementById(`link-values-${linkId}`);
|
||||
if (!container || !link) return;
|
||||
function renderLinkValueTags() {
|
||||
const container = document.getElementById('link-value-tags');
|
||||
if (!container) return;
|
||||
const link = getMainLink();
|
||||
if (!link || !link.values || link.values.length === 0) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = (link.values || []).map(v => `
|
||||
container.innerHTML = link.values.map(v => `
|
||||
<span class="inline-flex items-center bg-blue-100 text-blue-700 text-xs px-2 py-0.5 rounded">
|
||||
${escapeHtml(v.display_text || 'ID: ' + (v.linkable_id || v.id))}
|
||||
<button onclick="removeLinkValue('${linkId}', ${v.linkable_id || v.id})" class="ml-1 text-blue-400 hover:text-blue-600">×</button>
|
||||
<button onclick="removeLinkValue(${v.linkable_id || v.id})" class="ml-1 text-blue-400 hover:text-blue-600">×</button>
|
||||
</span>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 페이지 로드 시 소스 테이블 옵션 로드
|
||||
loadSourceTableOptions().then(() => {
|
||||
if (templateState.template_links.length > 0) {
|
||||
renderTemplateLinks();
|
||||
}
|
||||
populateLinkSourceTable();
|
||||
});
|
||||
|
||||
// ===== 동적 필드 렌더링 (검사 기준서 셀) =====
|
||||
@@ -1841,31 +1911,32 @@ class="w-full px-2 py-1 border border-gray-200 rounded text-xs">`;
|
||||
case 'json_tolerance':
|
||||
return renderToleranceInput(sid, iid, val || item.tolerance);
|
||||
|
||||
case 'json_criteria': {
|
||||
const c = val || item.standard_criteria;
|
||||
// 기준값 + min/max 범위 복합 UI
|
||||
const stdVal = getItemFieldValue(item, 'standard');
|
||||
return `<input type="text" value="${escapeHtml(stdVal || '')}" placeholder="기준값"
|
||||
onchange="updateDynamicField('${sid}', '${iid}', '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="${c?.min ?? ''}" placeholder="min"
|
||||
onchange="updateStandardCriteria('${sid}', '${iid}', '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('${sid}', '${iid}', 'min_op', this.value)"
|
||||
class="px-0 py-0.5 border border-gray-200 rounded text-xs">
|
||||
<option value="gte" ${(c?.min_op || 'gte') === 'gte' ? 'selected' : ''}>이상</option>
|
||||
<option value="gt" ${c?.min_op === 'gt' ? 'selected' : ''}>초과</option>
|
||||
</select>
|
||||
<span class="text-xs text-gray-400 text-center">~</span>
|
||||
<input type="number" step="any" value="${c?.max ?? ''}" placeholder="max"
|
||||
onchange="updateStandardCriteria('${sid}', '${iid}', '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('${sid}', '${iid}', 'max_op', this.value)"
|
||||
class="px-0 py-0.5 border border-gray-200 rounded text-xs">
|
||||
<option value="lte" ${(c?.max_op || 'lte') === 'lte' ? 'selected' : ''}>이하</option>
|
||||
<option value="lt" ${c?.max_op === 'lt' ? 'selected' : ''}>미만</option>
|
||||
</select>
|
||||
case 'text_with_criteria': {
|
||||
// 상단: 검사기준 텍스트, 하단: 기준범위 min/max
|
||||
const c = getItemFieldValue(item, 'standard_criteria') || item.standard_criteria;
|
||||
return `<div class="flex flex-col gap-1">
|
||||
<input type="text" value="${escapeHtml(val || '')}" placeholder="검사기준"
|
||||
onchange="updateDynamicField('${sid}', '${iid}', '${fk}', this.value)"
|
||||
class="w-full px-2 py-1 border border-gray-200 rounded text-xs">
|
||||
<div style="display:grid;grid-template-columns:1fr auto 8px 1fr auto;align-items:center;gap:2px">
|
||||
<input type="number" step="any" value="${c?.min ?? ''}" placeholder="min"
|
||||
onchange="updateStandardCriteria('${sid}', '${iid}', 'min', this.value)"
|
||||
class="w-full px-1 py-0.5 border border-gray-200 rounded text-xs text-center">
|
||||
<select onchange="updateStandardCriteria('${sid}', '${iid}', 'min_op', this.value)"
|
||||
class="py-0.5 border border-gray-200 rounded text-xs" style="min-width:48px;padding-left:2px;padding-right:2px">
|
||||
<option value="gte" ${(c?.min_op || 'gte') === 'gte' ? 'selected' : ''}>이상</option>
|
||||
<option value="gt" ${c?.min_op === 'gt' ? 'selected' : ''}>초과</option>
|
||||
</select>
|
||||
<span class="text-xs text-gray-400 text-center">~</span>
|
||||
<input type="number" step="any" value="${c?.max ?? ''}" placeholder="max"
|
||||
onchange="updateStandardCriteria('${sid}', '${iid}', 'max', this.value)"
|
||||
class="w-full px-1 py-0.5 border border-gray-200 rounded text-xs text-center">
|
||||
<select onchange="updateStandardCriteria('${sid}', '${iid}', 'max_op', this.value)"
|
||||
class="py-0.5 border border-gray-200 rounded text-xs" style="min-width:48px;padding-left:2px;padding-right:2px">
|
||||
<option value="lte" ${(c?.max_op || 'lte') === 'lte' ? 'selected' : ''}>이하</option>
|
||||
<option value="lt" ${c?.max_op === 'lt' ? 'selected' : ''}>미만</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -140,6 +140,22 @@ class="w-full rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:rin
|
||||
<img src="{{ asset('storage/' . $section->image_path) }}" alt="{{ $section->title }}" class="max-w-full h-auto mb-4 rounded">
|
||||
@endif
|
||||
|
||||
{{-- 연결 품목 규격 정보 --}}
|
||||
@if(!empty($linkedItemSpecs))
|
||||
<div class="mb-3 flex flex-wrap gap-2 items-center text-xs">
|
||||
<span class="text-gray-500">연결 품목 규격:</span>
|
||||
@foreach($linkedItemSpecs as $spec)
|
||||
<span class="inline-flex items-center gap-1 bg-yellow-50 border border-yellow-200 rounded px-2 py-0.5">
|
||||
<span class="font-medium">{{ $spec['name'] }}</span>
|
||||
@if($spec['thickness'])<span class="text-gray-500">t={{ $spec['thickness'] }}</span>@endif
|
||||
@if($spec['width'])<span class="text-gray-500">w={{ $spec['width'] }}</span>@endif
|
||||
@if($spec['length'])<span class="text-gray-500">l={{ $spec['length'] }}</span>@endif
|
||||
</span>
|
||||
@endforeach
|
||||
<span class="text-gray-400 ml-1">← 해당 범위 행이 노란색으로 표시됩니다</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 검사 데이터 테이블 --}}
|
||||
@if($section->items->count() > 0 && $template->columns->count() > 0)
|
||||
@php
|
||||
@@ -230,6 +246,39 @@ class="w-full rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:rin
|
||||
$idx++;
|
||||
}
|
||||
|
||||
// 연결 품목 규격 정보 (자동 하이라이트용)
|
||||
$itemSpecs = $linkedItemSpecs ?? [];
|
||||
$matchRow = function($item) use ($itemSpecs) {
|
||||
$c = $item->getFieldValue('standard_criteria');
|
||||
if (!$c || !is_array($c) || (!isset($c['min']) && !isset($c['max']))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 검사항목명에서 비교 대상 결정 (두께→thickness, 너비→width, 길이→length)
|
||||
$itemName = mb_strtolower(trim($item->getFieldValue('item') ?? ''));
|
||||
$specKey = 'thickness'; // 기본값
|
||||
if (str_contains($itemName, '너비') || str_contains($itemName, 'width')) {
|
||||
$specKey = 'width';
|
||||
} elseif (str_contains($itemName, '길이') || str_contains($itemName, 'length')) {
|
||||
$specKey = 'length';
|
||||
}
|
||||
|
||||
foreach ($itemSpecs as $spec) {
|
||||
$val = $spec[$specKey] ?? null;
|
||||
if ($val === null) continue;
|
||||
|
||||
$min = isset($c['min']) ? (float)$c['min'] : null;
|
||||
$max = isset($c['max']) ? (float)$c['max'] : null;
|
||||
$minOp = $c['min_op'] ?? 'gte';
|
||||
$maxOp = $c['max_op'] ?? 'lte';
|
||||
|
||||
$minOk = $min === null || ($minOp === 'gt' ? $val > $min : $val >= $min);
|
||||
$maxOk = $max === null || ($maxOp === 'lt' ? $val < $max : $val <= $max);
|
||||
if ($minOk && $maxOk) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 측정치 컬럼 정보
|
||||
$hasComplex = $template->columns->contains(fn($c) => $c->column_type === 'complex' && $c->sub_labels);
|
||||
$maxFreqN = $allItems->max(fn($i) => $i->getFieldValue('frequency_n')) ?: 0;
|
||||
@@ -300,8 +349,8 @@ class="px-2 py-2 text-center text-xs font-medium text-gray-600 uppercase border
|
||||
@php $rowNum++; @endphp
|
||||
@if($row['type'] === 'single')
|
||||
{{-- 단일 항목 --}}
|
||||
@php $item = $row['item']; $rowIndex = $globalRowIndex; $globalRowIndex++; @endphp
|
||||
<tr class="hover:bg-blue-50" data-row-index="{{ $rowIndex }}">
|
||||
@php $item = $row['item']; $rowIndex = $globalRowIndex; $globalRowIndex++; $isMatch = $matchRow($item); @endphp
|
||||
<tr class="{{ $isMatch ? 'bg-yellow-50 border-l-4 border-l-yellow-400' : 'hover:bg-blue-50' }}" data-row-index="{{ $rowIndex }}" @if($isMatch) title="연결 품목 규격 범위에 해당" @endif>
|
||||
@foreach($template->columns as $col)
|
||||
@if($col->column_type === 'complex' && $col->sub_labels)
|
||||
{{-- 측정치: measurement_type에 따라 분기 (getFieldValue 사용) --}}
|
||||
@@ -472,8 +521,8 @@ class="w-full text-center text-sm border-0 focus:ring-1 focus:ring-blue-400 roun
|
||||
{{-- 그룹 항목 --}}
|
||||
@php $groupItems = $row['items']; $groupCount = count($groupItems); @endphp
|
||||
@foreach($groupItems as $itemIdx => $item)
|
||||
@php $rowIndex = $globalRowIndex; $globalRowIndex++; @endphp
|
||||
<tr class="hover:bg-blue-50" data-row-index="{{ $rowIndex }}">
|
||||
@php $rowIndex = $globalRowIndex; $globalRowIndex++; $isMatch = $matchRow($item); @endphp
|
||||
<tr class="{{ $isMatch ? 'bg-yellow-50 border-l-4 border-l-yellow-400' : 'hover:bg-blue-50' }}" data-row-index="{{ $rowIndex }}" @if($isMatch) title="연결 품목 규격 범위에 해당" @endif>
|
||||
@foreach($template->columns as $col)
|
||||
@if($col->column_type === 'complex' && $col->sub_labels)
|
||||
{{-- 측정치: 각 행 개별 렌더링 (getFieldValue 사용) --}}
|
||||
|
||||
@@ -43,6 +43,9 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
@foreach($statuses as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
@if(auth()->user()?->is_super_admin)
|
||||
<option value="TRASHED">🗑 휴지통</option>
|
||||
@endif
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -108,6 +111,8 @@ class="w-full sm:w-36 px-3 py-2 border border-gray-300 rounded-lg focus:outline-
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
const isSuperAdmin = {{ auth()->user()?->is_super_admin ? 'true' : 'false' }};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadDocuments();
|
||||
|
||||
@@ -127,6 +132,12 @@ function loadDocuments(page = 1) {
|
||||
const params = new URLSearchParams(formData);
|
||||
params.set('page', page);
|
||||
|
||||
// 휴지통 필터 처리
|
||||
if (params.get('status') === 'TRASHED') {
|
||||
params.delete('status');
|
||||
params.set('trashed', '1');
|
||||
}
|
||||
|
||||
// 로딩 표시
|
||||
document.getElementById('documentList').innerHTML = `
|
||||
<tr>
|
||||
@@ -200,13 +211,16 @@ function renderDocuments(documents) {
|
||||
<span class="text-sm text-gray-600">${formatDate(doc.created_at)}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<a href="/documents/${doc.id}" class="text-gray-600 hover:text-gray-900 mr-3">보기</a>
|
||||
${doc.status === 'DRAFT' || doc.status === 'REJECTED' ? `
|
||||
<a href="/documents/${doc.id}/edit" class="text-blue-600 hover:text-blue-900 mr-3">수정</a>
|
||||
` : ''}
|
||||
${doc.status === 'DRAFT' ? `
|
||||
${doc.deleted_at ? `
|
||||
<button onclick="restoreDocument(${doc.id}, '${doc.document_no}')" class="text-green-600 hover:text-green-900 mr-2">복원</button>
|
||||
<button onclick="forceDeleteDocument(${doc.id}, '${doc.document_no}')" class="text-red-800 hover:text-red-900">영구삭제</button>
|
||||
` : `
|
||||
<a href="/documents/${doc.id}" class="text-gray-600 hover:text-gray-900 mr-3">보기</a>
|
||||
${doc.status === 'DRAFT' || doc.status === 'REJECTED' ? `
|
||||
<a href="/documents/${doc.id}/edit" class="text-blue-600 hover:text-blue-900 mr-3">수정</a>
|
||||
` : ''}
|
||||
<button onclick="deleteDocument(${doc.id}, '${doc.document_no}')" class="text-red-600 hover:text-red-900">삭제</button>
|
||||
` : ''}
|
||||
`}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
@@ -285,5 +299,56 @@ function deleteDocument(id, documentNo) {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function restoreDocument(id, documentNo) {
|
||||
if (!confirm(`문서 "${documentNo}"을(를) 복원하시겠습니까?`)) return;
|
||||
|
||||
fetch(`/api/admin/documents/${id}/restore`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showToast(result.message || '복원되었습니다.', 'success');
|
||||
loadDocuments();
|
||||
} else {
|
||||
showToast(result.message || '복원에 실패했습니다.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Restore error:', error);
|
||||
showToast('복원 중 오류가 발생했습니다.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function forceDeleteDocument(id, documentNo) {
|
||||
if (!confirm(`[영구삭제] 문서 "${documentNo}"을(를) 영구 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.`)) return;
|
||||
if (!confirm('정말로 영구 삭제하시겠습니까? 복구가 불가능합니다.')) return;
|
||||
|
||||
fetch(`/api/admin/documents/${id}/force`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showToast(result.message || '영구 삭제되었습니다.', 'success');
|
||||
loadDocuments();
|
||||
} else {
|
||||
showToast(result.message || '영구 삭제에 실패했습니다.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Force delete error:', error);
|
||||
showToast('영구 삭제 중 오류가 발생했습니다.', 'error');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@endpush
|
||||
|
||||
Reference in New Issue
Block a user