feat(tksupport): Sprint 001 Section C — 전사 휴가관리 구현
- 전사 휴가 부여/관리 (company-holidays) CRUD + 연차차감 트랜잭션 - 전체 휴가관리 대시보드 (vacation-dashboard) 부서별/직원별 현황 - 내 휴가 현황 개선 (/my-status) balance_type별 카드, 전사 휴가일 - requireSupportTeam 미들웨어, 부서명 JOIN, 마이그레이션 002 추가 - 사이드바 roles 기반 메뉴 필터링 (하위호환 유지) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
229
tksupport/web/company-holidays.html
Normal file
229
tksupport/web/company-holidays.html
Normal file
@@ -0,0 +1,229 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>전사 휴가 관리 - TK 행정지원</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tksupport.css?v=2026032301">
|
||||
</head>
|
||||
<body>
|
||||
<header class="bg-purple-700 text-white sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-14">
|
||||
<div class="flex items-center gap-3">
|
||||
<button id="mobileMenuBtn" onclick="toggleMobileMenu()" class="lg:hidden text-purple-200 hover:text-white">
|
||||
<i class="fas fa-bars text-xl"></i>
|
||||
</button>
|
||||
<i class="fas fa-building text-xl text-purple-200"></i>
|
||||
<h1 class="text-lg font-semibold">TK 행정지원</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div id="headerUserName" class="text-sm font-medium hidden sm:block">-</div>
|
||||
<div id="headerUserAvatar" class="w-8 h-8 bg-purple-600 rounded-full flex items-center justify-center text-sm font-semibold">-</div>
|
||||
<button onclick="doLogout()" class="text-purple-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
|
||||
<div class="flex gap-6">
|
||||
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-48 flex-shrink-0 pt-2"></nav>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="bg-white rounded-xl shadow-sm p-5 mb-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-calendar-day text-purple-500 mr-2"></i>전사 휴가 관리</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<select id="yearSelect" class="input-field px-3 py-2 rounded-lg text-sm" onchange="loadHolidays()">
|
||||
</select>
|
||||
<button onclick="openAddModal()" class="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700">
|
||||
<i class="fas fa-plus mr-1"></i>추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>날짜</th>
|
||||
<th>휴가명</th>
|
||||
<th>유형</th>
|
||||
<th class="hide-mobile">설명</th>
|
||||
<th>상태</th>
|
||||
<th class="text-right">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="holidaysBody">
|
||||
<tr><td colspan="6" class="text-center text-gray-400 py-8">로딩 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 추가 모달 -->
|
||||
<div id="addModal" class="hidden modal-overlay" onclick="if(event.target===this)closeAddModal()">
|
||||
<div class="modal-content p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold">전사 휴가 등록</h3>
|
||||
<button onclick="closeAddModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<form id="addForm">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">날짜 <span class="text-red-400">*</span></label>
|
||||
<input type="date" id="addDate" class="input-field w-full px-3 py-2 rounded-lg text-sm" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">휴가명 <span class="text-red-400">*</span></label>
|
||||
<input type="text" id="addName" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="예: 창립기념일" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">유형 <span class="text-red-400">*</span></label>
|
||||
<div class="flex gap-4 mt-1">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="radio" name="addType" value="PAID" checked class="text-purple-600"> 유급휴가
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="radio" name="addType" value="ANNUAL_DEDUCT" class="text-purple-600"> 연차차감
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">설명</label>
|
||||
<input type="text" id="addDesc" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="설명 (선택)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-4 gap-2">
|
||||
<button type="button" onclick="closeAddModal()" class="px-4 py-2 border rounded-lg text-sm hover:bg-gray-50">취소</button>
|
||||
<button type="submit" class="px-6 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700">등록</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksupport-core.js?v=2026032301"></script>
|
||||
<script>
|
||||
async function initPage() {
|
||||
if (!initAuth()) return;
|
||||
if (!currentUser || !['support_team','admin','system'].includes(currentUser.role)) {
|
||||
document.querySelector('.flex-1').innerHTML = '<div class="bg-white rounded-xl shadow-sm p-8 text-center text-gray-500"><i class="fas fa-lock text-4xl mb-3 block"></i>지원팀 이상 권한이 필요합니다</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 연도 셀렉트
|
||||
const sel = document.getElementById('yearSelect');
|
||||
const thisYear = new Date().getFullYear();
|
||||
for (let y = thisYear + 1; y >= thisYear - 2; y--) {
|
||||
sel.innerHTML += `<option value="${y}" ${y === thisYear ? 'selected' : ''}>${y}년</option>`;
|
||||
}
|
||||
|
||||
loadHolidays();
|
||||
|
||||
document.getElementById('addForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
holiday_date: document.getElementById('addDate').value,
|
||||
holiday_name: document.getElementById('addName').value,
|
||||
holiday_type: document.querySelector('input[name="addType"]:checked').value,
|
||||
description: document.getElementById('addDesc').value
|
||||
};
|
||||
try {
|
||||
await api('/vacation/company/holidays', { method: 'POST', body: JSON.stringify(data) });
|
||||
showToast('전사 휴가가 등록되었습니다');
|
||||
closeAddModal();
|
||||
loadHolidays();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadHolidays() {
|
||||
const year = document.getElementById('yearSelect').value;
|
||||
try {
|
||||
const res = await api('/vacation/company/holidays?year=' + year);
|
||||
renderHolidays(res.data);
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderHolidays(holidays) {
|
||||
const tbody = document.getElementById('holidaysBody');
|
||||
if (holidays.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-state"><i class="fas fa-calendar-times block"></i>등록된 전사 휴가가 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = holidays.map(h => {
|
||||
const typeBadge = h.holiday_type === 'PAID'
|
||||
? '<span class="badge badge-green">유급</span>'
|
||||
: '<span class="badge badge-amber">연차차감</span>';
|
||||
const statusText = h.holiday_type === 'ANNUAL_DEDUCT'
|
||||
? (h.deduction_applied_at ? '<span class="badge badge-blue">차감완료</span>' : '<span class="badge badge-gray">미차감</span>')
|
||||
: '<span class="badge badge-green">해당없음</span>';
|
||||
const actions = [];
|
||||
if (h.holiday_type === 'ANNUAL_DEDUCT' && !h.deduction_applied_at) {
|
||||
actions.push(`<button onclick="applyDeduction(${h.id})" class="text-amber-600 hover:text-amber-800 text-sm mr-1" title="차감 실행"><i class="fas fa-calculator"></i></button>`);
|
||||
}
|
||||
if (!h.deduction_applied_at) {
|
||||
actions.push(`<button onclick="deleteHoliday(${h.id})" class="text-red-500 hover:text-red-700 text-sm" title="삭제"><i class="fas fa-trash"></i></button>`);
|
||||
}
|
||||
return `<tr>
|
||||
<td class="whitespace-nowrap">${formatDate(h.holiday_date)}</td>
|
||||
<td class="font-medium">${escapeHtml(h.holiday_name)}</td>
|
||||
<td>${typeBadge}</td>
|
||||
<td class="hide-mobile text-gray-500 text-sm max-w-[200px] truncate">${escapeHtml(h.description || '-')}</td>
|
||||
<td>${statusText}</td>
|
||||
<td class="text-right whitespace-nowrap">${actions.join('') || '-'}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function applyDeduction(id) {
|
||||
if (!confirm('이 전사 휴가에 대해 전 직원 연차 차감을 실행하시겠습니까?\n\n실행 후 되돌릴 수 없습니다.')) return;
|
||||
try {
|
||||
const res = await api('/vacation/company/holidays/' + id + '/apply-deduction', { method: 'POST' });
|
||||
showToast(res.message);
|
||||
if (res.warning) {
|
||||
setTimeout(() => showToast(res.warning, 'error'), 1500);
|
||||
}
|
||||
loadHolidays();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHoliday(id) {
|
||||
if (!confirm('이 전사 휴가를 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await api('/vacation/company/holidays/' + id, { method: 'DELETE' });
|
||||
showToast('전사 휴가가 삭제되었습니다');
|
||||
loadHolidays();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
document.getElementById('addDate').value = '';
|
||||
document.getElementById('addName').value = '';
|
||||
document.getElementById('addDesc').value = '';
|
||||
document.querySelector('input[name="addType"][value="PAID"]').checked = true;
|
||||
document.getElementById('addModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeAddModal() {
|
||||
document.getElementById('addModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
initPage();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>대시보드 - TK 행정지원</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tksupport.css?v=2026031401">
|
||||
<link rel="stylesheet" href="/static/css/tksupport.css?v=2026032301">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
@@ -123,7 +123,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksupport-core.js?v=2026031401"></script>
|
||||
<script src="/static/js/tksupport-core.js?v=2026032301"></script>
|
||||
<script>
|
||||
let vacationTypes = [];
|
||||
|
||||
|
||||
@@ -106,10 +106,16 @@ function renderNavbar() {
|
||||
{ href: '/vacation-request.html', icon: 'fa-paper-plane', label: '휴가 신청', match: ['vacation-request.html'] },
|
||||
{ href: '/vacation-status.html', icon: 'fa-calendar-check', label: '내 휴가 현황', match: ['vacation-status.html'] },
|
||||
{ href: '/vacation-approval.html', icon: 'fa-clipboard-check', label: '휴가 승인', match: ['vacation-approval.html'], admin: true },
|
||||
{ href: '/company-holidays.html', icon: 'fa-calendar-day', label: '전사 휴가 관리', match: ['company-holidays.html'], roles: ['support_team','admin','system'] },
|
||||
{ href: '/vacation-dashboard.html', icon: 'fa-chart-bar', label: '전체 휴가관리', match: ['vacation-dashboard.html'], roles: ['support_team','admin','system'] },
|
||||
];
|
||||
const nav = document.getElementById('sideNav');
|
||||
if (!nav) return;
|
||||
nav.innerHTML = links.filter(l => !l.admin || isAdmin).map(l => {
|
||||
nav.innerHTML = links.filter(l => {
|
||||
if (l.roles) return currentUser && l.roles.includes(currentUser.role);
|
||||
if (l.admin) return isAdmin;
|
||||
return true;
|
||||
}).map(l => {
|
||||
const active = l.match.some(m => currentPage === m || currentPage.endsWith(m));
|
||||
return `<a href="${l.href}" class="nav-link flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm transition-colors ${active ? 'active' : 'text-gray-600 hover:bg-gray-100'}">
|
||||
<i class="fas ${l.icon} w-5 text-center"></i><span>${l.label}</span></a>`;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>휴가 승인 - TK 행정지원</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tksupport.css?v=2026031401">
|
||||
<link rel="stylesheet" href="/static/css/tksupport.css?v=2026032301">
|
||||
</head>
|
||||
<body>
|
||||
<header class="bg-purple-700 text-white sticky top-0 z-50">
|
||||
@@ -54,6 +54,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>신청자</th>
|
||||
<th class="hide-mobile">부서</th>
|
||||
<th>유형</th>
|
||||
<th>기간</th>
|
||||
<th class="text-center">일수</th>
|
||||
@@ -63,7 +64,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pendingBody">
|
||||
<tr><td colspan="7" class="text-center text-gray-400 py-8">로딩 중...</td></tr>
|
||||
<tr><td colspan="8" class="text-center text-gray-400 py-8">로딩 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -83,6 +84,12 @@
|
||||
<option value="cancelled">취소</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">부서</label>
|
||||
<select id="filterAllDept" class="input-field px-3 py-2 rounded-lg text-sm">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onclick="loadAllRequests()" class="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700">
|
||||
<i class="fas fa-search mr-1"></i>조회
|
||||
</button>
|
||||
@@ -92,6 +99,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>신청자</th>
|
||||
<th class="hide-mobile">부서</th>
|
||||
<th>유형</th>
|
||||
<th>기간</th>
|
||||
<th class="text-center">일수</th>
|
||||
@@ -101,7 +109,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="allRequestsBody">
|
||||
<tr><td colspan="7" class="text-center text-gray-400 py-8">조회 버튼을 클릭하세요</td></tr>
|
||||
<tr><td colspan="8" class="text-center text-gray-400 py-8">조회 버튼을 클릭하세요</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -195,7 +203,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksupport-core.js?v=2026031401"></script>
|
||||
<script src="/static/js/tksupport-core.js?v=2026032301"></script>
|
||||
<script>
|
||||
let reviewAction = '';
|
||||
let reviewRequestId = null;
|
||||
@@ -260,12 +268,13 @@
|
||||
document.getElementById('pendingCount').textContent = data.length;
|
||||
const tbody = document.getElementById('pendingBody');
|
||||
if (data.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="empty-state"><i class="fas fa-check-circle block text-green-400"></i>대기 중인 신청이 없습니다</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="empty-state"><i class="fas fa-check-circle block text-green-400"></i>대기 중인 신청이 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = data.map(r => `
|
||||
<tr>
|
||||
<td class="font-medium">${escapeHtml(r.user_name || r.username)}</td>
|
||||
<td class="hide-mobile text-gray-500 text-sm">${escapeHtml(r.department_name || '-')}</td>
|
||||
<td>${escapeHtml(r.vacation_type_name)}</td>
|
||||
<td class="whitespace-nowrap">${formatDate(r.start_date)}${r.start_date !== r.end_date ? ' ~ ' + formatDate(r.end_date) : ''}</td>
|
||||
<td class="text-center">${r.days_used}</td>
|
||||
@@ -284,18 +293,33 @@
|
||||
|
||||
async function loadAllRequests() {
|
||||
const status = document.getElementById('filterAllStatus').value;
|
||||
const params = status ? `?status=${status}` : '';
|
||||
const deptId = document.getElementById('filterAllDept').value;
|
||||
let params = [];
|
||||
if (status) params.push('status=' + status);
|
||||
if (deptId) params.push('department_id=' + deptId);
|
||||
const qs = params.length > 0 ? '?' + params.join('&') : '';
|
||||
try {
|
||||
const res = await api('/vacation/requests' + params);
|
||||
const res = await api('/vacation/requests' + qs);
|
||||
const data = res.data;
|
||||
const tbody = document.getElementById('allRequestsBody');
|
||||
|
||||
// 부서 필터 옵션 갱신
|
||||
const deptSel = document.getElementById('filterAllDept');
|
||||
const currentDept = deptSel.value;
|
||||
const depts = [...new Set(data.map(r => r.department_name).filter(Boolean))].sort();
|
||||
deptSel.innerHTML = '<option value="">전체</option>';
|
||||
depts.forEach(d => {
|
||||
deptSel.innerHTML += `<option value="" ${d === currentDept ? 'selected' : ''}>${escapeHtml(d)}</option>`;
|
||||
});
|
||||
|
||||
if (data.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="empty-state">내역이 없습니다</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="empty-state">내역이 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = data.map(r => `
|
||||
<tr>
|
||||
<td class="font-medium">${escapeHtml(r.user_name || r.username)}</td>
|
||||
<td class="hide-mobile text-gray-500 text-sm">${escapeHtml(r.department_name || '-')}</td>
|
||||
<td>${escapeHtml(r.vacation_type_name)}</td>
|
||||
<td class="whitespace-nowrap">${formatDate(r.start_date)}${r.start_date !== r.end_date ? ' ~ ' + formatDate(r.end_date) : ''}</td>
|
||||
<td class="text-center">${r.days_used}</td>
|
||||
|
||||
231
tksupport/web/vacation-dashboard.html
Normal file
231
tksupport/web/vacation-dashboard.html
Normal file
@@ -0,0 +1,231 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>전체 휴가관리 - TK 행정지원</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tksupport.css?v=2026032301">
|
||||
</head>
|
||||
<body>
|
||||
<header class="bg-purple-700 text-white sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-14">
|
||||
<div class="flex items-center gap-3">
|
||||
<button id="mobileMenuBtn" onclick="toggleMobileMenu()" class="lg:hidden text-purple-200 hover:text-white">
|
||||
<i class="fas fa-bars text-xl"></i>
|
||||
</button>
|
||||
<i class="fas fa-building text-xl text-purple-200"></i>
|
||||
<h1 class="text-lg font-semibold">TK 행정지원</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div id="headerUserName" class="text-sm font-medium hidden sm:block">-</div>
|
||||
<div id="headerUserAvatar" class="w-8 h-8 bg-purple-600 rounded-full flex items-center justify-center text-sm font-semibold">-</div>
|
||||
<button onclick="doLogout()" class="text-purple-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
|
||||
<div class="flex gap-6">
|
||||
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-48 flex-shrink-0 pt-2"></nav>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- 필터 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-5 mb-5">
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">연도</label>
|
||||
<select id="yearSelect" class="input-field px-3 py-2 rounded-lg text-sm"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">부서</label>
|
||||
<select id="deptFilter" class="input-field px-3 py-2 rounded-lg text-sm">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">이름</label>
|
||||
<input type="text" id="nameSearch" class="input-field px-3 py-2 rounded-lg text-sm" placeholder="이름 검색">
|
||||
</div>
|
||||
<button onclick="loadDashboard()" class="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700">
|
||||
<i class="fas fa-search mr-1"></i>조회
|
||||
</button>
|
||||
<button disabled class="px-4 py-2 bg-gray-300 text-gray-500 rounded-lg text-sm cursor-not-allowed" title="추후 지원 예정">
|
||||
<i class="fas fa-file-excel mr-1"></i>엑셀
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 요약 카드 -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-5">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-purple-600" id="statTotal">-</div>
|
||||
<div class="stat-label">전체 직원</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-blue-600" id="statAvgRemaining">-</div>
|
||||
<div class="stat-label">평균 잔여일</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-red-600" id="statLowBalance">-</div>
|
||||
<div class="stat-label">소진 임박</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 부서별 현황 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-5 mb-5">
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-sitemap text-purple-500 mr-2"></i>부서별 현황</h2>
|
||||
<div id="deptSummary" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div class="text-center text-gray-400 py-4 col-span-full">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 직원별 상세 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-5">
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-users text-purple-500 mr-2"></i>직원별 상세</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>이름</th>
|
||||
<th>부서</th>
|
||||
<th class="text-center">기본연차</th>
|
||||
<th class="text-center hide-mobile">이월</th>
|
||||
<th class="text-center hide-mobile">장기근속</th>
|
||||
<th class="text-center">총 잔여</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="employeesBody">
|
||||
<tr><td colspan="6" class="text-center text-gray-400 py-8">로딩 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksupport-core.js?v=2026032301"></script>
|
||||
<script>
|
||||
async function initPage() {
|
||||
if (!initAuth()) return;
|
||||
if (!currentUser || !['support_team','admin','system'].includes(currentUser.role)) {
|
||||
document.querySelector('.flex-1').innerHTML = '<div class="bg-white rounded-xl shadow-sm p-8 text-center text-gray-500"><i class="fas fa-lock text-4xl mb-3 block"></i>지원팀 이상 권한이 필요합니다</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const sel = document.getElementById('yearSelect');
|
||||
const thisYear = new Date().getFullYear();
|
||||
for (let y = thisYear + 1; y >= thisYear - 2; y--) {
|
||||
sel.innerHTML += `<option value="${y}" ${y === thisYear ? 'selected' : ''}>${y}년</option>`;
|
||||
}
|
||||
|
||||
loadDashboard();
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
const year = document.getElementById('yearSelect').value;
|
||||
const deptId = document.getElementById('deptFilter').value;
|
||||
const searchName = document.getElementById('nameSearch').value;
|
||||
let url = '/vacation/dashboard?year=' + year;
|
||||
if (deptId) url += '&department_id=' + deptId;
|
||||
if (searchName) url += '&search_name=' + encodeURIComponent(searchName);
|
||||
|
||||
try {
|
||||
const res = await api(url);
|
||||
const { summary, employees } = res.data;
|
||||
renderSummary(summary);
|
||||
renderEmployees(employees);
|
||||
populateDeptFilter(summary);
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function populateDeptFilter(summary) {
|
||||
const sel = document.getElementById('deptFilter');
|
||||
const currentVal = sel.value;
|
||||
sel.innerHTML = '<option value="">전체</option>';
|
||||
summary.forEach(s => {
|
||||
sel.innerHTML += `<option value="${s.department_id || ''}" ${String(s.department_id) === currentVal ? 'selected' : ''}>${escapeHtml(s.department_name)}</option>`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderSummary(summary) {
|
||||
let totalEmployees = 0, totalAvg = 0, totalLow = 0, avgCount = 0;
|
||||
summary.forEach(s => {
|
||||
totalEmployees += parseInt(s.employee_count || 0);
|
||||
if (s.avg_remaining !== null) { totalAvg += parseFloat(s.avg_remaining) * parseInt(s.employee_count); avgCount += parseInt(s.employee_count); }
|
||||
totalLow += parseInt(s.low_balance_count || 0);
|
||||
});
|
||||
document.getElementById('statTotal').textContent = totalEmployees;
|
||||
document.getElementById('statAvgRemaining').textContent = avgCount > 0 ? (totalAvg / avgCount).toFixed(1) : '-';
|
||||
document.getElementById('statLowBalance').textContent = totalLow;
|
||||
|
||||
const container = document.getElementById('deptSummary');
|
||||
if (summary.length === 0) {
|
||||
container.innerHTML = '<div class="text-center text-gray-400 py-4 col-span-full">데이터가 없습니다</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = summary.map(s => `
|
||||
<div class="border rounded-lg p-4">
|
||||
<div class="font-medium text-gray-800 mb-2">${escapeHtml(s.department_name)}</div>
|
||||
<div class="grid grid-cols-3 gap-2 text-center text-sm">
|
||||
<div>
|
||||
<div class="text-lg font-bold text-purple-600">${s.employee_count}</div>
|
||||
<div class="text-xs text-gray-500">직원</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-bold text-blue-600">${s.avg_remaining !== null ? parseFloat(s.avg_remaining).toFixed(1) : '-'}</div>
|
||||
<div class="text-xs text-gray-500">평균잔여</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-bold ${parseInt(s.low_balance_count) > 0 ? 'text-red-600' : 'text-green-600'}">${s.low_balance_count || 0}</div>
|
||||
<div class="text-xs text-gray-500">소진임박</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderEmployees(employees) {
|
||||
const tbody = document.getElementById('employeesBody');
|
||||
if (employees.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">직원 데이터가 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = employees.map(emp => {
|
||||
const auto = emp.balances.find(b => b.balance_type === 'AUTO');
|
||||
const carry = emp.balances.find(b => b.balance_type === 'CARRY_OVER');
|
||||
const longSvc = emp.balances.find(b => b.balance_type === 'LONG_SERVICE');
|
||||
|
||||
const autoText = auto ? `${auto.used_days}/${auto.total_days}` : '-';
|
||||
const carryText = carry ? `${carry.remaining_days}` : '-';
|
||||
const longText = longSvc && parseFloat(longSvc.total_days) > 0 ? `${longSvc.remaining_days}` : '-';
|
||||
|
||||
let totalRemaining = 0;
|
||||
emp.balances.forEach(b => { totalRemaining += parseFloat(b.remaining_days || 0); });
|
||||
|
||||
const isLow = auto && parseFloat(auto.total_days - auto.used_days) <= 2;
|
||||
|
||||
return `<tr>
|
||||
<td class="font-medium">${escapeHtml(emp.name || emp.username)}</td>
|
||||
<td class="text-gray-600 text-sm">${escapeHtml(emp.department_name)}</td>
|
||||
<td class="text-center">${autoText}</td>
|
||||
<td class="text-center hide-mobile">${carryText}</td>
|
||||
<td class="text-center hide-mobile">${longText}</td>
|
||||
<td class="text-center font-bold ${isLow ? 'text-red-600' : 'text-purple-600'}">
|
||||
${totalRemaining % 1 === 0 ? totalRemaining : totalRemaining.toFixed(1)}
|
||||
${isLow ? ' <i class="fas fa-exclamation-triangle text-red-400 text-xs"></i>' : ''}
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
initPage();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>내 휴가 현황 - TK 행정지원</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tksupport.css?v=2026031401">
|
||||
<link rel="stylesheet" href="/static/css/tksupport.css?v=2026032301">
|
||||
</head>
|
||||
<body>
|
||||
<header class="bg-purple-700 text-white sticky top-0 z-50">
|
||||
@@ -33,19 +33,46 @@
|
||||
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-48 flex-shrink-0 pt-2"></nav>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- 잔여일 -->
|
||||
<!-- 연도 선택 -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-calendar-check text-purple-500 mr-2"></i>내 휴가 현황</h2>
|
||||
<select id="yearSelect" class="input-field px-3 py-2 rounded-lg text-sm" onchange="loadMyStatus()"></select>
|
||||
</div>
|
||||
|
||||
<!-- 잔여일 카드 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-5 mb-5">
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-3"><i class="fas fa-chart-pie text-purple-500 mr-2"></i>잔여일 현황</h2>
|
||||
<div id="balanceCards" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3"><i class="fas fa-chart-pie text-purple-500 mr-2"></i>잔여일 현황</h3>
|
||||
<div id="balanceCards" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
<div class="text-center text-gray-400 py-4 col-span-full">로딩 중...</div>
|
||||
</div>
|
||||
<div id="totalBalance" class="mt-3 pt-3 border-t text-center hidden">
|
||||
<span class="text-sm text-gray-500">총 잔여일: </span>
|
||||
<span id="totalRemainingValue" class="text-lg font-bold text-purple-700"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 -->
|
||||
<!-- 연장근로 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-5 mb-5">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-2"><i class="fas fa-clock text-purple-500 mr-2"></i>연장근로</h3>
|
||||
<div class="text-center text-gray-400 py-3">
|
||||
<div class="text-2xl font-bold text-gray-300">--시간</div>
|
||||
<div class="text-xs text-gray-400 mt-1">(추후 업데이트 예정)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 전사 휴가일 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-5 mb-5">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3"><i class="fas fa-calendar-day text-purple-500 mr-2"></i>전사 휴가일</h3>
|
||||
<div id="companyHolidays" class="space-y-2">
|
||||
<div class="text-center text-gray-400 py-3">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 휴가 사용 이력 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-5">
|
||||
<div class="flex flex-wrap items-end gap-3 mb-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">상태</label>
|
||||
<h3 class="text-sm font-semibold text-gray-700"><i class="fas fa-history text-purple-500 mr-2"></i>휴가 사용 이력</h3>
|
||||
<div class="ml-auto">
|
||||
<select id="filterStatus" class="input-field px-3 py-2 rounded-lg text-sm">
|
||||
<option value="">전체</option>
|
||||
<option value="pending">대기</option>
|
||||
@@ -93,42 +120,109 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksupport-core.js?v=2026031401"></script>
|
||||
<script src="/static/js/tksupport-core.js?v=2026032301"></script>
|
||||
<script>
|
||||
let cachedRequests = [];
|
||||
|
||||
async function initStatusPage() {
|
||||
if (!initAuth()) return;
|
||||
|
||||
try {
|
||||
const balanceRes = await api('/vacation/balance');
|
||||
const balances = balanceRes.data.balances;
|
||||
const container = document.getElementById('balanceCards');
|
||||
if (balances.length === 0) {
|
||||
container.innerHTML = '<div class="text-center text-gray-400 py-4 col-span-full">배정된 휴가가 없습니다</div>';
|
||||
} else {
|
||||
container.innerHTML = balances.map(b => `
|
||||
<div class="border rounded-lg p-3 text-center">
|
||||
<div class="text-xs text-gray-500 mb-1">${escapeHtml(b.type_name)}</div>
|
||||
<div class="text-xl font-bold text-purple-600">${b.remaining_days}</div>
|
||||
<div class="text-xs text-gray-400 mt-1">${b.used_days} / ${b.total_days} 사용</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
// 연도 셀렉트
|
||||
const sel = document.getElementById('yearSelect');
|
||||
const thisYear = new Date().getFullYear();
|
||||
for (let y = thisYear + 1; y >= thisYear - 2; y--) {
|
||||
sel.innerHTML += `<option value="${y}" ${y === thisYear ? 'selected' : ''}>${y}년</option>`;
|
||||
}
|
||||
|
||||
loadRequests();
|
||||
loadMyStatus();
|
||||
}
|
||||
|
||||
async function loadMyStatus() {
|
||||
const year = document.getElementById('yearSelect').value;
|
||||
try {
|
||||
const res = await api('/vacation/my-status?year=' + year);
|
||||
const { balances, requests, company_holidays } = res.data;
|
||||
|
||||
renderBalanceCards(balances);
|
||||
renderCompanyHolidays(company_holidays);
|
||||
cachedRequests = requests;
|
||||
renderRequests(requests);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function balanceTypeLabel(bt) {
|
||||
const m = { AUTO: '기본연차', CARRY_OVER: '이월연차', LONG_SERVICE: '장기근속', MANUAL: '추가부여', COMPANY_GRANT: '회사부여' };
|
||||
return m[bt] || bt || '기본연차';
|
||||
}
|
||||
|
||||
function renderBalanceCards(balances) {
|
||||
const container = document.getElementById('balanceCards');
|
||||
// 장기근속 total_days=0이면 숨김
|
||||
const visible = balances.filter(b => {
|
||||
if ((b.balance_type === 'LONG_SERVICE') && parseFloat(b.total_days) === 0) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (visible.length === 0) {
|
||||
container.innerHTML = '<div class="text-center text-gray-400 py-4 col-span-full">배정된 휴가가 없습니다</div>';
|
||||
document.getElementById('totalBalance').classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
let totalRemaining = 0;
|
||||
container.innerHTML = visible.map(b => {
|
||||
const remaining = parseFloat(b.remaining_days);
|
||||
totalRemaining += remaining;
|
||||
const isNegative = remaining < 0;
|
||||
const label = balanceTypeLabel(b.balance_type);
|
||||
let subtitle = `${b.used_days} / ${b.total_days} 사용`;
|
||||
if (b.balance_type === 'CARRY_OVER' && b.expires_at) {
|
||||
subtitle += `<br><span class="text-xs text-amber-500">만료: ${formatDate(b.expires_at)}</span>`;
|
||||
}
|
||||
if (b.balance_type === 'LONG_SERVICE') {
|
||||
subtitle += `<br><span class="text-xs text-blue-500">만료없음</span>`;
|
||||
}
|
||||
return `<div class="border rounded-lg p-3 text-center">
|
||||
<div class="text-xs text-gray-500 mb-1">${escapeHtml(label)}</div>
|
||||
<div class="text-xl font-bold ${isNegative ? 'text-red-600' : 'text-purple-600'}">${remaining % 1 === 0 ? remaining : remaining.toFixed(1)}</div>
|
||||
<div class="text-xs text-gray-400 mt-1">${subtitle}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const totalEl = document.getElementById('totalBalance');
|
||||
totalEl.classList.remove('hidden');
|
||||
const totalVal = document.getElementById('totalRemainingValue');
|
||||
totalVal.textContent = (totalRemaining % 1 === 0 ? totalRemaining : totalRemaining.toFixed(1)) + '일';
|
||||
totalVal.className = `text-lg font-bold ${totalRemaining < 0 ? 'text-red-700' : 'text-purple-700'}`;
|
||||
}
|
||||
|
||||
function renderCompanyHolidays(holidays) {
|
||||
const container = document.getElementById('companyHolidays');
|
||||
if (!holidays || holidays.length === 0) {
|
||||
container.innerHTML = '<div class="text-center text-gray-400 py-3">등록된 전사 휴가일이 없습니다</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = holidays.map(h => {
|
||||
const typeBadge = h.holiday_type === 'PAID'
|
||||
? '<span class="badge badge-green text-xs">유급</span>'
|
||||
: '<span class="badge badge-amber text-xs">연차차감</span>';
|
||||
return `<div class="flex items-center justify-between border rounded-lg px-3 py-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium text-gray-700">${formatDate(h.holiday_date)}</span>
|
||||
<span class="text-sm text-gray-600">${escapeHtml(h.holiday_name)}</span>
|
||||
</div>
|
||||
${typeBadge}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function loadRequests() {
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
const params = status ? `?status=${status}` : '';
|
||||
try {
|
||||
const res = await api('/vacation/requests' + params);
|
||||
renderRequests(res.data);
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
const filtered = status ? cachedRequests.filter(r => r.status === status) : cachedRequests;
|
||||
renderRequests(filtered);
|
||||
}
|
||||
|
||||
function renderRequests(requests) {
|
||||
@@ -186,7 +280,7 @@
|
||||
try {
|
||||
await api('/vacation/requests/' + id + '/cancel', { method: 'PATCH' });
|
||||
showToast('휴가 신청이 취소되었습니다');
|
||||
loadRequests();
|
||||
loadMyStatus();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user