카카오톡 인앱 WebView는 서브도메인 간 쿠키를 공유하지 않아 tkds에서 로그인 후 tkfb로 리다이렉트 시 인증이 풀리는 문제. - sso-relay.js: URL hash의 _sso= 토큰을 로컬 쿠키+localStorage로 설정 - gateway dashboard: 로그인 후 redirect URL에 #_sso=<token> 추가 - 전 서비스 HTML: core JS 직전에 sso-relay.js 로드 (81개 파일) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
294 lines
15 KiB
HTML
294 lines
15 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="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">
|
|
<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">
|
|
<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>
|
|
<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/sso-relay.js?v=20260401"></script>
|
|
<script src="/static/js/tksupport-core.js?v=2026040101"></script>
|
|
<script>
|
|
let cachedRequests = [];
|
|
|
|
async function initStatusPage() {
|
|
if (!initAuth()) 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>`;
|
|
}
|
|
|
|
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 filtered = status ? cachedRequests.filter(r => r.status === status) : cachedRequests;
|
|
renderRequests(filtered);
|
|
}
|
|
|
|
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('휴가 신청이 취소되었습니다');
|
|
loadMyStatus();
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
}
|
|
|
|
initStatusPage();
|
|
</script>
|
|
</body>
|
|
</html>
|