카카오톡 인앱 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>
412 lines
23 KiB
HTML
412 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">
|
|
<style>
|
|
.cal-cell { width: 28px; height: 28px; font-size: 11px; display: flex; align-items: center; justify-content: center; border-radius: 4px; flex-shrink: 0; cursor: default; }
|
|
.cal-cell.weekend { background: #fef2f2; color: #f87171; }
|
|
.cal-cell.holiday { background: #fef2f2; color: #f87171; }
|
|
.yearly-table th, .yearly-table td { padding: 6px 8px; font-size: 13px; white-space: nowrap; }
|
|
.yearly-table td.clickable { cursor: pointer; }
|
|
.yearly-table td.clickable:hover { background: #f3f4f6; }
|
|
</style>
|
|
</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="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-5">
|
|
<div class="stat-card">
|
|
<div class="stat-value text-purple-600" id="statTotal">-</div>
|
|
<div class="stat-label">전체 직원</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value text-blue-600" id="statAvgRemaining">-</div>
|
|
<div class="stat-label">평균 잔여일</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value text-red-600" id="statLowBalance">-</div>
|
|
<div class="stat-label">소진 임박</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ===== View 1: 연간 총괄 ===== -->
|
|
<div id="view1Section">
|
|
<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="yearSelect" class="input-field px-3 py-2 rounded-lg text-sm"></select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">부서</label>
|
|
<select id="deptFilter" class="input-field px-3 py-2 rounded-lg text-sm">
|
|
<option value="">전체</option>
|
|
</select>
|
|
</div>
|
|
<button onclick="loadAll()" 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>
|
|
<h2 class="text-base font-semibold text-gray-800 mb-3"><i class="fas fa-calendar-alt text-purple-500 mr-2"></i>연간 사용현황</h2>
|
|
<div class="overflow-x-auto">
|
|
<table class="yearly-table w-full border-collapse">
|
|
<thead>
|
|
<tr class="bg-gray-50 border-b-2 border-gray-200">
|
|
<th class="text-left">부서</th>
|
|
<th class="text-left">이름</th>
|
|
<th class="text-center">1월</th><th class="text-center">2월</th><th class="text-center">3월</th>
|
|
<th class="text-center">4월</th><th class="text-center">5월</th><th class="text-center">6월</th>
|
|
<th class="text-center">7월</th><th class="text-center">8월</th><th class="text-center">9월</th>
|
|
<th class="text-center">10월</th><th class="text-center">11월</th><th class="text-center">12월</th>
|
|
<th class="text-center">전체</th>
|
|
<th class="text-center">사용</th>
|
|
<th class="text-center">잔여</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="yearlyBody">
|
|
<tr><td colspan="17" class="text-center text-gray-400 py-8">로딩 중...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ===== View 2: 월간 상세 ===== -->
|
|
<div id="view2Section" class="hidden">
|
|
<div class="bg-white rounded-xl shadow-sm p-5 mb-5">
|
|
<div class="flex flex-wrap items-end gap-3 mb-4">
|
|
<button onclick="showView1()" class="px-3 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm hover:bg-gray-200">
|
|
<i class="fas fa-arrow-left mr-1"></i>연간 총괄
|
|
</button>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">연도</label>
|
|
<select id="v2YearSelect" class="input-field px-3 py-2 rounded-lg text-sm"></select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">월</label>
|
|
<select id="v2MonthSelect" class="input-field px-3 py-2 rounded-lg text-sm"></select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">부서</label>
|
|
<select id="v2DeptFilter" class="input-field px-3 py-2 rounded-lg text-sm">
|
|
<option value="">선택</option>
|
|
</select>
|
|
</div>
|
|
<button onclick="loadMonthlyDetail()" 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="flex flex-wrap gap-2 mb-4 text-xs">
|
|
<span class="px-2 py-1 rounded bg-red-50 text-red-400">휴일</span>
|
|
<span class="px-2 py-1 rounded bg-blue-100 text-blue-800">연 연차</span>
|
|
<span class="px-2 py-1 rounded bg-green-100 text-green-800">반 반차</span>
|
|
<span class="px-2 py-1 rounded bg-teal-100 text-teal-800">반반 반반차</span>
|
|
<span class="px-2 py-1 rounded bg-amber-100 text-amber-800">조 조퇴</span>
|
|
<span class="px-2 py-1 rounded bg-indigo-100 text-indigo-800">유 유급</span>
|
|
<span class="px-2 py-1 rounded bg-purple-100 text-purple-800">특 특별</span>
|
|
<span class="px-2 py-1 rounded bg-orange-100 text-orange-800">병 병가</span>
|
|
</div>
|
|
<h2 class="text-base font-semibold text-gray-800 mb-3" id="v2Title"><i class="fas fa-calendar-day text-purple-500 mr-2"></i>월간 상세</h2>
|
|
<div id="calendarContainer" class="space-y-4">
|
|
<div class="text-center text-gray-400 py-8">부서를 선택하세요</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="/static/js/sso-relay.js?v=20260401"></script>
|
|
<script src="/static/js/tksupport-core.js?v=2026040101"></script>
|
|
<script>
|
|
const TYPE_COLOR = {
|
|
ANNUAL_FULL: { bg: 'bg-blue-100', text: 'text-blue-800', label: '연' },
|
|
ANNUAL_HALF: { bg: 'bg-green-100', text: 'text-green-800', label: '반' },
|
|
ANNUAL_QUARTER: { bg: 'bg-teal-100', text: 'text-teal-800', label: '반반' },
|
|
EARLY_LEAVE: { bg: 'bg-amber-100', text: 'text-amber-800', label: '조' },
|
|
PAID: { bg: 'bg-indigo-100', text: 'text-indigo-800', label: '유' },
|
|
SPECIAL: { bg: 'bg-purple-100', text: 'text-purple-800', label: '특' },
|
|
SICK: { bg: 'bg-orange-100', text: 'text-orange-800', label: '병' },
|
|
LONG_SERVICE: { bg: 'bg-purple-100', text: 'text-purple-800', label: '특' }
|
|
};
|
|
const DEFAULT_TYPE = { bg: 'bg-gray-100', text: 'text-gray-800', label: '?' };
|
|
|
|
let cachedDepts = [];
|
|
|
|
async function initPage() {
|
|
if (!initAuth()) return;
|
|
// 권한은 API requirePage에서 체크 — 403 시 loadAll에서 에러 표시
|
|
const thisYear = new Date().getFullYear();
|
|
[document.getElementById('yearSelect'), document.getElementById('v2YearSelect')].forEach(sel => {
|
|
for (let y = thisYear + 1; y >= thisYear - 2; y--)
|
|
sel.innerHTML += `<option value="${y}" ${y === thisYear ? 'selected' : ''}>${y}년</option>`;
|
|
});
|
|
const mSel = document.getElementById('v2MonthSelect');
|
|
for (let m = 1; m <= 12; m++) mSel.innerHTML += `<option value="${m}" ${m === new Date().getMonth() + 1 ? 'selected' : ''}>${m}월</option>`;
|
|
loadAll();
|
|
}
|
|
|
|
async function loadAll() {
|
|
const year = document.getElementById('yearSelect').value;
|
|
try {
|
|
const [dashRes, yearlyRes] = await Promise.all([
|
|
api('/vacation/dashboard?year=' + year),
|
|
api('/vacation/dashboard/yearly-overview?year=' + year)
|
|
]);
|
|
renderSummaryCards(dashRes.data.summary);
|
|
populateDeptFilters(dashRes.data.summary);
|
|
renderYearlyTable(yearlyRes.data);
|
|
} catch (err) { showToast(err.message, 'error'); }
|
|
}
|
|
|
|
function renderSummaryCards(summary) {
|
|
let total = 0, totalAvg = 0, totalLow = 0, avgCount = 0;
|
|
summary.forEach(s => {
|
|
total += parseInt(s.employee_count || 0);
|
|
if (s.avg_remaining !== null) { totalAvg += parseFloat(s.avg_remaining) * parseInt(s.employee_count); avgCount += parseInt(s.employee_count); }
|
|
totalLow += parseInt(s.low_balance_count || 0);
|
|
});
|
|
document.getElementById('statTotal').textContent = total;
|
|
document.getElementById('statAvgRemaining').textContent = avgCount > 0 ? (totalAvg / avgCount).toFixed(1) : '-';
|
|
document.getElementById('statLowBalance').textContent = totalLow;
|
|
}
|
|
|
|
function populateDeptFilters(summary) {
|
|
cachedDepts = summary.map(s => ({ id: s.department_id, name: s.department_name }));
|
|
[document.getElementById('deptFilter'), document.getElementById('v2DeptFilter')].forEach((sel, i) => {
|
|
const val = sel.value;
|
|
sel.innerHTML = i === 0 ? '<option value="">전체</option>' : '<option value="">선택</option>';
|
|
cachedDepts.forEach(d => { sel.innerHTML += `<option value="${d.id || ''}" ${String(d.id) === val ? 'selected' : ''}>${escapeHtml(d.name)}</option>`; });
|
|
});
|
|
}
|
|
|
|
function renderYearlyTable(data) {
|
|
const { users: rows, balances: balRows, companyDeductions: deductions } = data;
|
|
const balMap = {};
|
|
balRows.forEach(b => { balMap[b.user_id] = { granted: parseFloat(b.granted || 0), used: parseFloat(b.used || 0) }; });
|
|
|
|
// 전사 차감 월별 목록 (holiday_date 포함)
|
|
const deductionsByMonth = {};
|
|
(deductions || []).forEach(d => {
|
|
if (!deductionsByMonth[d.month]) deductionsByMonth[d.month] = [];
|
|
deductionsByMonth[d.month].push(d.holiday_date);
|
|
});
|
|
|
|
// 직원별 월 데이터 병합
|
|
const empMap = {};
|
|
rows.forEach(r => {
|
|
if (!empMap[r.user_id]) {
|
|
empMap[r.user_id] = { user_id: r.user_id, name: r.name, username: r.username, hire_date: r.hire_date, department_id: r.department_id, department_name: r.department_name, months: {} };
|
|
}
|
|
if (r.month !== null) empMap[r.user_id].months[r.month] = parseFloat(r.total_days);
|
|
});
|
|
|
|
// 전사 차감분 합산 (hire_date <= holiday_date 조건)
|
|
Object.values(empMap).forEach(emp => {
|
|
Object.entries(deductionsByMonth).forEach(([m, dates]) => {
|
|
dates.forEach(hDate => {
|
|
if (emp.hire_date && emp.hire_date.substring(0, 10) <= hDate.substring(0, 10)) {
|
|
emp.months[m] = (emp.months[m] || 0) + 1;
|
|
}
|
|
});
|
|
});
|
|
});
|
|
const employees = Object.values(empMap);
|
|
|
|
// 부서 필터
|
|
const filterDept = document.getElementById('deptFilter').value;
|
|
const filtered = filterDept ? employees.filter(e => String(e.department_id) === filterDept) : employees;
|
|
|
|
// 부서별 그룹핑
|
|
const deptGroups = {};
|
|
filtered.forEach(e => {
|
|
const key = e.department_id;
|
|
if (!deptGroups[key]) deptGroups[key] = { name: e.department_name, employees: [] };
|
|
deptGroups[key].employees.push(e);
|
|
});
|
|
|
|
const tbody = document.getElementById('yearlyBody');
|
|
if (filtered.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="17" class="text-center text-gray-400 py-8">데이터가 없습니다</td></tr>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
let deptIdx = 0;
|
|
Object.entries(deptGroups).forEach(([deptId, group]) => {
|
|
group.employees.forEach((emp, idx) => {
|
|
const bal = balMap[emp.user_id] || { granted: 0, used: 0 };
|
|
const remaining = bal.granted - bal.used;
|
|
const remainClass = remaining <= 3 ? 'text-orange-600 font-bold' : 'text-gray-800';
|
|
const zebraClass = idx % 2 === 1 ? ' bg-gray-50' : '';
|
|
const deptBorder = idx === 0 && deptIdx > 0 ? ' border-t-2 border-gray-300' : ' border-b border-gray-100';
|
|
html += `<tr class="${deptBorder}${zebraClass}">`;
|
|
if (idx === 0) {
|
|
html += `<td class="font-medium text-gray-700 align-top" rowspan="${group.employees.length}">${escapeHtml(group.name)}</td>`;
|
|
}
|
|
html += `<td class="clickable font-medium text-purple-700 hover:underline" onclick="showMonthlyDetail(${deptId}, ${new Date().getMonth() + 1})">${escapeHtml(emp.name || emp.username)}</td>`;
|
|
for (let m = 1; m <= 12; m++) {
|
|
const val = emp.months[m];
|
|
if (val) {
|
|
const display = val % 1 === 0 ? val.toFixed(0) : val.toFixed(1);
|
|
html += `<td class="text-center clickable text-blue-600" onclick="showMonthlyDetail(${deptId}, ${m})">${display}</td>`;
|
|
} else {
|
|
html += '<td class="text-center text-gray-300">-</td>';
|
|
}
|
|
}
|
|
html += `<td class="text-center text-gray-600">${bal.granted % 1 === 0 ? bal.granted : bal.granted.toFixed(1)}</td>`;
|
|
html += `<td class="text-center text-gray-600">${bal.used % 1 === 0 ? bal.used : bal.used.toFixed(1)}</td>`;
|
|
html += `<td class="text-center ${remainClass}">${remaining % 1 === 0 ? remaining : remaining.toFixed(1)}</td>`;
|
|
html += '</tr>';
|
|
});
|
|
deptIdx++;
|
|
});
|
|
tbody.innerHTML = html;
|
|
}
|
|
|
|
// ===== View 2 =====
|
|
function showMonthlyDetail(deptId, month) {
|
|
document.getElementById('view1Section').classList.add('hidden');
|
|
document.getElementById('view2Section').classList.remove('hidden');
|
|
document.getElementById('v2YearSelect').value = document.getElementById('yearSelect').value;
|
|
document.getElementById('v2MonthSelect').value = month;
|
|
document.getElementById('v2DeptFilter').value = deptId || '';
|
|
loadMonthlyDetail();
|
|
}
|
|
|
|
function showView1() {
|
|
document.getElementById('view2Section').classList.add('hidden');
|
|
document.getElementById('view1Section').classList.remove('hidden');
|
|
}
|
|
|
|
async function loadMonthlyDetail() {
|
|
const year = document.getElementById('v2YearSelect').value;
|
|
const month = document.getElementById('v2MonthSelect').value;
|
|
const deptId = document.getElementById('v2DeptFilter').value;
|
|
if (!deptId) { document.getElementById('calendarContainer').innerHTML = '<div class="text-center text-gray-400 py-8">부서를 선택하세요</div>'; return; }
|
|
|
|
const deptName = cachedDepts.find(d => String(d.id) === deptId)?.name || '';
|
|
document.getElementById('v2Title').innerHTML = `<i class="fas fa-calendar-day text-purple-500 mr-2"></i>${escapeHtml(deptName)} — ${year}년 ${month}월`;
|
|
|
|
try {
|
|
const res = await api(`/vacation/dashboard/monthly-detail?year=${year}&month=${month}&department_id=${deptId}`);
|
|
renderCalendarGrid(res.data, parseInt(year), parseInt(month));
|
|
} catch (err) { showToast(err.message, 'error'); }
|
|
}
|
|
|
|
function renderCalendarGrid(data, year, month) {
|
|
const { records, holidays } = data;
|
|
const container = document.getElementById('calendarContainer');
|
|
const daysInMonth = new Date(year, month, 0).getDate();
|
|
const holidaySet = {};
|
|
const deductionSet = {};
|
|
holidays.forEach(h => {
|
|
const d = new Date(h.holiday_date).getDate();
|
|
if (h.holiday_type === 'ANNUAL_DEDUCT' && h.deduction_applied_at) {
|
|
deductionSet[d] = h.holiday_name;
|
|
} else {
|
|
holidaySet[d] = h.holiday_name;
|
|
}
|
|
});
|
|
|
|
// user_id별 그룹핑
|
|
const userMap = {};
|
|
records.forEach(r => {
|
|
if (!userMap[r.user_id]) userMap[r.user_id] = { name: r.name, username: r.username, records: [] };
|
|
if (r.start_date) userMap[r.user_id].records.push(r);
|
|
});
|
|
|
|
if (Object.keys(userMap).length === 0) {
|
|
container.innerHTML = '<div class="text-center text-gray-400 py-8">직원이 없습니다</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
Object.values(userMap).forEach(user => {
|
|
// 날짜별 휴가 매핑
|
|
const dayVacation = {};
|
|
let monthTotal = 0;
|
|
const longLeaves = [];
|
|
user.records.forEach(r => {
|
|
const start = new Date(r.start_date);
|
|
const end = new Date(r.end_date);
|
|
const startDay = start.getMonth() + 1 === month ? start.getDate() : 1;
|
|
const endDay = end.getMonth() + 1 === month ? end.getDate() : daysInMonth;
|
|
const spanDays = endDay - startDay + 1;
|
|
for (let d = startDay; d <= endDay; d++) dayVacation[d] = r.type_code;
|
|
monthTotal += parseFloat(r.days_used);
|
|
if (spanDays >= 5) {
|
|
const tc = TYPE_COLOR[r.type_code] || DEFAULT_TYPE;
|
|
longLeaves.push(`${r.type_name || tc.label} ${start.getMonth() + 1}/${start.getDate()}~${end.getMonth() + 1}/${end.getDate()}`);
|
|
}
|
|
});
|
|
|
|
html += `<div class="border rounded-lg p-3">`;
|
|
html += `<div class="flex justify-between items-center mb-2">`;
|
|
html += `<span class="font-medium text-gray-800 text-sm">${escapeHtml(user.name || user.username)}</span>`;
|
|
html += `<span class="text-xs text-gray-500">합계 <strong class="text-purple-600">${monthTotal % 1 === 0 ? monthTotal : monthTotal.toFixed(1)}</strong>일</span>`;
|
|
html += `</div>`;
|
|
html += `<div class="flex gap-0.5 flex-wrap">`;
|
|
for (let d = 1; d <= daysInMonth; d++) {
|
|
const date = new Date(year, month - 1, d);
|
|
const dow = date.getDay();
|
|
const isWeekend = dow === 0 || dow === 6;
|
|
const isHoliday = holidaySet[d];
|
|
const vacType = dayVacation[d];
|
|
|
|
const isDeduction = deductionSet[d];
|
|
if (vacType) {
|
|
const tc = TYPE_COLOR[vacType] || DEFAULT_TYPE;
|
|
html += `<div class="cal-cell ${tc.bg} ${tc.text} font-medium" title="${d}일">${tc.label}</div>`;
|
|
} else if (isDeduction) {
|
|
const tc = TYPE_COLOR.PAID;
|
|
html += `<div class="cal-cell ${tc.bg} ${tc.text} font-medium" title="${isDeduction}">${tc.label}</div>`;
|
|
} else if (isWeekend || isHoliday) {
|
|
html += `<div class="cal-cell weekend" title="${isHoliday || (dow === 0 ? '일' : '토')}">${d}</div>`;
|
|
} else {
|
|
html += `<div class="cal-cell text-gray-400">${d}</div>`;
|
|
}
|
|
}
|
|
html += `</div>`;
|
|
if (longLeaves.length > 0) {
|
|
html += `<div class="mt-1.5 text-xs text-gray-500">※ ${longLeaves.join(', ')}</div>`;
|
|
}
|
|
html += `</div>`;
|
|
});
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
initPage();
|
|
</script>
|
|
</body>
|
|
</html>
|