- System2 신고: SSO JWT 인증 전환, API base 정리 - System3 부적합: SSO 인증 매니저 통합, 권한 체계 정비 - User Management: SSO 토큰 기반 사용자 관리 API 연동 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
311 lines
17 KiB
JavaScript
311 lines
17 KiB
JavaScript
/* ===== Vacation CRUD ===== */
|
|
let vacTypes = [], vacBalances = [], vacationsLoaded = false, vacWorkers = [];
|
|
|
|
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 loadVacWorkers();
|
|
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 loadVacWorkers() {
|
|
try {
|
|
const r = await api('/workers');
|
|
vacWorkers = (r.data || []).filter(w => w.status !== 'inactive');
|
|
} catch(e) { console.warn('작업자 로드 실패:', e); }
|
|
}
|
|
|
|
function renderVacTypeSidebar() {
|
|
const c = document.getElementById('vacTypeSidebar');
|
|
if (!c) return;
|
|
if (!vacTypes.length) { c.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">등록된 유형이 없습니다.</p>'; return; }
|
|
c.innerHTML = vacTypes.map(vt => `
|
|
<div class="group flex items-center justify-between p-2 rounded-lg ${vt.is_active ? 'bg-gray-50' : 'bg-gray-50 opacity-50'} hover:bg-blue-50 transition-colors">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm font-medium text-gray-800 truncate flex items-center gap-1.5">
|
|
${vt.type_name}
|
|
${vt.is_system ? '<span class="text-[10px] px-1 py-0.5 rounded bg-blue-50 text-blue-500">시스템</span>' : ''}
|
|
${vt.is_special ? '<span class="text-[10px] px-1 py-0.5 rounded bg-purple-50 text-purple-500">특별</span>' : ''}
|
|
${!vt.is_active ? '<span class="text-[10px] px-1 py-0.5 rounded bg-gray-100 text-gray-400">비활성</span>' : ''}
|
|
</div>
|
|
<div class="text-xs text-gray-400 mt-0.5">
|
|
${vt.type_code} | 차감 ${vt.deduct_days}일 | 우선순위 ${vt.priority}
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-0.5 ml-1 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button onclick="editVacType(${vt.id})" class="p-1 text-slate-400 hover:text-slate-600 rounded" title="수정"><i class="fas fa-pen text-[10px]"></i></button>
|
|
${!vt.is_system ? `<button onclick="deleteVacType(${vt.id},'${(vt.type_name||'').replace(/'/g,"\\'")}')" class="p-1 text-red-300 hover:text-red-500 rounded" title="비활성화"><i class="fas fa-ban text-[10px]"></i></button>` : ''}
|
|
</div>
|
|
</div>`).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}`);
|
|
vacBalances = r.data || [];
|
|
renderVacBalanceTable();
|
|
} catch(e) {
|
|
document.getElementById('vacBalanceTable').innerHTML = `<div class="text-red-500 text-center py-6"><p class="text-sm">${e.message}</p></div>`;
|
|
}
|
|
}
|
|
|
|
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 renderVacBalanceTable() {
|
|
const c = document.getElementById('vacBalanceTable');
|
|
if (!vacBalances.length) {
|
|
c.innerHTML = '<div class="text-gray-400 text-center py-8 text-sm"><i class="fas fa-calendar-xmark text-3xl mb-2"></i><p>배정된 연차가 없습니다. "자동 계산" 또는 "개별 배정"을 이용하세요.</p></div>';
|
|
return;
|
|
}
|
|
// 부서 -> 작업자 -> 휴가유형 그룹핑
|
|
const deptMap = {};
|
|
vacBalances.forEach(b => {
|
|
const deptName = b.department_name || '미배정';
|
|
if (!deptMap[deptName]) deptMap[deptName] = {};
|
|
if (!deptMap[deptName][b.worker_id]) deptMap[deptName][b.worker_id] = { name: b.worker_name, hire_date: b.hire_date, items: [] };
|
|
deptMap[deptName][b.worker_id].items.push(b);
|
|
});
|
|
|
|
const deptNames = Object.keys(deptMap).sort((a, b) => a === '미배정' ? 1 : b === '미배정' ? -1 : a.localeCompare(b));
|
|
let html = '<div class="space-y-3">';
|
|
|
|
deptNames.forEach(deptName => {
|
|
const workers = deptMap[deptName];
|
|
const workerCount = Object.keys(workers).length;
|
|
// 부서 합계
|
|
let dTotal = 0, dUsed = 0;
|
|
Object.values(workers).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 += `<div class="border border-gray-200 rounded-lg overflow-hidden">`;
|
|
// 헤더 (클릭으로 접기/펼치기)
|
|
html += `<div onclick="toggleVacDept('${escHtml(deptName).replace(/'/g, "\\'")}')" class="flex items-center justify-between px-4 py-3 bg-slate-50 cursor-pointer hover:bg-slate-100 select-none transition-colors">
|
|
<div class="flex items-center gap-3">
|
|
<i id="vacDeptIcon_${eid}" class="fas fa-chevron-down text-xs text-gray-400 transition-transform" style="${collapsed ? 'transform:rotate(-90deg)' : ''}"></i>
|
|
<span class="text-sm font-bold text-gray-700"><i class="fas fa-building mr-1.5 text-blue-400"></i>${escHtml(deptName)}</span>
|
|
<span class="text-xs text-gray-400">${workerCount}명</span>
|
|
</div>
|
|
<div class="flex items-center gap-4">
|
|
<div class="flex items-center gap-2 text-xs">
|
|
<span class="text-gray-400">배정 <b class="text-gray-600">${dTotal}</b></span>
|
|
<span class="text-gray-400">사용 <b class="text-gray-600">${dUsed}</b></span>
|
|
<span class="text-gray-400">잔여 <b class="${dRemain <= 0 ? 'text-red-500' : 'text-emerald-600'}">${dRemain}</b></span>
|
|
</div>
|
|
<div class="w-24 h-2 bg-gray-200 rounded-full overflow-hidden">
|
|
<div class="${barColor} h-full rounded-full transition-all" style="width:${usagePct}%"></div>
|
|
</div>
|
|
<span class="text-xs font-medium ${usagePct >= 80 ? 'text-red-500' : usagePct >= 50 ? 'text-amber-500' : 'text-emerald-600'}">${usagePct}%</span>
|
|
</div>
|
|
</div>`;
|
|
// 테이블 본문
|
|
html += `<div id="vacDept_${eid}" class="${collapsed ? 'hidden' : ''}">`;
|
|
html += '<table class="w-full text-sm"><thead class="bg-gray-50"><tr>';
|
|
html += '<th class="text-left px-3 py-2 text-xs font-semibold text-gray-500">작업자</th>';
|
|
html += '<th class="text-left px-3 py-2 text-xs font-semibold text-gray-500">입사일</th>';
|
|
html += '<th class="text-left px-3 py-2 text-xs font-semibold text-gray-500">휴가유형</th>';
|
|
html += '<th class="text-center px-3 py-2 text-xs font-semibold text-gray-500">배정</th>';
|
|
html += '<th class="text-center px-3 py-2 text-xs font-semibold text-gray-500">사용</th>';
|
|
html += '<th class="text-center px-3 py-2 text-xs font-semibold text-gray-500">잔여</th>';
|
|
html += '<th class="text-left px-3 py-2 text-xs font-semibold text-gray-500">비고</th>';
|
|
html += '<th class="px-3 py-2 text-xs font-semibold text-gray-500 w-16"></th>';
|
|
html += '</tr></thead><tbody>';
|
|
|
|
Object.values(workers).forEach(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';
|
|
html += `<tr class="border-t border-gray-100 hover:bg-gray-50">`;
|
|
if (i === 0) {
|
|
html += `<td class="px-3 py-2 font-medium text-gray-800" rowspan="${g.items.length}">${escHtml(g.name)}</td>`;
|
|
html += `<td class="px-3 py-2 text-xs text-gray-400" rowspan="${g.items.length}">${g.hire_date ? new Date(g.hire_date).toISOString().substring(0,10) : '-'}</td>`;
|
|
}
|
|
html += `<td class="px-3 py-2"><span class="px-1.5 py-0.5 rounded text-xs bg-slate-50 text-slate-600">${escHtml(b.type_name)}</span></td>`;
|
|
html += `<td class="px-3 py-2 text-center">${b.total_days}</td>`;
|
|
html += `<td class="px-3 py-2 text-center">${b.used_days}</td>`;
|
|
html += `<td class="px-3 py-2 text-center ${remClass}">${remaining}</td>`;
|
|
html += `<td class="px-3 py-2 text-xs text-gray-400 truncate max-w-[150px]" title="${escHtml(b.notes||'')}">${escHtml(b.notes||'')}</td>`;
|
|
html += `<td class="px-3 py-2 text-center">
|
|
<button onclick="editVacBalance(${b.id})" class="p-1 text-slate-400 hover:text-slate-600" title="수정"><i class="fas fa-pen text-xs"></i></button>
|
|
<button onclick="deleteVacBalance(${b.id})" class="p-1 text-red-300 hover:text-red-500" title="삭제"><i class="fas fa-trash-alt text-xs"></i></button>
|
|
</td>`;
|
|
html += '</tr>';
|
|
});
|
|
});
|
|
html += '</tbody></table></div></div>';
|
|
});
|
|
html += '</div>';
|
|
c.innerHTML = html;
|
|
}
|
|
|
|
// 자동 계산
|
|
async function autoCalcVacation() {
|
|
const year = document.getElementById('vacYear')?.value || new Date().getFullYear();
|
|
if (!confirm(`${year}년 전체 작업자 연차를 입사일 기준으로 자동 계산합니다.\n기존 배정이 있으면 덮어씁니다. 진행하시겠습니까?`)) return;
|
|
try {
|
|
const r = await api('/vacations/balances/auto-calculate', { method: 'POST', body: JSON.stringify({ year: parseInt(year) }) });
|
|
showToast(`${r.data.count}명 자동 배정 완료`);
|
|
await loadVacBalances();
|
|
} catch(e) { showToast(e.message, 'error'); }
|
|
}
|
|
|
|
// 개별 배정 모달
|
|
function openVacBalanceModal(editId) {
|
|
document.getElementById('vbEditId').value = '';
|
|
document.getElementById('vacBalanceForm').reset();
|
|
document.getElementById('vbTotalDays').value = '0';
|
|
document.getElementById('vbUsedDays').value = '0';
|
|
document.getElementById('vacBalModalTitle').textContent = '연차 배정';
|
|
// 작업자 셀렉트
|
|
const wSel = document.getElementById('vbWorker');
|
|
wSel.innerHTML = '<option value="">선택</option>';
|
|
vacWorkers.forEach(w => { wSel.innerHTML += `<option value="${w.worker_id}">${escapeHtml(w.worker_name)}</option>`; });
|
|
// 유형 셀렉트
|
|
const tSel = document.getElementById('vbType');
|
|
tSel.innerHTML = '<option value="">선택</option>';
|
|
vacTypes.filter(t => t.is_active).forEach(t => { tSel.innerHTML += `<option value="${t.id}">${escapeHtml(t.type_name)} (${escapeHtml(t.type_code)})</option>`; });
|
|
if (editId) {
|
|
const b = vacBalances.find(x => x.id === editId);
|
|
if (!b) return;
|
|
document.getElementById('vacBalModalTitle').textContent = '배정 수정';
|
|
document.getElementById('vbEditId').value = b.id;
|
|
wSel.value = b.worker_id;
|
|
wSel.disabled = true;
|
|
tSel.value = b.vacation_type_id;
|
|
tSel.disabled = true;
|
|
document.getElementById('vbTotalDays').value = b.total_days;
|
|
document.getElementById('vbUsedDays').value = b.used_days;
|
|
document.getElementById('vbNotes').value = b.notes || '';
|
|
} else {
|
|
wSel.disabled = false;
|
|
tSel.disabled = false;
|
|
}
|
|
document.getElementById('vacBalanceModal').classList.remove('hidden');
|
|
}
|
|
function closeVacBalanceModal() {
|
|
document.getElementById('vacBalanceModal').classList.add('hidden');
|
|
document.getElementById('vbWorker').disabled = false;
|
|
document.getElementById('vbType').disabled = false;
|
|
}
|
|
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,
|
|
notes: document.getElementById('vbNotes').value.trim() || null
|
|
})});
|
|
showToast('수정되었습니다.');
|
|
} else {
|
|
const year = document.getElementById('vacYear')?.value || new Date().getFullYear();
|
|
await api('/vacations/balances', { method: 'POST', body: JSON.stringify({
|
|
worker_id: parseInt(document.getElementById('vbWorker').value),
|
|
vacation_type_id: parseInt(document.getElementById('vbType').value),
|
|
year: parseInt(year),
|
|
total_days: parseFloat(document.getElementById('vbTotalDays').value) || 0,
|
|
used_days: parseFloat(document.getElementById('vbUsedDays').value) || 0,
|
|
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'); }
|
|
}
|