From 672a7039df6cefeb3f915fab49b20309c4fd5b92 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Mon, 30 Mar 2026 13:02:48 +0900 Subject: [PATCH] =?UTF-8?q?feat(sprint-004):=20=EC=9B=94=EA=B0=84=20?= =?UTF-8?q?=EB=B9=84=EA=B5=90=C2=B7=ED=99=95=EC=9D=B8=C2=B7=EC=A0=95?= =?UTF-8?q?=EC=82=B0=20=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=20(Se?= =?UTF-8?q?ction=20B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - monthly-comparison.html: 작업자 뷰 + 관리자 뷰 통합 페이지 - monthly-comparison.js: 일별 비교 카드(7상태), 확인/반려 워크플로우, 관리자 진행바+필터+엑셀, Mock 데이터 포함 - monthly-comparison.css: 모바일 우선 스타일 - tkfb-core.js: NAV_MENU에 월간 비교·확인 추가 - 권한: role 기반 mode 자동 결정, 일반 작업자 admin 접근 차단 - 상태 전이: pending→confirmed/rejected, rejected→confirmed 재확인 가능 - 엑셀: pending 0명일 때 활성화 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/css/monthly-comparison.css | 305 ++++++++++ system1-factory/web/js/monthly-comparison.js | 523 ++++++++++++++++++ .../pages/attendance/monthly-comparison.html | 164 ++++++ system1-factory/web/static/js/tkfb-core.js | 1 + 4 files changed, 993 insertions(+) create mode 100644 system1-factory/web/css/monthly-comparison.css create mode 100644 system1-factory/web/js/monthly-comparison.js create mode 100644 system1-factory/web/pages/attendance/monthly-comparison.html diff --git a/system1-factory/web/css/monthly-comparison.css b/system1-factory/web/css/monthly-comparison.css new file mode 100644 index 0000000..6e8dc21 --- /dev/null +++ b/system1-factory/web/css/monthly-comparison.css @@ -0,0 +1,305 @@ +/* monthly-comparison.css — 월간 비교·확인·정산 */ + +/* Header */ +.mc-header { + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); + color: white; + padding: 14px 16px; + border-radius: 0 0 16px 16px; + margin: -16px -16px 0; + position: sticky; + top: 56px; + z-index: 20; +} +.mc-header-row { display: flex; align-items: center; gap: 12px; } +.mc-back-btn { + width: 32px; height: 32px; + display: flex; align-items: center; justify-content: center; + background: rgba(255,255,255,0.15); + border-radius: 8px; + border: none; color: white; cursor: pointer; +} +.mc-header h1 { font-size: 1.05rem; font-weight: 700; flex: 1; } +.mc-view-toggle { + width: 32px; height: 32px; + display: flex; align-items: center; justify-content: center; + background: rgba(255,255,255,0.15); + border-radius: 8px; + border: none; color: white; cursor: pointer; +} + +/* Month Navigation */ +.mc-month-nav { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 12px 0; + position: relative; +} +.mc-month-nav button { + width: 36px; height: 36px; + border-radius: 50%; + display: flex; align-items: center; justify-content: center; + color: #6b7280; background: #f3f4f6; + border: none; cursor: pointer; +} +.mc-month-nav button:hover { background: #e5e7eb; } +.mc-month-nav span { font-size: 1rem; font-weight: 700; color: #1f2937; } +.mc-status-badge { + position: absolute; right: 0; top: 50%; transform: translateY(-50%); + font-size: 0.7rem; font-weight: 600; + padding: 3px 8px; border-radius: 12px; +} +.mc-status-badge.pending { background: #fef3c7; color: #92400e; } +.mc-status-badge.confirmed { background: #dcfce7; color: #166534; } +.mc-status-badge.rejected { background: #fef2f2; color: #991b1b; } + +/* Summary Cards */ +.mc-summary-cards { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; + margin-bottom: 12px; +} +@media (min-width: 640px) { + .mc-summary-cards { grid-template-columns: repeat(4, 1fr); } +} +.mc-card { + background: white; + border-radius: 10px; + padding: 12px 8px; + text-align: center; + box-shadow: 0 1px 3px rgba(0,0,0,0.06); +} +.mc-card-value { font-size: 1.25rem; font-weight: 800; color: #1f2937; } +.mc-card-label { font-size: 0.7rem; color: #6b7280; margin-top: 2px; } + +/* Mismatch Alert */ +.mc-mismatch-alert { + display: flex; align-items: center; gap: 8px; + padding: 10px 12px; + background: #fffbeb; border: 1px solid #fde68a; + border-radius: 8px; + margin-bottom: 12px; + font-size: 0.8rem; color: #92400e; +} + +/* Daily List */ +.mc-daily-list { padding-bottom: 100px; } +.mc-daily-card { + background: white; + border-radius: 10px; + padding: 12px; + margin-bottom: 6px; + box-shadow: 0 1px 2px rgba(0,0,0,0.04); + border-left: 3px solid transparent; +} +.mc-daily-card.match { border-left-color: #10b981; } +.mc-daily-card.mismatch { background: #fffbeb; border-left-color: #f59e0b; } +.mc-daily-card.report_only { background: #eff6ff; border-left-color: #3b82f6; } +.mc-daily-card.attend_only { background: #f5f3ff; border-left-color: #8b5cf6; } +.mc-daily-card.vacation { background: #f0fdf4; border-left-color: #34d399; } +.mc-daily-card.holiday { background: #f9fafb; border-left-color: #9ca3af; } +.mc-daily-card.none { background: #fef2f2; border-left-color: #ef4444; } + +.mc-daily-header { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 6px; +} +.mc-daily-date { font-size: 0.85rem; font-weight: 600; color: #1f2937; } +.mc-daily-status { font-size: 0.7rem; font-weight: 600; display: flex; align-items: center; gap: 4px; } +.mc-daily-row { font-size: 0.8rem; color: #374151; margin: 2px 0; } +.mc-daily-row span { color: #6b7280; } +.mc-daily-diff { + font-size: 0.75rem; font-weight: 600; color: #f59e0b; + margin-top: 4px; + display: flex; align-items: center; gap: 4px; +} + +/* Bottom Actions */ +.mc-bottom-actions { + position: fixed; + bottom: 0; left: 0; right: 0; + display: flex; gap: 8px; + padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px)); + background: white; + border-top: 1px solid #e5e7eb; + z-index: 30; + max-width: 480px; + margin: 0 auto; +} +.mc-confirm-btn { + flex: 1; padding: 12px; + background: #10b981; color: white; + font-size: 0.85rem; font-weight: 700; + border: none; border-radius: 10px; cursor: pointer; +} +.mc-confirm-btn:hover { background: #059669; } +.mc-reject-btn { + flex: 1; padding: 12px; + background: white; color: #ef4444; + font-size: 0.85rem; font-weight: 700; + border: 2px solid #fecaca; border-radius: 10px; cursor: pointer; +} +.mc-reject-btn:hover { background: #fef2f2; } + +.mc-confirmed-status { + display: flex; align-items: center; gap: 8px; + padding: 16px; + text-align: center; + justify-content: center; + font-size: 0.85rem; color: #059669; font-weight: 600; + margin-bottom: 80px; +} + +/* Admin View */ +.mc-admin-summary { + background: white; border-radius: 10px; + padding: 16px; margin-bottom: 12px; + box-shadow: 0 1px 3px rgba(0,0,0,0.06); +} +.mc-progress-bar { + height: 8px; background: #e5e7eb; border-radius: 4px; + overflow: hidden; margin-bottom: 8px; +} +.mc-progress-fill { + height: 100%; + background: linear-gradient(90deg, #f59e0b, #10b981); + border-radius: 4px; + transition: width 0.3s; +} +.mc-progress-text { font-size: 0.8rem; font-weight: 600; color: #1f2937; margin-bottom: 4px; } +.mc-status-counts { font-size: 0.75rem; color: #6b7280; display: flex; gap: 12px; } + +/* Filter Tabs */ +.mc-filter-tabs { + display: flex; gap: 4px; + padding: 4px; background: #f3f4f6; + border-radius: 10px; margin-bottom: 12px; +} +.mc-tab { + flex: 1; padding: 8px 4px; + font-size: 0.75rem; font-weight: 600; + color: #6b7280; background: transparent; + border: none; border-radius: 8px; cursor: pointer; + text-align: center; +} +.mc-tab.active { background: white; color: #2563eb; box-shadow: 0 1px 3px rgba(0,0,0,0.08); } + +/* Worker List (admin) */ +.mc-worker-list { padding-bottom: 100px; } +.mc-worker-card { + background: white; + border-radius: 10px; + padding: 12px; + margin-bottom: 6px; + box-shadow: 0 1px 2px rgba(0,0,0,0.04); + cursor: pointer; + transition: background 0.15s; +} +.mc-worker-card:active { background: #f9fafb; } +.mc-worker-name { font-size: 0.875rem; font-weight: 600; color: #1f2937; } +.mc-worker-dept { font-size: 0.7rem; color: #9ca3af; } +.mc-worker-stats { font-size: 0.75rem; color: #6b7280; margin: 4px 0; } +.mc-worker-status { + display: flex; align-items: center; justify-content: space-between; +} +.mc-worker-status-badge { + font-size: 0.65rem; font-weight: 600; + padding: 2px 8px; border-radius: 10px; +} +.mc-worker-status-badge.confirmed { background: #dcfce7; color: #166534; } +.mc-worker-status-badge.pending { background: #fef3c7; color: #92400e; } +.mc-worker-status-badge.rejected { background: #fef2f2; color: #991b1b; } +.mc-worker-reject-reason { + font-size: 0.7rem; color: #991b1b; + margin-top: 4px; padding-left: 8px; + border-left: 2px solid #fecaca; +} +.mc-worker-mismatch { + font-size: 0.65rem; font-weight: 600; + color: #f59e0b; background: #fffbeb; + padding: 1px 6px; border-radius: 4px; +} + +/* Bottom Export */ +.mc-bottom-export { + position: fixed; + bottom: 0; left: 0; right: 0; + padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px)); + background: white; + border-top: 1px solid #e5e7eb; + z-index: 30; + max-width: 480px; + margin: 0 auto; + text-align: center; +} +.mc-export-btn { + width: 100%; padding: 12px; + background: #059669; color: white; + font-size: 0.85rem; font-weight: 700; + border: none; border-radius: 10px; cursor: pointer; +} +.mc-export-btn:disabled { background: #d1d5db; cursor: not-allowed; } +.mc-export-note { font-size: 0.7rem; color: #9ca3af; margin-top: 4px; } + +/* Modal */ +.mc-modal-overlay { + position: fixed; inset: 0; + background: rgba(0,0,0,0.4); + z-index: 50; + display: flex; align-items: center; justify-content: center; + padding: 16px; +} +.mc-modal { + background: white; border-radius: 12px; + width: 100%; max-width: 400px; overflow: hidden; +} +.mc-modal-header { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 16px; + border-bottom: 1px solid #f3f4f6; + font-weight: 700; font-size: 0.9rem; +} +.mc-modal-header button { background: none; border: none; color: #9ca3af; cursor: pointer; font-size: 1.1rem; } +.mc-modal-body { padding: 16px; } +.mc-modal-desc { font-size: 0.85rem; color: #374151; margin-bottom: 8px; } +.mc-textarea { + width: 100%; border: 1px solid #e5e7eb; border-radius: 8px; + padding: 10px; font-size: 0.85rem; resize: none; +} +.mc-modal-note { font-size: 0.75rem; color: #6b7280; margin-top: 8px; } +.mc-modal-footer { + display: flex; gap: 8px; padding: 12px 16px; + border-top: 1px solid #f3f4f6; +} +.mc-modal-cancel { + flex: 1; padding: 10px; border: 1px solid #e5e7eb; + border-radius: 8px; background: white; cursor: pointer; font-size: 0.8rem; +} +.mc-modal-submit { + flex: 1; padding: 10px; background: #ef4444; color: white; + border: none; border-radius: 8px; font-size: 0.8rem; font-weight: 600; cursor: pointer; +} + +/* Empty / No Permission */ +.mc-empty { + display: flex; flex-direction: column; align-items: center; + gap: 8px; padding: 48px 16px; color: #9ca3af; font-size: 0.875rem; +} + +/* Skeleton (reuse) */ +.ds-skeleton { + height: 56px; + background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%); + background-size: 200% 100%; + animation: ds-shimmer 1.5s infinite; + border-radius: 10px; + margin-bottom: 6px; +} +@keyframes ds-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } +.ds-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 48px 16px; color: #9ca3af; font-size: 0.875rem; } +.ds-link { color: #2563eb; font-size: 0.8rem; text-decoration: underline; } + +@media (max-width: 480px) { body { max-width: 480px; margin: 0 auto; } } diff --git a/system1-factory/web/js/monthly-comparison.js b/system1-factory/web/js/monthly-comparison.js new file mode 100644 index 0000000..6775781 --- /dev/null +++ b/system1-factory/web/js/monthly-comparison.js @@ -0,0 +1,523 @@ +/** + * monthly-comparison.js — 월간 비교·확인·정산 + * Sprint 004 Section B + */ + +// ===== Mock ===== +const MOCK_ENABLED = true; + +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 = window.currentUser; + if (!user) return; + + // mode 결정: URL > role 기반 자동 + if (urlMode === 'admin') { + if (ADMIN_ROLES.includes(user.role)) { + currentMode = 'admin'; + } else { + currentMode = 'my'; + showToast('관리자 전용 기능입니다', 'error'); + } + } else if (currentUserId && ADMIN_ROLES.includes(user.role)) { + currentMode = 'detail'; + } else if (!urlMode && ADMIN_ROLES.includes(user.role)) { + currentMode = 'admin'; + } else { + currentMode = 'my'; + } + + // 관리자 뷰 전환 버튼 (관리자만) + 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; + 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})` : ''; + attendLine = `
근태관리: ${r.attendance.total_work_hours}h (${escHtml(r.attendance.attendance_type)}${vacInfo})
`; + } else if (r.status !== 'holiday') { + attendLine = '
근태관리: 미입력
'; + } + + if (r.status === 'mismatch' && r.hours_diff) { + 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'); + + if (!conf) { actions.classList.remove('hidden'); statusEl.classList.add('hidden'); return; } + + badge.textContent = { pending: '미확인', confirmed: '확인완료', rejected: '반려' }[conf.status] || ''; + badge.className = `mc-status-badge ${conf.status}`; + + if (conf.status === 'confirmed') { + actions.classList.add('hidden'); + statusEl.classList.remove('hidden'); + const dt = conf.confirmed_at ? new Date(conf.confirmed_at).toLocaleString('ko') : ''; + document.getElementById('confirmedText').textContent = `${dt} 확인 완료`; + } else if (conf.status === 'rejected') { + // 재확인 가능 + actions.classList.remove('hidden'); + statusEl.classList.add('hidden'); + } else { + actions.classList.remove('hidden'); + 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.pending || 0} 대기` + + `❌ ${s.rejected || 0} 반려`; +} + +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 => { + const statusBadge = `${ + { confirmed: '확인완료', pending: '미확인', rejected: '반려' }[w.status] || '' + }`; + 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 === 'pending').length; + + if (pendingCount === 0) { + btn.disabled = false; + note.textContent = '모든 작업자가 확인을 완료했습니다'; + } else { + btn.disabled = true; + const rejectedCount = (workers || []).filter(w => w.status === 'rejected').length; + note.textContent = `${pendingCount}명 미확인${rejectedCount > 0 ? `, ${rejectedCount}명 반려` : ''} — 전원 확인 후 다운로드 가능합니다`; + } +} + +// ===== Actions ===== +async function confirmMonth() { + if (!confirm(`${currentYear}년 ${currentMonth}월 근무 내역을 확인하시겠습니까?`)) return; + + 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'); + } +} + +function openRejectModal() { + document.getElementById('rejectReason').value = ''; + document.getElementById('rejectModal').classList.remove('hidden'); +} + +function closeRejectModal() { + document.getElementById('rejectModal').classList.add('hidden'); +} + +async function submitReject() { + const reason = document.getElementById('rejectReason').value.trim(); + if (!reason) { + showToast('반려 사유를 입력해주세요', 'error'); + return; + } + + 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'); + } +} + +async function downloadExcel() { + try { + if (MOCK_ENABLED) { + showToast('Mock 모드에서는 다운로드를 지원하지 않습니다', 'info'); + return; + } + const token = localStorage.getItem('sso_token') || ''; + 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'); + } +} + +// ===== 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, '"'); +} + +function showToast(msg, type) { + if (window.showToast) { window.showToast(msg, type); return; } + const c = document.getElementById('toastContainer'); + const t = document.createElement('div'); + t.className = `toast toast-${type || 'info'}`; + t.textContent = msg; + c.appendChild(t); + setTimeout(() => t.remove(), 3000); +} + +// ESC로 모달 닫기 +document.addEventListener('keydown', e => { + if (e.key === 'Escape') closeRejectModal(); +}); diff --git a/system1-factory/web/pages/attendance/monthly-comparison.html b/system1-factory/web/pages/attendance/monthly-comparison.html new file mode 100644 index 0000000..5949788 --- /dev/null +++ b/system1-factory/web/pages/attendance/monthly-comparison.html @@ -0,0 +1,164 @@ + + + + + + 월간 근무 비교 - TK 공장관리 + + + + + + + +
+
+
+
+ + +

TK 공장관리

+
+
+ +
-
+ +
+
+
+
+ + +
+
+ +
+ + +
+
+ +

월간 근무 비교

+ +
+
+ + +
+ + 2026년 3월 + +
+
+ + +
+
+
-
총근무일
+
-
총시간
+
-
연장근로
+
-
휴가
+
+ + + +
+
+
+
+
+ +
+ + +
+ + +
+ + + + + + + + + + +
+
+
+ + + + + +
+ + + + + + diff --git a/system1-factory/web/static/js/tkfb-core.js b/system1-factory/web/static/js/tkfb-core.js index efee0f8..327976f 100644 --- a/system1-factory/web/static/js/tkfb-core.js +++ b/system1-factory/web/static/js/tkfb-core.js @@ -137,6 +137,7 @@ const NAV_MENU = [ { href: '/pages/attendance/vacation-management.html', icon: 'fa-cog', label: '휴가 관리', key: 'attendance.vacation_management', admin: true }, { href: '/pages/attendance/vacation-allocation.html', icon: 'fa-plus-circle', label: '휴가 발생 입력', key: 'attendance.vacation_allocation', admin: true }, { href: '/pages/attendance/annual-overview.html', icon: 'fa-chart-pie', label: '연간 휴가 현황', key: 'attendance.annual_overview', admin: true }, + { href: '/pages/attendance/monthly-comparison.html', icon: 'fa-scale-balanced', label: '월간 비교·확인', key: 'attendance.monthly_comparison' }, ]}, { cat: '시스템 관리', admin: true, items: [ { href: `${_tkuserBase}/?tab=users`, icon: 'fa-users-cog', label: '사용자 관리', key: 'admin.user_management', external: true },