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:
backuppc
2025-11-28 17:36:20 +09:00
parent c9b5d756e1
commit adcdc40169
32 changed files with 6668 additions and 292 deletions

View File

@@ -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>