feat(tkuser): 연차/휴가 관리 프론트엔드 개편 (Sprint 001 Section B)
- workers 기반 → sso_users 기반 전환 (vacWorkers→vacUsers, /workers→/users) - 휴가 탭: 부서 필터, 이름 검색, balance_type 뱃지, 장기근속 제외 체크박스 - 배정 모달: balance_type/expires_at 필드 추가, 사용자 부서별 optgroup - 부서 탭: 팀장 표시/편집, 승인권한 CRUD - 연차 설정 탭/JS 신규: 기본연차·장기근속·이월연차 설정 UI - API 미완성 대응: 필드명 폴백(worker_id→user_id), 404 graceful degradation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
/* ===== Vacation CRUD ===== */
|
||||
let vacTypes = [], vacBalances = [], vacationsLoaded = false, vacWorkers = [];
|
||||
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() {
|
||||
// 연도 셀렉트 초기화
|
||||
@@ -14,7 +22,8 @@ async function loadVacationsTab() {
|
||||
}
|
||||
}
|
||||
await loadVacTypes();
|
||||
await loadVacWorkers();
|
||||
await loadVacUsers();
|
||||
populateVacDeptFilter();
|
||||
await loadVacBalances();
|
||||
vacationsLoaded = true;
|
||||
}
|
||||
@@ -27,11 +36,25 @@ async function loadVacTypes() {
|
||||
} catch(e) { console.warn('휴가 유형 로드 실패:', e); }
|
||||
}
|
||||
|
||||
async function loadVacWorkers() {
|
||||
async function loadVacUsers() {
|
||||
try {
|
||||
const r = await api('/workers');
|
||||
vacWorkers = (r.data || []).filter(w => w.status !== 'inactive');
|
||||
} catch(e) { console.warn('작업자 로드 실패:', e); }
|
||||
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 = '<option value="">전체 부서</option>';
|
||||
const depts = departmentsCache && departmentsCache.length ? departmentsCache : [];
|
||||
depts.forEach(d => {
|
||||
sel.innerHTML += `<option value="${d.department_id}">${escHtml(d.department_name)}</option>`;
|
||||
});
|
||||
}
|
||||
|
||||
function filterVacBalances() {
|
||||
renderVacBalanceTable();
|
||||
}
|
||||
|
||||
function renderVacTypeSidebar() {
|
||||
@@ -118,7 +141,12 @@ async function loadVacBalances() {
|
||||
const year = document.getElementById('vacYear')?.value || new Date().getFullYear();
|
||||
try {
|
||||
const r = await api(`/vacations/balances/year/${year}`);
|
||||
vacBalances = r.data || [];
|
||||
// 필드명 정규화 (과도기 호환: 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 = `<div class="text-red-500 text-center py-6"><p class="text-sm">${e.message}</p></div>`;
|
||||
@@ -135,30 +163,55 @@ function toggleVacDept(deptName) {
|
||||
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');
|
||||
if (!vacBalances.length) {
|
||||
|
||||
// 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 = '<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 => {
|
||||
filtered.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 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 = '<div class="space-y-3">';
|
||||
|
||||
deptNames.forEach(deptName => {
|
||||
const workers = deptMap[deptName];
|
||||
const workerCount = Object.keys(workers).length;
|
||||
const usersMap = deptMap[deptName];
|
||||
const userCount = Object.keys(usersMap).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; }));
|
||||
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';
|
||||
@@ -171,7 +224,7 @@ function renderVacBalanceTable() {
|
||||
<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>
|
||||
<span class="text-xs text-gray-400">${userCount}명</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
@@ -188,7 +241,7 @@ function renderVacBalanceTable() {
|
||||
// 테이블 본문
|
||||
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-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>';
|
||||
@@ -198,16 +251,25 @@ function renderVacBalanceTable() {
|
||||
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 => {
|
||||
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 += `<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>`;
|
||||
const rowspan = g.items.length;
|
||||
html += `<td class="px-3 py-2 font-medium text-gray-800" rowspan="${rowspan}">
|
||||
${escHtml(g.name)}
|
||||
${showLsCheckbox ? `<div class="mt-1"><label class="flex items-center gap-1 text-xs text-gray-400 cursor-pointer" title="장기근속 제외"><input type="checkbox" class="rounded" ${g.long_service_excluded ? 'checked' : ''} onchange="toggleLongServiceExclusion(${uid}, this.checked)"><span>장기근속 제외</span></label></div>` : ''}
|
||||
</td>`;
|
||||
html += `<td class="px-3 py-2 text-xs text-gray-400" rowspan="${rowspan}">${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">
|
||||
<span class="px-1.5 py-0.5 rounded text-xs bg-slate-50 text-slate-600">${escHtml(b.type_name)}</span>
|
||||
${btStyle ? `<span class="ml-1 px-1.5 py-0.5 rounded text-[10px] ${btStyle.cls}">${btStyle.label}</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>`;
|
||||
@@ -225,43 +287,93 @@ function renderVacBalanceTable() {
|
||||
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();
|
||||
if (!confirm(`${year}년 전체 작업자 연차를 입사일 기준으로 자동 계산합니다.\n기존 배정이 있으면 덮어씁니다. 진행하시겠습니까?`)) return;
|
||||
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 r = await api('/vacations/balances/auto-calculate', { method: 'POST', body: JSON.stringify({ year: parseInt(year) }) });
|
||||
showToast(`${r.data.count}명 자동 배정 완료`);
|
||||
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'); }
|
||||
}
|
||||
|
||||
// balance_type 변경 시 expires_at 기본값 조정
|
||||
function onBalanceTypeChange() {
|
||||
const bt = document.getElementById('vbBalanceType').value;
|
||||
const expiresInput = document.getElementById('vbExpiresAt');
|
||||
const year = document.getElementById('vacYear')?.value || new Date().getFullYear();
|
||||
|
||||
if (bt === 'LONG_SERVICE') {
|
||||
expiresInput.disabled = true;
|
||||
expiresInput.value = '';
|
||||
} else {
|
||||
expiresInput.disabled = false;
|
||||
if (bt === 'CARRY_OVER') {
|
||||
// 해당연 2월말
|
||||
const febEnd = new Date(parseInt(year), 2, 0); // month=2, day=0 = last day of Feb
|
||||
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 wSel = document.getElementById('vbWorker');
|
||||
wSel.innerHTML = '<option value="">선택</option>';
|
||||
|
||||
const year = document.getElementById('vacYear')?.value || new Date().getFullYear();
|
||||
document.getElementById('vbExpiresAt').value = `${year}-12-31`;
|
||||
|
||||
// 사용자 셀렉트 (부서별 optgroup)
|
||||
const uSel = document.getElementById('vbUser');
|
||||
uSel.innerHTML = '<option value="">선택</option>';
|
||||
const byDept = {};
|
||||
vacWorkers.forEach(w => {
|
||||
const dept = w.department_name || '부서 미지정';
|
||||
vacUsers.forEach(u => {
|
||||
const dept = u.department_name || '부서 미지정';
|
||||
if (!byDept[dept]) byDept[dept] = [];
|
||||
byDept[dept].push(w);
|
||||
byDept[dept].push(u);
|
||||
});
|
||||
Object.keys(byDept).sort().forEach(dept => {
|
||||
const group = document.createElement('optgroup');
|
||||
group.label = dept;
|
||||
byDept[dept].forEach(w => {
|
||||
byDept[dept].forEach(u => {
|
||||
const o = document.createElement('option');
|
||||
o.value = w.worker_id;
|
||||
o.textContent = w.worker_name;
|
||||
o.value = u.id;
|
||||
o.textContent = u.name || u.username;
|
||||
group.appendChild(o);
|
||||
});
|
||||
wSel.appendChild(group);
|
||||
uSel.appendChild(group);
|
||||
});
|
||||
// 유형 셀렉트
|
||||
const tSel = document.getElementById('vbType');
|
||||
@@ -272,23 +384,27 @@ function openVacBalanceModal(editId) {
|
||||
if (!b) return;
|
||||
document.getElementById('vacBalModalTitle').textContent = '배정 수정';
|
||||
document.getElementById('vbEditId').value = b.id;
|
||||
wSel.value = b.worker_id;
|
||||
wSel.disabled = true;
|
||||
uSel.value = b.user_id;
|
||||
uSel.disabled = true;
|
||||
tSel.value = b.vacation_type_id;
|
||||
tSel.disabled = true;
|
||||
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 {
|
||||
wSel.disabled = false;
|
||||
uSel.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('vbUser').disabled = false;
|
||||
document.getElementById('vbType').disabled = false;
|
||||
document.getElementById('vbExpiresAt').disabled = false;
|
||||
}
|
||||
function editVacBalance(id) { openVacBalanceModal(id); }
|
||||
|
||||
@@ -300,17 +416,21 @@ document.getElementById('vacBalanceForm').addEventListener('submit', async e =>
|
||||
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 {
|
||||
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),
|
||||
user_id: parseInt(document.getElementById('vbUser').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,
|
||||
balance_type: document.getElementById('vbBalanceType').value,
|
||||
expires_at: document.getElementById('vbExpiresAt').value || null,
|
||||
notes: document.getElementById('vbNotes').value.trim() || null
|
||||
})});
|
||||
showToast('배정되었습니다.');
|
||||
|
||||
Reference in New Issue
Block a user