feat: React 프론트엔드 기능 대폭 확장

- 월별근무표: 휴일/근무일 관리, 자동 초기화
- 메일양식: 템플릿 CRUD, To/CC/BCC 설정
- 그룹정보: 부서 관리, 비트 연산 기반 권한 설정
- 업무일지: 수정 성공 메시지 제거, 오늘 근무시간 필터링 수정
- 웹소켓 메시지 type 충돌 버그 수정

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
backuppc
2025-11-27 17:25:31 +09:00
parent b57af6dad7
commit c9b5d756e1
65 changed files with 14028 additions and 467 deletions

View File

@@ -0,0 +1,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>
);
}

View File

@@ -0,0 +1,402 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ShoppingCart,
FileCheck,
AlertTriangle,
CheckCircle,
Flag,
RefreshCw,
ClipboardList,
Clock,
} from 'lucide-react';
import { comms } from '@/communication';
import { TodoModel, PurchaseItem } from '@/types';
interface StatCardProps {
title: string;
value: number | string;
icon: React.ReactNode;
color: string;
onClick?: () => void;
}
function StatCard({ title, value, icon, color, onClick }: StatCardProps) {
return (
<div
onClick={onClick}
className={`glass-effect rounded-2xl p-6 card-hover ${onClick ? 'cursor-pointer' : ''}`}
>
<div className="flex items-center justify-between">
<div>
<p className="text-white/60 text-sm font-medium">{title}</p>
<p className={`text-3xl font-bold mt-2 ${color}`}>{value}</p>
</div>
<div className={`p-3 rounded-xl ${color.replace('text-', 'bg-').replace('-400', '-500/20')}`}>
{icon}
</div>
</div>
</div>
);
}
export function Dashboard() {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
// 통계 데이터
const [purchaseNR, setPurchaseNR] = useState(0);
const [purchaseCR, setPurchaseCR] = useState(0);
const [todoCount, setTodoCount] = useState(0);
const [todayWorkHrs, setTodayWorkHrs] = useState(0);
// 목록 데이터
const [urgentTodos, setUrgentTodos] = useState<TodoModel[]>([]);
const [purchaseNRList, setPurchaseNRList] = useState<PurchaseItem[]>([]);
const [purchaseCRList, setPurchaseCRList] = useState<PurchaseItem[]>([]);
// 모달 상태
const [showNRModal, setShowNRModal] = useState(false);
const [showCRModal, setShowCRModal] = useState(false);
const loadDashboardData = useCallback(async () => {
try {
// 오늘 날짜 (로컬 시간 기준)
const now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
// 현재 로그인 사용자 ID 가져오기
let currentUserId = '';
try {
const loginStatus = await comms.checkLoginStatus();
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
currentUserId = loginStatus.User.Id;
}
} catch (error) {
console.error('로그인 정보 로드 오류:', error);
}
// 병렬로 데이터 로드
const [
purchaseCount,
urgentTodosResponse,
allTodosResponse,
jobreportResponse,
] = await Promise.all([
comms.getPurchaseWaitCount(),
comms.getUrgentTodos(),
comms.getTodos(),
comms.getJobReportList(todayStr, todayStr, currentUserId, ''),
]);
setPurchaseNR(purchaseCount.NR);
setPurchaseCR(purchaseCount.CR);
if (urgentTodosResponse.Success && urgentTodosResponse.Data) {
setUrgentTodos(urgentTodosResponse.Data.slice(0, 5));
}
if (allTodosResponse.Success && allTodosResponse.Data) {
// 진행, 대기 상태의 할일만 카운트 (보류, 취소 제외)
const pendingCount = allTodosResponse.Data.filter(t => t.status === '0' || t.status === '1').length;
setTodoCount(pendingCount);
}
// 오늘 업무일지 작성시간 계산
if (jobreportResponse.Success && jobreportResponse.Data) {
const totalHrs = jobreportResponse.Data.reduce((acc, item) => acc + (item.hrs || 0), 0);
setTodayWorkHrs(totalHrs);
} else {
setTodayWorkHrs(0);
}
} catch (error) {
console.error('대시보드 데이터 로드 오류:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
const loadNRList = async () => {
try {
const list = await comms.getPurchaseNRList();
setPurchaseNRList(list);
setShowNRModal(true);
} catch (error) {
console.error('NR 목록 로드 오류:', error);
}
};
const loadCRList = async () => {
try {
const list = await comms.getPurchaseCRList();
setPurchaseCRList(list);
setShowCRModal(true);
} catch (error) {
console.error('CR 목록 로드 오류:', error);
}
};
useEffect(() => {
loadDashboardData();
// 30초마다 자동 새로고침
const interval = setInterval(loadDashboardData, 30000);
return () => clearInterval(interval);
}, [loadDashboardData]);
const handleRefresh = () => {
setRefreshing(true);
loadDashboardData();
};
const getStatusText = (status: string) => {
switch (status) {
case '0': return '대기';
case '1': return '진행';
case '2': return '취소';
case '3': return '보류';
case '5': return '완료';
default: return '대기';
}
};
const getStatusClass = (status: string) => {
switch (status) {
case '0': return 'bg-gray-500/20 text-gray-300';
case '1': return 'bg-primary-500/20 text-primary-300';
case '2': return 'bg-danger-500/20 text-danger-300';
case '3': return 'bg-warning-500/20 text-warning-300';
case '5': return 'bg-success-500/20 text-success-300';
default: return 'bg-white/10 text-white/50';
}
};
const getPriorityText = (seqno: number) => {
switch (seqno) {
case 1: return '중요';
case 2: return '매우 중요';
case 3: return '긴급';
default: return '보통';
}
};
const getPriorityClass = (seqno: number) => {
switch (seqno) {
case 1: return 'bg-primary-500/20 text-primary-300';
case 2: return 'bg-warning-500/20 text-warning-300';
case 3: return 'bg-danger-500/20 text-danger-300';
default: return 'bg-white/10 text-white/50';
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
</div>
);
}
return (
<div className="space-y-6 animate-fade-in">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-white"> </h2>
<button
onClick={handleRefresh}
disabled={refreshing}
className="flex items-center space-x-2 px-4 py-2 glass-effect rounded-lg text-white/70 hover:text-white transition-colors"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
<span></span>
</button>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<StatCard
title="구매요청 (NR)"
value={purchaseNR}
icon={<ShoppingCart className="w-6 h-6 text-primary-400" />}
color="text-primary-400"
onClick={loadNRList}
/>
<StatCard
title="구매요청 (CR)"
value={purchaseCR}
icon={<FileCheck className="w-6 h-6 text-success-400" />}
color="text-success-400"
onClick={loadCRList}
/>
<StatCard
title="미완료 할일"
value={todoCount}
icon={<ClipboardList className="w-6 h-6 text-warning-400" />}
color="text-warning-400"
onClick={() => navigate('/todo')}
/>
<StatCard
title="금일 업무일지"
value={`${todayWorkHrs}시간`}
icon={<Clock className="w-6 h-6 text-cyan-400" />}
color="text-cyan-400"
onClick={() => navigate('/jobreport')}
/>
</div>
{/* 급한 할일 목록 */}
<div className="glass-effect rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<h3 className="text-lg font-semibold text-white flex items-center">
<AlertTriangle className="w-5 h-5 mr-2 text-warning-400" />
</h3>
<button
onClick={() => navigate('/todo')}
className="text-sm text-primary-400 hover:text-primary-300 transition-colors"
>
</button>
</div>
<div className="divide-y divide-white/10">
{urgentTodos.length > 0 ? (
urgentTodos.map((todo) => (
<div
key={todo.idx}
className="px-6 py-4 hover:bg-white/5 transition-colors cursor-pointer"
onClick={() => navigate('/todo')}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
{todo.flag && (
<Flag className="w-4 h-4 text-warning-400" />
)}
<div>
<p className="text-white font-medium">
{todo.title || '제목 없음'}
</p>
<p className="text-white/60 text-sm line-clamp-1">
{todo.remark}
</p>
</div>
</div>
<div className="flex items-center space-x-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getPriorityClass(todo.seqno)}`}>
{getPriorityText(todo.seqno)}
</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(todo.status)}`}>
{getStatusText(todo.status)}
</span>
{todo.expire && (
<span className={`text-xs ${new Date(todo.expire) < new Date() ? 'text-danger-400' : 'text-white/60'}`}>
{new Date(todo.expire).toLocaleDateString('ko-KR')}
</span>
)}
</div>
</div>
</div>
))
) : (
<div className="px-6 py-8 text-center text-white/50">
<CheckCircle className="w-12 h-12 mx-auto mb-3 text-success-400/50" />
<p> </p>
</div>
)}
</div>
</div>
{/* NR 모달 */}
{showNRModal && (
<Modal title="구매요청 (NR) 목록" onClose={() => setShowNRModal(false)}>
<PurchaseTable data={purchaseNRList} />
</Modal>
)}
{/* CR 모달 */}
{showCRModal && (
<Modal title="구매요청 (CR) 목록" onClose={() => setShowCRModal(false)}>
<PurchaseTable data={purchaseCRList} />
</Modal>
)}
</div>
);
}
// 모달 컴포넌트
interface ModalProps {
title: string;
onClose: () => void;
children: React.ReactNode;
}
function Modal({ title, onClose, children }: ModalProps) {
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-effect rounded-2xl w-full max-w-4xl max-h-[80vh] overflow-hidden animate-slide-up">
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">{title}</h3>
<button
onClick={onClose}
className="text-white/70 hover:text-white transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="overflow-auto max-h-[calc(80vh-80px)]">
{children}
</div>
</div>
</div>
);
}
// 구매 테이블 컴포넌트
function PurchaseTable({ data }: { data: PurchaseItem[] }) {
if (data.length === 0) {
return (
<div className="px-6 py-8 text-center text-white/50">
</div>
);
}
return (
<table className="w-full">
<thead className="bg-white/10">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-right text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-right text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-right text-xs font-medium text-white/70 uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{data.map((item, idx) => (
<tr key={idx} className="hover:bg-white/5">
<td className="px-4 py-3 text-white/80 text-sm">{item.pdate}</td>
<td className="px-4 py-3 text-white/80 text-sm">{item.process}</td>
<td className="px-4 py-3 text-white text-sm">{item.pumname}</td>
<td className="px-4 py-3 text-white/80 text-sm">{item.pumscale}</td>
<td className="px-4 py-3 text-white/80 text-sm text-right">
{item.pumqtyreq?.toLocaleString()} {item.pumunit}
</td>
<td className="px-4 py-3 text-white/80 text-sm text-right">
{item.pumprice?.toLocaleString()}
</td>
<td className="px-4 py-3 text-white text-sm text-right font-medium">
{item.pumamt?.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
);
}

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

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

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

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

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

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

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

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

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

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

View 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';