Files
TK-FB-Project/web-ui/js/safety-management.js
Hyungi Ahn 74d3a78aa3 feat: 페이지 구조 재구성 및 사이드바 네비게이션 구현
- 페이지 폴더 재구성: 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>
2026-02-02 14:27:22 +09:00

448 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 안전관리 대시보드 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;