- 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>
988 lines
55 KiB
JavaScript
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>
|
|
);
|
|
} |