- MailService.cs 추가: ServiceBase 상속받는 Windows 서비스 클래스 - Program.cs 수정: 서비스/콘솔 모드 지원, 설치/제거 기능 추가 - 프로젝트 설정: System.ServiceProcess 참조 추가 - 배치 파일 추가: 서비스 설치/제거/콘솔실행 스크립트 주요 기능: - Windows 서비스로 백그라운드 실행 - 명령행 인수로 모드 선택 (-install, -uninstall, -console) - EventLog를 통한 서비스 로깅 - 안전한 서비스 시작/중지 처리 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
884 lines
54 KiB
JavaScript
884 lines
54 KiB
JavaScript
const { useState, useEffect } = React;
|
|
|
|
function Project() {
|
|
// 상태 관리
|
|
const [projects, setProjects] = useState([]);
|
|
const [filteredProjects, setFilteredProjects] = useState([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [currentUser, setCurrentUser] = useState('사용자');
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [itemsPerPage] = useState(10);
|
|
|
|
// 모달 상태
|
|
const [showProjectModal, setShowProjectModal] = useState(false);
|
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
|
const [currentProjectIdx, setCurrentProjectIdx] = useState(null);
|
|
|
|
// 필터 상태
|
|
const [filters, setFilters] = useState({
|
|
status: '진행',
|
|
search: '',
|
|
manager: 'my'
|
|
});
|
|
|
|
// UI 상태
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
const [showColumnDropdown, setShowColumnDropdown] = useState(false);
|
|
const [visibleColumns, setVisibleColumns] = useState({
|
|
serial: false,
|
|
plant: false,
|
|
line: false,
|
|
package: false,
|
|
staff: false,
|
|
process: false,
|
|
expire: false,
|
|
delivery: false,
|
|
design: false,
|
|
electric: false,
|
|
program: false,
|
|
budgetDue: false,
|
|
budget: false,
|
|
jasmin: false
|
|
});
|
|
|
|
// 프로젝트 폼 상태
|
|
const [projectForm, setProjectForm] = useState({
|
|
idx: 0,
|
|
name: '',
|
|
process: '',
|
|
sdate: '',
|
|
edate: '',
|
|
ddate: '',
|
|
odate: '',
|
|
userManager: '',
|
|
status: '진행',
|
|
memo: ''
|
|
});
|
|
|
|
// 통계 상태
|
|
const [statusCounts, setStatusCounts] = useState({
|
|
진행: 0,
|
|
완료: 0,
|
|
대기: 0,
|
|
중단: 0
|
|
});
|
|
|
|
// 컴포넌트 마운트시 초기화
|
|
useEffect(() => {
|
|
getCurrentUser();
|
|
loadProjects();
|
|
}, []);
|
|
|
|
// 필터 변경시 프로젝트 목록 새로 로드
|
|
useEffect(() => {
|
|
loadProjects();
|
|
}, [filters.status, filters.manager]);
|
|
|
|
// 검색어 변경시 필터링
|
|
useEffect(() => {
|
|
filterData();
|
|
}, [filters.search, projects]);
|
|
|
|
// 현재 사용자 정보 가져오기
|
|
const getCurrentUser = async () => {
|
|
try {
|
|
const response = await fetch('/Common/GetCurrentUser');
|
|
const data = await response.json();
|
|
|
|
if (data.Success && data.Data) {
|
|
const userName = data.Data.userName || data.Data.name || '사용자';
|
|
setCurrentUser(userName);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error getting current user:', error);
|
|
}
|
|
};
|
|
|
|
// 프로젝트 목록 로드
|
|
const loadProjects = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const response = await fetch(`/Project/GetProjects?status=${filters.status}&userFilter=${filters.manager}`);
|
|
const data = await response.json();
|
|
|
|
if (data.Success) {
|
|
const projectData = data.Data || [];
|
|
setProjects(projectData);
|
|
setFilteredProjects(projectData);
|
|
updateStatusCounts(projectData);
|
|
|
|
if (data.CurrentUser) {
|
|
setCurrentUser(data.CurrentUser);
|
|
}
|
|
} else {
|
|
console.error('Error:', data.Message);
|
|
showNotification('데이터를 불러오는데 실패했습니다.', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
showNotification('데이터를 불러오는데 실패했습니다.', 'error');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// 상태별 카운트 업데이트
|
|
const updateStatusCounts = (projectData) => {
|
|
const counts = { 진행: 0, 완료: 0, 대기: 0, 중단: 0 };
|
|
|
|
projectData.forEach(project => {
|
|
const status = project.상태 || '진행';
|
|
counts[status] = (counts[status] || 0) + 1;
|
|
});
|
|
|
|
setStatusCounts(counts);
|
|
};
|
|
|
|
// 데이터 필터링
|
|
const filterData = () => {
|
|
if (!filters.search.trim()) {
|
|
setFilteredProjects(projects);
|
|
setCurrentPage(1);
|
|
return;
|
|
}
|
|
|
|
const searchTerm = filters.search.toLowerCase();
|
|
const filtered = projects.filter(project => {
|
|
return (project.프로젝트명 && project.프로젝트명.toLowerCase().includes(searchTerm)) ||
|
|
(project.프로젝트공정 && project.프로젝트공정.toLowerCase().includes(searchTerm)) ||
|
|
(project.자산번호 && project.자산번호.toLowerCase().includes(searchTerm)) ||
|
|
(project.장비모델 && project.장비모델.toLowerCase().includes(searchTerm)) ||
|
|
(project.시리얼번호 && project.시리얼번호.toLowerCase().includes(searchTerm)) ||
|
|
(project.프로젝트관리자 && project.프로젝트관리자.toLowerCase().includes(searchTerm)) ||
|
|
(project.설계담당 && project.설계담당.toLowerCase().includes(searchTerm)) ||
|
|
(project.전장담당 && project.전장담당.toLowerCase().includes(searchTerm)) ||
|
|
(project.프로그램담당 && project.프로그램담당.toLowerCase().includes(searchTerm));
|
|
});
|
|
|
|
setFilteredProjects(filtered);
|
|
setCurrentPage(1);
|
|
};
|
|
|
|
// 프로젝트 추가 모달 표시
|
|
const showAddProjectModal = () => {
|
|
setCurrentProjectIdx(null);
|
|
setProjectForm({
|
|
idx: 0,
|
|
name: '',
|
|
process: '',
|
|
sdate: '',
|
|
edate: '',
|
|
ddate: '',
|
|
odate: '',
|
|
userManager: '',
|
|
status: '진행',
|
|
memo: ''
|
|
});
|
|
setShowProjectModal(true);
|
|
};
|
|
|
|
// 프로젝트 편집
|
|
const editProject = async (idx) => {
|
|
setCurrentProjectIdx(idx);
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
const response = await fetch(`/Project/GetProject?id=${idx}`);
|
|
const data = await response.json();
|
|
|
|
if (data.Success && data.Data) {
|
|
const project = data.Data;
|
|
setProjectForm({
|
|
idx: project.idx,
|
|
name: project.프로젝트명 || '',
|
|
process: project.프로젝트공정 || '',
|
|
sdate: project.시작일 || '',
|
|
edate: project.완료일 || '',
|
|
ddate: project.만료일 || '',
|
|
odate: project.출고일 || '',
|
|
userManager: project.프로젝트관리자 || '',
|
|
status: project.상태 || '진행',
|
|
memo: project.memo || ''
|
|
});
|
|
setShowProjectModal(true);
|
|
} else {
|
|
showNotification('프로젝트 정보를 불러오는데 실패했습니다: ' + data.Message, 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
showNotification('프로젝트 정보를 불러오는데 실패했습니다.', 'error');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// 프로젝트 저장 (추가/수정)
|
|
const saveProject = async (e) => {
|
|
e.preventDefault();
|
|
setIsLoading(true);
|
|
|
|
const projectData = {
|
|
...projectForm,
|
|
idx: currentProjectIdx ? parseInt(projectForm.idx) : 0,
|
|
sdate: projectForm.sdate || null,
|
|
edate: projectForm.edate || null,
|
|
ddate: projectForm.ddate || null,
|
|
odate: projectForm.odate || null
|
|
};
|
|
|
|
const url = currentProjectIdx ? '/Project/UpdateProject' : '/Project/CreateProject';
|
|
const method = currentProjectIdx ? 'PUT' : 'POST';
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(projectData)
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.Success) {
|
|
showNotification(data.Message || (currentProjectIdx ? '프로젝트가 수정되었습니다.' : '프로젝트가 추가되었습니다.'), 'success');
|
|
setShowProjectModal(false);
|
|
loadProjects();
|
|
} else {
|
|
showNotification('오류: ' + data.Message, 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
showNotification('저장 중 오류가 발생했습니다.', 'error');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// 프로젝트 삭제
|
|
const deleteProject = async () => {
|
|
if (!currentProjectIdx) return;
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
const response = await fetch(`/Project/DeleteProject?id=${currentProjectIdx}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.Success) {
|
|
showNotification(data.Message || '프로젝트가 삭제되었습니다.', 'success');
|
|
setShowDeleteModal(false);
|
|
setShowProjectModal(false);
|
|
loadProjects();
|
|
} else {
|
|
showNotification('오류: ' + data.Message, 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
showNotification('삭제 중 오류가 발생했습니다.', 'error');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// 필터 초기화
|
|
const clearFilters = () => {
|
|
setFilters({
|
|
status: '진행',
|
|
search: '',
|
|
manager: 'my'
|
|
});
|
|
};
|
|
|
|
// 엑셀 다운로드
|
|
const exportToExcel = () => {
|
|
if (filteredProjects.length === 0) {
|
|
showNotification('내보낼 데이터가 없습니다.', 'warning');
|
|
return;
|
|
}
|
|
|
|
const headers = ['상태', '자산번호', '장비모델', '시리얼번호', '우선순위', '요청국가', '요청공장', '요청라인', '요청부서패키지', '요청자', '프로젝트공정', '시작일', '완료일', '만료일', '출고일', '프로젝트명', '프로젝트관리자', '설계담당', '전장담당', '프로그램담당', '예산만기일', '예산', '웹관리번호'];
|
|
const csvContent = [
|
|
headers.join(','),
|
|
...filteredProjects.map(project => [
|
|
project.상태 || '',
|
|
project.자산번호 || '',
|
|
project.장비모델 || '',
|
|
project.시리얼번호 || '',
|
|
project.우선순위 || '',
|
|
project.요청국가 || '',
|
|
project.요청공장 || '',
|
|
project.요청라인 || '',
|
|
project.요청부서패키지 || '',
|
|
project.요청자 || '',
|
|
project.프로젝트공정 || '',
|
|
project.시작일 || '',
|
|
project.완료일 || '',
|
|
project.만료일 || '',
|
|
project.출고일 || '',
|
|
project.프로젝트명 || '',
|
|
project.프로젝트관리자 || '',
|
|
project.설계담당 || '',
|
|
project.전장담당 || '',
|
|
project.프로그램담당 || '',
|
|
project.예산만기일 || '',
|
|
project.예산 || '',
|
|
project.웹관리번호 || ''
|
|
].join(','))
|
|
].join('\n');
|
|
|
|
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
const link = document.createElement('a');
|
|
const url = URL.createObjectURL(blob);
|
|
link.setAttribute('href', url);
|
|
link.setAttribute('download', `프로젝트목록_${new Date().toISOString().split('T')[0]}.csv`);
|
|
link.style.visibility = 'hidden';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
};
|
|
|
|
// 유틸리티 함수들
|
|
const getStatusClass = (status) => {
|
|
switch(status) {
|
|
case '진행': return 'bg-blue-500/20 text-blue-300';
|
|
case '완료': return 'bg-green-500/20 text-green-300';
|
|
case '대기': return 'bg-yellow-500/20 text-yellow-300';
|
|
case '중단': return 'bg-red-500/20 text-red-300';
|
|
default: return 'bg-gray-500/20 text-gray-300';
|
|
}
|
|
};
|
|
|
|
const formatDateToMonthDay = (dateString) => {
|
|
if (!dateString) return '';
|
|
|
|
try {
|
|
const date = new Date(dateString);
|
|
if (isNaN(date.getTime())) return dateString;
|
|
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
return `${month}-${day}`;
|
|
} catch (error) {
|
|
return dateString;
|
|
}
|
|
};
|
|
|
|
// 알림 표시 함수
|
|
const showNotification = (message, type = 'info') => {
|
|
const colors = {
|
|
info: 'bg-blue-500/90 backdrop-blur-sm',
|
|
success: 'bg-green-500/90 backdrop-blur-sm',
|
|
warning: 'bg-yellow-500/90 backdrop-blur-sm',
|
|
error: 'bg-red-500/90 backdrop-blur-sm'
|
|
};
|
|
|
|
const notification = document.createElement('div');
|
|
notification.className = `fixed top-4 right-4 ${colors[type]} text-white px-4 py-3 rounded-lg z-50 transition-all duration-300 transform translate-x-0 opacity-100 shadow-lg border border-white/20`;
|
|
notification.innerHTML = `
|
|
<div class="flex items-center">
|
|
<span class="ml-2">${message}</span>
|
|
</div>
|
|
`;
|
|
|
|
notification.style.transform = 'translateX(100%)';
|
|
notification.style.opacity = '0';
|
|
document.body.appendChild(notification);
|
|
|
|
setTimeout(() => {
|
|
notification.style.transform = 'translateX(0)';
|
|
notification.style.opacity = '1';
|
|
}, 10);
|
|
|
|
setTimeout(() => {
|
|
notification.style.transform = 'translateX(100%)';
|
|
notification.style.opacity = '0';
|
|
setTimeout(() => notification.remove(), 300);
|
|
}, 3000);
|
|
};
|
|
|
|
// 페이지네이션 계산
|
|
const totalPages = Math.ceil(filteredProjects.length / itemsPerPage);
|
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
|
const endIndex = startIndex + itemsPerPage;
|
|
const paginatedProjects = filteredProjects.slice(startIndex, endIndex);
|
|
|
|
const changePage = (page) => {
|
|
if (page >= 1 && page <= totalPages) {
|
|
setCurrentPage(page);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 py-8">
|
|
{/* 프로젝트 목록 */}
|
|
<div className="glass-effect rounded-lg overflow-hidden animate-slide-up">
|
|
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
|
<h2 className="text-xl font-semibold text-white flex items-center">
|
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
|
</svg>
|
|
프로젝트 목록
|
|
</h2>
|
|
<div className="flex space-x-3">
|
|
<button onClick={showAddProjectModal} className="bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center text-sm" title="프로젝트 추가">
|
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
|
</svg>
|
|
프로젝트 추가
|
|
</button>
|
|
<button onClick={() => setShowFilters(!showFilters)} className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors flex items-center text-sm" title="필터 표시/숨김">
|
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707L13 14v6a1 1 0 01-.707.293l-4-1A1 1 0 018 19v-5L0.293 7.293A1 1 0 010 6.586V4z"></path>
|
|
</svg>
|
|
필터
|
|
<svg className={`w-4 h-4 ml-2 transition-transform ${showFilters ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 상태별 카드 */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 p-4 border-b border-white/10">
|
|
<div className="bg-white/10 rounded-lg p-4 text-center">
|
|
<div className="text-2xl font-bold text-blue-300 mb-1">{statusCounts.진행}</div>
|
|
<div className="text-sm text-white/60">진행</div>
|
|
</div>
|
|
<div className="bg-white/10 rounded-lg p-4 text-center">
|
|
<div className="text-2xl font-bold text-green-300 mb-1">{statusCounts.완료}</div>
|
|
<div className="text-sm text-white/60">완료</div>
|
|
</div>
|
|
<div className="bg-white/10 rounded-lg p-4 text-center">
|
|
<div className="text-2xl font-bold text-yellow-300 mb-1">{statusCounts.대기}</div>
|
|
<div className="text-sm text-white/60">대기</div>
|
|
</div>
|
|
<div className="bg-white/10 rounded-lg p-4 text-center">
|
|
<div className="text-2xl font-bold text-red-300 mb-1">{statusCounts.중단}</div>
|
|
<div className="text-sm text-white/60">중단</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 필터 영역 */}
|
|
{showFilters && (
|
|
<div className="p-4 border-b border-white/10 animate-slide-up">
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
{/* 좌측: 필터 컨트롤 */}
|
|
<div className="lg:col-span-2 space-y-3">
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-white/80 mb-1">상태</label>
|
|
<select
|
|
value={filters.status}
|
|
onChange={(e) => setFilters({...filters, status: e.target.value})}
|
|
className="w-full bg-white/20 border border-white/30 rounded-md px-2 py-1 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent text-xs"
|
|
>
|
|
<option value="진행">진행</option>
|
|
<option value="완료">완료</option>
|
|
<option value="대기">대기</option>
|
|
<option value="중단">중단</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-white/80 mb-1">검색</label>
|
|
<input
|
|
type="text"
|
|
value={filters.search}
|
|
onChange={(e) => setFilters({...filters, search: e.target.value})}
|
|
placeholder="프로젝트명 검색..."
|
|
className="w-full bg-white/20 border border-white/30 rounded-md px-2 py-1 text-white placeholder-white/60 focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-white/80 mb-1">담당자</label>
|
|
<select
|
|
value={filters.manager}
|
|
onChange={(e) => setFilters({...filters, manager: e.target.value})}
|
|
className="w-full bg-white/20 border border-white/30 rounded-md px-2 py-1 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent text-xs"
|
|
>
|
|
<option value="my">내 프로젝트</option>
|
|
<option value="all">전체 프로젝트</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 우측: 액션 버튼들 */}
|
|
<div className="flex flex-wrap gap-2 justify-end">
|
|
<div className="relative">
|
|
<button onClick={() => setShowColumnDropdown(!showColumnDropdown)} className="bg-purple-500 hover:bg-purple-600 text-white px-4 py-2 rounded-lg flex items-center transition-colors text-sm" title="컬럼 표시/숨김">
|
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2H9z"></path>
|
|
</svg>
|
|
컬럼 설정
|
|
</button>
|
|
{showColumnDropdown && (
|
|
<div className="absolute top-full right-0 bg-gray-800/95 backdrop-blur-sm border border-white/20 rounded-lg p-3 min-w-48 z-50 mt-1">
|
|
<div className="text-sm font-medium text-white/80 mb-2">표시할 컬럼 선택</div>
|
|
<div className="space-y-1 text-sm max-h-40 overflow-y-auto">
|
|
{Object.entries({
|
|
serial: '시리얼번호',
|
|
plant: '요청공장',
|
|
line: '요청라인',
|
|
package: '요청부서패키지',
|
|
staff: '요청자',
|
|
process: '프로젝트공정',
|
|
expire: '만료일',
|
|
delivery: '출고일',
|
|
design: '설계담당',
|
|
electric: '전장담당',
|
|
program: '프로그램담당',
|
|
budgetDue: '예산만기일',
|
|
budget: '예산',
|
|
jasmin: '웹관리번호'
|
|
}).map(([key, label]) => (
|
|
<label key={key} className="flex items-center text-white cursor-pointer hover:text-blue-300">
|
|
<input
|
|
type="checkbox"
|
|
checked={visibleColumns[key]}
|
|
onChange={(e) => setVisibleColumns({...visibleColumns, [key]: e.target.checked})}
|
|
className="mr-2"
|
|
/>
|
|
{label}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button onClick={exportToExcel} className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-lg flex items-center transition-colors text-sm" title="엑셀 다운로드">
|
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
|
</svg>
|
|
엑셀 다운로드
|
|
</button>
|
|
<button onClick={clearFilters} className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg flex items-center transition-colors text-sm" title="필터 초기화">
|
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
|
</svg>
|
|
필터 초기화
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 데이터 테이블 */}
|
|
<div className="px-4 py-3 bg-white/5">
|
|
<div className="text-sm text-white/70 font-medium">프로젝트 현황</div>
|
|
</div>
|
|
<div className="overflow-x-auto custom-scrollbar">
|
|
<table className="w-full divide-y divide-white/20">
|
|
<thead className="bg-white/10">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-20">상태</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 min-w-32">프로젝트명</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">자산번호</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-28">장비모델</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">우선순위</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-20">요청국가</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-28">프로젝트관리자</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">시작일</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">완료일</th>
|
|
{visibleColumns.serial && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">시리얼번호</th>}
|
|
{visibleColumns.plant && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-20">요청공장</th>}
|
|
{visibleColumns.line && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-20">요청라인</th>}
|
|
{visibleColumns.package && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-28">요청부서패키지</th>}
|
|
{visibleColumns.staff && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-20">요청자</th>}
|
|
{visibleColumns.process && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">프로젝트공정</th>}
|
|
{visibleColumns.expire && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">만료일</th>}
|
|
{visibleColumns.delivery && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">출고일</th>}
|
|
{visibleColumns.design && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">설계담당</th>}
|
|
{visibleColumns.electric && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">전장담당</th>}
|
|
{visibleColumns.program && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">프로그램담당</th>}
|
|
{visibleColumns.budgetDue && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">예산만기일</th>}
|
|
{visibleColumns.budget && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-20">예산</th>}
|
|
{visibleColumns.jasmin && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider w-24">웹관리번호</th>}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-white/10">
|
|
{isLoading ? (
|
|
<tr>
|
|
<td colSpan="23" className="px-6 py-4 text-center">
|
|
<div className="inline-flex items-center">
|
|
<div className="loading inline-block w-5 h-5 border-3 border-white/30 border-t-white rounded-full animate-spin mr-3"></div>
|
|
<span className="text-white/80">데이터를 불러오는 중...</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : paginatedProjects.length === 0 ? (
|
|
<tr>
|
|
<td colSpan="23" className="px-6 py-4 text-center text-white/60">데이터가 없습니다.</td>
|
|
</tr>
|
|
) : (
|
|
paginatedProjects.map(project => (
|
|
<tr key={project.idx} onClick={() => editProject(project.idx)} className="hover:bg-white/10 cursor-pointer transition-colors">
|
|
<td className="px-4 py-4 text-sm border-r border-white/20">
|
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusClass(project.상태)}`}>
|
|
{project.상태 || '진행'}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-4 text-sm border-r border-white/20 font-medium">{project.프로젝트명 || ''}</td>
|
|
<td className="px-4 py-4 text-sm border-r border-white/20">{project.자산번호 || ''}</td>
|
|
<td className="px-4 py-4 text-sm border-r border-white/20">{project.장비모델 || ''}</td>
|
|
<td className="px-4 py-4 text-sm border-r border-white/20">{project.우선순위 || ''}</td>
|
|
<td className="px-4 py-4 text-sm border-r border-white/20">{project.요청국가 || ''}</td>
|
|
<td className="px-4 py-4 text-sm border-r border-white/20">{project.프로젝트관리자 || ''}</td>
|
|
<td className="px-4 py-4 text-sm border-r border-white/20">{formatDateToMonthDay(project.시작일)}</td>
|
|
<td className="px-4 py-4 text-sm border-r border-white/20">{formatDateToMonthDay(project.완료일)}</td>
|
|
{visibleColumns.serial && <td className="px-3 py-4 text-sm border-r border-white/20">{project.시리얼번호 || ''}</td>}
|
|
{visibleColumns.plant && <td className="px-3 py-4 text-sm border-r border-white/20">{project.요청공장 || ''}</td>}
|
|
{visibleColumns.line && <td className="px-3 py-4 text-sm border-r border-white/20">{project.요청라인 || ''}</td>}
|
|
{visibleColumns.package && <td className="px-3 py-4 text-sm border-r border-white/20">{project.요청부서패키지 || ''}</td>}
|
|
{visibleColumns.staff && <td className="px-3 py-4 text-sm border-r border-white/20">{project.요청자 || ''}</td>}
|
|
{visibleColumns.process && <td className="px-3 py-4 text-sm border-r border-white/20">{project.프로젝트공정 || ''}</td>}
|
|
{visibleColumns.expire && <td className="px-3 py-4 text-sm border-r border-white/20">{formatDateToMonthDay(project.만료일)}</td>}
|
|
{visibleColumns.delivery && <td className="px-3 py-4 text-sm border-r border-white/20">{formatDateToMonthDay(project.출고일)}</td>}
|
|
{visibleColumns.design && <td className="px-3 py-4 text-sm border-r border-white/20">{project.설계담당 || ''}</td>}
|
|
{visibleColumns.electric && <td className="px-3 py-4 text-sm border-r border-white/20">{project.전장담당 || ''}</td>}
|
|
{visibleColumns.program && <td className="px-3 py-4 text-sm border-r border-white/20">{project.프로그램담당 || ''}</td>}
|
|
{visibleColumns.budgetDue && <td className="px-3 py-4 text-sm border-r border-white/20">{formatDateToMonthDay(project.예산만기일)}</td>}
|
|
{visibleColumns.budget && <td className="px-3 py-4 text-sm border-r border-white/20">{project.예산 || ''}</td>}
|
|
{visibleColumns.jasmin && (
|
|
<td className="px-3 py-4 text-sm">
|
|
{project.웹관리번호 ? (
|
|
<a href={`https://scwa.amkor.co.kr/jasmine/view/${project.웹관리번호}`} target="_blank" rel="noopener noreferrer" className="text-blue-300 hover:text-blue-200 underline">
|
|
{project.웹관리번호}
|
|
</a>
|
|
) : ''}
|
|
</td>
|
|
)}
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* 페이징 */}
|
|
<div className="px-4 py-4 border-t border-white/10">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm text-white/60">
|
|
총 {filteredProjects.length}개 중 {startIndex + 1}-{Math.min(endIndex, filteredProjects.length)}개 표시
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<button onClick={() => changePage(currentPage - 1)} disabled={currentPage <= 1} className="px-3 py-1 bg-white/20 hover:bg-white/30 text-white rounded-md text-sm disabled:opacity-50 disabled:cursor-not-allowed">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 19l-7-7 7-7"></path>
|
|
</svg>
|
|
</button>
|
|
<div className="flex space-x-1">
|
|
{Array.from({length: Math.min(5, totalPages)}, (_, i) => {
|
|
const pageNum = Math.max(1, currentPage - 2) + i;
|
|
if (pageNum > totalPages) return null;
|
|
return (
|
|
<button
|
|
key={pageNum}
|
|
onClick={() => changePage(pageNum)}
|
|
className={`px-3 py-1 rounded-md text-sm transition-colors ${
|
|
pageNum === currentPage
|
|
? 'bg-primary-500 text-white'
|
|
: 'bg-white/20 hover:bg-white/30 text-white'
|
|
}`}
|
|
>
|
|
{pageNum}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<button onClick={() => changePage(currentPage + 1)} disabled={currentPage >= totalPages} className="px-3 py-1 bg-white/20 hover:bg-white/30 text-white rounded-md text-sm disabled:opacity-50 disabled:cursor-not-allowed">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 프로젝트 추가/편집 모달 */}
|
|
{showProjectModal && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 z-50">
|
|
<div className="flex items-center justify-center min-h-screen p-4">
|
|
<div className="glass-effect rounded-lg w-full max-w-2xl animate-slide-up">
|
|
<div className="p-6">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h3 className="text-xl font-semibold">{currentProjectIdx ? '프로젝트 편집' : '프로젝트 추가'}</h3>
|
|
<button onClick={() => setShowProjectModal(false)} className="text-white/60 hover:text-white">
|
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={saveProject}>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-white/80 mb-2">프로젝트명 *</label>
|
|
<input
|
|
type="text"
|
|
value={projectForm.name}
|
|
onChange={(e) => setProjectForm({...projectForm, name: e.target.value})}
|
|
required
|
|
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white placeholder-white/60 focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-white/80 mb-2">부서</label>
|
|
<input
|
|
type="text"
|
|
value={projectForm.process}
|
|
onChange={(e) => setProjectForm({...projectForm, process: e.target.value})}
|
|
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white placeholder-white/60 focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-white/80 mb-2">시작일</label>
|
|
<input
|
|
type="date"
|
|
value={projectForm.sdate}
|
|
onChange={(e) => setProjectForm({...projectForm, sdate: e.target.value})}
|
|
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-white/80 mb-2">종료일</label>
|
|
<input
|
|
type="date"
|
|
value={projectForm.edate}
|
|
onChange={(e) => setProjectForm({...projectForm, edate: e.target.value})}
|
|
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-white/80 mb-2">개발완료일</label>
|
|
<input
|
|
type="date"
|
|
value={projectForm.ddate}
|
|
onChange={(e) => setProjectForm({...projectForm, ddate: e.target.value})}
|
|
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-white/80 mb-2">운영개시일</label>
|
|
<input
|
|
type="date"
|
|
value={projectForm.odate}
|
|
onChange={(e) => setProjectForm({...projectForm, odate: e.target.value})}
|
|
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-white/80 mb-2">담당자</label>
|
|
<input
|
|
type="text"
|
|
value={projectForm.userManager}
|
|
onChange={(e) => setProjectForm({...projectForm, userManager: e.target.value})}
|
|
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white placeholder-white/60 focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-white/80 mb-2">상태</label>
|
|
<select
|
|
value={projectForm.status}
|
|
onChange={(e) => setProjectForm({...projectForm, status: e.target.value})}
|
|
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
|
|
>
|
|
<option value="진행">진행</option>
|
|
<option value="완료">완료</option>
|
|
<option value="대기">대기</option>
|
|
<option value="중단">중단</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-white/80 mb-2">메모</label>
|
|
<textarea
|
|
value={projectForm.memo}
|
|
onChange={(e) => setProjectForm({...projectForm, memo: e.target.value})}
|
|
rows="3"
|
|
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white placeholder-white/60 focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
|
|
></textarea>
|
|
</div>
|
|
|
|
<div className="flex justify-between items-center">
|
|
{currentProjectIdx && (
|
|
<button type="button" onClick={() => setShowDeleteModal(true)} className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md transition-colors">
|
|
삭제
|
|
</button>
|
|
)}
|
|
<div className="flex space-x-2 ml-auto">
|
|
<button type="button" onClick={() => setShowProjectModal(false)} className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-md transition-colors">
|
|
취소
|
|
</button>
|
|
<button type="submit" className="bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-md transition-colors">
|
|
저장
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 삭제 확인 모달 */}
|
|
{showDeleteModal && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 z-50">
|
|
<div className="flex items-center justify-center min-h-screen p-4">
|
|
<div className="glass-effect rounded-lg w-full max-w-md animate-slide-up">
|
|
<div className="p-6">
|
|
<div className="flex items-center mb-4">
|
|
<svg className="w-8 h-8 text-red-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
|
|
</svg>
|
|
<h3 className="text-lg font-semibold">삭제 확인</h3>
|
|
</div>
|
|
<p className="text-white/80 mb-6">선택한 프로젝트를 삭제하시겠습니까?<br><span className="text-sm text-gray-500">이 작업은 되돌릴 수 없습니다.</span></p>
|
|
<div className="flex justify-end space-x-2">
|
|
<button onClick={() => setShowDeleteModal(false)} className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-md transition-colors">
|
|
취소
|
|
</button>
|
|
<button onClick={deleteProject} className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md transition-colors">
|
|
삭제
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 로딩 인디케이터 */}
|
|
{isLoading && (
|
|
<div className="fixed top-4 right-4 bg-white/20 backdrop-blur-sm rounded-full px-4 py-2 text-white text-sm z-40">
|
|
<div className="flex items-center">
|
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
|
처리 중...
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 외부 클릭시 드롭다운 닫기 */}
|
|
{showColumnDropdown && (
|
|
<div
|
|
className="fixed inset-0 z-10"
|
|
onClick={() => setShowColumnDropdown(false)}
|
|
></div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |