Files
sam-manage/resources/views/documents/index.blade.php
권혁성 0f5c509bed feat:문서관리 필터 기능 개선 - 양식분류 필터 추가, onchange 즉시 검색
- 상태/템플릿 필터에 onchange 즉시 검색 적용
- 양식분류(category) 필터 추가 (API + 뷰 컨트롤러 + 프론트)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 03:38:30 +09:00

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