Files
TK-FB-Project/web-ui/js/work-report-calendar.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

1109 lines
37 KiB
JavaScript

// 작업 현황 캘린더 JavaScript
// 전역 변수
let currentDate = new Date();
let monthlyData = {}; // 월별 데이터 캐시
// 작업자 데이터는 allWorkers 변수 사용
let currentModalDate = null;
// DOM 요소
const elements = {
monthYearTitle: null,
calendarDays: null,
prevMonthBtn: null,
nextMonthBtn: null,
todayBtn: null,
dailyWorkModal: null,
modalTitle: null,
modalTotalWorkers: null,
modalTotalHours: null,
modalTotalTasks: null,
modalErrorCount: null,
modalWorkersList: null,
statusFilter: null,
loadingSpinner: null
};
// 초기화
document.addEventListener('DOMContentLoaded', async function() {
console.log('🚀 작업 현황 캘린더 초기화 시작');
// DOM 요소 초기화
initializeElements();
// 이벤트 리스너 등록
setupEventListeners();
// 작업자 데이터 로드 (한 번만)
await loadWorkersData();
// 현재 월 캘린더 렌더링
await renderCalendar();
console.log('✅ 작업 현황 캘린더 초기화 완료');
});
// DOM 요소 초기화
function initializeElements() {
elements.monthYearTitle = document.getElementById('monthYearTitle');
elements.calendarDays = document.getElementById('calendarDays');
elements.prevMonthBtn = document.getElementById('prevMonthBtn');
elements.nextMonthBtn = document.getElementById('nextMonthBtn');
elements.todayBtn = document.getElementById('todayBtn');
elements.dailyWorkModal = document.getElementById('dailyWorkModal');
elements.modalTitle = document.getElementById('modalTitle');
elements.modalSummary = document.querySelector('.daily-summary'); // 요약 섹션
elements.modalTotalWorkers = document.getElementById('modalTotalWorkers');
elements.modalTotalHours = document.getElementById('modalTotalHours');
elements.modalTotalTasks = document.getElementById('modalTotalTasks');
elements.modalErrorCount = document.getElementById('modalErrorCount');
elements.modalWorkersList = document.getElementById('modalWorkersList');
elements.modalNoData = document.getElementById('modalNoData');
elements.statusFilter = document.getElementById('statusFilter');
elements.loadingSpinner = document.getElementById('loadingSpinner');
}
// 이벤트 리스너 설정
function setupEventListeners() {
elements.prevMonthBtn.addEventListener('click', () => {
currentDate.setMonth(currentDate.getMonth() - 1);
renderCalendar();
});
elements.nextMonthBtn.addEventListener('click', () => {
currentDate.setMonth(currentDate.getMonth() + 1);
renderCalendar();
});
elements.todayBtn.addEventListener('click', () => {
currentDate = new Date();
renderCalendar();
});
elements.statusFilter.addEventListener('change', filterWorkersList);
// 모달 외부 클릭 시 닫기
elements.dailyWorkModal.addEventListener('click', (e) => {
if (e.target === elements.dailyWorkModal) {
closeDailyWorkModal();
}
});
// ESC 키로 모달 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && elements.dailyWorkModal.style.display !== 'none') {
closeDailyWorkModal();
}
});
}
// 작업자 데이터 로드 (캐시)
async function loadWorkersData() {
if (allWorkers.length > 0) return allWorkers;
try {
console.log('👥 작업자 데이터 로딩...');
const response = await window.apiCall('/workers');
allWorkers = Array.isArray(response) ? response : (response.data || []);
console.log(`✅ 작업자 ${allWorkers.length}명 로드 완료`);
return allWorkers;
} catch (error) {
console.error('작업자 데이터 로딩 오류:', error);
showToast('작업자 데이터를 불러오는데 실패했습니다.', 'error');
return [];
}
}
// 월별 작업 데이터 로드 (집계 테이블 사용으로 최적화)
async function loadMonthlyWorkData(year, month) {
const monthKey = `${year}-${String(month + 1).padStart(2, '0')}`;
if (monthlyData[monthKey]) {
console.log(`📋 캐시된 ${monthKey} 데이터 사용`);
return monthlyData[monthKey];
}
try {
console.log(`📋 ${monthKey} 집계 데이터 로딩...`);
// 새로운 월별 집계 API 사용 (단일 호출)
const response = await window.apiCall(`/monthly-status/calendar?year=${year}&month=${month + 1}`);
if (response.success) {
const calendarData = response.data;
console.log(`📊 ${monthKey} 집계 데이터:`, Object.keys(calendarData).length, '일');
// 날짜별 상태 데이터로 변환
const monthData = {};
// 해당 월의 모든 날짜 초기화
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const currentDay = new Date(firstDay);
while (currentDay <= lastDay) {
const dateStr = currentDay.toISOString().split('T')[0];
if (calendarData[dateStr]) {
// 집계 데이터가 있는 경우
const dayData = calendarData[dateStr];
monthData[dateStr] = {
hasData: dayData.workingWorkers > 0,
hasIssues: dayData.hasIssues,
hasErrors: dayData.hasErrors,
hasOvertimeWarning: dayData.hasOvertimeWarning,
totalWorkers: dayData.totalWorkers,
workerCount: dayData.totalWorkers,
workingWorkers: dayData.workingWorkers,
incompleteWorkers: dayData.incompleteWorkers,
partialWorkers: dayData.partialWorkers,
errorWorkers: dayData.errorWorkers,
overtimeWarningWorkers: dayData.overtimeWarningWorkers,
totalHours: dayData.totalHours,
totalTasks: dayData.totalTasks,
errorCount: dayData.errorCount,
lastUpdated: dayData.lastUpdated
};
} else {
// 집계 데이터가 없는 경우 (작업 없음)
monthData[dateStr] = {
hasData: false,
hasIssues: false,
hasErrors: false,
workerCount: 0,
workingWorkers: 0,
incompleteWorkers: 0,
partialWorkers: 0,
errorWorkers: 0,
totalHours: 0,
totalTasks: 0,
errorCount: 0
};
}
currentDay.setDate(currentDay.getDate() + 1);
}
// 캐시에 저장
monthlyData[monthKey] = monthData;
console.log(`${monthKey} 집계 데이터 로드 완료 (${Object.keys(monthData).length}일 데이터)`);
console.log('📊 월별 데이터 샘플:', Object.entries(monthData).slice(0, 5));
return monthData;
} else {
throw new Error(response.message || '집계 데이터 조회 실패');
}
} catch (error) {
console.error(`${monthKey} 집계 데이터 로딩 오류:`, error);
// 폴백: 기존 방식으로 순차 로딩
console.log(`📋 폴백: ${monthKey} 기존 방식 로딩 시작...`);
return await loadMonthlyWorkDataFallback(year, month);
}
}
// 폴백: 순차적 로딩 (지연 시간 포함)
async function loadMonthlyWorkDataFallback(year, month) {
const monthKey = `${year}-${String(month + 1).padStart(2, '0')}`;
const monthData = {};
try {
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const currentDay = new Date(firstDay);
let loadedCount = 0;
const totalDays = lastDay.getDate();
while (currentDay <= lastDay) {
const dateStr = currentDay.toISOString().split('T')[0];
try {
const response = await window.apiCall(`/daily-work-reports?date=${dateStr}&view_all=true`);
monthData[dateStr] = Array.isArray(response) ? response : (response.data || []);
loadedCount++;
// 진행률 표시
if (loadedCount % 5 === 0) {
console.log(`📋 ${monthKey} 로딩 진행률: ${loadedCount}/${totalDays}`);
}
// API 부하 방지를 위한 지연 (100ms)
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
console.warn(`${dateStr} 데이터 로딩 실패:`, error.message);
monthData[dateStr] = [];
}
currentDay.setDate(currentDay.getDate() + 1);
}
// 캐시에 저장
monthlyData[monthKey] = monthData;
console.log(`${monthKey} 순차 로딩 완료 (${loadedCount}/${totalDays}일)`);
return monthData;
} catch (error) {
console.error(`${monthKey} 순차 로딩 오류:`, error);
showToast('작업 데이터를 불러오는데 실패했습니다.', 'error');
return {};
}
}
// 캘린더 렌더링
async function renderCalendar() {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
// 헤더 업데이트
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월',
'7월', '8월', '9월', '10월', '11월', '12월'];
const monthText = `${year}${monthNames[month]}`;
elements.monthYearTitle.textContent = monthText;
// 로딩 표시
showLoading(true);
try {
// 월별 데이터 로드
const monthData = await loadMonthlyWorkData(year, month);
// 캘린더 날짜 생성
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - firstDay.getDay()); // 주의 시작일 (일요일)
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
let calendarHTML = '';
const currentDay = new Date(startDate);
// 6주 * 7일 = 42일 렌더링
for (let i = 0; i < 42; i++) {
// 로컬 시간대로 날짜 문자열 생성 (UTC 변환 문제 방지)
const year = currentDay.getFullYear();
const month_num = String(currentDay.getMonth() + 1).padStart(2, '0');
const day_num = String(currentDay.getDate()).padStart(2, '0');
const dateStr = `${year}-${month_num}-${day_num}`;
const dayNumber = currentDay.getDate();
const isCurrentMonth = currentDay.getMonth() === month;
const isToday = dateStr === todayStr;
const isSunday = currentDay.getDay() === 0;
const isSaturday = currentDay.getDay() === 6;
// 해당 날짜의 작업 데이터 (집계 데이터 구조)
let dayWorkData = monthData[dateStr] || {
hasData: false,
hasIssues: false,
hasErrors: false,
workerCount: 0
};
// 실제 데이터 사용 (테스트 데이터 제거)
const dayStatus = analyzeDayStatus(dayWorkData);
// 디버깅: 상태가 있는 날짜만 로그
if (dayStatus.hasData || dayStatus.hasIssues || dayStatus.hasIncomplete || dayStatus.hasOvertimeWarning) {
let statusText = '이상없음';
if (dayStatus.hasOvertimeWarning) statusText = '확인필요';
else if (dayStatus.hasIncomplete) statusText = '미입력';
else if (dayStatus.hasIssues) statusText = '부분입력';
console.log(`📅 ${dateStr} (${dayNumber}일):`, {
상태: statusText,
작업자수: dayStatus.workerCount,
dayStatus,
원본데이터: dayWorkData
});
}
let dayClasses = ['calendar-day'];
if (!isCurrentMonth) dayClasses.push('other-month');
if (isToday) dayClasses.push('today');
if (isSunday) dayClasses.push('sunday');
if (isSaturday) dayClasses.push('saturday');
if (isSunday || isSaturday) dayClasses.push('weekend');
// 문제가 있는지 확인
const hasAnyProblem = dayStatus.hasOvertimeWarning || dayStatus.hasIncomplete || dayStatus.hasIssues;
// 문제가 없으면 초록색 배경
if (dayStatus.hasData && !hasAnyProblem) {
dayClasses.push('has-normal'); // 이상없음 (초록)
}
// 문제가 있으면 범례 아이콘들을 그대로 표시
let statusIcons = '';
if (hasAnyProblem) {
// 범례와 동일한 아이콘들 표시
if (dayStatus.hasOvertimeWarning) {
statusIcons += '<div class="legend-icon purple">●</div>';
}
if (dayStatus.hasIncomplete) {
statusIcons += '<div class="legend-icon red">●</div>';
}
if (dayStatus.hasIssues) {
statusIcons += '<div class="legend-icon orange">●</div>';
}
}
calendarHTML += `
<div class="${dayClasses.join(' ')}" onclick="openDailyWorkModal('${dateStr}')">
<div class="day-number">${dayNumber}</div>
${statusIcons}
</div>
`;
currentDay.setDate(currentDay.getDate() + 1);
}
elements.calendarDays.innerHTML = calendarHTML;
} catch (error) {
console.error('캘린더 렌더링 오류:', error);
showToast('캘린더를 불러오는데 실패했습니다.', 'error');
} finally {
showLoading(false);
}
}
// 일별 상태 분석 (집계 데이터 또는 원본 데이터 처리)
function analyzeDayStatus(dayData) {
// 새로운 집계 데이터 구조인지 확인 (monthly_summary에서 온 데이터)
if (dayData && typeof dayData === 'object' && 'totalWorkers' in dayData) {
// 미입력 판단: allWorkers 배열 길이와 실제 작업한 작업자 수 비교
const totalRegisteredWorkers = allWorkers ? allWorkers.length : 10; // 실제 등록된 작업자 수
const actualIncompleteWorkers = Math.max(0, totalRegisteredWorkers - dayData.workingWorkers);
const result = {
hasData: dayData.totalWorkers > 0,
hasIssues: dayData.partialWorkers > 0, // 부분입력 작업자가 있으면 true
hasIncomplete: actualIncompleteWorkers > 0 || dayData.incompleteWorkers > 0, // 실제 미입력 작업자가 있으면 true
hasOvertimeWarning: dayData.hasOvertimeWarning || dayData.overtimeWarningWorkers > 0, // 12시간 초과
workerCount: dayData.totalWorkers || 0
};
// 디버깅: 모든 데이터 로그 (미입력 문제 해결용)
console.log('📊 analyzeDayStatus 결과:', {
dayData,
result,
actualIncompleteWorkers,
workingWorkers: dayData.workingWorkers,
totalRegisteredWorkers: totalRegisteredWorkers,
allWorkersLength: allWorkers ? allWorkers.length : 'undefined'
});
return result;
}
// 기존 hasData 구조 확인
if (dayData && typeof dayData === 'object' && dayData.hasData !== undefined) {
return {
hasData: dayData.hasData,
hasIssues: dayData.hasIssues,
hasErrors: dayData.hasErrors,
workerCount: dayData.workerCount || 0
};
}
// 폴백: 기존 방식으로 분석 (원본 작업 데이터 배열)
if (!Array.isArray(dayData) || dayData.length === 0) {
return {
hasData: false,
hasIssues: false,
hasErrors: false,
workerCount: 0
};
}
// 작업자별로 그룹화
const workerGroups = {};
dayData.forEach(work => {
if (!workerGroups[work.worker_id]) {
workerGroups[work.worker_id] = [];
}
workerGroups[work.worker_id].push(work);
});
const workerCount = Object.keys(workerGroups).length;
let hasIssues = false;
let hasErrors = false;
// 각 작업자의 상태 분석 - 문제가 있는지만 확인
Object.values(workerGroups).forEach(workerWork => {
const totalHours = workerWork.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0);
const hasError = workerWork.some(w => w.work_status_id === 2);
const hasVacation = workerWork.some(w => w.project_id === 13);
// 오류가 있는 경우
if (hasError) {
hasErrors = true;
}
// 휴가가 아닌데 미입력이거나 부분입력인 경우
else if (!hasVacation && (totalHours === 0 || totalHours < 8)) {
hasIssues = true;
}
});
return {
hasData: true,
hasIssues,
hasErrors,
workerCount
};
}
// 일일 작업 현황 모달 열기
async function openDailyWorkModal(dateStr) {
console.log(`🗓️ 클릭된 날짜: ${dateStr}`);
currentModalDate = dateStr;
// 날짜 포맷팅
const date = new Date(dateStr + 'T00:00:00');
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
console.log(`📅 파싱된 날짜: ${year}${month}${day}`);
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
const dayName = dayNames[date.getDay()];
elements.modalTitle.textContent = `${year}${month}${day}일 (${dayName}) 작업 현황`;
try {
// 새로운 집계 API로 작업자별 상세 정보 조회
const response = await window.apiCall(`/monthly-status/daily-details?date=${dateStr}`);
if (response.success) {
const { workers, summary } = response.data;
renderModalDataFromSummary(workers, summary);
} else {
// 폴백: 기존 API 사용
console.log('집계 API 실패, 기존 API로 폴백');
const fallbackResponse = await window.apiCall(`/daily-work-reports?date=${dateStr}&view_all=true`);
const workData = Array.isArray(fallbackResponse) ? fallbackResponse : (fallbackResponse.data || []);
renderModalData(workData);
}
// 모달 표시
elements.dailyWorkModal.style.display = 'flex';
document.body.style.overflow = 'hidden';
} catch (error) {
console.error('일일 작업 데이터 로딩 오류:', error);
showToast('해당 날짜의 작업 데이터를 불러오는데 실패했습니다.', 'error');
}
}
// 집계 데이터로 모달 렌더링 (최적화된 버전)
async function renderModalDataFromSummary(workers, summary) {
// 전체 작업자 목록 가져오기
const allWorkers = await loadWorkersData();
// 작업한 작업자 ID 목록
const workedWorkerIds = new Set(workers.map(w => w.workerId));
// 미기입 작업자 추가 (대시보드와 동일한 상태 판단 로직 적용)
const missingWorkers = allWorkers
.filter(worker => !workedWorkerIds.has(worker.worker_id))
.map(worker => {
return {
workerId: worker.worker_id,
workerName: worker.worker_name,
jobType: worker.job_type,
totalHours: 0,
actualWorkHours: 0,
vacationHours: 0,
totalWorkCount: 0,
regularWorkCount: 0,
errorWorkCount: 0,
status: 'incomplete',
hasVacation: false,
hasError: false,
hasIssues: true
};
});
// 전체 작업자 목록 (작업한 사람 + 미기입 사람)
const allWorkersList = [...workers, ...missingWorkers];
// 요약 정보 업데이트 (전체 작업자 수 포함)
if (elements.modalTotalWorkers) {
elements.modalTotalWorkers.textContent = `${allWorkersList.length}`;
}
if (elements.modalTotalHours) {
elements.modalTotalHours.textContent = `${summary.totalHours.toFixed(1)}h`;
}
if (elements.modalTotalTasks) {
elements.modalTotalTasks.textContent = `${summary.totalTasks}`;
}
if (elements.modalErrorCount) {
elements.modalErrorCount.textContent = `${summary.errorCount}`;
elements.modalErrorCount.className = summary.errorCount > 0 ? 'summary-value error' : 'summary-value';
}
// 작업자 리스트 렌더링
if (allWorkersList.length === 0) {
elements.modalWorkersList.innerHTML = '<div class="empty-state">등록된 작업자가 없습니다.</div>';
return;
}
const workersHtml = allWorkersList.map(worker => {
// 상태 텍스트 및 색상 결정 (에러가 있어도 작업시간 기준으로 판단)
let statusText = '미입력';
let statusClass = 'incomplete';
// 에러 여부와 관계없이 작업시간 기준으로 상태 결정
const totalHours = worker.totalHours || 0;
const hasVacation = worker.hasVacation || false;
const vacationHours = worker.vacationHours || 0;
if (totalHours > 12) {
statusText = '확인필요'; statusClass = 'overtime-warning';
} else if (hasVacation && vacationHours > 0) {
switch (vacationHours) {
case 8: statusText = '연차'; statusClass = 'vacation-full'; break;
case 6: statusText = '조퇴'; statusClass = 'vacation-half-half'; break;
case 4: statusText = '반차'; statusClass = 'vacation-half'; break;
case 2: statusText = '반반차'; statusClass = 'vacation-quarter'; break;
default: statusText = '연차'; statusClass = 'vacation-full';
}
} else if (totalHours > 8) {
statusText = '연장근로'; statusClass = 'overtime';
} else if (totalHours === 8) {
statusText = '정시근로'; statusClass = 'complete';
} else if (totalHours > 0) {
statusText = '부분입력'; statusClass = 'partial';
} else {
statusText = '미입력'; statusClass = 'incomplete';
}
// 작업자 이름의 첫 글자 추출
const initial = worker.workerName ? worker.workerName.charAt(0) : '?';
return `
<div class="worker-card ${statusClass}" onclick="openWorkerModal(${worker.workerId}, '${currentModalDate}')">
<div class="worker-avatar">
<div class="avatar-circle">
<span class="avatar-text">${initial}</span>
</div>
</div>
<div class="worker-info">
<div class="worker-name">${worker.workerName}</div>
<div class="worker-job">${worker.jobType || '일반'}</div>
</div>
<div class="worker-status">
<div class="status-badge ${statusClass}">${statusText}</div>
</div>
<div class="worker-stats">
<div class="stat-row">
<span class="stat-label">작업시간</span>
<span class="stat-value">${worker.actualWorkHours.toFixed(1)}h</span>
</div>
<div class="stat-row">
<span class="stat-label">정규</span>
<span class="stat-value">${worker.regularWorkCount}건</span>
<span class="stat-label">에러</span>
<span class="stat-value ${worker.errorWorkCount > 0 ? 'error' : ''}">${worker.errorWorkCount}건</span>
</div>
</div>
<div class="worker-actions">
<button class="btn-work-entry" onclick="event.stopPropagation(); openWorkerModal(${worker.workerId}, '${currentModalDate}')" title="작업입력">
작업입력
</button>
</div>
</div>
`;
}).join('');
elements.modalWorkersList.innerHTML = workersHtml;
}
// 모달 데이터 렌더링 (폴백용 - 기존 방식)
function renderModalData(workData) {
// 작업자별로 그룹화
const workerGroups = {};
workData.forEach(work => {
if (!workerGroups[work.worker_id]) {
workerGroups[work.worker_id] = {
worker_id: work.worker_id,
worker_name: work.worker_name,
job_type: work.job_type,
works: []
};
}
workerGroups[work.worker_id].works.push(work);
});
// 요약 정보 계산
const totalWorkers = Object.keys(workerGroups).length;
const totalHours = workData.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0);
const totalTasks = workData.length;
const errorCount = workData.filter(w => w.work_status_id === 2).length;
// 요약 정보 업데이트
elements.modalTotalWorkers.textContent = `${totalWorkers}`;
elements.modalTotalHours.textContent = `${totalHours.toFixed(1)}h`;
elements.modalTotalTasks.textContent = `${totalTasks}`;
elements.modalErrorCount.textContent = `${errorCount}`;
// 작업자 리스트 렌더링
renderWorkersList(Object.values(workerGroups));
}
// 작업자 리스트 렌더링
function renderWorkersList(workerGroups) {
const workersHTML = workerGroups.map(workerGroup => {
const totalHours = workerGroup.works.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0);
const hasError = workerGroup.works.some(w => w.work_status_id === 2);
const hasVacation = workerGroup.works.some(w => w.project_id === 13);
const regularWorkCount = workerGroup.works.filter(w => w.project_id !== 13 && w.work_status_id !== 2).length;
const errorWorkCount = workerGroup.works.filter(w => w.project_id !== 13 && w.work_status_id === 2).length;
// 상태 결정
let status, statusText, statusBadge;
if (hasVacation) {
const vacationHours = workerGroup.works
.filter(w => w.project_id === 13)
.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 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) {
status = 'overtime';
statusText = '연장근로';
statusBadge = '연장근로';
} else if (totalHours === 8) {
status = 'complete';
statusText = '정시근로';
statusBadge = '정시근로';
} else if (totalHours > 0) {
status = 'partial';
statusText = '부분입력';
statusBadge = '부분입력';
} else {
status = 'incomplete';
statusText = '미입력';
statusBadge = '미입력';
}
return `
<div class="worker-status-row ${status}" data-status="${status}">
<div class="worker-basic-info">
<div class="worker-avatar">
<span>${workerGroup.worker_name.charAt(0)}</span>
</div>
<div class="worker-details">
<h4 class="worker-name">${workerGroup.worker_name}</h4>
<p class="worker-job">${workerGroup.job_type || '작업자'}</p>
</div>
</div>
<div class="worker-status-indicator">
<span class="status-badge status-${status}">${statusBadge}</span>
</div>
<div class="worker-stats-inline">
<div class="stat-item">
<span class="stat-label">작업시간</span>
<span class="stat-value">${totalHours.toFixed(1)}h</span>
</div>
<div class="stat-item">
<span class="stat-label">정규</span>
<span class="stat-value">${regularWorkCount}건</span>
</div>
${errorWorkCount > 0 ? `
<div class="stat-item error">
<span class="stat-label">에러</span>
<span class="stat-value">${errorWorkCount}건</span>
</div>
` : ''}
</div>
</div>
`;
}).join('');
elements.modalWorkersList.innerHTML = workersHTML;
}
// 작업자 리스트 필터링
function filterWorkersList() {
const filterValue = elements.statusFilter.value;
const workerRows = elements.modalWorkersList.querySelectorAll('.worker-status-row');
workerRows.forEach(row => {
const status = row.dataset.status;
if (filterValue === 'all' || status === filterValue ||
(filterValue === 'vacation' && status.startsWith('vacation'))) {
row.style.display = 'flex';
} else {
row.style.display = 'none';
}
});
}
// 모달 닫기
function closeDailyWorkModal() {
elements.dailyWorkModal.style.display = 'none';
document.body.style.overflow = '';
currentModalDate = null;
}
// 로딩 표시
function showLoading(show) {
if (elements.loadingSpinner) {
elements.loadingSpinner.style.display = show ? 'flex' : 'none';
}
}
// 토스트 메시지 (간단한 구현)
function showToast(message, type = 'info') {
// 기존 토스트가 있으면 제거
const existingToast = document.querySelector('.toast-message');
if (existingToast) {
existingToast.remove();
}
const toast = document.createElement('div');
toast.className = `toast-message toast-${type}`;
toast.textContent = message;
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 12px 24px;
background: ${type === 'error' ? '#ef4444' : type === 'success' ? '#10b981' : '#3b82f6'};
color: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 10000;
font-weight: 500;
max-width: 400px;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
}
// 작업자 개별 작업 모달 열기
async function openWorkerModal(workerId, date) {
try {
// 작업자 정보 찾기
const worker = allWorkers.find(w => w.worker_id === workerId);
if (!worker) {
showToast('작업자 정보를 찾을 수 없습니다.', 'error');
return;
}
// 작업 입력 모달 열기
await openWorkEntryModal(workerId, worker.worker_name, date);
} catch (error) {
console.error('작업자 모달 열기 오류:', error);
showToast('작업 입력 모달을 여는데 실패했습니다.', 'error');
}
}
// 작업 입력 모달 열기
async function openWorkEntryModal(workerId, workerName, date) {
try {
// 모달 요소들 가져오기
const modal = document.getElementById('workEntryModal');
const titleElement = document.getElementById('workEntryModalTitle');
const workerNameDisplay = document.getElementById('workerNameDisplay');
const workerIdInput = document.getElementById('workerId');
const workDateInput = document.getElementById('workDate');
if (!modal) {
showToast('작업 입력 모달을 찾을 수 없습니다.', 'error');
return;
}
// 모달 제목 및 정보 설정
titleElement.textContent = `${workerName} - 작업 입력`;
workerNameDisplay.value = workerName;
workerIdInput.value = workerId;
workDateInput.value = date;
// 프로젝트 및 상태 데이터 로드
await loadModalData();
// 모달 표시
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
} catch (error) {
console.error('작업 입력 모달 열기 오류:', error);
showToast('작업 입력 모달을 여는데 실패했습니다.', 'error');
}
}
// 모달 데이터 로드 (프로젝트, 작업 상태)
async function loadModalData() {
try {
// 프로젝트 목록 로드
const projectsResponse = await window.apiCall('/projects');
const projects = Array.isArray(projectsResponse) ? projectsResponse : (projectsResponse.data || []);
const projectSelect = document.getElementById('projectSelect');
projectSelect.innerHTML = '<option value="">프로젝트를 선택하세요</option>';
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.project_id;
option.textContent = project.project_name;
projectSelect.appendChild(option);
});
// 작업 상태 목록 로드 (하드코딩으로 대체)
const statuses = [
{ status_id: 1, status_name: '완료' },
{ status_id: 2, status_name: '오류' },
{ status_id: 3, status_name: '진행중' }
];
const statusSelect = document.getElementById('workStatusSelect');
statusSelect.innerHTML = '<option value="">상태를 선택하세요</option>';
statuses.forEach(status => {
const option = document.createElement('option');
option.value = status.status_id;
option.textContent = status.status_name;
statusSelect.appendChild(option);
});
} catch (error) {
console.error('모달 데이터 로드 오류:', error);
showToast('데이터를 불러오는데 실패했습니다.', 'error');
}
}
// 작업 입력 모달 닫기
function closeWorkEntryModal() {
const modal = document.getElementById('workEntryModal');
if (modal) {
modal.style.display = 'none';
document.body.style.overflow = 'auto';
// 폼 초기화
const form = document.getElementById('workEntryForm');
if (form) {
form.reset();
}
}
}
// 휴가 처리
function handleVacation(type) {
const projectSelect = document.getElementById('projectSelect');
const workHours = document.getElementById('workHours');
const workStatusSelect = document.getElementById('workStatusSelect');
const workDescription = document.getElementById('workDescription');
// 연차/휴무 프로젝트 선택 (project_id: 13)
projectSelect.value = '13';
// 휴가 유형에 따른 시간 설정
switch (type) {
case 'full': // 연차
workHours.value = '8';
workDescription.value = '연차';
break;
case 'half': // 반차
workHours.value = '4';
workDescription.value = '반차';
break;
case 'quarter': // 반반차
workHours.value = '2';
workDescription.value = '반반차';
break;
case 'early': // 조퇴
workHours.value = '6';
workDescription.value = '조퇴';
break;
}
// 완료 상태로 설정 (status_id: 1)
workStatusSelect.value = '1';
}
// 작업 저장
async function saveWorkEntry() {
try {
const form = document.getElementById('workEntryForm');
const formData = new FormData(form);
const workData = {
worker_id: document.getElementById('workerId').value,
project_id: document.getElementById('projectSelect').value,
work_hours: document.getElementById('workHours').value,
work_status_id: document.getElementById('workStatusSelect').value,
description: document.getElementById('workDescription').value,
report_date: document.getElementById('workDate').value
};
// 필수 필드 검증
if (!workData.project_id || !workData.work_hours || !workData.work_status_id) {
showToast('필수 항목을 모두 입력해주세요.', 'error');
return;
}
// API 호출
const response = await window.apiCall('/daily-work-reports', 'POST', workData);
if (response.success || response.id) {
showToast('작업이 성공적으로 저장되었습니다.', 'success');
closeWorkEntryModal();
// 캘린더 새로고침
await renderCalendar();
// 현재 열린 모달이 있다면 새로고침
if (currentModalDate) {
await openDailyWorkModal(currentModalDate);
}
} else {
throw new Error(response.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('작업 저장 오류:', error);
showToast(error.message || '작업 저장에 실패했습니다.', 'error');
}
}
// 모달 닫기 함수
function closeDailyWorkModal() {
if (elements.dailyWorkModal) {
elements.dailyWorkModal.style.display = 'none';
document.body.style.overflow = 'auto';
}
}
// 전역 변수로 작업자 목록 저장
let allWorkers = [];
// 시간 업데이트 함수
function updateCurrentTime() {
const now = new Date();
const timeString = now.toLocaleTimeString('ko-KR', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
const timeValueElement = document.getElementById('timeValue');
if (timeValueElement) {
timeValueElement.textContent = timeString;
}
}
// 사용자 정보 업데이트 함수
function updateUserInfo() {
const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
console.log('👤 localStorage userInfo:', userInfo);
const userNameElement = document.getElementById('userName');
const userRoleElement = document.getElementById('userRole');
const userInitialElement = document.getElementById('userInitial');
if (userNameElement) {
if (userInfo.worker_name) {
userNameElement.textContent = userInfo.worker_name;
} else {
userNameElement.textContent = '사용자';
}
}
if (userRoleElement) {
if (userInfo.job_type) {
userRoleElement.textContent = userInfo.job_type;
} else {
userRoleElement.textContent = '작업자';
}
}
if (userInitialElement) {
if (userInfo.worker_name) {
userInitialElement.textContent = userInfo.worker_name.charAt(0);
} else {
userInitialElement.textContent = '사';
}
}
}
// 페이지 초기화 개선
function initializePage() {
// 시간 업데이트 시작
updateCurrentTime();
setInterval(updateCurrentTime, 1000);
// 사용자 정보 업데이트
updateUserInfo();
// 프로필 메뉴 토글
const userProfile = document.getElementById('userProfile');
const profileMenu = document.getElementById('profileMenu');
if (userProfile && profileMenu) {
userProfile.addEventListener('click', (e) => {
e.stopPropagation();
profileMenu.style.display = profileMenu.style.display === 'block' ? 'none' : 'block';
});
// 외부 클릭 시 메뉴 닫기
document.addEventListener('click', () => {
profileMenu.style.display = 'none';
});
}
// 로그아웃 버튼
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) {
logoutBtn.addEventListener('click', () => {
localStorage.removeItem('token');
localStorage.removeItem('userInfo');
window.location.href = '/pages/auth/login.html';
});
}
}
// DOMContentLoaded 이벤트에 초기화 함수 추가
document.addEventListener('DOMContentLoaded', function() {
initializePage();
});
// 전역 함수로 노출
window.openDailyWorkModal = openDailyWorkModal;
window.closeDailyWorkModal = closeDailyWorkModal;
window.openWorkerModal = openWorkerModal;
window.openWorkEntryModal = openWorkEntryModal;
window.closeWorkEntryModal = closeWorkEntryModal;
window.handleVacation = handleVacation;
window.saveWorkEntry = saveWorkEntry;