Files
TK-FB-Project/web-ui/js/modern-dashboard.js
Hyungi Ahn 746e09420b feat: 캘린더 기반 작업 현황 확인 시스템 구현
- 월별 캘린더 UI로 작업 현황을 한눈에 확인 가능
- 미입력(빨강), 부분입력(주황), 확인필요(보라), 이상없음(초록) 상태 표시
- 범례 아이콘(●)을 사용한 직관적인 상태 표시
- 날짜 클릭 시 해당일 작업자별 상세 현황 모달
- 작업자 클릭 시 개별 작업 입력/수정 모달
- 휴가 처리 기능 (연차, 반차, 반반차, 조퇴)
- 월별 집계 데이터 최적화로 API 호출 최소화

백엔드:
- monthly_worker_status, monthly_summary 테이블 추가
- 자동 집계 stored procedure 및 trigger 구현
- 확인필요(12시간 초과) 상태 감지 로직
- 출석 관리 시스템 확장

프론트엔드:
- 캘린더 그리드 UI 구현
- 상태별 색상 및 아이콘 표시
- 모달 기반 상세 정보 표시
- 반응형 디자인 적용
2025-11-04 10:12:07 +09:00

1266 lines
43 KiB
JavaScript
Raw 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.
// ✅ modern-dashboard.js - 모던 대시보드 JavaScript
// API 설정 및 함수들은 api-config.js에서 로드됨
// window.API, window.apiCall, window.getAuthHeaders 사용
// 인증 관련 함수들
function getAuthData() {
const token = localStorage.getItem('token');
const user = localStorage.getItem('user');
return {
token,
user: user ? JSON.parse(user) : null
};
}
// 전역 변수
let currentUser = null;
let workersData = [];
let workData = [];
let selectedDate = new Date().toISOString().split('T')[0];
// 모달 관련 변수
let currentModalWorker = null;
let modalWorkTypes = [];
let modalWorkStatusTypes = [];
let modalErrorTypes = [];
let modalProjects = [];
let modalExistingWork = [];
// DOM 요소
const elements = {
currentTime: document.getElementById('currentTime'),
timeValue: document.getElementById('timeValue'),
userName: document.getElementById('userName'),
userRole: document.getElementById('userRole'),
userInitial: document.getElementById('userInitial'),
selectedDate: document.getElementById('selectedDate'),
refreshBtn: document.getElementById('refreshBtn'),
logoutBtn: document.getElementById('logoutBtn'),
// 요약 카드
todayWorkers: document.getElementById('todayWorkers'),
totalHours: document.getElementById('totalHours'),
activeProjects: document.getElementById('activeProjects'),
errorCount: document.getElementById('errorCount'),
// 컨테이너
workStatusContainer: document.getElementById('workStatusContainer'),
workersContainer: document.getElementById('workersContainer'),
toastContainer: document.getElementById('toastContainer')
};
// ========== 초기화 ========== //
document.addEventListener('DOMContentLoaded', async () => {
// API 함수가 로드될 때까지 기다림
let retryCount = 0;
const maxRetries = 50; // 5초 대기
while (!window.apiCall && retryCount < maxRetries) {
await new Promise(resolve => setTimeout(resolve, 100));
retryCount++;
}
if (!window.apiCall) {
console.error('❌ API 함수를 로드할 수 없습니다.');
showToast('시스템을 초기화할 수 없습니다. 페이지를 새로고침해주세요.', 'error');
return;
}
try {
await initializeDashboard();
} catch (error) {
console.error('대시보드 초기화 오류:', error);
showToast('대시보드를 불러오는 중 오류가 발생했습니다.', 'error');
}
});
async function initializeDashboard() {
console.log('🚀 모던 대시보드 초기화 시작');
// 사용자 정보 설정
setupUserInfo();
// 시간 업데이트 시작
updateCurrentTime();
setInterval(updateCurrentTime, 1000);
// 날짜 설정
elements.selectedDate.value = selectedDate;
// 이벤트 리스너 설정
setupEventListeners();
// 데이터 로드
await loadDashboardData();
// 관리자 권한 확인
checkAdminAccess();
console.log('✅ 모던 대시보드 초기화 완료');
}
// ========== 사용자 정보 설정 ========== //
function setupUserInfo() {
const authData = getAuthData();
if (authData && authData.user) {
currentUser = authData.user;
// 사용자 이름 설정
elements.userName.textContent = currentUser.name || currentUser.username;
// 사용자 역할 설정
const roleMap = {
'admin': '관리자',
'system': '시스템 관리자',
'group_leader': '그룹장',
'leader': '그룹장',
'user': '작업자'
};
elements.userRole.textContent = roleMap[currentUser.role] || '작업자';
// 아바타 초기값 설정
const initial = (currentUser.name || currentUser.username).charAt(0);
elements.userInitial.textContent = initial;
console.log('👤 사용자 정보 설정 완료:', currentUser.name);
}
}
// ========== 시간 업데이트 ========== //
function updateCurrentTime() {
const now = new Date();
const timeString = now.toLocaleTimeString('ko-KR', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
elements.timeValue.textContent = timeString;
}
// ========== 이벤트 리스너 ========== //
function setupEventListeners() {
// 날짜 변경
elements.selectedDate.addEventListener('change', (e) => {
selectedDate = e.target.value;
loadDashboardData();
});
// 새로고침 버튼
elements.refreshBtn.addEventListener('click', () => {
loadDashboardData();
showToast('데이터를 새로고침했습니다.', 'success');
});
// 로그아웃 버튼
elements.logoutBtn.addEventListener('click', () => {
if (confirm('로그아웃하시겠습니까?')) {
localStorage.clear();
window.location.href = '/index.html';
}
});
// 뷰 컨트롤 버튼들
const listViewBtn = document.getElementById('listViewBtn');
const cardViewBtn = document.getElementById('cardViewBtn');
if (listViewBtn) {
listViewBtn.addEventListener('click', () => {
displayWorkers(workersData, 'list');
updateViewButtons('list');
});
}
if (cardViewBtn) {
cardViewBtn.addEventListener('click', () => {
displayWorkers(workersData, 'card');
updateViewButtons('card');
});
}
}
// ========== 데이터 로드 ========== //
async function loadDashboardData() {
console.log('📊 대시보드 데이터 로딩 시작');
try {
// 로딩 상태 표시
showLoadingState();
// 병렬로 데이터 로드
const [workersResult, workResult] = await Promise.all([
loadWorkers(),
loadWorkData(selectedDate)
]);
// 요약 데이터 업데이트
updateSummaryCards();
// 작업 현황 표시
displayWorkStatus();
// 작업자 현황 표시
displayWorkers(workersData, 'card');
console.log('✅ 대시보드 데이터 로딩 완료');
} catch (error) {
console.error('❌ 대시보드 데이터 로딩 오류:', error);
showErrorState();
showToast('데이터를 불러오는 중 오류가 발생했습니다.', 'error');
}
}
async function loadWorkers() {
try {
console.log('👥 작업자 데이터 로딩...');
const response = await window.apiCall('/workers');
workersData = Array.isArray(response) ? response : (response.data || []);
console.log(`✅ 작업자 ${workersData.length}명 로드 완료`);
return workersData;
} catch (error) {
console.error('작업자 데이터 로딩 오류:', error);
workersData = [];
throw error;
}
}
async function loadWorkData(date) {
try {
console.log(`📋 ${date} 작업 데이터 로딩...`);
const response = await window.apiCall(`/daily-work-reports?date=${date}&view_all=true`);
workData = Array.isArray(response) ? response : (response.data || []);
console.log(`✅ 작업 데이터 ${workData.length}건 로드 완료`);
return workData;
} catch (error) {
console.error('작업 데이터 로딩 오류:', error);
workData = [];
throw error;
}
}
// ========== 요약 카드 업데이트 ========== //
function updateSummaryCards() {
// 오늘 작업자 수
const todayWorkersCount = new Set(workData.map(w => w.worker_id)).size;
updateSummaryCard(elements.todayWorkers, todayWorkersCount, '명');
// 총 작업 시간
const totalHours = workData.reduce((sum, work) => sum + parseFloat(work.work_hours || 0), 0);
updateSummaryCard(elements.totalHours, totalHours.toFixed(1), '시간');
// 진행 중인 프로젝트
const activeProjectsCount = new Set(workData.map(w => w.project_id)).size;
updateSummaryCard(elements.activeProjects, activeProjectsCount, '개');
// 오류 발생 건수
const errorCount = workData.filter(w => w.work_status_id === 2).length;
updateSummaryCard(elements.errorCount, errorCount, '건');
}
function updateSummaryCard(element, value, unit) {
if (element) {
const numberElement = element.querySelector('.value-number');
const unitElement = element.querySelector('.value-unit');
if (numberElement) numberElement.textContent = value;
if (unitElement) unitElement.textContent = unit;
}
}
// ========== 작업 현황 표시 (작업자 중심) ========== //
function displayWorkStatus() {
if (!elements.workStatusContainer) return;
// 모든 작업자 데이터 가져오기 (작업이 없는 작업자도 포함)
const allWorkers = workersData || [];
if (allWorkers.length === 0) {
elements.workStatusContainer.innerHTML = `
<div class="empty-state">
<div class="empty-icon">👥</div>
<h3>등록된 작업자가 없습니다</h3>
<p>시스템에 작업자가 등록되어 있지 않습니다.</p>
</div>
`;
return;
}
// 작업자별 상황 분석
const workerStatusList = allWorkers.map(worker => {
const todayWork = workData.filter(w => w.worker_id === worker.worker_id);
const totalHours = todayWork.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0);
// 휴가/연차 제외한 실제 작업시간 계산
const actualWorkHours = todayWork
.filter(w => w.project_id !== 13) // 연차/휴무 프로젝트 제외
.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0);
const hasError = todayWork.some(w => w.work_status_id === 2);
// 정규 작업과 에러 작업 건수 분리
const regularWorkCount = todayWork.filter(w => w.project_id !== 13 && w.work_status_id !== 2).length;
const errorWorkCount = todayWork.filter(w => w.project_id !== 13 && w.work_status_id === 2).length;
// 상태 판단 로직 (개선된 버전)
let status = 'incomplete';
let statusText = '미입력';
let statusBadge = '미입력';
let vacationType = null;
// 휴가 처리된 경우 확인 (프로젝트 ID 13 = "연차/휴무" 또는 설명에 휴가 키워드)
const hasVacationRecord = todayWork.some(w =>
w.project_id === 13 || // 연차/휴무 프로젝트
(w.description && (
w.description.includes('연차') ||
w.description.includes('반차') ||
w.description.includes('휴가')
))
);
// 연차/휴무 프로젝트의 시간 계산
const vacationHours = todayWork
.filter(w => w.project_id === 13)
.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0);
if (totalHours > 12) {
status = 'overtime-warning';
statusText = '초과근무 확인필요';
statusBadge = '확인필요';
} else if (hasVacationRecord && vacationHours > 0) {
// 연차/휴무 시간에 따른 상태 결정
if (vacationHours === 8) {
status = 'vacation-full';
statusText = '연차';
statusBadge = '연차';
} else if (vacationHours === 6) {
status = 'vacation-half-half';
statusText = '조퇴';
statusBadge = '조퇴';
} else if (vacationHours === 4) {
status = 'vacation-half';
statusText = '반차';
statusBadge = '반차';
} else if (vacationHours === 2) {
status = 'vacation-quarter';
statusText = '반반차';
statusBadge = '반반차';
}
} else if (totalHours > 8) {
// 8시간 초과 - 연장근로
status = 'overtime';
statusText = '연장근로';
statusBadge = '연장근로';
} else if (totalHours === 8) {
// 정확히 8시간 - 정시근로
status = 'complete';
statusText = '정시근로';
statusBadge = '정시근로';
} else if (totalHours > 0) {
// 0시간 초과 8시간 미만 - 부분 입력
status = 'partial';
statusText = '부분 입력';
statusBadge = '부분입력';
// 휴가 처리 필요 여부 판단
if (totalHours === 0) {
vacationType = 'full';
} else if (totalHours === 4) {
vacationType = 'half';
} else if (totalHours === 6) {
vacationType = 'half-half'; // 2시간 더 추가해서 조퇴 처리
}
} else {
// 0시간 - 미입력
status = 'incomplete';
statusText = '미입력';
statusBadge = '미입력';
vacationType = 'full';
}
return {
...worker,
todayWork,
totalHours,
actualWorkHours,
regularWorkCount,
errorWorkCount,
hasError,
status,
statusText,
statusBadge,
vacationType
};
});
elements.workStatusContainer.innerHTML = `
<div class="worker-status-list">
<div class="worker-status-header">
<div class="header-title">
<h3>작업자별 현황</h3>
<span class="header-date">${selectedDate}</span>
</div>
<div class="status-legend">
<span class="legend-item legend-complete">정시근로</span>
<span class="legend-item legend-overtime">연장근로</span>
<span class="legend-item legend-vacation">휴가</span>
<span class="legend-item legend-partial">부분입력</span>
<span class="legend-item legend-incomplete">미입력</span>
<span class="legend-item legend-error">오류</span>
</div>
</div>
<div class="worker-status-rows">
${workerStatusList.map(worker => `
<div class="worker-status-row ${worker.status}" data-worker-id="${worker.worker_id}">
<div class="worker-basic-info">
<div class="worker-avatar">
<span>${worker.worker_name.charAt(0)}</span>
</div>
<div class="worker-details">
<h4 class="worker-name">${worker.worker_name}</h4>
<p class="worker-job">${worker.job_type || '작업자'}</p>
</div>
</div>
<div class="worker-status-indicator">
<span class="status-badge status-${worker.status}">${worker.statusBadge}</span>
</div>
<div class="worker-stats-inline">
<div class="stat-item">
<span class="stat-label">작업시간</span>
<span class="stat-value ${worker.actualWorkHours > 12 ? 'warning' : ''}">${worker.actualWorkHours.toFixed(1)}h</span>
</div>
<div class="stat-item">
<span class="stat-label">정규</span>
<span class="stat-value">${worker.regularWorkCount}건</span>
</div>
${worker.errorWorkCount > 0 ? `
<div class="stat-item error">
<span class="stat-label">에러</span>
<span class="stat-value">${worker.errorWorkCount}건</span>
</div>
` : ''}
</div>
<div class="worker-actions-inline">
<button class="btn btn-sm btn-primary worker-edit-btn" onclick="openWorkerModal(${worker.worker_id}, '${worker.worker_name}')">
작업입력
</button>
${worker.vacationType ? `
<button class="btn btn-sm btn-secondary vacation-btn" onclick="handleVacation(${worker.worker_id}, '${worker.vacationType}')">
${worker.vacationType === 'full' ? '연차처리' : worker.vacationType === 'half' ? '반차처리' : '반반차처리'}
</button>
` : ''}
${worker.status === 'overtime-warning' ? `
<button class="btn btn-sm btn-warning confirm-overtime-btn" onclick="confirmOvertime(${worker.worker_id})">
정상확인
</button>
` : ''}
</div>
</div>
`).join('')}
</div>
</div>
`;
}
function groupWorkDataByProject() {
const groups = {};
workData.forEach(work => {
const projectName = work.project_name || '미지정 프로젝트';
if (!groups[projectName]) {
groups[projectName] = [];
}
groups[projectName].push(work);
});
return groups;
}
// ========== 작업자 현황 표시 ========== //
function displayWorkers(workers, viewType = 'card') {
if (!elements.workersContainer) return;
if (workers.length === 0) {
elements.workersContainer.innerHTML = `
<div class="empty-state">
<div class="empty-icon">👥</div>
<h3>작업자 데이터가 없습니다</h3>
<p>등록된 작업자가 없습니다.</p>
</div>
`;
return;
}
if (viewType === 'list') {
displayWorkersAsList(workers);
} else {
displayWorkersAsCards(workers);
}
}
function displayWorkersAsCards(workers) {
elements.workersContainer.innerHTML = `
<div class="workers-grid grid grid-cols-4">
${workers.map(worker => {
const todayWork = workData.filter(w => w.worker_id === worker.worker_id);
const totalHours = todayWork.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0);
// 휴가/연차 제외한 실제 작업시간 계산
const actualWorkHours = todayWork
.filter(w => w.project_id !== 13) // 연차/휴무 프로젝트 제외
.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0);
const hasError = todayWork.some(w => w.work_status_id === 2);
// 정규 작업과 에러 작업 건수 분리
const regularWorkCount = todayWork.filter(w => w.project_id !== 13 && w.work_status_id !== 2).length;
const errorWorkCount = todayWork.filter(w => w.project_id !== 13 && w.work_status_id === 2).length;
return `
<div class="worker-card card">
<div class="card-body">
<div class="worker-header">
<div class="worker-avatar">
<span>${worker.worker_name.charAt(0)}</span>
</div>
<div class="worker-info">
<h4 class="worker-name">${worker.worker_name}</h4>
<p class="worker-job">${worker.job_type || '작업자'}</p>
</div>
<div class="worker-status">
<span class="status-dot ${todayWork.length > 0 ? 'active' : 'inactive'}"></span>
</div>
</div>
<div class="worker-stats">
<div class="stat">
<span class="stat-label">작업시간</span>
<span class="stat-value">${actualWorkHours.toFixed(1)}h</span>
</div>
<div class="stat">
<span class="stat-label">정규</span>
<span class="stat-value">${regularWorkCount}건</span>
</div>
${errorWorkCount > 0 ? `
<div class="stat error">
<span class="stat-label">에러</span>
<span class="stat-value">${errorWorkCount}건</span>
</div>
` : ''}
</div>
</div>
</div>
`;
}).join('')}
</div>
`;
}
function displayWorkersAsList(workers) {
elements.workersContainer.innerHTML = `
<div class="workers-table">
<table class="table">
<thead>
<tr>
<th>작업자</th>
<th>직종</th>
<th>오늘 작업</th>
<th>작업 시간</th>
<th>상태</th>
</tr>
</thead>
<tbody>
${workers.map(worker => {
const todayWork = workData.filter(w => w.worker_id === worker.worker_id);
const totalHours = todayWork.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0);
const hasError = todayWork.some(w => w.work_status_id === 2);
return `
<tr>
<td>
<div class="worker-cell">
<div class="worker-avatar small">
<span>${worker.worker_name.charAt(0)}</span>
</div>
<span class="worker-name">${worker.worker_name}</span>
</div>
</td>
<td>${worker.job_type || '작업자'}</td>
<td>${todayWork.length}건</td>
<td>${totalHours.toFixed(1)}시간</td>
<td>
<span class="badge ${todayWork.length > 0 ? 'badge-success' : 'badge-gray'}">
${todayWork.length > 0 ? '작업 중' : '대기'}
</span>
${hasError ? '<span class="badge badge-error">오류</span>' : ''}
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
}
// ========== 뷰 버튼 업데이트 ========== //
function updateViewButtons(activeView) {
const listBtn = document.getElementById('listViewBtn');
const cardBtn = document.getElementById('cardViewBtn');
if (listBtn && cardBtn) {
listBtn.classList.toggle('btn-primary', activeView === 'list');
listBtn.classList.toggle('btn-secondary', activeView !== 'list');
cardBtn.classList.toggle('btn-primary', activeView === 'card');
cardBtn.classList.toggle('btn-secondary', activeView !== 'card');
}
}
// ========== 관리자 권한 확인 ========== //
function checkAdminAccess() {
const adminElements = document.querySelectorAll('.admin-only');
const isAdmin = currentUser && ['admin', 'system'].includes(currentUser.access_level);
adminElements.forEach(element => {
if (isAdmin) {
element.classList.add('visible');
}
});
}
// ========== 상태 표시 ========== //
function showLoadingState() {
const loadingHTML = `
<div class="loading-state">
<div class="spinner"></div>
<p>데이터를 불러오는 중...</p>
</div>
`;
if (elements.workStatusContainer) {
elements.workStatusContainer.innerHTML = loadingHTML;
}
if (elements.workersContainer) {
elements.workersContainer.innerHTML = loadingHTML;
}
}
function showErrorState() {
const errorHTML = `
<div class="error-state">
<div class="error-icon">⚠️</div>
<h3>데이터를 불러올 수 없습니다</h3>
<p>네트워크 연결을 확인하고 다시 시도해주세요.</p>
<button class="btn btn-primary" onclick="loadDashboardData()">
<span>🔄</span>
다시 시도
</button>
</div>
`;
if (elements.workStatusContainer) {
elements.workStatusContainer.innerHTML = errorHTML;
}
if (elements.workersContainer) {
elements.workersContainer.innerHTML = errorHTML;
}
}
// ========== 토스트 알림 ========== //
function showToast(message, type = 'info', duration = 3000) {
if (!elements.toastContainer) return;
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const iconMap = {
success: '✅',
error: '❌',
warning: '⚠️',
info: ''
};
toast.innerHTML = `
<div class="toast-icon">${iconMap[type] || ''}</div>
<div class="toast-message">${message}</div>
<button class="toast-close" onclick="this.parentElement.remove()">×</button>
`;
elements.toastContainer.appendChild(toast);
// 자동 제거
setTimeout(() => {
if (toast.parentElement) {
toast.remove();
}
}, duration);
}
// ========== 작업자 관련 액션 함수들 ========== //
function openWorkerModal(workerId, workerName) {
console.log(`📝 ${workerName}(ID: ${workerId}) 작업 보고서 모달 열기`);
// 모달 데이터 설정
currentModalWorker = {
id: workerId,
name: workerName,
date: selectedDate
};
// 모달 표시
showWorkerModal();
}
function handleVacation(workerId, vacationType) {
console.log(`🏖️ 작업자 ${workerId} 휴가 처리: ${vacationType}`);
const vacationNames = {
'full': '연차',
'half': '반차',
'half-half': '반반차'
};
const vacationHours = {
'full': 8,
'half': 4,
'half-half': 2
};
if (confirm(`${vacationNames[vacationType]} 처리하시겠습니까?\n(${vacationHours[vacationType]}시간으로 자동 입력됩니다)`)) {
// 휴가 처리 API 호출
processVacation(workerId, vacationType, vacationHours[vacationType]);
}
}
async function processVacation(workerId, vacationType, hours) {
try {
showToast(`휴가 처리 중...`, 'info');
// 휴가용 작업 보고서 생성 (특별한 작업 유형으로)
const vacationReport = {
report_date: selectedDate,
worker_id: workerId,
project_id: 1, // 기본 프로젝트 (휴가용)
work_type_id: 999, // 휴가 전용 작업 유형 (DB에 추가 필요)
work_status_id: 1, // 정상 상태
error_type_id: null,
work_hours: hours,
created_by: currentUser?.user_id || 1
};
const response = await window.apiCall(`${window.API}/daily-work-reports`, {
method: 'POST',
body: JSON.stringify(vacationReport)
});
showToast(`휴가 처리가 완료되었습니다.`, 'success');
await loadDashboardData(); // 데이터 새로고침
} catch (error) {
console.error('휴가 처리 오류:', error);
showToast(`휴가 처리 중 오류가 발생했습니다: ${error.message}`, 'error');
}
}
function confirmOvertime(workerId) {
console.log(`⚠️ 작업자 ${workerId} 초과근무 확인`);
if (confirm('12시간을 초과한 작업시간이 정상적인 입력인지 확인하시겠습니까?')) {
// 초과근무 확인 처리
processOvertimeConfirmation(workerId);
}
}
async function processOvertimeConfirmation(workerId) {
try {
showToast('초과근무 승인 처리 중...', 'info');
// 새로운 근태 관리 API 사용
const overtimeData = {
worker_id: workerId,
date: selectedDate
};
const response = await window.apiCall('/attendance/overtime/approve', 'POST', overtimeData);
if (response.success) {
showToast('초과근무가 정상으로 승인되었습니다.', 'success');
await loadDashboardData(); // 데이터 새로고침
} else {
throw new Error(response.message || '초과근무 승인에 실패했습니다.');
}
} catch (error) {
console.error('초과근무 승인 오류:', error);
showToast(`초과근무 승인 중 오류가 발생했습니다: ${error.message}`, 'error');
}
}
// ========== 모달 시스템 ========== //
function showWorkerModal() {
// 모달이 없으면 생성
if (!document.getElementById('workerModal')) {
createWorkerModal();
}
// 모달 데이터 로드 및 표시
loadModalData();
document.getElementById('workerModal').style.display = 'flex';
document.body.style.overflow = 'hidden'; // 배경 스크롤 방지
}
function hideWorkerModal() {
document.getElementById('workerModal').style.display = 'none';
document.body.style.overflow = 'auto'; // 배경 스크롤 복원
resetModalForm();
}
function createWorkerModal() {
const modalHTML = `
<div id="workerModal" class="modal-overlay">
<div class="modal-container">
<div class="modal-header">
<h2 id="modalTitle">작업자 보고서</h2>
<button class="modal-close-btn" onclick="hideWorkerModal()">×</button>
</div>
<div class="modal-body">
<!-- 작업자 정보 -->
<div class="modal-worker-info">
<div class="modal-worker-avatar">
<span id="modalWorkerInitial">작</span>
</div>
<div class="modal-worker-details">
<h3 id="modalWorkerName">작업자명</h3>
<p id="modalWorkerDate">날짜</p>
</div>
<div class="modal-worker-summary">
<div class="modal-stat">
<span class="modal-stat-label">총 시간</span>
<span class="modal-stat-value" id="modalTotalHours">0h</span>
</div>
<div class="modal-stat">
<span class="modal-stat-label">작업 건수</span>
<span class="modal-stat-value" id="modalWorkCount">0건</span>
</div>
</div>
</div>
<!-- 기존 작업 목록 -->
<div class="modal-section">
<div class="modal-section-header">
<h4>기존 작업 목록</h4>
<button class="btn btn-sm btn-primary" id="modalAddWorkBtn">새 작업 추가</button>
</div>
<div id="modalExistingWork" class="modal-existing-work">
<!-- 기존 작업들이 여기에 표시됩니다 -->
</div>
</div>
<!-- 새 작업 추가 폼 -->
<div class="modal-section" id="modalNewWorkSection" style="display: none;">
<div class="modal-section-header">
<h4>새 작업 추가</h4>
<button class="btn btn-sm btn-secondary" id="modalCancelWorkBtn">취소</button>
</div>
<div class="modal-work-form">
<div class="modal-form-row">
<div class="modal-form-group">
<label>프로젝트</label>
<select id="modalProjectSelect" class="modal-select">
<option value="">프로젝트 선택</option>
</select>
</div>
<div class="modal-form-group">
<label>작업 유형</label>
<select id="modalWorkTypeSelect" class="modal-select">
<option value="">작업 유형 선택</option>
</select>
</div>
</div>
<div class="modal-form-group">
<label>업무 상태</label>
<select id="modalWorkStatusSelect" class="modal-select">
<option value="">업무 상태 선택</option>
</select>
</div>
<div class="modal-form-group" id="modalErrorTypeGroup" style="display: none;">
<label>에러 유형</label>
<select id="modalErrorTypeSelect" class="modal-select">
<option value="">에러 유형 선택</option>
</select>
</div>
<div class="modal-form-group">
<label>작업 시간</label>
<input type="number" id="modalWorkHours" class="modal-input" step="0.25" min="0.25" max="24" value="1.00">
<div class="modal-quick-time">
<button type="button" class="modal-time-btn" data-hours="0.5">0.5h</button>
<button type="button" class="modal-time-btn" data-hours="1">1h</button>
<button type="button" class="modal-time-btn" data-hours="2">2h</button>
<button type="button" class="modal-time-btn" data-hours="4">4h</button>
<button type="button" class="modal-time-btn" data-hours="8">8h</button>
</div>
</div>
<button class="btn btn-success modal-save-btn" id="modalSaveWorkBtn">작업 저장</button>
</div>
</div>
<!-- 휴가 처리 -->
<div class="modal-section">
<div class="modal-section-header">
<h4>휴가 처리</h4>
</div>
<div class="modal-vacation-buttons">
<button class="btn btn-warning modal-vacation-btn" data-type="full">연차 (8시간)</button>
<button class="btn btn-warning modal-vacation-btn" data-type="half-half">반반차 (6시간)</button>
<button class="btn btn-warning modal-vacation-btn" data-type="half">반차 (4시간)</button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="hideWorkerModal()">닫기</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
setupModalEventListeners();
}
function setupModalEventListeners() {
// 모달 외부 클릭 시 닫기
document.getElementById('workerModal').addEventListener('click', (e) => {
if (e.target.id === 'workerModal') {
hideWorkerModal();
}
});
// 새 작업 추가/취소 버튼
document.getElementById('modalAddWorkBtn').addEventListener('click', showModalNewWorkForm);
document.getElementById('modalCancelWorkBtn').addEventListener('click', hideModalNewWorkForm);
document.getElementById('modalSaveWorkBtn').addEventListener('click', saveModalNewWork);
// 업무 상태 변경 시 에러 유형 토글
document.getElementById('modalWorkStatusSelect').addEventListener('change', toggleModalErrorType);
// 빠른 시간 버튼
document.querySelectorAll('.modal-time-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
document.getElementById('modalWorkHours').value = e.target.dataset.hours;
});
});
// 휴가 처리 버튼
document.querySelectorAll('.modal-vacation-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const vacationType = e.target.dataset.type;
handleModalVacation(vacationType);
});
});
}
async function loadModalData() {
if (!currentModalWorker) return;
try {
// 모달 헤더 업데이트
document.getElementById('modalTitle').textContent = `${currentModalWorker.name} 작업 보고서`;
document.getElementById('modalWorkerName').textContent = currentModalWorker.name;
document.getElementById('modalWorkerDate').textContent = currentModalWorker.date;
document.getElementById('modalWorkerInitial').textContent = currentModalWorker.name.charAt(0);
// 병렬로 데이터 로드
await Promise.all([
loadModalExistingWork(),
loadModalDropdownData()
]);
// UI 업데이트
updateModalSummary();
renderModalExistingWork();
populateModalDropdowns();
} catch (error) {
console.error('모달 데이터 로드 오류:', error);
showToast('데이터 로드 중 오류가 발생했습니다.', 'error');
}
}
async function loadModalExistingWork() {
try {
const response = await window.apiCall(`${window.API}/daily-work-reports?date=${currentModalWorker.date}&worker_id=${currentModalWorker.id}`);
modalExistingWork = Array.isArray(response) ? response : (response.data || []);
} catch (error) {
console.error('기존 작업 로드 오류:', error);
modalExistingWork = [];
}
}
async function loadModalDropdownData() {
try {
const [projectsRes, workTypesRes, workStatusRes, errorTypesRes] = await Promise.all([
window.apiCall(`${window.API}/projects`),
window.apiCall(`${window.API}/daily-work-reports/work-types`),
window.apiCall(`${window.API}/daily-work-reports/work-status-types`),
window.apiCall(`${window.API}/daily-work-reports/error-types`)
]);
modalProjects = Array.isArray(projectsRes) ? projectsRes : (projectsRes.data || []);
modalWorkTypes = Array.isArray(workTypesRes) ? workTypesRes : (workTypesRes.data || []);
modalWorkStatusTypes = Array.isArray(workStatusRes) ? workStatusRes : (workStatusRes.data || []);
modalErrorTypes = Array.isArray(errorTypesRes) ? errorTypesRes : (errorTypesRes.data || []);
} catch (error) {
console.error('드롭다운 데이터 로드 오류:', error);
}
}
function updateModalSummary() {
const totalHours = modalExistingWork.reduce((sum, work) => sum + parseFloat(work.work_hours || 0), 0);
const workCount = modalExistingWork.length;
document.getElementById('modalTotalHours').textContent = `${totalHours.toFixed(1)}h`;
document.getElementById('modalWorkCount').textContent = `${workCount}`;
// 12시간 초과 경고
if (totalHours > 12) {
document.getElementById('modalTotalHours').classList.add('warning');
} else {
document.getElementById('modalTotalHours').classList.remove('warning');
}
}
function renderModalExistingWork() {
const container = document.getElementById('modalExistingWork');
if (modalExistingWork.length === 0) {
container.innerHTML = `
<div class="modal-empty-state">
<div class="modal-empty-icon">—</div>
<p>등록된 작업이 없습니다.</p>
</div>
`;
return;
}
container.innerHTML = modalExistingWork.map(work => `
<div class="modal-work-item">
<div class="modal-work-info">
<h5>${work.project_name || '미지정 프로젝트'}</h5>
<p>${work.work_type_name || '미지정 작업'}</p>
${work.work_status_id === 2 && work.error_type_name ? `
<span class="modal-error-badge">${work.error_type_name}</span>
` : ''}
</div>
<div class="modal-work-actions">
<span class="modal-work-hours">${work.work_hours}h</span>
<button class="btn btn-xs btn-danger" onclick="deleteModalWork(${work.id})">삭제</button>
</div>
</div>
`).join('');
}
function populateModalDropdowns() {
// 프로젝트 드롭다운
const projectSelect = document.getElementById('modalProjectSelect');
projectSelect.innerHTML = '<option value="">프로젝트 선택</option>';
modalProjects.forEach(project => {
projectSelect.innerHTML += `<option value="${project.project_id}">${project.project_name}</option>`;
});
// 작업 유형 드롭다운
const workTypeSelect = document.getElementById('modalWorkTypeSelect');
workTypeSelect.innerHTML = '<option value="">작업 유형 선택</option>';
modalWorkTypes.forEach(type => {
workTypeSelect.innerHTML += `<option value="${type.id}">${type.name}</option>`;
});
// 작업 상태 드롭다운
const workStatusSelect = document.getElementById('modalWorkStatusSelect');
workStatusSelect.innerHTML = '<option value="">업무 상태 선택</option>';
modalWorkStatusTypes.forEach(status => {
workStatusSelect.innerHTML += `<option value="${status.id}">${status.name}</option>`;
});
// 에러 유형 드롭다운
const errorTypeSelect = document.getElementById('modalErrorTypeSelect');
errorTypeSelect.innerHTML = '<option value="">에러 유형 선택</option>';
modalErrorTypes.forEach(error => {
errorTypeSelect.innerHTML += `<option value="${error.id}">${error.name}</option>`;
});
}
function showModalNewWorkForm() {
document.getElementById('modalNewWorkSection').style.display = 'block';
document.getElementById('modalAddWorkBtn').style.display = 'none';
}
function hideModalNewWorkForm() {
document.getElementById('modalNewWorkSection').style.display = 'none';
document.getElementById('modalAddWorkBtn').style.display = 'block';
resetModalForm();
}
function resetModalForm() {
document.getElementById('modalProjectSelect').value = '';
document.getElementById('modalWorkTypeSelect').value = '';
document.getElementById('modalWorkStatusSelect').value = '';
document.getElementById('modalErrorTypeSelect').value = '';
document.getElementById('modalWorkHours').value = '1.00';
document.getElementById('modalErrorTypeGroup').style.display = 'none';
}
function toggleModalErrorType() {
const workStatusSelect = document.getElementById('modalWorkStatusSelect');
const errorTypeGroup = document.getElementById('modalErrorTypeGroup');
if (workStatusSelect.value === '2') { // 에러 상태
errorTypeGroup.style.display = 'block';
} else {
errorTypeGroup.style.display = 'none';
document.getElementById('modalErrorTypeSelect').value = '';
}
}
async function saveModalNewWork() {
try {
const projectId = document.getElementById('modalProjectSelect').value;
const workTypeId = document.getElementById('modalWorkTypeSelect').value;
const workStatusId = document.getElementById('modalWorkStatusSelect').value;
const errorTypeId = document.getElementById('modalErrorTypeSelect').value;
const workHours = document.getElementById('modalWorkHours').value;
// 유효성 검사
if (!projectId || !workTypeId || !workStatusId || !workHours) {
showToast('모든 필수 필드를 입력해주세요.', 'error');
return;
}
if (workStatusId === '2' && !errorTypeId) {
showToast('에러 상태일 때는 에러 유형을 선택해야 합니다.', 'error');
return;
}
const workData = {
report_date: currentModalWorker.date,
worker_id: currentModalWorker.id,
project_id: parseInt(projectId),
work_type_id: parseInt(workTypeId),
work_status_id: parseInt(workStatusId),
error_type_id: workStatusId === '2' ? parseInt(errorTypeId) : null,
work_hours: parseFloat(workHours),
created_by: currentUser?.user_id || 1
};
await window.apiCall(`${window.API}/daily-work-reports`, {
method: 'POST',
body: JSON.stringify(workData)
});
showToast('작업이 성공적으로 저장되었습니다.', 'success');
// 데이터 새로고침
await loadModalExistingWork();
updateModalSummary();
renderModalExistingWork();
hideModalNewWorkForm();
// 대시보드 데이터도 새로고침
await loadDashboardData();
} catch (error) {
console.error('작업 저장 오류:', error);
showToast(`작업 저장 중 오류가 발생했습니다: ${error.message}`, 'error');
}
}
async function deleteModalWork(workId) {
if (!confirm('이 작업을 삭제하시겠습니까?')) {
return;
}
try {
await window.apiCall(`${window.API}/daily-work-reports/${workId}`, {
method: 'DELETE'
});
showToast('작업이 성공적으로 삭제되었습니다.', 'success');
// 데이터 새로고침
await loadModalExistingWork();
updateModalSummary();
renderModalExistingWork();
// 대시보드 데이터도 새로고침
await loadDashboardData();
} catch (error) {
console.error('작업 삭제 오류:', error);
showToast(`작업 삭제 중 오류가 발생했습니다: ${error.message}`, 'error');
}
}
async function handleModalVacation(vacationType) {
const vacationTypeMap = {
'full': { code: 'ANNUAL_FULL', name: '연차', hours: 8 },
'half': { code: 'ANNUAL_HALF', name: '반차', hours: 4 },
'half-half': { code: 'ANNUAL_QUARTER', name: '반반차', hours: 2 }
};
const vacation = vacationTypeMap[vacationType];
if (!vacation) return;
if (!confirm(`${vacation.name} 처리하시겠습니까?\n(${vacation.hours}시간으로 자동 입력됩니다)`)) {
return;
}
try {
// 새로운 근태 관리 API 사용
const vacationData = {
worker_id: currentModalWorker.id,
date: currentModalWorker.date,
vacation_type: vacation.code
};
const response = await window.apiCall('/attendance/vacation', 'POST', vacationData);
if (response.success) {
showToast(`${vacation.name} 처리가 완료되었습니다.`, 'success');
// 데이터 새로고침
await loadModalExistingWork();
updateModalSummary();
renderModalExistingWork();
// 대시보드 데이터도 새로고침
await loadDashboardData();
} else {
throw new Error(response.message || '휴가 처리에 실패했습니다.');
}
} catch (error) {
console.error('휴가 처리 오류:', error);
showToast(`휴가 처리 중 오류가 발생했습니다: ${error.message}`, 'error');
}
}
// ========== 전역 함수 (HTML에서 호출) ========== //
window.loadDashboardData = loadDashboardData;
window.showToast = showToast;
window.updateSummaryCards = updateSummaryCards;
window.displayWorkers = displayWorkers;
window.openWorkerModal = openWorkerModal;
window.hideWorkerModal = hideWorkerModal;
window.deleteModalWork = deleteModalWork;
window.handleVacation = handleVacation;
window.confirmOvertime = confirmOvertime;