1103 lines
42 KiB
TypeScript
1103 lines
42 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
ShoppingCart,
|
|
FileCheck,
|
|
AlertTriangle,
|
|
CheckCircle,
|
|
Flag,
|
|
RefreshCw,
|
|
ClipboardList,
|
|
Clock,
|
|
FileText,
|
|
Share2,
|
|
Lock,
|
|
List,
|
|
Plus,
|
|
X,
|
|
Loader2,
|
|
Edit2,
|
|
Trash2,
|
|
} from 'lucide-react';
|
|
import { comms } from '@/communication';
|
|
import { TodoModel, TodoStatus, TodoPriority, PurchaseItem, NoteItem, JobReportItem } from '@/types';
|
|
import { NoteViewModal } from '@/components/note/NoteViewModal';
|
|
import { NoteEditModal } from '@/components/note/NoteEditModal';
|
|
|
|
interface StatCardProps {
|
|
title: string;
|
|
value: number | string;
|
|
icon: React.ReactNode;
|
|
color: string;
|
|
onClick?: () => void;
|
|
}
|
|
|
|
function StatCard({ title, value, icon, color, onClick }: StatCardProps) {
|
|
return (
|
|
<div
|
|
onClick={onClick}
|
|
className={`glass-effect rounded-2xl p-6 card-hover ${onClick ? 'cursor-pointer' : ''}`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-white/60 text-sm font-medium">{title}</p>
|
|
<p className={`text-3xl font-bold mt-2 ${color}`}>{value}</p>
|
|
</div>
|
|
<div className={`p-3 rounded-xl ${color.replace('text-', 'bg-').replace('-400', '-500/20')}`}>
|
|
{icon}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Dashboard() {
|
|
const navigate = useNavigate();
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
|
|
// 통계 데이터
|
|
const [purchaseNR, setPurchaseNR] = useState(0);
|
|
const [purchaseCR, setPurchaseCR] = useState(0);
|
|
const [todoCount, setTodoCount] = useState(0);
|
|
const [todayWorkHrs, setTodayWorkHrs] = useState(0);
|
|
|
|
// 목록 데이터
|
|
const [urgentTodos, setUrgentTodos] = useState<TodoModel[]>([]);
|
|
const [purchaseNRList, setPurchaseNRList] = useState<PurchaseItem[]>([]);
|
|
const [purchaseCRList, setPurchaseCRList] = useState<PurchaseItem[]>([]);
|
|
const [recentNotes, setRecentNotes] = useState<NoteItem[]>([]);
|
|
|
|
// 모달 상태
|
|
const [showNRModal, setShowNRModal] = useState(false);
|
|
const [showCRModal, setShowCRModal] = useState(false);
|
|
const [showNoteModal, setShowNoteModal] = useState(false);
|
|
const [showNoteEditModal, setShowNoteEditModal] = useState(false);
|
|
const [showNoteAddModal, setShowNoteAddModal] = useState(false);
|
|
const [selectedNote, setSelectedNote] = useState<NoteItem | null>(null);
|
|
const [editingNote, setEditingNote] = useState<NoteItem | null>(null);
|
|
const [processing, setProcessing] = useState(false);
|
|
|
|
// 할일 추가 모달 상태
|
|
const [showTodoAddModal, setShowTodoAddModal] = useState(false);
|
|
const [showTodoEditModal, setShowTodoEditModal] = useState(false);
|
|
const [editingTodo, setEditingTodo] = useState<TodoModel | null>(null);
|
|
const [todoFormData, setTodoFormData] = useState({
|
|
title: '',
|
|
remark: '',
|
|
expire: '',
|
|
seqno: 0 as TodoPriority,
|
|
flag: false,
|
|
request: '',
|
|
status: '0' as TodoStatus,
|
|
});
|
|
|
|
const loadDashboardData = useCallback(async () => {
|
|
try {
|
|
// 오늘 날짜 (로컬 시간 기준)
|
|
const now = new Date();
|
|
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
|
|
// 현재 로그인 사용자 ID 가져오기
|
|
let currentUserId = '';
|
|
try {
|
|
const loginStatus = await comms.checkLoginStatus();
|
|
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
|
|
currentUserId = loginStatus.User.Id;
|
|
}
|
|
} catch (error) {
|
|
console.error('로그인 정보 로드 오류:', error);
|
|
}
|
|
|
|
// 병렬로 데이터 로드
|
|
const [
|
|
purchaseCount,
|
|
urgentTodosResponse,
|
|
allTodosResponse,
|
|
jobreportResponse,
|
|
notesResponse,
|
|
] = await Promise.all([
|
|
comms.getPurchaseWaitCount(),
|
|
comms.getUrgentTodos(),
|
|
comms.getTodos(),
|
|
comms.getJobReportList(todayStr, todayStr, currentUserId, ''),
|
|
comms.getNoteList('2000-01-01', todayStr, ''),
|
|
]);
|
|
|
|
setPurchaseNR(purchaseCount.NR);
|
|
setPurchaseCR(purchaseCount.CR);
|
|
|
|
if (urgentTodosResponse.Success && urgentTodosResponse.Data) {
|
|
setUrgentTodos(urgentTodosResponse.Data.slice(0, 5));
|
|
}
|
|
|
|
if (allTodosResponse.Success && allTodosResponse.Data) {
|
|
// 진행, 대기 상태의 할일만 카운트 (보류, 취소 제외)
|
|
const pendingCount = allTodosResponse.Data.filter((t: TodoModel) => t.status === '0' || t.status === '1').length;
|
|
setTodoCount(pendingCount);
|
|
}
|
|
|
|
// 오늘 업무일지 작성시간 계산
|
|
if (jobreportResponse.Success && jobreportResponse.Data) {
|
|
const totalHrs = jobreportResponse.Data.reduce((acc: number, item: JobReportItem) => acc + (item.hrs || 0), 0);
|
|
setTodayWorkHrs(totalHrs);
|
|
} else {
|
|
setTodayWorkHrs(0);
|
|
}
|
|
|
|
// 최근 메모 목록 (최대 10개)
|
|
if (notesResponse.Success && notesResponse.Data) {
|
|
setRecentNotes(notesResponse.Data.slice(0, 10));
|
|
}
|
|
} catch (error) {
|
|
console.error('대시보드 데이터 로드 오류:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
}
|
|
}, []);
|
|
|
|
const loadNRList = async () => {
|
|
try {
|
|
const list = await comms.getPurchaseNRList();
|
|
setPurchaseNRList(list);
|
|
setShowNRModal(true);
|
|
} catch (error) {
|
|
console.error('NR 목록 로드 오류:', error);
|
|
}
|
|
};
|
|
|
|
const loadCRList = async () => {
|
|
try {
|
|
const list = await comms.getPurchaseCRList();
|
|
setPurchaseCRList(list);
|
|
setShowCRModal(true);
|
|
} catch (error) {
|
|
console.error('CR 목록 로드 오류:', error);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadDashboardData();
|
|
|
|
// 30초마다 자동 새로고침
|
|
const interval = setInterval(loadDashboardData, 30000);
|
|
return () => clearInterval(interval);
|
|
}, [loadDashboardData]);
|
|
|
|
const handleRefresh = () => {
|
|
setRefreshing(true);
|
|
loadDashboardData();
|
|
};
|
|
|
|
const getStatusText = (status: string) => {
|
|
switch (status) {
|
|
case '0': return '대기';
|
|
case '1': return '진행';
|
|
case '2': return '취소';
|
|
case '3': return '보류';
|
|
case '5': return '완료';
|
|
default: return '대기';
|
|
}
|
|
};
|
|
|
|
const getStatusClass = (status: string) => {
|
|
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 getPriorityText = (seqno: number) => {
|
|
switch (seqno) {
|
|
case 1: return '중요';
|
|
case 2: return '매우 중요';
|
|
case 3: return '긴급';
|
|
default: return '보통';
|
|
}
|
|
};
|
|
|
|
const getPriorityClass = (seqno: number) => {
|
|
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';
|
|
}
|
|
};
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
const handleNoteClick = async (note: NoteItem) => {
|
|
try {
|
|
const response = await comms.getNoteDetail(note.idx);
|
|
if (response.Success && response.Data) {
|
|
setSelectedNote(response.Data);
|
|
setShowNoteModal(true);
|
|
}
|
|
} catch (error) {
|
|
console.error('메모 조회 오류:', error);
|
|
}
|
|
};
|
|
|
|
const handleNoteAdd = () => {
|
|
setEditingNote(null);
|
|
setShowNoteAddModal(true);
|
|
};
|
|
|
|
const handleNoteDelete = async (note: NoteItem) => {
|
|
setProcessing(true);
|
|
try {
|
|
const response = await comms.deleteNote(note.idx);
|
|
if (response.Success) {
|
|
setShowNoteModal(false);
|
|
loadDashboardData();
|
|
} else {
|
|
alert(response.Message || '삭제에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('삭제 오류:', error);
|
|
alert('서버 연결에 실패했습니다: ' + (error instanceof Error ? error.message : String(error)));
|
|
} finally {
|
|
setProcessing(false);
|
|
}
|
|
};
|
|
|
|
const handleTodoAdd = () => {
|
|
setTodoFormData({
|
|
title: '',
|
|
remark: '',
|
|
expire: '',
|
|
seqno: 0,
|
|
flag: false,
|
|
request: '',
|
|
status: '0',
|
|
});
|
|
setShowTodoAddModal(true);
|
|
};
|
|
|
|
const handleTodoEdit = (todo: TodoModel) => {
|
|
setEditingTodo(todo);
|
|
setTodoFormData({
|
|
title: todo.title || '',
|
|
remark: todo.remark,
|
|
expire: todo.expire || '',
|
|
seqno: todo.seqno as TodoPriority,
|
|
flag: todo.flag,
|
|
request: todo.request || '',
|
|
status: todo.status as TodoStatus,
|
|
});
|
|
setShowTodoEditModal(true);
|
|
};
|
|
|
|
const handleTodoSave = async () => {
|
|
if (!todoFormData.remark.trim()) {
|
|
alert('할일 내용을 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
setProcessing(true);
|
|
try {
|
|
const response = await comms.createTodo(
|
|
todoFormData.title,
|
|
todoFormData.remark,
|
|
todoFormData.expire || null,
|
|
todoFormData.seqno,
|
|
todoFormData.flag,
|
|
todoFormData.request || null,
|
|
todoFormData.status
|
|
);
|
|
|
|
if (response.Success) {
|
|
setShowTodoAddModal(false);
|
|
loadDashboardData();
|
|
} else {
|
|
alert(response.Message || '할일 추가에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('할일 추가 오류:', error);
|
|
alert('서버 연결에 실패했습니다: ' + (error instanceof Error ? error.message : String(error)));
|
|
} finally {
|
|
setProcessing(false);
|
|
}
|
|
};
|
|
|
|
const handleTodoUpdate = async () => {
|
|
if (!editingTodo || !todoFormData.remark.trim()) {
|
|
alert('할일 내용을 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
setProcessing(true);
|
|
try {
|
|
const response = await comms.updateTodo(
|
|
editingTodo.idx,
|
|
todoFormData.title,
|
|
todoFormData.remark,
|
|
todoFormData.expire || null,
|
|
todoFormData.seqno,
|
|
todoFormData.flag,
|
|
todoFormData.request || null,
|
|
todoFormData.status
|
|
);
|
|
|
|
if (response.Success) {
|
|
setShowTodoEditModal(false);
|
|
setEditingTodo(null);
|
|
loadDashboardData();
|
|
} else {
|
|
alert(response.Message || '할일 수정에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('할일 수정 오류:', error);
|
|
alert('서버 연결에 실패했습니다: ' + (error instanceof Error ? error.message : String(error)));
|
|
} finally {
|
|
setProcessing(false);
|
|
}
|
|
};
|
|
|
|
const handleTodoDelete = async () => {
|
|
if (!editingTodo) return;
|
|
if (!confirm('정말로 이 할일을 삭제하시겠습니까?')) {
|
|
return;
|
|
}
|
|
|
|
setProcessing(true);
|
|
try {
|
|
const response = await comms.deleteTodo(editingTodo.idx);
|
|
if (response.Success) {
|
|
setShowTodoEditModal(false);
|
|
setEditingTodo(null);
|
|
loadDashboardData();
|
|
} else {
|
|
alert(response.Message || '할일 삭제에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('할일 삭제 오류:', error);
|
|
alert('서버 연결에 실패했습니다.');
|
|
} finally {
|
|
setProcessing(false);
|
|
}
|
|
};
|
|
|
|
const handleTodoComplete = async () => {
|
|
if (!editingTodo) return;
|
|
|
|
setProcessing(true);
|
|
try {
|
|
const response = await comms.updateTodo(
|
|
editingTodo.idx,
|
|
editingTodo.title,
|
|
editingTodo.remark,
|
|
editingTodo.expire,
|
|
editingTodo.seqno,
|
|
editingTodo.flag,
|
|
editingTodo.request,
|
|
'5' // 완료 상태
|
|
);
|
|
|
|
if (response.Success) {
|
|
setShowTodoEditModal(false);
|
|
setEditingTodo(null);
|
|
loadDashboardData();
|
|
} else {
|
|
alert(response.Message || '할일 완료 처리에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('할일 완료 오류:', error);
|
|
alert('서버 연결에 실패했습니다.');
|
|
} finally {
|
|
setProcessing(false);
|
|
}
|
|
};
|
|
|
|
const handleNoteSave = async (formData: {
|
|
pdate: string;
|
|
title: string;
|
|
uid: string;
|
|
description: string;
|
|
share: boolean;
|
|
guid: string;
|
|
}) => {
|
|
if (!formData.pdate) {
|
|
alert('날짜를 입력해주세요.');
|
|
return;
|
|
}
|
|
if (!formData.title.trim()) {
|
|
alert('제목을 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
setProcessing(true);
|
|
try {
|
|
const response = editingNote
|
|
? await comms.editNote(
|
|
editingNote.idx,
|
|
formData.pdate,
|
|
formData.title,
|
|
formData.uid,
|
|
formData.description,
|
|
'',
|
|
formData.share,
|
|
formData.guid
|
|
)
|
|
: await comms.addNote(
|
|
formData.pdate,
|
|
formData.title,
|
|
formData.uid,
|
|
formData.description,
|
|
'',
|
|
formData.share,
|
|
formData.guid
|
|
);
|
|
|
|
if (response.Success) {
|
|
setShowNoteEditModal(false);
|
|
setShowNoteAddModal(false);
|
|
loadDashboardData();
|
|
} else {
|
|
alert(response.Message || '저장에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('저장 오류:', error);
|
|
alert('서버 연결에 실패했습니다: ' + (error instanceof Error ? error.message : String(error)));
|
|
} finally {
|
|
setProcessing(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex gap-6 animate-fade-in">
|
|
{/* 메인 컨텐츠 */}
|
|
<div className="flex-1 space-y-6">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-2xl font-bold text-white">오늘의 현황</h2>
|
|
<button
|
|
onClick={handleRefresh}
|
|
disabled={refreshing}
|
|
className="flex items-center space-x-2 px-4 py-2 glass-effect rounded-lg text-white/70 hover:text-white transition-colors"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
|
|
<span>새로고침</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* 통계 카드 */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
|
<StatCard
|
|
title="구매요청 (NR)"
|
|
value={purchaseNR}
|
|
icon={<ShoppingCart className="w-6 h-6 text-primary-400" />}
|
|
color="text-primary-400"
|
|
onClick={loadNRList}
|
|
/>
|
|
<StatCard
|
|
title="구매요청 (CR)"
|
|
value={purchaseCR}
|
|
icon={<FileCheck className="w-6 h-6 text-success-400" />}
|
|
color="text-success-400"
|
|
onClick={loadCRList}
|
|
/>
|
|
<StatCard
|
|
title="미완료 할일"
|
|
value={todoCount}
|
|
icon={<ClipboardList className="w-6 h-6 text-warning-400" />}
|
|
color="text-warning-400"
|
|
onClick={() => navigate('/todo')}
|
|
/>
|
|
<StatCard
|
|
title="금일 업무일지"
|
|
value={`${todayWorkHrs}시간`}
|
|
icon={<Clock className="w-6 h-6 text-cyan-400" />}
|
|
color="text-cyan-400"
|
|
onClick={() => navigate('/jobreport')}
|
|
/>
|
|
</div>
|
|
|
|
{/* 할일 목록 */}
|
|
<div className="glass-effect rounded-2xl overflow-hidden">
|
|
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
|
<h3 className="text-lg font-semibold text-white flex items-center">
|
|
<AlertTriangle className="w-5 h-5 mr-2 text-warning-400" />
|
|
할일
|
|
</h3>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handleTodoAdd}
|
|
className="p-1.5 rounded-lg bg-primary-500/20 text-primary-400 hover:bg-primary-500/30 transition-colors"
|
|
title="할일 추가"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => navigate('/todo')}
|
|
className="p-1.5 rounded-lg bg-white/10 text-white/70 hover:bg-white/20 transition-colors"
|
|
title="전체보기"
|
|
>
|
|
<List className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="divide-y divide-white/10">
|
|
{urgentTodos.length > 0 ? (
|
|
urgentTodos.map((todo) => (
|
|
<div
|
|
key={todo.idx}
|
|
className="px-6 py-4 hover:bg-white/5 transition-colors cursor-pointer"
|
|
onClick={() => handleTodoEdit(todo)}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center space-x-4">
|
|
{todo.flag && (
|
|
<Flag className="w-4 h-4 text-warning-400" />
|
|
)}
|
|
<div>
|
|
<p className="text-white font-medium">
|
|
{todo.title || '제목 없음'}
|
|
</p>
|
|
<p className="text-white/60 text-sm line-clamp-1">
|
|
{todo.remark}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center space-x-3">
|
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getPriorityClass(todo.seqno)}`}>
|
|
{getPriorityText(todo.seqno)}
|
|
</span>
|
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(todo.status)}`}>
|
|
{getStatusText(todo.status)}
|
|
</span>
|
|
{todo.expire && (
|
|
<span className={`text-xs ${new Date(todo.expire) < new Date() ? 'text-danger-400' : 'text-white/60'}`}>
|
|
{new Date(todo.expire).toLocaleDateString('ko-KR')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="px-6 py-8 text-center text-white/50">
|
|
<CheckCircle className="w-12 h-12 mx-auto mb-3 text-success-400/50" />
|
|
<p>할일이 없습니다</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* NR 모달 */}
|
|
{showNRModal && (
|
|
<Modal title="구매요청 (NR) 목록" onClose={() => setShowNRModal(false)}>
|
|
<PurchaseTable data={purchaseNRList} />
|
|
</Modal>
|
|
)}
|
|
|
|
{/* CR 모달 */}
|
|
{showCRModal && (
|
|
<Modal title="구매요청 (CR) 목록" onClose={() => setShowCRModal(false)}>
|
|
<PurchaseTable data={purchaseCRList} />
|
|
</Modal>
|
|
)}
|
|
|
|
{/* 메모 보기 모달 */}
|
|
<NoteViewModal
|
|
isOpen={showNoteModal}
|
|
note={selectedNote}
|
|
onClose={() => setShowNoteModal(false)}
|
|
onEdit={(note) => {
|
|
setShowNoteModal(false);
|
|
setEditingNote(note);
|
|
setShowNoteEditModal(true);
|
|
}}
|
|
onDelete={handleNoteDelete}
|
|
/>
|
|
|
|
{/* 메모 편집 모달 */}
|
|
<NoteEditModal
|
|
isOpen={showNoteEditModal}
|
|
editingItem={editingNote}
|
|
processing={processing}
|
|
onClose={() => setShowNoteEditModal(false)}
|
|
onSave={handleNoteSave}
|
|
initialEditMode={true}
|
|
/>
|
|
|
|
{/* 메모 추가 모달 */}
|
|
<NoteEditModal
|
|
isOpen={showNoteAddModal}
|
|
editingItem={null}
|
|
processing={processing}
|
|
onClose={() => setShowNoteAddModal(false)}
|
|
onSave={handleNoteSave}
|
|
initialEditMode={true}
|
|
/>
|
|
|
|
{/* 할일 추가 모달 */}
|
|
{showTodoAddModal && (
|
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" onClick={() => setShowTodoAddModal(false)}>
|
|
<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"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* 헤더 */}
|
|
<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">
|
|
<Plus className="w-5 h-5 mr-2" />
|
|
할일 추가
|
|
</h2>
|
|
<button onClick={() => setShowTodoAddModal(false)} className="text-white/70 hover:text-white transition-colors">
|
|
<X className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* 내용 */}
|
|
<div className="p-6 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={todoFormData.title}
|
|
onChange={(e) => setTodoFormData(prev => ({ ...prev, 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={todoFormData.expire}
|
|
onChange={(e) => setTodoFormData(prev => ({ ...prev, 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={todoFormData.remark}
|
|
onChange={(e) => setTodoFormData(prev => ({ ...prev, 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
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-white/70 text-sm font-medium mb-2">요청자</label>
|
|
<input
|
|
type="text"
|
|
value={todoFormData.request}
|
|
onChange={(e) => setTodoFormData(prev => ({ ...prev, 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: '대기' },
|
|
{ value: '1', label: '진행' },
|
|
{ value: '3', label: '보류' },
|
|
{ value: '2', label: '취소' },
|
|
{ value: '5', label: '완료' },
|
|
].map((option) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
onClick={() => setTodoFormData(prev => ({ ...prev, status: option.value as TodoStatus }))}
|
|
className={`px-3 py-1 rounded-lg text-xs font-medium border transition-all ${
|
|
todoFormData.status === option.value
|
|
? getStatusClass(option.value)
|
|
: 'bg-white/10 text-white/50 border-white/20 hover:bg-white/20'
|
|
}`}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-white/70 text-sm font-medium mb-2">중요도</label>
|
|
<select
|
|
value={todoFormData.seqno}
|
|
onChange={(e) => setTodoFormData(prev => ({ ...prev, seqno: parseInt(e.target.value) as TodoPriority }))}
|
|
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 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={todoFormData.flag}
|
|
onChange={(e) => setTodoFormData(prev => ({ ...prev, flag: e.target.checked }))}
|
|
className="mr-2 text-primary-500 focus:ring-primary-400 focus:ring-offset-0 rounded"
|
|
/>
|
|
플래그 (상단 고정)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 푸터 */}
|
|
<div className="px-6 py-4 border-t border-white/10 flex justify-end space-x-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowTodoAddModal(false)}
|
|
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
|
|
>
|
|
취소
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleTodoSave}
|
|
disabled={processing}
|
|
className="bg-primary-500 hover:bg-primary-600 text-white px-6 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
|
|
>
|
|
{processing ? (
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
) : (
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
)}
|
|
추가
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 할일 수정 모달 */}
|
|
{showTodoEditModal && editingTodo && (
|
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" onClick={() => setShowTodoEditModal(false)}>
|
|
<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"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* 헤더 */}
|
|
<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">
|
|
<Edit2 className="w-5 h-5 mr-2" />
|
|
할일 수정
|
|
</h2>
|
|
<button onClick={() => setShowTodoEditModal(false)} className="text-white/70 hover:text-white transition-colors">
|
|
<X className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* 내용 */}
|
|
<div className="p-6 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={todoFormData.title}
|
|
onChange={(e) => setTodoFormData(prev => ({ ...prev, 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={todoFormData.expire}
|
|
onChange={(e) => setTodoFormData(prev => ({ ...prev, 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={todoFormData.remark}
|
|
onChange={(e) => setTodoFormData(prev => ({ ...prev, 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
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-white/70 text-sm font-medium mb-2">요청자</label>
|
|
<input
|
|
type="text"
|
|
value={todoFormData.request}
|
|
onChange={(e) => setTodoFormData(prev => ({ ...prev, 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: '대기' },
|
|
{ value: '1', label: '진행' },
|
|
{ value: '3', label: '보류' },
|
|
{ value: '2', label: '취소' },
|
|
{ value: '5', label: '완료' },
|
|
].map((option) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
onClick={() => setTodoFormData(prev => ({ ...prev, status: option.value as TodoStatus }))}
|
|
className={`px-3 py-1 rounded-lg text-xs font-medium border transition-all ${
|
|
todoFormData.status === option.value
|
|
? getStatusClass(option.value)
|
|
: 'bg-white/10 text-white/50 border-white/20 hover:bg-white/20'
|
|
}`}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-white/70 text-sm font-medium mb-2">중요도</label>
|
|
<select
|
|
value={todoFormData.seqno}
|
|
onChange={(e) => setTodoFormData(prev => ({ ...prev, seqno: parseInt(e.target.value) as TodoPriority }))}
|
|
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 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={todoFormData.flag}
|
|
onChange={(e) => setTodoFormData(prev => ({ ...prev, flag: e.target.checked }))}
|
|
className="mr-2 text-primary-500 focus:ring-primary-400 focus:ring-offset-0 rounded"
|
|
/>
|
|
플래그 (상단 고정)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 푸터 */}
|
|
<div className="px-6 py-4 border-t border-white/10 flex justify-between">
|
|
{/* 왼쪽: 삭제 버튼 */}
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={handleTodoDelete}
|
|
disabled={processing}
|
|
className="bg-danger-500 hover:bg-danger-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-2" />
|
|
삭제
|
|
</button>
|
|
</div>
|
|
|
|
{/* 오른쪽: 취소, 완료, 수정 버튼 */}
|
|
<div className="flex space-x-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowTodoEditModal(false)}
|
|
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
|
|
>
|
|
취소
|
|
</button>
|
|
{editingTodo.status !== '5' && (
|
|
<button
|
|
type="button"
|
|
onClick={handleTodoComplete}
|
|
disabled={processing}
|
|
className="bg-success-500 hover:bg-success-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
|
|
>
|
|
<CheckCircle className="w-4 h-4 mr-2" />
|
|
완료 처리
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={handleTodoUpdate}
|
|
disabled={processing}
|
|
className="bg-primary-500 hover:bg-primary-600 text-white px-6 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
|
|
>
|
|
{processing ? (
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
) : (
|
|
<Edit2 className="w-4 h-4 mr-2" />
|
|
)}
|
|
수정
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 우측 사이드바 - 메모 리스트 */}
|
|
<div className="w-60 space-y-4">
|
|
<div className="glass-effect rounded-2xl overflow-hidden">
|
|
<div className="px-4 py-3 border-b border-white/10 flex items-center justify-between">
|
|
<h3 className="text-base font-semibold text-white flex items-center">
|
|
<FileText className="w-4 h-4 mr-2" />
|
|
최근 메모
|
|
</h3>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handleNoteAdd}
|
|
className="p-1.5 rounded-lg bg-primary-500/20 text-primary-400 hover:bg-primary-500/30 transition-colors"
|
|
title="메모 추가"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => navigate('/note')}
|
|
className="p-1.5 rounded-lg bg-white/10 text-white/70 hover:bg-white/20 transition-colors"
|
|
title="전체보기"
|
|
>
|
|
<List className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="divide-y divide-white/10 max-h-[calc(100vh-200px)] overflow-y-auto">
|
|
{recentNotes.length > 0 ? (
|
|
recentNotes.map((note) => (
|
|
<div
|
|
key={note.idx}
|
|
className="px-4 py-2.5 hover:bg-white/5 transition-colors cursor-pointer group"
|
|
onClick={() => handleNoteClick(note)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{note.share ? (
|
|
<Share2 className="w-3 h-3 text-green-400 flex-shrink-0" />
|
|
) : (
|
|
<Lock className="w-3 h-3 text-blue-400 flex-shrink-0" />
|
|
)}
|
|
<p className="text-white text-sm truncate flex-1">
|
|
{(note.title || '제목 없음').length > 15 ? `${(note.title || '제목 없음').substring(0, 15)}...` : (note.title || '제목 없음')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="px-4 py-8 text-center text-white/50">
|
|
<FileText className="w-10 h-10 mx-auto mb-2 text-white/30" />
|
|
<p className="text-sm">등록된 메모가 없습니다</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 모달 컴포넌트
|
|
interface ModalProps {
|
|
title: string;
|
|
onClose: () => void;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
function Modal({ title, onClose, children }: ModalProps) {
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
|
<div className="glass-effect rounded-2xl w-full max-w-4xl max-h-[80vh] overflow-hidden animate-slide-up">
|
|
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
|
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
|
<button
|
|
onClick={onClose}
|
|
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" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div className="overflow-auto max-h-[calc(80vh-80px)]">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 구매 테이블 컴포넌트
|
|
function PurchaseTable({ data }: { data: PurchaseItem[] }) {
|
|
if (data.length === 0) {
|
|
return (
|
|
<div className="px-6 py-8 text-center text-white/50">
|
|
대기 중인 구매요청이 없습니다
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<table className="w-full">
|
|
<thead className="bg-white/10">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">요청일</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">공정</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">품명</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">규격</th>
|
|
<th className="px-4 py-3 text-right text-xs font-medium text-white/70 uppercase">수량</th>
|
|
<th className="px-4 py-3 text-right text-xs font-medium text-white/70 uppercase">단가</th>
|
|
<th className="px-4 py-3 text-right text-xs font-medium text-white/70 uppercase">금액</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-white/10">
|
|
{data.map((item, idx) => (
|
|
<tr key={idx} className="hover:bg-white/5">
|
|
<td className="px-4 py-3 text-white/80 text-sm">{item.pdate}</td>
|
|
<td className="px-4 py-3 text-white/80 text-sm">{item.process}</td>
|
|
<td className="px-4 py-3 text-white text-sm">{item.pumname}</td>
|
|
<td className="px-4 py-3 text-white/80 text-sm">{item.pumscale}</td>
|
|
<td className="px-4 py-3 text-white/80 text-sm text-right">
|
|
{item.pumqtyreq?.toLocaleString()} {item.pumunit}
|
|
</td>
|
|
<td className="px-4 py-3 text-white/80 text-sm text-right">
|
|
{item.pumprice?.toLocaleString()}
|
|
</td>
|
|
<td className="px-4 py-3 text-white text-sm text-right font-medium">
|
|
{item.pumamt?.toLocaleString()}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
);
|
|
}
|