개인정보 관련 업데이트 진행
ot 정보는 타인이 못봄 휴가신청 관련건도 타인이 못봄
This commit is contained in:
@@ -18,7 +18,34 @@ namespace Project.OWIN
|
||||
{
|
||||
app.UseCors(Microsoft.Owin.Cors.CorsOptions.AllowAll);
|
||||
|
||||
// 정적 파일 서빙을 가장 먼저 설정 (다른 모든 미들웨어보다 우선)
|
||||
// 캐시 방지 미들웨어를 가장 먼저 설정 (정적 파일 서빙보다 우선)
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
var path = context.Request.Path.Value;
|
||||
|
||||
// 모든 정적 리소스에 대해 캐시 방지 헤더 설정
|
||||
if (path.EndsWith(".js") ||
|
||||
path.EndsWith(".css") ||
|
||||
path.EndsWith(".jsx") ||
|
||||
path.EndsWith(".tsx") ||
|
||||
path.EndsWith(".html") ||
|
||||
path.EndsWith(".htm"))
|
||||
{
|
||||
context.Response.Headers["Cache-Control"] = "no-cache, no-store, must-revalidate";
|
||||
context.Response.Headers["Pragma"] = "no-cache";
|
||||
context.Response.Headers["Expires"] = "0";
|
||||
}
|
||||
|
||||
// JSX/TSX 파일을 JavaScript로 처리
|
||||
if (path.EndsWith(".jsx") || path.EndsWith(".tsx"))
|
||||
{
|
||||
context.Response.ContentType = "application/javascript; charset=utf-8";
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
// 정적 파일 서빙 설정
|
||||
var staticFileOptions = new FileServerOptions
|
||||
{
|
||||
EnableDefaultFiles = true,
|
||||
@@ -28,31 +55,6 @@ namespace Project.OWIN
|
||||
};
|
||||
app.UseFileServer(staticFileOptions);
|
||||
|
||||
// 캐시 방지 미들웨어 (정적 파일이 처리되지 않은 경우에만 적용)
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
var path = context.Request.Path.Value;
|
||||
|
||||
if (path.EndsWith(".js") ||
|
||||
path.EndsWith(".css") ||
|
||||
path.EndsWith(".jsx") ||
|
||||
path.EndsWith(".tsx") ||
|
||||
path.EndsWith(".html"))
|
||||
{
|
||||
context.Response.Headers["Cache-Control"] = "no-cache, no-store, must-revalidate";
|
||||
context.Response.Headers["Pragma"] = "no-cache";
|
||||
context.Response.Headers["Expires"] = "0";
|
||||
}
|
||||
|
||||
// JSX/TSX 파일을 JavaScript로 처리
|
||||
if (path.EndsWith(".jsx") || path.EndsWith(".tsx"))
|
||||
{
|
||||
context.Response.ContentType = "application/javascript";
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
// Configure Web API for Self-Host (정적 파일 후에 설정)
|
||||
HttpConfiguration config = new HttpConfiguration();
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<meta name="version" content="v2.0-20250127">
|
||||
<title>근태현황 대시보드</title>
|
||||
<title>근태현황 대시보드*</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
@@ -218,105 +218,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 출근 대상자 모달 -->
|
||||
<div id="presentUserModal" class="fixed inset-0 bg-black/50 backdrop-blur-sm hidden z-50">
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="glass-effect rounded-2xl w-full max-w-4xl max-h-[80vh] overflow-hidden animate-slide-up">
|
||||
<!-- 모달 헤더 -->
|
||||
<div class="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-white flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
금일 출근 대상자 목록
|
||||
</h2>
|
||||
<button onclick="hidePresentUserModal()" class="text-white/70 hover:text-white transition-colors">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 모달 내용 -->
|
||||
<div class="overflow-x-auto max-h-[60vh] custom-scrollbar">
|
||||
<table class="w-full">
|
||||
<thead class="bg-white/10 sticky top-0">
|
||||
<tr>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">사번</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">이름</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">공정</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">직급</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">상태</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">이메일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="presentUserTable" class="divide-y divide-white/10">
|
||||
<!-- 데이터가 여기에 표시됩니다 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 모달 푸터 -->
|
||||
<div class="px-6 py-4 border-t border-white/10 flex justify-between items-center">
|
||||
<p class="text-white/70 text-sm">총 <span id="presentUserCount">0</span>명</p>
|
||||
<button onclick="hidePresentUserModal()" class="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 휴가요청 모달 -->
|
||||
<div id="holidayRequestModal" class="fixed inset-0 bg-black/50 backdrop-blur-sm hidden z-50">
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="glass-effect rounded-2xl w-full max-w-6xl max-h-[80vh] overflow-hidden animate-slide-up">
|
||||
<!-- 모달 헤더 -->
|
||||
<div class="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-white flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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>
|
||||
휴가 신청 목록
|
||||
</h2>
|
||||
<button onclick="hideHolidayRequestModal()" class="text-white/70 hover:text-white transition-colors">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 모달 내용 -->
|
||||
<div class="overflow-x-auto max-h-[60vh] custom-scrollbar">
|
||||
<table class="w-full">
|
||||
<thead class="bg-white/10 sticky top-0">
|
||||
<tr>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">사번</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">이름</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">항목</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">일자</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">요청일</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">요청시간</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="holidayRequestTable" class="divide-y divide-white/10">
|
||||
<!-- 데이터가 여기에 표시됩니다 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 모달 푸터 -->
|
||||
<div class="px-6 py-4 border-t border-white/10 flex justify-between items-center">
|
||||
<p class="text-white/70 text-sm">총 <span id="holidayRequestCount">0</span>건</p>
|
||||
<button onclick="hideHolidayRequestModal()" class="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 구매NR 모달 -->
|
||||
<div id="purchaseNRModal" class="fixed inset-0 bg-black/50 backdrop-blur-sm hidden z-50">
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
|
||||
@@ -1,559 +0,0 @@
|
||||
// CommonApp.jsx - React Common Code Management Component for GroupWare
|
||||
const { useState, useEffect, useRef } = React;
|
||||
|
||||
const CommonApp = () => {
|
||||
// 상태 관리
|
||||
const [groupData, setGroupData] = useState([]);
|
||||
const [currentData, setCurrentData] = useState([]);
|
||||
const [selectedGroupCode, setSelectedGroupCode] = useState(null);
|
||||
const [selectedGroupName, setSelectedGroupName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deleteTargetIdx, setDeleteTargetIdx] = useState(null);
|
||||
const [editMode, setEditMode] = useState('add');
|
||||
|
||||
// 편집 폼 데이터
|
||||
const [editData, setEditData] = useState({
|
||||
idx: '',
|
||||
grp: '',
|
||||
code: '',
|
||||
svalue: '',
|
||||
ivalue: '',
|
||||
fvalue: '',
|
||||
svalue2: '',
|
||||
memo: ''
|
||||
});
|
||||
|
||||
// 페이지 로드시 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
loadGroups();
|
||||
}, []);
|
||||
|
||||
// API 호출 함수들
|
||||
const loadGroups = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('http://127.0.0.1:7979/Common/GetGroups');
|
||||
const data = await response.json();
|
||||
setGroupData(data || []);
|
||||
} catch (error) {
|
||||
console.error('그룹 데이터 로드 중 오류 발생:', error);
|
||||
showNotification('그룹 데이터 로드 중 오류가 발생했습니다.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadDataByGroup = async (grp) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let url = 'http://127.0.0.1:7979/Common/GetList';
|
||||
if (grp) {
|
||||
url += '?grp=' + encodeURIComponent(grp);
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
setCurrentData(data || []);
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 중 오류 발생:', error);
|
||||
showNotification('데이터 로드 중 오류가 발생했습니다.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveData = async () => {
|
||||
try {
|
||||
const data = {
|
||||
idx: parseInt(editData.idx) || 0,
|
||||
grp: editData.grp,
|
||||
code: editData.code,
|
||||
svalue: editData.svalue,
|
||||
ivalue: parseInt(editData.ivalue) || 0,
|
||||
fvalue: parseFloat(editData.fvalue) || 0.0,
|
||||
svalue2: editData.svalue2,
|
||||
memo: editData.memo
|
||||
};
|
||||
|
||||
setLoading(true);
|
||||
const response = await fetch('http://127.0.0.1:7979/Common/Save', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.Success) {
|
||||
showNotification(result.Message, 'success');
|
||||
setShowEditModal(false);
|
||||
if (selectedGroupCode) {
|
||||
loadDataByGroup(selectedGroupCode);
|
||||
}
|
||||
} else {
|
||||
showNotification(result.Message, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 중 오류 발생:', error);
|
||||
showNotification('저장 중 오류가 발생했습니다.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItem = async () => {
|
||||
if (!deleteTargetIdx) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('http://127.0.0.1:7979/Common/Delete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ idx: deleteTargetIdx })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.Success) {
|
||||
showNotification(data.Message, 'success');
|
||||
setShowDeleteModal(false);
|
||||
if (selectedGroupCode) {
|
||||
loadDataByGroup(selectedGroupCode);
|
||||
}
|
||||
} else {
|
||||
showNotification(data.Message, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('삭제 중 오류 발생:', error);
|
||||
showNotification('삭제 중 오류가 발생했습니다.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setDeleteTargetIdx(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 이벤트 핸들러들
|
||||
const selectGroup = (code, name) => {
|
||||
setSelectedGroupCode(code);
|
||||
setSelectedGroupName(name);
|
||||
loadDataByGroup(code);
|
||||
};
|
||||
|
||||
const openAddModal = () => {
|
||||
if (!selectedGroupCode) {
|
||||
showNotification('먼저 코드그룹을 선택하세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
setEditMode('add');
|
||||
setEditData({
|
||||
idx: '',
|
||||
grp: selectedGroupCode,
|
||||
code: '',
|
||||
svalue: '',
|
||||
ivalue: '',
|
||||
fvalue: '',
|
||||
svalue2: '',
|
||||
memo: ''
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (item) => {
|
||||
setEditMode('edit');
|
||||
setEditData({
|
||||
idx: item.idx,
|
||||
grp: item.grp || '',
|
||||
code: item.code || '',
|
||||
svalue: item.svalue || '',
|
||||
ivalue: item.ivalue || '',
|
||||
fvalue: item.fvalue || '',
|
||||
svalue2: item.svalue2 || '',
|
||||
memo: item.memo || ''
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const openDeleteModal = (idx) => {
|
||||
setDeleteTargetIdx(idx);
|
||||
setShowDeleteModal(true);
|
||||
setShowEditModal(false);
|
||||
};
|
||||
|
||||
const closeModals = () => {
|
||||
setShowEditModal(false);
|
||||
setShowDeleteModal(false);
|
||||
setDeleteTargetIdx(null);
|
||||
};
|
||||
|
||||
const handleInputChange = (field, value) => {
|
||||
setEditData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const showNotification = (message, type = 'info') => {
|
||||
// 기존 알림 제거
|
||||
const existing = document.querySelectorAll('.notification-toast');
|
||||
existing.forEach(el => el.remove());
|
||||
|
||||
const colors = {
|
||||
info: 'bg-blue-500/90',
|
||||
success: 'bg-green-500/90',
|
||||
warning: 'bg-yellow-500/90',
|
||||
error: 'bg-red-500/90'
|
||||
};
|
||||
|
||||
const icons = {
|
||||
info: '🔵',
|
||||
success: '✅',
|
||||
warning: '⚠️',
|
||||
error: '❌'
|
||||
};
|
||||
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification-toast fixed top-4 right-4 ${colors[type]} backdrop-blur-sm text-white px-4 py-3 rounded-lg z-50 shadow-lg border border-white/20`;
|
||||
notification.innerHTML = `<div class="flex items-center"><span class="mr-2">${icons[type]}</span>${message}</div>`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
notification.style.opacity = '0';
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
// 키보드 이벤트
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeModals();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 min-h-screen text-white">
|
||||
{/* Navigation Component */}
|
||||
<CommonNavigation currentPage="common" />
|
||||
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* 2열 구조 메인 컨테이너 */}
|
||||
<div className="flex gap-6 h-[calc(100vh-200px)]">
|
||||
{/* 좌측: 코드그룹 리스트 */}
|
||||
<div className="w-80">
|
||||
<div className="glass-effect rounded-2xl h-full card-hover animate-slide-up flex flex-col">
|
||||
<div className="p-4 border-b border-white/10">
|
||||
<h3 className="text-lg 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="M19 11H5m14-7l-7 7-7-7M19 21l-7-7-7 7"></path>
|
||||
</svg>
|
||||
코드그룹 목록
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-2">
|
||||
<div className="space-y-1">
|
||||
{groupData.length === 0 ? (
|
||||
<div className="text-white/70 text-center py-4">그룹 데이터가 없습니다.</div>
|
||||
) : (
|
||||
groupData.map(group => (
|
||||
<div
|
||||
key={group.code}
|
||||
className={`cursor-pointer p-3 rounded-lg border border-white/20 hover:bg-white/10 transition-all ${
|
||||
selectedGroupCode === group.code ? 'bg-white/20' : ''
|
||||
}`}
|
||||
onClick={() => selectGroup(group.code, group.memo)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center mr-3">
|
||||
<span className="text-white text-sm font-medium">{group.code}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-white font-medium">{group.memo}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 상세 데이터 */}
|
||||
<div className="flex-1">
|
||||
<div className="glass-effect rounded-2xl h-full card-hover animate-slide-up flex flex-col">
|
||||
{/* 상단 헤더 */}
|
||||
<div className="p-4 border-b border-white/10 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg 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="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>
|
||||
<span>
|
||||
{selectedGroupCode ? `${selectedGroupCode} - ${selectedGroupName}` : '코드그룹을 선택하세요'}
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-white/70 text-sm mt-1">
|
||||
총 <span className="text-white font-medium">{currentData.length}</span>건
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
className="bg-white/20 hover:bg-white/30 backdrop-blur-sm text-white px-4 py-2 rounded-lg transition-all border border-white/30 flex items-center text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4 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>
|
||||
</div>
|
||||
|
||||
{/* 데이터 테이블 */}
|
||||
<div className="flex-1 overflow-x-auto overflow-y-auto custom-scrollbar">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/10 sticky top-0">
|
||||
<tr>
|
||||
<th className="w-24 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="w-32 px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">값(문자열)</th>
|
||||
<th className="w-20 px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">값(숫자)</th>
|
||||
<th className="w-20 px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">값(실수)</th>
|
||||
<th className="w-24 px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">값2</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{currentData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="6" className="px-4 py-8 text-center text-white/70">
|
||||
<svg className="w-12 h-12 mx-auto mb-2 text-white/30" 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>
|
||||
{selectedGroupCode ? '데이터가 없습니다.' : '좌측에서 코드그룹을 선택하세요'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
currentData.map(item => (
|
||||
<tr
|
||||
key={item.idx}
|
||||
className="hover:bg-white/5 transition-colors cursor-pointer"
|
||||
onClick={() => openEditModal(item)}
|
||||
>
|
||||
<td className="px-4 py-4 whitespace-nowrap text-sm text-white">{item.code || '-'}</td>
|
||||
<td className="px-4 py-4 text-sm text-white">{item.memo || '-'}</td>
|
||||
<td className="px-4 py-4 text-sm text-white max-w-32 truncate" title={item.svalue || '-'}>
|
||||
{item.svalue || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap text-sm text-white">{item.ivalue || '0'}</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap text-sm text-white">{item.fvalue || '0.0'}</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap text-sm text-white">{item.svalue2 || '-'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 로딩 인디케이터 */}
|
||||
{loading && (
|
||||
<div className="fixed top-4 right-4 bg-white/20 backdrop-blur-sm rounded-full px-4 py-2 text-white text-sm z-40">
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
데이터 로딩 중...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 추가/편집 모달 */}
|
||||
{showEditModal && (
|
||||
<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 max-w-2xl 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">
|
||||
{editMode === 'add' ? '공용코드 추가' : '공용코드 편집'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={closeModals}
|
||||
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="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">코드그룹 *</label>
|
||||
<select
|
||||
value={editData.grp}
|
||||
onChange={(e) => handleInputChange('grp', e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
>
|
||||
<option value="" className="bg-gray-800 text-white">선택하세요</option>
|
||||
{groupData.map(group => (
|
||||
<option key={group.code} value={group.code} className="bg-gray-800 text-white">
|
||||
{group.code}-{group.memo}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">코드 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.code}
|
||||
onChange={(e) => handleInputChange('code', e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
placeholder="코드를 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">값(문자열)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.svalue}
|
||||
onChange={(e) => handleInputChange('svalue', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
placeholder="문자열 값"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">값(숫자)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editData.ivalue}
|
||||
onChange={(e) => handleInputChange('ivalue', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
placeholder="숫자 값"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">값(실수)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={editData.fvalue}
|
||||
onChange={(e) => handleInputChange('fvalue', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
placeholder="실수 값"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">값2</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.svalue2}
|
||||
onChange={(e) => handleInputChange('svalue2', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
placeholder="추가 문자열 값"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">비고</label>
|
||||
<textarea
|
||||
value={editData.memo}
|
||||
onChange={(e) => handleInputChange('memo', e.target.value)}
|
||||
rows="3"
|
||||
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
placeholder="비고사항을 입력하세요"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모달 푸터 */}
|
||||
<div className="px-6 py-4 border-t border-white/10 flex justify-between">
|
||||
{editMode === 'edit' && (
|
||||
<button
|
||||
onClick={() => openDeleteModal(editData.idx)}
|
||||
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition-colors flex items-center"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<button
|
||||
onClick={closeModals}
|
||||
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={saveData}
|
||||
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
{showDeleteModal && (
|
||||
<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 max-w-md animate-slide-up">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="w-12 h-12 bg-red-100/20 rounded-full flex items-center justify-center mr-4">
|
||||
<svg className="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 18.5c-.77.833.192 2.5 1.732 2.5z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-white">삭제 확인</h3>
|
||||
<p className="text-sm text-white/70">이 작업은 되돌릴 수 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-white/80 mb-6">
|
||||
선택한 공용코드를 삭제하시겠습니까?<br/>
|
||||
<span className="text-sm text-white/60">이 작업은 되돌릴 수 없습니다.</span>
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={closeModals}
|
||||
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={deleteItem}
|
||||
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,590 +0,0 @@
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
function CommonCode() {
|
||||
// 상태 관리
|
||||
const [currentData, setCurrentData] = useState([]);
|
||||
const [groupData, setGroupData] = useState([]);
|
||||
const [selectedGroupCode, setSelectedGroupCode] = useState(null);
|
||||
const [selectedGroupName, setSelectedGroupName] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deleteTargetIdx, setDeleteTargetIdx] = useState(null);
|
||||
const [editData, setEditData] = useState({
|
||||
idx: '',
|
||||
grp: '',
|
||||
code: '',
|
||||
svalue: '',
|
||||
ivalue: '',
|
||||
fvalue: '',
|
||||
svalue2: '',
|
||||
memo: ''
|
||||
});
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
|
||||
// 컴포넌트 마운트 시 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
loadGroups();
|
||||
}, []);
|
||||
|
||||
// 코드그룹 목록 로드
|
||||
const loadGroups = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/Common/GetGroups');
|
||||
const data = await response.json();
|
||||
setGroupData(data || []);
|
||||
} catch (error) {
|
||||
console.error('그룹 데이터 로드 중 오류 발생:', error);
|
||||
showNotification('그룹 데이터 로드 중 오류가 발생했습니다.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 그룹 선택 처리
|
||||
const selectGroup = async (code, name) => {
|
||||
setSelectedGroupCode(code);
|
||||
setSelectedGroupName(name);
|
||||
await loadDataByGroup(code);
|
||||
};
|
||||
|
||||
// 특정 그룹의 데이터 로드
|
||||
const loadDataByGroup = async (grp) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
let url = '/Common/GetList';
|
||||
if (grp) {
|
||||
url += '?grp=' + encodeURIComponent(grp);
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
setCurrentData(data || []);
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 중 오류 발생:', error);
|
||||
showNotification('데이터 로드 중 오류가 발생했습니다.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 추가 모달 표시
|
||||
const showAddModal = () => {
|
||||
if (!selectedGroupCode) {
|
||||
showNotification('먼저 코드그룹을 선택하세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEditMode(false);
|
||||
setEditData({
|
||||
idx: '',
|
||||
grp: selectedGroupCode,
|
||||
code: '',
|
||||
svalue: '',
|
||||
ivalue: '',
|
||||
fvalue: '',
|
||||
svalue2: '',
|
||||
memo: ''
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
// 편집 모달 표시
|
||||
const editItem = (idx) => {
|
||||
const item = currentData.find(x => x.idx === idx);
|
||||
if (!item) return;
|
||||
|
||||
setIsEditMode(true);
|
||||
setEditData({
|
||||
idx: item.idx,
|
||||
grp: item.grp || '',
|
||||
code: item.code || '',
|
||||
svalue: item.svalue || '',
|
||||
ivalue: item.ivalue || '',
|
||||
fvalue: item.fvalue || '',
|
||||
svalue2: item.svalue2 || '',
|
||||
memo: item.memo || ''
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
// 데이터 저장
|
||||
const saveData = async () => {
|
||||
const form = document.getElementById('editForm');
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
idx: parseInt(editData.idx) || 0,
|
||||
grp: editData.grp,
|
||||
code: editData.code,
|
||||
svalue: editData.svalue,
|
||||
ivalue: parseInt(editData.ivalue) || 0,
|
||||
fvalue: parseFloat(editData.fvalue) || 0.0,
|
||||
svalue2: editData.svalue2,
|
||||
memo: editData.memo
|
||||
};
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/Common/Save', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.Success) {
|
||||
showNotification(result.Message, 'success');
|
||||
setShowEditModal(false);
|
||||
if (selectedGroupCode) {
|
||||
await loadDataByGroup(selectedGroupCode);
|
||||
}
|
||||
} else {
|
||||
showNotification(result.Message, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 중 오류 발생:', error);
|
||||
showNotification('저장 중 오류가 발생했습니다.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTargetIdx) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/Common/Delete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ idx: deleteTargetIdx })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.Success) {
|
||||
showNotification(data.Message, 'success');
|
||||
setShowDeleteModal(false);
|
||||
setDeleteTargetIdx(null);
|
||||
if (selectedGroupCode) {
|
||||
await loadDataByGroup(selectedGroupCode);
|
||||
}
|
||||
} else {
|
||||
showNotification(data.Message, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('삭제 중 오류 발생:', error);
|
||||
showNotification('삭제 중 오류가 발생했습니다.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 요청
|
||||
const deleteCurrentItem = () => {
|
||||
if (!editData.idx) {
|
||||
showNotification('삭제할 항목이 없습니다.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteTargetIdx(parseInt(editData.idx));
|
||||
setShowEditModal(false);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
// 알림 표시 함수
|
||||
const showNotification = (message, type = 'info') => {
|
||||
const colors = {
|
||||
info: 'bg-blue-500/90 backdrop-blur-sm',
|
||||
success: 'bg-green-500/90 backdrop-blur-sm',
|
||||
warning: 'bg-yellow-500/90 backdrop-blur-sm',
|
||||
error: 'bg-red-500/90 backdrop-blur-sm'
|
||||
};
|
||||
|
||||
const icons = {
|
||||
info: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
),
|
||||
success: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 18.5c-.77.833.192 2.5 1.732 2.5z"></path>
|
||||
</svg>
|
||||
),
|
||||
error: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
)
|
||||
};
|
||||
|
||||
// React에서는 실제 DOM 조작 대신 Toast 라이브러리나 상태로 관리하는 것이 좋습니다
|
||||
// 여기서는 기존 방식을 유지하되 React 컴포넌트 스타일로 구현
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `fixed top-4 right-4 ${colors[type]} text-white px-4 py-3 rounded-lg z-50 transition-all duration-300 transform translate-x-0 opacity-100 shadow-lg border border-white/20`;
|
||||
notification.innerHTML = `
|
||||
<div class="flex items-center">
|
||||
${type === 'info' ? '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>' : ''}
|
||||
${type === 'success' ? '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>' : ''}
|
||||
${type === 'warning' ? '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 18.5c-.77.833.192 2.5 1.732 2.5z"></path></svg>' : ''}
|
||||
${type === 'error' ? '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>' : ''}
|
||||
<span class="ml-2">${message}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
notification.style.opacity = '0';
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.transform = 'translateX(0)';
|
||||
notification.style.opacity = '1';
|
||||
}, 10);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
notification.style.opacity = '0';
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
// 키보드 이벤트 핸들러
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setShowEditModal(false);
|
||||
setShowDeleteModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// 입력 필드 변경 핸들러
|
||||
const handleInputChange = (field, value) => {
|
||||
setEditData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* 2열 구조 메인 컨테이너 */}
|
||||
<div className="flex gap-6 h-[calc(100vh-200px)]">
|
||||
{/* 좌측: 코드그룹 리스트 */}
|
||||
<div className="w-80">
|
||||
<div className="glass-effect rounded-2xl h-full card-hover animate-slide-up flex flex-col">
|
||||
<div className="p-4 border-b border-white/10">
|
||||
<h3 className="text-lg 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="M19 11H5m14-7l-7 7-7-7M19 21l-7-7-7 7"></path>
|
||||
</svg>
|
||||
코드그룹 목록
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-2">
|
||||
<div className="space-y-1">
|
||||
{groupData.length === 0 ? (
|
||||
<div className="text-white/70 text-center py-4">그룹 데이터가 없습니다.</div>
|
||||
) : (
|
||||
groupData.map(group => (
|
||||
<div
|
||||
key={group.code}
|
||||
className={`group-item cursor-pointer p-3 rounded-lg border border-white/20 hover:bg-white/10 transition-all ${
|
||||
selectedGroupCode === group.code ? 'bg-white/20' : ''
|
||||
}`}
|
||||
onClick={() => selectGroup(group.code, group.memo)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center mr-3">
|
||||
<span className="text-white text-sm font-medium">{group.code}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-white font-medium">{group.memo}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 상세 데이터 */}
|
||||
<div className="flex-1">
|
||||
<div className="glass-effect rounded-2xl h-full card-hover animate-slide-up flex flex-col">
|
||||
{/* 상단 헤더 */}
|
||||
<div className="p-4 border-b border-white/10 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg 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="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>
|
||||
<span>{selectedGroupCode ? `${selectedGroupCode} - ${selectedGroupName}` : '코드그룹을 선택하세요'}</span>
|
||||
</h3>
|
||||
<p className="text-white/70 text-sm mt-1">총 <span className="text-white font-medium">{currentData.length}</span>건</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={showAddModal}
|
||||
className="bg-white/20 hover:bg-white/30 backdrop-blur-sm text-white px-4 py-2 rounded-lg transition-all border border-white/30 flex items-center text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4 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>
|
||||
</div>
|
||||
|
||||
{/* 데이터 테이블 */}
|
||||
<div className="flex-1 overflow-x-auto overflow-y-auto custom-scrollbar">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/10 sticky top-0">
|
||||
<tr>
|
||||
<th className="w-24 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="w-32 px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">값(문자열)</th>
|
||||
<th className="w-20 px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">값(숫자)</th>
|
||||
<th className="w-20 px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">값(실수)</th>
|
||||
<th className="w-24 px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">값2</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{currentData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="6" className="px-4 py-8 text-center text-white/70">
|
||||
<svg className="w-12 h-12 mx-auto mb-2 text-white/30" 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>
|
||||
{selectedGroupCode ? '데이터가 없습니다.' : '좌측에서 코드그룹을 선택하세요'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
currentData.map(item => (
|
||||
<tr
|
||||
key={item.idx}
|
||||
className="hover:bg-white/5 transition-colors cursor-pointer"
|
||||
onClick={() => editItem(item.idx)}
|
||||
>
|
||||
<td className="px-4 py-4 whitespace-nowrap text-sm text-white">{item.code || '-'}</td>
|
||||
<td className="px-4 py-4 text-sm text-white">{item.memo || '-'}</td>
|
||||
<td className="px-4 py-4 text-sm text-white svalue-cell" title={item.svalue || '-'}>{item.svalue || '-'}</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap text-sm text-white">{item.ivalue || '0'}</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap text-sm text-white">{item.fvalue || '0.0'}</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap text-sm text-white">{item.svalue2 || '-'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 로딩 인디케이터 */}
|
||||
{isLoading && (
|
||||
<div className="fixed top-4 right-4 bg-white/20 backdrop-blur-sm rounded-full px-4 py-2 text-white text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
데이터 로딩 중...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가/편집 모달 */}
|
||||
{showEditModal && (
|
||||
<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 max-w-2xl 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">
|
||||
{isEditMode ? '공용코드 편집' : '공용코드 추가'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowEditModal(false)}
|
||||
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>
|
||||
|
||||
{/* 모달 내용 */}
|
||||
<form id="editForm" className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">코드그룹 *</label>
|
||||
<select
|
||||
value={editData.grp}
|
||||
onChange={(e) => handleInputChange('grp', e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
>
|
||||
<option value="" className="bg-gray-800 text-white">선택하세요</option>
|
||||
{groupData.map(group => (
|
||||
<option key={group.code} value={group.code} className="bg-gray-800 text-white">
|
||||
{group.code}-{group.memo}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">코드 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.code}
|
||||
onChange={(e) => handleInputChange('code', e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
placeholder="코드를 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">값(문자열)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.svalue}
|
||||
onChange={(e) => handleInputChange('svalue', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
placeholder="문자열 값"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">값(숫자)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editData.ivalue}
|
||||
onChange={(e) => handleInputChange('ivalue', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
placeholder="숫자 값"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">값(실수)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={editData.fvalue}
|
||||
onChange={(e) => handleInputChange('fvalue', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
placeholder="실수 값"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">값2</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.svalue2}
|
||||
onChange={(e) => handleInputChange('svalue2', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
placeholder="추가 문자열 값"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">비고</label>
|
||||
<textarea
|
||||
value={editData.memo}
|
||||
onChange={(e) => handleInputChange('memo', e.target.value)}
|
||||
rows="3"
|
||||
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
placeholder="비고사항을 입력하세요"
|
||||
></textarea>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* 모달 푸터 */}
|
||||
<div className="px-6 py-4 border-t border-white/10 flex justify-between">
|
||||
<button
|
||||
onClick={deleteCurrentItem}
|
||||
disabled={!isEditMode}
|
||||
className={`bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition-colors flex items-center ${
|
||||
!isEditMode ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
삭제
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowEditModal(false)}
|
||||
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={saveData}
|
||||
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
{showDeleteModal && (
|
||||
<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 max-w-md animate-slide-up">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mr-4">
|
||||
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 18.5c-.77.833.192 2.5 1.732 2.5z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">삭제 확인</h3>
|
||||
<p className="text-sm text-gray-500">이 작업은 되돌릴 수 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-700 mb-6">
|
||||
선택한 공용코드를 삭제하시겠습니까?<br />
|
||||
<span className="text-sm text-gray-500">이 작업은 되돌릴 수 없습니다.</span>
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => { setShowDeleteModal(false); setDeleteTargetIdx(null); }}
|
||||
className="bg-gray-300 hover:bg-gray-400 text-gray-700 px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
// CommonNavigation.jsx - React Navigation Component for GroupWare
|
||||
const CommonNavigation = ({ currentPage = 'dashboard' }) => {
|
||||
const [menuItems, setMenuItems] = useState([]);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState('사용자');
|
||||
|
||||
// 기본 메뉴 아이템 - React 경로 사용
|
||||
const defaultMenuItems = [
|
||||
{
|
||||
key: 'dashboard',
|
||||
title: '대시보드',
|
||||
url: '/react/dashboard',
|
||||
icon: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z M8 5a2 2 0 012-2h4a2 2 0 012 2v2H8V5z',
|
||||
isVisible: true,
|
||||
sortOrder: 1
|
||||
},
|
||||
{
|
||||
key: 'common',
|
||||
title: '공용코드',
|
||||
url: '/react/common',
|
||||
icon: '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',
|
||||
isVisible: true,
|
||||
sortOrder: 2
|
||||
},
|
||||
{
|
||||
key: 'jobreport',
|
||||
title: '업무일지',
|
||||
url: '/react/jobreport',
|
||||
icon: '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',
|
||||
isVisible: true,
|
||||
sortOrder: 3
|
||||
},
|
||||
{
|
||||
key: 'kuntae',
|
||||
title: '근태관리',
|
||||
url: '/react/kuntae',
|
||||
icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
isVisible: true,
|
||||
sortOrder: 4
|
||||
},
|
||||
{
|
||||
key: 'todo',
|
||||
title: '할일관리',
|
||||
url: '/react/todo',
|
||||
icon: '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 2M12 12l2 2 4-4',
|
||||
isVisible: true,
|
||||
sortOrder: 5
|
||||
},
|
||||
{
|
||||
key: 'project',
|
||||
title: '프로젝트',
|
||||
url: '/react/project',
|
||||
icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
|
||||
isVisible: true,
|
||||
sortOrder: 6
|
||||
},
|
||||
{
|
||||
key: 'purchase',
|
||||
title: '구매관리',
|
||||
url: '/Purchase/',
|
||||
icon: 'M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z',
|
||||
isVisible: true,
|
||||
sortOrder: 7
|
||||
},
|
||||
{
|
||||
key: 'customer',
|
||||
title: '고객관리',
|
||||
url: '/Customer/',
|
||||
icon: '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',
|
||||
isVisible: true,
|
||||
sortOrder: 8
|
||||
}
|
||||
];
|
||||
|
||||
// 메뉴 아이템 로드 - defaultMenuItems 사용 (React 경로)
|
||||
useEffect(() => {
|
||||
setMenuItems(defaultMenuItems);
|
||||
}, []);
|
||||
|
||||
// 보이는 메뉴 아이템만 정렬해서 반환
|
||||
const visibleItems = menuItems
|
||||
.filter(item => item.isVisible)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
|
||||
return (
|
||||
<nav className="glass-effect border-b border-white/10 relative z-40">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* 로고 및 브랜드 */}
|
||||
<div className="flex items-center space-x-8">
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z M8 5a2 2 0 012-2h4a2 2 0 012 2v2H8V5z"></path>
|
||||
</svg>
|
||||
<span className="text-xl font-bold text-white">GroupWare</span>
|
||||
</div>
|
||||
|
||||
{/* 데스크톱 메뉴 */}
|
||||
<nav className="hidden md:flex space-x-1">
|
||||
{visibleItems.map(item => (
|
||||
<a
|
||||
key={item.key}
|
||||
href={item.url}
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
currentPage === item.key
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d={item.icon}></path>
|
||||
</svg>
|
||||
{item.title}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* 우측 메뉴 */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-sm text-white/60">
|
||||
<span>{currentUser}</span>
|
||||
</div>
|
||||
|
||||
{/* 모바일 메뉴 버튼 */}
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="md:hidden text-white/60 hover:text-white focus:outline-none focus:text-white"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{mobileMenuOpen ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모바일 메뉴 */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden border-t border-white/10 py-2">
|
||||
{visibleItems.map(item => (
|
||||
<a
|
||||
key={item.key}
|
||||
href={item.url}
|
||||
className={`block px-3 py-2 text-sm font-medium transition-colors ${
|
||||
currentPage === item.key
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/10'
|
||||
}`}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<svg className="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d={item.icon}></path>
|
||||
</svg>
|
||||
{item.title}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -1,669 +0,0 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// 개발중 경고 메시지 컴포넌트
|
||||
function DevWarning({ show = false }) {
|
||||
// show props가 false이거나 없으면 아무것도 렌더링하지 않음
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dev-warning animate-slide-up">
|
||||
<div className="flex items-center">
|
||||
<svg className="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="title">🚧 개발중인 기능입니다</p>
|
||||
<p className="description">일부 기능이 정상적으로 동작하지 않을 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,988 +0,0 @@
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
function JobReport() {
|
||||
// 상태 관리
|
||||
const [jobData, setJobData] = useState([]);
|
||||
const [filteredData, setFilteredData] = useState([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [sortColumn, setSortColumn] = useState('pdate');
|
||||
const [sortDirection, setSortDirection] = useState('desc');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editData, setEditData] = useState({});
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
|
||||
// 필터 상태
|
||||
const [filters, setFilters] = useState({
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
status: '',
|
||||
type: '',
|
||||
user: '',
|
||||
project: '',
|
||||
search: ''
|
||||
});
|
||||
|
||||
// 통계 데이터
|
||||
const [statistics, setStatistics] = useState({
|
||||
totalDays: 0,
|
||||
todayHours: 0,
|
||||
todayProgress: 0,
|
||||
totalOT: 0,
|
||||
activeProjects: 0
|
||||
});
|
||||
|
||||
// 컴포넌트 마운트시 초기화
|
||||
useEffect(() => {
|
||||
initializeFilters();
|
||||
loadJobData();
|
||||
loadUserList();
|
||||
}, []);
|
||||
|
||||
// 필터 변경 시 데이터 필터링
|
||||
useEffect(() => {
|
||||
filterData();
|
||||
}, [jobData, filters]);
|
||||
|
||||
// 초기 필터 설정 (오늘부터 -2주)
|
||||
const initializeFilters = () => {
|
||||
const now = new Date();
|
||||
const today = now.toISOString().split('T')[0];
|
||||
const twoWeeksAgo = new Date(now.getTime() - (14 * 24 * 60 * 60 * 1000)).toISOString().split('T')[0];
|
||||
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
startDate: twoWeeksAgo,
|
||||
endDate: today
|
||||
}));
|
||||
};
|
||||
|
||||
// 업무일지 데이터 로드
|
||||
const loadJobData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
let url = '/Jobreport/GetJobData';
|
||||
const params = new URLSearchParams();
|
||||
if (filters.startDate) params.append('startDate', filters.startDate);
|
||||
if (filters.endDate) params.append('endDate', filters.endDate);
|
||||
if (filters.user) params.append('user', filters.user);
|
||||
|
||||
if (params.toString()) {
|
||||
url += '?' + params.toString();
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: 데이터를 불러오는데 실패했습니다.`);
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
const responseData = JSON.parse(responseText);
|
||||
|
||||
if (Array.isArray(responseData)) {
|
||||
setJobData(responseData);
|
||||
} else if (responseData.error) {
|
||||
throw new Error(responseData.error);
|
||||
} else {
|
||||
setJobData([]);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading job data:', error);
|
||||
setJobData([]);
|
||||
showNotification('데이터를 불러오는데 실패했습니다: ' + error.message, 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 사용자 목록 로드
|
||||
const loadUserList = async () => {
|
||||
try {
|
||||
const response = await fetch('/Jobreport/GetUsers');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// 사용자 데이터를 상태로 저장 (필요시)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('사용자 목록 로드 중 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 통계 업데이트
|
||||
useEffect(() => {
|
||||
updateStatistics();
|
||||
}, [jobData]);
|
||||
|
||||
const updateStatistics = () => {
|
||||
const totalDays = new Set(jobData.map(item => item.pdate)).size;
|
||||
const totalOT = jobData.reduce((sum, item) => sum + (parseFloat(item.ot) || 0), 0);
|
||||
const activeProjects = new Set(jobData.filter(item => item.status === '진행중').map(item => item.projectName)).size;
|
||||
|
||||
const now = new Date();
|
||||
const today = now.toISOString().split('T')[0];
|
||||
|
||||
const todayData = jobData.filter(item => {
|
||||
if (!item.pdate) return false;
|
||||
const itemDate = item.pdate.toString();
|
||||
if (itemDate.length >= 10) {
|
||||
return itemDate.substring(0, 10) === today;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
let todayHours = 0;
|
||||
if (todayData.length > 0) {
|
||||
todayHours = todayData.reduce((sum, item) => sum + (parseFloat(item.hrs) || 0), 0);
|
||||
}
|
||||
const todayProgress = (todayHours / 8) * 100;
|
||||
|
||||
setStatistics({
|
||||
totalDays,
|
||||
todayHours,
|
||||
todayProgress,
|
||||
totalOT,
|
||||
activeProjects
|
||||
});
|
||||
};
|
||||
|
||||
// 데이터 필터링
|
||||
const filterData = () => {
|
||||
let filtered = jobData.filter(item => {
|
||||
const statusMatch = !filters.status || item.status === filters.status;
|
||||
const typeMatch = !filters.type || item.type === filters.type;
|
||||
const projectMatch = !filters.project || item.projectName === filters.project;
|
||||
const searchMatch = !filters.search ||
|
||||
(item.description && item.description.toLowerCase().includes(filters.search.toLowerCase())) ||
|
||||
(item.projectName && item.projectName.toLowerCase().includes(filters.search.toLowerCase())) ||
|
||||
(item.requestpart && item.requestpart.toLowerCase().includes(filters.search.toLowerCase()));
|
||||
|
||||
return statusMatch && typeMatch && projectMatch && searchMatch;
|
||||
});
|
||||
|
||||
// 정렬
|
||||
filtered.sort((a, b) => {
|
||||
let aVal = a[sortColumn];
|
||||
let bVal = b[sortColumn];
|
||||
|
||||
if (sortColumn === 'pdate') {
|
||||
aVal = new Date(aVal);
|
||||
bVal = new Date(bVal);
|
||||
} else if (['hrs', 'ot'].includes(sortColumn)) {
|
||||
aVal = parseFloat(aVal) || 0;
|
||||
bVal = parseFloat(bVal) || 0;
|
||||
} else {
|
||||
aVal = (aVal || '').toString().toLowerCase();
|
||||
bVal = (bVal || '').toString().toLowerCase();
|
||||
}
|
||||
|
||||
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
setFilteredData(filtered);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// 필터 변경 핸들러
|
||||
const handleFilterChange = (field, value) => {
|
||||
setFilters(prev => ({ ...prev, [field]: value }));
|
||||
|
||||
// 날짜 필터 변경시 데이터 다시 로드
|
||||
if (field === 'startDate' || field === 'endDate' || field === 'user') {
|
||||
setTimeout(() => loadJobData(), 100);
|
||||
}
|
||||
};
|
||||
|
||||
// 필터 초기화
|
||||
const clearFilters = () => {
|
||||
const now = new Date();
|
||||
const today = now.toISOString().split('T')[0];
|
||||
const twoWeeksAgo = new Date(now.getTime() - (14 * 24 * 60 * 60 * 1000)).toISOString().split('T')[0];
|
||||
|
||||
setFilters({
|
||||
startDate: twoWeeksAgo,
|
||||
endDate: today,
|
||||
status: '',
|
||||
type: '',
|
||||
user: '',
|
||||
project: '',
|
||||
search: ''
|
||||
});
|
||||
|
||||
setTimeout(() => loadJobData(), 100);
|
||||
};
|
||||
|
||||
// 정렬 처리
|
||||
const handleSort = (column) => {
|
||||
if (sortColumn === column) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortColumn(column);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
// 추가 모달 표시
|
||||
const showAddJobModal = () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
setIsEditMode(false);
|
||||
setEditData({
|
||||
idx: '',
|
||||
pdate: today,
|
||||
status: '진행 중',
|
||||
projectName: '',
|
||||
requestpart: '',
|
||||
type: '',
|
||||
hrs: '8',
|
||||
ot: '',
|
||||
otStart: '',
|
||||
otEnd: '',
|
||||
description: ''
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
// 편집 모달 표시
|
||||
const showEditJobModal = async (item) => {
|
||||
try {
|
||||
// 상세 정보 로드
|
||||
const response = await fetch(`/Jobreport/GetJobDetail?id=${item.idx}`);
|
||||
if (response.ok) {
|
||||
const fullItem = await response.json();
|
||||
item = fullItem.error ? item : fullItem;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load full details, using truncated data:', error);
|
||||
}
|
||||
|
||||
setIsEditMode(true);
|
||||
setEditData({
|
||||
idx: item.idx || '',
|
||||
pdate: item.pdate || '',
|
||||
status: item.status || '',
|
||||
projectName: item.projectName || '',
|
||||
requestpart: item.requestpart || '',
|
||||
type: item.type || '',
|
||||
hrs: item.hrs || '',
|
||||
ot: item.ot || '',
|
||||
otStart: item.otStart || '',
|
||||
otEnd: item.otEnd || '',
|
||||
description: item.description || ''
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
// 저장 처리
|
||||
const handleSave = async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = new URLSearchParams();
|
||||
Object.keys(editData).forEach(key => {
|
||||
if (key !== 'idx' || editData.idx) {
|
||||
formData.append(key, editData[key]);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const url = isEditMode ? '/Jobreport/Edit' : '/Jobreport/Add';
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${isEditMode ? '수정' : '추가'}에 실패했습니다.`);
|
||||
}
|
||||
|
||||
setShowEditModal(false);
|
||||
await loadJobData();
|
||||
showNotification(`업무일지가 성공적으로 ${isEditMode ? '수정' : '추가'}되었습니다.`, 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving job:', error);
|
||||
showNotification(`업무일지 ${isEditMode ? '수정' : '추가'} 중 오류가 발생했습니다: ` + error.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 처리
|
||||
const handleDelete = async () => {
|
||||
if (!editData.idx) {
|
||||
showNotification('삭제할 수 없는 항목입니다.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm('정말로 이 업무일지를 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/Jobreport/Delete/${editData.idx}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setShowEditModal(false);
|
||||
await loadJobData();
|
||||
showNotification('업무일지가 성공적으로 삭제되었습니다.', 'success');
|
||||
} else {
|
||||
throw new Error(result.message || '삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting job:', error);
|
||||
showNotification('업무일지 삭제 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 엑셀 내보내기
|
||||
const exportToExcel = () => {
|
||||
if (filteredData.length === 0) {
|
||||
showNotification('내보낼 데이터가 없습니다.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const periodText = filters.startDate && filters.endDate ? `_${filters.startDate}_${filters.endDate}` : '';
|
||||
const headers = ['날짜', '상태', '프로젝트명', '요청부서', '타입', '업무내용', '근무시간', '초과근무'];
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...filteredData.map(item => [
|
||||
formatDate(item.pdate),
|
||||
item.status || '',
|
||||
item.projectName || '',
|
||||
item.requestpart || '',
|
||||
item.type || '',
|
||||
`"${(item.description || '').replace(/"/g, '""')}"`,
|
||||
item.hrs || '',
|
||||
item.ot || ''
|
||||
].join(','))
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `업무일지${periodText}_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
// 유틸리티 함수들
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ko-KR');
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case '진행 중': return 'bg-blue-100 text-blue-800';
|
||||
case '진행 완료': return 'bg-green-100 text-green-800';
|
||||
case '대기': return 'bg-yellow-100 text-yellow-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
// 알림 표시 함수
|
||||
const showNotification = (message, type = 'info') => {
|
||||
const colors = {
|
||||
info: 'bg-blue-500/90 backdrop-blur-sm',
|
||||
success: 'bg-green-500/90 backdrop-blur-sm',
|
||||
warning: 'bg-yellow-500/90 backdrop-blur-sm',
|
||||
error: 'bg-red-500/90 backdrop-blur-sm'
|
||||
};
|
||||
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `fixed top-4 right-4 ${colors[type]} text-white px-4 py-3 rounded-lg z-50 transition-all duration-300 transform translate-x-0 opacity-100 shadow-lg border border-white/20`;
|
||||
notification.innerHTML = `
|
||||
<div class="flex items-center">
|
||||
<span class="ml-2">${message}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
notification.style.opacity = '0';
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.transform = 'translateX(0)';
|
||||
notification.style.opacity = '1';
|
||||
}, 10);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
notification.style.opacity = '0';
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
// 페이지네이션
|
||||
const maxPage = Math.ceil(filteredData.length / pageSize);
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const pageData = filteredData.slice(startIndex, endIndex);
|
||||
|
||||
// 프로젝트 목록 업데이트
|
||||
const uniqueProjects = [...new Set(jobData.map(item => item.projectName).filter(Boolean))];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* 개발중 경고 메시지 */}
|
||||
<div className="bg-orange-500 rounded-lg p-4 mb-6 border-l-4 border-orange-700 animate-slide-up shadow-lg">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 text-orange-900 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-white font-bold text-base">🚧 개발중인 기능입니다</p>
|
||||
<p className="text-orange-100 text-sm font-medium">일부 기능이 정상적으로 동작하지 않을 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8 animate-slide-up">
|
||||
<div className="glass-effect rounded-lg p-6 card-hover">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 bg-primary-500/20 rounded-lg">
|
||||
<svg className="w-6 h-6 text-white" 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>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-white/80">총 업무일수</p>
|
||||
<p className="text-2xl font-bold text-white">{statistics.totalDays}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="glass-effect rounded-lg p-6 card-hover">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 bg-success-500/20 rounded-lg">
|
||||
<svg className="w-6 h-6 text-white" 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>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-white/80">오늘 근무시간</p>
|
||||
<p className={`text-2xl font-bold ${statistics.todayHours < 8 ? 'text-red-300' : 'text-green-300'}`}>
|
||||
{statistics.todayHours.toFixed(1)}h
|
||||
</p>
|
||||
<p className="text-sm text-white/60">(목표 8시간의 {statistics.todayProgress.toFixed(0)}%)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="glass-effect rounded-lg p-6 card-hover">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 bg-warning-500/20 rounded-lg">
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-white/80">총 초과근무</p>
|
||||
<p className="text-2xl font-bold text-white">{statistics.totalOT.toFixed(1)}h</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="glass-effect rounded-lg p-6 card-hover">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 bg-purple-500/20 rounded-lg">
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-white/80">진행중 프로젝트</p>
|
||||
<p className="text-2xl font-bold text-white">{statistics.activeProjects}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 및 검색 */}
|
||||
<div className="glass-effect rounded-lg mb-6 animate-slide-up">
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* 좌측: 필터 컨트롤 */}
|
||||
<div className="lg:col-span-2 space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/80 mb-1">조회기간</label>
|
||||
<div className="flex space-x-1">
|
||||
<input
|
||||
type="date"
|
||||
value={filters.startDate}
|
||||
onChange={(e) => handleFilterChange('startDate', e.target.value)}
|
||||
className="flex-1 bg-white/20 border border-white/30 rounded-md px-2 py-1 text-white placeholder-white/60 focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent text-xs"
|
||||
/>
|
||||
<span className="flex items-center text-white/60 text-xs">~</span>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.endDate}
|
||||
onChange={(e) => handleFilterChange('endDate', e.target.value)}
|
||||
className="flex-1 bg-white/20 border border-white/30 rounded-md px-2 py-1 text-white placeholder-white/60 focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/80 mb-1">상태</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => handleFilterChange('status', e.target.value)}
|
||||
className="w-full bg-white/20 border border-white/30 rounded-md px-2 py-1 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent text-xs"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="진행중">진행중</option>
|
||||
<option value="완료">완료</option>
|
||||
<option value="대기">대기</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/80 mb-1">타입</label>
|
||||
<select
|
||||
value={filters.type}
|
||||
onChange={(e) => handleFilterChange('type', e.target.value)}
|
||||
className="w-full bg-white/20 border border-white/30 rounded-md px-2 py-1 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent text-xs"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="개발">개발</option>
|
||||
<option value="유지보수">유지보수</option>
|
||||
<option value="분석">분석</option>
|
||||
<option value="테스트">테스트</option>
|
||||
<option value="문서작업">문서작업</option>
|
||||
<option value="회의">회의</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/80 mb-1">프로젝트</label>
|
||||
<select
|
||||
value={filters.project}
|
||||
onChange={(e) => handleFilterChange('project', e.target.value)}
|
||||
className="w-full bg-white/20 border border-white/30 rounded-md px-2 py-1 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent text-xs"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{uniqueProjects.map(project => (
|
||||
<option key={project} value={project}>{project}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/80 mb-1">검색</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||
placeholder="업무 내용 검색..."
|
||||
className="w-full bg-white/20 border border-white/30 rounded-md px-2 py-1 text-white placeholder-white/60 focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 액션 버튼들 */}
|
||||
<div className="flex flex-col space-y-2 justify-center">
|
||||
<button
|
||||
onClick={showAddJobModal}
|
||||
className="bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-md flex items-center justify-center transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" 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
|
||||
onClick={exportToExcel}
|
||||
className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-md flex items-center justify-center transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
|
||||
엑셀 다운로드
|
||||
</button>
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-md flex items-center justify-center transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 테이블 */}
|
||||
<div className="glass-effect rounded-lg overflow-hidden animate-slide-up custom-scrollbar">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-white/20">
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider cursor-pointer hover:bg-white/20 transition-colors"
|
||||
onClick={() => handleSort('pdate')}
|
||||
>
|
||||
날짜 <svg className="w-4 h-4 inline ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider cursor-pointer hover:bg-white/20 transition-colors"
|
||||
onClick={() => handleSort('status')}
|
||||
>
|
||||
상태 <svg className="w-4 h-4 inline ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider cursor-pointer hover:bg-white/20 transition-colors"
|
||||
onClick={() => handleSort('hrs')}
|
||||
>
|
||||
근무시간 <svg className="w-4 h-4 inline ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider cursor-pointer hover:bg-white/20 transition-colors"
|
||||
onClick={() => handleSort('projectName')}
|
||||
>
|
||||
프로젝트명 <svg className="w-4 h-4 inline ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider">업무내용</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider cursor-pointer hover:bg-white/20 transition-colors"
|
||||
onClick={() => handleSort('requestpart')}
|
||||
>
|
||||
요청부서 <svg className="w-4 h-4 inline ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider cursor-pointer hover:bg-white/20 transition-colors"
|
||||
onClick={() => handleSort('type')}
|
||||
>
|
||||
타입 <svg className="w-4 h-4 inline ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan="7" className="p-8 text-center">
|
||||
<div className="inline-flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span className="text-white/80">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : pageData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="7" className="p-8 text-center">
|
||||
<svg className="w-12 h-12 text-white/60 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||
</svg>
|
||||
<p className="text-white/70">업무일지 데이터가 없습니다.</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
pageData.map((item, index) => {
|
||||
const hrs = parseFloat(item.hrs) || 0;
|
||||
const ot = parseFloat(item.ot) || 0;
|
||||
let workTimeDisplay = '';
|
||||
|
||||
if (hrs > 0) {
|
||||
workTimeDisplay = hrs.toFixed(1);
|
||||
if (ot > 0) {
|
||||
workTimeDisplay += '+' + ot.toFixed(1);
|
||||
}
|
||||
} else {
|
||||
workTimeDisplay = '-';
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={item.idx || index}
|
||||
className="hover:bg-white/10 cursor-pointer transition-colors"
|
||||
onClick={() => showEditJobModal(item)}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
|
||||
{formatDate(item.pdate)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(item.status)}`}>
|
||||
{item.status || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
|
||||
{workTimeDisplay}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
|
||||
<div className="flex items-center">
|
||||
<span>{item.projectName || '-'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-white">
|
||||
<div className="max-w-xs truncate" title={item.description || ''}>
|
||||
{item.description || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
|
||||
{item.requestpart || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
|
||||
{item.type || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
<div className="mt-6 flex items-center justify-between glass-effect rounded-lg p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-white/80">페이지당 행 수:</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
setPageSize(parseInt(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="bg-white/20 border border-white/30 rounded-md px-2 py-1 text-sm text-white"
|
||||
>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage <= 1}
|
||||
className="px-3 py-1 border border-white/30 rounded-md text-sm text-white hover:bg-white/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<span className="text-sm text-white/80">{currentPage} / {maxPage}</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.min(maxPage, currentPage + 1))}
|
||||
disabled={currentPage >= maxPage}
|
||||
className="px-3 py-1 border border-white/30 rounded-md text-sm text-white hover:bg-white/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 편집 모달 */}
|
||||
{showEditModal && (
|
||||
<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 max-w-6xl 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
업무일지 {isEditMode ? '편집' : '추가'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowEditModal(false)}
|
||||
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>
|
||||
|
||||
{/* 모달 내용 */}
|
||||
<form onSubmit={handleSave} className="p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8">
|
||||
{/* 좌측: 기본 정보 */}
|
||||
<div className="lg:col-span-2">
|
||||
<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="date"
|
||||
value={editData.pdate || ''}
|
||||
onChange={(e) => setEditData(prev => ({...prev, pdate: e.target.value}))}
|
||||
required
|
||||
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>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">상태 *</label>
|
||||
<select
|
||||
value={editData.status || ''}
|
||||
onChange={(e) => setEditData(prev => ({...prev, status: e.target.value}))}
|
||||
required
|
||||
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="">선택하세요</option>
|
||||
<option value="진행 중">진행 중</option>
|
||||
<option value="완료">완료</option>
|
||||
<option value="대기">대기</option>
|
||||
<option value="보류">보류</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">요청부서</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.requestpart || ''}
|
||||
onChange={(e) => setEditData(prev => ({...prev, requestpart: 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">타입</label>
|
||||
<select
|
||||
value={editData.type || ''}
|
||||
onChange={(e) => setEditData(prev => ({...prev, type: 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"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
<option value="개발">개발</option>
|
||||
<option value="유지보수">유지보수</option>
|
||||
<option value="분석">분석</option>
|
||||
<option value="테스트">테스트</option>
|
||||
<option value="문서작업">문서작업</option>
|
||||
<option value="회의">회의</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">근무시간 (시간) *</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={editData.hrs || ''}
|
||||
onChange={(e) => setEditData(prev => ({...prev, hrs: e.target.value}))}
|
||||
required
|
||||
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>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">초과근무 (시간)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={editData.ot || ''}
|
||||
onChange={(e) => setEditData(prev => ({...prev, ot: 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>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">초과근무 시작시간</label>
|
||||
<input
|
||||
type="time"
|
||||
value={editData.otStart || ''}
|
||||
onChange={(e) => setEditData(prev => ({...prev, otStart: 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>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">초과근무 종료시간</label>
|
||||
<input
|
||||
type="time"
|
||||
value={editData.otEnd || ''}
|
||||
onChange={(e) => setEditData(prev => ({...prev, otEnd: 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>
|
||||
|
||||
{/* 우측: 프로젝트명과 업무내용 */}
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">프로젝트명 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.projectName || ''}
|
||||
onChange={(e) => setEditData(prev => ({...prev, projectName: e.target.value}))}
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">업무내용 *</label>
|
||||
<textarea
|
||||
value={editData.description || ''}
|
||||
onChange={(e) => setEditData(prev => ({...prev, description: e.target.value}))}
|
||||
rows="15"
|
||||
required
|
||||
className="w-full h-full min-h-[360px] 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 resize-vertical"
|
||||
placeholder="상세한 업무 내용을 입력하세요..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* 모달 푸터 */}
|
||||
<div className="px-6 py-4 border-t border-white/10 flex justify-between items-center bg-black/10 rounded-b-2xl">
|
||||
{isEditMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className="bg-danger-500 hover:bg-danger-600 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
<div className="flex space-x-3 ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEditModal(false)}
|
||||
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
onClick={handleSave}
|
||||
className="bg-primary-500 hover:bg-primary-600 text-white px-6 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
function Kuntae() {
|
||||
// 상태 관리
|
||||
const [kuntaeData, setKuntaeData] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [filters, setFilters] = useState({
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
});
|
||||
|
||||
// 통계 데이터
|
||||
const [statistics, setStatistics] = useState({
|
||||
totalDays: 0,
|
||||
approvedDays: 0,
|
||||
pendingDays: 0,
|
||||
rejectedDays: 0
|
||||
});
|
||||
|
||||
// 컴포넌트 마운트시 초기화
|
||||
useEffect(() => {
|
||||
initializeFilters();
|
||||
}, []);
|
||||
|
||||
// 초기 필터 설정 (이번 달)
|
||||
const initializeFilters = () => {
|
||||
const now = new Date();
|
||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
setFilters({
|
||||
startDate: firstDay.toISOString().split('T')[0],
|
||||
endDate: lastDay.toISOString().split('T')[0]
|
||||
});
|
||||
|
||||
// 초기 데이터 로드
|
||||
loadKuntaeData({
|
||||
startDate: firstDay.toISOString().split('T')[0],
|
||||
endDate: lastDay.toISOString().split('T')[0]
|
||||
});
|
||||
};
|
||||
|
||||
// 근태 데이터 로드
|
||||
const loadKuntaeData = async (searchFilters = filters) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
let url = '/Kuntae/GetData';
|
||||
const params = new URLSearchParams();
|
||||
if (searchFilters.startDate) params.append('startDate', searchFilters.startDate);
|
||||
if (searchFilters.endDate) params.append('endDate', searchFilters.endDate);
|
||||
|
||||
if (params.toString()) {
|
||||
url += '?' + params.toString();
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: 데이터를 불러오는데 실패했습니다.`);
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
if (Array.isArray(responseData)) {
|
||||
setKuntaeData(responseData);
|
||||
updateStatistics(responseData);
|
||||
} else if (responseData.error) {
|
||||
throw new Error(responseData.error);
|
||||
} else {
|
||||
setKuntaeData([]);
|
||||
updateStatistics([]);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading kuntae data:', error);
|
||||
setKuntaeData([]);
|
||||
updateStatistics([]);
|
||||
showNotification('데이터를 불러오는데 실패했습니다: ' + error.message, 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 통계 업데이트
|
||||
const updateStatistics = (data) => {
|
||||
const totalDays = data.length;
|
||||
const approvedDays = data.filter(item => item.status === '승인').length;
|
||||
const pendingDays = data.filter(item => item.status === '대기').length;
|
||||
const rejectedDays = data.filter(item => item.status === '반려').length;
|
||||
|
||||
setStatistics({
|
||||
totalDays,
|
||||
approvedDays,
|
||||
pendingDays,
|
||||
rejectedDays
|
||||
});
|
||||
};
|
||||
|
||||
// 필터 변경 핸들러
|
||||
const handleFilterChange = (field, value) => {
|
||||
setFilters(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// 검색 실행
|
||||
const handleSearch = () => {
|
||||
loadKuntaeData();
|
||||
};
|
||||
|
||||
// 유틸리티 함수들
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ko-KR');
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case '승인': return 'bg-green-100 text-green-800';
|
||||
case '대기': return 'bg-yellow-100 text-yellow-800';
|
||||
case '반려': return 'bg-red-100 text-red-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
// 알림 표시 함수
|
||||
const showNotification = (message, type = 'info') => {
|
||||
const colors = {
|
||||
info: 'bg-blue-500/90 backdrop-blur-sm',
|
||||
success: 'bg-green-500/90 backdrop-blur-sm',
|
||||
warning: 'bg-yellow-500/90 backdrop-blur-sm',
|
||||
error: 'bg-red-500/90 backdrop-blur-sm'
|
||||
};
|
||||
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `fixed top-4 right-4 ${colors[type]} text-white px-4 py-3 rounded-lg z-50 transition-all duration-300 transform translate-x-0 opacity-100 shadow-lg border border-white/20`;
|
||||
notification.innerHTML = `
|
||||
<div class="flex items-center">
|
||||
<span class="ml-2">${message}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
notification.style.opacity = '0';
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.transform = 'translateX(0)';
|
||||
notification.style.opacity = '1';
|
||||
}, 10);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
notification.style.opacity = '0';
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* 개발중 경고 메시지 */}
|
||||
<div className="bg-orange-500 rounded-lg p-4 mb-6 border-l-4 border-orange-700 animate-slide-up shadow-lg">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 text-orange-900 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-white font-bold text-base">🚧 개발중인 기능입니다</p>
|
||||
<p className="text-orange-100 text-sm font-medium">일부 기능이 정상적으로 동작하지 않을 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 필터 섹션 */}
|
||||
<div className="glass-effect rounded-lg p-6 mb-6 animate-slide-up">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">시작일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.startDate}
|
||||
onChange={(e) => handleFilterChange('startDate', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/20 border border-white/30 rounded-md text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">종료일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.endDate}
|
||||
onChange={(e) => handleFilterChange('endDate', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/20 border border-white/30 rounded-md text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={isLoading}
|
||||
className="w-full glass-effect text-white px-4 py-2 rounded-md hover:bg-white/30 transition-colors duration-200 flex items-center justify-center disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="loading inline-block w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"></div>
|
||||
로딩중...
|
||||
</>
|
||||
) : (
|
||||
'조회'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6 animate-slide-up">
|
||||
<div className="glass-effect rounded-lg p-6 card-hover">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 bg-primary-500/20 rounded-lg">
|
||||
<svg className="w-6 h-6 text-white" 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>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-white/80">총 신청건</p>
|
||||
<p className="text-2xl font-bold text-white">{statistics.totalDays}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="glass-effect rounded-lg p-6 card-hover">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 bg-success-500/20 rounded-lg">
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-white/80">승인</p>
|
||||
<p className="text-2xl font-bold text-green-300">{statistics.approvedDays}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="glass-effect rounded-lg p-6 card-hover">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 bg-warning-500/20 rounded-lg">
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-white/80">대기</p>
|
||||
<p className="text-2xl font-bold text-yellow-300">{statistics.pendingDays}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="glass-effect rounded-lg p-6 card-hover">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 bg-red-500/20 rounded-lg">
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-white/80">반려</p>
|
||||
<p className="text-2xl font-bold text-red-300">{statistics.rejectedDays}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 테이블 */}
|
||||
<div className="glass-effect rounded-lg overflow-hidden animate-slide-up">
|
||||
<div className="table-container custom-scrollbar" style={{ maxHeight: '600px', overflowY: 'auto' }}>
|
||||
<table className="min-w-full divide-y divide-white/20">
|
||||
<thead className="bg-white/10 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider">신청일</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider">시작일</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider">종료일</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider">휴가종류</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider">일수</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider">상태</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider">사유</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan="7" className="p-8 text-center">
|
||||
<div className="inline-flex items-center">
|
||||
<div className="loading inline-block w-5 h-5 border-3 border-white/30 border-t-white rounded-full animate-spin mr-3"></div>
|
||||
<span className="text-white/80">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : kuntaeData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="7" className="p-8 text-center">
|
||||
<svg className="w-12 h-12 text-white/60 mx-auto mb-4" 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>
|
||||
<p className="text-white/70">근태 데이터가 없습니다.</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
kuntaeData.map((item, index) => (
|
||||
<tr key={item.idx || index} className="hover:bg-white/10 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
|
||||
{formatDate(item.requestDate)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
|
||||
{formatDate(item.startDate)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
|
||||
{formatDate(item.endDate)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
|
||||
{item.type || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
|
||||
{item.days || '0'}일
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(item.status)}`}>
|
||||
{item.status || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-white">
|
||||
<div className="max-w-xs truncate" title={item.reason || ''}>
|
||||
{item.reason || '-'}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
// LoginApp.jsx - React Login Component for GroupWare
|
||||
const { useState, useEffect, useRef } = React;
|
||||
|
||||
function LoginApp() {
|
||||
const [formData, setFormData] = useState({
|
||||
gcode: '',
|
||||
userId: '',
|
||||
password: '',
|
||||
rememberMe: false
|
||||
});
|
||||
|
||||
const [userGroups, setUserGroups] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [message, setMessage] = useState({ type: '', text: '', show: false });
|
||||
const [isFormReady, setIsFormReady] = useState(false);
|
||||
|
||||
const gcodeRef = useRef(null);
|
||||
const userIdRef = useRef(null);
|
||||
const passwordRef = useRef(null);
|
||||
|
||||
// 메시지 표시 함수
|
||||
const showMessage = (type, text) => {
|
||||
setMessage({ type, text, show: true });
|
||||
setTimeout(() => {
|
||||
setMessage(prev => ({ ...prev, show: false }));
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
// 폼 데이터 업데이트
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}));
|
||||
};
|
||||
|
||||
// 사용자 그룹 목록 로드
|
||||
const loadUserGroups = async () => {
|
||||
try {
|
||||
const response = await fetch('/DashBoard/GetUserGroups');
|
||||
const data = await response.json();
|
||||
|
||||
// 유효한 그룹만 필터링
|
||||
const validGroups = data.filter(group => group.gcode && group.name);
|
||||
setUserGroups(validGroups);
|
||||
|
||||
// 이전 로그인 정보 로드
|
||||
await loadPreviousLoginInfo();
|
||||
|
||||
} catch (error) {
|
||||
console.error('그룹 목록 로드 중 오류 발생:', error);
|
||||
showMessage('error', '부서 목록을 불러오는 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 이전 로그인 정보 로드
|
||||
const loadPreviousLoginInfo = async () => {
|
||||
try {
|
||||
const response = await fetch('/Home/GetPreviousLoginInfo');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.Success && result.Data) {
|
||||
const { Gcode, UserId } = result.Data;
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
gcode: Gcode || '',
|
||||
userId: UserId ? UserId.split(';')[0] : ''
|
||||
}));
|
||||
|
||||
// 포커스 설정
|
||||
setTimeout(() => {
|
||||
if (Gcode && UserId) {
|
||||
passwordRef.current?.focus();
|
||||
} else {
|
||||
gcodeRef.current?.focus();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
setIsFormReady(true);
|
||||
|
||||
} catch (error) {
|
||||
console.error('이전 로그인 정보 로드 중 오류 발생:', error);
|
||||
setIsFormReady(true);
|
||||
setTimeout(() => {
|
||||
gcodeRef.current?.focus();
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
// 로그인 처리
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const { gcode, userId, password, rememberMe } = formData;
|
||||
|
||||
// 유효성 검사
|
||||
if (!gcode || !userId || !password) {
|
||||
showMessage('error', '그룹코드/사용자ID/비밀번호를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/Home/Login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
Gcode: gcode,
|
||||
UserId: userId,
|
||||
Password: password,
|
||||
RememberMe: rememberMe
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.Success) {
|
||||
// 로그인 성공
|
||||
showMessage('success', data.Message);
|
||||
|
||||
// WebView2에 로그인 성공 메시지 전송
|
||||
if (window.chrome && window.chrome.webview) {
|
||||
window.chrome.webview.postMessage('LOGIN_SUCCESS');
|
||||
}
|
||||
|
||||
// 리다이렉트 URL이 있으면 이동
|
||||
if (data.RedirectUrl) {
|
||||
setTimeout(() => {
|
||||
window.location.href = data.RedirectUrl;
|
||||
}, 1000);
|
||||
}
|
||||
} else {
|
||||
// 로그인 실패
|
||||
showMessage('error', data.Message || '로그인에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('로그인 요청 중 오류 발생:', error);
|
||||
showMessage('error', '서버 연결에 실패했습니다. 다시 시도해주세요.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 컴포넌트 마운트 시 실행
|
||||
useEffect(() => {
|
||||
loadUserGroups();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="gradient-bg min-h-screen flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* 로그인 카드 */}
|
||||
<div className="glass-effect rounded-3xl p-8 card-hover animate-bounce-in">
|
||||
{/* 로고 및 제목 */}
|
||||
<div className="text-center mb-8 animate-fade-in">
|
||||
<div className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">GroupWare</h1>
|
||||
<p className="text-white/70 text-sm">로그인하여 시스템에 접속하세요</p>
|
||||
</div>
|
||||
|
||||
{/* 로그인 폼 */}
|
||||
<form onSubmit={handleLogin} className="space-y-6 animate-slide-up">
|
||||
{/* Gcode 드롭다운 */}
|
||||
<div className="relative">
|
||||
<select
|
||||
ref={gcodeRef}
|
||||
name="gcode"
|
||||
value={formData.gcode}
|
||||
onChange={handleInputChange}
|
||||
className="input-field w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white focus:outline-none focus:border-primary-400 input-focus appearance-none"
|
||||
required
|
||||
disabled={!isFormReady}
|
||||
>
|
||||
<option value="" className="text-gray-800">부서를 선택하세요</option>
|
||||
{userGroups.map(group => (
|
||||
<option key={group.gcode} value={group.gcode} className="text-gray-800">
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute right-3 top-3 pointer-events-none">
|
||||
<svg className="w-5 h-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사용자 ID 입력 */}
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={userIdRef}
|
||||
type="text"
|
||||
name="userId"
|
||||
value={formData.userId}
|
||||
onChange={handleInputChange}
|
||||
className="input-field w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-white/60 focus:outline-none focus:border-primary-400 input-focus"
|
||||
placeholder="사원번호"
|
||||
required
|
||||
disabled={!isFormReady}
|
||||
/>
|
||||
<div className="absolute right-3 top-3">
|
||||
<svg className="w-5 h-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 입력 */}
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={passwordRef}
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className="input-field w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-white/60 focus:outline-none focus:border-primary-400 input-focus"
|
||||
placeholder="비밀번호"
|
||||
required
|
||||
disabled={!isFormReady}
|
||||
/>
|
||||
<div className="absolute right-3 top-3">
|
||||
<svg className="w-5 h-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 로그인 버튼 */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !isFormReady}
|
||||
className="w-full bg-primary-500 hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold py-3 px-4 rounded-xl transition-all duration-300 transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-primary-400 focus:ring-offset-2 focus:ring-offset-transparent"
|
||||
>
|
||||
<span className="flex items-center justify-center">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
로그인 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path>
|
||||
</svg>
|
||||
로그인
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* 추가 옵션 */}
|
||||
<div className="mt-6 text-center">
|
||||
<div className="flex items-center justify-center space-x-4 text-sm">
|
||||
<label className="flex items-center text-white/70 hover:text-white cursor-pointer transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="rememberMe"
|
||||
checked={formData.rememberMe}
|
||||
onChange={handleInputChange}
|
||||
className="mr-2 w-4 h-4 text-primary-500 bg-white/10 border-white/20 rounded focus:ring-primary-400 focus:ring-2"
|
||||
/>
|
||||
로그인 정보 저장
|
||||
</label>
|
||||
<a href="#" className="text-primary-300 hover:text-primary-200 transition-colors">비밀번호 찾기</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="text-center mt-6 animate-fade-in">
|
||||
<p className="text-white/50 text-sm">
|
||||
© 2024 GroupWare System. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 메시지 표시 */}
|
||||
{message.show && (
|
||||
<div className={`fixed top-4 left-1/2 transform -translate-x-1/2 px-6 py-3 rounded-lg shadow-lg animate-slide-up ${
|
||||
message.type === 'error' ? 'bg-red-500' : 'bg-green-500'
|
||||
} text-white`}>
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{message.type === 'error' ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path>
|
||||
)}
|
||||
</svg>
|
||||
<span>{message.text}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,884 +0,0 @@
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
function Project() {
|
||||
// 상태 관리
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [filteredProjects, setFilteredProjects] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState('사용자');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage] = useState(10);
|
||||
|
||||
// 모달 상태
|
||||
const [showProjectModal, setShowProjectModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [currentProjectIdx, setCurrentProjectIdx] = useState(null);
|
||||
|
||||
// 필터 상태
|
||||
const [filters, setFilters] = useState({
|
||||
status: '진행',
|
||||
search: '',
|
||||
manager: 'my'
|
||||
});
|
||||
|
||||
// UI 상태
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [showColumnDropdown, setShowColumnDropdown] = useState(false);
|
||||
const [visibleColumns, setVisibleColumns] = useState({
|
||||
serial: false,
|
||||
plant: false,
|
||||
line: false,
|
||||
package: false,
|
||||
staff: false,
|
||||
process: false,
|
||||
expire: false,
|
||||
delivery: false,
|
||||
design: false,
|
||||
electric: false,
|
||||
program: false,
|
||||
budgetDue: false,
|
||||
budget: false,
|
||||
jasmin: false
|
||||
});
|
||||
|
||||
// 프로젝트 폼 상태
|
||||
const [projectForm, setProjectForm] = useState({
|
||||
idx: 0,
|
||||
name: '',
|
||||
process: '',
|
||||
sdate: '',
|
||||
edate: '',
|
||||
ddate: '',
|
||||
odate: '',
|
||||
userManager: '',
|
||||
status: '진행',
|
||||
memo: ''
|
||||
});
|
||||
|
||||
// 통계 상태
|
||||
const [statusCounts, setStatusCounts] = useState({
|
||||
진행: 0,
|
||||
완료: 0,
|
||||
대기: 0,
|
||||
중단: 0
|
||||
});
|
||||
|
||||
// 컴포넌트 마운트시 초기화
|
||||
useEffect(() => {
|
||||
getCurrentUser();
|
||||
loadProjects();
|
||||
}, []);
|
||||
|
||||
// 필터 변경시 프로젝트 목록 새로 로드
|
||||
useEffect(() => {
|
||||
loadProjects();
|
||||
}, [filters.status, filters.manager]);
|
||||
|
||||
// 검색어 변경시 필터링
|
||||
useEffect(() => {
|
||||
filterData();
|
||||
}, [filters.search, projects]);
|
||||
|
||||
// 현재 사용자 정보 가져오기
|
||||
const getCurrentUser = async () => {
|
||||
try {
|
||||
const response = await fetch('/Common/GetCurrentUser');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.Success && data.Data) {
|
||||
const userName = data.Data.userName || data.Data.name || '사용자';
|
||||
setCurrentUser(userName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting current user:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 프로젝트 목록 로드
|
||||
const loadProjects = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/Project/GetProjects?status=${filters.status}&userFilter=${filters.manager}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.Success) {
|
||||
const projectData = data.Data || [];
|
||||
setProjects(projectData);
|
||||
setFilteredProjects(projectData);
|
||||
updateStatusCounts(projectData);
|
||||
|
||||
if (data.CurrentUser) {
|
||||
setCurrentUser(data.CurrentUser);
|
||||
}
|
||||
} else {
|
||||
console.error('Error:', data.Message);
|
||||
showNotification('데이터를 불러오는데 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showNotification('데이터를 불러오는데 실패했습니다.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 상태별 카운트 업데이트
|
||||
const updateStatusCounts = (projectData) => {
|
||||
const counts = { 진행: 0, 완료: 0, 대기: 0, 중단: 0 };
|
||||
|
||||
projectData.forEach(project => {
|
||||
const status = project.상태 || '진행';
|
||||
counts[status] = (counts[status] || 0) + 1;
|
||||
});
|
||||
|
||||
setStatusCounts(counts);
|
||||
};
|
||||
|
||||
// 데이터 필터링
|
||||
const filterData = () => {
|
||||
if (!filters.search.trim()) {
|
||||
setFilteredProjects(projects);
|
||||
setCurrentPage(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const searchTerm = filters.search.toLowerCase();
|
||||
const filtered = projects.filter(project => {
|
||||
return (project.프로젝트명 && project.프로젝트명.toLowerCase().includes(searchTerm)) ||
|
||||
(project.프로젝트공정 && project.프로젝트공정.toLowerCase().includes(searchTerm)) ||
|
||||
(project.자산번호 && project.자산번호.toLowerCase().includes(searchTerm)) ||
|
||||
(project.장비모델 && project.장비모델.toLowerCase().includes(searchTerm)) ||
|
||||
(project.시리얼번호 && project.시리얼번호.toLowerCase().includes(searchTerm)) ||
|
||||
(project.프로젝트관리자 && project.프로젝트관리자.toLowerCase().includes(searchTerm)) ||
|
||||
(project.설계담당 && project.설계담당.toLowerCase().includes(searchTerm)) ||
|
||||
(project.전장담당 && project.전장담당.toLowerCase().includes(searchTerm)) ||
|
||||
(project.프로그램담당 && project.프로그램담당.toLowerCase().includes(searchTerm));
|
||||
});
|
||||
|
||||
setFilteredProjects(filtered);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// 프로젝트 추가 모달 표시
|
||||
const showAddProjectModal = () => {
|
||||
setCurrentProjectIdx(null);
|
||||
setProjectForm({
|
||||
idx: 0,
|
||||
name: '',
|
||||
process: '',
|
||||
sdate: '',
|
||||
edate: '',
|
||||
ddate: '',
|
||||
odate: '',
|
||||
userManager: '',
|
||||
status: '진행',
|
||||
memo: ''
|
||||
});
|
||||
setShowProjectModal(true);
|
||||
};
|
||||
|
||||
// 프로젝트 편집
|
||||
const editProject = async (idx) => {
|
||||
setCurrentProjectIdx(idx);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/Project/GetProject?id=${idx}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.Success && data.Data) {
|
||||
const project = data.Data;
|
||||
setProjectForm({
|
||||
idx: project.idx,
|
||||
name: project.프로젝트명 || '',
|
||||
process: project.프로젝트공정 || '',
|
||||
sdate: project.시작일 || '',
|
||||
edate: project.완료일 || '',
|
||||
ddate: project.만료일 || '',
|
||||
odate: project.출고일 || '',
|
||||
userManager: project.프로젝트관리자 || '',
|
||||
status: project.상태 || '진행',
|
||||
memo: project.memo || ''
|
||||
});
|
||||
setShowProjectModal(true);
|
||||
} else {
|
||||
showNotification('프로젝트 정보를 불러오는데 실패했습니다: ' + data.Message, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showNotification('프로젝트 정보를 불러오는데 실패했습니다.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 프로젝트 저장 (추가/수정)
|
||||
const saveProject = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
const projectData = {
|
||||
...projectForm,
|
||||
idx: currentProjectIdx ? parseInt(projectForm.idx) : 0,
|
||||
sdate: projectForm.sdate || null,
|
||||
edate: projectForm.edate || null,
|
||||
ddate: projectForm.ddate || null,
|
||||
odate: projectForm.odate || null
|
||||
};
|
||||
|
||||
const url = currentProjectIdx ? '/Project/UpdateProject' : '/Project/CreateProject';
|
||||
const method = currentProjectIdx ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(projectData)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.Success) {
|
||||
showNotification(data.Message || (currentProjectIdx ? '프로젝트가 수정되었습니다.' : '프로젝트가 추가되었습니다.'), 'success');
|
||||
setShowProjectModal(false);
|
||||
loadProjects();
|
||||
} else {
|
||||
showNotification('오류: ' + data.Message, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showNotification('저장 중 오류가 발생했습니다.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 프로젝트 삭제
|
||||
const deleteProject = async () => {
|
||||
if (!currentProjectIdx) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/Project/DeleteProject?id=${currentProjectIdx}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.Success) {
|
||||
showNotification(data.Message || '프로젝트가 삭제되었습니다.', 'success');
|
||||
setShowDeleteModal(false);
|
||||
setShowProjectModal(false);
|
||||
loadProjects();
|
||||
} else {
|
||||
showNotification('오류: ' + data.Message, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showNotification('삭제 중 오류가 발생했습니다.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 필터 초기화
|
||||
const clearFilters = () => {
|
||||
setFilters({
|
||||
status: '진행',
|
||||
search: '',
|
||||
manager: 'my'
|
||||
});
|
||||
};
|
||||
|
||||
// 엑셀 다운로드
|
||||
const exportToExcel = () => {
|
||||
if (filteredProjects.length === 0) {
|
||||
showNotification('내보낼 데이터가 없습니다.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = ['상태', '자산번호', '장비모델', '시리얼번호', '우선순위', '요청국가', '요청공장', '요청라인', '요청부서패키지', '요청자', '프로젝트공정', '시작일', '완료일', '만료일', '출고일', '프로젝트명', '프로젝트관리자', '설계담당', '전장담당', '프로그램담당', '예산만기일', '예산', '웹관리번호'];
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...filteredProjects.map(project => [
|
||||
project.상태 || '',
|
||||
project.자산번호 || '',
|
||||
project.장비모델 || '',
|
||||
project.시리얼번호 || '',
|
||||
project.우선순위 || '',
|
||||
project.요청국가 || '',
|
||||
project.요청공장 || '',
|
||||
project.요청라인 || '',
|
||||
project.요청부서패키지 || '',
|
||||
project.요청자 || '',
|
||||
project.프로젝트공정 || '',
|
||||
project.시작일 || '',
|
||||
project.완료일 || '',
|
||||
project.만료일 || '',
|
||||
project.출고일 || '',
|
||||
project.프로젝트명 || '',
|
||||
project.프로젝트관리자 || '',
|
||||
project.설계담당 || '',
|
||||
project.전장담당 || '',
|
||||
project.프로그램담당 || '',
|
||||
project.예산만기일 || '',
|
||||
project.예산 || '',
|
||||
project.웹관리번호 || ''
|
||||
].join(','))
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `프로젝트목록_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
// 유틸리티 함수들
|
||||
const getStatusClass = (status) => {
|
||||
switch(status) {
|
||||
case '진행': return 'bg-blue-500/20 text-blue-300';
|
||||
case '완료': return 'bg-green-500/20 text-green-300';
|
||||
case '대기': return 'bg-yellow-500/20 text-yellow-300';
|
||||
case '중단': return 'bg-red-500/20 text-red-300';
|
||||
default: return 'bg-gray-500/20 text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDateToMonthDay = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return dateString;
|
||||
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${month}-${day}`;
|
||||
} catch (error) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
// 알림 표시 함수
|
||||
const showNotification = (message, type = 'info') => {
|
||||
const colors = {
|
||||
info: 'bg-blue-500/90 backdrop-blur-sm',
|
||||
success: 'bg-green-500/90 backdrop-blur-sm',
|
||||
warning: 'bg-yellow-500/90 backdrop-blur-sm',
|
||||
error: 'bg-red-500/90 backdrop-blur-sm'
|
||||
};
|
||||
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `fixed top-4 right-4 ${colors[type]} text-white px-4 py-3 rounded-lg z-50 transition-all duration-300 transform translate-x-0 opacity-100 shadow-lg border border-white/20`;
|
||||
notification.innerHTML = `
|
||||
<div class="flex items-center">
|
||||
<span class="ml-2">${message}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
notification.style.opacity = '0';
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.transform = 'translateX(0)';
|
||||
notification.style.opacity = '1';
|
||||
}, 10);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
notification.style.opacity = '0';
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
// 페이지네이션 계산
|
||||
const totalPages = Math.ceil(filteredProjects.length / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const paginatedProjects = filteredProjects.slice(startIndex, endIndex);
|
||||
|
||||
const changePage = (page) => {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
setCurrentPage(page);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* 프로젝트 목록 */}
|
||||
<div className="glass-effect rounded-lg 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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
||||
</svg>
|
||||
프로젝트 목록
|
||||
</h2>
|
||||
<div className="flex space-x-3">
|
||||
<button onClick={showAddProjectModal} className="bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center text-sm" title="프로젝트 추가">
|
||||
<svg className="w-4 h-4 mr-2" 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 onClick={() => setShowFilters(!showFilters)} className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors flex items-center text-sm" title="필터 표시/숨김">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707L13 14v6a1 1 0 01-.707.293l-4-1A1 1 0 018 19v-5L0.293 7.293A1 1 0 010 6.586V4z"></path>
|
||||
</svg>
|
||||
필터
|
||||
<svg className={`w-4 h-4 ml-2 transition-transform ${showFilters ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상태별 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 p-4 border-b border-white/10">
|
||||
<div className="bg-white/10 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-blue-300 mb-1">{statusCounts.진행}</div>
|
||||
<div className="text-sm text-white/60">진행</div>
|
||||
</div>
|
||||
<div className="bg-white/10 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-300 mb-1">{statusCounts.완료}</div>
|
||||
<div className="text-sm text-white/60">완료</div>
|
||||
</div>
|
||||
<div className="bg-white/10 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-yellow-300 mb-1">{statusCounts.대기}</div>
|
||||
<div className="text-sm text-white/60">대기</div>
|
||||
</div>
|
||||
<div className="bg-white/10 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-red-300 mb-1">{statusCounts.중단}</div>
|
||||
<div className="text-sm text-white/60">중단</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 영역 */}
|
||||
{showFilters && (
|
||||
<div className="p-4 border-b border-white/10 animate-slide-up">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* 좌측: 필터 컨트롤 */}
|
||||
<div className="lg:col-span-2 space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/80 mb-1">상태</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({...filters, status: e.target.value})}
|
||||
className="w-full bg-white/20 border border-white/30 rounded-md px-2 py-1 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent text-xs"
|
||||
>
|
||||
<option value="진행">진행</option>
|
||||
<option value="완료">완료</option>
|
||||
<option value="대기">대기</option>
|
||||
<option value="중단">중단</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/80 mb-1">검색</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({...filters, search: e.target.value})}
|
||||
placeholder="프로젝트명 검색..."
|
||||
className="w-full bg-white/20 border border-white/30 rounded-md px-2 py-1 text-white placeholder-white/60 focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/80 mb-1">담당자</label>
|
||||
<select
|
||||
value={filters.manager}
|
||||
onChange={(e) => setFilters({...filters, manager: e.target.value})}
|
||||
className="w-full bg-white/20 border border-white/30 rounded-md px-2 py-1 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent text-xs"
|
||||
>
|
||||
<option value="my">내 프로젝트</option>
|
||||
<option value="all">전체 프로젝트</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 액션 버튼들 */}
|
||||
<div className="flex flex-wrap gap-2 justify-end">
|
||||
<div className="relative">
|
||||
<button onClick={() => setShowColumnDropdown(!showColumnDropdown)} className="bg-purple-500 hover:bg-purple-600 text-white px-4 py-2 rounded-lg flex items-center transition-colors text-sm" title="컬럼 표시/숨김">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2H9z"></path>
|
||||
</svg>
|
||||
컬럼 설정
|
||||
</button>
|
||||
{showColumnDropdown && (
|
||||
<div className="absolute top-full right-0 bg-gray-800/95 backdrop-blur-sm border border-white/20 rounded-lg p-3 min-w-48 z-50 mt-1">
|
||||
<div className="text-sm font-medium text-white/80 mb-2">표시할 컬럼 선택</div>
|
||||
<div className="space-y-1 text-sm max-h-40 overflow-y-auto">
|
||||
{Object.entries({
|
||||
serial: '시리얼번호',
|
||||
plant: '요청공장',
|
||||
line: '요청라인',
|
||||
package: '요청부서패키지',
|
||||
staff: '요청자',
|
||||
process: '프로젝트공정',
|
||||
expire: '만료일',
|
||||
delivery: '출고일',
|
||||
design: '설계담당',
|
||||
electric: '전장담당',
|
||||
program: '프로그램담당',
|
||||
budgetDue: '예산만기일',
|
||||
budget: '예산',
|
||||
jasmin: '웹관리번호'
|
||||
}).map(([key, label]) => (
|
||||
<label key={key} className="flex items-center text-white cursor-pointer hover:text-blue-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visibleColumns[key]}
|
||||
onChange={(e) => setVisibleColumns({...visibleColumns, [key]: e.target.checked})}
|
||||
className="mr-2"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={exportToExcel} className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-lg flex items-center transition-colors text-sm" title="엑셀 다운로드">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
|
||||
엑셀 다운로드
|
||||
</button>
|
||||
<button onClick={clearFilters} className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg flex items-center transition-colors text-sm" title="필터 초기화">
|
||||
<svg className="w-4 h-4 mr-2" 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 테이블 */}
|
||||
<div className="px-4 py-3 bg-white/5">
|
||||
<div className="text-sm text-white/70 font-medium">프로젝트 현황</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto custom-scrollbar">
|
||||
<table className="w-full divide-y divide-white/20">
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-20">상태</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 min-w-32">프로젝트명</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">자산번호</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-28">장비모델</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">우선순위</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-20">요청국가</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-28">프로젝트관리자</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">시작일</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">완료일</th>
|
||||
{visibleColumns.serial && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">시리얼번호</th>}
|
||||
{visibleColumns.plant && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-20">요청공장</th>}
|
||||
{visibleColumns.line && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-20">요청라인</th>}
|
||||
{visibleColumns.package && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-28">요청부서패키지</th>}
|
||||
{visibleColumns.staff && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-20">요청자</th>}
|
||||
{visibleColumns.process && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">프로젝트공정</th>}
|
||||
{visibleColumns.expire && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">만료일</th>}
|
||||
{visibleColumns.delivery && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">출고일</th>}
|
||||
{visibleColumns.design && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">설계담당</th>}
|
||||
{visibleColumns.electric && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">전장담당</th>}
|
||||
{visibleColumns.program && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">프로그램담당</th>}
|
||||
{visibleColumns.budgetDue && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">예산만기일</th>}
|
||||
{visibleColumns.budget && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-20">예산</th>}
|
||||
{visibleColumns.jasmin && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider w-24">웹관리번호</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan="23" className="px-6 py-4 text-center">
|
||||
<div className="inline-flex items-center">
|
||||
<div className="loading inline-block w-5 h-5 border-3 border-white/30 border-t-white rounded-full animate-spin mr-3"></div>
|
||||
<span className="text-white/80">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : paginatedProjects.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="23" className="px-6 py-4 text-center text-white/60">데이터가 없습니다.</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedProjects.map(project => (
|
||||
<tr key={project.idx} onClick={() => editProject(project.idx)} className="hover:bg-white/10 cursor-pointer transition-colors">
|
||||
<td className="px-4 py-4 text-sm border-r border-white/20">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusClass(project.상태)}`}>
|
||||
{project.상태 || '진행'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm border-r border-white/20 font-medium">{project.프로젝트명 || ''}</td>
|
||||
<td className="px-4 py-4 text-sm border-r border-white/20">{project.자산번호 || ''}</td>
|
||||
<td className="px-4 py-4 text-sm border-r border-white/20">{project.장비모델 || ''}</td>
|
||||
<td className="px-4 py-4 text-sm border-r border-white/20">{project.우선순위 || ''}</td>
|
||||
<td className="px-4 py-4 text-sm border-r border-white/20">{project.요청국가 || ''}</td>
|
||||
<td className="px-4 py-4 text-sm border-r border-white/20">{project.프로젝트관리자 || ''}</td>
|
||||
<td className="px-4 py-4 text-sm border-r border-white/20">{formatDateToMonthDay(project.시작일)}</td>
|
||||
<td className="px-4 py-4 text-sm border-r border-white/20">{formatDateToMonthDay(project.완료일)}</td>
|
||||
{visibleColumns.serial && <td className="px-3 py-4 text-sm border-r border-white/20">{project.시리얼번호 || ''}</td>}
|
||||
{visibleColumns.plant && <td className="px-3 py-4 text-sm border-r border-white/20">{project.요청공장 || ''}</td>}
|
||||
{visibleColumns.line && <td className="px-3 py-4 text-sm border-r border-white/20">{project.요청라인 || ''}</td>}
|
||||
{visibleColumns.package && <td className="px-3 py-4 text-sm border-r border-white/20">{project.요청부서패키지 || ''}</td>}
|
||||
{visibleColumns.staff && <td className="px-3 py-4 text-sm border-r border-white/20">{project.요청자 || ''}</td>}
|
||||
{visibleColumns.process && <td className="px-3 py-4 text-sm border-r border-white/20">{project.프로젝트공정 || ''}</td>}
|
||||
{visibleColumns.expire && <td className="px-3 py-4 text-sm border-r border-white/20">{formatDateToMonthDay(project.만료일)}</td>}
|
||||
{visibleColumns.delivery && <td className="px-3 py-4 text-sm border-r border-white/20">{formatDateToMonthDay(project.출고일)}</td>}
|
||||
{visibleColumns.design && <td className="px-3 py-4 text-sm border-r border-white/20">{project.설계담당 || ''}</td>}
|
||||
{visibleColumns.electric && <td className="px-3 py-4 text-sm border-r border-white/20">{project.전장담당 || ''}</td>}
|
||||
{visibleColumns.program && <td className="px-3 py-4 text-sm border-r border-white/20">{project.프로그램담당 || ''}</td>}
|
||||
{visibleColumns.budgetDue && <td className="px-3 py-4 text-sm border-r border-white/20">{formatDateToMonthDay(project.예산만기일)}</td>}
|
||||
{visibleColumns.budget && <td className="px-3 py-4 text-sm border-r border-white/20">{project.예산 || ''}</td>}
|
||||
{visibleColumns.jasmin && (
|
||||
<td className="px-3 py-4 text-sm">
|
||||
{project.웹관리번호 ? (
|
||||
<a href={`https://scwa.amkor.co.kr/jasmine/view/${project.웹관리번호}`} target="_blank" rel="noopener noreferrer" className="text-blue-300 hover:text-blue-200 underline">
|
||||
{project.웹관리번호}
|
||||
</a>
|
||||
) : ''}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 페이징 */}
|
||||
<div className="px-4 py-4 border-t border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-white/60">
|
||||
총 {filteredProjects.length}개 중 {startIndex + 1}-{Math.min(endIndex, filteredProjects.length)}개 표시
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button onClick={() => changePage(currentPage - 1)} disabled={currentPage <= 1} className="px-3 py-1 bg-white/20 hover:bg-white/30 text-white rounded-md text-sm disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex space-x-1">
|
||||
{Array.from({length: Math.min(5, totalPages)}, (_, i) => {
|
||||
const pageNum = Math.max(1, currentPage - 2) + i;
|
||||
if (pageNum > totalPages) return null;
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => changePage(pageNum)}
|
||||
className={`px-3 py-1 rounded-md text-sm transition-colors ${
|
||||
pageNum === currentPage
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-white/20 hover:bg-white/30 text-white'
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button onClick={() => changePage(currentPage + 1)} disabled={currentPage >= totalPages} className="px-3 py-1 bg-white/20 hover:bg-white/30 text-white rounded-md text-sm disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 추가/편집 모달 */}
|
||||
{showProjectModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50">
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<div className="glass-effect rounded-lg w-full max-w-2xl animate-slide-up">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-xl font-semibold">{currentProjectIdx ? '프로젝트 편집' : '프로젝트 추가'}</h3>
|
||||
<button onClick={() => setShowProjectModal(false)} className="text-white/60 hover:text-white">
|
||||
<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>
|
||||
|
||||
<form onSubmit={saveProject}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">프로젝트명 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={projectForm.name}
|
||||
onChange={(e) => setProjectForm({...projectForm, name: e.target.value})}
|
||||
required
|
||||
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white placeholder-white/60 focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">부서</label>
|
||||
<input
|
||||
type="text"
|
||||
value={projectForm.process}
|
||||
onChange={(e) => setProjectForm({...projectForm, process: e.target.value})}
|
||||
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white placeholder-white/60 focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">시작일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={projectForm.sdate}
|
||||
onChange={(e) => setProjectForm({...projectForm, sdate: e.target.value})}
|
||||
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">종료일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={projectForm.edate}
|
||||
onChange={(e) => setProjectForm({...projectForm, edate: e.target.value})}
|
||||
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">개발완료일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={projectForm.ddate}
|
||||
onChange={(e) => setProjectForm({...projectForm, ddate: e.target.value})}
|
||||
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">운영개시일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={projectForm.odate}
|
||||
onChange={(e) => setProjectForm({...projectForm, odate: e.target.value})}
|
||||
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">담당자</label>
|
||||
<input
|
||||
type="text"
|
||||
value={projectForm.userManager}
|
||||
onChange={(e) => setProjectForm({...projectForm, userManager: e.target.value})}
|
||||
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white placeholder-white/60 focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">상태</label>
|
||||
<select
|
||||
value={projectForm.status}
|
||||
onChange={(e) => setProjectForm({...projectForm, status: e.target.value})}
|
||||
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
|
||||
>
|
||||
<option value="진행">진행</option>
|
||||
<option value="완료">완료</option>
|
||||
<option value="대기">대기</option>
|
||||
<option value="중단">중단</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">메모</label>
|
||||
<textarea
|
||||
value={projectForm.memo}
|
||||
onChange={(e) => setProjectForm({...projectForm, memo: e.target.value})}
|
||||
rows="3"
|
||||
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white placeholder-white/60 focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
{currentProjectIdx && (
|
||||
<button type="button" onClick={() => setShowDeleteModal(true)} className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md transition-colors">
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
<div className="flex space-x-2 ml-auto">
|
||||
<button type="button" onClick={() => setShowProjectModal(false)} className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-md transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit" className="bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-md transition-colors">
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
{showDeleteModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50">
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<div className="glass-effect rounded-lg w-full max-w-md animate-slide-up">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<svg className="w-8 h-8 text-red-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
|
||||
</svg>
|
||||
<h3 className="text-lg font-semibold">삭제 확인</h3>
|
||||
</div>
|
||||
<p className="text-white/80 mb-6">선택한 프로젝트를 삭제하시겠습니까?<br><span className="text-sm text-gray-500">이 작업은 되돌릴 수 없습니다.</span></p>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button onClick={() => setShowDeleteModal(false)} className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-md transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button onClick={deleteProject} className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md transition-colors">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로딩 인디케이터 */}
|
||||
{isLoading && (
|
||||
<div className="fixed top-4 right-4 bg-white/20 backdrop-blur-sm rounded-full px-4 py-2 text-white text-sm z-40">
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
처리 중...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 외부 클릭시 드롭다운 닫기 */}
|
||||
{showColumnDropdown && (
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setShowColumnDropdown(false)}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
// TestApp.jsx - React Test Component for GroupWare
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
function TestApp() {
|
||||
const [status, setStatus] = useState('loading');
|
||||
const [counter, setCounter] = useState(0);
|
||||
const [serverTime, setServerTime] = useState('');
|
||||
const [apiTest, setApiTest] = useState({ status: 'pending', message: '' });
|
||||
|
||||
// 컴포넌트가 마운트될 때 실행
|
||||
useEffect(() => {
|
||||
// React가 정상적으로 로드되었음을 표시
|
||||
setTimeout(() => {
|
||||
setStatus('success');
|
||||
setServerTime(new Date().toLocaleString('ko-KR'));
|
||||
}, 1000);
|
||||
|
||||
// API 테스트
|
||||
testAPI();
|
||||
}, []);
|
||||
|
||||
// GroupWare API 테스트 함수
|
||||
const testAPI = async () => {
|
||||
try {
|
||||
// Home 컨트롤러 테스트
|
||||
const response = await fetch('/Home');
|
||||
if (response.ok) {
|
||||
setApiTest({ status: 'success', message: 'API 연결 성공' });
|
||||
} else {
|
||||
setApiTest({ status: 'warning', message: `API 응답: ${response.status}` });
|
||||
}
|
||||
} catch (error) {
|
||||
setApiTest({ status: 'error', message: `API 오류: ${error.message}` });
|
||||
}
|
||||
};
|
||||
|
||||
const buttonStyle = {
|
||||
padding: '10px 20px',
|
||||
margin: '5px',
|
||||
border: 'none',
|
||||
borderRadius: '5px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={`status ${status === 'success' ? 'success' : 'loading'}`}>
|
||||
{status === 'success' ? (
|
||||
<div>
|
||||
<h3>✅ React 컴포넌트가 성공적으로 로드되었습니다!</h3>
|
||||
<p><strong>현재 시간:</strong> {serverTime}</p>
|
||||
<p><strong>파일 위치:</strong> /react/TestApp.jsx</p>
|
||||
</div>
|
||||
) : (
|
||||
<h3>React 컴포넌트를 로딩 중입니다...</h3>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status === 'success' && (
|
||||
<div>
|
||||
<div className="status">
|
||||
<h3>📊 상태 관리 테스트</h3>
|
||||
<p><strong>카운터:</strong> {counter}</p>
|
||||
<button
|
||||
onClick={() => setCounter(counter + 1)}
|
||||
style={{
|
||||
...buttonStyle,
|
||||
backgroundColor: '#007bff',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
증가 (+1)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCounter(counter - 1)}
|
||||
style={{
|
||||
...buttonStyle,
|
||||
backgroundColor: '#dc3545',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
감소 (-1)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCounter(0)}
|
||||
style={{
|
||||
...buttonStyle,
|
||||
backgroundColor: '#6c757d',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
리셋
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`status ${
|
||||
apiTest.status === 'success' ? 'success' :
|
||||
apiTest.status === 'error' ? 'error' : 'loading'
|
||||
}`}>
|
||||
<h3>🌐 GroupWare API 연결 테스트</h3>
|
||||
<p><strong>상태:</strong> {apiTest.message}</p>
|
||||
<p><strong>테스트 엔드포인트:</strong> /Home</p>
|
||||
<button
|
||||
onClick={testAPI}
|
||||
style={{
|
||||
...buttonStyle,
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
API 다시 테스트
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="status">
|
||||
<h3>📋 React + OWIN 통합 테스트 체크리스트</h3>
|
||||
<ul style={{ textAlign: 'left' }}>
|
||||
<li>✅ OWIN 정적 파일 서빙</li>
|
||||
<li>✅ React 라이브러리 로딩 (CDN)</li>
|
||||
<li>✅ JSX 파일 분리 및 로딩</li>
|
||||
<li>✅ JSX 컴파일 (Babel)</li>
|
||||
<li>✅ React Hooks (useState, useEffect)</li>
|
||||
<li>✅ 이벤트 핸들링</li>
|
||||
<li>✅ API 호출 (fetch)</li>
|
||||
<li>✅ 반응형 UI 업데이트</li>
|
||||
<li>✅ 컴포넌트 모듈화</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="status success">
|
||||
<h3>🎉 통합 테스트 결과</h3>
|
||||
<p>GroupWare + OWIN + React 환경이 성공적으로 구성되었습니다!</p>
|
||||
<p><strong>다음 단계:</strong></p>
|
||||
<ul style={{ textAlign: 'left' }}>
|
||||
<li>React Router 추가 (SPA 라우팅)</li>
|
||||
<li>상태 관리 라이브러리 추가 (Redux/Zustand)</li>
|
||||
<li>UI 컴포넌트 라이브러리 추가 (Material-UI/Ant Design)</li>
|
||||
<li>번들링 도구 설정 (Webpack/Vite)</li>
|
||||
<li>TypeScript 지원 추가</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,811 +0,0 @@
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
function Todo() {
|
||||
// 상태 관리
|
||||
const [todos, setTodos] = useState([]);
|
||||
const [activeTodos, setActiveTodos] = useState([]);
|
||||
const [completedTodos, setCompletedTodos] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentTab, setCurrentTab] = useState('active');
|
||||
const [currentEditId, setCurrentEditId] = useState(null);
|
||||
|
||||
// 모달 상태
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
|
||||
// 폼 상태
|
||||
const [todoForm, setTodoForm] = useState({
|
||||
title: '',
|
||||
remark: '',
|
||||
expire: '',
|
||||
seqno: 0,
|
||||
flag: false,
|
||||
request: '',
|
||||
status: '0'
|
||||
});
|
||||
|
||||
const [editForm, setEditForm] = useState({
|
||||
idx: 0,
|
||||
title: '',
|
||||
remark: '',
|
||||
expire: '',
|
||||
seqno: 0,
|
||||
flag: false,
|
||||
request: '',
|
||||
status: '0'
|
||||
});
|
||||
|
||||
// 컴포넌트 마운트시 할일 목록 로드
|
||||
useEffect(() => {
|
||||
loadTodos();
|
||||
}, []);
|
||||
|
||||
// 할일 목록을 활성/완료로 분리
|
||||
useEffect(() => {
|
||||
const active = todos.filter(todo => (todo.status || '0') !== '5');
|
||||
const completed = todos.filter(todo => (todo.status || '0') === '5');
|
||||
setActiveTodos(active);
|
||||
setCompletedTodos(completed);
|
||||
}, [todos]);
|
||||
|
||||
// 할일 목록 로드
|
||||
const loadTodos = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/Todo/GetTodos');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.Success) {
|
||||
setTodos(data.Data || []);
|
||||
} else {
|
||||
showNotification(data.Message || '할일 목록을 불러올 수 없습니다.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('할일 목록 로드 중 오류:', error);
|
||||
showNotification('서버 연결에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 새 할일 추가
|
||||
const addTodo = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!todoForm.remark.trim()) {
|
||||
showNotification('할일 내용을 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/Todo/CreateTodo', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...todoForm,
|
||||
seqno: parseInt(todoForm.seqno),
|
||||
expire: todoForm.expire || null,
|
||||
request: todoForm.request || null
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.Success) {
|
||||
setShowAddModal(false);
|
||||
setTodoForm({
|
||||
title: '',
|
||||
remark: '',
|
||||
expire: '',
|
||||
seqno: 0,
|
||||
flag: false,
|
||||
request: '',
|
||||
status: '0'
|
||||
});
|
||||
loadTodos();
|
||||
showNotification(data.Message || '할일이 추가되었습니다.', 'success');
|
||||
} else {
|
||||
showNotification(data.Message || '할일 추가에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('할일 추가 중 오류:', error);
|
||||
showNotification('서버 연결에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 할일 수정
|
||||
const updateTodo = async () => {
|
||||
if (!editForm.remark.trim()) {
|
||||
showNotification('할일 내용을 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/Todo/UpdateTodo', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...editForm,
|
||||
seqno: parseInt(editForm.seqno),
|
||||
expire: editForm.expire || null,
|
||||
request: editForm.request || null
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.Success) {
|
||||
setShowEditModal(false);
|
||||
setCurrentEditId(null);
|
||||
loadTodos();
|
||||
showNotification(data.Message || '할일이 수정되었습니다.', 'success');
|
||||
} else {
|
||||
showNotification(data.Message || '할일 수정에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('할일 수정 중 오류:', error);
|
||||
showNotification('서버 연결에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 상태 업데이트 (편집 모달에서 바로 서버에 반영)
|
||||
const updateTodoStatus = async (status) => {
|
||||
if (!currentEditId) return;
|
||||
|
||||
const formData = {
|
||||
...editForm,
|
||||
status: status,
|
||||
seqno: parseInt(editForm.seqno),
|
||||
expire: editForm.expire || null,
|
||||
request: editForm.request || null
|
||||
};
|
||||
|
||||
if (!formData.remark.trim()) {
|
||||
showNotification('할일 내용을 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/Todo/UpdateTodo', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.Success) {
|
||||
setShowEditModal(false);
|
||||
setCurrentEditId(null);
|
||||
loadTodos();
|
||||
showNotification(`상태가 '${getStatusText(status)}'(으)로 변경되었습니다.`, 'success');
|
||||
} else {
|
||||
showNotification(data.Message || '상태 변경에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('상태 변경 중 오류:', error);
|
||||
showNotification('서버 연결에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 할일 편집 모달 열기
|
||||
const editTodo = async (id) => {
|
||||
setCurrentEditId(id);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/Todo/GetTodo?id=${id}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.Success && data.Data) {
|
||||
const todo = data.Data;
|
||||
setEditForm({
|
||||
idx: todo.idx,
|
||||
title: todo.title || '',
|
||||
remark: todo.remark || '',
|
||||
expire: todo.expire ? new Date(todo.expire).toISOString().split('T')[0] : '',
|
||||
seqno: todo.seqno || 0,
|
||||
flag: todo.flag || false,
|
||||
request: todo.request || '',
|
||||
status: todo.status || '0'
|
||||
});
|
||||
setShowEditModal(true);
|
||||
} else {
|
||||
showNotification(data.Message || '할일 정보를 불러올 수 없습니다.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('할일 조회 중 오류:', error);
|
||||
showNotification('서버 연결에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 할일 삭제
|
||||
const deleteTodo = async (id) => {
|
||||
if (!confirm('정말로 이 할일을 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/Todo/DeleteTodo?id=${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.Success) {
|
||||
loadTodos();
|
||||
showNotification(data.Message || '할일이 삭제되었습니다.', 'success');
|
||||
} else {
|
||||
showNotification(data.Message || '할일 삭제에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('할일 삭제 중 오류:', error);
|
||||
showNotification('서버 연결에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 유틸리티 함수들
|
||||
const getStatusClass = (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 getStatusText = (status) => {
|
||||
switch(status) {
|
||||
case '0': return '대기';
|
||||
case '1': return '진행';
|
||||
case '2': return '취소';
|
||||
case '3': return '보류';
|
||||
case '5': return '완료';
|
||||
default: return '대기';
|
||||
}
|
||||
};
|
||||
|
||||
const getSeqnoClass = (seqno) => {
|
||||
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';
|
||||
}
|
||||
};
|
||||
|
||||
const getSeqnoText = (seqno) => {
|
||||
switch(seqno) {
|
||||
case 1: return '중요';
|
||||
case 2: return '매우 중요';
|
||||
case 3: return '긴급';
|
||||
default: return '보통';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleDateString('ko-KR');
|
||||
};
|
||||
|
||||
// 알림 표시 함수
|
||||
const showNotification = (message, type = 'info') => {
|
||||
const colors = {
|
||||
info: 'bg-blue-500/90 backdrop-blur-sm',
|
||||
success: 'bg-green-500/90 backdrop-blur-sm',
|
||||
warning: 'bg-yellow-500/90 backdrop-blur-sm',
|
||||
error: 'bg-red-500/90 backdrop-blur-sm'
|
||||
};
|
||||
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `fixed top-4 right-4 ${colors[type]} text-white px-4 py-3 rounded-lg z-50 transition-all duration-300 transform translate-x-0 opacity-100 shadow-lg border border-white/20`;
|
||||
notification.innerHTML = `
|
||||
<div class="flex items-center">
|
||||
<span class="ml-2">${message}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
notification.style.opacity = '0';
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.transform = 'translateX(0)';
|
||||
notification.style.opacity = '1';
|
||||
}, 10);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
notification.style.opacity = '0';
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
// 할일 행 렌더링
|
||||
const renderTodoRow = (todo, includeOkdate = false) => {
|
||||
const statusClass = getStatusClass(todo.status);
|
||||
const statusText = getStatusText(todo.status);
|
||||
|
||||
const flagClass = todo.flag ? 'bg-warning-500/20 text-warning-300' : 'bg-white/10 text-white/50';
|
||||
const flagText = todo.flag ? '고정' : '일반';
|
||||
|
||||
const seqnoClass = getSeqnoClass(todo.seqno);
|
||||
const seqnoText = getSeqnoText(todo.seqno);
|
||||
|
||||
const expireText = formatDate(todo.expire);
|
||||
const isExpired = todo.expire && new Date(todo.expire) < new Date();
|
||||
const expireClass = isExpired ? 'text-danger-400' : 'text-white/80';
|
||||
|
||||
const okdateText = formatDate(todo.okdate);
|
||||
const okdateClass = todo.okdate ? 'text-success-400' : 'text-white/80';
|
||||
|
||||
return (
|
||||
<tr key={todo.idx} className="hover:bg-white/5 transition-colors cursor-pointer" onClick={() => editTodo(todo.idx)}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusClass}`}>
|
||||
{statusText}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${flagClass}`}>
|
||||
{flagText}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-white">{todo.title || '제목 없음'}</td>
|
||||
<td className="px-6 py-4 text-white/80 max-w-xs truncate">{todo.remark || ''}</td>
|
||||
<td className="px-6 py-4 text-white/80">{todo.request || '-'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${seqnoClass}`}>
|
||||
{seqnoText}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`px-6 py-4 whitespace-nowrap ${expireClass}`}>{expireText}</td>
|
||||
{includeOkdate && <td className={`px-6 py-4 whitespace-nowrap ${okdateClass}`}>{okdateText}</td>}
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => editTodo(todo.idx)} className="text-primary-400 hover:text-primary-300 mr-3 transition-colors">
|
||||
수정
|
||||
</button>
|
||||
<button onClick={() => deleteTodo(todo.idx)} className="text-danger-400 hover:text-danger-300 transition-colors">
|
||||
삭제
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* 할일 목록 */}
|
||||
<div className="glass-effect rounded-2xl 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="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>
|
||||
내 할일 목록
|
||||
</h2>
|
||||
<button onClick={() => setShowAddModal(true)} className="bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center text-sm">
|
||||
<svg className="w-4 h-4 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>
|
||||
</div>
|
||||
|
||||
{/* 탭 메뉴 */}
|
||||
<div className="px-6 py-2 border-b border-white/10">
|
||||
<div className="flex space-x-1 bg-white/5 rounded-lg p-1">
|
||||
<button onClick={() => setCurrentTab('active')} className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
currentTab === 'active'
|
||||
? 'text-white bg-white/20 shadow-sm'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/10'
|
||||
}`}>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
<span>진행중인 할일</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-primary-500/30 text-primary-200 rounded-full">{activeTodos.length}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button onClick={() => setCurrentTab('completed')} className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
currentTab === 'completed'
|
||||
? 'text-white bg-white/20 shadow-sm'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/10'
|
||||
}`}>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<span>완료된 할일</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-success-500/30 text-success-200 rounded-full">{completedTodos.length}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 진행중인 할일 테이블 */}
|
||||
{currentTab === 'active' && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">진행상태</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">플래그</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">제목</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">내용</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">요청자</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">중요도</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">만료일</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan="8" className="p-8 text-center">
|
||||
<div className="inline-flex items-center">
|
||||
<div className="loading inline-block w-5 h-5 border-3 border-white/30 border-t-white rounded-full animate-spin mr-3"></div>
|
||||
<span className="text-white/80">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : activeTodos.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="8" className="px-6 py-8 text-center text-white/50">
|
||||
진행중인 할일이 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
activeTodos.map(todo => renderTodoRow(todo, false))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 완료된 할일 테이블 */}
|
||||
{currentTab === 'completed' && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">진행상태</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">플래그</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">제목</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">내용</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">요청자</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">중요도</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">만료일</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">완료일</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan="9" className="p-8 text-center">
|
||||
<div className="inline-flex items-center">
|
||||
<div className="loading inline-block w-5 h-5 border-3 border-white/30 border-t-white rounded-full animate-spin mr-3"></div>
|
||||
<span className="text-white/80">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : completedTodos.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="9" className="px-6 py-8 text-center text-white/50">
|
||||
완료된 할일이 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
completedTodos.map(todo => renderTodoRow(todo, true))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 로딩 인디케이터 */}
|
||||
{isLoading && (
|
||||
<div className="fixed top-4 right-4 bg-white/20 backdrop-blur-sm rounded-full px-4 py-2 text-white text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
처리 중...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 새 할일 추가 모달 */}
|
||||
{showAddModal && (
|
||||
<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 max-w-2xl 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 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
새 할일 추가
|
||||
</h2>
|
||||
<button onClick={() => setShowAddModal(false)} 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="p-6">
|
||||
<form onSubmit={addTodo} className="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={todoForm.title}
|
||||
onChange={(e) => setTodoForm({...todoForm, 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={todoForm.expire}
|
||||
onChange={(e) => setTodoForm({...todoForm, 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={todoForm.remark}
|
||||
onChange={(e) => setTodoForm({...todoForm, 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
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">요청자</label>
|
||||
<input
|
||||
type="text"
|
||||
value={todoForm.request}
|
||||
onChange={(e) => setTodoForm({...todoForm, 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: '대기', class: 'bg-gray-500/20 text-gray-300'},
|
||||
{value: '1', label: '진행', class: 'bg-primary-500/20 text-primary-300'},
|
||||
{value: '3', label: '보류', class: 'bg-warning-500/20 text-warning-300'},
|
||||
{value: '2', label: '취소', class: 'bg-danger-500/20 text-danger-300'},
|
||||
{value: '5', label: '완료', class: 'bg-success-500/20 text-success-300'}
|
||||
].map(status => (
|
||||
<button
|
||||
key={status.value}
|
||||
type="button"
|
||||
onClick={() => setTodoForm({...todoForm, status: status.value})}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-medium transition-all ${
|
||||
todoForm.status === status.value
|
||||
? status.class + ' border-' + status.class.split(' ')[0].replace('bg-', '').replace('/20', '/30')
|
||||
: 'bg-white/10 text-white/50 border border-white/20 hover:bg-white/20'
|
||||
}`}
|
||||
>
|
||||
{status.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">중요도</label>
|
||||
<select
|
||||
value={todoForm.seqno}
|
||||
onChange={(e) => setTodoForm({...todoForm, seqno: parseInt(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"
|
||||
>
|
||||
<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">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={todoForm.flag}
|
||||
onChange={(e) => setTodoForm({...todoForm, flag: e.target.checked})}
|
||||
className="mr-2 text-primary-500 focus:ring-primary-400 focus:ring-offset-0 rounded"
|
||||
/>
|
||||
플래그 (상단 고정)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* 모달 푸터 */}
|
||||
<div className="px-6 py-4 border-t border-white/10 flex justify-end space-x-3">
|
||||
<button type="button" onClick={() => setShowAddModal(false)} className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit" form="todoForm" onClick={addTodo} className="bg-primary-500 hover:bg-primary-600 text-white px-6 py-2 rounded-lg transition-colors flex items-center">
|
||||
<svg className="w-4 h-4 mr-2" 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 수정 모달 */}
|
||||
{showEditModal && (
|
||||
<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 max-w-2xl 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
할일 수정
|
||||
</h2>
|
||||
<button onClick={() => {setShowEditModal(false); setCurrentEditId(null);}} 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="p-6">
|
||||
<div className="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={editForm.title}
|
||||
onChange={(e) => setEditForm({...editForm, 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={editForm.expire}
|
||||
onChange={(e) => setEditForm({...editForm, 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={editForm.remark}
|
||||
onChange={(e) => setEditForm({...editForm, 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
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">요청자</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.request}
|
||||
onChange={(e) => setEditForm({...editForm, 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: '대기', class: 'bg-gray-500/20 text-gray-300'},
|
||||
{value: '1', label: '진행', class: 'bg-primary-500/20 text-primary-300'},
|
||||
{value: '3', label: '보류', class: 'bg-warning-500/20 text-warning-300'},
|
||||
{value: '2', label: '취소', class: 'bg-danger-500/20 text-danger-300'},
|
||||
{value: '5', label: '완료', class: 'bg-success-500/20 text-success-300'}
|
||||
].map(status => (
|
||||
<button
|
||||
key={status.value}
|
||||
type="button"
|
||||
onClick={() => updateTodoStatus(status.value)}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-medium transition-all ${
|
||||
editForm.status === status.value
|
||||
? status.class + ' border-' + status.class.split(' ')[0].replace('bg-', '').replace('/20', '/30')
|
||||
: 'bg-white/10 text-white/50 border border-white/20 hover:bg-white/20'
|
||||
}`}
|
||||
>
|
||||
{status.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">중요도</label>
|
||||
<select
|
||||
value={editForm.seqno}
|
||||
onChange={(e) => setEditForm({...editForm, seqno: parseInt(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"
|
||||
>
|
||||
<option value="0">보통</option>
|
||||
<option value="1">중요</option>
|
||||
<option value="2">매우 중요</option>
|
||||
<option value="3">긴급</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center text-white/70 text-sm font-medium mt-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.flag}
|
||||
onChange={(e) => setEditForm({...editForm, flag: e.target.checked})}
|
||||
className="mr-2 text-primary-500 focus:ring-primary-400 focus:ring-offset-0 rounded"
|
||||
/>
|
||||
플래그 (상단 고정)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모달 푸터 */}
|
||||
<div className="px-6 py-4 border-t border-white/10 flex justify-end space-x-3">
|
||||
<button onClick={() => {setShowEditModal(false); setCurrentEditId(null);}} className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button onClick={updateTodo} className="bg-primary-500 hover:bg-primary-600 text-white px-6 py-2 rounded-lg transition-colors">
|
||||
수정
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<title>공용코드관리 (React)</title>
|
||||
<link rel="stylesheet" href="/lib/css/tailwind.min.css">
|
||||
<script src="/lib/js/tailwind-config.js"></script>
|
||||
|
||||
<!-- Tailwind 설정 -->
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
},
|
||||
danger: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.card-hover:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* 스크롤바 스타일링 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* 애니메이션 */
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* 셀렉트 박스 옵션 스타일링 */
|
||||
select option {
|
||||
background-color: #374151 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
select option:hover {
|
||||
background-color: #4B5563 !important;
|
||||
}
|
||||
|
||||
select option:checked {
|
||||
background-color: #6366F1 !important;
|
||||
}
|
||||
|
||||
/* 테이블 셀 텍스트 오버플로우 처리 */
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 값(문자열) 열 최대 너비 제한 */
|
||||
.svalue-cell {
|
||||
max-width: 128px; /* w-32 = 128px */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 개발중 경고 스타일 */
|
||||
.dev-warning {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 25px rgba(245, 158, 11, 0.3);
|
||||
z-index: 1000;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.dev-warning .icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dev-warning .title {
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dev-warning .description {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
margin: 2px 0 0 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 min-h-screen text-white">
|
||||
<!-- 로딩 스켈레톤 UI -->
|
||||
<div id="react-common-app" class="animate-fade-in">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex gap-6 h-[calc(100vh-200px)]">
|
||||
<!-- 좌측 스켈레톤 -->
|
||||
<div class="w-80">
|
||||
<div class="glass-effect rounded-2xl h-full animate-pulse">
|
||||
<div class="p-4 border-b border-white/10">
|
||||
<div class="h-6 bg-white/20 rounded w-3/4"></div>
|
||||
</div>
|
||||
<div class="p-4 space-y-3">
|
||||
<div class="h-16 bg-white/10 rounded-lg"></div>
|
||||
<div class="h-16 bg-white/10 rounded-lg"></div>
|
||||
<div class="h-16 bg-white/10 rounded-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 우측 스켈레톤 -->
|
||||
<div class="flex-1">
|
||||
<div class="glass-effect rounded-2xl h-full animate-pulse">
|
||||
<div class="p-4 border-b border-white/10 flex justify-between items-center">
|
||||
<div class="h-6 bg-white/20 rounded w-1/3"></div>
|
||||
<div class="h-8 bg-white/20 rounded w-20"></div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="space-y-3">
|
||||
<div class="h-10 bg-white/10 rounded"></div>
|
||||
<div class="h-10 bg-white/10 rounded"></div>
|
||||
<div class="h-10 bg-white/10 rounded"></div>
|
||||
<div class="h-10 bg-white/10 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- React Local -->
|
||||
<script crossorigin src="/lib/js/react.development.js"></script>
|
||||
<script crossorigin src="/lib/js/react-dom.development.js"></script>
|
||||
<script src="/lib/js/babel.min.js"></script>
|
||||
|
||||
<!-- 공통 네비게이션 컴포넌트 -->
|
||||
<script type="text/babel" src="/react/component/CommonNavigation"></script>
|
||||
|
||||
<!-- 개발중 경고 컴포넌트 -->
|
||||
<script type="text/babel" src="/react/component/DevWarning"></script>
|
||||
|
||||
<!-- App 컴포넌트 -->
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div>
|
||||
<CommonNavigation currentPage="common" />
|
||||
<CommonCode />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- 공용코드 컴포넌트 로드 -->
|
||||
<script type="text/babel" src="/react/component/CommonCode"></script>
|
||||
|
||||
<!-- 앱 초기화 -->
|
||||
<script type="text/babel">
|
||||
const root = ReactDOM.createRoot(document.getElementById('react-common-app'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,297 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<meta name="version" content="v2.0-20250905-react">
|
||||
<title>근태현황 대시보드 (React)</title>
|
||||
<link rel="stylesheet" href="/lib/css/tailwind.min.css">
|
||||
<script src="/lib/js/tailwind-config.js"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
},
|
||||
danger: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.card-hover:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* 스크롤바 스타일링 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* React 컴포넌트 로딩 스타일 */
|
||||
.react-dashboard-container {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.1) 25%, rgba(255,255,255,0.2) 50%, rgba(255,255,255,0.1) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="react-dashboard-app" class="react-dashboard-container">
|
||||
<!-- 로딩 화면 -->
|
||||
<div class="bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 min-h-screen text-white">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- 로딩 헤더 -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="loading-skeleton h-10 w-80 mx-auto rounded-lg mb-4"></div>
|
||||
<div class="loading-skeleton h-6 w-60 mx-auto rounded-lg mb-2"></div>
|
||||
<div class="loading-skeleton h-4 w-40 mx-auto rounded-lg"></div>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 통계 카드들 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8">
|
||||
<div class="glass-effect rounded-2xl p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="loading-skeleton h-4 w-16 rounded mb-2"></div>
|
||||
<div class="loading-skeleton h-8 w-12 rounded"></div>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-success-500/20 rounded-full flex items-center justify-center">
|
||||
<div class="loading-skeleton w-6 h-6 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-effect rounded-2xl p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="loading-skeleton h-4 w-16 rounded mb-2"></div>
|
||||
<div class="loading-skeleton h-8 w-12 rounded"></div>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-warning-500/20 rounded-full flex items-center justify-center">
|
||||
<div class="loading-skeleton w-6 h-6 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-effect rounded-2xl p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="loading-skeleton h-4 w-16 rounded mb-2"></div>
|
||||
<div class="loading-skeleton h-8 w-12 rounded"></div>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-primary-500/20 rounded-full flex items-center justify-center">
|
||||
<div class="loading-skeleton w-6 h-6 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-effect rounded-2xl p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="loading-skeleton h-4 w-20 rounded mb-2"></div>
|
||||
<div class="loading-skeleton h-8 w-12 rounded"></div>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-danger-500/20 rounded-full flex items-center justify-center">
|
||||
<div class="loading-skeleton w-6 h-6 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-effect rounded-2xl p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="loading-skeleton h-4 w-20 rounded mb-2"></div>
|
||||
<div class="loading-skeleton h-8 w-12 rounded"></div>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-500/20 rounded-full flex items-center justify-center">
|
||||
<div class="loading-skeleton w-6 h-6 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 추가 정보 -->
|
||||
<div class="glass-effect rounded-2xl p-6">
|
||||
<div class="loading-skeleton h-8 w-32 rounded mb-4"></div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 bg-success-500/20 rounded-full mx-auto mb-2">
|
||||
<div class="loading-skeleton w-full h-full rounded-full"></div>
|
||||
</div>
|
||||
<div class="loading-skeleton h-4 w-16 rounded mx-auto mb-1"></div>
|
||||
<div class="loading-skeleton h-3 w-24 rounded mx-auto"></div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 bg-warning-500/20 rounded-full mx-auto mb-2">
|
||||
<div class="loading-skeleton w-full h-full rounded-full"></div>
|
||||
</div>
|
||||
<div class="loading-skeleton h-4 w-16 rounded mx-auto mb-1"></div>
|
||||
<div class="loading-skeleton h-3 w-24 rounded mx-auto"></div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 bg-primary-500/20 rounded-full mx-auto mb-2">
|
||||
<div class="loading-skeleton w-full h-full rounded-full"></div>
|
||||
</div>
|
||||
<div class="loading-skeleton h-4 w-16 rounded mx-auto mb-1"></div>
|
||||
<div class="loading-skeleton h-3 w-24 rounded mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 텍스트 -->
|
||||
<div class="text-center mt-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-4"></div>
|
||||
<p class="text-white/70">React Dashboard 컴포넌트를 로딩 중입니다...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- React Local -->
|
||||
<script crossorigin src="/lib/js/react.development.js"></script>
|
||||
<script crossorigin src="/lib/js/react-dom.development.js"></script>
|
||||
<script src="/lib/js/babel.min.js"></script>
|
||||
|
||||
<!-- 공통 컴포넌트 로드 -->
|
||||
<script type="text/babel" src="/react/component/CommonNavigation"></script>
|
||||
<script type="text/babel" src="/react/component/DevWarning"></script>
|
||||
|
||||
<!-- Dashboard 컴포넌트 -->
|
||||
<script type="text/babel" src="/react/component/DashboardApp"></script>
|
||||
|
||||
<!-- 앱 초기화 -->
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
function App() {
|
||||
console.log('✅ App 컴포넌트 렌더링 시작');
|
||||
console.log('📊 CommonNavigation 사용 가능:', typeof CommonNavigation);
|
||||
console.log('📊 DashboardApp 사용 가능:', typeof DashboardApp);
|
||||
console.log('📊 DevWarning 사용 가능:', typeof DevWarning);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CommonNavigation currentPage="dashboard" />
|
||||
<DashboardApp />
|
||||
<DevWarning show={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 루트 렌더링
|
||||
const root = ReactDOM.createRoot(document.getElementById('react-dashboard-app'));
|
||||
root.render(<App />);
|
||||
|
||||
console.log('✅ React Dashboard 앱이 마운트되었습니다.');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,261 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<title>업무일지 (React)</title>
|
||||
<link rel="stylesheet" href="/lib/css/tailwind.min.css">
|
||||
<script src="/lib/js/tailwind-config.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
|
||||
<style>
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.card-hover:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* 스크롤바 스타일링 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* 애니메이션 */
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* 셀렉트 박스 옵션 스타일링 */
|
||||
select option {
|
||||
background-color: #374151 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
select option:hover {
|
||||
background-color: #4B5563 !important;
|
||||
}
|
||||
|
||||
select option:checked {
|
||||
background-color: #6366F1 !important;
|
||||
}
|
||||
|
||||
/* 개발중 경고 스타일 */
|
||||
.dev-warning {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 25px rgba(245, 158, 11, 0.3);
|
||||
z-index: 1000;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.dev-warning .icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dev-warning .title {
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dev-warning .description {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
margin: 2px 0 0 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 min-h-screen text-white">
|
||||
<!-- 로딩 스켈레톤 UI -->
|
||||
<div id="react-jobreport-app" class="animate-fade-in">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
{/* 스켈레톤 UI */}
|
||||
<div className="space-y-6">
|
||||
{/* 경고 메시지 스켈레톤 */}
|
||||
<div className="bg-white/10 rounded-lg p-4 animate-pulse">
|
||||
<div className="h-6 bg-white/20 rounded w-3/4"></div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 스켈레톤 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="glass-effect rounded-lg p-6 animate-pulse">
|
||||
<div className="flex items-center">
|
||||
<div className="w-12 h-12 bg-white/20 rounded-lg mr-4"></div>
|
||||
<div>
|
||||
<div className="h-4 bg-white/20 rounded w-20 mb-2"></div>
|
||||
<div className="h-6 bg-white/20 rounded w-12"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="glass-effect rounded-lg p-6 animate-pulse">
|
||||
<div className="flex items-center">
|
||||
<div className="w-12 h-12 bg-white/20 rounded-lg mr-4"></div>
|
||||
<div>
|
||||
<div className="h-4 bg-white/20 rounded w-24 mb-2"></div>
|
||||
<div className="h-6 bg-white/20 rounded w-16"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="glass-effect rounded-lg p-6 animate-pulse">
|
||||
<div className="flex items-center">
|
||||
<div className="w-12 h-12 bg-white/20 rounded-lg mr-4"></div>
|
||||
<div>
|
||||
<div className="h-4 bg-white/20 rounded w-20 mb-2"></div>
|
||||
<div className="h-6 bg-white/20 rounded w-14"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="glass-effect rounded-lg p-6 animate-pulse">
|
||||
<div className="flex items-center">
|
||||
<div className="w-12 h-12 bg-white/20 rounded-lg mr-4"></div>
|
||||
<div>
|
||||
<div className="h-4 bg-white/20 rounded w-28 mb-2"></div>
|
||||
<div className="h-6 bg-white/20 rounded w-8"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 스켈레톤 */}
|
||||
<div className="glass-effect rounded-lg p-4 animate-pulse">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="h-8 bg-white/10 rounded"></div>
|
||||
<div className="h-8 bg-white/10 rounded"></div>
|
||||
<div className="h-8 bg-white/10 rounded"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="h-8 bg-white/10 rounded"></div>
|
||||
<div className="h-8 bg-white/10 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-10 bg-white/10 rounded"></div>
|
||||
<div className="h-10 bg-white/10 rounded"></div>
|
||||
<div className="h-10 bg-white/10 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 스켈레톤 */}
|
||||
<div className="glass-effect rounded-lg p-4 animate-pulse">
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-7 gap-4">
|
||||
<div className="h-6 bg-white/20 rounded"></div>
|
||||
<div className="h-6 bg-white/20 rounded"></div>
|
||||
<div className="h-6 bg-white/20 rounded"></div>
|
||||
<div className="h-6 bg-white/20 rounded"></div>
|
||||
<div className="h-6 bg-white/20 rounded"></div>
|
||||
<div className="h-6 bg-white/20 rounded"></div>
|
||||
<div className="h-6 bg-white/20 rounded"></div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-7 gap-4">
|
||||
<div className="h-4 bg-white/10 rounded"></div>
|
||||
<div className="h-4 bg-white/10 rounded"></div>
|
||||
<div className="h-4 bg-white/10 rounded"></div>
|
||||
<div className="h-4 bg-white/10 rounded"></div>
|
||||
<div className="h-4 bg-white/10 rounded"></div>
|
||||
<div className="h-4 bg-white/10 rounded"></div>
|
||||
<div className="h-4 bg-white/10 rounded"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-4">
|
||||
<div className="h-4 bg-white/10 rounded"></div>
|
||||
<div className="h-4 bg-white/10 rounded"></div>
|
||||
<div className="h-4 bg-white/10 rounded"></div>
|
||||
<div className="h-4 bg-white/10 rounded"></div>
|
||||
<div className="h-4 bg-white/10 rounded"></div>
|
||||
<div className="h-4 bg-white/10 rounded"></div>
|
||||
<div className="h-4 bg-white/10 rounded"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-4">
|
||||
<div className="h-4 bg-white/10 rounded"></div>
|
||||
<div className="h-4 bg-white/10 rounded"></div>
|
||||
<div className="h-4 bg-white/10 rounded"></div>
|
||||
<div className="h-4 bg-white/10 rounded"></div>
|
||||
<div className="h-4 bg-white/10 rounded"></div>
|
||||
<div className="h-4 bg-white/10 rounded"></div>
|
||||
<div className="h-4 bg-white/10 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- React Local -->
|
||||
<script crossorigin src="/lib/js/react.development.js"></script>
|
||||
<script crossorigin src="/lib/js/react-dom.development.js"></script>
|
||||
<script src="/lib/js/babel.min.js"></script>
|
||||
|
||||
<!-- 공통 컴포넌트 로드 -->
|
||||
<script type="text/babel" src="/react/component/CommonNavigation"></script>
|
||||
<script type="text/babel" src="/react/component/DevWarning"></script>
|
||||
|
||||
<!-- 앱 컴포넌트 -->
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div>
|
||||
<CommonNavigation currentPage="jobreport" />
|
||||
<JobReport />
|
||||
<DevWarning />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- 업무일지 컴포넌트 로드 -->
|
||||
<script type="text/babel" src="/react/component/JobReport"></script>
|
||||
|
||||
<!-- 앱 초기화 -->
|
||||
<script type="text/babel">
|
||||
const root = ReactDOM.createRoot(document.getElementById('react-jobreport-app'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,120 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>React JSX Test - GroupWare</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background-color: white;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
.header h1 {
|
||||
color: #333;
|
||||
margin: 0;
|
||||
font-size: 2.5em;
|
||||
}
|
||||
.header p {
|
||||
color: #666;
|
||||
font-size: 1.2em;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.status {
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.success {
|
||||
background-color: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
color: #155724;
|
||||
}
|
||||
.loading {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
color: #856404;
|
||||
}
|
||||
.error {
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
color: #721c24;
|
||||
}
|
||||
.tech-info {
|
||||
background-color: #e3f2fd;
|
||||
border: 1px solid #90caf9;
|
||||
color: #0d47a1;
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.version-info {
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🚀 React JSX Integration</h1>
|
||||
<p>GroupWare + OWIN + React + JSX 모듈화 테스트</p>
|
||||
<div class="tech-info">
|
||||
<strong>기술 스택:</strong> C# WinForms + OWIN + React 18 + Babel JSX + 모듈화된 컴포넌트
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="react-app">
|
||||
<div class="status loading">
|
||||
React JSX 컴포넌트를 로딩 중입니다...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="version-info">
|
||||
<p><strong>환경:</strong> .NET Framework 4.6 + OWIN + React 18.2.0</p>
|
||||
<p><strong>포트:</strong> 7979 | <strong>파일 위치:</strong> /react-jsx-test.html</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- React & ReactDOM CDN -->
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
||||
<!-- Babel standalone for JSX -->
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
|
||||
<!-- JSX Component Import -->
|
||||
<script type="text/babel" src="/react/component/TestApp"></script>
|
||||
|
||||
<!-- App Initialization -->
|
||||
<script type="text/babel">
|
||||
// JSX 컴포넌트를 DOM에 렌더링
|
||||
const root = ReactDOM.createRoot(document.getElementById('react-app'));
|
||||
root.render(<TestApp />);
|
||||
|
||||
console.log('✅ React JSX 컴포넌트가 성공적으로 마운트되었습니다.');
|
||||
console.log('📁 컴포넌트 파일: /react/TestApp.jsx');
|
||||
console.log('🌐 테스트 페이지: /react-jsx-test.html');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,241 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<title>근태관리 (React)</title>
|
||||
<link rel="stylesheet" href="/lib/css/tailwind.min.css">
|
||||
<script src="/lib/js/tailwind-config.js"></script>
|
||||
|
||||
<style>
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.card-hover:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 3px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.table-container {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 스크롤바 스타일링 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* 애니메이션 */
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* 개발중 경고 스타일 */
|
||||
.dev-warning {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 25px rgba(245, 158, 11, 0.3);
|
||||
z-index: 1000;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.dev-warning .icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dev-warning .title {
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dev-warning .description {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
margin: 2px 0 0 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 min-h-screen text-white">
|
||||
<!-- 로딩 스켈레톤 UI -->
|
||||
<div id="react-kuntae-app" class="animate-fade-in">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="space-y-6">
|
||||
{/* 경고 메시지 스켈레톤 */}
|
||||
<div class="bg-white/10 rounded-lg p-4 animate-pulse">
|
||||
<div class="h-6 bg-white/20 rounded w-3/4"></div>
|
||||
</div>
|
||||
|
||||
{/* 필터 스켈레톤 */}
|
||||
<div class="glass-effect rounded-lg p-6 animate-pulse">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="h-16 bg-white/10 rounded"></div>
|
||||
<div class="h-16 bg-white/10 rounded"></div>
|
||||
<div class="h-16 bg-white/10 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 스켈레톤 */}
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div class="glass-effect rounded-lg p-6 animate-pulse">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-white/20 rounded-lg mr-4"></div>
|
||||
<div>
|
||||
<div class="h-4 bg-white/20 rounded w-20 mb-2"></div>
|
||||
<div class="h-6 bg-white/20 rounded w-12"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-effect rounded-lg p-6 animate-pulse">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-white/20 rounded-lg mr-4"></div>
|
||||
<div>
|
||||
<div class="h-4 bg-white/20 rounded w-16 mb-2"></div>
|
||||
<div class="h-6 bg-white/20 rounded w-8"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-effect rounded-lg p-6 animate-pulse">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-white/20 rounded-lg mr-4"></div>
|
||||
<div>
|
||||
<div class="h-4 bg-white/20 rounded w-16 mb-2"></div>
|
||||
<div class="h-6 bg-white/20 rounded w-8"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-effect rounded-lg p-6 animate-pulse">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-white/20 rounded-lg mr-4"></div>
|
||||
<div>
|
||||
<div class="h-4 bg-white/20 rounded w-16 mb-2"></div>
|
||||
<div class="h-6 bg-white/20 rounded w-8"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 스켈레톤 */}
|
||||
<div class="glass-effect rounded-lg p-4 animate-pulse">
|
||||
<div class="space-y-3">
|
||||
<div class="grid grid-cols-7 gap-4">
|
||||
<div class="h-6 bg-white/20 rounded"></div>
|
||||
<div class="h-6 bg-white/20 rounded"></div>
|
||||
<div class="h-6 bg-white/20 rounded"></div>
|
||||
<div class="h-6 bg-white/20 rounded"></div>
|
||||
<div class="h-6 bg-white/20 rounded"></div>
|
||||
<div class="h-6 bg-white/20 rounded"></div>
|
||||
<div class="h-6 bg-white/20 rounded"></div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="grid grid-cols-7 gap-4">
|
||||
<div class="h-4 bg-white/10 rounded"></div>
|
||||
<div class="h-4 bg-white/10 rounded"></div>
|
||||
<div class="h-4 bg-white/10 rounded"></div>
|
||||
<div class="h-4 bg-white/10 rounded"></div>
|
||||
<div class="h-4 bg-white/10 rounded"></div>
|
||||
<div class="h-4 bg-white/10 rounded"></div>
|
||||
<div class="h-4 bg-white/10 rounded"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-7 gap-4">
|
||||
<div class="h-4 bg-white/10 rounded"></div>
|
||||
<div class="h-4 bg-white/10 rounded"></div>
|
||||
<div class="h-4 bg-white/10 rounded"></div>
|
||||
<div class="h-4 bg-white/10 rounded"></div>
|
||||
<div class="h-4 bg-white/10 rounded"></div>
|
||||
<div class="h-4 bg-white/10 rounded"></div>
|
||||
<div class="h-4 bg-white/10 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- React Local -->
|
||||
<script crossorigin src="/lib/js/react.development.js"></script>
|
||||
<script crossorigin src="/lib/js/react-dom.development.js"></script>
|
||||
<script src="/lib/js/babel.min.js"></script>
|
||||
|
||||
<!-- 공통 컴포넌트 로드 -->
|
||||
<script type="text/babel" src="/react/component/CommonNavigation"></script>
|
||||
<script type="text/babel" src="/react/component/DevWarning"></script>
|
||||
|
||||
<!-- 근태관리 컴포넌트 로드 -->
|
||||
<script type="text/babel" src="/react/component/Kuntae"></script>
|
||||
|
||||
<!-- 앱 컴포넌트 -->
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div>
|
||||
<CommonNavigation currentPage="kuntae" />
|
||||
<Kuntae />
|
||||
<DevWarning />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 루트 렌더링
|
||||
const root = ReactDOM.createRoot(document.getElementById('react-kuntae-app'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,177 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>로그인 - GroupWare (React)</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
},
|
||||
danger: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.6s ease-in-out',
|
||||
'slide-up': 'slideUp 0.4s ease-out',
|
||||
'bounce-in': 'bounceIn 0.6s ease-out',
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(20px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
bounceIn: {
|
||||
'0%': { transform: 'scale(0.3)', opacity: '0' },
|
||||
'50%': { transform: 'scale(1.05)' },
|
||||
'70%': { transform: 'scale(0.9)' },
|
||||
'100%': { transform: 'scale(1)', opacity: '1' },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.card-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.input-focus {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.input-focus:focus {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
.floating-label {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.input-field:focus + .floating-label,
|
||||
.input-field:not(:placeholder-shown) + .floating-label {
|
||||
transform: translateY(-1.5rem) scale(0.85);
|
||||
color: #3b82f6;
|
||||
}
|
||||
/* 드롭다운 스타일 */
|
||||
select.input-field option {
|
||||
background-color: #1f2937;
|
||||
color: white;
|
||||
}
|
||||
select.input-field:focus option:checked {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
select.input-field option:hover {
|
||||
background-color: #374151;
|
||||
}
|
||||
/* React 컴포넌트 전용 스타일 */
|
||||
.react-login-container {
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="react-login-app" class="react-login-container">
|
||||
<div class="gradient-bg min-h-screen flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="glass-effect rounded-3xl p-8 card-hover">
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-8 h-8 text-white animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-white mb-2">GroupWare</h1>
|
||||
<p class="text-white/70 text-sm">React 로그인 컴포넌트를 로딩 중입니다...</p>
|
||||
<div class="mt-6">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- React & ReactDOM CDN -->
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
||||
<!-- Babel standalone for JSX -->
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
|
||||
<!-- React Login Component -->
|
||||
<script type="text/babel" src="/react/component/LoginApp"></script>
|
||||
|
||||
<!-- App Initialization -->
|
||||
<script type="text/babel">
|
||||
const root = ReactDOM.createRoot(document.getElementById('react-login-app'));
|
||||
root.render(<LoginApp />);
|
||||
|
||||
console.log('✅ React 로그인 페이지가 성공적으로 마운트되었습니다.');
|
||||
console.log('📁 컴포넌트 파일: /react/LoginApp.jsx');
|
||||
console.log('🌐 페이지 URL: /react/login');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,191 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>프로젝트 관리 - GroupWare (React)</title>
|
||||
<link rel="stylesheet" href="/lib/css/tailwind.min.css">
|
||||
<script src="/lib/js/tailwind-config.js"></script>
|
||||
<style>
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.bg-primary-500 {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
|
||||
.bg-primary-600 {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
.hover\:bg-primary-600:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
.bg-green-500 {
|
||||
background-color: #10b981;
|
||||
}
|
||||
|
||||
.bg-green-600 {
|
||||
background-color: #059669;
|
||||
}
|
||||
|
||||
.hover\:bg-green-600:hover {
|
||||
background-color: #059669;
|
||||
}
|
||||
|
||||
.bg-red-500 {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
.bg-red-600 {
|
||||
background-color: #dc2626;
|
||||
}
|
||||
|
||||
.hover\:bg-red-600:hover {
|
||||
background-color: #dc2626;
|
||||
}
|
||||
|
||||
select option {
|
||||
background-color: #1f2937;
|
||||
color: white;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
select option:hover {
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
select option:checked {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
|
||||
select option:focus {
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
.loading {
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top: 3px solid #fff;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 min-h-screen text-white">
|
||||
<div id="react-project">
|
||||
<!-- 스켈레톤 로딩 UI -->
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="glass-effect rounded-lg overflow-hidden animate-pulse">
|
||||
<div class="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
||||
<div class="h-6 bg-white/20 rounded w-40"></div>
|
||||
<div class="flex space-x-3">
|
||||
<div class="h-10 bg-white/20 rounded w-32"></div>
|
||||
<div class="h-10 bg-white/20 rounded w-24"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 p-4">
|
||||
<div class="bg-white/10 rounded-lg p-4">
|
||||
<div class="h-8 bg-white/20 rounded mb-2"></div>
|
||||
<div class="h-4 bg-white/20 rounded w-16"></div>
|
||||
</div>
|
||||
<div class="bg-white/10 rounded-lg p-4">
|
||||
<div class="h-8 bg-white/20 rounded mb-2"></div>
|
||||
<div class="h-4 bg-white/20 rounded w-16"></div>
|
||||
</div>
|
||||
<div class="bg-white/10 rounded-lg p-4">
|
||||
<div class="h-8 bg-white/20 rounded mb-2"></div>
|
||||
<div class="h-4 bg-white/20 rounded w-16"></div>
|
||||
</div>
|
||||
<div class="bg-white/10 rounded-lg p-4">
|
||||
<div class="h-8 bg-white/20 rounded mb-2"></div>
|
||||
<div class="h-4 bg-white/20 rounded w-16"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
<div class="h-4 bg-white/20 rounded w-full"></div>
|
||||
<div class="h-4 bg-white/20 rounded w-3/4"></div>
|
||||
<div class="h-4 bg-white/20 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- React Local -->
|
||||
<script crossorigin src="/lib/js/react.development.js"></script>
|
||||
<script crossorigin src="/lib/js/react-dom.development.js"></script>
|
||||
<script src="/lib/js/babel.min.js"></script>
|
||||
|
||||
<!-- 공통 컴포넌트 로드 -->
|
||||
<script type="text/babel" src="/react/component/CommonNavigation"></script>
|
||||
<script type="text/babel" src="/react/component/DevWarning"></script>
|
||||
|
||||
<!-- Project 컴포넌트 -->
|
||||
<script type="text/babel" src="/react/component/Project"></script>
|
||||
|
||||
<!-- 앱 초기화 -->
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div>
|
||||
<CommonNavigation currentPage="project" />
|
||||
<Project />
|
||||
<DevWarning />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 루트 렌더링
|
||||
const root = ReactDOM.createRoot(document.getElementById('react-project'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,197 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>React Test Page - GroupWare</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background-color: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.status {
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.success {
|
||||
background-color: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
color: #155724;
|
||||
}
|
||||
.loading {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
color: #856404;
|
||||
}
|
||||
.error {
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
color: #721c24;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🚀 React Integration Test</h1>
|
||||
<p>GroupWare + OWIN + React 통합 테스트</p>
|
||||
</div>
|
||||
|
||||
<div id="react-app">
|
||||
<div class="status loading">
|
||||
React 컴포넌트를 로딩 중입니다...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- React & ReactDOM CDN -->
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
||||
<!-- Babel standalone for JSX -->
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
|
||||
<!-- React Component -->
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
// 메인 React 컴포넌트
|
||||
function ReactTestApp() {
|
||||
const [status, setStatus] = useState('loading');
|
||||
const [counter, setCounter] = useState(0);
|
||||
const [serverTime, setServerTime] = useState('');
|
||||
const [apiTest, setApiTest] = useState({ status: 'pending', message: '' });
|
||||
|
||||
// 컴포넌트가 마운트될 때 실행
|
||||
useEffect(() => {
|
||||
// React가 정상적으로 로드되었음을 표시
|
||||
setTimeout(() => {
|
||||
setStatus('success');
|
||||
setServerTime(new Date().toLocaleString('ko-KR'));
|
||||
}, 1000);
|
||||
|
||||
// API 테스트 (GroupWare의 기존 컨트롤러 테스트)
|
||||
testAPI();
|
||||
}, []);
|
||||
|
||||
// GroupWare API 테스트 함수
|
||||
const testAPI = async () => {
|
||||
try {
|
||||
// Home 컨트롤러 테스트 (기존에 있을 것으로 예상)
|
||||
const response = await fetch('/Home');
|
||||
if (response.ok) {
|
||||
setApiTest({ status: 'success', message: 'API 연결 성공' });
|
||||
} else {
|
||||
setApiTest({ status: 'warning', message: `API 응답: ${response.status}` });
|
||||
}
|
||||
} catch (error) {
|
||||
setApiTest({ status: 'error', message: `API 오류: ${error.message}` });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={`status ${status === 'success' ? 'success' : 'loading'}`}>
|
||||
{status === 'success' ? (
|
||||
<div>
|
||||
<h3>✅ React 컴포넌트가 성공적으로 로드되었습니다!</h3>
|
||||
<p><strong>현재 시간:</strong> {serverTime}</p>
|
||||
</div>
|
||||
) : (
|
||||
<h3>React 컴포넌트를 로딩 중입니다...</h3>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status === 'success' && (
|
||||
<div>
|
||||
<div className="status">
|
||||
<h3>📊 상태 관리 테스트</h3>
|
||||
<p><strong>카운터:</strong> {counter}</p>
|
||||
<button
|
||||
onClick={() => setCounter(counter + 1)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
marginRight: '10px',
|
||||
backgroundColor: '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '5px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
증가 (+1)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCounter(0)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '5px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
리셋
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`status ${
|
||||
apiTest.status === 'success' ? 'success' :
|
||||
apiTest.status === 'error' ? 'error' : 'loading'
|
||||
}`}>
|
||||
<h3>🌐 API 연결 테스트</h3>
|
||||
<p><strong>상태:</strong> {apiTest.message}</p>
|
||||
<button
|
||||
onClick={testAPI}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '5px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
API 다시 테스트
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="status">
|
||||
<h3>📋 통합 테스트 체크리스트</h3>
|
||||
<ul style={{ textAlign: 'left' }}>
|
||||
<li>✅ OWIN 정적 파일 서빙</li>
|
||||
<li>✅ React 라이브러리 로딩 (CDN)</li>
|
||||
<li>✅ JSX 컴파일 (Babel)</li>
|
||||
<li>✅ React Hooks (useState, useEffect)</li>
|
||||
<li>✅ 이벤트 핸들링</li>
|
||||
<li>✅ API 호출 (fetch)</li>
|
||||
<li>✅ 반응형 UI 업데이트</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// React 컴포넌트를 DOM에 렌더링
|
||||
const root = ReactDOM.createRoot(document.getElementById('react-app'));
|
||||
root.render(<ReactTestApp />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,115 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<meta name="version" content="v1.0-20250127">
|
||||
<title>할일 관리 - GroupWare (React)</title>
|
||||
<link rel="stylesheet" href="/lib/css/tailwind.min.css">
|
||||
<script src="/lib/js/tailwind-config.js"></script>
|
||||
<style>
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.card-hover:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.loading {
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top: 3px solid #fff;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 min-h-screen text-white">
|
||||
<div id="react-todo">
|
||||
<!-- 스켈레톤 로딩 UI -->
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="glass-effect rounded-2xl overflow-hidden animate-pulse">
|
||||
<div class="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
||||
<div class="h-6 bg-white/20 rounded w-40"></div>
|
||||
<div class="h-10 bg-white/20 rounded w-32"></div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
<div class="h-4 bg-white/20 rounded w-full"></div>
|
||||
<div class="h-4 bg-white/20 rounded w-3/4"></div>
|
||||
<div class="h-4 bg-white/20 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- React Local -->
|
||||
<script crossorigin src="/lib/js/react.development.js"></script>
|
||||
<script crossorigin src="/lib/js/react-dom.development.js"></script>
|
||||
<script src="/lib/js/babel.min.js"></script>
|
||||
|
||||
<!-- 공통 컴포넌트 로드 -->
|
||||
<script type="text/babel" src="/react/component/CommonNavigation"></script>
|
||||
<script type="text/babel" src="/react/component/DevWarning"></script>
|
||||
|
||||
<!-- Todo 컴포넌트 -->
|
||||
<script type="text/babel" src="/react/component/Todo"></script>
|
||||
|
||||
<!-- 앱 초기화 -->
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div>
|
||||
<CommonNavigation currentPage="todo" />
|
||||
<Todo />
|
||||
<DevWarning />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 루트 렌더링
|
||||
const root = ReactDOM.createRoot(document.getElementById('react-todo'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user