feat: 품목정보 상세 패널 추가 및 프로젝트/근태/권한 기능 확장
- Items: 우측에 이미지, 담당자, 입고/발주내역 패널 추가 (fItems 윈폼 동일) - Project: 목록 및 상세 다이얼로그 구현 - Kuntae: 오류검사/수정 기능 추가 - UserAuth: 사용자 권한 관리 페이지 추가 - UserGroup: 그룹정보 다이얼로그로 전환 - Header: 사용자 메뉴 서브메뉴 방향 수정, 즐겨찾기 기능 - Backend API: Items 상세/담당자/구매내역, 근태 오류검사, 프로젝트 목록 등 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
188
Project/frontend/src/components/favorite/FavoriteDialog.tsx
Normal file
188
Project/frontend/src/components/favorite/FavoriteDialog.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Star, Folder, Globe, FileText, Database, Server, Mail, Image, Film, Music, Archive, Terminal, Settings, HardDrive, Network, Cloud } from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { FavoriteItem } from '@/types';
|
||||
|
||||
interface FavoriteDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// URL 유형에 따른 아이콘 및 색상 반환
|
||||
function getIconInfo(url: string): { icon: React.ElementType; color: string; bgColor: string } {
|
||||
const lowerUrl = url.toLowerCase();
|
||||
|
||||
// 로컬 폴더/드라이브 경로
|
||||
if (lowerUrl.match(/^[a-z]:\\/i) || lowerUrl.startsWith('\\\\') || lowerUrl.startsWith('file:')) {
|
||||
// 네트워크 경로
|
||||
if (lowerUrl.startsWith('\\\\')) {
|
||||
return { icon: Network, color: 'text-purple-400', bgColor: 'from-purple-500/30 to-purple-600/30' };
|
||||
}
|
||||
return { icon: Folder, color: 'text-yellow-400', bgColor: 'from-yellow-500/30 to-yellow-600/30' };
|
||||
}
|
||||
|
||||
// 웹 URL
|
||||
if (lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://')) {
|
||||
// 특정 서비스 감지
|
||||
if (lowerUrl.includes('mail') || lowerUrl.includes('outlook') || lowerUrl.includes('gmail')) {
|
||||
return { icon: Mail, color: 'text-red-400', bgColor: 'from-red-500/30 to-red-600/30' };
|
||||
}
|
||||
if (lowerUrl.includes('cloud') || lowerUrl.includes('drive') || lowerUrl.includes('onedrive') || lowerUrl.includes('dropbox')) {
|
||||
return { icon: Cloud, color: 'text-sky-400', bgColor: 'from-sky-500/30 to-sky-600/30' };
|
||||
}
|
||||
if (lowerUrl.includes('server') || lowerUrl.includes('admin')) {
|
||||
return { icon: Server, color: 'text-orange-400', bgColor: 'from-orange-500/30 to-orange-600/30' };
|
||||
}
|
||||
if (lowerUrl.includes('database') || lowerUrl.includes('sql') || lowerUrl.includes('db')) {
|
||||
return { icon: Database, color: 'text-emerald-400', bgColor: 'from-emerald-500/30 to-emerald-600/30' };
|
||||
}
|
||||
return { icon: Globe, color: 'text-blue-400', bgColor: 'from-blue-500/30 to-blue-600/30' };
|
||||
}
|
||||
|
||||
// 실행 파일
|
||||
if (lowerUrl.endsWith('.exe') || lowerUrl.endsWith('.bat') || lowerUrl.endsWith('.cmd') || lowerUrl.endsWith('.ps1')) {
|
||||
return { icon: Terminal, color: 'text-green-400', bgColor: 'from-green-500/30 to-green-600/30' };
|
||||
}
|
||||
|
||||
// 문서 파일
|
||||
if (lowerUrl.match(/\.(doc|docx|pdf|txt|xls|xlsx|ppt|pptx|hwp)$/i)) {
|
||||
return { icon: FileText, color: 'text-blue-400', bgColor: 'from-blue-500/30 to-blue-600/30' };
|
||||
}
|
||||
|
||||
// 이미지 파일
|
||||
if (lowerUrl.match(/\.(jpg|jpeg|png|gif|bmp|svg|webp)$/i)) {
|
||||
return { icon: Image, color: 'text-pink-400', bgColor: 'from-pink-500/30 to-pink-600/30' };
|
||||
}
|
||||
|
||||
// 비디오 파일
|
||||
if (lowerUrl.match(/\.(mp4|avi|mkv|mov|wmv)$/i)) {
|
||||
return { icon: Film, color: 'text-violet-400', bgColor: 'from-violet-500/30 to-violet-600/30' };
|
||||
}
|
||||
|
||||
// 오디오 파일
|
||||
if (lowerUrl.match(/\.(mp3|wav|flac|aac|ogg)$/i)) {
|
||||
return { icon: Music, color: 'text-rose-400', bgColor: 'from-rose-500/30 to-rose-600/30' };
|
||||
}
|
||||
|
||||
// 압축 파일
|
||||
if (lowerUrl.match(/\.(zip|rar|7z|tar|gz)$/i)) {
|
||||
return { icon: Archive, color: 'text-amber-400', bgColor: 'from-amber-500/30 to-amber-600/30' };
|
||||
}
|
||||
|
||||
// 설정 파일
|
||||
if (lowerUrl.match(/\.(ini|cfg|config|xml|json|yaml|yml)$/i)) {
|
||||
return { icon: Settings, color: 'text-gray-400', bgColor: 'from-gray-500/30 to-gray-600/30' };
|
||||
}
|
||||
|
||||
// 드라이브 루트
|
||||
if (lowerUrl.match(/^[a-z]:$/i)) {
|
||||
return { icon: HardDrive, color: 'text-slate-400', bgColor: 'from-slate-500/30 to-slate-600/30' };
|
||||
}
|
||||
|
||||
// 기본값: 폴더
|
||||
return { icon: Folder, color: 'text-yellow-400', bgColor: 'from-yellow-500/30 to-yellow-600/30' };
|
||||
}
|
||||
|
||||
export function FavoriteDialog({ isOpen, onClose }: FavoriteDialogProps) {
|
||||
const [favorites, setFavorites] = useState<FavoriteItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadFavorites();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadFavorites = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await comms.getFavoriteList();
|
||||
if (response.Success && response.Data) {
|
||||
// 이름으로 정렬
|
||||
const sorted = (response.Data as FavoriteItem[]).sort((a, b) =>
|
||||
a.name.localeCompare(b.name, 'ko')
|
||||
);
|
||||
setFavorites(sorted);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('즐겨찾기 로드 오류:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemClick = (url: string) => {
|
||||
window.open(url, '_blank');
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="relative w-full max-w-4xl mx-4 glass-effect-solid rounded-2xl shadow-2xl overflow-hidden animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<Star className="w-5 h-5 text-yellow-400" />
|
||||
<h2 className="text-lg font-semibold text-white">즐겨찾기</h2>
|
||||
<span className="text-white/50 text-sm">({favorites.length}개)</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg text-white/60 hover:text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 max-h-[70vh] overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white/60" />
|
||||
</div>
|
||||
) : favorites.length === 0 ? (
|
||||
<div className="text-center py-12 text-white/50">
|
||||
등록된 즐겨찾기가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
{favorites.map((item, index) => {
|
||||
const { icon: Icon, color, bgColor } = getIconInfo(item.url);
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleItemClick(item.url)}
|
||||
className="group flex flex-col items-center p-4 rounded-xl bg-white/5 hover:bg-white/15 border border-white/10 hover:border-white/30 transition-all duration-200 hover:scale-105"
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg bg-gradient-to-br ${bgColor} flex items-center justify-center mb-3 group-hover:opacity-80 transition-opacity`}>
|
||||
<Icon className={`w-5 h-5 ${color}`} />
|
||||
</div>
|
||||
<span className="text-sm text-white/80 group-hover:text-white text-center line-clamp-2 leading-tight">
|
||||
{item.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-3 border-t border-white/10 bg-black/20">
|
||||
<p className="text-xs text-white/40 text-center">
|
||||
클릭하면 새 탭에서 열립니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
Project/frontend/src/components/favorite/index.ts
Normal file
1
Project/frontend/src/components/favorite/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { FavoriteDialog } from './FavoriteDialog';
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Save, Trash2 } from 'lucide-react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { X, Save, Trash2, Upload, Clipboard, ImageIcon } from 'lucide-react';
|
||||
import { ItemInfo } from '@/types';
|
||||
import { comms } from '@/communication';
|
||||
|
||||
interface ItemEditDialogProps {
|
||||
item: ItemInfo | null;
|
||||
@@ -13,13 +14,169 @@ interface ItemEditDialogProps {
|
||||
export function ItemEditDialog({ item, isOpen, onClose, onSave, onDelete }: ItemEditDialogProps) {
|
||||
const [editData, setEditData] = useState<ItemInfo | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [imageData, setImageData] = useState<string | null>(null);
|
||||
const [imageLoading, setImageLoading] = useState(false);
|
||||
const [imageSaving, setImageSaving] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dropZoneRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
setEditData({ ...item });
|
||||
setImageData(null);
|
||||
// 기존 품목인 경우 이미지 로드
|
||||
if (item.idx > 0) {
|
||||
loadImage(item.idx);
|
||||
}
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
// 이미지 로드
|
||||
const loadImage = async (idx: number) => {
|
||||
setImageLoading(true);
|
||||
try {
|
||||
const result = await comms.getItemImage(idx);
|
||||
if (result.Success && result.Data) {
|
||||
setImageData(`data:image/jpeg;base64,${result.Data}`);
|
||||
} else {
|
||||
setImageData(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('이미지 로드 실패:', error);
|
||||
setImageData(null);
|
||||
} finally {
|
||||
setImageLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ESC 키로 닫기
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// 이미지를 Base64로 변환
|
||||
const convertToBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
// 이미지 파일 처리
|
||||
const handleImageFile = async (file: File) => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('이미지 파일만 업로드 가능합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const base64 = await convertToBase64(file);
|
||||
setImageData(base64);
|
||||
|
||||
// 기존 품목인 경우 바로 저장
|
||||
if (editData && editData.idx > 0) {
|
||||
setImageSaving(true);
|
||||
const result = await comms.saveItemImage(editData.idx, base64);
|
||||
if (!result.Success) {
|
||||
alert(result.Message || '이미지 저장 실패');
|
||||
}
|
||||
setImageSaving(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('이미지 처리 실패:', error);
|
||||
alert('이미지 처리에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 파일 선택
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
handleImageFile(file);
|
||||
}
|
||||
// 같은 파일 다시 선택 가능하도록
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
// 클립보드에서 붙여넣기
|
||||
const handlePaste = useCallback(async () => {
|
||||
try {
|
||||
const items = await navigator.clipboard.read();
|
||||
for (const item of items) {
|
||||
const imageType = item.types.find(type => type.startsWith('image/'));
|
||||
if (imageType) {
|
||||
const blob = await item.getType(imageType);
|
||||
const file = new File([blob], 'clipboard-image.png', { type: imageType });
|
||||
await handleImageFile(file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
alert('클립보드에 이미지가 없습니다.');
|
||||
} catch (error) {
|
||||
console.error('클립보드 읽기 실패:', error);
|
||||
alert('클립보드에서 이미지를 가져올 수 없습니다.');
|
||||
}
|
||||
}, [editData]);
|
||||
|
||||
// 드래그 앤 드롭
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
const file = files[0];
|
||||
await handleImageFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
// 이미지 삭제
|
||||
const handleDeleteImage = async () => {
|
||||
if (!editData) return;
|
||||
|
||||
if (!confirm('이미지를 삭제하시겠습니까?')) return;
|
||||
|
||||
if (editData.idx > 0) {
|
||||
setImageSaving(true);
|
||||
const result = await comms.deleteItemImage(editData.idx);
|
||||
if (result.Success) {
|
||||
setImageData(null);
|
||||
} else {
|
||||
alert(result.Message || '이미지 삭제 실패');
|
||||
}
|
||||
setImageSaving(false);
|
||||
} else {
|
||||
setImageData(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen || !editData) return null;
|
||||
|
||||
const isNew = editData.idx === 0;
|
||||
@@ -48,10 +205,13 @@ export function ItemEditDialog({ item, isOpen, onClose, onSave, onDelete }: Item
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* 배경 오버레이 */}
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onMouseDown={onClose} />
|
||||
|
||||
{/* 다이얼로그 */}
|
||||
<div className="relative bg-slate-800 rounded-xl shadow-2xl w-full max-w-lg mx-4 border border-white/10">
|
||||
{/* 다이얼로그 - 이미지 영역 포함해서 더 넓게 */}
|
||||
<div
|
||||
className="relative bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl mx-4 border border-white/10"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
@@ -65,145 +225,245 @@ export function ItemEditDialog({ item, isOpen, onClose, onSave, onDelete }: Item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="p-4 space-y-4 max-h-[60vh] overflow-auto">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* SID */}
|
||||
{/* 내용 - 좌우 레이아웃 */}
|
||||
<div className="flex max-h-[70vh]">
|
||||
{/* 왼쪽: 폼 필드 */}
|
||||
<div className="flex-1 p-4 space-y-4 overflow-auto">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* SID */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">SID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.sid}
|
||||
onChange={(e) => setEditData({ ...editData, sid: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 분류 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">분류</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.cate}
|
||||
onChange={(e) => setEditData({ ...editData, cate: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품명 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">SID</label>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">품명</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.sid}
|
||||
onChange={(e) => setEditData({ ...editData, sid: e.target.value })}
|
||||
value={editData.name}
|
||||
onChange={(e) => setEditData({ ...editData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 분류 */}
|
||||
{/* 모델 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">분류</label>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">모델</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.cate}
|
||||
onChange={(e) => setEditData({ ...editData, cate: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품명 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">품명</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.name}
|
||||
onChange={(e) => setEditData({ ...editData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 모델 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">모델</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.model}
|
||||
onChange={(e) => setEditData({ ...editData, model: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* 규격 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">규격</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.scale}
|
||||
onChange={(e) => setEditData({ ...editData, scale: e.target.value })}
|
||||
value={editData.model}
|
||||
onChange={(e) => setEditData({ ...editData, model: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 단위 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* 규격 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">규격</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.scale}
|
||||
onChange={(e) => setEditData({ ...editData, scale: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 단위 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">단위</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.unit}
|
||||
onChange={(e) => setEditData({ ...editData, unit: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 단가 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">단가</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editData.price}
|
||||
onChange={(e) => setEditData({ ...editData, price: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white text-right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 공급처 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">공급처</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.supply}
|
||||
onChange={(e) => setEditData({ ...editData, supply: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 제조사 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">제조사</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.manu}
|
||||
onChange={(e) => setEditData({ ...editData, manu: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 보관장소 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">단위</label>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">보관장소</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.unit}
|
||||
onChange={(e) => setEditData({ ...editData, unit: e.target.value })}
|
||||
value={editData.storage}
|
||||
onChange={(e) => setEditData({ ...editData, storage: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 단가 */}
|
||||
{/* 메모 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">단가</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editData.price}
|
||||
onChange={(e) => setEditData({ ...editData, price: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white text-right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 공급처 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">공급처</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.supply}
|
||||
onChange={(e) => setEditData({ ...editData, supply: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">메모</label>
|
||||
<textarea
|
||||
value={editData.memo}
|
||||
onChange={(e) => setEditData({ ...editData, memo: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 제조사 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">제조사</label>
|
||||
{/* 비활성화 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editData.manu}
|
||||
onChange={(e) => setEditData({ ...editData, manu: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
type="checkbox"
|
||||
id="disable"
|
||||
checked={editData.disable}
|
||||
onChange={(e) => setEditData({ ...editData, disable: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-white/20 bg-white/10"
|
||||
/>
|
||||
<label htmlFor="disable" className="text-sm text-white/70">비활성화</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 보관장소 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">보관장소</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.storage}
|
||||
onChange={(e) => setEditData({ ...editData, storage: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
{/* 오른쪽: 이미지 영역 */}
|
||||
<div className="w-72 p-4 border-l border-white/10 flex flex-col">
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">이미지</label>
|
||||
|
||||
{/* 메모 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">메모</label>
|
||||
<textarea
|
||||
value={editData.memo}
|
||||
onChange={(e) => setEditData({ ...editData, memo: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white resize-none"
|
||||
/>
|
||||
</div>
|
||||
{/* 이미지 드롭존 */}
|
||||
<div
|
||||
ref={dropZoneRef}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
className={`flex-1 min-h-[200px] rounded-lg border-2 border-dashed transition-colors flex items-center justify-center overflow-hidden ${
|
||||
isDragging
|
||||
? 'border-blue-400 bg-blue-500/20'
|
||||
: 'border-white/20 bg-white/5 hover:border-white/40'
|
||||
}`}
|
||||
>
|
||||
{imageLoading ? (
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white/50 mx-auto mb-2"></div>
|
||||
<p className="text-white/50 text-sm">로딩 중...</p>
|
||||
</div>
|
||||
) : imageSaving ? (
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-400 mx-auto mb-2"></div>
|
||||
<p className="text-white/50 text-sm">저장 중...</p>
|
||||
</div>
|
||||
) : imageData ? (
|
||||
<img
|
||||
src={imageData}
|
||||
alt="품목 이미지"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center p-4">
|
||||
<ImageIcon className="w-12 h-12 text-white/30 mx-auto mb-2" />
|
||||
<p className="text-white/50 text-sm">
|
||||
이미지를 드래그하거나<br />
|
||||
아래 버튼을 사용하세요
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 비활성화 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="disable"
|
||||
checked={editData.disable}
|
||||
onChange={(e) => setEditData({ ...editData, disable: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-white/20 bg-white/10"
|
||||
/>
|
||||
<label htmlFor="disable" className="text-sm text-white/70">비활성화</label>
|
||||
{/* 이미지 버튼들 */}
|
||||
<div className="mt-3 space-y-2">
|
||||
{/* 파일 선택 */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={imageSaving}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white/80 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
파일 선택
|
||||
</button>
|
||||
|
||||
{/* 붙여넣기 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePaste}
|
||||
disabled={imageSaving}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white/80 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Clipboard className="w-4 h-4" />
|
||||
붙여넣기
|
||||
</button>
|
||||
|
||||
{/* 이미지 삭제 */}
|
||||
{imageData && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteImage}
|
||||
disabled={imageSaving}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-red-600/20 hover:bg-red-600/40 rounded-lg text-red-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
이미지 삭제
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 신규 품목 안내 */}
|
||||
{isNew && imageData && (
|
||||
<p className="text-xs text-yellow-400/80 text-center">
|
||||
* 품목 저장 후 이미지가 적용됩니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -33,6 +33,17 @@ export function JobTypeSelectModal({
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
const [selectedPath, setSelectedPath] = useState<string>('');
|
||||
|
||||
// ESC 키로 닫기
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -161,9 +172,8 @@ export function JobTypeSelectModal({
|
||||
|
||||
// 항목 더블클릭
|
||||
const handleDoubleClick = (item: JobTypeItem) => {
|
||||
const process = item.process || 'N/A';
|
||||
const jobgrp = item.jobgrp || 'N/A';
|
||||
onSelect(process, jobgrp, item.type);
|
||||
// 원본 값 그대로 전달 (N/A 변환은 상위에서 처리)
|
||||
onSelect(item.process || '', item.jobgrp || '', item.type);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -172,7 +182,10 @@ export function JobTypeSelectModal({
|
||||
if (selectedPath) {
|
||||
const parts = selectedPath.split('|');
|
||||
if (parts.length === 3) {
|
||||
onSelect(parts[0], parts[1], parts[2]);
|
||||
// N/A 값은 빈 문자열로 변환하여 전달 (상위에서 처리)
|
||||
const process = parts[0] === 'N/A' ? '' : parts[0];
|
||||
const jobgrp = parts[1] === 'N/A' ? '' : parts[1];
|
||||
onSelect(process, jobgrp, parts[2]);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
@@ -183,12 +196,12 @@ export function JobTypeSelectModal({
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60]"
|
||||
onClick={onClose}
|
||||
onMouseDown={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 max-h-[85vh] flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
||||
@@ -300,13 +313,22 @@ export function JobTypeSelectModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 선택된 항목 표시 */}
|
||||
{/* 선택된 항목 표시 (WinForms과 동일: type ← jobgrp) */}
|
||||
{selectedPath && (
|
||||
<div className="px-6 py-3 border-t border-white/10 bg-primary-500/10">
|
||||
<div className="text-sm">
|
||||
<span className="text-white/50">선택: </span>
|
||||
<span className="text-primary-300 font-medium">
|
||||
{selectedPath.split('|').reverse().join(' ← ')}
|
||||
{(() => {
|
||||
const parts = selectedPath.split('|');
|
||||
// parts[0]=process, parts[1]=jobgrp, parts[2]=type
|
||||
const type = parts[2] || '';
|
||||
const jobgrp = parts[1] || '';
|
||||
if (jobgrp && jobgrp !== 'N/A') {
|
||||
return `${type} ← ${jobgrp}`;
|
||||
}
|
||||
return type;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
import { FileText, Plus, Trash2, X, Loader2, ChevronDown } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { FileText, Plus, Trash2, X, Loader2, ChevronDown, Search } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { JobReportItem } from '@/types';
|
||||
import { JobReportItem, CommonCode } from '@/types';
|
||||
import { JobTypeSelectModal } from './JobTypeSelectModal';
|
||||
import { ProjectSearchDialog } from './ProjectSearchDialog';
|
||||
import { comms } from '@/communication';
|
||||
|
||||
export interface JobreportFormData {
|
||||
pdate: string;
|
||||
projectName: string;
|
||||
pidx: number | null; // 프로젝트 인덱스 (-1이면 프로젝트 연결 없음)
|
||||
requestpart: string;
|
||||
package: string;
|
||||
type: string;
|
||||
@@ -27,6 +30,7 @@ const formatDateLocal = (date: Date) => {
|
||||
export const initialFormData: JobreportFormData = {
|
||||
pdate: formatDateLocal(new Date()),
|
||||
projectName: '',
|
||||
pidx: null,
|
||||
requestpart: '',
|
||||
package: '',
|
||||
type: '',
|
||||
@@ -61,6 +65,52 @@ export function JobreportEditModal({
|
||||
onDelete,
|
||||
}: JobreportEditModalProps) {
|
||||
const [showJobTypeModal, setShowJobTypeModal] = useState(false);
|
||||
const [showProjectSearch, setShowProjectSearch] = useState(false);
|
||||
const [requestPartList, setRequestPartList] = useState<CommonCode[]>([]);
|
||||
const [packageList, setPackageList] = useState<CommonCode[]>([]);
|
||||
const [processList, setProcessList] = useState<CommonCode[]>([]);
|
||||
const [statusList, setStatusList] = useState<CommonCode[]>([]);
|
||||
const [loadingCodes, setLoadingCodes] = useState(false);
|
||||
|
||||
// ESC 키로 닫기
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen && !showJobTypeModal && !showProjectSearch) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose, showJobTypeModal, showProjectSearch]);
|
||||
|
||||
// 공용코드 로드 (WebSocket에서 동일 응답타입 충돌 방지를 위해 순차 로드)
|
||||
const loadCommonCodes = useCallback(async () => {
|
||||
setLoadingCodes(true);
|
||||
try {
|
||||
// WebSocket 모드에서는 같은 응답타입을 사용하므로 순차적으로 로드
|
||||
const requestPart = await comms.getCommonList('13'); // 요청부서
|
||||
setRequestPartList(requestPart || []);
|
||||
|
||||
const packages = await comms.getCommonList('14'); // 패키지
|
||||
setPackageList(packages || []);
|
||||
|
||||
const processes = await comms.getCommonList('16'); // 공정(프로세스)
|
||||
setProcessList(processes || []);
|
||||
|
||||
const statuses = await comms.getCommonList('12'); // 상태
|
||||
setStatusList(statuses || []);
|
||||
} catch (error) {
|
||||
console.error('공용코드 로드 오류:', error);
|
||||
} finally {
|
||||
setLoadingCodes(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadCommonCodes();
|
||||
}
|
||||
}, [isOpen, loadCommonCodes]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -73,22 +123,30 @@ export function JobreportEditModal({
|
||||
|
||||
// 업무형태 선택 처리
|
||||
const handleJobTypeSelect = (process: string, jobgrp: string, type: string) => {
|
||||
// WinForms과 동일하게 N/A 처리: jobgrp만 N/A 처리, process는 빈 값 허용
|
||||
const normalizedJobgrp = (!jobgrp || jobgrp === '(N/A)') ? 'N/A' : jobgrp;
|
||||
// process가 N/A면 빈 문자열로 (공정 드롭다운에서 선택하도록)
|
||||
const normalizedProcess = (process === 'N/A') ? '' : process;
|
||||
|
||||
onFormChange({
|
||||
...formData,
|
||||
process,
|
||||
jobgrp,
|
||||
process: normalizedProcess || formData.process, // process가 없으면 기존 값 유지
|
||||
jobgrp: normalizedJobgrp,
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
// 업무형태 표시 텍스트
|
||||
// 업무형태 표시 텍스트 (type ← jobgrp 형태, WinForms과 동일)
|
||||
const getJobTypeDisplayText = () => {
|
||||
if (!formData.type) {
|
||||
return '업무형태를 선택하세요';
|
||||
}
|
||||
|
||||
// WinForms: fullname = $"{jtype} ← {jgrp}"
|
||||
if (formData.jobgrp && formData.jobgrp !== 'N/A') {
|
||||
return `${formData.type} ← ${formData.jobgrp}`;
|
||||
}
|
||||
|
||||
return formData.type;
|
||||
};
|
||||
|
||||
@@ -138,12 +196,12 @@ export function JobreportEditModal({
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50"
|
||||
onClick={onClose}
|
||||
onMouseDown={onClose}
|
||||
>
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<div
|
||||
className="glass-effect rounded-2xl w-full max-w-3xl animate-slide-up max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between sticky top-0 bg-slate-800/95 backdrop-blur z-10">
|
||||
@@ -178,43 +236,98 @@ export function JobreportEditModal({
|
||||
<div className="col-span-3">
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">
|
||||
프로젝트명 *
|
||||
{formData.pidx !== null && formData.pidx > 0 && (
|
||||
<span className="ml-2 text-xs text-primary-400 font-mono">[pidx: {formData.pidx}]</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.projectName}
|
||||
onChange={(e) => handleFieldChange('projectName', e.target.value)}
|
||||
className="w-full bg-white/20 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"
|
||||
placeholder="프로젝트 또는 아이템명"
|
||||
required
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.projectName}
|
||||
onChange={(e) => {
|
||||
handleFieldChange('projectName', e.target.value);
|
||||
// 프로젝트명을 직접 수정하면 pidx 연결 해제
|
||||
if (formData.pidx !== null && formData.pidx > 0) {
|
||||
onFormChange({ ...formData, projectName: e.target.value, pidx: -1 });
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
setShowProjectSearch(true);
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-white/20 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"
|
||||
placeholder="프로젝트명 입력 후 Enter로 검색"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowProjectSearch(true)}
|
||||
className="px-3 py-2 bg-white/20 hover:bg-white/30 border border-white/30 rounded-lg text-white transition-colors"
|
||||
title="프로젝트 검색"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2행: 요청부서, 패키지 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 2행: 요청부서, 패키지, 공정 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">
|
||||
요청부서
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
<select
|
||||
value={formData.requestpart}
|
||||
onChange={(e) => handleFieldChange('requestpart', e.target.value)}
|
||||
className="w-full bg-white/20 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"
|
||||
placeholder="요청부서"
|
||||
/>
|
||||
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"
|
||||
disabled={loadingCodes}
|
||||
>
|
||||
<option value="" className="bg-gray-800">선택...</option>
|
||||
{requestPartList.map((item) => (
|
||||
<option key={item.idx} value={item.memo || item.svalue} className="bg-gray-800">
|
||||
{item.memo || item.svalue}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">
|
||||
패키지
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
<select
|
||||
value={formData.package}
|
||||
onChange={(e) => handleFieldChange('package', e.target.value)}
|
||||
className="w-full bg-white/20 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"
|
||||
placeholder="패키지"
|
||||
/>
|
||||
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"
|
||||
disabled={loadingCodes}
|
||||
>
|
||||
<option value="" className="bg-gray-800">선택...</option>
|
||||
{packageList.map((item) => (
|
||||
<option key={item.idx} value={item.memo || item.svalue} className="bg-gray-800">
|
||||
{item.memo || item.svalue}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">
|
||||
공정 *
|
||||
</label>
|
||||
<select
|
||||
value={formData.process}
|
||||
onChange={(e) => handleFieldChange('process', 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"
|
||||
disabled={loadingCodes}
|
||||
>
|
||||
<option value="" className="bg-gray-800">선택...</option>
|
||||
{processList.map((item) => (
|
||||
<option key={item.idx} value={item.memo || item.svalue} className="bg-gray-800">
|
||||
{item.memo || item.svalue}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -235,33 +348,33 @@ export function JobreportEditModal({
|
||||
<span>{getJobTypeDisplayText()}</span>
|
||||
<ChevronDown className="w-4 h-4 text-white/50" />
|
||||
</button>
|
||||
{formData.process && (
|
||||
<div className="mt-1 text-xs text-white/50">
|
||||
공정: {formData.process}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 4행: 상태, 근무시간, 초과시간 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">
|
||||
상태
|
||||
상태 *
|
||||
</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => handleFieldChange('status', 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"
|
||||
disabled={loadingCodes}
|
||||
>
|
||||
<option value="진행 완료" className="bg-gray-800">
|
||||
진행 완료
|
||||
</option>
|
||||
<option value="진행 중" className="bg-gray-800">
|
||||
진행 중
|
||||
</option>
|
||||
<option value="대기" className="bg-gray-800">
|
||||
대기
|
||||
</option>
|
||||
{statusList.length > 0 ? (
|
||||
statusList.map((item) => (
|
||||
<option key={item.idx} value={item.memo || item.svalue} className="bg-gray-800">
|
||||
{item.memo || item.svalue}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<option value="진행 완료" className="bg-gray-800">진행 완료</option>
|
||||
<option value="진행 중" className="bg-gray-800">진행 중</option>
|
||||
<option value="대기" className="bg-gray-800">대기</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -366,6 +479,20 @@ export function JobreportEditModal({
|
||||
onClose={() => setShowJobTypeModal(false)}
|
||||
onSelect={handleJobTypeSelect}
|
||||
/>
|
||||
|
||||
{/* 프로젝트 검색 다이얼로그 */}
|
||||
<ProjectSearchDialog
|
||||
isOpen={showProjectSearch}
|
||||
onClose={() => setShowProjectSearch(false)}
|
||||
onSelect={(project) => {
|
||||
onFormChange({
|
||||
...formData,
|
||||
projectName: project.name,
|
||||
pidx: project.idx > 0 ? project.idx : -1,
|
||||
});
|
||||
}}
|
||||
initialSearchKey={formData.projectName}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Search, X, Folder, FileText, Check } from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { ProjectSearchItem } from '@/types';
|
||||
|
||||
interface ProjectSearchDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (project: { idx: number; name: string }) => void;
|
||||
initialSearchKey?: string;
|
||||
}
|
||||
|
||||
export function ProjectSearchDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
initialSearchKey = '',
|
||||
}: ProjectSearchDialogProps) {
|
||||
const [projects, setProjects] = useState<ProjectSearchItem[]>([]);
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedProject, setSelectedProject] = useState<ProjectSearchItem | null>(null);
|
||||
|
||||
// 프로젝트 검색
|
||||
const searchProjects = useCallback(async (keyword: string) => {
|
||||
if (!keyword.trim()) {
|
||||
setProjects([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await comms.searchProjects(keyword);
|
||||
if (result.Success && result.Data) {
|
||||
setProjects(result.Data);
|
||||
} else {
|
||||
setProjects([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 검색 실패:', error);
|
||||
setProjects([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 다이얼로그 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSearchKey(initialSearchKey);
|
||||
setSelectedProject(null);
|
||||
if (initialSearchKey) {
|
||||
searchProjects(initialSearchKey);
|
||||
} else {
|
||||
setProjects([]);
|
||||
}
|
||||
}
|
||||
}, [isOpen, initialSearchKey, searchProjects]);
|
||||
|
||||
// 검색어 변경 시 검색 (디바운스)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (searchKey.trim()) {
|
||||
searchProjects(searchKey);
|
||||
} else {
|
||||
setProjects([]);
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchKey, searchProjects]);
|
||||
|
||||
// 선택 확정
|
||||
const handleConfirm = () => {
|
||||
if (selectedProject) {
|
||||
onSelect({ idx: selectedProject.idx, name: selectedProject.name });
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// ESC 키로 닫기
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 flex items-center justify-center z-[60]"
|
||||
onMouseDown={onClose}
|
||||
>
|
||||
<div
|
||||
className="glass-effect rounded-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="p-4 border-b border-white/10 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="w-5 h-5 text-primary-400" />
|
||||
<h2 className="text-lg font-semibold text-white">프로젝트/항목 검색</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="p-4 border-b border-white/10 shrink-0">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/50" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchKey}
|
||||
onChange={(e) => setSearchKey(e.target.value)}
|
||||
placeholder="프로젝트명 또는 번호로 검색..."
|
||||
autoFocus
|
||||
className="w-full pl-10 pr-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 목록 */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-400 mx-auto mb-2"></div>
|
||||
<p className="text-white/50">검색 중...</p>
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="text-center py-8 text-white/50">
|
||||
{searchKey ? '검색 결과가 없습니다' : '검색어를 입력하세요'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{projects.map((project, index) => (
|
||||
<button
|
||||
key={`${project.source}-${project.idx}-${index}`}
|
||||
onClick={() => setSelectedProject(project)}
|
||||
onDoubleClick={() => {
|
||||
setSelectedProject(project);
|
||||
onSelect({ idx: project.idx, name: project.name });
|
||||
onClose();
|
||||
}}
|
||||
className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${
|
||||
selectedProject?.idx === project.idx && selectedProject?.name === project.name
|
||||
? 'bg-primary-500/30 border border-primary-400/50'
|
||||
: 'bg-white/5 hover:bg-white/10 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{/* 아이콘 */}
|
||||
<div className={`shrink-0 ${project.source === 'project' ? 'text-blue-400' : 'text-gray-400'}`}>
|
||||
{project.source === 'project' ? (
|
||||
<Folder className="w-5 h-5" />
|
||||
) : (
|
||||
<FileText className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 정보 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{project.idx > 0 && (
|
||||
<span className="text-xs text-white/40 font-mono">[{project.idx}]</span>
|
||||
)}
|
||||
<span className="font-medium text-white truncate">{project.name}</span>
|
||||
{project.status && (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
project.status === '진행' ? 'bg-green-500/20 text-green-400' :
|
||||
project.status === '준비' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||
'bg-gray-500/20 text-gray-400'
|
||||
}`}>
|
||||
{project.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-white/50 mt-0.5 truncate">
|
||||
{project.source === 'project' ? (
|
||||
<>
|
||||
{project.userManager && `담당: ${project.userManager}`}
|
||||
{project.userMain && ` | 챔피언: ${project.userMain}`}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
과거 등록 항목
|
||||
{project.lastDate && ` | 최근: ${project.lastDate}`}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선택 체크 */}
|
||||
{selectedProject?.idx === project.idx && selectedProject?.name === project.name && (
|
||||
<Check className="w-5 h-5 text-primary-400 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="p-4 border-t border-white/10 flex items-center justify-between shrink-0">
|
||||
<span className="text-sm text-white/50">
|
||||
{projects.length}건
|
||||
{selectedProject && ` | 선택: ${selectedProject.name}`}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedProject}
|
||||
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg text-white transition-colors"
|
||||
>
|
||||
선택
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { X, Search, RefreshCw, ChevronLeft, ChevronRight, Check, AlertTriangle } from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { KuntaeErrorCheckResult, KuntaeErrorCheckResponse } from '@/types';
|
||||
|
||||
interface KuntaeErrorCheckDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// 날짜를 2자리 연도로 포맷팅 (2025-01-15 -> 25-01-15)
|
||||
const formatDateShort = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
const parts = dateStr.split('-');
|
||||
if (parts.length === 3) {
|
||||
return `${parts[0].slice(-2)}-${parts[1]}-${parts[2]}`;
|
||||
}
|
||||
return dateStr;
|
||||
};
|
||||
|
||||
export function KuntaeErrorCheckDialog({ isOpen, onClose }: KuntaeErrorCheckDialogProps) {
|
||||
// 날짜 상태
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
|
||||
// 검사 결과
|
||||
const [okList, setOkList] = useState<KuntaeErrorCheckResult[]>([]);
|
||||
const [ngList, setNgList] = useState<KuntaeErrorCheckResult[]>([]);
|
||||
|
||||
// 선택된 오류 항목
|
||||
const [selectedErrors, setSelectedErrors] = useState<Set<string>>(new Set());
|
||||
|
||||
// 상태
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [isFixing, setIsFixing] = useState(false);
|
||||
const [currentDate, setCurrentDate] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
// 이번달로 초기화
|
||||
const setThisMonth = useCallback(() => {
|
||||
const now = new Date();
|
||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
setStartDate(firstDay.toISOString().split('T')[0]);
|
||||
setEndDate(lastDay.toISOString().split('T')[0]);
|
||||
}, []);
|
||||
|
||||
// 이전달
|
||||
const setPrevMonth = useCallback(() => {
|
||||
const current = startDate ? new Date(startDate) : new Date();
|
||||
const firstDay = new Date(current.getFullYear(), current.getMonth() - 1, 1);
|
||||
const lastDay = new Date(current.getFullYear(), current.getMonth(), 0);
|
||||
setStartDate(firstDay.toISOString().split('T')[0]);
|
||||
setEndDate(lastDay.toISOString().split('T')[0]);
|
||||
}, [startDate]);
|
||||
|
||||
// 다음달
|
||||
const setNextMonth = useCallback(() => {
|
||||
const current = startDate ? new Date(startDate) : new Date();
|
||||
const firstDay = new Date(current.getFullYear(), current.getMonth() + 1, 1);
|
||||
const lastDay = new Date(current.getFullYear(), current.getMonth() + 2, 0);
|
||||
setStartDate(firstDay.toISOString().split('T')[0]);
|
||||
setEndDate(lastDay.toISOString().split('T')[0]);
|
||||
}, [startDate]);
|
||||
|
||||
// 다이얼로그 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setThisMonth();
|
||||
setOkList([]);
|
||||
setNgList([]);
|
||||
setSelectedErrors(new Set());
|
||||
setMessage('');
|
||||
setCurrentDate('');
|
||||
}
|
||||
}, [isOpen, setThisMonth]);
|
||||
|
||||
// ESC 키 핸들러
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen && !isChecking && !isFixing) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, isChecking, isFixing, onClose]);
|
||||
|
||||
// 검사 실행
|
||||
const handleCheck = async () => {
|
||||
if (!startDate || !endDate) {
|
||||
setMessage('검사 기간을 설정해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChecking(true);
|
||||
setOkList([]);
|
||||
setNgList([]);
|
||||
setSelectedErrors(new Set());
|
||||
setMessage('검사 중...');
|
||||
|
||||
try {
|
||||
const result = await comms.kuntaeErrorCheck(startDate, endDate) as KuntaeErrorCheckResponse;
|
||||
|
||||
if (result.Success) {
|
||||
setOkList(result.OkList || []);
|
||||
setNgList(result.NgList || []);
|
||||
|
||||
// 마감되지 않은 오류 항목 자동 선택
|
||||
const autoSelect = new Set<string>();
|
||||
(result.NgList || []).forEach(item => {
|
||||
if (!item.IsMagam) {
|
||||
autoSelect.add(item.Date);
|
||||
}
|
||||
});
|
||||
setSelectedErrors(autoSelect);
|
||||
|
||||
setMessage(`검사 완료: 정상 ${result.OkList?.length || 0}건, 오류 ${result.NgList?.length || 0}건`);
|
||||
} else {
|
||||
setMessage(result.Message || '검사 중 오류가 발생했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage('검사 중 오류가 발생했습니다.');
|
||||
console.error('Error checking:', error);
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
setCurrentDate('');
|
||||
}
|
||||
};
|
||||
|
||||
// 오류 수정
|
||||
const handleFix = async () => {
|
||||
if (selectedErrors.size === 0) {
|
||||
setMessage('정정할 자료가 선택되지 않았습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('선택한 항목을 재생성 할까요?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFixing(true);
|
||||
setMessage('수정 중...');
|
||||
|
||||
try {
|
||||
const dates = Array.from(selectedErrors);
|
||||
const result = await comms.kuntaeFixErrors(dates);
|
||||
|
||||
if (result.Success) {
|
||||
setMessage('수정이 완료되었습니다. 다시 검사를 실행해주세요.');
|
||||
// 수정 후 자동으로 재검사
|
||||
setTimeout(() => handleCheck(), 500);
|
||||
} else {
|
||||
setMessage(result.Message || '수정 중 오류가 발생했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage('수정 중 오류가 발생했습니다.');
|
||||
console.error('Error fixing:', error);
|
||||
} finally {
|
||||
setIsFixing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 체크박스 토글
|
||||
const toggleError = (date: string, isMagam: boolean) => {
|
||||
if (isMagam) return; // 마감된 항목은 선택 불가
|
||||
|
||||
const newSelected = new Set(selectedErrors);
|
||||
if (newSelected.has(date)) {
|
||||
newSelected.delete(date);
|
||||
} else {
|
||||
newSelected.add(date);
|
||||
}
|
||||
setSelectedErrors(newSelected);
|
||||
};
|
||||
|
||||
// 전체 선택/해제
|
||||
const toggleAllErrors = () => {
|
||||
const selectableItems = ngList.filter(item => !item.IsMagam);
|
||||
if (selectedErrors.size === selectableItems.length) {
|
||||
setSelectedErrors(new Set());
|
||||
} else {
|
||||
setSelectedErrors(new Set(selectableItems.map(item => item.Date)));
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[10000]">
|
||||
<div className="glass-effect-solid rounded-xl w-[900px] max-h-[90vh] 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 flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-warning-400" />
|
||||
근태 자료 오류 확인
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isChecking || isFixing}
|
||||
className="text-white/60 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 검사 버튼 */}
|
||||
<div className="px-6 py-4">
|
||||
<button
|
||||
onClick={handleCheck}
|
||||
disabled={isChecking || isFixing}
|
||||
className="w-full py-4 bg-primary-500 hover:bg-primary-600 disabled:bg-gray-500
|
||||
text-white font-bold text-xl rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{isChecking ? (
|
||||
<>
|
||||
<RefreshCw className="w-6 h-6 animate-spin" />
|
||||
검사 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="w-6 h-6" />
|
||||
검사
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 검사 기간 */}
|
||||
<div className="px-6 py-3 border-t border-white/10">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-white/70 text-sm font-medium">검사기간:</span>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white text-sm"
|
||||
/>
|
||||
<span className="text-white/50">~</span>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white text-sm"
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={setPrevMonth}
|
||||
className="px-3 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white text-sm flex items-center gap-1"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
이전달
|
||||
</button>
|
||||
<button
|
||||
onClick={setThisMonth}
|
||||
className="px-3 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white text-sm"
|
||||
>
|
||||
이번달
|
||||
</button>
|
||||
<button
|
||||
onClick={setNextMonth}
|
||||
className="px-3 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white text-sm flex items-center gap-1"
|
||||
>
|
||||
다음달
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 현재 검사 날짜 표시 */}
|
||||
{currentDate && (
|
||||
<div className="px-6 py-3 bg-black/30 text-center">
|
||||
<span className="text-4xl font-mono text-green-400">{currentDate}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 결과 테이블 영역 */}
|
||||
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
|
||||
{/* 정상 목록 */}
|
||||
{okList.length > 0 && (
|
||||
<div className="border border-white/10 rounded-lg overflow-hidden">
|
||||
<div className="bg-white/5 px-4 py-2 border-b border-white/10 flex items-center justify-between">
|
||||
<span className="text-white/70 font-medium">정상 ({okList.length}건)</span>
|
||||
{message && (
|
||||
<span className="text-white/60 text-sm">{message}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-80 overflow-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead className="bg-white/5 sticky top-0">
|
||||
<tr className="text-white/60">
|
||||
<th className="px-2 py-2 text-left w-20 border-r border-white/10">일자</th>
|
||||
<th className="px-3 py-2 text-left w-24 border-r border-white/10">구분</th>
|
||||
<th className="px-2 py-2 text-center w-16 border-r border-white/10">발생(일)</th>
|
||||
<th className="px-2 py-2 text-center w-16 border-r border-white/10">발생(시간)</th>
|
||||
<th className="px-2 py-2 text-center w-16 border-r border-white/10">사용(일)</th>
|
||||
<th className="px-2 py-2 text-center w-16 border-r border-white/10">사용(시간)</th>
|
||||
<th className="px-3 py-2 text-left">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{okList.map((item) => (
|
||||
<tr key={item.Date} className="text-white/80 hover:bg-white/5">
|
||||
<td className="px-2 py-2 w-20 border-r border-white/10">{formatDateShort(item.Date)}</td>
|
||||
<td className="px-3 py-2 border-r border-white/10">{item.Gubun}</td>
|
||||
<td className="px-2 py-2 text-center border-r border-white/10">{item.OccurDay}</td>
|
||||
<td className="px-2 py-2 text-center border-r border-white/10">{item.OccurTime}</td>
|
||||
<td className="px-2 py-2 text-center border-r border-white/10">{item.UseDay}</td>
|
||||
<td className="px-2 py-2 text-center border-r border-white/10">{item.UseTime}</td>
|
||||
<td className="px-3 py-2">{item.CateError}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 오류 목록 */}
|
||||
{ngList.length > 0 && (
|
||||
<div className="border border-danger-500/50 rounded-lg overflow-hidden">
|
||||
<div className="bg-danger-500/10 px-4 py-2 border-b border-danger-500/30 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-danger-400 font-medium">
|
||||
불량 - 불일치 데이터가 있는 날짜입니다 ({ngList.length}건)
|
||||
</span>
|
||||
{message && okList.length === 0 && (
|
||||
<span className="text-white/60 text-sm">{message}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleAllErrors}
|
||||
className="text-xs text-white/60 hover:text-white"
|
||||
>
|
||||
{selectedErrors.size === ngList.filter(i => !i.IsMagam).length ? '전체 해제' : '전체 선택'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead className="bg-white/5 sticky top-0">
|
||||
<tr className="text-white/60">
|
||||
<th className="px-2 py-2 w-10 border-r border-white/10">
|
||||
<Check className="w-4 h-4 mx-auto" />
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left w-20 border-r border-white/10">일자</th>
|
||||
<th className="px-3 py-2 text-left w-24 border-r border-white/10">구분</th>
|
||||
<th className="px-2 py-2 text-center w-16 border-r border-white/10">발생(일)</th>
|
||||
<th className="px-2 py-2 text-center w-16 border-r border-white/10">발생(시간)</th>
|
||||
<th className="px-2 py-2 text-center w-16 border-r border-white/10">사용(일)</th>
|
||||
<th className="px-2 py-2 text-center w-16 border-r border-white/10">사용(시간)</th>
|
||||
<th className="px-3 py-2 text-left">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{ngList.map((item) => (
|
||||
<tr
|
||||
key={item.Date}
|
||||
className={`hover:bg-white/5 cursor-pointer ${
|
||||
item.IsMagam ? 'text-blue-400' : 'text-danger-400'
|
||||
}`}
|
||||
onClick={() => toggleError(item.Date, item.IsMagam)}
|
||||
>
|
||||
<td className="px-2 py-2 text-center border-r border-white/10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedErrors.has(item.Date)}
|
||||
onChange={() => toggleError(item.Date, item.IsMagam)}
|
||||
disabled={item.IsMagam}
|
||||
className="w-4 h-4 rounded accent-primary-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 w-20 border-r border-white/10">{formatDateShort(item.Date)}</td>
|
||||
<td className="px-3 py-2 border-r border-white/10">{item.Gubun}</td>
|
||||
<td className="px-2 py-2 text-center border-r border-white/10">{item.OccurDay}</td>
|
||||
<td className="px-2 py-2 text-center border-r border-white/10">{item.OccurTime}</td>
|
||||
<td className="px-2 py-2 text-center border-r border-white/10">{item.UseDay}</td>
|
||||
<td className="px-2 py-2 text-center border-r border-white/10">{item.UseTime}</td>
|
||||
<td className="px-3 py-2">
|
||||
{item.CateError}
|
||||
{item.IsMagam && <span className="ml-2 text-xs">(마감됨)</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 빈 상태 */}
|
||||
{okList.length === 0 && ngList.length === 0 && !isChecking && (
|
||||
<div className="text-center py-12 text-white/50">
|
||||
검사 버튼을 클릭하여 근태 자료를 검사하세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="px-6 py-4 border-t border-white/10">
|
||||
<button
|
||||
onClick={handleFix}
|
||||
disabled={isChecking || isFixing || selectedErrors.size === 0}
|
||||
className="w-full py-3 bg-warning-500 hover:bg-warning-600 disabled:bg-gray-500
|
||||
text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{isFixing ? (
|
||||
<>
|
||||
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||
수정 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
오류 수정 ({selectedErrors.size}건)
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,13 +17,19 @@ import {
|
||||
CalendarDays,
|
||||
Mail,
|
||||
Shield,
|
||||
List,
|
||||
AlertTriangle,
|
||||
Star,
|
||||
} from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { UserInfoDialog } from '@/components/user/UserInfoDialog';
|
||||
import { UserGroupDialog } from '@/components/user/UserGroupDialog';
|
||||
import { KuntaeErrorCheckDialog } from '@/components/kuntae/KuntaeErrorCheckDialog';
|
||||
import { FavoriteDialog } from '@/components/favorite/FavoriteDialog';
|
||||
import { AmkorLogo } from './AmkorLogo';
|
||||
|
||||
interface HeaderProps {
|
||||
isConnected: boolean;
|
||||
isConnected?: boolean; // deprecated, no longer used
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
@@ -54,12 +60,32 @@ interface DropdownMenuConfig {
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
// 일반 메뉴 항목
|
||||
const navItems: NavItem[] = [
|
||||
// 좌측 메뉴 항목
|
||||
const leftNavItems: NavItem[] = [
|
||||
{ path: '/jobreport', icon: FileText, label: '업무일지' },
|
||||
{ path: '/project', icon: FolderKanban, label: '프로젝트' },
|
||||
];
|
||||
|
||||
// 좌측 드롭다운 메뉴 (근태)
|
||||
const leftDropdownMenus: DropdownMenuConfig[] = [
|
||||
{
|
||||
label: '근태',
|
||||
icon: ClockIcon,
|
||||
items: [
|
||||
{ type: 'link', path: '/kuntae', icon: List, label: '목록' },
|
||||
{ type: 'action', icon: AlertTriangle, label: '오류검사', action: 'kuntaeErrorCheck' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 좌측 단독 액션 버튼 (즐겨찾기)
|
||||
const leftActionItems: NavItem[] = [
|
||||
{ icon: Star, label: '즐겨찾기', action: 'favorite' },
|
||||
];
|
||||
|
||||
// 우측 메뉴 항목
|
||||
const rightNavItems: NavItem[] = [
|
||||
{ path: '/todo', icon: CheckSquare, label: '할일' },
|
||||
{ path: '/kuntae', icon: ClockIcon, label: '근태' },
|
||||
];
|
||||
|
||||
// 드롭다운 메뉴 (2단계 지원)
|
||||
@@ -80,12 +106,13 @@ const dropdownMenus: DropdownMenuConfig[] = [
|
||||
items: [
|
||||
{ icon: User, label: '정보', action: 'userInfo' },
|
||||
{ path: '/user/list', icon: Users, label: '목록' },
|
||||
{ path: '/user/auth', icon: Shield, label: '권한' },
|
||||
{ icon: Users, label: '그룹정보', action: 'userGroup' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: 'link', path: '/monthly-work', icon: CalendarDays, label: '월별근무표' },
|
||||
{ type: 'link', path: '/mail-form', icon: Mail, label: '메일양식' },
|
||||
{ type: 'link', path: '/user-group', icon: Shield, label: '그룹정보' },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -162,6 +189,21 @@ function DropdownNavMenu({
|
||||
<item.icon className="w-4 h-4" />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
) : item.type === 'action' ? (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
if (item.action) {
|
||||
onAction?.(item.action);
|
||||
}
|
||||
onItemClick?.();
|
||||
}}
|
||||
className="flex items-center space-x-2 px-4 py-2 text-sm transition-colors text-white/70 hover:bg-white/10 hover:text-white w-full text-left"
|
||||
>
|
||||
<item.icon className="w-4 h-4" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
key={item.label}
|
||||
@@ -185,7 +227,7 @@ function DropdownNavMenu({
|
||||
</div>
|
||||
|
||||
{activeSubmenu === item.label && item.submenu && (
|
||||
<div className="absolute left-full top-0 ml-1 min-w-[120px] glass-effect-solid rounded-lg py-1 z-[10000]">
|
||||
<div className="absolute right-full top-0 mr-1 min-w-[120px] glass-effect-solid rounded-lg py-1 z-[10000]">
|
||||
{item.submenu.items.map((subItem) => (
|
||||
subItem.path ? (
|
||||
<NavLink
|
||||
@@ -279,6 +321,20 @@ function MobileDropdownMenu({
|
||||
<item.icon className="w-4 h-4" />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
) : item.type === 'action' ? (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={() => {
|
||||
if (item.action) {
|
||||
onAction?.(item.action);
|
||||
}
|
||||
onItemClick?.();
|
||||
}}
|
||||
className="flex items-center space-x-3 px-4 py-2 rounded-lg transition-all duration-200 text-white/70 hover:bg-white/10 hover:text-white w-full text-left"
|
||||
>
|
||||
<item.icon className="w-4 h-4" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div key={item.label}>
|
||||
<button
|
||||
@@ -334,14 +390,23 @@ function MobileDropdownMenu({
|
||||
);
|
||||
}
|
||||
|
||||
export function Header({ isConnected }: HeaderProps) {
|
||||
export function Header(_props: HeaderProps) {
|
||||
const navigate = useNavigate();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [showUserInfoDialog, setShowUserInfoDialog] = useState(false);
|
||||
const [showUserGroupDialog, setShowUserGroupDialog] = useState(false);
|
||||
const [showKuntaeErrorCheckDialog, setShowKuntaeErrorCheckDialog] = useState(false);
|
||||
const [showFavoriteDialog, setShowFavoriteDialog] = useState(false);
|
||||
|
||||
const handleAction = (action: string) => {
|
||||
if (action === 'userInfo') {
|
||||
setShowUserInfoDialog(true);
|
||||
} else if (action === 'userGroup') {
|
||||
setShowUserGroupDialog(true);
|
||||
} else if (action === 'kuntaeErrorCheck') {
|
||||
setShowKuntaeErrorCheckDialog(true);
|
||||
} else if (action === 'favorite') {
|
||||
setShowFavoriteDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -366,15 +431,54 @@ export function Header({ isConnected }: HeaderProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
{/* Desktop Navigation - Left */}
|
||||
<nav className="hidden lg:flex items-center space-x-1">
|
||||
{/* 드롭다운 메뉴들 */}
|
||||
{/* 좌측 일반 메뉴들 */}
|
||||
{leftNavItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path!}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center space-x-2 px-4 py-2 rounded-lg transition-all duration-200 text-sm font-medium',
|
||||
isActive
|
||||
? 'bg-white/20 text-white shadow-lg'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="w-4 h-4" />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{/* 좌측 드롭다운 메뉴들 (근태) */}
|
||||
{leftDropdownMenus.map((menu) => (
|
||||
<DropdownNavMenu key={menu.label} menu={menu} onAction={handleAction} />
|
||||
))}
|
||||
|
||||
{/* 좌측 액션 버튼들 (즐겨찾기) */}
|
||||
{leftActionItems.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={() => item.action && handleAction(item.action)}
|
||||
className="flex items-center space-x-2 px-4 py-2 rounded-lg transition-all duration-200 text-sm font-medium text-white/70 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<item.icon className="w-4 h-4" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Desktop Navigation - Right */}
|
||||
<nav className="hidden lg:flex items-center space-x-1">
|
||||
{/* 드롭다운 메뉴들 (공용정보) */}
|
||||
{dropdownMenus.map((menu) => (
|
||||
<DropdownNavMenu key={menu.label} menu={menu} onAction={handleAction} />
|
||||
))}
|
||||
|
||||
{/* 일반 메뉴들 */}
|
||||
{navItems.map((item) => (
|
||||
{/* 우측 메뉴들 (할일) */}
|
||||
{rightNavItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path!}
|
||||
@@ -392,21 +496,61 @@ export function Header({ isConnected }: HeaderProps) {
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Right Section: Connection Status (Icon only) */}
|
||||
<div
|
||||
className={`w-2.5 h-2.5 rounded-full ${
|
||||
isConnected ? 'bg-success-400 animate-pulse' : 'bg-danger-400'
|
||||
}`}
|
||||
title={isConnected ? '연결됨' : '연결 끊김'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation Dropdown */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="lg:hidden border-t border-white/10">
|
||||
<nav className="px-4 py-2 space-y-1">
|
||||
{/* 드롭다운 메뉴들 */}
|
||||
{/* 좌측 일반 메뉴들 */}
|
||||
{leftNavItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path!}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center space-x-3 px-4 py-3 rounded-lg transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{/* 좌측 드롭다운 메뉴들 (근태) */}
|
||||
{leftDropdownMenus.map((menu) => (
|
||||
<MobileDropdownMenu
|
||||
key={menu.label}
|
||||
menu={menu}
|
||||
onItemClick={() => setIsMobileMenuOpen(false)}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 좌측 액션 버튼들 (즐겨찾기) */}
|
||||
{leftActionItems.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={() => {
|
||||
if (item.action) handleAction(item.action);
|
||||
setIsMobileMenuOpen(false);
|
||||
}}
|
||||
className="flex items-center space-x-3 px-4 py-3 rounded-lg transition-all duration-200 text-white/70 hover:bg-white/10 hover:text-white w-full text-left"
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="border-t border-white/10 my-2" />
|
||||
|
||||
{/* 우측 드롭다운 메뉴들 (공용정보) */}
|
||||
{dropdownMenus.map((menu) => (
|
||||
<MobileDropdownMenu
|
||||
key={menu.label}
|
||||
@@ -416,8 +560,8 @@ export function Header({ isConnected }: HeaderProps) {
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 일반 메뉴들 */}
|
||||
{navItems.map((item) => (
|
||||
{/* 우측 메뉴들 (할일) */}
|
||||
{rightNavItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path!}
|
||||
@@ -445,6 +589,24 @@ export function Header({ isConnected }: HeaderProps) {
|
||||
isOpen={showUserInfoDialog}
|
||||
onClose={() => setShowUserInfoDialog(false)}
|
||||
/>
|
||||
|
||||
{/* User Group Dialog */}
|
||||
<UserGroupDialog
|
||||
isOpen={showUserGroupDialog}
|
||||
onClose={() => setShowUserGroupDialog(false)}
|
||||
/>
|
||||
|
||||
{/* Kuntae Error Check Dialog */}
|
||||
<KuntaeErrorCheckDialog
|
||||
isOpen={showKuntaeErrorCheckDialog}
|
||||
onClose={() => setShowKuntaeErrorCheckDialog(false)}
|
||||
/>
|
||||
|
||||
{/* Favorite Dialog */}
|
||||
<FavoriteDialog
|
||||
isOpen={showFavoriteDialog}
|
||||
onClose={() => setShowFavoriteDialog(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
488
Project/frontend/src/components/project/ProjectDetailDialog.tsx
Normal file
488
Project/frontend/src/components/project/ProjectDetailDialog.tsx
Normal file
@@ -0,0 +1,488 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
X,
|
||||
FolderOpen,
|
||||
Save,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { ProjectListItem, ProjectHistory, ProjectDailyMemo } from '@/types';
|
||||
|
||||
interface ProjectDetailDialogProps {
|
||||
project: ProjectListItem;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// 상태별 색상 매핑
|
||||
const statusColors: Record<string, { text: string; bg: string; border: string }> = {
|
||||
검토: { text: 'text-blue-400', bg: 'bg-blue-500/20', border: 'border-blue-500/30' },
|
||||
진행: { text: 'text-green-400', bg: 'bg-green-500/20', border: 'border-green-500/30' },
|
||||
대기: { text: 'text-yellow-400', bg: 'bg-yellow-500/20', border: 'border-yellow-500/30' },
|
||||
보류: { text: 'text-orange-400', bg: 'bg-orange-500/20', border: 'border-orange-500/30' },
|
||||
완료: { text: 'text-purple-400', bg: 'bg-purple-500/20', border: 'border-purple-500/30' },
|
||||
'완료(보고)': { text: 'text-gray-400', bg: 'bg-gray-500/20', border: 'border-gray-500/30' },
|
||||
취소: { text: 'text-red-400', bg: 'bg-red-500/20', border: 'border-red-500/30' },
|
||||
};
|
||||
|
||||
const statusOptions = ['검토', '진행', '대기', '보류', '완료', '완료(보고)', '취소'];
|
||||
|
||||
export function ProjectDetailDialog({ project, onClose }: ProjectDetailDialogProps) {
|
||||
const [history, setHistory] = useState<ProjectHistory[]>([]);
|
||||
const [dailyMemos, setDailyMemos] = useState<ProjectDailyMemo[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<'history' | 'memo' | 'complete'>('history');
|
||||
|
||||
// 편집 가능한 필드들
|
||||
const [formData, setFormData] = useState({
|
||||
name: project.name || '',
|
||||
status: project.status || '',
|
||||
process: project.process || '',
|
||||
part: project.part || '',
|
||||
asset: project.asset || '',
|
||||
model: project.model || '',
|
||||
serial: project.serial || '',
|
||||
orderno: project.orderno || '',
|
||||
userManager: project.userManager || '',
|
||||
usermain: project.usermain || '',
|
||||
usersub: project.usersub || '',
|
||||
userhw2: project.userhw2 || '',
|
||||
ReqLine: project.ReqLine || '',
|
||||
reqstaff: project.reqstaff || '',
|
||||
ReqSite: project.ReqSite || '',
|
||||
ReqPlant: project.ReqPlant || '',
|
||||
ReqPackage: project.ReqPackage || '',
|
||||
remark_req: project.remark_req || '',
|
||||
sdate: project.sdate?.substring(0, 10) || '',
|
||||
ddate: project.ddate?.substring(0, 10) || '',
|
||||
edate: project.edate?.substring(0, 10) || '',
|
||||
odate: project.odate?.substring(0, 10) || '',
|
||||
costo: project.costo?.toString() || '',
|
||||
costn: project.costn?.toString() || '',
|
||||
cnt: project.cnt?.toString() || '',
|
||||
path: project.path || '',
|
||||
memo: project.memo || '',
|
||||
progress: project.progress?.toString() || '0',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadDetails();
|
||||
}, [project.idx]);
|
||||
|
||||
const loadDetails = async () => {
|
||||
try {
|
||||
const [historyRes, memoRes] = await Promise.all([
|
||||
comms.getProjectHistory(project.idx),
|
||||
comms.getProjectDailyMemo(project.idx),
|
||||
]);
|
||||
|
||||
if (historyRes.Success && historyRes.Data) {
|
||||
setHistory(historyRes.Data as ProjectHistory[]);
|
||||
}
|
||||
if (memoRes.Success && memoRes.Data) {
|
||||
setDailyMemos(memoRes.Data as ProjectDailyMemo[]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('상세 정보 로드 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return '-';
|
||||
return dateStr.substring(0, 10);
|
||||
};
|
||||
|
||||
const openJasmin = (jasminId?: number) => {
|
||||
if (jasminId && jasminId > 0) {
|
||||
window.open(`https://scwa.amkor.co.kr/jasmine/view/${jasminId}`, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const statusColor = statusColors[formData.status] || { text: 'text-white', bg: 'bg-white/10', border: 'border-white/20' };
|
||||
|
||||
// 입력 필드 스타일
|
||||
const inputClass = "w-full px-2 py-1.5 text-sm bg-white/5 border border-white/20 rounded text-white focus:outline-none focus:border-primary-500";
|
||||
const labelClass = "text-xs text-white/60 mb-1 block";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-gray-900/95 border border-white/10 rounded-2xl shadow-2xl w-[1200px] h-[90vh] flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => handleChange('status', e.target.value)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium border ${statusColor.bg} ${statusColor.text} ${statusColor.border}`}
|
||||
>
|
||||
{statusOptions.map(opt => (
|
||||
<option key={opt} value={opt} className="bg-gray-800 text-white">{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">{project.name}</h2>
|
||||
<p className="text-sm text-white/50">IDX: {project.idx} | PNO: {project.pno || '-'} | Order: {project.orderno || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="flex items-center gap-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors text-sm">
|
||||
<Save className="w-4 h-4" />
|
||||
저장
|
||||
</button>
|
||||
{project.path && (
|
||||
<button className="flex items-center gap-2 px-3 py-2 bg-white/10 hover:bg-white/20 text-white/80 rounded-lg transition-colors text-sm">
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
폴더
|
||||
</button>
|
||||
)}
|
||||
{project.jasmin && project.jasmin > 0 && (
|
||||
<button
|
||||
onClick={() => openJasmin(project.jasmin)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-white/10 hover:bg-white/20 text-white/80 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Jasmin
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onClose} className="p-2 rounded-lg hover:bg-white/10 transition-colors ml-2">
|
||||
<X className="w-5 h-5 text-white/70" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 - 스크롤 가능 */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex gap-6">
|
||||
{/* 왼쪽 영역 */}
|
||||
<div className="w-[400px] shrink-0 space-y-4">
|
||||
{/* 기본 정보 */}
|
||||
<div className="glass-effect rounded-xl p-4">
|
||||
<h3 className="text-white font-medium mb-3 pb-2 border-b border-white/10">기본 정보</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className={labelClass}>프로젝트명</label>
|
||||
<input type="text" value={formData.name} onChange={(e) => handleChange('name', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>공정</label>
|
||||
<input type="text" value={formData.process} onChange={(e) => handleChange('process', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>파트</label>
|
||||
<input type="text" value={formData.part} onChange={(e) => handleChange('part', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>Asset</label>
|
||||
<input type="text" value={formData.asset} onChange={(e) => handleChange('asset', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Order#</label>
|
||||
<input type="text" value={formData.orderno} onChange={(e) => handleChange('orderno', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>Model#</label>
|
||||
<input type="text" value={formData.model} onChange={(e) => handleChange('model', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Serial#</label>
|
||||
<input type="text" value={formData.serial} onChange={(e) => handleChange('serial', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 담당자 */}
|
||||
<div className="glass-effect rounded-xl p-4">
|
||||
<h3 className="text-white font-medium mb-3 pb-2 border-b border-white/10">담당자</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>Champion</label>
|
||||
<input type="text" value={formData.userManager} onChange={(e) => handleChange('userManager', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Design</label>
|
||||
<input type="text" value={formData.usermain} onChange={(e) => handleChange('usermain', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>S/W</label>
|
||||
<input type="text" value={formData.usersub} onChange={(e) => handleChange('usersub', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>ePanel</label>
|
||||
<input type="text" value={formData.userhw2} onChange={(e) => handleChange('userhw2', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요청 정보 */}
|
||||
<div className="glass-effect rounded-xl p-4">
|
||||
<h3 className="text-white font-medium mb-3 pb-2 border-b border-white/10">요청 정보</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>Line</label>
|
||||
<input type="text" value={formData.ReqLine} onChange={(e) => handleChange('ReqLine', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>요청자</label>
|
||||
<input type="text" value={formData.reqstaff} onChange={(e) => handleChange('reqstaff', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>Site</label>
|
||||
<input type="text" value={formData.ReqSite} onChange={(e) => handleChange('ReqSite', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Plant</label>
|
||||
<input type="text" value={formData.ReqPlant} onChange={(e) => handleChange('ReqPlant', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Package</label>
|
||||
<input type="text" value={formData.ReqPackage} onChange={(e) => handleChange('ReqPackage', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>요청내용</label>
|
||||
<textarea
|
||||
value={formData.remark_req}
|
||||
onChange={(e) => handleChange('remark_req', e.target.value)}
|
||||
rows={2}
|
||||
className={`${inputClass} resize-none`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 중앙 영역 */}
|
||||
<div className="w-[280px] shrink-0 space-y-4">
|
||||
{/* 일정 */}
|
||||
<div className="glass-effect rounded-xl p-4">
|
||||
<h3 className="text-white font-medium mb-3 pb-2 border-b border-white/10">일정</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>시작일</label>
|
||||
<input type="date" value={formData.sdate} onChange={(e) => handleChange('sdate', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>만료일</label>
|
||||
<input type="date" value={formData.ddate} onChange={(e) => handleChange('ddate', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>완료일</label>
|
||||
<input type="date" value={formData.edate} onChange={(e) => handleChange('edate', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>출고일</label>
|
||||
<input type="date" value={formData.odate} onChange={(e) => handleChange('odate', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 진행률 */}
|
||||
<div className="glass-effect rounded-xl p-4">
|
||||
<h3 className="text-white font-medium mb-3 pb-2 border-b border-white/10">진행률</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={formData.progress}
|
||||
onChange={(e) => handleChange('progress', e.target.value)}
|
||||
className="flex-1 h-2 bg-white/10 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={formData.progress}
|
||||
onChange={(e) => handleChange('progress', e.target.value)}
|
||||
className="w-16 px-2 py-1.5 text-sm bg-white/5 border border-white/20 rounded text-white text-center"
|
||||
/>
|
||||
<span className="text-sm text-white/60">%</span>
|
||||
</div>
|
||||
<div className="mt-3 h-3 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary-500 transition-all"
|
||||
style={{ width: `${formData.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 비용 */}
|
||||
<div className="glass-effect rounded-xl p-4">
|
||||
<h3 className="text-white font-medium mb-3 pb-2 border-b border-white/10">비용</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className={labelClass}>예상비용</label>
|
||||
<input type="number" value={formData.costo} onChange={(e) => handleChange('costo', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>실비용</label>
|
||||
<input type="number" value={formData.costn} onChange={(e) => handleChange('costn', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>수량</label>
|
||||
<input type="number" value={formData.cnt} onChange={(e) => handleChange('cnt', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 저장경로 */}
|
||||
<div className="glass-effect rounded-xl p-4">
|
||||
<h3 className="text-white font-medium mb-3 pb-2 border-b border-white/10">저장경로</h3>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.path}
|
||||
onChange={(e) => handleChange('path', e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder="\\server\path..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽 영역 - 탭 */}
|
||||
<div className="flex-1 min-w-0 flex flex-col glass-effect rounded-xl overflow-hidden">
|
||||
{/* 탭 헤더 */}
|
||||
<div className="flex border-b border-white/10 shrink-0">
|
||||
<button
|
||||
onClick={() => setActiveTab('history')}
|
||||
className={`px-4 py-3 text-sm transition-colors ${activeTab === 'history'
|
||||
? 'bg-white/10 text-white border-b-2 border-primary-500'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
주간업무현황 ({history.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('memo')}
|
||||
className={`px-4 py-3 text-sm transition-colors ${activeTab === 'memo'
|
||||
? 'bg-white/10 text-white border-b-2 border-primary-500'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
일업무현황 ({dailyMemos.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('complete')}
|
||||
className={`px-4 py-3 text-sm transition-colors ${activeTab === 'complete'
|
||||
? 'bg-white/10 text-white border-b-2 border-primary-500'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
완료정보
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 탭 콘텐츠 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{activeTab === 'history' && (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex bg-white/5 text-xs text-white/60 border-b border-white/10 shrink-0">
|
||||
<div className="w-24 px-3 py-2 border-r border-white/10">날짜</div>
|
||||
<div className="flex-1 px-3 py-2 border-r border-white/10">내용</div>
|
||||
<div className="w-20 px-3 py-2">작성자</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{history.length === 0 ? (
|
||||
<div className="p-8 text-center text-white/40 text-sm">등록된 내용이 없습니다.</div>
|
||||
) : (
|
||||
history.map((h, idx) => (
|
||||
<div key={h.idx} className={`flex text-sm border-b border-white/5 ${idx % 2 === 0 ? 'bg-white/[0.02]' : ''} hover:bg-white/5`}>
|
||||
<div className="w-24 px-3 py-2 border-r border-white/10 text-white/50">{formatDate(h.pdate)}</div>
|
||||
<div className="flex-1 px-3 py-2 border-r border-white/10 text-white">{h.remark}</div>
|
||||
<div className="w-20 px-3 py-2 text-white/50">{h.wname}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'memo' && (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex bg-white/5 text-xs text-white/60 border-b border-white/10 shrink-0">
|
||||
<div className="w-24 px-3 py-2 border-r border-white/10">날짜</div>
|
||||
<div className="flex-1 px-3 py-2 border-r border-white/10">내용</div>
|
||||
<div className="w-20 px-3 py-2">작성자</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{dailyMemos.length === 0 ? (
|
||||
<div className="p-8 text-center text-white/40 text-sm">등록된 메모가 없습니다.</div>
|
||||
) : (
|
||||
dailyMemos.map((m, idx) => (
|
||||
<div key={m.idx} className={`flex text-sm border-b border-white/5 ${idx % 2 === 0 ? 'bg-white/[0.02]' : ''} hover:bg-white/5`}>
|
||||
<div className="w-24 px-3 py-2 border-r border-white/10 text-white/50">{formatDate(m.pdate)}</div>
|
||||
<div className="flex-1 px-3 py-2 border-r border-white/10 text-white">{m.remark}</div>
|
||||
<div className="w-20 px-3 py-2 text-white/50">{m.wname}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'complete' && (
|
||||
<div className="p-4 space-y-4 overflow-y-auto">
|
||||
<div>
|
||||
<label className={labelClass}>배경</label>
|
||||
<textarea rows={3} className={`${inputClass} resize-none`} placeholder="프로젝트 배경..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>설명</label>
|
||||
<textarea rows={3} className={`${inputClass} resize-none`} placeholder="프로젝트 설명..." />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>개선 전</label>
|
||||
<textarea rows={3} className={`${inputClass} resize-none`} placeholder="개선 전 상태..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>개선 후</label>
|
||||
<textarea rows={3} className={`${inputClass} resize-none`} placeholder="개선 후 상태..." />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>유형효과</label>
|
||||
<textarea rows={2} className={`${inputClass} resize-none`} placeholder="유형 효과..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>무형효과</label>
|
||||
<textarea rows={2} className={`${inputClass} resize-none`} placeholder="무형 효과..." />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t border-white/10 shrink-0 bg-white/5">
|
||||
<span className="text-sm text-white/50">PNO: {project.pno || '-'} | Jasmin: {project.jasmin || '-'} | 진행률: {formData.progress}%</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-white/10 text-white rounded-lg hover:bg-white/20 transition-colors"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
Project/frontend/src/components/project/index.ts
Normal file
1
Project/frontend/src/components/project/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ProjectDetailDialog } from './ProjectDetailDialog';
|
||||
530
Project/frontend/src/components/user/UserGroupDialog.tsx
Normal file
530
Project/frontend/src/components/user/UserGroupDialog.tsx
Normal file
@@ -0,0 +1,530 @@
|
||||
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';
|
||||
|
||||
interface UserGroupDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
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 UserGroupDialog({ isOpen, onClose }: UserGroupDialogProps) {
|
||||
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(() => {
|
||||
if (isOpen) {
|
||||
loadData();
|
||||
}
|
||||
}, [isOpen, loadData]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
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="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-gray-900/95 border border-white/10 rounded-2xl shadow-2xl w-[1000px] max-h-[85vh] flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary-500/20 rounded-lg">
|
||||
<Users className="w-5 h-5 text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">그룹정보</h2>
|
||||
<p className="text-white/50 text-sm">부서/그룹 및 권한 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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-40"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
className="p-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 gap-2 px-3 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors text-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>새 그룹</span>
|
||||
</button>
|
||||
<button onClick={onClose} className="p-2 rounded-lg hover:bg-white/10 transition-colors ml-2">
|
||||
<X className="w-5 h-5 text-white/70" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 목록 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{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>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-white/5 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70">부서명</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70">경로</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 w-20">권한</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 w-16">구매</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 w-16">기술</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 w-16">메일</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70">관리자정보</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 w-24">작업</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-2 text-white font-medium">{item.dept}</td>
|
||||
<td className="px-4 py-2 text-white/70 text-xs">{item.path_kj || '-'}</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
<button
|
||||
onClick={() => openPermissionModal(item)}
|
||||
className="inline-flex items-center gap-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-2 text-center">
|
||||
{item.advpurchase && <Check className="w-4 h-4 text-success-400 mx-auto" />}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{item.advkisul && <Check className="w-4 h-4 text-success-400 mx-auto" />}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{item.usemail && <Check className="w-4 h-4 text-success-400 mx-auto" />}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-white/70 text-xs truncate max-w-40">{item.managerinfo || '-'}</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<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 className="flex items-center justify-between px-6 py-3 border-t border-white/10 shrink-0 bg-white/5">
|
||||
<span className="text-sm text-white/50">{filteredItems.length}개 그룹</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-white/10 text-white rounded-lg hover:bg-white/20 transition-colors"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 그룹 편집 모달 */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-gray-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-[60] flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-gray-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>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,17 @@ function PasswordDialog({ isOpen, onClose, onConfirm }: PasswordDialogProps) {
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// ESC 키로 닫기
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!newPassword) {
|
||||
setError('새 비밀번호를 입력하세요.');
|
||||
@@ -141,6 +152,17 @@ export function UserInfoDialog({ isOpen, onClose, userId, onSave }: UserInfoDial
|
||||
}
|
||||
}, [isOpen, userId]);
|
||||
|
||||
// ESC 키로 닫기 (비밀번호 다이얼로그가 열려있지 않을 때만)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen && !showPasswordDialog) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose, showPasswordDialog]);
|
||||
|
||||
const loadUserInfo = async () => {
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
|
||||
212
Project/frontend/src/components/user/UserSearchDialog.tsx
Normal file
212
Project/frontend/src/components/user/UserSearchDialog.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Search, X, Users, Check } from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { GroupUser } from '@/types';
|
||||
|
||||
interface UserSearchDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (user: GroupUser) => void;
|
||||
title?: string;
|
||||
excludeUsers?: string[]; // 제외할 사용자 ID 목록
|
||||
initialSearchKey?: string; // 초기 검색어
|
||||
}
|
||||
|
||||
export function UserSearchDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
title = '사용자 검색',
|
||||
excludeUsers = [],
|
||||
initialSearchKey = '',
|
||||
}: UserSearchDialogProps) {
|
||||
const [users, setUsers] = useState<GroupUser[]>([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState<GroupUser[]>([]);
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<GroupUser | null>(null);
|
||||
|
||||
// 사용자 목록 로드
|
||||
const loadUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await comms.getUserList('%');
|
||||
if (Array.isArray(result)) {
|
||||
// 제외 목록에 없고, 계정 사용 중이고, 퇴사하지 않은 사용자만 표시
|
||||
const filtered = result.filter(
|
||||
(u: GroupUser) =>
|
||||
u.useUserState &&
|
||||
!excludeUsers.includes(u.id) &&
|
||||
!u.outdate // 퇴사일이 없는 사용자만 (재직 중)
|
||||
);
|
||||
setUsers(filtered);
|
||||
setFilteredUsers(filtered);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('사용자 목록 로드 실패:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [excludeUsers]);
|
||||
|
||||
// 다이얼로그 열릴 때 데이터 로드
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadUsers();
|
||||
setSearchKey(initialSearchKey); // 초기 검색어 설정
|
||||
setSelectedUser(null);
|
||||
}
|
||||
}, [isOpen, loadUsers, initialSearchKey]);
|
||||
|
||||
// 검색 필터링
|
||||
useEffect(() => {
|
||||
if (!searchKey.trim()) {
|
||||
setFilteredUsers(users);
|
||||
} else {
|
||||
const key = searchKey.toLowerCase();
|
||||
setFilteredUsers(
|
||||
users.filter(
|
||||
(u) =>
|
||||
u.id.toLowerCase().includes(key) ||
|
||||
(u.name || '').toLowerCase().includes(key) ||
|
||||
(u.email || '').toLowerCase().includes(key)
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [searchKey, users]);
|
||||
|
||||
// 선택 확정
|
||||
const handleConfirm = () => {
|
||||
if (selectedUser) {
|
||||
onSelect(selectedUser);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// ESC 키로 닫기
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
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-lg max-h-[80vh] overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="p-4 border-b border-white/10 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-primary-400" />
|
||||
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="p-4 border-b border-white/10 shrink-0">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/50" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchKey}
|
||||
onChange={(e) => setSearchKey(e.target.value)}
|
||||
placeholder="사번, 이름, 이메일로 검색..."
|
||||
autoFocus
|
||||
className="w-full pl-10 pr-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사용자 목록 */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-400 mx-auto mb-2"></div>
|
||||
<p className="text-white/50">로딩 중...</p>
|
||||
</div>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<div className="text-center py-8 text-white/50">
|
||||
{searchKey ? '검색 결과가 없습니다' : '사용자가 없습니다'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{filteredUsers.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
onClick={() => setSelectedUser(user)}
|
||||
onDoubleClick={() => {
|
||||
setSelectedUser(user);
|
||||
onSelect(user);
|
||||
onClose();
|
||||
}}
|
||||
className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${
|
||||
selectedUser?.id === user.id
|
||||
? 'bg-primary-500/30 border border-primary-400/50'
|
||||
: 'bg-white/5 hover:bg-white/10 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-white/80 text-sm">{user.id}</span>
|
||||
<span className="font-medium text-white">{user.name}</span>
|
||||
{user.grade && (
|
||||
<span className="text-xs text-white/50 bg-white/10 px-1.5 py-0.5 rounded">
|
||||
{user.grade}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-white/50 mt-0.5 truncate">
|
||||
{user.email || '-'} {user.processs && `| ${user.processs}`}
|
||||
</div>
|
||||
</div>
|
||||
{selectedUser?.id === user.id && (
|
||||
<Check className="w-5 h-5 text-primary-400 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="p-4 border-t border-white/10 flex items-center justify-between shrink-0">
|
||||
<span className="text-sm text-white/50">
|
||||
{filteredUsers.length}명 {selectedUser && `| 선택: ${selectedUser.id} (${selectedUser.name})`}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedUser}
|
||||
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg text-white transition-colors"
|
||||
>
|
||||
선택
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { UserInfoDialog } from './UserInfoDialog';
|
||||
export { UserSearchDialog } from './UserSearchDialog';
|
||||
|
||||
Reference in New Issue
Block a user