fix: 캘린더 모달 중복 카드 문제 및 삭제 권한 개선
- monthly_worker_status 조회 시 GROUP BY로 중복 데이터 합산 - 작업보고서 삭제 권한을 그룹장 이상으로 제한 (admin, system, group_leader) - 중복 데이터 정리를 위한 마이그레이션 SQL 추가 (009_fix_duplicate_monthly_status.sql) - synology_deployment 버전에도 동일 수정 적용
This commit is contained in:
534
synology_deployment/web-ui/js/admin-settings.js
Normal file
534
synology_deployment/web-ui/js/admin-settings.js
Normal file
@@ -0,0 +1,534 @@
|
||||
// admin-settings.js - 관리자 설정 페이지
|
||||
|
||||
// 전역 변수
|
||||
let currentUser = null;
|
||||
let users = [];
|
||||
let filteredUsers = [];
|
||||
let currentEditingUser = null;
|
||||
|
||||
// DOM 요소
|
||||
const elements = {
|
||||
// 시간
|
||||
timeValue: document.getElementById('timeValue'),
|
||||
|
||||
// 사용자 정보
|
||||
userName: document.getElementById('userName'),
|
||||
userRole: document.getElementById('userRole'),
|
||||
userInitial: document.getElementById('userInitial'),
|
||||
|
||||
// 검색 및 필터
|
||||
userSearch: document.getElementById('userSearch'),
|
||||
filterButtons: document.querySelectorAll('.filter-btn'),
|
||||
|
||||
// 테이블
|
||||
usersTableBody: document.getElementById('usersTableBody'),
|
||||
emptyState: document.getElementById('emptyState'),
|
||||
|
||||
// 버튼
|
||||
addUserBtn: document.getElementById('addUserBtn'),
|
||||
saveUserBtn: document.getElementById('saveUserBtn'),
|
||||
confirmDeleteBtn: document.getElementById('confirmDeleteBtn'),
|
||||
|
||||
// 모달
|
||||
userModal: document.getElementById('userModal'),
|
||||
deleteModal: document.getElementById('deleteModal'),
|
||||
modalTitle: document.getElementById('modalTitle'),
|
||||
|
||||
// 폼
|
||||
userForm: document.getElementById('userForm'),
|
||||
userNameInput: document.getElementById('userName'),
|
||||
userIdInput: document.getElementById('userId'),
|
||||
userPasswordInput: document.getElementById('userPassword'),
|
||||
userRoleSelect: document.getElementById('userRole'),
|
||||
userEmailInput: document.getElementById('userEmail'),
|
||||
userPhoneInput: document.getElementById('userPhone'),
|
||||
passwordGroup: document.getElementById('passwordGroup'),
|
||||
|
||||
// 토스트
|
||||
toastContainer: document.getElementById('toastContainer')
|
||||
};
|
||||
|
||||
// ========== 초기화 ========== //
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
console.log('🔧 관리자 설정 페이지 초기화 시작');
|
||||
|
||||
try {
|
||||
await initializePage();
|
||||
console.log('✅ 관리자 설정 페이지 초기화 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 페이지 초기화 오류:', error);
|
||||
showToast('페이지를 불러오는 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
async function initializePage() {
|
||||
// 이벤트 리스너 설정
|
||||
setupEventListeners();
|
||||
|
||||
// 사용자 목록 로드
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
// ========== 사용자 정보 설정 ========== //
|
||||
function setupUserInfo() {
|
||||
const authData = getAuthData();
|
||||
if (authData && authData.user) {
|
||||
currentUser = authData.user;
|
||||
|
||||
// 사용자 이름 설정
|
||||
if (elements.userName) {
|
||||
elements.userName.textContent = currentUser.name || currentUser.username;
|
||||
}
|
||||
|
||||
// 사용자 역할 설정
|
||||
const roleMap = {
|
||||
'admin': '관리자',
|
||||
'system': '시스템 관리자',
|
||||
'leader': '그룹장',
|
||||
'user': '작업자'
|
||||
};
|
||||
if (elements.userRole) {
|
||||
elements.userRole.textContent = roleMap[currentUser.role] || '작업자';
|
||||
}
|
||||
|
||||
// 아바타 초기값 설정
|
||||
if (elements.userInitial) {
|
||||
const initial = (currentUser.name || currentUser.username).charAt(0);
|
||||
elements.userInitial.textContent = initial;
|
||||
}
|
||||
|
||||
console.log('👤 사용자 정보 설정 완료:', currentUser.name);
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthData() {
|
||||
const token = localStorage.getItem('token');
|
||||
const user = localStorage.getItem('user');
|
||||
return {
|
||||
token,
|
||||
user: user ? JSON.parse(user) : null
|
||||
};
|
||||
}
|
||||
|
||||
// ========== 시간 업데이트 ========== //
|
||||
function updateCurrentTime() {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString('ko-KR', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
if (elements.timeValue) {
|
||||
elements.timeValue.textContent = timeString;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 이벤트 리스너 ========== //
|
||||
function setupEventListeners() {
|
||||
// 검색
|
||||
if (elements.userSearch) {
|
||||
elements.userSearch.addEventListener('input', handleSearch);
|
||||
}
|
||||
|
||||
// 필터 버튼
|
||||
elements.filterButtons.forEach(btn => {
|
||||
btn.addEventListener('click', handleFilter);
|
||||
});
|
||||
|
||||
// 사용자 추가 버튼
|
||||
if (elements.addUserBtn) {
|
||||
elements.addUserBtn.addEventListener('click', openAddUserModal);
|
||||
}
|
||||
|
||||
// 사용자 저장 버튼
|
||||
if (elements.saveUserBtn) {
|
||||
elements.saveUserBtn.addEventListener('click', saveUser);
|
||||
}
|
||||
|
||||
// 삭제 확인 버튼
|
||||
if (elements.confirmDeleteBtn) {
|
||||
elements.confirmDeleteBtn.addEventListener('click', confirmDeleteUser);
|
||||
}
|
||||
|
||||
// 로그아웃 버튼
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', handleLogout);
|
||||
}
|
||||
|
||||
// 프로필 드롭다운
|
||||
const userProfile = document.getElementById('userProfile');
|
||||
const profileMenu = document.getElementById('profileMenu');
|
||||
if (userProfile && profileMenu) {
|
||||
userProfile.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
profileMenu.style.display = profileMenu.style.display === 'block' ? 'none' : 'block';
|
||||
});
|
||||
|
||||
document.addEventListener('click', () => {
|
||||
profileMenu.style.display = 'none';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 사용자 관리 ========== //
|
||||
async function loadUsers() {
|
||||
try {
|
||||
console.log('👥 사용자 목록 로딩...');
|
||||
|
||||
// 실제 API에서 사용자 데이터 가져오기
|
||||
const response = await window.apiCall('/users');
|
||||
users = Array.isArray(response) ? response : (response.data || []);
|
||||
|
||||
console.log(`✅ 사용자 ${users.length}명 로드 완료`);
|
||||
|
||||
// 필터링된 사용자 목록 초기화
|
||||
filteredUsers = [...users];
|
||||
|
||||
// 테이블 렌더링
|
||||
renderUsersTable();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 사용자 목록 로딩 오류:', error);
|
||||
showToast('사용자 목록을 불러오는 중 오류가 발생했습니다.', 'error');
|
||||
users = [];
|
||||
filteredUsers = [];
|
||||
renderUsersTable();
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsersTable() {
|
||||
if (!elements.usersTableBody) return;
|
||||
|
||||
if (filteredUsers.length === 0) {
|
||||
elements.usersTableBody.innerHTML = '';
|
||||
if (elements.emptyState) {
|
||||
elements.emptyState.style.display = 'block';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (elements.emptyState) {
|
||||
elements.emptyState.style.display = 'none';
|
||||
}
|
||||
|
||||
elements.usersTableBody.innerHTML = filteredUsers.map(user => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="user-info">
|
||||
<div class="user-avatar-small">${(user.name || user.username).charAt(0)}</div>
|
||||
<div class="user-details">
|
||||
<h4>${user.name || user.username}</h4>
|
||||
<p>${user.email || '이메일 없음'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><strong>${user.username}</strong></td>
|
||||
<td>
|
||||
<span class="role-badge ${user.role}">
|
||||
${getRoleIcon(user.role)} ${getRoleName(user.role)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge ${user.is_active ? 'active' : 'inactive'}">
|
||||
${user.is_active ? '활성' : '비활성'}
|
||||
</span>
|
||||
</td>
|
||||
<td>${formatDate(user.last_login) || '로그인 기록 없음'}</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn edit" onclick="editUser(${user.user_id})">
|
||||
수정
|
||||
</button>
|
||||
<button class="action-btn toggle" onclick="toggleUserStatus(${user.user_id})">
|
||||
${user.is_active ? '비활성화' : '활성화'}
|
||||
</button>
|
||||
<button class="action-btn delete" onclick="deleteUser(${user.user_id})">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function getRoleIcon(role) {
|
||||
const icons = {
|
||||
admin: '👑',
|
||||
leader: '👨💼',
|
||||
user: '👤'
|
||||
};
|
||||
return icons[role] || '👤';
|
||||
}
|
||||
|
||||
function getRoleName(role) {
|
||||
const names = {
|
||||
admin: '관리자',
|
||||
leader: '그룹장',
|
||||
user: '작업자'
|
||||
};
|
||||
return names[role] || '작업자';
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return null;
|
||||
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 검색 및 필터링 ========== //
|
||||
function handleSearch(e) {
|
||||
const searchTerm = e.target.value.toLowerCase();
|
||||
|
||||
filteredUsers = users.filter(user => {
|
||||
return (user.name && user.name.toLowerCase().includes(searchTerm)) ||
|
||||
(user.username && user.username.toLowerCase().includes(searchTerm)) ||
|
||||
(user.email && user.email.toLowerCase().includes(searchTerm));
|
||||
});
|
||||
|
||||
renderUsersTable();
|
||||
}
|
||||
|
||||
function handleFilter(e) {
|
||||
const filterType = e.target.dataset.filter;
|
||||
|
||||
// 활성 버튼 변경
|
||||
elements.filterButtons.forEach(btn => btn.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
|
||||
// 필터링
|
||||
if (filterType === 'all') {
|
||||
filteredUsers = [...users];
|
||||
} else {
|
||||
filteredUsers = users.filter(user => user.role === filterType);
|
||||
}
|
||||
|
||||
renderUsersTable();
|
||||
}
|
||||
|
||||
// ========== 모달 관리 ========== //
|
||||
function openAddUserModal() {
|
||||
currentEditingUser = null;
|
||||
|
||||
if (elements.modalTitle) {
|
||||
elements.modalTitle.textContent = '새 사용자 추가';
|
||||
}
|
||||
|
||||
// 폼 초기화
|
||||
if (elements.userForm) {
|
||||
elements.userForm.reset();
|
||||
}
|
||||
|
||||
// 비밀번호 필드 표시
|
||||
if (elements.passwordGroup) {
|
||||
elements.passwordGroup.style.display = 'block';
|
||||
}
|
||||
|
||||
if (elements.userPasswordInput) {
|
||||
elements.userPasswordInput.required = true;
|
||||
}
|
||||
|
||||
if (elements.userModal) {
|
||||
elements.userModal.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
function editUser(userId) {
|
||||
const user = users.find(u => u.user_id === userId);
|
||||
if (!user) return;
|
||||
|
||||
currentEditingUser = user;
|
||||
|
||||
if (elements.modalTitle) {
|
||||
elements.modalTitle.textContent = '사용자 정보 수정';
|
||||
}
|
||||
|
||||
// 폼에 데이터 채우기
|
||||
if (elements.userNameInput) elements.userNameInput.value = user.name || '';
|
||||
if (elements.userIdInput) elements.userIdInput.value = user.username || '';
|
||||
if (elements.userRoleSelect) elements.userRoleSelect.value = user.role || '';
|
||||
if (elements.userEmailInput) elements.userEmailInput.value = user.email || '';
|
||||
if (elements.userPhoneInput) elements.userPhoneInput.value = user.phone || '';
|
||||
|
||||
// 비밀번호 필드 숨기기 (수정 시에는 선택사항)
|
||||
if (elements.passwordGroup) {
|
||||
elements.passwordGroup.style.display = 'none';
|
||||
}
|
||||
|
||||
if (elements.userPasswordInput) {
|
||||
elements.userPasswordInput.required = false;
|
||||
}
|
||||
|
||||
if (elements.userModal) {
|
||||
elements.userModal.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
function closeUserModal() {
|
||||
if (elements.userModal) {
|
||||
elements.userModal.style.display = 'none';
|
||||
}
|
||||
currentEditingUser = null;
|
||||
}
|
||||
|
||||
function deleteUser(userId) {
|
||||
const user = users.find(u => u.user_id === userId);
|
||||
if (!user) return;
|
||||
|
||||
currentEditingUser = user;
|
||||
|
||||
if (elements.deleteModal) {
|
||||
elements.deleteModal.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
if (elements.deleteModal) {
|
||||
elements.deleteModal.style.display = 'none';
|
||||
}
|
||||
currentEditingUser = null;
|
||||
}
|
||||
|
||||
// ========== 사용자 CRUD ========== //
|
||||
async function saveUser() {
|
||||
try {
|
||||
const formData = {
|
||||
name: elements.userNameInput?.value,
|
||||
username: elements.userIdInput?.value,
|
||||
role: elements.userRoleSelect?.value,
|
||||
email: elements.userEmailInput?.value,
|
||||
phone: elements.userPhoneInput?.value
|
||||
};
|
||||
|
||||
// 유효성 검사
|
||||
if (!formData.name || !formData.username || !formData.role) {
|
||||
showToast('필수 항목을 모두 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 비밀번호 처리
|
||||
if (!currentEditingUser && elements.userPasswordInput?.value) {
|
||||
formData.password = elements.userPasswordInput.value;
|
||||
} else if (currentEditingUser && elements.userPasswordInput?.value) {
|
||||
formData.password = elements.userPasswordInput.value;
|
||||
}
|
||||
|
||||
let response;
|
||||
if (currentEditingUser) {
|
||||
// 수정
|
||||
response = await window.apiCall(`/users/${currentEditingUser.user_id}`, 'PUT', formData);
|
||||
} else {
|
||||
// 생성
|
||||
response = await window.apiCall('/users', 'POST', formData);
|
||||
}
|
||||
|
||||
if (response.success || response.user_id) {
|
||||
const action = currentEditingUser ? '수정' : '생성';
|
||||
showToast(`사용자가 성공적으로 ${action}되었습니다.`, 'success');
|
||||
|
||||
closeUserModal();
|
||||
await loadUsers();
|
||||
} else {
|
||||
throw new Error(response.message || '사용자 저장에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('사용자 저장 오류:', error);
|
||||
showToast(`사용자 저장 중 오류가 발생했습니다: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteUser() {
|
||||
if (!currentEditingUser) return;
|
||||
|
||||
try {
|
||||
const response = await window.apiCall(`/users/${currentEditingUser.user_id}`, 'DELETE');
|
||||
|
||||
if (response.success) {
|
||||
showToast('사용자가 성공적으로 삭제되었습니다.', 'success');
|
||||
closeDeleteModal();
|
||||
await loadUsers();
|
||||
} else {
|
||||
throw new Error(response.message || '사용자 삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('사용자 삭제 오류:', error);
|
||||
showToast(`사용자 삭제 중 오류가 발생했습니다: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleUserStatus(userId) {
|
||||
try {
|
||||
const user = users.find(u => u.user_id === userId);
|
||||
if (!user) return;
|
||||
|
||||
const newStatus = !user.is_active;
|
||||
const response = await window.apiCall(`/users/${userId}/status`, 'PUT', { is_active: newStatus });
|
||||
|
||||
if (response.success) {
|
||||
const action = newStatus ? '활성화' : '비활성화';
|
||||
showToast(`사용자가 성공적으로 ${action}되었습니다.`, 'success');
|
||||
await loadUsers();
|
||||
} else {
|
||||
throw new Error(response.message || '사용자 상태 변경에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('사용자 상태 변경 오류:', error);
|
||||
showToast(`사용자 상태 변경 중 오류가 발생했습니다: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 로그아웃 ========== //
|
||||
function handleLogout() {
|
||||
if (confirm('로그아웃하시겠습니까?')) {
|
||||
localStorage.clear();
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 토스트 알림 ========== //
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
if (!elements.toastContainer) 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>
|
||||
`;
|
||||
|
||||
elements.toastContainer.appendChild(toast);
|
||||
|
||||
// 자동 제거
|
||||
setTimeout(() => {
|
||||
if (toast.parentElement) {
|
||||
toast.remove();
|
||||
}
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// ========== 전역 함수 (HTML에서 호출) ========== //
|
||||
window.editUser = editUser;
|
||||
window.deleteUser = deleteUser;
|
||||
window.toggleUserStatus = toggleUserStatus;
|
||||
window.closeUserModal = closeUserModal;
|
||||
window.closeDeleteModal = closeDeleteModal;
|
||||
34
synology_deployment/web-ui/js/admin.js
Normal file
34
synology_deployment/web-ui/js/admin.js
Normal file
@@ -0,0 +1,34 @@
|
||||
// ✅ /js/admin.js (수정됨 - 중복 로딩 제거)
|
||||
async function initDashboard() {
|
||||
// 로그인 토큰 확인
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ navbar, sidebar는 각각의 모듈에서 처리하도록 변경
|
||||
// load-navbar.js, load-sidebar.js가 자동으로 처리함
|
||||
|
||||
// ✅ 콘텐츠만 직접 로딩 (admin-sections.html이 자동 로딩됨)
|
||||
console.log('관리자 대시보드 초기화 완료');
|
||||
}
|
||||
|
||||
// ✅ 보조 함수 - 필요시 수동 컴포넌트 로딩용
|
||||
async function loadComponent(id, url) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const html = await res.text();
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.innerHTML = html;
|
||||
} else {
|
||||
console.warn(`요소를 찾을 수 없습니다: ${id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`컴포넌트 로딩 실패 (${url}):`, err);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initDashboard);
|
||||
198
synology_deployment/web-ui/js/api-config.js
Normal file
198
synology_deployment/web-ui/js/api-config.js
Normal file
@@ -0,0 +1,198 @@
|
||||
// api-config.js - nginx 프록시 대응 API 설정
|
||||
|
||||
function getApiBaseUrl() {
|
||||
const hostname = window.location.hostname;
|
||||
const protocol = window.location.protocol;
|
||||
const port = window.location.port;
|
||||
|
||||
console.log('🌐 감지된 환경:', { hostname, protocol, port });
|
||||
|
||||
// 🔗 nginx 프록시를 통한 접근 (권장)
|
||||
// nginx가 /api/ 요청을 백엔드로 프록시하므로 포트 없이 접근
|
||||
if (hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.startsWith('172.') ||
|
||||
hostname === 'localhost' || hostname === '127.0.0.1' ||
|
||||
hostname.includes('.local') || hostname.includes('hyungi')) {
|
||||
|
||||
// 현재 웹서버의 도메인/IP를 그대로 사용하되 API 포트(20005)로 직접 연결
|
||||
const baseUrl = `${protocol}//${hostname}:20005/api`;
|
||||
|
||||
console.log('✅ nginx 프록시 사용:', baseUrl);
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
// 🚨 백업: 직접 접근 (nginx 프록시 실패시에만)
|
||||
console.warn('⚠️ 직접 API 접근 (백업 모드)');
|
||||
return `${protocol}//${hostname}:20005/api`;
|
||||
}
|
||||
|
||||
// API 설정
|
||||
const API_URL = getApiBaseUrl();
|
||||
|
||||
// 전역 변수로 설정
|
||||
window.API = API_URL;
|
||||
window.API_BASE_URL = API_URL;
|
||||
|
||||
function ensureAuthenticated() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token || token === 'undefined' || token === 'null') {
|
||||
console.log('🚨 인증되지 않은 사용자. 로그인 페이지로 이동합니다.');
|
||||
clearAuthData(); // 만약을 위해 한번 더 정리
|
||||
window.location.href = '/index.html';
|
||||
return false; // 이후 코드 실행 방지
|
||||
}
|
||||
|
||||
// 토큰 만료 확인
|
||||
if (isTokenExpired(token)) {
|
||||
console.log('🚨 토큰이 만료되었습니다. 로그인 페이지로 이동합니다.');
|
||||
clearAuthData();
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/index.html';
|
||||
return false;
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
// 토큰 만료 확인 함수
|
||||
function isTokenExpired(token) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
return payload.exp < currentTime;
|
||||
} catch (error) {
|
||||
console.error('토큰 파싱 오류:', error);
|
||||
return true; // 파싱 실패 시 만료된 것으로 간주
|
||||
}
|
||||
}
|
||||
|
||||
// 인증 데이터 정리 함수
|
||||
function clearAuthData() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('userInfo');
|
||||
localStorage.removeItem('currentUser');
|
||||
}
|
||||
|
||||
function getAuthHeaders() {
|
||||
const token = localStorage.getItem('token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
};
|
||||
}
|
||||
|
||||
// 🔧 개선된 API 호출 함수 (에러 처리 강화)
|
||||
async function apiCall(url, method = 'GET', data = null) {
|
||||
// 상대 경로를 절대 경로로 변환
|
||||
const fullUrl = url.startsWith('http') ? url : `${API}${url}`;
|
||||
|
||||
const options = {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
}
|
||||
};
|
||||
|
||||
// POST/PUT 요청시 데이터 추가
|
||||
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
|
||||
options.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`📡 API 호출: ${fullUrl} (${method})`);
|
||||
const response = await fetch(fullUrl, options);
|
||||
|
||||
// 인증 만료 처리
|
||||
if (response.status === 401) {
|
||||
console.error('🚨 인증 실패: 토큰이 만료되었거나 유효하지 않습니다.');
|
||||
clearAuthData();
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/index.html';
|
||||
throw new Error('인증에 실패했습니다.');
|
||||
}
|
||||
|
||||
// 응답 실패 처리
|
||||
if (!response.ok) {
|
||||
let errorMessage = `HTTP ${response.status}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData.error || errorData.message || errorMessage;
|
||||
} catch (e) {
|
||||
// JSON 파싱 실패시 기본 메시지 사용
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log(`✅ API 성공: ${fullUrl}`);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ API 오류 (${fullUrl}):`, error);
|
||||
|
||||
// 네트워크 오류 vs 서버 오류 구분
|
||||
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
||||
throw new Error('네트워크 연결 오류입니다. 인터넷 연결을 확인해주세요.');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 디버깅 정보
|
||||
console.log('🔗 API Base URL:', API);
|
||||
console.log('🌐 Current Location:', {
|
||||
hostname: window.location.hostname,
|
||||
protocol: window.location.protocol,
|
||||
port: window.location.port,
|
||||
href: window.location.href
|
||||
});
|
||||
|
||||
// 🧪 API 연결 테스트 함수 (개발용)
|
||||
async function testApiConnection() {
|
||||
try {
|
||||
console.log('🧪 API 연결 테스트 시작...');
|
||||
const response = await fetch(`${API}/health`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('✅ API 연결 성공!');
|
||||
return true;
|
||||
} else {
|
||||
console.log('❌ API 연결 실패:', response.status);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('❌ API 연결 오류:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 함수로 설정
|
||||
window.ensureAuthenticated = ensureAuthenticated;
|
||||
window.getAuthHeaders = getAuthHeaders;
|
||||
window.apiCall = apiCall;
|
||||
window.testApiConnection = testApiConnection;
|
||||
window.isTokenExpired = isTokenExpired;
|
||||
window.clearAuthData = clearAuthData;
|
||||
|
||||
// 개발 모드에서 자동 테스트
|
||||
if (window.location.hostname === 'localhost' || window.location.hostname.startsWith('192.168.')) {
|
||||
setTimeout(() => {
|
||||
testApiConnection();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// 주기적으로 토큰 만료 확인 (5분마다)
|
||||
setInterval(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token && isTokenExpired(token)) {
|
||||
console.log('🚨 주기적 확인: 토큰이 만료되었습니다.');
|
||||
clearAuthData();
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
}, 5 * 60 * 1000); // 5분마다 확인
|
||||
136
synology_deployment/web-ui/js/api-helper.js
Normal file
136
synology_deployment/web-ui/js/api-helper.js
Normal file
@@ -0,0 +1,136 @@
|
||||
// /public/js/api-helper.js
|
||||
// ES6 모듈 의존성 제거 - 브라우저 호환성 개선
|
||||
|
||||
// API 설정 (window 객체에서 가져오기)
|
||||
const API_BASE_URL = window.API_BASE_URL || 'http://localhost:20005/api';
|
||||
|
||||
// 인증 관련 함수들 (직접 구현)
|
||||
function getToken() {
|
||||
const token = localStorage.getItem('token');
|
||||
return token && token !== 'undefined' && token !== 'null' ? token : null;
|
||||
}
|
||||
|
||||
function clearAuthData() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 API를 호출합니다. (인증이 필요 없는 public 요청)
|
||||
* @param {string} username - 사용자 아이디
|
||||
* @param {string} password - 사용자 비밀번호
|
||||
* @returns {Promise<object>} - API 응답 결과
|
||||
*/
|
||||
async function login(username, password) {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
// API 에러 응답을 그대로 에러 객체로 던져서 호출부에서 처리하도록 함
|
||||
throw new Error(result.error || '로그인에 실패했습니다.');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 인증이 필요한 API 요청을 위한 fetch 래퍼 함수
|
||||
* @param {string} endpoint - /로 시작하는 API 엔드포인트
|
||||
* @param {object} options - fetch 함수에 전달할 옵션
|
||||
* @returns {Promise<Response>} - fetch 응답 객체
|
||||
*/
|
||||
async function authFetch(endpoint, options = {}) {
|
||||
const token = getToken();
|
||||
|
||||
if (!token) {
|
||||
console.error('토큰이 없습니다. 로그인이 필요합니다.');
|
||||
clearAuthData(); // 인증 정보 정리
|
||||
window.location.href = '/index.html'; // 로그인 페이지로 리디렉션
|
||||
// 에러를 던져서 후속 실행을 중단
|
||||
throw new Error('인증 토큰이 없습니다.');
|
||||
}
|
||||
|
||||
const defaultHeaders = {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
// 401 Unauthorized 에러 발생 시, 토큰이 유효하지 않다는 의미
|
||||
if (response.status === 401) {
|
||||
console.error('인증 실패. 토큰이 만료되었거나 유효하지 않습니다.');
|
||||
clearAuthData(); // 만료된 인증 정보 정리
|
||||
window.location.href = '/index.html';
|
||||
throw new Error('인증에 실패했습니다.');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// 공통 API 요청 함수들
|
||||
|
||||
/**
|
||||
* GET 요청 헬퍼
|
||||
* @param {string} endpoint - API 엔드포인트
|
||||
*/
|
||||
async function apiGet(endpoint) {
|
||||
const response = await authFetch(endpoint);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 요청 헬퍼
|
||||
* @param {string} endpoint - API 엔드포인트
|
||||
* @param {object} data - 전송할 데이터
|
||||
*/
|
||||
async function apiPost(endpoint, data) {
|
||||
const response = await authFetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT 요청 헬퍼
|
||||
* @param {string} endpoint - API 엔드포인트
|
||||
* @param {object} data - 전송할 데이터
|
||||
*/
|
||||
async function apiPut(endpoint, data) {
|
||||
const response = await authFetch(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 요청 헬퍼
|
||||
* @param {string} endpoint - API 엔드포인트
|
||||
*/
|
||||
async function apiDelete(endpoint) {
|
||||
const response = await authFetch(endpoint, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 전역 함수로 설정
|
||||
window.login = login;
|
||||
window.apiGet = apiGet;
|
||||
window.apiPost = apiPost;
|
||||
window.apiPut = apiPut;
|
||||
window.apiDelete = apiDelete;
|
||||
window.getToken = getToken;
|
||||
window.clearAuthData = clearAuthData;
|
||||
1038
synology_deployment/web-ui/js/attendance-validation.js
Normal file
1038
synology_deployment/web-ui/js/attendance-validation.js
Normal file
File diff suppressed because it is too large
Load Diff
170
synology_deployment/web-ui/js/attendance.js
Normal file
170
synology_deployment/web-ui/js/attendance.js
Normal file
@@ -0,0 +1,170 @@
|
||||
import { API, getAuthHeaders } from '/js/api-config.js';
|
||||
|
||||
const yearSel = document.getElementById('year');
|
||||
const monthSel = document.getElementById('month');
|
||||
const container = document.getElementById('attendanceTableContainer');
|
||||
|
||||
const holidays = [
|
||||
'2025-01-01','2025-01-27','2025-01-28','2025-01-29','2025-01-30','2025-01-31',
|
||||
'2025-03-01','2025-03-03','2025-05-01','2025-05-05','2025-05-06',
|
||||
'2025-06-03','2025-06-06','2025-08-15','2025-10-03','2025-10-09','2025-12-25'
|
||||
];
|
||||
|
||||
const leaveDefaults = {
|
||||
'김두수':16,'임영규':16,'반치원':16,'황인용':16,'표영진':15,
|
||||
'김윤섭':16,'이창호':16,'최광욱':16,'박현수':14,'조윤호':0
|
||||
};
|
||||
|
||||
let workers = [];
|
||||
|
||||
// ✅ 셀렉트 박스 옵션 + 기본 선택 추가
|
||||
function fillSelectOptions() {
|
||||
const currentY = new Date().getFullYear();
|
||||
const currentM = String(new Date().getMonth() + 1).padStart(2, '0');
|
||||
|
||||
for (let y = currentY; y <= currentY + 5; y++) {
|
||||
const selected = y === currentY ? 'selected' : '';
|
||||
yearSel.insertAdjacentHTML('beforeend', `<option value="${y}" ${selected}>${y}</option>`);
|
||||
}
|
||||
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
const mm = String(m).padStart(2, '0');
|
||||
const selected = mm === currentM ? 'selected' : '';
|
||||
monthSel.insertAdjacentHTML('beforeend', `<option value="${mm}" ${selected}>${m}월</option>`);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 작업자 목록 불러오기
|
||||
async function fetchWorkers() {
|
||||
try {
|
||||
const res = await fetch(`${API}/workers`, { headers: getAuthHeaders() });
|
||||
workers = await res.json();
|
||||
workers.sort((a, b) => a.worker_id - b.worker_id);
|
||||
} catch (err) {
|
||||
alert('작업자 불러오기 실패');
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 출근부 불러오기 (해당 연도 전체)
|
||||
async function loadAttendance() {
|
||||
const year = yearSel.value;
|
||||
const month = monthSel.value;
|
||||
if (!year || !month) return alert('연도와 월을 선택하세요');
|
||||
|
||||
const lastDay = new Date(+year, +month, 0).getDate();
|
||||
const start = `${year}-01-01`;
|
||||
const end = `${year}-12-31`;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/workreports?start=${start}&end=${end}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const data = await res.json();
|
||||
renderTable(data, year, month, lastDay);
|
||||
} catch (err) {
|
||||
alert('출근부 로딩 실패');
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 테이블 렌더링
|
||||
function renderTable(data, year, month, lastDay) {
|
||||
container.innerHTML = '';
|
||||
const weekdays = ['일','월','화','수','목','금','토'];
|
||||
const tbl = document.createElement('table');
|
||||
|
||||
// ⬆️ 헤더 구성
|
||||
let thead = `<thead><tr><th rowspan="2">작업자</th>`;
|
||||
for (let d = 1; d <= lastDay; d++) thead += `<th>${d}</th>`;
|
||||
thead += `<th class="divider" rowspan="2">잔업합계</th><th rowspan="2">사용연차</th><th rowspan="2">잔여연차</th></tr><tr>`;
|
||||
for (let d = 1; d <= lastDay; d++) {
|
||||
const dow = new Date(+year, +month - 1, d).getDay();
|
||||
thead += `<th>${weekdays[dow]}</th>`;
|
||||
}
|
||||
thead += '</tr></thead>';
|
||||
tbl.innerHTML = thead;
|
||||
|
||||
// ⬇️ 본문
|
||||
workers.forEach(w => {
|
||||
// ✅ 월간 데이터 (표에 표시용)
|
||||
const recsThisMonth = data.filter(r =>
|
||||
r.worker_id === w.worker_id &&
|
||||
new Date(r.date).getFullYear() === +year &&
|
||||
new Date(r.date).getMonth() + 1 === +month
|
||||
);
|
||||
|
||||
// ✅ 연간 데이터 (연차 계산용)
|
||||
const recsThisYear = data.filter(r =>
|
||||
r.worker_id === w.worker_id &&
|
||||
new Date(r.date).getFullYear() === +year
|
||||
);
|
||||
|
||||
let otSum = 0;
|
||||
let row = `<tr><td>${w.worker_name}</td>`;
|
||||
|
||||
for (let d = 1; d <= lastDay; d++) {
|
||||
const dd = String(d).padStart(2, '0');
|
||||
const date = `${year}-${month}-${dd}`;
|
||||
|
||||
const rec = recsThisMonth.find(r => {
|
||||
const rDate = new Date(r.date);
|
||||
const yyyy = rDate.getFullYear();
|
||||
const mm = String(rDate.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(rDate.getDate()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}` === date;
|
||||
});
|
||||
|
||||
const dow = new Date(+year, +month - 1, d).getDay();
|
||||
const isWe = dow === 0 || dow === 6;
|
||||
const isHo = holidays.includes(date);
|
||||
|
||||
let txt = '', cls = '';
|
||||
if (rec) {
|
||||
const ot = +rec.overtime_hours || 0;
|
||||
if (ot > 0) {
|
||||
txt = ot; cls = 'overtime-cell'; otSum += ot;
|
||||
} else if (rec.work_details) {
|
||||
const d = rec.work_details;
|
||||
if (['연차','반차','반반차','조퇴'].includes(d)) {
|
||||
txt = d; cls = 'leave';
|
||||
} else if (d === '유급') {
|
||||
txt = d; cls = 'paid-leave';
|
||||
} else if (d === '휴무') {
|
||||
txt = d; cls = 'holiday';
|
||||
} else {
|
||||
txt = d;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
txt = (isWe || isHo) ? '휴무' : '';
|
||||
cls = (isWe || isHo) ? 'holiday' : 'no-data';
|
||||
}
|
||||
|
||||
row += `<td class="${cls}">${txt}</td>`;
|
||||
}
|
||||
|
||||
const usedTot = recsThisYear
|
||||
.filter(r => ['연차','반차','반반차','조퇴'].includes(r.work_details))
|
||||
.reduce((s, r) => s + (
|
||||
r.work_details === '연차' ? 1 :
|
||||
r.work_details === '반차' ? 0.5 :
|
||||
r.work_details === '반반차' ? 0.25 : 0.75
|
||||
), 0);
|
||||
|
||||
const remain = (leaveDefaults[w.worker_name] || 0) - usedTot;
|
||||
|
||||
row += `<td class="divider overtime-sum">${otSum.toFixed(1)}</td>`;
|
||||
row += `<td>${usedTot.toFixed(2)}</td><td>${remain.toFixed(2)}</td></tr>`;
|
||||
row += `<tr class="separator"><td colspan="${lastDay + 4}"></td></tr>`;
|
||||
|
||||
tbl.insertAdjacentHTML('beforeend', row);
|
||||
});
|
||||
|
||||
container.appendChild(tbl);
|
||||
}
|
||||
|
||||
// ✅ 초기 로딩
|
||||
fillSelectOptions();
|
||||
fetchWorkers().then(() => {
|
||||
loadAttendance(); // 자동 조회
|
||||
});
|
||||
document.getElementById('loadAttendance').addEventListener('click', loadAttendance);
|
||||
42
synology_deployment/web-ui/js/auth-check.js
Normal file
42
synology_deployment/web-ui/js/auth-check.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// /js/auth-check.js
|
||||
// auth.js의 함수들을 직접 구현 (모듈 의존성 제거)
|
||||
|
||||
function isLoggedIn() {
|
||||
const token = localStorage.getItem('token');
|
||||
return token && token !== 'undefined' && token !== 'null';
|
||||
}
|
||||
|
||||
function getUser() {
|
||||
const user = localStorage.getItem('user');
|
||||
return user ? JSON.parse(user) : null;
|
||||
}
|
||||
|
||||
function clearAuthData() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
|
||||
// 즉시 실행 함수로 스코프를 보호하고 로직을 실행
|
||||
(function() {
|
||||
if (!isLoggedIn()) {
|
||||
console.log('🚨 인증되지 않은 사용자. 로그인 페이지로 이동합니다.');
|
||||
clearAuthData(); // 만약을 위해 한번 더 정리
|
||||
window.location.href = '/index.html';
|
||||
return; // 이후 코드 실행 방지
|
||||
}
|
||||
|
||||
const currentUser = getUser();
|
||||
|
||||
// 사용자 정보가 유효한지 확인 (토큰은 있지만 유저 정보가 깨졌을 경우)
|
||||
if (!currentUser || !currentUser.username || !currentUser.role) {
|
||||
console.error('🚨 사용자 정보가 유효하지 않습니다. 강제 로그아웃 처리합니다.');
|
||||
clearAuthData();
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`✅ ${currentUser.username}(${currentUser.role})님 인증 성공.`);
|
||||
|
||||
// 역할 기반 메뉴 제어 로직은 각 컴포넌트 로더(load-navbar.js 등)로 이전함.
|
||||
// 전역 변수 할당(window.currentUser) 제거.
|
||||
})();
|
||||
76
synology_deployment/web-ui/js/auth.js
Normal file
76
synology_deployment/web-ui/js/auth.js
Normal file
@@ -0,0 +1,76 @@
|
||||
// js/auth.js
|
||||
|
||||
/**
|
||||
* JWT 토큰을 디코딩하여 페이로드(내용)를 반환합니다.
|
||||
* @param {string} token - JWT 토큰
|
||||
* @returns {object|null} - 디코딩된 페이로드 객체 또는 파싱 실패 시 null
|
||||
*/
|
||||
export function parseJwt(token) {
|
||||
try {
|
||||
// 토큰의 두 번째 부분(payload)을 base64 디코딩하고 JSON으로 파싱
|
||||
return JSON.parse(atob(token.split('.')[1]));
|
||||
} catch (e) {
|
||||
console.error("잘못된 토큰입니다.", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* localStorage에서 인증 토큰을 가져옵니다.
|
||||
* @returns {string|null} - 저장된 토큰 또는 토큰이 없을 경우 null
|
||||
*/
|
||||
export function getToken() {
|
||||
return localStorage.getItem('token');
|
||||
}
|
||||
|
||||
/**
|
||||
* localStorage에서 사용자 정보를 가져옵니다.
|
||||
* @returns {object|null} - 저장된 사용자 객체 또는 정보가 없을 경우 null
|
||||
*/
|
||||
export function getUser() {
|
||||
const user = localStorage.getItem('user');
|
||||
try {
|
||||
return user ? JSON.parse(user) : null;
|
||||
} catch(e) {
|
||||
console.error("사용자 정보를 파싱하는 데 실패했습니다.", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 성공 후 토큰과 사용자 정보를 localStorage에 저장합니다.
|
||||
* @param {string} token - 서버에서 받은 JWT 토큰
|
||||
* @param {object} user - 서버에서 받은 사용자 정보 객체
|
||||
*/
|
||||
export function saveAuthData(token, user) {
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃 시 localStorage에서 인증 정보를 제거합니다.
|
||||
*/
|
||||
export function clearAuthData() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 사용자가 로그인 상태인지 확인합니다.
|
||||
* @returns {boolean} - 로그인 상태이면 true, 아니면 false
|
||||
*/
|
||||
export function isLoggedIn() {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 선택 사항: 토큰 만료 여부 확인 로직 추가 가능
|
||||
// const payload = parseJwt(token);
|
||||
// if (payload && payload.exp * 1000 > Date.now()) {
|
||||
// return true;
|
||||
// }
|
||||
// return false;
|
||||
|
||||
return !!token;
|
||||
}
|
||||
59
synology_deployment/web-ui/js/calendar.js
Normal file
59
synology_deployment/web-ui/js/calendar.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// ✅ /js/calendar.js
|
||||
export function renderCalendar(containerId, onDateSelect) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
let currentDate = new Date();
|
||||
let selectedDateStr = '';
|
||||
|
||||
function drawCalendar(date) {
|
||||
container.innerHTML = '';
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
const lastDate = new Date(year, month + 1, 0).getDate();
|
||||
|
||||
const nav = document.createElement('div');
|
||||
nav.className = 'nav';
|
||||
const prev = document.createElement('button');
|
||||
prev.textContent = '◀';
|
||||
prev.addEventListener('click', () => {
|
||||
currentDate = new Date(year, month - 1, 1);
|
||||
drawCalendar(currentDate);
|
||||
});
|
||||
const title = document.createElement('div');
|
||||
title.innerHTML = `<strong>${year}년 ${month + 1}월</strong>`;
|
||||
const next = document.createElement('button');
|
||||
next.textContent = '▶';
|
||||
next.addEventListener('click', () => {
|
||||
currentDate = new Date(year, month + 1, 1);
|
||||
drawCalendar(currentDate);
|
||||
});
|
||||
nav.append(prev, title, next);
|
||||
container.appendChild(nav);
|
||||
|
||||
['일','월','화','수','목','금','토'].forEach(day => {
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = `<strong>${day}</strong>`;
|
||||
container.appendChild(el);
|
||||
});
|
||||
|
||||
for (let i = 0; i < firstDay; i++) container.appendChild(document.createElement('div'));
|
||||
|
||||
for (let i = 1; i <= lastDate; i++) {
|
||||
const btn = document.createElement('button');
|
||||
const ymd = `${year}-${String(month + 1).padStart(2, '0')}-${String(i).padStart(2, '0')}`;
|
||||
btn.textContent = i;
|
||||
btn.className = (ymd === selectedDateStr) ? 'selected-date' : '';
|
||||
btn.addEventListener('click', () => {
|
||||
selectedDateStr = ymd;
|
||||
drawCalendar(currentDate);
|
||||
onDateSelect(ymd);
|
||||
});
|
||||
container.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
drawCalendar(currentDate);
|
||||
}
|
||||
|
||||
211
synology_deployment/web-ui/js/change-password.js
Normal file
211
synology_deployment/web-ui/js/change-password.js
Normal file
@@ -0,0 +1,211 @@
|
||||
// js/change-password.js
|
||||
// 개인 비밀번호 변경 페이지 JavaScript
|
||||
|
||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
||||
|
||||
// 인증 확인
|
||||
const token = ensureAuthenticated();
|
||||
|
||||
// DOM 요소
|
||||
const form = document.getElementById('changePasswordForm');
|
||||
const messageArea = document.getElementById('message-area');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const resetBtn = document.getElementById('resetBtn');
|
||||
|
||||
// 비밀번호 토글 기능
|
||||
document.querySelectorAll('.password-toggle').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const targetId = this.getAttribute('data-target');
|
||||
const input = document.getElementById(targetId);
|
||||
|
||||
if (input) {
|
||||
const isPassword = input.type === 'password';
|
||||
input.type = isPassword ? 'text' : 'password';
|
||||
this.textContent = isPassword ? '👁️🗨️' : '👁️';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 초기화 버튼
|
||||
resetBtn?.addEventListener('click', () => {
|
||||
form.reset();
|
||||
clearMessages();
|
||||
document.getElementById('passwordStrength').innerHTML = '';
|
||||
});
|
||||
|
||||
// 메시지 표시 함수
|
||||
function showMessage(type, message) {
|
||||
messageArea.innerHTML = `
|
||||
<div class="message-box ${type}">
|
||||
${type === 'error' ? '❌' : '✅'} ${message}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 에러 메시지는 5초 후 자동 제거
|
||||
if (type === 'error') {
|
||||
setTimeout(clearMessages, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
messageArea.innerHTML = '';
|
||||
}
|
||||
|
||||
// 비밀번호 강도 체크
|
||||
async function checkPasswordStrength(password) {
|
||||
if (!password) {
|
||||
document.getElementById('passwordStrength').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/check-password-strength`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
updatePasswordStrengthUI(result);
|
||||
} catch (error) {
|
||||
console.error('Password strength check error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 비밀번호 강도 UI 업데이트
|
||||
function updatePasswordStrengthUI(strength) {
|
||||
const container = document.getElementById('passwordStrength');
|
||||
if (!container) return;
|
||||
|
||||
const colors = {
|
||||
0: '#f44336',
|
||||
1: '#ff9800',
|
||||
2: '#ffc107',
|
||||
3: '#4caf50',
|
||||
4: '#2196f3'
|
||||
};
|
||||
|
||||
const strengthText = strength.strengthText || '비밀번호를 입력하세요';
|
||||
const color = colors[strength.strength] || '#ccc';
|
||||
const percentage = (strength.score / strength.maxScore) * 100;
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="margin-top: 12px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<span style="font-size: 0.85rem; color: ${color}; font-weight: 500;">
|
||||
${strengthText}
|
||||
</span>
|
||||
<span style="font-size: 0.8rem; color: #666;">
|
||||
${strength.score}/${strength.maxScore}
|
||||
</span>
|
||||
</div>
|
||||
<div style="height: 6px; background: #e0e0e0; border-radius: 3px; overflow: hidden;">
|
||||
<div style="width: ${percentage}%; height: 100%; background: ${color}; transition: all 0.3s;"></div>
|
||||
</div>
|
||||
${strength.feedback && strength.feedback.length > 0 ? `
|
||||
<ul style="margin-top: 10px; font-size: 0.8rem; color: #666; padding-left: 20px;">
|
||||
${strength.feedback.map(f => `<li>${f}</li>`).join('')}
|
||||
</ul>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 비밀번호 입력 이벤트
|
||||
let strengthCheckTimer;
|
||||
document.getElementById('newPassword')?.addEventListener('input', (e) => {
|
||||
clearTimeout(strengthCheckTimer);
|
||||
strengthCheckTimer = setTimeout(() => {
|
||||
checkPasswordStrength(e.target.value);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// 폼 제출
|
||||
form?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
clearMessages();
|
||||
|
||||
const currentPassword = document.getElementById('currentPassword').value;
|
||||
const newPassword = document.getElementById('newPassword').value;
|
||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||
|
||||
// 유효성 검사
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
showMessage('error', '모든 필드를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
showMessage('error', '새 비밀번호가 일치하지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
showMessage('error', '비밀번호는 최소 6자 이상이어야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPassword === newPassword) {
|
||||
showMessage('error', '새 비밀번호는 현재 비밀번호와 달라야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 버튼 상태 변경
|
||||
const originalText = submitBtn.innerHTML;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span>⏳</span><span>처리 중...</span>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/change-password`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword
|
||||
})
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok && result.success) {
|
||||
showMessage('success', '비밀번호가 성공적으로 변경되었습니다.');
|
||||
form.reset();
|
||||
document.getElementById('passwordStrength').innerHTML = '';
|
||||
|
||||
// 카운트다운 시작
|
||||
let countdown = 3;
|
||||
const countdownInterval = setInterval(() => {
|
||||
showMessage('success',
|
||||
`비밀번호가 변경되었습니다. ${countdown}초 후 로그인 페이지로 이동합니다.`
|
||||
);
|
||||
countdown--;
|
||||
|
||||
if (countdown < 0) {
|
||||
clearInterval(countdownInterval);
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
} else {
|
||||
const errorMessage = result.error || '비밀번호 변경에 실패했습니다.';
|
||||
showMessage('error', errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Password change error:', error);
|
||||
showMessage('error', '서버와의 연결에 실패했습니다. 잠시 후 다시 시도해주세요.');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalText;
|
||||
}
|
||||
});
|
||||
|
||||
// 페이지 로드 시 현재 사용자 정보 표시
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
console.log('🔐 비밀번호 변경 페이지 로드됨');
|
||||
console.log('👤 현재 사용자:', user.username || 'Unknown');
|
||||
});
|
||||
795
synology_deployment/web-ui/js/code-management.js
Normal file
795
synology_deployment/web-ui/js/code-management.js
Normal file
@@ -0,0 +1,795 @@
|
||||
// 코드 관리 페이지 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
function updateUserInfo() {
|
||||
let userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
|
||||
let authUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
|
||||
const finalUserInfo = {
|
||||
worker_name: userInfo.worker_name || authUser.username || authUser.worker_name,
|
||||
job_type: userInfo.job_type || authUser.role || authUser.job_type,
|
||||
username: authUser.username || userInfo.username
|
||||
};
|
||||
|
||||
const userNameElement = document.getElementById('userName');
|
||||
const userRoleElement = document.getElementById('userRole');
|
||||
const userInitialElement = document.getElementById('userInitial');
|
||||
|
||||
if (userNameElement) {
|
||||
userNameElement.textContent = finalUserInfo.worker_name || '사용자';
|
||||
}
|
||||
|
||||
if (userRoleElement) {
|
||||
const roleMap = {
|
||||
'leader': '그룹장',
|
||||
'worker': '작업자',
|
||||
'admin': '관리자',
|
||||
'system': '시스템 관리자'
|
||||
};
|
||||
userRoleElement.textContent = roleMap[finalUserInfo.job_type] || finalUserInfo.job_type || '작업자';
|
||||
}
|
||||
|
||||
if (userInitialElement) {
|
||||
const name = finalUserInfo.worker_name || '사용자';
|
||||
userInitialElement.textContent = name.charAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 프로필 메뉴 설정
|
||||
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;
|
||||
66
synology_deployment/web-ui/js/daily-issue-api.js
Normal file
66
synology_deployment/web-ui/js/daily-issue-api.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// /js/daily-issue-api.js
|
||||
import { apiGet, apiPost } from './api-helper.js';
|
||||
|
||||
/**
|
||||
* 이슈 보고서 작성을 위해 필요한 초기 데이터(프로젝트, 이슈 유형)를 가져옵니다.
|
||||
* @returns {Promise<{projects: Array, issueTypes: Array}>}
|
||||
*/
|
||||
export async function getInitialData() {
|
||||
try {
|
||||
const [projects, issueTypes] = await Promise.all([
|
||||
apiGet('/projects'),
|
||||
apiGet('/issue-types')
|
||||
]);
|
||||
return { projects, issueTypes };
|
||||
} catch (error) {
|
||||
console.error('이슈 보고서 초기 데이터 로딩 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 날짜에 근무한 작업자 목록을 가져옵니다.
|
||||
* @param {string} date - 조회할 날짜 (YYYY-MM-DD)
|
||||
* @returns {Promise<Array>} - 작업자 목록
|
||||
*/
|
||||
export async function getWorkersByDate(date) {
|
||||
try {
|
||||
// 백엔드에 해당 날짜의 작업자 목록을 요청하는 API가 있다고 가정합니다.
|
||||
// (예: /api/workers?work_date=YYYY-MM-DD)
|
||||
// 현재는 기존 로직을 최대한 활용하여 구현합니다.
|
||||
let workers = [];
|
||||
const reports = await apiGet(`/daily-work-reports?date=${date}`);
|
||||
|
||||
if (reports && reports.length > 0) {
|
||||
const workerMap = new Map();
|
||||
reports.forEach(r => {
|
||||
if (!workerMap.has(r.worker_id)) {
|
||||
workerMap.set(r.worker_id, { worker_id: r.worker_id, worker_name: r.worker_name });
|
||||
}
|
||||
});
|
||||
workers = Array.from(workerMap.values());
|
||||
} else {
|
||||
// 보고서가 없으면 전체 작업자 목록을 가져옵니다.
|
||||
workers = await apiGet('/workers');
|
||||
}
|
||||
return workers.sort((a, b) => a.worker_name.localeCompare(b.worker_name));
|
||||
} catch (error) {
|
||||
console.error(`${date}의 작업자 목록 로딩 실패:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 작성된 이슈 보고서 데이터를 서버에 전송합니다.
|
||||
* @param {object} issueData - 전송할 이슈 데이터
|
||||
* @returns {Promise<object>} - 서버 응답 결과
|
||||
*/
|
||||
export async function createIssueReport(issueData) {
|
||||
try {
|
||||
const result = await apiPost('/issue-reports', issueData);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('이슈 보고서 생성 요청 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
103
synology_deployment/web-ui/js/daily-issue-ui.js
Normal file
103
synology_deployment/web-ui/js/daily-issue-ui.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// /js/daily-issue-ui.js
|
||||
|
||||
const DOM = {
|
||||
dateSelect: document.getElementById('dateSelect'),
|
||||
projectSelect: document.getElementById('projectSelect'),
|
||||
issueTypeSelect: document.getElementById('issueTypeSelect'),
|
||||
timeStart: document.getElementById('timeStart'),
|
||||
timeEnd: document.getElementById('timeEnd'),
|
||||
workerList: document.getElementById('workerList'),
|
||||
form: document.getElementById('issueForm'),
|
||||
submitBtn: document.getElementById('submitBtn'),
|
||||
};
|
||||
|
||||
function createOption(value, text) {
|
||||
const option = document.createElement('option');
|
||||
option.value = value;
|
||||
option.textContent = text;
|
||||
return option;
|
||||
}
|
||||
|
||||
export function populateProjects(projects) {
|
||||
DOM.projectSelect.innerHTML = '<option value="">-- 프로젝트 선택 --</option>';
|
||||
if (Array.isArray(projects)) {
|
||||
projects.forEach(p => DOM.projectSelect.appendChild(createOption(p.project_id, p.project_name)));
|
||||
}
|
||||
}
|
||||
|
||||
export function populateIssueTypes(issueTypes) {
|
||||
DOM.issueTypeSelect.innerHTML = '<option value="">-- 이슈 유형 선택 --</option>';
|
||||
if (Array.isArray(issueTypes)) {
|
||||
issueTypes.forEach(t => DOM.issueTypeSelect.appendChild(createOption(t.issue_type_id, `${t.category}:${t.subcategory}`)));
|
||||
}
|
||||
}
|
||||
|
||||
export function populateTimeOptions() {
|
||||
for (let h = 0; h < 24; h++) {
|
||||
for (let m of [0, 30]) {
|
||||
const time = `${String(h).padStart(2, '0')}:${m === 0 ? '00' : '30'}`;
|
||||
DOM.timeStart.appendChild(createOption(time, time));
|
||||
DOM.timeEnd.appendChild(createOption(time, time.replace('00:00', '24:00')));
|
||||
}
|
||||
}
|
||||
DOM.timeEnd.value = "24:00"; // 기본값 설정
|
||||
}
|
||||
|
||||
export function renderWorkerList(workers) {
|
||||
DOM.workerList.innerHTML = '';
|
||||
if (!Array.isArray(workers) || workers.length === 0) {
|
||||
DOM.workerList.textContent = '선택 가능한 작업자가 없습니다.';
|
||||
return;
|
||||
}
|
||||
workers.forEach(worker => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn';
|
||||
btn.textContent = worker.worker_name;
|
||||
btn.dataset.id = worker.worker_id;
|
||||
btn.addEventListener('click', () => btn.classList.toggle('selected'));
|
||||
DOM.workerList.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
export function getFormData() {
|
||||
const selectedWorkers = [...DOM.workerList.querySelectorAll('.btn.selected')].map(b => b.dataset.id);
|
||||
|
||||
if (selectedWorkers.length === 0) {
|
||||
alert('작업자를 한 명 이상 선택해주세요.');
|
||||
return null;
|
||||
}
|
||||
if (DOM.timeEnd.value <= DOM.timeStart.value) {
|
||||
alert('종료 시간은 시작 시간보다 이후여야 합니다.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const formData = new FormData(DOM.form);
|
||||
const data = {
|
||||
date: formData.get('dateSelect'), // input name 속성이 없어 직접 가져옴
|
||||
project_id: DOM.projectSelect.value,
|
||||
issue_type_id: DOM.issueTypeSelect.value,
|
||||
start_time: DOM.timeStart.value,
|
||||
end_time: DOM.timeEnd.value,
|
||||
worker_ids: selectedWorkers, // worker_id -> worker_ids 로 명확하게 변경
|
||||
};
|
||||
|
||||
for (const key in data) {
|
||||
if (!data[key] || (Array.isArray(data[key]) && data[key].length === 0)) {
|
||||
alert('모든 필수 항목을 입력해주세요.');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export function setSubmitButtonState(isLoading) {
|
||||
if (isLoading) {
|
||||
DOM.submitBtn.disabled = true;
|
||||
DOM.submitBtn.textContent = '등록 중...';
|
||||
} else {
|
||||
DOM.submitBtn.disabled = false;
|
||||
DOM.submitBtn.textContent = '등록';
|
||||
}
|
||||
}
|
||||
89
synology_deployment/web-ui/js/daily-issue.js
Normal file
89
synology_deployment/web-ui/js/daily-issue.js
Normal file
@@ -0,0 +1,89 @@
|
||||
// /js/daily-issue.js
|
||||
|
||||
import { getInitialData, getWorkersByDate, createIssueReport } from './daily-issue-api.js';
|
||||
import {
|
||||
populateProjects,
|
||||
populateIssueTypes,
|
||||
populateTimeOptions,
|
||||
renderWorkerList,
|
||||
getFormData,
|
||||
setSubmitButtonState
|
||||
} from './daily-issue-ui.js';
|
||||
|
||||
const dateSelect = document.getElementById('dateSelect');
|
||||
const form = document.getElementById('issueForm');
|
||||
|
||||
/**
|
||||
* 날짜가 변경될 때마다 해당 날짜의 작업자 목록을 다시 불러옵니다.
|
||||
*/
|
||||
async function handleDateChange() {
|
||||
const selectedDate = dateSelect.value;
|
||||
if (!selectedDate) {
|
||||
document.getElementById('workerList').textContent = '날짜를 먼저 선택하세요.';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('workerList').textContent = '작업자 목록을 불러오는 중...';
|
||||
try {
|
||||
const workers = await getWorkersByDate(selectedDate);
|
||||
renderWorkerList(workers);
|
||||
} catch (error) {
|
||||
document.getElementById('workerList').textContent = '작업자 목록 로딩에 실패했습니다.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 제출 이벤트를 처리합니다.
|
||||
*/
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
const issueData = getFormData();
|
||||
|
||||
if (!issueData) return; // 유효성 검사 실패
|
||||
|
||||
setSubmitButtonState(true);
|
||||
try {
|
||||
const result = await createIssueReport(issueData);
|
||||
if (result.success) {
|
||||
alert('✅ 이슈가 성공적으로 등록되었습니다.');
|
||||
form.reset(); // 폼 초기화
|
||||
dateSelect.value = new Date().toISOString().split('T')[0]; // 날짜 오늘로 리셋
|
||||
handleDateChange(); // 작업자 목록 새로고침
|
||||
} else {
|
||||
throw new Error(result.error || '알 수 없는 오류가 발생했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`🚨 등록 실패: ${error.message}`);
|
||||
} finally {
|
||||
setSubmitButtonState(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 초기화 함수
|
||||
*/
|
||||
async function initializePage() {
|
||||
// 오늘 날짜 기본 설정
|
||||
dateSelect.value = new Date().toISOString().split('T')[0];
|
||||
|
||||
populateTimeOptions();
|
||||
|
||||
// 프로젝트, 이슈유형, 작업자 목록을 병렬로 로드
|
||||
try {
|
||||
const [initialData] = await Promise.all([
|
||||
getInitialData(),
|
||||
handleDateChange() // 초기 작업자 목록 로드
|
||||
]);
|
||||
populateProjects(initialData.projects);
|
||||
populateIssueTypes(initialData.issueTypes);
|
||||
} catch (error) {
|
||||
alert('페이지 초기화 중 오류가 발생했습니다. 새로고침 해주세요.');
|
||||
}
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
dateSelect.addEventListener('change', handleDateChange);
|
||||
form.addEventListener('submit', handleSubmit);
|
||||
}
|
||||
|
||||
// DOM이 로드되면 페이지 초기화를 시작합니다.
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
1009
synology_deployment/web-ui/js/daily-report-viewer 복사본.js
Normal file
1009
synology_deployment/web-ui/js/daily-report-viewer 복사본.js
Normal file
File diff suppressed because it is too large
Load Diff
81
synology_deployment/web-ui/js/daily-report-viewer.js
Normal file
81
synology_deployment/web-ui/js/daily-report-viewer.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// /js/daily-report-viewer.js
|
||||
|
||||
import { fetchReportData } from './report-viewer-api.js';
|
||||
import { renderReport, processReportData, showLoading, showError } from './report-viewer-ui.js';
|
||||
import { exportToExcel, printReport } from './report-viewer-export.js';
|
||||
import { getUser } from './auth.js';
|
||||
|
||||
// 전역 상태: 현재 화면에 표시된 데이터
|
||||
let currentProcessedData = null;
|
||||
|
||||
/**
|
||||
* 날짜를 기준으로 보고서를 검색하고 화면에 렌더링합니다.
|
||||
*/
|
||||
async function searchReports() {
|
||||
const dateInput = document.getElementById('reportDate');
|
||||
const selectedDate = dateInput.value;
|
||||
|
||||
if (!selectedDate) {
|
||||
showError('날짜를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(true);
|
||||
currentProcessedData = null; // 새 검색이 시작되면 이전 데이터 초기화
|
||||
|
||||
try {
|
||||
const rawData = await fetchReportData(selectedDate);
|
||||
currentProcessedData = processReportData(rawData, selectedDate);
|
||||
renderReport(currentProcessedData);
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
renderReport(null); // 에러 발생 시 데이터 없는 화면 표시
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지의 모든 이벤트 리스너를 설정합니다.
|
||||
*/
|
||||
function setupEventListeners() {
|
||||
document.getElementById('searchBtn')?.addEventListener('click', searchReports);
|
||||
document.getElementById('todayBtn')?.addEventListener('click', () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('reportDate').value = today;
|
||||
searchReports();
|
||||
});
|
||||
|
||||
document.getElementById('reportDate')?.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') searchReports();
|
||||
});
|
||||
|
||||
document.getElementById('exportExcelBtn')?.addEventListener('click', () => {
|
||||
exportToExcel(currentProcessedData);
|
||||
});
|
||||
|
||||
document.getElementById('printBtn')?.addEventListener('click', printReport);
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지가 처음 로드될 때 실행되는 초기화 함수
|
||||
*/
|
||||
function initializePage() {
|
||||
// auth.js를 사용하여 인증 상태 확인
|
||||
const user = getUser();
|
||||
if (!user) {
|
||||
showError('로그인이 필요합니다. 2초 후 로그인 페이지로 이동합니다.');
|
||||
setTimeout(() => window.location.href = '/index.html', 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
setupEventListeners();
|
||||
|
||||
// 페이지 로드 시 오늘 날짜로 자동 검색
|
||||
const dateInput = document.getElementById('reportDate');
|
||||
dateInput.value = new Date().toISOString().split('T')[0];
|
||||
searchReports();
|
||||
}
|
||||
|
||||
// DOM이 로드되면 페이지 초기화를 시작합니다.
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
1127
synology_deployment/web-ui/js/daily-work-report.js
Normal file
1127
synology_deployment/web-ui/js/daily-work-report.js
Normal file
File diff suppressed because it is too large
Load Diff
49
synology_deployment/web-ui/js/factory-upload.js
Normal file
49
synology_deployment/web-ui/js/factory-upload.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { API, getAuthHeaders } from '/js/api-config.js';
|
||||
|
||||
document.getElementById('uploadForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
try {
|
||||
// FormData를 사용할 때는 Content-Type을 설정하지 않음 (자동 설정됨)
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch(`${API}/factoryinfo`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.message || '등록 실패');
|
||||
}
|
||||
|
||||
alert('등록 완료!');
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('등록 실패: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// 파일 선택 시 미리보기 (선택사항)
|
||||
const fileInput = document.querySelector('input[name="map_image"]');
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
// 미리보기 요소가 있을 경우에만 동작
|
||||
const preview = document.getElementById('file-preview');
|
||||
if (preview) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
preview.innerHTML = `<img src="${e.target.result}" alt="미리보기" style="max-width: 200px; max-height: 200px; border-radius: 8px;">`;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
38
synology_deployment/web-ui/js/factory-view.js
Normal file
38
synology_deployment/web-ui/js/factory-view.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { API, getAuthHeaders } from '/js/api-config.js';
|
||||
|
||||
(async () => {
|
||||
const pathParts = location.pathname.split('/');
|
||||
const id = pathParts[pathParts.length - 1];
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/factoryinfo/${id}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('조회 실패');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// DOM 요소가 존재하는지 확인 후 설정
|
||||
const nameEl = document.getElementById('factoryName');
|
||||
if (nameEl) nameEl.textContent = data.factory_name;
|
||||
|
||||
const addressEl = document.getElementById('factoryAddress');
|
||||
if (addressEl) addressEl.textContent = '📍 ' + data.address;
|
||||
|
||||
const imageEl = document.getElementById('factoryImage');
|
||||
if (imageEl) imageEl.src = data.map_image_url;
|
||||
|
||||
const descEl = document.getElementById('factoryDescription');
|
||||
if (descEl) descEl.textContent = data.description;
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const container = document.querySelector('.container');
|
||||
if (container) {
|
||||
container.innerHTML = '<p>공장 정보를 불러올 수 없습니다.</p>';
|
||||
}
|
||||
}
|
||||
})();
|
||||
103
synology_deployment/web-ui/js/group-leader-dashboard.js
Normal file
103
synology_deployment/web-ui/js/group-leader-dashboard.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// /js/group-leader-dashboard.js
|
||||
// 그룹장 전용 대시보드 기능
|
||||
|
||||
console.log('📊 그룹장 대시보드 스크립트 로딩');
|
||||
|
||||
// 팀 현황 새로고침
|
||||
async function refreshTeamStatus() {
|
||||
console.log('🔄 팀 현황 새로고침 시작');
|
||||
|
||||
try {
|
||||
// 로딩 상태 표시
|
||||
const teamList = document.getElementById('team-list');
|
||||
if (teamList) {
|
||||
teamList.innerHTML = '<div style="text-align: center; padding: 20px;">⏳ 로딩 중...</div>';
|
||||
}
|
||||
|
||||
// 실제로는 API 호출
|
||||
// const response = await fetch('/api/team-status', { headers: getAuthHeaders() });
|
||||
// const data = await response.json();
|
||||
|
||||
// 임시 데이터로 업데이트 (실제 API 연동 시 교체)
|
||||
setTimeout(() => {
|
||||
updateTeamStatusUI();
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 팀 현황 로딩 실패:', error);
|
||||
const teamList = document.getElementById('team-list');
|
||||
if (teamList) {
|
||||
teamList.innerHTML = '<div style="text-align: center; padding: 20px; color: #f44336;">❌ 로딩 실패</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 팀 현황 UI 업데이트 (임시 데이터)
|
||||
function updateTeamStatusUI() {
|
||||
const teamData = [
|
||||
{ name: '김작업', status: 'present', statusText: '출근' },
|
||||
{ name: '이현장', status: 'present', statusText: '출근' },
|
||||
{ name: '박휴가', status: 'absent', statusText: '휴가' },
|
||||
{ name: '최작업', status: 'present', statusText: '출근' },
|
||||
{ name: '정현장', status: 'present', statusText: '출근' }
|
||||
];
|
||||
|
||||
const teamList = document.getElementById('team-list');
|
||||
if (teamList) {
|
||||
teamList.innerHTML = teamData.map(member => `
|
||||
<div class="team-member ${member.status}">
|
||||
<span class="member-name">${member.name}</span>
|
||||
<span class="member-status">${member.statusText}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 통계 업데이트
|
||||
const presentCount = teamData.filter(m => m.status === 'present').length;
|
||||
const absentCount = teamData.filter(m => m.status === 'absent').length;
|
||||
|
||||
const totalEl = document.getElementById('team-total');
|
||||
const presentEl = document.getElementById('team-present');
|
||||
const absentEl = document.getElementById('team-absent');
|
||||
|
||||
if (totalEl) totalEl.textContent = teamData.length;
|
||||
if (presentEl) presentEl.textContent = presentCount;
|
||||
if (absentEl) absentEl.textContent = absentCount;
|
||||
|
||||
console.log('✅ 팀 현황 업데이트 완료');
|
||||
}
|
||||
|
||||
// 환영 메시지 개인화
|
||||
function personalizeWelcome() {
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const welcomeMsg = document.getElementById('welcome-message');
|
||||
|
||||
if (user && user.name && welcomeMsg) {
|
||||
welcomeMsg.textContent = `${user.name}님의 실시간 팀 현황 및 작업 모니터링`;
|
||||
console.log('✅ 환영 메시지 개인화 완료');
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('🚀 그룹장 대시보드 초기화 시작');
|
||||
|
||||
// 사용자 정보 확인
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
console.log('👤 현재 사용자:', user);
|
||||
|
||||
// 권한 확인
|
||||
if (user.access_level !== 'group_leader') {
|
||||
console.warn('⚠️ 그룹장 권한 없음:', user.access_level);
|
||||
// 필요시 다른 페이지로 리다이렉트
|
||||
}
|
||||
|
||||
// 초기화 작업
|
||||
personalizeWelcome();
|
||||
updateTeamStatusUI();
|
||||
|
||||
console.log('✅ 그룹장 대시보드 초기화 완료');
|
||||
});
|
||||
|
||||
// 전역 함수로 내보내기 (HTML에서 사용)
|
||||
window.refreshTeamStatus = refreshTeamStatus;
|
||||
170
synology_deployment/web-ui/js/load-navbar.js
Normal file
170
synology_deployment/web-ui/js/load-navbar.js
Normal file
@@ -0,0 +1,170 @@
|
||||
// js/load-navbar.js
|
||||
// 브라우저 호환 버전 - ES6 모듈 제거
|
||||
|
||||
// 역할 이름을 한글로 변환하는 맵
|
||||
const ROLE_NAMES = {
|
||||
admin: '관리자',
|
||||
system: '시스템 관리자',
|
||||
leader: '그룹장',
|
||||
user: '작업자',
|
||||
support: '지원팀',
|
||||
default: '사용자',
|
||||
};
|
||||
|
||||
/**
|
||||
* 사용자 역할에 따라 메뉴 항목을 필터링합니다.
|
||||
* @param {Document} doc - 파싱된 HTML 문서 객체
|
||||
* @param {string} userRole - 현재 사용자의 역할
|
||||
*/
|
||||
function filterMenuByRole(doc, userRole) {
|
||||
const selectors = [
|
||||
{ role: 'admin', selector: '.admin-only' },
|
||||
{ role: 'system', selector: '.system-only' },
|
||||
{ role: 'leader', selector: '.leader-only' },
|
||||
];
|
||||
|
||||
selectors.forEach(({ role, selector }) => {
|
||||
// 사용자가 해당 역할을 가지고 있지 않으면 메뉴 항목을 제거
|
||||
if (userRole !== role && userRole !== 'system') { // system 권한도 admin 메뉴 접근 가능
|
||||
doc.querySelectorAll(selector).forEach(el => el.remove());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 바에 사용자 정보를 채웁니다.
|
||||
* @param {Document} doc - 파싱된 HTML 문서 객체
|
||||
* @param {object} user - 현재 사용자 객체
|
||||
*/
|
||||
function populateUserInfo(doc, user) {
|
||||
const displayName = user.name || user.username;
|
||||
const roleName = ROLE_NAMES[user.role] || ROLE_NAMES.default;
|
||||
|
||||
// 상단 바 사용자 이름
|
||||
const userNameEl = doc.getElementById('user-name');
|
||||
if (userNameEl) userNameEl.textContent = displayName;
|
||||
|
||||
// 상단 바 사용자 역할
|
||||
const userRoleEl = doc.getElementById('user-role');
|
||||
if (userRoleEl) userRoleEl.textContent = roleName;
|
||||
|
||||
// 드롭다운 메뉴 사용자 이름
|
||||
const dropdownNameEl = doc.getElementById('dropdown-user-fullname');
|
||||
if (dropdownNameEl) dropdownNameEl.textContent = displayName;
|
||||
|
||||
// 드롭다운 메뉴 사용자 아이디
|
||||
const dropdownIdEl = doc.getElementById('dropdown-user-id');
|
||||
if (dropdownIdEl) dropdownIdEl.textContent = `@${user.username}`;
|
||||
|
||||
// Admin 버튼 제거됨
|
||||
|
||||
// System 버튼 표시 여부 결정 (system 권한만)
|
||||
const systemBtn = doc.getElementById('systemBtn');
|
||||
if (systemBtn && user.role === 'system') {
|
||||
systemBtn.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 바와 관련된 모든 이벤트를 설정합니다.
|
||||
*/
|
||||
function setupNavbarEvents() {
|
||||
const userInfoDropdown = document.getElementById('user-info-dropdown');
|
||||
const profileDropdownMenu = document.getElementById('profile-dropdown-menu');
|
||||
|
||||
// 드롭다운 토글
|
||||
if (userInfoDropdown && profileDropdownMenu) {
|
||||
userInfoDropdown.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
profileDropdownMenu.classList.toggle('show');
|
||||
userInfoDropdown.classList.toggle('active');
|
||||
});
|
||||
}
|
||||
|
||||
// 로그아웃 버튼
|
||||
const logoutButton = document.getElementById('dropdown-logout');
|
||||
if (logoutButton) {
|
||||
logoutButton.addEventListener('click', () => {
|
||||
if (confirm('로그아웃 하시겠습니까?')) {
|
||||
clearAuthData();
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Admin 버튼 제거됨
|
||||
|
||||
// System 버튼 클릭 이벤트
|
||||
const systemButton = document.getElementById('systemBtn');
|
||||
if (systemButton) {
|
||||
systemButton.addEventListener('click', () => {
|
||||
window.location.href = '/pages/dashboard/system.html';
|
||||
});
|
||||
}
|
||||
|
||||
// Dashboard 버튼 클릭 이벤트
|
||||
const dashboardButton = document.querySelector('.dashboard-btn');
|
||||
if (dashboardButton) {
|
||||
dashboardButton.addEventListener('click', () => {
|
||||
window.location.href = '/pages/dashboard/group-leader.html';
|
||||
});
|
||||
}
|
||||
|
||||
// 외부 클릭 시 드롭다운 닫기
|
||||
document.addEventListener('click', (e) => {
|
||||
if (profileDropdownMenu && !userInfoDropdown.contains(e.target) && !profileDropdownMenu.contains(e.target)) {
|
||||
profileDropdownMenu.classList.remove('show');
|
||||
userInfoDropdown.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 시간을 업데이트하는 함수
|
||||
*/
|
||||
function updateTime() {
|
||||
const timeElement = document.getElementById('current-time');
|
||||
if (timeElement) {
|
||||
const now = new Date();
|
||||
timeElement.textContent = now.toLocaleTimeString('ko-KR', { hour12: false });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 메인 로직: DOMContentLoaded 시 실행
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const navbarContainer = document.getElementById('navbar-container');
|
||||
if (!navbarContainer) return;
|
||||
|
||||
const currentUser = getUser();
|
||||
if (!currentUser) return; // 사용자가 없으면 아무 작업도 하지 않음
|
||||
|
||||
try {
|
||||
const response = await fetch('/components/navbar.html');
|
||||
const htmlText = await response.text();
|
||||
|
||||
// 1. 텍스트를 가상 DOM으로 파싱
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlText, 'text/html');
|
||||
|
||||
// 2. DOM에 삽입하기 *전*에 내용 수정
|
||||
filterMenuByRole(doc, currentUser.role);
|
||||
populateUserInfo(doc, currentUser);
|
||||
|
||||
// 3. 수정 완료된 HTML을 실제 DOM에 삽입 (깜빡임 방지)
|
||||
navbarContainer.innerHTML = doc.body.innerHTML;
|
||||
|
||||
// 4. DOM에 삽입된 후에 이벤트 리스너 설정
|
||||
setupNavbarEvents();
|
||||
|
||||
// 5. 실시간 시간 업데이트 시작
|
||||
updateTime();
|
||||
setInterval(updateTime, 1000);
|
||||
|
||||
console.log('✅ 네비게이션 바 로딩 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('🔴 네비게이션 바 로딩 중 오류 발생:', error);
|
||||
navbarContainer.innerHTML = '<p>네비게이션 바를 불러오는 데 실패했습니다.</p>';
|
||||
}
|
||||
});
|
||||
104
synology_deployment/web-ui/js/load-sections.js
Normal file
104
synology_deployment/web-ui/js/load-sections.js
Normal file
@@ -0,0 +1,104 @@
|
||||
// /js/load-sections.js
|
||||
import { getUser } from './auth.js';
|
||||
import { apiGet } from './api-helper.js';
|
||||
|
||||
// 역할에 따라 불러올 섹션 HTML 파일을 매핑합니다.
|
||||
const SECTION_MAP = {
|
||||
admin: '/components/sections/admin-sections.html',
|
||||
system: '/components/sections/admin-sections.html', // system도 admin과 동일한 섹션을 사용
|
||||
leader: '/components/sections/leader-sections.html',
|
||||
user: '/components/sections/user-sections.html',
|
||||
default: '/components/sections/user-sections.html', // 역할이 없는 경우 기본값
|
||||
};
|
||||
|
||||
/**
|
||||
* API를 통해 대시보드 통계 데이터를 가져옵니다.
|
||||
* @returns {Promise<object|null>} 통계 데이터 또는 에러 시 null
|
||||
*/
|
||||
async function fetchDashboardStats() {
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
// 실제 백엔드 엔드포인트는 /api/dashboard/stats 와 같은 형태로 구현될 수 있습니다.
|
||||
const stats = await apiGet(`/workreports?start=${today}&end=${today}`);
|
||||
// 필요한 데이터 형태로 가공 (예시)
|
||||
return {
|
||||
today_reports_count: stats.length,
|
||||
today_workers_count: new Set(stats.map(d => d.worker_id)).size,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('대시보드 통계 데이터 로드 실패:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 가상 DOM에 통계 데이터를 채워 넣습니다.
|
||||
* @param {Document} doc - 파싱된 HTML 문서 객체
|
||||
* @param {object} stats - 통계 데이터
|
||||
*/
|
||||
function populateStatsData(doc, stats) {
|
||||
if (!stats) return;
|
||||
|
||||
const todayStatsEl = doc.getElementById('today-stats');
|
||||
if (todayStatsEl) {
|
||||
todayStatsEl.innerHTML = `
|
||||
<p>📝 오늘 등록된 작업: ${stats.today_reports_count}건</p>
|
||||
<p>👥 참여 작업자: ${stats.today_workers_count}명</p>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메인 로직: 페이지에 역할별 섹션을 로드하고 내용을 채웁니다.
|
||||
*/
|
||||
async function initializeSections() {
|
||||
const mainContainer = document.querySelector('main[id$="-sections"]');
|
||||
if (!mainContainer) {
|
||||
console.error('섹션을 담을 메인 컨테이너를 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
mainContainer.innerHTML = '<div class="loading">콘텐츠를 불러오는 중...</div>';
|
||||
|
||||
const currentUser = getUser();
|
||||
if (!currentUser) {
|
||||
mainContainer.innerHTML = '<div class="error-state">사용자 정보를 찾을 수 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const sectionFile = SECTION_MAP[currentUser.role] || SECTION_MAP.default;
|
||||
|
||||
try {
|
||||
// 1. 역할에 맞는 HTML 템플릿과 동적 데이터를 동시에 로드 (Promise.all 활용)
|
||||
const [htmlResponse, statsData] = await Promise.all([
|
||||
fetch(sectionFile),
|
||||
fetchDashboardStats()
|
||||
]);
|
||||
|
||||
if (!htmlResponse.ok) {
|
||||
throw new Error(`섹션 파일(${sectionFile})을 불러오는 데 실패했습니다.`);
|
||||
}
|
||||
const htmlText = await htmlResponse.text();
|
||||
|
||||
// 2. 텍스트를 가상 DOM으로 파싱
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlText, 'text/html');
|
||||
|
||||
// 3. (필요 시) 역할 기반으로 가상 DOM 필터링 - 현재는 파일 자체가 역할별로 나뉘어 불필요
|
||||
// filterByRole(doc, currentUser.role);
|
||||
|
||||
// 4. 가상 DOM에 동적 데이터 채우기
|
||||
populateStatsData(doc, statsData);
|
||||
|
||||
// 5. 모든 수정이 완료된 HTML을 실제 DOM에 한 번에 삽입
|
||||
mainContainer.innerHTML = doc.body.innerHTML;
|
||||
|
||||
console.log(`✅ ${currentUser.role} 역할의 섹션 로딩 완료.`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('섹션 로딩 중 오류 발생:', error);
|
||||
mainContainer.innerHTML = `<div class="error-state">콘텐츠 로딩에 실패했습니다: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// DOM이 로드되면 섹션 초기화를 시작합니다.
|
||||
document.addEventListener('DOMContentLoaded', initializeSections);
|
||||
67
synology_deployment/web-ui/js/load-sidebar.js
Normal file
67
synology_deployment/web-ui/js/load-sidebar.js
Normal file
@@ -0,0 +1,67 @@
|
||||
// /js/load-sidebar.js
|
||||
import { getUser } from './auth.js';
|
||||
|
||||
/**
|
||||
* 사용자 역할에 따라 사이드바 메뉴 항목을 필터링합니다.
|
||||
* @param {Document} doc - 파싱된 HTML 문서 객체
|
||||
* @param {string} userRole - 현재 사용자의 역할
|
||||
*/
|
||||
function filterSidebarByRole(doc, userRole) {
|
||||
// 'system' 역할은 모든 메뉴를 볼 수 있으므로 필터링하지 않음
|
||||
if (userRole === 'system') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 역할과 그에 해당하는 클래스 선택자 매핑
|
||||
const roleClassMap = {
|
||||
admin: '.admin-only',
|
||||
leader: '.leader-only',
|
||||
user: '.user-only', // 또는 'worker-only' 등, sidebar.html에 정의된 클래스에 맞춰야 함
|
||||
support: '.support-only'
|
||||
};
|
||||
|
||||
// 모든 역할 기반 선택자를 가져옴
|
||||
const allRoleSelectors = Object.values(roleClassMap).join(', ');
|
||||
const allRoleElements = doc.querySelectorAll(allRoleSelectors);
|
||||
|
||||
allRoleElements.forEach(el => {
|
||||
// 요소가 현재 사용자 역할에 해당하는 클래스를 가지고 있는지 확인
|
||||
const userRoleSelector = roleClassMap[userRole];
|
||||
if (!userRoleSelector || !el.matches(userRoleSelector)) {
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const sidebarContainer = document.getElementById('sidebar-container');
|
||||
if (!sidebarContainer) return;
|
||||
|
||||
const currentUser = getUser();
|
||||
if (!currentUser) return; // 비로그인 상태면 사이드바를 로드하지 않음
|
||||
|
||||
try {
|
||||
const response = await fetch('/components/sidebar.html');
|
||||
if (!response.ok) {
|
||||
throw new Error(`사이드바 파일을 불러올 수 없습니다: ${response.statusText}`);
|
||||
}
|
||||
const htmlText = await response.text();
|
||||
|
||||
// 1. 텍스트를 가상 DOM으로 파싱
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlText, 'text/html');
|
||||
|
||||
// 2. DOM에 삽입하기 *전*에 역할에 따라 메뉴 필터링
|
||||
filterSidebarByRole(doc, currentUser.role);
|
||||
|
||||
// 3. 수정 완료된 HTML을 실제 DOM에 삽입
|
||||
sidebarContainer.innerHTML = doc.body.innerHTML;
|
||||
|
||||
console.log('✅ 사이드바 로딩 및 필터링 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('🔴 사이드바 로딩 실패:', error);
|
||||
sidebarContainer.innerHTML = '<p>메뉴 로딩 실패</p>';
|
||||
}
|
||||
});
|
||||
67
synology_deployment/web-ui/js/login.js
Normal file
67
synology_deployment/web-ui/js/login.js
Normal file
@@ -0,0 +1,67 @@
|
||||
// /js/login.js
|
||||
// ES6 모듈 의존성 제거 - 브라우저 호환성 개선
|
||||
|
||||
// 인증 데이터 저장 함수 (직접 구현)
|
||||
function saveAuthData(token, user) {
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
function clearAuthData() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
|
||||
document.getElementById('loginForm').addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const errorDiv = document.getElementById('error');
|
||||
|
||||
const submitBtn = e.target.querySelector('button[type="submit"]');
|
||||
const originalText = submitBtn.textContent;
|
||||
|
||||
// 로딩 상태 시작
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '로그인 중...';
|
||||
errorDiv.style.display = 'none';
|
||||
|
||||
try {
|
||||
// API 헬퍼를 통해 로그인 요청 (window 객체에서 가져오기)
|
||||
const result = await window.login(username, password);
|
||||
|
||||
if (result.success && result.data && result.data.token) {
|
||||
// 인증 정보 저장
|
||||
saveAuthData(result.data.token, result.data.user);
|
||||
|
||||
// 백엔드가 지정한 URL로 리디렉션
|
||||
const redirectUrl = result.data.redirectUrl || '/pages/dashboard/user.html'; // 혹시 모를 예외처리
|
||||
|
||||
// 부드러운 화면 전환 효과
|
||||
document.body.style.transition = 'opacity 0.3s ease-out';
|
||||
document.body.style.opacity = '0';
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = redirectUrl;
|
||||
}, 300);
|
||||
|
||||
} else {
|
||||
// 이 케이스는 api-helper에서 throw new Error()로 처리되어 catch 블록으로 바로 이동합니다.
|
||||
// 하지만, 만약의 경우를 대비해 방어 코드를 남겨둡니다.
|
||||
clearAuthData();
|
||||
errorDiv.textContent = result.error || '로그인에 실패했습니다.';
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('로그인 오류:', err);
|
||||
clearAuthData();
|
||||
// api-helper에서 보낸 에러 메시지를 표시
|
||||
errorDiv.textContent = err.message || '서버 연결에 실패했습니다.';
|
||||
errorDiv.style.display = 'block';
|
||||
} finally {
|
||||
// 로딩 상태 해제
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
}
|
||||
});
|
||||
86
synology_deployment/web-ui/js/manage-issue.js
Normal file
86
synology_deployment/web-ui/js/manage-issue.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { API, getAuthHeaders } from '/js/api-config.js';
|
||||
|
||||
function createRow(item, cols, delHandler) {
|
||||
const tr = document.createElement('tr');
|
||||
cols.forEach(key => {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = item[key];
|
||||
tr.appendChild(td);
|
||||
});
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.textContent = '삭제';
|
||||
delBtn.className = 'btn-delete';
|
||||
delBtn.onclick = () => delHandler(item);
|
||||
const td = document.createElement('td');
|
||||
td.appendChild(delBtn);
|
||||
tr.appendChild(td);
|
||||
return tr;
|
||||
}
|
||||
|
||||
const form = document.getElementById('issueTypeForm');
|
||||
form?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
category: document.getElementById('category').value,
|
||||
subcategory: document.getElementById('subcategory').value
|
||||
};
|
||||
try {
|
||||
const res = await fetch(`${API}/issue-types`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (res.ok && result.success) {
|
||||
alert('✅ 등록 완료');
|
||||
form.reset();
|
||||
loadIssueTypes();
|
||||
} else {
|
||||
alert('❌ 실패: ' + (result.error || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('🚨 서버 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadIssueTypes() {
|
||||
const tbody = document.getElementById('issueTypeTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="4">불러오는 중...</td></tr>';
|
||||
try {
|
||||
const res = await fetch(`${API}/issue-types`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const list = await res.json();
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (Array.isArray(list)) {
|
||||
list.forEach(item => {
|
||||
const row = createRow(item, ['issue_type_id', 'category', 'subcategory'], async t => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const delRes = await fetch(`${API}/issue-types/${t.issue_type_id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (delRes.ok) {
|
||||
loadIssueTypes();
|
||||
} else {
|
||||
alert('삭제 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('삭제 중 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="4">데이터 형식 오류</td></tr>';
|
||||
}
|
||||
} catch (err) {
|
||||
tbody.innerHTML = '<tr><td colspan="4">로드 실패: ' + err.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadIssueTypes();
|
||||
});
|
||||
93
synology_deployment/web-ui/js/manage-pipespec.js
Normal file
93
synology_deployment/web-ui/js/manage-pipespec.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
||||
|
||||
// 인증 확인
|
||||
ensureAuthenticated();
|
||||
|
||||
// 행 생성
|
||||
function createRow(item, delHandler) {
|
||||
const tr = document.createElement('tr');
|
||||
const label = `${item.material} / ${item.diameter_in} / ${item.schedule}`;
|
||||
tr.innerHTML = `
|
||||
<td>${item.spec_id}</td>
|
||||
<td>${label}</td>
|
||||
<td><button class="btn-delete">삭제</button></td>
|
||||
`;
|
||||
tr.querySelector('.btn-delete').onclick = () => delHandler(item);
|
||||
return tr;
|
||||
}
|
||||
|
||||
// 등록
|
||||
document.getElementById('specForm')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const material = document.getElementById('material').value.trim();
|
||||
const diameter = document.getElementById('diameter_in').value.trim();
|
||||
const schedule = document.getElementById('schedule').value.trim();
|
||||
|
||||
if (!material || !diameter || !schedule) {
|
||||
return alert('모든 항목을 입력하세요.');
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/pipespecs`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ material, diameter_in: diameter, schedule })
|
||||
});
|
||||
const result = await res.json();
|
||||
if (res.ok && result.success) {
|
||||
alert('✅ 등록 완료');
|
||||
e.target.reset();
|
||||
loadSpecs();
|
||||
} else {
|
||||
alert('❌ 실패: ' + (result.error || '등록 실패'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('🚨 서버 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// 불러오기
|
||||
async function loadSpecs() {
|
||||
const tbody = document.getElementById('specTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="3">불러오는 중...</td></tr>';
|
||||
try {
|
||||
const res = await fetch(`${API}/pipespecs`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const list = await res.json();
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (Array.isArray(list)) {
|
||||
list.forEach(item => {
|
||||
const row = createRow(item, async (spec) => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const delRes = await fetch(`${API}/pipespecs/${spec.spec_id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (delRes.ok) {
|
||||
loadSpecs();
|
||||
} else {
|
||||
alert('삭제 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('삭제 중 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="3">데이터 형식 오류</td></tr>';
|
||||
}
|
||||
} catch (err) {
|
||||
tbody.innerHTML = '<tr><td colspan="3">로드 실패: ' + err.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', loadSpecs);
|
||||
108
synology_deployment/web-ui/js/manage-project.js
Normal file
108
synology_deployment/web-ui/js/manage-project.js
Normal file
@@ -0,0 +1,108 @@
|
||||
// /js/manage-project.js
|
||||
|
||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
||||
|
||||
// 인증 확인
|
||||
ensureAuthenticated();
|
||||
|
||||
function createRow(item, cols, delHandler) {
|
||||
const tr = document.createElement('tr');
|
||||
cols.forEach(key => {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = item[key];
|
||||
tr.appendChild(td);
|
||||
});
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.textContent = '삭제';
|
||||
delBtn.className = 'btn-delete';
|
||||
delBtn.onclick = () => delHandler(item);
|
||||
const td = document.createElement('td');
|
||||
td.appendChild(delBtn);
|
||||
tr.appendChild(td);
|
||||
return tr;
|
||||
}
|
||||
|
||||
const projectForm = document.getElementById('projectForm');
|
||||
projectForm?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
job_no: document.getElementById('job_no').value.trim(),
|
||||
project_name: document.getElementById('project_name').value.trim(),
|
||||
contract_date: document.getElementById('contract_date').value,
|
||||
due_date: document.getElementById('due_date').value,
|
||||
delivery_method: document.getElementById('delivery_method').value.trim(),
|
||||
site: document.getElementById('site').value.trim(),
|
||||
pm: document.getElementById('pm').value.trim()
|
||||
};
|
||||
|
||||
if (!body.project_name || !body.job_no) {
|
||||
return alert('필수 항목을 입력하세요.');
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/projects`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (res.ok && result.success) {
|
||||
alert('✅ 등록 완료');
|
||||
projectForm.reset();
|
||||
loadProjects();
|
||||
} else {
|
||||
alert('❌ 실패: ' + (result.error || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('🚨 서버 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadProjects() {
|
||||
const tbody = document.getElementById('projectTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="9">불러오는 중...</td></tr>';
|
||||
try {
|
||||
const res = await fetch(`${API}/projects`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const list = await res.json();
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (Array.isArray(list)) {
|
||||
list.forEach(item => {
|
||||
const row = createRow(item, [
|
||||
'project_id', 'job_no', 'project_name', 'contract_date',
|
||||
'due_date', 'delivery_method', 'site', 'pm'
|
||||
], async p => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const delRes = await fetch(`${API}/projects/${p.project_id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (delRes.ok) {
|
||||
alert('✅ 삭제 완료');
|
||||
loadProjects();
|
||||
} else {
|
||||
alert('❌ 삭제 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('🚨 삭제 중 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="9">데이터 형식 오류</td></tr>';
|
||||
}
|
||||
} catch (err) {
|
||||
tbody.innerHTML = '<tr><td colspan="9">로드 실패: ' + err.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', loadProjects);
|
||||
288
synology_deployment/web-ui/js/manage-user.js
Normal file
288
synology_deployment/web-ui/js/manage-user.js
Normal file
@@ -0,0 +1,288 @@
|
||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
||||
|
||||
// 인증 확인
|
||||
const token = ensureAuthenticated();
|
||||
|
||||
const accessLabels = {
|
||||
worker: '작업자',
|
||||
group_leader: '그룹장',
|
||||
support_team: '지원팀',
|
||||
admin: '관리자',
|
||||
system: '시스템'
|
||||
};
|
||||
|
||||
// 현재 사용자 정보 가져오기
|
||||
const currentUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const isSystemUser = currentUser.access_level === 'system';
|
||||
|
||||
function createRow(item, cols, delHandler) {
|
||||
const tr = document.createElement('tr');
|
||||
cols.forEach(key => {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = item[key] || '-';
|
||||
tr.appendChild(td);
|
||||
});
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.textContent = '삭제';
|
||||
delBtn.className = 'btn-delete';
|
||||
delBtn.onclick = () => delHandler(item);
|
||||
const td = document.createElement('td');
|
||||
td.appendChild(delBtn);
|
||||
tr.appendChild(td);
|
||||
return tr;
|
||||
}
|
||||
|
||||
// 내 비밀번호 변경
|
||||
const myPasswordForm = document.getElementById('myPasswordForm');
|
||||
myPasswordForm?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
|
||||
const currentPassword = document.getElementById('currentPassword').value;
|
||||
const newPassword = document.getElementById('newPassword').value;
|
||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||
|
||||
// 새 비밀번호 확인
|
||||
if (newPassword !== confirmPassword) {
|
||||
alert('❌ 새 비밀번호가 일치하지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 비밀번호 강도 검사
|
||||
if (newPassword.length < 6) {
|
||||
alert('❌ 비밀번호는 최소 6자 이상이어야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/change-password`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword
|
||||
})
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok && result.success) {
|
||||
showToast('✅ 비밀번호가 변경되었습니다.');
|
||||
myPasswordForm.reset();
|
||||
|
||||
// 3초 후 로그인 페이지로 이동
|
||||
setTimeout(() => {
|
||||
alert('비밀번호가 변경되어 다시 로그인해주세요.');
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/index.html';
|
||||
}, 2000);
|
||||
} else {
|
||||
alert('❌ 비밀번호 변경 실패: ' + (result.error || '현재 비밀번호가 올바르지 않습니다.'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Password change error:', error);
|
||||
alert('🚨 서버 오류: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// 시스템 권한자만 볼 수 있는 사용자 비밀번호 변경 섹션
|
||||
if (isSystemUser) {
|
||||
const systemCard = document.getElementById('systemPasswordChangeCard');
|
||||
if (systemCard) {
|
||||
systemCard.style.display = 'block';
|
||||
}
|
||||
|
||||
// 사용자 비밀번호 변경 (시스템 권한자)
|
||||
const userPasswordForm = document.getElementById('userPasswordForm');
|
||||
userPasswordForm?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
|
||||
const targetUserId = document.getElementById('targetUserId').value;
|
||||
const newPassword = document.getElementById('targetNewPassword').value;
|
||||
|
||||
if (!targetUserId) {
|
||||
alert('❌ 사용자를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
alert('❌ 비밀번호는 최소 6자 이상이어야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('정말로 이 사용자의 비밀번호를 변경하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/admin/change-password`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
userId: targetUserId,
|
||||
newPassword
|
||||
})
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok && result.success) {
|
||||
showToast('✅ 사용자 비밀번호가 변경되었습니다.');
|
||||
userPasswordForm.reset();
|
||||
} else {
|
||||
alert('❌ 비밀번호 변경 실패: ' + (result.error || '권한이 없습니다.'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Admin password change error:', error);
|
||||
alert('🚨 서버 오류: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 사용자 등록
|
||||
const userForm = document.getElementById('userForm');
|
||||
userForm?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
username: document.getElementById('username').value.trim(),
|
||||
password: document.getElementById('password').value.trim(),
|
||||
name: document.getElementById('name').value.trim(),
|
||||
access_level: document.getElementById('access_level').value,
|
||||
worker_id: document.getElementById('worker_id').value || null
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (res.ok && result.success) {
|
||||
showToast('✅ 등록 완료');
|
||||
userForm.reset();
|
||||
loadUsers();
|
||||
} else {
|
||||
alert('❌ 실패: ' + (result.error || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
alert('🚨 서버 오류: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadUsers() {
|
||||
const tbody = document.getElementById('userTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="6">불러오는 중...</td></tr>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/users`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const list = await res.json();
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (Array.isArray(list)) {
|
||||
// 시스템 권한자용 사용자 선택 옵션도 업데이트
|
||||
if (isSystemUser) {
|
||||
const targetUserSelect = document.getElementById('targetUserId');
|
||||
if (targetUserSelect) {
|
||||
targetUserSelect.innerHTML = '<option value="">사용자 선택</option>';
|
||||
list.forEach(user => {
|
||||
// 본인은 제외
|
||||
if (user.user_id !== currentUser.user_id) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = user.user_id;
|
||||
opt.textContent = `${user.name} (${user.username})`;
|
||||
targetUserSelect.appendChild(opt);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
list.forEach(item => {
|
||||
item.access_level = accessLabels[item.access_level] || item.access_level;
|
||||
item.worker_id = item.worker_id || '-';
|
||||
const row = createRow(item, [
|
||||
'user_id', 'username', 'name', 'access_level', 'worker_id'
|
||||
], async u => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const delRes = await fetch(`${API}/auth/users/${u.user_id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (delRes.ok) {
|
||||
showToast('✅ 삭제 완료');
|
||||
loadUsers();
|
||||
} else {
|
||||
alert('❌ 삭제 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('🚨 삭제 중 오류 발생');
|
||||
}
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="6">데이터 형식 오류</td></tr>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load users error:', error);
|
||||
tbody.innerHTML = '<tr><td colspan="6">로드 실패: ' + error.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkerOptions() {
|
||||
const select = document.getElementById('worker_id');
|
||||
if (!select) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/workers`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const workers = await res.json();
|
||||
if (Array.isArray(workers)) {
|
||||
workers.forEach(w => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = w.worker_id;
|
||||
opt.textContent = `${w.worker_name} (${w.worker_id})`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('작업자 목록 불러오기 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
const toast = document.createElement('div');
|
||||
toast.textContent = message;
|
||||
toast.style.position = 'fixed';
|
||||
toast.style.bottom = '30px';
|
||||
toast.style.left = '50%';
|
||||
toast.style.transform = 'translateX(-50%)';
|
||||
toast.style.background = '#323232';
|
||||
toast.style.color = '#fff';
|
||||
toast.style.padding = '10px 20px';
|
||||
toast.style.borderRadius = '6px';
|
||||
toast.style.fontSize = '14px';
|
||||
toast.style.zIndex = 9999;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 2000);
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
loadUsers();
|
||||
loadWorkerOptions();
|
||||
});
|
||||
111
synology_deployment/web-ui/js/manage-worker.js
Normal file
111
synology_deployment/web-ui/js/manage-worker.js
Normal file
@@ -0,0 +1,111 @@
|
||||
// /js/manage-worker.js
|
||||
|
||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
||||
|
||||
// 인증 확인
|
||||
ensureAuthenticated();
|
||||
|
||||
// ✅ 테이블 행 생성
|
||||
function createRow(item, cols, delHandler) {
|
||||
const tr = document.createElement('tr');
|
||||
cols.forEach(key => {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = item[key] || '-';
|
||||
tr.appendChild(td);
|
||||
});
|
||||
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.textContent = '삭제';
|
||||
delBtn.className = 'btn-delete';
|
||||
delBtn.onclick = () => delHandler(item);
|
||||
|
||||
const td = document.createElement('td');
|
||||
td.appendChild(delBtn);
|
||||
tr.appendChild(td);
|
||||
|
||||
return tr;
|
||||
}
|
||||
|
||||
// ✅ 작업자 등록
|
||||
const workerForm = document.getElementById('workerForm');
|
||||
workerForm?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
|
||||
const body = {
|
||||
worker_name: document.getElementById('workerName').value.trim(),
|
||||
position: document.getElementById('position').value.trim()
|
||||
};
|
||||
|
||||
if (!body.worker_name || !body.position) {
|
||||
return alert('모든 필드를 입력해주세요.');
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/workers`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok && result.success) {
|
||||
alert('✅ 등록 완료');
|
||||
workerForm.reset();
|
||||
loadWorkers();
|
||||
} else {
|
||||
alert('❌ 실패: ' + (result.error || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('🚨 서버 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// ✅ 작업자 목록 불러오기
|
||||
async function loadWorkers() {
|
||||
const tbody = document.getElementById('workerTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="4">불러오는 중...</td></tr>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/workers`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const response = await res.json();
|
||||
const list = response.data || response; // 새로운 API 응답 구조 지원
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (Array.isArray(list)) {
|
||||
list.forEach(item => {
|
||||
const row = createRow(item, ['worker_id', 'worker_name', 'position'], async w => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const delRes = await fetch(`${API}/workers/${w.worker_id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (delRes.ok) {
|
||||
alert('✅ 삭제 완료');
|
||||
loadWorkers();
|
||||
} else {
|
||||
alert('❌ 삭제 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('🚨 삭제 중 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="4">데이터 형식 오류</td></tr>';
|
||||
}
|
||||
} catch (err) {
|
||||
tbody.innerHTML = '<tr><td colspan="4">로드 실패: ' + err.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 초기 로딩
|
||||
window.addEventListener('DOMContentLoaded', loadWorkers);
|
||||
954
synology_deployment/web-ui/js/management-dashboard.js
Normal file
954
synology_deployment/web-ui/js/management-dashboard.js
Normal file
@@ -0,0 +1,954 @@
|
||||
// management-dashboard.js - 관리자 대시보드 전용 스크립트
|
||||
|
||||
// =================================================================
|
||||
// 🌐 통합 API 설정 import
|
||||
// =================================================================
|
||||
import { API, getAuthHeaders, apiCall } from '/js/api-config.js';
|
||||
|
||||
// 전역 변수
|
||||
let workers = [];
|
||||
let workData = [];
|
||||
let filteredWorkData = [];
|
||||
let currentDate = '';
|
||||
let currentUser = null;
|
||||
|
||||
// 권한 레벨 매핑
|
||||
const ACCESS_LEVELS = {
|
||||
worker: 1,
|
||||
group_leader: 2,
|
||||
support_team: 3,
|
||||
admin: 4,
|
||||
system: 5
|
||||
};
|
||||
|
||||
// 한국 시간 기준 오늘 날짜 가져오기
|
||||
function 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}`;
|
||||
}
|
||||
|
||||
// 현재 로그인한 사용자 정보 가져오기
|
||||
function getCurrentUser() {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return null;
|
||||
|
||||
const payloadBase64 = token.split('.')[1];
|
||||
if (payloadBase64) {
|
||||
const payload = JSON.parse(atob(payloadBase64));
|
||||
console.log('토큰에서 추출한 사용자 정보:', payload);
|
||||
return payload;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('토큰에서 사용자 정보 추출 실패:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
const userInfo = localStorage.getItem('user') || localStorage.getItem('userInfo') || localStorage.getItem('currentUser');
|
||||
if (userInfo) {
|
||||
const parsed = JSON.parse(userInfo);
|
||||
console.log('localStorage에서 가져온 사용자 정보:', parsed);
|
||||
return parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('localStorage에서 사용자 정보 가져오기 실패:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 권한 체크 함수
|
||||
function checkPermission() {
|
||||
currentUser = getCurrentUser();
|
||||
|
||||
if (!currentUser) {
|
||||
showMessage('로그인이 필요합니다.', 'error');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
return false;
|
||||
}
|
||||
|
||||
const userAccessLevel = currentUser.access_level;
|
||||
const accessLevelValue = ACCESS_LEVELS[userAccessLevel] || 0;
|
||||
|
||||
console.log('사용자 권한 체크:', {
|
||||
username: currentUser.username || currentUser.name,
|
||||
access_level: userAccessLevel,
|
||||
level_value: accessLevelValue,
|
||||
required_level: ACCESS_LEVELS.group_leader
|
||||
});
|
||||
|
||||
if (accessLevelValue < ACCESS_LEVELS.group_leader) {
|
||||
showMessage('그룹장 이상의 권한이 필요합니다. 현재 권한: ' + userAccessLevel, 'error');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 3000);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 메시지 표시
|
||||
function showMessage(message, type = 'info') {
|
||||
const container = document.getElementById('message-container');
|
||||
container.innerHTML = `<div class="message ${type}">${message}</div>`;
|
||||
|
||||
if (type === 'success') {
|
||||
setTimeout(() => {
|
||||
hideMessage();
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function hideMessage() {
|
||||
document.getElementById('message-container').innerHTML = '';
|
||||
}
|
||||
|
||||
// 로딩 표시
|
||||
function showLoading() {
|
||||
document.getElementById('loadingSpinner').style.display = 'flex';
|
||||
document.getElementById('summarySection').style.display = 'none';
|
||||
document.getElementById('actionBar').style.display = 'none';
|
||||
document.getElementById('workersSection').style.display = 'none';
|
||||
document.getElementById('noDataMessage').style.display = 'none';
|
||||
}
|
||||
|
||||
function hideLoading() {
|
||||
document.getElementById('loadingSpinner').style.display = 'none';
|
||||
}
|
||||
|
||||
// 작업자 데이터 로드
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
console.log('작업자 데이터 로딩 중... (통합 API)');
|
||||
const data = await apiCall(`${API}/workers`);
|
||||
workers = Array.isArray(data) ? data : (data.data || data.workers || []);
|
||||
console.log('✅ 작업자 로드 성공:', workers.length);
|
||||
} catch (error) {
|
||||
console.error('작업자 로딩 오류:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 날짜의 작업 데이터 로드 (개선된 버전)
|
||||
async function loadWorkData(date) {
|
||||
try {
|
||||
console.log(`${date} 날짜의 작업 데이터 로딩 중... (통합 API)`);
|
||||
|
||||
// 1차: view_all=true로 전체 데이터 시도
|
||||
let queryParams = `date=${date}&view_all=true`;
|
||||
console.log(`🔍 1차 시도: ${API}/daily-work-reports?${queryParams}`);
|
||||
|
||||
let data = await apiCall(`${API}/daily-work-reports?${queryParams}`);
|
||||
workData = Array.isArray(data) ? data : (data.data || []);
|
||||
|
||||
// 데이터가 없으면 다른 방법들 시도
|
||||
if (workData.length === 0) {
|
||||
console.log('⚠️ view_all로 데이터 없음, 다른 방법 시도...');
|
||||
|
||||
// 2차: admin=true로 시도
|
||||
queryParams = `date=${date}&admin=true`;
|
||||
console.log(`🔍 2차 시도: ${API}/daily-work-reports?${queryParams}`);
|
||||
|
||||
data = await apiCall(`${API}/daily-work-reports?${queryParams}`);
|
||||
workData = Array.isArray(data) ? data : (data.data || []);
|
||||
|
||||
if (workData.length === 0) {
|
||||
// 3차: 날짜 경로 파라미터로 시도
|
||||
console.log(`🔍 3차 시도: ${API}/daily-work-reports/date/${date}`);
|
||||
|
||||
data = await apiCall(`${API}/daily-work-reports/date/${date}`);
|
||||
workData = Array.isArray(data) ? data : (data.data || []);
|
||||
|
||||
if (workData.length === 0) {
|
||||
// 4차: 기본 파라미터만으로 시도
|
||||
console.log(`🔍 4차 시도: ${API}/daily-work-reports?date=${date}`);
|
||||
|
||||
data = await apiCall(`${API}/daily-work-reports?date=${date}`);
|
||||
workData = Array.isArray(data) ? data : (data.data || []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ 최종 작업 데이터 로드 결과: ${workData.length}개`);
|
||||
|
||||
// 디버깅을 위한 상세 로그
|
||||
if (workData.length > 0) {
|
||||
console.log('📊 로드된 데이터 샘플:', workData.slice(0, 3));
|
||||
const uniqueWorkers = [...new Set(workData.map(w => w.worker_name))];
|
||||
console.log('👥 데이터에 포함된 작업자들:', uniqueWorkers);
|
||||
} else {
|
||||
console.log('❌ 해당 날짜에 작업 데이터가 없거나 접근 권한이 없습니다.');
|
||||
}
|
||||
|
||||
return workData;
|
||||
} catch (error) {
|
||||
console.error('작업 데이터 로딩 오류:', error);
|
||||
|
||||
// 에러 시에도 빈 배열 반환하여 앱이 중단되지 않도록
|
||||
workData = [];
|
||||
|
||||
// 구체적인 에러 정보 표시
|
||||
if (error.message.includes('403')) {
|
||||
console.log('🔒 권한 부족으로 인한 접근 제한');
|
||||
throw new Error('해당 날짜의 데이터에 접근할 권한이 없습니다.');
|
||||
} else if (error.message.includes('404')) {
|
||||
console.log('📭 해당 날짜에 데이터 없음');
|
||||
throw new Error('해당 날짜에 입력된 작업 데이터가 없습니다.');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 대시보드 데이터 로드
|
||||
async function loadDashboardData() {
|
||||
const selectedDate = document.getElementById('selectedDate').value;
|
||||
|
||||
if (!selectedDate) {
|
||||
showMessage('날짜를 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
currentDate = selectedDate;
|
||||
showLoading();
|
||||
hideMessage();
|
||||
|
||||
try {
|
||||
// 병렬로 데이터 로드
|
||||
await Promise.all([
|
||||
loadWorkers(),
|
||||
loadWorkData(selectedDate)
|
||||
]);
|
||||
|
||||
// 데이터 분석 및 표시
|
||||
const dashboardData = analyzeDashboardData();
|
||||
displayDashboard(dashboardData);
|
||||
|
||||
hideLoading();
|
||||
|
||||
} catch (error) {
|
||||
console.error('대시보드 데이터 로드 실패:', error);
|
||||
hideLoading();
|
||||
showMessage('데이터를 불러오는 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
|
||||
// 에러 시 데이터 없음 메시지 표시
|
||||
document.getElementById('noDataMessage').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// 대시보드 데이터 분석 (개선된 버전)
|
||||
function analyzeDashboardData() {
|
||||
console.log('대시보드 데이터 분석 시작');
|
||||
|
||||
// 작업자별 데이터 그룹화
|
||||
const workerWorkData = {};
|
||||
workData.forEach(work => {
|
||||
const workerId = work.worker_id;
|
||||
if (!workerWorkData[workerId]) {
|
||||
workerWorkData[workerId] = [];
|
||||
}
|
||||
workerWorkData[workerId].push(work);
|
||||
});
|
||||
|
||||
// 전체 통계 계산
|
||||
const totalWorkers = workers.length;
|
||||
const workersWithData = Object.keys(workerWorkData).length;
|
||||
const workersWithoutData = totalWorkers - workersWithData;
|
||||
const totalHours = workData.reduce((sum, work) => sum + parseFloat(work.work_hours || 0), 0);
|
||||
const totalEntries = workData.length;
|
||||
const errorCount = workData.filter(work => work.work_status_id === 2).length;
|
||||
|
||||
// 작업자별 상세 분석 (개선된 버전)
|
||||
const workerAnalysis = workers.map(worker => {
|
||||
const workerWorks = workerWorkData[worker.worker_id] || [];
|
||||
const workerHours = workerWorks.reduce((sum, work) => sum + parseFloat(work.work_hours || 0), 0);
|
||||
|
||||
// 작업 유형 분석 (실제 이름으로)
|
||||
const workTypes = [...new Set(workerWorks.map(work => work.work_type_name).filter(Boolean))];
|
||||
|
||||
// 프로젝트 분석
|
||||
const workerProjects = [...new Set(workerWorks.map(work => work.project_name).filter(Boolean))];
|
||||
|
||||
// 기여자 분석
|
||||
const workerContributors = [...new Set(workerWorks.map(work => work.created_by_name).filter(Boolean))];
|
||||
|
||||
// 상태 결정 (더 세밀한 기준)
|
||||
let status = 'missing';
|
||||
if (workerWorks.length > 0) {
|
||||
if (workerHours >= 6) {
|
||||
status = 'completed'; // 6시간 이상을 완료로 간주
|
||||
} else {
|
||||
status = 'partial'; // 1시간 이상이지만 6시간 미만은 부분입력
|
||||
}
|
||||
}
|
||||
|
||||
// 최근 업데이트 시간
|
||||
const lastUpdate = workerWorks.length > 0
|
||||
? new Date(Math.max(...workerWorks.map(work => new Date(work.created_at))))
|
||||
: null;
|
||||
|
||||
return {
|
||||
...worker,
|
||||
status,
|
||||
totalHours: Math.round(workerHours * 10) / 10, // 소수점 1자리로 반올림
|
||||
entryCount: workerWorks.length,
|
||||
workTypes, // 작업 유형 배열 (실제 이름)
|
||||
projects: workerProjects,
|
||||
contributors: workerContributors,
|
||||
lastUpdate,
|
||||
works: workerWorks
|
||||
};
|
||||
});
|
||||
|
||||
const summary = {
|
||||
totalWorkers,
|
||||
completedWorkers: workerAnalysis.filter(w => w.status === 'completed').length,
|
||||
missingWorkers: workerAnalysis.filter(w => w.status === 'missing').length,
|
||||
partialWorkers: workerAnalysis.filter(w => w.status === 'partial').length,
|
||||
totalHours: Math.round(totalHours * 10) / 10,
|
||||
totalEntries,
|
||||
errorCount
|
||||
};
|
||||
|
||||
console.log('대시보드 분석 결과:', { summary, workerAnalysis });
|
||||
|
||||
return {
|
||||
summary,
|
||||
workers: workerAnalysis,
|
||||
date: currentDate
|
||||
};
|
||||
}
|
||||
|
||||
// 대시보드 표시
|
||||
function displayDashboard(data) {
|
||||
displaySummary(data.summary);
|
||||
displayWorkers(data.workers);
|
||||
|
||||
// 섹션 표시
|
||||
document.getElementById('summarySection').style.display = 'block';
|
||||
document.getElementById('actionBar').style.display = 'flex';
|
||||
document.getElementById('workersSection').style.display = 'block';
|
||||
|
||||
// 필터링 설정
|
||||
filteredWorkData = data.workers;
|
||||
setupFiltering();
|
||||
|
||||
console.log('✅ 대시보드 표시 완료');
|
||||
}
|
||||
|
||||
// 요약 섹션 표시
|
||||
function displaySummary(summary) {
|
||||
document.getElementById('totalWorkers').textContent = summary.totalWorkers;
|
||||
document.getElementById('completedWorkers').textContent = summary.completedWorkers;
|
||||
document.getElementById('missingWorkers').textContent = summary.missingWorkers;
|
||||
document.getElementById('totalHours').textContent = summary.totalHours + 'h';
|
||||
document.getElementById('totalEntries').textContent = summary.totalEntries;
|
||||
document.getElementById('errorCount').textContent = summary.errorCount;
|
||||
}
|
||||
|
||||
// 작업자 목록 표시 (테이블 형태로 개선)
|
||||
function displayWorkers(workersData) {
|
||||
const tableBody = document.getElementById('workersTableBody');
|
||||
tableBody.innerHTML = '';
|
||||
|
||||
if (workersData.length === 0) {
|
||||
tableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="9" class="no-data-row">표시할 작업자가 없습니다.</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
workersData.forEach(worker => {
|
||||
const row = createWorkerRow(worker);
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// 작업자 테이블 행 생성 (개선된 버전)
|
||||
function createWorkerRow(worker) {
|
||||
const row = document.createElement('tr');
|
||||
|
||||
const statusText = {
|
||||
completed: '✅ 완료',
|
||||
missing: '❌ 미입력',
|
||||
partial: '⚠️ 부분입력'
|
||||
};
|
||||
|
||||
const statusClass = {
|
||||
completed: 'completed',
|
||||
missing: 'missing',
|
||||
partial: 'partial'
|
||||
};
|
||||
|
||||
// 작업 유형 태그 생성 (실제 이름으로)
|
||||
const workTypeTags = worker.workTypes && worker.workTypes.length > 0
|
||||
? worker.workTypes.map(type => `<span class="work-type-tag">${type}</span>`).join('')
|
||||
: '<span class="work-type-tag" style="background: #f8f9fa; color: #6c757d;">없음</span>';
|
||||
|
||||
// 프로젝트 태그 생성
|
||||
const projectTags = worker.projects && worker.projects.length > 0
|
||||
? worker.projects.map(project => `<span class="project-tag">${project}</span>`).join('')
|
||||
: '<span class="project-tag" style="background: #f8f9fa; color: #6c757d;">없음</span>';
|
||||
|
||||
// 기여자 태그 생성
|
||||
const contributorTags = worker.contributors && worker.contributors.length > 0
|
||||
? worker.contributors.map(contributor => `<span class="contributor-tag">${contributor}</span>`).join('')
|
||||
: '<span class="contributor-tag" style="background: #f8f9fa; color: #6c757d;">없음</span>';
|
||||
|
||||
// 시간에 따른 스타일 클래스
|
||||
let hoursClass = 'zero';
|
||||
if (worker.totalHours > 0) {
|
||||
hoursClass = worker.totalHours >= 6 ? 'full' : 'partial';
|
||||
}
|
||||
|
||||
// 업데이트 시간 포맷팅 및 스타일
|
||||
let updateTimeText = '없음';
|
||||
let updateClass = '';
|
||||
if (worker.lastUpdate) {
|
||||
const now = new Date();
|
||||
const diff = now - worker.lastUpdate;
|
||||
const hours = diff / (1000 * 60 * 60);
|
||||
|
||||
updateTimeText = formatDateTime(worker.lastUpdate);
|
||||
updateClass = hours < 1 ? 'recent' : hours > 24 ? 'old' : '';
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
<td>
|
||||
<div class="worker-name-cell">
|
||||
👤 ${worker.worker_name}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge ${statusClass[worker.status]}">${statusText[worker.status]}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="hours-cell ${hoursClass}">${worker.totalHours}h</div>
|
||||
</td>
|
||||
<td>
|
||||
<strong>${worker.entryCount}</strong>개
|
||||
</td>
|
||||
<td>
|
||||
<div class="work-types-container">${workTypeTags}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="projects-container">${projectTags}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="contributors-container">${contributorTags}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="update-time ${updateClass}">${updateTimeText}</div>
|
||||
</td>
|
||||
<td>
|
||||
<button class="detail-btn" onclick="showWorkerDetailSafe('${worker.worker_id}')">
|
||||
📋 상세
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
// 날짜/시간 포맷팅
|
||||
function formatDateTime(date) {
|
||||
const d = new Date(date);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const hours = String(d.getHours()).padStart(2, '0');
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// 작업자 상세 모달 표시 (안전한 버전)
|
||||
function showWorkerDetailSafe(workerId) {
|
||||
// 현재 분석된 데이터에서 해당 작업자 찾기
|
||||
const worker = filteredWorkData.find(w => w.worker_id == workerId);
|
||||
if (!worker) {
|
||||
showMessage('작업자 정보를 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showWorkerDetail(worker);
|
||||
}
|
||||
|
||||
// 작업자 상세 모달 표시 (개선된 버전)
|
||||
function showWorkerDetail(worker) {
|
||||
const modal = document.getElementById('workerDetailModal');
|
||||
const modalTitle = document.getElementById('modalWorkerName');
|
||||
const modalBody = document.getElementById('modalWorkerDetails');
|
||||
|
||||
modalTitle.textContent = `👤 ${worker.worker_name} 상세 현황`;
|
||||
|
||||
let detailHtml = `
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h4>📊 기본 정보</h4>
|
||||
<p><strong>작업자명:</strong> ${worker.worker_name}</p>
|
||||
<p><strong>총 작업시간:</strong> ${worker.totalHours}시간</p>
|
||||
<p><strong>작업 항목 수:</strong> ${worker.entryCount}개</p>
|
||||
<p><strong>상태:</strong> ${worker.status === 'completed' ? '✅ 완료' : worker.status === 'missing' ? '❌ 미입력' : '⚠️ 부분입력'}</p>
|
||||
<p><strong>작업 유형:</strong> ${worker.workTypes && worker.workTypes.length > 0 ? worker.workTypes.join(', ') : '없음'}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (worker.works && worker.works.length > 0) {
|
||||
detailHtml += `
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h4>🔧 작업 내역</h4>
|
||||
<div style="max-height: 400px; overflow-y: auto;">
|
||||
`;
|
||||
|
||||
worker.works.forEach((work, index) => {
|
||||
detailHtml += `
|
||||
<div style="border: 1px solid #e9ecef; padding: 15px; margin-bottom: 10px; border-radius: 8px; background: #f8f9fa; position: relative;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px;">
|
||||
<p><strong>작업 ${index + 1}</strong></p>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button onclick="editWorkItem('${work.id}')" style="background: #007bff; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">
|
||||
✏️ 수정
|
||||
</button>
|
||||
<button onclick="deleteWorkItem('${work.id}')" style="background: #dc3545; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p><strong>프로젝트:</strong> ${work.project_name || '미지정'}</p>
|
||||
<p><strong>작업 유형:</strong> ${work.work_type_name || '미지정'}</p>
|
||||
<p><strong>작업 시간:</strong> ${work.work_hours}시간</p>
|
||||
<p><strong>상태:</strong> ${work.work_status_name || '미지정'}</p>
|
||||
${work.error_type_name ? `<p><strong>에러 유형:</strong> ${work.error_type_name}</p>` : ''}
|
||||
<p><strong>입력자:</strong> ${work.created_by_name || '미지정'}</p>
|
||||
<p><strong>입력 시간:</strong> ${formatDateTime(work.created_at)}</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
detailHtml += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
detailHtml += `
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h4>📭 작업 내역</h4>
|
||||
<p style="text-align: center; color: #666; padding: 20px;">입력된 작업이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (worker.contributors && worker.contributors.length > 0) {
|
||||
detailHtml += `
|
||||
<div>
|
||||
<h4>👥 기여자</h4>
|
||||
<p>${worker.contributors.join(', ')}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
modalBody.innerHTML = detailHtml;
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
// 작업 항목 수정 함수 (통합 API 사용)
|
||||
async function editWorkItem(workId) {
|
||||
try {
|
||||
console.log('수정할 작업 ID:', workId);
|
||||
|
||||
// 현재 작업 데이터에서 해당 작업 찾기
|
||||
let workData = null;
|
||||
for (const worker of filteredWorkData) {
|
||||
if (worker.works) {
|
||||
workData = worker.works.find(work => work.id == workId);
|
||||
if (workData) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!workData) {
|
||||
showMessage('수정할 작업을 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 필요한 마스터 데이터 로드
|
||||
await loadMasterDataForEdit();
|
||||
|
||||
// 수정 모달 표시
|
||||
showEditModal(workData);
|
||||
|
||||
} catch (error) {
|
||||
console.error('작업 정보 조회 오류:', error);
|
||||
showMessage('작업 정보를 불러올 수 없습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 수정용 마스터 데이터 로드
|
||||
async function loadMasterDataForEdit() {
|
||||
try {
|
||||
if (!window.projects || window.projects.length === 0) {
|
||||
const projectData = await apiCall(`${API}/projects`);
|
||||
window.projects = Array.isArray(projectData) ? projectData : (projectData.projects || []);
|
||||
}
|
||||
|
||||
if (!window.workTypes || window.workTypes.length === 0) {
|
||||
const workTypeData = await apiCall(`${API}/daily-work-reports/work-types`);
|
||||
window.workTypes = Array.isArray(workTypeData) ? workTypeData : [];
|
||||
}
|
||||
|
||||
if (!window.workStatusTypes || window.workStatusTypes.length === 0) {
|
||||
const statusData = await apiCall(`${API}/daily-work-reports/work-status-types`);
|
||||
window.workStatusTypes = Array.isArray(statusData) ? statusData : [];
|
||||
}
|
||||
|
||||
if (!window.errorTypes || window.errorTypes.length === 0) {
|
||||
const errorData = await apiCall(`${API}/daily-work-reports/error-types`);
|
||||
window.errorTypes = Array.isArray(errorData) ? errorData : [];
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('마스터 데이터 로드 오류:', error);
|
||||
|
||||
// 기본값 설정
|
||||
window.projects = window.projects || [];
|
||||
window.workTypes = window.workTypes || [
|
||||
{id: 1, name: 'Base'},
|
||||
{id: 2, name: 'Vessel'},
|
||||
{id: 3, name: 'Piping'}
|
||||
];
|
||||
window.workStatusTypes = window.workStatusTypes || [
|
||||
{id: 1, name: '정규'},
|
||||
{id: 2, name: '에러'}
|
||||
];
|
||||
window.errorTypes = window.errorTypes || [
|
||||
{id: 1, name: '설계미스'},
|
||||
{id: 2, name: '외주작업 불량'},
|
||||
{id: 3, name: '입고지연'},
|
||||
{id: 4, name: '작업 불량'}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 수정 모달 표시
|
||||
function showEditModal(workData) {
|
||||
// 기존 상세 모달 닫기
|
||||
closeWorkerDetailModal();
|
||||
|
||||
const modalHtml = `
|
||||
<div class="edit-modal" id="editModal">
|
||||
<div class="edit-modal-content">
|
||||
<div class="edit-modal-header">
|
||||
<h3>✏️ 작업 수정</h3>
|
||||
<button class="close-modal-btn" onclick="closeEditModal()">×</button>
|
||||
</div>
|
||||
<div class="edit-modal-body">
|
||||
<div class="edit-form-group">
|
||||
<label>🏗️ 프로젝트</label>
|
||||
<select class="edit-select" id="editProject">
|
||||
<option value="">프로젝트 선택</option>
|
||||
${(window.projects || []).map(p => `
|
||||
<option value="${p.project_id}" ${p.project_id == workData.project_id ? 'selected' : ''}>
|
||||
${p.project_name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group">
|
||||
<label>⚙️ 작업 유형</label>
|
||||
<select class="edit-select" id="editWorkType">
|
||||
<option value="">작업 유형 선택</option>
|
||||
${(window.workTypes || []).map(wt => `
|
||||
<option value="${wt.id}" ${wt.id == workData.work_type_id ? 'selected' : ''}>
|
||||
${wt.name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group">
|
||||
<label>📊 업무 상태</label>
|
||||
<select class="edit-select" id="editWorkStatus">
|
||||
<option value="">업무 상태 선택</option>
|
||||
${(window.workStatusTypes || []).map(ws => `
|
||||
<option value="${ws.id}" ${ws.id == workData.work_status_id ? 'selected' : ''}>
|
||||
${ws.name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group" id="editErrorTypeGroup" style="${workData.work_status_id == 2 ? '' : 'display: none;'}">
|
||||
<label>❌ 에러 유형</label>
|
||||
<select class="edit-select" id="editErrorType">
|
||||
<option value="">에러 유형 선택</option>
|
||||
${(window.errorTypes || []).map(et => `
|
||||
<option value="${et.id}" ${et.id == workData.error_type_id ? 'selected' : ''}>
|
||||
${et.name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group">
|
||||
<label>⏰ 작업 시간</label>
|
||||
<input type="number" class="edit-input" id="editWorkHours"
|
||||
value="${workData.work_hours}"
|
||||
min="0" max="24" step="0.5">
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeEditModal()">취소</button>
|
||||
<button class="btn btn-success" onclick="saveEditedWork('${workData.id}')">💾 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
|
||||
// 업무 상태 변경 이벤트
|
||||
document.getElementById('editWorkStatus').addEventListener('change', (e) => {
|
||||
const errorTypeGroup = document.getElementById('editErrorTypeGroup');
|
||||
if (e.target.value === '2') {
|
||||
errorTypeGroup.style.display = 'block';
|
||||
} else {
|
||||
errorTypeGroup.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 수정 모달 닫기
|
||||
function closeEditModal() {
|
||||
const modal = document.getElementById('editModal');
|
||||
if (modal) {
|
||||
modal.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// 수정된 작업 저장 (통합 API 사용)
|
||||
async function saveEditedWork(workId) {
|
||||
try {
|
||||
const projectId = document.getElementById('editProject').value;
|
||||
const workTypeId = document.getElementById('editWorkType').value;
|
||||
const workStatusId = document.getElementById('editWorkStatus').value;
|
||||
const errorTypeId = document.getElementById('editErrorType').value;
|
||||
const workHours = document.getElementById('editWorkHours').value;
|
||||
|
||||
if (!projectId || !workTypeId || !workStatusId || !workHours) {
|
||||
showMessage('모든 필수 항목을 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (workStatusId === '2' && !errorTypeId) {
|
||||
showMessage('에러 상태인 경우 에러 유형을 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
project_id: parseInt(projectId),
|
||||
work_type_id: parseInt(workTypeId),
|
||||
work_status_id: parseInt(workStatusId),
|
||||
error_type_id: errorTypeId ? parseInt(errorTypeId) : null,
|
||||
work_hours: parseFloat(workHours)
|
||||
};
|
||||
|
||||
showMessage('작업을 수정하는 중... (통합 API)', 'loading');
|
||||
|
||||
const result = await apiCall(`${API}/daily-work-reports/${workId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
console.log('✅ 수정 성공 (통합 API):', result);
|
||||
showMessage('✅ 작업이 성공적으로 수정되었습니다!', 'success');
|
||||
|
||||
closeEditModal();
|
||||
closeWorkerDetailModal();
|
||||
|
||||
// 데이터 새로고침
|
||||
await loadDashboardData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 수정 실패:', error);
|
||||
showMessage('수정 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 작업 항목 삭제 함수 (통합 API 사용)
|
||||
async function deleteWorkItem(workId) {
|
||||
if (!confirm('정말로 이 작업을 삭제하시겠습니까?\n삭제된 작업은 복구할 수 없습니다.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('삭제할 작업 ID:', workId);
|
||||
|
||||
showMessage('작업을 삭제하는 중... (통합 API)', 'loading');
|
||||
|
||||
// 개별 항목 삭제 API 호출 - 통합 API 사용
|
||||
const result = await apiCall(`${API}/daily-work-reports/${workId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
console.log('✅ 삭제 성공 (통합 API):', result);
|
||||
showMessage('✅ 작업이 성공적으로 삭제되었습니다!', 'success');
|
||||
|
||||
closeWorkerDetailModal();
|
||||
|
||||
// 데이터 새로고침
|
||||
await loadDashboardData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 삭제 실패:', error);
|
||||
showMessage('삭제 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 작업자 상세 모달 닫기
|
||||
function closeWorkerDetailModal() {
|
||||
document.getElementById('workerDetailModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// 필터링 설정
|
||||
function setupFiltering() {
|
||||
const showOnlyMissingCheckbox = document.getElementById('showOnlyMissing');
|
||||
|
||||
showOnlyMissingCheckbox.addEventListener('change', (e) => {
|
||||
if (e.target.checked) {
|
||||
// 미입력자만 필터링
|
||||
const missingWorkers = filteredWorkData.filter(worker => worker.status === 'missing');
|
||||
displayWorkers(missingWorkers);
|
||||
} else {
|
||||
// 전체 표시
|
||||
displayWorkers(filteredWorkData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 엑셀 다운로드 (개선된 버전)
|
||||
function exportToExcel() {
|
||||
try {
|
||||
// CSV 형태로 데이터 구성 (개선된 버전)
|
||||
let csvContent = "작업자명,상태,총시간,작업항목수,작업유형,프로젝트,기여자,최근업데이트\n";
|
||||
|
||||
filteredWorkData.forEach(worker => {
|
||||
const statusText = {
|
||||
completed: '완료',
|
||||
missing: '미입력',
|
||||
partial: '부분입력'
|
||||
};
|
||||
|
||||
const workTypes = worker.workTypes && worker.workTypes.length > 0 ? worker.workTypes.join('; ') : '없음';
|
||||
const projects = worker.projects && worker.projects.length > 0 ? worker.projects.join('; ') : '없음';
|
||||
const contributors = worker.contributors && worker.contributors.length > 0 ? worker.contributors.join('; ') : '없음';
|
||||
const lastUpdate = worker.lastUpdate ? formatDateTime(worker.lastUpdate) : '없음';
|
||||
|
||||
csvContent += `"${worker.worker_name}","${statusText[worker.status]}","${worker.totalHours}","${worker.entryCount}","${workTypes}","${projects}","${contributors}","${lastUpdate}"\n`;
|
||||
});
|
||||
|
||||
// UTF-8 BOM 추가 (한글 깨짐 방지)
|
||||
const BOM = '\uFEFF';
|
||||
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `작업현황_${currentDate}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
showMessage('✅ 엑셀 파일이 다운로드되었습니다!', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('엑셀 다운로드 오류:', error);
|
||||
showMessage('엑셀 다운로드 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 새로고침
|
||||
function refreshData() {
|
||||
loadDashboardData();
|
||||
}
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
function setupEventListeners() {
|
||||
document.getElementById('loadDataBtn').addEventListener('click', loadDashboardData);
|
||||
document.getElementById('refreshBtn').addEventListener('click', refreshData);
|
||||
document.getElementById('exportBtn').addEventListener('click', exportToExcel);
|
||||
|
||||
// 엔터키로 조회
|
||||
document.getElementById('selectedDate').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
loadDashboardData();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 초기화
|
||||
async function init() {
|
||||
try {
|
||||
// 권한 체크
|
||||
if (!checkPermission()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 권한 체크 메시지 숨기기
|
||||
document.getElementById('permission-check-message').style.display = 'none';
|
||||
|
||||
// 오늘 날짜 설정
|
||||
document.getElementById('selectedDate').value = getKoreaToday();
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
setupEventListeners();
|
||||
|
||||
console.log('✅ 관리자 대시보드 초기화 완료 (통합 API 설정 적용)');
|
||||
|
||||
// 자동으로 오늘 데이터 로드
|
||||
loadDashboardData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
showMessage('초기화 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 권한 체크 메시지 표시
|
||||
document.getElementById('permission-check-message').style.display = 'block';
|
||||
|
||||
// 토큰 확인
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token || token === 'undefined') {
|
||||
showMessage('로그인이 필요합니다.', 'error');
|
||||
localStorage.removeItem('token');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
// 초기화 실행
|
||||
init();
|
||||
});
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.closeWorkerDetailModal = closeWorkerDetailModal;
|
||||
window.refreshData = refreshData;
|
||||
window.showWorkerDetailSafe = showWorkerDetailSafe;
|
||||
window.showWorkerDetail = showWorkerDetail;
|
||||
window.editWorkItem = editWorkItem;
|
||||
window.deleteWorkItem = deleteWorkItem;
|
||||
window.closeEditModal = closeEditModal;
|
||||
window.saveEditedWork = saveEditedWork;
|
||||
1293
synology_deployment/web-ui/js/modern-dashboard.js
Normal file
1293
synology_deployment/web-ui/js/modern-dashboard.js
Normal file
File diff suppressed because it is too large
Load Diff
122
synology_deployment/web-ui/js/my-profile.js
Normal file
122
synology_deployment/web-ui/js/my-profile.js
Normal file
@@ -0,0 +1,122 @@
|
||||
// js/my-profile.js
|
||||
// 내 프로필 페이지 JavaScript
|
||||
|
||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
||||
|
||||
// 인증 확인
|
||||
const token = ensureAuthenticated();
|
||||
|
||||
// 권한 레벨 한글 매핑
|
||||
const accessLevelMap = {
|
||||
worker: '작업자',
|
||||
group_leader: '그룹장',
|
||||
support_team: '지원팀',
|
||||
admin: '관리자',
|
||||
system: '시스템 관리자'
|
||||
};
|
||||
|
||||
// 프로필 데이터 로드
|
||||
async function loadProfile() {
|
||||
try {
|
||||
// 먼저 로컬 스토리지에서 기본 정보 표시
|
||||
const storedUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
if (storedUser) {
|
||||
updateProfileUI(storedUser);
|
||||
}
|
||||
|
||||
// API에서 최신 정보 가져오기
|
||||
const res = await fetch(`${API}/auth/me`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const userData = await res.json();
|
||||
|
||||
// 로컬 스토리지 업데이트
|
||||
const updatedUser = {
|
||||
...storedUser,
|
||||
...userData
|
||||
};
|
||||
localStorage.setItem('user', JSON.stringify(updatedUser));
|
||||
|
||||
// UI 업데이트
|
||||
updateProfileUI(userData);
|
||||
|
||||
} catch (error) {
|
||||
console.error('프로필 로딩 실패:', error);
|
||||
showError('프로필 정보를 불러오는데 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 프로필 UI 업데이트
|
||||
function updateProfileUI(user) {
|
||||
// 헤더 정보
|
||||
const avatar = document.getElementById('profileAvatar');
|
||||
if (avatar && user.name) {
|
||||
// 이름의 첫 글자를 아바타로 사용
|
||||
const initial = user.name.charAt(0).toUpperCase();
|
||||
if (initial.match(/[A-Z가-힣]/)) {
|
||||
avatar.textContent = initial;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('profileName').textContent = user.name || user.username || '사용자';
|
||||
document.getElementById('profileRole').textContent = accessLevelMap[user.access_level] || user.access_level || '역할 미지정';
|
||||
|
||||
// 기본 정보
|
||||
document.getElementById('userId').textContent = user.user_id || '-';
|
||||
document.getElementById('username').textContent = user.username || '-';
|
||||
document.getElementById('fullName').textContent = user.name || '-';
|
||||
document.getElementById('accessLevel').textContent = accessLevelMap[user.access_level] || user.access_level || '-';
|
||||
document.getElementById('workerId').textContent = user.worker_id || '연결되지 않음';
|
||||
|
||||
// 날짜 포맷팅
|
||||
if (user.created_at) {
|
||||
const createdDate = new Date(user.created_at);
|
||||
document.getElementById('createdAt').textContent = formatDate(createdDate);
|
||||
}
|
||||
|
||||
if (user.last_login_at) {
|
||||
const lastLoginDate = new Date(user.last_login_at);
|
||||
document.getElementById('lastLogin').textContent = formatDateTime(lastLoginDate);
|
||||
} else {
|
||||
document.getElementById('lastLogin').textContent = '첫 로그인';
|
||||
}
|
||||
|
||||
// 이메일
|
||||
document.getElementById('email').textContent = user.email || '등록되지 않음';
|
||||
}
|
||||
|
||||
// 날짜 포맷팅 함수
|
||||
function formatDate(date) {
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateTime(date) {
|
||||
return date.toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// 에러 표시
|
||||
function showError(message) {
|
||||
// 간단한 알림으로 처리
|
||||
alert('❌ ' + message);
|
||||
}
|
||||
|
||||
// 페이지 로드 시 실행
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('👤 프로필 페이지 로드됨');
|
||||
loadProfile();
|
||||
});
|
||||
37
synology_deployment/web-ui/js/project-analysis-api.js
Normal file
37
synology_deployment/web-ui/js/project-analysis-api.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// /js/project-analysis-api.js
|
||||
import { apiGet } from './api-helper.js';
|
||||
|
||||
/**
|
||||
* 분석 페이지에 필요한 모든 초기 데이터(마스터 데이터)를 병렬로 가져옵니다.
|
||||
* 이 데이터는 필터 옵션을 채우는 데 사용됩니다.
|
||||
* @returns {Promise<{workers: Array, projects: Array, tasks: Array}>}
|
||||
*/
|
||||
export async function getMasterData() {
|
||||
try {
|
||||
const [workers, projects, tasks] = await Promise.all([
|
||||
apiGet('/workers'),
|
||||
apiGet('/projects'),
|
||||
apiGet('/tasks')
|
||||
]);
|
||||
return { workers, projects, tasks };
|
||||
} catch (error) {
|
||||
console.error('마스터 데이터 로딩 실패:', error);
|
||||
throw new Error('필터링에 필요한 데이터를 불러오는 데 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 기간의 모든 분석 데이터를 백엔드에서 직접 가져옵니다.
|
||||
* @param {string} startDate - 시작일 (YYYY-MM-DD)
|
||||
* @param {string} endDate - 종료일 (YYYY-MM-DD)
|
||||
* @returns {Promise<object>} - 요약, 집계, 상세 데이터가 모두 포함된 분석 결과 객체
|
||||
*/
|
||||
export async function getAnalysisReport(startDate, endDate) {
|
||||
try {
|
||||
const analysisData = await apiGet(`/analysis?startDate=${startDate}&endDate=${endDate}`);
|
||||
return analysisData;
|
||||
} catch (error) {
|
||||
console.error('분석 보고서 데이터 로딩 실패:', error);
|
||||
throw new Error(`분석 데이터를 불러오는 데 실패했습니다: ${error.message}`);
|
||||
}
|
||||
}
|
||||
170
synology_deployment/web-ui/js/project-analysis-ui.js
Normal file
170
synology_deployment/web-ui/js/project-analysis-ui.js
Normal file
@@ -0,0 +1,170 @@
|
||||
// /js/project-analysis-ui.js
|
||||
|
||||
const DOM = {
|
||||
// 기간 설정
|
||||
startDate: document.getElementById('startDate'),
|
||||
endDate: document.getElementById('endDate'),
|
||||
// 카드 및 필터
|
||||
analysisCard: document.getElementById('analysisCard'),
|
||||
summaryCards: document.getElementById('summaryCards'),
|
||||
projectFilter: document.getElementById('projectFilter'),
|
||||
workerFilter: document.getElementById('workerFilter'),
|
||||
taskFilter: document.getElementById('taskFilter'),
|
||||
// 탭
|
||||
tabButtons: document.querySelectorAll('.tab-button'),
|
||||
tabContents: document.querySelectorAll('.analysis-content'),
|
||||
// 테이블 본문
|
||||
projectTableBody: document.getElementById('projectTableBody'),
|
||||
workerTableBody: document.getElementById('workerTableBody'),
|
||||
taskTableBody: document.getElementById('taskTableBody'),
|
||||
detailTableBody: document.getElementById('detailTableBody'),
|
||||
};
|
||||
|
||||
/**
|
||||
* 날짜 input 값을 YYYY-MM-DD 형식의 문자열로 반환
|
||||
* @param {Date} date - 날짜 객체
|
||||
* @returns {string} - 포맷된 날짜 문자열
|
||||
*/
|
||||
const formatDate = (date) => date.toISOString().split('T')[0];
|
||||
|
||||
/**
|
||||
* UI상의 날짜 선택기를 기본값(이번 달)으로 설정합니다.
|
||||
*/
|
||||
export function setDefaultDates() {
|
||||
const now = new Date();
|
||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
DOM.startDate.value = formatDate(firstDay);
|
||||
DOM.endDate.value = formatDate(lastDay);
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석 실행 전후의 UI 상태를 관리합니다 (로딩 표시 등)
|
||||
* @param {'loading' | 'data' | 'no-data' | 'error'} state - UI 상태
|
||||
*/
|
||||
export function setUIState(state) {
|
||||
const projectCols = 5;
|
||||
const detailCols = 8;
|
||||
const messages = {
|
||||
loading: '📊 데이터 분석 중...',
|
||||
'no-data': '해당 기간에 분석할 데이터가 없습니다.',
|
||||
error: '오류가 발생했습니다. 다시 시도해주세요.',
|
||||
};
|
||||
|
||||
if (state === 'data') {
|
||||
DOM.analysisCard.style.display = 'block';
|
||||
} else {
|
||||
const message = messages[state];
|
||||
const html = `<tr><td colspan="${projectCols}" class="${state}">${message}</td></tr>`;
|
||||
const detailHtml = `<tr><td colspan="${detailCols}" class="${state}">${message}</td></tr>`;
|
||||
DOM.projectTableBody.innerHTML = html;
|
||||
DOM.workerTableBody.innerHTML = html;
|
||||
DOM.taskTableBody.innerHTML = html;
|
||||
DOM.detailTableBody.innerHTML = detailHtml;
|
||||
DOM.summaryCards.innerHTML = '';
|
||||
DOM.analysisCard.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 마스터 데이터를 기반으로 필터 옵션을 채웁니다.
|
||||
* @param {{workers: Array, projects: Array, tasks: Array}} masterData - 마스터 데이터
|
||||
*/
|
||||
export function updateFilterOptions(masterData) {
|
||||
const createOptions = (items, key, value) => {
|
||||
let html = '<option value="">전체</option>';
|
||||
items.forEach(item => {
|
||||
html += `<option value="${item[key]}">${item[value]}</option>`;
|
||||
});
|
||||
return html;
|
||||
};
|
||||
DOM.projectFilter.innerHTML = createOptions(masterData.projects, 'project_id', 'project_name');
|
||||
DOM.workerFilter.innerHTML = createOptions(masterData.workers, 'worker_id', 'worker_name');
|
||||
DOM.taskFilter.innerHTML = createOptions(masterData.tasks, 'task_id', 'category');
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 카드 데이터를 렌더링합니다.
|
||||
* @param {object} summary - 요약 데이터
|
||||
*/
|
||||
export function renderSummary(summary) {
|
||||
DOM.summaryCards.innerHTML = `
|
||||
<div class="summary-card"><h4>총 투입 시간</h4><div class="value">${(summary.totalHours || 0).toFixed(1)}h</div></div>
|
||||
<div class="summary-card"><h4>참여 프로젝트</h4><div class="value">${summary.totalProjects || 0}개</div></div>
|
||||
<div class="summary-card"><h4>참여 인원</h4><div class="value">${summary.totalWorkers || 0}명</div></div>
|
||||
<div class="summary-card"><h4>작업 분류</h4><div class="value">${summary.totalTasks || 0}개</div></div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계된 데이터를 받아 테이블을 렌더링하는 범용 함수
|
||||
* @param {HTMLElement} tableBodyEl - 렌더링할 테이블의 tbody 요소
|
||||
* @param {Array} data - 집계된 데이터 배열
|
||||
* @param {function} rowRenderer - 각 행을 렌더링하는 함수
|
||||
*/
|
||||
function renderTable(tableBodyEl, data, rowRenderer) {
|
||||
if (!data || data.length === 0) {
|
||||
tableBodyEl.innerHTML = '<tr><td colspan="5" class="no-data">데이터가 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
tableBodyEl.innerHTML = data.map(rowRenderer).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계된 데이터를 기반으로 모든 분석 테이블을 렌더링합니다.
|
||||
* @param {object} analysis - 프로젝트/작업자/작업별 집계 데이터
|
||||
*/
|
||||
export function renderAnalysisTables(analysis) {
|
||||
renderTable(DOM.projectTableBody, analysis.byProject, (p, i) => `
|
||||
<tr><td>${i + 1}</td><td class="project-col" title="${p.name}">${p.name}</td><td class="hours-col">${p.hours}h</td>
|
||||
<td>${p.percentage}%</td><td>${p.participants}명</td></tr>`);
|
||||
|
||||
renderTable(DOM.workerTableBody, analysis.byWorker, (w, i) => `
|
||||
<tr><td>${i + 1}</td><td class="worker-col">${w.name}</td><td class="hours-col">${w.hours}h</td>
|
||||
<td>${w.percentage}%</td><td>${w.participants}개</td></tr>`);
|
||||
|
||||
renderTable(DOM.taskTableBody, analysis.byTask, (t, i) => `
|
||||
<tr><td>${i + 1}</td><td class="task-col" title="${t.name}">${t.name}</td><td class="hours-col">${t.hours}h</td>
|
||||
<td>${t.percentage}%</td><td>${t.participants}명</td></tr>`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 내역 테이블을 렌더링합니다.
|
||||
* @param {Array} detailData - 필터링된 상세 데이터
|
||||
*/
|
||||
export function renderDetailTable(detailData) {
|
||||
if (!detailData || detailData.length === 0) {
|
||||
DOM.detailTableBody.innerHTML = '<tr><td colspan="8" class="no-data">데이터가 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
DOM.detailTableBody.innerHTML = detailData.map((item, index) => `
|
||||
<tr><td>${index + 1}</td><td>${formatDate(new Date(item.date))}</td>
|
||||
<td class="project-col" title="${item.project_name}">${item.project_name}</td>
|
||||
<td class="worker-col">${item.worker_name}</td><td class="task-col" title="${item.task_category}">${item.task_category}</td>
|
||||
<td>${item.work_details || '정상근무'}</td>
|
||||
<td class="hours-col">${item.work_hours}h</td>
|
||||
<td title="${item.memo || '-'}">${(item.memo || '-').substring(0, 20)}</td></tr>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 탭 UI를 제어합니다.
|
||||
* @param {string} tabName - 활성화할 탭의 이름
|
||||
*/
|
||||
export function switchTab(tabName) {
|
||||
DOM.tabButtons.forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tabName));
|
||||
DOM.tabContents.forEach(content => content.classList.toggle('active', content.id === `${tabName}Tab`));
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자로부터 현재 필터 값을 가져옵니다.
|
||||
* @returns {{project: string, worker: string, task: string}}
|
||||
*/
|
||||
export function getCurrentFilters() {
|
||||
return {
|
||||
project: DOM.projectFilter.value,
|
||||
worker: DOM.workerFilter.value,
|
||||
task: DOM.taskFilter.value,
|
||||
};
|
||||
}
|
||||
106
synology_deployment/web-ui/js/project-analysis.js
Normal file
106
synology_deployment/web-ui/js/project-analysis.js
Normal file
@@ -0,0 +1,106 @@
|
||||
// /js/project-analysis.js
|
||||
import { getMasterData, getAnalysisReport } from './project-analysis-api.js';
|
||||
import {
|
||||
setDefaultDates,
|
||||
setUIState,
|
||||
updateFilterOptions,
|
||||
renderSummary,
|
||||
renderAnalysisTables,
|
||||
renderDetailTable,
|
||||
switchTab,
|
||||
} from './project-analysis-ui.js';
|
||||
|
||||
// DOM 요소 참조 (이벤트 리스너 설정용)
|
||||
const DOM = {
|
||||
startDate: document.getElementById('startDate'),
|
||||
endDate: document.getElementById('endDate'),
|
||||
analyzeBtn: document.getElementById('analyzeBtn'),
|
||||
quickMonthBtn: document.getElementById('quickMonth'),
|
||||
quickLastMonthBtn: document.getElementById('quickLastMonth'),
|
||||
// 필터 버튼은 현재 아무 기능도 하지 않으므로 주석 처리 또는 제거 가능
|
||||
// applyFilterBtn: document.getElementById('applyFilter'),
|
||||
tabButtons: document.querySelectorAll('.tab-button'),
|
||||
};
|
||||
|
||||
/**
|
||||
* 분석 실행 버튼 클릭 이벤트 핸들러
|
||||
*/
|
||||
async function handleAnalysis() {
|
||||
const startDate = DOM.startDate.value;
|
||||
const endDate = DOM.endDate.value;
|
||||
|
||||
if (!startDate || !endDate || startDate > endDate) {
|
||||
alert('올바른 분석 기간을 설정해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUIState('loading');
|
||||
try {
|
||||
const analysisResult = await getAnalysisReport(startDate, endDate);
|
||||
|
||||
if (!analysisResult.summary.totalHours) {
|
||||
setUIState('no-data');
|
||||
return;
|
||||
}
|
||||
|
||||
renderSummary(analysisResult.summary);
|
||||
renderAnalysisTables(analysisResult);
|
||||
renderDetailTable(analysisResult.details);
|
||||
setUIState('data');
|
||||
|
||||
} catch (error) {
|
||||
console.error('분석 처리 중 오류:', error);
|
||||
setUIState('error');
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 빠른 날짜 설정 버튼 핸들러
|
||||
*/
|
||||
function handleQuickDate(monthType) {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
const firstDay = monthType === 'this' ? new Date(year, month, 1) : new Date(year, month - 1, 1);
|
||||
const lastDay = monthType === 'this' ? new Date(year, month + 1, 0) : new Date(year, month, 0);
|
||||
|
||||
DOM.startDate.value = firstDay.toISOString().split('T')[0];
|
||||
DOM.endDate.value = lastDay.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 설정
|
||||
*/
|
||||
function setupEventListeners() {
|
||||
DOM.analyzeBtn.addEventListener('click', handleAnalysis);
|
||||
DOM.quickMonthBtn.addEventListener('click', () => handleQuickDate('this'));
|
||||
DOM.quickLastMonthBtn.addEventListener('click', () => handleQuickDate('last'));
|
||||
|
||||
DOM.tabButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
|
||||
});
|
||||
|
||||
// 프론트엔드 필터링은 제거되었으므로 관련 이벤트 리스너는 주석 처리합니다.
|
||||
// DOM.applyFilterBtn.addEventListener('click', ...);
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 초기화 함수
|
||||
*/
|
||||
async function initialize() {
|
||||
setDefaultDates();
|
||||
setupEventListeners();
|
||||
|
||||
try {
|
||||
const masterData = await getMasterData();
|
||||
updateFilterOptions(masterData);
|
||||
await handleAnalysis();
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
setUIState('error');
|
||||
}
|
||||
}
|
||||
|
||||
// 초기화 실행
|
||||
document.addEventListener('DOMContentLoaded', initialize);
|
||||
634
synology_deployment/web-ui/js/project-management.js
Normal file
634
synology_deployment/web-ui/js/project-management.js
Normal file
@@ -0,0 +1,634 @@
|
||||
// 프로젝트 관리 페이지 JavaScript
|
||||
|
||||
// 전역 변수
|
||||
let allProjects = [];
|
||||
let filteredProjects = [];
|
||||
let currentEditingProject = null;
|
||||
let currentStatusFilter = 'all'; // 'all', 'active', 'inactive'
|
||||
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('📁 프로젝트 관리 페이지 초기화 시작');
|
||||
|
||||
initializePage();
|
||||
loadProjects();
|
||||
});
|
||||
|
||||
// 페이지 초기화
|
||||
function initializePage() {
|
||||
// 시간 업데이트 시작
|
||||
updateCurrentTime();
|
||||
setInterval(updateCurrentTime, 1000);
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
updateUserInfo();
|
||||
|
||||
// 프로필 메뉴 토글
|
||||
setupProfileMenu();
|
||||
|
||||
// 로그아웃 버튼
|
||||
setupLogoutButton();
|
||||
|
||||
// 검색 입력 이벤트
|
||||
setupSearchInput();
|
||||
}
|
||||
|
||||
// 현재 시간 업데이트
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
function updateUserInfo() {
|
||||
let userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
|
||||
let authUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
|
||||
const finalUserInfo = {
|
||||
worker_name: userInfo.worker_name || authUser.username || authUser.worker_name,
|
||||
job_type: userInfo.job_type || authUser.role || authUser.job_type,
|
||||
username: authUser.username || userInfo.username
|
||||
};
|
||||
|
||||
const userNameElement = document.getElementById('userName');
|
||||
const userRoleElement = document.getElementById('userRole');
|
||||
const userInitialElement = document.getElementById('userInitial');
|
||||
|
||||
if (userNameElement) {
|
||||
userNameElement.textContent = finalUserInfo.worker_name || '사용자';
|
||||
}
|
||||
|
||||
if (userRoleElement) {
|
||||
const roleMap = {
|
||||
'leader': '그룹장',
|
||||
'worker': '작업자',
|
||||
'admin': '관리자',
|
||||
'system': '시스템 관리자'
|
||||
};
|
||||
userRoleElement.textContent = roleMap[finalUserInfo.job_type] || finalUserInfo.job_type || '작업자';
|
||||
}
|
||||
|
||||
if (userInitialElement) {
|
||||
const name = finalUserInfo.worker_name || '사용자';
|
||||
userInitialElement.textContent = name.charAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 프로필 메뉴 설정
|
||||
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';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 입력 설정
|
||||
function setupSearchInput() {
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function() {
|
||||
searchProjects();
|
||||
});
|
||||
|
||||
searchInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
searchProjects();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 목록 로드
|
||||
async function loadProjects() {
|
||||
try {
|
||||
console.log('📊 프로젝트 목록 로딩 시작');
|
||||
|
||||
const response = await apiCall('/projects', 'GET');
|
||||
|
||||
console.log('📊 API 응답 구조:', response);
|
||||
|
||||
// API 응답이 { success: true, data: [...] } 형태인 경우 처리
|
||||
let projectData = [];
|
||||
if (response && response.success && Array.isArray(response.data)) {
|
||||
projectData = response.data;
|
||||
} else if (Array.isArray(response)) {
|
||||
projectData = response;
|
||||
} else {
|
||||
console.warn('프로젝트 데이터가 배열이 아닙니다:', response);
|
||||
projectData = [];
|
||||
}
|
||||
|
||||
allProjects = projectData;
|
||||
|
||||
console.log(`✅ 프로젝트 ${allProjects.length}개 로드 완료`);
|
||||
|
||||
// 초기 필터 적용
|
||||
applyAllFilters();
|
||||
updateStatCardActiveState();
|
||||
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로딩 오류:', error);
|
||||
showToast('프로젝트 목록을 불러오는데 실패했습니다.', 'error');
|
||||
allProjects = [];
|
||||
filteredProjects = [];
|
||||
renderProjects();
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 목록 렌더링
|
||||
function renderProjects() {
|
||||
const projectsGrid = document.getElementById('projectsGrid');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
|
||||
if (!projectsGrid || !emptyState) return;
|
||||
|
||||
if (filteredProjects.length === 0) {
|
||||
projectsGrid.style.display = 'none';
|
||||
emptyState.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
projectsGrid.style.display = 'grid';
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
const projectsHtml = filteredProjects.map(project => {
|
||||
// 프로젝트 상태 아이콘 및 텍스트
|
||||
const statusMap = {
|
||||
'planning': { icon: '📋', text: '계획', color: '#6b7280' },
|
||||
'active': { icon: '🚀', text: '진행중', color: '#10b981' },
|
||||
'completed': { icon: '✅', text: '완료', color: '#3b82f6' },
|
||||
'cancelled': { icon: '❌', text: '취소', color: '#ef4444' }
|
||||
};
|
||||
|
||||
const status = statusMap[project.project_status] || statusMap['active'];
|
||||
// is_active 값 처리 (DB에서 0/1로 오는 경우 대비)
|
||||
const isInactive = project.is_active === 0 || project.is_active === false || project.is_active === 'false';
|
||||
|
||||
console.log('🎨 카드 렌더링:', {
|
||||
project_id: project.project_id,
|
||||
project_name: project.project_name,
|
||||
is_active_raw: project.is_active,
|
||||
isInactive: isInactive
|
||||
});
|
||||
|
||||
return `
|
||||
<div class="project-card ${isInactive ? 'inactive' : ''}" onclick="editProject(${project.project_id})">
|
||||
${isInactive ? '<div class="inactive-overlay"><span class="inactive-badge">🚫 비활성화됨</span></div>' : ''}
|
||||
<div class="project-header">
|
||||
<div class="project-info">
|
||||
<div class="project-job-no">${project.job_no || 'Job No. 없음'}</div>
|
||||
<h3 class="project-name">
|
||||
${project.project_name}
|
||||
${isInactive ? '<span class="inactive-label">(비활성)</span>' : ''}
|
||||
</h3>
|
||||
<div class="project-meta">
|
||||
<span style="color: ${status.color}; font-weight: 500;">${status.icon} ${status.text}</span>
|
||||
${project.contract_date ? `<span>📅 계약일: ${formatDate(project.contract_date)}</span>` : ''}
|
||||
${project.due_date ? `<span>⏰ 납기일: ${formatDate(project.due_date)}</span>` : ''}
|
||||
${project.completed_date ? `<span>🎯 완료일: ${formatDate(project.completed_date)}</span>` : ''}
|
||||
${project.pm ? `<span>👤 PM: ${project.pm}</span>` : ''}
|
||||
${project.site ? `<span>📍 현장: ${project.site}</span>` : ''}
|
||||
${isInactive ? '<span class="inactive-notice">⚠️ 작업보고서에서 숨김</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-actions">
|
||||
<button class="btn-edit" onclick="event.stopPropagation(); editProject(${project.project_id})" title="수정">
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn-delete" onclick="event.stopPropagation(); confirmDeleteProject(${project.project_id})" title="삭제">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
projectsGrid.innerHTML = projectsHtml;
|
||||
}
|
||||
|
||||
// 프로젝트 통계 업데이트
|
||||
function updateProjectStats() {
|
||||
const activeProjects = filteredProjects.filter(p => p.is_active === 1 || p.is_active === true);
|
||||
const inactiveProjects = filteredProjects.filter(p => p.is_active === 0 || p.is_active === false);
|
||||
|
||||
const activeProjectsElement = document.getElementById('activeProjects');
|
||||
const inactiveProjectsElement = document.getElementById('inactiveProjects');
|
||||
const totalProjectsElement = document.getElementById('totalProjects');
|
||||
|
||||
if (activeProjectsElement) {
|
||||
activeProjectsElement.textContent = activeProjects.length;
|
||||
}
|
||||
|
||||
if (inactiveProjectsElement) {
|
||||
inactiveProjectsElement.textContent = inactiveProjects.length;
|
||||
}
|
||||
|
||||
if (totalProjectsElement) {
|
||||
totalProjectsElement.textContent = filteredProjects.length;
|
||||
}
|
||||
|
||||
console.log('📊 프로젝트 통계:', {
|
||||
전체: filteredProjects.length,
|
||||
활성: activeProjects.length,
|
||||
비활성: inactiveProjects.length
|
||||
});
|
||||
}
|
||||
|
||||
// 날짜 포맷팅
|
||||
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 filterByStatus(status) {
|
||||
currentStatusFilter = status;
|
||||
|
||||
// 통계 카드 활성화 상태 업데이트
|
||||
updateStatCardActiveState();
|
||||
|
||||
// 필터링 적용
|
||||
applyAllFilters();
|
||||
|
||||
console.log(`🔍 상태 필터 적용: ${status}`);
|
||||
}
|
||||
|
||||
// 통계 카드 활성화 상태 업데이트
|
||||
function updateStatCardActiveState() {
|
||||
// 모든 통계 카드에서 active 클래스 제거
|
||||
document.querySelectorAll('.stat-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
|
||||
// 현재 선택된 필터에 active 클래스 추가
|
||||
const activeCard = document.querySelector(`.${currentStatusFilter === 'active' ? 'active-stat' :
|
||||
currentStatusFilter === 'inactive' ? 'inactive-stat' : 'total-stat'}`);
|
||||
if (activeCard) {
|
||||
activeCard.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 필터 적용 (검색 + 상태)
|
||||
function applyAllFilters() {
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const searchTerm = searchInput ? searchInput.value.toLowerCase().trim() : '';
|
||||
|
||||
// 1단계: 상태 필터링
|
||||
let statusFiltered = [...allProjects];
|
||||
if (currentStatusFilter === 'active') {
|
||||
statusFiltered = allProjects.filter(p => p.is_active === 1 || p.is_active === true);
|
||||
} else if (currentStatusFilter === 'inactive') {
|
||||
statusFiltered = allProjects.filter(p => p.is_active === 0 || p.is_active === false);
|
||||
}
|
||||
|
||||
// 2단계: 검색 필터링
|
||||
if (!searchTerm) {
|
||||
filteredProjects = statusFiltered;
|
||||
} else {
|
||||
filteredProjects = statusFiltered.filter(project =>
|
||||
project.project_name.toLowerCase().includes(searchTerm) ||
|
||||
(project.job_no && project.job_no.toLowerCase().includes(searchTerm)) ||
|
||||
(project.pm && project.pm.toLowerCase().includes(searchTerm)) ||
|
||||
(project.site && project.site.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
}
|
||||
|
||||
renderProjects();
|
||||
updateProjectStats();
|
||||
}
|
||||
|
||||
// 프로젝트 검색 (기존 함수 수정)
|
||||
function searchProjects() {
|
||||
applyAllFilters();
|
||||
}
|
||||
|
||||
// 프로젝트 필터링
|
||||
function filterProjects() {
|
||||
const statusFilter = document.getElementById('statusFilter');
|
||||
const selectedStatus = statusFilter ? statusFilter.value : '';
|
||||
|
||||
// 현재는 상태 필드가 없으므로 기본 필터링만 적용
|
||||
searchProjects();
|
||||
}
|
||||
|
||||
// 프로젝트 정렬
|
||||
function sortProjects() {
|
||||
const sortBy = document.getElementById('sortBy');
|
||||
const sortField = sortBy ? sortBy.value : 'created_at';
|
||||
|
||||
filteredProjects.sort((a, b) => {
|
||||
switch (sortField) {
|
||||
case 'project_name':
|
||||
return a.project_name.localeCompare(b.project_name);
|
||||
case 'due_date':
|
||||
if (!a.due_date && !b.due_date) return 0;
|
||||
if (!a.due_date) return 1;
|
||||
if (!b.due_date) return -1;
|
||||
return new Date(a.due_date) - new Date(b.due_date);
|
||||
case 'created_at':
|
||||
default:
|
||||
return new Date(b.created_at || 0) - new Date(a.created_at || 0);
|
||||
}
|
||||
});
|
||||
|
||||
renderProjects();
|
||||
}
|
||||
|
||||
// 프로젝트 목록 새로고침
|
||||
async function refreshProjectList() {
|
||||
const refreshBtn = document.querySelector('.btn-secondary');
|
||||
if (refreshBtn) {
|
||||
const originalText = refreshBtn.innerHTML;
|
||||
refreshBtn.innerHTML = '<span class="btn-icon">⏳</span>새로고침 중...';
|
||||
refreshBtn.disabled = true;
|
||||
|
||||
await loadProjects();
|
||||
|
||||
refreshBtn.innerHTML = originalText;
|
||||
refreshBtn.disabled = false;
|
||||
} else {
|
||||
await loadProjects();
|
||||
}
|
||||
|
||||
showToast('프로젝트 목록이 새로고침되었습니다.', 'success');
|
||||
}
|
||||
|
||||
// 프로젝트 모달 열기
|
||||
function openProjectModal(project = null) {
|
||||
const modal = document.getElementById('projectModal');
|
||||
const modalTitle = document.getElementById('modalTitle');
|
||||
const deleteBtn = document.getElementById('deleteProjectBtn');
|
||||
|
||||
if (!modal) return;
|
||||
|
||||
currentEditingProject = project;
|
||||
|
||||
if (project) {
|
||||
// 수정 모드
|
||||
modalTitle.textContent = '프로젝트 수정';
|
||||
deleteBtn.style.display = 'inline-flex';
|
||||
|
||||
// 폼에 데이터 채우기
|
||||
document.getElementById('projectId').value = project.project_id;
|
||||
document.getElementById('jobNo').value = project.job_no || '';
|
||||
document.getElementById('projectName').value = project.project_name || '';
|
||||
document.getElementById('contractDate').value = project.contract_date || '';
|
||||
document.getElementById('dueDate').value = project.due_date || '';
|
||||
document.getElementById('deliveryMethod').value = project.delivery_method || '';
|
||||
document.getElementById('site').value = project.site || '';
|
||||
document.getElementById('pm').value = project.pm || '';
|
||||
document.getElementById('projectStatus').value = project.project_status || 'active';
|
||||
document.getElementById('completedDate').value = project.completed_date || '';
|
||||
// is_active 값 처리 (DB에서 0/1로 오는 경우 대비)
|
||||
const isActiveValue = project.is_active === 1 || project.is_active === true || project.is_active === 'true';
|
||||
document.getElementById('isActive').checked = isActiveValue;
|
||||
|
||||
console.log('🔧 프로젝트 로드:', {
|
||||
project_id: project.project_id,
|
||||
project_name: project.project_name,
|
||||
is_active_raw: project.is_active,
|
||||
is_active_processed: isActiveValue
|
||||
});
|
||||
} else {
|
||||
// 신규 등록 모드
|
||||
modalTitle.textContent = '새 프로젝트 등록';
|
||||
deleteBtn.style.display = 'none';
|
||||
|
||||
// 폼 초기화
|
||||
document.getElementById('projectForm').reset();
|
||||
document.getElementById('projectId').value = '';
|
||||
}
|
||||
|
||||
modal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// 첫 번째 입력 필드에 포커스
|
||||
setTimeout(() => {
|
||||
const firstInput = document.getElementById('jobNo');
|
||||
if (firstInput) firstInput.focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// 프로젝트 모달 닫기
|
||||
function closeProjectModal() {
|
||||
const modal = document.getElementById('projectModal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
currentEditingProject = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 편집
|
||||
function editProject(projectId) {
|
||||
const project = allProjects.find(p => p.project_id === projectId);
|
||||
if (project) {
|
||||
openProjectModal(project);
|
||||
} else {
|
||||
showToast('프로젝트를 찾을 수 없습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 저장
|
||||
async function saveProject() {
|
||||
try {
|
||||
const form = document.getElementById('projectForm');
|
||||
const formData = new FormData(form);
|
||||
|
||||
const projectData = {
|
||||
job_no: document.getElementById('jobNo').value.trim(),
|
||||
project_name: document.getElementById('projectName').value.trim(),
|
||||
contract_date: document.getElementById('contractDate').value || null,
|
||||
due_date: document.getElementById('dueDate').value || null,
|
||||
delivery_method: document.getElementById('deliveryMethod').value || null,
|
||||
site: document.getElementById('site').value.trim() || null,
|
||||
pm: document.getElementById('pm').value.trim() || null,
|
||||
project_status: document.getElementById('projectStatus').value || 'active',
|
||||
completed_date: document.getElementById('completedDate').value || null,
|
||||
is_active: document.getElementById('isActive').checked ? 1 : 0
|
||||
};
|
||||
|
||||
console.log('💾 저장할 프로젝트 데이터:', projectData);
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!projectData.job_no || !projectData.project_name) {
|
||||
showToast('Job No.와 프로젝트명은 필수 입력 항목입니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const projectId = document.getElementById('projectId').value;
|
||||
let response;
|
||||
|
||||
if (projectId) {
|
||||
// 수정
|
||||
response = await apiCall(`/projects/${projectId}`, 'PUT', projectData);
|
||||
} else {
|
||||
// 신규 등록
|
||||
response = await apiCall('/projects', 'POST', projectData);
|
||||
}
|
||||
|
||||
if (response && (response.success || response.project_id)) {
|
||||
const action = projectId ? '수정' : '등록';
|
||||
showToast(`프로젝트가 성공적으로 ${action}되었습니다.`, 'success');
|
||||
|
||||
closeProjectModal();
|
||||
await loadProjects();
|
||||
} else {
|
||||
throw new Error(response?.message || '저장에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('프로젝트 저장 오류:', error);
|
||||
showToast(error.message || '프로젝트 저장 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 삭제 확인
|
||||
function confirmDeleteProject(projectId) {
|
||||
const project = allProjects.find(p => p.project_id === projectId);
|
||||
if (!project) {
|
||||
showToast('프로젝트를 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`"${project.project_name}" 프로젝트를 정말 삭제하시겠습니까?\n\n⚠️ 삭제된 프로젝트는 복구할 수 없습니다.`)) {
|
||||
deleteProjectById(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 삭제 (수정 모드에서)
|
||||
function deleteProject() {
|
||||
if (currentEditingProject) {
|
||||
confirmDeleteProject(currentEditingProject.project_id);
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 삭제 실행
|
||||
async function deleteProjectById(projectId) {
|
||||
try {
|
||||
const response = await apiCall(`/projects/${projectId}`, 'DELETE');
|
||||
|
||||
if (response && response.success) {
|
||||
showToast('프로젝트가 성공적으로 삭제되었습니다.', 'success');
|
||||
|
||||
closeProjectModal();
|
||||
await loadProjects();
|
||||
} else {
|
||||
throw new Error(response?.message || '삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('프로젝트 삭제 오류:', error);
|
||||
showToast(error.message || '프로젝트 삭제 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 토스트 메시지 표시
|
||||
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.openProjectModal = openProjectModal;
|
||||
window.closeProjectModal = closeProjectModal;
|
||||
window.editProject = editProject;
|
||||
window.saveProject = saveProject;
|
||||
window.deleteProject = deleteProject;
|
||||
window.confirmDeleteProject = confirmDeleteProject;
|
||||
window.searchProjects = searchProjects;
|
||||
window.filterProjects = filterProjects;
|
||||
window.sortProjects = sortProjects;
|
||||
window.refreshProjectList = refreshProjectList;
|
||||
window.filterByStatus = filterByStatus;
|
||||
91
synology_deployment/web-ui/js/report-viewer-api.js
Normal file
91
synology_deployment/web-ui/js/report-viewer-api.js
Normal file
@@ -0,0 +1,91 @@
|
||||
// /js/report-viewer-api.js
|
||||
import { apiGet } from './api-helper.js';
|
||||
import { getUser } from './auth.js';
|
||||
|
||||
/**
|
||||
* 보고서 조회를 위한 마스터 데이터를 로드합니다. (작업 유형, 상태 등)
|
||||
* 실패 시 기본값을 반환할 수 있도록 개별적으로 처리합니다.
|
||||
* @returns {Promise<object>} - 각 마스터 데이터 배열을 포함하는 객체
|
||||
*/
|
||||
export async function loadMasterData() {
|
||||
const masterData = {
|
||||
workTypes: [],
|
||||
workStatusTypes: [],
|
||||
errorTypes: []
|
||||
};
|
||||
try {
|
||||
// Promise.allSettled를 사용해 일부 API가 실패해도 전체가 중단되지 않도록 함
|
||||
const results = await Promise.allSettled([
|
||||
apiGet('/daily-work-reports/work-types'),
|
||||
apiGet('/daily-work-reports/work-status-types'),
|
||||
apiGet('/daily-work-reports/error-types')
|
||||
]);
|
||||
|
||||
if (results[0].status === 'fulfilled') masterData.workTypes = results[0].value;
|
||||
if (results[1].status === 'fulfilled') masterData.workStatusTypes = results[1].value;
|
||||
if (results[2].status === 'fulfilled') masterData.errorTypes = results[2].value;
|
||||
|
||||
return masterData;
|
||||
} catch (error) {
|
||||
console.error('마스터 데이터 로딩 중 심각한 오류 발생:', error);
|
||||
// 최소한의 기본값이라도 반환
|
||||
return masterData;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 사용자의 권한을 확인하여 적절한 API 엔드포인트와 파라미터를 결정합니다.
|
||||
* @param {string} selectedDate - 조회할 날짜
|
||||
* @returns {string} - 호출할 API URL
|
||||
*/
|
||||
function getReportApiUrl(selectedDate) {
|
||||
const user = getUser();
|
||||
|
||||
// 관리자(admin, system)는 모든 데이터를 조회
|
||||
if (user && (user.role === 'admin' || user.role === 'system')) {
|
||||
// 백엔드에서 GET /daily-work-reports?date=YYYY-MM-DD 요청 시
|
||||
// 권한을 확인하고 모든 데이터를 내려준다고 가정
|
||||
return `/daily-work-reports?date=${selectedDate}`;
|
||||
}
|
||||
|
||||
// 그 외 사용자(leader, user)는 본인이 생성한 데이터만 조회
|
||||
// 백엔드에서 동일한 엔드포인트로 요청 시, 권한을 확인하고
|
||||
// 본인 데이터만 필터링해서 내려준다고 가정
|
||||
// (만약 엔드포인트가 다르다면 이 부분을 수정해야 함)
|
||||
return `/daily-work-reports?date=${selectedDate}`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 특정 날짜의 작업 보고서 데이터를 서버에서 가져옵니다.
|
||||
* @param {string} selectedDate - 조회할 날짜 (YYYY-MM-DD)
|
||||
* @returns {Promise<Array>} - 작업 보고서 데이터 배열
|
||||
*/
|
||||
export async function fetchReportData(selectedDate) {
|
||||
if (!selectedDate) {
|
||||
throw new Error('조회할 날짜가 선택되지 않았습니다.');
|
||||
}
|
||||
|
||||
const apiUrl = getReportApiUrl(selectedDate);
|
||||
|
||||
try {
|
||||
const rawData = await apiGet(apiUrl);
|
||||
|
||||
// 서버 응답이 { success: true, data: [...] } 형태일 경우와 [...] 형태일 경우 모두 처리
|
||||
if (rawData && rawData.success && Array.isArray(rawData.data)) {
|
||||
return rawData.data;
|
||||
}
|
||||
if (Array.isArray(rawData)) {
|
||||
return rawData;
|
||||
}
|
||||
|
||||
// 예상치 못한 형식의 응답
|
||||
console.warn('예상치 못한 형식의 API 응답:', rawData);
|
||||
return [];
|
||||
|
||||
} catch (error) {
|
||||
console.error(`${selectedDate}의 작업 보고서 조회 실패:`, error);
|
||||
throw new Error('서버에서 데이터를 가져오는 데 실패했습니다.');
|
||||
}
|
||||
}
|
||||
72
synology_deployment/web-ui/js/report-viewer-export.js
Normal file
72
synology_deployment/web-ui/js/report-viewer-export.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// /js/report-viewer-export.js
|
||||
|
||||
/**
|
||||
* 주어진 데이터를 CSV 형식의 문자열로 변환합니다.
|
||||
* @param {object} reportData - 요약 및 작업자별 데이터
|
||||
* @returns {string} - CSV 형식의 문자열
|
||||
*/
|
||||
function convertToCsv(reportData) {
|
||||
let csvContent = "\uFEFF"; // UTF-8 BOM
|
||||
csvContent += "작업자명,프로젝트명,작업유형,작업상태,에러유형,작업시간,입력자\n";
|
||||
|
||||
reportData.workers.forEach(worker => {
|
||||
worker.entries.forEach(entry => {
|
||||
const row = [
|
||||
worker.worker_name,
|
||||
entry.project_name,
|
||||
entry.work_type_name,
|
||||
entry.work_status_name,
|
||||
entry.error_type_name,
|
||||
entry.work_hours,
|
||||
entry.created_by_name
|
||||
].map(field => `"${String(field || '').replace(/"/g, '""')}"`).join(',');
|
||||
csvContent += row + "\n";
|
||||
});
|
||||
});
|
||||
return csvContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 가공된 보고서 데이터를 CSV 파일로 다운로드합니다.
|
||||
* @param {object|null} reportData - UI에 표시된 가공된 데이터
|
||||
*/
|
||||
export function exportToExcel(reportData) {
|
||||
if (!reportData || !reportData.workers || reportData.workers.length === 0) {
|
||||
alert('내보낼 데이터가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const csv = convertToCsv(reportData);
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const fileName = `작업보고서_${reportData.summary.date}.csv`;
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', fileName);
|
||||
link.style.visibility = 'hidden';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Excel 내보내기 실패:', error);
|
||||
alert('Excel 파일을 생성하는 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 페이지의 인쇄 기능을 호출합니다.
|
||||
*/
|
||||
export function printReport() {
|
||||
try {
|
||||
window.print();
|
||||
} catch (error) {
|
||||
console.error('인쇄 실패:', error);
|
||||
alert('인쇄 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
144
synology_deployment/web-ui/js/report-viewer-ui.js
Normal file
144
synology_deployment/web-ui/js/report-viewer-ui.js
Normal file
@@ -0,0 +1,144 @@
|
||||
// /js/report-viewer-ui.js
|
||||
|
||||
/**
|
||||
* 데이터를 가공하여 UI에 표시하기 좋은 요약 형태로 변환합니다.
|
||||
* @param {Array} rawData - 서버에서 받은 원시 데이터 배열
|
||||
* @param {string} selectedDate - 선택된 날짜
|
||||
* @returns {object} - 요약 정보와 작업자별로 그룹화된 데이터를 포함하는 객체
|
||||
*/
|
||||
export function processReportData(rawData, selectedDate) {
|
||||
if (!Array.isArray(rawData) || rawData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workerGroups = {};
|
||||
let totalHours = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
rawData.forEach(item => {
|
||||
const workerName = item.worker_name || '미지정';
|
||||
const workHours = parseFloat(item.work_hours || 0);
|
||||
totalHours += workHours;
|
||||
if (item.work_status_id === 2) errorCount++; // '에러' 상태 ID가 2라고 가정
|
||||
|
||||
if (!workerGroups[workerName]) {
|
||||
workerGroups[workerName] = {
|
||||
worker_name: workerName,
|
||||
total_hours: 0,
|
||||
entries: []
|
||||
};
|
||||
}
|
||||
workerGroups[workerName].total_hours += workHours;
|
||||
workerGroups[workerName].entries.push(item);
|
||||
});
|
||||
|
||||
return {
|
||||
summary: {
|
||||
date: selectedDate,
|
||||
total_workers: Object.keys(workerGroups).length,
|
||||
total_hours: totalHours,
|
||||
total_entries: rawData.length,
|
||||
error_count: errorCount
|
||||
},
|
||||
workers: Object.values(workerGroups)
|
||||
};
|
||||
}
|
||||
|
||||
function displaySummary(summary) {
|
||||
const elements = {
|
||||
totalWorkers: summary.total_workers,
|
||||
totalHours: `${summary.total_hours}시간`,
|
||||
totalEntries: `${summary.total_entries}개`,
|
||||
errorCount: `${summary.error_count}개`
|
||||
};
|
||||
Object.entries(elements).forEach(([id, value]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = value;
|
||||
});
|
||||
document.getElementById('reportSummary').style.display = 'block';
|
||||
}
|
||||
|
||||
function createWorkEntryElement(entry) {
|
||||
const entryDiv = document.createElement('div');
|
||||
entryDiv.className = `work-entry ${entry.work_status_id === 2 ? 'error-entry' : ''}`;
|
||||
entryDiv.innerHTML = `
|
||||
<div class="entry-header">
|
||||
<div class="project-name">${entry.project_name || '프로젝트 미지정'}</div>
|
||||
<div class="work-hours">${entry.work_hours || 0}시간</div>
|
||||
</div>
|
||||
<div class="entry-details">
|
||||
<div class="entry-detail">
|
||||
<span class="detail-label">작업 유형:</span>
|
||||
<span class="detail-value">${entry.work_type_name || '-'}</span>
|
||||
</div>
|
||||
${entry.work_status_id === 2 ? `
|
||||
<div class="entry-detail">
|
||||
<span class="detail-label">에러 유형:</span>
|
||||
<span class="detail-value error-type">${entry.error_type_name || '에러'}</span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
return entryDiv;
|
||||
}
|
||||
|
||||
function displayWorkersDetails(workers) {
|
||||
const workersListEl = document.getElementById('workersList');
|
||||
workersListEl.innerHTML = '';
|
||||
workers.forEach(worker => {
|
||||
const workerCard = document.createElement('div');
|
||||
workerCard.className = 'worker-card';
|
||||
workerCard.innerHTML = `
|
||||
<div class="worker-header">
|
||||
<div class="worker-name">👤 ${worker.worker_name}</div>
|
||||
<div class="worker-total-hours">총 ${worker.total_hours}시간</div>
|
||||
</div>
|
||||
`;
|
||||
const entriesContainer = document.createElement('div');
|
||||
entriesContainer.className = 'work-entries';
|
||||
worker.entries.forEach(entry => entriesContainer.appendChild(createWorkEntryElement(entry)));
|
||||
workerCard.appendChild(entriesContainer);
|
||||
workersListEl.appendChild(workerCard);
|
||||
});
|
||||
document.getElementById('workersReport').style.display = 'block';
|
||||
}
|
||||
|
||||
const hideElement = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.display = 'none';
|
||||
};
|
||||
|
||||
/**
|
||||
* 가공된 데이터를 받아 화면 전체를 렌더링합니다.
|
||||
* @param {object|null} processedData - 가공된 데이터 또는 데이터가 없을 경우 null
|
||||
*/
|
||||
export function renderReport(processedData) {
|
||||
hideElement('loadingSpinner');
|
||||
hideElement('errorMessage');
|
||||
hideElement('noDataMessage');
|
||||
hideElement('reportSummary');
|
||||
hideElement('workersReport');
|
||||
hideElement('exportSection');
|
||||
|
||||
if (!processedData) {
|
||||
document.getElementById('noDataMessage').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
displaySummary(processedData.summary);
|
||||
displayWorkersDetails(processedData.workers);
|
||||
document.getElementById('exportSection').style.display = 'block';
|
||||
}
|
||||
|
||||
export function showLoading(isLoading) {
|
||||
document.getElementById('loadingSpinner').style.display = isLoading ? 'flex' : 'none';
|
||||
if(isLoading) {
|
||||
hideElement('errorMessage');
|
||||
hideElement('noDataMessage');
|
||||
}
|
||||
}
|
||||
|
||||
export function showError(message) {
|
||||
const errorEl = document.getElementById('errorMessage');
|
||||
errorEl.querySelector('.error-text').textContent = message;
|
||||
errorEl.style.display = 'block';
|
||||
hideElement('loadingSpinner');
|
||||
}
|
||||
907
synology_deployment/web-ui/js/system-dashboard.js
Normal file
907
synology_deployment/web-ui/js/system-dashboard.js
Normal file
@@ -0,0 +1,907 @@
|
||||
// System Dashboard JavaScript
|
||||
console.log('🚀 system-dashboard.js loaded');
|
||||
|
||||
import { apiRequest } from './api-helper.js';
|
||||
import { getCurrentUser } from './auth.js';
|
||||
|
||||
console.log('📦 modules imported successfully');
|
||||
|
||||
// 전역 변수
|
||||
let systemData = {
|
||||
users: [],
|
||||
logs: [],
|
||||
systemStatus: {}
|
||||
};
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('📄 DOM loaded, starting initialization');
|
||||
initializeSystemDashboard();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
// Setup event listeners
|
||||
function setupEventListeners() {
|
||||
// Add event listeners to all data-action buttons
|
||||
const actionButtons = document.querySelectorAll('[data-action]');
|
||||
|
||||
actionButtons.forEach(button => {
|
||||
const action = button.getAttribute('data-action');
|
||||
|
||||
switch(action) {
|
||||
case 'account-management':
|
||||
button.addEventListener('click', openAccountManagement);
|
||||
console.log('✅ Account management button listener added');
|
||||
break;
|
||||
case 'system-logs':
|
||||
button.addEventListener('click', openSystemLogs);
|
||||
console.log('✅ System logs button listener added');
|
||||
break;
|
||||
case 'database-management':
|
||||
button.addEventListener('click', openDatabaseManagement);
|
||||
console.log('✅ Database management button listener added');
|
||||
break;
|
||||
case 'system-settings':
|
||||
button.addEventListener('click', openSystemSettings);
|
||||
console.log('✅ System settings button listener added');
|
||||
break;
|
||||
case 'backup-management':
|
||||
button.addEventListener('click', openBackupManagement);
|
||||
console.log('✅ Backup management button listener added');
|
||||
break;
|
||||
case 'monitoring':
|
||||
button.addEventListener('click', openMonitoring);
|
||||
console.log('✅ Monitoring button listener added');
|
||||
break;
|
||||
case 'close-modal':
|
||||
button.addEventListener('click', () => closeModal('account-modal'));
|
||||
console.log('✅ Modal close button listener added');
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`🎯 Total ${actionButtons.length} event listeners setup completed`);
|
||||
}
|
||||
|
||||
// Initialize system dashboard
|
||||
async function initializeSystemDashboard() {
|
||||
try {
|
||||
console.log('🚀 Starting system dashboard initialization...');
|
||||
|
||||
// Load user info
|
||||
await loadUserInfo();
|
||||
console.log('✅ User info loaded');
|
||||
|
||||
// Load system status
|
||||
await loadSystemStatus();
|
||||
console.log('✅ System status loaded');
|
||||
|
||||
// Load user statistics
|
||||
await loadUserStats();
|
||||
console.log('✅ User statistics loaded');
|
||||
|
||||
// Load recent activities
|
||||
await loadRecentActivities();
|
||||
console.log('✅ Recent activities loaded');
|
||||
|
||||
// Setup auto-refresh (every 30 seconds)
|
||||
setInterval(refreshSystemStatus, 30000);
|
||||
|
||||
console.log('🎉 System dashboard initialization completed');
|
||||
} catch (error) {
|
||||
console.error('❌ System dashboard initialization error:', error);
|
||||
showNotification('Error loading system dashboard', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 정보 로드
|
||||
async function loadUserInfo() {
|
||||
try {
|
||||
const user = getCurrentUser();
|
||||
if (user && user.name) {
|
||||
document.getElementById('user-name').textContent = user.name;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('사용자 정보 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 시스템 상태 로드
|
||||
async function loadSystemStatus() {
|
||||
try {
|
||||
// 서버 상태 확인
|
||||
const serverStatus = await checkServerStatus();
|
||||
updateServerStatus(serverStatus);
|
||||
|
||||
// 데이터베이스 상태 확인
|
||||
const dbStatus = await checkDatabaseStatus();
|
||||
updateDatabaseStatus(dbStatus);
|
||||
|
||||
// 시스템 알림 확인
|
||||
const alerts = await getSystemAlerts();
|
||||
updateSystemAlerts(alerts);
|
||||
|
||||
} catch (error) {
|
||||
console.error('시스템 상태 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 서버 상태 확인
|
||||
async function checkServerStatus() {
|
||||
try {
|
||||
const response = await apiRequest('/api/system/status', 'GET');
|
||||
return response.success ? 'online' : 'offline';
|
||||
} catch (error) {
|
||||
return 'offline';
|
||||
}
|
||||
}
|
||||
|
||||
// 데이터베이스 상태 확인
|
||||
async function checkDatabaseStatus() {
|
||||
try {
|
||||
const response = await apiRequest('/api/system/db-status', 'GET');
|
||||
return response;
|
||||
} catch (error) {
|
||||
return { status: 'error', connections: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// 시스템 알림 가져오기
|
||||
async function getSystemAlerts() {
|
||||
try {
|
||||
const response = await apiRequest('/api/system/alerts', 'GET');
|
||||
return response.alerts || [];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 서버 상태 업데이트
|
||||
function updateServerStatus(status) {
|
||||
const serverCheckTime = document.getElementById('server-check-time');
|
||||
const statusElements = document.querySelectorAll('.status-value');
|
||||
|
||||
if (serverCheckTime) {
|
||||
serverCheckTime.textContent = new Date().toLocaleTimeString('ko-KR');
|
||||
}
|
||||
|
||||
// 서버 상태 표시 업데이트 로직 추가
|
||||
}
|
||||
|
||||
// 데이터베이스 상태 업데이트
|
||||
function updateDatabaseStatus(dbStatus) {
|
||||
const dbConnections = document.getElementById('db-connections');
|
||||
if (dbConnections && dbStatus.connections !== undefined) {
|
||||
dbConnections.textContent = dbStatus.connections;
|
||||
}
|
||||
}
|
||||
|
||||
// 시스템 알림 업데이트
|
||||
function updateSystemAlerts(alerts) {
|
||||
const systemAlerts = document.getElementById('system-alerts');
|
||||
if (systemAlerts) {
|
||||
systemAlerts.textContent = alerts.length;
|
||||
systemAlerts.className = `status-value ${alerts.length > 0 ? 'warning' : 'online'}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 통계 로드
|
||||
async function loadUserStats() {
|
||||
try {
|
||||
const response = await apiRequest('/api/system/users/stats', 'GET');
|
||||
|
||||
if (response.success) {
|
||||
const activeUsers = document.getElementById('active-users');
|
||||
const totalUsers = document.getElementById('total-users');
|
||||
|
||||
if (activeUsers) activeUsers.textContent = response.data.active || 0;
|
||||
if (totalUsers) totalUsers.textContent = response.data.total || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('사용자 통계 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 최근 활동 로드
|
||||
async function loadRecentActivities() {
|
||||
try {
|
||||
const response = await apiRequest('/api/system/recent-activities', 'GET');
|
||||
|
||||
if (response.success && response.data) {
|
||||
displayRecentActivities(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('최근 활동 로드 오류:', error);
|
||||
displayDefaultActivities();
|
||||
}
|
||||
}
|
||||
|
||||
// 최근 활동 표시
|
||||
function displayRecentActivities(activities) {
|
||||
const container = document.getElementById('recent-activities');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
if (!activities || activities.length === 0) {
|
||||
container.innerHTML = '<p style="text-align: center; color: #7f8c8d; padding: 2rem;">최근 활동이 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const html = activities.map(activity => `
|
||||
<div class="activity-item">
|
||||
<div class="activity-icon">
|
||||
<i class="fas ${getActivityIcon(activity.type)}"></i>
|
||||
</div>
|
||||
<div class="activity-info">
|
||||
<h4>${activity.title}</h4>
|
||||
<p>${activity.description}</p>
|
||||
</div>
|
||||
<div class="activity-time">
|
||||
${formatTimeAgo(activity.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// 기본 활동 표시 (데이터 로드 실패 시)
|
||||
function displayDefaultActivities() {
|
||||
const container = document.getElementById('recent-activities');
|
||||
if (!container) return;
|
||||
|
||||
const defaultActivities = [
|
||||
{
|
||||
type: 'system',
|
||||
title: '시스템 시작',
|
||||
description: '시스템이 정상적으로 시작되었습니다.',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
];
|
||||
|
||||
displayRecentActivities(defaultActivities);
|
||||
}
|
||||
|
||||
// 활동 타입에 따른 아이콘 반환
|
||||
function getActivityIcon(type) {
|
||||
const icons = {
|
||||
'login': 'fa-sign-in-alt',
|
||||
'user_create': 'fa-user-plus',
|
||||
'user_update': 'fa-user-edit',
|
||||
'user_delete': 'fa-user-minus',
|
||||
'system': 'fa-cog',
|
||||
'database': 'fa-database',
|
||||
'backup': 'fa-download',
|
||||
'error': 'fa-exclamation-triangle'
|
||||
};
|
||||
|
||||
return icons[type] || 'fa-info-circle';
|
||||
}
|
||||
|
||||
// 시간 포맷팅 (몇 분 전, 몇 시간 전 등)
|
||||
function formatTimeAgo(dateString) {
|
||||
const now = new Date();
|
||||
const date = new Date(dateString);
|
||||
const diffInSeconds = Math.floor((now - date) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return '방금 전';
|
||||
} else if (diffInSeconds < 3600) {
|
||||
return `${Math.floor(diffInSeconds / 60)}분 전`;
|
||||
} else if (diffInSeconds < 86400) {
|
||||
return `${Math.floor(diffInSeconds / 3600)}시간 전`;
|
||||
} else {
|
||||
return `${Math.floor(diffInSeconds / 86400)}일 전`;
|
||||
}
|
||||
}
|
||||
|
||||
// 시스템 상태 새로고침
|
||||
async function refreshSystemStatus() {
|
||||
try {
|
||||
await loadSystemStatus();
|
||||
await loadUserStats();
|
||||
} catch (error) {
|
||||
console.error('시스템 상태 새로고침 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Open account management
|
||||
function openAccountManagement() {
|
||||
console.log('🎯 Account management button clicked');
|
||||
const modal = document.getElementById('account-modal');
|
||||
const content = document.getElementById('account-management-content');
|
||||
|
||||
console.log('Modal element:', modal);
|
||||
console.log('Content element:', content);
|
||||
|
||||
if (modal && content) {
|
||||
console.log('✅ Modal and content elements found, loading content...');
|
||||
// Load account management content
|
||||
loadAccountManagementContent(content);
|
||||
modal.style.display = 'block';
|
||||
console.log('✅ Modal displayed');
|
||||
} else {
|
||||
console.error('❌ Modal or content element not found');
|
||||
}
|
||||
}
|
||||
|
||||
// 계정 관리 컨텐츠 로드
|
||||
async function loadAccountManagementContent(container) {
|
||||
try {
|
||||
container.innerHTML = `
|
||||
<div class="loading-spinner">
|
||||
<i class="fas fa-spinner fa-spin"></i> 로딩 중...
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 사용자 목록 로드
|
||||
const response = await apiRequest('/api/system/users', 'GET');
|
||||
|
||||
if (response.success) {
|
||||
displayAccountManagement(container, response.data);
|
||||
} else {
|
||||
throw new Error(response.error || '사용자 목록을 불러올 수 없습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('계정 관리 컨텐츠 로드 오류:', error);
|
||||
container.innerHTML = `
|
||||
<div class="error-message">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<p>계정 정보를 불러오는 중 오류가 발생했습니다.</p>
|
||||
<button class="btn btn-primary" onclick="loadAccountManagementContent(document.getElementById('account-management-content'))">
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 계정 관리 화면 표시
|
||||
function displayAccountManagement(container, users) {
|
||||
const html = `
|
||||
<div class="account-management">
|
||||
<div class="account-header">
|
||||
<h4><i class="fas fa-users"></i> 사용자 계정 관리</h4>
|
||||
<button class="btn btn-primary" onclick="openCreateUserForm()">
|
||||
<i class="fas fa-plus"></i> 새 사용자
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="account-filters">
|
||||
<input type="text" id="user-search" placeholder="사용자 검색..." onkeyup="filterUsers()">
|
||||
<select id="role-filter" onchange="filterUsers()">
|
||||
<option value="">모든 권한</option>
|
||||
<option value="system">시스템</option>
|
||||
<option value="admin">관리자</option>
|
||||
<option value="leader">그룹장</option>
|
||||
<option value="user">사용자</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="users-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>사용자명</th>
|
||||
<th>이름</th>
|
||||
<th>권한</th>
|
||||
<th>상태</th>
|
||||
<th>마지막 로그인</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-tbody">
|
||||
${generateUsersTableRows(users)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
systemData.users = users;
|
||||
}
|
||||
|
||||
// 사용자 테이블 행 생성
|
||||
function generateUsersTableRows(users) {
|
||||
if (!users || users.length === 0) {
|
||||
return '<tr><td colspan="7" style="text-align: center; padding: 2rem;">등록된 사용자가 없습니다.</td></tr>';
|
||||
}
|
||||
|
||||
return users.map(user => `
|
||||
<tr data-user-id="${user.user_id}">
|
||||
<td>${user.user_id}</td>
|
||||
<td>${user.username}</td>
|
||||
<td>${user.name || '-'}</td>
|
||||
<td>
|
||||
<span class="role-badge role-${user.role}">
|
||||
${getRoleDisplayName(user.role)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge ${user.is_active ? 'active' : 'inactive'}">
|
||||
${user.is_active ? '활성' : '비활성'}
|
||||
</span>
|
||||
</td>
|
||||
<td>${user.last_login_at ? formatDate(user.last_login_at) : '없음'}</td>
|
||||
<td class="action-buttons">
|
||||
<button class="btn-small btn-edit" onclick="editUser(${user.user_id})" title="수정">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn-small btn-delete" onclick="deleteUser(${user.user_id})" title="삭제">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 권한 표시명 반환
|
||||
function getRoleDisplayName(role) {
|
||||
const roleNames = {
|
||||
'system': '시스템',
|
||||
'admin': '관리자',
|
||||
'leader': '그룹장',
|
||||
'user': '사용자'
|
||||
};
|
||||
return roleNames[role] || role;
|
||||
}
|
||||
|
||||
// 날짜 포맷팅
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
// 시스템 로그 열기
|
||||
function openSystemLogs() {
|
||||
console.log('시스템 로그 버튼 클릭됨');
|
||||
const modal = document.getElementById('account-modal');
|
||||
const content = document.getElementById('account-management-content');
|
||||
|
||||
if (modal && content) {
|
||||
loadSystemLogsContent(content);
|
||||
modal.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// 시스템 로그 컨텐츠 로드
|
||||
async function loadSystemLogsContent(container) {
|
||||
try {
|
||||
container.innerHTML = `
|
||||
<div class="system-logs">
|
||||
<h4><i class="fas fa-file-alt"></i> 시스템 로그</h4>
|
||||
<div class="log-filters">
|
||||
<select id="log-type-filter">
|
||||
<option value="">모든 로그</option>
|
||||
<option value="login">로그인</option>
|
||||
<option value="activity">활동</option>
|
||||
<option value="error">오류</option>
|
||||
</select>
|
||||
<input type="date" id="log-date-filter">
|
||||
<button class="btn btn-primary" onclick="filterLogs()">
|
||||
<i class="fas fa-search"></i> 검색
|
||||
</button>
|
||||
</div>
|
||||
<div class="logs-container">
|
||||
<div class="loading-spinner">
|
||||
<i class="fas fa-spinner fa-spin"></i> 로그 로딩 중...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 로그 데이터 로드
|
||||
await loadLogsData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('시스템 로그 로드 오류:', error);
|
||||
container.innerHTML = `
|
||||
<div class="error-message">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<p>시스템 로그를 불러오는 중 오류가 발생했습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 로그 데이터 로드
|
||||
async function loadLogsData() {
|
||||
try {
|
||||
const response = await apiRequest('/api/system/logs/activity', 'GET');
|
||||
const logsContainer = document.querySelector('.logs-container');
|
||||
|
||||
if (response.success && response.data) {
|
||||
displayLogs(response.data, logsContainer);
|
||||
} else {
|
||||
logsContainer.innerHTML = '<p>로그 데이터가 없습니다.</p>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('로그 데이터 로드 오류:', error);
|
||||
document.querySelector('.logs-container').innerHTML = '<p>로그 데이터를 불러올 수 없습니다.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// 로그 표시
|
||||
function displayLogs(logs, container) {
|
||||
if (!logs || logs.length === 0) {
|
||||
container.innerHTML = '<p>표시할 로그가 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const html = `
|
||||
<table class="logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시간</th>
|
||||
<th>유형</th>
|
||||
<th>사용자</th>
|
||||
<th>내용</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${logs.map(log => `
|
||||
<tr>
|
||||
<td>${formatDate(log.created_at)}</td>
|
||||
<td><span class="log-type ${log.type}">${log.type}</span></td>
|
||||
<td>${log.username || '-'}</td>
|
||||
<td>${log.description}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// 로그 필터링
|
||||
function filterLogs() {
|
||||
console.log('로그 필터링 실행');
|
||||
// 실제 구현은 추후 추가
|
||||
showNotification('로그 필터링 기능은 개발 중입니다.', 'info');
|
||||
}
|
||||
|
||||
// 데이터베이스 관리 열기
|
||||
function openDatabaseManagement() {
|
||||
console.log('데이터베이스 관리 버튼 클릭됨');
|
||||
showNotification('데이터베이스 관리 기능은 개발 중입니다.', 'info');
|
||||
}
|
||||
|
||||
// 시스템 설정 열기
|
||||
function openSystemSettings() {
|
||||
console.log('시스템 설정 버튼 클릭됨');
|
||||
showNotification('시스템 설정 기능은 개발 중입니다.', 'info');
|
||||
}
|
||||
|
||||
// 백업 관리 열기
|
||||
function openBackupManagement() {
|
||||
console.log('백업 관리 버튼 클릭됨');
|
||||
showNotification('백업 관리 기능은 개발 중입니다.', 'info');
|
||||
}
|
||||
|
||||
// 모니터링 열기
|
||||
function openMonitoring() {
|
||||
console.log('모니터링 버튼 클릭됨');
|
||||
showNotification('모니터링 기능은 개발 중입니다.', 'info');
|
||||
}
|
||||
|
||||
// 모달 닫기
|
||||
function closeModal(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// 로그아웃
|
||||
function logout() {
|
||||
if (confirm('로그아웃 하시겠습니까?')) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
|
||||
// 알림 표시
|
||||
function showNotification(message, type = 'info') {
|
||||
// 간단한 알림 표시 (나중에 토스트 라이브러리로 교체 가능)
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification notification-${type}`;
|
||||
notification.textContent = message;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// 사용자 편집
|
||||
async function editUser(userId) {
|
||||
try {
|
||||
// 사용자 정보 가져오기
|
||||
const response = await apiRequest(`/api/system/users`, 'GET');
|
||||
if (!response.success) {
|
||||
throw new Error('사용자 정보를 가져올 수 없습니다.');
|
||||
}
|
||||
|
||||
const user = response.data.find(u => u.user_id === userId);
|
||||
if (!user) {
|
||||
throw new Error('해당 사용자를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 편집 폼 표시
|
||||
showUserEditForm(user);
|
||||
|
||||
} catch (error) {
|
||||
console.error('사용자 편집 오류:', error);
|
||||
showNotification('사용자 정보를 불러오는 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 편집 폼 표시
|
||||
function showUserEditForm(user) {
|
||||
const formHtml = `
|
||||
<div class="user-edit-form">
|
||||
<h4><i class="fas fa-user-edit"></i> 사용자 정보 수정</h4>
|
||||
<form id="edit-user-form">
|
||||
<div class="form-group">
|
||||
<label for="edit-username">사용자명</label>
|
||||
<input type="text" id="edit-username" value="${user.username}" disabled>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-name">이름</label>
|
||||
<input type="text" id="edit-name" value="${user.name || ''}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-email">이메일</label>
|
||||
<input type="email" id="edit-email" value="${user.email || ''}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-role">권한</label>
|
||||
<select id="edit-role" required>
|
||||
<option value="system" ${user.role === 'system' ? 'selected' : ''}>시스템</option>
|
||||
<option value="admin" ${user.role === 'admin' ? 'selected' : ''}>관리자</option>
|
||||
<option value="leader" ${user.role === 'leader' ? 'selected' : ''}>그룹장</option>
|
||||
<option value="user" ${user.role === 'user' ? 'selected' : ''}>사용자</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-is-active">상태</label>
|
||||
<select id="edit-is-active" required>
|
||||
<option value="1" ${user.is_active ? 'selected' : ''}>활성</option>
|
||||
<option value="0" ${!user.is_active ? 'selected' : ''}>비활성</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-worker-id">작업자 ID</label>
|
||||
<input type="number" id="edit-worker-id" value="${user.worker_id || ''}">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> 저장
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('account-modal')">
|
||||
<i class="fas fa-times"></i> 취소
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const container = document.getElementById('account-management-content');
|
||||
container.innerHTML = formHtml;
|
||||
|
||||
// 폼 제출 이벤트 리스너
|
||||
document.getElementById('edit-user-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
await updateUser(user.user_id);
|
||||
});
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
async function updateUser(userId) {
|
||||
try {
|
||||
const formData = {
|
||||
name: document.getElementById('edit-name').value,
|
||||
email: document.getElementById('edit-email').value || null,
|
||||
role: document.getElementById('edit-role').value,
|
||||
access_level: document.getElementById('edit-role').value,
|
||||
is_active: parseInt(document.getElementById('edit-is-active').value),
|
||||
worker_id: document.getElementById('edit-worker-id').value || null
|
||||
};
|
||||
|
||||
const response = await apiRequest(`/api/system/users/${userId}`, 'PUT', formData);
|
||||
|
||||
if (response.success) {
|
||||
showNotification('사용자 정보가 성공적으로 업데이트되었습니다.', 'success');
|
||||
closeModal('account-modal');
|
||||
// 계정 관리 다시 로드
|
||||
setTimeout(() => openAccountManagement(), 500);
|
||||
} else {
|
||||
throw new Error(response.error || '업데이트에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('사용자 업데이트 오류:', error);
|
||||
showNotification('사용자 정보 업데이트 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 삭제
|
||||
async function deleteUser(userId) {
|
||||
try {
|
||||
// 사용자 정보 가져오기
|
||||
const response = await apiRequest(`/api/system/users`, 'GET');
|
||||
if (!response.success) {
|
||||
throw new Error('사용자 정보를 가져올 수 없습니다.');
|
||||
}
|
||||
|
||||
const user = response.data.find(u => u.user_id === userId);
|
||||
if (!user) {
|
||||
throw new Error('해당 사용자를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 삭제 확인
|
||||
if (!confirm(`정말로 사용자 '${user.username}'를 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 사용자 삭제 요청
|
||||
const deleteResponse = await apiRequest(`/api/system/users/${userId}`, 'DELETE');
|
||||
|
||||
if (deleteResponse.success) {
|
||||
showNotification('사용자가 성공적으로 삭제되었습니다.', 'success');
|
||||
// 계정 관리 다시 로드
|
||||
setTimeout(() => {
|
||||
const container = document.getElementById('account-management-content');
|
||||
if (container) {
|
||||
loadAccountManagementContent(container);
|
||||
}
|
||||
}, 500);
|
||||
} else {
|
||||
throw new Error(deleteResponse.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('사용자 삭제 오류:', error);
|
||||
showNotification('사용자 삭제 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 새 사용자 생성 폼 열기
|
||||
function openCreateUserForm() {
|
||||
const formHtml = `
|
||||
<div class="user-create-form">
|
||||
<h4><i class="fas fa-user-plus"></i> 새 사용자 생성</h4>
|
||||
<form id="create-user-form">
|
||||
<div class="form-group">
|
||||
<label for="create-username">사용자명 *</label>
|
||||
<input type="text" id="create-username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="create-password">비밀번호 *</label>
|
||||
<input type="password" id="create-password" required minlength="6">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="create-name">이름 *</label>
|
||||
<input type="text" id="create-name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="create-email">이메일</label>
|
||||
<input type="email" id="create-email">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="create-role">권한 *</label>
|
||||
<select id="create-role" required>
|
||||
<option value="">권한 선택</option>
|
||||
<option value="system">시스템</option>
|
||||
<option value="admin">관리자</option>
|
||||
<option value="leader">그룹장</option>
|
||||
<option value="user">사용자</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="create-worker-id">작업자 ID</label>
|
||||
<input type="number" id="create-worker-id">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> 생성
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="loadAccountManagementContent(document.getElementById('account-management-content'))">
|
||||
<i class="fas fa-arrow-left"></i> 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const container = document.getElementById('account-management-content');
|
||||
container.innerHTML = formHtml;
|
||||
|
||||
// 폼 제출 이벤트 리스너
|
||||
document.getElementById('create-user-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
await createUser();
|
||||
});
|
||||
}
|
||||
|
||||
// 새 사용자 생성
|
||||
async function createUser() {
|
||||
try {
|
||||
const formData = {
|
||||
username: document.getElementById('create-username').value,
|
||||
password: document.getElementById('create-password').value,
|
||||
name: document.getElementById('create-name').value,
|
||||
email: document.getElementById('create-email').value || null,
|
||||
role: document.getElementById('create-role').value,
|
||||
access_level: document.getElementById('create-role').value,
|
||||
worker_id: document.getElementById('create-worker-id').value || null
|
||||
};
|
||||
|
||||
const response = await apiRequest('/api/system/users', 'POST', formData);
|
||||
|
||||
if (response.success) {
|
||||
showNotification('새 사용자가 성공적으로 생성되었습니다.', 'success');
|
||||
// 계정 관리 목록으로 돌아가기
|
||||
setTimeout(() => {
|
||||
const container = document.getElementById('account-management-content');
|
||||
loadAccountManagementContent(container);
|
||||
}, 500);
|
||||
} else {
|
||||
throw new Error(response.error || '사용자 생성에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('사용자 생성 오류:', error);
|
||||
showNotification('사용자 생성 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 필터링
|
||||
function filterUsers() {
|
||||
const searchTerm = document.getElementById('user-search').value.toLowerCase();
|
||||
const roleFilter = document.getElementById('role-filter').value;
|
||||
const rows = document.querySelectorAll('#users-tbody tr');
|
||||
|
||||
rows.forEach(row => {
|
||||
const username = row.cells[1].textContent.toLowerCase();
|
||||
const name = row.cells[2].textContent.toLowerCase();
|
||||
const role = row.querySelector('.role-badge').textContent.toLowerCase();
|
||||
|
||||
const matchesSearch = username.includes(searchTerm) || name.includes(searchTerm);
|
||||
const matchesRole = !roleFilter || role.includes(roleFilter);
|
||||
|
||||
row.style.display = matchesSearch && matchesRole ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// 모달 관련 함수들만 전역으로 노출 (동적으로 생성되는 HTML에서 사용)
|
||||
window.closeModal = closeModal;
|
||||
window.editUser = editUser;
|
||||
window.deleteUser = deleteUser;
|
||||
window.openCreateUserForm = openCreateUserForm;
|
||||
window.filterUsers = filterUsers;
|
||||
window.filterLogs = filterLogs;
|
||||
|
||||
// 테스트용 전역 함수
|
||||
window.testFunction = function() {
|
||||
console.log('🧪 테스트 함수 호출됨!');
|
||||
alert('테스트 함수가 정상적으로 작동합니다!');
|
||||
};
|
||||
|
||||
console.log('🌐 전역 함수들 노출 완료');
|
||||
|
||||
// 모달 외부 클릭 시 닫기
|
||||
window.onclick = function(event) {
|
||||
const modals = document.querySelectorAll('.modal');
|
||||
modals.forEach(modal => {
|
||||
if (event.target === modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
});
|
||||
};
|
||||
93
synology_deployment/web-ui/js/user-dashboard.js
Normal file
93
synology_deployment/web-ui/js/user-dashboard.js
Normal file
@@ -0,0 +1,93 @@
|
||||
// /js/user-dashboard.js
|
||||
import { getUser } from './auth.js';
|
||||
import { apiGet } from './api-helper.js'; // 개선된 api-helper를 사용합니다.
|
||||
|
||||
/**
|
||||
* API를 호출하여 오늘의 작업 일정을 불러와 화면에 표시합니다.
|
||||
*/
|
||||
async function loadTodaySchedule() {
|
||||
const scheduleContainer = document.getElementById('today-schedule');
|
||||
scheduleContainer.innerHTML = '<p>📅 오늘의 작업 일정을 불러오는 중...</p>';
|
||||
|
||||
try {
|
||||
// 예시: /api/dashboard/today-schedule 엔드포인트에서 데이터를 가져옵니다.
|
||||
// 실제 엔드포인트는 백엔드 구현에 따라 달라질 수 있습니다.
|
||||
const scheduleData = await apiGet('/dashboard/today-schedule');
|
||||
|
||||
if (scheduleData && scheduleData.length > 0) {
|
||||
const scheduleHtml = scheduleData.map(item => `
|
||||
<div class="schedule-item">
|
||||
<span class="time">${item.time}</span>
|
||||
<span class="task">${item.task_name}</span>
|
||||
<span class="status ${item.status}">${item.status_kor}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
scheduleContainer.innerHTML = scheduleHtml;
|
||||
} else {
|
||||
scheduleContainer.innerHTML = '<p>오늘 예정된 작업이 없습니다.</p>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('오늘의 작업 일정 로드 실패:', error);
|
||||
scheduleContainer.innerHTML = '<p class="error">일정 정보를 불러오는 데 실패했습니다.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API를 호출하여 현재 사용자의 작업 통계를 불러와 화면에 표시합니다.
|
||||
*/
|
||||
async function loadWorkStats() {
|
||||
const statsContainer = document.getElementById('work-stats');
|
||||
statsContainer.innerHTML = '<p>📈 내 작업 현황을 불러오는 중...</p>';
|
||||
|
||||
try {
|
||||
// 예시: /api/dashboard/my-stats 엔드포인트에서 데이터를 가져옵니다.
|
||||
const statsData = await apiGet('/dashboard/my-stats');
|
||||
|
||||
if (statsData) {
|
||||
const statsHtml = `
|
||||
<div class="stat-item">
|
||||
<span>이번 주 작업 시간:</span>
|
||||
<strong>${statsData.weekly_hours || 0} 시간</strong>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>이번 달 작업 시간:</span>
|
||||
<strong>${statsData.monthly_hours || 0} 시간</strong>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>완료한 작업 수:</span>
|
||||
<strong>${statsData.completed_tasks || 0} 건</strong>
|
||||
</div>
|
||||
`;
|
||||
statsContainer.innerHTML = statsHtml;
|
||||
} else {
|
||||
statsContainer.innerHTML = '<p>표시할 통계 정보가 없습니다.</p>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업 통계 로드 실패:', error);
|
||||
statsContainer.innerHTML = '<p class="error">통계 정보를 불러오는 데 실패했습니다.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 환영 메시지를 사용자 이름으로 개인화합니다.
|
||||
*/
|
||||
function personalizeWelcome() {
|
||||
// 전역 변수 대신 auth.js 모듈을 통해 사용자 정보를 가져옵니다.
|
||||
const user = getUser();
|
||||
if (user) {
|
||||
const welcomeEl = document.getElementById('welcome-message');
|
||||
if (welcomeEl) {
|
||||
welcomeEl.textContent = `${user.name || user.username}님, 환영합니다! 오늘 하루도 안전하게 작업하세요.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 초기화 함수
|
||||
function initializeDashboard() {
|
||||
personalizeWelcome();
|
||||
loadTodaySchedule();
|
||||
loadWorkStats();
|
||||
}
|
||||
|
||||
// DOM이 로드되면 대시보드 초기화를 시작합니다.
|
||||
document.addEventListener('DOMContentLoaded', initializeDashboard);
|
||||
830
synology_deployment/web-ui/js/work-analysis.js
Normal file
830
synology_deployment/web-ui/js/work-analysis.js
Normal file
@@ -0,0 +1,830 @@
|
||||
// 작업 분석 페이지 JavaScript
|
||||
|
||||
// 전역 변수
|
||||
let currentMode = 'period';
|
||||
let currentTab = 'worker';
|
||||
let analysisData = null;
|
||||
let projectChart = null;
|
||||
let errorByProjectChart = null;
|
||||
let errorTimelineChart = null;
|
||||
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('📈 작업 분석 페이지 초기화 시작');
|
||||
|
||||
initializePage();
|
||||
loadInitialData();
|
||||
});
|
||||
|
||||
// 페이지 초기화
|
||||
function initializePage() {
|
||||
// 시간 업데이트 시작
|
||||
updateCurrentTime();
|
||||
setInterval(updateCurrentTime, 1000);
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
updateUserInfo();
|
||||
|
||||
// 프로필 메뉴 토글
|
||||
setupProfileMenu();
|
||||
|
||||
// 로그아웃 버튼
|
||||
setupLogoutButton();
|
||||
|
||||
// 기본 날짜 설정은 HTML에서 처리됨 (새로운 UI)
|
||||
console.log('✅ 작업 분석 페이지 초기화 완료');
|
||||
}
|
||||
|
||||
// 현재 시간 업데이트
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
function updateUserInfo() {
|
||||
let userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
|
||||
let authUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
|
||||
const finalUserInfo = {
|
||||
worker_name: userInfo.worker_name || authUser.username || authUser.worker_name,
|
||||
job_type: userInfo.job_type || authUser.role || authUser.job_type,
|
||||
username: authUser.username || userInfo.username
|
||||
};
|
||||
|
||||
const userNameElement = document.getElementById('userName');
|
||||
const userRoleElement = document.getElementById('userRole');
|
||||
const userInitialElement = document.getElementById('userInitial');
|
||||
|
||||
if (userNameElement) {
|
||||
userNameElement.textContent = finalUserInfo.worker_name || '사용자';
|
||||
}
|
||||
|
||||
if (userRoleElement) {
|
||||
const roleMap = {
|
||||
'leader': '그룹장',
|
||||
'worker': '작업자',
|
||||
'admin': '관리자',
|
||||
'system': '시스템 관리자'
|
||||
};
|
||||
userRoleElement.textContent = roleMap[finalUserInfo.job_type] || finalUserInfo.job_type || '작업자';
|
||||
}
|
||||
|
||||
if (userInitialElement) {
|
||||
const name = finalUserInfo.worker_name || '사용자';
|
||||
userInitialElement.textContent = name.charAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 프로필 메뉴 설정
|
||||
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 loadInitialData() {
|
||||
try {
|
||||
console.log('📊 초기 데이터 로딩 시작');
|
||||
|
||||
// 프로젝트 목록 로드
|
||||
const projects = await apiCall('/projects/active/list', 'GET');
|
||||
const projectData = Array.isArray(projects) ? projects : (projects.data || []);
|
||||
|
||||
// 프로젝트 필터 옵션 업데이트
|
||||
updateProjectFilters(projectData);
|
||||
|
||||
console.log('✅ 초기 데이터 로딩 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('초기 데이터 로딩 오류:', error);
|
||||
showToast('초기 데이터를 불러오는데 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 필터 업데이트
|
||||
function updateProjectFilters(projects) {
|
||||
const projectFilter = document.getElementById('projectFilter');
|
||||
const projectModeSelect = document.getElementById('projectModeSelect');
|
||||
|
||||
if (projectFilter) {
|
||||
projectFilter.innerHTML = '<option value="">전체 프로젝트</option>';
|
||||
projects.forEach(project => {
|
||||
projectFilter.innerHTML += `<option value="${project.project_id}">${project.project_name}</option>`;
|
||||
});
|
||||
}
|
||||
|
||||
if (projectModeSelect) {
|
||||
projectModeSelect.innerHTML = '<option value="">프로젝트를 선택하세요</option>';
|
||||
projects.forEach(project => {
|
||||
projectModeSelect.innerHTML += `<option value="${project.project_id}">${project.project_name}</option>`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 분석 모드 전환
|
||||
function switchAnalysisMode(mode) {
|
||||
currentMode = mode;
|
||||
|
||||
// 탭 버튼 활성화 상태 변경
|
||||
document.querySelectorAll('.mode-tab').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
document.querySelector(`[data-mode="${mode}"]`).classList.add('active');
|
||||
|
||||
// 모드 콘텐츠 표시/숨김
|
||||
document.querySelectorAll('.analysis-mode').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
document.getElementById(`${mode}-mode`).classList.add('active');
|
||||
|
||||
console.log(`🔄 분석 모드 전환: ${mode}`);
|
||||
}
|
||||
|
||||
// 분석 탭 전환
|
||||
function switchAnalysisTab(tab) {
|
||||
currentTab = tab;
|
||||
|
||||
// 탭 버튼 활성화 상태 변경
|
||||
document.querySelectorAll('.analysis-tab').forEach(tabBtn => {
|
||||
tabBtn.classList.remove('active');
|
||||
});
|
||||
document.querySelector(`[data-tab="${tab}"]`).classList.add('active');
|
||||
|
||||
// 탭 콘텐츠 표시/숨김
|
||||
document.querySelectorAll('.analysis-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
document.getElementById(`${tab}-analysis`).classList.add('active');
|
||||
|
||||
console.log(`🔄 분석 탭 전환: ${tab}`);
|
||||
}
|
||||
|
||||
// 기간별 분석 로드
|
||||
async function loadPeriodAnalysis() {
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
const endDate = document.getElementById('endDate').value;
|
||||
const projectId = document.getElementById('projectFilter').value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
showToast('시작일과 종료일을 모두 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (new Date(startDate) > new Date(endDate)) {
|
||||
showToast('시작일이 종료일보다 늦을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(true);
|
||||
|
||||
try {
|
||||
console.log('📊 기간별 분석 데이터 로딩 시작');
|
||||
|
||||
// API 호출 파라미터 구성
|
||||
const params = new URLSearchParams({
|
||||
start: startDate,
|
||||
end: endDate
|
||||
});
|
||||
|
||||
if (projectId) {
|
||||
params.append('project_id', projectId);
|
||||
}
|
||||
|
||||
// 여러 API를 병렬로 호출하여 종합 분석 데이터 구성
|
||||
console.log('📡 API 파라미터:', params.toString());
|
||||
|
||||
const [statsRes, workerStatsRes, projectStatsRes, errorAnalysisRes] = await Promise.all([
|
||||
apiCall(`/work-analysis/stats?${params}`, 'GET').catch(err => {
|
||||
console.error('❌ stats API 오류:', err);
|
||||
return { data: null };
|
||||
}),
|
||||
apiCall(`/work-analysis/worker-stats?${params}`, 'GET').catch(err => {
|
||||
console.error('❌ worker-stats API 오류:', err);
|
||||
return { data: [] };
|
||||
}),
|
||||
apiCall(`/work-analysis/project-stats?${params}`, 'GET').catch(err => {
|
||||
console.error('❌ project-stats API 오류:', err);
|
||||
return { data: [] };
|
||||
}),
|
||||
apiCall(`/work-analysis/error-analysis?${params}`, 'GET').catch(err => {
|
||||
console.error('❌ error-analysis API 오류:', err);
|
||||
return { data: {} };
|
||||
})
|
||||
]);
|
||||
|
||||
console.log('📊 개별 API 응답:');
|
||||
console.log(' - stats:', statsRes);
|
||||
console.log(' - worker-stats:', workerStatsRes);
|
||||
console.log(' - project-stats:', projectStatsRes);
|
||||
console.log(' - error-analysis:', errorAnalysisRes);
|
||||
|
||||
// 종합 분석 데이터 구성
|
||||
analysisData = {
|
||||
summary: statsRes.data || statsRes,
|
||||
workerStats: workerStatsRes.data || workerStatsRes,
|
||||
projectStats: projectStatsRes.data || projectStatsRes,
|
||||
errorStats: errorAnalysisRes.data || errorAnalysisRes
|
||||
};
|
||||
|
||||
console.log('📊 분석 데이터:', analysisData);
|
||||
console.log('📊 요약 통계:', analysisData.summary);
|
||||
console.log('👥 작업자 통계:', analysisData.workerStats);
|
||||
console.log('📁 프로젝트 통계:', analysisData.projectStats);
|
||||
console.log('⚠️ 오류 통계:', analysisData.errorStats);
|
||||
|
||||
// 결과 표시
|
||||
displayPeriodAnalysis(analysisData);
|
||||
|
||||
// 결과 섹션 표시
|
||||
document.getElementById('periodResults').style.display = 'block';
|
||||
|
||||
showToast('분석이 완료되었습니다.', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('기간별 분석 오류:', error);
|
||||
showToast('분석 중 오류가 발생했습니다.', 'error');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 기간별 분석 결과 표시
|
||||
function displayPeriodAnalysis(data) {
|
||||
// 요약 통계 업데이트
|
||||
updateSummaryStats(data.summary || {});
|
||||
|
||||
// 작업자별 분석 표시
|
||||
displayWorkerAnalysis(data.workerStats || []);
|
||||
|
||||
// 프로젝트별 분석 표시
|
||||
displayProjectAnalysis(data.projectStats || []);
|
||||
|
||||
// 오류 분석 표시 (전체 분석 데이터도 함께 전달)
|
||||
displayErrorAnalysis(data.errorStats || {}, data);
|
||||
}
|
||||
|
||||
// 요약 통계 업데이트
|
||||
function updateSummaryStats(summary) {
|
||||
// API 응답 구조에 맞게 필드명 조정
|
||||
document.getElementById('totalHours').textContent = `${summary.totalHours || summary.total_hours || 0}h`;
|
||||
document.getElementById('totalWorkers').textContent = `${summary.activeworkers || summary.activeWorkers || summary.total_workers || 0}명`;
|
||||
document.getElementById('totalProjects').textContent = `${summary.activeProjects || summary.active_projects || summary.total_projects || 0}개`;
|
||||
document.getElementById('errorRate').textContent = `${summary.errorRate || summary.error_rate || 0}%`;
|
||||
}
|
||||
|
||||
// 작업자별 분석 표시
|
||||
function displayWorkerAnalysis(workerStats) {
|
||||
const grid = document.getElementById('workerAnalysisGrid');
|
||||
|
||||
console.log('👥 작업자 분석 데이터 확인:', workerStats);
|
||||
console.log('👥 데이터 타입:', typeof workerStats);
|
||||
console.log('👥 배열 여부:', Array.isArray(workerStats));
|
||||
console.log('👥 길이:', workerStats ? workerStats.length : 'undefined');
|
||||
|
||||
if (!workerStats || (Array.isArray(workerStats) && workerStats.length === 0)) {
|
||||
console.log('👥 빈 데이터로 인한 empty-state 표시');
|
||||
grid.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">👥</div>
|
||||
<h3>분석할 작업자 데이터가 없습니다.</h3>
|
||||
<p>선택한 기간에 등록된 작업이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
let gridHtml = '';
|
||||
|
||||
workerStats.forEach(worker => {
|
||||
const workerName = worker.worker_name || worker.name || '알 수 없음';
|
||||
const totalHours = worker.total_hours || worker.totalHours || 0;
|
||||
|
||||
gridHtml += `
|
||||
<div class="worker-card">
|
||||
<div class="worker-header">
|
||||
<div class="worker-info">
|
||||
<div class="worker-avatar">${workerName.charAt(0)}</div>
|
||||
<div class="worker-name">${workerName}</div>
|
||||
</div>
|
||||
<div class="worker-total-hours">${totalHours}h</div>
|
||||
</div>
|
||||
<div class="worker-projects">
|
||||
`;
|
||||
|
||||
// API 응답 구조에 따라 프로젝트 데이터 처리
|
||||
const projects = worker.projects || worker.project_details || [];
|
||||
if (projects.length > 0) {
|
||||
projects.forEach(project => {
|
||||
const projectName = project.project_name || project.name || '프로젝트';
|
||||
gridHtml += `
|
||||
<div class="project-item">
|
||||
<div class="project-name">${projectName}</div>
|
||||
<div class="work-items">
|
||||
`;
|
||||
|
||||
const works = project.works || project.work_details || project.tasks || [];
|
||||
if (works.length > 0) {
|
||||
works.forEach(work => {
|
||||
const workName = work.work_name || work.task_name || work.name || '작업';
|
||||
const workHours = work.hours || work.total_hours || work.work_hours || 0;
|
||||
gridHtml += `
|
||||
<div class="work-item">
|
||||
<div class="work-name">${workName}</div>
|
||||
<div class="work-hours">${workHours}h</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
} else {
|
||||
gridHtml += `
|
||||
<div class="work-item">
|
||||
<div class="work-name">총 작업시간</div>
|
||||
<div class="work-hours">${project.total_hours || project.hours || 0}h</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
gridHtml += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
} else {
|
||||
gridHtml += `
|
||||
<div class="project-item">
|
||||
<div class="project-name">전체 작업</div>
|
||||
<div class="work-items">
|
||||
<div class="work-item">
|
||||
<div class="work-name">총 작업시간</div>
|
||||
<div class="work-hours">${totalHours}h</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
gridHtml += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
grid.innerHTML = gridHtml;
|
||||
}
|
||||
|
||||
// 프로젝트별 분석 표시
|
||||
function displayProjectAnalysis(projectStats) {
|
||||
const detailsContainer = document.getElementById('projectDetails');
|
||||
|
||||
console.log('📁 프로젝트 분석 데이터 확인:', projectStats);
|
||||
console.log('📁 데이터 타입:', typeof projectStats);
|
||||
console.log('📁 배열 여부:', Array.isArray(projectStats));
|
||||
console.log('📁 길이:', projectStats ? projectStats.length : 'undefined');
|
||||
|
||||
if (projectStats && projectStats.length > 0) {
|
||||
console.log('📁 첫 번째 프로젝트 데이터:', projectStats[0]);
|
||||
}
|
||||
|
||||
if (!projectStats || projectStats.length === 0) {
|
||||
console.log('📁 빈 데이터로 인한 empty-state 표시');
|
||||
detailsContainer.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📁</div>
|
||||
<h3>분석할 프로젝트 데이터가 없습니다.</h3>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 프로젝트 상세 정보 표시
|
||||
let detailsHtml = '';
|
||||
|
||||
// 전체 시간 계산 (퍼센트 계산용)
|
||||
const totalAllHours = projectStats.reduce((sum, p) => {
|
||||
return sum + (p.totalHours || p.total_hours || p.hours || 0);
|
||||
}, 0);
|
||||
|
||||
projectStats.forEach(project => {
|
||||
console.log('📁 개별 프로젝트 처리:', project);
|
||||
|
||||
const projectName = project.project_name || project.name || project.projectName || '프로젝트';
|
||||
const totalHours = project.totalHours || project.total_hours || project.hours || 0;
|
||||
|
||||
// 퍼센트 계산
|
||||
let percentage = project.percentage || project.percent || 0;
|
||||
if (percentage === 0 && totalAllHours > 0) {
|
||||
percentage = Math.round((totalHours / totalAllHours) * 100);
|
||||
}
|
||||
|
||||
detailsHtml += `
|
||||
<div class="project-detail-card">
|
||||
<div class="project-detail-header">
|
||||
<div class="project-detail-name">${projectName}</div>
|
||||
<div class="project-percentage">${percentage}%</div>
|
||||
</div>
|
||||
<div class="project-hours">${totalHours}시간</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
detailsContainer.innerHTML = detailsHtml;
|
||||
|
||||
// 차트 업데이트
|
||||
updateProjectChart(projectStats);
|
||||
}
|
||||
|
||||
// 프로젝트 차트 업데이트
|
||||
function updateProjectChart(projectStats) {
|
||||
const ctx = document.getElementById('projectChart');
|
||||
|
||||
if (projectChart) {
|
||||
projectChart.destroy();
|
||||
}
|
||||
|
||||
const labels = projectStats.map(p => p.project_name || p.name || p.projectName || '프로젝트');
|
||||
const data = projectStats.map(p => p.totalHours || p.total_hours || p.hours || 0);
|
||||
|
||||
console.log('📊 차트 라벨:', labels);
|
||||
console.log('📊 차트 데이터:', data);
|
||||
const colors = [
|
||||
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
|
||||
'#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1'
|
||||
];
|
||||
|
||||
projectChart = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: data,
|
||||
backgroundColor: colors.slice(0, data.length),
|
||||
borderWidth: 2,
|
||||
borderColor: '#ffffff'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
padding: 20,
|
||||
usePointStyle: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 오류 분석 표시
|
||||
function displayErrorAnalysis(errorStats, allData) {
|
||||
console.log('⚠️ 오류 분석 데이터 확인:', errorStats);
|
||||
console.log('⚠️ 데이터 타입:', typeof errorStats);
|
||||
console.log('⚠️ 배열 여부:', Array.isArray(errorStats));
|
||||
|
||||
// errorStats가 배열인 경우 첫 번째 요소 사용
|
||||
let errorData = errorStats;
|
||||
if (Array.isArray(errorStats) && errorStats.length > 0) {
|
||||
errorData = errorStats[0];
|
||||
console.log('⚠️ 배열에서 첫 번째 요소 사용:', errorData);
|
||||
}
|
||||
|
||||
// 오류 요약 업데이트 - 실제 데이터 구조에 맞게 수정
|
||||
const errorHours = errorData.totalHours || errorData.total_hours || errorData.error_hours || 0;
|
||||
|
||||
// 전체 작업 시간에서 오류 시간을 빼서 정규 시간 계산
|
||||
// 요약 통계에서 전체 시간을 가져와서 계산
|
||||
const totalHours = allData && allData.summary ? allData.summary.totalHours : 0;
|
||||
const normalHours = Math.max(0, totalHours - errorHours);
|
||||
|
||||
console.log('⚠️ 정규 시간:', normalHours, '오류 시간:', errorHours);
|
||||
|
||||
document.getElementById('normalHours').textContent = `${normalHours}h`;
|
||||
document.getElementById('errorHours').textContent = `${errorHours}h`;
|
||||
|
||||
// 프로젝트별 에러율 차트
|
||||
if (errorStats.projectErrorRates) {
|
||||
updateErrorByProjectChart(errorStats.projectErrorRates);
|
||||
}
|
||||
|
||||
// 일별 오류 추이 차트
|
||||
if (errorStats.dailyErrorTrend) {
|
||||
updateErrorTimelineChart(errorStats.dailyErrorTrend);
|
||||
}
|
||||
|
||||
// 오류 유형별 분석
|
||||
if (errorStats.errorTypes) {
|
||||
displayErrorTypes(errorStats.errorTypes);
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트별 에러율 차트 업데이트
|
||||
function updateErrorByProjectChart(projectErrorRates) {
|
||||
const ctx = document.getElementById('errorByProjectChart');
|
||||
|
||||
if (errorByProjectChart) {
|
||||
errorByProjectChart.destroy();
|
||||
}
|
||||
|
||||
const labels = projectErrorRates.map(p => p.project_name);
|
||||
const data = projectErrorRates.map(p => p.error_rate);
|
||||
|
||||
errorByProjectChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: '에러율 (%)',
|
||||
data: data,
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.8)',
|
||||
borderColor: 'rgba(239, 68, 68, 1)',
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return value + '%';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 일별 오류 추이 차트 업데이트
|
||||
function updateErrorTimelineChart(dailyErrorTrend) {
|
||||
const ctx = document.getElementById('errorTimelineChart');
|
||||
|
||||
if (errorTimelineChart) {
|
||||
errorTimelineChart.destroy();
|
||||
}
|
||||
|
||||
const labels = dailyErrorTrend.map(d => formatDate(new Date(d.date)));
|
||||
const errorData = dailyErrorTrend.map(d => d.error_count);
|
||||
const totalData = dailyErrorTrend.map(d => d.total_count);
|
||||
|
||||
errorTimelineChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '총 작업',
|
||||
data: totalData,
|
||||
borderColor: 'rgba(59, 130, 246, 1)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: '오류 작업',
|
||||
data: errorData,
|
||||
borderColor: 'rgba(239, 68, 68, 1)',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
fill: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 오류 유형별 분석 표시
|
||||
function displayErrorTypes(errorTypes) {
|
||||
const container = document.getElementById('errorTypesAnalysis');
|
||||
|
||||
if (!errorTypes || errorTypes.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">⚠️</div>
|
||||
<h3>오류 유형 데이터가 없습니다.</h3>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<h4>🔍 오류 유형별 상세 분석</h4>';
|
||||
|
||||
errorTypes.forEach(errorType => {
|
||||
html += `
|
||||
<div class="error-type-item">
|
||||
<div class="error-type-info">
|
||||
<div class="error-type-icon">⚠️</div>
|
||||
<div class="error-type-name">${errorType.error_name}</div>
|
||||
</div>
|
||||
<div class="error-type-stats">
|
||||
<div class="error-type-count">${errorType.count}건</div>
|
||||
<div class="error-type-percentage">${errorType.percentage}%</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// 프로젝트별 분석 로드
|
||||
async function loadProjectAnalysis() {
|
||||
const projectId = document.getElementById('projectModeSelect').value;
|
||||
const startDate = document.getElementById('projectStartDate').value;
|
||||
const endDate = document.getElementById('projectEndDate').value;
|
||||
|
||||
if (!projectId) {
|
||||
showToast('프로젝트를 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(true);
|
||||
|
||||
try {
|
||||
console.log('📁 프로젝트별 분석 데이터 로딩 시작');
|
||||
|
||||
// API 호출 파라미터 구성
|
||||
const params = new URLSearchParams({
|
||||
project_id: projectId
|
||||
});
|
||||
|
||||
if (startDate) params.append('start', startDate);
|
||||
if (endDate) params.append('end', endDate);
|
||||
|
||||
// 프로젝트별 상세 분석 데이터 로드
|
||||
const response = await apiCall(`/work-analysis/project-worktype-analysis?${params}`, 'GET');
|
||||
const projectAnalysisData = response.data || response;
|
||||
|
||||
console.log('📁 프로젝트 분석 데이터:', projectAnalysisData);
|
||||
|
||||
// 결과 표시
|
||||
displayProjectModeAnalysis(projectAnalysisData);
|
||||
|
||||
// 결과 섹션 표시
|
||||
document.getElementById('projectModeResults').style.display = 'block';
|
||||
|
||||
showToast('프로젝트 분석이 완료되었습니다.', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('프로젝트별 분석 오류:', error);
|
||||
showToast('프로젝트 분석 중 오류가 발생했습니다.', 'error');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트별 분석 결과 표시
|
||||
function displayProjectModeAnalysis(data) {
|
||||
const container = document.getElementById('projectModeResults');
|
||||
|
||||
// 프로젝트별 분석 결과 HTML 생성
|
||||
let html = `
|
||||
<div class="project-mode-analysis">
|
||||
<h3>📁 ${data.project_name} 분석 결과</h3>
|
||||
<!-- 프로젝트별 상세 분석 내용 -->
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// 로딩 상태 표시/숨김
|
||||
function showLoading(show) {
|
||||
const overlay = document.getElementById('loadingOverlay');
|
||||
if (overlay) {
|
||||
overlay.style.display = show ? 'flex' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// 날짜 포맷팅
|
||||
function formatDate(date) {
|
||||
if (!date) return '';
|
||||
|
||||
const d = new Date(date);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// 토스트 메시지 표시
|
||||
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: '10000',
|
||||
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.switchAnalysisMode = switchAnalysisMode;
|
||||
window.switchAnalysisTab = switchAnalysisTab;
|
||||
window.loadPeriodAnalysis = loadPeriodAnalysis;
|
||||
window.loadProjectAnalysis = loadProjectAnalysis;
|
||||
231
synology_deployment/web-ui/js/work-analysis/api-client.js
Normal file
231
synology_deployment/web-ui/js/work-analysis/api-client.js
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Work Analysis API Client Module
|
||||
* 작업 분석 관련 모든 API 호출을 관리하는 모듈
|
||||
*/
|
||||
|
||||
class WorkAnalysisAPIClient {
|
||||
constructor() {
|
||||
this.baseURL = window.API_BASE_URL || 'http://localhost:20005/api';
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 API 호출 메서드
|
||||
* @param {string} endpoint - API 엔드포인트
|
||||
* @param {string} method - HTTP 메서드
|
||||
* @param {Object} data - 요청 데이터
|
||||
* @returns {Promise<Object>} API 응답
|
||||
*/
|
||||
async apiCall(endpoint, method = 'GET', data = null) {
|
||||
try {
|
||||
const config = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
};
|
||||
|
||||
if (data && method !== 'GET') {
|
||||
config.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
console.log(`📡 API 호출: ${this.baseURL}${endpoint} (${method})`);
|
||||
|
||||
const response = await fetch(`${this.baseURL}${endpoint}`, config);
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
console.log(`✅ API 성공: ${this.baseURL}${endpoint}`);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ API 실패: ${this.baseURL}${endpoint}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 범위 파라미터 생성
|
||||
* @param {string} startDate - 시작일
|
||||
* @param {string} endDate - 종료일
|
||||
* @param {Object} additionalParams - 추가 파라미터
|
||||
* @returns {URLSearchParams} URL 파라미터
|
||||
*/
|
||||
createDateParams(startDate, endDate, additionalParams = {}) {
|
||||
const params = new URLSearchParams({
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
...additionalParams
|
||||
});
|
||||
return params;
|
||||
}
|
||||
|
||||
// ========== 기본 통계 API ==========
|
||||
|
||||
/**
|
||||
* 기본 통계 조회
|
||||
*/
|
||||
async getBasicStats(startDate, endDate, projectId = null) {
|
||||
console.log('🔍 getBasicStats 호출:', startDate, '~', endDate, projectId ? `(프로젝트: ${projectId})` : '');
|
||||
const params = this.createDateParams(startDate, endDate,
|
||||
projectId ? { project_id: projectId } : {}
|
||||
);
|
||||
console.log('🌐 API 요청 URL:', `/work-analysis/stats?${params}`);
|
||||
return await this.apiCall(`/work-analysis/stats?${params}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일별 추이 조회
|
||||
*/
|
||||
async getDailyTrend(startDate, endDate, projectId = null) {
|
||||
const params = this.createDateParams(startDate, endDate,
|
||||
projectId ? { project_id: projectId } : {}
|
||||
);
|
||||
return await this.apiCall(`/work-analysis/daily-trend?${params}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업자별 통계 조회
|
||||
*/
|
||||
async getWorkerStats(startDate, endDate, projectId = null) {
|
||||
const params = this.createDateParams(startDate, endDate,
|
||||
projectId ? { project_id: projectId } : {}
|
||||
);
|
||||
return await this.apiCall(`/work-analysis/worker-stats?${params}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트별 통계 조회
|
||||
*/
|
||||
async getProjectStats(startDate, endDate) {
|
||||
const params = this.createDateParams(startDate, endDate);
|
||||
return await this.apiCall(`/work-analysis/project-stats?${params}`);
|
||||
}
|
||||
|
||||
// ========== 상세 분석 API ==========
|
||||
|
||||
/**
|
||||
* 프로젝트별-작업유형별 분석
|
||||
*/
|
||||
async getProjectWorkTypeAnalysis(startDate, endDate, limit = 2000) {
|
||||
const params = this.createDateParams(startDate, endDate, { limit });
|
||||
return await this.apiCall(`/work-analysis/project-worktype-analysis?${params}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 작업 데이터 조회
|
||||
*/
|
||||
async getRecentWork(startDate, endDate, limit = 2000) {
|
||||
const params = this.createDateParams(startDate, endDate, { limit });
|
||||
return await this.apiCall(`/work-analysis/recent-work?${params}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 오류 분석 데이터 조회
|
||||
*/
|
||||
async getErrorAnalysis(startDate, endDate) {
|
||||
const params = this.createDateParams(startDate, endDate);
|
||||
return await this.apiCall(`/work-analysis/error-analysis?${params}`);
|
||||
}
|
||||
|
||||
// ========== 배치 API 호출 ==========
|
||||
|
||||
/**
|
||||
* 여러 API를 병렬로 호출
|
||||
* @param {Array} apiCalls - API 호출 배열
|
||||
* @returns {Promise<Array>} 결과 배열
|
||||
*/
|
||||
async batchCall(apiCalls) {
|
||||
console.log('🔄 배치 API 호출 시작:', apiCalls.length, '개');
|
||||
|
||||
const promises = apiCalls.map(async ({ name, method, ...args }) => {
|
||||
try {
|
||||
const result = await this[method](...args);
|
||||
return { name, success: true, data: result };
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ ${name} API 오류:`, error);
|
||||
return { name, success: false, error: error.message, data: null };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
console.log('✅ 배치 API 호출 완료');
|
||||
|
||||
return results.reduce((acc, result) => {
|
||||
acc[result.name] = result;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 데이터를 위한 배치 호출
|
||||
*/
|
||||
async getChartData(startDate, endDate, projectId = null) {
|
||||
return await this.batchCall([
|
||||
{
|
||||
name: 'dailyTrend',
|
||||
method: 'getDailyTrend',
|
||||
startDate,
|
||||
endDate,
|
||||
projectId
|
||||
},
|
||||
{
|
||||
name: 'workerStats',
|
||||
method: 'getWorkerStats',
|
||||
startDate,
|
||||
endDate,
|
||||
projectId
|
||||
},
|
||||
{
|
||||
name: 'projectStats',
|
||||
method: 'getProjectStats',
|
||||
startDate,
|
||||
endDate
|
||||
},
|
||||
{
|
||||
name: 'errorAnalysis',
|
||||
method: 'getErrorAnalysis',
|
||||
startDate,
|
||||
endDate
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트 분포 분석을 위한 배치 호출
|
||||
*/
|
||||
async getProjectDistributionData(startDate, endDate) {
|
||||
return await this.batchCall([
|
||||
{
|
||||
name: 'projectWorkType',
|
||||
method: 'getProjectWorkTypeAnalysis',
|
||||
startDate,
|
||||
endDate
|
||||
},
|
||||
{
|
||||
name: 'workerStats',
|
||||
method: 'getWorkerStats',
|
||||
startDate,
|
||||
endDate
|
||||
},
|
||||
{
|
||||
name: 'recentWork',
|
||||
method: 'getRecentWork',
|
||||
startDate,
|
||||
endDate
|
||||
}
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 인스턴스 생성
|
||||
window.WorkAnalysisAPI = new WorkAnalysisAPIClient();
|
||||
|
||||
// 하위 호환성을 위한 전역 함수
|
||||
window.apiCall = (endpoint, method, data) => {
|
||||
return window.WorkAnalysisAPI.apiCall(endpoint, method, data);
|
||||
};
|
||||
|
||||
// Export는 브라우저 환경에서 제거됨
|
||||
455
synology_deployment/web-ui/js/work-analysis/chart-renderer.js
Normal file
455
synology_deployment/web-ui/js/work-analysis/chart-renderer.js
Normal file
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* Work Analysis Chart Renderer Module
|
||||
* 작업 분석 차트 렌더링을 담당하는 모듈
|
||||
*/
|
||||
|
||||
class WorkAnalysisChartRenderer {
|
||||
constructor() {
|
||||
this.charts = new Map(); // 차트 인스턴스 관리
|
||||
this.dataProcessor = window.WorkAnalysisDataProcessor;
|
||||
this.defaultColors = [
|
||||
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
|
||||
'#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1'
|
||||
];
|
||||
}
|
||||
|
||||
// ========== 차트 관리 ==========
|
||||
|
||||
/**
|
||||
* 기존 차트 제거
|
||||
* @param {string} chartId - 차트 ID
|
||||
*/
|
||||
destroyChart(chartId) {
|
||||
if (this.charts.has(chartId)) {
|
||||
this.charts.get(chartId).destroy();
|
||||
this.charts.delete(chartId);
|
||||
console.log('🗑️ 차트 제거:', chartId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 차트 제거
|
||||
*/
|
||||
destroyAllCharts() {
|
||||
this.charts.forEach((chart, id) => {
|
||||
chart.destroy();
|
||||
console.log('🗑️ 차트 제거:', id);
|
||||
});
|
||||
this.charts.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 생성 및 등록
|
||||
* @param {string} chartId - 차트 ID
|
||||
* @param {HTMLCanvasElement} canvas - 캔버스 요소
|
||||
* @param {Object} config - 차트 설정
|
||||
* @returns {Chart} 생성된 차트 인스턴스
|
||||
*/
|
||||
createChart(chartId, canvas, config) {
|
||||
// 기존 차트가 있으면 제거
|
||||
this.destroyChart(chartId);
|
||||
|
||||
const chart = new Chart(canvas, config);
|
||||
this.charts.set(chartId, chart);
|
||||
|
||||
console.log('📊 차트 생성:', chartId);
|
||||
return chart;
|
||||
}
|
||||
|
||||
// ========== 시계열 차트 ==========
|
||||
|
||||
/**
|
||||
* 시계열 차트 렌더링 (기간별 작업 현황)
|
||||
* @param {string} startDate - 시작일
|
||||
* @param {string} endDate - 종료일
|
||||
* @param {string} projectId - 프로젝트 ID (선택사항)
|
||||
*/
|
||||
async renderTimeSeriesChart(startDate, endDate, projectId = '') {
|
||||
console.log('📈 시계열 차트 렌더링 시작');
|
||||
|
||||
try {
|
||||
const api = window.WorkAnalysisAPI;
|
||||
const dailyTrendResponse = await api.getDailyTrend(startDate, endDate, projectId);
|
||||
|
||||
if (!dailyTrendResponse.success || !dailyTrendResponse.data) {
|
||||
throw new Error('일별 추이 데이터를 가져올 수 없습니다');
|
||||
}
|
||||
|
||||
const canvas = document.getElementById('workStatusChart');
|
||||
if (!canvas) {
|
||||
console.error('❌ workStatusChart 캔버스를 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
const chartData = this.dataProcessor.processTimeSeriesData(dailyTrendResponse.data);
|
||||
|
||||
const config = {
|
||||
type: 'line',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
aspectRatio: 2,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '작업시간 (h)'
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: '작업자 수 (명)'
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '일별 작업 현황'
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.createChart('workStatus', canvas, config);
|
||||
console.log('✅ 시계열 차트 렌더링 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 시계열 차트 렌더링 실패:', error);
|
||||
this._showChartError('workStatusChart', '시계열 차트를 불러올 수 없습니다');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 스택 바 차트 ==========
|
||||
|
||||
/**
|
||||
* 스택 바 차트 렌더링 (프로젝트별 → 작업유형별)
|
||||
* @param {Array} projectData - 프로젝트 데이터
|
||||
*/
|
||||
renderStackedBarChart(projectData) {
|
||||
console.log('📊 스택 바 차트 렌더링 시작');
|
||||
|
||||
const canvas = document.getElementById('projectDistributionChart');
|
||||
if (!canvas) {
|
||||
console.error('❌ projectDistributionChart 캔버스를 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectData || !projectData.projects || projectData.projects.length === 0) {
|
||||
this._showChartError('projectDistributionChart', '프로젝트 데이터가 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 변환
|
||||
const { labels, datasets } = this._processStackedBarData(projectData.projects);
|
||||
|
||||
const config = {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
aspectRatio: 2,
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '프로젝트'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '작업시간 (h)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '프로젝트별 작업유형 분포'
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top'
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
title: function(context) {
|
||||
return `${context[0].label}`;
|
||||
},
|
||||
label: function(context) {
|
||||
const workType = context.dataset.label;
|
||||
const hours = context.parsed.y;
|
||||
const percentage = ((hours / projectData.totalHours) * 100).toFixed(1);
|
||||
return `${workType}: ${hours}h (${percentage}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.createChart('projectDistribution', canvas, config);
|
||||
console.log('✅ 스택 바 차트 렌더링 완료');
|
||||
}
|
||||
|
||||
/**
|
||||
* 스택 바 차트 데이터 처리
|
||||
*/
|
||||
_processStackedBarData(projects) {
|
||||
// 모든 작업유형 수집
|
||||
const allWorkTypes = new Set();
|
||||
projects.forEach(project => {
|
||||
project.workTypes.forEach(wt => {
|
||||
allWorkTypes.add(wt.work_type_name);
|
||||
});
|
||||
});
|
||||
|
||||
const workTypeArray = Array.from(allWorkTypes);
|
||||
const labels = projects.map(p => p.project_name);
|
||||
|
||||
// 작업유형별 데이터셋 생성
|
||||
const datasets = workTypeArray.map((workTypeName, index) => {
|
||||
const data = projects.map(project => {
|
||||
const workType = project.workTypes.find(wt => wt.work_type_name === workTypeName);
|
||||
return workType ? workType.totalHours : 0;
|
||||
});
|
||||
|
||||
return {
|
||||
label: workTypeName,
|
||||
data,
|
||||
backgroundColor: this.defaultColors[index % this.defaultColors.length],
|
||||
borderColor: this.defaultColors[index % this.defaultColors.length],
|
||||
borderWidth: 1
|
||||
};
|
||||
});
|
||||
|
||||
return { labels, datasets };
|
||||
}
|
||||
|
||||
// ========== 도넛 차트 ==========
|
||||
|
||||
/**
|
||||
* 도넛 차트 렌더링 (작업자별 성과)
|
||||
* @param {Array} workerData - 작업자 데이터
|
||||
*/
|
||||
renderWorkerPerformanceChart(workerData) {
|
||||
console.log('👤 작업자별 성과 차트 렌더링 시작');
|
||||
|
||||
const canvas = document.getElementById('workerPerformanceChart');
|
||||
if (!canvas) {
|
||||
console.error('❌ workerPerformanceChart 캔버스를 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workerData || workerData.length === 0) {
|
||||
this._showChartError('workerPerformanceChart', '작업자 데이터가 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
const chartData = this.dataProcessor.processDonutChartData(
|
||||
workerData.map(worker => ({
|
||||
name: worker.worker_name,
|
||||
hours: worker.totalHours
|
||||
}))
|
||||
);
|
||||
|
||||
const config = {
|
||||
type: 'doughnut',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
aspectRatio: 1,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '작업자별 작업시간 분포'
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'right'
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const label = context.label;
|
||||
const value = context.parsed;
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const percentage = ((value / total) * 100).toFixed(1);
|
||||
return `${label}: ${value}h (${percentage}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.createChart('workerPerformance', canvas, config);
|
||||
console.log('✅ 작업자별 성과 차트 렌더링 완료');
|
||||
}
|
||||
|
||||
// ========== 오류 분석 차트 ==========
|
||||
|
||||
/**
|
||||
* 오류 분석 차트 렌더링
|
||||
* @param {Array} errorData - 오류 데이터
|
||||
*/
|
||||
renderErrorAnalysisChart(errorData) {
|
||||
console.log('⚠️ 오류 분석 차트 렌더링 시작');
|
||||
|
||||
const canvas = document.getElementById('errorAnalysisChart');
|
||||
if (!canvas) {
|
||||
console.error('❌ errorAnalysisChart 캔버스를 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!errorData || errorData.length === 0) {
|
||||
this._showChartError('errorAnalysisChart', '오류 데이터가 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
// 오류가 있는 데이터만 필터링
|
||||
const errorItems = errorData.filter(item =>
|
||||
item.error_count > 0 || (item.errorDetails && item.errorDetails.length > 0)
|
||||
);
|
||||
|
||||
if (errorItems.length === 0) {
|
||||
this._showChartError('errorAnalysisChart', '오류가 발생한 항목이 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
const chartData = this.dataProcessor.processDonutChartData(
|
||||
errorItems.map(item => ({
|
||||
name: item.project_name || item.name,
|
||||
hours: item.errorHours || item.error_count
|
||||
}))
|
||||
);
|
||||
|
||||
const config = {
|
||||
type: 'doughnut',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
aspectRatio: 1,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '프로젝트별 오류 분포'
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom'
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const label = context.label;
|
||||
const value = context.parsed;
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const percentage = ((value / total) * 100).toFixed(1);
|
||||
return `${label}: ${value}h (${percentage}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.createChart('errorAnalysis', canvas, config);
|
||||
console.log('✅ 오류 분석 차트 렌더링 완료');
|
||||
}
|
||||
|
||||
// ========== 유틸리티 ==========
|
||||
|
||||
/**
|
||||
* 차트 오류 표시
|
||||
* @param {string} canvasId - 캔버스 ID
|
||||
* @param {string} message - 오류 메시지
|
||||
*/
|
||||
_showChartError(canvasId, message) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!canvas) return;
|
||||
|
||||
const container = canvas.parentElement;
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<div style="
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
">
|
||||
<div style="font-size: 3rem; margin-bottom: 1rem;">📊</div>
|
||||
<div style="font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem;">차트를 표시할 수 없습니다</div>
|
||||
<div style="font-size: 0.9rem;">${message}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 리사이즈
|
||||
*/
|
||||
resizeCharts() {
|
||||
this.charts.forEach((chart, id) => {
|
||||
try {
|
||||
chart.resize();
|
||||
console.log('📏 차트 리사이즈:', id);
|
||||
} catch (error) {
|
||||
console.warn('⚠️ 차트 리사이즈 실패:', id, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 상태 확인
|
||||
*/
|
||||
getChartStatus() {
|
||||
const status = {};
|
||||
this.charts.forEach((chart, id) => {
|
||||
status[id] = {
|
||||
type: chart.config.type,
|
||||
datasetCount: chart.data.datasets.length,
|
||||
dataPointCount: chart.data.labels ? chart.data.labels.length : 0
|
||||
};
|
||||
});
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 인스턴스 생성
|
||||
window.WorkAnalysisChartRenderer = new WorkAnalysisChartRenderer();
|
||||
|
||||
// 윈도우 리사이즈 이벤트 리스너
|
||||
window.addEventListener('resize', () => {
|
||||
window.WorkAnalysisChartRenderer.resizeCharts();
|
||||
});
|
||||
|
||||
// Export는 브라우저 환경에서 제거됨
|
||||
355
synology_deployment/web-ui/js/work-analysis/data-processor.js
Normal file
355
synology_deployment/web-ui/js/work-analysis/data-processor.js
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Work Analysis Data Processor Module
|
||||
* 작업 분석 데이터 가공 및 변환을 담당하는 모듈
|
||||
*/
|
||||
|
||||
class WorkAnalysisDataProcessor {
|
||||
|
||||
// ========== 유틸리티 함수 ==========
|
||||
|
||||
/**
|
||||
* 주말 여부 확인
|
||||
* @param {string} dateString - 날짜 문자열
|
||||
* @returns {boolean} 주말 여부
|
||||
*/
|
||||
isWeekendDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const dayOfWeek = date.getDay();
|
||||
return dayOfWeek === 0 || dayOfWeek === 6; // 일요일(0) 또는 토요일(6)
|
||||
}
|
||||
|
||||
/**
|
||||
* 연차/휴무 프로젝트 여부 확인
|
||||
* @param {string} projectName - 프로젝트명
|
||||
* @returns {boolean} 연차/휴무 여부
|
||||
*/
|
||||
isVacationProject(projectName) {
|
||||
if (!projectName) return false;
|
||||
const vacationKeywords = ['연차', '휴무', '휴가', '병가', '특별휴가'];
|
||||
return vacationKeywords.some(keyword => projectName.includes(keyword));
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 포맷팅 (간단한 형식)
|
||||
* @param {string} dateString - 날짜 문자열
|
||||
* @returns {string} 포맷된 날짜
|
||||
*/
|
||||
formatSimpleDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
return dateString.split('T')[0]; // 시간 부분 제거
|
||||
}
|
||||
|
||||
// ========== 프로젝트 분포 데이터 처리 ==========
|
||||
|
||||
/**
|
||||
* 프로젝트별 데이터 집계
|
||||
* @param {Array} recentWorkData - 최근 작업 데이터
|
||||
* @returns {Object} 집계된 프로젝트 데이터
|
||||
*/
|
||||
aggregateProjectData(recentWorkData) {
|
||||
console.log('📊 프로젝트 데이터 집계 시작');
|
||||
|
||||
if (!recentWorkData || recentWorkData.length === 0) {
|
||||
return { projects: [], totalHours: 0 };
|
||||
}
|
||||
|
||||
const projectMap = new Map();
|
||||
let vacationData = null;
|
||||
|
||||
recentWorkData.forEach(work => {
|
||||
const isWeekend = this.isWeekendDate(work.report_date);
|
||||
const isVacation = this.isVacationProject(work.project_name);
|
||||
|
||||
// 주말 연차는 제외
|
||||
if (isWeekend && isVacation) {
|
||||
console.log('🏖️ 주말 연차/휴무 제외:', work.report_date, work.project_name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVacation) {
|
||||
// 연차/휴무 통합 처리
|
||||
if (!vacationData) {
|
||||
vacationData = {
|
||||
project_id: 'vacation',
|
||||
project_name: '연차/휴무',
|
||||
job_no: null,
|
||||
totalHours: 0,
|
||||
workTypes: new Map()
|
||||
};
|
||||
}
|
||||
this._addWorkToProject(vacationData, work, '연차/휴무');
|
||||
} else {
|
||||
// 일반 프로젝트 처리
|
||||
const projectKey = work.project_id || 'unknown';
|
||||
|
||||
if (!projectMap.has(projectKey)) {
|
||||
projectMap.set(projectKey, {
|
||||
project_id: projectKey,
|
||||
project_name: work.project_name || `프로젝트 ${projectKey}`,
|
||||
job_no: work.job_no,
|
||||
totalHours: 0,
|
||||
workTypes: new Map()
|
||||
});
|
||||
}
|
||||
|
||||
const project = projectMap.get(projectKey);
|
||||
this._addWorkToProject(project, work);
|
||||
}
|
||||
});
|
||||
|
||||
// 결과 배열 생성
|
||||
const projects = Array.from(projectMap.values());
|
||||
if (vacationData && vacationData.totalHours > 0) {
|
||||
projects.push(vacationData);
|
||||
}
|
||||
|
||||
// 작업유형을 배열로 변환하고 정렬
|
||||
projects.forEach(project => {
|
||||
project.workTypes = Array.from(project.workTypes.values())
|
||||
.sort((a, b) => b.totalHours - a.totalHours);
|
||||
});
|
||||
|
||||
// 프로젝트를 총 시간 순으로 정렬 (연차/휴무는 맨 아래)
|
||||
projects.sort((a, b) => {
|
||||
if (a.project_id === 'vacation') return 1;
|
||||
if (b.project_id === 'vacation') return -1;
|
||||
return b.totalHours - a.totalHours;
|
||||
});
|
||||
|
||||
const totalHours = projects.reduce((sum, p) => sum + p.totalHours, 0);
|
||||
|
||||
console.log('✅ 프로젝트 데이터 집계 완료:', projects.length, '개 프로젝트');
|
||||
return { projects, totalHours };
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트에 작업 데이터 추가 (내부 헬퍼)
|
||||
*/
|
||||
_addWorkToProject(project, work, overrideWorkTypeName = null) {
|
||||
const hours = parseFloat(work.work_hours) || 0;
|
||||
project.totalHours += hours;
|
||||
|
||||
const workTypeKey = work.work_type_id || 'unknown';
|
||||
const workTypeName = overrideWorkTypeName || work.work_type_name || `작업유형 ${workTypeKey}`;
|
||||
|
||||
if (!project.workTypes.has(workTypeKey)) {
|
||||
project.workTypes.set(workTypeKey, {
|
||||
work_type_id: workTypeKey,
|
||||
work_type_name: workTypeName,
|
||||
totalHours: 0
|
||||
});
|
||||
}
|
||||
|
||||
project.workTypes.get(workTypeKey).totalHours += hours;
|
||||
}
|
||||
|
||||
// ========== 오류 분석 데이터 처리 ==========
|
||||
|
||||
/**
|
||||
* 작업 형태별 오류 데이터 집계
|
||||
* @param {Array} recentWorkData - 최근 작업 데이터
|
||||
* @returns {Array} 집계된 오류 데이터
|
||||
*/
|
||||
aggregateErrorData(recentWorkData) {
|
||||
console.log('📊 오류 분석 데이터 집계 시작');
|
||||
|
||||
if (!recentWorkData || recentWorkData.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const workTypeMap = new Map();
|
||||
let vacationData = null;
|
||||
|
||||
recentWorkData.forEach(work => {
|
||||
const isWeekend = this.isWeekendDate(work.report_date);
|
||||
const isVacation = this.isVacationProject(work.project_name);
|
||||
|
||||
// 주말 연차는 완전히 제외
|
||||
if (isWeekend && isVacation) {
|
||||
console.log('🏖️ 주말 연차/휴무 제외:', work.report_date, work.project_name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVacation) {
|
||||
// 모든 연차/휴무를 하나로 통합
|
||||
if (!vacationData) {
|
||||
vacationData = {
|
||||
project_id: 'vacation',
|
||||
project_name: '연차/휴무',
|
||||
job_no: null,
|
||||
work_type_id: 'vacation',
|
||||
work_type_name: '연차/휴무',
|
||||
regularHours: 0,
|
||||
errorHours: 0,
|
||||
errorDetails: new Map(),
|
||||
isVacation: true
|
||||
};
|
||||
}
|
||||
|
||||
this._addWorkToErrorData(vacationData, work);
|
||||
} else {
|
||||
// 일반 프로젝트 처리
|
||||
const workTypeKey = work.work_type_id || 'unknown';
|
||||
const combinedKey = `${work.project_id || 'unknown'}_${workTypeKey}`;
|
||||
|
||||
if (!workTypeMap.has(combinedKey)) {
|
||||
workTypeMap.set(combinedKey, {
|
||||
project_id: work.project_id,
|
||||
project_name: work.project_name || `프로젝트 ${work.project_id}`,
|
||||
job_no: work.job_no,
|
||||
work_type_id: workTypeKey,
|
||||
work_type_name: work.work_type_name || `작업유형 ${workTypeKey}`,
|
||||
regularHours: 0,
|
||||
errorHours: 0,
|
||||
errorDetails: new Map(),
|
||||
isVacation: false
|
||||
});
|
||||
}
|
||||
|
||||
const workTypeData = workTypeMap.get(combinedKey);
|
||||
this._addWorkToErrorData(workTypeData, work);
|
||||
}
|
||||
});
|
||||
|
||||
// 결과 배열 생성
|
||||
const result = Array.from(workTypeMap.values());
|
||||
|
||||
// 연차/휴무 데이터가 있으면 추가
|
||||
if (vacationData && (vacationData.regularHours > 0 || vacationData.errorHours > 0)) {
|
||||
result.push(vacationData);
|
||||
}
|
||||
|
||||
// 최종 데이터 처리
|
||||
const processedResult = result.map(wt => ({
|
||||
...wt,
|
||||
totalHours: wt.regularHours + wt.errorHours,
|
||||
errorRate: wt.regularHours + wt.errorHours > 0 ?
|
||||
((wt.errorHours / (wt.regularHours + wt.errorHours)) * 100).toFixed(1) : '0.0',
|
||||
errorDetails: Array.from(wt.errorDetails.entries()).map(([type, hours]) => ({
|
||||
type, hours
|
||||
}))
|
||||
})).filter(wt => wt.totalHours > 0) // 시간이 있는 것만 표시
|
||||
.sort((a, b) => {
|
||||
// 연차/휴무를 맨 아래로
|
||||
if (a.isVacation && !b.isVacation) return 1;
|
||||
if (!a.isVacation && b.isVacation) return -1;
|
||||
|
||||
// 같은 프로젝트 내에서는 오류 시간 순으로 정렬
|
||||
if (a.project_id === b.project_id) {
|
||||
return b.errorHours - a.errorHours;
|
||||
}
|
||||
|
||||
// 다른 프로젝트는 프로젝트명 순으로 정렬
|
||||
return (a.project_name || '').localeCompare(b.project_name || '');
|
||||
});
|
||||
|
||||
console.log('✅ 오류 분석 데이터 집계 완료:', processedResult.length, '개 항목');
|
||||
return processedResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업 데이터를 오류 분석 데이터에 추가 (내부 헬퍼)
|
||||
*/
|
||||
_addWorkToErrorData(workTypeData, work) {
|
||||
const hours = parseFloat(work.work_hours) || 0;
|
||||
|
||||
if (work.work_status === 'error' || work.error_type_id) {
|
||||
workTypeData.errorHours += hours;
|
||||
|
||||
// 오류 유형별 세분화
|
||||
const errorTypeName = work.error_type_name || work.error_description || '설계미스';
|
||||
if (!workTypeData.errorDetails.has(errorTypeName)) {
|
||||
workTypeData.errorDetails.set(errorTypeName, 0);
|
||||
}
|
||||
workTypeData.errorDetails.set(errorTypeName,
|
||||
workTypeData.errorDetails.get(errorTypeName) + hours
|
||||
);
|
||||
} else {
|
||||
workTypeData.regularHours += hours;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 차트 데이터 처리 ==========
|
||||
|
||||
/**
|
||||
* 시계열 차트 데이터 변환
|
||||
* @param {Array} dailyData - 일별 데이터
|
||||
* @returns {Object} 차트 데이터
|
||||
*/
|
||||
processTimeSeriesData(dailyData) {
|
||||
if (!dailyData || dailyData.length === 0) {
|
||||
return { labels: [], datasets: [] };
|
||||
}
|
||||
|
||||
const labels = dailyData.map(item => this.formatSimpleDate(item.date));
|
||||
const hours = dailyData.map(item => item.total_hours || 0);
|
||||
const workers = dailyData.map(item => item.worker_count || 0);
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '총 작업시간',
|
||||
data: hours,
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: '참여 작업자 수',
|
||||
data: workers,
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
tension: 0.4,
|
||||
yAxisID: 'y1'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 도넛 차트 데이터 변환
|
||||
* @param {Array} projectData - 프로젝트 데이터
|
||||
* @returns {Object} 차트 데이터
|
||||
*/
|
||||
processDonutChartData(projectData) {
|
||||
if (!projectData || projectData.length === 0) {
|
||||
return { labels: [], datasets: [] };
|
||||
}
|
||||
|
||||
const labels = projectData.map(item => item.project_name || item.name);
|
||||
const data = projectData.map(item => item.total_hours || item.hours || 0);
|
||||
const colors = this._generateColors(data.length);
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [{
|
||||
data,
|
||||
backgroundColor: colors,
|
||||
borderWidth: 2,
|
||||
borderColor: '#ffffff'
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 색상 생성 헬퍼
|
||||
*/
|
||||
_generateColors(count) {
|
||||
const baseColors = [
|
||||
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
|
||||
'#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1'
|
||||
];
|
||||
|
||||
const colors = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
colors.push(baseColors[i % baseColors.length]);
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 인스턴스 생성
|
||||
window.WorkAnalysisDataProcessor = new WorkAnalysisDataProcessor();
|
||||
|
||||
// Export는 브라우저 환경에서 제거됨
|
||||
612
synology_deployment/web-ui/js/work-analysis/main-controller.js
Normal file
612
synology_deployment/web-ui/js/work-analysis/main-controller.js
Normal file
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* Work Analysis Main Controller Module
|
||||
* 작업 분석 페이지의 메인 컨트롤러 - 모든 모듈을 조율하고 사용자 상호작용을 처리
|
||||
*/
|
||||
|
||||
class WorkAnalysisMainController {
|
||||
constructor() {
|
||||
this.api = window.WorkAnalysisAPI;
|
||||
this.state = window.WorkAnalysisState;
|
||||
this.dataProcessor = window.WorkAnalysisDataProcessor;
|
||||
this.tableRenderer = window.WorkAnalysisTableRenderer;
|
||||
this.chartRenderer = window.WorkAnalysisChartRenderer;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* 초기화
|
||||
*/
|
||||
init() {
|
||||
console.log('🚀 작업 분석 메인 컨트롤러 초기화');
|
||||
|
||||
this.setupEventListeners();
|
||||
this.setupStateListeners();
|
||||
this.initializeUI();
|
||||
|
||||
console.log('✅ 작업 분석 메인 컨트롤러 초기화 완료');
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 설정
|
||||
*/
|
||||
setupEventListeners() {
|
||||
// 기간 확정 버튼
|
||||
const confirmButton = document.getElementById('confirmPeriodBtn');
|
||||
if (confirmButton) {
|
||||
confirmButton.addEventListener('click', () => this.handlePeriodConfirm());
|
||||
}
|
||||
|
||||
// 분석 모드 탭
|
||||
document.querySelectorAll('[data-mode]').forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const mode = e.target.dataset.mode;
|
||||
this.handleModeChange(mode);
|
||||
});
|
||||
});
|
||||
|
||||
// 분석 탭 네비게이션
|
||||
document.querySelectorAll('[data-tab]').forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const tabId = e.target.dataset.tab;
|
||||
this.handleTabChange(tabId);
|
||||
});
|
||||
});
|
||||
|
||||
// 개별 분석 실행 버튼들
|
||||
this.setupAnalysisButtons();
|
||||
|
||||
// 날짜 입력 필드
|
||||
const startDateInput = document.getElementById('startDate');
|
||||
const endDateInput = document.getElementById('endDate');
|
||||
|
||||
if (startDateInput && endDateInput) {
|
||||
[startDateInput, endDateInput].forEach(input => {
|
||||
input.addEventListener('change', () => this.handleDateChange());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 분석 버튼 설정
|
||||
*/
|
||||
setupAnalysisButtons() {
|
||||
const buttons = [
|
||||
{ selector: 'button[onclick*="analyzeWorkStatus"]', handler: () => this.analyzeWorkStatus() },
|
||||
{ selector: 'button[onclick*="analyzeProjectDistribution"]', handler: () => this.analyzeProjectDistribution() },
|
||||
{ selector: 'button[onclick*="analyzeWorkerPerformance"]', handler: () => this.analyzeWorkerPerformance() },
|
||||
{ selector: 'button[onclick*="analyzeErrorAnalysis"]', handler: () => this.analyzeErrorAnalysis() }
|
||||
];
|
||||
|
||||
buttons.forEach(({ selector, handler }) => {
|
||||
const button = document.querySelector(selector);
|
||||
if (button) {
|
||||
// 기존 onclick 제거하고 새 이벤트 리스너 추가
|
||||
button.removeAttribute('onclick');
|
||||
button.addEventListener('click', handler);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 리스너 설정
|
||||
*/
|
||||
setupStateListeners() {
|
||||
// 기간 확정 상태 변경 시 UI 업데이트
|
||||
this.state.subscribe('periodConfirmed', (newState, prevState) => {
|
||||
this.updateAnalysisButtons(newState.isAnalysisEnabled);
|
||||
|
||||
if (newState.confirmedPeriod.confirmed && !prevState.confirmedPeriod.confirmed) {
|
||||
this.showAnalysisTabs();
|
||||
}
|
||||
});
|
||||
|
||||
// 로딩 상태 변경 시 UI 업데이트
|
||||
this.state.subscribe('loadingState', (newState) => {
|
||||
if (newState.isLoading) {
|
||||
this.showLoading(newState.loadingMessage);
|
||||
} else {
|
||||
this.hideLoading();
|
||||
}
|
||||
});
|
||||
|
||||
// 탭 변경 시 UI 업데이트
|
||||
this.state.subscribe('tabChange', (newState) => {
|
||||
this.updateActiveTab(newState.currentTab);
|
||||
});
|
||||
|
||||
// 에러 발생 시 처리
|
||||
this.state.subscribe('errorOccurred', (newState) => {
|
||||
if (newState.lastError) {
|
||||
this.handleError(newState.lastError);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* UI 초기화
|
||||
*/
|
||||
initializeUI() {
|
||||
// 기본 날짜 설정
|
||||
const currentState = this.state.getState();
|
||||
const startDateInput = document.getElementById('startDate');
|
||||
const endDateInput = document.getElementById('endDate');
|
||||
|
||||
if (startDateInput && currentState.confirmedPeriod.start) {
|
||||
startDateInput.value = currentState.confirmedPeriod.start;
|
||||
}
|
||||
|
||||
if (endDateInput && currentState.confirmedPeriod.end) {
|
||||
endDateInput.value = currentState.confirmedPeriod.end;
|
||||
}
|
||||
|
||||
// 분석 버튼 초기 상태 설정
|
||||
this.updateAnalysisButtons(false);
|
||||
|
||||
// 분석 탭 숨김
|
||||
this.hideAnalysisTabs();
|
||||
}
|
||||
|
||||
// ========== 이벤트 핸들러 ==========
|
||||
|
||||
/**
|
||||
* 기간 확정 처리
|
||||
*/
|
||||
async handlePeriodConfirm() {
|
||||
try {
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
const endDate = document.getElementById('endDate').value;
|
||||
|
||||
console.log('🔄 기간 확정 처리 시작:', startDate, '~', endDate);
|
||||
|
||||
this.state.confirmPeriod(startDate, endDate);
|
||||
|
||||
this.showToast('기간이 확정되었습니다', 'success');
|
||||
|
||||
console.log('✅ 기간 확정 완료 - 각 분석 버튼을 눌러서 데이터를 확인하세요');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 기간 확정 처리 오류:', error);
|
||||
this.state.setError(error);
|
||||
this.showToast(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석 모드 변경 처리
|
||||
*/
|
||||
handleModeChange(mode) {
|
||||
try {
|
||||
this.state.setAnalysisMode(mode);
|
||||
this.updateModeButtons(mode);
|
||||
|
||||
// 캐시 초기화
|
||||
this.state.clearCache();
|
||||
|
||||
} catch (error) {
|
||||
this.state.setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 탭 변경 처리
|
||||
*/
|
||||
handleTabChange(tabId) {
|
||||
this.state.setCurrentTab(tabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 변경 처리
|
||||
*/
|
||||
handleDateChange() {
|
||||
// 날짜가 변경되면 기간 확정 상태 해제
|
||||
this.state.updateState({
|
||||
confirmedPeriod: {
|
||||
...this.state.getState().confirmedPeriod,
|
||||
confirmed: false
|
||||
},
|
||||
isAnalysisEnabled: false
|
||||
});
|
||||
|
||||
this.updateAnalysisButtons(false);
|
||||
this.hideAnalysisTabs();
|
||||
}
|
||||
|
||||
// ========== 분석 실행 ==========
|
||||
|
||||
/**
|
||||
* 기본 통계 로드
|
||||
*/
|
||||
async loadBasicStats() {
|
||||
const currentState = this.state.getState();
|
||||
const { start, end } = currentState.confirmedPeriod;
|
||||
|
||||
try {
|
||||
console.log('📊 기본 통계 로딩 시작 - 기간:', start, '~', end);
|
||||
this.state.startLoading('기본 통계를 로딩 중입니다...');
|
||||
|
||||
console.log('🌐 API 호출 전 - getBasicStats 호출...');
|
||||
const statsResponse = await this.api.getBasicStats(start, end);
|
||||
|
||||
console.log('📊 기본 통계 API 응답:', statsResponse);
|
||||
|
||||
if (statsResponse.success && statsResponse.data) {
|
||||
const stats = statsResponse.data;
|
||||
|
||||
// 정상/오류 시간 계산
|
||||
const totalHours = stats.totalHours || 0;
|
||||
const errorReports = stats.errorRate || 0;
|
||||
const errorHours = Math.round(totalHours * (errorReports / 100));
|
||||
const normalHours = totalHours - errorHours;
|
||||
|
||||
const cardData = {
|
||||
totalHours: totalHours,
|
||||
normalHours: normalHours,
|
||||
errorHours: errorHours,
|
||||
workerCount: stats.activeWorkers || stats.activeworkers || 0,
|
||||
errorRate: errorReports
|
||||
};
|
||||
|
||||
this.state.setCache('basicStats', cardData);
|
||||
this.updateResultCards(cardData);
|
||||
|
||||
console.log('✅ 기본 통계 로딩 완료:', cardData);
|
||||
} else {
|
||||
// 기본값으로 카드 업데이트
|
||||
const defaultData = {
|
||||
totalHours: 0,
|
||||
normalHours: 0,
|
||||
errorHours: 0,
|
||||
workerCount: 0,
|
||||
errorRate: 0
|
||||
};
|
||||
this.updateResultCards(defaultData);
|
||||
console.warn('⚠️ 기본 통계 데이터가 없어서 기본값으로 설정');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 기본 통계 로드 실패:', error);
|
||||
// 에러 시에도 기본값으로 카드 업데이트
|
||||
const defaultData = {
|
||||
totalHours: 0,
|
||||
normalHours: 0,
|
||||
errorHours: 0,
|
||||
workerCount: 0,
|
||||
errorRate: 0
|
||||
};
|
||||
this.updateResultCards(defaultData);
|
||||
} finally {
|
||||
this.state.stopLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기간별 작업 현황 분석
|
||||
*/
|
||||
async analyzeWorkStatus() {
|
||||
if (!this.state.canAnalyze()) {
|
||||
this.showToast('기간을 먼저 확정해주세요', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = this.state.getState();
|
||||
const { start, end } = currentState.confirmedPeriod;
|
||||
|
||||
try {
|
||||
this.state.startLoading('기간별 작업 현황을 분석 중입니다...');
|
||||
|
||||
// 실제 API 호출
|
||||
const batchData = await this.api.batchCall([
|
||||
{
|
||||
name: 'projectWorkType',
|
||||
method: 'getProjectWorkTypeAnalysis',
|
||||
startDate: start,
|
||||
endDate: end
|
||||
},
|
||||
{
|
||||
name: 'workerStats',
|
||||
method: 'getWorkerStats',
|
||||
startDate: start,
|
||||
endDate: end
|
||||
},
|
||||
{
|
||||
name: 'recentWork',
|
||||
method: 'getRecentWork',
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
limit: 2000
|
||||
}
|
||||
]);
|
||||
|
||||
console.log('🔍 기간별 작업 현황 API 응답:', batchData);
|
||||
|
||||
// 데이터 처리
|
||||
const recentWorkData = batchData.recentWork?.success ? batchData.recentWork.data.data : [];
|
||||
const workerData = batchData.workerStats?.success ? batchData.workerStats.data.data : [];
|
||||
|
||||
const projectData = this.dataProcessor.aggregateProjectData(recentWorkData);
|
||||
|
||||
// 테이블 렌더링
|
||||
this.tableRenderer.renderWorkStatusTable(projectData, workerData, recentWorkData);
|
||||
|
||||
this.showToast('기간별 작업 현황 분석이 완료되었습니다', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 기간별 작업 현황 분석 오류:', error);
|
||||
this.state.setError(error);
|
||||
this.showToast('기간별 작업 현황 분석에 실패했습니다', 'error');
|
||||
} finally {
|
||||
this.state.stopLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트별 분포 분석
|
||||
*/
|
||||
async analyzeProjectDistribution() {
|
||||
if (!this.state.canAnalyze()) {
|
||||
this.showToast('기간을 먼저 확정해주세요', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = this.state.getState();
|
||||
const { start, end } = currentState.confirmedPeriod;
|
||||
|
||||
try {
|
||||
this.state.startLoading('프로젝트별 분포를 분석 중입니다...');
|
||||
|
||||
// 실제 API 호출
|
||||
const distributionData = await this.api.getProjectDistributionData(start, end);
|
||||
|
||||
console.log('🔍 프로젝트별 분포 API 응답:', distributionData);
|
||||
|
||||
// 데이터 처리
|
||||
const recentWorkData = distributionData.recentWork?.success ? distributionData.recentWork.data.data : [];
|
||||
const workerData = distributionData.workerStats?.success ? distributionData.workerStats.data.data : [];
|
||||
|
||||
const projectData = this.dataProcessor.aggregateProjectData(recentWorkData);
|
||||
|
||||
console.log('📊 취합된 프로젝트 데이터:', projectData);
|
||||
|
||||
// 테이블 렌더링
|
||||
this.tableRenderer.renderProjectDistributionTable(projectData, workerData);
|
||||
|
||||
this.showToast('프로젝트별 분포 분석이 완료되었습니다', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 프로젝트별 분포 분석 오류:', error);
|
||||
this.state.setError(error);
|
||||
this.showToast('프로젝트별 분포 분석에 실패했습니다', 'error');
|
||||
} finally {
|
||||
this.state.stopLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업자별 성과 분석
|
||||
*/
|
||||
async analyzeWorkerPerformance() {
|
||||
if (!this.state.canAnalyze()) {
|
||||
this.showToast('기간을 먼저 확정해주세요', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = this.state.getState();
|
||||
const { start, end } = currentState.confirmedPeriod;
|
||||
|
||||
try {
|
||||
this.state.startLoading('작업자별 성과를 분석 중입니다...');
|
||||
|
||||
const workerStatsResponse = await this.api.getWorkerStats(start, end);
|
||||
|
||||
console.log('👤 작업자 통계 API 응답:', workerStatsResponse);
|
||||
|
||||
if (workerStatsResponse.success && workerStatsResponse.data) {
|
||||
this.chartRenderer.renderWorkerPerformanceChart(workerStatsResponse.data);
|
||||
this.showToast('작업자별 성과 분석이 완료되었습니다', 'success');
|
||||
} else {
|
||||
throw new Error('작업자 데이터를 가져올 수 없습니다');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 작업자별 성과 분석 오류:', error);
|
||||
this.state.setError(error);
|
||||
this.showToast('작업자별 성과 분석에 실패했습니다', 'error');
|
||||
} finally {
|
||||
this.state.stopLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 오류 분석
|
||||
*/
|
||||
async analyzeErrorAnalysis() {
|
||||
if (!this.state.canAnalyze()) {
|
||||
this.showToast('기간을 먼저 확정해주세요', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = this.state.getState();
|
||||
const { start, end } = currentState.confirmedPeriod;
|
||||
|
||||
try {
|
||||
this.state.startLoading('오류 분석을 진행 중입니다...');
|
||||
|
||||
// 병렬로 API 호출
|
||||
const [recentWorkResponse, errorAnalysisResponse] = await Promise.all([
|
||||
this.api.getRecentWork(start, end, 2000),
|
||||
this.api.getErrorAnalysis(start, end)
|
||||
]);
|
||||
|
||||
console.log('🔍 오류 분석 API 응답:', recentWorkResponse);
|
||||
|
||||
if (recentWorkResponse.success && recentWorkResponse.data) {
|
||||
this.tableRenderer.renderErrorAnalysisTable(recentWorkResponse.data);
|
||||
this.showToast('오류 분석이 완료되었습니다', 'success');
|
||||
} else {
|
||||
throw new Error('작업 데이터를 가져올 수 없습니다');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 오류 분석 실패:', error);
|
||||
this.state.setError(error);
|
||||
this.showToast('오류 분석에 실패했습니다', 'error');
|
||||
} finally {
|
||||
this.state.stopLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// ========== UI 업데이트 ==========
|
||||
|
||||
/**
|
||||
* 결과 카드 업데이트
|
||||
*/
|
||||
updateResultCards(stats) {
|
||||
const cards = {
|
||||
totalHours: stats.totalHours || 0,
|
||||
normalHours: stats.normalHours || 0,
|
||||
errorHours: stats.errorHours || 0,
|
||||
workerCount: stats.activeWorkers || 0,
|
||||
errorRate: stats.errorRate || 0
|
||||
};
|
||||
|
||||
Object.entries(cards).forEach(([key, value]) => {
|
||||
const element = document.getElementById(key);
|
||||
if (element) {
|
||||
element.textContent = typeof value === 'number' ?
|
||||
(key.includes('Rate') ? `${value}%` : value.toLocaleString()) : value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석 버튼 상태 업데이트
|
||||
*/
|
||||
updateAnalysisButtons(enabled) {
|
||||
const buttons = document.querySelectorAll('.chart-analyze-btn');
|
||||
buttons.forEach(button => {
|
||||
button.disabled = !enabled;
|
||||
button.style.opacity = enabled ? '1' : '0.5';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석 탭 표시
|
||||
*/
|
||||
showAnalysisTabs() {
|
||||
const tabNavigation = document.getElementById('analysisTabNavigation');
|
||||
if (tabNavigation) {
|
||||
tabNavigation.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석 탭 숨김
|
||||
*/
|
||||
hideAnalysisTabs() {
|
||||
const tabNavigation = document.getElementById('analysisTabNavigation');
|
||||
if (tabNavigation) {
|
||||
tabNavigation.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 탭 업데이트
|
||||
*/
|
||||
updateActiveTab(tabId) {
|
||||
// 탭 버튼 업데이트
|
||||
document.querySelectorAll('.tab-button').forEach(button => {
|
||||
button.classList.remove('active');
|
||||
if (button.dataset.tab === tabId) {
|
||||
button.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// 탭 컨텐츠 업데이트
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
if (content.id === `${tabId}-tab`) {
|
||||
content.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 모드 버튼 업데이트
|
||||
*/
|
||||
updateModeButtons(mode) {
|
||||
document.querySelectorAll('[data-mode]').forEach(button => {
|
||||
button.classList.remove('active');
|
||||
if (button.dataset.mode === mode) {
|
||||
button.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 로딩 표시
|
||||
*/
|
||||
showLoading(message = '분석 중입니다...') {
|
||||
const loadingElement = document.getElementById('loadingState');
|
||||
if (loadingElement) {
|
||||
const textElement = loadingElement.querySelector('.loading-text');
|
||||
if (textElement) {
|
||||
textElement.textContent = message;
|
||||
}
|
||||
loadingElement.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로딩 숨김
|
||||
*/
|
||||
hideLoading() {
|
||||
const loadingElement = document.getElementById('loadingState');
|
||||
if (loadingElement) {
|
||||
loadingElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스트 메시지 표시
|
||||
*/
|
||||
showToast(message, type = 'info') {
|
||||
console.log(`📢 ${type.toUpperCase()}: ${message}`);
|
||||
|
||||
// 간단한 토스트 구현 (실제로는 더 정교한 토스트 라이브러리 사용 권장)
|
||||
if (type === 'error') {
|
||||
alert(`❌ ${message}`);
|
||||
} else if (type === 'success') {
|
||||
console.log(`✅ ${message}`);
|
||||
} else if (type === 'warning') {
|
||||
alert(`⚠️ ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 처리
|
||||
*/
|
||||
handleError(errorInfo) {
|
||||
console.error('❌ 에러 발생:', errorInfo);
|
||||
this.showToast(errorInfo.message, 'error');
|
||||
}
|
||||
|
||||
// ========== 유틸리티 ==========
|
||||
|
||||
/**
|
||||
* 컨트롤러 상태 디버그
|
||||
*/
|
||||
debug() {
|
||||
console.log('🔍 메인 컨트롤러 상태:');
|
||||
console.log('- API 클라이언트:', this.api);
|
||||
console.log('- 상태 관리자:', this.state.getState());
|
||||
console.log('- 차트 상태:', this.chartRenderer.getChartStatus());
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 인스턴스 생성 및 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.WorkAnalysisMainController = new WorkAnalysisMainController();
|
||||
});
|
||||
|
||||
// Export는 브라우저 환경에서 제거됨
|
||||
267
synology_deployment/web-ui/js/work-analysis/module-loader.js
Normal file
267
synology_deployment/web-ui/js/work-analysis/module-loader.js
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Work Analysis Module Loader
|
||||
* 작업 분석 모듈들을 순서대로 로드하고 초기화하는 로더
|
||||
*/
|
||||
|
||||
class WorkAnalysisModuleLoader {
|
||||
constructor() {
|
||||
this.modules = [
|
||||
{ name: 'API Client', path: '/js/work-analysis/api-client.js', loaded: false },
|
||||
{ name: 'Data Processor', path: '/js/work-analysis/data-processor.js', loaded: false },
|
||||
{ name: 'State Manager', path: '/js/work-analysis/state-manager.js', loaded: false },
|
||||
{ name: 'Table Renderer', path: '/js/work-analysis/table-renderer.js', loaded: false },
|
||||
{ name: 'Chart Renderer', path: '/js/work-analysis/chart-renderer.js', loaded: false },
|
||||
{ name: 'Main Controller', path: '/js/work-analysis/main-controller.js', loaded: false }
|
||||
];
|
||||
|
||||
this.loadingPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 모듈 로드
|
||||
* @returns {Promise} 로딩 완료 Promise
|
||||
*/
|
||||
async loadAll() {
|
||||
if (this.loadingPromise) {
|
||||
return this.loadingPromise;
|
||||
}
|
||||
|
||||
this.loadingPromise = this._loadModules();
|
||||
return this.loadingPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈들을 순차적으로 로드
|
||||
*/
|
||||
async _loadModules() {
|
||||
console.log('🚀 작업 분석 모듈 로딩 시작');
|
||||
|
||||
try {
|
||||
// 의존성 순서대로 로드
|
||||
for (const module of this.modules) {
|
||||
await this._loadModule(module);
|
||||
}
|
||||
|
||||
console.log('✅ 모든 작업 분석 모듈 로딩 완료');
|
||||
this._onAllModulesLoaded();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 모듈 로딩 실패:', error);
|
||||
this._onLoadingError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 모듈 로드
|
||||
* @param {Object} module - 모듈 정보
|
||||
*/
|
||||
async _loadModule(module) {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`📦 로딩 중: ${module.name}`);
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = module.path;
|
||||
script.type = 'text/javascript';
|
||||
|
||||
script.onload = () => {
|
||||
module.loaded = true;
|
||||
console.log(`✅ 로딩 완료: ${module.name}`);
|
||||
resolve();
|
||||
};
|
||||
|
||||
script.onerror = (error) => {
|
||||
console.error(`❌ 로딩 실패: ${module.name}`, error);
|
||||
reject(new Error(`Failed to load ${module.name}: ${module.path}`));
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 모듈 로딩 완료 시 호출
|
||||
*/
|
||||
_onAllModulesLoaded() {
|
||||
// 전역 변수 확인
|
||||
const requiredGlobals = [
|
||||
'WorkAnalysisAPI',
|
||||
'WorkAnalysisDataProcessor',
|
||||
'WorkAnalysisState',
|
||||
'WorkAnalysisTableRenderer',
|
||||
'WorkAnalysisChartRenderer'
|
||||
];
|
||||
|
||||
const missingGlobals = requiredGlobals.filter(name => !window[name]);
|
||||
|
||||
if (missingGlobals.length > 0) {
|
||||
console.warn('⚠️ 일부 전역 객체가 누락됨:', missingGlobals);
|
||||
}
|
||||
|
||||
// 하위 호환성을 위한 전역 함수들 설정
|
||||
this._setupLegacyFunctions();
|
||||
|
||||
// 모듈 로딩 완료 이벤트 발생
|
||||
window.dispatchEvent(new CustomEvent('workAnalysisModulesLoaded', {
|
||||
detail: { modules: this.modules }
|
||||
}));
|
||||
|
||||
console.log('🎉 작업 분석 시스템 준비 완료');
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 호환성을 위한 전역 함수 설정
|
||||
*/
|
||||
_setupLegacyFunctions() {
|
||||
// 기존 HTML에서 사용하던 함수들을 새 모듈 시스템으로 연결
|
||||
const legacyFunctions = {
|
||||
// 기간 확정
|
||||
confirmPeriod: () => {
|
||||
if (window.WorkAnalysisMainController) {
|
||||
window.WorkAnalysisMainController.handlePeriodConfirm();
|
||||
}
|
||||
},
|
||||
|
||||
// 분석 모드 변경
|
||||
switchAnalysisMode: (mode) => {
|
||||
if (window.WorkAnalysisState) {
|
||||
window.WorkAnalysisState.setAnalysisMode(mode);
|
||||
}
|
||||
},
|
||||
|
||||
// 탭 변경
|
||||
switchTab: (tabId) => {
|
||||
if (window.WorkAnalysisState) {
|
||||
window.WorkAnalysisState.setCurrentTab(tabId);
|
||||
}
|
||||
},
|
||||
|
||||
// 개별 분석 함수들
|
||||
analyzeWorkStatus: () => {
|
||||
if (window.WorkAnalysisMainController) {
|
||||
window.WorkAnalysisMainController.analyzeWorkStatus();
|
||||
}
|
||||
},
|
||||
|
||||
analyzeProjectDistribution: () => {
|
||||
if (window.WorkAnalysisMainController) {
|
||||
window.WorkAnalysisMainController.analyzeProjectDistribution();
|
||||
}
|
||||
},
|
||||
|
||||
analyzeWorkerPerformance: () => {
|
||||
if (window.WorkAnalysisMainController) {
|
||||
window.WorkAnalysisMainController.analyzeWorkerPerformance();
|
||||
}
|
||||
},
|
||||
|
||||
analyzeErrorAnalysis: () => {
|
||||
if (window.WorkAnalysisMainController) {
|
||||
window.WorkAnalysisMainController.analyzeErrorAnalysis();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 전역 함수로 등록
|
||||
Object.assign(window, legacyFunctions);
|
||||
|
||||
console.log('🔗 하위 호환성 함수 설정 완료');
|
||||
}
|
||||
|
||||
/**
|
||||
* 로딩 에러 처리
|
||||
*/
|
||||
_onLoadingError(error) {
|
||||
// 에러 UI 표시
|
||||
const container = document.querySelector('.analysis-container');
|
||||
if (container) {
|
||||
const errorHTML = `
|
||||
<div style="
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 50vh;
|
||||
text-align: center;
|
||||
color: #ef4444;
|
||||
">
|
||||
<div style="font-size: 4rem; margin-bottom: 1rem;">⚠️</div>
|
||||
<h2 style="margin-bottom: 1rem;">모듈 로딩 실패</h2>
|
||||
<p style="margin-bottom: 2rem; color: #666;">
|
||||
작업 분석 시스템을 로드하는 중 오류가 발생했습니다.<br>
|
||||
페이지를 새로고침하거나 관리자에게 문의하세요.
|
||||
</p>
|
||||
<button onclick="location.reload()" style="
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
">
|
||||
페이지 새로고침
|
||||
</button>
|
||||
<details style="margin-top: 2rem; text-align: left; max-width: 600px;">
|
||||
<summary style="cursor: pointer; color: #666;">기술적 세부사항</summary>
|
||||
<pre style="
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
overflow-x: auto;
|
||||
font-size: 0.875rem;
|
||||
">${error.message}</pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = errorHTML;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로딩 상태 확인
|
||||
* @returns {Object} 로딩 상태 정보
|
||||
*/
|
||||
getLoadingStatus() {
|
||||
const total = this.modules.length;
|
||||
const loaded = this.modules.filter(m => m.loaded).length;
|
||||
|
||||
return {
|
||||
total,
|
||||
loaded,
|
||||
percentage: Math.round((loaded / total) * 100),
|
||||
isComplete: loaded === total,
|
||||
modules: this.modules.map(m => ({
|
||||
name: m.name,
|
||||
loaded: m.loaded
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 모듈 로딩 상태 확인
|
||||
* @param {string} moduleName - 모듈명
|
||||
* @returns {boolean} 로딩 완료 여부
|
||||
*/
|
||||
isModuleLoaded(moduleName) {
|
||||
const module = this.modules.find(m => m.name === moduleName);
|
||||
return module ? module.loaded : false;
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 인스턴스 생성
|
||||
window.WorkAnalysisModuleLoader = new WorkAnalysisModuleLoader();
|
||||
|
||||
// 자동 로딩 시작 (DOM이 준비되면)
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.WorkAnalysisModuleLoader.loadAll();
|
||||
});
|
||||
} else {
|
||||
// DOM이 이미 준비된 경우 즉시 로딩
|
||||
window.WorkAnalysisModuleLoader.loadAll();
|
||||
}
|
||||
|
||||
// Export는 브라우저 환경에서 제거됨
|
||||
382
synology_deployment/web-ui/js/work-analysis/state-manager.js
Normal file
382
synology_deployment/web-ui/js/work-analysis/state-manager.js
Normal file
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* Work Analysis State Manager Module
|
||||
* 작업 분석 페이지의 상태 관리를 담당하는 모듈
|
||||
*/
|
||||
|
||||
class WorkAnalysisStateManager {
|
||||
constructor() {
|
||||
this.state = {
|
||||
// 분석 설정
|
||||
analysisMode: 'period', // 'period' | 'project'
|
||||
confirmedPeriod: {
|
||||
start: null,
|
||||
end: null,
|
||||
confirmed: false
|
||||
},
|
||||
|
||||
// UI 상태
|
||||
currentTab: 'work-status',
|
||||
isAnalysisEnabled: false,
|
||||
isLoading: false,
|
||||
|
||||
// 데이터 캐시
|
||||
cache: {
|
||||
basicStats: null,
|
||||
chartData: null,
|
||||
projectDistribution: null,
|
||||
errorAnalysis: null
|
||||
},
|
||||
|
||||
// 에러 상태
|
||||
lastError: null
|
||||
};
|
||||
|
||||
this.listeners = new Map();
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* 초기화
|
||||
*/
|
||||
init() {
|
||||
console.log('🔧 상태 관리자 초기화');
|
||||
|
||||
// 기본 날짜 설정 (현재 월)
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
this.updateState({
|
||||
confirmedPeriod: {
|
||||
start: this.formatDate(startOfMonth),
|
||||
end: this.formatDate(endOfMonth),
|
||||
confirmed: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 업데이트
|
||||
* @param {Object} updates - 업데이트할 상태
|
||||
*/
|
||||
updateState(updates) {
|
||||
const prevState = { ...this.state };
|
||||
this.state = { ...this.state, ...updates };
|
||||
|
||||
console.log('🔄 상태 업데이트:', updates);
|
||||
|
||||
// 리스너들에게 상태 변경 알림
|
||||
this.notifyListeners(prevState, this.state);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 리스너 등록
|
||||
* @param {string} key - 리스너 키
|
||||
* @param {Function} callback - 콜백 함수
|
||||
*/
|
||||
subscribe(key, callback) {
|
||||
this.listeners.set(key, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 리스너 제거
|
||||
* @param {string} key - 리스너 키
|
||||
*/
|
||||
unsubscribe(key) {
|
||||
this.listeners.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스너들에게 알림
|
||||
*/
|
||||
notifyListeners(prevState, newState) {
|
||||
this.listeners.forEach((callback, key) => {
|
||||
try {
|
||||
callback(newState, prevState);
|
||||
} catch (error) {
|
||||
console.error(`❌ 리스너 ${key} 오류:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 분석 설정 관리 ==========
|
||||
|
||||
/**
|
||||
* 분석 모드 변경
|
||||
* @param {string} mode - 분석 모드 ('period' | 'project')
|
||||
*/
|
||||
setAnalysisMode(mode) {
|
||||
if (mode !== 'period' && mode !== 'project') {
|
||||
throw new Error('유효하지 않은 분석 모드입니다.');
|
||||
}
|
||||
|
||||
this.updateState({
|
||||
analysisMode: mode,
|
||||
currentTab: 'work-status' // 모드 변경 시 첫 번째 탭으로 리셋
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 기간 확정
|
||||
* @param {string} startDate - 시작일
|
||||
* @param {string} endDate - 종료일
|
||||
*/
|
||||
confirmPeriod(startDate, endDate) {
|
||||
// 날짜 유효성 검사
|
||||
if (!startDate || !endDate) {
|
||||
throw new Error('시작일과 종료일을 모두 입력해주세요.');
|
||||
}
|
||||
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
if (start > end) {
|
||||
throw new Error('시작일이 종료일보다 늦을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 최대 1년 제한
|
||||
const maxDays = 365;
|
||||
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24));
|
||||
if (daysDiff > maxDays) {
|
||||
throw new Error(`분석 기간은 최대 ${maxDays}일까지 가능합니다.`);
|
||||
}
|
||||
|
||||
this.updateState({
|
||||
confirmedPeriod: {
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
confirmed: true
|
||||
},
|
||||
isAnalysisEnabled: true,
|
||||
// 기간 변경 시 캐시 초기화
|
||||
cache: {
|
||||
basicStats: null,
|
||||
chartData: null,
|
||||
projectDistribution: null,
|
||||
errorAnalysis: null
|
||||
}
|
||||
});
|
||||
|
||||
console.log('✅ 기간 확정:', startDate, '~', endDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 탭 변경
|
||||
* @param {string} tabId - 탭 ID
|
||||
*/
|
||||
setCurrentTab(tabId) {
|
||||
const validTabs = ['work-status', 'project-distribution', 'worker-performance', 'error-analysis'];
|
||||
|
||||
if (!validTabs.includes(tabId)) {
|
||||
console.warn('유효하지 않은 탭 ID:', tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateState({ currentTab: tabId });
|
||||
|
||||
// DOM 업데이트 직접 수행
|
||||
this.updateTabDOM(tabId);
|
||||
|
||||
console.log('🔄 탭 전환:', tabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 탭 DOM 업데이트
|
||||
* @param {string} tabId - 탭 ID
|
||||
*/
|
||||
updateTabDOM(tabId) {
|
||||
// 탭 버튼 업데이트
|
||||
document.querySelectorAll('.tab-button').forEach(button => {
|
||||
button.classList.remove('active');
|
||||
if (button.dataset.tab === tabId) {
|
||||
button.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// 탭 컨텐츠 업데이트
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
if (content.id === `${tabId}-tab`) {
|
||||
content.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 로딩 상태 관리 ==========
|
||||
|
||||
/**
|
||||
* 로딩 시작
|
||||
* @param {string} message - 로딩 메시지
|
||||
*/
|
||||
startLoading(message = '분석 중입니다...') {
|
||||
this.updateState({
|
||||
isLoading: true,
|
||||
loadingMessage: message
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 로딩 종료
|
||||
*/
|
||||
stopLoading() {
|
||||
this.updateState({
|
||||
isLoading: false,
|
||||
loadingMessage: null
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 데이터 캐시 관리 ==========
|
||||
|
||||
/**
|
||||
* 캐시 데이터 저장
|
||||
* @param {string} key - 캐시 키
|
||||
* @param {*} data - 저장할 데이터
|
||||
*/
|
||||
setCache(key, data) {
|
||||
this.updateState({
|
||||
cache: {
|
||||
...this.state.cache,
|
||||
[key]: {
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 데이터 조회
|
||||
* @param {string} key - 캐시 키
|
||||
* @param {number} maxAge - 최대 유효 시간 (밀리초)
|
||||
* @returns {*} 캐시된 데이터 또는 null
|
||||
*/
|
||||
getCache(key, maxAge = 5 * 60 * 1000) { // 기본 5분
|
||||
const cached = this.state.cache[key];
|
||||
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const age = Date.now() - cached.timestamp;
|
||||
if (age > maxAge) {
|
||||
console.log('🗑️ 캐시 만료:', key);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('📦 캐시 히트:', key);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 초기화
|
||||
* @param {string} key - 특정 키만 초기화 (선택사항)
|
||||
*/
|
||||
clearCache(key = null) {
|
||||
if (key) {
|
||||
this.updateState({
|
||||
cache: {
|
||||
...this.state.cache,
|
||||
[key]: null
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.updateState({
|
||||
cache: {
|
||||
basicStats: null,
|
||||
chartData: null,
|
||||
projectDistribution: null,
|
||||
errorAnalysis: null
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 에러 관리 ==========
|
||||
|
||||
/**
|
||||
* 에러 설정
|
||||
* @param {Error|string} error - 에러 객체 또는 메시지
|
||||
*/
|
||||
setError(error) {
|
||||
const errorInfo = {
|
||||
message: error instanceof Error ? error.message : error,
|
||||
timestamp: Date.now(),
|
||||
stack: error instanceof Error ? error.stack : null
|
||||
};
|
||||
|
||||
this.updateState({
|
||||
lastError: errorInfo,
|
||||
isLoading: false
|
||||
});
|
||||
|
||||
console.error('❌ 에러 발생:', errorInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 초기화
|
||||
*/
|
||||
clearError() {
|
||||
this.updateState({ lastError: null });
|
||||
}
|
||||
|
||||
// ========== 유틸리티 ==========
|
||||
|
||||
/**
|
||||
* 날짜 포맷팅
|
||||
* @param {Date} date - 날짜 객체
|
||||
* @returns {string} YYYY-MM-DD 형식
|
||||
*/
|
||||
formatDate(date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 상태 조회
|
||||
* @returns {Object} 현재 상태
|
||||
*/
|
||||
getState() {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석 가능 여부 확인
|
||||
* @returns {boolean} 분석 가능 여부
|
||||
*/
|
||||
canAnalyze() {
|
||||
return this.state.confirmedPeriod.confirmed &&
|
||||
this.state.confirmedPeriod.start &&
|
||||
this.state.confirmedPeriod.end &&
|
||||
!this.state.isLoading;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 디버그 정보 출력
|
||||
*/
|
||||
debug() {
|
||||
console.log('🔍 현재 상태:', this.state);
|
||||
console.log('👂 등록된 리스너:', Array.from(this.listeners.keys()));
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 인스턴스 생성
|
||||
window.WorkAnalysisState = new WorkAnalysisStateManager();
|
||||
|
||||
// 하위 호환성을 위한 전역 변수들
|
||||
Object.defineProperty(window, 'currentAnalysisMode', {
|
||||
get: () => window.WorkAnalysisState.state.analysisMode,
|
||||
set: (value) => window.WorkAnalysisState.setAnalysisMode(value)
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'confirmedStartDate', {
|
||||
get: () => window.WorkAnalysisState.state.confirmedPeriod.start
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'confirmedEndDate', {
|
||||
get: () => window.WorkAnalysisState.state.confirmedPeriod.end
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'isAnalysisEnabled', {
|
||||
get: () => window.WorkAnalysisState.state.isAnalysisEnabled
|
||||
});
|
||||
|
||||
// Export는 브라우저 환경에서 제거됨
|
||||
510
synology_deployment/web-ui/js/work-analysis/table-renderer.js
Normal file
510
synology_deployment/web-ui/js/work-analysis/table-renderer.js
Normal file
@@ -0,0 +1,510 @@
|
||||
/**
|
||||
* Work Analysis Table Renderer Module
|
||||
* 작업 분석 테이블 렌더링을 담당하는 모듈
|
||||
*/
|
||||
|
||||
class WorkAnalysisTableRenderer {
|
||||
constructor() {
|
||||
this.dataProcessor = window.WorkAnalysisDataProcessor;
|
||||
}
|
||||
|
||||
// ========== 프로젝트 분포 테이블 ==========
|
||||
|
||||
/**
|
||||
* 프로젝트 분포 테이블 렌더링 (Production Report 스타일)
|
||||
* @param {Array} projectData - 프로젝트 데이터
|
||||
* @param {Array} workerData - 작업자 데이터
|
||||
*/
|
||||
renderProjectDistributionTable(projectData, workerData) {
|
||||
console.log('📋 프로젝트별 분포 테이블 렌더링 시작');
|
||||
|
||||
const tbody = document.getElementById('projectDistributionTableBody');
|
||||
const tfoot = document.getElementById('projectDistributionTableFooter');
|
||||
|
||||
if (!tbody) {
|
||||
console.error('❌ projectDistributionTableBody 요소를 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
// 프로젝트 데이터가 없으면 작업자 데이터로 대체
|
||||
if (!projectData || !projectData.projects || projectData.projects.length === 0) {
|
||||
console.log('⚠️ 프로젝트 데이터가 없어서 작업자 데이터로 대체합니다.');
|
||||
this._renderFallbackTable(workerData, tbody, tfoot);
|
||||
return;
|
||||
}
|
||||
|
||||
let tableRows = [];
|
||||
let grandTotalHours = 0;
|
||||
let grandTotalManDays = 0;
|
||||
let grandTotalLaborCost = 0;
|
||||
|
||||
// 공수당 인건비 (350,000원)
|
||||
const manDayRate = 350000;
|
||||
|
||||
// 먼저 전체 시간을 계산 (부하율 계산용)
|
||||
projectData.projects.forEach(project => {
|
||||
project.workTypes.forEach(workType => {
|
||||
grandTotalHours += workType.totalHours;
|
||||
});
|
||||
});
|
||||
|
||||
// 프로젝트별로 렌더링
|
||||
projectData.projects.forEach(project => {
|
||||
const projectName = project.project_name || '알 수 없는 프로젝트';
|
||||
const jobNo = project.job_no || 'N/A';
|
||||
const workTypes = project.workTypes || [];
|
||||
|
||||
if (workTypes.length === 0) {
|
||||
// 작업유형이 없는 경우
|
||||
const projectHours = project.totalHours || 0;
|
||||
const manDays = Math.round((projectHours / 8) * 100) / 100;
|
||||
const laborCost = manDays * manDayRate;
|
||||
const loadRate = grandTotalHours > 0 ? ((projectHours / grandTotalHours) * 100).toFixed(2) : '0.00';
|
||||
|
||||
grandTotalManDays += manDays;
|
||||
grandTotalLaborCost += laborCost;
|
||||
|
||||
const isVacation = project.project_id === 'vacation';
|
||||
const displayText = isVacation ? projectName : jobNo;
|
||||
|
||||
tableRows.push(`
|
||||
<tr class="project-group ${isVacation ? 'vacation-project' : ''}">
|
||||
<td class="project-name">${displayText}</td>
|
||||
<td class="work-content">데이터 없음</td>
|
||||
<td class="man-days">${manDays}</td>
|
||||
<td class="load-rate">${loadRate}%</td>
|
||||
<td class="labor-cost">₩${laborCost.toLocaleString()}</td>
|
||||
</tr>
|
||||
`);
|
||||
} else {
|
||||
// 작업유형별 렌더링
|
||||
workTypes.forEach((workType, index) => {
|
||||
const isFirstWorkType = index === 0;
|
||||
const rowspan = workTypes.length;
|
||||
const workTypeHours = workType.totalHours || 0;
|
||||
const manDays = Math.round((workTypeHours / 8) * 100) / 100;
|
||||
const laborCost = manDays * manDayRate;
|
||||
const loadRate = grandTotalHours > 0 ? ((workTypeHours / grandTotalHours) * 100).toFixed(2) : '0.00';
|
||||
|
||||
grandTotalManDays += manDays;
|
||||
grandTotalLaborCost += laborCost;
|
||||
|
||||
const isVacation = project.project_id === 'vacation';
|
||||
const displayText = isVacation ? projectName : jobNo;
|
||||
|
||||
tableRows.push(`
|
||||
<tr class="project-group ${isVacation ? 'vacation-project' : ''}">
|
||||
${isFirstWorkType ? `<td class="project-name" rowspan="${rowspan}">${displayText}</td>` : ''}
|
||||
<td class="work-content">${workType.work_type_name}</td>
|
||||
<td class="man-days">${manDays}</td>
|
||||
<td class="load-rate">${loadRate}%</td>
|
||||
<td class="labor-cost">₩${laborCost.toLocaleString()}</td>
|
||||
</tr>
|
||||
`);
|
||||
});
|
||||
|
||||
// 프로젝트 소계 행 추가
|
||||
const projectTotalHours = workTypes.reduce((sum, wt) => sum + (wt.totalHours || 0), 0);
|
||||
const projectTotalManDays = Math.round((projectTotalHours / 8) * 100) / 100;
|
||||
const projectTotalLaborCost = projectTotalManDays * manDayRate;
|
||||
const projectLoadRate = grandTotalHours > 0 ? ((projectTotalHours / grandTotalHours) * 100).toFixed(2) : '0.00';
|
||||
|
||||
tableRows.push(`
|
||||
<tr class="project-subtotal">
|
||||
<td colspan="2"><strong>${projectName} 소계</strong></td>
|
||||
<td><strong>${projectTotalManDays}</strong></td>
|
||||
<td><strong>${projectLoadRate}%</strong></td>
|
||||
<td><strong>₩${projectTotalLaborCost.toLocaleString()}</strong></td>
|
||||
</tr>
|
||||
`);
|
||||
}
|
||||
});
|
||||
|
||||
// 테이블 업데이트
|
||||
tbody.innerHTML = tableRows.join('');
|
||||
|
||||
// 총계 업데이트
|
||||
if (tfoot) {
|
||||
document.getElementById('totalManDays').textContent = grandTotalManDays.toFixed(2);
|
||||
document.getElementById('totalLaborCost').textContent = `₩${grandTotalLaborCost.toLocaleString()}`;
|
||||
tfoot.style.display = 'table-footer-group';
|
||||
}
|
||||
|
||||
console.log('✅ 프로젝트별 분포 테이블 렌더링 완료');
|
||||
}
|
||||
|
||||
/**
|
||||
* 대체 테이블 렌더링 (작업자 데이터 기반)
|
||||
*/
|
||||
_renderFallbackTable(workerData, tbody, tfoot) {
|
||||
if (!workerData || workerData.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center; padding: 2rem; color: #666;">
|
||||
해당 기간에 데이터가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
if (tfoot) tfoot.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const manDayRate = 350000;
|
||||
let totalManDays = 0;
|
||||
let totalLaborCost = 0;
|
||||
|
||||
const tableRows = workerData.map(worker => {
|
||||
const hours = worker.totalHours || 0;
|
||||
const manDays = Math.round((hours / 8) * 100) / 100;
|
||||
const laborCost = manDays * manDayRate;
|
||||
|
||||
totalManDays += manDays;
|
||||
totalLaborCost += laborCost;
|
||||
|
||||
return `
|
||||
<tr class="project-group">
|
||||
<td class="project-name">작업자 기반</td>
|
||||
<td class="work-content">${worker.worker_name}</td>
|
||||
<td class="man-days">${manDays}</td>
|
||||
<td class="load-rate">-</td>
|
||||
<td class="labor-cost">₩${laborCost.toLocaleString()}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
tbody.innerHTML = tableRows.join('');
|
||||
|
||||
// 총계 업데이트
|
||||
if (tfoot) {
|
||||
document.getElementById('totalManDays').textContent = totalManDays.toFixed(2);
|
||||
document.getElementById('totalLaborCost').textContent = `₩${totalLaborCost.toLocaleString()}`;
|
||||
tfoot.style.display = 'table-footer-group';
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 오류 분석 테이블 ==========
|
||||
|
||||
/**
|
||||
* 오류 분석 테이블 렌더링
|
||||
* @param {Array} recentWorkData - 최근 작업 데이터
|
||||
*/
|
||||
renderErrorAnalysisTable(recentWorkData) {
|
||||
console.log('📊 오류 분석 테이블 렌더링 시작');
|
||||
console.log('📊 받은 데이터:', recentWorkData);
|
||||
|
||||
const tableBody = document.getElementById('errorAnalysisTableBody');
|
||||
const tableFooter = document.getElementById('errorAnalysisTableFooter');
|
||||
|
||||
console.log('📊 DOM 요소 확인:', { tableBody, tableFooter });
|
||||
|
||||
// DOM 요소 존재 확인
|
||||
if (!tableBody) {
|
||||
console.error('❌ errorAnalysisTableBody 요소를 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!recentWorkData || recentWorkData.length === 0) {
|
||||
tableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; padding: 2rem; color: #666;">
|
||||
해당 기간에 오류 데이터가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
if (tableFooter) {
|
||||
tableFooter.style.display = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 작업 형태별 오류 데이터 집계
|
||||
const errorData = this.dataProcessor.aggregateErrorData(recentWorkData);
|
||||
|
||||
let tableRows = [];
|
||||
let grandTotalHours = 0;
|
||||
let grandTotalRegularHours = 0;
|
||||
let grandTotalErrorHours = 0;
|
||||
|
||||
// 프로젝트별로 그룹화
|
||||
const projectGroups = new Map();
|
||||
errorData.forEach(workType => {
|
||||
const projectKey = workType.isVacation ? 'vacation' : workType.project_id;
|
||||
if (!projectGroups.has(projectKey)) {
|
||||
projectGroups.set(projectKey, []);
|
||||
}
|
||||
projectGroups.get(projectKey).push(workType);
|
||||
});
|
||||
|
||||
// 프로젝트별로 렌더링
|
||||
Array.from(projectGroups.entries()).forEach(([projectKey, workTypes]) => {
|
||||
workTypes.forEach((workType, index) => {
|
||||
grandTotalHours += workType.totalHours;
|
||||
grandTotalRegularHours += workType.regularHours;
|
||||
grandTotalErrorHours += workType.errorHours;
|
||||
|
||||
const rowClass = workType.isVacation ? 'vacation-project' : 'project-group';
|
||||
const isFirstWorkType = index === 0;
|
||||
const rowspan = workTypes.length;
|
||||
|
||||
// 세부시간 구성
|
||||
let detailHours = [];
|
||||
if (workType.regularHours > 0) {
|
||||
detailHours.push(`<span class="regular-hours">정규: ${workType.regularHours}h</span>`);
|
||||
}
|
||||
|
||||
// 오류 세부사항 추가
|
||||
workType.errorDetails.forEach(error => {
|
||||
detailHours.push(`<span class="error-hours">오류: ${error.type} ${error.hours}h</span>`);
|
||||
});
|
||||
|
||||
// 작업 타입 구성 (단순화)
|
||||
let workTypeDisplay = '';
|
||||
if (workType.regularHours > 0) {
|
||||
workTypeDisplay += `
|
||||
<div class="work-type-item regular">
|
||||
<span class="work-type-status">정규시간</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
workType.errorDetails.forEach(error => {
|
||||
workTypeDisplay += `
|
||||
<div class="work-type-item error">
|
||||
<span class="work-type-status">오류: ${error.type}</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
tableRows.push(`
|
||||
<tr class="${rowClass}">
|
||||
${isFirstWorkType ? `<td class="project-name" rowspan="${rowspan}">${workType.isVacation ? '연차/휴무' : (workType.project_name || 'N/A')}</td>` : ''}
|
||||
<td class="work-content">${workType.work_type_name}</td>
|
||||
<td class="total-hours">${workType.totalHours}h</td>
|
||||
<td class="detail-hours">
|
||||
${detailHours.join('<br>')}
|
||||
</td>
|
||||
<td class="work-type">
|
||||
<div class="work-type-breakdown">
|
||||
${workTypeDisplay}
|
||||
</div>
|
||||
</td>
|
||||
<td class="error-percentage ${workType.errorHours > 0 ? 'has-error' : ''}">${workType.errorRate}%</td>
|
||||
</tr>
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
if (tableRows.length === 0) {
|
||||
tableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; padding: 2rem; color: #666;">
|
||||
해당 기간에 작업 데이터가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
if (tableFooter) {
|
||||
tableFooter.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
tableBody.innerHTML = tableRows.join('');
|
||||
|
||||
// 총계 업데이트
|
||||
const totalErrorRate = grandTotalHours > 0 ? ((grandTotalErrorHours / grandTotalHours) * 100).toFixed(1) : '0.0';
|
||||
|
||||
// 안전한 DOM 요소 접근
|
||||
const totalErrorHoursElement = document.getElementById('totalErrorHours');
|
||||
if (totalErrorHoursElement) {
|
||||
totalErrorHoursElement.textContent = `${grandTotalHours}h`;
|
||||
}
|
||||
|
||||
if (tableFooter) {
|
||||
const detailHoursCell = tableFooter.querySelector('.total-row td:nth-child(4)');
|
||||
const errorRateCell = tableFooter.querySelector('.total-row td:nth-child(6)');
|
||||
|
||||
if (detailHoursCell) {
|
||||
detailHoursCell.innerHTML = `
|
||||
<strong>정규: ${grandTotalRegularHours}h<br>오류: ${grandTotalErrorHours}h</strong>
|
||||
`;
|
||||
}
|
||||
|
||||
if (errorRateCell) {
|
||||
errorRateCell.innerHTML = `<strong>${totalErrorRate}%</strong>`;
|
||||
}
|
||||
|
||||
tableFooter.style.display = 'table-footer-group';
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ 오류 분석 테이블 렌더링 완료');
|
||||
}
|
||||
|
||||
// ========== 기간별 작업 현황 테이블 ==========
|
||||
|
||||
/**
|
||||
* 기간별 작업 현황 테이블 렌더링
|
||||
* @param {Array} projectData - 프로젝트 데이터
|
||||
* @param {Array} workerData - 작업자 데이터
|
||||
* @param {Array} recentWorkData - 최근 작업 데이터
|
||||
*/
|
||||
renderWorkStatusTable(projectData, workerData, recentWorkData) {
|
||||
console.log('📈 기간별 작업 현황 테이블 렌더링 시작');
|
||||
|
||||
const tableContainer = document.querySelector('#work-status-tab .table-container');
|
||||
if (!tableContainer) {
|
||||
console.error('❌ 작업 현황 테이블 컨테이너를 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터가 없는 경우 처리
|
||||
if (!workerData || workerData.length === 0) {
|
||||
tableContainer.innerHTML = `
|
||||
<div style="text-align: center; padding: 3rem; color: #666;">
|
||||
<div style="font-size: 3rem; margin-bottom: 1rem;">📊</div>
|
||||
<div style="font-size: 1.2rem; margin-bottom: 0.5rem;">데이터가 없습니다</div>
|
||||
<div style="font-size: 0.9rem;">선택한 기간에 작업 데이터가 없습니다.</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 작업자별 데이터 처리
|
||||
const workerStats = this._processWorkerStats(workerData, recentWorkData);
|
||||
|
||||
let tableHTML = `
|
||||
<table class="work-status-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>작업자</th>
|
||||
<th>분류(프로젝트)</th>
|
||||
<th>작업내용</th>
|
||||
<th>투입시간</th>
|
||||
<th>작업공수</th>
|
||||
<th>작업일/일평균시간</th>
|
||||
<th>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
let totalHours = 0;
|
||||
let totalManDays = 0;
|
||||
|
||||
workerStats.forEach(worker => {
|
||||
worker.projects.forEach((project, projectIndex) => {
|
||||
project.workTypes.forEach((workType, workTypeIndex) => {
|
||||
const isFirstProject = projectIndex === 0 && workTypeIndex === 0;
|
||||
const workerRowspan = worker.totalRowspan;
|
||||
|
||||
totalHours += workType.hours;
|
||||
totalManDays += workType.manDays;
|
||||
|
||||
tableHTML += `
|
||||
<tr class="worker-group">
|
||||
${isFirstProject ? `
|
||||
<td class="worker-name" rowspan="${workerRowspan}">${worker.name}</td>
|
||||
` : ''}
|
||||
<td class="project-name">${project.name}</td>
|
||||
<td class="work-content">${workType.name}</td>
|
||||
<td class="work-hours">${workType.hours}h</td>
|
||||
${isFirstProject ? `
|
||||
<td class="man-days" rowspan="${workerRowspan}">${worker.totalManDays.toFixed(1)}</td>
|
||||
<td class="work-days" rowspan="${workerRowspan}">${worker.workDays}일 / ${worker.avgHours.toFixed(1)}h</td>
|
||||
` : ''}
|
||||
<td class="remarks">${workType.remarks}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
tableHTML += `
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="3"><strong>총 공수</strong></td>
|
||||
<td><strong>${totalHours}h</strong></td>
|
||||
<td><strong>${totalManDays.toFixed(1)}</strong></td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
`;
|
||||
|
||||
tableContainer.innerHTML = tableHTML;
|
||||
console.log('✅ 기간별 작업 현황 테이블 렌더링 완료');
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업자별 통계 처리 (내부 헬퍼)
|
||||
*/
|
||||
_processWorkerStats(workerData, recentWorkData) {
|
||||
if (!workerData || workerData.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return workerData.map(worker => {
|
||||
// 해당 작업자의 작업 데이터 필터링
|
||||
const workerWork = recentWorkData ?
|
||||
recentWorkData.filter(work => work.worker_id === worker.worker_id) : [];
|
||||
|
||||
// 프로젝트별로 그룹화
|
||||
const projectMap = new Map();
|
||||
workerWork.forEach(work => {
|
||||
const projectKey = work.project_id || 'unknown';
|
||||
if (!projectMap.has(projectKey)) {
|
||||
projectMap.set(projectKey, {
|
||||
name: work.project_name || `프로젝트 ${projectKey}`,
|
||||
workTypes: new Map()
|
||||
});
|
||||
}
|
||||
|
||||
const project = projectMap.get(projectKey);
|
||||
const workTypeKey = work.work_type_id || 'unknown';
|
||||
const workTypeName = work.work_type_name || `작업유형 ${workTypeKey}`;
|
||||
|
||||
if (!project.workTypes.has(workTypeKey)) {
|
||||
project.workTypes.set(workTypeKey, {
|
||||
name: workTypeName,
|
||||
hours: 0,
|
||||
remarks: '정상'
|
||||
});
|
||||
}
|
||||
|
||||
const workType = project.workTypes.get(workTypeKey);
|
||||
workType.hours += parseFloat(work.work_hours) || 0;
|
||||
|
||||
// 오류가 있으면 비고 업데이트
|
||||
if (work.work_status === 'error' || work.error_type_id) {
|
||||
workType.remarks = work.error_type_name || work.error_description || '오류';
|
||||
}
|
||||
});
|
||||
|
||||
// 프로젝트 배열로 변환
|
||||
const projects = Array.from(projectMap.values()).map(project => ({
|
||||
...project,
|
||||
workTypes: Array.from(project.workTypes.values()).map(wt => ({
|
||||
...wt,
|
||||
manDays: Math.round((wt.hours / 8) * 10) / 10
|
||||
}))
|
||||
}));
|
||||
|
||||
// 전체 행 수 계산
|
||||
const totalRowspan = projects.reduce((sum, p) => sum + p.workTypes.length, 0);
|
||||
|
||||
return {
|
||||
name: worker.worker_name,
|
||||
totalHours: worker.totalHours || 0,
|
||||
totalManDays: (worker.totalHours || 0) / 8,
|
||||
workDays: worker.workingDays || 0,
|
||||
avgHours: worker.avgHours || 0,
|
||||
projects,
|
||||
totalRowspan: Math.max(totalRowspan, 1)
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 인스턴스 생성
|
||||
window.WorkAnalysisTableRenderer = new WorkAnalysisTableRenderer();
|
||||
|
||||
// Export는 브라우저 환경에서 제거됨
|
||||
262
synology_deployment/web-ui/js/work-management.js
Normal file
262
synology_deployment/web-ui/js/work-management.js
Normal file
@@ -0,0 +1,262 @@
|
||||
// 작업 관리 페이지 JavaScript
|
||||
|
||||
// 전역 변수
|
||||
let statsData = {
|
||||
projects: 0,
|
||||
workers: 0,
|
||||
tasks: 0,
|
||||
codeTypes: 0
|
||||
};
|
||||
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('🔧 작업 관리 페이지 초기화 시작');
|
||||
|
||||
initializePage();
|
||||
loadStatistics();
|
||||
});
|
||||
|
||||
// 페이지 초기화
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
function updateUserInfo() {
|
||||
let userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
|
||||
let authUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
|
||||
const finalUserInfo = {
|
||||
worker_name: userInfo.worker_name || authUser.username || authUser.worker_name,
|
||||
job_type: userInfo.job_type || authUser.role || authUser.job_type,
|
||||
username: authUser.username || userInfo.username
|
||||
};
|
||||
|
||||
const userNameElement = document.getElementById('userName');
|
||||
const userRoleElement = document.getElementById('userRole');
|
||||
const userInitialElement = document.getElementById('userInitial');
|
||||
|
||||
if (userNameElement) {
|
||||
userNameElement.textContent = finalUserInfo.worker_name || '사용자';
|
||||
}
|
||||
|
||||
if (userRoleElement) {
|
||||
const roleMap = {
|
||||
'leader': '그룹장',
|
||||
'worker': '작업자',
|
||||
'admin': '관리자',
|
||||
'system': '시스템 관리자'
|
||||
};
|
||||
userRoleElement.textContent = roleMap[finalUserInfo.job_type] || finalUserInfo.job_type || '작업자';
|
||||
}
|
||||
|
||||
if (userInitialElement) {
|
||||
const name = finalUserInfo.worker_name || '사용자';
|
||||
userInitialElement.textContent = name.charAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 프로필 메뉴 설정
|
||||
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 loadStatistics() {
|
||||
try {
|
||||
console.log('📊 통계 데이터 로딩 시작');
|
||||
|
||||
// 프로젝트 수 조회
|
||||
try {
|
||||
const projectsResponse = await apiCall('/projects', 'GET');
|
||||
if (projectsResponse && Array.isArray(projectsResponse)) {
|
||||
statsData.projects = projectsResponse.length;
|
||||
updateStatDisplay('projectCount', statsData.projects);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('프로젝트 통계 로드 실패:', error);
|
||||
updateStatDisplay('projectCount', '오류');
|
||||
}
|
||||
|
||||
// 작업자 수 조회
|
||||
try {
|
||||
const workersResponse = await apiCall('/workers', 'GET');
|
||||
if (workersResponse && Array.isArray(workersResponse)) {
|
||||
const activeWorkers = workersResponse.filter(w => w.status === 'active');
|
||||
statsData.workers = activeWorkers.length;
|
||||
updateStatDisplay('workerCount', statsData.workers);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('작업자 통계 로드 실패:', error);
|
||||
updateStatDisplay('workerCount', '오류');
|
||||
}
|
||||
|
||||
// 작업 유형 수 조회
|
||||
try {
|
||||
const tasksResponse = await apiCall('/tasks', 'GET');
|
||||
if (tasksResponse && Array.isArray(tasksResponse)) {
|
||||
const activeTasks = tasksResponse.filter(t => t.is_active);
|
||||
statsData.tasks = activeTasks.length;
|
||||
updateStatDisplay('taskCount', statsData.tasks);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('작업 유형 통계 로드 실패:', error);
|
||||
updateStatDisplay('taskCount', '오류');
|
||||
}
|
||||
|
||||
// 코드 타입 수 조회 (임시로 고정값)
|
||||
statsData.codeTypes = 3; // ISSUE_TYPE, ERROR_TYPE, WORK_STATUS
|
||||
updateStatDisplay('codeTypeCount', statsData.codeTypes);
|
||||
|
||||
console.log('✅ 통계 데이터 로딩 완료:', statsData);
|
||||
|
||||
} catch (error) {
|
||||
console.error('통계 데이터 로딩 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 통계 표시 업데이트
|
||||
function updateStatDisplay(elementId, value) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (element) {
|
||||
element.textContent = value;
|
||||
|
||||
// 애니메이션 효과
|
||||
element.style.transform = 'scale(1.1)';
|
||||
setTimeout(() => {
|
||||
element.style.transform = 'scale(1)';
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
// 최근 활동 관련 함수들 제거됨
|
||||
|
||||
// 페이지 네비게이션
|
||||
function navigateToPage(url) {
|
||||
console.log(`🔗 페이지 이동: ${url}`);
|
||||
|
||||
// 로딩 효과
|
||||
const card = event.currentTarget;
|
||||
const originalContent = card.innerHTML;
|
||||
|
||||
card.style.opacity = '0.7';
|
||||
card.style.pointerEvents = 'none';
|
||||
|
||||
// 잠시 후 페이지 이동
|
||||
setTimeout(() => {
|
||||
window.location.href = url;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// 토스트 메시지 표시
|
||||
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.navigateToPage = navigateToPage;
|
||||
46
synology_deployment/web-ui/js/work-report-api.js
Normal file
46
synology_deployment/web-ui/js/work-report-api.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// /js/work-report-api.js
|
||||
import { apiGet, apiPost } from './api-helper.js';
|
||||
|
||||
/**
|
||||
* 작업 보고서 작성을 위해 필요한 초기 데이터(작업자, 프로젝트, 태스크)를 가져옵니다.
|
||||
* Promise.all을 사용하여 병렬로 API를 호출합니다.
|
||||
* @returns {Promise<{workers: Array, projects: Array, tasks: Array}>}
|
||||
*/
|
||||
export async function getInitialData() {
|
||||
try {
|
||||
const [workers, projects, tasks] = await Promise.all([
|
||||
apiGet('/workers'),
|
||||
apiGet('/projects'),
|
||||
apiGet('/tasks')
|
||||
]);
|
||||
|
||||
// 데이터 형식 검증
|
||||
if (!Array.isArray(workers) || !Array.isArray(projects) || !Array.isArray(tasks)) {
|
||||
throw new Error('서버에서 받은 데이터 형식이 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
// 작업자 목록은 ID 기준으로 정렬
|
||||
workers.sort((a, b) => a.worker_id - b.worker_id);
|
||||
|
||||
return { workers, projects, tasks };
|
||||
} catch (error) {
|
||||
console.error('초기 데이터 로딩 중 오류 발생:', error);
|
||||
// 에러를 다시 던져서 호출한 쪽에서 처리할 수 있도록 함
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 작성된 작업 보고서 데이터를 서버에 전송합니다.
|
||||
* @param {Array<object>} reportData - 전송할 작업 보고서 데이터 배열
|
||||
* @returns {Promise<object>} - 서버의 응답 결과
|
||||
*/
|
||||
export async function createWorkReport(reportData) {
|
||||
try {
|
||||
const result = await apiPost('/workreports', reportData);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('작업 보고서 생성 요청 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
1668
synology_deployment/web-ui/js/work-report-calendar.js
Normal file
1668
synology_deployment/web-ui/js/work-report-calendar.js
Normal file
File diff suppressed because it is too large
Load Diff
79
synology_deployment/web-ui/js/work-report-create.js
Normal file
79
synology_deployment/web-ui/js/work-report-create.js
Normal file
@@ -0,0 +1,79 @@
|
||||
// /js/work-report-create.js
|
||||
import { renderCalendar } from './calendar.js';
|
||||
import { getInitialData, createWorkReport } from './work-report-api.js';
|
||||
import { initializeReportTable, getReportData } from './work-report-ui.js';
|
||||
|
||||
// 전역 상태 변수
|
||||
let selectedDate = '';
|
||||
|
||||
/**
|
||||
* 날짜가 선택되었을 때 실행되는 콜백 함수.
|
||||
* 초기 데이터를 로드하고 테이블을 렌더링합니다.
|
||||
* @param {string} date - 선택된 날짜 (YYYY-MM-DD 형식)
|
||||
*/
|
||||
async function onDateSelect(date) {
|
||||
selectedDate = date;
|
||||
const tableBody = document.getElementById('reportBody');
|
||||
tableBody.innerHTML = '<tr><td colspan="8" class="text-center">데이터를 불러오는 중...</td></tr>';
|
||||
|
||||
try {
|
||||
const initialData = await getInitialData();
|
||||
initializeReportTable(initialData);
|
||||
} catch (error) {
|
||||
alert('데이터를 불러오는 데 실패했습니다: ' + error.message);
|
||||
tableBody.innerHTML = '<tr><td colspan="8" class="text-center error">오류 발생! 데이터를 불러올 수 없습니다.</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* '전체 등록' 버튼 클릭 시 실행되는 이벤트 핸들러.
|
||||
* 폼 데이터를 서버에 전송합니다.
|
||||
*/
|
||||
async function handleSubmit() {
|
||||
if (!selectedDate) {
|
||||
alert('먼저 달력에서 날짜를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const reportData = getReportData();
|
||||
if (!reportData) {
|
||||
// getReportData 내부에서 이미 alert으로 사용자에게 알림
|
||||
return;
|
||||
}
|
||||
|
||||
// 각 항목에 선택된 날짜 추가
|
||||
const payload = reportData.map(item => ({ ...item, date: selectedDate }));
|
||||
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '등록 중...';
|
||||
|
||||
try {
|
||||
const result = await createWorkReport(payload);
|
||||
if (result.success) {
|
||||
alert('✅ 작업 보고서가 성공적으로 등록되었습니다!');
|
||||
// 성공 후 폼을 다시 로드하거나, 다른 페이지로 이동 등의 로직 추가 가능
|
||||
onDateSelect(selectedDate); // 현재 날짜의 폼을 다시 로드
|
||||
} else {
|
||||
throw new Error(result.error || '알 수 없는 오류로 등록에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('❌ 등록 실패: ' + error.message);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '전체 등록';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 초기화 함수
|
||||
*/
|
||||
function initializePage() {
|
||||
renderCalendar('calendar', onDateSelect);
|
||||
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
submitBtn.addEventListener('click', handleSubmit);
|
||||
}
|
||||
|
||||
// DOM이 로드되면 페이지 초기화를 시작합니다.
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
208
synology_deployment/web-ui/js/work-report-manage.js
Normal file
208
synology_deployment/web-ui/js/work-report-manage.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import { renderCalendar } from '/js/calendar.js';
|
||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
||||
|
||||
// 인증 확인
|
||||
ensureAuthenticated();
|
||||
|
||||
const calendarEl = document.getElementById('calendar');
|
||||
const reportBody = document.getElementById('reportBody');
|
||||
let selectedDate = '';
|
||||
|
||||
// 캘린더 렌더링
|
||||
renderCalendar('calendar', (dateStr) => {
|
||||
selectedDate = dateStr;
|
||||
loadReports();
|
||||
});
|
||||
|
||||
// 보고서 로딩
|
||||
async function loadReports() {
|
||||
if (!selectedDate) return;
|
||||
reportBody.innerHTML = '<tr><td colspan="8">불러오는 중...</td></tr>';
|
||||
|
||||
try {
|
||||
const [wRes, pRes, rRes] = await Promise.all([
|
||||
fetch(`${API}/workers`, { headers: getAuthHeaders() }),
|
||||
fetch(`${API}/projects/active/list`, { headers: getAuthHeaders() }),
|
||||
fetch(`${API}/workreports?start=${selectedDate}&end=${selectedDate}`, { headers: getAuthHeaders() })
|
||||
]);
|
||||
|
||||
if (![wRes, pRes, rRes].every(res => res.ok)) throw new Error('불러오기 실패');
|
||||
|
||||
const [workers, projects, reports] = await Promise.all([
|
||||
wRes.json(), pRes.json(), rRes.json()
|
||||
]);
|
||||
|
||||
// 배열 체크
|
||||
if (!Array.isArray(workers) || !Array.isArray(projects) || !Array.isArray(reports)) {
|
||||
throw new Error('잘못된 데이터 형식');
|
||||
}
|
||||
|
||||
if (!reports.length) {
|
||||
reportBody.innerHTML = '<tr><td colspan="8">등록된 보고서가 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const nameMap = Object.fromEntries(workers.map(w => [w.worker_id, w.worker_name]));
|
||||
const projMap = Object.fromEntries(projects.map(p => [p.project_id, p.project_name]));
|
||||
// const taskMap = Object.fromEntries(tasks.map(t => [t.task_id, `${t.category}:${t.subcategory}`])); // tasks 테이블 삭제됨
|
||||
|
||||
reportBody.innerHTML = '';
|
||||
reports.forEach((r, i) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${i + 1}</td>
|
||||
<td>${nameMap[r.worker_id] || r.worker_id}</td>
|
||||
<td><select data-id="project">
|
||||
${projects.map(p =>
|
||||
`<option value="${p.project_id}" ${p.project_id === r.project_id ? 'selected' : ''}>${p.project_name}</option>`
|
||||
).join('')}</select></td>
|
||||
<td><select data-id="task" disabled>
|
||||
<option>작업 유형 (삭제됨)</option>
|
||||
</select></td>
|
||||
<td><input type="number" min="0" step="0.5" value="${r.overtime_hours || ''}" data-id="overtime"></td>
|
||||
<td><select data-id="work_details">
|
||||
${['근무', '연차', '유급', '반차', '반반차', '조퇴', '휴무'].map(opt =>
|
||||
`<option value="${opt}" ${r.work_details === opt ? 'selected' : ''}>${opt}</option>`
|
||||
).join('')}</select></td>
|
||||
<td><input type="text" value="${r.memo || ''}" data-id="memo"></td>
|
||||
<td>
|
||||
<button class="action-btn save-btn">저장</button>
|
||||
<button class="action-btn delete-btn">삭제</button>
|
||||
</td>`;
|
||||
|
||||
// 저장 버튼
|
||||
tr.querySelector('.save-btn').onclick = async () => {
|
||||
// 입력값 검증
|
||||
const projectId = tr.querySelector('[data-id="project"]').value;
|
||||
const taskId = tr.querySelector('[data-id="task"]').value;
|
||||
const overtimeHours = tr.querySelector('[data-id="overtime"]').value;
|
||||
|
||||
if (!projectId || !taskId) {
|
||||
alert('❌ 프로젝트와 작업을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 날짜 형식 처리 - MySQL DATE 형식으로 변환
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return selectedDate;
|
||||
|
||||
// 이미 YYYY-MM-DD 형식인지 확인
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
// ISO 형식이나 다른 형식을 YYYY-MM-DD로 변환
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) {
|
||||
return selectedDate; // 잘못된 날짜면 선택된 날짜 사용
|
||||
}
|
||||
|
||||
return date.toISOString().split('T')[0]; // YYYY-MM-DD 형식으로 변환
|
||||
};
|
||||
|
||||
const payload = {
|
||||
date: formatDate(r.date), // 날짜 형식 변환
|
||||
worker_id: r.worker_id, // 기존 작업자 ID 유지
|
||||
project_id: Number(projectId),
|
||||
task_id: Number(taskId),
|
||||
overtime_hours: overtimeHours ? Number(overtimeHours) : null,
|
||||
work_details: tr.querySelector('[data-id="work_details"]').value,
|
||||
memo: tr.querySelector('[data-id="memo"]').value.trim() || null
|
||||
};
|
||||
|
||||
// 저장 버튼 상태 변경 (로딩 중)
|
||||
const saveBtn = tr.querySelector('.save-btn');
|
||||
const originalText = saveBtn.textContent;
|
||||
const originalColor = saveBtn.style.backgroundColor;
|
||||
|
||||
saveBtn.textContent = '저장 중...';
|
||||
saveBtn.style.backgroundColor = '#ffc107';
|
||||
saveBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/workreports/${r.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok && result.success) {
|
||||
// 성공 상태 표시
|
||||
saveBtn.textContent = '✅ 완료';
|
||||
saveBtn.style.backgroundColor = '#28a745';
|
||||
saveBtn.style.color = 'white';
|
||||
|
||||
setTimeout(() => {
|
||||
saveBtn.textContent = originalText;
|
||||
saveBtn.style.backgroundColor = originalColor;
|
||||
saveBtn.style.color = '';
|
||||
saveBtn.disabled = false;
|
||||
}, 2000);
|
||||
|
||||
// alert 대신 조용한 알림
|
||||
console.log('저장 완료:', result);
|
||||
} else {
|
||||
console.error('저장 실패:', result);
|
||||
alert(`❌ 저장 실패: ${result.error || result.message || '알 수 없는 오류'}`);
|
||||
|
||||
// 실패 시 버튼 복원
|
||||
saveBtn.textContent = originalText;
|
||||
saveBtn.style.backgroundColor = originalColor;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('저장 요청 에러:', err);
|
||||
alert('❌ 저장 요청 실패: ' + err.message);
|
||||
|
||||
// 에러 시 버튼 복원
|
||||
saveBtn.textContent = originalText;
|
||||
saveBtn.style.backgroundColor = originalColor;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 버튼
|
||||
tr.querySelector('.delete-btn').onclick = async () => {
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/workreports/${r.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
tr.remove();
|
||||
// 행 번호 다시 매기기
|
||||
updateRowNumbers();
|
||||
alert('✅ 삭제 완료');
|
||||
} else {
|
||||
const result = await res.json();
|
||||
alert(`❌ 삭제 실패: ${result.error || result.message || '알 수 없는 오류'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('삭제 요청 에러:', err);
|
||||
alert('❌ 삭제 요청 실패: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
reportBody.appendChild(tr);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('데이터 로딩 에러:', err);
|
||||
reportBody.innerHTML = '<tr><td colspan="8">❌ 불러오기 실패: ' + err.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// 행 번호 다시 매기기
|
||||
function updateRowNumbers() {
|
||||
reportBody.querySelectorAll('tr').forEach((tr, i) => {
|
||||
const firstTd = tr.querySelector('td:first-child');
|
||||
if (firstTd) firstTd.textContent = i + 1;
|
||||
});
|
||||
}
|
||||
1055
synology_deployment/web-ui/js/work-report-review.js
Normal file
1055
synology_deployment/web-ui/js/work-report-review.js
Normal file
File diff suppressed because it is too large
Load Diff
141
synology_deployment/web-ui/js/work-report-ui.js
Normal file
141
synology_deployment/web-ui/js/work-report-ui.js
Normal file
@@ -0,0 +1,141 @@
|
||||
// /js/work-report-ui.js
|
||||
|
||||
const DEFAULT_PROJECT_ID = '13'; // 나중에는 API나 설정에서 받아오는 것이 좋음
|
||||
const DEFAULT_TASK_ID = '15';
|
||||
|
||||
/**
|
||||
* 주어진 데이터를 바탕으로 <select> 요소의 <option>들을 생성합니다.
|
||||
* @param {Array<object>} items - 옵션으로 만들 데이터 배열
|
||||
* @param {string} valueField - <option>의 value 속성에 사용할 필드 이름
|
||||
* @param {string} textField - <option>의 텍스트에 사용할 필드 이름
|
||||
* @returns {string} - 생성된 HTML 옵션 문자열
|
||||
*/
|
||||
function createOptions(items, valueField, textField) {
|
||||
return items.map(item => `<option value="${item[valueField]}">${textField(item)}</option>`).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 모든 행 번호를 다시 매깁니다.
|
||||
* @param {HTMLTableSectionElement} tableBody - tbody 요소
|
||||
*/
|
||||
function updateRowNumbers(tableBody) {
|
||||
tableBody.querySelectorAll('tr').forEach((tr, index) => {
|
||||
tr.cells[0].textContent = index + 1;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 하나의 작업 보고서 행(tr)을 생성합니다.
|
||||
* @param {object} worker - 작업자 정보
|
||||
* @param {Array} projects - 전체 프로젝트 목록
|
||||
* @param {Array} tasks - 전체 태스크 목록
|
||||
* @param {number} index - 행 번호
|
||||
* @returns {HTMLTableRowElement} - 생성된 tr 요소
|
||||
*/
|
||||
function createReportRow(worker, projects, tasks, index) {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
tr.innerHTML = `
|
||||
<td>${index + 1}</td>
|
||||
<td>
|
||||
<input type="hidden" name="worker_id" value="${worker.worker_id}">
|
||||
${worker.worker_name}
|
||||
</td>
|
||||
<td><select name="project_id">${createOptions(projects, 'project_id', p => p.project_name)}</select></td>
|
||||
<td><select name="task_id">${createOptions(tasks, 'task_id', t => `${t.category}:${t.subcategory}`)}</select></td>
|
||||
<td>
|
||||
<select name="overtime">
|
||||
<option value="">없음</option>
|
||||
${[1, 2, 3, 4].map(n => `<option>${n}</option>`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select name="work_type">
|
||||
${['근무', '연차', '유급', '반차', '반반차', '조퇴', '휴무'].map(t => `<option>${t}</option>`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" name="memo" placeholder="메모"></td>
|
||||
<td><button type="button" class="remove-btn">x</button></td>
|
||||
`;
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
const workTypeSelect = tr.querySelector('[name="work_type"]');
|
||||
const projectSelect = tr.querySelector('[name="project_id"]');
|
||||
const taskSelect = tr.querySelector('[name="task_id"]');
|
||||
|
||||
workTypeSelect.addEventListener('change', () => {
|
||||
const isDisabled = ['연차', '휴무', '유급'].includes(workTypeSelect.value);
|
||||
projectSelect.disabled = isDisabled;
|
||||
taskSelect.disabled = isDisabled;
|
||||
if (isDisabled) {
|
||||
projectSelect.value = DEFAULT_PROJECT_ID;
|
||||
taskSelect.value = DEFAULT_TASK_ID;
|
||||
}
|
||||
});
|
||||
|
||||
tr.querySelector('.remove-btn').addEventListener('click', () => {
|
||||
tr.remove();
|
||||
updateRowNumbers(tr.parentElement);
|
||||
});
|
||||
|
||||
return tr;
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업 보고서 테이블을 초기화하고 데이터를 채웁니다.
|
||||
* @param {{workers: Array, projects: Array, tasks: Array}} initialData - 초기 데이터
|
||||
*/
|
||||
export function initializeReportTable(initialData) {
|
||||
const tableBody = document.getElementById('reportBody');
|
||||
if (!tableBody) return;
|
||||
|
||||
tableBody.innerHTML = ''; // 기존 내용 초기화
|
||||
const { workers, projects, tasks } = initialData;
|
||||
|
||||
if (!workers || workers.length === 0) {
|
||||
tableBody.innerHTML = '<tr><td colspan="8" class="text-center">등록할 작업자 정보가 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
workers.forEach((worker, index) => {
|
||||
const row = createReportRow(worker, projects, tasks, index);
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블에서 폼 데이터를 추출하여 배열로 반환합니다.
|
||||
* @returns {Array<object>|null} - 추출된 데이터 배열 또는 유효성 검사 실패 시 null
|
||||
*/
|
||||
export function getReportData() {
|
||||
const tableBody = document.getElementById('reportBody');
|
||||
const rows = tableBody.querySelectorAll('tr');
|
||||
|
||||
if (rows.length === 0 || (rows.length === 1 && rows[0].cells.length < 2)) {
|
||||
alert('등록할 내용이 없습니다.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const reportData = [];
|
||||
const workerIds = new Set();
|
||||
|
||||
for (const tr of rows) {
|
||||
const workerId = tr.querySelector('[name="worker_id"]').value;
|
||||
if (workerIds.has(workerId)) {
|
||||
alert(`오류: 작업자 '${tr.cells[1].textContent.trim()}'가 중복 등록되었습니다.`);
|
||||
return null;
|
||||
}
|
||||
workerIds.add(workerId);
|
||||
|
||||
reportData.push({
|
||||
worker_id: workerId,
|
||||
project_id: tr.querySelector('[name="project_id"]').value,
|
||||
task_id: tr.querySelector('[name="task_id"]').value,
|
||||
overtime_hours: tr.querySelector('[name="overtime"]').value || 0,
|
||||
work_details: tr.querySelector('[name="work_type"]').value,
|
||||
memo: tr.querySelector('[name="memo"]').value
|
||||
});
|
||||
}
|
||||
|
||||
return reportData;
|
||||
}
|
||||
776
synology_deployment/web-ui/js/work-review.js
Normal file
776
synology_deployment/web-ui/js/work-review.js
Normal file
@@ -0,0 +1,776 @@
|
||||
// work-review.js - 통합 API 설정 적용 버전
|
||||
|
||||
// =================================================================
|
||||
// 🌐 통합 API 설정 import
|
||||
// =================================================================
|
||||
import { API, getAuthHeaders, apiCall } from '/js/api-config.js';
|
||||
|
||||
// 전역 변수
|
||||
let currentDate = new Date();
|
||||
let selectedDate = null;
|
||||
let selectedDateData = null;
|
||||
let basicData = {
|
||||
workTypes: [],
|
||||
workStatusTypes: [],
|
||||
errorTypes: [],
|
||||
projects: []
|
||||
};
|
||||
|
||||
// 현재 사용자 정보 가져오기
|
||||
function 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('토큰에서 사용자 정보 추출 실패:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 메시지 표시
|
||||
function showMessage(message, type = 'info') {
|
||||
const container = document.getElementById('message-container');
|
||||
container.innerHTML = `<div class="message ${type}">${message}</div>`;
|
||||
|
||||
if (type !== 'loading') {
|
||||
setTimeout(() => {
|
||||
container.innerHTML = '';
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// 날짜 포맷팅
|
||||
function formatDate(date) {
|
||||
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}`;
|
||||
}
|
||||
|
||||
// 월 표시 업데이트
|
||||
function updateMonthDisplay() {
|
||||
const monthElement = document.getElementById('currentMonth');
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth() + 1;
|
||||
monthElement.textContent = `${year}년 ${month}월`;
|
||||
}
|
||||
|
||||
// 근무 유형 분류
|
||||
function classifyWorkType(totalHours) {
|
||||
if (totalHours === 0) return { type: 'vacation', label: '휴무' };
|
||||
if (totalHours === 2) return { type: 'vacation', label: '조퇴' };
|
||||
if (totalHours === 4) return { type: 'vacation', label: '반차' };
|
||||
if (totalHours === 6) return { type: 'vacation', label: '반반차' };
|
||||
if (totalHours === 8) return { type: 'normal-work', label: '정시근무' };
|
||||
if (totalHours > 8) return { type: 'overtime', label: '잔업' };
|
||||
return { type: 'vacation', label: '기타' };
|
||||
}
|
||||
|
||||
// 캘린더 렌더링 (데이터 로드 없이)
|
||||
function renderCalendar() {
|
||||
const calendar = document.getElementById('calendar');
|
||||
|
||||
// 기존 날짜 셀들 제거 (헤더는 유지)
|
||||
const dayHeaders = calendar.querySelectorAll('.day-header');
|
||||
calendar.innerHTML = '';
|
||||
dayHeaders.forEach(header => calendar.appendChild(header));
|
||||
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
|
||||
// 해당 월의 첫째 날과 마지막 날
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
|
||||
// 첫째 주의 시작 (일요일부터 시작)
|
||||
const startDate = new Date(firstDay);
|
||||
startDate.setDate(startDate.getDate() - firstDay.getDay());
|
||||
|
||||
// 마지막 주의 끝
|
||||
const endDate = new Date(lastDay);
|
||||
endDate.setDate(endDate.getDate() + (6 - lastDay.getDay()));
|
||||
|
||||
// 오늘 날짜
|
||||
const today = new Date();
|
||||
const todayStr = formatDate(today);
|
||||
|
||||
// 날짜 셀 생성
|
||||
let currentCalendarDate = new Date(startDate);
|
||||
|
||||
while (currentCalendarDate <= endDate) {
|
||||
const dateStr = formatDate(currentCalendarDate);
|
||||
const isCurrentMonth = currentCalendarDate.getMonth() === month;
|
||||
const isToday = dateStr === todayStr;
|
||||
const isSelected = selectedDate === dateStr;
|
||||
|
||||
const dayCell = document.createElement('div');
|
||||
dayCell.className = 'day-cell';
|
||||
|
||||
if (!isCurrentMonth) {
|
||||
dayCell.classList.add('other-month');
|
||||
}
|
||||
|
||||
if (isToday) {
|
||||
dayCell.classList.add('today');
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
dayCell.classList.add('selected');
|
||||
}
|
||||
|
||||
// 날짜 번호
|
||||
const dayNumber = document.createElement('div');
|
||||
dayNumber.className = 'day-number';
|
||||
dayNumber.textContent = currentCalendarDate.getDate();
|
||||
dayCell.appendChild(dayNumber);
|
||||
|
||||
// 클릭 이벤트 - 현재 월의 날짜만 클릭 가능
|
||||
if (isCurrentMonth) {
|
||||
dayCell.style.cursor = 'pointer';
|
||||
dayCell.addEventListener('click', () => {
|
||||
selectedDate = dateStr;
|
||||
loadDayData(dateStr);
|
||||
renderCalendar(); // 선택 상태 업데이트를 위해 재렌더링
|
||||
});
|
||||
}
|
||||
|
||||
calendar.appendChild(dayCell);
|
||||
currentCalendarDate.setDate(currentCalendarDate.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 날짜 데이터 로드 (통합 API 사용)
|
||||
async function loadDayData(dateStr) {
|
||||
try {
|
||||
showMessage(`${dateStr} 데이터를 불러오는 중... (통합 API)`, 'loading');
|
||||
|
||||
const data = await apiCall(`${API}/daily-work-reports?date=${dateStr}`);
|
||||
const dataArray = Array.isArray(data) ? data : (data.data || []);
|
||||
|
||||
// 데이터 처리
|
||||
processDayData(dateStr, dataArray);
|
||||
renderDayInfo();
|
||||
|
||||
document.getElementById('message-container').innerHTML = '';
|
||||
|
||||
} catch (error) {
|
||||
console.error('날짜 데이터 로드 실패:', error);
|
||||
showMessage('데이터를 불러올 수 없습니다: ' + error.message, 'error');
|
||||
selectedDateData = null;
|
||||
renderDayInfo();
|
||||
}
|
||||
}
|
||||
|
||||
// 일별 데이터 처리
|
||||
function processDayData(dateStr, works) {
|
||||
const dayData = {
|
||||
date: dateStr,
|
||||
totalHours: 0,
|
||||
workers: new Set(),
|
||||
reviewed: Math.random() > 0.3, // 임시: 70% 확률로 검토 완료
|
||||
details: works
|
||||
};
|
||||
|
||||
works.forEach(work => {
|
||||
dayData.totalHours += parseFloat(work.work_hours || 0);
|
||||
dayData.workers.add(work.worker_name || work.worker_id);
|
||||
});
|
||||
|
||||
const workType = classifyWorkType(dayData.totalHours);
|
||||
dayData.workType = workType.type;
|
||||
dayData.workLabel = workType.label;
|
||||
|
||||
selectedDateData = dayData;
|
||||
}
|
||||
|
||||
// 선택된 날짜 정보 렌더링
|
||||
function renderDayInfo() {
|
||||
const dayInfoContainer = document.getElementById('day-info-container');
|
||||
|
||||
if (!selectedDate) {
|
||||
dayInfoContainer.innerHTML = `
|
||||
<div class="day-info-placeholder">
|
||||
<h3>📅 날짜를 선택하세요</h3>
|
||||
<p>캘린더에서 날짜를 클릭하면 해당 날짜의 작업 정보를 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedDateData) {
|
||||
dayInfoContainer.innerHTML = `
|
||||
<div class="day-info-placeholder">
|
||||
<h3>📅 ${selectedDate}</h3>
|
||||
<p>해당 날짜에 등록된 작업이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = selectedDateData;
|
||||
|
||||
// 작업자별 상세 정보 생성
|
||||
const workerDetailsHtml = Array.from(data.workers).map(worker => {
|
||||
const workerWorks = data.details.filter(w => (w.worker_name || w.worker_id) === worker);
|
||||
const workerHours = workerWorks.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0);
|
||||
|
||||
const workerWorkItemsHtml = workerWorks.map(work => `
|
||||
<div class="work-item-detail">
|
||||
<div class="work-item-info">
|
||||
<strong>${work.project_name || '프로젝트'}</strong> - ${work.work_hours}시간<br>
|
||||
<small>작업: ${work.work_type_name || '미지정'} | 상태: ${work.work_status_name || '미지정'}</small>
|
||||
${work.error_type_name ? `<br><small style="color: #dc3545;">에러: ${work.error_type_name}</small>` : ''}
|
||||
</div>
|
||||
<div class="work-item-actions">
|
||||
<button class="edit-work-btn" onclick="editWorkItem('${work.id}')">✏️ 수정</button>
|
||||
<button class="delete-work-btn" onclick="deleteWorkItem('${work.id}')">🗑️ 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<div class="worker-detail-section">
|
||||
<div class="worker-header-detail">
|
||||
<strong>👤 ${worker}</strong> - 총 ${workerHours}시간
|
||||
<button class="delete-worker-btn" onclick="deleteWorkerAllWorks('${selectedDate}', '${worker}')">
|
||||
🗑️ 전체삭제
|
||||
</button>
|
||||
</div>
|
||||
<div class="worker-work-items">
|
||||
${workerWorkItemsHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
dayInfoContainer.innerHTML = `
|
||||
<div class="day-info-content">
|
||||
<div class="day-info-header">
|
||||
<h3>📅 ${selectedDate} 작업 정보</h3>
|
||||
<div class="day-info-actions">
|
||||
<button class="review-toggle ${data.reviewed ? 'reviewed' : ''}" onclick="toggleReview()">
|
||||
${data.reviewed ? '✅ 검토완료' : '⏳ 검토하기'}
|
||||
</button>
|
||||
<button class="refresh-day-btn" onclick="refreshCurrentDay()">
|
||||
🔄 새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="day-summary">
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">총 작업시간:</span>
|
||||
<span class="summary-value">${data.totalHours}시간</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">근무 유형:</span>
|
||||
<span class="summary-value ${data.workType}">${data.workLabel}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">작업자 수:</span>
|
||||
<span class="summary-value">${data.workers.size}명</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">검토 상태:</span>
|
||||
<span class="summary-value ${data.reviewed ? 'reviewed' : 'unreviewed'}">
|
||||
${data.reviewed ? '✅ 검토완료' : '⏳ 미검토'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workers-detail-container">
|
||||
<h4>👥 작업자별 상세</h4>
|
||||
${workerDetailsHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 검토 상태 토글
|
||||
function toggleReview() {
|
||||
if (selectedDateData) {
|
||||
selectedDateData.reviewed = !selectedDateData.reviewed;
|
||||
renderDayInfo();
|
||||
|
||||
// TODO: 실제로는 여기서 API 호출해서 DB에 저장해야 함
|
||||
console.log(`검토 상태 변경: ${selectedDate} - ${selectedDateData.reviewed ? '검토완료' : '미검토'}`);
|
||||
|
||||
showMessage(`검토 상태가 ${selectedDateData.reviewed ? '완료' : '미완료'}로 변경되었습니다.`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 날짜 새로고침
|
||||
function refreshCurrentDay() {
|
||||
if (selectedDate) {
|
||||
loadDayData(selectedDate);
|
||||
}
|
||||
}
|
||||
|
||||
// 🛠️ 작업 항목 수정 함수 (통합 API 사용)
|
||||
async function editWorkItem(workId) {
|
||||
try {
|
||||
console.log('수정할 작업 ID:', workId);
|
||||
|
||||
if (!selectedDateData) {
|
||||
showMessage('작업 데이터를 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const workData = selectedDateData.details.find(work => work.id == workId);
|
||||
if (!workData) {
|
||||
showMessage('수정할 작업 데이터를 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 기본 데이터가 없으면 로드
|
||||
if (basicData.workTypes.length === 0) {
|
||||
showMessage('기본 데이터를 불러오는 중... (통합 API)', 'loading');
|
||||
await loadBasicData();
|
||||
}
|
||||
|
||||
showEditModal(workData);
|
||||
document.getElementById('message-container').innerHTML = '';
|
||||
|
||||
} catch (error) {
|
||||
console.error('작업 정보 조회 오류:', error);
|
||||
showMessage('작업 정보를 불러올 수 없습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 🛠️ 수정 모달 표시 (개선된 버전)
|
||||
function showEditModal(workData) {
|
||||
const modalHtml = `
|
||||
<div class="edit-modal" id="editModal">
|
||||
<div class="edit-modal-content">
|
||||
<div class="edit-modal-header">
|
||||
<h3>✏️ 작업 수정</h3>
|
||||
<button class="close-modal-btn" onclick="closeEditModal()">×</button>
|
||||
</div>
|
||||
<div class="edit-modal-body">
|
||||
<div class="edit-form-group">
|
||||
<label>🏗️ 프로젝트</label>
|
||||
<select class="edit-select" id="editProject" required>
|
||||
<option value="">프로젝트 선택</option>
|
||||
${basicData.projects.map(p => `
|
||||
<option value="${p.project_id}" ${p.project_id == workData.project_id ? 'selected' : ''}>
|
||||
${p.project_name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group">
|
||||
<label>⚙️ 작업 유형</label>
|
||||
<select class="edit-select" id="editWorkType" required>
|
||||
<option value="">작업 유형 선택</option>
|
||||
${basicData.workTypes.map(wt => `
|
||||
<option value="${wt.id}" ${wt.id == workData.work_type_id ? 'selected' : ''}>
|
||||
${wt.name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group">
|
||||
<label>📊 업무 상태</label>
|
||||
<select class="edit-select" id="editWorkStatus" required>
|
||||
<option value="">업무 상태 선택</option>
|
||||
${basicData.workStatusTypes.map(ws => `
|
||||
<option value="${ws.id}" ${ws.id == workData.work_status_id ? 'selected' : ''}>
|
||||
${ws.name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group" id="editErrorTypeGroup" style="${workData.work_status_id == 2 ? '' : 'display: none;'}">
|
||||
<label>❌ 에러 유형</label>
|
||||
<select class="edit-select" id="editErrorType">
|
||||
<option value="">에러 유형 선택</option>
|
||||
${basicData.errorTypes.map(et => `
|
||||
<option value="${et.id}" ${et.id == workData.error_type_id ? 'selected' : ''}>
|
||||
${et.name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group">
|
||||
<label>⏰ 작업 시간</label>
|
||||
<input type="number" class="edit-input" id="editWorkHours"
|
||||
value="${workData.work_hours}"
|
||||
min="0" max="24" step="0.5" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeEditModal()">취소</button>
|
||||
<button class="btn btn-success" onclick="saveEditedWork('${workData.id}')">💾 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
|
||||
// 업무 상태 변경 이벤트
|
||||
document.getElementById('editWorkStatus').addEventListener('change', (e) => {
|
||||
const errorTypeGroup = document.getElementById('editErrorTypeGroup');
|
||||
if (e.target.value === '2') {
|
||||
errorTypeGroup.style.display = 'block';
|
||||
} else {
|
||||
errorTypeGroup.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 🛠️ 수정 모달 닫기
|
||||
function closeEditModal() {
|
||||
const modal = document.getElementById('editModal');
|
||||
if (modal) {
|
||||
modal.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// 🛠️ 수정된 작업 저장 (통합 API 사용)
|
||||
async function saveEditedWork(workId) {
|
||||
try {
|
||||
// 입력값 검증
|
||||
const projectId = document.getElementById('editProject').value;
|
||||
const workTypeId = document.getElementById('editWorkType').value;
|
||||
const workStatusId = document.getElementById('editWorkStatus').value;
|
||||
const errorTypeId = document.getElementById('editErrorType').value;
|
||||
const workHours = document.getElementById('editWorkHours').value;
|
||||
|
||||
// 필수값 체크
|
||||
if (!projectId) {
|
||||
showMessage('프로젝트를 선택해주세요.', 'error');
|
||||
document.getElementById('editProject').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workTypeId) {
|
||||
showMessage('작업 유형을 선택해주세요.', 'error');
|
||||
document.getElementById('editWorkType').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workStatusId) {
|
||||
showMessage('업무 상태를 선택해주세요.', 'error');
|
||||
document.getElementById('editWorkStatus').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workHours || workHours <= 0) {
|
||||
showMessage('작업 시간을 올바르게 입력해주세요.', 'error');
|
||||
document.getElementById('editWorkHours').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (workStatusId === '2' && !errorTypeId) {
|
||||
showMessage('에러 상태인 경우 에러 유형을 선택해주세요.', 'error');
|
||||
document.getElementById('editErrorType').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
project_id: parseInt(projectId),
|
||||
work_type_id: parseInt(workTypeId),
|
||||
work_status_id: parseInt(workStatusId),
|
||||
error_type_id: errorTypeId ? parseInt(errorTypeId) : null,
|
||||
work_hours: parseFloat(workHours)
|
||||
};
|
||||
|
||||
showMessage('작업을 수정하는 중... (통합 API)', 'loading');
|
||||
|
||||
// 저장 버튼 비활성화
|
||||
const saveBtn = document.querySelector('.btn-success');
|
||||
const originalText = saveBtn.textContent;
|
||||
saveBtn.textContent = '저장 중...';
|
||||
saveBtn.disabled = true;
|
||||
|
||||
const result = await apiCall(`${API}/daily-work-reports/my-entry/${workId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
console.log('✅ 수정 성공 (통합 API):', result);
|
||||
showMessage('✅ 작업이 성공적으로 수정되었습니다!', 'success');
|
||||
|
||||
closeEditModal();
|
||||
refreshCurrentDay(); // 현재 날짜 데이터 새로고침
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 수정 실패:', error);
|
||||
showMessage('수정 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
|
||||
// 버튼 복원
|
||||
const saveBtn = document.querySelector('.btn-success');
|
||||
if (saveBtn) {
|
||||
saveBtn.textContent = '💾 저장';
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🗑️ 작업 항목 삭제 (통합 API 사용)
|
||||
async function deleteWorkItem(workId) {
|
||||
// 확인 대화상자
|
||||
const confirmDelete = await showConfirmDialog(
|
||||
'작업 삭제 확인',
|
||||
'정말로 이 작업을 삭제하시겠습니까?',
|
||||
'삭제된 작업은 복구할 수 없습니다.'
|
||||
);
|
||||
|
||||
if (!confirmDelete) return;
|
||||
|
||||
try {
|
||||
console.log('삭제할 작업 ID:', workId);
|
||||
|
||||
showMessage('작업을 삭제하는 중... (통합 API)', 'loading');
|
||||
|
||||
const result = await apiCall(`${API}/daily-work-reports/my-entry/${workId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
console.log('✅ 삭제 성공 (통합 API):', result);
|
||||
showMessage('✅ 작업이 성공적으로 삭제되었습니다!', 'success');
|
||||
|
||||
refreshCurrentDay(); // 현재 날짜 데이터 새로고침
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 삭제 실패:', error);
|
||||
showMessage('삭제 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 🗑️ 작업자의 모든 작업 삭제 (통합 API 사용)
|
||||
async function deleteWorkerAllWorks(date, workerName) {
|
||||
// 확인 대화상자
|
||||
const confirmDelete = await showConfirmDialog(
|
||||
'전체 작업 삭제 확인',
|
||||
`정말로 ${workerName}님의 ${date} 모든 작업을 삭제하시겠습니까?`,
|
||||
'삭제된 작업들은 복구할 수 없습니다.'
|
||||
);
|
||||
|
||||
if (!confirmDelete) return;
|
||||
|
||||
try {
|
||||
if (!selectedDateData) return;
|
||||
|
||||
const workerWorks = selectedDateData.details.filter(w => (w.worker_name || w.worker_id) === workerName);
|
||||
|
||||
if (workerWorks.length === 0) {
|
||||
showMessage('삭제할 작업이 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showMessage(`${workerName}님의 작업들을 삭제하는 중... (통합 API)`, 'loading');
|
||||
|
||||
// 순차적으로 삭제 (병렬 처리하면 서버 부하 발생 가능)
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const work of workerWorks) {
|
||||
try {
|
||||
await apiCall(`${API}/daily-work-reports/my-entry/${work.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`작업 ${work.id} 삭제 실패:`, error);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (failCount === 0) {
|
||||
showMessage(`✅ ${workerName}님의 모든 작업(${successCount}개)이 삭제되었습니다!`, 'success');
|
||||
} else {
|
||||
showMessage(`⚠️ ${successCount}개 삭제 완료, ${failCount}개 삭제 실패`, 'warning');
|
||||
}
|
||||
|
||||
refreshCurrentDay(); // 현재 날짜 데이터 새로고침
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 전체 삭제 실패:', error);
|
||||
showMessage('작업 삭제 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 확인 대화상자 표시
|
||||
function showConfirmDialog(title, message, warning) {
|
||||
return new Promise((resolve) => {
|
||||
const modalHtml = `
|
||||
<div class="confirm-modal" id="confirmModal">
|
||||
<div class="confirm-modal-content">
|
||||
<div class="confirm-modal-header">
|
||||
<h3>⚠️ ${title}</h3>
|
||||
</div>
|
||||
<div class="confirm-modal-body">
|
||||
<p><strong>${message}</strong></p>
|
||||
<p style="color: #dc3545; font-size: 0.9rem;">${warning}</p>
|
||||
</div>
|
||||
<div class="confirm-modal-footer">
|
||||
<button class="btn btn-secondary" onclick="resolveConfirm(false)">취소</button>
|
||||
<button class="btn btn-danger" onclick="resolveConfirm(true)">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
|
||||
// 전역 함수로 resolve 함수 노출
|
||||
window.resolveConfirm = (result) => {
|
||||
const modal = document.getElementById('confirmModal');
|
||||
if (modal) modal.remove();
|
||||
delete window.resolveConfirm;
|
||||
resolve(result);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 기본 데이터 로드 (통합 API 사용)
|
||||
async function loadBasicData() {
|
||||
try {
|
||||
console.log('🔗 통합 API 설정을 사용한 기본 데이터 로딩...');
|
||||
|
||||
const promises = [
|
||||
// 활성 프로젝트 로드
|
||||
apiCall(`${API}/projects/active/list`)
|
||||
.then(data => Array.isArray(data) ? data : (data.projects || []))
|
||||
.catch(() => []),
|
||||
|
||||
// 작업 유형 로드
|
||||
apiCall(`${API}/daily-work-reports/work-types`)
|
||||
.then(data => Array.isArray(data) ? data : [
|
||||
{id: 1, name: 'Base'},
|
||||
{id: 2, name: 'Vessel'},
|
||||
{id: 3, name: 'Piping'}
|
||||
])
|
||||
.catch(() => [
|
||||
{id: 1, name: 'Base'},
|
||||
{id: 2, name: 'Vessel'},
|
||||
{id: 3, name: 'Piping'}
|
||||
]),
|
||||
|
||||
// 업무 상태 유형 로드
|
||||
apiCall(`${API}/daily-work-reports/work-status-types`)
|
||||
.then(data => Array.isArray(data) ? data : [
|
||||
{id: 1, name: '정규'},
|
||||
{id: 2, name: '에러'}
|
||||
])
|
||||
.catch(() => [
|
||||
{id: 1, name: '정규'},
|
||||
{id: 2, name: '에러'}
|
||||
]),
|
||||
|
||||
// 에러 유형 로드
|
||||
apiCall(`${API}/daily-work-reports/error-types`)
|
||||
.then(data => Array.isArray(data) ? data : [
|
||||
{id: 1, name: '설계미스'},
|
||||
{id: 2, name: '외주작업 불량'},
|
||||
{id: 3, name: '입고지연'},
|
||||
{id: 4, name: '작업 불량'}
|
||||
])
|
||||
.catch(() => [
|
||||
{id: 1, name: '설계미스'},
|
||||
{id: 2, name: '외주작업 불량'},
|
||||
{id: 3, name: '입고지연'},
|
||||
{id: 4, name: '작업 불량'}
|
||||
])
|
||||
];
|
||||
|
||||
const [projects, workTypes, workStatusTypes, errorTypes] = await Promise.all(promises);
|
||||
|
||||
basicData = {
|
||||
projects,
|
||||
workTypes,
|
||||
workStatusTypes,
|
||||
errorTypes
|
||||
};
|
||||
|
||||
console.log('✅ 기본 데이터 로드 완료 (통합 API):', basicData);
|
||||
} catch (error) {
|
||||
console.error('기본 데이터 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
function setupEventListeners() {
|
||||
document.getElementById('prevMonth').addEventListener('click', () => {
|
||||
currentDate.setMonth(currentDate.getMonth() - 1);
|
||||
updateMonthDisplay();
|
||||
selectedDate = null;
|
||||
selectedDateData = null;
|
||||
renderCalendar();
|
||||
renderDayInfo();
|
||||
});
|
||||
|
||||
document.getElementById('nextMonth').addEventListener('click', () => {
|
||||
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||
updateMonthDisplay();
|
||||
selectedDate = null;
|
||||
selectedDateData = null;
|
||||
renderCalendar();
|
||||
renderDayInfo();
|
||||
});
|
||||
|
||||
// 오늘 날짜로 이동 버튼 추가
|
||||
document.getElementById('goToday')?.addEventListener('click', () => {
|
||||
const today = new Date();
|
||||
currentDate = new Date(today);
|
||||
updateMonthDisplay();
|
||||
renderCalendar();
|
||||
|
||||
// 오늘 날짜 자동 선택
|
||||
const todayStr = formatDate(today);
|
||||
selectedDate = todayStr;
|
||||
loadDayData(todayStr);
|
||||
});
|
||||
}
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.toggleReview = toggleReview;
|
||||
window.refreshCurrentDay = refreshCurrentDay;
|
||||
window.editWorkItem = editWorkItem;
|
||||
window.deleteWorkItem = deleteWorkItem;
|
||||
window.deleteWorkerAllWorks = deleteWorkerAllWorks;
|
||||
window.closeEditModal = closeEditModal;
|
||||
window.saveEditedWork = saveEditedWork;
|
||||
|
||||
// 초기화
|
||||
async function init() {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token || token === 'undefined') {
|
||||
showMessage('로그인이 필요합니다.', 'error');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
updateMonthDisplay();
|
||||
setupEventListeners();
|
||||
renderCalendar();
|
||||
renderDayInfo();
|
||||
|
||||
// 기본 데이터 미리 로드
|
||||
await loadBasicData();
|
||||
|
||||
console.log('✅ 검토 페이지 초기화 완료 (통합 API 설정 적용)');
|
||||
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
showMessage('초기화 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
509
synology_deployment/web-ui/js/worker-individual-report.js
Normal file
509
synology_deployment/web-ui/js/worker-individual-report.js
Normal file
@@ -0,0 +1,509 @@
|
||||
// worker-individual-report.js - 작업자별 개별 보고서 관리
|
||||
|
||||
// 전역 변수
|
||||
let currentWorkerId = null;
|
||||
let currentWorkerName = '';
|
||||
let selectedDate = '';
|
||||
let currentUser = null;
|
||||
let workTypes = [];
|
||||
let workStatusTypes = [];
|
||||
let errorTypes = [];
|
||||
let projects = [];
|
||||
let existingWork = [];
|
||||
|
||||
// URL 파라미터에서 정보 추출
|
||||
function getUrlParams() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return {
|
||||
worker_id: urlParams.get('worker_id'),
|
||||
worker_name: decodeURIComponent(urlParams.get('worker_name') || ''),
|
||||
date: urlParams.get('date') || new Date().toISOString().split('T')[0]
|
||||
};
|
||||
}
|
||||
|
||||
// 현재 로그인한 사용자 정보 가져오기
|
||||
function 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('토큰에서 사용자 정보 추출 실패:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
const userInfo = localStorage.getItem('user');
|
||||
if (userInfo) {
|
||||
return JSON.parse(userInfo);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('localStorage에서 사용자 정보 파싱 실패:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 메시지 표시 함수
|
||||
function showMessage(msg, type = 'info') {
|
||||
const container = document.getElementById('message-container');
|
||||
if (container) {
|
||||
container.innerHTML = `<div class="message ${type}">${msg}</div>`;
|
||||
setTimeout(() => {
|
||||
container.innerHTML = '';
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// API 함수가 로드될 때까지 기다림
|
||||
let retryCount = 0;
|
||||
const maxRetries = 50;
|
||||
|
||||
while (!window.apiCall && retryCount < maxRetries) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
retryCount++;
|
||||
}
|
||||
|
||||
if (!window.apiCall) {
|
||||
console.error('❌ API 함수를 로드할 수 없습니다.');
|
||||
showMessage('시스템을 초기화할 수 없습니다. 페이지를 새로고침해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await initializePage();
|
||||
} catch (error) {
|
||||
console.error('페이지 초기화 오류:', error);
|
||||
showMessage('페이지를 불러오는 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
async function initializePage() {
|
||||
console.log('🚀 개별 작업 보고서 페이지 초기화 시작');
|
||||
|
||||
// URL 파라미터 추출
|
||||
const params = getUrlParams();
|
||||
currentWorkerId = parseInt(params.worker_id);
|
||||
currentWorkerName = params.worker_name;
|
||||
selectedDate = params.date;
|
||||
|
||||
// 사용자 정보 설정
|
||||
currentUser = getCurrentUser();
|
||||
|
||||
if (!currentWorkerId || !currentWorkerName) {
|
||||
showMessage('잘못된 접근입니다. 작업자 정보가 없습니다.', 'error');
|
||||
setTimeout(() => {
|
||||
window.history.back();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
// 페이지 제목 설정
|
||||
updatePageHeader();
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
setupEventListeners();
|
||||
|
||||
// 초기 데이터 로드
|
||||
await loadInitialData();
|
||||
|
||||
console.log('✅ 개별 작업 보고서 페이지 초기화 완료');
|
||||
}
|
||||
|
||||
function updatePageHeader() {
|
||||
document.getElementById('pageTitle').textContent = `👤 ${currentWorkerName} 작업 보고서`;
|
||||
document.getElementById('pageSubtitle').textContent = `${selectedDate} 작업 내용을 관리합니다.`;
|
||||
|
||||
// 작업자 정보 카드 업데이트
|
||||
document.getElementById('workerInitial').textContent = currentWorkerName.charAt(0);
|
||||
document.getElementById('workerName').textContent = currentWorkerName;
|
||||
document.getElementById('selectedDate').textContent = selectedDate;
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
// 새 작업 추가 버튼
|
||||
document.getElementById('addNewWorkBtn').addEventListener('click', showNewWorkForm);
|
||||
document.getElementById('cancelNewWorkBtn').addEventListener('click', hideNewWorkForm);
|
||||
document.getElementById('saveNewWorkBtn').addEventListener('click', saveNewWork);
|
||||
|
||||
// 업무 상태 변경 시 에러 유형 섹션 토글
|
||||
document.getElementById('newWorkStatusSelect').addEventListener('change', toggleErrorTypeSection);
|
||||
|
||||
// 빠른 시간 버튼
|
||||
document.querySelectorAll('.quick-time-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
document.getElementById('newWorkHours').value = e.target.dataset.hours;
|
||||
});
|
||||
});
|
||||
|
||||
// 휴가 처리 버튼들
|
||||
document.querySelectorAll('.vacation-process-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const vacationType = e.target.dataset.type;
|
||||
handleVacationProcess(vacationType);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
showMessage('데이터를 불러오는 중...', 'loading');
|
||||
|
||||
// 병렬로 데이터 로드
|
||||
await Promise.all([
|
||||
loadWorkerInfo(),
|
||||
loadExistingWork(),
|
||||
loadProjects(),
|
||||
loadWorkTypes(),
|
||||
loadWorkStatusTypes(),
|
||||
loadErrorTypes()
|
||||
]);
|
||||
|
||||
// UI 업데이트
|
||||
updateWorkerSummary();
|
||||
renderExistingWork();
|
||||
populateDropdowns();
|
||||
|
||||
showMessage('데이터 로드 완료', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('초기 데이터 로드 실패:', error);
|
||||
showMessage('데이터 로드 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkerInfo() {
|
||||
try {
|
||||
const response = await window.apiCall(`${window.API}/workers/${currentWorkerId}`);
|
||||
const worker = response.data || response;
|
||||
document.getElementById('workerJob').textContent = worker.job_type || '작업자';
|
||||
} catch (error) {
|
||||
console.error('작업자 정보 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExistingWork() {
|
||||
try {
|
||||
const response = await window.apiCall(`${window.API}/daily-work-reports?date=${selectedDate}&worker_id=${currentWorkerId}`);
|
||||
existingWork = Array.isArray(response) ? response : (response.data || []);
|
||||
console.log(`✅ 기존 작업 ${existingWork.length}건 로드 완료`);
|
||||
} catch (error) {
|
||||
console.error('기존 작업 로드 오류:', error);
|
||||
existingWork = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const response = await window.apiCall(`${window.API}/projects/active/list`);
|
||||
projects = Array.isArray(response) ? response : (response.data || []);
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로드 오류:', error);
|
||||
projects = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkTypes() {
|
||||
try {
|
||||
const response = await window.apiCall(`${window.API}/daily-work-reports/work-types`);
|
||||
workTypes = Array.isArray(response) ? response : (response.data || []);
|
||||
} catch (error) {
|
||||
console.error('작업 유형 로드 오류:', error);
|
||||
workTypes = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkStatusTypes() {
|
||||
try {
|
||||
const response = await window.apiCall(`${window.API}/daily-work-reports/work-status-types`);
|
||||
workStatusTypes = Array.isArray(response) ? response : (response.data || []);
|
||||
} catch (error) {
|
||||
console.error('작업 상태 유형 로드 오류:', error);
|
||||
workStatusTypes = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadErrorTypes() {
|
||||
try {
|
||||
const response = await window.apiCall(`${window.API}/daily-work-reports/error-types`);
|
||||
errorTypes = Array.isArray(response) ? response : (response.data || []);
|
||||
} catch (error) {
|
||||
console.error('에러 유형 로드 오류:', error);
|
||||
errorTypes = [];
|
||||
}
|
||||
}
|
||||
|
||||
function updateWorkerSummary() {
|
||||
const totalHours = existingWork.reduce((sum, work) => sum + parseFloat(work.work_hours || 0), 0);
|
||||
const workCount = existingWork.length;
|
||||
|
||||
document.getElementById('totalHours').textContent = `${totalHours.toFixed(1)}h`;
|
||||
document.getElementById('workCount').textContent = `${workCount}건`;
|
||||
|
||||
// 12시간 초과 경고
|
||||
if (totalHours > 12) {
|
||||
document.getElementById('totalHours').classList.add('warning');
|
||||
showMessage(`⚠️ 총 작업시간이 ${totalHours.toFixed(1)}시간으로 12시간을 초과했습니다.`, 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
function renderExistingWork() {
|
||||
const container = document.getElementById('existingWorkList');
|
||||
|
||||
if (existingWork.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📭</div>
|
||||
<h3>등록된 작업이 없습니다</h3>
|
||||
<p>${selectedDate}에 ${currentWorkerName}님의 작업이 등록되지 않았습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = existingWork.map(work => `
|
||||
<div class="existing-work-item" data-work-id="${work.id}">
|
||||
<div class="work-item-header">
|
||||
<div class="work-item-info">
|
||||
<h4>${work.project_name || '미지정 프로젝트'}</h4>
|
||||
<p>${work.work_type_name || '미지정 작업'}</p>
|
||||
</div>
|
||||
<div class="work-item-status">
|
||||
<span class="status-badge ${work.work_status_id === 2 ? 'error' : 'normal'}">
|
||||
${work.work_status_name || '정상'}
|
||||
</span>
|
||||
<span class="work-hours">${work.work_hours}h</span>
|
||||
</div>
|
||||
</div>
|
||||
${work.work_status_id === 2 && work.error_type_name ? `
|
||||
<div class="work-item-error">
|
||||
<span class="error-label">오류:</span>
|
||||
<span class="error-type">${work.error_type_name}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="work-item-actions">
|
||||
<button class="btn btn-sm btn-primary" onclick="editWork(${work.id})">
|
||||
✏️ 수정
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteWork(${work.id})">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function populateDropdowns() {
|
||||
// 프로젝트 드롭다운
|
||||
const projectSelect = document.getElementById('newProjectSelect');
|
||||
projectSelect.innerHTML = '<option value="">프로젝트를 선택하세요</option>';
|
||||
projects.forEach(project => {
|
||||
const option = document.createElement('option');
|
||||
option.value = project.project_id;
|
||||
option.textContent = project.project_name;
|
||||
projectSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// 작업 유형 드롭다운
|
||||
const workTypeSelect = document.getElementById('newWorkTypeSelect');
|
||||
workTypeSelect.innerHTML = '<option value="">작업 유형을 선택하세요</option>';
|
||||
workTypes.forEach(type => {
|
||||
const option = document.createElement('option');
|
||||
option.value = type.id;
|
||||
option.textContent = type.name;
|
||||
workTypeSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// 작업 상태 드롭다운
|
||||
const workStatusSelect = document.getElementById('newWorkStatusSelect');
|
||||
workStatusSelect.innerHTML = '<option value="">업무 상태를 선택하세요</option>';
|
||||
workStatusTypes.forEach(status => {
|
||||
const option = document.createElement('option');
|
||||
option.value = status.id;
|
||||
option.textContent = status.name;
|
||||
workStatusSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// 에러 유형 드롭다운
|
||||
const errorTypeSelect = document.getElementById('newErrorTypeSelect');
|
||||
errorTypeSelect.innerHTML = '<option value="">에러 유형을 선택하세요</option>';
|
||||
errorTypes.forEach(error => {
|
||||
const option = document.createElement('option');
|
||||
option.value = error.id;
|
||||
option.textContent = error.name;
|
||||
errorTypeSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function showNewWorkForm() {
|
||||
document.getElementById('newWorkSection').style.display = 'block';
|
||||
document.getElementById('addNewWorkBtn').style.display = 'none';
|
||||
}
|
||||
|
||||
function hideNewWorkForm() {
|
||||
document.getElementById('newWorkSection').style.display = 'none';
|
||||
document.getElementById('addNewWorkBtn').style.display = 'block';
|
||||
resetNewWorkForm();
|
||||
}
|
||||
|
||||
function resetNewWorkForm() {
|
||||
document.getElementById('newProjectSelect').value = '';
|
||||
document.getElementById('newWorkTypeSelect').value = '';
|
||||
document.getElementById('newWorkStatusSelect').value = '';
|
||||
document.getElementById('newErrorTypeSelect').value = '';
|
||||
document.getElementById('newWorkHours').value = '1.00';
|
||||
document.getElementById('newErrorTypeSection').classList.remove('visible');
|
||||
}
|
||||
|
||||
function toggleErrorTypeSection() {
|
||||
const workStatusSelect = document.getElementById('newWorkStatusSelect');
|
||||
const errorSection = document.getElementById('newErrorTypeSection');
|
||||
const errorTypeSelect = document.getElementById('newErrorTypeSelect');
|
||||
|
||||
if (workStatusSelect.value === '2') { // 에러 상태
|
||||
errorSection.classList.add('visible');
|
||||
errorTypeSelect.setAttribute('required', 'true');
|
||||
} else {
|
||||
errorSection.classList.remove('visible');
|
||||
errorTypeSelect.removeAttribute('required');
|
||||
errorTypeSelect.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNewWork() {
|
||||
try {
|
||||
const projectId = document.getElementById('newProjectSelect').value;
|
||||
const workTypeId = document.getElementById('newWorkTypeSelect').value;
|
||||
const workStatusId = document.getElementById('newWorkStatusSelect').value;
|
||||
const errorTypeId = document.getElementById('newErrorTypeSelect').value;
|
||||
const workHours = document.getElementById('newWorkHours').value;
|
||||
|
||||
// 유효성 검사
|
||||
if (!projectId || !workTypeId || !workStatusId || !workHours) {
|
||||
showMessage('모든 필수 필드를 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (workStatusId === '2' && !errorTypeId) {
|
||||
showMessage('에러 상태일 때는 에러 유형을 선택해야 합니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showMessage('작업을 저장하는 중...', 'loading');
|
||||
|
||||
const workData = {
|
||||
report_date: selectedDate,
|
||||
worker_id: currentWorkerId,
|
||||
project_id: parseInt(projectId),
|
||||
work_type_id: parseInt(workTypeId),
|
||||
work_status_id: parseInt(workStatusId),
|
||||
error_type_id: workStatusId === '2' ? parseInt(errorTypeId) : null,
|
||||
work_hours: parseFloat(workHours),
|
||||
created_by: currentUser?.user_id || 1
|
||||
};
|
||||
|
||||
const response = await window.apiCall(`${window.API}/daily-work-reports`, 'POST', workData);
|
||||
|
||||
showMessage('작업이 성공적으로 저장되었습니다.', 'success');
|
||||
|
||||
// 데이터 새로고침
|
||||
await loadExistingWork();
|
||||
updateWorkerSummary();
|
||||
renderExistingWork();
|
||||
hideNewWorkForm();
|
||||
|
||||
} catch (error) {
|
||||
console.error('작업 저장 오류:', error);
|
||||
showMessage(`작업 저장 중 오류가 발생했습니다: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function editWork(workId) {
|
||||
// TODO: 작업 수정 모달 또는 인라인 편집 구현
|
||||
console.log(`작업 ${workId} 수정`);
|
||||
showMessage('작업 수정 기능은 곧 구현될 예정입니다.', 'info');
|
||||
}
|
||||
|
||||
async function deleteWork(workId) {
|
||||
if (!confirm('이 작업을 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showMessage('작업을 삭제하는 중...', 'loading');
|
||||
|
||||
await window.apiCall(`${window.API}/daily-work-reports/${workId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
showMessage('작업이 성공적으로 삭제되었습니다.', 'success');
|
||||
|
||||
// 데이터 새로고침
|
||||
await loadExistingWork();
|
||||
updateWorkerSummary();
|
||||
renderExistingWork();
|
||||
|
||||
} catch (error) {
|
||||
console.error('작업 삭제 오류:', error);
|
||||
showMessage(`작업 삭제 중 오류가 발생했습니다: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVacationProcess(vacationType) {
|
||||
const vacationNames = {
|
||||
'full': '연차',
|
||||
'half-half': '반반차',
|
||||
'half': '반차'
|
||||
};
|
||||
|
||||
const vacationHours = {
|
||||
'full': 8,
|
||||
'half-half': 6,
|
||||
'half': 4
|
||||
};
|
||||
|
||||
if (!confirm(`${vacationNames[vacationType]} 처리하시겠습니까?\n(${vacationHours[vacationType]}시간으로 자동 입력됩니다)`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showMessage(`${vacationNames[vacationType]} 처리 중...`, 'loading');
|
||||
|
||||
// 휴가용 작업 보고서 생성
|
||||
const vacationWork = {
|
||||
report_date: selectedDate,
|
||||
worker_id: currentWorkerId,
|
||||
project_id: 1, // 기본 프로젝트 (휴가용)
|
||||
work_type_id: 999, // 휴가 전용 작업 유형 (DB에 추가 필요)
|
||||
work_status_id: 1, // 정상 상태
|
||||
error_type_id: null,
|
||||
work_hours: vacationHours[vacationType],
|
||||
created_by: currentUser?.user_id || 1
|
||||
};
|
||||
|
||||
const response = await window.apiCall(`${window.API}/daily-work-reports`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(vacationWork)
|
||||
});
|
||||
|
||||
showMessage(`${vacationNames[vacationType]} 처리가 완료되었습니다.`, 'success');
|
||||
|
||||
// 데이터 새로고침
|
||||
await loadExistingWork();
|
||||
updateWorkerSummary();
|
||||
renderExistingWork();
|
||||
|
||||
} catch (error) {
|
||||
console.error('휴가 처리 오류:', error);
|
||||
showMessage(`휴가 처리 중 오류가 발생했습니다: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 함수로 등록
|
||||
window.editWork = editWork;
|
||||
window.deleteWork = deleteWork;
|
||||
734
synology_deployment/web-ui/js/worker-management.js
Normal file
734
synology_deployment/web-ui/js/worker-management.js
Normal file
@@ -0,0 +1,734 @@
|
||||
// 작업자 관리 페이지 JavaScript
|
||||
|
||||
// 전역 변수
|
||||
let allWorkers = [];
|
||||
let filteredWorkers = [];
|
||||
let currentEditingWorker = null;
|
||||
let currentStatusFilter = 'all'; // 'all', 'active', 'inactive'
|
||||
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('👥 작업자 관리 페이지 초기화 시작');
|
||||
|
||||
initializePage();
|
||||
loadWorkers();
|
||||
});
|
||||
|
||||
// 페이지 초기화
|
||||
function initializePage() {
|
||||
// 시간 업데이트 시작
|
||||
updateCurrentTime();
|
||||
setInterval(updateCurrentTime, 1000);
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
updateUserInfo();
|
||||
|
||||
// 프로필 메뉴 토글
|
||||
setupProfileMenu();
|
||||
|
||||
// 로그아웃 버튼
|
||||
setupLogoutButton();
|
||||
|
||||
// 검색 입력 이벤트
|
||||
setupSearchInput();
|
||||
}
|
||||
|
||||
// 현재 시간 업데이트
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
function updateUserInfo() {
|
||||
let userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
|
||||
let authUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
|
||||
const finalUserInfo = {
|
||||
worker_name: userInfo.worker_name || authUser.username || authUser.worker_name,
|
||||
job_type: userInfo.job_type || authUser.role || authUser.job_type,
|
||||
username: authUser.username || userInfo.username
|
||||
};
|
||||
|
||||
const userNameElement = document.getElementById('userName');
|
||||
const userRoleElement = document.getElementById('userRole');
|
||||
const userInitialElement = document.getElementById('userInitial');
|
||||
|
||||
if (userNameElement) {
|
||||
userNameElement.textContent = finalUserInfo.worker_name || '사용자';
|
||||
}
|
||||
|
||||
if (userRoleElement) {
|
||||
const roleMap = {
|
||||
'leader': '그룹장',
|
||||
'worker': '작업자',
|
||||
'admin': '관리자',
|
||||
'system': '시스템 관리자'
|
||||
};
|
||||
userRoleElement.textContent = roleMap[finalUserInfo.job_type] || finalUserInfo.job_type || '작업자';
|
||||
}
|
||||
|
||||
if (userInitialElement) {
|
||||
const name = finalUserInfo.worker_name || '사용자';
|
||||
userInitialElement.textContent = name.charAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 프로필 메뉴 설정
|
||||
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';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 입력 설정
|
||||
function setupSearchInput() {
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function() {
|
||||
searchWorkers();
|
||||
});
|
||||
|
||||
searchInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
searchWorkers();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 작업자 목록 로드
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
console.log('📊 작업자 목록 로딩 시작');
|
||||
|
||||
const response = await apiCall('/workers?limit=1000', 'GET'); // 모든 작업자 조회
|
||||
|
||||
console.log('📊 API 응답 구조:', response);
|
||||
|
||||
// API 응답이 { success: true, data: [...] } 형태인 경우 처리
|
||||
let workerData = [];
|
||||
if (response && response.success && Array.isArray(response.data)) {
|
||||
workerData = response.data;
|
||||
} else if (Array.isArray(response)) {
|
||||
workerData = response;
|
||||
} else {
|
||||
console.warn('작업자 데이터가 배열이 아닙니다:', response);
|
||||
workerData = [];
|
||||
}
|
||||
|
||||
allWorkers = workerData;
|
||||
|
||||
console.log(`✅ 작업자 ${allWorkers.length}명 로드 완료`);
|
||||
|
||||
// 초기 필터 적용
|
||||
applyAllFilters();
|
||||
updateStatCardActiveState();
|
||||
|
||||
} catch (error) {
|
||||
console.error('작업자 로딩 오류:', error);
|
||||
showToast('작업자 목록을 불러오는데 실패했습니다.', 'error');
|
||||
allWorkers = [];
|
||||
filteredWorkers = [];
|
||||
renderWorkers();
|
||||
}
|
||||
}
|
||||
|
||||
// 작업자 목록 렌더링
|
||||
function renderWorkers() {
|
||||
const workersGrid = document.getElementById('workersGrid');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
|
||||
if (!workersGrid || !emptyState) return;
|
||||
|
||||
if (filteredWorkers.length === 0) {
|
||||
workersGrid.style.display = 'none';
|
||||
emptyState.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
workersGrid.style.display = 'grid';
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
const workersHtml = filteredWorkers.map(worker => {
|
||||
// 작업자 상태 및 직책 아이콘
|
||||
const jobTypeMap = {
|
||||
'worker': { icon: '👷', text: '작업자', color: '#6b7280' },
|
||||
'leader': { icon: '👨💼', text: '그룹장', color: '#3b82f6' },
|
||||
'admin': { icon: '👨💻', text: '관리자', color: '#8b5cf6' }
|
||||
};
|
||||
|
||||
const jobType = jobTypeMap[worker.job_type] || jobTypeMap['worker'];
|
||||
const isInactive = worker.status === 'inactive' || worker.is_active === 0 || worker.is_active === false;
|
||||
|
||||
console.log('🎨 카드 렌더링:', {
|
||||
worker_id: worker.worker_id,
|
||||
worker_name: worker.worker_name,
|
||||
status: worker.status,
|
||||
is_active: worker.is_active,
|
||||
isInactive: isInactive
|
||||
});
|
||||
|
||||
return `
|
||||
<div class="project-card worker-card ${isInactive ? 'inactive' : ''}" onclick="editWorker(${worker.worker_id})">
|
||||
${isInactive ? '<div class="inactive-overlay"><span class="inactive-badge">🚫 비활성화됨</span></div>' : ''}
|
||||
<div class="project-header">
|
||||
<div class="project-info">
|
||||
<div class="worker-avatar">
|
||||
<span class="avatar-initial">${worker.worker_name.charAt(0)}</span>
|
||||
</div>
|
||||
<h3 class="project-name">
|
||||
${worker.worker_name}
|
||||
${isInactive ? '<span class="inactive-label">(비활성)</span>' : ''}
|
||||
</h3>
|
||||
<div class="project-meta">
|
||||
<span style="color: ${jobType.color}; font-weight: 500;">${jobType.icon} ${jobType.text}</span>
|
||||
${worker.phone_number ? `<span>📞 ${worker.phone_number}</span>` : ''}
|
||||
${worker.email ? `<span>📧 ${worker.email}</span>` : ''}
|
||||
${worker.department ? `<span>🏢 ${worker.department}</span>` : ''}
|
||||
${worker.hire_date ? `<span>📅 입사: ${formatDate(worker.hire_date)}</span>` : ''}
|
||||
${isInactive ? '<span class="inactive-notice">⚠️ 작업보고서에서 숨김</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-actions">
|
||||
<button class="btn-toggle ${isInactive ? 'btn-activate' : 'btn-deactivate'}"
|
||||
onclick="event.stopPropagation(); toggleWorkerStatus(${worker.worker_id})"
|
||||
title="${isInactive ? '활성화' : '비활성화'}">
|
||||
${isInactive ? '✅' : '❌'}
|
||||
</button>
|
||||
<button class="btn-edit" onclick="event.stopPropagation(); editWorker(${worker.worker_id})" title="수정">
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn-delete" onclick="event.stopPropagation(); confirmDeleteWorker(${worker.worker_id})" title="삭제">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
workersGrid.innerHTML = workersHtml;
|
||||
}
|
||||
|
||||
// 작업자 통계 업데이트
|
||||
function updateWorkerStats() {
|
||||
const activeWorkers = filteredWorkers.filter(w => w.status !== 'inactive' && w.is_active !== 0 && w.is_active !== false);
|
||||
const inactiveWorkers = filteredWorkers.filter(w => w.status === 'inactive' || w.is_active === 0 || w.is_active === false);
|
||||
|
||||
const activeWorkersElement = document.getElementById('activeWorkers');
|
||||
const inactiveWorkersElement = document.getElementById('inactiveWorkers');
|
||||
const totalWorkersElement = document.getElementById('totalWorkers');
|
||||
|
||||
if (activeWorkersElement) {
|
||||
activeWorkersElement.textContent = activeWorkers.length;
|
||||
}
|
||||
|
||||
if (inactiveWorkersElement) {
|
||||
inactiveWorkersElement.textContent = inactiveWorkers.length;
|
||||
}
|
||||
|
||||
if (totalWorkersElement) {
|
||||
totalWorkersElement.textContent = filteredWorkers.length;
|
||||
}
|
||||
|
||||
console.log('📊 작업자 통계:', {
|
||||
전체: filteredWorkers.length,
|
||||
활성: activeWorkers.length,
|
||||
비활성: inactiveWorkers.length
|
||||
});
|
||||
}
|
||||
|
||||
// 날짜 포맷팅
|
||||
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 filterByStatus(status) {
|
||||
currentStatusFilter = status;
|
||||
|
||||
// 통계 카드 활성화 상태 업데이트
|
||||
updateStatCardActiveState();
|
||||
|
||||
// 필터링 적용
|
||||
applyAllFilters();
|
||||
|
||||
console.log(`🔍 상태 필터 적용: ${status}`);
|
||||
}
|
||||
|
||||
// 통계 카드 활성화 상태 업데이트
|
||||
function updateStatCardActiveState() {
|
||||
// 모든 통계 카드에서 active 클래스 제거
|
||||
document.querySelectorAll('.stat-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
|
||||
// 현재 선택된 필터에 active 클래스 추가
|
||||
const activeCard = document.querySelector(`.${currentStatusFilter === 'active' ? 'active-stat' :
|
||||
currentStatusFilter === 'inactive' ? 'inactive-stat' : 'total-stat'}`);
|
||||
if (activeCard) {
|
||||
activeCard.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 필터 적용 (검색 + 상태 + 직책)
|
||||
function applyAllFilters() {
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const jobTypeFilter = document.getElementById('jobTypeFilter');
|
||||
const statusFilter = document.getElementById('statusFilter');
|
||||
|
||||
const searchTerm = searchInput ? searchInput.value.toLowerCase().trim() : '';
|
||||
const jobTypeValue = jobTypeFilter ? jobTypeFilter.value : '';
|
||||
const statusValue = statusFilter ? statusFilter.value : '';
|
||||
|
||||
// 1단계: 상태 필터링 (통계 카드 클릭)
|
||||
let statusFiltered = [...allWorkers];
|
||||
if (currentStatusFilter === 'active') {
|
||||
statusFiltered = allWorkers.filter(w => w.status !== 'inactive' && w.is_active !== 0 && w.is_active !== false);
|
||||
} else if (currentStatusFilter === 'inactive') {
|
||||
statusFiltered = allWorkers.filter(w => w.status === 'inactive' || w.is_active === 0 || w.is_active === false);
|
||||
}
|
||||
|
||||
// 2단계: 드롭다운 상태 필터링
|
||||
if (statusValue) {
|
||||
if (statusValue === 'active') {
|
||||
statusFiltered = statusFiltered.filter(w => w.status !== 'inactive' && w.is_active !== 0 && w.is_active !== false);
|
||||
} else if (statusValue === 'inactive') {
|
||||
statusFiltered = statusFiltered.filter(w => w.status === 'inactive' || w.is_active === 0 || w.is_active === false);
|
||||
}
|
||||
}
|
||||
|
||||
// 3단계: 직책 필터링
|
||||
let jobTypeFiltered = statusFiltered;
|
||||
if (jobTypeValue) {
|
||||
jobTypeFiltered = statusFiltered.filter(w => w.job_type === jobTypeValue);
|
||||
}
|
||||
|
||||
// 4단계: 검색 필터링
|
||||
if (!searchTerm) {
|
||||
filteredWorkers = jobTypeFiltered;
|
||||
} else {
|
||||
filteredWorkers = jobTypeFiltered.filter(worker =>
|
||||
worker.worker_name.toLowerCase().includes(searchTerm) ||
|
||||
(worker.phone_number && worker.phone_number.toLowerCase().includes(searchTerm)) ||
|
||||
(worker.email && worker.email.toLowerCase().includes(searchTerm)) ||
|
||||
(worker.department && worker.department.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
}
|
||||
|
||||
renderWorkers();
|
||||
updateWorkerStats();
|
||||
}
|
||||
|
||||
// 작업자 검색
|
||||
function searchWorkers() {
|
||||
applyAllFilters();
|
||||
}
|
||||
|
||||
// 작업자 필터링
|
||||
function filterWorkers() {
|
||||
applyAllFilters();
|
||||
}
|
||||
|
||||
// 작업자 정렬
|
||||
function sortWorkers() {
|
||||
const sortBy = document.getElementById('sortBy');
|
||||
const sortField = sortBy ? sortBy.value : 'created_at';
|
||||
|
||||
filteredWorkers.sort((a, b) => {
|
||||
switch (sortField) {
|
||||
case 'worker_name':
|
||||
return a.worker_name.localeCompare(b.worker_name);
|
||||
case 'job_type':
|
||||
const jobOrder = { 'admin': 0, 'leader': 1, 'worker': 2 };
|
||||
return (jobOrder[a.job_type] || 3) - (jobOrder[b.job_type] || 3);
|
||||
case 'created_at':
|
||||
default:
|
||||
return new Date(b.created_at || 0) - new Date(a.created_at || 0);
|
||||
}
|
||||
});
|
||||
|
||||
renderWorkers();
|
||||
}
|
||||
|
||||
// 작업자 목록 새로고침
|
||||
async function refreshWorkerList() {
|
||||
const refreshBtn = document.querySelector('.btn-secondary');
|
||||
if (refreshBtn) {
|
||||
const originalText = refreshBtn.innerHTML;
|
||||
refreshBtn.innerHTML = '<span class="btn-icon">⏳</span>새로고침 중...';
|
||||
refreshBtn.disabled = true;
|
||||
|
||||
await loadWorkers();
|
||||
|
||||
refreshBtn.innerHTML = originalText;
|
||||
refreshBtn.disabled = false;
|
||||
} else {
|
||||
await loadWorkers();
|
||||
}
|
||||
|
||||
showToast('작업자 목록이 새로고침되었습니다.', 'success');
|
||||
}
|
||||
|
||||
// 작업자 모달 열기
|
||||
function openWorkerModal(worker = null) {
|
||||
const modal = document.getElementById('workerModal');
|
||||
const modalTitle = document.getElementById('modalTitle');
|
||||
const deleteBtn = document.getElementById('deleteWorkerBtn');
|
||||
|
||||
if (!modal) return;
|
||||
|
||||
currentEditingWorker = worker;
|
||||
|
||||
if (worker) {
|
||||
// 수정 모드
|
||||
modalTitle.textContent = '작업자 정보 수정';
|
||||
deleteBtn.style.display = 'inline-flex';
|
||||
|
||||
// 폼에 데이터 채우기
|
||||
document.getElementById('workerId').value = worker.worker_id;
|
||||
document.getElementById('workerName').value = worker.worker_name || '';
|
||||
document.getElementById('jobType').value = worker.job_type || 'worker';
|
||||
document.getElementById('phoneNumber').value = worker.phone_number || '';
|
||||
document.getElementById('email').value = worker.email || '';
|
||||
document.getElementById('hireDate').value = worker.hire_date || '';
|
||||
document.getElementById('department').value = worker.department || '';
|
||||
document.getElementById('notes').value = worker.notes || '';
|
||||
|
||||
// is_active 값 처리 (DB에서 0/1로 오는 경우 대비)
|
||||
const isActiveValue = worker.status !== 'inactive' && worker.is_active !== 0 && worker.is_active !== false;
|
||||
document.getElementById('isActive').checked = isActiveValue;
|
||||
|
||||
console.log('🔧 작업자 로드:', {
|
||||
worker_id: worker.worker_id,
|
||||
worker_name: worker.worker_name,
|
||||
status: worker.status,
|
||||
is_active_raw: worker.is_active,
|
||||
is_active_processed: isActiveValue
|
||||
});
|
||||
} else {
|
||||
// 신규 등록 모드
|
||||
modalTitle.textContent = '새 작업자 등록';
|
||||
deleteBtn.style.display = 'none';
|
||||
|
||||
// 폼 초기화
|
||||
document.getElementById('workerForm').reset();
|
||||
document.getElementById('workerId').value = '';
|
||||
document.getElementById('isActive').checked = true;
|
||||
}
|
||||
|
||||
modal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// 첫 번째 입력 필드에 포커스
|
||||
setTimeout(() => {
|
||||
const firstInput = document.getElementById('workerName');
|
||||
if (firstInput) firstInput.focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// 작업자 모달 닫기
|
||||
function closeWorkerModal() {
|
||||
const modal = document.getElementById('workerModal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
currentEditingWorker = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 작업자 편집
|
||||
function editWorker(workerId) {
|
||||
const worker = allWorkers.find(w => w.worker_id === workerId);
|
||||
if (worker) {
|
||||
openWorkerModal(worker);
|
||||
} else {
|
||||
showToast('작업자를 찾을 수 없습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 작업자 저장
|
||||
async function saveWorker() {
|
||||
try {
|
||||
const form = document.getElementById('workerForm');
|
||||
|
||||
const workerData = {
|
||||
worker_name: document.getElementById('workerName').value.trim(),
|
||||
job_type: document.getElementById('jobType').value || 'worker',
|
||||
phone_number: document.getElementById('phoneNumber').value.trim() || null,
|
||||
email: document.getElementById('email').value.trim() || null,
|
||||
hire_date: document.getElementById('hireDate').value || null,
|
||||
department: document.getElementById('department').value.trim() || null,
|
||||
notes: document.getElementById('notes').value.trim() || null,
|
||||
status: document.getElementById('isActive').checked ? 'active' : 'inactive'
|
||||
};
|
||||
|
||||
console.log('💾 저장할 작업자 데이터:', workerData);
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!workerData.worker_name) {
|
||||
showToast('작업자명은 필수 입력 항목입니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const workerId = document.getElementById('workerId').value;
|
||||
let response;
|
||||
|
||||
if (workerId) {
|
||||
// 수정
|
||||
response = await apiCall(`/workers/${workerId}`, 'PUT', workerData);
|
||||
} else {
|
||||
// 신규 등록
|
||||
response = await apiCall('/workers', 'POST', workerData);
|
||||
}
|
||||
|
||||
if (response && (response.success || response.worker_id)) {
|
||||
const action = workerId ? '수정' : '등록';
|
||||
showToast(`작업자가 성공적으로 ${action}되었습니다.`, 'success');
|
||||
|
||||
closeWorkerModal();
|
||||
await loadWorkers();
|
||||
} else {
|
||||
throw new Error(response?.message || '저장에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('작업자 저장 오류:', error);
|
||||
showToast(error.message || '작업자 저장 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 작업자 상태 토글 (활성화/비활성화)
|
||||
async function toggleWorkerStatus(workerId) {
|
||||
const worker = allWorkers.find(w => w.worker_id === workerId);
|
||||
if (!worker) {
|
||||
showToast('작업자를 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const isCurrentlyInactive = worker.status === 'inactive' || worker.is_active === 0 || worker.is_active === false;
|
||||
const newStatus = isCurrentlyInactive ? 'active' : 'inactive';
|
||||
const actionText = isCurrentlyInactive ? '활성화' : '비활성화';
|
||||
|
||||
if (!confirm(`"${worker.worker_name}" 작업자를 ${actionText}하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🔄 작업자 상태 변경: ${worker.worker_name} → ${newStatus}`);
|
||||
|
||||
try {
|
||||
const updateData = {
|
||||
...worker,
|
||||
status: newStatus,
|
||||
is_active: newStatus === 'active' ? 1 : 0
|
||||
};
|
||||
|
||||
const response = await window.apiCall(`${window.API}/workers/${workerId}`, 'PUT', updateData);
|
||||
|
||||
if (response) {
|
||||
// 로컬 데이터 업데이트
|
||||
const workerIndex = allWorkers.findIndex(w => w.worker_id === workerId);
|
||||
if (workerIndex !== -1) {
|
||||
allWorkers[workerIndex].status = newStatus;
|
||||
allWorkers[workerIndex].is_active = newStatus === 'active' ? 1 : 0;
|
||||
}
|
||||
|
||||
// UI 새로고침
|
||||
applyAllFilters();
|
||||
updateWorkerStats();
|
||||
|
||||
showToast(`${worker.worker_name} 작업자가 ${actionText}되었습니다.`, 'success');
|
||||
console.log(`✅ 작업자 상태 변경 완료: ${worker.worker_name} → ${newStatus}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업자 상태 변경 오류:', error);
|
||||
showToast(`작업자 상태 변경에 실패했습니다: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 작업자 삭제 확인
|
||||
function confirmDeleteWorker(workerId) {
|
||||
console.log('🔍 삭제 요청된 작업자 ID:', workerId);
|
||||
|
||||
const worker = allWorkers.find(w => w.worker_id === workerId);
|
||||
if (!worker) {
|
||||
console.error('❌ 작업자를 찾을 수 없음:', workerId);
|
||||
showToast('작업자를 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('👤 삭제 대상 작업자:', {
|
||||
worker_id: worker.worker_id,
|
||||
worker_name: worker.worker_name,
|
||||
job_type: worker.job_type
|
||||
});
|
||||
|
||||
// 더 명확한 확인 메시지
|
||||
const confirmMessage = `⚠️ 작업자 삭제 확인 ⚠️
|
||||
|
||||
삭제할 작업자: ${worker.worker_name} (ID: ${worker.worker_id})
|
||||
직책: ${worker.job_type || '미지정'}
|
||||
|
||||
정말로 이 작업자를 삭제하시겠습니까?
|
||||
|
||||
⚠️ 주의: 삭제된 작업자와 관련된 모든 데이터가 함께 삭제됩니다.
|
||||
- 작업 보고서
|
||||
- 이슈 보고서
|
||||
- 월별 통계
|
||||
- 그룹 소속 정보
|
||||
|
||||
이 작업은 되돌릴 수 없습니다!`;
|
||||
|
||||
if (confirm(confirmMessage)) {
|
||||
console.log('✅ 사용자가 삭제를 확인함');
|
||||
deleteWorkerById(workerId);
|
||||
} else {
|
||||
console.log('❌ 사용자가 삭제를 취소함');
|
||||
}
|
||||
}
|
||||
|
||||
// 작업자 삭제 (수정 모드에서)
|
||||
function deleteWorker() {
|
||||
if (currentEditingWorker) {
|
||||
confirmDeleteWorker(currentEditingWorker.worker_id);
|
||||
}
|
||||
}
|
||||
|
||||
// 작업자 삭제 실행
|
||||
async function deleteWorkerById(workerId) {
|
||||
console.log('🗑️ 작업자 삭제 실행 시작:', workerId);
|
||||
|
||||
try {
|
||||
const worker = allWorkers.find(w => w.worker_id === workerId);
|
||||
console.log('🔍 삭제 실행 전 작업자 정보:', worker);
|
||||
|
||||
const response = await window.apiCall(`${window.API}/workers/${workerId}`, 'DELETE');
|
||||
console.log('📡 삭제 API 응답:', response);
|
||||
|
||||
if (response && (response.success || response.message)) {
|
||||
console.log('✅ 작업자 삭제 성공');
|
||||
showToast(`작업자 "${worker?.worker_name || workerId}"가 성공적으로 삭제되었습니다.`, 'success');
|
||||
|
||||
closeWorkerModal();
|
||||
await loadWorkers();
|
||||
} else {
|
||||
throw new Error(response?.message || '삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 작업자 삭제 오류:', error);
|
||||
showToast(error.message || '작업자 삭제 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 토스트 메시지 표시
|
||||
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.openWorkerModal = openWorkerModal;
|
||||
window.closeWorkerModal = closeWorkerModal;
|
||||
window.editWorker = editWorker;
|
||||
window.saveWorker = saveWorker;
|
||||
window.deleteWorker = deleteWorker;
|
||||
window.confirmDeleteWorker = confirmDeleteWorker;
|
||||
window.searchWorkers = searchWorkers;
|
||||
window.filterWorkers = filterWorkers;
|
||||
window.sortWorkers = sortWorkers;
|
||||
window.refreshWorkerList = refreshWorkerList;
|
||||
window.filterByStatus = filterByStatus;
|
||||
Reference in New Issue
Block a user