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:
@@ -3,6 +3,7 @@ let myRequests = [];
|
||||
let categories = [];
|
||||
let workplaces = [];
|
||||
let purposes = [];
|
||||
let departments = [];
|
||||
|
||||
/* ===== Status badge for visit requests ===== */
|
||||
function vrStatusBadge(s) {
|
||||
@@ -10,21 +11,40 @@ 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>`;
|
||||
}
|
||||
|
||||
/* ===== Load form data (purposes, categories) ===== */
|
||||
function requestTypeBadge(t) {
|
||||
return t === 'internal'
|
||||
? '<span class="badge badge-blue">내부</span>'
|
||||
: '<span class="badge badge-amber">외부</span>';
|
||||
}
|
||||
|
||||
/* ===== Toggle form fields based on request type ===== */
|
||||
function toggleRequestType() {
|
||||
const isInternal = document.querySelector('input[name="requestType"]:checked')?.value === 'internal';
|
||||
document.getElementById('companyField').style.display = isInternal ? 'none' : '';
|
||||
document.getElementById('countField').style.display = isInternal ? 'none' : '';
|
||||
document.getElementById('departmentField').style.display = isInternal ? '' : 'none';
|
||||
document.getElementById('visitorNameRequired').classList.toggle('hidden', !isInternal);
|
||||
}
|
||||
|
||||
/* ===== Load form data (purposes, categories, departments) ===== */
|
||||
async function loadFormData() {
|
||||
try {
|
||||
const [purposeRes, categoryRes] = await Promise.all([
|
||||
const [purposeRes, categoryRes, deptRes] = await Promise.all([
|
||||
api('/visit-requests/purposes/active'),
|
||||
api('/visit-requests/categories')
|
||||
api('/visit-requests/categories'),
|
||||
api('/visit-requests/departments')
|
||||
]);
|
||||
purposes = purposeRes.data || [];
|
||||
categories = categoryRes.data || [];
|
||||
departments = deptRes.data || [];
|
||||
|
||||
const purposeSelect = document.getElementById('purposeId');
|
||||
purposeSelect.innerHTML = '<option value="">선택</option>' +
|
||||
@@ -33,6 +53,10 @@ async function loadFormData() {
|
||||
const categorySelect = document.getElementById('categoryId');
|
||||
categorySelect.innerHTML = '<option value="">선택</option>' +
|
||||
categories.map(c => `<option value="${c.category_id}">${escapeHtml(c.category_name)}</option>`).join('');
|
||||
|
||||
const deptSelect = document.getElementById('departmentId');
|
||||
deptSelect.innerHTML = '<option value="">선택 (선택사항)</option>' +
|
||||
departments.map(d => `<option value="${d.department_id}">${escapeHtml(d.name)}</option>`).join('');
|
||||
} catch (e) {
|
||||
showToast('폼 데이터 로드 실패: ' + e.message, 'error');
|
||||
}
|
||||
@@ -76,29 +100,74 @@ function renderMyRequests() {
|
||||
}
|
||||
tbody.innerHTML = myRequests.map(r => {
|
||||
const canDelete = r.status === 'pending';
|
||||
const displayName = r.request_type === 'internal'
|
||||
? escapeHtml(r.visitor_name || r.requester_full_name || '-')
|
||||
: escapeHtml(r.visitor_company);
|
||||
|
||||
// 체크인/체크아웃 버튼
|
||||
let checkBtn = '';
|
||||
const canCheckIn = (r.request_type === 'internal' && r.status === 'approved') ||
|
||||
(r.request_type === 'external' && ['approved', 'training_completed'].includes(r.status));
|
||||
if (canCheckIn) {
|
||||
checkBtn = `<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>`;
|
||||
}
|
||||
if (r.status === 'checked_in') {
|
||||
checkBtn = `<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>`;
|
||||
}
|
||||
|
||||
return `<tr>
|
||||
<td>${formatDate(r.created_at)}</td>
|
||||
<td>${escapeHtml(r.visitor_company)}</td>
|
||||
<td>${requestTypeBadge(r.request_type)}</td>
|
||||
<td>${displayName}</td>
|
||||
<td class="text-center">${r.visitor_count}</td>
|
||||
<td>${escapeHtml(r.workplace_name || '-')}</td>
|
||||
<td>${formatDate(r.visit_date)}</td>
|
||||
<td class="hide-mobile">${r.visit_time ? String(r.visit_time).substring(0, 5) : '-'}</td>
|
||||
<td>${escapeHtml(r.purpose_name || '-')}</td>
|
||||
<td>${vrStatusBadge(r.status)}</td>
|
||||
<td class="text-right">
|
||||
${canDelete ? `<button onclick="deleteRequest(${r.request_id})" class="text-gray-400 hover:text-red-500 text-xs" title="삭제"><i class="fas fa-trash"></i></button>` : ''}
|
||||
<td class="text-right whitespace-nowrap">
|
||||
${checkBtn}
|
||||
${canDelete ? `<button onclick="deleteRequest(${r.request_id})" class="text-gray-400 hover:text-red-500 text-xs ml-1" title="삭제"><i class="fas fa-trash"></i></button>` : ''}
|
||||
${r.status === 'rejected' && r.rejection_reason ? `<button onclick="alert('반려 사유: ' + ${JSON.stringify(r.rejection_reason)})" class="text-gray-400 hover:text-gray-600 text-xs ml-1" title="반려사유"><i class="fas fa-info-circle"></i></button>` : ''}
|
||||
</td>
|
||||
</tr>`;
|
||||
}).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 loadMyRequests();
|
||||
} 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 loadMyRequests();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Submit request ===== */
|
||||
async function submitRequest(e) {
|
||||
e.preventDefault();
|
||||
const requestType = document.querySelector('input[name="requestType"]:checked')?.value || 'external';
|
||||
const isInternal = requestType === 'internal';
|
||||
|
||||
const data = {
|
||||
visitor_company: document.getElementById('visitorCompany').value.trim(),
|
||||
visitor_count: parseInt(document.getElementById('visitorCount').value) || 1,
|
||||
request_type: requestType,
|
||||
visitor_company: isInternal ? '내부' : document.getElementById('visitorCompany').value.trim(),
|
||||
visitor_name: document.getElementById('visitorName').value.trim() || null,
|
||||
visitor_count: isInternal ? 1 : (parseInt(document.getElementById('visitorCount').value) || 1),
|
||||
department_id: isInternal ? (parseInt(document.getElementById('departmentId').value) || null) : null,
|
||||
category_id: parseInt(document.getElementById('categoryId').value) || null,
|
||||
workplace_id: parseInt(document.getElementById('workplaceId').value) || null,
|
||||
visit_date: document.getElementById('visitDate').value,
|
||||
@@ -107,7 +176,11 @@ async function submitRequest(e) {
|
||||
notes: document.getElementById('notes').value.trim() || null
|
||||
};
|
||||
|
||||
if (!data.visitor_company) { showToast('업체명을 입력해주세요', 'error'); return; }
|
||||
if (isInternal) {
|
||||
if (!data.visitor_name) { showToast('방문자 이름을 입력해주세요', 'error'); return; }
|
||||
} else {
|
||||
if (!data.visitor_company) { showToast('업체명을 입력해주세요', 'error'); return; }
|
||||
}
|
||||
if (!data.category_id) { showToast('작업장 분류를 선택해주세요', 'error'); return; }
|
||||
if (!data.workplace_id) { showToast('작업장을 선택해주세요', 'error'); return; }
|
||||
if (!data.visit_date) { showToast('방문일을 선택해주세요', 'error'); return; }
|
||||
@@ -116,10 +189,11 @@ async function submitRequest(e) {
|
||||
|
||||
try {
|
||||
await api('/visit-requests/requests', { method: 'POST', body: JSON.stringify(data) });
|
||||
showToast('출입 신청이 완료되었습니다');
|
||||
showToast(isInternal ? '내부 출입 신고가 완료되었습니다' : '출입 신청이 완료되었습니다');
|
||||
document.getElementById('visitRequestForm').reset();
|
||||
document.getElementById('workplaceId').innerHTML = '<option value="">분류를 먼저 선택하세요</option>';
|
||||
document.getElementById('visitorCount').value = '1';
|
||||
toggleRequestType();
|
||||
await loadMyRequests();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
@@ -146,6 +220,12 @@ function initVisitRequestPage() {
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
document.getElementById('visitDate').value = today;
|
||||
|
||||
// Request type toggle
|
||||
document.querySelectorAll('input[name="requestType"]').forEach(r => {
|
||||
r.addEventListener('change', toggleRequestType);
|
||||
});
|
||||
toggleRequestType();
|
||||
|
||||
// Category change -> load workplaces
|
||||
document.getElementById('categoryId').addEventListener('change', function() {
|
||||
loadWorkplaces(this.value);
|
||||
|
||||
Reference in New Issue
Block a user