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 = '데이터를 불러올 수 없습니다
네트워크 오류
데이터를 불러올 수 없습니다
네트워크 오류
데이터가 없습니다
해당 조건의 작업자가 없습니다