/** * monthly-comparison.js — 월간 비교·확인·정산 * Sprint 004 Section B */ // ===== Mock ===== const MOCK_ENABLED = false; const MOCK_MY_RECORDS = { success: true, data: { user: { user_id: 10, worker_name: '김철수', job_type: '용접', department_name: '생산1팀' }, period: { year: 2026, month: 3 }, summary: { total_work_days: 22, total_work_hours: 182.5, total_overtime_hours: 6.5, vacation_days: 1, mismatch_count: 3, mismatch_details: { hours_diff: 2, missing_report: 1, missing_attendance: 0 } }, confirmation: { status: 'pending', confirmed_at: null, reject_reason: null }, daily_records: [ { date: '2026-03-01', day_of_week: '월', is_holiday: false, work_report: { total_hours: 8.0, entries: [{ project_name: 'A동 신축', work_type: '용접', hours: 8.0 }] }, attendance: { total_work_hours: 8.0, attendance_type: '정시근로', vacation_type: null }, status: 'match', hours_diff: 0 }, { date: '2026-03-02', day_of_week: '화', is_holiday: false, work_report: { total_hours: 9.0, entries: [{ project_name: 'A동 신축', work_type: '용접', hours: 9.0 }] }, attendance: { total_work_hours: 8.0, attendance_type: '정시근로', vacation_type: null }, status: 'mismatch', hours_diff: 1.0 }, { date: '2026-03-03', day_of_week: '수', is_holiday: false, work_report: null, attendance: { total_work_hours: 0, attendance_type: '휴가근로', vacation_type: '연차' }, status: 'vacation', hours_diff: 0 }, { date: '2026-03-04', day_of_week: '목', is_holiday: false, work_report: { total_hours: 8.0, entries: [{ project_name: 'A동 신축', work_type: '용접', hours: 8.0 }] }, attendance: null, status: 'report_only', hours_diff: 0 }, { date: '2026-03-05', day_of_week: '금', is_holiday: false, work_report: { total_hours: 8.0, entries: [{ project_name: 'B동 보수', work_type: '배관', hours: 8.0 }] }, attendance: { total_work_hours: 8.0, attendance_type: '정시근로', vacation_type: null }, status: 'match', hours_diff: 0 }, { date: '2026-03-06', day_of_week: '토', is_holiday: true, work_report: null, attendance: null, status: 'holiday', hours_diff: 0 }, { date: '2026-03-07', day_of_week: '일', is_holiday: true, work_report: null, attendance: null, status: 'holiday', hours_diff: 0 }, ] } }; const MOCK_ADMIN_STATUS = { success: true, data: { period: { year: 2026, month: 3 }, summary: { total_workers: 25, confirmed: 15, pending: 8, rejected: 2 }, workers: [ { user_id: 10, worker_name: '김철수', job_type: '용접', department_name: '생산1팀', total_work_days: 22, total_work_hours: 182.5, total_overtime_hours: 6.5, status: 'confirmed', confirmed_at: '2026-03-30T10:00:00', mismatch_count: 0 }, { user_id: 11, worker_name: '이영희', job_type: '도장', department_name: '생산1팀', total_work_days: 20, total_work_hours: 168.0, total_overtime_hours: 2.0, status: 'pending', confirmed_at: null, mismatch_count: 0 }, { user_id: 12, worker_name: '박민수', job_type: '배관', department_name: '생산2팀', total_work_days: 22, total_work_hours: 190.0, total_overtime_hours: 14.0, status: 'rejected', confirmed_at: null, reject_reason: '3/15 근무시간 오류', mismatch_count: 2 }, ] } }; // ===== State ===== let currentYear, currentMonth; let currentMode = 'my'; // 'my' | 'admin' | 'detail' let currentUserId = null; let comparisonData = null; let adminData = null; let currentFilter = 'all'; const ADMIN_ROLES = ['support_team', 'admin', 'system']; const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토']; // ===== Init ===== document.addEventListener('DOMContentLoaded', () => { const now = new Date(); currentYear = now.getFullYear(); currentMonth = now.getMonth() + 1; // URL 파라미터 const params = new URLSearchParams(location.search); if (params.get('year')) currentYear = parseInt(params.get('year')); if (params.get('month')) currentMonth = parseInt(params.get('month')); if (params.get('user_id')) currentUserId = parseInt(params.get('user_id')); const urlMode = params.get('mode'); setTimeout(() => { const user = typeof getCurrentUser === 'function' ? getCurrentUser() : window.currentUser; if (!user) return; // 비관리자 → 작업자 전용 확인 페이지로 리다이렉트 if (!ADMIN_ROLES.includes(user.role)) { location.href = '/pages/attendance/my-monthly-confirm.html'; return; } // 관리자 mode 결정 if (currentUserId) { currentMode = 'detail'; } else { currentMode = 'admin'; } // 관리자 뷰 전환 버튼 (관리자만) if (ADMIN_ROLES.includes(user.role)) { document.getElementById('viewToggleBtn').classList.remove('hidden'); } updateMonthLabel(); loadData(); }, 500); }); // ===== Month Nav ===== function updateMonthLabel() { document.getElementById('monthLabel').textContent = `${currentYear}년 ${currentMonth}월`; } function changeMonth(delta) { currentMonth += delta; if (currentMonth > 12) { currentMonth = 1; currentYear++; } if (currentMonth < 1) { currentMonth = 12; currentYear--; } updateMonthLabel(); loadData(); } // ===== Data Load ===== async function loadData() { if (currentMode === 'admin') { await loadAdminStatus(); } else { await loadMyRecords(); } } async function loadMyRecords() { document.getElementById('workerView').classList.remove('hidden'); document.getElementById('adminView').classList.add('hidden'); document.getElementById('pageTitle').textContent = currentMode === 'detail' ? '작업자 근무 비교' : '월간 근무 비교'; const listEl = document.getElementById('dailyList'); listEl.innerHTML = '
'; try { let res; if (MOCK_ENABLED) { res = JSON.parse(JSON.stringify(MOCK_MY_RECORDS)); } else { const endpoint = currentMode === 'detail' && currentUserId ? `/monthly-comparison/records?year=${currentYear}&month=${currentMonth}&user_id=${currentUserId}` : `/monthly-comparison/my-records?year=${currentYear}&month=${currentMonth}`; res = await window.apiCall(endpoint); } if (!res || !res.success) { listEl.innerHTML = '

데이터를 불러올 수 없습니다

'; return; } comparisonData = res.data; // detail 모드: 작업자 이름 + 검토완료 버튼 (상단 헤더) if (currentMode === 'detail' && comparisonData.user) { var isChecked = comparisonData.confirmation && comparisonData.confirmation.admin_checked; var checkBtnHtml = ''; document.getElementById('pageTitle').innerHTML = (comparisonData.user.worker_name || '') + ' 근무 비교' + checkBtnHtml; } renderSummaryCards(comparisonData.summary); renderMismatchAlert(comparisonData.summary); renderDailyList(comparisonData.daily_records || []); renderConfirmationStatus(comparisonData.confirmation); } catch (e) { listEl.innerHTML = '

네트워크 오류

'; } } async function loadAdminStatus() { document.getElementById('workerView').classList.add('hidden'); document.getElementById('adminView').classList.remove('hidden'); document.getElementById('pageTitle').textContent = '월간 근무 확인 현황'; const listEl = document.getElementById('adminWorkerList'); listEl.innerHTML = '
'; try { let res; if (MOCK_ENABLED) { res = JSON.parse(JSON.stringify(MOCK_ADMIN_STATUS)); } else { res = await window.apiCall(`/monthly-comparison/all-status?year=${currentYear}&month=${currentMonth}`); } if (!res || !res.success) { listEl.innerHTML = '

데이터를 불러올 수 없습니다

'; return; } adminData = res.data; renderAdminSummary(adminData.summary); renderWorkerList(adminData.workers || []); updateExportButton(adminData.summary, adminData.workers || []); } catch (e) { listEl.innerHTML = '

네트워크 오류

'; } } // ===== Render: Worker View ===== function renderSummaryCards(s) { document.getElementById('totalDays').textContent = s.total_work_days || 0; document.getElementById('totalHours').textContent = (s.total_work_hours || 0) + 'h'; document.getElementById('overtimeHours').textContent = (s.total_overtime_hours || 0) + 'h'; document.getElementById('vacationDays').textContent = (s.vacation_days || 0) + '일'; } function renderMismatchAlert(s) { const el = document.getElementById('mismatchAlert'); if (!s.mismatch_count || s.mismatch_count === 0) { el.classList.add('hidden'); return; } el.classList.remove('hidden'); const details = s.mismatch_details || {}; const parts = []; if (details.hours_diff) parts.push(`시간차이 ${details.hours_diff}건`); if (details.missing_report) parts.push(`보고서만 ${details.missing_report}건`); if (details.missing_attendance) parts.push(`근태만 ${details.missing_attendance}건`); document.getElementById('mismatchText').textContent = `${s.mismatch_count}건의 불일치가 있습니다` + (parts.length ? ` (${parts.join(' | ')})` : ''); } function renderDailyList(records) { const el = document.getElementById('dailyList'); if (!records.length) { el.innerHTML = '

데이터가 없습니다

'; return; } el.innerHTML = records.map(r => { const dateStr = r.date.substring(5); // "03-01" const dayStr = r.day_of_week || ''; const icon = getStatusIcon(r.status); const label = getStatusLabel(r.status, r); let reportLine = ''; let attendLine = ''; let diffLine = ''; if (r.work_report) { const entries = (r.work_report.entries || []).map(e => `${e.project_name}-${e.work_type}`).join(', '); reportLine = `
작업보고: ${r.work_report.total_hours}h (${escHtml(entries)})
`; } else if (r.status !== 'holiday') { reportLine = '
작업보고: -
'; } if (r.attendance) { const vacInfo = r.attendance.vacation_type ? ` (${r.attendance.vacation_type})` : ''; // 주말+0h → 편집 불필요 const showEdit = currentMode === 'detail' && !(r.is_holiday && r.attendance.total_work_hours === 0); const editBtn = showEdit ? `` : ''; // 주말+0h → 근태 행 숨김 (주말로 표시) if (r.is_holiday && r.attendance.total_work_hours === 0) { // 주말 표시만, 근태 행 생략 } else { attendLine = `
근태관리: ${r.attendance.total_work_hours}h (${escHtml(r.attendance.attendance_type)}${vacInfo})${editBtn}
`; } } else if (r.status !== 'holiday') { const addBtn = currentMode === 'detail' ? `` : ''; attendLine = `
근태관리: 미입력${addBtn}
`; } if (r.hours_diff && r.hours_diff !== 0) { const sign = r.hours_diff > 0 ? '+' : ''; diffLine = `
차이: ${sign}${r.hours_diff}h
`; } return `
${dateStr}(${dayStr})
${icon} ${label}
${reportLine}${attendLine}${diffLine}
`; }).join(''); } function renderConfirmationStatus(conf) { const actions = document.getElementById('bottomActions'); const statusEl = document.getElementById('confirmedStatus'); const badge = document.getElementById('statusBadge'); // 관리자 페이지: 확인/문제 버튼 항상 숨김 (작업자는 my-monthly-confirm에서 처리) actions.classList.add('hidden'); if (!conf) { statusEl.classList.add('hidden'); badge.textContent = ''; return; } var displayStatus = (conf.status === 'pending' && conf.admin_checked) ? 'admin_checked' : conf.status; var labels = { pending: '미검토', admin_checked: '검토완료', review_sent: '확인요청', confirmed: '확인완료', change_request: '수정요청', rejected: '반려' }; badge.textContent = labels[displayStatus] || ''; badge.className = 'mc-status-badge ' + displayStatus; if (conf.status === 'confirmed') { statusEl.classList.remove('hidden'); var dt = conf.confirmed_at ? new Date(conf.confirmed_at).toLocaleString('ko') : ''; document.getElementById('confirmedText').textContent = dt + ' 확인 완료'; } else if (conf.status === 'rejected') { statusEl.classList.remove('hidden'); document.getElementById('confirmedText').textContent = '반려: ' + (conf.reject_reason || '-'); } else if (conf.status === 'change_request') { statusEl.classList.remove('hidden'); document.getElementById('confirmedText').textContent = '수정요청 접수됨'; } else { statusEl.classList.add('hidden'); } } // ===== Render: Admin View ===== function renderAdminSummary(s) { const total = s.total_workers || 1; const pct = Math.round((s.confirmed || 0) / total * 100); document.getElementById('progressFill').style.width = pct + '%'; document.getElementById('progressText').textContent = `확인 현황: ${s.confirmed || 0}/${total}명 완료`; document.getElementById('statusCounts').innerHTML = `✅ ${s.confirmed || 0} 확인` + `📩 ${s.review_sent || 0} 확인요청` + `⏳ ${s.pending || 0} 미검토` + `📝 ${s.change_request || 0} 수정요청` + `❌ ${s.rejected || 0} 반려`; // 확인요청 일괄 발송 버튼 — 전원 검토완료 시만 활성화 var reviewBtn = document.getElementById('reviewSendBtn'); if (reviewBtn) { var pendingCount = (s.pending || 0); var uncheckedCount = (adminData?.workers || []).filter(function(w) { return !w.admin_checked && w.status === 'pending'; }).length; if (pendingCount > 0 && uncheckedCount === 0) { reviewBtn.classList.remove('hidden'); reviewBtn.disabled = false; reviewBtn.textContent = `${pendingCount}명 확인요청 발송`; reviewBtn.style.background = '#2563eb'; } else if (pendingCount > 0 && uncheckedCount > 0) { reviewBtn.classList.remove('hidden'); reviewBtn.disabled = true; reviewBtn.textContent = `${uncheckedCount}명 미검토 — 전원 검토 후 발송 가능`; reviewBtn.style.background = '#9ca3af'; } else { reviewBtn.classList.add('hidden'); } } } function renderWorkerList(workers) { const el = document.getElementById('adminWorkerList'); let filtered = workers; if (currentFilter !== 'all') { filtered = workers.filter(w => w.status === currentFilter); } if (!filtered.length) { el.innerHTML = '

해당 조건의 작업자가 없습니다

'; return; } el.innerHTML = filtered.map(w => { // admin_checked면 "미검토" → "검토완료"로 표시 var displayStatus = (w.status === 'pending' && w.admin_checked) ? 'admin_checked' : w.status; const statusLabels = { confirmed: '확인완료', pending: '미검토', admin_checked: '검토완료', review_sent: '확인요청', change_request: '수정요청', rejected: '반려' }; const statusBadge = `${statusLabels[displayStatus] || ''}`; const mismatchBadge = w.mismatch_count > 0 ? `⚠️ 불일치${w.mismatch_count}` : ''; const rejectReason = w.status === 'rejected' && w.reject_reason ? `
사유: ${escHtml(w.reject_reason)}
` : ''; const confirmedAt = w.confirmed_at ? `(${new Date(w.confirmed_at).toLocaleDateString('ko')})` : ''; return `
${escHtml(w.worker_name)} ${mismatchBadge}
${escHtml(w.department_name)} · ${escHtml(w.job_type)}
${w.total_work_days}일 | ${w.total_work_hours}h | 연장 ${w.total_overtime_hours}h
${statusBadge} ${confirmedAt}
${rejectReason}
`; }).join(''); } function filterWorkers(status) { currentFilter = status; document.querySelectorAll('.mc-tab').forEach(t => { t.classList.toggle('active', t.dataset.filter === status); }); if (adminData) renderWorkerList(adminData.workers || []); } function updateExportButton(summary, workers) { const btn = document.getElementById('exportBtn'); const note = document.getElementById('exportNote'); const pendingCount = (workers || []).filter(w => !w.status || w.status === 'pending').length; const rejectedCount = (workers || []).filter(w => w.status === 'rejected').length; const allConfirmed = pendingCount === 0 && rejectedCount === 0; if (allConfirmed) { btn.disabled = false; note.textContent = '모든 작업자가 확인을 완료했습니다'; } else { btn.disabled = true; const parts = []; if (pendingCount > 0) parts.push(`${pendingCount}명 미확인`); if (rejectedCount > 0) parts.push(`${rejectedCount}명 반려`); note.textContent = `${parts.join(', ')} — 전원 확인 후 다운로드 가능합니다`; } } // ===== Actions ===== let isProcessing = false; async function confirmMonth() { if (isProcessing) return; if (!confirm(`${currentYear}년 ${currentMonth}월 근무 내역을 확인하시겠습니까?`)) return; isProcessing = true; try { let res; if (MOCK_ENABLED) { await new Promise(r => setTimeout(r, 500)); res = { success: true, message: '확인이 완료되었습니다.' }; } else { res = await window.apiCall('/monthly-comparison/confirm', 'POST', { year: currentYear, month: currentMonth, status: 'confirmed' }); } if (res && res.success) { showToast(res.message || '확인 완료', 'success'); loadMyRecords(); } else { showToast(res?.message || '처리 실패', 'error'); } } catch (e) { showToast('네트워크 오류', 'error'); } finally { isProcessing = false; } } function openRejectModal() { document.getElementById('rejectReason').value = ''; document.getElementById('rejectModal').classList.remove('hidden'); } function closeRejectModal() { document.getElementById('rejectModal').classList.add('hidden'); } async function submitReject() { if (isProcessing) return; const reason = document.getElementById('rejectReason').value.trim(); if (!reason) { showToast('반려 사유를 입력해주세요', 'error'); return; } isProcessing = true; try { let res; if (MOCK_ENABLED) { await new Promise(r => setTimeout(r, 500)); res = { success: true, message: '이의가 접수되었습니다. 지원팀에 알림이 전달됩니다.' }; } else { res = await window.apiCall('/monthly-comparison/confirm', 'POST', { year: currentYear, month: currentMonth, status: 'rejected', reject_reason: reason }); } if (res && res.success) { showToast(res.message || '반려 제출 완료', 'success'); closeRejectModal(); loadMyRecords(); } else { showToast(res?.message || '처리 실패', 'error'); } } catch (e) { showToast('네트워크 오류', 'error'); } finally { isProcessing = false; } } async function downloadExcel() { try { if (MOCK_ENABLED) { showToast('Mock 모드에서는 다운로드를 지원하지 않습니다', 'info'); return; } const token = (window.getSSOToken && window.getSSOToken()) || ''; const response = await fetch(`${window.API_BASE_URL}/monthly-comparison/export?year=${currentYear}&month=${currentMonth}`, { headers: { 'Authorization': `Bearer ${token}` } }); if (!response.ok) throw new Error('다운로드 실패'); const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `월간근무_${currentYear}년${currentMonth}월.xlsx`; a.click(); window.URL.revokeObjectURL(url); } catch (e) { showToast('엑셀 다운로드 실패', 'error'); } } // ===== Admin Check (검토완료 토글) ===== async function toggleAdminCheck() { if (!currentUserId || isProcessing) return; var isCurrentlyChecked = comparisonData?.confirmation?.admin_checked; var newChecked = !isCurrentlyChecked; isProcessing = true; try { var res = await window.apiCall('/monthly-comparison/admin-check', 'POST', { user_id: currentUserId, year: currentYear, month: currentMonth, checked: newChecked }); if (res && res.success) { // 상태 업데이트 if (comparisonData.confirmation) { comparisonData.confirmation.admin_checked = newChecked ? 1 : 0; } var btn = document.getElementById('headerCheckBtn'); if (btn) { btn.textContent = newChecked ? '✓ 검토완료' : '검토하기'; btn.style.background = newChecked ? '#dcfce7' : '#f3f4f6'; btn.style.color = newChecked ? '#166534' : '#6b7280'; } showToast(newChecked ? '검토완료' : '검토 해제', 'success'); } else { showToast(res?.message || '처리 실패', 'error'); } } catch (e) { showToast('네트워크 오류', 'error'); } finally { isProcessing = false; } } // 목록으로 복귀 (월 유지) function goBackToList() { location.href = '/pages/attendance/monthly-comparison.html?mode=admin&year=' + currentYear + '&month=' + currentMonth; } // ===== Review Send (확인요청 일괄 발송) ===== async function sendReviewAll() { if (isProcessing) return; if (!confirm(currentYear + '년 ' + currentMonth + '월 미검토 작업자 전체에게 확인요청을 발송하시겠습니까?')) return; isProcessing = true; try { var res = await window.apiCall('/monthly-comparison/review-send', 'POST', { year: currentYear, month: currentMonth }); if (res && res.success) { showToast(res.message || '확인요청 발송 완료', 'success'); loadAdminStatus(); } else { showToast(res && res.message || '발송 실패', 'error'); } } catch (e) { showToast('네트워크 오류', 'error'); } finally { isProcessing = false; } } // ===== View Toggle ===== function toggleViewMode() { if (currentMode === 'admin') { currentMode = 'my'; } else { currentMode = 'admin'; } currentFilter = 'all'; loadData(); } function viewWorkerDetail(userId) { location.href = `/pages/attendance/monthly-comparison.html?mode=detail&user_id=${userId}&year=${currentYear}&month=${currentMonth}`; } // ===== Helpers ===== function getStatusIcon(status) { const icons = { match: '', mismatch: '', report_only: '', attend_only: '', vacation: '', holiday: '', none: '' }; return icons[status] || ''; } function getStatusLabel(status, record) { const labels = { match: '일치', mismatch: '불일치', report_only: '보고서만', attend_only: '근태만', holiday: '주말', none: '미입력' }; if (status === 'vacation') { return record?.attendance?.vacation_type || '연차'; } return labels[status] || ''; } function escHtml(s) { return (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // showToast — tkfb-core.js의 전역 showToast 사용 (재정의 불필요) // ===== Inline Attendance Edit (detail mode) ===== function getAttendanceTypeId(hours, vacTypeId) { if (vacTypeId) return 4; // VACATION if (hours >= 8) return 1; // REGULAR if (hours > 0) return 3; // PARTIAL return 0; } function editAttendance(date, currentHours, currentVacTypeId) { const el = document.getElementById('attend-' + date); if (!el) return; const vacTypeId = currentVacTypeId === 'null' || currentVacTypeId === null ? '' : currentVacTypeId; el.innerHTML = `
h
`; } function onVacTypeChange(date) { const vacType = document.getElementById('editVacType-' + date).value; const hoursInput = document.getElementById('editHours-' + date); if (vacType === '1') hoursInput.value = '0'; // 연차 → 0시간 else if (vacType === '2') hoursInput.value = '4'; // 반차 → 4시간 else if (vacType === '3') hoursInput.value = '6'; // 반반차 → 6시간 else if (vacType === '10') hoursInput.value = '2'; // 조퇴 → 2시간 } async function saveAttendance(date) { const hours = parseFloat(document.getElementById('editHours-' + date).value) || 0; const vacTypeVal = document.getElementById('editVacType-' + date).value; const vacTypeId = vacTypeVal ? parseInt(vacTypeVal) : null; const attTypeId = getAttendanceTypeId(hours, vacTypeId); try { await window.apiCall('/attendance/records', 'POST', { record_date: date, user_id: currentUserId, total_work_hours: hours, vacation_type_id: vacTypeId, attendance_type_id: attTypeId }); showToast('근태 수정 완료', 'success'); await loadData(); // 전체 새로고침 } catch (e) { showToast('저장 실패: ' + (e.message || e), 'error'); } } // ESC로 모달 닫기 document.addEventListener('keydown', e => { if (e.key === 'Escape') closeRejectModal(); });