feat(tkfb): 연간 연차 현황 — 사용자 클릭 시 월별 세부내역 모달

- 사용자 이름 클릭 → 월별 요약 테이블 (연차/반차/반반차/합계)
- 상세 내역: 일자별 사용 기록
- 기존 /attendance/records API 활용 (별도 백엔드 불필요)
- 경조사 모달과 동일 패턴 (overlay, ESC, 배경 클릭 닫기)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-31 08:39:49 +09:00
parent b2ce691ef9
commit b67e8f2c9f

View File

@@ -339,6 +339,22 @@
</div>
</div>
<!-- 월별 사용 내역 모달 -->
<div class="modal-overlay" id="monthlyDetailModal">
<div class="modal-content" style="max-width:600px;">
<div class="modal-header">
<h3 class="modal-title" id="monthlyDetailTitle">연차 사용 내역</h3>
<button class="modal-close" onclick="closeMonthlyDetail()">&times;</button>
</div>
<div class="modal-body" id="monthlyDetailBody" style="max-height:60vh;overflow-y:auto;">
<p style="text-align:center;color:#9ca3af;padding:1rem;">로딩 중...</p>
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick="closeMonthlyDetail()">닫기</button>
</div>
</div>
</div>
<script src="/static/js/tkfb-core.js?v=2026031601"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
@@ -497,7 +513,7 @@
return `
<tr data-user-id="${w.user_id}">
<td>${idx + 1}</td>
<td class="worker-name">${w.worker_name}</td>
<td class="worker-name" style="cursor:pointer;color:#2563eb;text-decoration:underline" onclick="openMonthlyDetail(${w.user_id}, '${w.worker_name.replace(/'/g, "\\'")}')">${w.worker_name}</td>
<td>
<input type="number" class="num-input ${carryover < 0 ? 'negative' : ''}"
value="${fmtNum(carryover)}" step="0.25"
@@ -660,12 +676,104 @@
// ESC로 모달 닫기
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeSpecialModal();
if (e.key === 'Escape') { closeSpecialModal(); closeMonthlyDetail(); }
});
document.getElementById('specialModal').addEventListener('click', e => {
if (e.target.id === 'specialModal') closeSpecialModal();
});
// ===== 월별 사용 내역 모달 =====
async function openMonthlyDetail(userId, workerName) {
const year = parseInt(document.getElementById('yearSelect').value);
document.getElementById('monthlyDetailTitle').textContent = `${workerName}${year}년 연차 사용 내역`;
document.getElementById('monthlyDetailBody').innerHTML = '<p style="text-align:center;color:#9ca3af;padding:1rem;">로딩 중...</p>';
document.getElementById('monthlyDetailModal').classList.add('active');
try {
const res = await axios.get(`/attendance/records?start_date=${year}-01-01&end_date=${year}-12-31&user_id=${userId}`);
const records = (res.data.data || []).filter(r => r.vacation_type_id);
if (records.length === 0) {
document.getElementById('monthlyDetailBody').innerHTML = '<p style="text-align:center;color:#9ca3af;padding:1rem;">연차 사용 내역이 없습니다</p>';
return;
}
// 월별 그룹핑
const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
const monthly = {};
const totals = { ANNUAL_FULL: 0, ANNUAL_HALF: 0, ANNUAL_QUARTER: 0, other: 0 };
records.forEach(r => {
const d = new Date(r.record_date);
const m = d.getMonth() + 1;
if (!monthly[m]) monthly[m] = { ANNUAL_FULL: 0, ANNUAL_HALF: 0, ANNUAL_QUARTER: 0, other: 0, total: 0 };
const days = parseFloat(r.vacation_days) || 1;
const code = r.vacation_type_code || 'other';
if (['ANNUAL_FULL', 'ANNUAL_HALF', 'ANNUAL_QUARTER'].includes(code)) {
monthly[m][code] += days;
totals[code] += days;
} else {
monthly[m].other += days;
totals.other += days;
}
monthly[m].total += days;
});
const grandTotal = totals.ANNUAL_FULL + totals.ANNUAL_HALF + totals.ANNUAL_QUARTER + totals.other;
// 월별 요약 테이블
let html = '<h4 style="font-size:0.85rem;font-weight:600;margin-bottom:0.5rem;">월별 요약</h4>';
html += '<table class="data-table" style="margin-bottom:1rem;"><thead><tr>';
html += '<th style="position:static !important;">월</th><th style="position:static !important;">연차</th><th style="position:static !important;">반차</th><th style="position:static !important;">반반차</th><th style="position:static !important;">합계</th>';
html += '</tr></thead><tbody>';
for (let m = 1; m <= 12; m++) {
if (!monthly[m]) continue;
const d = monthly[m];
html += `<tr>
<td>${m}월</td>
<td>${d.ANNUAL_FULL > 0 ? fmtNum(d.ANNUAL_FULL) : '-'}</td>
<td>${d.ANNUAL_HALF > 0 ? fmtNum(d.ANNUAL_HALF) : '-'}</td>
<td>${d.ANNUAL_QUARTER > 0 ? fmtNum(d.ANNUAL_QUARTER) : '-'}</td>
<td style="font-weight:600">${fmtNum(d.total)}</td>
</tr>`;
}
html += `<tr style="font-weight:700;border-top:2px solid #e5e7eb;">
<td>합계</td>
<td>${fmtNum(totals.ANNUAL_FULL)}</td>
<td>${fmtNum(totals.ANNUAL_HALF)}</td>
<td>${fmtNum(totals.ANNUAL_QUARTER)}</td>
<td style="color:#059669">${fmtNum(grandTotal)}</td>
</tr>`;
html += '</tbody></table>';
// 상세 내역
html += '<h4 style="font-size:0.85rem;font-weight:600;margin-bottom:0.5rem;">상세 내역</h4>';
html += '<div style="font-size:0.8rem;">';
records.sort((a, b) => a.record_date.localeCompare(b.record_date)).forEach(r => {
const d = new Date(r.record_date);
const dateStr = `${d.getMonth()+1}/${String(d.getDate()).padStart(2,'0')}(${DAYS_KR[d.getDay()]})`;
const days = parseFloat(r.vacation_days) || 1;
html += `<div style="padding:0.25rem 0;border-bottom:1px solid #f3f4f6;display:flex;justify-content:space-between">
<span>${dateStr} ${r.vacation_type_name || ''}</span>
<span style="color:#6b7280">${fmtNum(days)}일</span>
</div>`;
});
html += '</div>';
document.getElementById('monthlyDetailBody').innerHTML = html;
} catch (e) {
document.getElementById('monthlyDetailBody').innerHTML = '<p style="text-align:center;color:#ef4444;padding:1rem;">데이터 로드 실패</p>';
}
}
function closeMonthlyDetail() {
document.getElementById('monthlyDetailModal').classList.remove('active');
}
document.getElementById('monthlyDetailModal').addEventListener('click', e => {
if (e.target.id === 'monthlyDetailModal') closeMonthlyDetail();
});
// ===== 저장 =====
async function saveAll() {
const balancesToSave = [];