Files
tk-factory-services/tksupport/web/vacation-dashboard.html
Hyungi Ahn 36391c02e1 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>
2026-03-23 08:16:50 +09:00

232 lines
12 KiB
HTML

<!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>