feat(dashboard): 대시보드 UI 개선 — 환영 인사 + 현황 카드 + 시스템 설명
- A: 시간대별 환영 인사, 날짜, 날씨 API 연동 - B: 오늘 현황 숫자카드 (출근/작업/이슈) — 권한 기반 동적 표시 - C: 시스템 카드에 설명 텍스트 추가 - fix: notification-bell 드롭다운 position:fixed + 스크롤 시 닫기 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -241,6 +241,52 @@
|
|||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Welcome Section ===== */
|
||||||
|
.welcome-section {
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.welcome-greeting {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e3a5f;
|
||||||
|
}
|
||||||
|
.welcome-meta {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Stats Cards ===== */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.stats-grid { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 18px 14px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.stat-icon { font-size: 22px; margin-bottom: 4px; }
|
||||||
|
.stat-label { font-size: 12px; color: #6b7280; font-weight: 500; }
|
||||||
|
.stat-value { font-size: 28px; font-weight: 700; margin-top: 4px; }
|
||||||
|
|
||||||
|
/* ===== Card desc ===== */
|
||||||
|
.card-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
.card-desc { font-size: 11px; color: #9ca3af; font-weight: 400; line-height: 1.3; }
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -280,6 +326,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<div class="welcome-section" id="welcomeSection" style="display:none">
|
||||||
|
<div class="welcome-greeting" id="welcomeGreeting"></div>
|
||||||
|
<div class="welcome-meta">
|
||||||
|
<span id="welcomeDate"></span>
|
||||||
|
<span id="welcomeWeather"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section" id="statsSection" style="display:none">
|
||||||
|
<div class="stats-grid" id="statsGrid"></div>
|
||||||
|
</div>
|
||||||
<div class="section" id="bannerSection" style="display:none">
|
<div class="section" id="bannerSection" style="display:none">
|
||||||
<div class="banner-list" id="bannerList"></div>
|
<div class="banner-list" id="bannerList"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -433,13 +489,13 @@
|
|||||||
|
|
||||||
// ===== Card Definitions =====
|
// ===== Card Definitions =====
|
||||||
var SYSTEM_CARDS = [
|
var SYSTEM_CARDS = [
|
||||||
{ id: 'factory', name: '공장관리', icon: '\uD83C\uDFED', subdomain: 'tkfb', path: '/pages/dashboard.html', pageKey: 's1.dashboard', color: '#1a56db' },
|
{ id: 'factory', name: '공장관리', desc: '작업장 현황, TBM, 설비관리', icon: '\uD83C\uDFED', subdomain: 'tkfb', path: '/pages/dashboard.html', pageKey: 's1.dashboard', color: '#1a56db' },
|
||||||
{ id: 'report_sys', name: '신고', icon: '\uD83D\uDEA8', subdomain: 'tkreport', accessKey: 'system2', color: '#dc2626' },
|
{ id: 'report_sys', name: '신고', desc: '사건·사고 신고 접수', icon: '\uD83D\uDEA8', subdomain: 'tkreport', accessKey: 'system2', color: '#dc2626' },
|
||||||
{ id: 'quality', name: '부적합관리', icon: '\uD83D\uDCCA', subdomain: 'tkqc', pageKey: 'issues_dashboard', color: '#059669' },
|
{ id: 'quality', name: '부적합관리', desc: '부적합 이슈 추적·처리', icon: '\uD83D\uDCCA', subdomain: 'tkqc', pageKey: 'issues_dashboard', color: '#059669' },
|
||||||
{ id: 'purchase', name: '구매관리', icon: '\uD83D\uDED2', subdomain: 'tkpurchase', pageKey: 'purchasing_schedule', color: '#d97706' },
|
{ id: 'purchase', name: '구매관리', desc: '자재 구매, 일용직 관리', icon: '\uD83D\uDED2', subdomain: 'tkpurchase', pageKey: 'purchasing_schedule', color: '#d97706' },
|
||||||
{ id: 'safety', name: '안전관리', icon: '\uD83D\uDD27', subdomain: 'tksafety', pageKey: 'safety_visit_management', color: '#7c3aed' },
|
{ id: 'safety', name: '안전관리', desc: '안전 점검, 방문 관리', icon: '\uD83E\uDDBA', subdomain: 'tksafety', pageKey: 'safety_visit_management', color: '#7c3aed' },
|
||||||
{ id: 'support', name: '행정지원', icon: '\uD83C\uDFE2', subdomain: 'tksupport', comingSoon: true, color: '#6b7280' },
|
{ id: 'support', name: '행정지원', desc: '전사 행정 업무 지원', icon: '\uD83C\uDFE2', subdomain: 'tksupport', comingSoon: true, color: '#6b7280' },
|
||||||
{ id: 'admin', name: '통합관리', icon: '\u2699\uFE0F', subdomain: 'tkuser', pageKey: 'tkuser.users', color: '#0891b2' }
|
{ id: 'admin', name: '통합관리', desc: '사용자·권한 관리', icon: '\u2699\uFE0F', subdomain: 'tkuser', pageKey: 'tkuser.users', color: '#0891b2' }
|
||||||
];
|
];
|
||||||
|
|
||||||
// ===== Rendering =====
|
// ===== Rendering =====
|
||||||
@@ -485,7 +541,18 @@
|
|||||||
nameSpan.appendChild(badge);
|
nameSpan.appendChild(badge);
|
||||||
}
|
}
|
||||||
|
|
||||||
a.appendChild(nameSpan);
|
if (card.desc && isSystem) {
|
||||||
|
var textDiv = document.createElement('div');
|
||||||
|
textDiv.className = 'card-text';
|
||||||
|
textDiv.appendChild(nameSpan);
|
||||||
|
var descSpan = document.createElement('span');
|
||||||
|
descSpan.className = 'card-desc';
|
||||||
|
descSpan.textContent = card.desc;
|
||||||
|
textDiv.appendChild(descSpan);
|
||||||
|
a.appendChild(textDiv);
|
||||||
|
} else {
|
||||||
|
a.appendChild(nameSpan);
|
||||||
|
}
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,11 +608,146 @@
|
|||||||
if (systemAccess.system3 !== false) allowed.add('issues_dashboard');
|
if (systemAccess.system3 !== false) allowed.add('issues_dashboard');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A: Welcome section
|
||||||
|
showWelcome(user, token);
|
||||||
|
|
||||||
|
// B: Today stats
|
||||||
|
loadTodayStats(token, allowed);
|
||||||
|
|
||||||
// Render banners + system cards
|
// Render banners + system cards
|
||||||
loadBanners(token, allowed);
|
loadBanners(token, allowed);
|
||||||
renderSection('systemSection', 'systemGrid', SYSTEM_CARDS, allowed, systemAccess, true);
|
renderSection('systemSection', 'systemGrid', SYSTEM_CARDS, allowed, systemAccess, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== A: Welcome =====
|
||||||
|
function getGreeting() {
|
||||||
|
var h = new Date().getHours();
|
||||||
|
if (h >= 6 && h < 12) return '좋은 아침입니다';
|
||||||
|
if (h >= 12 && h < 18) return '좋은 오후입니다';
|
||||||
|
return '좋은 저녁입니다';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeatherIcon(sky, precip) {
|
||||||
|
var p = Number(precip);
|
||||||
|
if (p === 1 || p === 2 || p === 4 || p === 5 || p === 6) return '\uD83C\uDF27\uFE0F';
|
||||||
|
if (p === 3 || p === 7) return '\u2744\uFE0F';
|
||||||
|
if (sky === 'overcast') return '\u2601\uFE0F';
|
||||||
|
if (sky === 'cloudy') return '\u26C5';
|
||||||
|
return '\u2600\uFE0F';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSkyLabel(sky) {
|
||||||
|
if (sky === 'clear') return '맑음';
|
||||||
|
if (sky === 'cloudy') return '구름많음';
|
||||||
|
if (sky === 'overcast') return '흐림';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWelcome(user, token) {
|
||||||
|
var section = document.getElementById('welcomeSection');
|
||||||
|
var name = user.name || user.username;
|
||||||
|
document.getElementById('welcomeGreeting').textContent = getGreeting() + ', ' + name + '님';
|
||||||
|
|
||||||
|
var now = new Date();
|
||||||
|
var days = ['일','월','화','수','목','금','토'];
|
||||||
|
var dateStr = now.getFullYear() + '년 ' + (now.getMonth()+1) + '월 ' + now.getDate() + '일 (' + days[now.getDay()] + ')';
|
||||||
|
document.getElementById('welcomeDate').textContent = dateStr;
|
||||||
|
|
||||||
|
section.style.display = '';
|
||||||
|
|
||||||
|
// Weather (async, hide on failure)
|
||||||
|
var weatherEl = document.getElementById('welcomeWeather');
|
||||||
|
weatherEl.textContent = '';
|
||||||
|
fetch('/api/tbm/weather/current', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||||
|
.then(function(r) { return r.ok ? r.json() : Promise.reject(); })
|
||||||
|
.then(function(json) {
|
||||||
|
var d = json.data;
|
||||||
|
if (!d) return;
|
||||||
|
var icon = getWeatherIcon(d.skyCondition, d.precipitationType);
|
||||||
|
var label = getSkyLabel(d.skyCondition);
|
||||||
|
var temp = d.temperature != null ? Math.round(d.temperature) + '\u00B0C' : '';
|
||||||
|
weatherEl.textContent = '| ' + icon + ' ' + label + ' ' + temp;
|
||||||
|
})
|
||||||
|
.catch(function() { weatherEl.textContent = ''; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== B: Today Stats =====
|
||||||
|
function loadTodayStats(token, allowed) {
|
||||||
|
var today = new Date().toISOString().slice(0, 10);
|
||||||
|
var cards = [];
|
||||||
|
var fetches = [];
|
||||||
|
|
||||||
|
// Attendance
|
||||||
|
if (allowed.has('s1.attendance.daily_status') || allowed.has('s1.attendance.vacation_management') || allowed.has('s1.dashboard')) {
|
||||||
|
var idx = cards.length;
|
||||||
|
cards.push({ icon: '\uD83D\uDC77', label: '출근', value: '\u2013', color: '#1a56db', visible: true });
|
||||||
|
fetches.push(
|
||||||
|
fetch('/api/attendance/daily-status?date=' + today, { headers: { 'Authorization': 'Bearer ' + token } })
|
||||||
|
.then(function(r) { return r.ok ? r.json() : Promise.reject(); })
|
||||||
|
.then(function(json) {
|
||||||
|
var data = json.data;
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
cards[idx].value = data.filter(function(d) { return d.status !== 'vacation'; }).length + '명';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { cards[idx].visible = false; })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Work reports
|
||||||
|
if (allowed.has('s1.dashboard')) {
|
||||||
|
var idx2 = cards.length;
|
||||||
|
cards.push({ icon: '\uD83D\uDD27', label: '작업', value: '\u2013', color: '#059669', visible: true });
|
||||||
|
fetches.push(
|
||||||
|
fetch('/api/work-analysis/dashboard?start=' + today + '&end=' + today, { headers: { 'Authorization': 'Bearer ' + token } })
|
||||||
|
.then(function(r) { return r.ok ? r.json() : Promise.reject(); })
|
||||||
|
.then(function(json) {
|
||||||
|
var stats = json.data && json.data.stats;
|
||||||
|
if (stats) {
|
||||||
|
cards[idx2].value = (stats.totalReports || 0) + '건';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { cards[idx2].visible = false; })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issues
|
||||||
|
var idx3 = cards.length;
|
||||||
|
cards.push({ icon: '\u26A0\uFE0F', label: '이슈', value: '\u2013', color: '#dc2626', visible: true });
|
||||||
|
fetches.push(
|
||||||
|
fetch('/api/work-issues/stats/summary', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||||
|
.then(function(r) { return r.ok ? r.json() : Promise.reject(); })
|
||||||
|
.then(function(json) {
|
||||||
|
var data = json.data;
|
||||||
|
if (data && data.openCount != null) {
|
||||||
|
cards[idx3].value = data.openCount + '건';
|
||||||
|
} else if (data && data.total != null) {
|
||||||
|
cards[idx3].value = data.total + '건';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { cards[idx3].visible = false; })
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fetches.length === 0) return;
|
||||||
|
|
||||||
|
Promise.allSettled(fetches).then(function() {
|
||||||
|
var visible = cards.filter(function(c) { return c.visible; });
|
||||||
|
if (visible.length === 0) return;
|
||||||
|
|
||||||
|
var grid = document.getElementById('statsGrid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
visible.forEach(function(c) {
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.className = 'stat-card';
|
||||||
|
div.innerHTML = '<div class="stat-icon">' + c.icon + '</div>'
|
||||||
|
+ '<div class="stat-label">' + c.label + '</div>'
|
||||||
|
+ '<div class="stat-value" style="color:' + c.color + '">' + c.value + '</div>';
|
||||||
|
grid.appendChild(div);
|
||||||
|
});
|
||||||
|
document.getElementById('statsSection').style.display = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Login =====
|
// ===== Login =====
|
||||||
async function handleLogin(e) {
|
async function handleLogin(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
'</svg>' +
|
'</svg>' +
|
||||||
'<span id="notif-badge" style="display:none;position:absolute;top:0;right:0;background:#EF4444;color:#fff;font-size:11px;font-weight:600;min-width:18px;height:18px;line-height:18px;text-align:center;border-radius:9px;padding:0 4px;">0</span>' +
|
'<span id="notif-badge" style="display:none;position:absolute;top:0;right:0;background:#EF4444;color:#fff;font-size:11px;font-weight:600;min-width:18px;height:18px;line-height:18px;text-align:center;border-radius:9px;padding:0 4px;">0</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div id="notif-dropdown" style="display:none;position:absolute;top:100%;right:0;width:340px;max-height:420px;background:#fff;border:1px solid #E5E7EB;border-radius:8px;box-shadow:0 10px 25px rgba(0,0,0,.15);z-index:9999;overflow:hidden;margin-top:4px;">' +
|
'<div id="notif-dropdown" style="display:none;position:fixed;width:340px;max-height:420px;background:#fff;border:1px solid #E5E7EB;border-radius:8px;box-shadow:0 10px 25px rgba(0,0,0,.15);z-index:9999;overflow:hidden;">' +
|
||||||
'<div style="padding:12px 16px;border-bottom:1px solid #F3F4F6;display:flex;justify-content:space-between;align-items:center;">' +
|
'<div style="padding:12px 16px;border-bottom:1px solid #F3F4F6;display:flex;justify-content:space-between;align-items:center;">' +
|
||||||
'<span style="font-weight:600;font-size:14px;color:#111827;">알림</span>' +
|
'<span style="font-weight:600;font-size:14px;color:#111827;">알림</span>' +
|
||||||
'<div style="display:flex;gap:8px;align-items:center;">' +
|
'<div style="display:flex;gap:8px;align-items:center;">' +
|
||||||
@@ -132,9 +132,33 @@
|
|||||||
openDropdown();
|
openDropdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onScrollWhileOpen() {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
|
||||||
function openDropdown() {
|
function openDropdown() {
|
||||||
dropdownOpen = true;
|
dropdownOpen = true;
|
||||||
document.getElementById('notif-dropdown').style.display = 'block';
|
var dd = document.getElementById('notif-dropdown');
|
||||||
|
var btn = document.getElementById('notif-bell-btn');
|
||||||
|
var rect = btn.getBoundingClientRect();
|
||||||
|
|
||||||
|
// 드롭다운 너비: 뷰포트 좁으면 양쪽 8px 여백
|
||||||
|
var ddWidth = Math.min(340, window.innerWidth - 16);
|
||||||
|
dd.style.width = ddWidth + 'px';
|
||||||
|
dd.style.top = (rect.bottom + 4) + 'px';
|
||||||
|
|
||||||
|
// 우측 정렬 기본, 왼쪽 넘치면 보정
|
||||||
|
var rightOffset = window.innerWidth - rect.right;
|
||||||
|
if (rightOffset + ddWidth > window.innerWidth - 8) {
|
||||||
|
dd.style.right = 'auto';
|
||||||
|
dd.style.left = '8px';
|
||||||
|
} else {
|
||||||
|
dd.style.left = 'auto';
|
||||||
|
dd.style.right = Math.max(8, rightOffset) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
dd.style.display = 'block';
|
||||||
|
window.addEventListener('scroll', onScrollWhileOpen, { once: true });
|
||||||
loadNotifications();
|
loadNotifications();
|
||||||
updatePushToggleUI();
|
updatePushToggleUI();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user