Files
Groupware/Project/Web/wwwroot/react/DashboardApp.jsx
ChiKyun Kim 6bd4f84192 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>
2025-09-11 09:08:40 +09:00

669 lines
38 KiB
JavaScript

// DashboardApp.jsx - React Dashboard Component for GroupWare
const { useState, useEffect, useRef } = React;
function DashboardApp() {
// 상태 관리
const [dashboardData, setDashboardData] = useState({
presentCount: 0,
leaveCount: 0,
leaveRequestCount: 0,
purchaseCountNR: 0,
purchaseCountCR: 0
});
const [isLoading, setIsLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState('');
const [modals, setModals] = useState({
presentUsers: false,
holidayUsers: false,
holidayRequest: false,
purchaseNR: false,
purchaseCR: false
});
const [modalData, setModalData] = useState({
presentUsers: [],
holidayUsers: [],
holidayRequests: [],
purchaseNR: [],
purchaseCR: []
});
const [todoList, setTodoList] = useState([]);
// 모달 제어 함수
const showModal = (modalName) => {
setModals(prev => ({ ...prev, [modalName]: true }));
loadModalData(modalName);
};
const hideModal = (modalName) => {
setModals(prev => ({ ...prev, [modalName]: false }));
};
// Dashboard 데이터 로드
const loadDashboardData = async () => {
setIsLoading(true);
try {
// 실제 DashBoardController API 호출
const [
currentUserResponse,
leaveCountResponse,
holyRequestResponse,
purchaseWaitResponse
] = await Promise.all([
fetch('http://127.0.0.1:7979/DashBoard/GetCurrentUserCount'),
fetch('http://127.0.0.1:7979/DashBoard/TodayCountH'),
fetch('http://127.0.0.1:7979/DashBoard/GetHolydayRequestCount'),
fetch('http://127.0.0.1:7979/DashBoard/GetPurchaseWaitCount')
]);
// 현재 근무자 수 (JSON 응답)
const currentUserData = await currentUserResponse.json();
const presentCount = currentUserData.Count || 0;
// 휴가자 수 (텍스트 응답)
const leaveCountText = await leaveCountResponse.text();
const leaveCount = parseInt(leaveCountText.replace(/"/g, ''), 10) || 0;
// 휴가 요청 수 (JSON 응답)
const holyRequestData = await holyRequestResponse.json();
const leaveRequestCount = holyRequestData.HOLY || 0;
// 구매 대기 수 (JSON 응답)
const purchaseWaitData = await purchaseWaitResponse.json();
const purchaseCountNR = purchaseWaitData.NR || 0;
const purchaseCountCR = purchaseWaitData.CR || 0;
setDashboardData({
presentCount,
leaveCount,
leaveRequestCount,
purchaseCountNR,
purchaseCountCR
});
setLastUpdated(new Date().toLocaleString('ko-KR'));
} catch (error) {
console.error('대시보드 데이터 로드 실패:', error);
} finally {
setIsLoading(false);
}
};
// 모달 데이터 로드
const loadModalData = async (modalName) => {
try {
let endpoint = '';
switch (modalName) {
case 'presentUsers':
endpoint = 'http://127.0.0.1:7979/DashBoard/GetPresentUserList';
break;
case 'holidayUsers':
endpoint = 'http://127.0.0.1:7979/DashBoard/GetholyUser';
break;
case 'holidayRequest':
endpoint = 'http://127.0.0.1:7979/DashBoard/GetholyRequestUser';
break;
case 'purchaseNR':
endpoint = 'http://127.0.0.1:7979/DashBoard/GetPurchaseNRList';
break;
case 'purchaseCR':
endpoint = 'http://127.0.0.1:7979/DashBoard/GetPurchaseCRList';
break;
default:
return;
}
const response = await fetch(endpoint);
const data = await response.json();
setModalData(prev => ({
...prev,
[modalName]: Array.isArray(data) ? data : []
}));
} catch (error) {
console.error(`모달 데이터 로드 실패 (${modalName}):`, error);
setModalData(prev => ({
...prev,
[modalName]: []
}));
}
};
// Todo 목록 로드
const loadTodoList = async () => {
try {
const response = await fetch('http://127.0.0.1:7979/Todo/GetUrgentTodos');
const data = await response.json();
if (data.Success && data.Data) {
setTodoList(data.Data);
} else {
setTodoList([]);
}
} catch (error) {
console.error('Todo 목록 로드 실패:', error);
setTodoList([]);
}
};
// 자동 새로고침 (30초마다)
useEffect(() => {
loadDashboardData();
loadModalData('holidayUsers'); // 휴가자 목록 자동 로드
loadTodoList(); // Todo 목록 자동 로드
const interval = setInterval(() => {
loadDashboardData();
loadModalData('holidayUsers'); // 30초마다 휴가자 목록도 새로고침
loadTodoList(); // 30초마다 Todo 목록도 새로고침
}, 30000); // 30초
return () => clearInterval(interval);
}, []);
// 통계 카드 컴포넌트
const StatCard = ({ title, count, icon, color, onClick, isClickable = false }) => {
return (
<div
className={`glass-effect rounded-2xl p-6 card-hover animate-slide-up ${isClickable ? 'cursor-pointer' : ''}`}
onClick={onClick}
>
<div className="flex items-center justify-between">
<div>
<p className="text-white/70 text-sm font-medium">{title}</p>
<p className="text-3xl font-bold text-white">
{isLoading ? (
<div className="animate-pulse bg-white/20 h-8 w-16 rounded"></div>
) : count}
</p>
</div>
<div className={`w-12 h-12 ${color}/20 rounded-full flex items-center justify-center`}>
{icon}
</div>
</div>
</div>
);
};
// 테이블 모달 컴포넌트
const TableModal = ({ isOpen, onClose, title, headers, data, renderRow, maxWidth = 'max-w-4xl' }) => {
if (!isOpen) return null;
return (
<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 ${maxWidth} max-h-[80vh] 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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{title}
</h2>
<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"></path>
</svg>
</button>
</div>
{/* 모달 내용 */}
<div className="overflow-x-auto max-h-[60vh] custom-scrollbar">
<table className="w-full">
<thead className="bg-white/10 sticky top-0">
<tr>
{headers.map((header, index) => (
<th key={index} className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">
{header}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{data.length > 0 ? (
data.map((item, index) => renderRow(item, index))
) : (
<tr>
<td colSpan={headers.length} className="px-6 py-8 text-center text-white/50">
데이터가 없습니다.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* 모달 푸터 */}
<div className="px-6 py-4 border-t border-white/10 flex justify-between items-center">
<p className="text-white/70 text-sm">
<span className="font-medium">{data.length}</span>
</p>
<button
onClick={onClose}
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
>
닫기
</button>
</div>
</div>
</div>
</div>
);
};
return (
<div className="bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 min-h-screen text-white">
<div className="container mx-auto px-4 py-8">
{/* 헤더 */}
<div className="text-center mb-8 animate-fade-in">
<h1 className="text-4xl font-bold mb-4">근태현황 대시보드</h1>
<p className="text-white/70">실시간 근태 업무 현황을 확인하세요</p>
{lastUpdated && (
<p className="text-white/50 text-sm mt-2">마지막 업데이트: {lastUpdated}</p>
)}
</div>
{/* 새로고침 버튼 */}
<div className="flex justify-end mb-6">
<button
onClick={loadDashboardData}
disabled={isLoading}
className="glass-effect rounded-lg px-4 py-2 text-white hover:bg-white/10 transition-all duration-300 disabled:opacity-50"
>
{isLoading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2 inline-block"></div>
) : (
<svg className="w-4 h-4 mr-2 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
)}
새로고침
</button>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8">
<StatCard
title="출근(대상)"
count={dashboardData.presentCount}
isClickable={true}
onClick={() => showModal('presentUsers')}
color="bg-success-500"
icon={
<svg className="w-6 h-6 text-success-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
}
/>
<StatCard
title="휴가"
count={dashboardData.leaveCount}
isClickable={true}
onClick={() => showModal('holidayUsers')}
color="bg-warning-500"
icon={
<svg className="w-6 h-6 text-warning-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
}
/>
<StatCard
title="휴가요청"
count={dashboardData.leaveRequestCount}
isClickable={true}
onClick={() => showModal('holidayRequest')}
color="bg-primary-500"
icon={
<svg className="w-6 h-6 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
}
/>
<StatCard
title="구매요청(NR)"
count={dashboardData.purchaseCountNR}
isClickable={true}
onClick={() => showModal('purchaseNR')}
color="bg-danger-500"
icon={
<svg className="w-6 h-6 text-danger-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"></path>
</svg>
}
/>
<StatCard
title="구매요청(CR)"
count={dashboardData.purchaseCountCR}
isClickable={true}
onClick={() => showModal('purchaseCR')}
color="bg-purple-500"
icon={
<svg className="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"></path>
</svg>
}
/>
</div>
{/* 2칸 레이아웃: 좌측 휴가현황, 우측 할일 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 animate-slide-up">
{/* 좌측: 휴가/기타 현황 */}
<div className="glass-effect rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-white/10">
<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="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
</svg>
휴가/기타 현황
</h2>
</div>
<div className="overflow-x-auto max-h-[460px] custom-scrollbar">
<table className="w-full">
<thead className="bg-white/10 sticky top-0">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">이름</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">형태</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">종류</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">기간</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">사유</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{modalData.holidayUsers.map((user, index) => {
// 형태에 따른 색상 결정
const typeColorClass = (user.type === '휴가') ? 'bg-green-500/20 text-green-300' : 'bg-warning-500/20 text-warning-300';
// 종류에 따른 색상 결정
let cateColorClass = 'bg-warning-500/20 text-warning-300'; // 기본값
if (user.cate === '휴가') {
cateColorClass = 'bg-warning-500/20 text-warning-300'; // 노란색 계열
} else if (user.cate === '파견') {
cateColorClass = 'bg-purple-500/20 text-purple-300'; // 보라색 계열
} else {
cateColorClass = 'bg-warning-500/20 text-warning-300'; // 기타는 주황색 계열
}
// 기간 표시 형식 개선
let periodText = '';
if (user.sdate && user.edate) {
if (user.sdate === user.edate) {
periodText = user.sdate;
} else {
periodText = `${user.sdate}~${user.edate}`;
}
} else {
periodText = '-';
}
return (
<tr key={index} className="hover:bg-white/5">
<td className="px-4 py-3 text-white text-sm font-medium">{user.name || '이름 없음'}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${typeColorClass}`}>
{user.type || 'N/A'}
</span>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${cateColorClass}`}>
{user.cate || '종류 없음'}
</span>
</td>
<td className="px-4 py-3 text-white text-sm">{periodText}</td>
<td className="px-4 py-3 text-white/70 text-sm">{user.title || '사유 없음'}</td>
</tr>
);
})}
{modalData.holidayUsers.length === 0 && (
<tr>
<td colSpan="5" className="px-4 py-8 text-center text-white/50">
현재 휴가자가 없습니다
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* 우측: 할일 */}
<div className="glass-effect rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-white/10">
<h2 className="text-xl font-semibold text-white flex items-center justify-between">
<span className="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>
할일
</span>
<div className="flex items-center space-x-2">
<button
className="text-xs bg-primary-500/20 hover:bg-primary-500/30 text-primary-300 hover:text-primary-200 px-3 py-1 rounded-full transition-colors flex items-center"
onClick={() => alert('할일 추가 기능은 준비 중입니다.')}
>
<svg className="w-3 h-3 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>
<button
className="text-xs bg-white/20 hover:bg-white/30 px-3 py-1 rounded-full transition-colors"
onClick={() => window.location.href = '/react/todo'}
>
전체보기
</button>
</div>
</h2>
</div>
<div className="p-4">
<div className="space-y-3 max-h-[384px] overflow-y-auto custom-scrollbar">
{todoList.length > 0 ? (
todoList.map((todo, index) => {
const flagIcon = todo.flag ? '📌 ' : '';
// 상태별 클래스
const getTodoStatusClass = (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 getTodoStatusText = (status) => {
switch(status) {
case '0': return '대기';
case '1': return '진행';
case '2': return '취소';
case '3': return '보류';
case '5': return '완료';
default: return '대기';
}
};
const getTodoSeqnoClass = (seqno) => {
switch(seqno) {
case '0': return 'bg-gray-500/20 text-gray-300';
case '1': return 'bg-success-500/20 text-success-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 getTodoSeqnoText = (seqno) => {
switch(seqno) {
case '0': return '낮음';
case '1': return '보통';
case '2': return '높음';
case '3': return '긴급';
default: return '보통';
}
};
const statusClass = getTodoStatusClass(todo.status);
const statusText = getTodoStatusText(todo.status);
const seqnoClass = getTodoSeqnoClass(todo.seqno);
const seqnoText = getTodoSeqnoText(todo.seqno);
const expireText = todo.expire ? new Date(todo.expire).toLocaleDateString('ko-KR') : '';
const isExpired = todo.expire && new Date(todo.expire) < new Date();
const expireClass = isExpired ? 'text-danger-400' : 'text-white/60';
// 만료일이 지난 경우 배경을 적색계통으로 강조
const expiredBgClass = isExpired ? 'bg-danger-600/30 border-danger-400/40 hover:bg-danger-600/40' : 'bg-white/10 hover:bg-white/15 border-white/20';
return (
<div key={index} className={`${expiredBgClass} backdrop-blur-sm rounded-lg p-3 transition-colors cursor-pointer border`}
onClick={() => alert('Todo 상세보기 기능은 준비 중입니다.')}>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-1">
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${statusClass}`}>
{statusText}
</span>
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${seqnoClass}`}>
{seqnoText}
</span>
</div>
{expireText && (
<span className={`text-xs ${expireClass}`}>
{expireText}
</span>
)}
</div>
<p className="text-white text-sm font-medium mb-1">
{flagIcon}{todo.title || '제목 없음'}
</p>
{todo.description && (
<p className="text-white/70 text-xs mb-2 line-clamp-2">
{todo.description}
</p>
)}
{todo.request && (
<p className="text-white/50 text-xs mt-2">요청자: {todo.request}</p>
)}
</div>
);
})
) : (
<div className="text-center text-white/50 py-8">
<svg className="w-8 h-8 mx-auto mb-2 opacity-50" 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>
급한 할일이 없습니다
</div>
)}
</div>
</div>
</div>
</div>
{/* 출근 대상자 모달 */}
<TableModal
isOpen={modals.presentUsers}
onClose={() => hideModal('presentUsers')}
title="금일 출근 대상자 목록"
headers={['사번', '이름', '공정', '직급', '상태', '이메일']}
data={modalData.presentUsers}
renderRow={(user, index) => (
<tr key={index} className="hover:bg-white/5">
<td className="px-6 py-4 text-white text-sm">{user.id || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{user.name || '이름 없음'}</td>
<td className="px-6 py-4 text-white text-sm">{user.gname || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{user.level || 'N/A'}</td>
<td className="px-6 py-4 text-success-400 text-sm">출근</td>
<td className="px-6 py-4 text-white/70 text-sm">{user.email || 'N/A'}</td>
</tr>
)}
/>
{/* 휴가 요청 모달 */}
<TableModal
isOpen={modals.holidayRequest}
onClose={() => hideModal('holidayRequest')}
title="휴가 신청 목록"
maxWidth="max-w-6xl"
headers={['사번', '이름', '항목', '일자', '요청일', '요청시간', '비고']}
data={modalData.holidayRequests}
renderRow={(request, index) => (
<tr key={index} className="hover:bg-white/5">
<td className="px-6 py-4 text-white text-sm">{request.uid || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{request.name || '이름 없음'}</td>
<td className="px-6 py-4 text-white text-sm">{request.cate || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">
{request.sdate && request.edate ? `${request.sdate} ~ ${request.edate}` : 'N/A'}
</td>
<td className="px-6 py-4 text-white text-sm">{request.holydays || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{request.holytimes || 'N/A'}</td>
<td className="px-6 py-4 text-white/70 text-sm">{request.HolyReason || request.remark || 'N/A'}</td>
</tr>
)}
/>
{/* 구매요청 NR 모달 */}
<TableModal
isOpen={modals.purchaseNR}
onClose={() => hideModal('purchaseNR')}
title="구매요청(NR) 목록"
maxWidth="max-w-7xl"
headers={['요청일', '공정', '품목', '규격', '단위', '수량', '단가', '금액']}
data={modalData.purchaseNR}
renderRow={(item, index) => (
<tr key={index} className="hover:bg-white/5">
<td className="px-6 py-4 text-white text-sm">{item.pdate || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{item.process || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{item.pumname || '품목 없음'}</td>
<td className="px-6 py-4 text-white text-sm">{item.pumscale || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{item.pumunit || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{item.pumqtyreq || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">
{item.pumprice ? `${Number(item.pumprice).toLocaleString()}` : 'N/A'}
</td>
<td className="px-6 py-4 text-danger-400 text-sm font-medium">
{item.pumamt ? `${Number(item.pumamt).toLocaleString()}` : 'N/A'}
</td>
</tr>
)}
/>
{/* 구매요청 CR 모달 */}
<TableModal
isOpen={modals.purchaseCR}
onClose={() => hideModal('purchaseCR')}
title="구매요청(CR) 목록"
maxWidth="max-w-7xl"
headers={['요청일', '공정', '품목', '규격', '단위', '수량', '단가', '금액']}
data={modalData.purchaseCR}
renderRow={(item, index) => (
<tr key={index} className="hover:bg-white/5">
<td className="px-6 py-4 text-white text-sm">{item.pdate || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{item.process || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{item.pumname || '품목 없음'}</td>
<td className="px-6 py-4 text-white text-sm">{item.pumscale || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{item.pumunit || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{item.pumqtyreq || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">
{item.pumprice ? `${Number(item.pumprice).toLocaleString()}` : 'N/A'}
</td>
<td className="px-6 py-4 text-purple-400 text-sm font-medium">
{item.pumamt ? `${Number(item.pumamt).toLocaleString()}` : 'N/A'}
</td>
</tr>
)}
/>
</div>
</div>
);
}