|
|
|
|
@@ -7,6 +7,14 @@
|
|
|
|
|
<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">
|
|
|
|
|
@@ -33,32 +41,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">
|
|
|
|
|
<div class="flex flex-wrap items-end gap-3">
|
|
|
|
|
<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>
|
|
|
|
|
<div>
|
|
|
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">이름</label>
|
|
|
|
|
<input type="text" id="nameSearch" class="input-field px-3 py-2 rounded-lg text-sm" placeholder="이름 검색">
|
|
|
|
|
</div>
|
|
|
|
|
<button onclick="loadDashboard()" 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>
|
|
|
|
|
<button disabled class="px-4 py-2 bg-gray-300 text-gray-500 rounded-lg text-sm cursor-not-allowed" title="추후 지원 예정">
|
|
|
|
|
<i class="fas fa-file-excel mr-1"></i>엑셀
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 요약 카드 -->
|
|
|
|
|
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-5">
|
|
|
|
|
<div class="stat-card">
|
|
|
|
|
@@ -75,33 +57,88 @@
|
|
|
|
|
</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-sitemap text-purple-500 mr-2"></i>부서별 현황</h2>
|
|
|
|
|
<div id="deptSummary" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
|
|
|
<div class="text-center text-gray-400 py-4 col-span-full">로딩 중...</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>
|
|
|
|
|
|
|
|
|
|
<!-- 직원별 상세 -->
|
|
|
|
|
<div class="bg-white rounded-xl shadow-sm p-5">
|
|
|
|
|
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-users text-purple-500 mr-2"></i>직원별 상세</h2>
|
|
|
|
|
<div class="overflow-x-auto">
|
|
|
|
|
<table class="data-table">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>이름</th>
|
|
|
|
|
<th>부서</th>
|
|
|
|
|
<th class="text-center">기본연차</th>
|
|
|
|
|
<th class="text-center hide-mobile">이월</th>
|
|
|
|
|
<th class="text-center hide-mobile">장기근속</th>
|
|
|
|
|
<th class="text-center">총 잔여</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody id="employeesBody">
|
|
|
|
|
<tr><td colspan="6" class="text-center text-gray-400 py-8">로딩 중...</td></tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
<!-- ===== 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>
|
|
|
|
|
@@ -110,119 +147,233 @@
|
|
|
|
|
|
|
|
|
|
<script src="/static/js/tksupport-core.js?v=2026032301"></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;
|
|
|
|
|
if (!currentUser || !['support_team','admin','system'].includes(currentUser.role)) {
|
|
|
|
|
document.querySelector('.flex-1').innerHTML = '<div class="bg-white rounded-xl shadow-sm p-8 text-center text-gray-500"><i class="fas fa-lock text-4xl mb-3 block"></i>지원팀 이상 권한이 필요합니다</div>';
|
|
|
|
|
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>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loadDashboard();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadDashboard() {
|
|
|
|
|
const year = document.getElementById('yearSelect').value;
|
|
|
|
|
const deptId = document.getElementById('deptFilter').value;
|
|
|
|
|
const searchName = document.getElementById('nameSearch').value;
|
|
|
|
|
let url = '/vacation/dashboard?year=' + year;
|
|
|
|
|
if (deptId) url += '&department_id=' + deptId;
|
|
|
|
|
if (searchName) url += '&search_name=' + encodeURIComponent(searchName);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const res = await api(url);
|
|
|
|
|
const { summary, employees } = res.data;
|
|
|
|
|
renderSummary(summary);
|
|
|
|
|
renderEmployees(employees);
|
|
|
|
|
populateDeptFilter(summary);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
showToast(err.message, 'error');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function populateDeptFilter(summary) {
|
|
|
|
|
const sel = document.getElementById('deptFilter');
|
|
|
|
|
const currentVal = sel.value;
|
|
|
|
|
sel.innerHTML = '<option value="">전체</option>';
|
|
|
|
|
summary.forEach(s => {
|
|
|
|
|
sel.innerHTML += `<option value="${s.department_id || ''}" ${String(s.department_id) === currentVal ? 'selected' : ''}>${escapeHtml(s.department_name)}</option>`;
|
|
|
|
|
[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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderSummary(summary) {
|
|
|
|
|
let totalEmployees = 0, totalAvg = 0, totalLow = 0, avgCount = 0;
|
|
|
|
|
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 => {
|
|
|
|
|
totalEmployees += parseInt(s.employee_count || 0);
|
|
|
|
|
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 = totalEmployees;
|
|
|
|
|
document.getElementById('statTotal').textContent = total;
|
|
|
|
|
document.getElementById('statAvgRemaining').textContent = avgCount > 0 ? (totalAvg / avgCount).toFixed(1) : '-';
|
|
|
|
|
document.getElementById('statLowBalance').textContent = totalLow;
|
|
|
|
|
|
|
|
|
|
const container = document.getElementById('deptSummary');
|
|
|
|
|
if (summary.length === 0) {
|
|
|
|
|
container.innerHTML = '<div class="text-center text-gray-400 py-4 col-span-full">데이터가 없습니다</div>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
container.innerHTML = summary.map(s => `
|
|
|
|
|
<div class="border rounded-lg p-4">
|
|
|
|
|
<div class="font-medium text-gray-800 mb-2">${escapeHtml(s.department_name)}</div>
|
|
|
|
|
<div class="grid grid-cols-3 gap-2 text-center text-sm">
|
|
|
|
|
<div>
|
|
|
|
|
<div class="text-lg font-bold text-purple-600">${s.employee_count}</div>
|
|
|
|
|
<div class="text-xs text-gray-500">직원</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<div class="text-lg font-bold text-blue-600">${s.avg_remaining !== null ? parseFloat(s.avg_remaining).toFixed(1) : '-'}</div>
|
|
|
|
|
<div class="text-xs text-gray-500">평균잔여</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<div class="text-lg font-bold ${parseInt(s.low_balance_count) > 0 ? 'text-red-600' : 'text-green-600'}">${s.low_balance_count || 0}</div>
|
|
|
|
|
<div class="text-xs text-gray-500">소진임박</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`).join('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderEmployees(employees) {
|
|
|
|
|
const tbody = document.getElementById('employeesBody');
|
|
|
|
|
if (employees.length === 0) {
|
|
|
|
|
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">직원 데이터가 없습니다</td></tr>';
|
|
|
|
|
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 } = data;
|
|
|
|
|
const balMap = {};
|
|
|
|
|
balRows.forEach(b => { balMap[b.user_id] = { granted: parseFloat(b.granted || 0), used: parseFloat(b.used || 0) }; });
|
|
|
|
|
|
|
|
|
|
// 직원별 월 데이터 병합
|
|
|
|
|
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, 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);
|
|
|
|
|
});
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
tbody.innerHTML = employees.map(emp => {
|
|
|
|
|
const auto = emp.balances.find(b => b.balance_type === 'AUTO');
|
|
|
|
|
const carry = emp.balances.find(b => b.balance_type === 'CARRY_OVER');
|
|
|
|
|
const longSvc = emp.balances.find(b => b.balance_type === 'LONG_SERVICE');
|
|
|
|
|
|
|
|
|
|
const autoText = auto ? `${auto.used_days}/${auto.total_days}` : '-';
|
|
|
|
|
const carryText = carry ? `${carry.remaining_days}` : '-';
|
|
|
|
|
const longText = longSvc && parseFloat(longSvc.total_days) > 0 ? `${longSvc.remaining_days}` : '-';
|
|
|
|
|
let html = '';
|
|
|
|
|
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';
|
|
|
|
|
html += '<tr class="border-b border-gray-100">';
|
|
|
|
|
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>';
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
tbody.innerHTML = html;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let totalRemaining = 0;
|
|
|
|
|
emp.balances.forEach(b => { totalRemaining += parseFloat(b.remaining_days || 0); });
|
|
|
|
|
// ===== 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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isLow = auto && parseFloat(auto.total_days - auto.used_days) <= 2;
|
|
|
|
|
function showView1() {
|
|
|
|
|
document.getElementById('view2Section').classList.add('hidden');
|
|
|
|
|
document.getElementById('view1Section').classList.remove('hidden');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `<tr>
|
|
|
|
|
<td class="font-medium">${escapeHtml(emp.name || emp.username)}</td>
|
|
|
|
|
<td class="text-gray-600 text-sm">${escapeHtml(emp.department_name)}</td>
|
|
|
|
|
<td class="text-center">${autoText}</td>
|
|
|
|
|
<td class="text-center hide-mobile">${carryText}</td>
|
|
|
|
|
<td class="text-center hide-mobile">${longText}</td>
|
|
|
|
|
<td class="text-center font-bold ${isLow ? 'text-red-600' : 'text-purple-600'}">
|
|
|
|
|
${totalRemaining % 1 === 0 ? totalRemaining : totalRemaining.toFixed(1)}
|
|
|
|
|
${isLow ? ' <i class="fas fa-exclamation-triangle text-red-400 text-xs"></i>' : ''}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>`;
|
|
|
|
|
}).join('');
|
|
|
|
|
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 = {};
|
|
|
|
|
holidays.forEach(h => {
|
|
|
|
|
const d = new Date(h.holiday_date).getDate();
|
|
|
|
|
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];
|
|
|
|
|
|
|
|
|
|
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 (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();
|
|
|
|
|
|