feat: TBM 모바일 시스템 + 작업 분할/이동 + 권한 통합
TBM 시스템: - 4단계 워크플로우 (draft→세부편집→완료→작업보고) - 모바일 전용 TBM 페이지 (tbm-mobile.html) + 3단계 생성 위자드 - 작업자 작업 분할 (work_hours + split_seq) - 작업자 이동 보내기/빼오기 (tbm_transfers 테이블) - 생성 시 중복 배정 방지 (당일 배정 현황 조회) - 데스크탑 TBM 페이지 세부편집 기능 추가 작업보고서: - 모바일 전용 작업보고서 페이지 (report-create-mobile.html) - TBM에서 사전 등록된 work_hours 자동 반영 권한 시스템: - tkuser user_page_permissions 테이블과 system1 페이지 접근 연동 - pageAccessRoutes를 userRoutes보다 먼저 등록 (라우트 우선순위 수정) - TKUSER_DEFAULT_ACCESS 폴백 추가 (개인→부서→기본값 3단계) - 권한 캐시키 갱신 (userPageAccess_v2) 기타: - app-init.js 캐시 버스팅 (v=5) - iOS Safari touch-action: manipulation 적용 - KST 타임존 날짜 버그 수정 (toISOString UTC 이슈) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
443
system1-factory/web/js/mobile-dashboard.js
Normal file
443
system1-factory/web/js/mobile-dashboard.js
Normal file
@@ -0,0 +1,443 @@
|
||||
// mobile-dashboard.js - 모바일 대시보드 v2
|
||||
// 공장별 카테고리 탭 → 작업장 리스트 → 작업장별 상태 요약
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
if (window.innerWidth > 768) return;
|
||||
|
||||
var today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// ==================== 캐시 변수 ====================
|
||||
var categories = [];
|
||||
var allWorkplaces = [];
|
||||
var tbmByWorkplace = {};
|
||||
var visitorsByWorkplace = {};
|
||||
var movedByWorkplace = {};
|
||||
var issuesByWorkplace = {};
|
||||
var workplacesByCategory = {};
|
||||
|
||||
// ==================== 유틸리티 ====================
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function waitForApi(timeout) {
|
||||
timeout = timeout || 5000;
|
||||
return new Promise(function(resolve, reject) {
|
||||
if (window.apiCall) return resolve();
|
||||
var elapsed = 0;
|
||||
var interval = setInterval(function() {
|
||||
elapsed += 50;
|
||||
if (window.apiCall) { clearInterval(interval); resolve(); }
|
||||
else if (elapsed >= timeout) { clearInterval(interval); reject(new Error('apiCall timeout')); }
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 데이터 그룹핑 ====================
|
||||
|
||||
function groupTbmByWorkplace(sessions) {
|
||||
tbmByWorkplace = {};
|
||||
if (!Array.isArray(sessions)) return;
|
||||
sessions.forEach(function(s) {
|
||||
var wpId = s.workplace_id;
|
||||
if (!wpId) return;
|
||||
if (!tbmByWorkplace[wpId]) {
|
||||
tbmByWorkplace[wpId] = { taskCount: 0, totalWorkers: 0, sessions: [] };
|
||||
}
|
||||
tbmByWorkplace[wpId].taskCount++;
|
||||
// team_member_count + leader 1
|
||||
tbmByWorkplace[wpId].totalWorkers += (parseInt(s.team_member_count) || 0) + 1;
|
||||
tbmByWorkplace[wpId].sessions.push(s);
|
||||
});
|
||||
}
|
||||
|
||||
function groupVisitorsByWorkplace(requests) {
|
||||
visitorsByWorkplace = {};
|
||||
if (!Array.isArray(requests)) return;
|
||||
requests.forEach(function(r) {
|
||||
// 오늘 날짜 + 승인된 건만
|
||||
if (r.visit_date !== today) return;
|
||||
if (r.status !== 'approved') return;
|
||||
var wpId = r.workplace_id;
|
||||
if (!wpId) return;
|
||||
if (!visitorsByWorkplace[wpId]) {
|
||||
visitorsByWorkplace[wpId] = { visitCount: 0, totalVisitors: 0, requests: [] };
|
||||
}
|
||||
visitorsByWorkplace[wpId].visitCount++;
|
||||
visitorsByWorkplace[wpId].totalVisitors += parseInt(r.visitor_count) || 0;
|
||||
visitorsByWorkplace[wpId].requests.push(r);
|
||||
});
|
||||
}
|
||||
|
||||
function groupMovedByWorkplace(items) {
|
||||
movedByWorkplace = {};
|
||||
if (!Array.isArray(items)) return;
|
||||
items.forEach(function(eq) {
|
||||
var wpId = eq.current_workplace_id;
|
||||
if (!wpId) return;
|
||||
if (!movedByWorkplace[wpId]) {
|
||||
movedByWorkplace[wpId] = { movedCount: 0, items: [] };
|
||||
}
|
||||
movedByWorkplace[wpId].movedCount++;
|
||||
movedByWorkplace[wpId].items.push(eq);
|
||||
});
|
||||
}
|
||||
|
||||
function groupIssuesByWorkplace(issues) {
|
||||
issuesByWorkplace = {};
|
||||
if (!Array.isArray(issues)) return;
|
||||
var activeStatuses = ['reported', 'received', 'in_progress'];
|
||||
issues.forEach(function(issue) {
|
||||
var wpId = issue.workplace_id;
|
||||
if (!wpId) return;
|
||||
if (activeStatuses.indexOf(issue.status) === -1) return;
|
||||
if (!issuesByWorkplace[wpId]) {
|
||||
issuesByWorkplace[wpId] = { activeCount: 0, items: [] };
|
||||
}
|
||||
issuesByWorkplace[wpId].activeCount++;
|
||||
issuesByWorkplace[wpId].items.push(issue);
|
||||
});
|
||||
}
|
||||
|
||||
function groupWorkplacesByCategory(workplaces) {
|
||||
workplacesByCategory = {};
|
||||
if (!Array.isArray(workplaces)) return;
|
||||
workplaces.forEach(function(wp) {
|
||||
var catId = wp.category_id;
|
||||
if (!catId) return;
|
||||
if (!workplacesByCategory[catId]) {
|
||||
workplacesByCategory[catId] = [];
|
||||
}
|
||||
workplacesByCategory[catId].push(wp);
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 렌더링 ====================
|
||||
|
||||
function renderCategoryTabs() {
|
||||
var container = document.getElementById('mCategoryTabs');
|
||||
if (!container || !categories.length) return;
|
||||
|
||||
var html = '';
|
||||
categories.forEach(function(cat, idx) {
|
||||
html += '<button class="md-cat-tab' + (idx === 0 ? ' active' : '') +
|
||||
'" data-id="' + cat.category_id + '">' +
|
||||
escapeHtml(cat.category_name) + '</button>';
|
||||
});
|
||||
|
||||
// 전체 탭
|
||||
html += '<button class="md-cat-tab" data-id="all">전체</button>';
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// 이벤트 바인딩
|
||||
var tabs = container.querySelectorAll('.md-cat-tab');
|
||||
tabs.forEach(function(tab) {
|
||||
tab.addEventListener('click', function() {
|
||||
tabs.forEach(function(t) { t.classList.remove('active'); });
|
||||
tab.classList.add('active');
|
||||
var catId = tab.getAttribute('data-id');
|
||||
selectCategory(catId);
|
||||
});
|
||||
});
|
||||
|
||||
// 첫 번째 카테고리 자동 선택
|
||||
if (categories.length > 0) {
|
||||
selectCategory(String(categories[0].category_id));
|
||||
}
|
||||
}
|
||||
|
||||
function selectCategory(categoryId) {
|
||||
var workplaces;
|
||||
if (categoryId === 'all') {
|
||||
workplaces = allWorkplaces.filter(function(wp) { return wp.is_active !== false; });
|
||||
} else {
|
||||
workplaces = (workplacesByCategory[categoryId] || []).filter(function(wp) {
|
||||
return wp.is_active !== false;
|
||||
});
|
||||
}
|
||||
renderWorkplaceList(workplaces);
|
||||
}
|
||||
|
||||
function renderWorkplaceList(workplaces) {
|
||||
var container = document.getElementById('mWorkplaceList');
|
||||
if (!container) return;
|
||||
|
||||
if (!workplaces || workplaces.length === 0) {
|
||||
container.innerHTML = '<div class="md-wp-empty-all">등록된 작업장이 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
workplaces.forEach(function(wp) {
|
||||
var wpId = wp.workplace_id;
|
||||
var tbm = tbmByWorkplace[wpId];
|
||||
var visitors = visitorsByWorkplace[wpId];
|
||||
var moved = movedByWorkplace[wpId];
|
||||
var issues = issuesByWorkplace[wpId];
|
||||
|
||||
var hasAny = tbm || visitors || moved || issues;
|
||||
|
||||
html += '<div class="md-wp-card" data-wp-id="' + wpId + '">';
|
||||
|
||||
// 헤더 (클릭 영역)
|
||||
html += '<div class="md-wp-header">';
|
||||
html += '<h3 class="md-wp-name">' + escapeHtml(wp.workplace_name);
|
||||
if (hasAny) {
|
||||
html += '<span class="md-wp-toggle">▼</span>';
|
||||
}
|
||||
html += '</h3>';
|
||||
|
||||
if (!hasAny) {
|
||||
html += '<p class="md-wp-no-activity">오늘 활동이 없습니다</p>';
|
||||
} else {
|
||||
html += '<div class="md-wp-stats">';
|
||||
|
||||
// TBM 작업
|
||||
if (tbm) {
|
||||
html += '<div class="md-wp-stat-row">' +
|
||||
'<span class="md-wp-stat-icon">🛠</span>' +
|
||||
'<span class="md-wp-stat-text">작업 ' + tbm.taskCount + '건 · ' + tbm.totalWorkers + '명</span>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// 방문
|
||||
if (visitors) {
|
||||
html += '<div class="md-wp-stat-row">' +
|
||||
'<span class="md-wp-stat-icon">🚪</span>' +
|
||||
'<span class="md-wp-stat-text">방문 ' + visitors.visitCount + '건 · ' + visitors.totalVisitors + '명</span>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// 신고 (미완료만)
|
||||
if (issues && issues.activeCount > 0) {
|
||||
html += '<div class="md-wp-stat-row md-wp-stat--warning">' +
|
||||
'<span class="md-wp-stat-icon">⚠</span>' +
|
||||
'<span class="md-wp-stat-text">신고 ' + issues.activeCount + '건</span>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// 이동설비
|
||||
if (moved && moved.movedCount > 0) {
|
||||
html += '<div class="md-wp-stat-row">' +
|
||||
'<span class="md-wp-stat-icon">↔</span>' +
|
||||
'<span class="md-wp-stat-text">이동설비 ' + moved.movedCount + '건</span>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>'; // .md-wp-header
|
||||
|
||||
// 상세 영역 (활동 있는 카드만)
|
||||
if (hasAny) {
|
||||
html += '<div class="md-wp-detail">' + renderCardDetail(wpId) + '</div>';
|
||||
}
|
||||
|
||||
html += '</div>'; // .md-wp-card
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// 클릭 이벤트 바인딩
|
||||
var cards = container.querySelectorAll('.md-wp-card[data-wp-id]');
|
||||
cards.forEach(function(card) {
|
||||
var wpId = card.getAttribute('data-wp-id');
|
||||
var hasActivity = tbmByWorkplace[wpId] || visitorsByWorkplace[wpId] ||
|
||||
movedByWorkplace[wpId] || issuesByWorkplace[wpId];
|
||||
if (!hasActivity) return;
|
||||
card.querySelector('.md-wp-header').addEventListener('click', function() {
|
||||
toggleCard(wpId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 카드 확장/접기 ====================
|
||||
|
||||
function toggleCard(wpId) {
|
||||
var allCards = document.querySelectorAll('.md-wp-card.expanded');
|
||||
var targetCard = document.querySelector('.md-wp-card[data-wp-id="' + wpId + '"]');
|
||||
if (!targetCard) return;
|
||||
|
||||
var isExpanded = targetCard.classList.contains('expanded');
|
||||
|
||||
// 다른 카드 모두 접기 (아코디언)
|
||||
allCards.forEach(function(card) {
|
||||
card.classList.remove('expanded');
|
||||
});
|
||||
|
||||
// 토글
|
||||
if (!isExpanded) {
|
||||
targetCard.classList.add('expanded');
|
||||
}
|
||||
}
|
||||
|
||||
function renderCardDetail(wpId) {
|
||||
var html = '';
|
||||
var tbm = tbmByWorkplace[wpId];
|
||||
var visitors = visitorsByWorkplace[wpId];
|
||||
var issues = issuesByWorkplace[wpId];
|
||||
var moved = movedByWorkplace[wpId];
|
||||
|
||||
// TBM 작업
|
||||
if (tbm && tbm.sessions.length > 0) {
|
||||
html += '<div class="md-wp-detail-section">';
|
||||
html += '<div class="md-wp-detail-title">▶ 작업</div>';
|
||||
tbm.sessions.forEach(function(s) {
|
||||
var taskName = s.task_name || '작업명 미지정';
|
||||
var leaderName = s.leader_name || '미지정';
|
||||
var memberCount = (parseInt(s.team_member_count) || 0) + 1;
|
||||
html += '<div class="md-wp-detail-item">';
|
||||
html += '<div class="md-wp-detail-main">' + escapeHtml(taskName) + '</div>';
|
||||
html += '<div class="md-wp-detail-sub">' + escapeHtml(leaderName) + ' · ' + memberCount + '명</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// 방문
|
||||
if (visitors && visitors.requests.length > 0) {
|
||||
html += '<div class="md-wp-detail-section">';
|
||||
html += '<div class="md-wp-detail-title">▶ 방문</div>';
|
||||
visitors.requests.forEach(function(r) {
|
||||
var company = r.visitor_company || '업체 미지정';
|
||||
var count = parseInt(r.visitor_count) || 0;
|
||||
var purpose = r.purpose_name || '';
|
||||
html += '<div class="md-wp-detail-item">';
|
||||
html += '<div class="md-wp-detail-main">' + escapeHtml(company) + ' · ' + count + '명';
|
||||
if (purpose) html += ' · ' + escapeHtml(purpose);
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// 신고
|
||||
if (issues && issues.items.length > 0) {
|
||||
var statusMap = { reported: '신고', received: '접수', in_progress: '처리중' };
|
||||
html += '<div class="md-wp-detail-section">';
|
||||
html += '<div class="md-wp-detail-title">▶ 신고</div>';
|
||||
issues.items.forEach(function(issue) {
|
||||
var category = issue.issue_category_name || '미분류';
|
||||
var desc = issue.additional_description || '';
|
||||
if (desc.length > 30) desc = desc.substring(0, 30) + '...';
|
||||
var statusText = statusMap[issue.status] || issue.status;
|
||||
var statusClass = 'md-wp-issue-status--' + (issue.status || 'reported');
|
||||
var reporter = issue.reporter_name || '';
|
||||
var icon = issue.status === 'in_progress' ? '🔴' : '⚠';
|
||||
html += '<div class="md-wp-detail-item">';
|
||||
html += '<div class="md-wp-detail-main">' + icon + ' ' + escapeHtml(category);
|
||||
if (desc) html += ' · ' + escapeHtml(desc);
|
||||
html += '</div>';
|
||||
html += '<div class="md-wp-detail-sub"><span class="md-wp-issue-status ' + statusClass + '">' + statusText + '</span>';
|
||||
if (reporter) html += ' → ' + escapeHtml(reporter);
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// 이동설비
|
||||
if (moved && moved.items.length > 0) {
|
||||
html += '<div class="md-wp-detail-section">';
|
||||
html += '<div class="md-wp-detail-title">▶ 이동설비</div>';
|
||||
moved.items.forEach(function(eq) {
|
||||
var eqName = eq.equipment_name || '설비명 미지정';
|
||||
var fromWp = eq.original_workplace_name || '?';
|
||||
var toWp = eq.current_workplace_name || '?';
|
||||
html += '<div class="md-wp-detail-item">';
|
||||
html += '<div class="md-wp-detail-main">' + escapeHtml(eqName) + '</div>';
|
||||
html += '<div class="md-wp-detail-sub">' + escapeHtml(fromWp) + ' → ' + escapeHtml(toWp) + '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// ==================== 초기화 ====================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
try {
|
||||
await waitForApi();
|
||||
} catch (e) {
|
||||
console.error('mobile-dashboard: apiCall not available');
|
||||
return;
|
||||
}
|
||||
|
||||
var view = document.getElementById('mobileDashboardView');
|
||||
if (!view) return;
|
||||
view.style.display = 'block';
|
||||
|
||||
// 날짜 표시
|
||||
var now = new Date();
|
||||
var days = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
var dateEl = document.getElementById('mDateValue');
|
||||
if (dateEl) {
|
||||
dateEl.textContent = now.getFullYear() + '.' +
|
||||
String(now.getMonth() + 1).padStart(2, '0') + '.' +
|
||||
String(now.getDate()).padStart(2, '0') + ' (' + days[now.getDay()] + ')';
|
||||
}
|
||||
|
||||
// 로딩 표시
|
||||
var listContainer = document.getElementById('mWorkplaceList');
|
||||
if (listContainer) {
|
||||
listContainer.innerHTML =
|
||||
'<div class="md-skeleton"></div>' +
|
||||
'<div class="md-skeleton" style="margin-top:8px;"></div>' +
|
||||
'<div class="md-skeleton" style="margin-top:8px;"></div>';
|
||||
}
|
||||
|
||||
// 데이터 병렬 로딩
|
||||
var results = await Promise.allSettled([
|
||||
window.apiCall('/workplaces/categories'),
|
||||
window.apiCall('/tbm/sessions/date/' + today),
|
||||
window.apiCall('/workplace-visits/requests?visit_date=' + today + '&status=approved'),
|
||||
window.apiCall('/equipments/moved/list'),
|
||||
window.apiCall('/work-issues?start_date=' + today + '&end_date=' + today),
|
||||
window.apiCall('/workplaces')
|
||||
]);
|
||||
|
||||
// 카테고리
|
||||
if (results[0].status === 'fulfilled' && results[0].value && results[0].value.success) {
|
||||
categories = results[0].value.data || [];
|
||||
}
|
||||
|
||||
// TBM
|
||||
if (results[1].status === 'fulfilled' && results[1].value && results[1].value.success) {
|
||||
groupTbmByWorkplace(results[1].value.data || []);
|
||||
}
|
||||
|
||||
// 방문
|
||||
if (results[2].status === 'fulfilled' && results[2].value && results[2].value.success) {
|
||||
groupVisitorsByWorkplace(results[2].value.data || []);
|
||||
}
|
||||
|
||||
// 이동설비
|
||||
if (results[3].status === 'fulfilled' && results[3].value && results[3].value.success) {
|
||||
groupMovedByWorkplace(results[3].value.data || []);
|
||||
}
|
||||
|
||||
// 신고
|
||||
if (results[4].status === 'fulfilled' && results[4].value && results[4].value.success) {
|
||||
groupIssuesByWorkplace(results[4].value.data || []);
|
||||
}
|
||||
|
||||
// 작업장 전체 (카테고리별 그룹핑)
|
||||
if (results[5].status === 'fulfilled' && results[5].value && results[5].value.success) {
|
||||
allWorkplaces = results[5].value.data || [];
|
||||
groupWorkplacesByCategory(allWorkplaces);
|
||||
}
|
||||
|
||||
// 렌더링
|
||||
renderCategoryTabs();
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user