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:
@@ -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>
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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'); }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
158
user-management/web/static/js/tkuser-vacation-settings.js
Normal file
158
user-management/web/static/js/tkuser-vacation-settings.js
Normal 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'); }
|
||||
}
|
||||
@@ -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