feat: React 프론트엔드 기능 대폭 확장
- 월별근무표: 휴일/근무일 관리, 자동 초기화 - 메일양식: 템플릿 CRUD, To/CC/BCC 설정 - 그룹정보: 부서 관리, 비트 연산 기반 권한 설정 - 업무일지: 수정 성공 메시지 제거, 오늘 근무시간 필터링 수정 - 웹소켓 메시지 type 충돌 버그 수정 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
402
Project/frontend/src/pages/Dashboard.tsx
Normal file
402
Project/frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ShoppingCart,
|
||||
FileCheck,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Flag,
|
||||
RefreshCw,
|
||||
ClipboardList,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { TodoModel, PurchaseItem } from '@/types';
|
||||
|
||||
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 [showNRModal, setShowNRModal] = useState(false);
|
||||
const [showCRModal, setShowCRModal] = useState(false);
|
||||
|
||||
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,
|
||||
] = await Promise.all([
|
||||
comms.getPurchaseWaitCount(),
|
||||
comms.getUrgentTodos(),
|
||||
comms.getTodos(),
|
||||
comms.getJobReportList(todayStr, todayStr, currentUserId, ''),
|
||||
]);
|
||||
|
||||
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 => t.status === '0' || t.status === '1').length;
|
||||
setTodoCount(pendingCount);
|
||||
}
|
||||
|
||||
// 오늘 업무일지 작성시간 계산
|
||||
if (jobreportResponse.Success && jobreportResponse.Data) {
|
||||
const totalHrs = jobreportResponse.Data.reduce((acc, item) => acc + (item.hrs || 0), 0);
|
||||
setTodayWorkHrs(totalHrs);
|
||||
} else {
|
||||
setTodayWorkHrs(0);
|
||||
}
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* 헤더 */}
|
||||
<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>
|
||||
<button
|
||||
onClick={() => navigate('/todo')}
|
||||
className="text-sm text-primary-400 hover:text-primary-300 transition-colors"
|
||||
>
|
||||
전체보기
|
||||
</button>
|
||||
</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={() => navigate('/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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user