feat(tksupport): 전체 휴가관리 대시보드 개편 — 연간 총괄 + 월간 캘린더 뷰

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-25 12:34:56 +09:00
parent 66db012754
commit 71289be375
4 changed files with 389 additions and 139 deletions

View File

@@ -46,6 +46,39 @@ const vacationDashboardController = {
console.error('휴가 대시보드 조회 오류:', error);
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다' });
}
},
async getYearlyOverview(req, res) {
try {
const year = parseInt(req.query.year) || new Date().getFullYear();
const [users, balances] = await Promise.all([
vacationDashboardModel.getYearlyOverview(year),
vacationDashboardModel.getBalances(year)
]);
res.json({ success: true, data: { users, balances } });
} catch (error) {
console.error('연간 총괄 조회 오류:', error);
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다' });
}
},
async getMonthlyDetail(req, res) {
try {
const year = parseInt(req.query.year) || new Date().getFullYear();
const month = parseInt(req.query.month);
const departmentId = parseInt(req.query.department_id) || 0;
if (!month || month < 1 || month > 12) {
return res.status(400).json({ success: false, error: '유효하지 않은 월입니다' });
}
const [records, holidays] = await Promise.all([
vacationDashboardModel.getMonthlyDetail(year, month, departmentId),
vacationDashboardModel.getHolidays(year, month)
]);
res.json({ success: true, data: { records, holidays } });
} catch (error) {
console.error('월간 상세 조회 오류:', error);
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다' });
}
}
};

View File

@@ -49,6 +49,70 @@ const vacationDashboardModel = {
const [rows] = await db.query(query, params);
return rows;
},
// View 1: 연간 총괄 — 전직원 월별 사용 합계
async getYearlyOverview(year) {
const db = getPool();
const [rows] = await db.query(`
SELECT
su.user_id, su.name, su.username,
COALESCE(d.department_id, 0) as department_id,
COALESCE(d.department_name, '미배정') as department_name,
MONTH(vr.start_date) as month,
SUM(vr.days_used) as total_days
FROM sso_users su
LEFT JOIN departments d ON su.department_id = d.department_id
LEFT JOIN sp_vacation_requests vr
ON su.user_id = vr.user_id AND vr.status = 'approved' AND YEAR(vr.start_date) = ?
WHERE su.is_active = 1 AND su.hire_date IS NOT NULL
GROUP BY su.user_id, MONTH(vr.start_date)
ORDER BY d.department_name, su.name
`, [year]);
return rows;
},
// View 1: 연간 부여/사용 잔액
async getBalances(year) {
const db = getPool();
const [rows] = await db.query(`
SELECT user_id, SUM(total_days) as granted, SUM(used_days) as used
FROM sp_vacation_balances
WHERE year = ? AND balance_type = 'AUTO'
GROUP BY user_id
`, [year]);
return rows;
},
// View 2: 월간 상세 — 부서 전직원 일별 휴가 ($1=year ON, $2=month ON, $3=deptId WHERE, $4=deptId WHERE)
async getMonthlyDetail(year, month, departmentId) {
const db = getPool();
const [rows] = await db.query(`
SELECT
su.user_id, su.name, su.username,
vr.start_date, vr.end_date, vr.days_used,
vt.type_code, vt.type_name
FROM sso_users su
LEFT JOIN sp_vacation_requests vr
ON su.user_id = vr.user_id AND vr.status = 'approved'
AND YEAR(vr.start_date) = ? AND MONTH(vr.start_date) = ?
LEFT JOIN vacation_types vt ON vr.vacation_type_id = vt.id
WHERE su.is_active = 1 AND su.hire_date IS NOT NULL
AND (su.department_id = ? OR (? = 0 AND su.department_id IS NULL))
ORDER BY su.name, vr.start_date
`, [year, month, departmentId, departmentId]);
return rows;
},
// View 2: 공휴일 표시용
async getHolidays(year, month) {
const db = getPool();
const [rows] = await db.query(`
SELECT holiday_date, holiday_name
FROM company_holidays
WHERE YEAR(holiday_date) = ? AND MONTH(holiday_date) = ?
`, [year, month]);
return rows;
}
};

View File

@@ -6,5 +6,7 @@ const ctrl = require('../controllers/vacationDashboardController');
router.use(requireAuth);
router.get('/', requireSupportTeam, ctrl.getDashboard);
router.get('/yearly-overview', requireSupportTeam, ctrl.getYearlyOverview);
router.get('/monthly-detail', requireSupportTeam, ctrl.getMonthlyDetail);
module.exports = router;

View File

@@ -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();