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:
backuppc
2025-11-27 17:25:31 +09:00
parent b57af6dad7
commit c9b5d756e1
65 changed files with 14028 additions and 467 deletions

View 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>
);
}