feat(tksafety): 통합 출입신고 관리 시스템 구현
- DB 마이그레이션: request_type, visitor_name, department_id, check_in/out_time 컬럼 + status ENUM 확장 - 4소스 UNION 대시보드: 방문(외부/내부) + TBM + 협력업체 통합 조회 - 체크인/체크아웃 API + 내부 출입 신고(승인 불필요) 지원 - 통합 출입 현황판 페이지 신규 (entry-dashboard.html) - 출입 신청/관리 페이지에 유형 필터 + 체크인/아웃 버튼 추가 - safety_entry_dashboard 권한 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,12 +8,20 @@ function vrStatusBadge(s) {
|
||||
pending: ['badge-amber', '대기중'],
|
||||
approved: ['badge-green', '승인됨'],
|
||||
rejected: ['badge-red', '반려됨'],
|
||||
training_completed: ['badge-blue', '교육완료']
|
||||
training_completed: ['badge-blue', '교육완료'],
|
||||
checked_in: ['badge-blue', '체크인'],
|
||||
checked_out: ['badge-gray', '체크아웃']
|
||||
};
|
||||
const [cls, label] = m[s] || ['badge-gray', s];
|
||||
return `<span class="badge ${cls}">${label}</span>`;
|
||||
}
|
||||
|
||||
function requestTypeBadge(t) {
|
||||
return t === 'internal'
|
||||
? '<span class="badge badge-blue">내부</span>'
|
||||
: '<span class="badge badge-amber">외부</span>';
|
||||
}
|
||||
|
||||
/* ===== Load requests ===== */
|
||||
async function loadRequests() {
|
||||
try {
|
||||
@@ -21,9 +29,11 @@ async function loadRequests() {
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
const dateFrom = document.getElementById('filterDateFrom').value;
|
||||
const dateTo = document.getElementById('filterDateTo').value;
|
||||
const type = document.getElementById('filterType').value;
|
||||
if (status) params.set('status', status);
|
||||
if (dateFrom) params.set('start_date', dateFrom);
|
||||
if (dateTo) params.set('end_date', dateTo);
|
||||
if (type) params.set('request_type', type);
|
||||
|
||||
const res = await api('/visit-requests/requests?' + params.toString());
|
||||
allRequests = res.data || [];
|
||||
@@ -35,12 +45,14 @@ async function loadRequests() {
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const counts = { pending: 0, approved: 0, rejected: 0, training_completed: 0 };
|
||||
const counts = { pending: 0, approved: 0, rejected: 0, training_completed: 0, checked_in: 0, checked_out: 0 };
|
||||
allRequests.forEach(r => { if (counts[r.status] !== undefined) counts[r.status]++; });
|
||||
document.getElementById('statPending').textContent = counts.pending;
|
||||
document.getElementById('statApproved').textContent = counts.approved;
|
||||
document.getElementById('statRejected').textContent = counts.rejected;
|
||||
document.getElementById('statTrainingDone').textContent = counts.training_completed;
|
||||
document.getElementById('statCheckedIn').textContent = counts.checked_in;
|
||||
document.getElementById('statCheckedOut').textContent = counts.checked_out;
|
||||
}
|
||||
|
||||
function renderRequestsTable() {
|
||||
@@ -51,6 +63,8 @@ function renderRequestsTable() {
|
||||
}
|
||||
tbody.innerHTML = allRequests.map(r => {
|
||||
let actions = '';
|
||||
|
||||
// 승인/반려 (pending만)
|
||||
if (r.status === 'pending') {
|
||||
actions = `
|
||||
<button onclick="openApproveModal(${r.request_id})" class="text-green-600 hover:text-green-800 text-xs px-2 py-1 border border-green-200 rounded hover:bg-green-50" title="승인">
|
||||
@@ -60,15 +74,32 @@ function renderRequestsTable() {
|
||||
<i class="fas fa-times"></i>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
// 체크인 버튼 (approved 또는 training_completed)
|
||||
const canCheckIn = (r.request_type === 'internal' && r.status === 'approved') ||
|
||||
(['approved', 'training_completed'].includes(r.status));
|
||||
if (canCheckIn) {
|
||||
actions += ` <button onclick="doCheckIn(${r.request_id})" class="text-blue-600 hover:text-blue-800 text-xs px-2 py-1 border border-blue-200 rounded hover:bg-blue-50" title="체크인"><i class="fas fa-sign-in-alt"></i></button>`;
|
||||
}
|
||||
|
||||
// 체크아웃 버튼 (checked_in)
|
||||
if (r.status === 'checked_in') {
|
||||
actions += ` <button onclick="doCheckOut(${r.request_id})" class="text-gray-600 hover:text-gray-800 text-xs px-2 py-1 border border-gray-200 rounded hover:bg-gray-50" title="체크아웃"><i class="fas fa-sign-out-alt"></i></button>`;
|
||||
}
|
||||
|
||||
actions += ` <button onclick="openDetailModal(${r.request_id})" class="text-gray-400 hover:text-gray-600 text-xs ml-1" title="상세"><i class="fas fa-eye"></i></button>`;
|
||||
if (r.status === 'pending') {
|
||||
actions += ` <button onclick="doDeleteRequest(${r.request_id})" class="text-gray-400 hover:text-red-500 text-xs ml-1" title="삭제"><i class="fas fa-trash"></i></button>`;
|
||||
}
|
||||
|
||||
const displayName = r.request_type === 'internal'
|
||||
? escapeHtml(r.visitor_name || r.requester_full_name || '-')
|
||||
: escapeHtml(r.visitor_company);
|
||||
|
||||
return `<tr>
|
||||
<td>${formatDate(r.created_at)}</td>
|
||||
<td>${requestTypeBadge(r.request_type)}</td>
|
||||
<td>${escapeHtml(r.requester_full_name || r.requester_name || '-')}</td>
|
||||
<td>${escapeHtml(r.visitor_company)}</td>
|
||||
<td>${displayName}</td>
|
||||
<td class="text-center">${r.visitor_count}</td>
|
||||
<td>${escapeHtml(r.workplace_name || '-')}</td>
|
||||
<td>${formatDate(r.visit_date)}</td>
|
||||
@@ -80,13 +111,40 @@ function renderRequestsTable() {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/* ===== Check-in / Check-out ===== */
|
||||
async function doCheckIn(id) {
|
||||
if (!confirm('체크인 처리하시겠습니까?')) return;
|
||||
try {
|
||||
await api('/visit-requests/requests/' + id + '/check-in', { method: 'PUT', body: JSON.stringify({}) });
|
||||
showToast('체크인 완료');
|
||||
await loadRequests();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function doCheckOut(id) {
|
||||
if (!confirm('체크아웃 처리하시겠습니까?')) return;
|
||||
try {
|
||||
await api('/visit-requests/requests/' + id + '/check-out', { method: 'PUT', body: JSON.stringify({}) });
|
||||
showToast('체크아웃 완료');
|
||||
await loadRequests();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Approve Modal ===== */
|
||||
function openApproveModal(id) {
|
||||
const r = allRequests.find(x => x.request_id === id);
|
||||
if (!r) return;
|
||||
actionRequestId = id;
|
||||
const displayName = r.request_type === 'internal'
|
||||
? escapeHtml(r.visitor_name || r.requester_full_name || '-')
|
||||
: escapeHtml(r.visitor_company);
|
||||
document.getElementById('approveDetail').innerHTML = `
|
||||
<p><strong>업체:</strong> ${escapeHtml(r.visitor_company)}</p>
|
||||
<p><strong>유형:</strong> ${r.request_type === 'internal' ? '내부 출입' : '외부 방문'}</p>
|
||||
<p><strong>업체/이름:</strong> ${displayName}</p>
|
||||
<p><strong>방문일:</strong> ${formatDate(r.visit_date)} ${r.visit_time ? String(r.visit_time).substring(0, 5) : ''}</p>
|
||||
<p><strong>작업장:</strong> ${escapeHtml(r.workplace_name || '-')}</p>
|
||||
<p><strong>인원:</strong> ${r.visitor_count}명</p>
|
||||
@@ -120,7 +178,7 @@ function openRejectModal(id) {
|
||||
if (!r) return;
|
||||
actionRequestId = id;
|
||||
document.getElementById('rejectDetail').innerHTML = `
|
||||
<p><strong>업체:</strong> ${escapeHtml(r.visitor_company)}</p>
|
||||
<p><strong>업체/이름:</strong> ${escapeHtml(r.visitor_company || r.visitor_name || '-')}</p>
|
||||
<p><strong>방문일:</strong> ${formatDate(r.visit_date)} ${r.visit_time ? String(r.visit_time).substring(0, 5) : ''}</p>
|
||||
<p><strong>작업장:</strong> ${escapeHtml(r.workplace_name || '-')}</p>
|
||||
`;
|
||||
@@ -158,8 +216,10 @@ function openDetailModal(id) {
|
||||
if (!r) return;
|
||||
document.getElementById('detailContent').innerHTML = `
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><span class="text-gray-500">유형:</span> ${r.request_type === 'internal' ? '내부 출입' : '외부 방문'}</div>
|
||||
<div><span class="text-gray-500">신청자:</span> <span class="font-medium">${escapeHtml(r.requester_full_name || r.requester_name || '-')}</span></div>
|
||||
<div><span class="text-gray-500">업체:</span> <span class="font-medium">${escapeHtml(r.visitor_company)}</span></div>
|
||||
<div><span class="text-gray-500">업체:</span> <span class="font-medium">${escapeHtml(r.visitor_company || '-')}</span></div>
|
||||
<div><span class="text-gray-500">방문자:</span> <span class="font-medium">${escapeHtml(r.visitor_name || '-')}</span></div>
|
||||
<div><span class="text-gray-500">인원:</span> <span class="font-medium">${r.visitor_count}명</span></div>
|
||||
<div><span class="text-gray-500">분류:</span> <span class="font-medium">${escapeHtml(r.category_name || '-')}</span></div>
|
||||
<div><span class="text-gray-500">작업장:</span> <span class="font-medium">${escapeHtml(r.workplace_name || '-')}</span></div>
|
||||
@@ -168,6 +228,9 @@ function openDetailModal(id) {
|
||||
<div><span class="text-gray-500">목적:</span> <span class="font-medium">${escapeHtml(r.purpose_name || '-')}</span></div>
|
||||
<div><span class="text-gray-500">상태:</span> ${vrStatusBadge(r.status)}</div>
|
||||
<div><span class="text-gray-500">신청일:</span> <span class="font-medium">${formatDateTime(r.created_at)}</span></div>
|
||||
${r.check_in_time ? `<div><span class="text-gray-500">체크인:</span> <span class="font-medium">${formatDateTime(r.check_in_time)}</span></div>` : ''}
|
||||
${r.check_out_time ? `<div><span class="text-gray-500">체크아웃:</span> <span class="font-medium">${formatDateTime(r.check_out_time)}</span></div>` : ''}
|
||||
${r.department_name ? `<div><span class="text-gray-500">부서:</span> <span class="font-medium">${escapeHtml(r.department_name)}</span></div>` : ''}
|
||||
${r.approver_name ? `<div><span class="text-gray-500">처리자:</span> <span class="font-medium">${escapeHtml(r.approver_name)}</span></div>` : ''}
|
||||
${r.approved_at ? `<div><span class="text-gray-500">처리일:</span> <span class="font-medium">${formatDateTime(r.approved_at)}</span></div>` : ''}
|
||||
${r.rejection_reason ? `<div class="col-span-2"><span class="text-gray-500">반려사유:</span> <span class="font-medium text-red-600">${escapeHtml(r.rejection_reason)}</span></div>` : ''}
|
||||
@@ -209,6 +272,7 @@ function initVisitManagementPage() {
|
||||
}
|
||||
|
||||
document.getElementById('filterStatus').addEventListener('change', loadRequests);
|
||||
document.getElementById('filterType').addEventListener('change', loadRequests);
|
||||
document.getElementById('filterDateFrom').addEventListener('change', loadRequests);
|
||||
document.getElementById('filterDateTo').addEventListener('change', loadRequests);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user