feat: React 프론트엔드 기능 대폭 확장
- 월별근무표: 휴일/근무일 관리, 자동 초기화 - 메일양식: 템플릿 CRUD, To/CC/BCC 설정 - 그룹정보: 부서 관리, 비트 연산 기반 권한 설정 - 업무일지: 수정 성공 메시지 제거, 오늘 근무시간 필터링 수정 - 웹소켓 메시지 type 충돌 버그 수정 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
341
Project/frontend/src/pages/CommonCode.tsx
Normal file
341
Project/frontend/src/pages/CommonCode.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Save, Trash2, RefreshCw, FolderOpen, Edit2 } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { comms } from '@/communication';
|
||||
import { CommonCodeGroup, CommonCode } from '@/types';
|
||||
|
||||
export function CommonCodePage() {
|
||||
const [groups, setGroups] = useState<CommonCodeGroup[]>([]);
|
||||
const [codes, setCodes] = useState<CommonCode[]>([]);
|
||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||||
const [selectedCode, setSelectedCode] = useState<CommonCode | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editData, setEditData] = useState<Partial<CommonCode>>({});
|
||||
|
||||
useEffect(() => {
|
||||
loadGroups();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedGroup) {
|
||||
loadCodes();
|
||||
setSelectedCode(null);
|
||||
}
|
||||
}, [selectedGroup]);
|
||||
|
||||
const loadGroups = async () => {
|
||||
try {
|
||||
const result = await comms.getCommonGroups();
|
||||
setGroups(result);
|
||||
} catch (error) {
|
||||
console.error('그룹 로드 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCodes = async () => {
|
||||
if (!selectedGroup) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await comms.getCommonList(selectedGroup);
|
||||
setCodes(result);
|
||||
} catch (error) {
|
||||
console.error('코드 로드 실패:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedCode) return;
|
||||
try {
|
||||
const saveData = { ...selectedCode, ...editData } as CommonCode;
|
||||
const response = await comms.saveCommon(saveData);
|
||||
if (response.Success) {
|
||||
setIsEditing(false);
|
||||
setEditData({});
|
||||
loadCodes();
|
||||
} else {
|
||||
alert(response.Message || '저장 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedCode || selectedCode.idx === 0) return;
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const response = await comms.deleteCommon(selectedCode.idx);
|
||||
if (response.Success) {
|
||||
setSelectedCode(null);
|
||||
setIsEditing(false);
|
||||
loadCodes();
|
||||
} else {
|
||||
alert(response.Message || '삭제 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddNew = () => {
|
||||
if (!selectedGroup) {
|
||||
alert('그룹을 먼저 선택하세요.');
|
||||
return;
|
||||
}
|
||||
const newCode: CommonCode = {
|
||||
idx: 0,
|
||||
grp: selectedGroup,
|
||||
code: '',
|
||||
svalue: '',
|
||||
ivalue: 0,
|
||||
fvalue: 0,
|
||||
memo: '',
|
||||
};
|
||||
setSelectedCode(newCode);
|
||||
setEditData(newCode);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (selectedCode?.idx === 0) {
|
||||
setSelectedCode(null);
|
||||
}
|
||||
setIsEditing(false);
|
||||
setEditData({});
|
||||
};
|
||||
|
||||
const selectedGroupName = groups.find((g) => g.code === selectedGroup)?.memo || '';
|
||||
|
||||
return (
|
||||
<div className="h-full flex gap-4">
|
||||
{/* 좌측: 그룹 목록 */}
|
||||
<div className="w-80 flex flex-col gap-4">
|
||||
{/* 그룹 목록 */}
|
||||
<div className="glass-effect rounded-xl flex-1 overflow-hidden flex flex-col">
|
||||
<div className="p-3 border-b border-white/10">
|
||||
<h2 className="text-sm font-semibold text-white/70">코드 그룹</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{groups.map((g) => (
|
||||
<button
|
||||
key={g.code}
|
||||
onClick={() => setSelectedGroup(g.code)}
|
||||
className={clsx(
|
||||
'w-full px-3 py-2 text-left text-sm flex items-center gap-2 transition-colors',
|
||||
selectedGroup === g.code
|
||||
? 'bg-blue-600/30 text-white border-l-2 border-blue-500'
|
||||
: 'text-white/70 hover:bg-white/5 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<FolderOpen className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">[{g.code}] {g.memo}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 중앙: 코드 목록 */}
|
||||
<div className="w-72 flex flex-col">
|
||||
<div className="glass-effect rounded-xl flex-1 overflow-hidden flex flex-col">
|
||||
<div className="p-3 border-b border-white/10 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white">{selectedGroupName || '그룹 선택'}</h2>
|
||||
<p className="text-xs text-white/50">{codes.length}건</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={loadCodes}
|
||||
className="p-1.5 bg-white/10 hover:bg-white/20 rounded text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddNew}
|
||||
className="p-1.5 bg-blue-600 hover:bg-blue-700 rounded text-white transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||
</div>
|
||||
) : selectedGroup ? (
|
||||
codes.length > 0 ? (
|
||||
codes.map((code) => (
|
||||
<button
|
||||
key={code.idx || 'new'}
|
||||
onClick={() => {
|
||||
setSelectedCode(code);
|
||||
setIsEditing(false);
|
||||
setEditData({});
|
||||
}}
|
||||
className={clsx(
|
||||
'w-full px-3 py-2 text-left border-b border-white/5 transition-colors',
|
||||
selectedCode?.idx === code.idx
|
||||
? 'bg-blue-600/20 border-l-2 border-l-blue-500'
|
||||
: 'hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<div className="text-sm text-white font-medium">{code.code}</div>
|
||||
<div className="text-xs text-white/50 truncate">{code.memo || code.svalue}</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="p-4 text-center text-white/50 text-sm">
|
||||
데이터가 없습니다.
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="p-4 text-center text-white/50 text-sm">
|
||||
그룹을 선택하세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 상세 정보 */}
|
||||
<div className="flex-1 glass-effect rounded-xl overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-white/10 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{selectedCode ? (isEditing ? '코드 편집' : '코드 상세') : '코드 선택'}
|
||||
</h2>
|
||||
{selectedCode && !isEditing && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditing(true);
|
||||
setEditData(selectedCode);
|
||||
}}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 rounded text-white text-sm transition-colors"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-red-600/20 hover:bg-red-600/40 rounded text-red-400 text-sm transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{selectedCode ? (
|
||||
<div className="space-y-4">
|
||||
{/* 코드 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">코드</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.code ?? selectedCode.code}
|
||||
onChange={(e) => setEditData({ ...editData, code: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 py-2 bg-white/5 rounded-lg text-white">{selectedCode.code}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 값(S) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">값 (String)</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.svalue ?? selectedCode.svalue}
|
||||
onChange={(e) => setEditData({ ...editData, svalue: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 py-2 bg-white/5 rounded-lg text-white">{selectedCode.svalue || '-'}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 값(I) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">값 (Integer)</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editData.ivalue ?? selectedCode.ivalue}
|
||||
onChange={(e) => setEditData({ ...editData, ivalue: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 py-2 bg-white/5 rounded-lg text-white">{selectedCode.ivalue}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 값(F) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">값 (Float)</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={editData.fvalue ?? selectedCode.fvalue}
|
||||
onChange={(e) => setEditData({ ...editData, fvalue: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 py-2 bg-white/5 rounded-lg text-white">{selectedCode.fvalue}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">설명</label>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editData.memo ?? selectedCode.memo}
|
||||
onChange={(e) => setEditData({ ...editData, memo: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white resize-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 py-2 bg-white/5 rounded-lg text-white min-h-[80px]">{selectedCode.memo || '-'}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 편집 모드 버튼 */}
|
||||
{isEditing && (
|
||||
<div className="flex items-center gap-2 pt-4">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex items-center gap-1 px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-white transition-colors"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
저장
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-white/50">
|
||||
좌측에서 코드를 선택하세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
402
Project/frontend/src/pages/Dashboard.tsx
Normal file
402
Project/frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ShoppingCart,
|
||||
FileCheck,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Flag,
|
||||
RefreshCw,
|
||||
ClipboardList,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { TodoModel, PurchaseItem } from '@/types';
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: number | string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon, color, onClick }: StatCardProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`glass-effect rounded-2xl p-6 card-hover ${onClick ? 'cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white/60 text-sm font-medium">{title}</p>
|
||||
<p className={`text-3xl font-bold mt-2 ${color}`}>{value}</p>
|
||||
</div>
|
||||
<div className={`p-3 rounded-xl ${color.replace('text-', 'bg-').replace('-400', '-500/20')}`}>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// 통계 데이터
|
||||
const [purchaseNR, setPurchaseNR] = useState(0);
|
||||
const [purchaseCR, setPurchaseCR] = useState(0);
|
||||
const [todoCount, setTodoCount] = useState(0);
|
||||
const [todayWorkHrs, setTodayWorkHrs] = useState(0);
|
||||
|
||||
// 목록 데이터
|
||||
const [urgentTodos, setUrgentTodos] = useState<TodoModel[]>([]);
|
||||
const [purchaseNRList, setPurchaseNRList] = useState<PurchaseItem[]>([]);
|
||||
const [purchaseCRList, setPurchaseCRList] = useState<PurchaseItem[]>([]);
|
||||
|
||||
// 모달 상태
|
||||
const [showNRModal, setShowNRModal] = useState(false);
|
||||
const [showCRModal, setShowCRModal] = useState(false);
|
||||
|
||||
const loadDashboardData = useCallback(async () => {
|
||||
try {
|
||||
// 오늘 날짜 (로컬 시간 기준)
|
||||
const now = new Date();
|
||||
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// 현재 로그인 사용자 ID 가져오기
|
||||
let currentUserId = '';
|
||||
try {
|
||||
const loginStatus = await comms.checkLoginStatus();
|
||||
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
|
||||
currentUserId = loginStatus.User.Id;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('로그인 정보 로드 오류:', error);
|
||||
}
|
||||
|
||||
// 병렬로 데이터 로드
|
||||
const [
|
||||
purchaseCount,
|
||||
urgentTodosResponse,
|
||||
allTodosResponse,
|
||||
jobreportResponse,
|
||||
] = await Promise.all([
|
||||
comms.getPurchaseWaitCount(),
|
||||
comms.getUrgentTodos(),
|
||||
comms.getTodos(),
|
||||
comms.getJobReportList(todayStr, todayStr, currentUserId, ''),
|
||||
]);
|
||||
|
||||
setPurchaseNR(purchaseCount.NR);
|
||||
setPurchaseCR(purchaseCount.CR);
|
||||
|
||||
if (urgentTodosResponse.Success && urgentTodosResponse.Data) {
|
||||
setUrgentTodos(urgentTodosResponse.Data.slice(0, 5));
|
||||
}
|
||||
|
||||
if (allTodosResponse.Success && allTodosResponse.Data) {
|
||||
// 진행, 대기 상태의 할일만 카운트 (보류, 취소 제외)
|
||||
const pendingCount = allTodosResponse.Data.filter(t => t.status === '0' || t.status === '1').length;
|
||||
setTodoCount(pendingCount);
|
||||
}
|
||||
|
||||
// 오늘 업무일지 작성시간 계산
|
||||
if (jobreportResponse.Success && jobreportResponse.Data) {
|
||||
const totalHrs = jobreportResponse.Data.reduce((acc, item) => acc + (item.hrs || 0), 0);
|
||||
setTodayWorkHrs(totalHrs);
|
||||
} else {
|
||||
setTodayWorkHrs(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('대시보드 데이터 로드 오류:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadNRList = async () => {
|
||||
try {
|
||||
const list = await comms.getPurchaseNRList();
|
||||
setPurchaseNRList(list);
|
||||
setShowNRModal(true);
|
||||
} catch (error) {
|
||||
console.error('NR 목록 로드 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCRList = async () => {
|
||||
try {
|
||||
const list = await comms.getPurchaseCRList();
|
||||
setPurchaseCRList(list);
|
||||
setShowCRModal(true);
|
||||
} catch (error) {
|
||||
console.error('CR 목록 로드 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardData();
|
||||
|
||||
// 30초마다 자동 새로고침
|
||||
const interval = setInterval(loadDashboardData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadDashboardData]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
loadDashboardData();
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case '0': return '대기';
|
||||
case '1': return '진행';
|
||||
case '2': return '취소';
|
||||
case '3': return '보류';
|
||||
case '5': return '완료';
|
||||
default: return '대기';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusClass = (status: string) => {
|
||||
switch (status) {
|
||||
case '0': return 'bg-gray-500/20 text-gray-300';
|
||||
case '1': return 'bg-primary-500/20 text-primary-300';
|
||||
case '2': return 'bg-danger-500/20 text-danger-300';
|
||||
case '3': return 'bg-warning-500/20 text-warning-300';
|
||||
case '5': return 'bg-success-500/20 text-success-300';
|
||||
default: return 'bg-white/10 text-white/50';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityText = (seqno: number) => {
|
||||
switch (seqno) {
|
||||
case 1: return '중요';
|
||||
case 2: return '매우 중요';
|
||||
case 3: return '긴급';
|
||||
default: return '보통';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityClass = (seqno: number) => {
|
||||
switch (seqno) {
|
||||
case 1: return 'bg-primary-500/20 text-primary-300';
|
||||
case 2: return 'bg-warning-500/20 text-warning-300';
|
||||
case 3: return 'bg-danger-500/20 text-danger-300';
|
||||
default: return 'bg-white/10 text-white/50';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-white">오늘의 현황</h2>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="flex items-center space-x-2 px-4 py-2 glass-effect rounded-lg text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
<span>새로고침</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
title="구매요청 (NR)"
|
||||
value={purchaseNR}
|
||||
icon={<ShoppingCart className="w-6 h-6 text-primary-400" />}
|
||||
color="text-primary-400"
|
||||
onClick={loadNRList}
|
||||
/>
|
||||
<StatCard
|
||||
title="구매요청 (CR)"
|
||||
value={purchaseCR}
|
||||
icon={<FileCheck className="w-6 h-6 text-success-400" />}
|
||||
color="text-success-400"
|
||||
onClick={loadCRList}
|
||||
/>
|
||||
<StatCard
|
||||
title="미완료 할일"
|
||||
value={todoCount}
|
||||
icon={<ClipboardList className="w-6 h-6 text-warning-400" />}
|
||||
color="text-warning-400"
|
||||
onClick={() => navigate('/todo')}
|
||||
/>
|
||||
<StatCard
|
||||
title="금일 업무일지"
|
||||
value={`${todayWorkHrs}시간`}
|
||||
icon={<Clock className="w-6 h-6 text-cyan-400" />}
|
||||
color="text-cyan-400"
|
||||
onClick={() => navigate('/jobreport')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 급한 할일 목록 */}
|
||||
<div className="glass-effect rounded-2xl overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center">
|
||||
<AlertTriangle className="w-5 h-5 mr-2 text-warning-400" />
|
||||
급한 할일
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => navigate('/todo')}
|
||||
className="text-sm text-primary-400 hover:text-primary-300 transition-colors"
|
||||
>
|
||||
전체보기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-white/10">
|
||||
{urgentTodos.length > 0 ? (
|
||||
urgentTodos.map((todo) => (
|
||||
<div
|
||||
key={todo.idx}
|
||||
className="px-6 py-4 hover:bg-white/5 transition-colors cursor-pointer"
|
||||
onClick={() => navigate('/todo')}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
{todo.flag && (
|
||||
<Flag className="w-4 h-4 text-warning-400" />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-white font-medium">
|
||||
{todo.title || '제목 없음'}
|
||||
</p>
|
||||
<p className="text-white/60 text-sm line-clamp-1">
|
||||
{todo.remark}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getPriorityClass(todo.seqno)}`}>
|
||||
{getPriorityText(todo.seqno)}
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(todo.status)}`}>
|
||||
{getStatusText(todo.status)}
|
||||
</span>
|
||||
{todo.expire && (
|
||||
<span className={`text-xs ${new Date(todo.expire) < new Date() ? 'text-danger-400' : 'text-white/60'}`}>
|
||||
{new Date(todo.expire).toLocaleDateString('ko-KR')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="px-6 py-8 text-center text-white/50">
|
||||
<CheckCircle className="w-12 h-12 mx-auto mb-3 text-success-400/50" />
|
||||
<p>급한 할일이 없습니다</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NR 모달 */}
|
||||
{showNRModal && (
|
||||
<Modal title="구매요청 (NR) 목록" onClose={() => setShowNRModal(false)}>
|
||||
<PurchaseTable data={purchaseNRList} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* CR 모달 */}
|
||||
{showCRModal && (
|
||||
<Modal title="구매요청 (CR) 목록" onClose={() => setShowCRModal(false)}>
|
||||
<PurchaseTable data={purchaseCRList} />
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 모달 컴포넌트
|
||||
interface ModalProps {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Modal({ title, onClose, children }: ModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="glass-effect rounded-2xl w-full max-w-4xl max-h-[80vh] overflow-hidden animate-slide-up">
|
||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[calc(80vh-80px)]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 구매 테이블 컴포넌트
|
||||
function PurchaseTable({ data }: { data: PurchaseItem[] }) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="px-6 py-8 text-center text-white/50">
|
||||
대기 중인 구매요청이 없습니다
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">요청일</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">공정</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">품명</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">규격</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-white/70 uppercase">수량</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-white/70 uppercase">단가</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-white/70 uppercase">금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{data.map((item, idx) => (
|
||||
<tr key={idx} className="hover:bg-white/5">
|
||||
<td className="px-4 py-3 text-white/80 text-sm">{item.pdate}</td>
|
||||
<td className="px-4 py-3 text-white/80 text-sm">{item.process}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.pumname}</td>
|
||||
<td className="px-4 py-3 text-white/80 text-sm">{item.pumscale}</td>
|
||||
<td className="px-4 py-3 text-white/80 text-sm text-right">
|
||||
{item.pumqtyreq?.toLocaleString()} {item.pumunit}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white/80 text-sm text-right">
|
||||
{item.pumprice?.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white text-sm text-right font-medium">
|
||||
{item.pumamt?.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
230
Project/frontend/src/pages/Items.tsx
Normal file
230
Project/frontend/src/pages/Items.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Search, Plus, Package } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { comms } from '@/communication';
|
||||
import { ItemInfo } from '@/types';
|
||||
import { ItemEditDialog } from '@/components/items';
|
||||
|
||||
export function ItemsPage() {
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [items, setItems] = useState<ItemInfo[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<ItemInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const result = await comms.getItemCategories();
|
||||
if (result.Success && result.Data) {
|
||||
setCategories(result.Data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('카테고리 로드 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadItems = async () => {
|
||||
if (!searchKey.trim()) {
|
||||
alert('검색어를 입력하세요');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await comms.getItemList(selectedCategory, searchKey);
|
||||
setItems(result);
|
||||
} catch (error) {
|
||||
console.error('품목 로드 실패:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (item: ItemInfo) => {
|
||||
const response = await comms.saveItem(item);
|
||||
if (response.Success) {
|
||||
setDialogOpen(false);
|
||||
setSelectedItem(null);
|
||||
if (searchKey) loadItems();
|
||||
} else {
|
||||
alert(response.Message || '저장 실패');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (idx: number) => {
|
||||
const response = await comms.deleteItem(idx);
|
||||
if (response.Success) {
|
||||
setDialogOpen(false);
|
||||
setSelectedItem(null);
|
||||
setItems(items.filter((i) => i.idx !== idx));
|
||||
} else {
|
||||
alert(response.Message || '삭제 실패');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddNew = () => {
|
||||
const newItem: ItemInfo = {
|
||||
idx: 0,
|
||||
sid: '',
|
||||
cate: selectedCategory !== 'all' ? selectedCategory : '',
|
||||
name: '',
|
||||
model: '',
|
||||
scale: '',
|
||||
unit: '',
|
||||
price: 0,
|
||||
supply: '',
|
||||
manu: '',
|
||||
storage: '',
|
||||
disable: false,
|
||||
memo: '',
|
||||
};
|
||||
setSelectedItem(newItem);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleRowClick = (item: ItemInfo) => {
|
||||
setSelectedItem(item);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const filteredItems = items.filter(
|
||||
(i) =>
|
||||
(i.sid ?? '').toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(i.name ?? '').toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(i.model ?? '').toLowerCase().includes(filter.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="glass-effect rounded-xl p-4 mb-4">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white min-w-[150px]"
|
||||
>
|
||||
<option value="all" className="bg-slate-800">-- 전체 --</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c} value={c} className="bg-slate-800">{c}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchKey}
|
||||
onChange={(e) => setSearchKey(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && loadItems()}
|
||||
placeholder="품목명/SID/모델 검색..."
|
||||
className="w-full pl-9 pr-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadItems}
|
||||
className="flex items-center gap-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white transition-colors"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
검색
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="필터..."
|
||||
className="px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40 w-32"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddNew}
|
||||
className="flex items-center gap-1 px-3 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-white transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="glass-effect rounded-xl flex-1 overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-white/10 flex items-center gap-2">
|
||||
<Package className="w-5 h-5 text-white/70" />
|
||||
<h2 className="text-lg font-semibold text-white">품목 목록</h2>
|
||||
<span className="text-sm text-white/50">({filteredItems.length}건)</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-white/5 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-28">SID</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-20">분류</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70">품명</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70">모델</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-white/70 w-24">단가</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-24">공급처</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-24">제조사</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{filteredItems.map((item) => (
|
||||
<tr
|
||||
key={item.idx || 'new'}
|
||||
onClick={() => handleRowClick(item)}
|
||||
className={clsx(
|
||||
'hover:bg-white/10 transition-colors cursor-pointer',
|
||||
item.disable && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<td className="px-3 py-2 text-white font-mono">{item.sid}</td>
|
||||
<td className="px-3 py-2 text-white/70">{item.cate}</td>
|
||||
<td className="px-3 py-2 text-white">{item.name}</td>
|
||||
<td className="px-3 py-2 text-white/70">{item.model}</td>
|
||||
<td className="px-3 py-2 text-white text-right">{(item.price ?? 0).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-white/70">{item.supply}</td>
|
||||
<td className="px-3 py-2 text-white/70">{item.manu}</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredItems.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-white/50">
|
||||
{items.length === 0 ? '검색어를 입력하고 검색 버튼을 클릭하세요.' : '검색 결과가 없습니다.'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 편집 다이얼로그 */}
|
||||
<ItemEditDialog
|
||||
item={selectedItem}
|
||||
isOpen={dialogOpen}
|
||||
onClose={() => {
|
||||
setDialogOpen(false);
|
||||
setSelectedItem(null);
|
||||
}}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
603
Project/frontend/src/pages/Jobreport.tsx
Normal file
603
Project/frontend/src/pages/Jobreport.tsx
Normal file
@@ -0,0 +1,603 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Copy,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { JobReportItem, JobReportUser } from '@/types';
|
||||
import { JobreportEditModal, JobreportFormData, initialFormData } from '@/components/jobreport/JobreportEditModal';
|
||||
|
||||
export function Jobreport() {
|
||||
const [jobreportList, setJobreportList] = useState<JobReportItem[]>([]);
|
||||
const [users, setUsers] = useState<JobReportUser[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
// 검색 조건
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [selectedUser, setSelectedUser] = useState('');
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
|
||||
// 모달 상태
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<JobReportItem | null>(null);
|
||||
const [formData, setFormData] = useState<JobreportFormData>(initialFormData);
|
||||
|
||||
// 페이징 상태
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 15;
|
||||
|
||||
// 권한 상태
|
||||
const [canViewOT, setCanViewOT] = useState(false);
|
||||
|
||||
// 오늘 근무시간 상태
|
||||
const [todayWork, setTodayWork] = useState({ hrs: 0, ot: 0 });
|
||||
|
||||
// 날짜 포맷 헬퍼 함수 (로컬 시간 기준)
|
||||
const formatDateLocal = (date: Date) => {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// 오늘 근무시간 로드
|
||||
const loadTodayWork = useCallback(async (userId: string) => {
|
||||
const todayStr = formatDateLocal(new Date());
|
||||
try {
|
||||
const response = await comms.getJobReportList(todayStr, todayStr, userId, '');
|
||||
if (response.Success && response.Data) {
|
||||
// 웹소켓 모드에서 응답 혼선 방지를 위해 오늘 날짜 데이터만 필터링
|
||||
const todayData = response.Data.filter(item => {
|
||||
const itemDate = item.pdate?.substring(0, 10);
|
||||
return itemDate === todayStr;
|
||||
});
|
||||
const work = todayData.reduce((acc, item) => ({
|
||||
hrs: acc.hrs + (item.hrs || 0),
|
||||
ot: acc.ot + (item.ot || 0)
|
||||
}), { hrs: 0, ot: 0 });
|
||||
setTodayWork(work);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('오늘 근무시간 로드 오류:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 초기화 완료 플래그
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
// 날짜 및 사용자 정보 초기화
|
||||
useEffect(() => {
|
||||
const initialize = async () => {
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
const sd = formatDateLocal(startOfMonth);
|
||||
const ed = formatDateLocal(endOfMonth);
|
||||
|
||||
setStartDate(sd);
|
||||
setEndDate(ed);
|
||||
|
||||
// 현재 로그인 사용자 정보 로드
|
||||
let userId = '';
|
||||
try {
|
||||
const loginStatus = await comms.checkLoginStatus();
|
||||
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
|
||||
userId = loginStatus.User.Id;
|
||||
setSelectedUser(userId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('로그인 정보 로드 오류:', error);
|
||||
}
|
||||
|
||||
// 사용자 목록 로드
|
||||
loadUsers();
|
||||
|
||||
// 권한 로드 (본인 조회이므로 canViewOT = true)
|
||||
try {
|
||||
const perm = await comms.getJobReportPermission(userId);
|
||||
if (perm.Success) {
|
||||
setCanViewOT(perm.CanViewOT);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('권한 정보 로드 오류:', error);
|
||||
}
|
||||
|
||||
// 초기화 완료 표시
|
||||
setInitialized(true);
|
||||
};
|
||||
|
||||
initialize();
|
||||
}, []);
|
||||
|
||||
// 초기화 완료 후 조회 실행
|
||||
useEffect(() => {
|
||||
if (initialized && startDate && endDate && selectedUser) {
|
||||
handleSearchAndLoadToday();
|
||||
}
|
||||
}, [initialized, startDate, endDate, selectedUser]);
|
||||
|
||||
// 검색 + 오늘 근무시간 로드 (순차 실행)
|
||||
const handleSearchAndLoadToday = async () => {
|
||||
await handleSearch();
|
||||
loadTodayWork(selectedUser);
|
||||
};
|
||||
|
||||
// 사용자 목록 로드
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const result = await comms.getJobReportUsers();
|
||||
setUsers(result || []);
|
||||
} catch (error) {
|
||||
console.error('사용자 목록 로드 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
if (!startDate || !endDate) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await comms.getJobReportList(startDate, endDate, selectedUser, searchKey);
|
||||
if (response.Success && response.Data) {
|
||||
setJobreportList(response.Data);
|
||||
} else {
|
||||
setJobreportList([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('업무일지 목록 로드 오류:', error);
|
||||
alert('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [startDate, endDate, selectedUser, searchKey]);
|
||||
|
||||
// 검색
|
||||
const handleSearch = async () => {
|
||||
if (new Date(startDate) > new Date(endDate)) {
|
||||
alert('시작일은 종료일보다 늦을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
// 선택된 담당자에 따라 권한 재확인
|
||||
try {
|
||||
const perm = await comms.getJobReportPermission(selectedUser);
|
||||
if (perm.Success) {
|
||||
setCanViewOT(perm.CanViewOT);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('권한 정보 로드 오류:', error);
|
||||
}
|
||||
await loadData();
|
||||
};
|
||||
|
||||
// 새 업무일지 추가 모달
|
||||
const openAddModal = () => {
|
||||
setEditingItem(null);
|
||||
setFormData(initialFormData);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
// 복사하여 새 업무일지 생성 모달
|
||||
const openCopyModal = async (item: JobReportItem, e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // 행 클릭 이벤트 방지
|
||||
try {
|
||||
const response = await comms.getJobReportDetail(item.idx);
|
||||
if (response.Success && response.Data) {
|
||||
const data = response.Data;
|
||||
setEditingItem(null); // 새로 추가하는 것이므로 null
|
||||
setFormData({
|
||||
pdate: new Date().toISOString().split('T')[0], // 오늘 날짜
|
||||
projectName: data.projectName || '',
|
||||
requestpart: data.requestpart || '',
|
||||
package: data.package || '',
|
||||
type: data.type || '',
|
||||
process: data.process || '',
|
||||
status: data.status || '진행 완료',
|
||||
description: data.description || '',
|
||||
hrs: 0, // 시간 초기화
|
||||
ot: 0, // OT 초기화
|
||||
jobgrp: '',
|
||||
tag: '',
|
||||
});
|
||||
setShowModal(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('업무일지 조회 오류:', error);
|
||||
alert('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 편집 모달
|
||||
const openEditModal = async (item: JobReportItem) => {
|
||||
try {
|
||||
const response = await comms.getJobReportDetail(item.idx);
|
||||
if (response.Success && response.Data) {
|
||||
const data = response.Data;
|
||||
setEditingItem(data);
|
||||
setFormData({
|
||||
pdate: data.pdate ? data.pdate.split('T')[0] : '',
|
||||
projectName: data.projectName || '',
|
||||
requestpart: data.requestpart || '',
|
||||
package: data.package || '',
|
||||
type: data.type || '',
|
||||
process: data.process || '',
|
||||
status: data.status || '진행 완료',
|
||||
description: data.description || '',
|
||||
hrs: data.hrs || 0,
|
||||
ot: data.ot || 0,
|
||||
jobgrp: '', // 뷰에 없는 필드
|
||||
tag: '', // 뷰에 없는 필드
|
||||
});
|
||||
setShowModal(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('업무일지 조회 오류:', error);
|
||||
alert('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (!formData.pdate) {
|
||||
alert('날짜를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!formData.projectName.trim()) {
|
||||
alert('프로젝트명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
let response;
|
||||
if (editingItem) {
|
||||
const itemIdx = editingItem.idx ?? (editingItem as unknown as Record<string, unknown>)['Idx'] as number;
|
||||
if (!itemIdx) {
|
||||
alert('수정할 항목의 ID를 찾을 수 없습니다.');
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
response = await comms.editJobReport(
|
||||
itemIdx,
|
||||
formData.pdate || '',
|
||||
formData.projectName || '',
|
||||
formData.requestpart || '',
|
||||
formData.package || '',
|
||||
formData.type || '',
|
||||
formData.process || '',
|
||||
formData.status || '진행 완료',
|
||||
formData.description || '',
|
||||
formData.hrs || 0,
|
||||
formData.ot || 0,
|
||||
formData.jobgrp || '',
|
||||
formData.tag || ''
|
||||
);
|
||||
} else {
|
||||
response = await comms.addJobReport(
|
||||
formData.pdate || '',
|
||||
formData.projectName || '',
|
||||
formData.requestpart || '',
|
||||
formData.package || '',
|
||||
formData.type || '',
|
||||
formData.process || '',
|
||||
formData.status || '진행 완료',
|
||||
formData.description || '',
|
||||
formData.hrs || 0,
|
||||
formData.ot || 0,
|
||||
formData.jobgrp || '',
|
||||
formData.tag || ''
|
||||
);
|
||||
}
|
||||
|
||||
if (response.Success) {
|
||||
setShowModal(false);
|
||||
loadData();
|
||||
loadTodayWork(selectedUser);
|
||||
} else {
|
||||
alert(response.Message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 오류:', error);
|
||||
alert('서버 연결에 실패했습니다: ' + (error instanceof Error ? error.message : String(error)));
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('정말로 이 업무일지를 삭제하시겠습니까?')) return;
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await comms.deleteJobReport(id);
|
||||
if (response.Success) {
|
||||
alert('삭제되었습니다.');
|
||||
loadData();
|
||||
loadTodayWork(selectedUser);
|
||||
} else {
|
||||
alert(response.Message || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('삭제 오류:', error);
|
||||
alert('서버 연결에 실패했습니다.');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 날짜 포맷 (YY.MM.DD)
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-';
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
const yy = String(date.getFullYear()).slice(-2);
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
return `${yy}.${mm}.${dd}`;
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
// 페이징 계산
|
||||
const totalPages = Math.ceil(jobreportList.length / pageSize);
|
||||
const paginatedList = jobreportList.slice(
|
||||
(currentPage - 1) * pageSize,
|
||||
currentPage * pageSize
|
||||
);
|
||||
|
||||
// 검색 시 페이지 초기화
|
||||
const handleSearchWithReset = () => {
|
||||
setCurrentPage(1);
|
||||
handleSearch();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* 검색 필터 */}
|
||||
<div className="glass-effect rounded-2xl p-6">
|
||||
<div className="flex gap-6">
|
||||
{/* 좌측: 필터 영역 */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* 필터 입력 영역: 2행 2열 */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
{/* 1행: 시작일, 담당자 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-white/70 text-sm font-medium whitespace-nowrap w-12">시작일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-36 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-white/70 text-sm font-medium whitespace-nowrap w-12">담당자</label>
|
||||
<select
|
||||
value={selectedUser}
|
||||
onChange={(e) => setSelectedUser(e.target.value)}
|
||||
className="w-44 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
>
|
||||
<option value="" className="bg-gray-800">전체</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id} className="bg-gray-800">
|
||||
{user.name}({user.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* 2행: 종료일, 검색어 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-white/70 text-sm font-medium whitespace-nowrap w-12">종료일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-36 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-white/70 text-sm font-medium whitespace-nowrap w-12">검색어</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchKey}
|
||||
onChange={(e) => setSearchKey(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearchWithReset()}
|
||||
placeholder="프로젝트, 내용 등"
|
||||
className="w-44 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역: 우측 수직 배치 */}
|
||||
<div className="grid grid-rows-2 gap-y-3">
|
||||
<button
|
||||
onClick={handleSearchWithReset}
|
||||
disabled={loading}
|
||||
className="h-10 bg-primary-500 hover:bg-primary-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
조회
|
||||
</button>
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
className="h-10 bg-success-500 hover:bg-success-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
새 업무일지
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 오늘 근무시간 */}
|
||||
<div className="flex-shrink-0 w-48">
|
||||
<div className="bg-white/10 rounded-xl p-4 h-full flex flex-col justify-center">
|
||||
<div className="text-white/70 text-sm font-medium mb-2 text-center">오늘 근무시간</div>
|
||||
<div className="text-center">
|
||||
<span className="text-3xl font-bold text-white">{todayWork.hrs}</span>
|
||||
<span className="text-white/70 text-lg ml-1">시간</span>
|
||||
</div>
|
||||
{todayWork.ot > 0 && (
|
||||
<div className="text-center mt-1">
|
||||
<span className="text-warning-400 text-sm">OT: {todayWork.ot}시간</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 테이블 */}
|
||||
<div className="glass-effect rounded-2xl overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center">
|
||||
<FileText className="w-5 h-5 mr-2" />
|
||||
업무일지 목록
|
||||
</h3>
|
||||
<span className="text-white/60 text-sm">{jobreportList.length}건</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
<th className="px-2 py-3 text-center text-xs font-medium text-white/70 uppercase w-10"></th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">날짜</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">프로젝트</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">업무형태</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">상태</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">시간</th>
|
||||
{canViewOT && <th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">OT</th>}
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">담당자</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={canViewOT ? 8 : 7} className="px-4 py-8 text-center">
|
||||
<div className="flex items-center justify-center">
|
||||
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-white/50" />
|
||||
<span className="text-white/50">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : jobreportList.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={canViewOT ? 8 : 7} className="px-4 py-8 text-center text-white/50">
|
||||
조회된 데이터가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedList.map((item) => (
|
||||
<tr
|
||||
key={item.idx}
|
||||
className={`hover:bg-white/5 transition-colors cursor-pointer ${item.type === '휴가' ? 'bg-gradient-to-r from-lime-400/30 via-emerald-400/20 to-teal-400/30' : ''}`}
|
||||
onClick={() => openEditModal(item)}
|
||||
>
|
||||
<td className="px-2 py-3 text-center">
|
||||
<button
|
||||
onClick={(e) => openCopyModal(item, e)}
|
||||
className="text-white/40 hover:text-primary-400 transition-colors"
|
||||
title="복사하여 새로 작성"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{formatDate(item.pdate)}</td>
|
||||
<td className={`px-4 py-3 text-sm font-medium max-w-xs truncate ${item.pidx && item.pidx > 0 ? 'text-white' : 'text-white/50'}`} title={item.projectName}>
|
||||
{item.projectName || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.type || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span className={`px-2 py-1 rounded text-xs ${
|
||||
item.status?.includes('완료') ? 'bg-green-500/20 text-green-400' : 'bg-white/20 text-white/70'
|
||||
}`}>
|
||||
{item.status || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white text-sm">
|
||||
{item.hrs || 0}h
|
||||
</td>
|
||||
{canViewOT && (
|
||||
<td className="px-4 py-3 text-white text-sm">
|
||||
{item.ot ? <span className="text-warning-400">{item.ot}h</span> : '-'}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3 text-white text-sm">{item.name || item.id || '-'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 페이징 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-6 py-4 border-t border-white/10 flex items-center justify-between">
|
||||
<div className="text-white/50 text-sm">
|
||||
총 {jobreportList.length}건 중 {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, jobreportList.length)}건
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span className="text-white/70 px-3">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 추가/수정 모달 */}
|
||||
<JobreportEditModal
|
||||
isOpen={showModal}
|
||||
editingItem={editingItem}
|
||||
formData={formData}
|
||||
processing={processing}
|
||||
onClose={() => setShowModal(false)}
|
||||
onFormChange={setFormData}
|
||||
onSave={handleSave}
|
||||
onDelete={(idx) => {
|
||||
handleDelete(idx);
|
||||
setShowModal(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
286
Project/frontend/src/pages/Kuntae.tsx
Normal file
286
Project/frontend/src/pages/Kuntae.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Calendar,
|
||||
Search,
|
||||
Trash2,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { KuntaeModel } from '@/types';
|
||||
|
||||
export function Kuntae() {
|
||||
const [kuntaeList, setKuntaeList] = useState<KuntaeModel[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
// 검색 조건
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
|
||||
// 통계
|
||||
const [stats, setStats] = useState({
|
||||
holyUsed: 0,
|
||||
alternateUsed: 0,
|
||||
holyRemain: 0,
|
||||
alternateRemain: 0,
|
||||
});
|
||||
|
||||
// 날짜 초기화 (현재 월)
|
||||
useEffect(() => {
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
setStartDate(startOfMonth.toISOString().split('T')[0]);
|
||||
setEndDate(endOfMonth.toISOString().split('T')[0]);
|
||||
}, []);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
if (!startDate || !endDate) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await comms.getKuntaeList(startDate, endDate);
|
||||
if (response.Success && response.Data) {
|
||||
setKuntaeList(response.Data);
|
||||
updateStats(response.Data);
|
||||
} else {
|
||||
setKuntaeList([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('근태 목록 로드 오류:', error);
|
||||
alert('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 통계 업데이트
|
||||
const updateStats = (data: KuntaeModel[]) => {
|
||||
const holyUsed = data.filter(item => item.cate === '연차' || item.cate === '휴가').length;
|
||||
const alternateUsed = data.filter(item => item.cate === '대체').length;
|
||||
|
||||
setStats({
|
||||
holyUsed,
|
||||
alternateUsed,
|
||||
holyRemain: 15 - holyUsed, // 예시 값
|
||||
alternateRemain: 5 - alternateUsed, // 예시 값
|
||||
});
|
||||
};
|
||||
|
||||
// 검색
|
||||
const handleSearch = () => {
|
||||
if (new Date(startDate) > new Date(endDate)) {
|
||||
alert('시작일은 종료일보다 늦을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
loadData();
|
||||
};
|
||||
|
||||
// 삭제
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('정말로 이 근태 데이터를 삭제하시겠습니까?')) return;
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await comms.deleteKuntae(id);
|
||||
if (response.Success) {
|
||||
alert('삭제되었습니다.');
|
||||
loadData();
|
||||
} else {
|
||||
alert(response.Message || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('삭제 오류:', error);
|
||||
alert('서버 연결에 실패했습니다.');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 날짜 포맷
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString('ko-KR');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* 개발중 경고 */}
|
||||
<div className="bg-warning-500/20 border border-warning-500/30 rounded-xl p-4 flex items-center">
|
||||
<AlertTriangle className="w-5 h-5 text-warning-400 mr-3 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-white font-medium">개발중인 기능입니다</p>
|
||||
<p className="text-white/60 text-sm">일부 기능이 정상적으로 동작하지 않을 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 필터 */}
|
||||
<div className="glass-effect rounded-2xl p-6">
|
||||
<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>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">종료일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={loading}
|
||||
className="w-full bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center justify-center disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
조회
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="휴가 사용"
|
||||
value={stats.holyUsed}
|
||||
icon={<Calendar className="w-6 h-6 text-primary-400" />}
|
||||
color="text-primary-400"
|
||||
/>
|
||||
<StatCard
|
||||
title="대체 사용"
|
||||
value={stats.alternateUsed}
|
||||
icon={<CheckCircle className="w-6 h-6 text-success-400" />}
|
||||
color="text-success-400"
|
||||
/>
|
||||
<StatCard
|
||||
title="잔량 (연차)"
|
||||
value={stats.holyRemain}
|
||||
icon={<Clock className="w-6 h-6 text-warning-400" />}
|
||||
color="text-warning-400"
|
||||
/>
|
||||
<StatCard
|
||||
title="잔량 (대체)"
|
||||
value={stats.alternateRemain}
|
||||
icon={<XCircle className="w-6 h-6 text-danger-400" />}
|
||||
color="text-danger-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 데이터 테이블 */}
|
||||
<div className="glass-effect rounded-2xl overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-white/10">
|
||||
<h3 className="text-lg font-semibold text-white">근태 상세 내역</h3>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">구분</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">시작일</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">종료일</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">사번</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">성명</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">사용(일)</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">내용</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">소스</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">등록자</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">등록일</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="px-4 py-8 text-center">
|
||||
<div className="flex items-center justify-center">
|
||||
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-white/50" />
|
||||
<span className="text-white/50">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : kuntaeList.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="px-4 py-8 text-center text-white/50">
|
||||
조회된 데이터가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
kuntaeList.map((item) => (
|
||||
<tr key={item.idx} className="hover:bg-white/5 transition-colors">
|
||||
<td className="px-4 py-3 text-white text-sm">{item.cate || '-'}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{formatDate(item.sdate)}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{formatDate(item.edate)}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.uid || '-'}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.uname || '-'}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.term || '-'}</td>
|
||||
<td className="px-4 py-3 text-white/80 text-sm max-w-xs truncate" title={item.contents}>
|
||||
{item.contents || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.extcate || '-'}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.wuid || '-'}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.wdate || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<button
|
||||
onClick={() => handleDelete(item.idx)}
|
||||
disabled={processing}
|
||||
className="text-danger-400 hover:text-danger-300 transition-colors disabled:opacity-50"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 통계 카드 컴포넌트
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: number;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon, color }: StatCardProps) {
|
||||
return (
|
||||
<div className="glass-effect rounded-xl p-4 card-hover">
|
||||
<div className="flex items-center">
|
||||
<div className={`p-3 rounded-lg ${color.replace('text-', 'bg-').replace('-400', '-500/20')}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-white/70">{title}</p>
|
||||
<p className={`text-2xl font-bold ${color}`}>{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
202
Project/frontend/src/pages/Login.tsx
Normal file
202
Project/frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { LogIn, Building2, User, Lock, Loader2 } from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { UserGroup } from '@/types';
|
||||
|
||||
interface LoginProps {
|
||||
onLoginSuccess: () => void;
|
||||
}
|
||||
|
||||
export function Login({ onLoginSuccess }: LoginProps) {
|
||||
const [groups, setGroups] = useState<UserGroup[]>([]);
|
||||
const [gcode, setGcode] = useState('');
|
||||
const [userId, setUserId] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadInitialData();
|
||||
}, []);
|
||||
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
// 그룹 목록 및 이전 로그인 정보 로드
|
||||
const [groupsData, prevInfo] = await Promise.all([
|
||||
comms.getUserGroups(),
|
||||
comms.getPreviousLoginInfo()
|
||||
]);
|
||||
|
||||
console.log('[Login] 부서 목록:', groupsData);
|
||||
setGroups(groupsData);
|
||||
|
||||
if (prevInfo.Success && prevInfo.Data) {
|
||||
if (prevInfo.Data.LastGcode) {
|
||||
setGcode(prevInfo.Data.LastGcode);
|
||||
}
|
||||
if (prevInfo.Data.LastId) {
|
||||
setUserId(prevInfo.Data.LastId);
|
||||
setRememberMe(true);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('초기 데이터 로드 실패:', err);
|
||||
} finally {
|
||||
setInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await comms.login(gcode, userId, password, rememberMe);
|
||||
|
||||
if (result.Success) {
|
||||
if (result.VersionWarning) {
|
||||
alert(result.VersionWarning);
|
||||
}
|
||||
onLoginSuccess();
|
||||
} else {
|
||||
setError(result.Message || '로그인에 실패했습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('서버 연결에 실패했습니다.');
|
||||
console.error('로그인 오류:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 text-white animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-white mb-2">GroupWare</h1>
|
||||
<p className="text-white/60">ATK4-EET</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<div className="glass-effect rounded-2xl p-8">
|
||||
<h2 className="text-xl font-semibold text-white mb-6 text-center">로그인</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* 부서 선택 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">
|
||||
소속 부서
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Building2 className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-white/50" />
|
||||
<select
|
||||
value={gcode}
|
||||
onChange={(e) => setGcode(e.target.value)}
|
||||
className="w-full bg-white/10 border border-white/20 rounded-xl pl-10 pr-4 py-3 text-white focus:outline-none focus:border-primary-400 appearance-none"
|
||||
required
|
||||
>
|
||||
<option value="">부서를 선택하세요</option>
|
||||
{groups.filter(g => g.gcode).map((group) => (
|
||||
<option key={group.gcode} value={group.gcode}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사용자 ID */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">
|
||||
사용자 ID
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-white/50" />
|
||||
<input
|
||||
type="text"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
className="w-full bg-white/10 border border-white/20 rounded-lg pl-10 pr-4 py-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="ID를 입력하세요"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">
|
||||
비밀번호
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-white/50" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-white/10 border border-white/20 rounded-lg pl-10 pr-4 py-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 로그인 정보 저장 */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="rememberMe"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-white/20 bg-white/10 text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
<label htmlFor="rememberMe" className="ml-2 text-sm text-white/70">
|
||||
로그인 정보 저장
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div className="bg-danger-500/20 border border-danger-500/50 rounded-lg px-4 py-3 text-danger-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로그인 버튼 */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-primary-500 hover:bg-primary-600 disabled:bg-primary-500/50 text-white font-medium py-3 rounded-lg transition-colors flex items-center justify-center space-x-2"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="w-5 h-5" />
|
||||
<span>로그인</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-white/40 text-sm mt-6">
|
||||
© 2024 GroupWare. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
474
Project/frontend/src/pages/MailForm.tsx
Normal file
474
Project/frontend/src/pages/MailForm.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Mail,
|
||||
Plus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Save,
|
||||
X,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { MailFormItem } from '@/types';
|
||||
|
||||
const initialFormData: Partial<MailFormItem> = {
|
||||
cate: '',
|
||||
title: '',
|
||||
tolist: '',
|
||||
bcc: '',
|
||||
cc: '',
|
||||
subject: '',
|
||||
tail: '',
|
||||
body: '',
|
||||
selfTo: false,
|
||||
selfCC: false,
|
||||
selfBCC: false,
|
||||
exceptmail: '',
|
||||
exceptmailcc: '',
|
||||
};
|
||||
|
||||
export function MailFormPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mailForms, setMailForms] = useState<MailFormItem[]>([]);
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<MailFormItem | null>(null);
|
||||
const [formData, setFormData] = useState<Partial<MailFormItem>>(initialFormData);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await comms.getMailFormList();
|
||||
if (response.Success && response.Data) {
|
||||
setMailForms(response.Data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 오류:', error);
|
||||
alert('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const filteredItems = mailForms.filter(item =>
|
||||
!searchKey ||
|
||||
item.title?.toLowerCase().includes(searchKey.toLowerCase()) ||
|
||||
item.cate?.toLowerCase().includes(searchKey.toLowerCase()) ||
|
||||
item.subject?.toLowerCase().includes(searchKey.toLowerCase())
|
||||
);
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingItem(null);
|
||||
setFormData(initialFormData);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (item: MailFormItem) => {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
cate: item.cate || '',
|
||||
title: item.title || '',
|
||||
tolist: item.tolist || '',
|
||||
bcc: item.bcc || '',
|
||||
cc: item.cc || '',
|
||||
subject: item.subject || '',
|
||||
tail: item.tail || '',
|
||||
body: item.body || '',
|
||||
selfTo: item.selfTo || false,
|
||||
selfCC: item.selfCC || false,
|
||||
selfBCC: item.selfBCC || false,
|
||||
exceptmail: item.exceptmail || '',
|
||||
exceptmailcc: item.exceptmailcc || '',
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.title?.trim()) {
|
||||
alert('양식명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
let response;
|
||||
if (editingItem) {
|
||||
response = await comms.editMailForm(
|
||||
editingItem.idx,
|
||||
formData.cate || '',
|
||||
formData.title || '',
|
||||
formData.tolist || '',
|
||||
formData.bcc || '',
|
||||
formData.cc || '',
|
||||
formData.subject || '',
|
||||
formData.tail || '',
|
||||
formData.body || '',
|
||||
formData.selfTo || false,
|
||||
formData.selfCC || false,
|
||||
formData.selfBCC || false,
|
||||
formData.exceptmail || '',
|
||||
formData.exceptmailcc || ''
|
||||
);
|
||||
} else {
|
||||
response = await comms.addMailForm(
|
||||
formData.cate || '',
|
||||
formData.title || '',
|
||||
formData.tolist || '',
|
||||
formData.bcc || '',
|
||||
formData.cc || '',
|
||||
formData.subject || '',
|
||||
formData.tail || '',
|
||||
formData.body || '',
|
||||
formData.selfTo || false,
|
||||
formData.selfCC || false,
|
||||
formData.selfBCC || false,
|
||||
formData.exceptmail || '',
|
||||
formData.exceptmailcc || ''
|
||||
);
|
||||
}
|
||||
|
||||
if (response.Success) {
|
||||
setShowModal(false);
|
||||
loadData();
|
||||
} else {
|
||||
alert(response.Message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 오류:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (item: MailFormItem) => {
|
||||
if (!confirm(`"${item.title}" 양식을 삭제하시겠습니까?`)) return;
|
||||
|
||||
try {
|
||||
const response = await comms.deleteMailForm(item.idx);
|
||||
if (response.Success) {
|
||||
loadData();
|
||||
} else {
|
||||
alert(response.Message || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('삭제 오류:', error);
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="glass-effect rounded-2xl p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-3 bg-primary-500/20 rounded-xl">
|
||||
<Mail className="w-6 h-6 text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">메일양식</h1>
|
||||
<p className="text-white/60 text-sm">메일 템플릿 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/40" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchKey}
|
||||
onChange={(e) => setSearchKey(e.target.value)}
|
||||
placeholder="검색..."
|
||||
className="pl-10 pr-4 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500 w-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>새 양식</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 목록 */}
|
||||
<div className="glass-effect rounded-2xl overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 text-white animate-spin" />
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-white/50">
|
||||
<Mail className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p>등록된 메일양식이 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-24">분류</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">양식명</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">제목</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20">To</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20">CC</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20">BCC</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-24">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{filteredItems.map((item) => (
|
||||
<tr key={item.idx} className="hover:bg-white/5 transition-colors">
|
||||
<td className="px-4 py-3 text-white/70 text-sm">{item.cate || '-'}</td>
|
||||
<td className="px-4 py-3 text-white text-sm font-medium">{item.title}</td>
|
||||
<td className="px-4 py-3 text-white/70 text-sm">{item.subject || '-'}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{item.selfTo && (
|
||||
<span className="inline-block w-5 h-5 bg-success-500/20 text-success-400 rounded text-xs leading-5">S</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{item.selfCC && (
|
||||
<span className="inline-block w-5 h-5 bg-warning-500/20 text-warning-400 rounded text-xs leading-5">S</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{item.selfBCC && (
|
||||
<span className="inline-block w-5 h-5 bg-primary-500/20 text-primary-400 rounded text-xs leading-5">S</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<button
|
||||
onClick={() => openEditModal(item)}
|
||||
className="p-1.5 hover:bg-white/10 rounded text-white/70 hover:text-white transition-colors"
|
||||
title="수정"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(item)}
|
||||
className="p-1.5 hover:bg-danger-500/20 rounded text-white/70 hover:text-danger-400 transition-colors"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 편집 모달 */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-slate-800 rounded-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* 모달 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
{editingItem ? '메일양식 수정' : '새 메일양식'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="p-2 hover:bg-white/10 rounded-lg text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 모달 내용 */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{/* 1행: 분류, 양식명 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">분류</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.cate || ''}
|
||||
onChange={(e) => setFormData({ ...formData, cate: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">양식명 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title || ''}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2행: 제목 */}
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">메일 제목</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.subject || ''}
|
||||
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 3행: 수신자 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">To (수신)</label>
|
||||
<textarea
|
||||
value={formData.tolist || ''}
|
||||
onChange={(e) => setFormData({ ...formData, tolist: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500 resize-none"
|
||||
placeholder="이메일 주소 (줄바꿈으로 구분)"
|
||||
/>
|
||||
<label className="flex items-center mt-1 text-sm text-white/60">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.selfTo || false}
|
||||
onChange={(e) => setFormData({ ...formData, selfTo: e.target.checked })}
|
||||
className="mr-2"
|
||||
/>
|
||||
본인 포함
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">CC (참조)</label>
|
||||
<textarea
|
||||
value={formData.cc || ''}
|
||||
onChange={(e) => setFormData({ ...formData, cc: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500 resize-none"
|
||||
placeholder="이메일 주소 (줄바꿈으로 구분)"
|
||||
/>
|
||||
<label className="flex items-center mt-1 text-sm text-white/60">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.selfCC || false}
|
||||
onChange={(e) => setFormData({ ...formData, selfCC: e.target.checked })}
|
||||
className="mr-2"
|
||||
/>
|
||||
본인 포함
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">BCC (숨은참조)</label>
|
||||
<textarea
|
||||
value={formData.bcc || ''}
|
||||
onChange={(e) => setFormData({ ...formData, bcc: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500 resize-none"
|
||||
placeholder="이메일 주소 (줄바꿈으로 구분)"
|
||||
/>
|
||||
<label className="flex items-center mt-1 text-sm text-white/60">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.selfBCC || false}
|
||||
onChange={(e) => setFormData({ ...formData, selfBCC: e.target.checked })}
|
||||
className="mr-2"
|
||||
/>
|
||||
본인 포함
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4행: 제외 메일 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">To 제외 메일</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.exceptmail || ''}
|
||||
onChange={(e) => setFormData({ ...formData, exceptmail: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500"
|
||||
placeholder="제외할 이메일 주소"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">CC 제외 메일</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.exceptmailcc || ''}
|
||||
onChange={(e) => setFormData({ ...formData, exceptmailcc: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500"
|
||||
placeholder="제외할 이메일 주소"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5행: 본문 */}
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">메일 본문</label>
|
||||
<textarea
|
||||
value={formData.body || ''}
|
||||
onChange={(e) => setFormData({ ...formData, body: e.target.value })}
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500 resize-none"
|
||||
placeholder="메일 본문 내용..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 6행: 꼬리말 */}
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">꼬리말</label>
|
||||
<textarea
|
||||
value={formData.tail || ''}
|
||||
onChange={(e) => setFormData({ ...formData, tail: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500 resize-none"
|
||||
placeholder="메일 꼬리말..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모달 푸터 */}
|
||||
<div className="flex items-center justify-end space-x-3 px-6 py-4 border-t border-white/10">
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
<span>저장</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
Project/frontend/src/pages/MonthlyWork.tsx
Normal file
248
Project/frontend/src/pages/MonthlyWork.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Calendar,
|
||||
Save,
|
||||
RefreshCw,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { HolidayItem } from '@/types';
|
||||
|
||||
interface DayInfo extends HolidayItem {
|
||||
dayOfWeek: number;
|
||||
dayName: string;
|
||||
}
|
||||
|
||||
export function MonthlyWorkPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [month, setMonth] = useState(() => {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
});
|
||||
const [holidays, setHolidays] = useState<DayInfo[]>([]);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 먼저 초기화 시도 (데이터 없으면 생성)
|
||||
await comms.initializeHoliday(month);
|
||||
|
||||
// 데이터 로드
|
||||
const response = await comms.getHolidayList(month);
|
||||
if (response.Success && response.Data) {
|
||||
const data = response.Data.map(item => {
|
||||
const date = new Date(item.pdate);
|
||||
return {
|
||||
...item,
|
||||
dayOfWeek: date.getDay(),
|
||||
dayName: dayNames[date.getDay()],
|
||||
};
|
||||
});
|
||||
setHolidays(data);
|
||||
setHasChanges(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 오류:', error);
|
||||
alert('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [month]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleMonthChange = (delta: number) => {
|
||||
const [year, mon] = month.split('-').map(Number);
|
||||
const newDate = new Date(year, mon - 1 + delta, 1);
|
||||
setMonth(`${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}`);
|
||||
};
|
||||
|
||||
const handleToggleFree = (idx: number) => {
|
||||
setHolidays(prev => prev.map(h =>
|
||||
h.idx === idx ? { ...h, free: !h.free } : h
|
||||
));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleMemoChange = (idx: number, memo: string) => {
|
||||
setHolidays(prev => prev.map(h =>
|
||||
h.idx === idx ? { ...h, memo } : h
|
||||
));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const dataToSave = holidays.map(({ idx, pdate, free, memo }) => ({
|
||||
idx, pdate, free, memo
|
||||
}));
|
||||
const response = await comms.saveHolidays(month, dataToSave as HolidayItem[]);
|
||||
if (response.Success) {
|
||||
setHasChanges(false);
|
||||
} else {
|
||||
alert(response.Message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 오류:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 통계 계산
|
||||
const workDays = holidays.filter(h => !h.free).length;
|
||||
const freeDays = holidays.filter(h => h.free).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="glass-effect rounded-2xl p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-3 bg-primary-500/20 rounded-xl">
|
||||
<Calendar className="w-6 h-6 text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">월별근무표</h1>
|
||||
<p className="text-white/60 text-sm">휴일 및 근무일 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* 월 선택 */}
|
||||
<div className="flex items-center space-x-2 bg-white/10 rounded-lg px-3 py-2">
|
||||
<button
|
||||
onClick={() => handleMonthChange(-1)}
|
||||
className="p-1 hover:bg-white/10 rounded transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
<input
|
||||
type="month"
|
||||
value={month}
|
||||
onChange={(e) => setMonth(e.target.value)}
|
||||
className="bg-transparent text-white text-center w-32 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleMonthChange(1)}
|
||||
className="p-1 hover:bg-white/10 rounded transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 버튼들 */}
|
||||
<button
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
<span className="hidden sm:inline">새로고침</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !hasChanges}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
<span>저장</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 */}
|
||||
<div className="mt-4 flex items-center space-x-6 text-sm">
|
||||
<span className="text-white/70">
|
||||
근무일: <span className="text-white font-semibold">{workDays}일</span>
|
||||
</span>
|
||||
<span className="text-white/70">
|
||||
휴일: <span className="text-danger-400 font-semibold">{freeDays}일</span>
|
||||
</span>
|
||||
<span className="text-white/70">
|
||||
총: <span className="text-white font-semibold">{holidays.length}일</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="glass-effect rounded-2xl overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 text-white animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-32">날짜</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20">요일</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-24">휴일</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">메모</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{holidays.map((day) => (
|
||||
<tr
|
||||
key={day.idx}
|
||||
className={`hover:bg-white/5 transition-colors ${
|
||||
day.dayOfWeek === 0 ? 'bg-danger-500/10' :
|
||||
day.dayOfWeek === 6 ? 'bg-primary-500/10' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3 text-white text-sm">
|
||||
{day.pdate}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-center text-sm font-medium ${
|
||||
day.dayOfWeek === 0 ? 'text-danger-400' :
|
||||
day.dayOfWeek === 6 ? 'text-primary-400' : 'text-white/70'
|
||||
}`}>
|
||||
{day.dayName}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
onClick={() => handleToggleFree(day.idx)}
|
||||
className={`w-8 h-8 rounded-lg transition-colors ${
|
||||
day.free
|
||||
? 'bg-danger-500/20 text-danger-400 hover:bg-danger-500/30'
|
||||
: 'bg-white/10 text-white/40 hover:bg-white/20'
|
||||
}`}
|
||||
>
|
||||
{day.free ? 'O' : '-'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="text"
|
||||
value={day.memo || ''}
|
||||
onChange={(e) => handleMemoChange(day.idx, e.target.value)}
|
||||
placeholder="메모 입력..."
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
Project/frontend/src/pages/Placeholder.tsx
Normal file
22
Project/frontend/src/pages/Placeholder.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Construction } from 'lucide-react';
|
||||
|
||||
interface PlaceholderPageProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function PlaceholderPage({ title }: PlaceholderPageProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full animate-fade-in">
|
||||
<div className="glass-effect rounded-2xl p-12 text-center">
|
||||
<Construction className="w-16 h-16 text-warning-400 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-white mb-2">{title}</h2>
|
||||
<p className="text-white/60">
|
||||
이 페이지는 현재 개발 중입니다.
|
||||
</p>
|
||||
<p className="text-white/40 text-sm mt-2">
|
||||
React 버전으로 마이그레이션 예정입니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
719
Project/frontend/src/pages/Todo.tsx
Normal file
719
Project/frontend/src/pages/Todo.tsx
Normal file
@@ -0,0 +1,719 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Flag,
|
||||
Zap,
|
||||
CheckCircle,
|
||||
X,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { TodoModel, TodoStatus, TodoPriority } from '@/types';
|
||||
|
||||
// 상태/중요도 유틸리티 함수들
|
||||
const getStatusText = (status: string): string => {
|
||||
switch (status) {
|
||||
case '0': return '대기';
|
||||
case '1': return '진행';
|
||||
case '2': return '취소';
|
||||
case '3': return '보류';
|
||||
case '5': return '완료';
|
||||
default: return '대기';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusClass = (status: string): string => {
|
||||
switch (status) {
|
||||
case '0': return 'bg-gray-500/20 text-gray-300 border-gray-500/30';
|
||||
case '1': return 'bg-primary-500/20 text-primary-300 border-primary-500/30';
|
||||
case '2': return 'bg-danger-500/20 text-danger-300 border-danger-500/30';
|
||||
case '3': return 'bg-warning-500/20 text-warning-300 border-warning-500/30';
|
||||
case '5': return 'bg-success-500/20 text-success-300 border-success-500/30';
|
||||
default: return 'bg-white/10 text-white/50 border-white/20';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityText = (seqno: number): string => {
|
||||
switch (seqno) {
|
||||
case 1: return '중요';
|
||||
case 2: return '매우 중요';
|
||||
case 3: return '긴급';
|
||||
default: return '보통';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityClass = (seqno: number): string => {
|
||||
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';
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 데이터 타입
|
||||
interface TodoFormData {
|
||||
title: string;
|
||||
remark: string;
|
||||
expire: string;
|
||||
seqno: TodoPriority;
|
||||
flag: boolean;
|
||||
request: string;
|
||||
status: TodoStatus;
|
||||
}
|
||||
|
||||
const initialFormData: TodoFormData = {
|
||||
title: '',
|
||||
remark: '',
|
||||
expire: '',
|
||||
seqno: 0,
|
||||
flag: false,
|
||||
request: '',
|
||||
status: '0',
|
||||
};
|
||||
|
||||
export function Todo() {
|
||||
const [todos, setTodos] = useState<TodoModel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'active' | 'hold' | 'completed' | 'cancelled'>('active');
|
||||
|
||||
// 모달 상태
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editingTodo, setEditingTodo] = useState<TodoModel | null>(null);
|
||||
const [formData, setFormData] = useState<TodoFormData>(initialFormData);
|
||||
|
||||
// 할일 목록 로드
|
||||
const loadTodos = useCallback(async () => {
|
||||
try {
|
||||
const response = await comms.getTodos();
|
||||
if (response.Success && response.Data) {
|
||||
setTodos(response.Data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('할일 목록 로드 오류:', error);
|
||||
alert('할일 목록을 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTodos();
|
||||
}, [loadTodos]);
|
||||
|
||||
// 필터링된 할일 목록
|
||||
const activeTodos = todos.filter(todo => todo.status === '0' || todo.status === '1'); // 대기 + 진행
|
||||
const holdTodos = todos.filter(todo => todo.status === '3'); // 보류
|
||||
const completedTodos = todos.filter(todo => todo.status === '5'); // 완료
|
||||
const cancelledTodos = todos.filter(todo => todo.status === '2'); // 취소
|
||||
|
||||
// 새 할일 추가
|
||||
const handleAdd = async () => {
|
||||
if (!formData.remark.trim()) {
|
||||
alert('할일 내용을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await comms.createTodo(
|
||||
formData.title,
|
||||
formData.remark,
|
||||
formData.expire || null,
|
||||
formData.seqno,
|
||||
formData.flag,
|
||||
formData.request || null,
|
||||
formData.status
|
||||
);
|
||||
|
||||
if (response.Success) {
|
||||
setShowAddModal(false);
|
||||
setFormData(initialFormData);
|
||||
loadTodos();
|
||||
} else {
|
||||
alert(response.Message || '할일 추가에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('할일 추가 오류:', error);
|
||||
alert('서버 연결에 실패했습니다.');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 할일 수정 모달 열기
|
||||
const openEditModal = async (todo: TodoModel) => {
|
||||
try {
|
||||
const response = await comms.getTodo(todo.idx);
|
||||
if (response.Success && response.Data) {
|
||||
const data = response.Data;
|
||||
setEditingTodo(data);
|
||||
setFormData({
|
||||
title: data.title || '',
|
||||
remark: data.remark || '',
|
||||
expire: data.expire ? data.expire.split('T')[0] : '',
|
||||
seqno: data.seqno as TodoPriority,
|
||||
flag: data.flag || false,
|
||||
request: data.request || '',
|
||||
status: data.status as TodoStatus,
|
||||
});
|
||||
setShowEditModal(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('할일 조회 오류:', error);
|
||||
alert('할일 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 할일 수정
|
||||
const handleUpdate = async () => {
|
||||
if (!editingTodo || !formData.remark.trim()) {
|
||||
alert('할일 내용을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await comms.updateTodo(
|
||||
editingTodo.idx,
|
||||
formData.title,
|
||||
formData.remark,
|
||||
formData.expire || null,
|
||||
formData.seqno,
|
||||
formData.flag,
|
||||
formData.request || null,
|
||||
formData.status
|
||||
);
|
||||
|
||||
if (response.Success) {
|
||||
setShowEditModal(false);
|
||||
setEditingTodo(null);
|
||||
setFormData(initialFormData);
|
||||
loadTodos();
|
||||
} else {
|
||||
alert(response.Message || '할일 수정에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('할일 수정 오류:', error);
|
||||
alert('서버 연결에 실패했습니다.');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 할일 삭제
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('정말로 이 할일을 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await comms.deleteTodo(id);
|
||||
if (response.Success) {
|
||||
loadTodos();
|
||||
} else {
|
||||
alert(response.Message || '할일 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('할일 삭제 오류:', error);
|
||||
alert('서버 연결에 실패했습니다.');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 상태 빠른 변경
|
||||
const handleQuickStatusChange = async (todo: TodoModel, newStatus: TodoStatus) => {
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await comms.updateTodo(
|
||||
todo.idx,
|
||||
todo.title,
|
||||
todo.remark,
|
||||
todo.expire,
|
||||
todo.seqno,
|
||||
todo.flag,
|
||||
todo.request,
|
||||
newStatus
|
||||
);
|
||||
|
||||
if (response.Success) {
|
||||
loadTodos();
|
||||
} else {
|
||||
alert(response.Message || '상태 변경에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('상태 변경 오류:', error);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* 헤더 */}
|
||||
<div className="glass-effect rounded-2xl overflow-hidden">
|
||||
<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" />
|
||||
</svg>
|
||||
내 할일 목록
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFormData(initialFormData);
|
||||
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"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
새 할일 추가
|
||||
</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={() => setActiveTab('active')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === '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">
|
||||
<Zap className="w-4 h-4" />
|
||||
<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={() => setActiveTab('hold')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'hold'
|
||||
? '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">
|
||||
<span>보류</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-warning-500/30 text-warning-200 rounded-full">
|
||||
{holdTodos.length}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('completed')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === '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">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<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>
|
||||
<button
|
||||
onClick={() => setActiveTab('cancelled')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'cancelled'
|
||||
? '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">
|
||||
<X className="w-4 h-4" />
|
||||
<span>취소</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-danger-500/30 text-danger-200 rounded-full">
|
||||
{cancelledTodos.length}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 할일 테이블 */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
{activeTab === 'active' && (
|
||||
<th className="px-2 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-10 border-r border-white/10"></th>
|
||||
)}
|
||||
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-16 border-r border-white/10">상태</th>
|
||||
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-16 border-r border-white/10">플래그</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider border-r border-white/10">제목</th>
|
||||
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-24 border-r border-white/10">요청자</th>
|
||||
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-20 border-r border-white/10">중요도</th>
|
||||
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-24">
|
||||
{activeTab === 'completed' ? '완료일' : '만료일'}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{(activeTab === 'active' ? activeTodos : activeTab === 'hold' ? holdTodos : activeTab === 'completed' ? completedTodos : cancelledTodos).map((todo) => (
|
||||
<TodoRow
|
||||
key={todo.idx}
|
||||
todo={todo}
|
||||
showOkdate={activeTab === 'completed'}
|
||||
showCompleteButton={activeTab === 'active'}
|
||||
onEdit={() => openEditModal(todo)}
|
||||
onComplete={() => handleQuickStatusChange(todo, '5')}
|
||||
/>
|
||||
))}
|
||||
{(activeTab === 'active' ? activeTodos : activeTab === 'hold' ? holdTodos : activeTab === 'completed' ? completedTodos : cancelledTodos).length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={activeTab === 'active' ? 7 : 6} className="px-6 py-8 text-center text-white/50">
|
||||
{activeTab === 'active' ? '진행중인 할일이 없습니다' : activeTab === 'hold' ? '보류된 할일이 없습니다' : activeTab === 'completed' ? '완료된 할일이 없습니다' : '취소된 할일이 없습니다'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 로딩 인디케이터 */}
|
||||
{processing && (
|
||||
<div className="fixed top-4 right-4 bg-white/20 backdrop-blur-sm rounded-full px-4 py-2 text-white text-sm flex items-center">
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
처리 중...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가 모달 */}
|
||||
{showAddModal && (
|
||||
<TodoModal
|
||||
title="새 할일 추가"
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
onSubmit={handleAdd}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
submitText="추가"
|
||||
processing={processing}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 수정 모달 */}
|
||||
{showEditModal && editingTodo && (
|
||||
<TodoModal
|
||||
title="할일 수정"
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
onSubmit={handleUpdate}
|
||||
onClose={() => {
|
||||
setShowEditModal(false);
|
||||
setEditingTodo(null);
|
||||
}}
|
||||
submitText="수정"
|
||||
processing={processing}
|
||||
isEdit={true}
|
||||
onComplete={() => {
|
||||
handleQuickStatusChange(editingTodo, '5');
|
||||
setShowEditModal(false);
|
||||
setEditingTodo(null);
|
||||
}}
|
||||
onDelete={() => {
|
||||
handleDelete(editingTodo.idx);
|
||||
setShowEditModal(false);
|
||||
setEditingTodo(null);
|
||||
}}
|
||||
currentStatus={editingTodo.status}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 할일 행 컴포넌트
|
||||
interface TodoRowProps {
|
||||
todo: TodoModel;
|
||||
showOkdate: boolean;
|
||||
showCompleteButton?: boolean;
|
||||
onEdit: () => void;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
function TodoRow({ todo, showOkdate, showCompleteButton = true, onEdit, onComplete }: TodoRowProps) {
|
||||
const isExpired = todo.expire && new Date(todo.expire) < new Date();
|
||||
|
||||
const handleComplete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (confirm('이 할일을 완료 처리하시겠습니까?')) {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="hover:bg-white/5 transition-colors cursor-pointer"
|
||||
onClick={onEdit}
|
||||
>
|
||||
{showCompleteButton && (
|
||||
<td className="px-2 py-4 text-center border-r border-white/10">
|
||||
<button
|
||||
onClick={handleComplete}
|
||||
className="p-1.5 bg-success-500/20 hover:bg-success-500/40 text-success-300 rounded-full transition-colors"
|
||||
title="완료 처리"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-3 py-4 text-center whitespace-nowrap border-r border-white/10">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getStatusClass(todo.status)}`}>
|
||||
{getStatusText(todo.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-center whitespace-nowrap border-r border-white/10">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
todo.flag ? 'bg-warning-500/20 text-warning-300' : 'bg-white/10 text-white/50'
|
||||
}`}>
|
||||
{todo.flag ? <Flag className="w-3 h-3 mr-1" /> : null}
|
||||
{todo.flag ? '고정' : '일반'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-left text-white border-r border-white/10">{todo.title || '제목 없음'}</td>
|
||||
<td className="px-3 py-4 text-center text-white/80 border-r border-white/10">{todo.request || '-'}</td>
|
||||
<td className="px-3 py-4 text-center whitespace-nowrap border-r border-white/10">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getPriorityClass(todo.seqno)}`}>
|
||||
{getPriorityText(todo.seqno)}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`px-3 py-4 text-center whitespace-nowrap ${showOkdate ? 'text-success-400' : (isExpired ? 'text-danger-400' : 'text-white/80')}`}>
|
||||
{showOkdate
|
||||
? (todo.okdate ? new Date(todo.okdate).toLocaleDateString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit' }) : '-')
|
||||
: (todo.expire ? new Date(todo.expire).toLocaleDateString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit' }) : '-')
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// 할일 모달 컴포넌트
|
||||
interface TodoModalProps {
|
||||
title: string;
|
||||
formData: TodoFormData;
|
||||
setFormData: React.Dispatch<React.SetStateAction<TodoFormData>>;
|
||||
onSubmit: () => void;
|
||||
onClose: () => void;
|
||||
submitText: string;
|
||||
processing: boolean;
|
||||
isEdit?: boolean;
|
||||
onComplete?: () => void;
|
||||
onDelete?: () => void;
|
||||
currentStatus?: string;
|
||||
}
|
||||
|
||||
function TodoModal({
|
||||
title,
|
||||
formData,
|
||||
setFormData,
|
||||
onSubmit,
|
||||
onClose,
|
||||
submitText,
|
||||
processing,
|
||||
isEdit = false,
|
||||
onComplete,
|
||||
onDelete,
|
||||
currentStatus,
|
||||
}: TodoModalProps) {
|
||||
const statusOptions: { value: TodoStatus; label: string }[] = [
|
||||
{ value: '0', label: '대기' },
|
||||
{ value: '1', label: '진행' },
|
||||
{ value: '3', label: '보류' },
|
||||
{ value: '2', label: '취소' },
|
||||
{ value: '5', label: '완료' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" onClick={onClose}>
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<div
|
||||
className="glass-effect rounded-2xl w-full max-w-2xl animate-slide-up"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-white flex items-center">
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
{title}
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-white/70 hover:text-white transition-colors">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">제목 (선택사항)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
placeholder="할일 제목을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">만료일 (선택사항)</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.expire}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, expire: e.target.value }))}
|
||||
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">내용 *</label>
|
||||
<textarea
|
||||
value={formData.remark}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, remark: e.target.value }))}
|
||||
rows={3}
|
||||
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
placeholder="할일 내용을 입력하세요 (필수)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">요청자</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.request}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, request: e.target.value }))}
|
||||
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
placeholder="업무 요청자를 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">진행상태</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{statusOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setFormData(prev => ({ ...prev, status: option.value }))}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-medium border transition-all ${
|
||||
formData.status === option.value
|
||||
? getStatusClass(option.value)
|
||||
: 'bg-white/10 text-white/50 border-white/20 hover:bg-white/20'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">중요도</label>
|
||||
<select
|
||||
value={formData.seqno}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, seqno: parseInt(e.target.value) as TodoPriority }))}
|
||||
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
>
|
||||
<option value={0}>보통</option>
|
||||
<option value={1}>중요</option>
|
||||
<option value={2}>매우 중요</option>
|
||||
<option value={3}>긴급</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center text-white/70 text-sm font-medium cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.flag}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, flag: e.target.checked }))}
|
||||
className="mr-2 text-primary-500 focus:ring-primary-400 focus:ring-offset-0 rounded"
|
||||
/>
|
||||
플래그 (상단 고정)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="px-6 py-4 border-t border-white/10 flex justify-between">
|
||||
{/* 왼쪽: 삭제 버튼 (편집 모드일 때만) */}
|
||||
<div>
|
||||
{isEdit && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={processing}
|
||||
className="bg-danger-500 hover:bg-danger-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 취소, 완료, 수정 버튼 */}
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
{isEdit && onComplete && currentStatus !== '5' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onComplete}
|
||||
disabled={processing}
|
||||
className="bg-success-500 hover:bg-success-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
완료 처리
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={processing}
|
||||
className="bg-primary-500 hover:bg-primary-600 text-white px-6 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
|
||||
>
|
||||
{processing ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Edit2 className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{submitText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
533
Project/frontend/src/pages/UserGroup.tsx
Normal file
533
Project/frontend/src/pages/UserGroup.tsx
Normal file
@@ -0,0 +1,533 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Users,
|
||||
Plus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Save,
|
||||
X,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Shield,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { UserGroupItem, PermissionInfo } from '@/types';
|
||||
|
||||
const initialFormData: Partial<UserGroupItem> = {
|
||||
dept: '',
|
||||
path_kj: '',
|
||||
permission: 0,
|
||||
advpurchase: false,
|
||||
advkisul: false,
|
||||
managerinfo: '',
|
||||
devinfo: '',
|
||||
usemail: false,
|
||||
};
|
||||
|
||||
// 비트 연산 헬퍼 함수
|
||||
const getBit = (value: number, index: number): boolean => {
|
||||
return ((value >> index) & 1) === 1;
|
||||
};
|
||||
|
||||
const setBit = (value: number, index: number, flag: boolean): number => {
|
||||
if (flag) {
|
||||
return value | (1 << index);
|
||||
} else {
|
||||
return value & ~(1 << index);
|
||||
}
|
||||
};
|
||||
|
||||
export function UserGroupPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [groups, setGroups] = useState<UserGroupItem[]>([]);
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showPermissionModal, setShowPermissionModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<UserGroupItem | null>(null);
|
||||
const [formData, setFormData] = useState<Partial<UserGroupItem>>(initialFormData);
|
||||
const [permissionInfo, setPermissionInfo] = useState<PermissionInfo[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [groupsRes, permRes] = await Promise.all([
|
||||
comms.getUserGroupList(),
|
||||
comms.getPermissionInfo()
|
||||
]);
|
||||
|
||||
if (groupsRes.Success && groupsRes.Data) {
|
||||
setGroups(groupsRes.Data);
|
||||
}
|
||||
if (permRes.Success && permRes.Data) {
|
||||
setPermissionInfo(permRes.Data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 오류:', error);
|
||||
alert('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const filteredItems = groups.filter(item =>
|
||||
!searchKey ||
|
||||
item.dept?.toLowerCase().includes(searchKey.toLowerCase()) ||
|
||||
item.managerinfo?.toLowerCase().includes(searchKey.toLowerCase())
|
||||
);
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingItem(null);
|
||||
setFormData(initialFormData);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (item: UserGroupItem) => {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
dept: item.dept || '',
|
||||
path_kj: item.path_kj || '',
|
||||
permission: item.permission || 0,
|
||||
advpurchase: item.advpurchase || false,
|
||||
advkisul: item.advkisul || false,
|
||||
managerinfo: item.managerinfo || '',
|
||||
devinfo: item.devinfo || '',
|
||||
usemail: item.usemail || false,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openPermissionModal = (item: UserGroupItem) => {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
...formData,
|
||||
dept: item.dept,
|
||||
permission: item.permission || 0,
|
||||
});
|
||||
setShowPermissionModal(true);
|
||||
};
|
||||
|
||||
const handlePermissionChange = (index: number, checked: boolean) => {
|
||||
const newPermission = setBit(formData.permission || 0, index, checked);
|
||||
setFormData({ ...formData, permission: newPermission });
|
||||
};
|
||||
|
||||
const handleSavePermission = async () => {
|
||||
if (!editingItem) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await comms.editUserGroup(
|
||||
editingItem.dept,
|
||||
editingItem.dept,
|
||||
editingItem.path_kj || '',
|
||||
formData.permission || 0,
|
||||
editingItem.advpurchase || false,
|
||||
editingItem.advkisul || false,
|
||||
editingItem.managerinfo || '',
|
||||
editingItem.devinfo || '',
|
||||
editingItem.usemail || false
|
||||
);
|
||||
|
||||
if (response.Success) {
|
||||
setShowPermissionModal(false);
|
||||
loadData();
|
||||
} else {
|
||||
alert(response.Message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 오류:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.dept?.trim()) {
|
||||
alert('부서명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
let response;
|
||||
if (editingItem) {
|
||||
response = await comms.editUserGroup(
|
||||
editingItem.dept,
|
||||
formData.dept || '',
|
||||
formData.path_kj || '',
|
||||
formData.permission || 0,
|
||||
formData.advpurchase || false,
|
||||
formData.advkisul || false,
|
||||
formData.managerinfo || '',
|
||||
formData.devinfo || '',
|
||||
formData.usemail || false
|
||||
);
|
||||
} else {
|
||||
response = await comms.addUserGroup(
|
||||
formData.dept || '',
|
||||
formData.path_kj || '',
|
||||
formData.permission || 0,
|
||||
formData.advpurchase || false,
|
||||
formData.advkisul || false,
|
||||
formData.managerinfo || '',
|
||||
formData.devinfo || '',
|
||||
formData.usemail || false
|
||||
);
|
||||
}
|
||||
|
||||
if (response.Success) {
|
||||
setShowModal(false);
|
||||
loadData();
|
||||
} else {
|
||||
alert(response.Message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 오류:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (item: UserGroupItem) => {
|
||||
if (!confirm(`"${item.dept}" 그룹을 삭제하시겠습니까?`)) return;
|
||||
|
||||
try {
|
||||
const response = await comms.deleteUserGroup(item.dept);
|
||||
if (response.Success) {
|
||||
loadData();
|
||||
} else {
|
||||
alert(response.Message || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('삭제 오류:', error);
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 권한 카운트 계산
|
||||
const getPermissionCount = (permission: number): number => {
|
||||
let count = 0;
|
||||
for (let i = 0; i < 11; i++) {
|
||||
if (getBit(permission, i)) count++;
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="glass-effect rounded-2xl p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-3 bg-primary-500/20 rounded-xl">
|
||||
<Users className="w-6 h-6 text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">그룹정보</h1>
|
||||
<p className="text-white/60 text-sm">부서/그룹 및 권한 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/40" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchKey}
|
||||
onChange={(e) => setSearchKey(e.target.value)}
|
||||
placeholder="부서명 검색..."
|
||||
className="pl-10 pr-4 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500 w-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>새 그룹</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 목록 */}
|
||||
<div className="glass-effect rounded-2xl overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 text-white animate-spin" />
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-white/50">
|
||||
<Users className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p>등록된 그룹이 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">부서명</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">경로</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-24">권한</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20">구매고급</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20">기술고급</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20">메일</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">관리자정보</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-28">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{filteredItems.map((item, index) => (
|
||||
<tr key={`${item.dept}-${index}`} className="hover:bg-white/5 transition-colors">
|
||||
<td className="px-4 py-3 text-white font-medium">{item.dept}</td>
|
||||
<td className="px-4 py-3 text-white/70 text-sm">{item.path_kj || '-'}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
onClick={() => openPermissionModal(item)}
|
||||
className="inline-flex items-center space-x-1 px-2 py-1 bg-primary-500/20 text-primary-400 rounded text-xs hover:bg-primary-500/30 transition-colors"
|
||||
>
|
||||
<Shield className="w-3 h-3" />
|
||||
<span>{getPermissionCount(item.permission || 0)}</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{item.advpurchase && <Check className="w-4 h-4 text-success-400 mx-auto" />}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{item.advkisul && <Check className="w-4 h-4 text-success-400 mx-auto" />}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{item.usemail && <Check className="w-4 h-4 text-success-400 mx-auto" />}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white/70 text-sm truncate max-w-48">{item.managerinfo || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<button
|
||||
onClick={() => openEditModal(item)}
|
||||
className="p-1.5 hover:bg-white/10 rounded text-white/70 hover:text-white transition-colors"
|
||||
title="수정"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(item)}
|
||||
className="p-1.5 hover:bg-danger-500/20 rounded text-white/70 hover:text-danger-400 transition-colors"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 그룹 편집 모달 */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-slate-800 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* 모달 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
{editingItem ? '그룹 수정' : '새 그룹'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="p-2 hover:bg-white/10 rounded-lg text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 모달 내용 */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{/* 부서명 */}
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">부서명 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.dept || ''}
|
||||
onChange={(e) => setFormData({ ...formData, dept: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 경로 */}
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">경로 (path_kj)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.path_kj || ''}
|
||||
onChange={(e) => setFormData({ ...formData, path_kj: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 체크박스들 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<label className="flex items-center space-x-2 text-white/70">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.advpurchase || false}
|
||||
onChange={(e) => setFormData({ ...formData, advpurchase: e.target.checked })}
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<span>구매고급</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-2 text-white/70">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.advkisul || false}
|
||||
onChange={(e) => setFormData({ ...formData, advkisul: e.target.checked })}
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<span>기술고급</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-2 text-white/70">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.usemail || false}
|
||||
onChange={(e) => setFormData({ ...formData, usemail: e.target.checked })}
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<span>메일 사용</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 관리자 정보 */}
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">관리자 정보</label>
|
||||
<textarea
|
||||
value={formData.managerinfo || ''}
|
||||
onChange={(e) => setFormData({ ...formData, managerinfo: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 개발자 정보 */}
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">개발자 정보</label>
|
||||
<textarea
|
||||
value={formData.devinfo || ''}
|
||||
onChange={(e) => setFormData({ ...formData, devinfo: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모달 푸터 */}
|
||||
<div className="flex items-center justify-end space-x-3 px-6 py-4 border-t border-white/10">
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
<span>저장</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 권한 설정 모달 */}
|
||||
{showPermissionModal && editingItem && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-slate-800 rounded-2xl w-full max-w-md max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* 모달 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">권한 설정</h2>
|
||||
<p className="text-white/60 text-sm">{editingItem.dept}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPermissionModal(false)}
|
||||
className="p-2 hover:bg-white/10 rounded-lg text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 모달 내용 */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{permissionInfo.map((perm) => (
|
||||
<label
|
||||
key={perm.index}
|
||||
className="flex items-center space-x-2 p-2 rounded-lg hover:bg-white/5 cursor-pointer"
|
||||
title={perm.description}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={getBit(formData.permission || 0, perm.index)}
|
||||
onChange={(e) => handlePermissionChange(perm.index, e.target.checked)}
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<span className="text-white/80 text-sm">{perm.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모달 푸터 */}
|
||||
<div className="flex items-center justify-end space-x-3 px-6 py-4 border-t border-white/10">
|
||||
<button
|
||||
onClick={() => setShowPermissionModal(false)}
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSavePermission}
|
||||
disabled={saving}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
<span>저장</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
555
Project/frontend/src/pages/UserList.tsx
Normal file
555
Project/frontend/src/pages/UserList.tsx
Normal file
@@ -0,0 +1,555 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Search, RefreshCw, Users, Check, X, User, Save } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { comms } from '@/communication';
|
||||
import { GroupUser, UserLevelInfo, UserFullData } from '@/types';
|
||||
|
||||
// 사용자 상세 다이얼로그 Props
|
||||
interface UserDetailDialogProps {
|
||||
user: GroupUser;
|
||||
levelInfo: UserLevelInfo | null;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
// 사용자 상세 다이얼로그 컴포넌트
|
||||
function UserDetailDialog({ user, levelInfo, onClose, onSave }: UserDetailDialogProps) {
|
||||
const [formData, setFormData] = useState<UserFullData>({
|
||||
id: user.id,
|
||||
name: user.name || '',
|
||||
nameE: user.nameE || '',
|
||||
grade: user.grade || '',
|
||||
email: user.email || '',
|
||||
tel: user.tel || '',
|
||||
hp: user.hp || '',
|
||||
indate: user.indate || '',
|
||||
outdate: user.outdate || '',
|
||||
memo: user.memo || '',
|
||||
processs: user.processs || '',
|
||||
state: user.state || '',
|
||||
level: user.level || 1,
|
||||
useUserState: user.useUserState || false,
|
||||
useJobReport: user.useJobReport || false,
|
||||
exceptHoly: user.exceptHoly || false,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 편집 가능 여부: 관리자(level >= 5) 또는 본인
|
||||
const isSelf = levelInfo?.CurrentUserId === user.id;
|
||||
const canEdit = levelInfo?.CanEdit || isSelf;
|
||||
const canEditAdmin = levelInfo?.CanEdit || false; // 관리자 전용 필드
|
||||
|
||||
const handleChange = (field: keyof UserFullData, value: string | number | boolean) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!canEdit) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await comms.saveUserFull(formData);
|
||||
if (result.Success) {
|
||||
onSave();
|
||||
onClose();
|
||||
} else {
|
||||
alert(result.Message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 실패:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div
|
||||
className="glass-effect rounded-xl w-full max-w-2xl max-h-[90vh] overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="p-4 border-b border-white/10 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-white/70" />
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
사용자 정보 {canEdit ? '' : '(읽기 전용)'}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/60 hover:text-white transition-colors text-xl"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="p-4 overflow-auto max-h-[calc(90vh-120px)]">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* 사번 (읽기 전용) */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">사번</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.id}
|
||||
disabled
|
||||
className="w-full px-3 py-2 bg-white/5 border border-white/20 rounded-lg text-white/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 성명 */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">성명</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
|
||||
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 영문명 */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">영문명</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.nameE}
|
||||
onChange={(e) => handleChange('nameE', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
|
||||
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 직책 */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">직책</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.grade}
|
||||
onChange={(e) => handleChange('grade', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
|
||||
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 공정 */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">공정</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.processs}
|
||||
onChange={(e) => handleChange('processs', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
|
||||
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상태 */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">상태</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.state}
|
||||
onChange={(e) => handleChange('state', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
|
||||
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 이메일 */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">이메일</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
|
||||
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 전화 */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">전화</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.tel}
|
||||
onChange={(e) => handleChange('tel', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
|
||||
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 입사일 */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">입사일</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.indate}
|
||||
onChange={(e) => handleChange('indate', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
placeholder="YYYY-MM-DD"
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
|
||||
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 퇴사일 */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">퇴사일</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.outdate}
|
||||
onChange={(e) => handleChange('outdate', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
placeholder="YYYY-MM-DD"
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
|
||||
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 레벨 */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">레벨</label>
|
||||
<select
|
||||
value={formData.level}
|
||||
onChange={(e) => handleChange('level', parseInt(e.target.value) || 0)}
|
||||
disabled={!canEditAdmin}
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
|
||||
canEditAdmin ? "bg-white/10" : "bg-white/5 text-white/50"
|
||||
)}
|
||||
>
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((lv) => (
|
||||
<option key={lv} value={lv} className="bg-gray-800 text-white">
|
||||
{lv}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 메모 */}
|
||||
<div className="col-span-3">
|
||||
<label className="block text-sm text-white/70 mb-1">메모</label>
|
||||
<textarea
|
||||
value={formData.memo}
|
||||
onChange={(e) => handleChange('memo', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
rows={2}
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 border border-white/20 rounded-lg text-white resize-none",
|
||||
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 관리자 전용 설정 */}
|
||||
<div className="col-span-3 border-t border-white/10 pt-4 mt-2">
|
||||
<h3 className="text-sm font-medium text-white/80 mb-3">
|
||||
관리자 설정 {!canEditAdmin && '(관리자만 수정 가능)'}
|
||||
</h3>
|
||||
<div className="flex items-center gap-6">
|
||||
{/* 계정 사용 */}
|
||||
<label className={clsx(
|
||||
"flex items-center gap-2 cursor-pointer",
|
||||
!canEditAdmin && "opacity-50 cursor-not-allowed"
|
||||
)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.useUserState}
|
||||
onChange={(e) => handleChange('useUserState', e.target.checked)}
|
||||
disabled={!canEditAdmin}
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<span className="text-sm text-white">계정 사용</span>
|
||||
</label>
|
||||
|
||||
{/* 일지 사용 */}
|
||||
<label className={clsx(
|
||||
"flex items-center gap-2 cursor-pointer",
|
||||
!canEditAdmin && "opacity-50 cursor-not-allowed"
|
||||
)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.useJobReport}
|
||||
onChange={(e) => handleChange('useJobReport', e.target.checked)}
|
||||
disabled={!canEditAdmin}
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<span className="text-sm text-white">일지 사용</span>
|
||||
</label>
|
||||
|
||||
{/* 휴가 제외 */}
|
||||
<label className={clsx(
|
||||
"flex items-center gap-2 cursor-pointer",
|
||||
!canEditAdmin && "opacity-50 cursor-not-allowed"
|
||||
)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.exceptHoly}
|
||||
onChange={(e) => handleChange('exceptHoly', e.target.checked)}
|
||||
disabled={!canEditAdmin}
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<span className="text-sm text-white">휴가 제외</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="p-4 border-t border-white/10 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-white transition-colors"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserListPage() {
|
||||
const [users, setUsers] = useState<GroupUser[]>([]);
|
||||
const [process, setProcess] = useState('%');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [levelInfo, setLevelInfo] = useState<UserLevelInfo | null>(null);
|
||||
const [selectedUser, setSelectedUser] = useState<GroupUser | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadLevelInfo();
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
const loadLevelInfo = async () => {
|
||||
try {
|
||||
const result = await comms.getCurrentUserLevel();
|
||||
if (result.Success && result.Data) {
|
||||
setLevelInfo(result.Data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('권한 정보 로드 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await comms.getUserList(process);
|
||||
if (Array.isArray(result)) {
|
||||
setUsers(result);
|
||||
} else if (result && typeof result === 'object') {
|
||||
const r = result as { Success?: boolean; Message?: string };
|
||||
if (r.Success === false) {
|
||||
console.error('사용자 목록 조회 실패:', r.Message);
|
||||
}
|
||||
setUsers([]);
|
||||
} else {
|
||||
console.error('사용자 목록 응답이 배열이 아님:', result);
|
||||
setUsers([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('사용자 목록 로드 실패:', error);
|
||||
setUsers([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadUsers();
|
||||
};
|
||||
|
||||
const handleRowClick = (user: GroupUser) => {
|
||||
setSelectedUser(user);
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(
|
||||
(u) =>
|
||||
(u.id ?? '').toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(u.name ?? '').toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(u.email ?? '').toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(u.tel ?? '').toLowerCase().includes(filter.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="glass-effect rounded-xl p-4 mb-4">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-white/70">공정</label>
|
||||
<input
|
||||
type="text"
|
||||
value={process}
|
||||
onChange={(e) => setProcess(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRefresh()}
|
||||
className="px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white w-24 text-center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="flex items-center gap-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
새로고침
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40" />
|
||||
<input
|
||||
type="text"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="검색..."
|
||||
className="pl-9 pr-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40 w-48"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="glass-effect rounded-xl flex-1 overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-white/10 flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-white/70" />
|
||||
<h2 className="text-lg font-semibold text-white">사용자 목록</h2>
|
||||
<span className="text-sm text-white/50">({filteredUsers.length}명)</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-white/5 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-24">사번</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-24">성명</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-20">직책</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70">이메일</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-28">전화</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-20">공정</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-16">상태</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-white/70 w-12">Lv</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-white/70 w-16">계정</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-white/70 w-16">일지</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{filteredUsers.map((user) => (
|
||||
<tr
|
||||
key={user.id}
|
||||
onClick={() => handleRowClick(user)}
|
||||
className={clsx(
|
||||
'hover:bg-white/5 transition-colors cursor-pointer',
|
||||
!user.useUserState && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<td className="px-3 py-2 text-white font-mono">{user.id}</td>
|
||||
<td className="px-3 py-2 text-white font-medium">{user.name}</td>
|
||||
<td className="px-3 py-2 text-white/70">{user.grade}</td>
|
||||
<td className="px-3 py-2">
|
||||
{user.email ? (
|
||||
<a
|
||||
href={`mailto:${user.email}`}
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{user.email}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-white/70">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-white/70">{user.tel}</td>
|
||||
<td className="px-3 py-2 text-white/70">{user.processs}</td>
|
||||
<td className="px-3 py-2 text-white/70">{user.state}</td>
|
||||
<td className="px-3 py-2 text-white text-center">{user.level}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{user.useUserState ? (
|
||||
<Check className="w-4 h-4 text-green-400 mx-auto" />
|
||||
) : (
|
||||
<X className="w-4 h-4 text-red-400 mx-auto" />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{user.useJobReport ? (
|
||||
<Check className="w-4 h-4 text-green-400 mx-auto" />
|
||||
) : (
|
||||
<X className="w-4 h-4 text-red-400 mx-auto" />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredUsers.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-4 py-8 text-center text-white/50">
|
||||
{users.length === 0 ? '공정을 입력하고 새로고침하세요.' : '검색 결과가 없습니다.'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사용자 상세 다이얼로그 */}
|
||||
{selectedUser && (
|
||||
<UserDetailDialog
|
||||
user={selectedUser}
|
||||
levelInfo={levelInfo}
|
||||
onClose={() => setSelectedUser(null)}
|
||||
onSave={() => loadUsers()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
Project/frontend/src/pages/index.ts
Normal file
12
Project/frontend/src/pages/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { Dashboard } from './Dashboard';
|
||||
export { Todo } from './Todo';
|
||||
export { Kuntae } from './Kuntae';
|
||||
export { Jobreport } from './Jobreport';
|
||||
export { PlaceholderPage } from './Placeholder';
|
||||
export { Login } from './Login';
|
||||
export { CommonCodePage } from './CommonCode';
|
||||
export { ItemsPage } from './Items';
|
||||
export { UserListPage } from './UserList';
|
||||
export { MonthlyWorkPage } from './MonthlyWork';
|
||||
export { MailFormPage } from './MailForm';
|
||||
export { UserGroupPage } from './UserGroup';
|
||||
Reference in New Issue
Block a user