feat(dashboard): 연차/연장근로 통합 + 연차 상세 모달
- 백엔드: type_code ANNUAL 매칭 실패 → 전체 합산으로 수정 details에 balance_type, expires_at 포함 - 프론트: 2열 카드 → 통합 리스트 (연차 탭 + 연장근로 행) - 연차 행 클릭 → 상세 모달 (이월/정기/장기/경조사 breakdown) 이월 소진/만료 isExpired() 적용 - 내 메뉴에서 "내 연차 정보" 자동 제거 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,15 +37,17 @@ const DashboardController = {
|
||||
const details = vacationRows.map(v => ({
|
||||
type_name: v.type_name,
|
||||
type_code: v.type_code,
|
||||
balance_type: v.balance_type || 'AUTO',
|
||||
expires_at: v.expires_at || null,
|
||||
total: parseFloat(v.total_days) || 0,
|
||||
used: parseFloat(v.used_days) || 0,
|
||||
remaining: parseFloat(v.remaining_days) || 0
|
||||
}));
|
||||
|
||||
const annualRow = vacationRows.find(v => v.type_code === 'ANNUAL');
|
||||
const totalDays = annualRow ? parseFloat(annualRow.total_days) || 0 : 0;
|
||||
const usedDays = annualRow ? parseFloat(annualRow.used_days) || 0 : 0;
|
||||
const remainingDays = annualRow ? parseFloat(annualRow.remaining_days) || 0 : 0;
|
||||
// 모든 balance_type 합산
|
||||
const totalDays = vacationRows.reduce((s, v) => s + (parseFloat(v.total_days) || 0), 0);
|
||||
const usedDays = vacationRows.reduce((s, v) => s + (parseFloat(v.used_days) || 0), 0);
|
||||
const remainingDays = totalDays - usedDays;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -24,21 +24,53 @@
|
||||
.pd-profile-name { font-size: 18px; font-weight: 700; }
|
||||
.pd-profile-sub { font-size: 13px; opacity: 0.8; margin-top: 2px; }
|
||||
|
||||
/* 현황 카드 */
|
||||
.pd-stats-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.pd-stat-card {
|
||||
background: rgba(255,255,255,0.15); border-radius: 12px; padding: 14px;
|
||||
backdrop-filter: blur(4px);
|
||||
/* 통합 정보 리스트 */
|
||||
.pd-info-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
.pd-info-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
background: rgba(255,255,255,0.12); border-radius: 10px; padding: 10px 12px;
|
||||
cursor: pointer; -webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.pd-stat-label { font-size: 11px; opacity: 0.8; margin-bottom: 4px; display: flex; align-items: center; gap: 4px; }
|
||||
.pd-stat-value { font-size: 22px; font-weight: 800; }
|
||||
.pd-stat-sub { font-size: 11px; opacity: 0.7; margin-top: 2px; }
|
||||
.pd-progress-bar { height: 5px; border-radius: 3px; background: rgba(255,255,255,0.2); margin-top: 8px; overflow: hidden; }
|
||||
.pd-progress-fill { height: 100%; border-radius: 3px; transition: width 0.6s ease; }
|
||||
.pd-info-row:active { background: rgba(255,255,255,0.18); }
|
||||
.pd-info-left { display: flex; align-items: center; gap: 8px; }
|
||||
.pd-info-icon { font-size: 14px; opacity: 0.8; width: 18px; text-align: center; }
|
||||
.pd-info-label { font-size: 12px; font-weight: 600; opacity: 0.9; }
|
||||
.pd-info-right { display: flex; align-items: center; gap: 6px; }
|
||||
.pd-info-value { font-size: 14px; font-weight: 700; }
|
||||
.pd-info-sub { font-size: 11px; opacity: 0.6; }
|
||||
.pd-info-arrow { font-size: 10px; opacity: 0.5; margin-left: 2px; }
|
||||
.pd-progress-bar { height: 4px; border-radius: 2px; background: rgba(255,255,255,0.2); overflow: hidden; }
|
||||
.pd-progress-fill { height: 100%; border-radius: 2px; transition: width 0.6s ease; }
|
||||
.pd-progress-green { background: #4ade80; }
|
||||
.pd-progress-yellow { background: #fbbf24; }
|
||||
.pd-progress-red { background: #f87171; }
|
||||
|
||||
/* 연차 상세 모달 */
|
||||
.pd-detail-modal {
|
||||
position: fixed; inset: 0; z-index: 100; display: flex; align-items: flex-end; justify-content: center;
|
||||
background: rgba(0,0,0,0.4); opacity: 0; pointer-events: none; transition: opacity 0.2s;
|
||||
}
|
||||
.pd-detail-modal.active { opacity: 1; pointer-events: auto; }
|
||||
.pd-detail-sheet {
|
||||
background: linear-gradient(135deg, #9a3412, #ea580c); color: white;
|
||||
border-radius: 16px 16px 0 0; width: 100%; max-width: 640px; padding: 20px;
|
||||
transform: translateY(100%); transition: transform 0.3s ease;
|
||||
}
|
||||
.pd-detail-modal.active .pd-detail-sheet { transform: translateY(0); }
|
||||
.pd-detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.pd-detail-title { font-size: 16px; font-weight: 700; }
|
||||
.pd-detail-close { background: none; border: none; color: white; opacity: 0.7; font-size: 18px; cursor: pointer; }
|
||||
.pd-detail-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.15); font-size: 13px;
|
||||
}
|
||||
.pd-detail-label { font-weight: 600; opacity: 0.9; }
|
||||
.pd-detail-value { text-align: right; opacity: 0.85; }
|
||||
.pd-detail-total {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 12px 0 0; font-size: 14px; font-weight: 700; margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 섹션 */
|
||||
.pd-section { margin-bottom: 20px; }
|
||||
.pd-section-title {
|
||||
|
||||
@@ -18,7 +18,6 @@ const PAGE_ICONS = {
|
||||
'inspection.work_status': 'fa-briefcase',
|
||||
'purchase.request': 'fa-shopping-cart',
|
||||
'purchase.analysis': 'fa-chart-line',
|
||||
'attendance.my_vacation_info': 'fa-info-circle',
|
||||
'attendance.monthly': 'fa-calendar',
|
||||
'attendance.vacation_request': 'fa-paper-plane',
|
||||
'attendance.vacation_management': 'fa-cog',
|
||||
@@ -34,6 +33,9 @@ const PAGE_ICONS = {
|
||||
'admin.attendance_report': 'fa-clipboard-check',
|
||||
};
|
||||
|
||||
// 내 메뉴에서 제외 (대시보드에서 직접 확인)
|
||||
const HIDDEN_PAGES = ['dashboard', 'attendance.my_vacation_info'];
|
||||
|
||||
const CATEGORY_COLORS = {
|
||||
'작업 관리': '#3b82f6',
|
||||
'공장 관리': '#f59e0b',
|
||||
@@ -43,13 +45,24 @@ const CATEGORY_COLORS = {
|
||||
};
|
||||
const DEFAULT_COLOR = '#06b6d4';
|
||||
|
||||
function isExpired(expiresAt) {
|
||||
if (!expiresAt) return false;
|
||||
const today = new Date(); today.setHours(0,0,0,0);
|
||||
const exp = new Date(expiresAt); exp.setHours(0,0,0,0);
|
||||
return today > exp;
|
||||
}
|
||||
|
||||
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
function fmtDays(n) { return n % 1 === 0 ? n.toString() : n.toFixed(1); }
|
||||
|
||||
let _dashboardData = null;
|
||||
|
||||
async function initDashboard() {
|
||||
showSkeleton();
|
||||
try {
|
||||
const result = await api('/dashboard/my-summary');
|
||||
if (!result.success) throw new Error(result.message || '데이터 로드 실패');
|
||||
_dashboardData = result.data;
|
||||
renderDashboard(result.data);
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
@@ -59,13 +72,12 @@ async function initDashboard() {
|
||||
function renderDashboard(data) {
|
||||
const { user, vacation, overtime, quick_access } = data;
|
||||
|
||||
// 프로필 카드
|
||||
const card = document.getElementById('profileCard');
|
||||
const initial = (user.worker_name || user.name || '?').charAt(0);
|
||||
const vacRemaining = vacation.remaining_days;
|
||||
const vacTotal = vacation.total_days;
|
||||
const vacUsed = vacation.used_days;
|
||||
const vacPct = vacTotal > 0 ? Math.round((vacUsed / vacTotal) * 100) : 0;
|
||||
const vacPct = vacTotal > 0 ? Math.round((vacUsed / Math.max(vacTotal, 1)) * 100) : 0;
|
||||
const vacColor = vacRemaining >= 5 ? 'green' : vacRemaining >= 3 ? 'yellow' : 'red';
|
||||
|
||||
const otHours = overtime.total_overtime_hours;
|
||||
@@ -82,40 +94,104 @@ function renderDashboard(data) {
|
||||
<div class="pd-profile-sub">${escHtml(user.job_type || '')}${user.job_type ? ' · ' : ''}${escHtml(user.department_name)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pd-stats-row">
|
||||
<div class="pd-stat-card">
|
||||
<div class="pd-stat-label"><i class="fas fa-umbrella-beach" style="font-size:12px"></i> 연차</div>
|
||||
${vacTotal > 0 ? `
|
||||
<div class="pd-stat-value">잔여 ${vacRemaining}일</div>
|
||||
<div class="pd-stat-sub">${vacTotal}일 중 ${vacUsed}일 사용</div>
|
||||
<div class="pd-progress-bar"><div class="pd-progress-fill pd-progress-${vacColor}" style="width:${vacPct}%"></div></div>
|
||||
` : `<div class="pd-stat-value" style="font-size:14px;opacity:0.7">연차 정보 미등록</div>`}
|
||||
<div class="pd-info-list">
|
||||
<div class="pd-info-row" onclick="openVacDetailModal()">
|
||||
<div class="pd-info-left">
|
||||
<i class="fas fa-umbrella-beach pd-info-icon"></i>
|
||||
<span class="pd-info-label">연차</span>
|
||||
</div>
|
||||
${vacTotal > 0 ? `
|
||||
<div class="pd-info-right">
|
||||
<span class="pd-info-value">잔여 <strong>${fmtDays(vacRemaining)}일</strong></span>
|
||||
<span class="pd-info-sub">/ ${fmtDays(vacTotal)}일</span>
|
||||
<i class="fas fa-chevron-right pd-info-arrow"></i>
|
||||
</div>
|
||||
` : `
|
||||
<div class="pd-info-right">
|
||||
<span class="pd-info-sub">미등록</span>
|
||||
<i class="fas fa-chevron-right pd-info-arrow"></i>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
${vacTotal > 0 ? `<div class="pd-progress-bar" style="margin:0 12px 8px"><div class="pd-progress-fill pd-progress-${vacColor}" style="width:${Math.min(vacPct, 100)}%"></div></div>` : ''}
|
||||
<div class="pd-info-row">
|
||||
<div class="pd-info-left">
|
||||
<i class="fas fa-clock pd-info-icon"></i>
|
||||
<span class="pd-info-label">연장근로</span>
|
||||
</div>
|
||||
<div class="pd-info-right">
|
||||
<span class="pd-info-value"><strong>${otHours.toFixed(1)}h</strong></span>
|
||||
<span class="pd-info-sub">이번달 ${otDays}일</span>
|
||||
</div>
|
||||
<div class="pd-stat-card">
|
||||
<div class="pd-stat-label"><i class="fas fa-clock" style="font-size:12px"></i> 연장근로</div>
|
||||
<div class="pd-stat-value">${otHours.toFixed(1)}h</div>
|
||||
<div class="pd-stat-sub">이번달 ${otDays}일</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 아이콘 그리드
|
||||
renderGrid('deptPagesGrid', 'deptPagesSection', quick_access.department_pages);
|
||||
renderGrid('personalPagesGrid', 'personalPagesSection', quick_access.personal_pages);
|
||||
renderGrid('adminPagesGrid', 'adminPagesSection', quick_access.admin_pages);
|
||||
}
|
||||
|
||||
function openVacDetailModal() {
|
||||
if (!_dashboardData) return;
|
||||
const { vacation } = _dashboardData;
|
||||
const details = vacation.details || [];
|
||||
|
||||
const groups = {};
|
||||
details.forEach(d => {
|
||||
const bt = d.balance_type || 'AUTO';
|
||||
if (!groups[bt]) groups[bt] = { total: 0, used: 0, remaining: 0, expires_at: d.expires_at, items: [] };
|
||||
groups[bt].total += d.total;
|
||||
groups[bt].used += d.used;
|
||||
groups[bt].remaining += d.remaining;
|
||||
if (d.expires_at) groups[bt].expires_at = d.expires_at;
|
||||
groups[bt].items.push(d);
|
||||
});
|
||||
|
||||
const LABELS = { CARRY_OVER: '이월연차', AUTO: '정기연차', MANUAL: '추가부여', LONG_SERVICE: '장기근속', COMPANY_GRANT: '경조사/특별' };
|
||||
const ORDER = ['CARRY_OVER', 'AUTO', 'MANUAL', 'LONG_SERVICE', 'COMPANY_GRANT'];
|
||||
|
||||
let html = '';
|
||||
ORDER.forEach(bt => {
|
||||
const g = groups[bt];
|
||||
if (!g || (g.total === 0 && g.used === 0)) return;
|
||||
const label = LABELS[bt] || bt;
|
||||
const expired = bt === 'CARRY_OVER' && isExpired(g.expires_at);
|
||||
const lapsed = expired ? Math.max(0, g.total - g.used) : 0;
|
||||
|
||||
html += `<div class="pd-detail-row">
|
||||
<span class="pd-detail-label">${label}</span>
|
||||
<span class="pd-detail-value">
|
||||
${g.total !== 0 ? `배정 ${fmtDays(g.total)}` : ''}
|
||||
${g.used > 0 ? ` · 사용 ${fmtDays(g.used)}` : ''}
|
||||
${expired && lapsed > 0 ? ` · <span style="color:#9ca3af;text-decoration:line-through">만료 ${fmtDays(lapsed)}</span>` : ''}
|
||||
${!expired && g.remaining !== 0 ? ` · 잔여 <strong>${fmtDays(g.remaining)}</strong>` : ''}
|
||||
</span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
if (!html) html = '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.6)">연차 정보가 없습니다</div>';
|
||||
|
||||
html += `<div class="pd-detail-total">
|
||||
<span>합계</span>
|
||||
<span>배정 ${fmtDays(vacation.total_days)} · 사용 ${fmtDays(vacation.used_days)} · 잔여 <strong>${fmtDays(vacation.remaining_days)}</strong></span>
|
||||
</div>`;
|
||||
|
||||
document.getElementById('vacDetailContent').innerHTML = html;
|
||||
document.getElementById('vacDetailModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeVacDetail() {
|
||||
document.getElementById('vacDetailModal').classList.remove('active');
|
||||
}
|
||||
|
||||
function renderGrid(gridId, sectionId, pages) {
|
||||
const grid = document.getElementById(gridId);
|
||||
const section = document.getElementById(sectionId);
|
||||
if (!pages || pages.length === 0) {
|
||||
section.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
if (!pages || pages.length === 0) { section.classList.add('hidden'); return; }
|
||||
section.classList.remove('hidden');
|
||||
|
||||
// dashboard 자체 제외
|
||||
const filtered = pages.filter(p => p.page_key !== 'dashboard');
|
||||
const filtered = pages.filter(p => !HIDDEN_PAGES.includes(p.page_key));
|
||||
if (filtered.length === 0) { section.classList.add('hidden'); return; }
|
||||
|
||||
grid.innerHTML = filtered.map(p => {
|
||||
@@ -140,12 +216,9 @@ function showSkeleton() {
|
||||
<div class="pd-skeleton" style="width:140px;height:14px"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pd-stats-row">
|
||||
<div class="pd-skeleton" style="height:90px"></div>
|
||||
<div class="pd-skeleton" style="height:90px"></div>
|
||||
</div>
|
||||
<div class="pd-skeleton" style="height:50px;margin-top:12px"></div>
|
||||
<div class="pd-skeleton" style="height:50px;margin-top:6px"></div>
|
||||
`;
|
||||
// 그리드 스켈레톤
|
||||
['deptPagesGrid'].forEach(id => {
|
||||
const g = document.getElementById(id);
|
||||
if (g) g.innerHTML = Array(8).fill('<div style="display:flex;flex-direction:column;align-items:center;gap:6px"><div class="pd-skeleton" style="width:52px;height:52px;border-radius:14px"></div><div class="pd-skeleton" style="width:40px;height:12px"></div></div>').join('');
|
||||
@@ -162,7 +235,6 @@ function showError(msg) {
|
||||
`;
|
||||
}
|
||||
|
||||
// tkfb-core.js 인증 완료 후 실행
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => setTimeout(initDashboard, 300));
|
||||
} else {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<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/tkfb.css?v=2026033002">
|
||||
<link rel="stylesheet" href="/css/production-dashboard.css?v=2026033003">
|
||||
<link rel="stylesheet" href="/css/production-dashboard.css?v=2026033104">
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<span id="headerUserName" class="hidden">-</span>
|
||||
@@ -34,7 +34,18 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- 연차 상세 모달 -->
|
||||
<div class="pd-detail-modal" id="vacDetailModal" onclick="if(event.target===this)closeVacDetail()">
|
||||
<div class="pd-detail-sheet">
|
||||
<div class="pd-detail-header">
|
||||
<span class="pd-detail-title"><i class="fas fa-umbrella-beach"></i> 연차 상세</span>
|
||||
<button class="pd-detail-close" onclick="closeVacDetail()"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div id="vacDetailContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tkfb-core.js?v=2026033002"></script>
|
||||
<script src="/js/production-dashboard.js?v=2026033003"></script>
|
||||
<script src="/js/production-dashboard.js?v=2026033104"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user