749 lines
28 KiB
TypeScript
749 lines
28 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
Plus,
|
|
Edit2,
|
|
Edit3,
|
|
Trash2,
|
|
Flag,
|
|
Zap,
|
|
CheckCircle,
|
|
X,
|
|
Loader2,
|
|
RefreshCw,
|
|
Calendar,
|
|
Check,
|
|
} from 'lucide-react';
|
|
import { comms } from '@/communication';
|
|
import { TodoModel, TodoStatus, TodoPriority } from '@/types';
|
|
import { clsx } from 'clsx';
|
|
|
|
// 상태/중요도 유틸리티 함수들
|
|
const getStatusText = (status: string): string => {
|
|
switch (status) {
|
|
case '0': return '대기';
|
|
case '1': return '진행';
|
|
case '2': return '취소';
|
|
case '3': return '보류';
|
|
case '5': return '완료';
|
|
default: return '대기';
|
|
}
|
|
};
|
|
|
|
const getStatusClass = (status: string): string => {
|
|
switch (status) {
|
|
case '0': return 'bg-white/5 text-white/40 border-white/10';
|
|
case '1': return 'bg-primary-500/10 text-primary-400 border-primary-500/20';
|
|
case '2': return 'bg-danger-500/10 text-danger-400 border-danger-500/20';
|
|
case '3': return 'bg-warning-500/10 text-warning-400 border-warning-500/20';
|
|
case '5': return 'bg-success-500/10 text-success-400 border-success-500/20';
|
|
default: return 'bg-white/5 text-white/30 border-white/5';
|
|
}
|
|
};
|
|
|
|
const getPriorityText = (seqno: number): string => {
|
|
switch (seqno) {
|
|
case -1: return '낮음';
|
|
case 1: return '중요';
|
|
case 2: return '매우 중요';
|
|
case 3: return '긴급';
|
|
default: return '보통';
|
|
}
|
|
};
|
|
|
|
const getPriorityClass = (seqno: number): string => {
|
|
switch (seqno) {
|
|
case -1: return 'text-white/20';
|
|
case 1: return 'text-primary-400 font-bold';
|
|
case 2: return 'text-warning-400 font-bold';
|
|
case 3: return 'text-danger-400 font-bold';
|
|
default: return 'text-white/40';
|
|
}
|
|
};
|
|
|
|
// 폼 데이터 타입
|
|
interface TodoFormData {
|
|
title: string;
|
|
remark: string;
|
|
expire: string;
|
|
seqno: TodoPriority;
|
|
flag: boolean;
|
|
request: string;
|
|
status: TodoStatus;
|
|
}
|
|
|
|
const initialFormData: TodoFormData = {
|
|
title: '',
|
|
remark: '',
|
|
expire: '',
|
|
seqno: 0,
|
|
flag: false,
|
|
request: '',
|
|
status: '0',
|
|
};
|
|
|
|
export function Todo() {
|
|
const [todos, setTodos] = useState<TodoModel[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [processing, setProcessing] = useState(false);
|
|
const [activeTab, setActiveTab] = useState<'active' | 'hold' | 'completed' | 'cancelled'>('active');
|
|
|
|
// 모달 상태
|
|
const [showAddModal, setShowAddModal] = useState(false);
|
|
const [showEditModal, setShowEditModal] = useState(false);
|
|
const [editingTodo, setEditingTodo] = useState<TodoModel | null>(null);
|
|
const [formData, setFormData] = useState<TodoFormData>(initialFormData);
|
|
|
|
// 할일 목록 로드
|
|
const loadTodos = useCallback(async () => {
|
|
try {
|
|
const response = await comms.getTodos();
|
|
if (response.Success && response.Data) {
|
|
setTodos(response.Data);
|
|
}
|
|
} catch (error) {
|
|
console.error('할일 목록 로드 오류:', error);
|
|
alert('할일 목록을 불러오는 중 오류가 발생했습니다.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadTodos();
|
|
}, [loadTodos]);
|
|
|
|
// 필터링된 할일 목록
|
|
const activeTodos = todos.filter(todo => todo.status === '0' || todo.status === '1'); // 대기 + 진행
|
|
const holdTodos = todos.filter(todo => todo.status === '3'); // 보류
|
|
const completedTodos = todos.filter(todo => todo.status === '5'); // 완료
|
|
const cancelledTodos = todos.filter(todo => todo.status === '2'); // 취소
|
|
|
|
// 새 할일 추가
|
|
const handleAdd = async () => {
|
|
if (!formData.remark.trim()) {
|
|
alert('할일 내용을 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
setProcessing(true);
|
|
try {
|
|
const response = await comms.createTodo(
|
|
formData.title,
|
|
formData.remark,
|
|
formData.expire || null,
|
|
formData.seqno,
|
|
formData.flag,
|
|
formData.request || null,
|
|
formData.status
|
|
);
|
|
|
|
if (response.Success) {
|
|
setShowAddModal(false);
|
|
setFormData(initialFormData);
|
|
loadTodos();
|
|
} else {
|
|
alert(response.Message || '할일 추가에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('할일 추가 오류:', error);
|
|
alert('서버 연결에 실패했습니다.');
|
|
} finally {
|
|
setProcessing(false);
|
|
}
|
|
};
|
|
|
|
// 할일 수정 모달 열기
|
|
const openEditModal = async (todo: TodoModel) => {
|
|
try {
|
|
const response = await comms.getTodo(todo.idx);
|
|
if (response.Success && response.Data) {
|
|
const data = response.Data;
|
|
setEditingTodo(data);
|
|
setFormData({
|
|
title: data.title || '',
|
|
remark: data.remark || '',
|
|
expire: data.expire ? data.expire.split('T')[0] : '',
|
|
seqno: data.seqno as TodoPriority,
|
|
flag: data.flag || false,
|
|
request: data.request || '',
|
|
status: data.status as TodoStatus,
|
|
});
|
|
setShowEditModal(true);
|
|
}
|
|
} catch (error) {
|
|
console.error('할일 조회 오류:', error);
|
|
alert('할일 정보를 불러오는 중 오류가 발생했습니다.');
|
|
}
|
|
};
|
|
|
|
// 할일 수정
|
|
const handleUpdate = async () => {
|
|
if (!editingTodo || !formData.remark.trim()) {
|
|
alert('할일 내용을 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
setProcessing(true);
|
|
try {
|
|
const response = await comms.updateTodo(
|
|
editingTodo.idx,
|
|
formData.title,
|
|
formData.remark,
|
|
formData.expire || null,
|
|
formData.seqno,
|
|
formData.flag,
|
|
formData.request || null,
|
|
formData.status
|
|
);
|
|
|
|
if (response.Success) {
|
|
setShowEditModal(false);
|
|
setEditingTodo(null);
|
|
setFormData(initialFormData);
|
|
loadTodos();
|
|
} else {
|
|
alert(response.Message || '할일 수정에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('할일 수정 오류:', error);
|
|
alert('서버 연결에 실패했습니다.');
|
|
} finally {
|
|
setProcessing(false);
|
|
}
|
|
};
|
|
|
|
// 할일 삭제
|
|
const handleDelete = async (id: number) => {
|
|
if (!confirm('정말로 이 할일을 삭제하시겠습니까?')) {
|
|
return;
|
|
}
|
|
|
|
setProcessing(true);
|
|
try {
|
|
const response = await comms.deleteTodo(id);
|
|
if (response.Success) {
|
|
loadTodos();
|
|
} else {
|
|
alert(response.Message || '할일 삭제에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('할일 삭제 오류:', error);
|
|
alert('서버 연결에 실패했습니다.');
|
|
} finally {
|
|
setProcessing(false);
|
|
}
|
|
};
|
|
|
|
// 상태 빠른 변경
|
|
const handleQuickStatusChange = async (todo: TodoModel, newStatus: TodoStatus) => {
|
|
setProcessing(true);
|
|
try {
|
|
const response = await comms.updateTodo(
|
|
todo.idx,
|
|
todo.title,
|
|
todo.remark,
|
|
todo.expire,
|
|
todo.seqno,
|
|
todo.flag,
|
|
todo.request,
|
|
newStatus
|
|
);
|
|
|
|
if (response.Success) {
|
|
loadTodos();
|
|
} else {
|
|
alert(response.Message || '상태 변경에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('상태 변경 오류:', error);
|
|
} finally {
|
|
setProcessing(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 animate-fade-in pb-10">
|
|
{/* 할일 요약 & 컨트롤 */}
|
|
<div className="glass-effect rounded-3xl overflow-hidden shadow-2xl border border-white/10">
|
|
<div className="px-6 py-4 border-b border-white/10 flex flex-col md:flex-row items-center justify-between gap-4 bg-white/5">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-primary-500/20 rounded-lg">
|
|
<CheckCircle className="w-5 h-5 text-primary-400" />
|
|
</div>
|
|
<h3 className="text-lg font-bold text-white tracking-tight">내 할일 목록</h3>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
{/* 개수 표시 */}
|
|
<div className="flex items-center gap-2 bg-white/5 px-3 py-1.5 rounded-xl border border-white/10 h-[38px]">
|
|
<span className="text-primary-400 font-bold text-sm tracking-tighter">{todos.length}</span>
|
|
<span className="text-white/40 text-[10px] uppercase font-bold">Total</span>
|
|
</div>
|
|
|
|
{/* 새로고침 */}
|
|
<button
|
|
onClick={loadTodos}
|
|
disabled={loading}
|
|
className="p-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-white/70 hover:text-white transition-all active:scale-95 disabled:opacity-50"
|
|
title="새로고침"
|
|
>
|
|
<RefreshCw className={clsx("w-4 h-4", loading && "animate-spin")} />
|
|
</button>
|
|
|
|
{/* 추가 버튼 */}
|
|
<button
|
|
onClick={() => {
|
|
setFormData(initialFormData);
|
|
setShowAddModal(true);
|
|
}}
|
|
className="p-2 bg-primary-500 hover:bg-primary-600 border border-white/20 rounded-xl text-white transition-all shadow-lg shadow-primary-500/20 active:scale-95 flex items-center justify-center"
|
|
title="새 할일 추가"
|
|
>
|
|
<Plus className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 탭 메뉴 */}
|
|
<div className="px-6 py-2 bg-white/[0.02]">
|
|
<div className="flex space-x-1 p-1">
|
|
{[
|
|
{ id: 'active', label: '진행중', icon: Zap, count: activeTodos.length, color: 'primary' },
|
|
{ id: 'hold', label: '보류', icon: Loader2, count: holdTodos.length, color: 'warning' },
|
|
{ id: 'completed', label: '완료', icon: CheckCircle, count: completedTodos.length, color: 'success' },
|
|
{ id: 'cancelled', label: '취소', icon: X, count: cancelledTodos.length, color: 'danger' },
|
|
].map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id as any)}
|
|
className={clsx(
|
|
"flex-1 px-4 py-2.5 text-xs font-bold rounded-xl transition-all duration-300 flex items-center justify-center gap-2 border",
|
|
activeTab === tab.id
|
|
? `text-white bg-${tab.color}-500/20 border-${tab.color}-500/30 shadow-lg shadow-${tab.color}-500/10`
|
|
: "text-white/30 border-transparent hover:text-white/60 hover:bg-white/5"
|
|
)}
|
|
>
|
|
<tab.icon className={clsx("w-3.5 h-3.5", activeTab === tab.id ? `text-${tab.color}-400` : "opacity-50")} />
|
|
<span>{tab.label}</span>
|
|
<span className={clsx(
|
|
"px-1.5 py-0.5 rounded-md text-[10px] min-w-[1.5rem]",
|
|
activeTab === tab.id ? `bg-${tab.color}-500/20 text-${tab.color}-200` : "bg-white/5 text-white/20"
|
|
)}>
|
|
{tab.count}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 할일 테이블 */}
|
|
<div className="overflow-x-auto custom-scrollbar max-h-[calc(100vh-320px)] overflow-y-auto">
|
|
<table className="w-full border-collapse">
|
|
<thead className="sticky top-0 z-10 bg-white/[0.05] backdrop-blur-md">
|
|
<tr className="border-b border-white/10">
|
|
{activeTab === 'active' && (
|
|
<th className="px-3 py-3.5 text-center text-[11px] font-bold text-white/30 uppercase tracking-widest w-12"></th>
|
|
)}
|
|
<th className="px-4 py-3.5 text-center text-[11px] font-bold text-white/30 uppercase tracking-widest w-20">상태</th>
|
|
<th className="px-6 py-3.5 text-left text-[11px] font-bold text-white/30 uppercase tracking-widest">할일 개요</th>
|
|
<th className="px-4 py-3.5 text-center text-[11px] font-bold text-white/30 uppercase tracking-widest w-28">요청자</th>
|
|
<th className="px-4 py-3.5 text-center text-[11px] font-bold text-white/30 uppercase tracking-widest w-20">우선순위</th>
|
|
<th className="px-4 py-3.5 text-center text-[11px] font-bold text-white/30 uppercase tracking-widest w-28">
|
|
{activeTab === 'completed' ? '완료일' : '만료일'}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-white/10">
|
|
{(activeTab === 'active' ? activeTodos : activeTab === 'hold' ? holdTodos : activeTab === 'completed' ? completedTodos : cancelledTodos).map((todo) => (
|
|
<TodoRow
|
|
key={todo.idx}
|
|
todo={todo}
|
|
showOkdate={activeTab === 'completed'}
|
|
showCompleteButton={activeTab === 'active'}
|
|
onEdit={() => openEditModal(todo)}
|
|
onComplete={() => handleQuickStatusChange(todo, '5')}
|
|
/>
|
|
))}
|
|
{(activeTab === 'active' ? activeTodos : activeTab === 'hold' ? holdTodos : activeTab === 'completed' ? completedTodos : cancelledTodos).length === 0 && (
|
|
<tr>
|
|
<td colSpan={activeTab === 'active' ? 6 : 5} className="px-6 py-8 text-center text-white/50">
|
|
{activeTab === 'active' ? '진행중인 할일이 없습니다' : activeTab === 'hold' ? '보류된 할일이 없습니다' : activeTab === 'completed' ? '완료된 할일이 없습니다' : '취소된 할일이 없습니다'}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 로딩 인디케이터 */}
|
|
{processing && (
|
|
<div className="fixed top-4 right-4 bg-white/20 backdrop-blur-sm rounded-full px-4 py-2 text-white text-sm flex items-center">
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
처리 중...
|
|
</div>
|
|
)}
|
|
|
|
{/* 추가 모달 */}
|
|
{showAddModal && (
|
|
<TodoModal
|
|
title="새 할일 추가"
|
|
formData={formData}
|
|
setFormData={setFormData}
|
|
onSubmit={handleAdd}
|
|
onClose={() => setShowAddModal(false)}
|
|
submitText="추가"
|
|
processing={processing}
|
|
/>
|
|
)}
|
|
|
|
{/* 수정 모달 */}
|
|
{showEditModal && editingTodo && (
|
|
<TodoModal
|
|
title="할일 수정"
|
|
formData={formData}
|
|
setFormData={setFormData}
|
|
onSubmit={handleUpdate}
|
|
onClose={() => {
|
|
setShowEditModal(false);
|
|
setEditingTodo(null);
|
|
}}
|
|
submitText="수정"
|
|
processing={processing}
|
|
isEdit={true}
|
|
onComplete={() => {
|
|
handleQuickStatusChange(editingTodo, '5');
|
|
setShowEditModal(false);
|
|
setEditingTodo(null);
|
|
}}
|
|
onDelete={() => {
|
|
handleDelete(editingTodo.idx);
|
|
setShowEditModal(false);
|
|
setEditingTodo(null);
|
|
}}
|
|
currentStatus={editingTodo.status}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 할일 행 컴포넌트
|
|
interface TodoRowProps {
|
|
todo: TodoModel;
|
|
showOkdate: boolean;
|
|
showCompleteButton?: boolean;
|
|
onEdit: () => void;
|
|
onComplete: () => void;
|
|
}
|
|
|
|
function TodoRow({ todo, showOkdate, showCompleteButton = true, onEdit, onComplete }: TodoRowProps) {
|
|
const isExpired = todo.expire && new Date(todo.expire) < new Date();
|
|
|
|
const handleComplete = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (confirm('이 할일을 완료 처리하시겠습니까?')) {
|
|
onComplete();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<tr
|
|
className="group hover:bg-white/[0.03] transition-all cursor-pointer border-b border-white/[0.02]"
|
|
onClick={onEdit}
|
|
>
|
|
{showCompleteButton && (
|
|
<td className="px-3 py-3 text-center">
|
|
<button
|
|
onClick={handleComplete}
|
|
className="p-1.5 bg-success-500/10 hover:bg-success-500/20 text-success-400 rounded-lg transition-all border border-success-500/20 active:scale-90"
|
|
title="완료 처리"
|
|
>
|
|
<CheckCircle className="w-3.5 h-3.5" />
|
|
</button>
|
|
</td>
|
|
)}
|
|
<td className="px-4 py-3 text-center whitespace-nowrap">
|
|
<span className={clsx(
|
|
"inline-flex items-center px-2 py-0.5 rounded text-xs font-bold border uppercase tracking-widest",
|
|
getStatusClass(todo.status)
|
|
)}>
|
|
{getStatusText(todo.status)}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-3 text-left">
|
|
<div className="flex items-center gap-2">
|
|
{todo.flag && (
|
|
<Flag className="w-3.5 h-3.5 text-warning-400 fill-warning-400/20 shrink-0" />
|
|
)}
|
|
<span className="text-sm font-bold text-white group-hover:text-primary-400 transition-colors truncate">
|
|
{todo.title || '제목 없음'}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 text-center text-sm font-medium text-white/70">{todo.request || '-'}</td>
|
|
<td className="px-4 py-3 text-center whitespace-nowrap">
|
|
<span className={clsx(
|
|
"inline-flex items-center px-2 py-0.5 rounded text-xs font-bold uppercase tracking-widest",
|
|
getPriorityClass(todo.seqno)
|
|
)}>
|
|
<Zap className={clsx("w-3.5 h-3.5 mr-1", todo.seqno > 0 ? "fill-current" : "opacity-20")} />
|
|
{getPriorityText(todo.seqno)}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-center whitespace-nowrap">
|
|
<div className={clsx(
|
|
"inline-flex items-center gap-2 px-3 py-1 bg-white/5 rounded-lg border border-white/5",
|
|
showOkdate ? 'text-success-400' : (isExpired ? 'text-danger-400' : 'text-white/40')
|
|
)}>
|
|
<Calendar className="w-3.5 h-3.5 opacity-30" />
|
|
<span className="text-sm font-mono font-medium">
|
|
{showOkdate
|
|
? (todo.okdate ? new Date(todo.okdate).toLocaleDateString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit' }) : '-')
|
|
: (todo.expire ? new Date(todo.expire).toLocaleDateString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit' }) : '-')
|
|
}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
// 할일 모달 컴포넌트
|
|
interface TodoModalProps {
|
|
title: string;
|
|
formData: TodoFormData;
|
|
setFormData: React.Dispatch<React.SetStateAction<TodoFormData>>;
|
|
onSubmit: () => void;
|
|
onClose: () => void;
|
|
submitText: string;
|
|
processing: boolean;
|
|
isEdit?: boolean;
|
|
onComplete?: () => void;
|
|
onDelete?: () => void;
|
|
currentStatus?: string;
|
|
}
|
|
|
|
function TodoModal({
|
|
title,
|
|
formData,
|
|
setFormData,
|
|
onSubmit,
|
|
onClose,
|
|
submitText,
|
|
processing,
|
|
isEdit = false,
|
|
onComplete,
|
|
onDelete,
|
|
currentStatus,
|
|
}: TodoModalProps) {
|
|
const statusOptions: { value: TodoStatus; label: string }[] = [
|
|
{ value: '0', label: '대기' },
|
|
{ value: '1', label: '진행' },
|
|
{ value: '3', label: '보류' },
|
|
{ value: '2', label: '취소' },
|
|
{ value: '5', label: '완료' },
|
|
];
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md animate-fade-in" onClick={onClose}>
|
|
<div className="dialog-container w-full max-w-2xl" onClick={(e) => e.stopPropagation()}>
|
|
{/* 헤더 */}
|
|
<div className="dialog-header">
|
|
<div className="flex items-center gap-4">
|
|
<div className={`p-2 rounded-lg ${isEdit ? 'bg-primary-500/20' : 'bg-primary-500/20'}`}>
|
|
{isEdit ? <Edit3 className="w-5 h-5 text-primary-400" /> : <Plus className="w-5 h-5 text-primary-400" />}
|
|
</div>
|
|
<h2 className="dialog-title">{title}</h2>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{isEdit && onComplete && currentStatus !== '5' && (
|
|
<button
|
|
onClick={onComplete}
|
|
disabled={processing}
|
|
className="px-4 py-1.5 bg-success-500 hover:bg-success-600 border border-white/20 rounded-xl text-white text-xs font-bold transition-all shadow-lg shadow-success-500/20 active:scale-95 flex items-center gap-2"
|
|
>
|
|
<Check className="w-3.5 h-3.5" />
|
|
완료 처리
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 hover:bg-white/10 rounded-full text-white/40 hover:text-white transition-all transform hover:rotate-90"
|
|
>
|
|
<X className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 내 */}
|
|
<div className="p-8 space-y-6 overflow-y-auto max-h-[70vh] custom-scrollbar">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] font-bold text-white/20 uppercase tracking-widest ml-1">제목 (선택사항)</label>
|
|
<input
|
|
type="text"
|
|
value={formData.title}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all"
|
|
placeholder="제목입력..."
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] font-bold text-white/20 uppercase tracking-widest ml-1">만료일 (선택사항)</label>
|
|
<div className="relative">
|
|
<Calendar className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-white/20 pointer-events-none" />
|
|
<input
|
|
type="date"
|
|
value={formData.expire}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, expire: e.target.value }))}
|
|
className="w-full bg-white/5 border border-white/10 rounded-xl pl-12 pr-4 py-3 text-sm text-white focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all [color-scheme:dark]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] font-bold text-white/20 uppercase tracking-widest ml-1">내용 *</label>
|
|
<textarea
|
|
value={formData.remark}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, remark: e.target.value }))}
|
|
rows={4}
|
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all resize-none"
|
|
placeholder="내용을 입력하세요..."
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] font-bold text-white/20 uppercase tracking-widest ml-1">업무 요청자</label>
|
|
<input
|
|
type="text"
|
|
value={formData.request}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, request: e.target.value }))}
|
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-medium"
|
|
placeholder="요청자 성명..."
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-2">
|
|
<div className="space-y-4">
|
|
<label className="text-[10px] font-bold text-white/20 uppercase tracking-widest ml-1">진행 상태</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{statusOptions.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
onClick={() => setFormData(prev => ({ ...prev, status: option.value }))}
|
|
className={clsx(
|
|
"px-3 py-1.5 rounded-lg text-[10px] font-bold border transition-all uppercase tracking-widest",
|
|
formData.status === option.value
|
|
? getStatusClass(option.value)
|
|
: "bg-white/5 text-white/20 border-white/5 hover:bg-white/10"
|
|
)}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-[10px] font-bold text-white/20 uppercase tracking-widest ml-1">우선순위 & FLAG</label>
|
|
<label className="flex items-center gap-2 cursor-pointer group">
|
|
<div className={clsx(
|
|
"w-8 h-4 rounded-full relative transition-all duration-300 border",
|
|
formData.flag ? "bg-warning-500/40 border-warning-500/50" : "bg-white/10 border-white/10"
|
|
)}>
|
|
<div className={clsx(
|
|
"absolute top-0.5 w-2.5 h-2.5 rounded-full bg-white transition-all duration-300 shadow-sm",
|
|
formData.flag ? "left-4.5 bg-warning-400" : "left-0.5 opacity-30"
|
|
)} />
|
|
</div>
|
|
<input
|
|
type="checkbox"
|
|
className="hidden"
|
|
checked={formData.flag}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, flag: e.target.checked }))}
|
|
/>
|
|
<span className={clsx(
|
|
"text-[10px] font-bold uppercase tracking-widest",
|
|
formData.flag ? "text-warning-400" : "text-white/20"
|
|
)}>고정</span>
|
|
</label>
|
|
</div>
|
|
<div className="flex gap-1.5">
|
|
{[
|
|
{ value: 3, label: 'URG', color: 'danger' },
|
|
{ value: 2, label: 'HIGH', color: 'warning' },
|
|
{ value: 1, label: 'MID', color: 'primary' },
|
|
{ value: 0, label: 'LOW', color: 'white' },
|
|
{ value: -1, label: 'MINI', color: 'white' },
|
|
].map((p) => (
|
|
<button
|
|
key={p.value}
|
|
type="button"
|
|
onClick={() => setFormData(prev => ({ ...prev, seqno: p.value as TodoPriority }))}
|
|
className={clsx(
|
|
"flex-1 py-2 rounded-lg text-[9px] font-extrabold border transition-all tracking-tighter",
|
|
formData.seqno === p.value
|
|
? `bg-${p.color}-500/20 text-${p.color === 'white' ? 'white' : p.color + '-400'} border-${p.color === 'white' ? 'white/20' : p.color + '-500/30'}`
|
|
: "bg-white/5 text-white/20 border-white/5 hover:bg-white/10"
|
|
)}
|
|
>
|
|
{p.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 푸터 */}
|
|
<div className="dialog-footer">
|
|
<div>
|
|
{isEdit && onDelete && (
|
|
<button
|
|
type="button"
|
|
onClick={onDelete}
|
|
disabled={processing}
|
|
className="px-5 py-2.5 rounded-xl bg-danger-500/10 hover:bg-danger-500/20 border border-danger-500/20 text-danger-400 text-sm font-bold transition-all active:scale-95 flex items-center gap-2"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
삭제하기
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-white/70 hover:text-white text-sm font-bold transition-all active:scale-95"
|
|
>
|
|
취소
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onSubmit}
|
|
disabled={processing}
|
|
className="px-8 py-2.5 bg-primary-500 hover:bg-primary-600 border border-white/20 rounded-xl text-white text-sm font-bold transition-all shadow-lg shadow-primary-500/20 active:scale-95 flex items-center gap-2"
|
|
>
|
|
{processing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Edit2 className="w-4 h-4" />}
|
|
{submitText} 완료
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|