Files
tk-factory-services/tksupport/web/vacation-status.html
Hyungi Ahn 3011495e6d feat(tksupport): 전사 행정지원 서비스 신규 구축 (Phase 1 - 휴가신청)
sso_users 기반 전사 휴가신청/승인/잔여일 관리 서비스.
기존 tkfb의 workers 종속 휴가 기능을 전사 확장.

- API: Express + MariaDB, SSO JWT 인증, 자동 마이그레이션
- Web: 대시보드, 휴가 신청/현황/승인 페이지 (보라색 테마)
- DB: sp_vacation_requests, sp_vacation_balances 신규 테이블
- Docker: API(30600), Web(30680) 포트 구성

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:39:59 +09:00

196 lines
9.8 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=1">
</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">
<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">
<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">
<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 mb-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>
<select id="filterStatus" class="input-field px-3 py-2 rounded-lg text-sm">
<option value="">전체</option>
<option value="pending">대기</option>
<option value="approved">승인</option>
<option value="rejected">반려</option>
<option value="cancelled">취소</option>
</select>
</div>
<button onclick="loadRequests()" 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>
</div>
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>유형</th>
<th>기간</th>
<th class="text-center">일수</th>
<th class="hide-mobile">사유</th>
<th>상태</th>
<th class="hide-mobile">검토자</th>
<th class="text-right">관리</th>
</tr>
</thead>
<tbody id="requestsBody">
<tr><td colspan="7" class="text-center text-gray-400 py-8">로딩 중...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- 상세 모달 -->
<div id="detailModal" class="hidden modal-overlay" onclick="if(event.target===this)closeDetail()">
<div class="modal-content p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">휴가 신청 상세</h3>
<button onclick="closeDetail()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<div id="detailContent"></div>
</div>
</div>
<script src="/static/js/tksupport-core.js?v=1"></script>
<script>
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);
}
loadRequests();
}
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');
}
}
function renderRequests(requests) {
const tbody = document.getElementById('requestsBody');
if (requests.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="empty-state"><i class="fas fa-calendar-times block"></i>신청 내역이 없습니다</td></tr>';
return;
}
tbody.innerHTML = requests.map(r => `
<tr>
<td>${escapeHtml(r.vacation_type_name)}</td>
<td class="whitespace-nowrap">${formatDate(r.start_date)}${r.start_date !== r.end_date ? '<br><span class="text-gray-400">~</span> ' + formatDate(r.end_date) : ''}</td>
<td class="text-center">${r.days_used}</td>
<td class="hide-mobile max-w-[200px] truncate">${escapeHtml(r.reason || '-')}</td>
<td>${statusBadge(r.status)}</td>
<td class="hide-mobile text-gray-500 text-sm">${escapeHtml(r.reviewer_name || '-')}</td>
<td class="text-right whitespace-nowrap">
<button onclick="showDetail(${r.request_id})" class="text-purple-600 hover:text-purple-800 text-sm mr-1" title="상세"><i class="fas fa-eye"></i></button>
${r.status === 'pending' ? `<button onclick="cancelRequest(${r.request_id})" class="text-red-500 hover:text-red-700 text-sm" title="취소"><i class="fas fa-times-circle"></i></button>` : ''}
</td>
</tr>
`).join('');
}
async function showDetail(id) {
try {
const res = await api('/vacation/requests/' + id);
const r = res.data;
document.getElementById('detailContent').innerHTML = `
<div class="grid grid-cols-2 gap-4 text-sm">
<div><span class="text-gray-500">유형:</span> <strong>${escapeHtml(r.vacation_type_name)}</strong></div>
<div><span class="text-gray-500">상태:</span> ${statusBadge(r.status)}</div>
<div><span class="text-gray-500">시작일:</span> ${formatDate(r.start_date)}</div>
<div><span class="text-gray-500">종료일:</span> ${formatDate(r.end_date)}</div>
<div><span class="text-gray-500">사용일수:</span> ${r.days_used}일</div>
<div><span class="text-gray-500">신청일:</span> ${formatDateTime(r.created_at)}</div>
<div class="col-span-2"><span class="text-gray-500">사유:</span> ${escapeHtml(r.reason || '-')}</div>
${r.reviewed_by ? `
<div><span class="text-gray-500">검토자:</span> ${escapeHtml(r.reviewer_name || '-')}</div>
<div><span class="text-gray-500">검토일:</span> ${formatDateTime(r.reviewed_at)}</div>
<div class="col-span-2"><span class="text-gray-500">검토 메모:</span> ${escapeHtml(r.review_note || '-')}</div>
` : ''}
</div>
`;
document.getElementById('detailModal').classList.remove('hidden');
} catch (err) {
showToast(err.message, 'error');
}
}
function closeDetail() { document.getElementById('detailModal').classList.add('hidden'); }
async function cancelRequest(id) {
if (!confirm('이 휴가 신청을 취소하시겠습니까?')) return;
try {
await api('/vacation/requests/' + id + '/cancel', { method: 'PATCH' });
showToast('휴가 신청이 취소되었습니다');
loadRequests();
} catch (err) {
showToast(err.message, 'error');
}
}
initStatusPage();
</script>
</body>
</html>