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>
This commit is contained in:
168
tksupport/web/vacation-request.html
Normal file
168
tksupport/web/vacation-request.html
Normal file
@@ -0,0 +1,168 @@
|
||||
<!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="hireDateWarning" class="hidden bg-amber-50 border border-amber-200 rounded-lg p-3 mb-3 text-sm text-amber-700">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>입사일이 미등록되어 연차가 자동 계산되지 않습니다. 관리자에게 문의하세요.
|
||||
</div>
|
||||
<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">
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-paper-plane text-purple-500 mr-2"></i>휴가 신청</h2>
|
||||
<form id="requestForm">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">휴가 유형 <span class="text-red-400">*</span></label>
|
||||
<select id="vacationType" class="input-field w-full px-3 py-2.5 rounded-lg" required>
|
||||
<option value="">선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">사용일수 <span class="text-red-400">*</span></label>
|
||||
<input type="number" id="daysUsed" step="0.5" min="0.5" class="input-field w-full px-3 py-2.5 rounded-lg" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">시작일 <span class="text-red-400">*</span></label>
|
||||
<input type="date" id="startDate" class="input-field w-full px-3 py-2.5 rounded-lg" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">종료일 <span class="text-red-400">*</span></label>
|
||||
<input type="date" id="endDate" class="input-field w-full px-3 py-2.5 rounded-lg" required>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">사유</label>
|
||||
<textarea id="reason" rows="2" class="input-field w-full px-3 py-2.5 rounded-lg resize-none" placeholder="휴가 사유를 입력하세요 (선택사항)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-5 gap-2">
|
||||
<a href="/" class="px-4 py-2.5 border border-gray-300 rounded-lg text-sm hover:bg-gray-50">취소</a>
|
||||
<button type="submit" class="px-6 py-2.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium">
|
||||
<i class="fas fa-paper-plane mr-2"></i>신청하기
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksupport-core.js?v=1"></script>
|
||||
<script>
|
||||
async function initRequestPage() {
|
||||
if (!initAuth()) return;
|
||||
|
||||
try {
|
||||
const [typesRes, balanceRes] = await Promise.all([
|
||||
api('/vacation/types'),
|
||||
api('/vacation/balance')
|
||||
]);
|
||||
|
||||
// 휴가 유형 드롭다운
|
||||
const sel = document.getElementById('vacationType');
|
||||
typesRes.data.forEach(t => {
|
||||
sel.innerHTML += `<option value="${t.id}">${escapeHtml(t.type_name)}</option>`;
|
||||
});
|
||||
|
||||
// 잔여일 카드
|
||||
const balances = balanceRes.data.balances;
|
||||
const hireDate = balanceRes.data.hire_date;
|
||||
if (!hireDate) document.getElementById('hireDateWarning').classList.remove('hidden');
|
||||
|
||||
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) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
|
||||
// 날짜 기본값
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
document.getElementById('startDate').value = today;
|
||||
document.getElementById('endDate').value = today;
|
||||
document.getElementById('daysUsed').value = '1';
|
||||
|
||||
document.getElementById('startDate').addEventListener('change', calcDays);
|
||||
document.getElementById('endDate').addEventListener('change', calcDays);
|
||||
|
||||
document.getElementById('requestForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
vacation_type_id: parseInt(document.getElementById('vacationType').value),
|
||||
start_date: document.getElementById('startDate').value,
|
||||
end_date: document.getElementById('endDate').value,
|
||||
days_used: parseFloat(document.getElementById('daysUsed').value),
|
||||
reason: document.getElementById('reason').value
|
||||
};
|
||||
|
||||
if (!data.vacation_type_id) { showToast('휴가 유형을 선택하세요', 'error'); return; }
|
||||
|
||||
try {
|
||||
await api('/vacation/requests', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
showToast('휴가 신청이 완료되었습니다');
|
||||
setTimeout(() => { location.href = '/vacation-status.html'; }, 1000);
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function calcDays() {
|
||||
const s = document.getElementById('startDate').value;
|
||||
const e = document.getElementById('endDate').value;
|
||||
if (s && e) {
|
||||
const diff = Math.floor((new Date(e) - new Date(s)) / 86400000) + 1;
|
||||
if (diff > 0) document.getElementById('daysUsed').value = diff;
|
||||
}
|
||||
}
|
||||
|
||||
initRequestPage();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user