feat(vacation): 이월 연차 소진/만료 구분 표시

- isExpired() 함수: 만료일 다음날부터 만료 (today > expires_at)
- 이월 셀에 만료 시 "소진 X + 만료 Y" 표시
  - 소진: 초록 (#10b981)
  - 만료: 회색 취소선 (#9ca3af)
- loadData()에 carryoverUsed, carryoverExpiresAt 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-31 08:55:30 +09:00
parent 6a721258b8
commit c615d0f121

View File

@@ -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 ? `<div style="font-size:10px;margin-top:2px;line-height:1.2">
<span style="color:#10b981">소진${fmtNum(carryoverUsed)}</span>
${carryoverLapsed > 0 ? `<span style="color:#9ca3af;text-decoration:line-through;margin-left:3px">만료${fmtNum(carryoverLapsed)}</span>` : ''}
</div>` : ''}
</td>
<td>
<input type="number" class="num-input"