- 순찰/점검 기능 개선 (zone-detail 페이지 추가) - 출근/근태 시스템 개선 (연차 조회, 근무현황) - 작업분석 대분류 그룹화 및 마이그레이션 스크립트 - 모바일 네비게이션 UI 추가 - NAS 배포 도구 및 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
626 lines
20 KiB
JavaScript
626 lines
20 KiB
JavaScript
// 프로젝트 관리 페이지 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 hours = String(now.getHours()).padStart(2, '0');
|
|
const minutes = String(now.getMinutes()).padStart(2, '0');
|
|
const seconds = String(now.getSeconds()).padStart(2, '0');
|
|
const timeString = `${hours}시 ${minutes}분 ${seconds}초`;
|
|
|
|
const timeElement = document.getElementById('timeValue');
|
|
if (timeElement) {
|
|
timeElement.textContent = timeString;
|
|
}
|
|
}
|
|
|
|
// navbar/sidebar는 app-init.js에서 공통 처리
|
|
function updateUserInfo() {
|
|
// app-init.js가 navbar 사용자 정보를 처리
|
|
}
|
|
|
|
// 프로필 메뉴 설정
|
|
function setupProfileMenu() {
|
|
const userProfile = document.getElementById('userProfile');
|
|
const profileMenu = document.getElementById('profileMenu');
|
|
|
|
if (userProfile && profileMenu) {
|
|
userProfile.addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
const isVisible = profileMenu.style.display === 'block';
|
|
profileMenu.style.display = isVisible ? 'none' : 'block';
|
|
});
|
|
|
|
// 외부 클릭 시 메뉴 닫기
|
|
document.addEventListener('click', function() {
|
|
profileMenu.style.display = 'none';
|
|
});
|
|
}
|
|
}
|
|
|
|
// 로그아웃 버튼 설정
|
|
function setupLogoutButton() {
|
|
const logoutBtn = document.getElementById('logoutBtn');
|
|
if (logoutBtn) {
|
|
logoutBtn.addEventListener('click', function() {
|
|
if (confirm('로그아웃 하시겠습니까?')) {
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('user');
|
|
localStorage.removeItem('userInfo');
|
|
window.location.href = '/index.html';
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// 검색 입력 설정
|
|
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 validStatuses = ['planning', 'active', 'completed', 'cancelled'];
|
|
const safeProjectStatus = validStatuses.includes(project.project_status) ? project.project_status : 'active';
|
|
const status = statusMap[safeProjectStatus];
|
|
// is_active 값 처리 (DB에서 0/1로 오는 경우 대비)
|
|
const isInactive = project.is_active === 0 || project.is_active === false || project.is_active === 'false';
|
|
|
|
// XSS 방지를 위한 안전한 값
|
|
const safeProjectId = parseInt(project.project_id) || 0;
|
|
const safeJobNo = escapeHtml(project.job_no || 'Job No. 없음');
|
|
const safeProjectName = escapeHtml(project.project_name || '-');
|
|
const safePm = escapeHtml(project.pm || '-');
|
|
const safeSite = escapeHtml(project.site || '-');
|
|
|
|
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(${safeProjectId})">
|
|
${isInactive ? '<div class="inactive-overlay"><span class="inactive-badge">🚫 비활성화됨</span></div>' : ''}
|
|
<div class="project-header">
|
|
<div class="project-info">
|
|
<div class="project-job-no">${safeJobNo}</div>
|
|
<h3 class="project-name">
|
|
${safeProjectName}
|
|
${isInactive ? '<span class="inactive-label">(비활성)</span>' : ''}
|
|
</h3>
|
|
<div class="project-meta">
|
|
<div class="meta-row">
|
|
<span class="meta-label">상태</span>
|
|
<span class="meta-value" style="color: ${status.color}; font-weight: 600;">${status.icon} ${status.text}</span>
|
|
</div>
|
|
<div class="meta-row">
|
|
<span class="meta-label">계약일</span>
|
|
<span class="meta-value">${project.contract_date ? formatDate(project.contract_date) : '-'}</span>
|
|
</div>
|
|
<div class="meta-row">
|
|
<span class="meta-label">납기일</span>
|
|
<span class="meta-value">${project.due_date ? formatDate(project.due_date) : '-'}</span>
|
|
</div>
|
|
<div class="meta-row">
|
|
<span class="meta-label">PM</span>
|
|
<span class="meta-value">${safePm}</span>
|
|
</div>
|
|
<div class="meta-row">
|
|
<span class="meta-label">현장</span>
|
|
<span class="meta-value">${safeSite}</span>
|
|
</div>
|
|
${isInactive ? '<div class="inactive-notice">⚠️ 작업보고서에서 숨김</div>' : ''}
|
|
</div>
|
|
</div>
|
|
<div class="project-actions">
|
|
<button class="btn-edit" onclick="event.stopPropagation(); editProject(${safeProjectId})" title="수정">
|
|
✏️ 수정
|
|
</button>
|
|
<button class="btn-delete" onclick="event.stopPropagation(); confirmDeleteProject(${safeProjectId})" 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;
|