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:
Hyungi Ahn
2026-03-23 08:16:50 +09:00
parent a3f7a324b1
commit 36391c02e1
19 changed files with 1040 additions and 62 deletions

View File

@@ -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');
}