feat: Sprint 002 리뷰 수정 + Sprint 003 대시보드 API/UI 구현
Sprint 002: - proxyInput created_by_name 누락 수정 - tbm-mobile 하단 네비에 현황 탭 추가 - proxy-input 저장 버튼 스피너 추가 Sprint 003: - GET /api/dashboard/my-summary API (연차/연장근로/페이지 통합) - 생산팀 대시보드 UI (프로필카드 + 아이콘 그리드) - dashboard-new.html 교체 (기존 .bak 백업) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
89
system1-factory/api/controllers/dashboardController.js
Normal file
89
system1-factory/api/controllers/dashboardController.js
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 대시보드 컨트롤러
|
||||
* Sprint 003 — 개인 요약 API
|
||||
*/
|
||||
const DashboardModel = require('../models/dashboardModel');
|
||||
const logger = require('../../shared/utils/logger');
|
||||
|
||||
const DashboardController = {
|
||||
/**
|
||||
* GET /api/dashboard/my-summary
|
||||
* 연차 잔여 + 월간 연장근로 + 접근 가능 페이지
|
||||
*/
|
||||
getMySummary: async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.user_id || req.user.id;
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
|
||||
// 1단계: 사용자 정보 먼저 조회 (worker_id 필요)
|
||||
const userInfo = await DashboardModel.getUserInfo(userId);
|
||||
if (!userInfo) {
|
||||
return res.status(404).json({ success: false, message: '사용자 정보를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const workerId = userInfo.worker_id;
|
||||
const departmentId = userInfo.department_id;
|
||||
const role = userInfo.role;
|
||||
|
||||
// 2단계: 나머지 3개 병렬 조회
|
||||
const [vacationRows, overtime, quickAccess] = await Promise.all([
|
||||
DashboardModel.getVacationBalance(workerId, year),
|
||||
DashboardModel.getMonthlyOvertime(userId, year, month),
|
||||
DashboardModel.getQuickAccess(userId, departmentId, role)
|
||||
]);
|
||||
|
||||
// 연차 응답 가공
|
||||
const details = vacationRows.map(v => ({
|
||||
type_name: v.type_name,
|
||||
type_code: v.type_code,
|
||||
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;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
user: {
|
||||
user_id: userInfo.user_id,
|
||||
name: userInfo.name,
|
||||
worker_name: userInfo.worker_name || userInfo.name,
|
||||
job_type: userInfo.job_type || '',
|
||||
department_name: userInfo.department_name,
|
||||
department_id: userInfo.department_id,
|
||||
role: userInfo.role
|
||||
},
|
||||
vacation: {
|
||||
year,
|
||||
total_days: totalDays,
|
||||
used_days: usedDays,
|
||||
remaining_days: remainingDays,
|
||||
details
|
||||
},
|
||||
overtime: {
|
||||
year,
|
||||
month,
|
||||
total_overtime_hours: parseFloat(overtime.total_overtime_hours) || 0,
|
||||
overtime_days: parseInt(overtime.overtime_days) || 0,
|
||||
total_work_days: parseInt(overtime.total_work_days) || 0,
|
||||
total_work_hours: parseFloat(overtime.total_work_hours) || 0,
|
||||
avg_daily_hours: parseFloat(parseFloat(overtime.avg_daily_hours || 0).toFixed(1))
|
||||
},
|
||||
quick_access: quickAccess
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('대시보드 요약 조회 오류:', err);
|
||||
res.status(500).json({ success: false, message: '대시보드 데이터 조회 중 오류가 발생했습니다.', error: err.message });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = DashboardController;
|
||||
@@ -116,7 +116,8 @@ const ProxyInputController = {
|
||||
note: entry.note || '',
|
||||
tbm_session_id: sessionId,
|
||||
tbm_assignment_id: assignmentId,
|
||||
created_by: userId
|
||||
created_by: userId,
|
||||
created_by_name: req.user.name || req.user.username || ''
|
||||
});
|
||||
|
||||
createdWorkers.push({
|
||||
|
||||
150
system1-factory/api/models/dashboardModel.js
Normal file
150
system1-factory/api/models/dashboardModel.js
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 대시보드 개인 요약 모델
|
||||
* Sprint 003 — 연차/연장근로/접근 페이지 통합 조회
|
||||
*/
|
||||
const { getDb } = require('../config/database');
|
||||
|
||||
const OVERTIME_THRESHOLD = 8; // 연장근로 기준 시간
|
||||
|
||||
const DashboardModel = {
|
||||
/**
|
||||
* 사용자 정보 조회 (쿼리 1 — 먼저 실행)
|
||||
*/
|
||||
getUserInfo: async (userId) => {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.execute(`
|
||||
SELECT u.user_id, u.name, u.role,
|
||||
w.worker_id, w.worker_name, w.job_type, w.department_id,
|
||||
COALESCE(d.department_name, '미배정') AS department_name
|
||||
FROM sso_users u
|
||||
LEFT JOIN workers w ON u.user_id = w.user_id
|
||||
LEFT JOIN departments d ON w.department_id = d.department_id
|
||||
WHERE u.user_id = ?
|
||||
`, [userId]);
|
||||
return rows[0] || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* 연차 현황 조회 (쿼리 2)
|
||||
*/
|
||||
getVacationBalance: async (workerId, year) => {
|
||||
if (!workerId) return [];
|
||||
const db = await getDb();
|
||||
const [rows] = await db.execute(`
|
||||
SELECT vbd.vacation_type_id, vbd.total_days, vbd.used_days, vbd.remaining_days,
|
||||
vt.type_name, vt.type_code
|
||||
FROM vacation_balance_details vbd
|
||||
JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
|
||||
WHERE vbd.worker_id = ? AND vbd.year = ?
|
||||
ORDER BY vt.priority
|
||||
`, [workerId, year]);
|
||||
return rows;
|
||||
},
|
||||
|
||||
/**
|
||||
* 월간 연장근로 조회 (쿼리 3)
|
||||
*/
|
||||
getMonthlyOvertime: async (userId, year, month) => {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.execute(`
|
||||
SELECT
|
||||
COUNT(CASE WHEN dar.total_work_hours > ${OVERTIME_THRESHOLD} THEN 1 END) AS overtime_days,
|
||||
COALESCE(SUM(CASE WHEN dar.total_work_hours > ${OVERTIME_THRESHOLD} THEN dar.total_work_hours - ${OVERTIME_THRESHOLD} ELSE 0 END), 0) AS total_overtime_hours,
|
||||
COUNT(*) AS total_work_days,
|
||||
COALESCE(SUM(dar.total_work_hours), 0) AS total_work_hours,
|
||||
COALESCE(AVG(dar.total_work_hours), 0) AS avg_daily_hours
|
||||
FROM daily_attendance_records dar
|
||||
WHERE dar.user_id = ? AND YEAR(dar.record_date) = ? AND MONTH(dar.record_date) = ?
|
||||
AND dar.total_work_hours > 0
|
||||
`, [userId, year, month]);
|
||||
return rows[0] || { overtime_days: 0, total_overtime_hours: 0, total_work_days: 0, total_work_hours: 0, avg_daily_hours: 0 };
|
||||
},
|
||||
|
||||
/**
|
||||
* 접근 가능 페이지 조회 (쿼리 4)
|
||||
*/
|
||||
getQuickAccess: async (userId, departmentId, role) => {
|
||||
const db = await getDb();
|
||||
const isAdmin = ['admin', 'system'].includes((role || '').toLowerCase());
|
||||
|
||||
// 모든 페이지 조회
|
||||
const [allPages] = await db.execute(`
|
||||
SELECT id, page_key, page_name, page_path, category, is_admin_only,
|
||||
COALESCE(icon, '') AS icon
|
||||
FROM pages
|
||||
ORDER BY display_order, page_name
|
||||
`);
|
||||
|
||||
if (isAdmin) {
|
||||
const adminPages = allPages.filter(p => p.is_admin_only);
|
||||
const normalPages = allPages.filter(p => !p.is_admin_only);
|
||||
return {
|
||||
department_pages: normalPages.map(formatPage),
|
||||
personal_pages: [],
|
||||
admin_pages: adminPages.map(formatPage)
|
||||
};
|
||||
}
|
||||
|
||||
// 부서 권한 페이지
|
||||
let deptPageIds = new Set();
|
||||
if (departmentId) {
|
||||
const [deptRows] = await db.execute(`
|
||||
SELECT dpp.page_id
|
||||
FROM department_page_permissions dpp
|
||||
WHERE dpp.department_id = ? AND dpp.can_access = 1
|
||||
`, [departmentId]);
|
||||
deptRows.forEach(r => deptPageIds.add(r.page_id));
|
||||
}
|
||||
|
||||
// 개인 권한 페이지
|
||||
const [personalRows] = await db.execute(`
|
||||
SELECT upa.page_id
|
||||
FROM user_page_access upa
|
||||
WHERE upa.user_id = ? AND upa.can_access = 1
|
||||
`, [userId]);
|
||||
const personalPageIds = new Set(personalRows.map(r => r.page_id));
|
||||
|
||||
// 기본 접근 페이지
|
||||
const defaultPages = allPages.filter(p => !p.is_admin_only);
|
||||
|
||||
// 분류
|
||||
const departmentPages = [];
|
||||
const personalPages = [];
|
||||
|
||||
for (const page of allPages) {
|
||||
if (page.is_admin_only) continue;
|
||||
|
||||
if (deptPageIds.has(page.id)) {
|
||||
departmentPages.push(formatPage(page));
|
||||
} else if (personalPageIds.has(page.id)) {
|
||||
personalPages.push(formatPage(page));
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 접근 페이지 중 부서/개인에 없는 것 추가
|
||||
const addedIds = new Set([...departmentPages.map(p => p.page_key), ...personalPages.map(p => p.page_key)]);
|
||||
for (const page of defaultPages) {
|
||||
if (!addedIds.has(page.page_key) && !page.is_admin_only) {
|
||||
departmentPages.push(formatPage(page));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
department_pages: departmentPages,
|
||||
personal_pages: personalPages,
|
||||
admin_pages: []
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
function formatPage(page) {
|
||||
return {
|
||||
page_key: page.page_key,
|
||||
page_name: page.page_name,
|
||||
page_path: page.page_path,
|
||||
icon: page.icon || '',
|
||||
category: page.category || ''
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = DashboardModel;
|
||||
@@ -59,9 +59,9 @@ const ProxyInputModel = {
|
||||
*/
|
||||
createWorkReport: async (conn, data) => {
|
||||
const [result] = await conn.query(`
|
||||
INSERT INTO daily_work_reports (report_date, user_id, project_id, work_type_id, task_id, work_status_id, work_hours, start_time, end_time, note, tbm_session_id, tbm_assignment_id, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
`, [data.report_date, data.user_id, data.project_id, data.work_type_id, data.task_id || null, data.work_status_id || 1, data.work_hours, data.start_time || null, data.end_time || null, data.note || '', data.tbm_session_id, data.tbm_assignment_id, data.created_by]);
|
||||
INSERT INTO daily_work_reports (report_date, user_id, project_id, work_type_id, task_id, work_status_id, work_hours, start_time, end_time, note, tbm_session_id, tbm_assignment_id, created_by, created_by_name, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
`, [data.report_date, data.user_id, data.project_id, data.work_type_id, data.task_id || null, data.work_status_id || 1, data.work_hours, data.start_time || null, data.end_time || null, data.note || '', data.tbm_session_id, data.tbm_assignment_id, data.created_by, data.created_by_name || '']);
|
||||
return result;
|
||||
},
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ function setupRoutes(app) {
|
||||
app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리
|
||||
app.use('/api/tbm', tbmRoutes); // TBM 시스템
|
||||
app.use('/api/proxy-input', require('./routes/proxyInputRoutes')); // 대리입력 + 일별현황
|
||||
app.use('/api/dashboard', require('./routes/dashboardRoutes')); // 대시보드 개인 요약
|
||||
app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유)
|
||||
app.use('/api/departments', departmentRoutes); // 부서 관리
|
||||
app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템
|
||||
|
||||
13
system1-factory/api/routes/dashboardRoutes.js
Normal file
13
system1-factory/api/routes/dashboardRoutes.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 대시보드 라우터
|
||||
* Sprint 003 — 개인 요약 API
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const dashboardController = require('../controllers/dashboardController');
|
||||
const { verifyToken } = require('../middlewares/auth');
|
||||
|
||||
// 모든 인증된 사용자 접근 가능
|
||||
router.get('/my-summary', verifyToken, dashboardController.getMySummary);
|
||||
|
||||
module.exports = router;
|
||||
72
system1-factory/web/css/production-dashboard.css
Normal file
72
system1-factory/web/css/production-dashboard.css
Normal file
@@ -0,0 +1,72 @@
|
||||
/* 생산팀 대시보드 — Sprint 003 */
|
||||
|
||||
.pd-main { max-width: 640px; margin: 0 auto; padding: 16px 16px 80px; }
|
||||
|
||||
/* 프로필 카드 */
|
||||
.pd-profile-card {
|
||||
background: linear-gradient(135deg, #1e3a5f, #2563eb);
|
||||
color: white; border-radius: 16px; padding: 20px; margin-bottom: 16px;
|
||||
}
|
||||
.pd-profile-header { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; }
|
||||
.pd-avatar {
|
||||
width: 48px; height: 48px; border-radius: 50%;
|
||||
background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center;
|
||||
font-size: 20px; font-weight: 700; flex-shrink: 0;
|
||||
}
|
||||
.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-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-progress-green { background: #4ade80; }
|
||||
.pd-progress-yellow { background: #fbbf24; }
|
||||
.pd-progress-red { background: #f87171; }
|
||||
|
||||
/* 섹션 */
|
||||
.pd-section { margin-bottom: 20px; }
|
||||
.pd-section-title {
|
||||
font-size: 12px; font-weight: 700; color: #6b7280;
|
||||
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
/* 아이콘 그리드 */
|
||||
.pd-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.pd-grid-item {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||
cursor: pointer; text-decoration: none; -webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.pd-grid-item:active .pd-grid-icon { transform: scale(0.93); }
|
||||
.pd-grid-icon {
|
||||
width: 52px; height: 52px; border-radius: 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: white; font-size: 20px; transition: transform 0.15s;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.pd-grid-label {
|
||||
font-size: 11px; text-align: center; color: #374151; line-height: 1.3;
|
||||
max-width: 64px; overflow: hidden; display: -webkit-box;
|
||||
-webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* 스켈레톤 */
|
||||
.pd-skeleton { background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%); background-size: 200% 100%; animation: pd-shimmer 1.5s infinite; border-radius: 8px; }
|
||||
@keyframes pd-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||
|
||||
/* 에러 */
|
||||
.pd-error { text-align: center; padding: 40px 20px; color: #6b7280; }
|
||||
.pd-error i { font-size: 40px; margin-bottom: 12px; color: #d1d5db; }
|
||||
.pd-error-btn { margin-top: 12px; padding: 8px 20px; background: #2563eb; color: white; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; }
|
||||
|
||||
/* 반응형 */
|
||||
@media (min-width: 640px) { .pd-grid { grid-template-columns: repeat(6, 1fr); } }
|
||||
@media (min-width: 1024px) { .pd-main { max-width: 800px; } .pd-grid { grid-template-columns: repeat(8, 1fr); } }
|
||||
177
system1-factory/web/js/production-dashboard.js
Normal file
177
system1-factory/web/js/production-dashboard.js
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* 생산팀 대시보드 — Sprint 003
|
||||
*/
|
||||
|
||||
const PAGE_ICONS = {
|
||||
'dashboard': 'fa-home',
|
||||
'work.tbm': 'fa-clipboard-list',
|
||||
'work.report_create': 'fa-file-alt',
|
||||
'work.analysis': 'fa-chart-bar',
|
||||
'work.nonconformity': 'fa-exclamation-triangle',
|
||||
'work.schedule': 'fa-calendar-alt',
|
||||
'work.meetings': 'fa-users',
|
||||
'work.daily_status': 'fa-chart-bar',
|
||||
'work.proxy_input': 'fa-user-edit',
|
||||
'factory.repair_management': 'fa-tools',
|
||||
'inspection.daily_patrol': 'fa-route',
|
||||
'inspection.checkin': 'fa-user-check',
|
||||
'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',
|
||||
'attendance.vacation_allocation': 'fa-plus-circle',
|
||||
'attendance.annual_overview': 'fa-chart-pie',
|
||||
'admin.user_management': 'fa-users-cog',
|
||||
'admin.projects': 'fa-project-diagram',
|
||||
'admin.tasks': 'fa-tasks',
|
||||
'admin.workplaces': 'fa-building',
|
||||
'admin.equipments': 'fa-cogs',
|
||||
'admin.departments': 'fa-sitemap',
|
||||
'admin.notifications': 'fa-bell',
|
||||
'admin.attendance_report': 'fa-clipboard-check',
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS = {
|
||||
'작업 관리': '#3b82f6',
|
||||
'공장 관리': '#f59e0b',
|
||||
'소모품 관리': '#10b981',
|
||||
'근태 관리': '#8b5cf6',
|
||||
'시스템 관리': '#6b7280',
|
||||
};
|
||||
const DEFAULT_COLOR = '#06b6d4';
|
||||
|
||||
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
async function initDashboard() {
|
||||
showSkeleton();
|
||||
try {
|
||||
const token = localStorage.getItem('sso_token') || getCookie('sso_token');
|
||||
const resp = await fetch('/api/dashboard/my-summary', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
if (!resp.ok) throw new Error('API 오류: ' + resp.status);
|
||||
const result = await resp.json();
|
||||
if (!result.success) throw new Error(result.message || '데이터 로드 실패');
|
||||
renderDashboard(result.data);
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function getCookie(name) {
|
||||
const v = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
|
||||
return v ? v.pop() : '';
|
||||
}
|
||||
|
||||
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 vacColor = vacRemaining >= 5 ? 'green' : vacRemaining >= 3 ? 'yellow' : 'red';
|
||||
|
||||
const otHours = overtime.total_overtime_hours;
|
||||
const otDays = overtime.overtime_days;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="pd-profile-header">
|
||||
<div class="pd-avatar">${escHtml(initial)}</div>
|
||||
<div>
|
||||
<div class="pd-profile-name">${escHtml(user.worker_name || user.name)}</div>
|
||||
<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>
|
||||
<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 renderGrid(gridId, sectionId, pages) {
|
||||
const grid = document.getElementById(gridId);
|
||||
const section = document.getElementById(sectionId);
|
||||
if (!pages || pages.length === 0) {
|
||||
section.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
section.classList.remove('hidden');
|
||||
|
||||
// dashboard 자체 제외
|
||||
const filtered = pages.filter(p => p.page_key !== 'dashboard');
|
||||
if (filtered.length === 0) { section.classList.add('hidden'); return; }
|
||||
|
||||
grid.innerHTML = filtered.map(p => {
|
||||
const icon = PAGE_ICONS[p.page_key] || p.icon || 'fa-circle';
|
||||
const color = CATEGORY_COLORS[p.category] || DEFAULT_COLOR;
|
||||
return `<a href="${escHtml(p.page_path)}" class="pd-grid-item">
|
||||
<div class="pd-grid-icon" style="background:${color}">
|
||||
<i class="fas ${icon}"></i>
|
||||
</div>
|
||||
<span class="pd-grid-label">${escHtml(p.page_name)}</span>
|
||||
</a>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function showSkeleton() {
|
||||
const card = document.getElementById('profileCard');
|
||||
card.innerHTML = `
|
||||
<div class="pd-profile-header">
|
||||
<div class="pd-skeleton" style="width:48px;height:48px;border-radius:50%"></div>
|
||||
<div style="flex:1">
|
||||
<div class="pd-skeleton" style="width:100px;height:18px;margin-bottom:6px"></div>
|
||||
<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>
|
||||
`;
|
||||
// 그리드 스켈레톤
|
||||
['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('');
|
||||
});
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
document.getElementById('profileCard').innerHTML = `
|
||||
<div class="pd-error">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<p>${escHtml(msg || '정보를 불러올 수 없습니다.')}</p>
|
||||
<button class="pd-error-btn" onclick="initDashboard()"><i class="fas fa-redo mr-1"></i>새로고침</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// tkfb-core.js 인증 완료 후 실행
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => setTimeout(initDashboard, 300));
|
||||
} else {
|
||||
setTimeout(initDashboard, 300);
|
||||
}
|
||||
@@ -2,143 +2,38 @@
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>대시보드 - TK 공장관리</title>
|
||||
<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=2026031601">
|
||||
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026033002">
|
||||
<link rel="stylesheet" href="/css/production-dashboard.css?v=2026033001">
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<header class="bg-orange-700 text-white sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-14">
|
||||
<div class="flex items-center gap-3">
|
||||
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white">
|
||||
<i class="fas fa-bars text-xl"></i>
|
||||
</button>
|
||||
<i class="fas fa-industry text-xl text-orange-200"></i>
|
||||
<h1 class="text-lg font-semibold">TK 공장관리</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
|
||||
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
|
||||
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div id="app-header"></div>
|
||||
<nav id="sideNav"></nav>
|
||||
<div id="mobileOverlay" class="mobile-overlay hidden" onclick="toggleSideNav()"></div>
|
||||
|
||||
<!-- Mobile overlay -->
|
||||
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
|
||||
<main class="pd-main">
|
||||
<section class="pd-profile-card" id="profileCard"></section>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
|
||||
<div class="flex gap-6">
|
||||
<!-- Sidebar Nav -->
|
||||
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
|
||||
<section class="pd-section" id="deptPagesSection">
|
||||
<h2 class="pd-section-title">내 메뉴</h2>
|
||||
<div class="pd-grid" id="deptPagesGrid"></div>
|
||||
</section>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- 날짜/시간 헤더 -->
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-800">대시보드</h2>
|
||||
<p class="text-sm text-gray-500 mt-0.5" id="dateTimeDisplay">-</p>
|
||||
</div>
|
||||
<button onclick="loadDashboard()" class="text-sm text-gray-500 hover:text-orange-600 border border-gray-200 px-3 py-1.5 rounded-lg hover:bg-orange-50">
|
||||
<i class="fas fa-sync-alt mr-1"></i>새로고침
|
||||
</button>
|
||||
</div>
|
||||
<section class="pd-section hidden" id="personalPagesSection">
|
||||
<h2 class="pd-section-title">추가 메뉴</h2>
|
||||
<div class="pd-grid" id="personalPagesGrid"></div>
|
||||
</section>
|
||||
|
||||
<!-- 요약 카드 -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-orange-600" id="statTbm">-</div>
|
||||
<div class="stat-label">금일 TBM</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-blue-600" id="statWorkers">-</div>
|
||||
<div class="stat-label">출근 인원</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-red-600" id="statRepairs">-</div>
|
||||
<div class="stat-label">수리 요청</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-purple-600" id="statNotifications"><i class="fas fa-external-link-alt text-base"></i></div>
|
||||
<div class="stat-label">알림 관리</div>
|
||||
</div>
|
||||
</div>
|
||||
<section class="pd-section hidden" id="adminPagesSection">
|
||||
<h2 class="pd-section-title">관리 도구</h2>
|
||||
<div class="pd-grid" id="adminPagesGrid"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<!-- 금일 TBM 현황 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-5">
|
||||
<h3 class="text-base font-semibold text-gray-800 mb-4">
|
||||
<i class="fas fa-clipboard-list text-orange-500 mr-2"></i>금일 TBM
|
||||
</h3>
|
||||
<div id="tbmList" class="space-y-2">
|
||||
<p class="text-gray-400 text-sm text-center py-4">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 최근 알림 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-5">
|
||||
<h3 class="text-base font-semibold text-gray-800 mb-4">
|
||||
<i class="fas fa-bell text-orange-500 mr-2"></i>최근 알림
|
||||
</h3>
|
||||
<div id="notificationList" class="space-y-2">
|
||||
<p class="text-gray-400 text-sm text-center py-4">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 미완료 수리 요청 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-5">
|
||||
<h3 class="text-base font-semibold text-gray-800 mb-4">
|
||||
<i class="fas fa-tools text-orange-500 mr-2"></i>수리 요청 현황
|
||||
</h3>
|
||||
<div id="repairList" class="space-y-2">
|
||||
<p class="text-gray-400 text-sm text-center py-4">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 빠른 이동 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-5">
|
||||
<h3 class="text-base font-semibold text-gray-800 mb-4">
|
||||
<i class="fas fa-rocket text-orange-500 mr-2"></i>빠른 이동
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<a href="/pages/work/tbm.html" class="flex items-center gap-3 p-3 rounded-lg border border-gray-100 hover:border-orange-200 hover:bg-orange-50 transition-colors">
|
||||
<i class="fas fa-clipboard-list text-orange-500 w-5 text-center"></i>
|
||||
<span class="text-sm text-gray-700">TBM 관리</span>
|
||||
</a>
|
||||
<a href="/pages/work/report-create.html" class="flex items-center gap-3 p-3 rounded-lg border border-gray-100 hover:border-orange-200 hover:bg-orange-50 transition-colors">
|
||||
<i class="fas fa-file-alt text-orange-500 w-5 text-center"></i>
|
||||
<span class="text-sm text-gray-700">작업보고서</span>
|
||||
</a>
|
||||
<a href="/pages/attendance/checkin.html" class="flex items-center gap-3 p-3 rounded-lg border border-gray-100 hover:border-orange-200 hover:bg-orange-50 transition-colors">
|
||||
<i class="fas fa-user-check text-orange-500 w-5 text-center"></i>
|
||||
<span class="text-sm text-gray-700">출근 체크</span>
|
||||
</a>
|
||||
<a href="/pages/admin/repair-management.html" class="flex items-center gap-3 p-3 rounded-lg border border-gray-100 hover:border-orange-200 hover:bg-orange-50 transition-colors">
|
||||
<i class="fas fa-tools text-orange-500 w-5 text-center"></i>
|
||||
<span class="text-sm text-gray-700">시설설비 관리</span>
|
||||
</a>
|
||||
<a href="/pages/attendance/vacation-request.html" class="flex items-center gap-3 p-3 rounded-lg border border-gray-100 hover:border-orange-200 hover:bg-orange-50 transition-colors">
|
||||
<i class="fas fa-paper-plane text-orange-500 w-5 text-center"></i>
|
||||
<span class="text-sm text-gray-700">휴가 신청</span>
|
||||
</a>
|
||||
<a href="/pages/dashboard.html" class="flex items-center gap-3 p-3 rounded-lg border border-gray-100 hover:border-orange-200 hover:bg-orange-50 transition-colors">
|
||||
<i class="fas fa-map text-orange-500 w-5 text-center"></i>
|
||||
<span class="text-sm text-gray-700">작업장 현황</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tkfb-core.js?v=2026031601"></script>
|
||||
<script src="/static/js/tkfb-dashboard.js?v=2026031701"></script>
|
||||
<script src="/static/js/tkfb-core.js?v=2026033002"></script>
|
||||
<script src="/js/production-dashboard.js?v=2026033001"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
<!-- Bottom Save -->
|
||||
<div class="pi-bottom" id="bottomSave">
|
||||
<button type="button" class="pi-save-btn" id="saveBtn" onclick="saveProxyInput()" disabled>
|
||||
<i class="fas fa-save mr-2"></i><span id="saveBtnText">저장할 작업자를 선택하세요</span>
|
||||
<i class="fas fa-spinner fa-spin mr-2" style="display:none"></i><i class="fas fa-save mr-2"></i><span id="saveBtnText">저장할 작업자를 선택하세요</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -95,6 +95,14 @@
|
||||
</svg>
|
||||
<span class="m-nav-label">작업보고</span>
|
||||
</a>
|
||||
<a href="/pages/work/daily-status.html" class="m-nav-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 20V10"/>
|
||||
<path d="M12 20V4"/>
|
||||
<path d="M6 20v-6"/>
|
||||
</svg>
|
||||
<span class="m-nav-label">현황</span>
|
||||
</a>
|
||||
<a href="/pages/attendance/checkin.html" class="m-nav-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
|
||||
Reference in New Issue
Block a user