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:
2026-02-05 09:26:13 +09:00
parent 07c22bee03
commit b14b991d1c
7 changed files with 562 additions and 280 deletions

View File

@@ -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)
*/

View File

@@ -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;
}
}

View File

@@ -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(),
];

View File

@@ -21,5 +21,6 @@ class Item extends Model
protected $casts = [
'is_active' => 'boolean',
'attributes' => 'array',
];
}

View File

@@ -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' : ''}>&#60;</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">&times;</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">&times;</button>
<button onclick="removeLinkValue(${v.linkable_id || v.id})" class="ml-1 text-blue-400 hover:text-blue-600">&times;</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>`;
}

View File

@@ -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 사용) --}}

View File

@@ -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