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:
Hyungi Ahn
2026-03-30 13:12:56 +09:00
parent 672a7039df
commit 7aaac1e334
11 changed files with 539 additions and 133 deletions

View 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;

View File

@@ -116,7 +116,8 @@ const ProxyInputController = {
note: entry.note || '', note: entry.note || '',
tbm_session_id: sessionId, tbm_session_id: sessionId,
tbm_assignment_id: assignmentId, tbm_assignment_id: assignmentId,
created_by: userId created_by: userId,
created_by_name: req.user.name || req.user.username || ''
}); });
createdWorkers.push({ createdWorkers.push({

View 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;

View File

@@ -59,9 +59,9 @@ const ProxyInputModel = {
*/ */
createWorkReport: async (conn, data) => { createWorkReport: async (conn, data) => {
const [result] = await conn.query(` 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) 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()) 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.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; return result;
}, },

View File

@@ -154,6 +154,7 @@ function setupRoutes(app) {
app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리 app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리
app.use('/api/tbm', tbmRoutes); // TBM 시스템 app.use('/api/tbm', tbmRoutes); // TBM 시스템
app.use('/api/proxy-input', require('./routes/proxyInputRoutes')); // 대리입력 + 일별현황 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/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유)
app.use('/api/departments', departmentRoutes); // 부서 관리 app.use('/api/departments', departmentRoutes); // 부서 관리
app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템 app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템

View 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;

View 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); } }

View 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);
}

View File

@@ -2,143 +2,38 @@
<html lang="ko"> <html lang="ko">
<head> <head>
<meta charset="UTF-8"> <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> <title>대시보드 - TK 공장관리</title>
<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/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> </head>
<body class="bg-gray-50"> <body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50"> <div id="app-header"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <nav id="sideNav"></nav>
<div class="flex justify-between items-center h-14"> <div id="mobileOverlay" class="mobile-overlay hidden" onclick="toggleSideNav()"></div>
<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>
<!-- Mobile overlay --> <main class="pd-main">
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div> <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"> <section class="pd-section" id="deptPagesSection">
<div class="flex gap-6"> <h2 class="pd-section-title">내 메뉴</h2>
<!-- Sidebar Nav --> <div class="pd-grid" id="deptPagesGrid"></div>
<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>
<div class="flex-1 min-w-0"> <section class="pd-section hidden" id="personalPagesSection">
<!-- 날짜/시간 헤더 --> <h2 class="pd-section-title">추가 메뉴</h2>
<div class="flex items-center justify-between mb-5"> <div class="pd-grid" id="personalPagesGrid"></div>
<div> </section>
<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="adminPagesSection">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5"> <h2 class="pd-section-title">관리 도구</h2>
<div class="stat-card"> <div class="pd-grid" id="adminPagesGrid"></div>
<div class="stat-value text-orange-600" id="statTbm">-</div> </section>
<div class="stat-label">금일 TBM</div> </main>
</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>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5"> <script src="/static/js/tkfb-core.js?v=2026033002"></script>
<!-- 금일 TBM 현황 --> <script src="/js/production-dashboard.js?v=2026033001"></script>
<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>
</body> </body>
</html> </html>

View File

@@ -87,7 +87,7 @@
<!-- Bottom Save --> <!-- Bottom Save -->
<div class="pi-bottom" id="bottomSave"> <div class="pi-bottom" id="bottomSave">
<button type="button" class="pi-save-btn" id="saveBtn" onclick="saveProxyInput()" disabled> <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> </button>
</div> </div>

View File

@@ -95,6 +95,14 @@
</svg> </svg>
<span class="m-nav-label">작업보고</span> <span class="m-nav-label">작업보고</span>
</a> </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"> <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"> <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"/> <circle cx="12" cy="12" r="10"/>