feat: 대시보드 작업장 현황 지도 구현

- 실시간 작업장 현황을 지도로 시각화
- 작업장 관리 페이지에서 정의한 구역 정보 활용
- TBM 작업자 및 방문자 현황 표시

주요 변경사항:
- dashboard.html: 작업장 현황 섹션 추가 (기존 작업 현황 테이블 제거)
- workplace-status.js: 지도 렌더링 및 데이터 통합 로직 구현
- modern-dashboard.js: 삭제된 DOM 요소 조건부 체크 추가

시각화 방식:
- 인원 없음: 회색 테두리 + 작업장 이름
- 내부 작업자: 파란색 영역 + 인원 수
- 외부 방문자: 보라색 영역 + 인원 수
- 둘 다: 초록색 영역 + 총 인원 수

기술 구현:
- Canvas API 기반 사각형 영역 렌더링
- map-regions API를 통한 데이터 일관성 보장
- 클릭 이벤트로 상세 정보 모달 표시

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-01-29 15:46:47 +09:00
parent e1227a69fe
commit b6485e3140
87 changed files with 17509 additions and 698 deletions

View File

@@ -0,0 +1,447 @@
// 안전관리 대시보드 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/admin/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;