feat: 안전 코드 tksafety 이관 + 사용자 관리 정리 + UI Tailwind 전환
Phase 1: tksafety에 출입신청/체크리스트 API·웹 추가, tkfb 안전 코드 삭제
Phase 2: 사용자 관리 페이지 삭제, API 축소, 알림 수신자 tkuser 이관
Phase 3: tkuser 권한 페이지 정의 업데이트
Phase 4: 전체 34개 페이지 Tailwind CSS + tkfb-core.js 전환,
미사용 CSS 20개·인프라 JS 10개·템플릿·컴포넌트 삭제
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
59
system1-factory/web/static/css/tkfb.css
Normal file
59
system1-factory/web/static/css/tkfb.css
Normal file
@@ -0,0 +1,59 @@
|
||||
/* tkfb global styles — orange theme */
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; margin: 0; }
|
||||
.fade-in { opacity: 0; transition: opacity 0.3s; }
|
||||
.fade-in.visible { opacity: 1; }
|
||||
|
||||
/* Input */
|
||||
.input-field { border: 1px solid #e2e8f0; transition: border-color 0.15s; outline: none; }
|
||||
.input-field:focus { border-color: #ea580c; box-shadow: 0 0 0 3px rgba(234,88,12,0.1); }
|
||||
|
||||
/* Toast */
|
||||
.toast-message { transition: opacity 0.3s; }
|
||||
|
||||
/* Nav active */
|
||||
.nav-link.active { background: rgba(234,88,12,0.12); color: #c2410c; font-weight: 600; }
|
||||
|
||||
/* Nav category */
|
||||
.nav-category-header { display: flex; align-items: center; gap: 0.5rem; width: 100%; padding: 0.5rem 1rem; background: none; border: none; cursor: pointer; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #9ca3af; transition: color 0.15s; }
|
||||
.nav-category-header:hover { color: #6b7280; }
|
||||
.nav-category-header .nav-arrow { margin-left: auto; font-size: 0.6rem; transition: transform 0.2s; }
|
||||
.nav-category.expanded .nav-arrow { transform: rotate(180deg); }
|
||||
.nav-category-items { display: none; }
|
||||
.nav-category.expanded .nav-category-items { display: flex; flex-direction: column; gap: 1px; }
|
||||
|
||||
/* Stat card */
|
||||
.stat-card { background: white; border-radius: 0.75rem; padding: 1.25rem; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
.stat-card .stat-value { font-size: 1.75rem; font-weight: 700; line-height: 1.2; }
|
||||
.stat-card .stat-label { font-size: 0.8rem; color: #6b7280; margin-top: 0.25rem; }
|
||||
|
||||
/* Table */
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
||||
.data-table th { background: #f1f5f9; padding: 0.625rem 0.75rem; text-align: left; font-weight: 600; color: #475569; white-space: nowrap; border-bottom: 2px solid #e2e8f0; }
|
||||
.data-table td { padding: 0.625rem 0.75rem; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
|
||||
.data-table tr:hover { background: #f8fafc; }
|
||||
|
||||
/* Badge */
|
||||
.badge { display: inline-flex; align-items: center; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; }
|
||||
.badge-green { background: #ecfdf5; color: #059669; }
|
||||
.badge-blue { background: #eff6ff; color: #2563eb; }
|
||||
.badge-amber { background: #fffbeb; color: #d97706; }
|
||||
.badge-red { background: #fef2f2; color: #dc2626; }
|
||||
.badge-gray { background: #f3f4f6; color: #6b7280; }
|
||||
.badge-orange { background: #fff7ed; color: #c2410c; }
|
||||
.badge-purple { background: #faf5ff; color: #7c3aed; }
|
||||
|
||||
/* Collapsible */
|
||||
.collapsible-content { max-height: 0; overflow: hidden; transition: max-height 0.3s ease; }
|
||||
.collapsible-content.open { max-height: 500px; }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 50; padding: 1rem; }
|
||||
.modal-content { background: white; border-radius: 0.75rem; max-width: 40rem; width: 100%; max-height: 90vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.2); }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.stat-card .stat-value { font-size: 1.25rem; }
|
||||
.data-table { font-size: 0.8rem; }
|
||||
.data-table th, .data-table td { padding: 0.5rem; }
|
||||
.hide-mobile { display: none; }
|
||||
}
|
||||
281
system1-factory/web/static/js/tkfb-core.js
Normal file
281
system1-factory/web/static/js/tkfb-core.js
Normal file
@@ -0,0 +1,281 @@
|
||||
/* ===== 서비스 워커 해제 ===== */
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then(function(regs) { regs.forEach(function(r) { r.unregister(); }); });
|
||||
if (typeof caches !== 'undefined') { caches.keys().then(function(ns) { ns.forEach(function(n) { caches.delete(n); }); }); }
|
||||
}
|
||||
|
||||
/* ===== Config ===== */
|
||||
const API_BASE = '/api';
|
||||
|
||||
/* ===== Token ===== */
|
||||
function _cookieGet(n) { const m = document.cookie.match(new RegExp('(?:^|; )' + n + '=([^;]*)')); return m ? decodeURIComponent(m[1]) : null; }
|
||||
function _cookieRemove(n) { let c = n + '=; path=/; max-age=0'; if (location.hostname.includes('technicalkorea.net')) c += '; domain=.technicalkorea.net; secure; samesite=lax'; document.cookie = c; }
|
||||
function getToken() { return _cookieGet('sso_token') || localStorage.getItem('sso_token'); }
|
||||
function getLoginUrl() {
|
||||
const h = location.hostname;
|
||||
const t = Date.now();
|
||||
if (h.includes('technicalkorea.net')) return location.protocol + '//tkfb.technicalkorea.net/login?redirect=' + encodeURIComponent(location.href) + '&_t=' + t;
|
||||
return location.protocol + '//' + h + ':30000/login?redirect=' + encodeURIComponent(location.href) + '&_t=' + t;
|
||||
}
|
||||
function decodeToken(t) { try { const b = atob(t.split('.')[1].replace(/-/g,'+').replace(/_/g,'/')); return JSON.parse(new TextDecoder().decode(Uint8Array.from(b, c => c.charCodeAt(0)))); } catch { return null; } }
|
||||
|
||||
/* ===== 리다이렉트 루프 방지 ===== */
|
||||
const _REDIRECT_KEY = '_sso_redirect_ts';
|
||||
function _safeRedirect() {
|
||||
const last = parseInt(sessionStorage.getItem(_REDIRECT_KEY) || '0', 10);
|
||||
if (Date.now() - last < 5000) { console.warn('[tkfb] 리다이렉트 루프 감지'); return; }
|
||||
sessionStorage.setItem(_REDIRECT_KEY, String(Date.now()));
|
||||
location.href = getLoginUrl();
|
||||
}
|
||||
|
||||
/* ===== API ===== */
|
||||
async function api(path, opts = {}) {
|
||||
const token = getToken();
|
||||
const headers = { 'Authorization': token ? `Bearer ${token}` : '', ...(opts.headers||{}) };
|
||||
if (!(opts.body instanceof FormData)) headers['Content-Type'] = 'application/json';
|
||||
const res = await fetch(API_BASE + path, { ...opts, headers });
|
||||
if (res.status === 401) { _safeRedirect(); throw new Error('인증 만료'); }
|
||||
if (res.headers.get('content-type')?.includes('text/csv')) return res;
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || data.message || '요청 실패');
|
||||
return data;
|
||||
}
|
||||
|
||||
/* ===== Toast ===== */
|
||||
function showToast(msg, type = 'success') {
|
||||
document.querySelector('.toast-message')?.remove();
|
||||
const el = document.createElement('div');
|
||||
el.className = `toast-message fixed bottom-4 right-4 px-4 py-3 rounded-lg text-white z-[10000] shadow-lg ${type==='success'?'bg-orange-500':'bg-red-500'}`;
|
||||
el.innerHTML = `<i class="fas ${type==='success'?'fa-check-circle':'fa-exclamation-circle'} mr-2"></i>${escapeHtml(msg)}`;
|
||||
document.body.appendChild(el);
|
||||
setTimeout(() => { el.classList.add('opacity-0'); setTimeout(() => el.remove(), 300); }, 3000);
|
||||
}
|
||||
|
||||
/* ===== Escape ===== */
|
||||
function escapeHtml(str) { if (!str) return ''; const d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
|
||||
|
||||
/* ===== Helpers ===== */
|
||||
function formatDate(d) { if (!d) return ''; return String(d).substring(0, 10); }
|
||||
function formatTime(d) { if (!d) return ''; return String(d).substring(11, 16); }
|
||||
function formatDateTime(d) { if (!d) return ''; return String(d).substring(0, 16).replace('T', ' '); }
|
||||
function debounce(fn, ms) { let t; return function(...args) { clearTimeout(t); t = setTimeout(() => fn.apply(this, args), ms); }; }
|
||||
|
||||
/* ===== Logout ===== */
|
||||
function doLogout() {
|
||||
if (!confirm('로그아웃?')) return;
|
||||
_cookieRemove('sso_token'); _cookieRemove('sso_user'); _cookieRemove('sso_refresh_token');
|
||||
['sso_token','sso_user','sso_refresh_token','token','user','access_token','currentUser','current_user','userInfo','userPageAccess'].forEach(k => localStorage.removeItem(k));
|
||||
location.href = getLoginUrl() + '&logout=1';
|
||||
}
|
||||
|
||||
/* ===== Page Access ===== */
|
||||
const _PA_CACHE_KEY = 'userPageAccess';
|
||||
const _PA_CACHE_DURATION = 5 * 60 * 1000; // 5분
|
||||
let _paPromise = null;
|
||||
|
||||
async function _fetchPageAccess(userId) {
|
||||
const cached = localStorage.getItem(_PA_CACHE_KEY);
|
||||
if (cached) {
|
||||
try {
|
||||
const c = JSON.parse(cached);
|
||||
if (Date.now() - c.timestamp < _PA_CACHE_DURATION) return c.keys;
|
||||
} catch { localStorage.removeItem(_PA_CACHE_KEY); }
|
||||
}
|
||||
if (_paPromise) return _paPromise;
|
||||
_paPromise = (async () => {
|
||||
try {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/users/${userId}/page-access`, {
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
const pages = data.data?.pageAccess || [];
|
||||
const keys = pages.filter(p => p.can_access == 1).map(p => p.page_key);
|
||||
localStorage.setItem(_PA_CACHE_KEY, JSON.stringify({ keys, timestamp: Date.now() }));
|
||||
return keys;
|
||||
} catch (e) {
|
||||
console.error('페이지 권한 조회 오류:', e);
|
||||
return [];
|
||||
} finally { _paPromise = null; }
|
||||
})();
|
||||
return _paPromise;
|
||||
}
|
||||
|
||||
/* ===== Navbar ===== */
|
||||
const NAV_MENU = [
|
||||
{ cat: null, href: '/pages/dashboard-new.html', icon: 'fa-home', label: '대시보드', key: 'dashboard' },
|
||||
{ cat: '작업 관리', items: [
|
||||
{ href: '/pages/work/tbm.html', icon: 'fa-clipboard-list', label: 'TBM 관리', key: 'work.tbm' },
|
||||
{ href: '/pages/work/report-create.html', icon: 'fa-file-alt', label: '작업보고서 작성', key: 'work.report_create' },
|
||||
{ href: '/pages/work/analysis.html', icon: 'fa-chart-bar', label: '작업 분석', key: 'work.analysis', admin: true },
|
||||
{ href: '/pages/work/nonconformity.html', icon: 'fa-exclamation-triangle', label: '부적합 현황', key: 'work.nonconformity' },
|
||||
]},
|
||||
{ cat: '공장 관리', items: [
|
||||
{ href: '/pages/admin/repair-management.html', icon: 'fa-tools', label: '시설설비 관리', key: 'factory.repair_management' },
|
||||
{ href: '/pages/inspection/daily-patrol.html', icon: 'fa-route', label: '일일순회점검', key: 'inspection.daily_patrol' },
|
||||
{ href: '/pages/attendance/checkin.html', icon: 'fa-user-check', label: '출근 체크', key: 'inspection.checkin' },
|
||||
{ href: '/pages/attendance/work-status.html', icon: 'fa-briefcase', label: '근무 현황', key: 'inspection.work_status' },
|
||||
]},
|
||||
{ cat: '근태 관리', items: [
|
||||
{ href: '/pages/attendance/my-vacation-info.html', icon: 'fa-info-circle', label: '내 연차 정보', key: 'attendance.my_vacation_info' },
|
||||
{ href: '/pages/attendance/monthly.html', icon: 'fa-calendar', label: '월간 근태', key: 'attendance.monthly' },
|
||||
{ href: '/pages/attendance/vacation-request.html', icon: 'fa-paper-plane', label: '휴가 신청', key: 'attendance.vacation_request' },
|
||||
{ href: '/pages/attendance/vacation-management.html', icon: 'fa-cog', label: '휴가 관리', key: 'attendance.vacation_management', admin: true },
|
||||
{ href: '/pages/attendance/vacation-allocation.html', icon: 'fa-plus-circle', label: '휴가 발생 입력', key: 'attendance.vacation_allocation', admin: true },
|
||||
{ href: '/pages/attendance/annual-overview.html', icon: 'fa-chart-pie', label: '연간 휴가 현황', key: 'attendance.annual_overview', admin: true },
|
||||
]},
|
||||
{ cat: '시스템 관리', admin: true, items: [
|
||||
{ href: 'https://tkuser.technicalkorea.net', icon: 'fa-users-cog', label: '사용자 관리', key: 'admin.user_management', external: true },
|
||||
{ href: '/pages/admin/projects.html', icon: 'fa-project-diagram', label: '프로젝트 관리', key: 'admin.projects' },
|
||||
{ href: '/pages/admin/tasks.html', icon: 'fa-tasks', label: '작업 관리', key: 'admin.tasks' },
|
||||
{ href: '/pages/admin/workplaces.html', icon: 'fa-building', label: '작업장 관리', key: 'admin.workplaces' },
|
||||
{ href: '/pages/admin/equipments.html', icon: 'fa-cogs', label: '설비 관리', key: 'admin.equipments' },
|
||||
{ href: '/pages/admin/departments.html', icon: 'fa-sitemap', label: '부서 관리', key: 'admin.departments' },
|
||||
{ href: '/pages/admin/notifications.html', icon: 'fa-bell', label: '알림 관리', key: 'admin.notifications' },
|
||||
{ href: '/pages/admin/attendance-report.html', icon: 'fa-clipboard-check', label: '출퇴근-보고서 대조', key: 'admin.attendance_report' },
|
||||
]},
|
||||
];
|
||||
|
||||
// 하위 페이지 → 부모 페이지 키 매핑
|
||||
const PAGE_KEY_ALIASES = {
|
||||
'dashboard_new': 'dashboard',
|
||||
'work.tbm_mobile': 'work.tbm',
|
||||
'work.tbm_create': 'work.tbm',
|
||||
'work.report_create_mobile': 'work.report_create',
|
||||
'admin.equipment_detail': 'admin.equipments',
|
||||
'admin.repair_management': 'factory.repair_management',
|
||||
'attendance.checkin': 'inspection.checkin',
|
||||
'attendance.work_status': 'inspection.work_status',
|
||||
};
|
||||
|
||||
function _getCurrentPageKey() {
|
||||
const path = location.pathname;
|
||||
if (!path.startsWith('/pages/')) return 'dashboard';
|
||||
const raw = path.substring(7).replace('.html', '').replace(/\//g, '.').replace(/-/g, '_');
|
||||
return PAGE_KEY_ALIASES[raw] || raw;
|
||||
}
|
||||
|
||||
function renderNavbar(accessibleKeys) {
|
||||
const nav = document.getElementById('sideNav');
|
||||
if (!nav) return;
|
||||
|
||||
const currentKey = _getCurrentPageKey();
|
||||
const isAdmin = currentUser && ['admin', 'system', 'system admin'].includes(currentUser.role);
|
||||
|
||||
let html = '';
|
||||
|
||||
for (const entry of NAV_MENU) {
|
||||
if (!entry.cat) {
|
||||
// Top-level link (dashboard)
|
||||
const active = currentKey === entry.key;
|
||||
html += `<a href="${entry.href}" class="nav-link flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm transition-colors ${active ? 'active' : 'text-gray-600 hover:bg-gray-100'}">
|
||||
<i class="fas ${entry.icon} w-5 text-center"></i><span>${entry.label}</span></a>`;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Category
|
||||
if (entry.admin && !isAdmin) continue;
|
||||
|
||||
const visibleItems = entry.items.filter(item => {
|
||||
if (item.admin && !isAdmin) return false;
|
||||
if (isAdmin) return true;
|
||||
return accessibleKeys.includes(item.key);
|
||||
});
|
||||
|
||||
if (visibleItems.length === 0) continue;
|
||||
|
||||
const hasActive = visibleItems.some(item => currentKey === item.key);
|
||||
|
||||
html += `<div class="nav-category${hasActive ? ' expanded' : ''}">
|
||||
<button class="nav-category-header" onclick="this.parentElement.classList.toggle('expanded')">
|
||||
<span>${entry.cat}</span><span class="nav-arrow">▾</span>
|
||||
</button>
|
||||
<div class="nav-category-items">`;
|
||||
|
||||
for (const item of visibleItems) {
|
||||
const active = currentKey === item.key;
|
||||
const target = item.external ? ' target="_blank"' : '';
|
||||
const arrow = item.external ? ' ↗' : '';
|
||||
html += `<a href="${item.href}"${target} class="nav-link flex items-center gap-3 px-4 py-2 rounded-lg text-sm transition-colors ${active ? 'active' : 'text-gray-600 hover:bg-gray-100'}" data-page-key="${item.key}">
|
||||
<i class="fas ${item.icon} w-5 text-center text-xs"></i><span>${item.label}${arrow}</span></a>`;
|
||||
}
|
||||
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
nav.innerHTML = html;
|
||||
}
|
||||
|
||||
/* ===== Mobile Menu ===== */
|
||||
function toggleMobileMenu() {
|
||||
const nav = document.getElementById('sideNav');
|
||||
const overlay = document.getElementById('mobileOverlay');
|
||||
if (!nav) return;
|
||||
const isOpen = nav.classList.contains('mobile-open');
|
||||
nav.classList.toggle('mobile-open');
|
||||
if (overlay) overlay.classList.toggle('hidden', isOpen);
|
||||
}
|
||||
|
||||
/* ===== State ===== */
|
||||
let currentUser = null;
|
||||
|
||||
/* ===== Init ===== */
|
||||
async function initAuth() {
|
||||
// 쿠키 우선 검증
|
||||
const cookieToken = _cookieGet('sso_token');
|
||||
const localToken = localStorage.getItem('sso_token');
|
||||
if (!cookieToken && localToken) {
|
||||
['sso_token','sso_user','sso_refresh_token','token','user','access_token',
|
||||
'currentUser','current_user','userInfo','userPageAccess'].forEach(k => localStorage.removeItem(k));
|
||||
_safeRedirect();
|
||||
return false;
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
if (!token) { _safeRedirect(); return false; }
|
||||
const decoded = decodeToken(token);
|
||||
if (!decoded) { _safeRedirect(); return false; }
|
||||
sessionStorage.removeItem(_REDIRECT_KEY);
|
||||
if (!localStorage.getItem('sso_token')) localStorage.setItem('sso_token', token);
|
||||
|
||||
currentUser = {
|
||||
id: decoded.user_id || decoded.id,
|
||||
username: decoded.username || decoded.sub,
|
||||
name: decoded.name || decoded.full_name,
|
||||
role: (decoded.role || decoded.access_level || '').toLowerCase()
|
||||
};
|
||||
|
||||
const dn = currentUser.name || currentUser.username;
|
||||
const nameEl = document.getElementById('headerUserName');
|
||||
const avatarEl = document.getElementById('headerUserAvatar');
|
||||
if (nameEl) nameEl.textContent = dn;
|
||||
if (avatarEl) avatarEl.textContent = dn.charAt(0).toUpperCase();
|
||||
|
||||
// Page access 기반 네비게이션
|
||||
const isAdmin = ['admin', 'system', 'system admin'].includes(currentUser.role);
|
||||
let accessibleKeys = [];
|
||||
if (!isAdmin) {
|
||||
accessibleKeys = await _fetchPageAccess(currentUser.id);
|
||||
// 현재 페이지 접근 권한 확인
|
||||
const pageKey = _getCurrentPageKey();
|
||||
if (pageKey && pageKey !== 'dashboard' && !pageKey.startsWith('profile.')) {
|
||||
if (!accessibleKeys.includes(pageKey)) {
|
||||
alert('이 페이지에 접근할 권한이 없습니다.');
|
||||
location.href = '/pages/dashboard-new.html';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderNavbar(accessibleKeys);
|
||||
|
||||
// Mobile menu overlay
|
||||
const mobileBtn = document.getElementById('mobileMenuBtn');
|
||||
if (mobileBtn) mobileBtn.addEventListener('click', toggleMobileMenu);
|
||||
const overlay = document.getElementById('mobileOverlay');
|
||||
if (overlay) overlay.addEventListener('click', toggleMobileMenu);
|
||||
|
||||
setTimeout(() => document.querySelector('.fade-in')?.classList.add('visible'), 50);
|
||||
return true;
|
||||
}
|
||||
127
system1-factory/web/static/js/tkfb-dashboard.js
Normal file
127
system1-factory/web/static/js/tkfb-dashboard.js
Normal file
@@ -0,0 +1,127 @@
|
||||
/* ===== Dashboard (대시보드) ===== */
|
||||
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
|
||||
function updateDateTime() {
|
||||
const now = new Date();
|
||||
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const h = String(now.getHours()).padStart(2, '0');
|
||||
const m = String(now.getMinutes()).padStart(2, '0');
|
||||
const el = document.getElementById('dateTimeDisplay');
|
||||
if (el) el.textContent = `${now.getFullYear()}년 ${now.getMonth()+1}월 ${now.getDate()}일 (${days[now.getDay()]}) ${h}:${m}`;
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
updateDateTime();
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
api('/tbm/sessions/date/' + today).catch(() => ({ data: [] })),
|
||||
api('/notifications/unread').catch(() => ({ data: [] })),
|
||||
api('/equipments/repair-requests?status=pending').catch(() => ({ data: [] })),
|
||||
api('/attendance/today-summary').catch(() => ({ data: {} })),
|
||||
]);
|
||||
|
||||
const tbmData = results[0].status === 'fulfilled' ? results[0].value : { data: [] };
|
||||
const notifData = results[1].status === 'fulfilled' ? results[1].value : { data: [] };
|
||||
const repairData = results[2].status === 'fulfilled' ? results[2].value : { data: [] };
|
||||
const attendData = results[3].status === 'fulfilled' ? results[3].value : { data: {} };
|
||||
|
||||
const tbmSessions = tbmData.data || [];
|
||||
const notifications = notifData.data || [];
|
||||
const repairs = repairData.data || [];
|
||||
const attendance = attendData.data || {};
|
||||
|
||||
// Stats
|
||||
document.getElementById('statTbm').textContent = tbmSessions.length;
|
||||
document.getElementById('statWorkers').textContent = attendance.checked_in_count || 0;
|
||||
document.getElementById('statRepairs').textContent = repairs.length;
|
||||
document.getElementById('statNotifications').textContent = notifications.length;
|
||||
|
||||
// TBM list
|
||||
renderTbmList(tbmSessions);
|
||||
renderNotificationList(notifications);
|
||||
renderRepairList(repairs);
|
||||
}
|
||||
|
||||
function renderTbmList(sessions) {
|
||||
const el = document.getElementById('tbmList');
|
||||
if (!sessions.length) {
|
||||
el.innerHTML = '<p class="text-gray-400 text-sm text-center py-4">금일 TBM이 없습니다</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = sessions.slice(0, 5).map(s => {
|
||||
const workers = s.team_member_count || 0;
|
||||
return `<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-800">${escapeHtml(s.workplace_name || s.session_title || 'TBM')}</div>
|
||||
<div class="text-xs text-gray-500">${escapeHtml(s.leader_name || '-')} · ${workers}명</div>
|
||||
</div>
|
||||
<span class="badge ${s.status === 'completed' ? 'badge-green' : 'badge-amber'}">${s.status === 'completed' ? '완료' : '진행중'}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
if (sessions.length > 5) {
|
||||
el.innerHTML += `<a href="/pages/work/tbm.html" class="block text-center text-xs text-orange-600 hover:text-orange-700 mt-2">전체 보기 (${sessions.length}건)</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderNotificationList(notifications) {
|
||||
const el = document.getElementById('notificationList');
|
||||
if (!notifications.length) {
|
||||
el.innerHTML = '<p class="text-gray-400 text-sm text-center py-4">새 알림이 없습니다</p>';
|
||||
return;
|
||||
}
|
||||
const icons = { repair: 'fa-wrench text-amber-500', safety: 'fa-shield-alt text-red-500', system: 'fa-bell text-blue-500', equipment: 'fa-cog text-gray-500', maintenance: 'fa-tools text-green-500' };
|
||||
el.innerHTML = notifications.slice(0, 5).map(n => {
|
||||
const iconClass = icons[n.type] || 'fa-bell text-gray-400';
|
||||
return `<div class="flex items-start gap-3 p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100" onclick="location.href='/pages/admin/notifications.html'">
|
||||
<i class="fas ${iconClass} mt-0.5"></i>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-gray-800 truncate">${escapeHtml(n.title)}</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">${formatTimeAgo(n.created_at)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderRepairList(repairs) {
|
||||
const el = document.getElementById('repairList');
|
||||
if (!repairs.length) {
|
||||
el.innerHTML = '<p class="text-gray-400 text-sm text-center py-4">대기 중인 수리 요청이 없습니다</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = repairs.slice(0, 5).map(r => {
|
||||
return `<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-800">${escapeHtml(r.equipment_name || r.title || '수리 요청')}</div>
|
||||
<div class="text-xs text-gray-500">${formatDate(r.created_at)}</div>
|
||||
</div>
|
||||
<span class="badge badge-red">대기</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
if (repairs.length > 5) {
|
||||
el.innerHTML += `<a href="/pages/admin/repair-management.html" class="block text-center text-xs text-orange-600 hover:text-orange-700 mt-2">전체 보기 (${repairs.length}건)</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeAgo(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now - d;
|
||||
const mins = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (mins < 1) return '방금 전';
|
||||
if (mins < 60) return `${mins}분 전`;
|
||||
if (hours < 24) return `${hours}시간 전`;
|
||||
if (days < 7) return `${days}일 전`;
|
||||
return formatDate(dateStr);
|
||||
}
|
||||
|
||||
/* ===== Init ===== */
|
||||
(async function() {
|
||||
if (!await initAuth()) return;
|
||||
updateDateTime();
|
||||
setInterval(updateDateTime, 60000);
|
||||
loadDashboard();
|
||||
})();
|
||||
119
system1-factory/web/static/js/tkfb-nonconformity.js
Normal file
119
system1-factory/web/static/js/tkfb-nonconformity.js
Normal file
@@ -0,0 +1,119 @@
|
||||
/* ===== 부적합 현황 (Nonconformity List) ===== */
|
||||
|
||||
const CATEGORY_TYPE = 'nonconformity';
|
||||
|
||||
const STATUS_LABELS = {
|
||||
reported: '신고', received: '접수', in_progress: '처리중',
|
||||
completed: '완료', closed: '종료'
|
||||
};
|
||||
|
||||
const STATUS_BADGE = {
|
||||
reported: 'badge-blue', received: 'badge-orange', in_progress: 'badge-purple',
|
||||
completed: 'badge-green', closed: 'badge-gray'
|
||||
};
|
||||
|
||||
function getReportUrl() {
|
||||
const h = location.hostname;
|
||||
if (h.includes('technicalkorea.net')) return 'https://tkreport.technicalkorea.net/pages/safety/issue-report.html?type=nonconformity';
|
||||
return location.protocol + '//' + h + ':30180/pages/safety/issue-report.html?type=nonconformity';
|
||||
}
|
||||
|
||||
function getIssueDetailUrl(reportId) {
|
||||
const h = location.hostname;
|
||||
if (h.includes('technicalkorea.net')) return `https://tkreport.technicalkorea.net/pages/safety/issue-detail.html?id=${reportId}&from=nonconformity`;
|
||||
return `${location.protocol}//${h}:30180/pages/safety/issue-detail.html?id=${reportId}&from=nonconformity`;
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const data = await api(`/work-issues/stats/summary?category_type=${CATEGORY_TYPE}`);
|
||||
if (data.success && data.data) {
|
||||
document.getElementById('statReported').textContent = data.data.reported || 0;
|
||||
document.getElementById('statReceived').textContent = data.data.received || 0;
|
||||
document.getElementById('statProgress').textContent = data.data.in_progress || 0;
|
||||
document.getElementById('statCompleted').textContent = data.data.completed || 0;
|
||||
}
|
||||
} catch {
|
||||
document.getElementById('statsGrid').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIssues() {
|
||||
const params = new URLSearchParams();
|
||||
params.append('category_type', CATEGORY_TYPE);
|
||||
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
const startDate = document.getElementById('filterStartDate').value;
|
||||
const endDate = document.getElementById('filterEndDate').value;
|
||||
|
||||
if (status) params.append('status', status);
|
||||
if (startDate) params.append('start_date', startDate);
|
||||
if (endDate) params.append('end_date', endDate);
|
||||
|
||||
try {
|
||||
const data = await api(`/work-issues?${params.toString()}`);
|
||||
if (data.success) renderIssues(data.data || []);
|
||||
} catch {
|
||||
document.getElementById('issueList').innerHTML =
|
||||
'<div class="bg-white rounded-xl shadow-sm p-8 text-center text-gray-400 text-sm">목록을 불러올 수 없습니다. 잠시 후 다시 시도해주세요.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderIssues(issues) {
|
||||
const el = document.getElementById('issueList');
|
||||
if (!issues.length) {
|
||||
el.innerHTML = '<div class="bg-white rounded-xl shadow-sm p-8 text-center"><p class="font-semibold text-gray-700 mb-1">등록된 부적합 신고가 없습니다</p><p class="text-sm text-gray-400">새로운 부적합을 신고하려면 \'부적합 신고\' 버튼을 클릭하세요.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = issues.map(issue => {
|
||||
const reportDate = formatDateTime(issue.report_date);
|
||||
let loc = escapeHtml(issue.custom_location || '');
|
||||
if (issue.factory_name) {
|
||||
loc = escapeHtml(issue.factory_name);
|
||||
if (issue.workplace_name) loc += ` - ${escapeHtml(issue.workplace_name)}`;
|
||||
}
|
||||
const title = escapeHtml(issue.issue_item_name || issue.issue_category_name || '부적합 신고');
|
||||
const categoryName = escapeHtml(issue.issue_category_name || '부적합');
|
||||
const reportId = parseInt(issue.report_id) || 0;
|
||||
const validStatuses = ['reported', 'received', 'in_progress', 'completed', 'closed'];
|
||||
const safeStatus = validStatuses.includes(issue.status) ? issue.status : 'reported';
|
||||
const reporter = escapeHtml(issue.reporter_full_name || issue.reporter_name || '-');
|
||||
const assigned = issue.assigned_full_name ? escapeHtml(issue.assigned_full_name) : '';
|
||||
|
||||
const photos = [issue.photo_path1, issue.photo_path2, issue.photo_path3, issue.photo_path4, issue.photo_path5].filter(Boolean);
|
||||
|
||||
return `<div class="bg-white rounded-xl shadow-sm p-4 border border-transparent hover:border-orange-200 hover:shadow-md transition-all cursor-pointer" onclick="location.href='${getIssueDetailUrl(reportId)}'">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<span class="text-sm text-gray-400">#${reportId}</span>
|
||||
<span class="badge ${STATUS_BADGE[safeStatus] || 'badge-gray'}">${STATUS_LABELS[issue.status] || escapeHtml(issue.status || '-')}</span>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<span class="badge badge-orange mr-1 text-xs">${categoryName}</span>
|
||||
<span class="font-semibold text-gray-800">${title}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3 text-sm text-gray-500">
|
||||
<span class="flex items-center gap-1"><i class="fas fa-user text-xs"></i>${reporter}</span>
|
||||
<span class="flex items-center gap-1"><i class="fas fa-calendar text-xs"></i>${reportDate}</span>
|
||||
${loc ? `<span class="flex items-center gap-1"><i class="fas fa-map-marker-alt text-xs"></i>${loc}</span>` : ''}
|
||||
${assigned ? `<span class="flex items-center gap-1"><i class="fas fa-user-cog text-xs"></i>담당: ${assigned}</span>` : ''}
|
||||
</div>
|
||||
${photos.length > 0 ? `<div class="flex gap-2 mt-3">${photos.slice(0, 3).map(p => `<img src="${encodeURI(p)}" alt="사진" loading="lazy" class="w-14 h-14 object-cover rounded border border-gray-200">`).join('')}${photos.length > 3 ? `<span class="flex items-center text-sm text-gray-400">+${photos.length - 3}</span>` : ''}</div>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/* ===== Init ===== */
|
||||
(async function() {
|
||||
if (!await initAuth()) return;
|
||||
|
||||
// 신고 버튼 URL 설정
|
||||
document.getElementById('btnNewReport').href = getReportUrl();
|
||||
|
||||
// 필터 이벤트
|
||||
document.getElementById('filterStatus').addEventListener('change', loadIssues);
|
||||
document.getElementById('filterStartDate').addEventListener('change', loadIssues);
|
||||
document.getElementById('filterEndDate').addEventListener('change', loadIssues);
|
||||
|
||||
await Promise.all([loadStats(), loadIssues()]);
|
||||
})();
|
||||
Reference in New Issue
Block a user