feat(tksupport): 전체 휴가관리 대시보드 개편 — 연간 총괄 + 월간 캘린더 뷰
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,39 @@ const vacationDashboardController = {
|
|||||||
console.error('휴가 대시보드 조회 오류:', error);
|
console.error('휴가 대시보드 조회 오류:', error);
|
||||||
res.status(500).json({ success: false, 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: '서버 오류가 발생했습니다' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,70 @@ const vacationDashboardModel = {
|
|||||||
|
|
||||||
const [rows] = await db.query(query, params);
|
const [rows] = await db.query(query, params);
|
||||||
return rows;
|
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,5 +6,7 @@ const ctrl = require('../controllers/vacationDashboardController');
|
|||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
|
||||||
router.get('/', requireSupportTeam, ctrl.getDashboard);
|
router.get('/', requireSupportTeam, ctrl.getDashboard);
|
||||||
|
router.get('/yearly-overview', requireSupportTeam, ctrl.getYearlyOverview);
|
||||||
|
router.get('/monthly-detail', requireSupportTeam, ctrl.getMonthlyDetail);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -7,6 +7,14 @@
|
|||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<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="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">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="bg-purple-700 text-white sticky top-0 z-50">
|
<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>
|
<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-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="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-5">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
@@ -75,33 +57,88 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 부서별 현황 -->
|
<!-- ===== View 1: 연간 총괄 ===== -->
|
||||||
<div class="bg-white rounded-xl shadow-sm p-5 mb-5">
|
<div id="view1Section">
|
||||||
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-sitemap text-purple-500 mr-2"></i>부서별 현황</h2>
|
<div class="bg-white rounded-xl shadow-sm p-5 mb-5">
|
||||||
<div id="deptSummary" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
<div class="flex flex-wrap items-end gap-3 mb-4">
|
||||||
<div class="text-center text-gray-400 py-4 col-span-full">로딩 중...</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- 직원별 상세 -->
|
<!-- ===== View 2: 월간 상세 ===== -->
|
||||||
<div class="bg-white rounded-xl shadow-sm p-5">
|
<div id="view2Section" class="hidden">
|
||||||
<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="bg-white rounded-xl shadow-sm p-5 mb-5">
|
||||||
<div class="overflow-x-auto">
|
<div class="flex flex-wrap items-end gap-3 mb-4">
|
||||||
<table class="data-table">
|
<button onclick="showView1()" class="px-3 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm hover:bg-gray-200">
|
||||||
<thead>
|
<i class="fas fa-arrow-left mr-1"></i>연간 총괄
|
||||||
<tr>
|
</button>
|
||||||
<th>이름</th>
|
<div>
|
||||||
<th>부서</th>
|
<label class="block text-xs font-medium text-gray-600 mb-1">연도</label>
|
||||||
<th class="text-center">기본연차</th>
|
<select id="v2YearSelect" class="input-field px-3 py-2 rounded-lg text-sm"></select>
|
||||||
<th class="text-center hide-mobile">이월</th>
|
</div>
|
||||||
<th class="text-center hide-mobile">장기근속</th>
|
<div>
|
||||||
<th class="text-center">총 잔여</th>
|
<label class="block text-xs font-medium text-gray-600 mb-1">월</label>
|
||||||
</tr>
|
<select id="v2MonthSelect" class="input-field px-3 py-2 rounded-lg text-sm"></select>
|
||||||
</thead>
|
</div>
|
||||||
<tbody id="employeesBody">
|
<div>
|
||||||
<tr><td colspan="6" class="text-center text-gray-400 py-8">로딩 중...</td></tr>
|
<label class="block text-xs font-medium text-gray-600 mb-1">부서</label>
|
||||||
</tbody>
|
<select id="v2DeptFilter" class="input-field px-3 py-2 rounded-lg text-sm">
|
||||||
</table>
|
<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>
|
</div>
|
||||||
@@ -110,119 +147,233 @@
|
|||||||
|
|
||||||
<script src="/static/js/tksupport-core.js?v=2026032301"></script>
|
<script src="/static/js/tksupport-core.js?v=2026032301"></script>
|
||||||
<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() {
|
async function initPage() {
|
||||||
if (!initAuth()) return;
|
if (!initAuth()) return;
|
||||||
if (!currentUser || !['support_team','admin','system'].includes(currentUser.role)) {
|
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>';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sel = document.getElementById('yearSelect');
|
|
||||||
const thisYear = new Date().getFullYear();
|
const thisYear = new Date().getFullYear();
|
||||||
for (let y = thisYear + 1; y >= thisYear - 2; y--) {
|
[document.getElementById('yearSelect'), document.getElementById('v2YearSelect')].forEach(sel => {
|
||||||
sel.innerHTML += `<option value="${y}" ${y === thisYear ? 'selected' : ''}>${y}년</option>`;
|
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>`;
|
|
||||||
});
|
});
|
||||||
|
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) {
|
async function loadAll() {
|
||||||
let totalEmployees = 0, totalAvg = 0, totalLow = 0, avgCount = 0;
|
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 => {
|
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); }
|
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);
|
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('statAvgRemaining').textContent = avgCount > 0 ? (totalAvg / avgCount).toFixed(1) : '-';
|
||||||
document.getElementById('statLowBalance').textContent = totalLow;
|
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) {
|
function populateDeptFilters(summary) {
|
||||||
const tbody = document.getElementById('employeesBody');
|
cachedDepts = summary.map(s => ({ id: s.department_id, name: s.department_name }));
|
||||||
if (employees.length === 0) {
|
[document.getElementById('deptFilter'), document.getElementById('v2DeptFilter')].forEach((sel, i) => {
|
||||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">직원 데이터가 없습니다</td></tr>';
|
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;
|
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}` : '-';
|
let html = '';
|
||||||
const carryText = carry ? `${carry.remaining_days}` : '-';
|
Object.entries(deptGroups).forEach(([deptId, group]) => {
|
||||||
const longText = longSvc && parseFloat(longSvc.total_days) > 0 ? `${longSvc.remaining_days}` : '-';
|
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;
|
// ===== View 2 =====
|
||||||
emp.balances.forEach(b => { totalRemaining += parseFloat(b.remaining_days || 0); });
|
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>
|
async function loadMonthlyDetail() {
|
||||||
<td class="font-medium">${escapeHtml(emp.name || emp.username)}</td>
|
const year = document.getElementById('v2YearSelect').value;
|
||||||
<td class="text-gray-600 text-sm">${escapeHtml(emp.department_name)}</td>
|
const month = document.getElementById('v2MonthSelect').value;
|
||||||
<td class="text-center">${autoText}</td>
|
const deptId = document.getElementById('v2DeptFilter').value;
|
||||||
<td class="text-center hide-mobile">${carryText}</td>
|
if (!deptId) { document.getElementById('calendarContainer').innerHTML = '<div class="text-center text-gray-400 py-8">부서를 선택하세요</div>'; return; }
|
||||||
<td class="text-center hide-mobile">${longText}</td>
|
|
||||||
<td class="text-center font-bold ${isLow ? 'text-red-600' : 'text-purple-600'}">
|
const deptName = cachedDepts.find(d => String(d.id) === deptId)?.name || '';
|
||||||
${totalRemaining % 1 === 0 ? totalRemaining : totalRemaining.toFixed(1)}
|
document.getElementById('v2Title').innerHTML = `<i class="fas fa-calendar-day text-purple-500 mr-2"></i>${escapeHtml(deptName)} — ${year}년 ${month}월`;
|
||||||
${isLow ? ' <i class="fas fa-exclamation-triangle text-red-400 text-xs"></i>' : ''}
|
|
||||||
</td>
|
try {
|
||||||
</tr>`;
|
const res = await api(`/vacation/dashboard/monthly-detail?year=${year}&month=${month}&department_id=${deptId}`);
|
||||||
}).join('');
|
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();
|
initPage();
|
||||||
|
|||||||
Reference in New Issue
Block a user