refactor: 코드 관리 페이지 삭제 및 프론트엔드 모듈화

- codes.html, code-management.js 삭제 (tasks.html에서 동일 기능 제공)
- 사이드바에서 코드 관리 링크 제거
- daily-work-report, tbm, workplace-management JS 모듈 분리
- common/security.js 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-05 06:42:12 +09:00
parent 36f110c90a
commit 170adcc149
25 changed files with 6202 additions and 1606 deletions

View File

@@ -1,766 +0,0 @@
// 코드 관리 페이지 JavaScript
// 전역 변수
let workStatusTypes = [];
let errorTypes = [];
let workTypes = [];
let currentCodeType = 'work-status';
let currentEditingCode = null;
// 페이지 초기화
document.addEventListener('DOMContentLoaded', function() {
console.log('🏷️ 코드 관리 페이지 초기화 시작');
initializePage();
loadAllCodes();
});
// 페이지 초기화
function initializePage() {
// 시간 업데이트 시작
updateCurrentTime();
setInterval(updateCurrentTime, 1000);
// 사용자 정보 업데이트
updateUserInfo();
// 프로필 메뉴 토글
setupProfileMenu();
// 로그아웃 버튼
setupLogoutButton();
}
// 현재 시간 업데이트
function updateCurrentTime() {
const now = new Date();
const timeString = now.toLocaleTimeString('ko-KR', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
const timeElement = document.getElementById('timeValue');
if (timeElement) {
timeElement.textContent = timeString;
}
}
// 사용자 정보 업데이트
// navbar/sidebar는 app-init.js에서 공통 처리
function updateUserInfo() {
// app-init.js가 navbar 사용자 정보를 처리
}
// 프로필 메뉴 설정
function setupProfileMenu() {
const userProfile = document.getElementById('userProfile');
const profileMenu = document.getElementById('profileMenu');
if (userProfile && profileMenu) {
userProfile.addEventListener('click', function(e) {
e.stopPropagation();
const isVisible = profileMenu.style.display === 'block';
profileMenu.style.display = isVisible ? 'none' : 'block';
});
// 외부 클릭 시 메뉴 닫기
document.addEventListener('click', function() {
profileMenu.style.display = 'none';
});
}
}
// 로그아웃 버튼 설정
function setupLogoutButton() {
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) {
logoutBtn.addEventListener('click', function() {
if (confirm('로그아웃 하시겠습니까?')) {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('userInfo');
window.location.href = '/index.html';
}
});
}
}
// 모든 코드 데이터 로드
async function loadAllCodes() {
try {
console.log('📊 모든 코드 데이터 로딩 시작');
await Promise.all([
loadWorkStatusTypes(),
// loadErrorTypes(), // 오류 유형은 신고 시스템으로 대체됨
loadWorkTypes()
]);
// 현재 활성 탭 렌더링
renderCurrentTab();
} catch (error) {
console.error('코드 데이터 로딩 오류:', error);
showToast('코드 데이터를 불러오는데 실패했습니다.', 'error');
}
}
// 작업 상태 유형 로드
async function loadWorkStatusTypes() {
try {
console.log('📊 작업 상태 유형 로딩...');
const response = await apiCall('/daily-work-reports/work-status-types', 'GET');
let statusData = [];
if (response && response.success && Array.isArray(response.data)) {
statusData = response.data;
} else if (Array.isArray(response)) {
statusData = response;
}
workStatusTypes = statusData;
console.log(`✅ 작업 상태 유형 ${workStatusTypes.length}개 로드 완료`);
} catch (error) {
console.error('작업 상태 유형 로딩 오류:', error);
workStatusTypes = [];
}
}
// 오류 유형 로드
async function loadErrorTypes() {
try {
console.log('⚠️ 오류 유형 로딩...');
const response = await apiCall('/daily-work-reports/error-types', 'GET');
let errorData = [];
if (response && response.success && Array.isArray(response.data)) {
errorData = response.data;
} else if (Array.isArray(response)) {
errorData = response;
}
errorTypes = errorData;
console.log(`✅ 오류 유형 ${errorTypes.length}개 로드 완료`);
} catch (error) {
console.error('오류 유형 로딩 오류:', error);
errorTypes = [];
}
}
// 작업 유형 로드
async function loadWorkTypes() {
try {
console.log('🔧 작업 유형 로딩...');
const response = await apiCall('/daily-work-reports/work-types', 'GET');
let typeData = [];
if (response && response.success && Array.isArray(response.data)) {
typeData = response.data;
} else if (Array.isArray(response)) {
typeData = response;
}
workTypes = typeData;
console.log(`✅ 작업 유형 ${workTypes.length}개 로드 완료`);
} catch (error) {
console.error('작업 유형 로딩 오류:', error);
workTypes = [];
}
}
// 코드 탭 전환
function switchCodeTab(tabName) {
// 탭 버튼 활성화 상태 변경
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('active');
});
document.querySelector(`[data-tab="${tabName}"]`).classList.add('active');
// 탭 콘텐츠 표시/숨김
document.querySelectorAll('.code-tab-content').forEach(content => {
content.classList.remove('active');
});
document.getElementById(`${tabName}-tab`).classList.add('active');
currentCodeType = tabName;
renderCurrentTab();
}
// 현재 탭 렌더링
function renderCurrentTab() {
switch (currentCodeType) {
case 'work-status':
renderWorkStatusTypes();
break;
// case 'error-types': // 오류 유형은 신고 시스템으로 대체됨
// renderErrorTypes();
// break;
case 'work-types':
renderWorkTypes();
break;
}
}
// 작업 상태 유형 렌더링
function renderWorkStatusTypes() {
const grid = document.getElementById('workStatusGrid');
if (!grid) return;
if (workStatusTypes.length === 0) {
grid.innerHTML = `
<div class="empty-state">
<div class="empty-icon">📊</div>
<h3>등록된 작업 상태 유형이 없습니다.</h3>
<p>"새 상태 추가" 버튼을 눌러 작업 상태를 등록해보세요.</p>
<button class="btn btn-primary" onclick="openCodeModal('work-status')">
첫 상태 추가하기
</button>
</div>
`;
updateWorkStatusStats();
return;
}
let gridHtml = '';
workStatusTypes.forEach(status => {
const isError = status.is_error === 1 || status.is_error === true;
const statusClass = isError ? 'error-status' : 'normal-status';
const statusIcon = isError ? '❌' : '✅';
const statusLabel = isError ? '오류' : '정상';
gridHtml += `
<div class="code-card ${statusClass}" onclick="editCode('work-status', ${status.id})">
<div class="code-header">
<div class="code-icon">${statusIcon}</div>
<div class="code-info">
<h3 class="code-name">${status.name}</h3>
<span class="code-label">${statusLabel}</span>
</div>
<div class="code-actions">
<button class="btn-small btn-edit" onclick="event.stopPropagation(); editCode('work-status', ${status.id})" title="수정">
✏️
</button>
<button class="btn-small btn-delete" onclick="event.stopPropagation(); confirmDeleteCode('work-status', ${status.id})" title="삭제">
🗑️
</button>
</div>
</div>
${status.description ? `<p class="code-description">${status.description}</p>` : ''}
<div class="code-meta">
<span class="code-date">등록: ${formatDate(status.created_at)}</span>
</div>
</div>
`;
});
grid.innerHTML = gridHtml;
updateWorkStatusStats();
}
// 오류 유형 렌더링
function renderErrorTypes() {
const grid = document.getElementById('errorTypesGrid');
if (!grid) return;
if (errorTypes.length === 0) {
grid.innerHTML = `
<div class="empty-state">
<div class="empty-icon">⚠️</div>
<h3>등록된 오류 유형이 없습니다.</h3>
<p>"새 오류 유형 추가" 버튼을 눌러 오류 유형을 등록해보세요.</p>
<button class="btn btn-primary" onclick="openCodeModal('error-types')">
첫 오류 유형 추가하기
</button>
</div>
`;
updateErrorTypesStats();
return;
}
let gridHtml = '';
errorTypes.forEach(error => {
const severityMap = {
'low': { icon: '🟢', label: '낮음', class: 'severity-low' },
'medium': { icon: '🟡', label: '보통', class: 'severity-medium' },
'high': { icon: '🟠', label: '높음', class: 'severity-high' },
'critical': { icon: '🔴', label: '심각', class: 'severity-critical' }
};
const severity = severityMap[error.severity] || severityMap.medium;
gridHtml += `
<div class="code-card error-type-card ${severity.class}" onclick="editCode('error-types', ${error.id})">
<div class="code-header">
<div class="code-icon">⚠️</div>
<div class="code-info">
<h3 class="code-name">${error.name}</h3>
<span class="code-label">${severity.icon} ${severity.label}</span>
</div>
<div class="code-actions">
<button class="btn-small btn-edit" onclick="event.stopPropagation(); editCode('error-types', ${error.id})" title="수정">
✏️
</button>
<button class="btn-small btn-delete" onclick="event.stopPropagation(); confirmDeleteCode('error-types', ${error.id})" title="삭제">
🗑️
</button>
</div>
</div>
${error.description ? `<p class="code-description">${error.description}</p>` : ''}
${error.solution_guide ? `<div class="solution-guide"><strong>해결 가이드:</strong><br>${error.solution_guide}</div>` : ''}
<div class="code-meta">
<span class="code-date">등록: ${formatDate(error.created_at)}</span>
${error.updated_at !== error.created_at ? `<span class="code-date">수정: ${formatDate(error.updated_at)}</span>` : ''}
</div>
</div>
`;
});
grid.innerHTML = gridHtml;
updateErrorTypesStats();
}
// 작업 유형 렌더링
function renderWorkTypes() {
const grid = document.getElementById('workTypesGrid');
if (!grid) return;
if (workTypes.length === 0) {
grid.innerHTML = `
<div class="empty-state">
<div class="empty-icon">🔧</div>
<h3>등록된 작업 유형이 없습니다.</h3>
<p>"새 작업 유형 추가" 버튼을 눌러 작업 유형을 등록해보세요.</p>
<button class="btn btn-primary" onclick="openCodeModal('work-types')">
첫 작업 유형 추가하기
</button>
</div>
`;
updateWorkTypesStats();
return;
}
let gridHtml = '';
workTypes.forEach(type => {
gridHtml += `
<div class="code-card work-type-card" onclick="editCode('work-types', ${type.id})">
<div class="code-header">
<div class="code-icon">🔧</div>
<div class="code-info">
<h3 class="code-name">${type.name}</h3>
${type.category ? `<span class="code-label">📁 ${type.category}</span>` : ''}
</div>
<div class="code-actions">
<button class="btn-small btn-edit" onclick="event.stopPropagation(); editCode('work-types', ${type.id})" title="수정">
✏️
</button>
<button class="btn-small btn-delete" onclick="event.stopPropagation(); confirmDeleteCode('work-types', ${type.id})" title="삭제">
🗑️
</button>
</div>
</div>
${type.description ? `<p class="code-description">${type.description}</p>` : ''}
<div class="code-meta">
<span class="code-date">등록: ${formatDate(type.created_at)}</span>
${type.updated_at !== type.created_at ? `<span class="code-date">수정: ${formatDate(type.updated_at)}</span>` : ''}
</div>
</div>
`;
});
grid.innerHTML = gridHtml;
updateWorkTypesStats();
}
// 작업 상태 통계 업데이트
function updateWorkStatusStats() {
const total = workStatusTypes.length;
const normal = workStatusTypes.filter(s => !s.is_error).length;
const error = workStatusTypes.filter(s => s.is_error).length;
document.getElementById('workStatusCount').textContent = total;
document.getElementById('normalStatusCount').textContent = normal;
document.getElementById('errorStatusCount').textContent = error;
}
// 오류 유형 통계 업데이트
function updateErrorTypesStats() {
const total = errorTypes.length;
const critical = errorTypes.filter(e => e.severity === 'critical').length;
const high = errorTypes.filter(e => e.severity === 'high').length;
const medium = errorTypes.filter(e => e.severity === 'medium').length;
const low = errorTypes.filter(e => e.severity === 'low').length;
document.getElementById('errorTypesCount').textContent = total;
document.getElementById('criticalErrorsCount').textContent = critical;
document.getElementById('highErrorsCount').textContent = high;
document.getElementById('mediumErrorsCount').textContent = medium;
document.getElementById('lowErrorsCount').textContent = low;
}
// 작업 유형 통계 업데이트
function updateWorkTypesStats() {
const total = workTypes.length;
const categories = new Set(workTypes.map(t => t.category).filter(Boolean)).size;
document.getElementById('workTypesCount').textContent = total;
document.getElementById('workCategoriesCount').textContent = categories;
}
// 코드 모달 열기
function openCodeModal(codeType, codeData = null) {
const modal = document.getElementById('codeModal');
const modalTitle = document.getElementById('modalTitle');
const deleteBtn = document.getElementById('deleteCodeBtn');
if (!modal) return;
currentEditingCode = codeData;
// 모든 전용 필드 숨기기
document.getElementById('isErrorGroup').style.display = 'none';
document.getElementById('severityGroup').style.display = 'none';
document.getElementById('solutionGuideGroup').style.display = 'none';
document.getElementById('categoryGroup').style.display = 'none';
// 코드 유형별 설정
switch (codeType) {
case 'work-status':
modalTitle.textContent = codeData ? '작업 상태 수정' : '새 작업 상태 추가';
document.getElementById('isErrorGroup').style.display = 'block';
break;
case 'error-types':
modalTitle.textContent = codeData ? '오류 유형 수정' : '새 오류 유형 추가';
document.getElementById('severityGroup').style.display = 'block';
document.getElementById('solutionGuideGroup').style.display = 'block';
break;
case 'work-types':
modalTitle.textContent = codeData ? '작업 유형 수정' : '새 작업 유형 추가';
document.getElementById('categoryGroup').style.display = 'block';
updateCategoryList();
break;
}
document.getElementById('codeType').value = codeType;
if (codeData) {
// 수정 모드
deleteBtn.style.display = 'inline-flex';
// 폼에 데이터 채우기
document.getElementById('codeId').value = codeData.id;
document.getElementById('codeName').value = codeData.name || '';
document.getElementById('codeDescription').value = codeData.description || '';
// 코드 유형별 필드 채우기
if (codeType === 'work-status') {
document.getElementById('isError').checked = codeData.is_error === 1 || codeData.is_error === true;
} else if (codeType === 'error-types') {
document.getElementById('severity').value = codeData.severity || 'medium';
document.getElementById('solutionGuide').value = codeData.solution_guide || '';
} else if (codeType === 'work-types') {
document.getElementById('category').value = codeData.category || '';
}
} else {
// 신규 등록 모드
deleteBtn.style.display = 'none';
// 폼 초기화
document.getElementById('codeForm').reset();
document.getElementById('codeId').value = '';
document.getElementById('codeType').value = codeType;
}
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
// 첫 번째 입력 필드에 포커스
setTimeout(() => {
document.getElementById('codeName').focus();
}, 100);
}
// 카테고리 목록 업데이트
function updateCategoryList() {
const categoryList = document.getElementById('categoryList');
if (categoryList) {
const categories = [...new Set(workTypes.map(t => t.category).filter(Boolean))].sort();
categoryList.innerHTML = categories.map(cat => `<option value="${cat}">`).join('');
}
}
// 코드 모달 닫기
function closeCodeModal() {
const modal = document.getElementById('codeModal');
if (modal) {
modal.style.display = 'none';
document.body.style.overflow = '';
currentEditingCode = null;
}
}
// 코드 편집
function editCode(codeType, codeId) {
let codeData = null;
switch (codeType) {
case 'work-status':
codeData = workStatusTypes.find(s => s.id === codeId);
break;
case 'error-types':
codeData = errorTypes.find(e => e.id === codeId);
break;
case 'work-types':
codeData = workTypes.find(t => t.id === codeId);
break;
}
if (codeData) {
openCodeModal(codeType, codeData);
} else {
showToast('코드를 찾을 수 없습니다.', 'error');
}
}
// 코드 저장
async function saveCode() {
try {
const codeType = document.getElementById('codeType').value;
const codeId = document.getElementById('codeId').value;
const codeData = {
name: document.getElementById('codeName').value.trim(),
description: document.getElementById('codeDescription').value.trim() || null
};
// 필수 필드 검증
if (!codeData.name) {
showToast('이름은 필수 입력 항목입니다.', 'error');
return;
}
// 코드 유형별 추가 필드
if (codeType === 'work-status') {
codeData.is_error = document.getElementById('isError').checked ? 1 : 0;
} else if (codeType === 'error-types') {
codeData.severity = document.getElementById('severity').value;
codeData.solution_guide = document.getElementById('solutionGuide').value.trim() || null;
} else if (codeType === 'work-types') {
codeData.category = document.getElementById('category').value.trim() || null;
}
console.log('💾 저장할 코드 데이터:', codeData);
let endpoint = '';
switch (codeType) {
case 'work-status':
endpoint = '/daily-work-reports/work-status-types';
break;
case 'error-types':
endpoint = '/daily-work-reports/error-types';
break;
case 'work-types':
endpoint = '/daily-work-reports/work-types';
break;
}
let response;
if (codeId) {
// 수정
response = await apiCall(`${endpoint}/${codeId}`, 'PUT', codeData);
} else {
// 신규 등록
response = await apiCall(endpoint, 'POST', codeData);
}
if (response && (response.success || response.id)) {
const action = codeId ? '수정' : '등록';
showToast(`코드가 성공적으로 ${action}되었습니다.`, 'success');
closeCodeModal();
await loadAllCodes();
} else {
throw new Error(response?.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('코드 저장 오류:', error);
showToast(error.message || '코드 저장 중 오류가 발생했습니다.', 'error');
}
}
// 코드 삭제 확인
function confirmDeleteCode(codeType, codeId) {
let codeData = null;
let typeName = '';
switch (codeType) {
case 'work-status':
codeData = workStatusTypes.find(s => s.id === codeId);
typeName = '작업 상태';
break;
case 'error-types':
codeData = errorTypes.find(e => e.id === codeId);
typeName = '오류 유형';
break;
case 'work-types':
codeData = workTypes.find(t => t.id === codeId);
typeName = '작업 유형';
break;
}
if (!codeData) {
showToast('코드를 찾을 수 없습니다.', 'error');
return;
}
if (confirm(`"${codeData.name}" ${typeName}을 정말 삭제하시겠습니까?\n\n⚠️ 삭제된 코드는 복구할 수 없습니다.`)) {
deleteCodeById(codeType, codeId);
}
}
// 코드 삭제 (수정 모드에서)
function deleteCode() {
if (currentEditingCode) {
const codeType = document.getElementById('codeType').value;
confirmDeleteCode(codeType, currentEditingCode.id);
}
}
// 코드 삭제 실행
async function deleteCodeById(codeType, codeId) {
try {
let endpoint = '';
switch (codeType) {
case 'work-status':
endpoint = '/daily-work-reports/work-status-types';
break;
case 'error-types':
endpoint = '/daily-work-reports/error-types';
break;
case 'work-types':
endpoint = '/daily-work-reports/work-types';
break;
}
const response = await apiCall(`${endpoint}/${codeId}`, 'DELETE');
if (response && response.success) {
showToast('코드가 성공적으로 삭제되었습니다.', 'success');
closeCodeModal();
await loadAllCodes();
} else {
throw new Error(response?.message || '삭제에 실패했습니다.');
}
} catch (error) {
console.error('코드 삭제 오류:', error);
showToast(error.message || '코드 삭제 중 오류가 발생했습니다.', 'error');
}
}
// 전체 새로고침
async function refreshAllCodes() {
const refreshBtn = document.querySelector('.btn-secondary');
if (refreshBtn) {
const originalText = refreshBtn.innerHTML;
refreshBtn.innerHTML = '<span class="btn-icon">⏳</span>새로고침 중...';
refreshBtn.disabled = true;
await loadAllCodes();
refreshBtn.innerHTML = originalText;
refreshBtn.disabled = false;
} else {
await loadAllCodes();
}
showToast('모든 코드 데이터가 새로고침되었습니다.', 'success');
}
// 날짜 포맷팅
function formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
}
// 토스트 메시지 표시
function showToast(message, type = 'info') {
// 기존 토스트 제거
const existingToast = document.querySelector('.toast');
if (existingToast) {
existingToast.remove();
}
// 새 토스트 생성
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
// 스타일 적용
Object.assign(toast.style, {
position: 'fixed',
top: '20px',
right: '20px',
padding: '12px 24px',
borderRadius: '8px',
color: 'white',
fontWeight: '500',
zIndex: '1000',
transform: 'translateX(100%)',
transition: 'transform 0.3s ease'
});
// 타입별 배경색
const colors = {
success: '#10b981',
error: '#ef4444',
warning: '#f59e0b',
info: '#3b82f6'
};
toast.style.backgroundColor = colors[type] || colors.info;
document.body.appendChild(toast);
// 애니메이션
setTimeout(() => {
toast.style.transform = 'translateX(0)';
}, 100);
// 자동 제거
setTimeout(() => {
toast.style.transform = 'translateX(100%)';
setTimeout(() => {
if (toast.parentNode) {
toast.remove();
}
}, 300);
}, 3000);
}
// 전역 함수로 노출
window.switchCodeTab = switchCodeTab;
window.openCodeModal = openCodeModal;
window.closeCodeModal = closeCodeModal;
window.editCode = editCode;
window.saveCode = saveCode;
window.deleteCode = deleteCode;
window.confirmDeleteCode = confirmDeleteCode;
window.refreshAllCodes = refreshAllCodes;

View File

@@ -0,0 +1,259 @@
/**
* Security Utilities - 보안 관련 유틸리티 함수
*
* XSS 방지, 입력값 검증, 안전한 DOM 조작을 위한 함수 모음
*
* @author TK-FB-Project
* @since 2026-02-04
*/
(function(global) {
'use strict';
const SecurityUtils = {
/**
* HTML 특수문자 이스케이프 (XSS 방지)
* innerHTML에 사용자 입력을 삽입할 때 반드시 사용
*
* @param {string} str - 이스케이프할 문자열
* @returns {string} 이스케이프된 문자열
*
* @example
* element.innerHTML = `<span>${SecurityUtils.escapeHtml(userInput)}</span>`;
*/
escapeHtml: function(str) {
if (str === null || str === undefined) return '';
if (typeof str !== 'string') str = String(str);
const htmlEntities = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
'/': '&#x2F;',
'`': '&#x60;',
'=': '&#x3D;'
};
return str.replace(/[&<>"'`=\/]/g, function(char) {
return htmlEntities[char];
});
},
/**
* URL 파라미터 이스케이프
* URL에 사용자 입력을 포함할 때 사용
*
* @param {string} str - 이스케이프할 문자열
* @returns {string} URL 인코딩된 문자열
*/
escapeUrl: function(str) {
if (str === null || str === undefined) return '';
return encodeURIComponent(String(str));
},
/**
* JavaScript 문자열 이스케이프
* 동적 JavaScript 생성 시 사용 (권장하지 않음)
*
* @param {string} str - 이스케이프할 문자열
* @returns {string} 이스케이프된 문자열
*/
escapeJs: function(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t');
},
/**
* 안전한 텍스트 설정
* innerHTML 대신 textContent 사용 권장
*
* @param {Element} element - DOM 요소
* @param {string} text - 설정할 텍스트
*/
setTextSafe: function(element, text) {
if (element && element.nodeType === 1) {
element.textContent = text;
}
},
/**
* 안전한 HTML 삽입
* 사용자 입력이 포함된 HTML을 삽입할 때 사용
*
* @param {Element} element - DOM 요소
* @param {string} template - HTML 템플릿 ({{변수}} 형식)
* @param {Object} data - 삽입할 데이터 (자동 이스케이프됨)
*
* @example
* SecurityUtils.setHtmlSafe(div, '<span>{{name}}</span>', { name: userInput });
*/
setHtmlSafe: function(element, template, data) {
if (!element || element.nodeType !== 1) return;
const self = this;
const safeHtml = template.replace(/\{\{(\w+)\}\}/g, function(match, key) {
return data.hasOwnProperty(key) ? self.escapeHtml(data[key]) : '';
});
element.innerHTML = safeHtml;
},
/**
* 입력값 검증 - 숫자
*
* @param {any} value - 검증할 값
* @param {Object} options - 옵션 { min, max, allowFloat }
* @returns {number|null} 유효한 숫자 또는 null
*/
validateNumber: function(value, options) {
options = options || {};
const num = options.allowFloat ? parseFloat(value) : parseInt(value, 10);
if (isNaN(num)) return null;
if (options.min !== undefined && num < options.min) return null;
if (options.max !== undefined && num > options.max) return null;
return num;
},
/**
* 입력값 검증 - 이메일
*
* @param {string} email - 검증할 이메일
* @returns {boolean} 유효 여부
*/
validateEmail: function(email) {
if (!email || typeof email !== 'string') return false;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
},
/**
* 입력값 검증 - 길이
*
* @param {string} str - 검증할 문자열
* @param {Object} options - 옵션 { min, max }
* @returns {boolean} 유효 여부
*/
validateLength: function(str, options) {
options = options || {};
if (!str || typeof str !== 'string') return false;
const len = str.length;
if (options.min !== undefined && len < options.min) return false;
if (options.max !== undefined && len > options.max) return false;
return true;
},
/**
* 안전한 JSON 파싱
*
* @param {string} jsonString - 파싱할 JSON 문자열
* @param {any} defaultValue - 파싱 실패 시 기본값
* @returns {any} 파싱된 객체 또는 기본값
*/
parseJsonSafe: function(jsonString, defaultValue) {
defaultValue = defaultValue === undefined ? null : defaultValue;
try {
return JSON.parse(jsonString);
} catch (e) {
console.warn('[SecurityUtils] JSON 파싱 실패:', e.message);
return defaultValue;
}
},
/**
* localStorage에서 안전하게 데이터 가져오기
*
* @param {string} key - 키
* @param {any} defaultValue - 기본값
* @returns {any} 저장된 값 또는 기본값
*/
getStorageSafe: function(key, defaultValue) {
try {
const item = localStorage.getItem(key);
if (item === null) return defaultValue;
return this.parseJsonSafe(item, defaultValue);
} catch (e) {
console.warn('[SecurityUtils] localStorage 접근 실패:', e.message);
return defaultValue;
}
},
/**
* URL 파라미터 안전하게 가져오기
*
* @param {string} name - 파라미터 이름
* @param {string} defaultValue - 기본값
* @returns {string} 파라미터 값 (이스케이프됨)
*/
getUrlParamSafe: function(name, defaultValue) {
defaultValue = defaultValue === undefined ? '' : defaultValue;
try {
const urlParams = new URLSearchParams(window.location.search);
const value = urlParams.get(name);
return value !== null ? value : defaultValue;
} catch (e) {
return defaultValue;
}
},
/**
* ID 파라미터 안전하게 가져오기 (숫자 검증)
*
* @param {string} name - 파라미터 이름
* @returns {number|null} 유효한 ID 또는 null
*/
getIdParamSafe: function(name) {
const value = this.getUrlParamSafe(name);
return this.validateNumber(value, { min: 1 });
},
/**
* Content Security Policy 위반 리포터
*
* @param {string} reportUri - 리포트 전송 URL
*/
enableCspReporting: function(reportUri) {
document.addEventListener('securitypolicyviolation', function(e) {
console.error('[CSP Violation]', {
blockedUri: e.blockedURI,
violatedDirective: e.violatedDirective,
originalPolicy: e.originalPolicy
});
if (reportUri) {
fetch(reportUri, {
method: 'POST',
body: JSON.stringify({
blocked_uri: e.blockedURI,
violated_directive: e.violatedDirective,
document_uri: e.documentURI,
timestamp: new Date().toISOString()
}),
headers: { 'Content-Type': 'application/json' }
}).catch(function() {});
}
});
}
};
// 전역 노출
global.SecurityUtils = SecurityUtils;
// 편의를 위한 단축 함수
global.escapeHtml = SecurityUtils.escapeHtml.bind(SecurityUtils);
global.escapeUrl = SecurityUtils.escapeUrl.bind(SecurityUtils);
console.log('[Module] common/security.js 로드 완료');
})(typeof window !== 'undefined' ? window : this);

View File

@@ -0,0 +1,386 @@
/**
* Daily Work Report - API Client
* 작업보고서 관련 모든 API 호출을 관리
*/
class DailyWorkReportAPI {
constructor() {
this.state = window.DailyWorkReportState;
console.log('[API] DailyWorkReportAPI 초기화');
}
/**
* 작업자 로드 (생산팀 소속)
*/
async loadWorkers() {
try {
console.log('[API] Workers 로딩 중...');
const data = await window.apiCall('/workers?limit=1000&department_id=1');
const allWorkers = Array.isArray(data) ? data : (data.data || data.workers || []);
// 퇴사자만 제외
const filtered = allWorkers.filter(worker => worker.employment_status !== 'resigned');
this.state.workers = filtered;
console.log(`[API] Workers 로드 완료: ${filtered.length}`);
return filtered;
} catch (error) {
console.error('[API] 작업자 로딩 오류:', error);
throw error;
}
}
/**
* 프로젝트 로드 (활성 프로젝트만)
*/
async loadProjects() {
try {
console.log('[API] Projects 로딩 중...');
const data = await window.apiCall('/projects/active/list');
const projects = Array.isArray(data) ? data : (data.data || data.projects || []);
this.state.projects = projects;
console.log(`[API] Projects 로드 완료: ${projects.length}`);
return projects;
} catch (error) {
console.error('[API] 프로젝트 로딩 오류:', error);
throw error;
}
}
/**
* 작업 유형 로드
*/
async loadWorkTypes() {
try {
const data = await window.apiCall('/daily-work-reports/work-types');
if (Array.isArray(data) && data.length > 0) {
this.state.workTypes = data;
console.log('[API] 작업 유형 로드 완료:', data.length);
return data;
}
throw new Error('API 실패');
} catch (error) {
console.log('[API] 작업 유형 API 사용 불가, 기본값 사용');
this.state.workTypes = [
{ id: 1, name: 'Base' },
{ id: 2, name: 'Vessel' },
{ id: 3, name: 'Piping' }
];
return this.state.workTypes;
}
}
/**
* 업무 상태 유형 로드
*/
async loadWorkStatusTypes() {
try {
const data = await window.apiCall('/daily-work-reports/work-status-types');
if (Array.isArray(data) && data.length > 0) {
this.state.workStatusTypes = data;
console.log('[API] 업무 상태 유형 로드 완료:', data.length);
return data;
}
throw new Error('API 실패');
} catch (error) {
console.log('[API] 업무 상태 유형 API 사용 불가, 기본값 사용');
this.state.workStatusTypes = [
{ id: 1, name: '정상', is_error: false },
{ id: 2, name: '부적합', is_error: true }
];
return this.state.workStatusTypes;
}
}
/**
* 오류 유형 로드 (신고 카테고리/아이템)
*/
async loadErrorTypes() {
try {
// 1. 신고 카테고리 (nonconformity만)
const categoriesResponse = await window.apiCall('/work-issues/categories');
if (categoriesResponse.success && categoriesResponse.data) {
this.state.issueCategories = categoriesResponse.data.filter(
c => c.category_type === 'nonconformity'
);
console.log('[API] 신고 카테고리 로드:', this.state.issueCategories.length);
}
// 2. 신고 아이템 전체
const itemsResponse = await window.apiCall('/work-issues/items');
if (itemsResponse.success && itemsResponse.data) {
// nonconformity 카테고리의 아이템만 필터링
const nonconfCatIds = this.state.issueCategories.map(c => c.category_id);
this.state.issueItems = itemsResponse.data.filter(
item => nonconfCatIds.includes(item.category_id)
);
console.log('[API] 신고 아이템 로드:', this.state.issueItems.length);
}
// 레거시 호환: errorTypes에 카테고리 매핑
this.state.errorTypes = this.state.issueCategories.map(cat => ({
id: cat.category_id,
name: cat.category_name
}));
} catch (error) {
console.error('[API] 오류 유형 로딩 오류:', error);
// 기본값 설정
this.state.errorTypes = [
{ id: 1, name: '자재 부적합' },
{ id: 2, name: '도면 오류' },
{ id: 3, name: '장비 고장' }
];
}
}
/**
* 미완료 TBM 세션 로드
*/
async loadIncompleteTbms() {
try {
const response = await window.apiCall('/tbm/sessions/incomplete-reports');
if (!response.success) {
throw new Error(response.message || '미완료 TBM 조회 실패');
}
let data = response.data || [];
// 사용자 권한 확인 및 필터링
const user = this.state.getUser();
if (user && user.role !== 'Admin' && user.access_level !== 'system') {
const userId = user.user_id;
data = data.filter(tbm => tbm.created_by === userId);
}
this.state.incompleteTbms = data;
console.log('[API] 미완료 TBM 로드 완료:', data.length);
return data;
} catch (error) {
console.error('[API] 미완료 TBM 로드 오류:', error);
throw error;
}
}
/**
* TBM 세션별 당일 신고 로드
*/
async loadDailyIssuesForTbms() {
const tbms = this.state.incompleteTbms;
if (!tbms || tbms.length === 0) {
console.log('[API] 미완료 TBM 없음, 신고 조회 건너뜀');
return;
}
// 고유한 날짜 수집
const uniqueDates = [...new Set(tbms.map(tbm => {
return window.DailyWorkReportUtils?.formatDateForApi(tbm.session_date) ||
this.formatDateForApi(tbm.session_date);
}).filter(Boolean))];
console.log('[API] 조회할 날짜들:', uniqueDates);
for (const dateStr of uniqueDates) {
if (this.state.dailyIssuesCache[dateStr]) {
console.log(`[API] 캐시 사용 (${dateStr})`);
continue;
}
try {
const response = await window.apiCall(`/work-issues?start_date=${dateStr}&end_date=${dateStr}`);
if (response.success) {
this.state.setDailyIssuesCache(dateStr, response.data || []);
console.log(`[API] 신고 로드 완료 (${dateStr}):`, this.state.dailyIssuesCache[dateStr].length);
} else {
this.state.setDailyIssuesCache(dateStr, []);
}
} catch (error) {
console.error(`[API] 신고 조회 오류 (${dateStr}):`, error);
this.state.setDailyIssuesCache(dateStr, []);
}
}
}
/**
* 완료된 작업보고서 조회
*/
async loadCompletedReports(date) {
try {
const response = await window.apiCall(`/daily-work-reports/v2/reports?date=${date}`);
if (response.success) {
console.log(`[API] 완료 보고서 로드 (${date}):`, response.data?.length || 0);
return response.data || [];
}
throw new Error(response.message || '조회 실패');
} catch (error) {
console.error('[API] 완료 보고서 로드 오류:', error);
throw error;
}
}
/**
* TBM 작업보고서 제출
*/
async submitTbmWorkReport(reportData) {
try {
const response = await window.apiCall('/daily-work-reports/from-tbm', 'POST', reportData);
if (!response.success) {
throw new Error(response.message || '제출 실패');
}
console.log('[API] TBM 작업보고서 제출 완료:', response);
return response;
} catch (error) {
console.error('[API] TBM 작업보고서 제출 오류:', error);
throw error;
}
}
/**
* 수동 작업보고서 제출
*/
async submitManualWorkReport(reportData) {
try {
const response = await window.apiCall('/daily-work-reports/v2/reports', 'POST', reportData);
if (!response.success) {
throw new Error(response.message || '제출 실패');
}
console.log('[API] 수동 작업보고서 제출 완료:', response);
return response;
} catch (error) {
console.error('[API] 수동 작업보고서 제출 오류:', error);
throw error;
}
}
/**
* 작업보고서 삭제
*/
async deleteWorkReport(reportId) {
try {
const response = await window.apiCall(`/daily-work-reports/v2/reports/${reportId}`, 'DELETE');
if (!response.success) {
throw new Error(response.message || '삭제 실패');
}
console.log('[API] 작업보고서 삭제 완료:', reportId);
return response;
} catch (error) {
console.error('[API] 작업보고서 삭제 오류:', error);
throw error;
}
}
/**
* 작업보고서 수정
*/
async updateWorkReport(reportId, updateData) {
try {
const response = await window.apiCall(`/daily-work-reports/v2/reports/${reportId}`, 'PUT', updateData);
if (!response.success) {
throw new Error(response.message || '수정 실패');
}
console.log('[API] 작업보고서 수정 완료:', reportId);
return response;
} catch (error) {
console.error('[API] 작업보고서 수정 오류:', error);
throw error;
}
}
/**
* 신고 카테고리 추가
*/
async addIssueCategory(categoryData) {
try {
const response = await window.apiCall('/work-issues/categories', 'POST', categoryData);
if (response.success) {
await this.loadErrorTypes(); // 목록 새로고침
}
return response;
} catch (error) {
console.error('[API] 카테고리 추가 오류:', error);
throw error;
}
}
/**
* 신고 아이템 추가
*/
async addIssueItem(itemData) {
try {
const response = await window.apiCall('/work-issues/items', 'POST', itemData);
if (response.success) {
await this.loadErrorTypes(); // 목록 새로고침
}
return response;
} catch (error) {
console.error('[API] 아이템 추가 오류:', error);
throw error;
}
}
/**
* 모든 기본 데이터 로드
*/
async loadAllData() {
console.log('[API] 모든 기본 데이터 로딩 시작...');
await Promise.all([
this.loadWorkers(),
this.loadProjects(),
this.loadWorkTypes(),
this.loadWorkStatusTypes(),
this.loadErrorTypes()
]);
console.log('[API] 모든 기본 데이터 로딩 완료');
}
// 유틸리티: 날짜 형식 변환 (API 형식)
formatDateForApi(date) {
if (!date) return null;
let dateObj;
if (date instanceof Date) {
dateObj = date;
} else if (typeof date === 'string') {
dateObj = new Date(date);
} else {
return null;
}
const year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const day = String(dateObj.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
}
// 전역 인스턴스 생성
window.DailyWorkReportAPI = new DailyWorkReportAPI();
// 하위 호환성: 기존 함수들
window.loadWorkers = () => window.DailyWorkReportAPI.loadWorkers();
window.loadProjects = () => window.DailyWorkReportAPI.loadProjects();
window.loadWorkTypes = () => window.DailyWorkReportAPI.loadWorkTypes();
window.loadWorkStatusTypes = () => window.DailyWorkReportAPI.loadWorkStatusTypes();
window.loadErrorTypes = () => window.DailyWorkReportAPI.loadErrorTypes();
window.loadIncompleteTbms = () => window.DailyWorkReportAPI.loadIncompleteTbms();
window.loadDailyIssuesForTbms = () => window.DailyWorkReportAPI.loadDailyIssuesForTbms();
window.loadCompletedReports = () => window.DailyWorkReportAPI.loadCompletedReports(
document.getElementById('completedReportDate')?.value
);
// 통합 데이터 로드 함수
window.loadData = async () => {
try {
window.showMessage?.('데이터를 불러오는 중...', 'loading');
await window.DailyWorkReportAPI.loadAllData();
window.hideMessage?.();
} catch (error) {
console.error('[API] 데이터 로드 실패:', error);
window.showMessage?.('데이터 로드 중 오류가 발생했습니다: ' + error.message, 'error');
}
};

View File

@@ -0,0 +1,318 @@
/**
* Daily Work Report - Module Loader
* 작업보고서 모듈을 초기화하고 연결하는 메인 진입점
*
* 로드 순서:
* 1. state.js - 전역 상태 관리
* 2. utils.js - 유틸리티 함수
* 3. api.js - API 클라이언트
* 4. index.js - 이 파일 (메인 컨트롤러)
*/
class DailyWorkReportController {
constructor() {
this.state = window.DailyWorkReportState;
this.api = window.DailyWorkReportAPI;
this.utils = window.DailyWorkReportUtils;
this.initialized = false;
console.log('[Controller] DailyWorkReportController 생성');
}
/**
* 초기화
*/
async init() {
if (this.initialized) {
console.log('[Controller] 이미 초기화됨');
return;
}
console.log('[Controller] 초기화 시작...');
try {
// 이벤트 리스너 설정
this.setupEventListeners();
// 기본 데이터 로드
await this.api.loadAllData();
// TBM 탭이 기본
await this.switchTab('tbm');
this.initialized = true;
console.log('[Controller] 초기화 완료');
} catch (error) {
console.error('[Controller] 초기화 실패:', error);
window.showMessage?.('초기화 중 오류가 발생했습니다: ' + error.message, 'error');
}
}
/**
* 이벤트 리스너 설정
*/
setupEventListeners() {
// 탭 버튼
const tbmBtn = document.getElementById('tbmReportTab');
const completedBtn = document.getElementById('completedReportTab');
if (tbmBtn) {
tbmBtn.addEventListener('click', () => this.switchTab('tbm'));
}
if (completedBtn) {
completedBtn.addEventListener('click', () => this.switchTab('completed'));
}
// 완료 보고서 날짜 변경
const completedDateInput = document.getElementById('completedReportDate');
if (completedDateInput) {
completedDateInput.addEventListener('change', () => this.loadCompletedReports());
}
console.log('[Controller] 이벤트 리스너 설정 완료');
}
/**
* 탭 전환
*/
async switchTab(tab) {
this.state.setCurrentTab(tab);
const tbmBtn = document.getElementById('tbmReportTab');
const completedBtn = document.getElementById('completedReportTab');
const tbmSection = document.getElementById('tbmReportSection');
const completedSection = document.getElementById('completedReportSection');
// 모든 탭 버튼 비활성화
tbmBtn?.classList.remove('active');
completedBtn?.classList.remove('active');
// 모든 섹션 숨기기
if (tbmSection) tbmSection.style.display = 'none';
if (completedSection) completedSection.style.display = 'none';
// 선택된 탭 활성화
if (tab === 'tbm') {
tbmBtn?.classList.add('active');
if (tbmSection) tbmSection.style.display = 'block';
await this.loadTbmData();
} else if (tab === 'completed') {
completedBtn?.classList.add('active');
if (completedSection) completedSection.style.display = 'block';
// 오늘 날짜로 초기화
const dateInput = document.getElementById('completedReportDate');
if (dateInput) {
dateInput.value = this.utils.getKoreaToday();
}
await this.loadCompletedReports();
}
}
/**
* TBM 데이터 로드
*/
async loadTbmData() {
try {
await this.api.loadIncompleteTbms();
await this.api.loadDailyIssuesForTbms();
// 렌더링은 기존 함수 사용 (점진적 마이그레이션)
if (typeof window.renderTbmWorkList === 'function') {
window.renderTbmWorkList();
}
} catch (error) {
console.error('[Controller] TBM 데이터 로드 오류:', error);
window.showMessage?.('TBM 데이터를 불러오는 중 오류가 발생했습니다.', 'error');
}
}
/**
* 완료 보고서 로드
*/
async loadCompletedReports() {
try {
const dateInput = document.getElementById('completedReportDate');
const date = dateInput?.value || this.utils.getKoreaToday();
const reports = await this.api.loadCompletedReports(date);
// 렌더링은 기존 함수 사용
if (typeof window.renderCompletedReports === 'function') {
window.renderCompletedReports(reports);
}
} catch (error) {
console.error('[Controller] 완료 보고서 로드 오류:', error);
window.showMessage?.('완료 보고서를 불러오는 중 오류가 발생했습니다.', 'error');
}
}
/**
* TBM 작업보고서 제출
*/
async submitTbmWorkReport(index) {
try {
const tbm = this.state.incompleteTbms[index];
if (!tbm) {
throw new Error('TBM 데이터를 찾을 수 없습니다.');
}
// 유효성 검사
const totalHoursInput = document.getElementById(`totalHours_${index}`);
const totalHours = parseFloat(totalHoursInput?.value);
if (!totalHours || totalHours <= 0) {
window.showMessage?.('작업시간을 입력해주세요.', 'warning');
return;
}
// 부적합 시간 계산
const defects = this.state.tempDefects[index] || [];
const errorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
const regularHours = totalHours - errorHours;
if (regularHours < 0) {
window.showMessage?.('부적합 시간이 총 작업시간을 초과할 수 없습니다.', 'warning');
return;
}
// API 데이터 구성
const user = this.state.getCurrentUser();
const reportData = {
tbm_session_id: tbm.session_id,
tbm_assignment_id: tbm.assignment_id,
worker_id: tbm.worker_id,
project_id: tbm.project_id,
work_type_id: tbm.work_type_id,
report_date: this.utils.formatDateForApi(tbm.session_date),
total_hours: totalHours,
regular_hours: regularHours,
error_hours: errorHours,
work_status_id: errorHours > 0 ? 2 : 1,
created_by: user?.user_id || user?.id,
defects: defects.map(d => ({
category_id: d.category_id,
item_id: d.item_id,
issue_report_id: d.issue_report_id,
defect_hours: d.defect_hours,
note: d.note
}))
};
const result = await this.api.submitTbmWorkReport(reportData);
window.showSaveResultModal?.(
'success',
'제출 완료',
`${tbm.worker_name}의 작업보고서가 제출되었습니다.`
);
// 목록 새로고침
await this.loadTbmData();
} catch (error) {
console.error('[Controller] 제출 오류:', error);
window.showSaveResultModal?.(
'error',
'제출 실패',
error.message || '작업보고서 제출 중 오류가 발생했습니다.'
);
}
}
/**
* 세션 일괄 제출
*/
async batchSubmitSession(sessionKey) {
const rows = document.querySelectorAll(`tr[data-session-key="${sessionKey}"][data-type="tbm"]`);
const indices = [];
rows.forEach(row => {
const index = parseInt(row.dataset.index);
const totalHoursInput = document.getElementById(`totalHours_${index}`);
if (totalHoursInput?.value && parseFloat(totalHoursInput.value) > 0) {
indices.push(index);
}
});
if (indices.length === 0) {
window.showMessage?.('제출할 항목이 없습니다. 작업시간을 입력해주세요.', 'warning');
return;
}
const confirmed = confirm(`${indices.length}건의 작업보고서를 일괄 제출하시겠습니까?`);
if (!confirmed) return;
let successCount = 0;
let failCount = 0;
for (const index of indices) {
try {
await this.submitTbmWorkReport(index);
successCount++;
} catch (error) {
failCount++;
console.error(`[Controller] 일괄 제출 오류 (index: ${index}):`, error);
}
}
if (failCount === 0) {
window.showSaveResultModal?.('success', '일괄 제출 완료', `${successCount}건이 성공적으로 제출되었습니다.`);
} else {
window.showSaveResultModal?.('warning', '일괄 제출 부분 완료', `성공: ${successCount}건, 실패: ${failCount}`);
}
}
/**
* 상태 디버그
*/
debug() {
console.log('[Controller] 상태 디버그:');
this.state.debug();
}
}
// 전역 인스턴스 생성
window.DailyWorkReportController = new DailyWorkReportController();
// 하위 호환성: 기존 전역 함수들
window.switchTab = (tab) => window.DailyWorkReportController.switchTab(tab);
window.submitTbmWorkReport = (index) => window.DailyWorkReportController.submitTbmWorkReport(index);
window.batchSubmitTbmSession = (sessionKey) => window.DailyWorkReportController.batchSubmitSession(sessionKey);
// 사용자 정보 함수
window.getUser = () => window.DailyWorkReportState.getUser();
window.getCurrentUser = () => window.DailyWorkReportState.getCurrentUser();
// 날짜 그룹 토글 (UI 함수)
window.toggleDateGroup = function(dateStr) {
const group = document.querySelector(`.date-group[data-date="${dateStr}"]`);
if (!group) return;
const isExpanded = group.classList.contains('expanded');
const content = group.querySelector('.date-group-content');
const icon = group.querySelector('.date-toggle-icon');
if (isExpanded) {
group.classList.remove('expanded');
group.classList.add('collapsed');
if (content) content.style.display = 'none';
if (icon) icon.textContent = '▶';
} else {
group.classList.remove('collapsed');
group.classList.add('expanded');
if (content) content.style.display = 'block';
if (icon) icon.textContent = '▼';
}
};
// DOMContentLoaded 이벤트에서 초기화
document.addEventListener('DOMContentLoaded', () => {
// 약간의 지연 후 초기화 (다른 스크립트 로드 대기)
setTimeout(() => {
window.DailyWorkReportController.init();
}, 100);
});
console.log('[Module] daily-work-report/index.js 로드 완료');

View File

@@ -0,0 +1,342 @@
/**
* Daily Work Report - State Manager
* 작업보고서 페이지의 전역 상태 관리
*/
class DailyWorkReportState {
constructor() {
// 마스터 데이터
this.workTypes = [];
this.workStatusTypes = [];
this.errorTypes = []; // 레거시 호환용
this.issueCategories = []; // 신고 카테고리 (nonconformity)
this.issueItems = []; // 신고 아이템
this.workers = [];
this.projects = [];
// UI 상태
this.selectedWorkers = new Set();
this.workEntryCounter = 0;
this.currentStep = 1;
this.editingWorkId = null;
this.currentTab = 'tbm';
// TBM 관련
this.incompleteTbms = [];
// 부적합 원인 관리
this.currentDefectIndex = null;
this.tempDefects = {}; // { index: [{ error_type_id, defect_hours, note }] }
// 작업장소 지도 관련
this.mapCanvas = null;
this.mapCtx = null;
this.mapImage = null;
this.mapRegions = [];
this.selectedWorkplace = null;
this.selectedWorkplaceName = null;
this.selectedWorkplaceCategory = null;
this.selectedWorkplaceCategoryName = null;
// 시간 선택 관련
this.currentEditingField = null; // { index, type: 'total' | 'error' }
this.currentTimeValue = 0;
// 캐시
this.dailyIssuesCache = {}; // { 'YYYY-MM-DD': [issues] }
// 리스너
this.listeners = new Map();
console.log('[State] DailyWorkReportState 초기화 완료');
}
/**
* 상태 업데이트
*/
update(key, value) {
const prevValue = this[key];
this[key] = value;
this.notifyListeners(key, value, prevValue);
}
/**
* 리스너 등록
*/
subscribe(key, callback) {
if (!this.listeners.has(key)) {
this.listeners.set(key, []);
}
this.listeners.get(key).push(callback);
}
/**
* 리스너에게 알림
*/
notifyListeners(key, newValue, prevValue) {
const keyListeners = this.listeners.get(key) || [];
keyListeners.forEach(callback => {
try {
callback(newValue, prevValue);
} catch (error) {
console.error(`[State] 리스너 오류 (${key}):`, error);
}
});
}
/**
* 현재 사용자 정보 가져오기
*/
getUser() {
const user = localStorage.getItem('user');
return user ? JSON.parse(user) : null;
}
/**
* 토큰에서 사용자 정보 추출
*/
getCurrentUser() {
try {
const token = localStorage.getItem('token');
if (!token) return null;
const payloadBase64 = token.split('.')[1];
if (payloadBase64) {
const payload = JSON.parse(atob(payloadBase64));
return payload;
}
} catch (error) {
console.log('[State] 토큰에서 사용자 정보 추출 실패:', error);
}
try {
const userInfo = localStorage.getItem('user') || localStorage.getItem('userInfo') || localStorage.getItem('currentUser');
if (userInfo) {
return JSON.parse(userInfo);
}
} catch (error) {
console.log('[State] localStorage에서 사용자 정보 가져오기 실패:', error);
}
return null;
}
/**
* 선택된 작업자 토글
*/
toggleWorkerSelection(workerId) {
if (this.selectedWorkers.has(workerId)) {
this.selectedWorkers.delete(workerId);
} else {
this.selectedWorkers.add(workerId);
}
this.notifyListeners('selectedWorkers', this.selectedWorkers, null);
}
/**
* 작업자 전체 선택/해제
*/
selectAllWorkers(select = true) {
if (select) {
this.workers.forEach(w => this.selectedWorkers.add(w.worker_id));
} else {
this.selectedWorkers.clear();
}
this.notifyListeners('selectedWorkers', this.selectedWorkers, null);
}
/**
* 작업 항목 카운터 증가
*/
incrementWorkEntryCounter() {
this.workEntryCounter++;
return this.workEntryCounter;
}
/**
* 탭 변경
*/
setCurrentTab(tab) {
const prevTab = this.currentTab;
this.currentTab = tab;
this.notifyListeners('currentTab', tab, prevTab);
}
/**
* 부적합 임시 저장소 초기화
*/
initTempDefects(index) {
if (!this.tempDefects[index]) {
this.tempDefects[index] = [];
}
}
/**
* 부적합 추가
*/
addTempDefect(index, defect) {
this.initTempDefects(index);
this.tempDefects[index].push(defect);
this.notifyListeners('tempDefects', this.tempDefects, null);
}
/**
* 부적합 업데이트
*/
updateTempDefect(index, defectIndex, field, value) {
if (this.tempDefects[index] && this.tempDefects[index][defectIndex]) {
this.tempDefects[index][defectIndex][field] = value;
this.notifyListeners('tempDefects', this.tempDefects, null);
}
}
/**
* 부적합 삭제
*/
removeTempDefect(index, defectIndex) {
if (this.tempDefects[index]) {
this.tempDefects[index].splice(defectIndex, 1);
this.notifyListeners('tempDefects', this.tempDefects, null);
}
}
/**
* 일일 이슈 캐시 설정
*/
setDailyIssuesCache(dateStr, issues) {
this.dailyIssuesCache[dateStr] = issues;
}
/**
* 일일 이슈 캐시 조회
*/
getDailyIssuesCache(dateStr) {
return this.dailyIssuesCache[dateStr] || [];
}
/**
* 상태 초기화
*/
reset() {
this.selectedWorkers.clear();
this.workEntryCounter = 0;
this.currentStep = 1;
this.editingWorkId = null;
this.tempDefects = {};
this.currentDefectIndex = null;
this.dailyIssuesCache = {};
}
/**
* 디버그 출력
*/
debug() {
console.log('[State] 현재 상태:', {
workTypes: this.workTypes.length,
workers: this.workers.length,
projects: this.projects.length,
selectedWorkers: this.selectedWorkers.size,
currentTab: this.currentTab,
incompleteTbms: this.incompleteTbms.length,
tempDefects: Object.keys(this.tempDefects).length
});
}
}
// 전역 인스턴스 생성
window.DailyWorkReportState = new DailyWorkReportState();
// 하위 호환성을 위한 전역 변수 프록시
const stateProxy = window.DailyWorkReportState;
// 기존 전역 변수들과 호환
Object.defineProperties(window, {
workTypes: {
get: () => stateProxy.workTypes,
set: (v) => { stateProxy.workTypes = v; }
},
workStatusTypes: {
get: () => stateProxy.workStatusTypes,
set: (v) => { stateProxy.workStatusTypes = v; }
},
errorTypes: {
get: () => stateProxy.errorTypes,
set: (v) => { stateProxy.errorTypes = v; }
},
issueCategories: {
get: () => stateProxy.issueCategories,
set: (v) => { stateProxy.issueCategories = v; }
},
issueItems: {
get: () => stateProxy.issueItems,
set: (v) => { stateProxy.issueItems = v; }
},
workers: {
get: () => stateProxy.workers,
set: (v) => { stateProxy.workers = v; }
},
projects: {
get: () => stateProxy.projects,
set: (v) => { stateProxy.projects = v; }
},
selectedWorkers: {
get: () => stateProxy.selectedWorkers,
set: (v) => { stateProxy.selectedWorkers = v; }
},
incompleteTbms: {
get: () => stateProxy.incompleteTbms,
set: (v) => { stateProxy.incompleteTbms = v; }
},
tempDefects: {
get: () => stateProxy.tempDefects,
set: (v) => { stateProxy.tempDefects = v; }
},
dailyIssuesCache: {
get: () => stateProxy.dailyIssuesCache,
set: (v) => { stateProxy.dailyIssuesCache = v; }
},
currentTab: {
get: () => stateProxy.currentTab,
set: (v) => { stateProxy.currentTab = v; }
},
currentStep: {
get: () => stateProxy.currentStep,
set: (v) => { stateProxy.currentStep = v; }
},
editingWorkId: {
get: () => stateProxy.editingWorkId,
set: (v) => { stateProxy.editingWorkId = v; }
},
workEntryCounter: {
get: () => stateProxy.workEntryCounter,
set: (v) => { stateProxy.workEntryCounter = v; }
},
currentDefectIndex: {
get: () => stateProxy.currentDefectIndex,
set: (v) => { stateProxy.currentDefectIndex = v; }
},
currentEditingField: {
get: () => stateProxy.currentEditingField,
set: (v) => { stateProxy.currentEditingField = v; }
},
currentTimeValue: {
get: () => stateProxy.currentTimeValue,
set: (v) => { stateProxy.currentTimeValue = v; }
},
selectedWorkplace: {
get: () => stateProxy.selectedWorkplace,
set: (v) => { stateProxy.selectedWorkplace = v; }
},
selectedWorkplaceName: {
get: () => stateProxy.selectedWorkplaceName,
set: (v) => { stateProxy.selectedWorkplaceName = v; }
},
selectedWorkplaceCategory: {
get: () => stateProxy.selectedWorkplaceCategory,
set: (v) => { stateProxy.selectedWorkplaceCategory = v; }
},
selectedWorkplaceCategoryName: {
get: () => stateProxy.selectedWorkplaceCategoryName,
set: (v) => { stateProxy.selectedWorkplaceCategoryName = v; }
}
});

View File

@@ -0,0 +1,470 @@
/**
* Daily Work Report - Utilities
* 작업보고서 관련 유틸리티 함수들
*/
class DailyWorkReportUtils {
constructor() {
console.log('[Utils] DailyWorkReportUtils 초기화');
}
/**
* 한국 시간 기준 오늘 날짜 (YYYY-MM-DD)
*/
getKoreaToday() {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 날짜를 API 형식(YYYY-MM-DD)으로 변환
*/
formatDateForApi(date) {
if (!date) return null;
let dateObj;
if (date instanceof Date) {
dateObj = date;
} else if (typeof date === 'string') {
dateObj = new Date(date);
} else {
return null;
}
const year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const day = String(dateObj.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 날짜 포맷팅 (표시용)
*/
formatDate(date) {
if (!date) return '-';
let dateObj;
if (date instanceof Date) {
dateObj = date;
} else if (typeof date === 'string') {
dateObj = new Date(date);
} else {
return '-';
}
const year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const day = String(dateObj.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 시간 포맷팅 (HH:mm)
*/
formatTime(time) {
if (!time) return '-';
if (typeof time === 'string' && time.includes(':')) {
return time.substring(0, 5);
}
return time;
}
/**
* 상태 라벨 반환
*/
getStatusLabel(status) {
const labels = {
'pending': '접수',
'in_progress': '처리중',
'resolved': '해결',
'completed': '완료',
'closed': '종료'
};
return labels[status] || status || '-';
}
/**
* 숫자 포맷팅 (천 단위 콤마)
*/
formatNumber(num) {
if (num === null || num === undefined) return '0';
return num.toLocaleString('ko-KR');
}
/**
* 소수점 자리수 포맷팅
*/
formatDecimal(num, decimals = 1) {
if (num === null || num === undefined) return '0';
return Number(num).toFixed(decimals);
}
/**
* 요일 반환
*/
getDayOfWeek(date) {
const days = ['일', '월', '화', '수', '목', '금', '토'];
const dateObj = date instanceof Date ? date : new Date(date);
return days[dateObj.getDay()];
}
/**
* 오늘인지 확인
*/
isToday(date) {
const today = this.getKoreaToday();
const targetDate = this.formatDateForApi(date);
return today === targetDate;
}
/**
* 두 날짜 사이 일수 계산
*/
daysBetween(date1, date2) {
const d1 = new Date(date1);
const d2 = new Date(date2);
const diffTime = Math.abs(d2 - d1);
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
/**
* 디바운스 함수
*/
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
/**
* 쓰로틀 함수
*/
throttle(func, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
/**
* HTML 이스케이프
*/
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* 객체 깊은 복사
*/
deepClone(obj) {
return JSON.parse(JSON.stringify(obj));
}
/**
* 빈 값 확인
*/
isEmpty(value) {
if (value === null || value === undefined) return true;
if (typeof value === 'string') return value.trim() === '';
if (Array.isArray(value)) return value.length === 0;
if (typeof value === 'object') return Object.keys(value).length === 0;
return false;
}
/**
* 숫자 유효성 검사
*/
isValidNumber(value) {
return !isNaN(value) && isFinite(value);
}
/**
* 시간 유효성 검사 (0-24)
*/
isValidHours(hours) {
const num = parseFloat(hours);
return this.isValidNumber(num) && num >= 0 && num <= 24;
}
/**
* 쿼리 스트링 파싱
*/
parseQueryString(queryString) {
const params = new URLSearchParams(queryString);
const result = {};
for (const [key, value] of params) {
result[key] = value;
}
return result;
}
/**
* 쿼리 스트링 생성
*/
buildQueryString(params) {
return new URLSearchParams(params).toString();
}
/**
* 로컬 스토리지 안전하게 가져오기
*/
getLocalStorage(key, defaultValue = null) {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
} catch (error) {
console.error('[Utils] localStorage 읽기 오류:', error);
return defaultValue;
}
}
/**
* 로컬 스토리지 안전하게 저장하기
*/
setLocalStorage(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (error) {
console.error('[Utils] localStorage 저장 오류:', error);
return false;
}
}
/**
* 배열 그룹화
*/
groupBy(array, key) {
return array.reduce((result, item) => {
const groupKey = typeof key === 'function' ? key(item) : item[key];
if (!result[groupKey]) {
result[groupKey] = [];
}
result[groupKey].push(item);
return result;
}, {});
}
/**
* 배열 정렬 (다중 키)
*/
sortBy(array, ...keys) {
return [...array].sort((a, b) => {
for (const key of keys) {
const direction = key.startsWith('-') ? -1 : 1;
const actualKey = key.replace(/^-/, '');
const aVal = a[actualKey];
const bVal = b[actualKey];
if (aVal < bVal) return -1 * direction;
if (aVal > bVal) return 1 * direction;
}
return 0;
});
}
/**
* UUID 생성
*/
generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
}
// 전역 인스턴스 생성
window.DailyWorkReportUtils = new DailyWorkReportUtils();
// 하위 호환성: 기존 함수들
window.getKoreaToday = () => window.DailyWorkReportUtils.getKoreaToday();
window.formatDateForApi = (date) => window.DailyWorkReportUtils.formatDateForApi(date);
window.formatDate = (date) => window.DailyWorkReportUtils.formatDate(date);
window.getStatusLabel = (status) => window.DailyWorkReportUtils.getStatusLabel(status);
// 메시지 표시 함수들
window.showMessage = function(message, type = 'info') {
const container = document.getElementById('message-container');
if (!container) {
console.log(`[Message] ${type}: ${message}`);
return;
}
container.innerHTML = `<div class="message ${type}">${message}</div>`;
if (type === 'success') {
setTimeout(() => window.hideMessage(), 5000);
}
};
window.hideMessage = function() {
const container = document.getElementById('message-container');
if (container) {
container.innerHTML = '';
}
};
// 저장 결과 모달
window.showSaveResultModal = function(type, title, message, details = null) {
const modal = document.getElementById('saveResultModal');
const titleElement = document.getElementById('resultModalTitle');
const contentElement = document.getElementById('resultModalContent');
if (!modal || !contentElement) {
alert(`${title}\n\n${message}`);
return;
}
const icons = {
success: '✅',
error: '❌',
warning: '⚠️',
info: ''
};
let content = `
<div class="result-icon ${type}">${icons[type] || icons.info}</div>
<h3 class="result-title ${type}">${title}</h3>
<p class="result-message">${message}</p>
`;
if (details) {
if (Array.isArray(details) && details.length > 0) {
content += `
<div class="result-details">
<h4>상세 정보:</h4>
<ul>${details.map(d => `<li>${d}</li>`).join('')}</ul>
</div>
`;
} else if (typeof details === 'string') {
content += `<div class="result-details"><p>${details}</p></div>`;
}
}
if (titleElement) titleElement.textContent = '저장 결과';
contentElement.innerHTML = content;
modal.style.display = 'flex';
// ESC 키로 닫기
const escHandler = (e) => {
if (e.key === 'Escape') {
window.closeSaveResultModal();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
// 배경 클릭으로 닫기
modal.onclick = (e) => {
if (e.target === modal) {
window.closeSaveResultModal();
}
};
};
window.closeSaveResultModal = function() {
const modal = document.getElementById('saveResultModal');
if (modal) {
modal.style.display = 'none';
}
};
// 단계 이동 함수
window.goToStep = function(stepNumber) {
const state = window.DailyWorkReportState;
for (let i = 1; i <= 3; i++) {
const step = document.getElementById(`step${i}`);
if (step) {
step.classList.remove('active', 'completed');
if (i < stepNumber) {
step.classList.add('completed');
const stepNum = step.querySelector('.step-number');
if (stepNum) stepNum.classList.add('completed');
} else if (i === stepNumber) {
step.classList.add('active');
}
}
}
window.updateProgressSteps(stepNumber);
state.currentStep = stepNumber;
};
window.updateProgressSteps = function(currentStepNumber) {
for (let i = 1; i <= 3; i++) {
const progressStep = document.getElementById(`progressStep${i}`);
if (progressStep) {
progressStep.classList.remove('active', 'completed');
if (i < currentStepNumber) {
progressStep.classList.add('completed');
} else if (i === currentStepNumber) {
progressStep.classList.add('active');
}
}
}
};
// 토스트 메시지 (간단 버전)
window.showToast = function(message, type = 'info', duration = 3000) {
console.log(`[Toast] ${type}: ${message}`);
// 기존 토스트 제거
const existingToast = document.querySelector('.toast-message');
if (existingToast) {
existingToast.remove();
}
// 새 토스트 생성
const toast = document.createElement('div');
toast.className = `toast-message toast-${type}`;
toast.textContent = message;
toast.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
padding: 12px 20px;
border-radius: 8px;
color: white;
font-size: 14px;
z-index: 10000;
animation: slideIn 0.3s ease;
background-color: ${type === 'success' ? '#10b981' : type === 'error' ? '#ef4444' : type === 'warning' ? '#f59e0b' : '#3b82f6'};
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, duration);
};
// 확인 다이얼로그
window.showConfirmDialog = function(message, onConfirm, onCancel) {
if (confirm(message)) {
onConfirm?.();
} else {
onCancel?.();
}
};

489
web-ui/js/tbm/api.js Normal file
View File

@@ -0,0 +1,489 @@
/**
* TBM - API Client
* TBM 관련 모든 API 호출을 관리
*/
class TbmAPI {
constructor() {
this.state = window.TbmState;
this.utils = window.TbmUtils;
console.log('[TbmAPI] 초기화 완료');
}
/**
* 초기 데이터 로드 (작업자, 프로젝트, 안전 체크리스트, 공정, 작업, 작업장)
*/
async loadInitialData() {
try {
// 현재 로그인한 사용자 정보 가져오기
const userInfo = JSON.parse(localStorage.getItem('user') || '{}');
this.state.currentUser = userInfo;
console.log('👤 로그인 사용자:', this.state.currentUser, 'worker_id:', this.state.currentUser?.worker_id);
// 병렬로 데이터 로드
await Promise.all([
this.loadWorkers(),
this.loadProjects(),
this.loadSafetyChecks(),
this.loadWorkTypes(),
this.loadTasks(),
this.loadWorkplaces(),
this.loadWorkplaceCategories()
]);
console.log('✅ 초기 데이터 로드 완료');
} catch (error) {
console.error('❌ 초기 데이터 로드 오류:', error);
window.showToast?.('데이터를 불러오는 중 오류가 발생했습니다.', 'error');
}
}
/**
* 작업자 목록 로드 (생산팀 소속만)
*/
async loadWorkers() {
try {
const response = await window.apiCall('/workers?limit=1000&department_id=1');
if (response) {
let workers = Array.isArray(response) ? response : (response.data || []);
// 활성 상태인 작업자만 필터링
workers = workers.filter(w => w.status === 'active' && w.employment_status === 'employed');
this.state.allWorkers = workers;
console.log('✅ 작업자 목록 로드:', workers.length + '명');
return workers;
}
} catch (error) {
console.error('❌ 작업자 로딩 오류:', error);
throw error;
}
}
/**
* 프로젝트 목록 로드 (활성 프로젝트만)
*/
async loadProjects() {
try {
const response = await window.apiCall('/projects?is_active=1');
if (response) {
const projects = Array.isArray(response) ? response : (response.data || []);
this.state.allProjects = projects.filter(p =>
p.is_active === 1 || p.is_active === true || p.is_active === '1'
);
console.log('✅ 프로젝트 목록 로드:', this.state.allProjects.length + '개 (활성)');
return this.state.allProjects;
}
} catch (error) {
console.error('❌ 프로젝트 로딩 오류:', error);
throw error;
}
}
/**
* 안전 체크리스트 로드
*/
async loadSafetyChecks() {
try {
const response = await window.apiCall('/tbm/safety-checks');
if (response && response.success) {
this.state.allSafetyChecks = response.data;
console.log('✅ 안전 체크리스트 로드:', this.state.allSafetyChecks.length + '개');
return this.state.allSafetyChecks;
}
} catch (error) {
console.error('❌ 안전 체크리스트 로딩 오류:', error);
}
}
/**
* 공정(Work Types) 목록 로드
*/
async loadWorkTypes() {
try {
const response = await window.apiCall('/daily-work-reports/work-types');
if (response && response.success) {
this.state.allWorkTypes = response.data || [];
console.log('✅ 공정 목록 로드:', this.state.allWorkTypes.length + '개');
return this.state.allWorkTypes;
}
} catch (error) {
console.error('❌ 공정 로딩 오류:', error);
}
}
/**
* 작업(Tasks) 목록 로드
*/
async loadTasks() {
try {
const response = await window.apiCall('/tasks/active/list');
if (response && response.success) {
this.state.allTasks = response.data || [];
console.log('✅ 작업 목록 로드:', this.state.allTasks.length + '개');
return this.state.allTasks;
}
} catch (error) {
console.error('❌ 작업 로딩 오류:', error);
}
}
/**
* 작업장 목록 로드
*/
async loadWorkplaces() {
try {
const response = await window.apiCall('/workplaces?is_active=true');
if (response && response.success) {
this.state.allWorkplaces = response.data || [];
console.log('✅ 작업장 목록 로드:', this.state.allWorkplaces.length + '개');
return this.state.allWorkplaces;
}
} catch (error) {
console.error('❌ 작업장 로딩 오류:', error);
}
}
/**
* 작업장 카테고리 로드
*/
async loadWorkplaceCategories() {
try {
const response = await window.apiCall('/workplaces/categories/active/list');
if (response && response.success) {
this.state.allWorkplaceCategories = response.data || [];
console.log('✅ 작업장 카테고리 로드:', this.state.allWorkplaceCategories.length + '개');
return this.state.allWorkplaceCategories;
}
} catch (error) {
console.error('❌ 작업장 카테고리 로딩 오류:', error);
}
}
/**
* 오늘의 TBM만 로드 (TBM 입력 탭용)
*/
async loadTodayOnlyTbm() {
const today = this.utils.getTodayKST();
try {
const response = await window.apiCall(`/tbm/sessions/date/${today}`);
if (response && response.success) {
this.state.todaySessions = response.data || [];
} else {
this.state.todaySessions = [];
}
console.log('✅ 오늘 TBM 로드:', this.state.todaySessions.length + '건');
return this.state.todaySessions;
} catch (error) {
console.error('❌ 오늘 TBM 조회 오류:', error);
window.showToast?.('오늘 TBM을 불러오는 중 오류가 발생했습니다.', 'error');
this.state.todaySessions = [];
return [];
}
}
/**
* 최근 TBM을 날짜별로 그룹화하여 로드
*/
async loadRecentTbmGroupedByDate() {
try {
const today = new Date();
const dates = [];
// 최근 N일의 날짜 생성
for (let i = 0; i < this.state.loadedDaysCount; i++) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
dates.push(dateStr);
}
// 각 날짜의 TBM 로드
this.state.dateGroupedSessions = {};
this.state.allLoadedSessions = [];
const promises = dates.map(date => window.apiCall(`/tbm/sessions/date/${date}`));
const results = await Promise.all(promises);
results.forEach((response, index) => {
const date = dates[index];
if (response && response.success && response.data && response.data.length > 0) {
let sessions = response.data;
// admin이 아니면 본인이 작성한 TBM만 필터링
if (!this.state.isAdminUser()) {
const userId = this.state.currentUser?.user_id;
const workerId = this.state.currentUser?.worker_id;
sessions = sessions.filter(s => {
return s.created_by === userId ||
s.leader_id === workerId ||
s.created_by_name === this.state.currentUser?.name;
});
}
if (sessions.length > 0) {
this.state.dateGroupedSessions[date] = sessions;
this.state.allLoadedSessions = this.state.allLoadedSessions.concat(sessions);
}
}
});
console.log('✅ 날짜별 TBM 로드 완료:', this.state.allLoadedSessions.length + '건');
return this.state.dateGroupedSessions;
} catch (error) {
console.error('❌ TBM 날짜별 로드 오류:', error);
window.showToast?.('TBM을 불러오는 중 오류가 발생했습니다.', 'error');
this.state.dateGroupedSessions = {};
return {};
}
}
/**
* 특정 날짜의 TBM 세션 목록 로드
*/
async loadTbmSessionsByDate(date) {
try {
const response = await window.apiCall(`/tbm/sessions/date/${date}`);
if (response && response.success) {
this.state.allSessions = response.data || [];
} else {
this.state.allSessions = [];
}
return this.state.allSessions;
} catch (error) {
console.error('❌ TBM 세션 조회 오류:', error);
window.showToast?.('TBM 세션을 불러오는 중 오류가 발생했습니다.', 'error');
this.state.allSessions = [];
return [];
}
}
/**
* TBM 세션 생성
*/
async createTbmSession(sessionData) {
try {
const response = await window.apiCall('/tbm/sessions', 'POST', sessionData);
if (!response || !response.success) {
throw new Error(response?.message || '세션 생성 실패');
}
console.log('✅ TBM 세션 생성 완료:', response.data?.session_id);
return response;
} catch (error) {
console.error('❌ TBM 세션 생성 오류:', error);
throw error;
}
}
/**
* TBM 세션 정보 조회
*/
async getSession(sessionId) {
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}`);
if (!response || !response.success) {
throw new Error(response?.message || '세션 조회 실패');
}
return response.data;
} catch (error) {
console.error('❌ TBM 세션 조회 오류:', error);
throw error;
}
}
/**
* TBM 팀원 조회
*/
async getTeamMembers(sessionId) {
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}/team`);
if (!response || !response.success) {
throw new Error(response?.message || '팀원 조회 실패');
}
return response.data || [];
} catch (error) {
console.error('❌ TBM 팀원 조회 오류:', error);
throw error;
}
}
/**
* TBM 팀원 일괄 추가
*/
async addTeamMembers(sessionId, members) {
try {
const response = await window.apiCall(
`/tbm/sessions/${sessionId}/team/batch`,
'POST',
{ members }
);
if (!response || !response.success) {
throw new Error(response?.message || '팀원 추가 실패');
}
console.log('✅ TBM 팀원 추가 완료:', members.length + '명');
return response;
} catch (error) {
console.error('❌ TBM 팀원 추가 오류:', error);
throw error;
}
}
/**
* TBM 팀원 전체 삭제
*/
async clearTeamMembers(sessionId) {
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}/team/clear`, 'DELETE');
return response;
} catch (error) {
console.error('❌ TBM 팀원 삭제 오류:', error);
throw error;
}
}
/**
* TBM 안전 체크 조회
*/
async getSafetyChecks(sessionId) {
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}/safety`);
return response?.data || [];
} catch (error) {
console.error('❌ 안전 체크 조회 오류:', error);
return [];
}
}
/**
* TBM 안전 체크 (필터링된) 조회
*/
async getFilteredSafetyChecks(sessionId) {
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}/safety-checks/filtered`);
if (!response || !response.success) {
throw new Error(response?.message || '체크리스트를 불러올 수 없습니다.');
}
return response.data;
} catch (error) {
console.error('❌ 필터링된 안전 체크 조회 오류:', error);
throw error;
}
}
/**
* TBM 안전 체크 저장
*/
async saveSafetyChecks(sessionId, records) {
try {
const response = await window.apiCall(
`/tbm/sessions/${sessionId}/safety`,
'POST',
{ records }
);
if (!response || !response.success) {
throw new Error(response?.message || '저장 실패');
}
return response;
} catch (error) {
console.error('❌ 안전 체크 저장 오류:', error);
throw error;
}
}
/**
* TBM 세션 완료 처리
*/
async completeTbmSession(sessionId, endTime) {
try {
const response = await window.apiCall(
`/tbm/sessions/${sessionId}/complete`,
'POST',
{ end_time: endTime }
);
if (!response || !response.success) {
throw new Error(response?.message || '완료 처리 실패');
}
console.log('✅ TBM 완료 처리:', sessionId);
return response;
} catch (error) {
console.error('❌ TBM 완료 처리 오류:', error);
throw error;
}
}
/**
* 작업 인계 저장
*/
async saveHandover(handoverData) {
try {
const response = await window.apiCall('/tbm/handovers', 'POST', handoverData);
if (!response || !response.success) {
throw new Error(response?.message || '인계 요청 실패');
}
return response;
} catch (error) {
console.error('❌ 작업 인계 저장 오류:', error);
throw error;
}
}
/**
* 카테고리별 작업장 로드
*/
async loadWorkplacesByCategory(categoryId) {
try {
const response = await window.apiCall(`/workplaces?category_id=${categoryId}`);
if (!response || !response.success || !response.data) {
return [];
}
return response.data;
} catch (error) {
console.error('❌ 작업장 로드 오류:', error);
return [];
}
}
/**
* 작업장 지도 영역 로드
*/
async loadMapRegions(categoryId) {
try {
const response = await window.apiCall(`/workplaces/categories/${categoryId}/map-regions`);
if (response && response.success) {
this.state.mapRegions = response.data || [];
return this.state.mapRegions;
}
return [];
} catch (error) {
console.error('❌ 지도 영역 로드 오류:', error);
return [];
}
}
}
// 전역 인스턴스 생성
window.TbmAPI = new TbmAPI();
// 하위 호환성: 기존 함수들
window.loadInitialData = () => window.TbmAPI.loadInitialData();
window.loadTodayOnlyTbm = () => window.TbmAPI.loadTodayOnlyTbm();
window.loadTodayTbm = () => window.TbmAPI.loadRecentTbmGroupedByDate();
window.loadAllTbm = () => {
window.TbmState.loadedDaysCount = 30;
return window.TbmAPI.loadRecentTbmGroupedByDate();
};
window.loadRecentTbmGroupedByDate = () => window.TbmAPI.loadRecentTbmGroupedByDate();
window.loadTbmSessionsByDate = (date) => window.TbmAPI.loadTbmSessionsByDate(date);
window.loadWorkplaceCategories = () => window.TbmAPI.loadWorkplaceCategories();
window.loadWorkplacesByCategory = (categoryId) => window.TbmAPI.loadWorkplacesByCategory(categoryId);
// 더 많은 날짜 로드
window.loadMoreTbmDays = async function() {
window.TbmState.loadedDaysCount += 7;
await window.TbmAPI.loadRecentTbmGroupedByDate();
window.showToast?.(`최근 ${window.TbmState.loadedDaysCount}일의 TBM을 로드했습니다.`, 'success');
};
console.log('[Module] tbm/api.js 로드 완료');

325
web-ui/js/tbm/index.js Normal file
View File

@@ -0,0 +1,325 @@
/**
* TBM - Module Loader
* TBM 모듈을 초기화하고 연결하는 메인 진입점
*
* 로드 순서:
* 1. state.js - 전역 상태 관리
* 2. utils.js - 유틸리티 함수
* 3. api.js - API 클라이언트
* 4. index.js - 이 파일 (메인 컨트롤러)
*/
class TbmController {
constructor() {
this.state = window.TbmState;
this.api = window.TbmAPI;
this.utils = window.TbmUtils;
this.initialized = false;
console.log('[TbmController] 생성');
}
/**
* 초기화
*/
async init() {
if (this.initialized) {
console.log('[TbmController] 이미 초기화됨');
return;
}
console.log('🛠️ TBM 관리 페이지 초기화');
// API 함수가 로드될 때까지 대기
let retryCount = 0;
while (!window.apiCall && retryCount < 50) {
await new Promise(resolve => setTimeout(resolve, 100));
retryCount++;
}
if (!window.apiCall) {
window.showToast?.('시스템을 초기화할 수 없습니다. 페이지를 새로고침해주세요.', 'error');
return;
}
// 오늘 날짜 설정 (서울 시간대 기준)
const today = this.utils.getTodayKST();
const tbmDateEl = document.getElementById('tbmDate');
const sessionDateEl = document.getElementById('sessionDate');
if (tbmDateEl) tbmDateEl.value = today;
if (sessionDateEl) sessionDateEl.value = today;
// 이벤트 리스너 설정
this.setupEventListeners();
// 초기 데이터 로드
await this.api.loadInitialData();
await this.api.loadTodayOnlyTbm();
// 렌더링
this.displayTodayTbmSessions();
this.initialized = true;
console.log('[TbmController] 초기화 완료');
}
/**
* 이벤트 리스너 설정
*/
setupEventListeners() {
// 탭 버튼들
document.querySelectorAll('.tbm-tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
const tabName = btn.dataset.tab;
if (tabName) this.switchTbmTab(tabName);
});
});
}
/**
* 탭 전환
*/
async switchTbmTab(tabName) {
this.state.setCurrentTab(tabName);
// 탭 버튼 활성화 상태 변경
document.querySelectorAll('.tbm-tab-btn').forEach(btn => {
if (btn.dataset.tab === tabName) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
// 탭 컨텐츠 표시 변경
document.querySelectorAll('.tbm-tab-content').forEach(content => {
content.classList.remove('active');
});
const tabContent = document.getElementById(`${tabName}-tab`);
if (tabContent) tabContent.classList.add('active');
// 탭에 따라 데이터 로드
if (tabName === 'tbm-input') {
await this.api.loadTodayOnlyTbm();
this.displayTodayTbmSessions();
} else if (tabName === 'tbm-manage') {
await this.api.loadRecentTbmGroupedByDate();
this.displayTbmGroupedByDate();
this.updateViewModeIndicator();
}
}
/**
* 오늘의 TBM 세션 표시
*/
displayTodayTbmSessions() {
const grid = document.getElementById('todayTbmGrid');
const emptyState = document.getElementById('todayEmptyState');
const todayTotalEl = document.getElementById('todayTotalSessions');
const todayCompletedEl = document.getElementById('todayCompletedSessions');
const todayActiveEl = document.getElementById('todayActiveSessions');
const sessions = this.state.todaySessions;
if (sessions.length === 0) {
if (grid) grid.innerHTML = '';
if (emptyState) emptyState.style.display = 'flex';
if (todayTotalEl) todayTotalEl.textContent = '0';
if (todayCompletedEl) todayCompletedEl.textContent = '0';
if (todayActiveEl) todayActiveEl.textContent = '0';
return;
}
if (emptyState) emptyState.style.display = 'none';
const completedCount = sessions.filter(s => s.status === 'completed').length;
const activeCount = sessions.filter(s => s.status === 'draft').length;
if (todayTotalEl) todayTotalEl.textContent = sessions.length;
if (todayCompletedEl) todayCompletedEl.textContent = completedCount;
if (todayActiveEl) todayActiveEl.textContent = activeCount;
if (grid) {
grid.innerHTML = sessions.map(session => this.createSessionCard(session)).join('');
}
}
/**
* 날짜별 그룹으로 TBM 표시
*/
displayTbmGroupedByDate() {
const container = document.getElementById('tbmDateGroupsContainer');
const emptyState = document.getElementById('emptyState');
const totalSessionsEl = document.getElementById('totalSessions');
const completedSessionsEl = document.getElementById('completedSessions');
if (!container) return;
const sortedDates = Object.keys(this.state.dateGroupedSessions).sort((a, b) =>
new Date(b) - new Date(a)
);
if (sortedDates.length === 0 || this.state.allLoadedSessions.length === 0) {
container.innerHTML = '';
if (emptyState) emptyState.style.display = 'flex';
if (totalSessionsEl) totalSessionsEl.textContent = '0';
if (completedSessionsEl) completedSessionsEl.textContent = '0';
return;
}
if (emptyState) emptyState.style.display = 'none';
// 통계 업데이트
const completedCount = this.state.allLoadedSessions.filter(s => s.status === 'completed').length;
if (totalSessionsEl) totalSessionsEl.textContent = this.state.allLoadedSessions.length;
if (completedSessionsEl) completedSessionsEl.textContent = completedCount;
// 날짜별 그룹 HTML 생성
const today = this.utils.getTodayKST();
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
container.innerHTML = sortedDates.map(date => {
const sessions = this.state.dateGroupedSessions[date];
const dateObj = new Date(date + 'T00:00:00');
const dayName = dayNames[dateObj.getDay()];
const isToday = date === today;
const [year, month, day] = date.split('-');
const displayDate = `${parseInt(month)}${parseInt(day)}`;
return `
<div class="tbm-date-group" data-date="${date}">
<div class="tbm-date-header ${isToday ? 'today' : ''}" onclick="toggleDateGroup('${date}')">
<span class="tbm-date-toggle">&#9660;</span>
<span class="tbm-date-title">${displayDate}</span>
<span class="tbm-date-day">${dayName}요일</span>
${isToday ? '<span class="tbm-today-badge">오늘</span>' : ''}
<span class="tbm-date-count">${sessions.length}건</span>
</div>
<div class="tbm-date-content">
<div class="tbm-date-grid">
${sessions.map(session => this.createSessionCard(session)).join('')}
</div>
</div>
</div>
`;
}).join('');
}
/**
* 뷰 모드 표시 업데이트
*/
updateViewModeIndicator() {
const indicator = document.getElementById('viewModeIndicator');
const text = document.getElementById('viewModeText');
if (indicator && text) {
if (this.state.isAdminUser()) {
indicator.style.display = 'none';
} else {
indicator.style.display = 'inline-flex';
text.textContent = '내 TBM';
}
}
}
/**
* TBM 세션 카드 생성
*/
createSessionCard(session) {
const statusBadge = this.utils.getStatusBadge(session.status);
const leaderName = session.leader_name || session.created_by_name || '작업 책임자';
const leaderRole = session.leader_name
? (session.leader_job_type || '작업자')
: '관리자';
return `
<div class="tbm-session-card" onclick="viewTbmSession(${session.session_id})">
<div class="tbm-card-header">
<div class="tbm-card-header-top">
<div>
<h3 class="tbm-card-leader">
${leaderName}
<span class="tbm-card-leader-role">${leaderRole}</span>
</h3>
</div>
${statusBadge}
</div>
<div class="tbm-card-date">
<span>&#128197;</span>
${this.utils.formatDate(session.session_date)} ${session.start_time ? '| ' + session.start_time : ''}
</div>
</div>
<div class="tbm-card-body">
<div class="tbm-card-info-grid">
<div class="tbm-card-info-item">
<span class="tbm-card-info-label">프로젝트</span>
<span class="tbm-card-info-value">${session.project_name || '-'}</span>
</div>
<div class="tbm-card-info-item">
<span class="tbm-card-info-label">공정</span>
<span class="tbm-card-info-value">${session.work_type_name || '-'}</span>
</div>
<div class="tbm-card-info-item">
<span class="tbm-card-info-label">작업장</span>
<span class="tbm-card-info-value">${session.work_location || '-'}</span>
</div>
<div class="tbm-card-info-item">
<span class="tbm-card-info-label">팀원</span>
<span class="tbm-card-info-value">${session.team_member_count || 0}명</span>
</div>
</div>
</div>
${session.status === 'draft' ? `
<div class="tbm-card-footer">
<button class="tbm-btn tbm-btn-primary tbm-btn-sm" onclick="event.stopPropagation(); openTeamCompositionModal(${session.session_id})">
&#128101; 팀 구성
</button>
<button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="event.stopPropagation(); openSafetyCheckModal(${session.session_id})">
&#10003; 안전 체크
</button>
</div>
` : ''}
</div>
`;
}
/**
* 디버그
*/
debug() {
console.log('[TbmController] 상태 디버그:');
this.state.debug();
}
}
// 전역 인스턴스 생성
window.TbmController = new TbmController();
// 하위 호환성: 기존 전역 함수들
window.switchTbmTab = (tabName) => window.TbmController.switchTbmTab(tabName);
window.displayTodayTbmSessions = () => window.TbmController.displayTodayTbmSessions();
window.displayTbmGroupedByDate = () => window.TbmController.displayTbmGroupedByDate();
window.displayTbmSessions = () => window.TbmController.displayTbmGroupedByDate();
window.createSessionCard = (session) => window.TbmController.createSessionCard(session);
window.updateViewModeIndicator = () => window.TbmController.updateViewModeIndicator();
// 날짜 그룹 토글
window.toggleDateGroup = function(date) {
const group = document.querySelector(`.tbm-date-group[data-date="${date}"]`);
if (group) {
group.classList.toggle('collapsed');
}
};
// DOMContentLoaded 이벤트에서 초기화
document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
window.TbmController.init();
}, 100);
});
console.log('[Module] tbm/index.js 로드 완료');

392
web-ui/js/tbm/state.js Normal file
View File

@@ -0,0 +1,392 @@
/**
* TBM - State Manager
* TBM 페이지의 전역 상태 관리
*/
class TbmState {
constructor() {
// 세션 데이터
this.allSessions = [];
this.todaySessions = [];
this.dateGroupedSessions = {};
this.allLoadedSessions = [];
this.loadedDaysCount = 7;
// 마스터 데이터
this.allWorkers = [];
this.allProjects = [];
this.allWorkTypes = [];
this.allTasks = [];
this.allSafetyChecks = [];
this.allWorkplaces = [];
this.allWorkplaceCategories = [];
// 현재 상태
this.currentUser = null;
this.currentSessionId = null;
this.currentTab = 'tbm-input';
// 작업자 관련
this.selectedWorkers = new Set();
this.workerTaskList = [];
this.selectedWorkersInModal = new Set();
this.currentEditingTaskLine = null;
// 작업장 선택 관련
this.selectedCategory = null;
this.selectedWorkplace = null;
this.selectedCategoryName = '';
this.selectedWorkplaceName = '';
// 일괄 설정 관련
this.isBulkMode = false;
this.bulkSelectedWorkers = new Set();
// 지도 관련
this.mapCanvas = null;
this.mapCtx = null;
this.mapImage = null;
this.mapRegions = [];
// 리스너
this.listeners = new Map();
console.log('[TbmState] 초기화 완료');
}
/**
* 상태 업데이트
*/
update(key, value) {
const prevValue = this[key];
this[key] = value;
this.notifyListeners(key, value, prevValue);
}
/**
* 리스너 등록
*/
subscribe(key, callback) {
if (!this.listeners.has(key)) {
this.listeners.set(key, []);
}
this.listeners.get(key).push(callback);
}
/**
* 리스너 알림
*/
notifyListeners(key, newValue, prevValue) {
const keyListeners = this.listeners.get(key) || [];
keyListeners.forEach(callback => {
try {
callback(newValue, prevValue);
} catch (error) {
console.error(`[TbmState] 리스너 오류 (${key}):`, error);
}
});
}
/**
* 현재 사용자 정보 가져오기
*/
getUser() {
if (!this.currentUser) {
const userInfo = localStorage.getItem('user');
this.currentUser = userInfo ? JSON.parse(userInfo) : null;
}
return this.currentUser;
}
/**
* Admin 여부 확인
*/
isAdminUser() {
const user = this.getUser();
if (!user) return false;
return user.role === 'Admin' || user.role === 'System Admin';
}
/**
* 탭 변경
*/
setCurrentTab(tab) {
const prevTab = this.currentTab;
this.currentTab = tab;
this.notifyListeners('currentTab', tab, prevTab);
}
/**
* 작업자 목록에 추가
*/
addWorkerToList(worker) {
this.workerTaskList.push({
worker_id: worker.worker_id,
worker_name: worker.worker_name,
job_type: worker.job_type,
tasks: [this.createEmptyTaskLine()]
});
this.notifyListeners('workerTaskList', this.workerTaskList, null);
}
/**
* 빈 작업 라인 생성
*/
createEmptyTaskLine() {
return {
task_line_id: this.generateUUID(),
project_id: null,
work_type_id: null,
task_id: null,
workplace_category_id: null,
workplace_id: null,
workplace_category_name: '',
workplace_name: '',
work_detail: null,
is_present: true
};
}
/**
* 작업자에 작업 라인 추가
*/
addTaskLineToWorker(workerIndex) {
if (this.workerTaskList[workerIndex]) {
this.workerTaskList[workerIndex].tasks.push(this.createEmptyTaskLine());
this.notifyListeners('workerTaskList', this.workerTaskList, null);
}
}
/**
* 작업 라인 제거
*/
removeTaskLine(workerIndex, taskIndex) {
if (this.workerTaskList[workerIndex]?.tasks) {
this.workerTaskList[workerIndex].tasks.splice(taskIndex, 1);
this.notifyListeners('workerTaskList', this.workerTaskList, null);
}
}
/**
* 작업자 제거
*/
removeWorkerFromList(workerIndex) {
const removed = this.workerTaskList.splice(workerIndex, 1);
this.notifyListeners('workerTaskList', this.workerTaskList, null);
return removed[0];
}
/**
* 작업장 선택 초기화
*/
resetWorkplaceSelection() {
this.selectedCategory = null;
this.selectedWorkplace = null;
this.selectedCategoryName = '';
this.selectedWorkplaceName = '';
this.mapCanvas = null;
this.mapCtx = null;
this.mapImage = null;
this.mapRegions = [];
}
/**
* 일괄 설정 초기화
*/
resetBulkSettings() {
this.isBulkMode = false;
this.bulkSelectedWorkers.clear();
}
/**
* 날짜별 세션 그룹화
*/
groupSessionsByDate(sessions) {
this.dateGroupedSessions = {};
this.allLoadedSessions = [];
sessions.forEach(session => {
const date = this.formatDate(session.session_date);
if (!this.dateGroupedSessions[date]) {
this.dateGroupedSessions[date] = [];
}
this.dateGroupedSessions[date].push(session);
this.allLoadedSessions.push(session);
});
}
/**
* 날짜 포맷팅
*/
formatDate(dateString) {
if (!dateString) return '';
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
return dateString;
}
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* UUID 생성
*/
generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/**
* 상태 초기화
*/
reset() {
this.workerTaskList = [];
this.selectedWorkers.clear();
this.selectedWorkersInModal.clear();
this.currentEditingTaskLine = null;
this.resetWorkplaceSelection();
this.resetBulkSettings();
}
/**
* 디버그 출력
*/
debug() {
console.log('[TbmState] 현재 상태:', {
allSessions: this.allSessions.length,
todaySessions: this.todaySessions.length,
allWorkers: this.allWorkers.length,
allProjects: this.allProjects.length,
workerTaskList: this.workerTaskList.length,
currentTab: this.currentTab
});
}
}
// 전역 인스턴스 생성
window.TbmState = new TbmState();
// 하위 호환성을 위한 전역 변수 프록시
const tbmStateProxy = window.TbmState;
Object.defineProperties(window, {
allSessions: {
get: () => tbmStateProxy.allSessions,
set: (v) => { tbmStateProxy.allSessions = v; }
},
todaySessions: {
get: () => tbmStateProxy.todaySessions,
set: (v) => { tbmStateProxy.todaySessions = v; }
},
allWorkers: {
get: () => tbmStateProxy.allWorkers,
set: (v) => { tbmStateProxy.allWorkers = v; }
},
allProjects: {
get: () => tbmStateProxy.allProjects,
set: (v) => { tbmStateProxy.allProjects = v; }
},
allWorkTypes: {
get: () => tbmStateProxy.allWorkTypes,
set: (v) => { tbmStateProxy.allWorkTypes = v; }
},
allTasks: {
get: () => tbmStateProxy.allTasks,
set: (v) => { tbmStateProxy.allTasks = v; }
},
allSafetyChecks: {
get: () => tbmStateProxy.allSafetyChecks,
set: (v) => { tbmStateProxy.allSafetyChecks = v; }
},
allWorkplaces: {
get: () => tbmStateProxy.allWorkplaces,
set: (v) => { tbmStateProxy.allWorkplaces = v; }
},
allWorkplaceCategories: {
get: () => tbmStateProxy.allWorkplaceCategories,
set: (v) => { tbmStateProxy.allWorkplaceCategories = v; }
},
currentUser: {
get: () => tbmStateProxy.currentUser,
set: (v) => { tbmStateProxy.currentUser = v; }
},
currentSessionId: {
get: () => tbmStateProxy.currentSessionId,
set: (v) => { tbmStateProxy.currentSessionId = v; }
},
selectedWorkers: {
get: () => tbmStateProxy.selectedWorkers,
set: (v) => { tbmStateProxy.selectedWorkers = v; }
},
workerTaskList: {
get: () => tbmStateProxy.workerTaskList,
set: (v) => { tbmStateProxy.workerTaskList = v; }
},
selectedWorkersInModal: {
get: () => tbmStateProxy.selectedWorkersInModal,
set: (v) => { tbmStateProxy.selectedWorkersInModal = v; }
},
currentEditingTaskLine: {
get: () => tbmStateProxy.currentEditingTaskLine,
set: (v) => { tbmStateProxy.currentEditingTaskLine = v; }
},
selectedCategory: {
get: () => tbmStateProxy.selectedCategory,
set: (v) => { tbmStateProxy.selectedCategory = v; }
},
selectedWorkplace: {
get: () => tbmStateProxy.selectedWorkplace,
set: (v) => { tbmStateProxy.selectedWorkplace = v; }
},
selectedCategoryName: {
get: () => tbmStateProxy.selectedCategoryName,
set: (v) => { tbmStateProxy.selectedCategoryName = v; }
},
selectedWorkplaceName: {
get: () => tbmStateProxy.selectedWorkplaceName,
set: (v) => { tbmStateProxy.selectedWorkplaceName = v; }
},
isBulkMode: {
get: () => tbmStateProxy.isBulkMode,
set: (v) => { tbmStateProxy.isBulkMode = v; }
},
bulkSelectedWorkers: {
get: () => tbmStateProxy.bulkSelectedWorkers,
set: (v) => { tbmStateProxy.bulkSelectedWorkers = v; }
},
dateGroupedSessions: {
get: () => tbmStateProxy.dateGroupedSessions,
set: (v) => { tbmStateProxy.dateGroupedSessions = v; }
},
allLoadedSessions: {
get: () => tbmStateProxy.allLoadedSessions,
set: (v) => { tbmStateProxy.allLoadedSessions = v; }
},
loadedDaysCount: {
get: () => tbmStateProxy.loadedDaysCount,
set: (v) => { tbmStateProxy.loadedDaysCount = v; }
},
mapRegions: {
get: () => tbmStateProxy.mapRegions,
set: (v) => { tbmStateProxy.mapRegions = v; }
},
mapCanvas: {
get: () => tbmStateProxy.mapCanvas,
set: (v) => { tbmStateProxy.mapCanvas = v; }
},
mapCtx: {
get: () => tbmStateProxy.mapCtx,
set: (v) => { tbmStateProxy.mapCtx = v; }
},
mapImage: {
get: () => tbmStateProxy.mapImage,
set: (v) => { tbmStateProxy.mapImage = v; }
}
});
console.log('[Module] tbm/state.js 로드 완료');

253
web-ui/js/tbm/utils.js Normal file
View File

@@ -0,0 +1,253 @@
/**
* TBM - Utilities
* TBM 관련 유틸리티 함수들
*/
class TbmUtils {
constructor() {
console.log('[TbmUtils] 초기화 완료');
}
/**
* 서울 시간대(Asia/Seoul, UTC+9) 기준 오늘 날짜를 YYYY-MM-DD 형식으로 반환
*/
getTodayKST() {
const now = new Date();
const kstOffset = 9 * 60;
const utc = now.getTime() + (now.getTimezoneOffset() * 60000);
const kstTime = new Date(utc + (kstOffset * 60000));
const year = kstTime.getFullYear();
const month = String(kstTime.getMonth() + 1).padStart(2, '0');
const day = String(kstTime.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* ISO 날짜 문자열을 YYYY-MM-DD 형식으로 변환
*/
formatDate(dateString) {
if (!dateString) return '';
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
return dateString;
}
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 날짜 표시용 포맷 (MM월 DD일)
*/
formatDateDisplay(dateString) {
if (!dateString) return '';
const [year, month, day] = dateString.split('-');
return `${parseInt(month)}${parseInt(day)}`;
}
/**
* 날짜를 연/월/일/요일 형식으로 포맷
*/
formatDateFull(dateString) {
if (!dateString) return '';
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
const [year, month, day] = dateString.split('-');
const dateObj = new Date(dateString);
const dayName = dayNames[dateObj.getDay()];
return `${year}${parseInt(month)}${parseInt(day)}일 (${dayName})`;
}
/**
* 요일 반환
*/
getDayOfWeek(dateString) {
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
const dateObj = new Date(dateString + 'T00:00:00');
return dayNames[dateObj.getDay()];
}
/**
* 오늘인지 확인
*/
isToday(dateString) {
const today = this.getTodayKST();
return this.formatDate(dateString) === today;
}
/**
* 현재 시간을 HH:MM 형식으로 반환
*/
getCurrentTime() {
return new Date().toTimeString().slice(0, 5);
}
/**
* UUID 생성
*/
generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/**
* HTML 이스케이프
*/
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* 날씨 조건명 반환
*/
getWeatherConditionName(code) {
const names = {
clear: '맑음',
rain: '비',
snow: '눈',
heat: '폭염',
cold: '한파',
wind: '강풍',
fog: '안개',
dust: '미세먼지'
};
return names[code] || code;
}
/**
* 날씨 아이콘 반환
*/
getWeatherIcon(code) {
const icons = {
clear: '☀️',
rain: '🌧️',
snow: '❄️',
heat: '🔥',
cold: '🥶',
wind: '💨',
fog: '🌫️',
dust: '😷'
};
return icons[code] || '🌤️';
}
/**
* 카테고리명 반환
*/
getCategoryName(category) {
const names = {
'PPE': '개인 보호 장비',
'EQUIPMENT': '장비 점검',
'ENVIRONMENT': '작업 환경',
'EMERGENCY': '비상 대응',
'WEATHER': '날씨',
'TASK': '작업'
};
return names[category] || category;
}
/**
* 상태 배지 HTML 반환
*/
getStatusBadge(status) {
const badges = {
'draft': '<span class="tbm-card-status draft">진행중</span>',
'completed': '<span class="tbm-card-status completed">완료</span>',
'cancelled': '<span class="tbm-card-status cancelled">취소</span>'
};
return badges[status] || '';
}
}
// 전역 인스턴스 생성
window.TbmUtils = new TbmUtils();
// 하위 호환성: 기존 함수들
window.getTodayKST = () => window.TbmUtils.getTodayKST();
window.formatDate = (dateString) => window.TbmUtils.formatDate(dateString);
// 토스트 알림
window.showToast = function(message, type = 'info', duration = 3000) {
const container = document.getElementById('toastContainer');
if (!container) {
console.log(`[Toast] ${type}: ${message}`);
return;
}
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const iconMap = {
success: '✅',
error: '❌',
warning: '⚠️',
info: ''
};
toast.innerHTML = `
<div class="toast-icon">${iconMap[type] || ''}</div>
<div class="toast-message">${message}</div>
<button class="toast-close" onclick="this.parentElement.remove()">×</button>
`;
toast.style.cssText = `
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: white;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
margin-bottom: 0.75rem;
min-width: 300px;
animation: slideIn 0.3s ease-out;
`;
container.appendChild(toast);
setTimeout(() => {
if (toast.parentElement) {
toast.style.animation = 'slideOut 0.3s ease-out';
setTimeout(() => toast.remove(), 300);
}
}, duration);
};
// 카테고리별 그룹화
window.groupChecksByCategory = function(checks) {
return checks.reduce((acc, check) => {
const category = check.check_category || 'OTHER';
if (!acc[category]) acc[category] = [];
acc[category].push(check);
return acc;
}, {});
};
// 작업별 그룹화
window.groupChecksByTask = function(checks) {
return checks.reduce((acc, check) => {
const taskId = check.task_id || 0;
const taskName = check.task_name || '기타 작업';
if (!acc[taskId]) acc[taskId] = { name: taskName, items: [] };
acc[taskId].items.push(check);
return acc;
}, {});
};
// Admin 사용자 확인
window.isAdminUser = function() {
return window.TbmState?.isAdminUser() || false;
};
console.log('[Module] tbm/utils.js 로드 완료');

View File

@@ -0,0 +1,329 @@
/**
* Workplace Management - API Client
* 작업장 관리 관련 모든 API 호출을 관리
*/
class WorkplaceAPI {
constructor() {
this.state = window.WorkplaceState;
this.utils = window.WorkplaceUtils;
console.log('[WorkplaceAPI] 초기화 완료');
}
/**
* 모든 데이터 로드
*/
async loadAllData() {
try {
await Promise.all([
this.loadCategories(),
this.loadWorkplaces()
]);
console.log('✅ 모든 데이터 로드 완료');
} catch (error) {
console.error('데이터 로딩 오류:', error);
window.showToast?.('데이터를 불러오는데 실패했습니다.', 'error');
}
}
/**
* 카테고리 목록 로드
*/
async loadCategories() {
try {
const response = await window.apiCall('/workplaces/categories', 'GET');
let categoryData = [];
if (response && response.success && Array.isArray(response.data)) {
categoryData = response.data;
} else if (Array.isArray(response)) {
categoryData = response;
}
this.state.categories = categoryData;
console.log(`✅ 카테고리 ${this.state.categories.length}개 로드 완료`);
return categoryData;
} catch (error) {
console.error('카테고리 로딩 오류:', error);
this.state.categories = [];
return [];
}
}
/**
* 카테고리 저장 (생성/수정)
*/
async saveCategory(categoryId, categoryData) {
try {
let response;
if (categoryId) {
response = await window.apiCall(`/workplaces/categories/${categoryId}`, 'PUT', categoryData);
} else {
response = await window.apiCall('/workplaces/categories', 'POST', categoryData);
}
if (response && (response.success || response.category_id)) {
return response;
}
throw new Error(response?.message || '저장에 실패했습니다.');
} catch (error) {
console.error('카테고리 저장 오류:', error);
throw error;
}
}
/**
* 카테고리 삭제
*/
async deleteCategory(categoryId) {
try {
const response = await window.apiCall(`/workplaces/categories/${categoryId}`, 'DELETE');
if (response && response.success) {
return response;
}
throw new Error(response?.message || '삭제에 실패했습니다.');
} catch (error) {
console.error('카테고리 삭제 오류:', error);
throw error;
}
}
/**
* 작업장 목록 로드
*/
async loadWorkplaces() {
try {
const response = await window.apiCall('/workplaces', 'GET');
let workplaceData = [];
if (response && response.success && Array.isArray(response.data)) {
workplaceData = response.data;
} else if (Array.isArray(response)) {
workplaceData = response;
}
this.state.workplaces = workplaceData;
console.log(`✅ 작업장 ${this.state.workplaces.length}개 로드 완료`);
return workplaceData;
} catch (error) {
console.error('작업장 로딩 오류:', error);
this.state.workplaces = [];
return [];
}
}
/**
* 작업장 저장 (생성/수정)
*/
async saveWorkplace(workplaceId, workplaceData) {
try {
let response;
if (workplaceId) {
response = await window.apiCall(`/workplaces/${workplaceId}`, 'PUT', workplaceData);
} else {
response = await window.apiCall('/workplaces', 'POST', workplaceData);
}
if (response && (response.success || response.workplace_id)) {
return response;
}
throw new Error(response?.message || '저장에 실패했습니다.');
} catch (error) {
console.error('작업장 저장 오류:', error);
throw error;
}
}
/**
* 작업장 삭제
*/
async deleteWorkplace(workplaceId) {
try {
const response = await window.apiCall(`/workplaces/${workplaceId}`, 'DELETE');
if (response && response.success) {
return response;
}
throw new Error(response?.message || '삭제에 실패했습니다.');
} catch (error) {
console.error('작업장 삭제 오류:', error);
throw error;
}
}
/**
* 카테고리의 지도 영역 로드
*/
async loadMapRegions(categoryId) {
try {
const response = await window.apiCall(`/workplaces/categories/${categoryId}/map-regions`, 'GET');
let regions = [];
if (response && response.success && Array.isArray(response.data)) {
regions = response.data;
} else if (Array.isArray(response)) {
regions = response;
}
return regions;
} catch (error) {
console.error('지도 영역 로드 오류:', error);
return [];
}
}
/**
* 작업장의 지도 영역 로드
*/
async loadWorkplaceMapRegion(workplaceId) {
try {
const response = await window.apiCall(`/workplaces/map-regions/workplace/${workplaceId}`, 'GET');
return response;
} catch (error) {
console.error('작업장 지도 영역 로드 오류:', error);
return null;
}
}
/**
* 작업장의 설비 목록 로드
*/
async loadWorkplaceEquipments(workplaceId) {
try {
const response = await window.apiCall(`/equipments/workplace/${workplaceId}`, 'GET');
let equipments = [];
if (response && response.success && Array.isArray(response.data)) {
equipments = response.data;
} else if (Array.isArray(response)) {
equipments = response;
}
// 지도 영역이 있는 설비만 workplaceEquipmentRegions에 추가
this.state.workplaceEquipmentRegions = equipments
.filter(eq => eq.map_x_percent != null && eq.map_y_percent != null)
.map(eq => ({
equipment_id: eq.equipment_id,
equipment_name: eq.equipment_name,
equipment_code: eq.equipment_code,
x_percent: parseFloat(eq.map_x_percent),
y_percent: parseFloat(eq.map_y_percent),
width_percent: parseFloat(eq.map_width_percent) || 10,
height_percent: parseFloat(eq.map_height_percent) || 10
}));
this.state.existingEquipments = equipments;
console.log(`✅ 작업장 ${workplaceId}의 설비 ${equipments.length}개 로드 완료`);
return equipments;
} catch (error) {
console.error('설비 로드 오류:', error);
this.state.workplaceEquipmentRegions = [];
this.state.existingEquipments = [];
return [];
}
}
/**
* 전체 설비 목록 로드
*/
async loadAllEquipments() {
try {
const response = await window.apiCall('/equipments', 'GET');
let equipments = [];
if (response && response.success && Array.isArray(response.data)) {
equipments = response.data;
} else if (Array.isArray(response)) {
equipments = response;
}
this.state.allEquipments = equipments;
console.log(`✅ 전체 설비 ${this.state.allEquipments.length}개 로드 완료`);
return equipments;
} catch (error) {
console.error('전체 설비 로드 오류:', error);
this.state.allEquipments = [];
return [];
}
}
/**
* 설비 지도 위치 업데이트
*/
async updateEquipmentMapPosition(equipmentId, positionData) {
try {
const response = await window.apiCall(`/equipments/${equipmentId}/map-position`, 'PATCH', positionData);
if (!response || !response.success) {
throw new Error(response?.message || '위치 저장 실패');
}
return response;
} catch (error) {
console.error('설비 위치 업데이트 오류:', error);
throw error;
}
}
/**
* 새 설비 생성
*/
async createEquipment(equipmentData) {
try {
const response = await window.apiCall('/equipments', 'POST', equipmentData);
if (!response || !response.success) {
throw new Error(response?.message || '설비 생성 실패');
}
return response;
} catch (error) {
console.error('설비 생성 오류:', error);
throw error;
}
}
/**
* 다음 관리번호 조회
*/
async getNextEquipmentCode() {
try {
const response = await window.apiCall('/equipments/next-code', 'GET');
if (response && response.success) {
return response.data.next_code;
}
return null;
} catch (error) {
console.error('다음 관리번호 조회 실패:', error);
return null;
}
}
/**
* 작업장 레이아웃 이미지 업로드
*/
async uploadWorkplaceLayout(workplaceId, formData) {
try {
const response = await fetch(
`${this.utils.getApiBaseUrl()}/workplaces/${workplaceId}/layout-image`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: formData
}
);
return await response.json();
} catch (error) {
console.error('레이아웃 이미지 업로드 오류:', error);
throw error;
}
}
}
// 전역 인스턴스 생성
window.WorkplaceAPI = new WorkplaceAPI();
// 하위 호환성: 기존 함수들
window.loadCategories = () => window.WorkplaceAPI.loadCategories();
window.loadWorkplaces = () => window.WorkplaceAPI.loadWorkplaces();
window.loadWorkplaceEquipments = (id) => window.WorkplaceAPI.loadWorkplaceEquipments(id);
window.loadAllEquipments = () => window.WorkplaceAPI.loadAllEquipments();
console.log('[Module] workplace-management/api.js 로드 완료');

View File

@@ -0,0 +1,553 @@
/**
* Workplace Management - Module Loader
* 작업장 관리 모듈을 초기화하고 연결하는 메인 진입점
*
* 로드 순서:
* 1. state.js - 전역 상태 관리
* 2. utils.js - 유틸리티 함수
* 3. api.js - API 클라이언트
* 4. index.js - 이 파일 (메인 컨트롤러)
*/
class WorkplaceController {
constructor() {
this.state = window.WorkplaceState;
this.api = window.WorkplaceAPI;
this.utils = window.WorkplaceUtils;
this.initialized = false;
console.log('[WorkplaceController] 생성');
}
/**
* 초기화
*/
async init() {
if (this.initialized) {
console.log('[WorkplaceController] 이미 초기화됨');
return;
}
console.log('🏗️ 작업장 관리 페이지 초기화 시작');
// API 함수가 로드될 때까지 대기
let retryCount = 0;
while (!window.apiCall && retryCount < 50) {
await new Promise(resolve => setTimeout(resolve, 100));
retryCount++;
}
if (!window.apiCall) {
window.showToast?.('시스템을 초기화할 수 없습니다. 페이지를 새로고침해주세요.', 'error');
return;
}
// 모든 데이터 로드
await this.loadAllData();
this.initialized = true;
console.log('[WorkplaceController] 초기화 완료');
}
/**
* 모든 데이터 로드
*/
async loadAllData() {
try {
await this.api.loadAllData();
this.renderCategoryTabs();
this.renderWorkplaces();
this.updateStatistics();
} catch (error) {
console.error('데이터 로딩 오류:', error);
window.showToast?.('데이터를 불러오는데 실패했습니다.', 'error');
}
}
/**
* 카테고리 탭 렌더링
*/
renderCategoryTabs() {
const tabsContainer = document.getElementById('categoryTabs');
if (!tabsContainer) return;
const categories = this.state.categories;
const workplaces = this.state.workplaces;
const currentCategoryId = this.state.currentCategoryId;
let tabsHtml = `
<button class="wp-tab-btn ${currentCategoryId === '' ? 'active' : ''}"
data-category=""
onclick="switchCategory('')">
<span class="wp-tab-icon">🏗️</span>
전체
<span class="wp-tab-count">${workplaces.length}</span>
</button>
`;
categories.forEach(category => {
const count = workplaces.filter(w => w.category_id === category.category_id).length;
const isActive = currentCategoryId === category.category_id;
tabsHtml += `
<button class="wp-tab-btn ${isActive ? 'active' : ''}"
data-category="${category.category_id}"
onclick="switchCategory(${category.category_id})">
<span class="wp-tab-icon">🏭</span>
${category.category_name}
<span class="wp-tab-count">${count}</span>
</button>
`;
});
tabsContainer.innerHTML = tabsHtml;
}
/**
* 카테고리 전환
*/
async switchCategory(categoryId) {
this.state.setCurrentCategory(categoryId);
this.renderCategoryTabs();
this.renderWorkplaces();
const layoutMapSection = document.getElementById('layoutMapSection');
const selectedCategoryName = document.getElementById('selectedCategoryName');
if (categoryId && layoutMapSection) {
const category = this.state.getCurrentCategory();
if (category) {
layoutMapSection.style.display = 'block';
if (selectedCategoryName) {
selectedCategoryName.textContent = category.category_name;
}
await this.updateLayoutPreview(category);
}
} else if (layoutMapSection) {
layoutMapSection.style.display = 'none';
}
}
/**
* 레이아웃 미리보기 업데이트
*/
async updateLayoutPreview(category) {
const previewDiv = document.getElementById('layoutMapPreview');
if (!previewDiv) return;
if (category.layout_image) {
const fullImageUrl = this.utils.getFullImageUrl(category.layout_image);
previewDiv.innerHTML = `
<div style="text-align: center;">
<canvas id="previewCanvas" style="max-width: 100%; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); cursor: default;"></canvas>
<p style="color: #64748b; margin-top: 12px; font-size: 14px;">
클릭하여 작업장 영역을 수정하려면 "지도 설정" 버튼을 누르세요
</p>
</div>
`;
await this.loadImageWithRegions(fullImageUrl, category.category_id);
} else {
previewDiv.innerHTML = `
<div style="padding: 40px;">
<span style="font-size: 48px;">🗺️</span>
<p style="color: #94a3b8; margin-top: 16px;">
이 공장의 레이아웃 이미지가 아직 등록되지 않았습니다
</p>
<p style="color: #cbd5e1; font-size: 14px; margin-top: 8px;">
"지도 설정" 버튼을 눌러 레이아웃 이미지를 업로드하고 작업장 위치를 지정하세요
</p>
</div>
`;
}
}
/**
* 이미지와 영역을 캔버스에 로드
*/
async loadImageWithRegions(imageUrl, categoryId) {
const canvas = document.getElementById('previewCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const img = new Image();
const self = this;
img.onload = async function() {
const maxWidth = 800;
const scale = img.width > maxWidth ? maxWidth / img.width : 1;
canvas.width = img.width * scale;
canvas.height = img.height * scale;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
try {
const regions = await self.api.loadMapRegions(categoryId);
regions.forEach(region => {
const x1 = (region.x_start / 100) * canvas.width;
const y1 = (region.y_start / 100) * canvas.height;
const x2 = (region.x_end / 100) * canvas.width;
const y2 = (region.y_end / 100) * canvas.height;
const width = x2 - x1;
const height = y2 - y1;
ctx.strokeStyle = '#10b981';
ctx.lineWidth = 2;
ctx.strokeRect(x1, y1, width, height);
ctx.fillStyle = 'rgba(16, 185, 129, 0.15)';
ctx.fillRect(x1, y1, width, height);
if (region.workplace_name) {
ctx.font = 'bold 14px sans-serif';
const textMetrics = ctx.measureText(region.workplace_name);
const textPadding = 4;
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
ctx.fillRect(x1 + 5, y1 + 5, textMetrics.width + textPadding * 2, 20);
ctx.fillStyle = '#10b981';
ctx.fillText(region.workplace_name, x1 + 5 + textPadding, y1 + 20);
}
});
if (regions.length > 0) {
console.log(`✅ 레이아웃 미리보기에 ${regions.length}개 영역 표시 완료`);
}
} catch (error) {
console.error('영역 로드 오류:', error);
}
};
img.onerror = function() {
console.error('이미지 로드 실패:', imageUrl);
};
img.src = imageUrl;
}
/**
* 작업장 렌더링
*/
renderWorkplaces() {
const grid = document.getElementById('workplaceGrid');
if (!grid) return;
const filtered = this.state.getFilteredWorkplaces();
if (filtered.length === 0) {
grid.innerHTML = `
<div class="wp-empty-state">
<div class="wp-empty-icon">🏗️</div>
<h3 class="wp-empty-title">등록된 작업장이 없습니다</h3>
<p class="wp-empty-description">"작업장 추가" 버튼을 눌러 작업장을 등록해보세요</p>
<button class="wp-btn wp-btn-primary" onclick="openWorkplaceModal()">
<span class="wp-btn-icon"></span>
첫 작업장 추가하기
</button>
</div>
`;
return;
}
let gridHtml = '';
filtered.forEach(workplace => {
const categoryName = workplace.category_name || '미분류';
const isActive = workplace.is_active === 1 || workplace.is_active === true;
const purposeIcon = this.utils.getPurposeIcon(workplace.workplace_purpose);
gridHtml += `
<div class="wp-card ${isActive ? '' : 'inactive'}" onclick="editWorkplace(${workplace.workplace_id})">
<div class="wp-card-header">
<div class="wp-card-icon">${purposeIcon}</div>
<div class="wp-card-info">
<h3 class="wp-card-title">${workplace.workplace_name}</h3>
<div class="wp-card-tags">
${workplace.category_id ? `<span class="wp-card-tag factory">🏭 ${categoryName}</span>` : ''}
${workplace.workplace_purpose ? `<span class="wp-card-tag purpose">${workplace.workplace_purpose}</span>` : ''}
</div>
</div>
<div class="wp-card-actions">
<button class="wp-card-btn map" onclick="event.stopPropagation(); openWorkplaceMapModal(${workplace.workplace_id})" title="지도 관리">
🗺️
</button>
<button class="wp-card-btn edit" onclick="event.stopPropagation(); editWorkplace(${workplace.workplace_id})" title="수정">
✏️
</button>
<button class="wp-card-btn delete" onclick="event.stopPropagation(); confirmDeleteWorkplace(${workplace.workplace_id})" title="삭제">
🗑️
</button>
</div>
</div>
${workplace.description ? `<p class="wp-card-description">${workplace.description}</p>` : ''}
<div class="wp-card-map" id="workplace-map-${workplace.workplace_id}"></div>
<div class="wp-card-meta">
<span class="wp-card-date">등록: ${this.utils.formatDate(workplace.created_at)}</span>
${workplace.updated_at !== workplace.created_at ? `<span class="wp-card-date">수정: ${this.utils.formatDate(workplace.updated_at)}</span>` : ''}
</div>
</div>
`;
});
grid.innerHTML = gridHtml;
filtered.forEach(workplace => {
if (workplace.category_id) {
this.loadWorkplaceMapThumbnail(workplace);
}
});
}
/**
* 작업장 카드에 지도 썸네일 로드
*/
async loadWorkplaceMapThumbnail(workplace) {
const thumbnailDiv = document.getElementById(`workplace-map-${workplace.workplace_id}`);
if (!thumbnailDiv) return;
if (workplace.layout_image) {
const fullImageUrl = this.utils.getFullImageUrl(workplace.layout_image);
let equipmentCount = 0;
try {
const eqResponse = await window.apiCall(`/equipments/workplace/${workplace.workplace_id}`, 'GET');
if (eqResponse && eqResponse.success && Array.isArray(eqResponse.data)) {
equipmentCount = eqResponse.data.filter(eq => eq.map_x_percent != null).length;
}
} catch (e) {
console.debug('설비 정보 로드 실패');
}
const canvasId = `layout-canvas-${workplace.workplace_id}`;
thumbnailDiv.innerHTML = `
<div style="text-align: center; padding: 10px; background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); border-radius: 8px; border: 1px solid #bae6fd; cursor: pointer;" onclick="openWorkplaceMapModal(${workplace.workplace_id})">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span style="font-size: 12px; color: #0369a1; font-weight: 600;">📍 작업장 지도</span>
${equipmentCount > 0 ? `<span style="font-size: 11px; background: #10b981; color: white; padding: 2px 8px; border-radius: 10px;">설비 ${equipmentCount}개</span>` : ''}
</div>
<canvas id="${canvasId}" style="max-width: 100%; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.12);"></canvas>
<div style="font-size: 11px; color: #64748b; margin-top: 8px;">클릭하여 지도 관리</div>
</div>
`;
await this.loadWorkplaceCanvasWithEquipments(workplace.workplace_id, fullImageUrl, canvasId);
return;
}
try {
const response = await this.api.loadWorkplaceMapRegion(workplace.workplace_id);
if (!response || (!response.success && !response.region_id)) {
thumbnailDiv.innerHTML = `
<div style="text-align: center; padding: 16px; background: #f9fafb; border-radius: 8px; border: 2px dashed #cbd5e1; cursor: pointer;" onclick="openWorkplaceMapModal(${workplace.workplace_id})">
<div style="font-size: 24px; margin-bottom: 8px;">🗺️</div>
<div style="font-size: 12px; color: #64748b;">클릭하여 지도 설정</div>
</div>
`;
return;
}
const region = response.success ? response.data : response;
if (!region || region.x_start === undefined || region.y_start === undefined ||
region.x_end === undefined || region.y_end === undefined) {
return;
}
const category = this.state.categories.find(c => c.category_id === workplace.category_id);
if (!category || !category.layout_image) return;
const fullImageUrl = this.utils.getFullImageUrl(category.layout_image);
const canvasId = `thumbnail-canvas-${workplace.workplace_id}`;
thumbnailDiv.innerHTML = `
<div style="text-align: center; padding: 10px; background: #f9fafb; border-radius: 8px; border: 1px solid #e5e7eb; cursor: pointer;" onclick="openWorkplaceMapModal(${workplace.workplace_id})">
<div style="font-size: 12px; color: #64748b; margin-bottom: 6px; font-weight: 500;">📍 공장 지도 내 위치</div>
<canvas id="${canvasId}" style="max-width: 100%; border-radius: 6px; box-shadow: 0 2px 6px rgba(0,0,0,0.1);"></canvas>
<div style="font-size: 11px; color: #94a3b8; margin-top: 6px;">클릭하여 상세 지도 설정</div>
</div>
`;
const img = new Image();
img.onload = function() {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const x1 = (region.x_start / 100) * img.width;
const y1 = (region.y_start / 100) * img.height;
const x2 = (region.x_end / 100) * img.width;
const y2 = (region.y_end / 100) * img.height;
const regionWidth = x2 - x1;
const regionHeight = y2 - y1;
const maxThumbWidth = 350;
const scale = regionWidth > maxThumbWidth ? maxThumbWidth / regionWidth : 1;
canvas.width = regionWidth * scale;
canvas.height = regionHeight * scale;
ctx.drawImage(
img,
x1, y1, regionWidth, regionHeight,
0, 0, canvas.width, canvas.height
);
ctx.strokeStyle = '#10b981';
ctx.lineWidth = 3;
ctx.strokeRect(0, 0, canvas.width, canvas.height);
};
img.onerror = function() {
thumbnailDiv.innerHTML = '';
};
img.src = fullImageUrl;
} catch (error) {
console.debug(`작업장 ${workplace.workplace_id}의 지도 영역 없음`);
}
}
/**
* 작업장 캔버스에 설비 영역 함께 그리기
*/
async loadWorkplaceCanvasWithEquipments(workplaceId, imageUrl, canvasId) {
const img = new Image();
img.onload = async function() {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const maxThumbWidth = 400;
const scale = img.width > maxThumbWidth ? maxThumbWidth / img.width : 1;
canvas.width = img.width * scale;
canvas.height = img.height * scale;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
try {
const response = await window.apiCall(`/equipments/workplace/${workplaceId}`, 'GET');
let equipments = [];
if (response && response.success && Array.isArray(response.data)) {
equipments = response.data.filter(eq => eq.map_x_percent != null);
}
equipments.forEach(eq => {
const x = (parseFloat(eq.map_x_percent) / 100) * canvas.width;
const y = (parseFloat(eq.map_y_percent) / 100) * canvas.height;
const width = (parseFloat(eq.map_width_percent || 10) / 100) * canvas.width;
const height = (parseFloat(eq.map_height_percent || 10) / 100) * canvas.height;
ctx.fillStyle = 'rgba(16, 185, 129, 0.2)';
ctx.fillRect(x, y, width, height);
ctx.strokeStyle = '#10b981';
ctx.lineWidth = 2;
ctx.strokeRect(x, y, width, height);
if (eq.equipment_code) {
ctx.font = 'bold 10px sans-serif';
const textMetrics = ctx.measureText(eq.equipment_code);
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
ctx.fillRect(x + 2, y + 2, textMetrics.width + 6, 14);
ctx.fillStyle = '#047857';
ctx.fillText(eq.equipment_code, x + 5, y + 12);
}
});
} catch (error) {
console.debug('설비 영역 로드 실패');
}
};
img.src = imageUrl;
}
/**
* 통계 업데이트
*/
async updateStatistics() {
const stats = this.state.getStatistics();
const factoryCountEl = document.getElementById('factoryCount');
const totalCountEl = document.getElementById('totalCount');
const activeCountEl = document.getElementById('activeCount');
const equipmentCountEl = document.getElementById('equipmentCount');
if (factoryCountEl) factoryCountEl.textContent = stats.factoryTotal;
if (totalCountEl) totalCountEl.textContent = stats.total;
if (activeCountEl) activeCountEl.textContent = stats.active;
if (equipmentCountEl) {
try {
const equipments = await this.api.loadAllEquipments();
equipmentCountEl.textContent = equipments.length;
} catch (e) {
equipmentCountEl.textContent = '-';
}
}
const sectionTotalEl = document.getElementById('sectionTotalCount');
const sectionActiveEl = document.getElementById('sectionActiveCount');
if (sectionTotalEl) sectionTotalEl.textContent = stats.filteredTotal;
if (sectionActiveEl) sectionActiveEl.textContent = stats.filteredActive;
}
/**
* 전체 새로고침
*/
async refreshWorkplaces() {
const refreshBtn = document.querySelector('.btn-secondary');
if (refreshBtn) {
const originalText = refreshBtn.innerHTML;
refreshBtn.innerHTML = '<span class="btn-icon">⏳</span>새로고침 중...';
refreshBtn.disabled = true;
await this.loadAllData();
refreshBtn.innerHTML = originalText;
refreshBtn.disabled = false;
} else {
await this.loadAllData();
}
window.showToast?.('데이터가 새로고침되었습니다.', 'success');
}
/**
* 디버그
*/
debug() {
console.log('[WorkplaceController] 상태 디버그:');
this.state.debug();
}
}
// 전역 인스턴스 생성
window.WorkplaceController = new WorkplaceController();
// 하위 호환성: 기존 전역 함수들
window.switchCategory = (categoryId) => window.WorkplaceController.switchCategory(categoryId);
window.renderCategoryTabs = () => window.WorkplaceController.renderCategoryTabs();
window.renderWorkplaces = () => window.WorkplaceController.renderWorkplaces();
window.updateStatistics = () => window.WorkplaceController.updateStatistics();
window.refreshWorkplaces = () => window.WorkplaceController.refreshWorkplaces();
window.loadAllData = () => window.WorkplaceController.loadAllData();
window.updateLayoutPreview = (category) => window.WorkplaceController.updateLayoutPreview(category);
// DOMContentLoaded 이벤트에서 초기화
document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
window.WorkplaceController.init();
}, 100);
});
console.log('[Module] workplace-management/index.js 로드 완료');

View File

@@ -0,0 +1,284 @@
/**
* Workplace Management - State Manager
* 작업장 관리 페이지의 전역 상태 관리
*/
class WorkplaceState {
constructor() {
// 마스터 데이터
this.categories = [];
this.workplaces = [];
this.allEquipments = [];
this.existingEquipments = [];
// 현재 상태
this.currentCategoryId = '';
this.currentEditingCategory = null;
this.currentEditingWorkplace = null;
this.currentWorkplaceMapId = null;
// 작업장 지도 관련
this.workplaceCanvas = null;
this.workplaceCtx = null;
this.workplaceImage = null;
this.workplaceIsDrawing = false;
this.workplaceStartX = 0;
this.workplaceStartY = 0;
this.workplaceCurrentRect = null;
this.workplaceEquipmentRegions = [];
// 전체화면 편집기 관련
this.fsCanvas = null;
this.fsCtx = null;
this.fsImage = null;
this.fsIsDrawing = false;
this.fsStartX = 0;
this.fsStartY = 0;
this.fsCurrentRect = null;
this.fsSidebarVisible = true;
// 리스너
this.listeners = new Map();
console.log('[WorkplaceState] 초기화 완료');
}
/**
* 상태 업데이트
*/
update(key, value) {
const prevValue = this[key];
this[key] = value;
this.notifyListeners(key, value, prevValue);
}
/**
* 리스너 등록
*/
subscribe(key, callback) {
if (!this.listeners.has(key)) {
this.listeners.set(key, []);
}
this.listeners.get(key).push(callback);
}
/**
* 리스너 알림
*/
notifyListeners(key, newValue, prevValue) {
const keyListeners = this.listeners.get(key) || [];
keyListeners.forEach(callback => {
try {
callback(newValue, prevValue);
} catch (error) {
console.error(`[WorkplaceState] 리스너 오류 (${key}):`, error);
}
});
}
/**
* 현재 카테고리 변경
*/
setCurrentCategory(categoryId) {
const prevId = this.currentCategoryId;
this.currentCategoryId = categoryId === '' ? '' : categoryId;
this.notifyListeners('currentCategoryId', this.currentCategoryId, prevId);
}
/**
* 현재 카테고리 정보 가져오기
*/
getCurrentCategory() {
if (!this.currentCategoryId) return null;
return this.categories.find(c => c.category_id == this.currentCategoryId);
}
/**
* 현재 카테고리별 작업장 가져오기
*/
getFilteredWorkplaces() {
if (this.currentCategoryId === '') {
return this.workplaces;
}
return this.workplaces.filter(w => w.category_id == this.currentCategoryId);
}
/**
* 작업장 지도 상태 초기화
*/
resetWorkplaceMapState() {
this.workplaceCanvas = null;
this.workplaceCtx = null;
this.workplaceImage = null;
this.workplaceIsDrawing = false;
this.workplaceCurrentRect = null;
}
/**
* 전체화면 편집기 상태 초기화
*/
resetFullscreenState() {
this.fsCanvas = null;
this.fsCtx = null;
this.fsImage = null;
this.fsIsDrawing = false;
this.fsCurrentRect = null;
}
/**
* 통계 계산
*/
getStatistics() {
const total = this.workplaces.length;
const active = this.workplaces.filter(w =>
w.is_active === 1 || w.is_active === true
).length;
const factoryTotal = this.categories.length;
const filtered = this.getFilteredWorkplaces();
const filteredActive = filtered.filter(w =>
w.is_active === 1 || w.is_active === true
).length;
return {
total,
active,
factoryTotal,
filteredTotal: filtered.length,
filteredActive
};
}
/**
* 상태 초기화
*/
reset() {
this.currentEditingCategory = null;
this.currentEditingWorkplace = null;
this.currentWorkplaceMapId = null;
this.resetWorkplaceMapState();
this.resetFullscreenState();
}
/**
* 디버그 출력
*/
debug() {
console.log('[WorkplaceState] 현재 상태:', {
categories: this.categories.length,
workplaces: this.workplaces.length,
currentCategoryId: this.currentCategoryId,
allEquipments: this.allEquipments.length,
workplaceEquipmentRegions: this.workplaceEquipmentRegions.length
});
}
}
// 전역 인스턴스 생성
window.WorkplaceState = new WorkplaceState();
// 하위 호환성을 위한 전역 변수 프록시
const wpStateProxy = window.WorkplaceState;
Object.defineProperties(window, {
categories: {
get: () => wpStateProxy.categories,
set: (v) => { wpStateProxy.categories = v; }
},
workplaces: {
get: () => wpStateProxy.workplaces,
set: (v) => { wpStateProxy.workplaces = v; }
},
currentCategoryId: {
get: () => wpStateProxy.currentCategoryId,
set: (v) => { wpStateProxy.currentCategoryId = v; }
},
currentEditingCategory: {
get: () => wpStateProxy.currentEditingCategory,
set: (v) => { wpStateProxy.currentEditingCategory = v; }
},
currentEditingWorkplace: {
get: () => wpStateProxy.currentEditingWorkplace,
set: (v) => { wpStateProxy.currentEditingWorkplace = v; }
},
workplaceCanvas: {
get: () => wpStateProxy.workplaceCanvas,
set: (v) => { wpStateProxy.workplaceCanvas = v; }
},
workplaceCtx: {
get: () => wpStateProxy.workplaceCtx,
set: (v) => { wpStateProxy.workplaceCtx = v; }
},
workplaceImage: {
get: () => wpStateProxy.workplaceImage,
set: (v) => { wpStateProxy.workplaceImage = v; }
},
workplaceIsDrawing: {
get: () => wpStateProxy.workplaceIsDrawing,
set: (v) => { wpStateProxy.workplaceIsDrawing = v; }
},
workplaceStartX: {
get: () => wpStateProxy.workplaceStartX,
set: (v) => { wpStateProxy.workplaceStartX = v; }
},
workplaceStartY: {
get: () => wpStateProxy.workplaceStartY,
set: (v) => { wpStateProxy.workplaceStartY = v; }
},
workplaceCurrentRect: {
get: () => wpStateProxy.workplaceCurrentRect,
set: (v) => { wpStateProxy.workplaceCurrentRect = v; }
},
workplaceEquipmentRegions: {
get: () => wpStateProxy.workplaceEquipmentRegions,
set: (v) => { wpStateProxy.workplaceEquipmentRegions = v; }
},
existingEquipments: {
get: () => wpStateProxy.existingEquipments,
set: (v) => { wpStateProxy.existingEquipments = v; }
},
allEquipments: {
get: () => wpStateProxy.allEquipments,
set: (v) => { wpStateProxy.allEquipments = v; }
},
fsCanvas: {
get: () => wpStateProxy.fsCanvas,
set: (v) => { wpStateProxy.fsCanvas = v; }
},
fsCtx: {
get: () => wpStateProxy.fsCtx,
set: (v) => { wpStateProxy.fsCtx = v; }
},
fsImage: {
get: () => wpStateProxy.fsImage,
set: (v) => { wpStateProxy.fsImage = v; }
},
fsIsDrawing: {
get: () => wpStateProxy.fsIsDrawing,
set: (v) => { wpStateProxy.fsIsDrawing = v; }
},
fsStartX: {
get: () => wpStateProxy.fsStartX,
set: (v) => { wpStateProxy.fsStartX = v; }
},
fsStartY: {
get: () => wpStateProxy.fsStartY,
set: (v) => { wpStateProxy.fsStartY = v; }
},
fsCurrentRect: {
get: () => wpStateProxy.fsCurrentRect,
set: (v) => { wpStateProxy.fsCurrentRect = v; }
},
fsSidebarVisible: {
get: () => wpStateProxy.fsSidebarVisible,
set: (v) => { wpStateProxy.fsSidebarVisible = v; }
}
});
// currentWorkplaceMapId를 window에도 설정
Object.defineProperty(window, 'currentWorkplaceMapId', {
get: () => wpStateProxy.currentWorkplaceMapId,
set: (v) => { wpStateProxy.currentWorkplaceMapId = v; }
});
console.log('[Module] workplace-management/state.js 로드 완료');

View File

@@ -0,0 +1,154 @@
/**
* Workplace Management - Utilities
* 작업장 관리 관련 유틸리티 함수들
*/
class WorkplaceUtils {
constructor() {
console.log('[WorkplaceUtils] 초기화 완료');
}
/**
* 날짜 포맷팅
*/
formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
}
/**
* API URL 생성
*/
getApiBaseUrl() {
return window.API_BASE_URL || 'http://localhost:20005/api';
}
/**
* 이미지 URL 생성
*/
getFullImageUrl(imagePath) {
if (!imagePath) return null;
if (imagePath.startsWith('http')) return imagePath;
return `${this.getApiBaseUrl()}${imagePath}`.replace('/api/', '/');
}
/**
* 작업장 용도 아이콘 반환
*/
getPurposeIcon(purpose) {
const icons = {
'작업구역': '🔧',
'설비': '⚙️',
'휴게시설': '☕',
'회의실': '💼',
'창고': '📦',
'기타': '📍'
};
return purpose ? (icons[purpose] || '📍') : '🏗️';
}
/**
* 퍼센트를 픽셀로 변환
*/
percentToPixel(percent, canvasSize) {
return (percent / 100) * canvasSize;
}
/**
* 픽셀을 퍼센트로 변환
*/
pixelToPercent(pixel, canvasSize) {
return (pixel / canvasSize) * 100;
}
/**
* 영역 좌표 정규화 (음수 처리)
*/
normalizeRect(rect, canvasWidth, canvasHeight) {
const xPercent = this.pixelToPercent(
Math.min(rect.x, rect.x + rect.width),
canvasWidth
);
const yPercent = this.pixelToPercent(
Math.min(rect.y, rect.y + rect.height),
canvasHeight
);
const widthPercent = this.pixelToPercent(
Math.abs(rect.width),
canvasWidth
);
const heightPercent = this.pixelToPercent(
Math.abs(rect.height),
canvasHeight
);
return { xPercent, yPercent, widthPercent, heightPercent };
}
}
// 전역 인스턴스 생성
window.WorkplaceUtils = new WorkplaceUtils();
// 하위 호환성: 기존 함수들
window.formatDate = (dateString) => window.WorkplaceUtils.formatDate(dateString);
// 토스트 메시지 표시
window.showToast = function(message, type = 'info') {
// 기존 토스트 제거
const existingToast = document.querySelector('.toast');
if (existingToast) {
existingToast.remove();
}
// 새 토스트 생성
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
// 스타일 적용
Object.assign(toast.style, {
position: 'fixed',
top: '20px',
right: '20px',
padding: '12px 24px',
borderRadius: '8px',
color: 'white',
fontWeight: '500',
zIndex: '1000',
transform: 'translateX(100%)',
transition: 'transform 0.3s ease'
});
// 타입별 배경색
const colors = {
success: '#10b981',
error: '#ef4444',
warning: '#f59e0b',
info: '#3b82f6'
};
toast.style.backgroundColor = colors[type] || colors.info;
document.body.appendChild(toast);
// 애니메이션
setTimeout(() => {
toast.style.transform = 'translateX(0)';
}, 100);
// 자동 제거
setTimeout(() => {
toast.style.transform = 'translateX(100%)';
setTimeout(() => {
if (toast.parentNode) {
toast.remove();
}
}, 300);
}, 3000);
};
console.log('[Module] workplace-management/utils.js 로드 완료');