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>
This commit is contained in:
811
Project/Web/wwwroot/react/Todo.jsx
Normal file
811
Project/Web/wwwroot/react/Todo.jsx
Normal file
@@ -0,0 +1,811 @@
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
function Todo() {
|
||||
// 상태 관리
|
||||
const [todos, setTodos] = useState([]);
|
||||
const [activeTodos, setActiveTodos] = useState([]);
|
||||
const [completedTodos, setCompletedTodos] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentTab, setCurrentTab] = useState('active');
|
||||
const [currentEditId, setCurrentEditId] = useState(null);
|
||||
|
||||
// 모달 상태
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
|
||||
// 폼 상태
|
||||
const [todoForm, setTodoForm] = useState({
|
||||
title: '',
|
||||
remark: '',
|
||||
expire: '',
|
||||
seqno: 0,
|
||||
flag: false,
|
||||
request: '',
|
||||
status: '0'
|
||||
});
|
||||
|
||||
const [editForm, setEditForm] = useState({
|
||||
idx: 0,
|
||||
title: '',
|
||||
remark: '',
|
||||
expire: '',
|
||||
seqno: 0,
|
||||
flag: false,
|
||||
request: '',
|
||||
status: '0'
|
||||
});
|
||||
|
||||
// 컴포넌트 마운트시 할일 목록 로드
|
||||
useEffect(() => {
|
||||
loadTodos();
|
||||
}, []);
|
||||
|
||||
// 할일 목록을 활성/완료로 분리
|
||||
useEffect(() => {
|
||||
const active = todos.filter(todo => (todo.status || '0') !== '5');
|
||||
const completed = todos.filter(todo => (todo.status || '0') === '5');
|
||||
setActiveTodos(active);
|
||||
setCompletedTodos(completed);
|
||||
}, [todos]);
|
||||
|
||||
// 할일 목록 로드
|
||||
const loadTodos = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/Todo/GetTodos');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.Success) {
|
||||
setTodos(data.Data || []);
|
||||
} else {
|
||||
showNotification(data.Message || '할일 목록을 불러올 수 없습니다.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('할일 목록 로드 중 오류:', error);
|
||||
showNotification('서버 연결에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 새 할일 추가
|
||||
const addTodo = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!todoForm.remark.trim()) {
|
||||
showNotification('할일 내용을 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/Todo/CreateTodo', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...todoForm,
|
||||
seqno: parseInt(todoForm.seqno),
|
||||
expire: todoForm.expire || null,
|
||||
request: todoForm.request || null
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.Success) {
|
||||
setShowAddModal(false);
|
||||
setTodoForm({
|
||||
title: '',
|
||||
remark: '',
|
||||
expire: '',
|
||||
seqno: 0,
|
||||
flag: false,
|
||||
request: '',
|
||||
status: '0'
|
||||
});
|
||||
loadTodos();
|
||||
showNotification(data.Message || '할일이 추가되었습니다.', 'success');
|
||||
} else {
|
||||
showNotification(data.Message || '할일 추가에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('할일 추가 중 오류:', error);
|
||||
showNotification('서버 연결에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 할일 수정
|
||||
const updateTodo = async () => {
|
||||
if (!editForm.remark.trim()) {
|
||||
showNotification('할일 내용을 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/Todo/UpdateTodo', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...editForm,
|
||||
seqno: parseInt(editForm.seqno),
|
||||
expire: editForm.expire || null,
|
||||
request: editForm.request || null
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.Success) {
|
||||
setShowEditModal(false);
|
||||
setCurrentEditId(null);
|
||||
loadTodos();
|
||||
showNotification(data.Message || '할일이 수정되었습니다.', 'success');
|
||||
} else {
|
||||
showNotification(data.Message || '할일 수정에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('할일 수정 중 오류:', error);
|
||||
showNotification('서버 연결에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 상태 업데이트 (편집 모달에서 바로 서버에 반영)
|
||||
const updateTodoStatus = async (status) => {
|
||||
if (!currentEditId) return;
|
||||
|
||||
const formData = {
|
||||
...editForm,
|
||||
status: status,
|
||||
seqno: parseInt(editForm.seqno),
|
||||
expire: editForm.expire || null,
|
||||
request: editForm.request || null
|
||||
};
|
||||
|
||||
if (!formData.remark.trim()) {
|
||||
showNotification('할일 내용을 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/Todo/UpdateTodo', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.Success) {
|
||||
setShowEditModal(false);
|
||||
setCurrentEditId(null);
|
||||
loadTodos();
|
||||
showNotification(`상태가 '${getStatusText(status)}'(으)로 변경되었습니다.`, 'success');
|
||||
} else {
|
||||
showNotification(data.Message || '상태 변경에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('상태 변경 중 오류:', error);
|
||||
showNotification('서버 연결에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 할일 편집 모달 열기
|
||||
const editTodo = async (id) => {
|
||||
setCurrentEditId(id);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/Todo/GetTodo?id=${id}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.Success && data.Data) {
|
||||
const todo = data.Data;
|
||||
setEditForm({
|
||||
idx: todo.idx,
|
||||
title: todo.title || '',
|
||||
remark: todo.remark || '',
|
||||
expire: todo.expire ? new Date(todo.expire).toISOString().split('T')[0] : '',
|
||||
seqno: todo.seqno || 0,
|
||||
flag: todo.flag || false,
|
||||
request: todo.request || '',
|
||||
status: todo.status || '0'
|
||||
});
|
||||
setShowEditModal(true);
|
||||
} else {
|
||||
showNotification(data.Message || '할일 정보를 불러올 수 없습니다.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('할일 조회 중 오류:', error);
|
||||
showNotification('서버 연결에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 할일 삭제
|
||||
const deleteTodo = async (id) => {
|
||||
if (!confirm('정말로 이 할일을 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/Todo/DeleteTodo?id=${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.Success) {
|
||||
loadTodos();
|
||||
showNotification(data.Message || '할일이 삭제되었습니다.', 'success');
|
||||
} else {
|
||||
showNotification(data.Message || '할일 삭제에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('할일 삭제 중 오류:', error);
|
||||
showNotification('서버 연결에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 유틸리티 함수들
|
||||
const getStatusClass = (status) => {
|
||||
switch(status) {
|
||||
case '0': return 'bg-gray-500/20 text-gray-300';
|
||||
case '1': return 'bg-primary-500/20 text-primary-300';
|
||||
case '2': return 'bg-danger-500/20 text-danger-300';
|
||||
case '3': return 'bg-warning-500/20 text-warning-300';
|
||||
case '5': return 'bg-success-500/20 text-success-300';
|
||||
default: return 'bg-white/10 text-white/50';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status) => {
|
||||
switch(status) {
|
||||
case '0': return '대기';
|
||||
case '1': return '진행';
|
||||
case '2': return '취소';
|
||||
case '3': return '보류';
|
||||
case '5': return '완료';
|
||||
default: return '대기';
|
||||
}
|
||||
};
|
||||
|
||||
const getSeqnoClass = (seqno) => {
|
||||
switch(seqno) {
|
||||
case 1: return 'bg-primary-500/20 text-primary-300';
|
||||
case 2: return 'bg-warning-500/20 text-warning-300';
|
||||
case 3: return 'bg-danger-500/20 text-danger-300';
|
||||
default: return 'bg-white/10 text-white/50';
|
||||
}
|
||||
};
|
||||
|
||||
const getSeqnoText = (seqno) => {
|
||||
switch(seqno) {
|
||||
case 1: return '중요';
|
||||
case 2: return '매우 중요';
|
||||
case 3: return '긴급';
|
||||
default: return '보통';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleDateString('ko-KR');
|
||||
};
|
||||
|
||||
// 알림 표시 함수
|
||||
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 renderTodoRow = (todo, includeOkdate = false) => {
|
||||
const statusClass = getStatusClass(todo.status);
|
||||
const statusText = getStatusText(todo.status);
|
||||
|
||||
const flagClass = todo.flag ? 'bg-warning-500/20 text-warning-300' : 'bg-white/10 text-white/50';
|
||||
const flagText = todo.flag ? '고정' : '일반';
|
||||
|
||||
const seqnoClass = getSeqnoClass(todo.seqno);
|
||||
const seqnoText = getSeqnoText(todo.seqno);
|
||||
|
||||
const expireText = formatDate(todo.expire);
|
||||
const isExpired = todo.expire && new Date(todo.expire) < new Date();
|
||||
const expireClass = isExpired ? 'text-danger-400' : 'text-white/80';
|
||||
|
||||
const okdateText = formatDate(todo.okdate);
|
||||
const okdateClass = todo.okdate ? 'text-success-400' : 'text-white/80';
|
||||
|
||||
return (
|
||||
<tr key={todo.idx} className="hover:bg-white/5 transition-colors cursor-pointer" onClick={() => editTodo(todo.idx)}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusClass}`}>
|
||||
{statusText}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${flagClass}`}>
|
||||
{flagText}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-white">{todo.title || '제목 없음'}</td>
|
||||
<td className="px-6 py-4 text-white/80 max-w-xs truncate">{todo.remark || ''}</td>
|
||||
<td className="px-6 py-4 text-white/80">{todo.request || '-'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${seqnoClass}`}>
|
||||
{seqnoText}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`px-6 py-4 whitespace-nowrap ${expireClass}`}>{expireText}</td>
|
||||
{includeOkdate && <td className={`px-6 py-4 whitespace-nowrap ${okdateClass}`}>{okdateText}</td>}
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => editTodo(todo.idx)} className="text-primary-400 hover:text-primary-300 mr-3 transition-colors">
|
||||
수정
|
||||
</button>
|
||||
<button onClick={() => deleteTodo(todo.idx)} className="text-danger-400 hover:text-danger-300 transition-colors">
|
||||
삭제
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* 할일 목록 */}
|
||||
<div className="glass-effect rounded-2xl 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="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||
</svg>
|
||||
내 할일 목록
|
||||
</h2>
|
||||
<button onClick={() => setShowAddModal(true)} className="bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center text-sm">
|
||||
<svg className="w-4 h-4 mr-1" 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>
|
||||
</div>
|
||||
|
||||
{/* 탭 메뉴 */}
|
||||
<div className="px-6 py-2 border-b border-white/10">
|
||||
<div className="flex space-x-1 bg-white/5 rounded-lg p-1">
|
||||
<button onClick={() => setCurrentTab('active')} className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
currentTab === 'active'
|
||||
? 'text-white bg-white/20 shadow-sm'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/10'
|
||||
}`}>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
<span>진행중인 할일</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-primary-500/30 text-primary-200 rounded-full">{activeTodos.length}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button onClick={() => setCurrentTab('completed')} className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
currentTab === 'completed'
|
||||
? 'text-white bg-white/20 shadow-sm'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/10'
|
||||
}`}>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<span>완료된 할일</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-success-500/30 text-success-200 rounded-full">{completedTodos.length}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 진행중인 할일 테이블 */}
|
||||
{currentTab === 'active' && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">진행상태</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">플래그</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">제목</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">내용</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">요청자</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">중요도</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">만료일</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan="8" className="p-8 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>
|
||||
) : activeTodos.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="8" className="px-6 py-8 text-center text-white/50">
|
||||
진행중인 할일이 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
activeTodos.map(todo => renderTodoRow(todo, false))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 완료된 할일 테이블 */}
|
||||
{currentTab === 'completed' && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">진행상태</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">플래그</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">제목</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">내용</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">요청자</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">중요도</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">만료일</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">완료일</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan="9" className="p-8 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>
|
||||
) : completedTodos.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="9" className="px-6 py-8 text-center text-white/50">
|
||||
완료된 할일이 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
completedTodos.map(todo => renderTodoRow(todo, true))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</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">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 새 할일 추가 모달 */}
|
||||
{showAddModal && (
|
||||
<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-2xl 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="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
새 할일 추가
|
||||
</h2>
|
||||
<button onClick={() => setShowAddModal(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>
|
||||
|
||||
{/* 모달 내용 */}
|
||||
<div className="p-6">
|
||||
<form onSubmit={addTodo} className="space-y-4">
|
||||
<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="text"
|
||||
value={todoForm.title}
|
||||
onChange={(e) => setTodoForm({...todoForm, title: 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"
|
||||
placeholder="할일 제목을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">만료일 (선택사항)</label>
|
||||
<input
|
||||
type="date"
|
||||
value={todoForm.expire}
|
||||
onChange={(e) => setTodoForm({...todoForm, expire: 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>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">내용 *</label>
|
||||
<textarea
|
||||
value={todoForm.remark}
|
||||
onChange={(e) => setTodoForm({...todoForm, remark: e.target.value})}
|
||||
rows="3"
|
||||
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"
|
||||
placeholder="할일 내용을 입력하세요 (필수)"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">요청자</label>
|
||||
<input
|
||||
type="text"
|
||||
value={todoForm.request}
|
||||
onChange={(e) => setTodoForm({...todoForm, request: 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"
|
||||
placeholder="업무 요청자를 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">진행상태</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{value: '0', label: '대기', class: 'bg-gray-500/20 text-gray-300'},
|
||||
{value: '1', label: '진행', class: 'bg-primary-500/20 text-primary-300'},
|
||||
{value: '3', label: '보류', class: 'bg-warning-500/20 text-warning-300'},
|
||||
{value: '2', label: '취소', class: 'bg-danger-500/20 text-danger-300'},
|
||||
{value: '5', label: '완료', class: 'bg-success-500/20 text-success-300'}
|
||||
].map(status => (
|
||||
<button
|
||||
key={status.value}
|
||||
type="button"
|
||||
onClick={() => setTodoForm({...todoForm, status: status.value})}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-medium transition-all ${
|
||||
todoForm.status === status.value
|
||||
? status.class + ' border-' + status.class.split(' ')[0].replace('bg-', '').replace('/20', '/30')
|
||||
: 'bg-white/10 text-white/50 border border-white/20 hover:bg-white/20'
|
||||
}`}
|
||||
>
|
||||
{status.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">중요도</label>
|
||||
<select
|
||||
value={todoForm.seqno}
|
||||
onChange={(e) => setTodoForm({...todoForm, seqno: parseInt(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="0">보통</option>
|
||||
<option value="1">중요</option>
|
||||
<option value="2">매우 중요</option>
|
||||
<option value="3">긴급</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center text-white/70 text-sm font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={todoForm.flag}
|
||||
onChange={(e) => setTodoForm({...todoForm, flag: e.target.checked})}
|
||||
className="mr-2 text-primary-500 focus:ring-primary-400 focus:ring-offset-0 rounded"
|
||||
/>
|
||||
플래그 (상단 고정)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* 모달 푸터 */}
|
||||
<div className="px-6 py-4 border-t border-white/10 flex justify-end space-x-3">
|
||||
<button type="button" onClick={() => setShowAddModal(false)} className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit" form="todoForm" onClick={addTodo} className="bg-primary-500 hover:bg-primary-600 text-white px-6 py-2 rounded-lg transition-colors flex items-center">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</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-2xl 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>
|
||||
할일 수정
|
||||
</h2>
|
||||
<button onClick={() => {setShowEditModal(false); setCurrentEditId(null);}} 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>
|
||||
|
||||
{/* 모달 내용 */}
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
<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="text"
|
||||
value={editForm.title}
|
||||
onChange={(e) => setEditForm({...editForm, title: 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"
|
||||
placeholder="할일 제목을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">만료일 (선택사항)</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editForm.expire}
|
||||
onChange={(e) => setEditForm({...editForm, expire: 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>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">내용 *</label>
|
||||
<textarea
|
||||
value={editForm.remark}
|
||||
onChange={(e) => setEditForm({...editForm, remark: e.target.value})}
|
||||
rows="3"
|
||||
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"
|
||||
placeholder="할일 내용을 입력하세요 (필수)"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">요청자</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.request}
|
||||
onChange={(e) => setEditForm({...editForm, request: 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"
|
||||
placeholder="업무 요청자를 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">진행상태</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{value: '0', label: '대기', class: 'bg-gray-500/20 text-gray-300'},
|
||||
{value: '1', label: '진행', class: 'bg-primary-500/20 text-primary-300'},
|
||||
{value: '3', label: '보류', class: 'bg-warning-500/20 text-warning-300'},
|
||||
{value: '2', label: '취소', class: 'bg-danger-500/20 text-danger-300'},
|
||||
{value: '5', label: '완료', class: 'bg-success-500/20 text-success-300'}
|
||||
].map(status => (
|
||||
<button
|
||||
key={status.value}
|
||||
type="button"
|
||||
onClick={() => updateTodoStatus(status.value)}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-medium transition-all ${
|
||||
editForm.status === status.value
|
||||
? status.class + ' border-' + status.class.split(' ')[0].replace('bg-', '').replace('/20', '/30')
|
||||
: 'bg-white/10 text-white/50 border border-white/20 hover:bg-white/20'
|
||||
}`}
|
||||
>
|
||||
{status.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">중요도</label>
|
||||
<select
|
||||
value={editForm.seqno}
|
||||
onChange={(e) => setEditForm({...editForm, seqno: parseInt(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="0">보통</option>
|
||||
<option value="1">중요</option>
|
||||
<option value="2">매우 중요</option>
|
||||
<option value="3">긴급</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center text-white/70 text-sm font-medium mt-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.flag}
|
||||
onChange={(e) => setEditForm({...editForm, flag: e.target.checked})}
|
||||
className="mr-2 text-primary-500 focus:ring-primary-400 focus:ring-offset-0 rounded"
|
||||
/>
|
||||
플래그 (상단 고정)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모달 푸터 */}
|
||||
<div className="px-6 py-4 border-t border-white/10 flex justify-end space-x-3">
|
||||
<button onClick={() => {setShowEditModal(false); setCurrentEditId(null);}} className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button onClick={updateTodo} className="bg-primary-500 hover:bg-primary-600 text-white px-6 py-2 rounded-lg transition-colors">
|
||||
수정
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user