From c615d0f12119a52b917627f8aae1607e03616e51 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 31 Mar 2026 08:55:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(vacation):=20=EC=9D=B4=EC=9B=94=20?= =?UTF-8?q?=EC=97=B0=EC=B0=A8=20=EC=86=8C=EC=A7=84/=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isExpired() 함수: 만료일 다음날부터 만료 (today > expires_at) - 이월 셀에 만료 시 "소진 X + 만료 Y" 표시 - 소진: 초록 (#10b981) - 만료: 회색 취소선 (#9ca3af) - loadData()에 carryoverUsed, carryoverExpiresAt 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/pages/attendance/annual-overview.html | 62 ++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/system1-factory/web/pages/attendance/annual-overview.html b/system1-factory/web/pages/attendance/annual-overview.html index 2ede310..118ac5e 100644 --- a/system1-factory/web/pages/attendance/annual-overview.html +++ b/system1-factory/web/pages/attendance/annual-overview.html @@ -232,6 +232,41 @@ border-top: 1px solid #e5e7eb; } + /* 아코디언 */ + .month-accordion { margin-bottom: 0.25rem; } + .month-header { + cursor: pointer; padding: 0.5rem; + background: #f9fafb; border-radius: 0.375rem; + font-weight: 600; font-size: 0.85rem; + display: flex; align-items: center; gap: 0.5rem; + user-select: none; + } + .month-header:hover { background: #f3f4f6; } + .month-arrow { font-size: 0.7rem; width: 1rem; text-align: center; } + .month-body { padding: 0.25rem 0 0.25rem 1.5rem; } + .record-row { + display: flex; justify-content: space-between; align-items: center; + padding: 0.3rem 0; border-bottom: 1px solid #f3f4f6; + font-size: 0.8rem; gap: 0.5rem; + } + .record-row .record-info { flex: 1; } + .record-row .record-days { color: #6b7280; white-space: nowrap; } + .record-actions { display: flex; gap: 0.25rem; white-space: nowrap; } + .record-actions button { + font-size: 0.7rem; padding: 0.15rem 0.4rem; + border: 1px solid #d1d5db; border-radius: 0.25rem; + background: white; cursor: pointer; + } + .record-actions button:hover { background: #f3f4f6; } + .record-actions .btn-del { color: #dc2626; border-color: #fca5a5; } + .record-actions .btn-del:hover { background: #fee2e2; } + /* 인라인 편집 */ + .edit-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.3rem 0; border-bottom: 1px solid #f3f4f6; font-size: 0.8rem; } + .edit-row input[type="date"] { padding: 0.2rem 0.3rem; border: 1px solid #93c5fd; border-radius: 0.25rem; font-size: 0.78rem; } + .edit-row select { padding: 0.2rem 0.3rem; border: 1px solid #93c5fd; border-radius: 0.25rem; font-size: 0.78rem; } + .edit-row .btn-save { background: #3b82f6; color: white; border: none; padding: 0.2rem 0.5rem; border-radius: 0.25rem; font-size: 0.7rem; cursor: pointer; } + .edit-row .btn-cancel { background: #f3f4f6; border: 1px solid #d1d5db; padding: 0.2rem 0.5rem; border-radius: 0.25rem; font-size: 0.7rem; cursor: pointer; } + .loading { text-align: center; padding: 2rem; color: #6b7280; } @media (max-width: 768px) { @@ -371,6 +406,14 @@ }, 50); })(); + // 만료 판단: 만료일 다음날부터 만료 (2/28 만료 → 3/1부터) + function isExpired(expiresAt) { + if (!expiresAt) return false; + const today = new Date(); today.setHours(0,0,0,0); + const exp = new Date(expiresAt); exp.setHours(0,0,0,0); + return today > exp; + } + // 숫자 포맷: 정수면 소수점 없이, 소수면 2자리 function fmtNum(v) { const n = parseFloat(v) || 0; @@ -383,6 +426,12 @@ let vacationData = {}; // { workerId: { carryover, annual, longService, specials: [{type, days}], totalUsed } } let currentWorkerId = null; + // 월별 세부 모달 상태 + let currentModalUserId = null; + let currentModalName = ''; + let editBackup = {}; + const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토']; + // 경조사 유형 const specialTypes = [ { code: 'WEDDING', name: '결혼', defaultDays: 5 }, @@ -446,6 +495,8 @@ workers.forEach(w => { vacationData[w.user_id] = { carryover: 0, + carryoverUsed: 0, + carryoverExpiresAt: null, annual: 0, longService: 0, specials: [], @@ -462,6 +513,8 @@ if (btype === 'CARRY_OVER' || code === 'CARRYOVER' || b.type_name === '이월') { data.carryover += parseFloat(b.total_days) || 0; + data.carryoverUsed += parseFloat(b.used_days) || 0; + data.carryoverExpiresAt = b.expires_at || data.carryoverExpiresAt; data.totalUsed += parseFloat(b.used_days) || 0; } else if (btype === 'LONG_SERVICE') { data.longService += parseFloat(b.total_days) || 0; @@ -500,8 +553,11 @@ } tbody.innerHTML = workers.map((w, idx) => { - const d = vacationData[w.user_id] || { carryover: 0, annual: 0, longService: 0, specials: [], totalUsed: 0 }; + const d = vacationData[w.user_id] || { carryover: 0, carryoverUsed: 0, carryoverExpiresAt: null, annual: 0, longService: 0, specials: [], totalUsed: 0 }; const carryover = parseFloat(d.carryover) || 0; + const carryoverExpired = isExpired(d.carryoverExpiresAt); + const carryoverUsed = parseFloat(d.carryoverUsed) || 0; + const carryoverLapsed = carryoverExpired ? Math.max(0, carryover - carryoverUsed) : 0; const annual = parseFloat(d.annual) || 0; const longService = parseFloat(d.longService) || 0; const totalUsed = parseFloat(d.totalUsed) || 0; @@ -520,6 +576,10 @@ data-field="carryover" onchange="updateField(${w.user_id}, 'carryover', this.value)" onblur="var n=parseFloat(this.value||0);this.value=n%1===0?n.toString():n.toFixed(2)"> + ${carryoverExpired && carryover > 0 ? `
+ 소진${fmtNum(carryoverUsed)} + ${carryoverLapsed > 0 ? `만료${fmtNum(carryoverLapsed)}` : ''} +
` : ''}