Files
Groupware/Project/Web/wwwroot/react/Project.jsx
ChiKyun Kim 6bd4f84192 feat(service): Console_SendMail을 Windows 서비스로 변환
- 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>
2025-09-11 09:08:40 +09:00

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