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:
Hyungi Ahn
2026-03-23 08:14:28 +09:00
parent c158da7832
commit a3f7a324b1
7 changed files with 527 additions and 67 deletions

View File

@@ -72,6 +72,9 @@
<button class="tab-btn px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap" data-tab="vacations" onclick="switchTab('vacations', event)">
<i class="fas fa-umbrella-beach mr-2"></i>휴가
</button>
<button class="tab-btn px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap" data-tab="vacationSettings" onclick="switchTab('vacationSettings', event)">
<i class="fas fa-sliders mr-2"></i>연차 설정
</button>
<span class="tab-divider"></span>
<!-- 거래/물품 -->
<span class="tab-group-label">거래</span>
@@ -513,6 +516,13 @@
<div id="deptMembersPanel" class="hidden mt-4 border-t pt-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3"><i class="fas fa-users text-slate-400 mr-1.5"></i>소속 인원</h3>
<div id="deptMembersList" class="space-y-2"></div>
<div id="deptApprovalSection" class="hidden mt-4 border-t pt-4">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-semibold text-gray-700"><i class="fas fa-user-check text-slate-400 mr-1.5"></i>승인권한</h4>
<button onclick="openApprovalModal()" class="text-xs text-slate-500 hover:text-slate-700 px-1.5 py-0.5 rounded hover:bg-gray-100" title="승인권한 추가"><i class="fas fa-plus"></i></button>
</div>
<div id="deptApprovalList" class="space-y-2"></div>
</div>
</div>
</div>
</div>
@@ -732,7 +742,11 @@
<div class="bg-white rounded-xl shadow-sm p-5 flex flex-col flex-1 overflow-hidden">
<div class="flex items-center justify-between mb-4 flex-wrap gap-2">
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-calendar-check text-slate-400 mr-2"></i>연차 배정</h2>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 flex-wrap">
<select id="vacDeptFilter" class="input-field px-3 py-1.5 rounded-lg text-sm" onchange="filterVacBalances()">
<option value="">전체 부서</option>
</select>
<input type="text" id="vacSearch" class="input-field px-3 py-1.5 rounded-lg text-sm w-36" placeholder="이름 검색" oninput="filterVacBalances()">
<select id="vacYear" class="input-field px-3 py-1.5 rounded-lg text-sm" onchange="loadVacBalances()">
</select>
<button onclick="autoCalcVacation()" class="px-3 py-1.5 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 text-xs font-medium" title="입사일 기반 연차 자동 계산">
@@ -751,6 +765,15 @@
</div>
</div>
<!-- ============ 연차 설정 탭 ============ -->
<div id="tab-vacationSettings" class="hidden">
<div class="max-w-3xl mx-auto space-y-6">
<div id="vacSettingsContent">
<div class="text-gray-400 text-center py-8"><i class="fas fa-spinner fa-spin text-2xl"></i><p class="mt-2 text-sm">로딩 중...</p></div>
</div>
</div>
</div>
<!-- 휴가 유형 모달 -->
<div id="vacTypeModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl max-w-sm w-full p-6">
@@ -796,8 +819,8 @@
</div>
<!-- 개별 배정 모달 -->
<div id="vacBalanceModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl max-w-sm w-full p-6">
<div id="vacBalanceModal" class="modal-overlay fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl max-w-md w-full p-6">
<div class="flex items-center justify-between mb-4">
<h3 id="vacBalModalTitle" class="text-base font-semibold text-gray-900">연차 배정</h3>
<button onclick="closeVacBalanceModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
@@ -805,8 +828,8 @@
<form id="vacBalanceForm" class="space-y-3">
<input type="hidden" id="vbEditId">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">작업<span class="text-red-400">*</span></label>
<select id="vbWorker" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" required>
<label class="block text-xs font-medium text-gray-600 mb-1">사용<span class="text-red-400">*</span></label>
<select id="vbUser" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" required>
<option value="">선택</option>
</select>
</div>
@@ -816,6 +839,16 @@
<option value="">선택</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">배정 유형</label>
<select id="vbBalanceType" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" onchange="onBalanceTypeChange()">
<option value="AUTO">기본연차</option>
<option value="MANUAL">추가부여</option>
<option value="CARRY_OVER">이월연차</option>
<option value="LONG_SERVICE">장기근속</option>
<option value="COMPANY_GRANT">회사부여</option>
</select>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">배정 일수</label>
@@ -826,6 +859,10 @@
<input type="number" id="vbUsedDays" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" value="0" step="0.5" min="0">
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">만료일</label>
<input type="date" id="vbExpiresAt" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">비고</label>
<input type="text" id="vbNotes" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="메모">
@@ -1159,6 +1196,12 @@
<label class="block text-xs font-medium text-gray-600 mb-1">표시순서</label>
<input type="number" id="editDeptOrder" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" min="0">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">팀장</label>
<select id="editDeptLeader" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="">미지정</option>
</select>
</div>
<div class="flex gap-3 pt-3">
<button type="button" onclick="closeDepartmentModal()" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm">취소</button>
<button type="submit" class="flex-1 px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium"><i class="fas fa-save mr-1"></i>저장</button>
@@ -1167,6 +1210,42 @@
</div>
</div>
<!-- 승인권한 추가 모달 -->
<div id="approvalAuthorityModal" class="modal-overlay fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl max-w-sm w-full p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900">승인권한 추가</h3>
<button onclick="closeApprovalModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<form id="approvalAuthorityForm" class="space-y-3">
<input type="hidden" id="approvalDeptId">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">승인 유형 <span class="text-red-400">*</span></label>
<select id="approvalType" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" required>
<option value="">선택</option>
<option value="VACATION">휴가</option>
<option value="PURCHASE">구매</option>
<option value="DOCUMENT">문서</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">승인자 <span class="text-red-400">*</span></label>
<select id="approvalUserId" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" required>
<option value="">선택</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">순서</label>
<input type="number" id="approvalOrder" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" value="1" min="1">
</div>
<div class="flex gap-3 pt-2">
<button type="button" onclick="closeApprovalModal()" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm">취소</button>
<button type="submit" class="flex-1 px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium"><i class="fas fa-save mr-1"></i>저장</button>
</div>
</form>
</div>
</div>
<!-- 이슈 카테고리 수정 모달 -->
<div id="editIssueCategoryModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl max-w-md w-full p-6">
@@ -2239,17 +2318,18 @@
</div>
<!-- JS: Core (config, token, api, toast, helpers, init) -->
<script src="/static/js/tkuser-core.js?v=2026031602"></script>
<script src="/static/js/tkuser-core.js?v=2026032301"></script>
<!-- JS: Tabs -->
<script src="/static/js/tkuser-tabs.js?v=2026031602"></script>
<script src="/static/js/tkuser-tabs.js?v=2026032301"></script>
<!-- JS: Individual modules -->
<script src="/static/js/tkuser-users.js?v=2026031601"></script>
<script src="/static/js/tkuser-users.js?v=2026032301"></script>
<script src="/static/js/tkuser-projects.js?v=2026031401"></script>
<script src="/static/js/tkuser-departments.js?v=2026031401"></script>
<script src="/static/js/tkuser-departments.js?v=2026032301"></script>
<script src="/static/js/tkuser-issue-types.js?v=2026031401"></script>
<script src="/static/js/tkuser-workplaces.js?v=2026031401"></script>
<script src="/static/js/tkuser-tasks.js?v=2026031401"></script>
<script src="/static/js/tkuser-vacations.js?v=2026031401"></script>
<script src="/static/js/tkuser-vacations.js?v=2026032301"></script>
<script src="/static/js/tkuser-vacation-settings.js?v=2026032301"></script>
<script src="/static/js/tkuser-layout-map.js?v=2026031401"></script>
<script src="/static/js/tkuser-partners.js?v=2026031601"></script>
<script src="/static/js/tkuser-vendors.js?v=2026031401"></script>

View File

@@ -193,7 +193,7 @@ async function init() {
document.querySelectorAll('.fade-in').forEach(el => el.classList.add('visible'));
// URL ?tab= 파라미터로 탭 자동 전환 (화이트리스트 + URL 정리)
const ALLOWED_TABS = ['users','projects','workplaces','workers','departments',
'permissions','issueTypes','tasks','vacations','partners','vendors',
'permissions','issueTypes','tasks','vacations','vacationSettings','partners','vendors',
'consumables','notificationRecipients','equipments'];
const urlTab = new URLSearchParams(location.search).get('tab');
if (urlTab && ALLOWED_TABS.includes(urlTab)) {

View File

@@ -1,6 +1,8 @@
/* ===== Departments CRUD ===== */
let departments = [], departmentsLoaded = false;
const APPROVAL_TYPE_LABELS = { VACATION: '휴가', PURCHASE: '구매', DOCUMENT: '문서' };
async function loadDepartments() {
try {
const r = await api('/departments'); departments = r.data || r;
@@ -22,6 +24,7 @@ function displayDepartments() {
<div class="text-sm font-medium text-gray-800 truncate"><i class="fas fa-sitemap mr-1.5 text-gray-400 text-xs"></i>${d.department_name}</div>
<div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5 flex-wrap">
<span class="text-gray-400">순서: ${d.display_order || 0}</span>
<span class="text-gray-400">| 팀장: ${d.leader_name ? escHtml(d.leader_name) : '<span class="text-gray-300">미지정</span>'}</span>
</div>
</div>
<div class="flex gap-1 ml-2 flex-shrink-0" onclick="event.stopPropagation()">
@@ -57,23 +60,24 @@ async function showDeptMembers(deptId) {
if (!members.length) {
list.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">소속 인원이 없습니다</p>';
return;
} else {
list.innerHTML = members.map(u => `
<div class="flex items-center justify-between p-2 bg-gray-50 rounded-lg">
<div class="flex items-center gap-2">
<div class="w-7 h-7 bg-slate-200 rounded-full flex items-center justify-center text-xs font-semibold text-slate-600">${(u.name || u.username).charAt(0)}</div>
<div>
<div class="text-sm font-medium text-gray-800">${u.name || u.username}</div>
<div class="text-xs text-gray-500">${u.username}</div>
</div>
</div>
<div class="flex items-center gap-1.5">
<span class="px-1.5 py-0.5 rounded text-xs ${u.role === 'admin' ? 'bg-red-50 text-red-600' : 'bg-slate-50 text-slate-500'}">${u.role === 'admin' ? '관리자' : '사용자'}</span>
${u.is_active === 0 || u.is_active === false ? '<span class="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-400">비활성</span>' : '<span class="px-1.5 py-0.5 rounded text-xs bg-emerald-50 text-emerald-600">활성</span>'}
</div>
</div>`).join('');
}
list.innerHTML = members.map(u => `
<div class="flex items-center justify-between p-2 bg-gray-50 rounded-lg">
<div class="flex items-center gap-2">
<div class="w-7 h-7 bg-slate-200 rounded-full flex items-center justify-center text-xs font-semibold text-slate-600">${(u.name || u.username).charAt(0)}</div>
<div>
<div class="text-sm font-medium text-gray-800">${u.name || u.username}</div>
<div class="text-xs text-gray-500">${u.username}</div>
</div>
</div>
<div class="flex items-center gap-1.5">
<span class="px-1.5 py-0.5 rounded text-xs ${u.role === 'admin' ? 'bg-red-50 text-red-600' : 'bg-slate-50 text-slate-500'}">${u.role === 'admin' ? '관리자' : '사용자'}</span>
${u.is_active === 0 || u.is_active === false ? '<span class="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-400">비활성</span>' : '<span class="px-1.5 py-0.5 rounded text-xs bg-emerald-50 text-emerald-600">활성</span>'}
</div>
</div>`).join('');
loadApprovalAuthorities(deptId);
}
document.getElementById('addDepartmentForm').addEventListener('submit', async e => {
@@ -88,12 +92,29 @@ document.getElementById('addDepartmentForm').addEventListener('submit', async e
} catch(e) { showToast(e.message, 'error'); }
});
function editDepartment(id) {
async function editDepartment(id) {
const d = departments.find(x => x.department_id === id); if (!d) return;
document.getElementById('editDeptId').value = d.department_id;
document.getElementById('editDeptName').value = d.department_name;
document.getElementById('editDeptDescription').value = d.description || '';
document.getElementById('editDeptOrder').value = d.display_order || 0;
// Populate leader dropdown
const leaderSel = document.getElementById('editDeptLeader');
leaderSel.innerHTML = '<option value="">미지정</option>';
let deptUsers = users;
if (!deptUsers || !deptUsers.length) {
try { const r = await api('/users'); deptUsers = r.data || r; } catch(e) { /* ignore */ }
}
const members = (deptUsers || []).filter(u => u.department_id === d.department_id && u.is_active !== 0 && u.is_active !== false);
members.forEach(u => {
const o = document.createElement('option');
o.value = u.id;
o.textContent = u.name || u.username;
if (d.leader_user_id && d.leader_user_id === u.id) o.selected = true;
leaderSel.appendChild(o);
});
document.getElementById('editDepartmentModal').classList.remove('hidden');
}
function closeDepartmentModal() { document.getElementById('editDepartmentModal').classList.add('hidden'); }
@@ -101,10 +122,12 @@ function closeDepartmentModal() { document.getElementById('editDepartmentModal')
document.getElementById('editDepartmentForm').addEventListener('submit', async e => {
e.preventDefault();
try {
const leaderId = document.getElementById('editDeptLeader').value;
await api(`/departments/${document.getElementById('editDeptId').value}`, { method: 'PUT', body: JSON.stringify({
department_name: document.getElementById('editDeptName').value.trim(),
description: document.getElementById('editDeptDescription').value.trim() || null,
display_order: parseInt(document.getElementById('editDeptOrder').value) || 0
display_order: parseInt(document.getElementById('editDeptOrder').value) || 0,
leader_user_id: leaderId ? parseInt(leaderId) : null
})});
showToast('수정되었습니다.'); closeDepartmentModal(); await loadDepartments();
await loadDepartmentsForSelect();
@@ -115,3 +138,80 @@ async function deleteDepartment(id, name) {
if (!confirm(`"${name}" 부서를 삭제하시겠습니까? 소속 인원은 부서 미지정으로 변경됩니다.`)) return;
try { await api(`/departments/${id}`, { method: 'DELETE' }); showToast('부서가 삭제되었습니다'); await loadDepartments(); } catch(e) { showToast(e.message, 'error'); }
}
/* ===== Approval Authorities ===== */
async function loadApprovalAuthorities(deptId) {
const section = document.getElementById('deptApprovalSection');
if (!section) return;
try {
const r = await api(`/departments/${deptId}/approval-authorities`);
const data = r.data || r;
section.classList.remove('hidden');
renderApprovalAuthorities(deptId, data);
} catch (e) {
section.classList.add('hidden');
}
}
function renderApprovalAuthorities(deptId, data) {
const list = document.getElementById('deptApprovalList');
if (!data || !data.length) {
list.innerHTML = '<p class="text-gray-400 text-center py-3 text-xs">등록된 승인권한이 없습니다</p>';
return;
}
list.innerHTML = `<table class="w-full text-sm"><thead><tr>
<th class="text-left px-2 py-1 text-xs font-semibold text-gray-500">유형</th>
<th class="text-left px-2 py-1 text-xs font-semibold text-gray-500">승인자</th>
<th class="text-center px-2 py-1 text-xs font-semibold text-gray-500">순서</th>
<th class="px-2 py-1 w-10"></th>
</tr></thead><tbody>${data.map(a => `<tr class="border-t border-gray-100">
<td class="px-2 py-1.5"><span class="px-1.5 py-0.5 rounded text-xs bg-slate-50 text-slate-600">${APPROVAL_TYPE_LABELS[a.approval_type] || a.approval_type}</span></td>
<td class="px-2 py-1.5 text-sm">${escHtml(a.approver_name || '')}</td>
<td class="px-2 py-1.5 text-center text-xs text-gray-400">${a.approval_order || 1}</td>
<td class="px-2 py-1.5 text-center"><button onclick="deleteApprovalAuthority(${deptId},${a.id})" class="p-1 text-red-300 hover:text-red-500" title="삭제"><i class="fas fa-trash-alt text-xs"></i></button></td>
</tr>`).join('')}</tbody></table>`;
}
function openApprovalModal() {
if (!selectedDeptForMembers) return;
document.getElementById('approvalDeptId').value = selectedDeptForMembers;
document.getElementById('approvalAuthorityForm').reset();
document.getElementById('approvalOrder').value = '1';
// Populate approver dropdown
const sel = document.getElementById('approvalUserId');
sel.innerHTML = '<option value="">선택</option>';
let availUsers = users;
if (availUsers && availUsers.length) {
availUsers.filter(u => u.is_active !== 0 && u.is_active !== false).forEach(u => {
sel.innerHTML += `<option value="${u.id}">${escHtml(u.name || u.username)}</option>`;
});
}
document.getElementById('approvalAuthorityModal').classList.remove('hidden');
}
function closeApprovalModal() { document.getElementById('approvalAuthorityModal').classList.add('hidden'); }
document.getElementById('approvalAuthorityForm').addEventListener('submit', async e => {
e.preventDefault();
const deptId = document.getElementById('approvalDeptId').value;
try {
await api(`/departments/${deptId}/approval-authorities`, { method: 'POST', body: JSON.stringify({
approval_type: document.getElementById('approvalType').value,
approver_user_id: parseInt(document.getElementById('approvalUserId').value),
approval_order: parseInt(document.getElementById('approvalOrder').value) || 1
})});
showToast('승인권한이 추가되었습니다.');
closeApprovalModal();
await loadApprovalAuthorities(parseInt(deptId));
} catch (e) { showToast(e.message, 'error'); }
});
async function deleteApprovalAuthority(deptId, authId) {
if (!confirm('이 승인권한을 삭제하시겠습니까?')) return;
try {
await api(`/departments/${deptId}/approval-authorities/${authId}`, { method: 'DELETE' });
showToast('삭제되었습니다.');
await loadApprovalAuthorities(deptId);
} catch (e) { showToast(e.message, 'error'); }
}

View File

@@ -38,4 +38,5 @@ function switchTab(name, event) {
if (name === 'consumables' && !consumablesLoaded) loadConsumablesTab();
if (name === 'notificationRecipients' && !nrLoaded) loadNotificationRecipientsTab();
if (name === 'equipments' && !equipmentsLoaded) loadEquipmentsTab();
if (name === 'vacationSettings' && typeof loadVacationSettingsTab === 'function') loadVacationSettingsTab();
}

View File

@@ -89,6 +89,7 @@ const TKUSER_PAGES = {
{ key: 'tkuser.issue_types', title: '이슈 유형 관리', icon: 'fa-exclamation-triangle', def: false, tab: 'issueTypes' },
{ key: 'tkuser.tasks', title: '작업 관리', icon: 'fa-tasks', def: false, tab: 'tasks' },
{ key: 'tkuser.vacations', title: '휴가 관리', icon: 'fa-umbrella-beach', def: false, tab: 'vacations' },
{ key: 'tkuser.vacation_settings', title: '연차 설정', icon: 'fa-sliders', def: false, tab: 'vacationSettings' },
{ key: 'tkuser.partners', title: '협력업체 관리', icon: 'fa-truck', def: false, tab: 'partners' },
{ key: 'tkuser.notification_recipients', title: '알림 수신자 관리', icon: 'fa-bell', def: false, tab: 'notificationRecipients' },
]

View File

@@ -0,0 +1,158 @@
/* ===== Vacation Settings ===== */
let vacSettingsLoaded = false, vacSettingsData = {};
async function loadVacationSettingsTab() {
if (vacSettingsLoaded) return;
await loadVacationSettings();
vacSettingsLoaded = true;
}
async function loadVacationSettings() {
const c = document.getElementById('vacSettingsContent');
try {
const r = await api('/vacation-settings');
const arr = r.data || r;
vacSettingsData = {};
(Array.isArray(arr) ? arr : []).forEach(s => { vacSettingsData[s.setting_key] = s; });
renderVacationSettings();
} catch (e) {
c.innerHTML = `<div class="bg-white rounded-xl shadow-sm p-8 text-center">
<i class="fas fa-exclamation-circle text-3xl text-gray-300 mb-3"></i>
<p class="text-gray-500 text-sm">설정을 불러올 수 없습니다</p>
<p class="text-gray-400 text-xs mt-1">${escHtml(e.message)}</p>
</div>`;
}
}
function _vsVal(key, fallback) {
const s = vacSettingsData[key];
return s ? s.setting_value : fallback;
}
function _vsDesc(key) {
const s = vacSettingsData[key];
return s && s.description ? s.description : '';
}
function renderVacationSettings() {
const c = document.getElementById('vacSettingsContent');
const isAdmin = currentUser && (currentUser.role === 'admin' || currentUser.role === 'system');
const dis = isAdmin ? '' : 'disabled';
c.innerHTML = `
<div class="space-y-6">
<!-- 기본 연차 -->
<div class="bg-white rounded-xl shadow-sm p-5">
<h3 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-calendar-days text-blue-400 mr-2"></i>기본 연차 설정</h3>
<div class="grid sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">1년 미만 규칙</label>
<select id="vs_first_year_rule" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" ${dis}>
<option value="monthly" ${_vsVal('first_year_rule','monthly')==='monthly'?'selected':''}>월별 발생</option>
<option value="proportional" ${_vsVal('first_year_rule','monthly')==='proportional'?'selected':''}>비례 배분</option>
<option value="full" ${_vsVal('first_year_rule','monthly')==='full'?'selected':''}>전체 부여</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">기본 일수</label>
<input type="number" id="vs_base_days" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" value="${_vsVal('base_days','15')}" min="0" step="1" ${dis}>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">근속 가산 (N년당 1일)</label>
<input type="number" id="vs_increment_per_years" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" value="${_vsVal('increment_per_years','2')}" min="1" step="1" ${dis}>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">최대 일수</label>
<input type="number" id="vs_max_days" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" value="${_vsVal('max_days','25')}" min="0" step="1" ${dis}>
</div>
</div>
</div>
<!-- 장기근속 -->
<div class="bg-white rounded-xl shadow-sm p-5">
<h3 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-award text-emerald-400 mr-2"></i>장기근속 보너스</h3>
<div class="grid sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">기준 근속년수</label>
<input type="number" id="vs_long_service_threshold_years" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" value="${_vsVal('long_service_threshold_years','5')}" min="1" step="1" ${dis}>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">보너스 일수</label>
<input type="number" id="vs_long_service_bonus_days" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" value="${_vsVal('long_service_bonus_days','5')}" min="0" step="1" ${dis}>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">만료 정책</label>
<select id="vs_long_service_expiry" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" ${dis}>
<option value="year_end" ${_vsVal('long_service_expiry','year_end')==='year_end'?'selected':''}>연말 만료</option>
<option value="never" ${_vsVal('long_service_expiry','year_end')==='never'?'selected':''}>만료 없음</option>
<option value="anniversary" ${_vsVal('long_service_expiry','year_end')==='anniversary'?'selected':''}>입사일 기준</option>
</select>
</div>
<div class="flex items-center">
<label class="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
<input type="checkbox" id="vs_long_service_auto_grant" class="rounded" ${_vsVal('long_service_auto_grant','true')==='true'?'checked':''} ${dis}>
자동 부여
</label>
</div>
</div>
</div>
<!-- 이월 연차 -->
<div class="bg-white rounded-xl shadow-sm p-5">
<h3 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-rotate text-orange-400 mr-2"></i>이월 연차</h3>
<div class="grid sm:grid-cols-2 gap-4">
<div class="flex items-center">
<label class="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
<input type="checkbox" id="vs_carry_over_enabled" class="rounded" ${_vsVal('carry_over_enabled','false')==='true'?'checked':''} ${dis}>
이월 허용
</label>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">최대 이월 일수</label>
<input type="number" id="vs_carry_over_max_days" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" value="${_vsVal('carry_over_max_days','5')}" min="0" step="1" ${dis}>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">이월 만료월 (M월말)</label>
<input type="number" id="vs_carry_over_expiry_month" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" value="${_vsVal('carry_over_expiry_month','3')}" min="1" max="12" step="1" ${dis}>
</div>
</div>
</div>
${isAdmin ? `
<div class="flex justify-end">
<button onclick="saveVacationSettings()" class="px-6 py-2.5 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium">
<i class="fas fa-save mr-1.5"></i>설정 저장
</button>
</div>` : ''}
</div>`;
}
async function saveVacationSettings() {
const fields = {
first_year_rule: document.getElementById('vs_first_year_rule').value,
base_days: document.getElementById('vs_base_days').value,
increment_per_years: document.getElementById('vs_increment_per_years').value,
max_days: document.getElementById('vs_max_days').value,
long_service_threshold_years: document.getElementById('vs_long_service_threshold_years').value,
long_service_bonus_days: document.getElementById('vs_long_service_bonus_days').value,
long_service_expiry: document.getElementById('vs_long_service_expiry').value,
long_service_auto_grant: document.getElementById('vs_long_service_auto_grant').checked ? 'true' : 'false',
carry_over_enabled: document.getElementById('vs_carry_over_enabled').checked ? 'true' : 'false',
carry_over_max_days: document.getElementById('vs_carry_over_max_days').value,
carry_over_expiry_month: document.getElementById('vs_carry_over_expiry_month').value,
};
// Only send changed values
const changes = {};
for (const [key, val] of Object.entries(fields)) {
if (_vsVal(key, '') !== val) changes[key] = val;
}
if (!Object.keys(changes).length) { showToast('변경된 설정이 없습니다.', 'error'); return; }
try {
await api('/vacation-settings', { method: 'PUT', body: JSON.stringify({ settings: changes }) });
showToast('설정이 저장되었습니다.');
vacSettingsLoaded = false;
await loadVacationSettingsTab();
} catch (e) { showToast(e.message, 'error'); }
}

View File

@@ -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('배정되었습니다.');