fix: 캘린더 모달 중복 카드 문제 및 삭제 권한 개선

- monthly_worker_status 조회 시 GROUP BY로 중복 데이터 합산
- 작업보고서 삭제 권한을 그룹장 이상으로 제한 (admin, system, group_leader)
- 중복 데이터 정리를 위한 마이그레이션 SQL 추가 (009_fix_duplicate_monthly_status.sql)
- synology_deployment 버전에도 동일 수정 적용
This commit is contained in:
Hyungi Ahn
2025-12-02 13:08:44 +09:00
parent beaffcad49
commit a9bce9d20b
419 changed files with 275129 additions and 394 deletions

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

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

View 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분마다 확인

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

File diff suppressed because it is too large Load Diff

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

View 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) 제거.
})();

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

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

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

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

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

View 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 = '등록';
}
}

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

View 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('서버에서 데이터를 가져오는 데 실패했습니다.');
}
}

View 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('인쇄 중 오류가 발생했습니다.');
}
}

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

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

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

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

View 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는 브라우저 환경에서 제거됨

View 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는 브라우저 환경에서 제거됨

View 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는 브라우저 환경에서 제거됨

View 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는 브라우저 환경에서 제거됨

View 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는 브라우저 환경에서 제거됨

View 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는 브라우저 환경에서 제거됨

View 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는 브라우저 환경에서 제거됨

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

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

File diff suppressed because it is too large Load Diff

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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