/* ===== 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 = `배정된 연차가 없습니다. "자동 계산" 또는 "개별 배정"을 이용하세요.
';
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 += '
';
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 = '