/* ===== Vacation CRUD ===== */ let vacTypes = [], vacBalances = [], vacationsLoaded = false, vacUsers = []; const BALANCE_TYPE_STYLES = { AUTO: { label: '기본연차', cls: 'bg-blue-50 text-blue-600' }, MANUAL: { label: '추가부여', cls: 'bg-purple-50 text-purple-600' }, CARRY_OVER: { label: '이월연차', cls: 'bg-orange-50 text-orange-600' }, LONG_SERVICE: { label: '장기근속', cls: 'bg-emerald-50 text-emerald-600' }, COMPANY_GRANT: { label: '회사부여', cls: 'bg-yellow-50 text-yellow-600' }, }; async function loadVacationsTab() { // 연도 셀렉트 초기화 const sel = document.getElementById('vacYear'); if (sel && !sel.children.length) { const curYear = new Date().getFullYear(); for (let y = curYear + 1; y >= curYear - 2; y--) { const o = document.createElement('option'); o.value = y; o.textContent = y + '년'; if (y === curYear) o.selected = true; sel.appendChild(o); } } await loadVacTypes(); await loadVacUsers(); populateVacDeptFilter(); await loadVacBalances(); vacationsLoaded = true; } async function loadVacTypes() { try { const r = await api('/vacations/types?all=true'); vacTypes = r.data || []; renderVacTypeSidebar(); } catch(e) { console.warn('휴가 유형 로드 실패:', e); } } async function loadVacUsers() { try { const r = await api('/users'); vacUsers = ((r.data || r) || []).filter(u => u.is_active !== 0 && u.is_active !== false); } catch(e) { console.warn('사용자 로드 실패:', e); } } function populateVacDeptFilter() { const sel = document.getElementById('vacDeptFilter'); if (!sel) return; sel.innerHTML = ''; const depts = departmentsCache && departmentsCache.length ? departmentsCache : []; depts.forEach(d => { sel.innerHTML += ``; }); } function filterVacBalances() { renderVacBalanceTable(); } function renderVacTypeSidebar() { const c = document.getElementById('vacTypeSidebar'); if (!c) return; if (!vacTypes.length) { c.innerHTML = '

등록된 유형이 없습니다.

'; return; } c.innerHTML = vacTypes.map(vt => `
${escHtml(vt.type_name)} ${vt.is_system ? '시스템' : ''} ${vt.is_special ? '특별' : ''} ${!vt.is_active ? '비활성' : ''}
${escHtml(vt.type_code)} | 차감 ${vt.deduct_days}일 | 우선순위 ${vt.priority}
${!vt.is_system ? `` : ''}
`).join(''); } // 유형 모달 function openVacTypeModal(editId) { document.getElementById('vtEditId').value = ''; document.getElementById('vacTypeForm').reset(); document.getElementById('vtDeductDays').value = '1.0'; document.getElementById('vtPriority').value = '99'; document.getElementById('vacTypeModalTitle').textContent = '휴가 유형 추가'; document.getElementById('vtCode').readOnly = false; if (editId) { const vt = vacTypes.find(v => v.id === editId); if (!vt) return; document.getElementById('vacTypeModalTitle').textContent = '휴가 유형 수정'; document.getElementById('vtEditId').value = vt.id; document.getElementById('vtCode').value = vt.type_code; document.getElementById('vtCode').readOnly = !!vt.is_system; document.getElementById('vtName').value = vt.type_name; document.getElementById('vtDeductDays').value = vt.deduct_days; document.getElementById('vtPriority').value = vt.priority; document.getElementById('vtDescription').value = vt.description || ''; document.getElementById('vtSpecial').checked = !!vt.is_special; } document.getElementById('vacTypeModal').classList.remove('hidden'); } function closeVacTypeModal() { document.getElementById('vacTypeModal').classList.add('hidden'); } function editVacType(id) { openVacTypeModal(id); } document.getElementById('vacTypeForm').addEventListener('submit', async e => { e.preventDefault(); const editId = document.getElementById('vtEditId').value; const body = { type_code: document.getElementById('vtCode').value.trim().toUpperCase(), type_name: document.getElementById('vtName').value.trim(), deduct_days: parseFloat(document.getElementById('vtDeductDays').value) || 1.0, priority: parseInt(document.getElementById('vtPriority').value) || 99, description: document.getElementById('vtDescription').value.trim() || null, is_special: document.getElementById('vtSpecial').checked }; try { if (editId) { await api(`/vacations/types/${editId}`, { method: 'PUT', body: JSON.stringify(body) }); showToast('휴가 유형이 수정되었습니다.'); } else { await api('/vacations/types', { method: 'POST', body: JSON.stringify(body) }); showToast('휴가 유형이 추가되었습니다.'); } closeVacTypeModal(); await loadVacTypes(); } catch(e) { showToast(e.message, 'error'); } }); async function deleteVacType(id, name) { if (!confirm(`"${name}" 유형을 비활성화하시겠습니까?`)) return; try { await api(`/vacations/types/${id}`, { method: 'DELETE' }); showToast('비활성화되었습니다.'); await loadVacTypes(); } catch(e) { showToast(e.message, 'error'); } } // 연차 배정 테이블 async function loadVacBalances() { const year = document.getElementById('vacYear')?.value || new Date().getFullYear(); try { const r = await api(`/vacations/balances/year/${year}`); // 필드명 정규화 (과도기 호환: worker_id → user_id) vacBalances = (r.data || []).map(b => ({ ...b, user_id: b.user_id || b.worker_id, user_name: b.user_name || b.worker_name, })); renderVacBalanceTable(); } catch(e) { document.getElementById('vacBalanceTable').innerHTML = `

${e.message}

`; } } let vacDeptCollapsed = {}; function toggleVacDept(deptName) { vacDeptCollapsed[deptName] = !vacDeptCollapsed[deptName]; const body = document.getElementById('vacDept_' + CSS.escape(deptName)); const icon = document.getElementById('vacDeptIcon_' + CSS.escape(deptName)); if (body) body.classList.toggle('hidden'); if (icon) icon.style.transform = vacDeptCollapsed[deptName] ? 'rotate(-90deg)' : ''; } function showLongServiceCheckbox(user) { // 백엔드 플래그 우선 if ('long_service_excluded' in user) return true; // fallback: hire_date 기준 5년 계산 if (!user.hire_date) return false; const years = (Date.now() - new Date(user.hire_date).getTime()) / (365.25 * 24 * 60 * 60 * 1000); return years >= 5; } function renderVacBalanceTable() { const c = document.getElementById('vacBalanceTable'); // Apply filters const deptFilter = document.getElementById('vacDeptFilter')?.value || ''; const searchTerm = (document.getElementById('vacSearch')?.value || '').toLowerCase().trim(); let filtered = [...vacBalances]; if (deptFilter) filtered = filtered.filter(b => String(b.department_id) === deptFilter); if (searchTerm) filtered = filtered.filter(b => (b.user_name || '').toLowerCase().includes(searchTerm)); // Hide LONG_SERVICE with total_days <= 0 or long_service_excluded filtered = filtered.filter(b => { if (b.balance_type === 'LONG_SERVICE' && (parseFloat(b.total_days) <= 0 || b.long_service_excluded === true)) return false; return true; }); if (!filtered.length) { c.innerHTML = '

배정된 연차가 없습니다. "자동 계산" 또는 "개별 배정"을 이용하세요.

'; return; } // 부서 -> 사용자 -> 휴가유형 그룹핑 const deptMap = {}; filtered.forEach(b => { const deptName = b.department_name || '미배정'; if (!deptMap[deptName]) deptMap[deptName] = {}; const uid = b.user_id; if (!deptMap[deptName][uid]) deptMap[deptName][uid] = { name: b.user_name, hire_date: b.hire_date, department_id: b.department_id, long_service_excluded: b.long_service_excluded, items: [] }; deptMap[deptName][uid].items.push(b); }); const deptNames = Object.keys(deptMap).sort((a, b) => a === '미배정' ? 1 : b === '미배정' ? -1 : a.localeCompare(b)); let html = '
'; deptNames.forEach(deptName => { const usersMap = deptMap[deptName]; const userCount = Object.keys(usersMap).length; // 부서 합계 let dTotal = 0, dUsed = 0; Object.values(usersMap).forEach(g => g.items.forEach(b => { dTotal += parseFloat(b.total_days) || 0; dUsed += parseFloat(b.used_days) || 0; })); const dRemain = dTotal - dUsed; const usagePct = dTotal > 0 ? Math.round((dUsed / dTotal) * 100) : 0; const barColor = usagePct >= 80 ? 'bg-red-400' : usagePct >= 50 ? 'bg-amber-400' : 'bg-emerald-400'; const collapsed = vacDeptCollapsed[deptName]; const eid = CSS.escape(deptName); html += `
`; // 헤더 (클릭으로 접기/펼치기) html += `
${escHtml(deptName)} ${userCount}명
배정 ${dTotal} 사용 ${dUsed} 잔여 ${dRemain}
${usagePct}%
`; // 테이블 본문 html += `
`; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; Object.entries(usersMap).forEach(([uid, g]) => { const showLsCheckbox = showLongServiceCheckbox(g); g.items.forEach((b, i) => { const remaining = parseFloat(b.remaining_days || (b.total_days - b.used_days)); const remClass = remaining <= 0 ? 'text-red-500 font-semibold' : remaining <= 3 ? 'text-amber-500 font-medium' : 'text-emerald-600'; const btStyle = b.balance_type && BALANCE_TYPE_STYLES[b.balance_type] ? BALANCE_TYPE_STYLES[b.balance_type] : null; html += ``; if (i === 0) { const rowspan = g.items.length; html += ``; html += ``; } html += ``; html += ``; html += ``; html += ``; html += ``; html += ``; html += ''; }); }); html += '
사용자입사일휴가유형배정사용잔여비고
${escHtml(g.name)} ${showLsCheckbox ? `
` : ''}
${g.hire_date ? new Date(g.hire_date).toISOString().substring(0,10) : '-'} ${escHtml(b.type_name)} ${btStyle ? `${btStyle.label}` : ''} ${b.total_days}${b.used_days}${remaining}${escHtml(b.notes||'')}
'; }); html += '
'; c.innerHTML = html; } // 장기근속 제외 토글 async function toggleLongServiceExclusion(userId, excluded) { try { await api('/vacations/long-service-exclusion', { method: 'PUT', body: JSON.stringify({ user_id: userId, excluded: excluded }) }); showToast(excluded ? '장기근속 제외 설정됨' : '장기근속 제외 해제됨'); await loadVacBalances(); } catch (e) { showToast(e.message, 'error'); await loadVacBalances(); // revert checkbox state } } // 자동 계산 async function autoCalcVacation() { const year = document.getElementById('vacYear')?.value || new Date().getFullYear(); const deptFilter = document.getElementById('vacDeptFilter')?.value || ''; let msg = `${year}년 전체 사용자 연차를 입사일 기준으로 자동 계산합니다.\n기존 배정이 있으면 덮어씁니다. 진행하시겠습니까?`; if (deptFilter) { const deptName = document.getElementById('vacDeptFilter')?.selectedOptions?.[0]?.textContent || ''; msg = `${year}년 "${deptName}" 부서 사용자 연차를 자동 계산합니다.\n진행하시겠습니까?`; } if (!confirm(msg)) return; try { const body = { year: parseInt(year) }; if (deptFilter) body.department_id = parseInt(deptFilter); const r = await api('/vacations/balances/auto-calculate', { method: 'POST', body: JSON.stringify(body) }); const count = (r.data && (r.data.calculated || r.data.count)) || 0; const skipped = (r.data && r.data.skipped) || 0; showToast(`${count}명 자동 배정 완료${skipped ? `, ${skipped}명 건너뜀` : ''}`); await loadVacBalances(); } catch(e) { showToast(e.message, 'error'); } } // vacation_type_id 자동 매핑 (vacTypes에서 type_code로 찾기, fallback 내장) const VAC_TYPE_FALLBACK = { ANNUAL_FULL: 1, CARRYOVER: 6, LONG_SERVICE: 7 }; function getVacTypeId(typeCode) { const t = vacTypes.find(v => v.type_code === typeCode); return t ? t.id : (VAC_TYPE_FALLBACK[typeCode] || 1); } // balance_type 변경 시 vacation_type_id + expires_at + 경조사 드롭다운 조정 function onBalanceTypeChange() { const bt = document.getElementById('vbBalanceType').value; const expiresInput = document.getElementById('vbExpiresAt'); const specialRow = document.getElementById('vbSpecialTypeRow'); const year = document.getElementById('vbYear')?.value || document.getElementById('vacYear')?.value || new Date().getFullYear(); // vacation_type_id 자동 설정 const typeMap = { AUTO: 'ANNUAL_FULL', MANUAL: 'ANNUAL_FULL', CARRY_OVER: 'CARRYOVER', LONG_SERVICE: 'LONG_SERVICE' }; if (typeMap[bt]) { document.getElementById('vbType').value = getVacTypeId(typeMap[bt]) || ''; } // 경조사 드롭다운 표시/숨김 if (bt === 'COMPANY_GRANT') { specialRow.classList.remove('hidden'); // 경조사 유형으로 vacation_type_id 설정 const specialCode = document.getElementById('vbSpecialType').value; document.getElementById('vbType').value = getVacTypeId(specialCode) || getVacTypeId('ANNUAL_FULL') || ''; } else { specialRow.classList.add('hidden'); } // 만료일 if (bt === 'LONG_SERVICE' || bt === 'COMPANY_GRANT') { expiresInput.disabled = true; expiresInput.value = ''; } else { expiresInput.disabled = false; if (bt === 'CARRY_OVER') { const febEnd = new Date(parseInt(year), 2, 0); expiresInput.value = febEnd.toISOString().substring(0, 10); } else { expiresInput.value = `${year}-12-31`; } } } // 개별 배정 모달 function openVacBalanceModal(editId) { document.getElementById('vbEditId').value = ''; document.getElementById('vacBalanceForm').reset(); document.getElementById('vbTotalDays').value = '0'; document.getElementById('vbUsedDays').value = '0'; document.getElementById('vbBalanceType').value = 'AUTO'; document.getElementById('vbExpiresAt').disabled = false; document.getElementById('vacBalModalTitle').textContent = '연차 배정'; const year = document.getElementById('vacYear')?.value || new Date().getFullYear(); document.getElementById('vbYear').value = year; document.getElementById('vbExpiresAt').value = `${year}-12-31`; // 사용자 셀렉트 (부서별 optgroup) const uSel = document.getElementById('vbUser'); uSel.innerHTML = ''; const byDept = {}; vacUsers.forEach(u => { const dept = u.department_name || '부서 미지정'; if (!byDept[dept]) byDept[dept] = []; byDept[dept].push(u); }); Object.keys(byDept).sort().forEach(dept => { const group = document.createElement('optgroup'); group.label = dept; byDept[dept].forEach(u => { const o = document.createElement('option'); o.value = u.user_id || u.id; o.textContent = u.name || u.username; group.appendChild(o); }); uSel.appendChild(group); }); // vacation_type_id 자동 설정 (기본: ANNUAL_FULL) document.getElementById('vbType').value = getVacTypeId('ANNUAL_FULL') || ''; document.getElementById('vbSpecialTypeRow')?.classList.add('hidden'); if (editId) { const b = vacBalances.find(x => x.id === editId); if (!b) return; document.getElementById('vacBalModalTitle').textContent = '배정 수정'; document.getElementById('vbEditId').value = b.id; uSel.value = b.user_id; uSel.disabled = true; document.getElementById('vbType').value = b.vacation_type_id; if (b.balance_type) document.getElementById('vbBalanceType').value = b.balance_type; document.getElementById('vbTotalDays').value = b.total_days; document.getElementById('vbUsedDays').value = b.used_days; if (b.expires_at) document.getElementById('vbExpiresAt').value = b.expires_at.substring(0, 10); document.getElementById('vbNotes').value = b.notes || ''; if (b.balance_type === 'LONG_SERVICE') document.getElementById('vbExpiresAt').disabled = true; } else { uSel.disabled = false; } onBalanceTypeChange(); // vacation_type_id + 만료일 + 경조사 드롭다운 설정 document.getElementById('vacBalanceModal').classList.remove('hidden'); } function closeVacBalanceModal() { document.getElementById('vacBalanceModal').classList.add('hidden'); document.getElementById('vbUser').disabled = false; document.getElementById('vbExpiresAt').disabled = false; document.getElementById('vbSpecialTypeRow')?.classList.add('hidden'); } function editVacBalance(id) { openVacBalanceModal(id); } document.getElementById('vacBalanceForm').addEventListener('submit', async e => { e.preventDefault(); const editId = document.getElementById('vbEditId').value; try { if (editId) { await api(`/vacations/balances/${editId}`, { method: 'PUT', body: JSON.stringify({ total_days: parseFloat(document.getElementById('vbTotalDays').value) || 0, used_days: parseFloat(document.getElementById('vbUsedDays').value) || 0, balance_type: document.getElementById('vbBalanceType').value, expires_at: document.getElementById('vbExpiresAt').value || null, notes: document.getElementById('vbNotes').value.trim() || null })}); showToast('수정되었습니다.'); } else { // vacation_type_id: hidden input에서 가져오되, 빈값이면 balance_type 기반 자동 결정 let vacTypeId = parseInt(document.getElementById('vbType').value); if (!vacTypeId || isNaN(vacTypeId)) { const bt = document.getElementById('vbBalanceType').value; const btMap = { AUTO: 'ANNUAL_FULL', MANUAL: 'ANNUAL_FULL', CARRY_OVER: 'CARRYOVER', LONG_SERVICE: 'LONG_SERVICE' }; vacTypeId = getVacTypeId(btMap[bt] || 'ANNUAL_FULL'); } await api('/vacations/balances', { method: 'POST', body: JSON.stringify({ user_id: parseInt(document.getElementById('vbUser').value), vacation_type_id: vacTypeId, year: parseInt(document.getElementById('vbYear').value), total_days: parseFloat(document.getElementById('vbTotalDays').value) || 0, used_days: parseFloat(document.getElementById('vbUsedDays').value) || 0, balance_type: document.getElementById('vbBalanceType').value, expires_at: document.getElementById('vbExpiresAt').value || null, notes: document.getElementById('vbNotes').value.trim() || null })}); showToast('배정되었습니다.'); } closeVacBalanceModal(); await loadVacBalances(); } catch(e) { showToast(e.message, 'error'); } }); async function deleteVacBalance(id) { if (!confirm('이 배정을 삭제하시겠습니까?')) return; try { await api(`/vacations/balances/${id}`, { method: 'DELETE' }); showToast('삭제되었습니다.'); await loadVacBalances(); } catch(e) { showToast(e.message, 'error'); } }