- 페이지 폴더 재구성: safety/, attendance/ 폴더 신규 생성 - work/ → safety/: 이슈 신고, 출입 신청 관련 페이지 이동 - common/ → attendance/: 근태/휴가 관련 페이지 이동 - admin/ 정리: safety-* 파일들을 safety/로 이동 - 사이드바 네비게이션 메뉴 구현 - 카테고리별 메뉴: 작업관리, 안전관리, 근태관리, 시스템관리 - 접기/펼치기 기능 및 상태 저장 - 관리자 전용 메뉴 자동 표시/숨김 - 날씨 API 연동 (기상청 단기예보) - TBM 및 navbar에 현재 날씨 표시 - weatherService.js 추가 - 안전 체크리스트 확장 - 기본/날씨별/작업별 체크 유형 추가 - checklist-manage.html 페이지 추가 - 이슈 신고 시스템 구현 - workIssueController, workIssueModel, workIssueRoutes 추가 - DB 마이그레이션 파일 추가 (실행 대기) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
448 lines
13 KiB
JavaScript
448 lines
13 KiB
JavaScript
// 안전관리 대시보드 JavaScript
|
||
|
||
let currentStatus = 'pending';
|
||
let requests = [];
|
||
let currentRejectRequestId = null;
|
||
|
||
// ==================== Toast 알림 ====================
|
||
|
||
function showToast(message, type = 'info', duration = 3000) {
|
||
const toastContainer = document.getElementById('toastContainer') || createToastContainer();
|
||
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast toast-${type}`;
|
||
|
||
const iconMap = {
|
||
success: '✅',
|
||
error: '❌',
|
||
warning: '⚠️',
|
||
info: 'ℹ️'
|
||
};
|
||
|
||
toast.innerHTML = `
|
||
<span class="toast-icon">${iconMap[type] || 'ℹ️'}</span>
|
||
<span class="toast-message">${message}</span>
|
||
`;
|
||
|
||
toastContainer.appendChild(toast);
|
||
|
||
setTimeout(() => toast.classList.add('show'), 10);
|
||
|
||
setTimeout(() => {
|
||
toast.classList.remove('show');
|
||
setTimeout(() => toast.remove(), 300);
|
||
}, duration);
|
||
}
|
||
|
||
function createToastContainer() {
|
||
const container = document.createElement('div');
|
||
container.id = 'toastContainer';
|
||
container.style.cssText = `
|
||
position: fixed;
|
||
top: 20px;
|
||
right: 20px;
|
||
z-index: 9999;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
`;
|
||
|
||
document.body.appendChild(container);
|
||
|
||
if (!document.getElementById('toastStyles')) {
|
||
const style = document.createElement('style');
|
||
style.id = 'toastStyles';
|
||
style.textContent = `
|
||
.toast {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px 20px;
|
||
background: white;
|
||
border-radius: 8px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||
opacity: 0;
|
||
transform: translateX(100px);
|
||
transition: all 0.3s ease;
|
||
min-width: 250px;
|
||
max-width: 400px;
|
||
}
|
||
.toast.show {
|
||
opacity: 1;
|
||
transform: translateX(0);
|
||
}
|
||
.toast-success { border-left: 4px solid #10b981; }
|
||
.toast-error { border-left: 4px solid #ef4444; }
|
||
.toast-warning { border-left: 4px solid #f59e0b; }
|
||
.toast-info { border-left: 4px solid #3b82f6; }
|
||
.toast-icon { font-size: 20px; }
|
||
.toast-message { font-size: 14px; color: #374151; }
|
||
`;
|
||
document.head.appendChild(style);
|
||
}
|
||
|
||
return container;
|
||
}
|
||
|
||
// ==================== 초기화 ====================
|
||
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
await loadRequests();
|
||
updateStats();
|
||
});
|
||
|
||
// ==================== 데이터 로드 ====================
|
||
|
||
/**
|
||
* 출입 신청 목록 로드
|
||
*/
|
||
async function loadRequests() {
|
||
try {
|
||
const filters = currentStatus === 'all' ? {} : { status: currentStatus };
|
||
const queryString = new URLSearchParams(filters).toString();
|
||
|
||
const response = await window.apiCall(`/workplace-visits/requests?${queryString}`, 'GET');
|
||
|
||
if (response && response.success) {
|
||
requests = response.data || [];
|
||
renderRequestTable();
|
||
updateStats();
|
||
}
|
||
} catch (error) {
|
||
console.error('출입 신청 목록 로드 오류:', error);
|
||
showToast('출입 신청 목록을 불러오는데 실패했습니다.', 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 통계 업데이트
|
||
*/
|
||
async function updateStats() {
|
||
try {
|
||
const response = await window.apiCall('/workplace-visits/requests', 'GET');
|
||
|
||
if (response && response.success) {
|
||
const allRequests = response.data || [];
|
||
|
||
const stats = {
|
||
pending: allRequests.filter(r => r.status === 'pending').length,
|
||
approved: allRequests.filter(r => r.status === 'approved').length,
|
||
training_completed: allRequests.filter(r => r.status === 'training_completed').length,
|
||
rejected: allRequests.filter(r => r.status === 'rejected').length
|
||
};
|
||
|
||
document.getElementById('statPending').textContent = stats.pending;
|
||
document.getElementById('statApproved').textContent = stats.approved;
|
||
document.getElementById('statTrainingCompleted').textContent = stats.training_completed;
|
||
document.getElementById('statRejected').textContent = stats.rejected;
|
||
}
|
||
} catch (error) {
|
||
console.error('통계 업데이트 오류:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 테이블 렌더링
|
||
*/
|
||
function renderRequestTable() {
|
||
const container = document.getElementById('requestTableContainer');
|
||
|
||
if (requests.length === 0) {
|
||
container.innerHTML = `
|
||
<div class="empty-state">
|
||
<div style="font-size: 48px; margin-bottom: 16px;">📭</div>
|
||
<h3>출입 신청이 없습니다</h3>
|
||
<p>현재 ${getStatusText(currentStatus)} 상태의 신청이 없습니다.</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
let html = `
|
||
<table class="request-table">
|
||
<thead>
|
||
<tr>
|
||
<th>신청일</th>
|
||
<th>신청자</th>
|
||
<th>방문자</th>
|
||
<th>인원</th>
|
||
<th>방문 작업장</th>
|
||
<th>방문 일시</th>
|
||
<th>목적</th>
|
||
<th>상태</th>
|
||
<th>작업</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
`;
|
||
|
||
requests.forEach(req => {
|
||
const statusText = {
|
||
'pending': '승인 대기',
|
||
'approved': '승인됨',
|
||
'rejected': '반려됨',
|
||
'training_completed': '교육 완료'
|
||
}[req.status] || req.status;
|
||
|
||
html += `
|
||
<tr>
|
||
<td>${new Date(req.created_at).toLocaleDateString()}</td>
|
||
<td>${req.requester_full_name || req.requester_name}</td>
|
||
<td>${req.visitor_company}</td>
|
||
<td>${req.visitor_count}명</td>
|
||
<td>${req.category_name} - ${req.workplace_name}</td>
|
||
<td>${req.visit_date} ${req.visit_time}</td>
|
||
<td>${req.purpose_name}</td>
|
||
<td><span class="status-badge ${req.status}">${statusText}</span></td>
|
||
<td>
|
||
<div class="action-buttons">
|
||
<button class="btn btn-sm btn-secondary" onclick="viewDetail(${req.request_id})">상세</button>
|
||
${req.status === 'pending' ? `
|
||
<button class="btn btn-sm btn-primary" onclick="approveRequest(${req.request_id})">승인</button>
|
||
<button class="btn btn-sm btn-danger" onclick="openRejectModal(${req.request_id})">반려</button>
|
||
` : ''}
|
||
${req.status === 'approved' ? `
|
||
<button class="btn btn-sm btn-primary" onclick="startTraining(${req.request_id})">교육 진행</button>
|
||
` : ''}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
|
||
html += `
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
/**
|
||
* 상태 텍스트 변환
|
||
*/
|
||
function getStatusText(status) {
|
||
const map = {
|
||
'pending': '승인 대기',
|
||
'approved': '승인 완료',
|
||
'rejected': '반려',
|
||
'training_completed': '교육 완료',
|
||
'all': '전체'
|
||
};
|
||
return map[status] || status;
|
||
}
|
||
|
||
// ==================== 탭 전환 ====================
|
||
|
||
/**
|
||
* 탭 전환
|
||
*/
|
||
async function switchTab(status) {
|
||
currentStatus = status;
|
||
|
||
// 탭 활성화 상태 변경
|
||
document.querySelectorAll('.status-tab').forEach(tab => {
|
||
if (tab.dataset.status === status) {
|
||
tab.classList.add('active');
|
||
} else {
|
||
tab.classList.remove('active');
|
||
}
|
||
});
|
||
|
||
await loadRequests();
|
||
}
|
||
|
||
// ==================== 상세보기 ====================
|
||
|
||
/**
|
||
* 상세보기 모달 열기
|
||
*/
|
||
async function viewDetail(requestId) {
|
||
try {
|
||
const response = await window.apiCall(`/workplace-visits/requests/${requestId}`, 'GET');
|
||
|
||
if (response && response.success) {
|
||
const req = response.data;
|
||
const statusText = {
|
||
'pending': '승인 대기',
|
||
'approved': '승인됨',
|
||
'rejected': '반려됨',
|
||
'training_completed': '교육 완료'
|
||
}[req.status] || req.status;
|
||
|
||
let html = `
|
||
<div class="detail-grid">
|
||
<div class="detail-label">신청 번호</div>
|
||
<div class="detail-value">#${req.request_id}</div>
|
||
|
||
<div class="detail-label">신청일</div>
|
||
<div class="detail-value">${new Date(req.created_at).toLocaleString()}</div>
|
||
|
||
<div class="detail-label">신청자</div>
|
||
<div class="detail-value">${req.requester_full_name || req.requester_name}</div>
|
||
|
||
<div class="detail-label">방문자 소속</div>
|
||
<div class="detail-value">${req.visitor_company}</div>
|
||
|
||
<div class="detail-label">방문 인원</div>
|
||
<div class="detail-value">${req.visitor_count}명</div>
|
||
|
||
<div class="detail-label">방문 구역</div>
|
||
<div class="detail-value">${req.category_name}</div>
|
||
|
||
<div class="detail-label">방문 작업장</div>
|
||
<div class="detail-value">${req.workplace_name}</div>
|
||
|
||
<div class="detail-label">방문 날짜</div>
|
||
<div class="detail-value">${req.visit_date}</div>
|
||
|
||
<div class="detail-label">방문 시간</div>
|
||
<div class="detail-value">${req.visit_time}</div>
|
||
|
||
<div class="detail-label">방문 목적</div>
|
||
<div class="detail-value">${req.purpose_name}</div>
|
||
|
||
<div class="detail-label">상태</div>
|
||
<div class="detail-value"><span class="status-badge ${req.status}">${statusText}</span></div>
|
||
</div>
|
||
`;
|
||
|
||
if (req.notes) {
|
||
html += `
|
||
<div style="margin-top: 16px; padding: 12px; background: var(--gray-50); border-radius: var(--radius-md);">
|
||
<strong>비고:</strong><br>
|
||
${req.notes}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
if (req.rejection_reason) {
|
||
html += `
|
||
<div style="margin-top: 16px; padding: 12px; background: var(--red-50); border-radius: var(--radius-md); color: var(--red-700);">
|
||
<strong>반려 사유:</strong><br>
|
||
${req.rejection_reason}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
if (req.approved_by) {
|
||
html += `
|
||
<div style="margin-top: 16px; padding: 12px; background: var(--blue-50); border-radius: var(--radius-md);">
|
||
<strong>처리 정보:</strong><br>
|
||
처리자: ${req.approver_name || 'Unknown'}<br>
|
||
처리 시간: ${new Date(req.approved_at).toLocaleString()}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
document.getElementById('detailContent').innerHTML = html;
|
||
document.getElementById('detailModal').style.display = 'flex';
|
||
}
|
||
} catch (error) {
|
||
console.error('상세 정보 로드 오류:', error);
|
||
showToast('상세 정보를 불러오는데 실패했습니다.', 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 상세보기 모달 닫기
|
||
*/
|
||
function closeDetailModal() {
|
||
document.getElementById('detailModal').style.display = 'none';
|
||
}
|
||
|
||
// ==================== 승인/반려 ====================
|
||
|
||
/**
|
||
* 승인 처리
|
||
*/
|
||
async function approveRequest(requestId) {
|
||
if (!confirm('이 출입 신청을 승인하시겠습니까?')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await window.apiCall(`/workplace-visits/requests/${requestId}/approve`, 'PUT');
|
||
|
||
if (response && response.success) {
|
||
showToast('출입 신청이 승인되었습니다.', 'success');
|
||
await loadRequests();
|
||
updateStats();
|
||
} else {
|
||
throw new Error(response?.message || '승인 실패');
|
||
}
|
||
} catch (error) {
|
||
console.error('승인 처리 오류:', error);
|
||
showToast(error.message || '승인 처리 중 오류가 발생했습니다.', 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 반려 모달 열기
|
||
*/
|
||
function openRejectModal(requestId) {
|
||
currentRejectRequestId = requestId;
|
||
document.getElementById('rejectionReason').value = '';
|
||
document.getElementById('rejectModal').style.display = 'flex';
|
||
}
|
||
|
||
/**
|
||
* 반려 모달 닫기
|
||
*/
|
||
function closeRejectModal() {
|
||
currentRejectRequestId = null;
|
||
document.getElementById('rejectModal').style.display = 'none';
|
||
}
|
||
|
||
/**
|
||
* 반려 확정
|
||
*/
|
||
async function confirmReject() {
|
||
const reason = document.getElementById('rejectionReason').value.trim();
|
||
|
||
if (!reason) {
|
||
showToast('반려 사유를 입력해주세요.', 'warning');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await window.apiCall(
|
||
`/workplace-visits/requests/${currentRejectRequestId}/reject`,
|
||
'PUT',
|
||
{ rejection_reason: reason }
|
||
);
|
||
|
||
if (response && response.success) {
|
||
showToast('출입 신청이 반려되었습니다.', 'success');
|
||
closeRejectModal();
|
||
await loadRequests();
|
||
updateStats();
|
||
} else {
|
||
throw new Error(response?.message || '반려 실패');
|
||
}
|
||
} catch (error) {
|
||
console.error('반려 처리 오류:', error);
|
||
showToast(error.message || '반려 처리 중 오류가 발생했습니다.', 'error');
|
||
}
|
||
}
|
||
|
||
// ==================== 안전교육 진행 ====================
|
||
|
||
/**
|
||
* 안전교육 진행 페이지로 이동
|
||
*/
|
||
function startTraining(requestId) {
|
||
window.location.href = `/pages/safety/training-conduct.html?request_id=${requestId}`;
|
||
}
|
||
|
||
// 전역 함수로 노출
|
||
window.showToast = showToast;
|
||
window.switchTab = switchTab;
|
||
window.viewDetail = viewDetail;
|
||
window.closeDetailModal = closeDetailModal;
|
||
window.approveRequest = approveRequest;
|
||
window.openRejectModal = openRejectModal;
|
||
window.closeRejectModal = closeRejectModal;
|
||
window.confirmReject = confirmReject;
|
||
window.startTraining = startTraining;
|