Files
Groupware/Project/Web/wwwroot/react/JobReport.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

988 lines
55 KiB
JavaScript

const { useState, useEffect } = React;
function JobReport() {
// 상태 관리
const [jobData, setJobData] = useState([]);
const [filteredData, setFilteredData] = useState([]);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [sortColumn, setSortColumn] = useState('pdate');
const [sortDirection, setSortDirection] = useState('desc');
const [isLoading, setIsLoading] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [editData, setEditData] = useState({});
const [isEditMode, setIsEditMode] = useState(false);
// 필터 상태
const [filters, setFilters] = useState({
startDate: '',
endDate: '',
status: '',
type: '',
user: '',
project: '',
search: ''
});
// 통계 데이터
const [statistics, setStatistics] = useState({
totalDays: 0,
todayHours: 0,
todayProgress: 0,
totalOT: 0,
activeProjects: 0
});
// 컴포넌트 마운트시 초기화
useEffect(() => {
initializeFilters();
loadJobData();
loadUserList();
}, []);
// 필터 변경 시 데이터 필터링
useEffect(() => {
filterData();
}, [jobData, filters]);
// 초기 필터 설정 (오늘부터 -2주)
const initializeFilters = () => {
const now = new Date();
const today = now.toISOString().split('T')[0];
const twoWeeksAgo = new Date(now.getTime() - (14 * 24 * 60 * 60 * 1000)).toISOString().split('T')[0];
setFilters(prev => ({
...prev,
startDate: twoWeeksAgo,
endDate: today
}));
};
// 업무일지 데이터 로드
const loadJobData = async () => {
setIsLoading(true);
try {
let url = '/Jobreport/GetJobData';
const params = new URLSearchParams();
if (filters.startDate) params.append('startDate', filters.startDate);
if (filters.endDate) params.append('endDate', filters.endDate);
if (filters.user) params.append('user', filters.user);
if (params.toString()) {
url += '?' + params.toString();
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: 데이터를 불러오는데 실패했습니다.`);
}
const responseText = await response.text();
const responseData = JSON.parse(responseText);
if (Array.isArray(responseData)) {
setJobData(responseData);
} else if (responseData.error) {
throw new Error(responseData.error);
} else {
setJobData([]);
}
} catch (error) {
console.error('Error loading job data:', error);
setJobData([]);
showNotification('데이터를 불러오는데 실패했습니다: ' + error.message, 'error');
} finally {
setIsLoading(false);
}
};
// 사용자 목록 로드
const loadUserList = async () => {
try {
const response = await fetch('/Jobreport/GetUsers');
if (response.ok) {
const data = await response.json();
// 사용자 데이터를 상태로 저장 (필요시)
}
} catch (error) {
console.error('사용자 목록 로드 중 오류:', error);
}
};
// 통계 업데이트
useEffect(() => {
updateStatistics();
}, [jobData]);
const updateStatistics = () => {
const totalDays = new Set(jobData.map(item => item.pdate)).size;
const totalOT = jobData.reduce((sum, item) => sum + (parseFloat(item.ot) || 0), 0);
const activeProjects = new Set(jobData.filter(item => item.status === '진행중').map(item => item.projectName)).size;
const now = new Date();
const today = now.toISOString().split('T')[0];
const todayData = jobData.filter(item => {
if (!item.pdate) return false;
const itemDate = item.pdate.toString();
if (itemDate.length >= 10) {
return itemDate.substring(0, 10) === today;
}
return false;
});
let todayHours = 0;
if (todayData.length > 0) {
todayHours = todayData.reduce((sum, item) => sum + (parseFloat(item.hrs) || 0), 0);
}
const todayProgress = (todayHours / 8) * 100;
setStatistics({
totalDays,
todayHours,
todayProgress,
totalOT,
activeProjects
});
};
// 데이터 필터링
const filterData = () => {
let filtered = jobData.filter(item => {
const statusMatch = !filters.status || item.status === filters.status;
const typeMatch = !filters.type || item.type === filters.type;
const projectMatch = !filters.project || item.projectName === filters.project;
const searchMatch = !filters.search ||
(item.description && item.description.toLowerCase().includes(filters.search.toLowerCase())) ||
(item.projectName && item.projectName.toLowerCase().includes(filters.search.toLowerCase())) ||
(item.requestpart && item.requestpart.toLowerCase().includes(filters.search.toLowerCase()));
return statusMatch && typeMatch && projectMatch && searchMatch;
});
// 정렬
filtered.sort((a, b) => {
let aVal = a[sortColumn];
let bVal = b[sortColumn];
if (sortColumn === 'pdate') {
aVal = new Date(aVal);
bVal = new Date(bVal);
} else if (['hrs', 'ot'].includes(sortColumn)) {
aVal = parseFloat(aVal) || 0;
bVal = parseFloat(bVal) || 0;
} else {
aVal = (aVal || '').toString().toLowerCase();
bVal = (bVal || '').toString().toLowerCase();
}
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
setFilteredData(filtered);
setCurrentPage(1);
};
// 필터 변경 핸들러
const handleFilterChange = (field, value) => {
setFilters(prev => ({ ...prev, [field]: value }));
// 날짜 필터 변경시 데이터 다시 로드
if (field === 'startDate' || field === 'endDate' || field === 'user') {
setTimeout(() => loadJobData(), 100);
}
};
// 필터 초기화
const clearFilters = () => {
const now = new Date();
const today = now.toISOString().split('T')[0];
const twoWeeksAgo = new Date(now.getTime() - (14 * 24 * 60 * 60 * 1000)).toISOString().split('T')[0];
setFilters({
startDate: twoWeeksAgo,
endDate: today,
status: '',
type: '',
user: '',
project: '',
search: ''
});
setTimeout(() => loadJobData(), 100);
};
// 정렬 처리
const handleSort = (column) => {
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('asc');
}
};
// 추가 모달 표시
const showAddJobModal = () => {
const today = new Date().toISOString().split('T')[0];
setIsEditMode(false);
setEditData({
idx: '',
pdate: today,
status: '진행 중',
projectName: '',
requestpart: '',
type: '',
hrs: '8',
ot: '',
otStart: '',
otEnd: '',
description: ''
});
setShowEditModal(true);
};
// 편집 모달 표시
const showEditJobModal = async (item) => {
try {
// 상세 정보 로드
const response = await fetch(`/Jobreport/GetJobDetail?id=${item.idx}`);
if (response.ok) {
const fullItem = await response.json();
item = fullItem.error ? item : fullItem;
}
} catch (error) {
console.warn('Failed to load full details, using truncated data:', error);
}
setIsEditMode(true);
setEditData({
idx: item.idx || '',
pdate: item.pdate || '',
status: item.status || '',
projectName: item.projectName || '',
requestpart: item.requestpart || '',
type: item.type || '',
hrs: item.hrs || '',
ot: item.ot || '',
otStart: item.otStart || '',
otEnd: item.otEnd || '',
description: item.description || ''
});
setShowEditModal(true);
};
// 저장 처리
const handleSave = async (event) => {
event.preventDefault();
const formData = new URLSearchParams();
Object.keys(editData).forEach(key => {
if (key !== 'idx' || editData.idx) {
formData.append(key, editData[key]);
}
});
try {
const url = isEditMode ? '/Jobreport/Edit' : '/Jobreport/Add';
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: formData
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${isEditMode ? '수정' : '추가'}에 실패했습니다.`);
}
setShowEditModal(false);
await loadJobData();
showNotification(`업무일지가 성공적으로 ${isEditMode ? '수정' : '추가'}되었습니다.`, 'success');
} catch (error) {
console.error('Error saving job:', error);
showNotification(`업무일지 ${isEditMode ? '수정' : '추가'} 중 오류가 발생했습니다: ` + error.message, 'error');
}
};
// 삭제 처리
const handleDelete = async () => {
if (!editData.idx) {
showNotification('삭제할 수 없는 항목입니다.', 'warning');
return;
}
if (!window.confirm('정말로 이 업무일지를 삭제하시겠습니까?')) {
return;
}
try {
const response = await fetch(`/Jobreport/Delete/${editData.idx}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('삭제에 실패했습니다.');
}
const result = await response.json();
if (result.success) {
setShowEditModal(false);
await loadJobData();
showNotification('업무일지가 성공적으로 삭제되었습니다.', 'success');
} else {
throw new Error(result.message || '삭제에 실패했습니다.');
}
} catch (error) {
console.error('Error deleting job:', error);
showNotification('업무일지 삭제 중 오류가 발생했습니다: ' + error.message, 'error');
}
};
// 엑셀 내보내기
const exportToExcel = () => {
if (filteredData.length === 0) {
showNotification('내보낼 데이터가 없습니다.', 'warning');
return;
}
const periodText = filters.startDate && filters.endDate ? `_${filters.startDate}_${filters.endDate}` : '';
const headers = ['날짜', '상태', '프로젝트명', '요청부서', '타입', '업무내용', '근무시간', '초과근무'];
const csvContent = [
headers.join(','),
...filteredData.map(item => [
formatDate(item.pdate),
item.status || '',
item.projectName || '',
item.requestpart || '',
item.type || '',
`"${(item.description || '').replace(/"/g, '""')}"`,
item.hrs || '',
item.ot || ''
].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', `업무일지${periodText}_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// 유틸리티 함수들
const formatDate = (dateString) => {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR');
};
const getStatusColor = (status) => {
switch (status) {
case '진행 중': return 'bg-blue-100 text-blue-800';
case '진행 완료': return 'bg-green-100 text-green-800';
case '대기': return 'bg-yellow-100 text-yellow-800';
default: return 'bg-gray-100 text-gray-800';
}
};
// 알림 표시 함수
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 maxPage = Math.ceil(filteredData.length / pageSize);
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
const pageData = filteredData.slice(startIndex, endIndex);
// 프로젝트 목록 업데이트
const uniqueProjects = [...new Set(jobData.map(item => item.projectName).filter(Boolean))];
return (
<div className="container mx-auto px-4 py-8">
{/* 개발중 경고 메시지 */}
<div className="bg-orange-500 rounded-lg p-4 mb-6 border-l-4 border-orange-700 animate-slide-up shadow-lg">
<div className="flex items-center">
<svg className="w-5 h-5 text-orange-900 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>
<div>
<p className="text-white font-bold text-base">🚧 개발중인 기능입니다</p>
<p className="text-orange-100 text-sm font-medium">일부 기능이 정상적으로 동작하지 않을 있습니다.</p>
</div>
</div>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8 animate-slide-up">
<div className="glass-effect rounded-lg p-6 card-hover">
<div className="flex items-center">
<div className="p-3 bg-primary-500/20 rounded-lg">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-white/80"> 업무일수</p>
<p className="text-2xl font-bold text-white">{statistics.totalDays}</p>
</div>
</div>
</div>
<div className="glass-effect rounded-lg p-6 card-hover">
<div className="flex items-center">
<div className="p-3 bg-success-500/20 rounded-lg">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-white/80">오늘 근무시간</p>
<p className={`text-2xl font-bold ${statistics.todayHours < 8 ? 'text-red-300' : 'text-green-300'}`}>
{statistics.todayHours.toFixed(1)}h
</p>
<p className="text-sm text-white/60">(목표 8시간의 {statistics.todayProgress.toFixed(0)}%)</p>
</div>
</div>
</div>
<div className="glass-effect rounded-lg p-6 card-hover">
<div className="flex items-center">
<div className="p-3 bg-warning-500/20 rounded-lg">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-white/80"> 초과근무</p>
<p className="text-2xl font-bold text-white">{statistics.totalOT.toFixed(1)}h</p>
</div>
</div>
</div>
<div className="glass-effect rounded-lg p-6 card-hover">
<div className="flex items-center">
<div className="p-3 bg-purple-500/20 rounded-lg">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"></path>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-white/80">진행중 프로젝트</p>
<p className="text-2xl font-bold text-white">{statistics.activeProjects}</p>
</div>
</div>
</div>
</div>
{/* 필터 및 검색 */}
<div className="glass-effect rounded-lg mb-6 animate-slide-up">
<div className="p-4">
<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>
<div className="flex space-x-1">
<input
type="date"
value={filters.startDate}
onChange={(e) => handleFilterChange('startDate', e.target.value)}
className="flex-1 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"
/>
<span className="flex items-center text-white/60 text-xs">~</span>
<input
type="date"
value={filters.endDate}
onChange={(e) => handleFilterChange('endDate', e.target.value)}
className="flex-1 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>
<div>
<label className="block text-xs font-medium text-white/80 mb-1">상태</label>
<select
value={filters.status}
onChange={(e) => handleFilterChange('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>
<select
value={filters.type}
onChange={(e) => handleFilterChange('type', 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>
<option value="테스트">테스트</option>
<option value="문서작업">문서작업</option>
<option value="회의">회의</option>
<option value="기타">기타</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-white/80 mb-1">프로젝트</label>
<select
value={filters.project}
onChange={(e) => handleFilterChange('project', 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>
{uniqueProjects.map(project => (
<option key={project} value={project}>{project}</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) => handleFilterChange('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>
</div>
{/* 우측: 액션 버튼들 */}
<div className="flex flex-col space-y-2 justify-center">
<button
onClick={showAddJobModal}
className="bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-md flex items-center justify-center transition-colors"
>
<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={exportToExcel}
className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-md flex items-center justify-center transition-colors"
>
<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-md flex items-center justify-center transition-colors"
>
<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>
{/* 데이터 테이블 */}
<div className="glass-effect rounded-lg overflow-hidden animate-slide-up custom-scrollbar">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-white/20">
<thead className="bg-white/10">
<tr>
<th
className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider cursor-pointer hover:bg-white/20 transition-colors"
onClick={() => handleSort('pdate')}
>
날짜 <svg className="w-4 h-4 inline ml-1" 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>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider cursor-pointer hover:bg-white/20 transition-colors"
onClick={() => handleSort('status')}
>
상태 <svg className="w-4 h-4 inline ml-1" 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>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider cursor-pointer hover:bg-white/20 transition-colors"
onClick={() => handleSort('hrs')}
>
근무시간 <svg className="w-4 h-4 inline ml-1" 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>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider cursor-pointer hover:bg-white/20 transition-colors"
onClick={() => handleSort('projectName')}
>
프로젝트명 <svg className="w-4 h-4 inline ml-1" 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>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider">업무내용</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider cursor-pointer hover:bg-white/20 transition-colors"
onClick={() => handleSort('requestpart')}
>
요청부서 <svg className="w-4 h-4 inline ml-1" 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>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider cursor-pointer hover:bg-white/20 transition-colors"
onClick={() => handleSort('type')}
>
타입 <svg className="w-4 h-4 inline ml-1" 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>
</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{isLoading ? (
<tr>
<td colSpan="7" className="p-8 text-center">
<div className="inline-flex items-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span className="text-white/80">데이터를 불러오는 ...</span>
</div>
</td>
</tr>
) : pageData.length === 0 ? (
<tr>
<td colSpan="7" className="p-8 text-center">
<svg className="w-12 h-12 text-white/60 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
<p className="text-white/70">업무일지 데이터가 없습니다.</p>
</td>
</tr>
) : (
pageData.map((item, index) => {
const hrs = parseFloat(item.hrs) || 0;
const ot = parseFloat(item.ot) || 0;
let workTimeDisplay = '';
if (hrs > 0) {
workTimeDisplay = hrs.toFixed(1);
if (ot > 0) {
workTimeDisplay += '+' + ot.toFixed(1);
}
} else {
workTimeDisplay = '-';
}
return (
<tr
key={item.idx || index}
className="hover:bg-white/10 cursor-pointer transition-colors"
onClick={() => showEditJobModal(item)}
>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
{formatDate(item.pdate)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(item.status)}`}>
{item.status || '-'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
{workTimeDisplay}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
<div className="flex items-center">
<span>{item.projectName || '-'}</span>
</div>
</td>
<td className="px-6 py-4 text-sm text-white">
<div className="max-w-xs truncate" title={item.description || ''}>
{item.description || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
{item.requestpart || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
{item.type || '-'}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{/* 페이지네이션 */}
<div className="mt-6 flex items-center justify-between glass-effect rounded-lg p-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-white/80">페이지당 :</span>
<select
value={pageSize}
onChange={(e) => {
setPageSize(parseInt(e.target.value));
setCurrentPage(1);
}}
className="bg-white/20 border border-white/30 rounded-md px-2 py-1 text-sm text-white"
>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage <= 1}
className="px-3 py-1 border border-white/30 rounded-md text-sm text-white hover:bg-white/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
이전
</button>
<span className="text-sm text-white/80">{currentPage} / {maxPage}</span>
<button
onClick={() => setCurrentPage(Math.min(maxPage, currentPage + 1))}
disabled={currentPage >= maxPage}
className="px-3 py-1 border border-white/30 rounded-md text-sm text-white hover:bg-white/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
다음
</button>
</div>
</div>
{/* 편집 모달 */}
{showEditModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50">
<div className="flex items-center justify-center min-h-screen p-4">
<div className="glass-effect rounded-2xl w-full max-w-6xl 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
업무일지 {isEditMode ? '편집' : '추가'}
</h2>
<button
onClick={() => setShowEditModal(false)}
className="text-white/70 hover:text-white transition-colors"
>
<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={handleSave} className="p-6">
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8">
{/* 좌측: 기본 정보 */}
<div className="lg:col-span-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2">날짜 *</label>
<input
type="date"
value={editData.pdate || ''}
onChange={(e) => setEditData(prev => ({...prev, pdate: e.target.value}))}
required
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">상태 *</label>
<select
value={editData.status || ''}
onChange={(e) => setEditData(prev => ({...prev, status: e.target.value}))}
required
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
>
<option value="">선택하세요</option>
<option value="진행 중">진행 </option>
<option value="완료">완료</option>
<option value="대기">대기</option>
<option value="보류">보류</option>
</select>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">요청부서</label>
<input
type="text"
value={editData.requestpart || ''}
onChange={(e) => setEditData(prev => ({...prev, requestpart: e.target.value}))}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">타입</label>
<select
value={editData.type || ''}
onChange={(e) => setEditData(prev => ({...prev, type: e.target.value}))}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
>
<option value="">선택하세요</option>
<option value="개발">개발</option>
<option value="유지보수">유지보수</option>
<option value="분석">분석</option>
<option value="테스트">테스트</option>
<option value="문서작업">문서작업</option>
<option value="회의">회의</option>
<option value="기타">기타</option>
</select>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">근무시간 (시간) *</label>
<input
type="number"
step="any"
value={editData.hrs || ''}
onChange={(e) => setEditData(prev => ({...prev, hrs: e.target.value}))}
required
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">초과근무 (시간)</label>
<input
type="number"
step="any"
value={editData.ot || ''}
onChange={(e) => setEditData(prev => ({...prev, ot: e.target.value}))}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">초과근무 시작시간</label>
<input
type="time"
value={editData.otStart || ''}
onChange={(e) => setEditData(prev => ({...prev, otStart: e.target.value}))}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">초과근무 종료시간</label>
<input
type="time"
value={editData.otEnd || ''}
onChange={(e) => setEditData(prev => ({...prev, otEnd: e.target.value}))}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
/>
</div>
</div>
</div>
{/* 우측: 프로젝트명과 업무내용 */}
<div className="lg:col-span-3 space-y-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2">프로젝트명 *</label>
<input
type="text"
value={editData.projectName || ''}
onChange={(e) => setEditData(prev => ({...prev, projectName: e.target.value}))}
required
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
/>
</div>
<div className="flex-grow">
<label className="block text-white/70 text-sm font-medium mb-2">업무내용 *</label>
<textarea
value={editData.description || ''}
onChange={(e) => setEditData(prev => ({...prev, description: e.target.value}))}
rows="15"
required
className="w-full h-full min-h-[360px] bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all resize-vertical"
placeholder="상세한 업무 내용을 입력하세요..."
/>
</div>
</div>
</div>
</form>
{/* 모달 푸터 */}
<div className="px-6 py-4 border-t border-white/10 flex justify-between items-center bg-black/10 rounded-b-2xl">
{isEditMode && (
<button
type="button"
onClick={handleDelete}
className="bg-danger-500 hover:bg-danger-600 text-white px-4 py-2 rounded-lg transition-colors"
>
삭제
</button>
)}
<div className="flex space-x-3 ml-auto">
<button
type="button"
onClick={() => setShowEditModal(false)}
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
>
취소
</button>
<button
type="submit"
onClick={handleSave}
className="bg-primary-500 hover:bg-primary-600 text-white px-6 py-2 rounded-lg transition-colors"
>
저장
</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}