- 상태/템플릿 필터에 onchange 즉시 검색 적용 - 양식분류(category) 필터 추가 (API + 뷰 컨트롤러 + 프론트) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
365 lines
15 KiB
PHP
365 lines
15 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', '문서 관리')
|
|
|
|
@section('content')
|
|
<!-- 페이지 헤더 -->
|
|
<div class="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-4 mb-6">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-gray-800">문서 관리</h1>
|
|
<p class="text-sm text-gray-500 mt-1 hidden sm:block">
|
|
작성된 문서를 관리합니다.
|
|
</p>
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
|
|
<a href="{{ route('documents.create') }}"
|
|
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
새 문서 작성
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 필터 영역 -->
|
|
<x-filter-collapsible id="filterForm">
|
|
<form id="filterForm" class="flex flex-wrap gap-2 sm:gap-4">
|
|
<input type="hidden" name="per_page" id="perPageInput" value="15">
|
|
<input type="hidden" name="page" id="pageInput" value="1">
|
|
|
|
<!-- 검색 -->
|
|
<div class="flex-1 min-w-0 w-full sm:w-auto">
|
|
<input type="text"
|
|
name="search"
|
|
placeholder="문서번호, 제목으로 검색..."
|
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
</div>
|
|
|
|
<!-- 상태 필터 -->
|
|
<div class="w-full sm:w-32">
|
|
<select name="status" onchange="loadDocuments()" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
<option value="">전체 상태</option>
|
|
@foreach($statuses as $value => $label)
|
|
<option value="{{ $value }}">{{ $label }}</option>
|
|
@endforeach
|
|
@if(auth()->user()?->is_super_admin)
|
|
<option value="TRASHED">🗑 휴지통</option>
|
|
@endif
|
|
</select>
|
|
</div>
|
|
|
|
<!-- 양식분류 필터 -->
|
|
<div class="w-full sm:w-40">
|
|
<select name="category" onchange="loadDocuments()" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
<option value="">전체 양식분류</option>
|
|
@foreach($categories as $category)
|
|
<option value="{{ $category }}">{{ $category }}</option>
|
|
@endforeach
|
|
</select>
|
|
</div>
|
|
|
|
<!-- 템플릿 필터 -->
|
|
<div class="w-full sm:w-40">
|
|
<select name="template_id" onchange="loadDocuments()" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
<option value="">전체 템플릿</option>
|
|
@foreach($templates as $template)
|
|
<option value="{{ $template->id }}">{{ $template->name }}</option>
|
|
@endforeach
|
|
</select>
|
|
</div>
|
|
|
|
<!-- 날짜 범위 필터 -->
|
|
<div class="flex items-center gap-1 w-full sm:w-auto">
|
|
<input type="date" name="date_from"
|
|
class="w-full sm:w-36 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
|
|
<span class="text-gray-400">~</span>
|
|
<input type="date" name="date_to"
|
|
class="w-full sm:w-36 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
|
|
</div>
|
|
|
|
<!-- 검색 버튼 -->
|
|
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition w-full sm:w-auto">
|
|
검색
|
|
</button>
|
|
</form>
|
|
</x-filter-collapsible>
|
|
|
|
<!-- 문서 목록 테이블 -->
|
|
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-gray-200">
|
|
<thead class="bg-gray-50">
|
|
<tr>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">문서번호</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">제목</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">템플릿</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">상태</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">작성자</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">작성일</th>
|
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">관리</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="documentList" class="bg-white divide-y divide-gray-200">
|
|
<!-- 로딩 스피너 -->
|
|
<tr id="loadingRow">
|
|
<td colspan="7" class="px-6 py-12 text-center">
|
|
<div class="flex justify-center items-center">
|
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- 페이지네이션 -->
|
|
<div id="pagination" class="px-6 py-4 border-t border-gray-200">
|
|
</div>
|
|
</div>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script>
|
|
const isSuperAdmin = {{ auth()->user()?->is_super_admin ? 'true' : 'false' }};
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
loadDocuments();
|
|
|
|
// 필터 폼 제출
|
|
const filterForm = document.getElementById('filterForm');
|
|
if (filterForm) {
|
|
filterForm.addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
loadDocuments();
|
|
});
|
|
}
|
|
});
|
|
|
|
function loadDocuments(page = 1) {
|
|
const form = document.getElementById('filterForm');
|
|
const formData = new FormData(form);
|
|
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>
|
|
<td colspan="7" class="px-6 py-12 text-center">
|
|
<div class="flex justify-center items-center">
|
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
|
|
fetch(`/api/admin/documents?${params.toString()}`, {
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
}
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
renderDocuments(data.data);
|
|
renderPagination(data);
|
|
})
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
document.getElementById('documentList').innerHTML = `
|
|
<tr>
|
|
<td colspan="7" class="px-6 py-12 text-center text-gray-500">
|
|
데이터를 불러오는 중 오류가 발생했습니다.
|
|
</td>
|
|
</tr>
|
|
`;
|
|
});
|
|
}
|
|
|
|
function renderDocuments(documents) {
|
|
const tbody = document.getElementById('documentList');
|
|
|
|
if (!documents || documents.length === 0) {
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="7" class="px-6 py-12 text-center text-gray-500">
|
|
문서가 없습니다.
|
|
</td>
|
|
</tr>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = documents.map(doc => `
|
|
<tr class="hover:bg-gray-50">
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
<span class="text-sm font-medium text-gray-900">${doc.document_no}</span>
|
|
</td>
|
|
<td class="px-6 py-4">
|
|
<a href="/documents/${doc.id}" class="text-sm text-blue-600 hover:text-blue-800 hover:underline">
|
|
${doc.title}
|
|
</a>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
<span class="text-sm text-gray-600">${doc.template?.name || '-'}</span>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusClass(doc.status)}">
|
|
${getStatusLabel(doc.status)}
|
|
</span>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
<span class="text-sm text-gray-600">${doc.creator?.name || '-'}</span>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
<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">
|
|
${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('');
|
|
}
|
|
|
|
function renderPagination(data) {
|
|
const container = document.getElementById('pagination');
|
|
|
|
if (data.last_page <= 1) {
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
let html = '<div class="flex items-center justify-between"><div class="text-sm text-gray-500">';
|
|
html += `총 ${data.total}건 중 ${data.from}-${data.to}건`;
|
|
html += '</div><div class="flex gap-1">';
|
|
|
|
for (let i = 1; i <= data.last_page; i++) {
|
|
const isActive = i === data.current_page;
|
|
html += `<button onclick="loadDocuments(${i})" class="px-3 py-1 text-sm rounded ${isActive ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}">${i}</button>`;
|
|
}
|
|
|
|
html += '</div></div>';
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function getStatusLabel(status) {
|
|
const labels = {
|
|
'DRAFT': '작성중',
|
|
'PENDING': '결재중',
|
|
'APPROVED': '승인',
|
|
'REJECTED': '반려',
|
|
'CANCELLED': '취소'
|
|
};
|
|
return labels[status] || status;
|
|
}
|
|
|
|
function getStatusClass(status) {
|
|
const classes = {
|
|
'DRAFT': 'bg-gray-100 text-gray-800',
|
|
'PENDING': 'bg-yellow-100 text-yellow-800',
|
|
'APPROVED': 'bg-green-100 text-green-800',
|
|
'REJECTED': 'bg-red-100 text-red-800',
|
|
'CANCELLED': 'bg-gray-100 text-gray-600'
|
|
};
|
|
return classes[status] || 'bg-gray-100 text-gray-800';
|
|
}
|
|
|
|
function formatDate(dateString) {
|
|
if (!dateString) return '-';
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
}
|
|
|
|
function deleteDocument(id, documentNo) {
|
|
showDeleteConfirm(documentNo, () => {
|
|
fetch(`/api/admin/documents/${id}`, {
|
|
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('Delete error:', error);
|
|
showToast('삭제 중 오류가 발생했습니다.', 'error');
|
|
});
|
|
});
|
|
}
|
|
|
|
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
|