Files
tk-factory-services/tksupport/web/vacation-approval.html
Hyungi Ahn 0de9d5bb48 feat(sso): 인앱 브라우저 SSO 토큰 릴레이 — 카톡 WebView 쿠키 미공유 해결
카카오톡 인앱 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>
2026-04-01 15:44:02 +09:00

414 lines
23 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 gap-1 mb-5 bg-white rounded-xl shadow-sm p-1">
<button onclick="switchTab('pending')" id="tabPending" class="flex-1 py-2.5 text-sm font-medium rounded-lg transition-colors bg-purple-600 text-white">
대기 중 <span id="pendingCount" class="ml-1 bg-white/20 px-1.5 py-0.5 rounded-full text-xs">0</span>
</button>
<button onclick="switchTab('all')" id="tabAll" class="flex-1 py-2.5 text-sm font-medium rounded-lg transition-colors text-gray-600 hover:bg-gray-100">
전체 내역
</button>
<button onclick="switchTab('balance')" id="tabBalance" class="flex-1 py-2.5 text-sm font-medium rounded-lg transition-colors text-gray-600 hover:bg-gray-100">
잔여일 관리
</button>
</div>
<!-- 대기 목록 -->
<div id="panelPending" 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-clock text-amber-500 mr-2"></i>승인 대기 목록</h2>
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>신청자</th>
<th class="hide-mobile">부서</th>
<th>유형</th>
<th>기간</th>
<th class="text-center">일수</th>
<th class="hide-mobile">사유</th>
<th class="hide-mobile">신청일</th>
<th class="text-right">처리</th>
</tr>
</thead>
<tbody id="pendingBody">
<tr><td colspan="8" class="text-center text-gray-400 py-8">로딩 중...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- 전체 내역 -->
<div id="panelAll" class="hidden 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-list text-purple-500 mr-2"></i>전체 휴가 내역</h2>
<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="filterAllStatus" 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>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">부서</label>
<select id="filterAllDept" class="input-field px-3 py-2 rounded-lg text-sm">
<option value="">전체</option>
</select>
</div>
<button onclick="loadAllRequests()" 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 class="hide-mobile">부서</th>
<th>유형</th>
<th>기간</th>
<th class="text-center">일수</th>
<th>상태</th>
<th class="hide-mobile">검토자</th>
<th class="text-right hide-mobile">신청일</th>
</tr>
</thead>
<tbody id="allRequestsBody">
<tr><td colspan="8" class="text-center text-gray-400 py-8">조회 버튼을 클릭하세요</td></tr>
</tbody>
</table>
</div>
</div>
<!-- 잔여일 관리 -->
<div id="panelBalance" class="hidden">
<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-plus-circle text-purple-500 mr-2"></i>잔여일 배정</h2>
<form id="allocateForm">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">직원 <span class="text-red-400">*</span></label>
<select id="allocUser" class="input-field w-full px-3 py-2 rounded-lg text-sm" required>
<option value="">선택</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">유형 <span class="text-red-400">*</span></label>
<select id="allocType" class="input-field w-full px-3 py-2 rounded-lg text-sm" required>
<option value="">선택</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">연도 <span class="text-red-400">*</span></label>
<input type="number" id="allocYear" class="input-field w-full px-3 py-2 rounded-lg text-sm" required>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">일수 <span class="text-red-400">*</span></label>
<input type="number" id="allocDays" step="0.5" min="0" class="input-field w-full px-3 py-2 rounded-lg text-sm" required>
</div>
<div class="flex items-end">
<button type="submit" class="w-full px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700">
<i class="fas fa-save mr-1"></i>배정
</button>
</div>
</div>
<div class="mt-2">
<label class="block text-xs font-medium text-gray-600 mb-1">메모</label>
<input type="text" id="allocNotes" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="배정 사유 (선택)">
</div>
</form>
</div>
<div class="bg-white rounded-xl shadow-sm p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-table text-purple-500 mr-2"></i>전체 잔여일 현황</h2>
<button onclick="loadAllBalances()" class="text-sm text-purple-600 hover:text-purple-800">
<i class="fas fa-sync-alt 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="text-center">사용</th>
<th class="text-center">잔여</th>
<th class="hide-mobile">메모</th>
</tr>
</thead>
<tbody id="balancesBody">
<tr><td colspan="6" class="text-center text-gray-400 py-8">로딩 중...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 승인/반려 모달 -->
<div id="reviewModal" class="hidden modal-overlay" onclick="if(event.target===this)closeReview()">
<div class="modal-content p-6">
<div class="flex justify-between items-center mb-4">
<h3 id="reviewModalTitle" class="text-lg font-semibold">휴가 승인</h3>
<button onclick="closeReview()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<div id="reviewDetail" class="text-sm mb-4"></div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">검토 메모</label>
<textarea id="reviewNote" rows="2" class="input-field w-full px-3 py-2 rounded-lg resize-none" placeholder="메모 (선택)"></textarea>
</div>
<div class="flex justify-end mt-4 gap-2">
<button onclick="closeReview()" class="px-4 py-2 border rounded-lg text-sm hover:bg-gray-50">취소</button>
<button id="reviewSubmitBtn" onclick="submitReview()" class="px-6 py-2 text-white rounded-lg text-sm font-medium">확인</button>
</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 reviewAction = '';
let reviewRequestId = null;
async function initApprovalPage() {
if (!initAuth()) return;
// 권한은 API requirePage에서 체크
document.getElementById('allocYear').value = new Date().getFullYear();
await loadDropdowns();
loadPending();
loadAllBalances();
document.getElementById('allocateForm').addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
user_id: parseInt(document.getElementById('allocUser').value),
vacation_type_id: parseInt(document.getElementById('allocType').value),
year: parseInt(document.getElementById('allocYear').value),
total_days: parseFloat(document.getElementById('allocDays').value),
notes: document.getElementById('allocNotes').value
};
try {
await api('/vacation/balance/allocate', { method: 'POST', body: JSON.stringify(data) });
showToast('잔여일이 배정되었습니다');
document.getElementById('allocDays').value = '';
document.getElementById('allocNotes').value = '';
loadAllBalances();
} catch (err) {
showToast(err.message, 'error');
}
});
}
async function loadDropdowns() {
try {
const [typesRes, usersRes] = await Promise.all([
api('/vacation/types'),
api('/vacation/users')
]);
const typeSel = document.getElementById('allocType');
typesRes.data.forEach(t => {
typeSel.innerHTML += `<option value="${t.id}">${escapeHtml(t.type_name)}</option>`;
});
const userSel = document.getElementById('allocUser');
usersRes.data.forEach(u => {
const hireMark = u.hire_date ? '' : ' (입사일 미등록)';
userSel.innerHTML += `<option value="${u.user_id}">${escapeHtml(u.name || u.username)}${hireMark}</option>`;
});
} catch (err) {
console.error(err);
}
}
async function loadPending() {
try {
const res = await api('/vacation/pending');
const data = res.data;
document.getElementById('pendingCount').textContent = data.length;
const tbody = document.getElementById('pendingBody');
if (data.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="empty-state"><i class="fas fa-check-circle block text-green-400"></i>대기 중인 신청이 없습니다</td></tr>';
return;
}
tbody.innerHTML = data.map(r => `
<tr>
<td class="font-medium">${escapeHtml(r.user_name || r.username)}</td>
<td class="hide-mobile text-gray-500 text-sm">${escapeHtml(r.department_name || '-')}</td>
<td>${escapeHtml(r.vacation_type_name)}</td>
<td class="whitespace-nowrap">${formatDate(r.start_date)}${r.start_date !== r.end_date ? ' ~ ' + 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 class="hide-mobile text-gray-500 text-xs">${formatDate(r.created_at)}</td>
<td class="text-right whitespace-nowrap">
<button onclick="openReview(${r.request_id}, 'approve', '${escapeHtml(r.user_name || r.username)}', '${escapeHtml(r.vacation_type_name)}', '${formatDate(r.start_date)} ~ ${formatDate(r.end_date)}', ${r.days_used})" class="px-3 py-1.5 bg-green-500 text-white rounded text-sm hover:bg-green-600 mr-1">승인</button>
<button onclick="openReview(${r.request_id}, 'reject', '${escapeHtml(r.user_name || r.username)}', '${escapeHtml(r.vacation_type_name)}', '${formatDate(r.start_date)} ~ ${formatDate(r.end_date)}', ${r.days_used})" class="px-3 py-1.5 bg-red-500 text-white rounded text-sm hover:bg-red-600">반려</button>
</td>
</tr>
`).join('');
} catch (err) {
showToast(err.message, 'error');
}
}
async function loadAllRequests() {
const status = document.getElementById('filterAllStatus').value;
const deptId = document.getElementById('filterAllDept').value;
let params = [];
if (status) params.push('status=' + status);
if (deptId) params.push('department_id=' + deptId);
const qs = params.length > 0 ? '?' + params.join('&') : '';
try {
const res = await api('/vacation/requests' + qs);
const data = res.data;
const tbody = document.getElementById('allRequestsBody');
// 부서 필터 옵션 갱신
const deptSel = document.getElementById('filterAllDept');
const currentDept = deptSel.value;
const depts = [...new Set(data.map(r => r.department_name).filter(Boolean))].sort();
deptSel.innerHTML = '<option value="">전체</option>';
depts.forEach(d => {
deptSel.innerHTML += `<option value="" ${d === currentDept ? 'selected' : ''}>${escapeHtml(d)}</option>`;
});
if (data.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="empty-state">내역이 없습니다</td></tr>';
return;
}
tbody.innerHTML = data.map(r => `
<tr>
<td class="font-medium">${escapeHtml(r.user_name || r.username)}</td>
<td class="hide-mobile text-gray-500 text-sm">${escapeHtml(r.department_name || '-')}</td>
<td>${escapeHtml(r.vacation_type_name)}</td>
<td class="whitespace-nowrap">${formatDate(r.start_date)}${r.start_date !== r.end_date ? ' ~ ' + formatDate(r.end_date) : ''}</td>
<td class="text-center">${r.days_used}</td>
<td>${statusBadge(r.status)}</td>
<td class="hide-mobile text-gray-500 text-sm">${escapeHtml(r.reviewer_name || '-')}</td>
<td class="text-right hide-mobile text-gray-500 text-xs">${formatDate(r.created_at)}</td>
</tr>
`).join('');
} catch (err) {
showToast(err.message, 'error');
}
}
async function loadAllBalances() {
try {
const year = new Date().getFullYear();
const res = await api('/vacation/balance/all?year=' + year);
const data = res.data;
const tbody = document.getElementById('balancesBody');
if (data.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">배정된 잔여일이 없습니다</td></tr>';
return;
}
tbody.innerHTML = data.map(b => `
<tr>
<td class="font-medium">${escapeHtml(b.user_name || b.username)}</td>
<td>${escapeHtml(b.type_name)}</td>
<td class="text-center">${b.total_days}</td>
<td class="text-center">${b.used_days}</td>
<td class="text-center font-bold ${parseFloat(b.remaining_days) <= 0 ? 'text-red-500' : 'text-purple-600'}">${b.remaining_days}</td>
<td class="hide-mobile text-gray-500 text-sm max-w-[200px] truncate">${escapeHtml(b.notes || '-')}</td>
</tr>
`).join('');
} catch (err) {
showToast(err.message, 'error');
}
}
function switchTab(tab) {
const tabs = ['pending', 'all', 'balance'];
tabs.forEach(t => {
const panel = document.getElementById('panel' + t.charAt(0).toUpperCase() + t.slice(1));
const btn = document.getElementById('tab' + t.charAt(0).toUpperCase() + t.slice(1));
if (t === tab) {
panel.classList.remove('hidden');
btn.className = 'flex-1 py-2.5 text-sm font-medium rounded-lg transition-colors bg-purple-600 text-white';
} else {
panel.classList.add('hidden');
btn.className = 'flex-1 py-2.5 text-sm font-medium rounded-lg transition-colors text-gray-600 hover:bg-gray-100';
}
});
}
function openReview(id, action, userName, typeName, dateRange, days) {
reviewRequestId = id;
reviewAction = action;
const isApprove = action === 'approve';
document.getElementById('reviewModalTitle').textContent = isApprove ? '휴가 승인' : '휴가 반려';
document.getElementById('reviewDetail').innerHTML = `
<div class="bg-gray-50 rounded-lg p-3 space-y-1">
<div><span class="text-gray-500">신청자:</span> <strong>${userName}</strong></div>
<div><span class="text-gray-500">유형:</span> ${typeName}</div>
<div><span class="text-gray-500">기간:</span> ${dateRange} (${days}일)</div>
</div>
`;
const btn = document.getElementById('reviewSubmitBtn');
btn.className = `px-6 py-2 text-white rounded-lg text-sm font-medium ${isApprove ? 'bg-green-600 hover:bg-green-700' : 'bg-red-600 hover:bg-red-700'}`;
btn.textContent = isApprove ? '승인' : '반려';
document.getElementById('reviewNote').value = '';
document.getElementById('reviewModal').classList.remove('hidden');
}
function closeReview() { document.getElementById('reviewModal').classList.add('hidden'); }
async function submitReview() {
const note = document.getElementById('reviewNote').value;
try {
await api(`/vacation/requests/${reviewRequestId}/${reviewAction}`, {
method: 'PATCH',
body: JSON.stringify({ review_note: note })
});
showToast(reviewAction === 'approve' ? '승인되었습니다' : '반려되었습니다');
closeReview();
loadPending();
} catch (err) {
showToast(err.message, 'error');
}
}
initApprovalPage();
</script>
</body>
</html>