From 76e4224b32f60cce327da7d5d82fb422ba1c2b65 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 31 Mar 2026 11:27:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(sprint004-b):=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=EC=9E=90=20=EC=9B=94=EA=B0=84=20=ED=99=95=EC=9D=B8=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=8B=A0=EA=B7=9C=20(=EB=AA=A8=EB=B0=94?= =?UTF-8?q?=EC=9D=BC=20=EC=A0=84=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - my-monthly-confirm.html/js/css: 출근부 형식 1인용 확인 페이지 - monthly-comparison.js: 비관리자 → my-monthly-confirm으로 리다이렉트 - 마이그레이션: pages 테이블에 attendance.my_monthly_confirm 등록 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../20260331_fix_deduct_days_precision.sql | 3 + .../web/css/my-monthly-confirm.css | 150 ++++++++++ system1-factory/web/js/monthly-comparison.js | 21 +- system1-factory/web/js/my-monthly-confirm.js | 271 ++++++++++++++++++ .../pages/attendance/monthly-comparison.html | 2 +- .../pages/attendance/my-monthly-confirm.html | 109 +++++++ 6 files changed, 543 insertions(+), 13 deletions(-) create mode 100644 system1-factory/web/css/my-monthly-confirm.css create mode 100644 system1-factory/web/js/my-monthly-confirm.js create mode 100644 system1-factory/web/pages/attendance/my-monthly-confirm.html diff --git a/system1-factory/api/db/migrations/20260331_fix_deduct_days_precision.sql b/system1-factory/api/db/migrations/20260331_fix_deduct_days_precision.sql index 3923205..a7070d7 100644 --- a/system1-factory/api/db/migrations/20260331_fix_deduct_days_precision.sql +++ b/system1-factory/api/db/migrations/20260331_fix_deduct_days_precision.sql @@ -10,3 +10,6 @@ WHERE svb.balance_type = 'COMPANY_GRANT' -- 조퇴 휴가 유형 추가 (0.75일 = 반차+반반차) INSERT IGNORE INTO vacation_types (type_code, type_name, deduct_days, is_active, priority) VALUES ('EARLY_LEAVE', '조퇴', 0.75, 1, 10); +-- 작업자 월간 확인 페이지 등록 +INSERT IGNORE INTO pages (page_key, page_name, page_path, category, display_order) +VALUES ('attendance.my_monthly_confirm', '월간 근무 확인', '/pages/attendance/my-monthly-confirm.html', '근태 관리', 25); diff --git a/system1-factory/web/css/my-monthly-confirm.css b/system1-factory/web/css/my-monthly-confirm.css new file mode 100644 index 0000000..355f281 --- /dev/null +++ b/system1-factory/web/css/my-monthly-confirm.css @@ -0,0 +1,150 @@ +/* my-monthly-confirm.css — 작업자 월간 확인 (모바일 전용) */ +body { max-width: 480px; margin: 0 auto; } + +/* 월 네비게이션 */ +.mmc-month-nav { + display: flex; align-items: center; justify-content: center; + gap: 12px; padding: 12px 0; position: relative; +} +.mmc-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; +} +.mmc-month-nav button:hover { background: #e5e7eb; } +.mmc-month-nav span { font-size: 1rem; font-weight: 700; color: #1f2937; } +.mmc-status-badge { + position: absolute; right: 0; top: 50%; transform: translateY(-50%); + font-size: 0.7rem; font-weight: 600; padding: 3px 8px; border-radius: 12px; +} +.mmc-status-badge.pending { background: #fef3c7; color: #92400e; } +.mmc-status-badge.confirmed { background: #dcfce7; color: #166534; } +.mmc-status-badge.rejected { background: #fef2f2; color: #991b1b; } + +/* 사용자 정보 */ +.mmc-user-info { + display: flex; align-items: baseline; gap: 8px; + padding: 0 4px 8px; font-size: 0.95rem; font-weight: 700; color: #1f2937; +} +.mmc-user-dept { font-size: 0.8rem; font-weight: 400; color: #6b7280; } + +/* 출근부 테이블 */ +.mmc-table-wrap { margin-bottom: 12px; } +.mmc-table { + width: 100%; border-collapse: collapse; background: white; + border-radius: 10px; overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,0.06); +} +.mmc-table td { padding: 10px 8px; border-bottom: 1px solid #f3f4f6; } +.col-day { width: 36px; text-align: center; font-weight: 600; font-size: 0.85rem; color: #374151; } +.col-dow { width: 28px; text-align: center; font-size: 0.8rem; color: #6b7280; } +.col-val { text-align: right; font-size: 0.85rem; color: #1f2937; padding-right: 12px; } +.dow-sun { color: #ef4444; font-weight: 600; } +.dow-sat { color: #3b82f6; font-weight: 600; } + +/* 행 스타일 */ +.row-holiday td { background: #f9fafb; } +.row-holiday-work td { background: #fefce8; } +.row-total td { background: #f0fdf4; font-weight: 700; font-size: 0.9rem; border-top: 2px solid #e5e7eb; } + +/* 값 스타일 */ +.val-vacation { color: #059669; font-weight: 600; } +.val-holiday { color: #9ca3af; } + +/* 연차 현황 */ +.mmc-vac-title { font-size: 0.8rem; font-weight: 600; color: #6b7280; margin-bottom: 6px; padding: 0 4px; } +.mmc-vac-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 16px; } +.mmc-vac-card { + background: white; border-radius: 10px; padding: 12px 8px; + text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.06); +} +.mmc-vac-num { font-size: 1.25rem; font-weight: 800; color: #1f2937; } +.mmc-vac-num.used { color: #f59e0b; } +.mmc-vac-num.remain { color: #059669; } +.mmc-vac-label { font-size: 0.7rem; color: #6b7280; margin-top: 2px; } + +/* 확인 상태 */ +.mmc-confirmed-status { + display: flex; align-items: center; gap: 8px; + justify-content: center; padding: 16px; + font-size: 0.85rem; color: #059669; font-weight: 600; + margin-bottom: 80px; +} + +/* 하단 버튼 */ +.mmc-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; +} +.mmc-confirm-btn { + flex: 1; padding: 14px; + background: #10b981; color: white; + font-size: 0.9rem; font-weight: 700; + border: none; border-radius: 12px; cursor: pointer; +} +.mmc-confirm-btn:hover { background: #059669; } +.mmc-reject-btn { + flex: 1; padding: 14px; + background: white; color: #ef4444; + font-size: 0.9rem; font-weight: 700; + border: 2px solid #fecaca; border-radius: 12px; cursor: pointer; +} +.mmc-reject-btn:hover { background: #fef2f2; } + +/* 모달 */ +.mmc-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; +} +.mmc-modal { + background: white; border-radius: 12px; + width: 100%; max-width: 400px; overflow: hidden; +} +.mmc-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; +} +.mmc-modal-header button { background: none; border: none; color: #9ca3af; cursor: pointer; font-size: 1.1rem; } +.mmc-modal-body { padding: 16px; } +.mmc-modal-desc { font-size: 0.85rem; color: #374151; margin-bottom: 8px; } +.mmc-textarea { + width: 100%; border: 1px solid #e5e7eb; border-radius: 8px; + padding: 10px; font-size: 0.85rem; resize: none; +} +.mmc-modal-note { font-size: 0.75rem; color: #6b7280; margin-top: 8px; } +.mmc-modal-footer { + display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid #f3f4f6; +} +.mmc-modal-cancel { + flex: 1; padding: 10px; border: 1px solid #e5e7eb; + border-radius: 8px; background: white; cursor: pointer; font-size: 0.8rem; +} +.mmc-modal-submit { + flex: 1; padding: 10px; background: #ef4444; color: white; + border: none; border-radius: 8px; font-size: 0.8rem; font-weight: 600; cursor: pointer; +} + +/* 빈 상태 */ +.mmc-empty { + display: flex; flex-direction: column; align-items: center; + gap: 8px; padding: 48px 16px; color: #9ca3af; font-size: 0.875rem; +} + +/* 스켈레톤 */ +.mmc-skeleton { + height: 40px; + background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%); + background-size: 200% 100%; + animation: mmc-shimmer 1.5s infinite; + border-radius: 8px; margin-bottom: 4px; +} +@keyframes mmc-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } + +/* 목록 아래 여백 (하단 고정 버튼 겹침 방지) */ +.mmc-table-wrap { padding-bottom: 8px; } +.mmc-vac-grid { margin-bottom: 80px; } diff --git a/system1-factory/web/js/monthly-comparison.js b/system1-factory/web/js/monthly-comparison.js index 381426e..81e0757 100644 --- a/system1-factory/web/js/monthly-comparison.js +++ b/system1-factory/web/js/monthly-comparison.js @@ -94,20 +94,17 @@ document.addEventListener('DOMContentLoaded', () => { 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)) { + // 비관리자 → 작업자 전용 확인 페이지로 리다이렉트 + if (!ADMIN_ROLES.includes(user.role)) { + location.href = '/pages/attendance/my-monthly-confirm.html'; + return; + } + + // 관리자 mode 결정 + if (currentUserId) { currentMode = 'detail'; - } else if (!urlMode && ADMIN_ROLES.includes(user.role)) { - currentMode = 'admin'; } else { - currentMode = 'my'; + currentMode = 'admin'; } // 관리자 뷰 전환 버튼 (관리자만) diff --git a/system1-factory/web/js/my-monthly-confirm.js b/system1-factory/web/js/my-monthly-confirm.js new file mode 100644 index 0000000..a71e15a --- /dev/null +++ b/system1-factory/web/js/my-monthly-confirm.js @@ -0,0 +1,271 @@ +/** + * my-monthly-confirm.js — 작업자 월간 근무 확인 (모바일 전용) + * Sprint 004 Section B + */ + +const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토']; +const VAC_TEXT = { + 'ANNUAL_FULL': '연차', 'ANNUAL_HALF': '반차', 'ANNUAL_QUARTER': '반반차', + 'EARLY_LEAVE': '조퇴', 'SICK': '병가', 'SICK_FULL': '병가', 'SICK_HALF': '병가(반)', + 'SPECIAL': '경조사', 'SPOUSE_BIRTH': '출산휴가' +}; + +let currentYear, currentMonth; +let isProcessing = false; + +// ===== Init ===== +document.addEventListener('DOMContentLoaded', function() { + var now = new Date(); + currentYear = now.getFullYear(); + currentMonth = now.getMonth() + 1; + + var params = new URLSearchParams(location.search); + if (params.get('year')) currentYear = parseInt(params.get('year')); + if (params.get('month')) currentMonth = parseInt(params.get('month')); + + setTimeout(function() { + if (!window.currentUser) return; + 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() { + var tableWrap = document.getElementById('tableWrap'); + tableWrap.innerHTML = '
'; + + try { + var userId = window.currentUser.user_id || window.currentUser.id; + var [recordsRes, balanceRes] = await Promise.all([ + window.apiCall('/monthly-comparison/my-records?year=' + currentYear + '&month=' + currentMonth), + window.apiCall('/attendance/vacation-balance/' + userId).catch(function() { return { success: true, data: [] }; }) + ]); + + if (!recordsRes || !recordsRes.success) { + tableWrap.innerHTML = '

데이터가 없습니다

'; + document.getElementById('bottomActions').classList.add('hidden'); + return; + } + + var data = recordsRes.data; + renderUserInfo(data.user); + renderAttendanceTable(data.daily_records || []); + renderVacationBalance(balanceRes.data || []); + renderConfirmStatus(data.confirmation); + } catch (e) { + tableWrap.innerHTML = '

네트워크 오류

'; + } +} + +// ===== Render ===== +function renderUserInfo(user) { + if (!user) return; + document.getElementById('userName').textContent = user.worker_name || user.name || '-'; + document.getElementById('userDept').textContent = + (user.job_type ? user.job_type + ' · ' : '') + (user.department_name || ''); +} + +function renderAttendanceTable(records) { + var el = document.getElementById('tableWrap'); + if (!records.length) { + el.innerHTML = '

해당 월 데이터가 없습니다

'; + return; + } + + var totalHours = 0; + var html = ''; + + records.forEach(function(r) { + var day = parseInt(r.date.substring(8)); + var dow = r.day_of_week || DAYS_KR[new Date(r.date).getDay()]; + var isHoliday = r.is_holiday; + var rowClass = isHoliday ? ' class="row-holiday"' : ''; + + // 셀 값 결정 + var val = '-'; + var valClass = ''; + + if (r.attendance && r.attendance.vacation_type) { + val = r.attendance.vacation_type; + valClass = 'val-vacation'; + } else if (isHoliday && (!r.attendance || !r.attendance.total_work_hours)) { + val = '휴무'; + valClass = 'val-holiday'; + } else if (r.attendance && r.attendance.total_work_hours > 0) { + var hrs = parseFloat(r.attendance.total_work_hours); + val = hrs.toFixed(2); + totalHours += hrs; + } else if (r.work_report && r.work_report.total_hours > 0) { + var hrs2 = parseFloat(r.work_report.total_hours); + val = hrs2.toFixed(2); + totalHours += hrs2; + } + + // 주말 근무 + if (isHoliday && r.attendance && r.attendance.total_work_hours > 0) { + val = parseFloat(r.attendance.total_work_hours).toFixed(2); + totalHours += parseFloat(r.attendance.total_work_hours); + rowClass = ' class="row-holiday-work"'; + valClass = ''; + } + + var dowClass = (dow === '일') ? 'dow-sun' : (dow === '토') ? 'dow-sat' : ''; + + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + }); + + html += ''; + html += ''; + html += ''; + html += ''; + html += '
' + day + '' + dow + '' + escHtml(val) + '
총 근무시간' + totalHours.toFixed(2) + 'h
'; + + el.innerHTML = html; +} + +function renderVacationBalance(balances) { + var el = document.getElementById('vacationCards'); + var total = 0, used = 0; + + if (Array.isArray(balances)) { + balances.forEach(function(b) { + total += parseFloat(b.total_days || b.remaining_days_total || 0); + used += parseFloat(b.used_days || 0); + }); + } + + var remaining = total - used; + el.innerHTML = + '
연차 현황
' + + '
' + + '
' + fmtNum(total) + '
신규
' + + '
' + fmtNum(used) + '
사용
' + + '
' + fmtNum(remaining) + '
잔여
' + + '
'; +} + +function renderConfirmStatus(conf) { + var actions = document.getElementById('bottomActions'); + var statusEl = document.getElementById('confirmedStatus'); + var badge = document.getElementById('statusBadge'); + + if (!conf || conf.status === 'pending') { + actions.classList.remove('hidden'); + statusEl.classList.add('hidden'); + badge.textContent = '미확인'; + badge.className = 'mmc-status-badge pending'; + return; + } + + if (conf.status === 'confirmed') { + actions.classList.add('hidden'); + statusEl.classList.remove('hidden'); + badge.textContent = '확인완료'; + badge.className = 'mmc-status-badge confirmed'; + var dt = conf.confirmed_at ? new Date(conf.confirmed_at).toLocaleDateString('ko') : ''; + document.getElementById('confirmedText').textContent = dt + ' 확인 완료'; + } else if (conf.status === 'rejected') { + actions.classList.remove('hidden'); + statusEl.classList.add('hidden'); + badge.textContent = '반려'; + badge.className = 'mmc-status-badge rejected'; + } +} + +// ===== Actions ===== +async function confirmMonth() { + if (isProcessing) return; + if (!confirm(currentYear + '년 ' + currentMonth + '월 근무 내역을 확인하시겠습니까?')) return; + + isProcessing = true; + try { + var res = await window.apiCall('/monthly-comparison/confirm', 'POST', { + year: currentYear, month: currentMonth, status: 'confirmed' + }); + if (res && res.success) { + showToast(res.message || '확인 완료', 'success'); + loadData(); + } else { + showToast(res && 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; + var reason = document.getElementById('rejectReason').value.trim(); + if (!reason) { showToast('반려 사유를 입력해주세요', 'error'); return; } + + isProcessing = true; + try { + var 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(); + loadData(); + } else { + showToast(res && res.message || '처리 실패', 'error'); + } + } catch (e) { + showToast('네트워크 오류', 'error'); + } finally { + isProcessing = false; + } +} + +// ===== Helpers ===== +function fmtNum(v) { + var n = parseFloat(v) || 0; + return n % 1 === 0 ? n.toString() : n.toFixed(1); +} + +function escHtml(s) { + return (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function showToast(msg, type) { + if (window.showToast) { window.showToast(msg, type); return; } + var c = document.getElementById('toastContainer'); + var t = document.createElement('div'); + t.className = 'toast toast-' + (type || 'info'); + t.textContent = msg; + c.appendChild(t); + setTimeout(function() { t.remove(); }, 3000); +} + +document.addEventListener('keydown', function(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 index c12c2c3..01dc20c 100644 --- a/system1-factory/web/pages/attendance/monthly-comparison.html +++ b/system1-factory/web/pages/attendance/monthly-comparison.html @@ -159,7 +159,7 @@ - + diff --git a/system1-factory/web/pages/attendance/my-monthly-confirm.html b/system1-factory/web/pages/attendance/my-monthly-confirm.html new file mode 100644 index 0000000..64ec759 --- /dev/null +++ b/system1-factory/web/pages/attendance/my-monthly-confirm.html @@ -0,0 +1,109 @@ + + + + + + 월간 근무 확인 - TK 공장관리 + + + + + + + +
+
+
+
+ + +

TK 공장관리

+
+
+ +
-
+ +
+
+
+
+ +
+
+ +
+ + +
+ + 2026년 3월 + +
+
+ + + + + +
+
+
+
+ + +
+ + + + + +
+ + +
+ +
+
+
+ + + + + +
+ + + + + + +