..
This commit is contained in:
@@ -262,7 +262,9 @@ export function JobreportEditModal({
|
||||
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">
|
||||
<div className={`px-6 py-4 border-b border-white/10 flex items-center justify-between sticky top-0 backdrop-blur z-10 ${
|
||||
editingItem ? 'bg-slate-800/95' : 'bg-primary-600/30'
|
||||
}`}>
|
||||
<h2 className="text-xl font-semibold text-white flex items-center">
|
||||
<FileText className="w-5 h-5 mr-2" />
|
||||
{editingItem ? '업무일지 수정' : '업무일지 등록'}
|
||||
|
||||
@@ -22,8 +22,11 @@ import {
|
||||
Building,
|
||||
Star,
|
||||
Bug,
|
||||
Settings,
|
||||
Key,
|
||||
} from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { comms } from '@/communication';
|
||||
import { UserInfoDialog } from '@/components/user/UserInfoDialog';
|
||||
import { UserGroupDialog } from '@/components/user/UserGroupDialog';
|
||||
import { KuntaeErrorCheckDialog } from '@/components/kuntae/KuntaeErrorCheckDialog';
|
||||
@@ -39,6 +42,7 @@ interface NavItem {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
action?: string;
|
||||
className?: string; // 추가: 클래스 이름
|
||||
}
|
||||
|
||||
interface SubMenu {
|
||||
@@ -54,6 +58,7 @@ interface MenuItem {
|
||||
label: string;
|
||||
submenu?: SubMenu;
|
||||
action?: string;
|
||||
className?: string; // gold 등 스타일 적용용
|
||||
}
|
||||
|
||||
interface DropdownMenuConfig {
|
||||
@@ -78,6 +83,13 @@ const leftDropdownMenus: DropdownMenuConfig[] = [
|
||||
{ type: 'action', icon: AlertTriangle, label: '오류검사', action: 'kuntaeErrorCheck' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '관리',
|
||||
icon: Settings,
|
||||
items: [
|
||||
{ type: 'link', path: '/license', icon: Key, label: '라이선스' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 좌측 단독 액션 버튼
|
||||
@@ -90,44 +102,54 @@ const rightNavItems: NavItem[] = [
|
||||
];
|
||||
|
||||
// 드롭다운 메뉴 (2단계 지원)
|
||||
const dropdownMenus: DropdownMenuConfig[] = [
|
||||
{
|
||||
label: '공용정보',
|
||||
icon: Database,
|
||||
items: [
|
||||
{ type: 'link', path: '/common', icon: Code, label: '공용코드' },
|
||||
{ type: 'link', path: '/items', icon: Package, label: '품목정보' },
|
||||
{ type: 'link', path: '/customs', icon: Building, label: '업체정보' },
|
||||
{
|
||||
type: 'submenu',
|
||||
icon: Users,
|
||||
label: '사용자',
|
||||
submenu: {
|
||||
label: '사용자',
|
||||
const getDropdownMenus = (userLevel: number, userCode: string): DropdownMenuConfig[] => {
|
||||
const mailListItem = {
|
||||
type: 'link' as const,
|
||||
path: '/mail-list',
|
||||
icon: Mail,
|
||||
label: '메일 내역',
|
||||
className: (userCode === '395552') ? 'text-[gold] font-bold' : '',
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
label: '공용정보',
|
||||
icon: Database,
|
||||
items: [
|
||||
{ type: 'link', path: '/common', icon: Code, label: '공용코드' },
|
||||
{ type: 'link', path: '/items', icon: Package, label: '품목정보' },
|
||||
{ type: 'link', path: '/customs', icon: Building, label: '업체정보' },
|
||||
{
|
||||
type: 'submenu',
|
||||
icon: Users,
|
||||
items: [
|
||||
{ icon: User, label: '정보', action: 'userInfo' },
|
||||
{ path: '/user/list', icon: Users, label: '목록' },
|
||||
{ path: '/user/auth', icon: Shield, label: '권한' },
|
||||
{ icon: Users, label: '그룹정보', action: 'userGroup' },
|
||||
],
|
||||
label: '사용자',
|
||||
submenu: {
|
||||
label: '사용자',
|
||||
icon: Users,
|
||||
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: '메일양식' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '문서',
|
||||
icon: FileText,
|
||||
items: [
|
||||
{ type: 'link', path: '/note', icon: FileText, label: '메모장' },
|
||||
{ type: 'link', path: '/patch-list', icon: FileText, label: '패치 내역' },
|
||||
{ type: 'link', path: '/bug-report', icon: Bug, label: '버그 신고' },
|
||||
{ type: 'link', path: '/mail-list', icon: Mail, label: '메일 내역' },
|
||||
],
|
||||
},
|
||||
];
|
||||
{ type: 'link', path: '/monthly-work', icon: CalendarDays, label: '월별근무표' },
|
||||
{ type: 'link', path: '/mail-form', icon: Mail, label: '메일양식' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '문서',
|
||||
icon: FileText,
|
||||
items: [
|
||||
{ type: 'link', path: '/note', icon: FileText, label: '메모장' },
|
||||
{ type: 'link', path: '/patch-list', icon: FileText, label: '패치 내역' },
|
||||
{ type: 'link', path: '/bug-report', icon: Bug, label: '버그 신고' },
|
||||
...(userLevel >= 9 || userCode === '395552' ? [mailListItem] : []),
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
function DropdownNavMenu({
|
||||
menu,
|
||||
@@ -194,7 +216,7 @@ function DropdownNavMenu({
|
||||
'flex items-center space-x-2 px-4 py-2 text-sm transition-colors',
|
||||
isActive
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
: (item.className || 'text-white/70 hover:bg-white/10 hover:text-white')
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -326,7 +348,7 @@ function MobileDropdownMenu({
|
||||
'flex items-center space-x-3 px-4 py-2 rounded-lg transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
: (item.className || 'text-white/70 hover:bg-white/10 hover:text-white')
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -373,7 +395,7 @@ function MobileDropdownMenu({
|
||||
'flex items-center space-x-3 px-4 py-2 rounded-lg transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
: (item.className || 'text-white/70 hover:bg-white/10 hover:text-white')
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -409,6 +431,27 @@ export function Header(_props: HeaderProps) {
|
||||
const [showUserGroupDialog, setShowUserGroupDialog] = useState(false);
|
||||
const [showKuntaeErrorCheckDialog, setShowKuntaeErrorCheckDialog] = useState(false);
|
||||
const [showFavoriteDialog, setShowFavoriteDialog] = useState(false);
|
||||
const [userLevel, setUserLevel] = useState<number>(0);
|
||||
const [userCode, setUserCode] = useState<string>('');
|
||||
|
||||
// 사용자 정보 로드
|
||||
useEffect(() => {
|
||||
const loadUserInfo = async () => {
|
||||
try {
|
||||
const loginStatus = await comms.checkLoginStatus();
|
||||
console.log('Login Status:', loginStatus);
|
||||
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
|
||||
const user = loginStatus.User as { Level?: number; Id?: string };
|
||||
setUserLevel(user.Level || 0);
|
||||
setUserCode(user.Id || '');
|
||||
console.log('userLevel:', user.Level, 'userCode:', user.Id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('사용자 정보 로드 오류:', error);
|
||||
}
|
||||
};
|
||||
loadUserInfo();
|
||||
}, []);
|
||||
|
||||
const handleAction = (action: string) => {
|
||||
if (action === 'userInfo') {
|
||||
@@ -485,7 +528,7 @@ export function Header(_props: HeaderProps) {
|
||||
{/* Desktop Navigation - Right */}
|
||||
<nav className="hidden lg:flex items-center space-x-1">
|
||||
{/* 드롭다운 메뉴들 (공용정보) */}
|
||||
{dropdownMenus.map((menu) => (
|
||||
{getDropdownMenus(userLevel, userCode).map((menu) => (
|
||||
<DropdownNavMenu key={menu.label} menu={menu} onAction={handleAction} />
|
||||
))}
|
||||
|
||||
@@ -574,7 +617,7 @@ export function Header(_props: HeaderProps) {
|
||||
<div className="border-t border-white/10 my-2" />
|
||||
|
||||
{/* 우측 드롭다운 메뉴들 (공용정보) */}
|
||||
{dropdownMenus.map((menu) => (
|
||||
{getDropdownMenus(userLevel, userCode).map((menu) => (
|
||||
<MobileDropdownMenu
|
||||
key={menu.label}
|
||||
menu={menu}
|
||||
|
||||
@@ -12,6 +12,7 @@ interface StatusBarProps {
|
||||
export function StatusBar({ userName, userDept, isConnected }: StatusBarProps) {
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [versionDisplay, setVersionDisplay] = useState('');
|
||||
const [hasNewVersion, setHasNewVersion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
|
||||
@@ -25,6 +26,7 @@ export function StatusBar({ userName, userDept, isConnected }: StatusBarProps) {
|
||||
const result = await comms.getAppVersion();
|
||||
if (result.Success) {
|
||||
setVersionDisplay(result.DisplayVersion);
|
||||
setHasNewVersion(result.HasNewVersion || false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('버전 정보 로드 오류:', error);
|
||||
@@ -41,8 +43,13 @@ export function StatusBar({ userName, userDept, isConnected }: StatusBarProps) {
|
||||
</div>
|
||||
|
||||
{/* Center: App Version */}
|
||||
<div className="text-white/50">
|
||||
<div className={`font-medium ${hasNewVersion ? 'text-yellow-400 animate-pulse' : 'text-white/50'}`}>
|
||||
{versionDisplay || 'Loading...'}
|
||||
{hasNewVersion && (
|
||||
<span className="ml-2 text-xs bg-yellow-500/20 text-yellow-400 px-2 py-0.5 rounded animate-pulse">
|
||||
업데이트 필요
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Connection Status & Time */}
|
||||
|
||||
293
Project/frontend/src/components/license/LicenseEditDialog.tsx
Normal file
293
Project/frontend/src/components/license/LicenseEditDialog.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import { X, Save, Trash2 } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { LicenseItem } from '@/types';
|
||||
|
||||
interface LicenseEditDialogProps {
|
||||
item: LicenseItem | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (data: Partial<LicenseItem>) => Promise<void>;
|
||||
onDelete?: (idx: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export function LicenseEditDialog({ item, isOpen, onClose, onSave, onDelete }: LicenseEditDialogProps) {
|
||||
const [formData, setFormData] = useState<Partial<LicenseItem>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
setFormData({
|
||||
idx: item.idx,
|
||||
expire: item.expire || false,
|
||||
name: item.name || '',
|
||||
version: item.version || '',
|
||||
meterialNo: item.meterialNo || '',
|
||||
supply: item.supply || '',
|
||||
qty: item.qty || 1,
|
||||
uids: item.uids || '',
|
||||
serialNo: item.serialNo || '',
|
||||
remark: item.remark || '',
|
||||
sdate: item.sdate ? item.sdate.split('T')[0] : '',
|
||||
edate: item.edate ? item.edate.split('T')[0] : '',
|
||||
manu: item.manu || '',
|
||||
});
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.name?.trim()) {
|
||||
alert('제품명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(formData);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Save failed:', error);
|
||||
alert('저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!formData.idx) return;
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (onDelete) {
|
||||
await onDelete(formData.idx);
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Delete failed:', error);
|
||||
alert('삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="glass-effect rounded-lg w-full max-w-3xl max-h-[90vh] overflow-y-auto m-4" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
{formData.idx ? '라이선스 수정' : '라이선스 추가'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-white/90 flex items-center space-x-2 border-b border-white/10 pb-2">
|
||||
<span>기본 정보</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="col-span-1 flex items-center">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.expire || false}
|
||||
onChange={(e) => setFormData({ ...formData, expire: e.target.checked })}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm text-white/70">만료</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-span-5">
|
||||
<label className="block text-sm text-white/70 mb-1">제품명 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name || ''}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<label className="block text-sm text-white/70 mb-1">버전</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.version || ''}
|
||||
onChange={(e) => setFormData({ ...formData, version: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<label className="block text-sm text-white/70 mb-1">자재번호</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.meterialNo || ''}
|
||||
onChange={(e) => setFormData({ ...formData, meterialNo: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공급 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-white/90 flex items-center space-x-2 border-b border-white/10 pb-2">
|
||||
<span>공급 정보</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">공급업체</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.supply || ''}
|
||||
onChange={(e) => setFormData({ ...formData, supply: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">제조사</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.manu || ''}
|
||||
onChange={(e) => setFormData({ ...formData, manu: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사용 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-white/90 flex items-center space-x-2 border-b border-white/10 pb-2">
|
||||
<span>사용 정보</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm text-white/70 mb-1">수량</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.qty || 1}
|
||||
onChange={(e) => setFormData({ ...formData, qty: parseInt(e.target.value) || 1 })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<label className="block text-sm text-white/70 mb-1">사용자</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.uids || ''}
|
||||
onChange={(e) => setFormData({ ...formData, uids: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-6">
|
||||
<label className="block text-sm text-white/70 mb-1">S/N</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.serialNo || ''}
|
||||
onChange={(e) => setFormData({ ...formData, serialNo: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기간 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-white/90 flex items-center space-x-2 border-b border-white/10 pb-2">
|
||||
<span>기간 정보</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">시작일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.sdate || ''}
|
||||
onChange={(e) => setFormData({ ...formData, sdate: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">종료일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.edate || ''}
|
||||
onChange={(e) => setFormData({ ...formData, edate: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-white/90 flex items-center space-x-2 border-b border-white/10 pb-2">
|
||||
<span>비고</span>
|
||||
</h3>
|
||||
<textarea
|
||||
value={formData.remark || ''}
|
||||
onChange={(e) => setFormData({ ...formData, remark: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500 resize-none"
|
||||
placeholder="추가 메모를 입력하세요..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-4 border-t border-white/10">
|
||||
<div>
|
||||
{formData.idx && onDelete && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={saving}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-red-500 hover:bg-red-600 disabled:bg-gray-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span>삭제</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 disabled:bg-gray-800 text-white rounded-lg transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
<span>{saving ? '저장 중...' : '저장'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
387
Project/frontend/src/components/license/LicenseList.tsx
Normal file
387
Project/frontend/src/components/license/LicenseList.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
FolderOpen,
|
||||
Download,
|
||||
Search,
|
||||
X,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { LicenseEditDialog } from './LicenseEditDialog';
|
||||
import type { LicenseItem } from '@/types';
|
||||
|
||||
export function LicenseList() {
|
||||
const [list, setList] = useState<LicenseItem[]>([]);
|
||||
const [filteredList, setFilteredList] = useState<LicenseItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [selectedItem, setSelectedItem] = useState<LicenseItem | null>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
// Pagination
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 25;
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
applyFilter();
|
||||
}, [searchText, list]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await comms.getLicenseList();
|
||||
if (response.Success && response.Data) {
|
||||
setList(response.Data);
|
||||
} else {
|
||||
alert(response.Message || '라이선스 목록을 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load license list:', error);
|
||||
alert('라이선스 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyFilter = () => {
|
||||
if (!searchText.trim()) {
|
||||
setFilteredList(list);
|
||||
return;
|
||||
}
|
||||
|
||||
const search = searchText.toLowerCase();
|
||||
const filtered = list.filter((item) => {
|
||||
return (
|
||||
item.name?.toLowerCase().includes(search) ||
|
||||
item.version?.toLowerCase().includes(search) ||
|
||||
item.supply?.toLowerCase().includes(search) ||
|
||||
item.manu?.toLowerCase().includes(search) ||
|
||||
item.serialNo?.toLowerCase().includes(search) ||
|
||||
item.meterialNo?.toLowerCase().includes(search) ||
|
||||
item.remark?.toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
setFilteredList(filtered);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setSelectedItem({
|
||||
expire: false,
|
||||
name: '',
|
||||
version: '',
|
||||
meterialNo: '',
|
||||
supply: '',
|
||||
qty: 1,
|
||||
uids: '',
|
||||
serialNo: '',
|
||||
remark: '',
|
||||
sdate: new Date().toISOString().split('T')[0],
|
||||
edate: '',
|
||||
manu: '',
|
||||
});
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleRowClick = (item: LicenseItem) => {
|
||||
setSelectedItem(item);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async (formData: Partial<LicenseItem>) => {
|
||||
if (!formData.name?.trim()) {
|
||||
alert('제품명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
let response;
|
||||
|
||||
if (formData.idx) {
|
||||
// Update
|
||||
response = await comms.updateLicense(
|
||||
formData.idx,
|
||||
formData.name!,
|
||||
formData.version || '',
|
||||
formData.meterialNo || '',
|
||||
formData.supply || '',
|
||||
formData.qty || 1,
|
||||
formData.uids || '',
|
||||
formData.serialNo || '',
|
||||
formData.remark || '',
|
||||
formData.sdate || '',
|
||||
formData.edate || '',
|
||||
formData.manu || '',
|
||||
formData.expire || false
|
||||
);
|
||||
} else {
|
||||
// Add
|
||||
response = await comms.addLicense(
|
||||
formData.name!,
|
||||
formData.version || '',
|
||||
formData.meterialNo || '',
|
||||
formData.supply || '',
|
||||
formData.qty || 1,
|
||||
formData.uids || '',
|
||||
formData.serialNo || '',
|
||||
formData.remark || '',
|
||||
formData.sdate || '',
|
||||
formData.edate || '',
|
||||
formData.manu || '',
|
||||
formData.expire || false
|
||||
);
|
||||
}
|
||||
|
||||
if (response.Success) {
|
||||
alert(response.Message || '저장되었습니다.');
|
||||
await loadData();
|
||||
} else {
|
||||
alert(response.Message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save license:', error);
|
||||
alert('저장에 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (idx: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await comms.deleteLicense(idx);
|
||||
if (response.Success) {
|
||||
alert(response.Message || '삭제되었습니다.');
|
||||
await loadData();
|
||||
} else {
|
||||
alert(response.Message || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete license:', error);
|
||||
alert('삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenFolder = async (item: LicenseItem, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!item.idx) {
|
||||
alert('저장된 자료만 폴더를 열 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await comms.openLicenseFolder(item.idx);
|
||||
if (!response.Success) {
|
||||
alert(response.Message || '폴더 열기에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to open folder:', error);
|
||||
alert('폴더 열기에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCSV = async () => {
|
||||
const filename = `license_${new Date().toISOString().split('T')[0]}.csv`;
|
||||
const filepath = `C:\\Temp\\${filename}`;
|
||||
|
||||
try {
|
||||
const response = await comms.exportLicenseCSV(filepath);
|
||||
if (response.Success) {
|
||||
alert(`CSV 파일이 생성되었습니다.\n\n${filepath}`);
|
||||
} else {
|
||||
alert(response.Message || 'CSV 내보내기에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to export CSV:', error);
|
||||
alert('CSV 내보내기에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setIsDialogOpen(false);
|
||||
setSelectedItem(null);
|
||||
};
|
||||
|
||||
// Pagination
|
||||
const totalPages = Math.ceil(filteredList.length / pageSize);
|
||||
const paginatedList = filteredList.slice(
|
||||
(currentPage - 1) * pageSize,
|
||||
currentPage * pageSize
|
||||
);
|
||||
|
||||
const goToPreviousPage = () => {
|
||||
setCurrentPage((prev) => Math.max(1, prev - 1));
|
||||
};
|
||||
|
||||
const goToNextPage = () => {
|
||||
setCurrentPage((prev) => Math.min(totalPages, prev + 1));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">라이선스 관리</h1>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={loading}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>추가</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
disabled={loading}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-green-500 hover:bg-green-600 disabled:bg-gray-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/50" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
placeholder="검색 (제품명, 버전, 공급업체, 제조사, S/N, 자재번호, 비고)"
|
||||
className="w-full pl-10 pr-10 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
{searchText && (
|
||||
<button
|
||||
onClick={() => setSearchText('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-white/50 hover:text-white"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="glass-effect rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-white border-r border-white/10 w-16">상태</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-white border-r border-white/10" style={{ width: '25%' }}>제품명</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-white border-r border-white/10" style={{ width: '25%' }}>버전</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-white border-r border-white/10 w-20">수량</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-white border-r border-white/10" style={{ width: '12%' }}>사용자</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-white" style={{ width: '15%' }}>S/N</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-white/70">
|
||||
로딩 중...
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading && paginatedList.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-white/70">
|
||||
데이터가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading &&
|
||||
paginatedList.map((item) => (
|
||||
<tr
|
||||
key={item.idx}
|
||||
onClick={() => handleRowClick(item)}
|
||||
className={`border-t border-white/10 hover:bg-white/10 cursor-pointer transition-colors ${
|
||||
item.expire ? 'bg-red-500/10' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3 text-center border-r border-white/10">
|
||||
<div className="flex justify-center" title={item.expire ? '만료' : '유효'}>
|
||||
{item.expire ? (
|
||||
<XCircle className="w-5 h-5 text-red-500" />
|
||||
) : (
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-white border-r border-white/10 max-w-xs">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={(e) => handleOpenFolder(item, e)}
|
||||
className="p-1 text-yellow-400 hover:text-yellow-300 transition-colors flex-shrink-0"
|
||||
title="폴더 열기"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="break-words">{item.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-white border-r border-white/10 break-words">{item.version}</td>
|
||||
<td className="px-4 py-3 text-sm text-white border-r border-white/10">{item.qty}</td>
|
||||
<td className="px-4 py-3 text-sm text-white border-r border-white/10 break-words max-w-[8rem]">{item.uids}</td>
|
||||
<td className="px-4 py-3 text-sm text-white break-words">{item.serialNo}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-white/10">
|
||||
<div className="text-sm text-white/70">
|
||||
전체 {filteredList.length}건 중 {(currentPage - 1) * pageSize + 1}~
|
||||
{Math.min(currentPage * pageSize, filteredList.length)}건
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={goToPreviousPage}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 text-white/70 hover:text-white disabled:text-white/30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<span className="text-sm text-white">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={goToNextPage}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 text-white/70 hover:text-white disabled:text-white/30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<LicenseEditDialog
|
||||
item={selectedItem}
|
||||
isOpen={isDialogOpen}
|
||||
onClose={handleCloseDialog}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
304
Project/frontend/src/components/mail/MailTestDialog.tsx
Normal file
304
Project/frontend/src/components/mail/MailTestDialog.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Mail, Send } from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
|
||||
interface MailTestDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MailTestDialog({ isOpen, onClose }: MailTestDialogProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
cate: '테스트',
|
||||
subject: '',
|
||||
fromlist: '',
|
||||
tolist: '',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
body: '',
|
||||
});
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadUserEmail = async () => {
|
||||
try {
|
||||
const response = await comms.checkLoginStatus();
|
||||
if (response.Success && response.IsLoggedIn && response.User) {
|
||||
const user = response.User as { Email?: string };
|
||||
if (user.Email) {
|
||||
setFormData(prev => ({ ...prev, fromlist: user.Email || '' }));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('사용자 정보 로드 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
loadUserEmail();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleSubmit = async (mode: 'queue' | 'direct' | 'outlook' = 'queue') => {
|
||||
if (!formData.subject.trim()) {
|
||||
alert('제목을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!formData.tolist.trim()) {
|
||||
alert('수신자를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!formData.body.trim()) {
|
||||
alert('내용을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
let response;
|
||||
if (mode === 'outlook') {
|
||||
// Outlook 미리보기
|
||||
response = await comms.sendMailOutlook(
|
||||
formData.subject,
|
||||
formData.tolist,
|
||||
formData.cc,
|
||||
formData.bcc,
|
||||
formData.body
|
||||
);
|
||||
} else if (mode === 'direct') {
|
||||
// 직접 발송
|
||||
response = await comms.sendMailDirect(
|
||||
formData.cate,
|
||||
formData.subject,
|
||||
formData.fromlist,
|
||||
formData.tolist,
|
||||
formData.cc,
|
||||
formData.bcc,
|
||||
formData.body
|
||||
);
|
||||
} else {
|
||||
// 발송 대기열에 추가
|
||||
response = await comms.addMailData(
|
||||
formData.cate,
|
||||
formData.subject,
|
||||
formData.fromlist,
|
||||
formData.tolist,
|
||||
formData.cc,
|
||||
formData.bcc,
|
||||
formData.body
|
||||
);
|
||||
}
|
||||
|
||||
if (response.Success) {
|
||||
alert(response.Message || '처리되었습니다.');
|
||||
if (mode !== 'outlook') {
|
||||
onClose();
|
||||
// 폼 초기화
|
||||
setFormData({
|
||||
cate: '테스트',
|
||||
subject: '',
|
||||
fromlist: formData.fromlist, // 발신자는 유지
|
||||
tolist: '',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
body: '',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
alert(response.Message || '메일 처리에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('메일 처리 오류:', error);
|
||||
alert('메일 처리 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="relative w-full max-w-3xl glass-effect-solid rounded-2xl shadow-2xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<Mail className="w-5 h-5 text-primary-400" />
|
||||
<h2 className="text-lg font-semibold text-white">메일 테스트</h2>
|
||||
</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 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
{/* 분류 */}
|
||||
<div>
|
||||
<label className="block text-white/80 text-sm font-medium mb-2">분류</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.cate}
|
||||
onChange={(e) => setFormData({ ...formData, cate: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<div>
|
||||
<label className="block text-white/80 text-sm font-medium mb-2">제목 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.subject}
|
||||
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
|
||||
placeholder="메일 제목을 입력하세요"
|
||||
className="w-full px-3 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>
|
||||
<label className="block text-white/80 text-sm font-medium mb-2">발신자</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.fromlist}
|
||||
onChange={(e) => setFormData({ ...formData, fromlist: e.target.value })}
|
||||
placeholder="발신자 이메일 (쉼표로 구분)"
|
||||
className="w-full px-3 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>
|
||||
<label className="block text-white/80 text-sm font-medium mb-2">수신자 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.tolist}
|
||||
onChange={(e) => setFormData({ ...formData, tolist: e.target.value })}
|
||||
placeholder="수신자 이메일 (쉼표로 구분)"
|
||||
className="w-full px-3 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>
|
||||
<label className="block text-white/80 text-sm font-medium mb-2">참조 (CC)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.cc}
|
||||
onChange={(e) => setFormData({ ...formData, cc: e.target.value })}
|
||||
placeholder="참조 이메일 (쉼표로 구분)"
|
||||
className="w-full px-3 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>
|
||||
<label className="block text-white/80 text-sm font-medium mb-2">숨은참조 (BCC)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.bcc}
|
||||
onChange={(e) => setFormData({ ...formData, bcc: e.target.value })}
|
||||
placeholder="숨은참조 이메일 (쉼표로 구분)"
|
||||
className="w-full px-3 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>
|
||||
<label className="block text-white/80 text-sm font-medium mb-2">내용 *</label>
|
||||
<textarea
|
||||
value={formData.body}
|
||||
onChange={(e) => setFormData({ ...formData, body: e.target.value })}
|
||||
placeholder="메일 내용을 입력하세요 (HTML 가능)"
|
||||
rows={8}
|
||||
className="w-full px-3 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 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-white/50 text-xs">
|
||||
* 메일은 발송 대기열에 추가됩니다. 실제 발송은 메일 서비스가 처리합니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-white/10 bg-black/20">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={processing}
|
||||
className="px-4 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSubmit('queue')}
|
||||
disabled={processing}
|
||||
className="px-4 py-2 rounded-lg bg-blue-500 hover:bg-blue-600 text-white transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
처리중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="w-4 h-4" />
|
||||
대기열 추가
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSubmit('direct')}
|
||||
disabled={processing}
|
||||
className="px-4 py-2 rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
처리중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4" />
|
||||
즉시 발송
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSubmit('outlook')}
|
||||
disabled={processing}
|
||||
className="px-4 py-2 rounded-lg bg-orange-500 hover:bg-orange-600 text-white transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
처리중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="w-4 h-4" />
|
||||
Outlook
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
519
Project/frontend/src/components/project/PartListDialog.tsx
Normal file
519
Project/frontend/src/components/project/PartListDialog.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Save, Trash2, Plus, RefreshCw } from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { PartListItem } from '@/types';
|
||||
|
||||
interface PartListDialogProps {
|
||||
projectIdx: number;
|
||||
projectName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PartListDialog({ projectIdx, projectName, onClose }: PartListDialogProps) {
|
||||
const [parts, setParts] = useState<PartListItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editingIdx, setEditingIdx] = useState<number | null>(null);
|
||||
const [editForm, setEditForm] = useState<Partial<PartListItem>>({});
|
||||
|
||||
// ESC 키 핸들러
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (editingIdx !== null) {
|
||||
setEditingIdx(null);
|
||||
setEditForm({});
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [editingIdx, onClose]);
|
||||
|
||||
// 데이터 로드
|
||||
const loadParts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
console.log('[PartList] 로드 시작, projectIdx:', projectIdx);
|
||||
const result = await comms.getPartList(projectIdx);
|
||||
console.log('[PartList] 결과:', result);
|
||||
if (result.Success && result.Data) {
|
||||
console.log('[PartList] 데이터 개수:', result.Data.length);
|
||||
setParts(result.Data);
|
||||
} else {
|
||||
console.error('[PartList] 실패:', result.Message);
|
||||
alert(result.Message || '파트리스트 로드 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('파트리스트 로드 실패:', error);
|
||||
alert('파트리스트 로드 중 오류: ' + error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadParts();
|
||||
}, [projectIdx]);
|
||||
|
||||
// 편집 시작
|
||||
const startEdit = (part: PartListItem) => {
|
||||
setEditingIdx(part.idx);
|
||||
setEditForm({ ...part });
|
||||
};
|
||||
|
||||
// 편집 취소
|
||||
const cancelEdit = () => {
|
||||
setEditingIdx(null);
|
||||
setEditForm({});
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (!editForm.itemname || !editForm.item) {
|
||||
alert('품명과 자재번호는 필수입니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await comms.savePartList(
|
||||
editingIdx || 0,
|
||||
projectIdx,
|
||||
editForm.itemgroup || '',
|
||||
editForm.itemname || '',
|
||||
editForm.item || '',
|
||||
editForm.itemmodel || '',
|
||||
editForm.itemscale || '',
|
||||
editForm.itemunit || '',
|
||||
editForm.qty || 0,
|
||||
editForm.price || 0,
|
||||
editForm.itemsupply || '',
|
||||
editForm.itemsupplyidx || 0,
|
||||
editForm.itemmanu || '',
|
||||
editForm.itemsid || '',
|
||||
editForm.option1 || '',
|
||||
editForm.remark || '',
|
||||
editForm.no || 0,
|
||||
editForm.qtybuy || 0
|
||||
);
|
||||
|
||||
if (result.Success) {
|
||||
await loadParts();
|
||||
cancelEdit();
|
||||
} else {
|
||||
alert(result.Message || '저장 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 실패:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제
|
||||
const handleDelete = async (idx: number) => {
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const result = await comms.deletePartList(idx);
|
||||
if (result.Success) {
|
||||
await loadParts();
|
||||
} else {
|
||||
alert(result.Message || '삭제 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('삭제 실패:', error);
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 새 항목 추가
|
||||
const addNew = () => {
|
||||
setEditingIdx(-1);
|
||||
setEditForm({
|
||||
Project: projectIdx,
|
||||
itemgroup: '',
|
||||
itemname: '',
|
||||
item: '',
|
||||
itemmodel: '',
|
||||
itemscale: '',
|
||||
itemunit: 'EA',
|
||||
qty: 1,
|
||||
price: 0,
|
||||
itemsupply: '',
|
||||
itemsupplyidx: 0,
|
||||
itemmanu: '',
|
||||
itemsid: '',
|
||||
option1: '',
|
||||
remark: '',
|
||||
no: 0,
|
||||
qtybuy: 0,
|
||||
});
|
||||
};
|
||||
|
||||
// 금액 계산
|
||||
const getAmount = (qty: number, price: number) => qty * price;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-slate-800/95 backdrop-blur rounded-lg w-full max-w-7xl max-h-[90vh] flex flex-col shadow-2xl border border-white/10">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10 bg-primary-600/30 sticky top-0 z-10">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white">파트리스트</h2>
|
||||
<p className="text-sm text-white/60">{projectName}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={addNew}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-primary-600 hover:bg-primary-500 text-white rounded transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="text-sm">추가</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={loadParts}
|
||||
disabled={loading}
|
||||
className="p-2 hover:bg-white/10 rounded transition-colors disabled:opacity-50"
|
||||
title="새로고침"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 text-white/70 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-white/10 rounded transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-white/70" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{loading && parts.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="w-8 h-8 text-primary-500 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full border-collapse">
|
||||
<thead className="sticky top-0 bg-slate-700/50 backdrop-blur z-10">
|
||||
<tr className="border-b border-white/10">
|
||||
<th className="px-2 py-2 text-left text-xs text-white/70 font-medium w-12">No</th>
|
||||
<th className="px-2 py-2 text-left text-xs text-white/70 font-medium w-24">그룹</th>
|
||||
<th className="px-2 py-2 text-left text-xs text-white/70 font-medium">품명</th>
|
||||
<th className="px-2 py-2 text-left text-xs text-white/70 font-medium w-32">모델</th>
|
||||
<th className="px-2 py-2 text-left text-xs text-white/70 font-medium w-32">규격</th>
|
||||
<th className="px-2 py-2 text-left text-xs text-white/70 font-medium w-16">단위</th>
|
||||
<th className="px-2 py-2 text-right text-xs text-white/70 font-medium w-20">수량</th>
|
||||
<th className="px-2 py-2 text-right text-xs text-white/70 font-medium w-28">단가</th>
|
||||
<th className="px-2 py-2 text-right text-xs text-white/70 font-medium w-32">금액</th>
|
||||
<th className="px-2 py-2 text-left text-xs text-white/70 font-medium w-32">공급처</th>
|
||||
<th className="px-2 py-2 text-center text-xs text-white/70 font-medium w-20">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parts.length === 0 && !loading ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="px-2 py-8 text-center text-white/40 text-sm">
|
||||
등록된 파트가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
parts.map((part) => {
|
||||
const isEditing = editingIdx === part.idx;
|
||||
return (
|
||||
<tr
|
||||
key={part.idx}
|
||||
className={`border-b border-white/5 hover:bg-white/5 transition-colors ${
|
||||
isEditing ? 'bg-primary-500/10' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.no || 0}
|
||||
onChange={(e) => setEditForm({ ...editForm, no: parseInt(e.target.value) || 0 })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.no || ''}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemgroup || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemgroup: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.itemgroup || ''}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemname || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemname: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/90 text-xs font-medium">{part.itemname || ''}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemmodel || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemmodel: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.itemmodel || ''}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemscale || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemscale: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.itemscale || ''}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemunit || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemunit: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.itemunit || ''}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.qty || 0}
|
||||
onChange={(e) => setEditForm({ ...editForm, qty: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none text-right"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.qty?.toLocaleString() || 0}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.price || 0}
|
||||
onChange={(e) => setEditForm({ ...editForm, price: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none text-right"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.price?.toLocaleString() || 0}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<span className="text-white/90 text-xs font-medium">
|
||||
{getAmount(
|
||||
isEditing ? editForm.qty || 0 : part.qty || 0,
|
||||
isEditing ? editForm.price || 0 : part.price || 0
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemsupply || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemsupply: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.itemsupply || ''}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="p-1 hover:bg-green-500/20 text-green-400 rounded transition-colors"
|
||||
title="저장"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
className="p-1 hover:bg-white/10 text-white/50 rounded transition-colors"
|
||||
title="취소"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
onClick={() => startEdit(part)}
|
||||
className="p-1 hover:bg-white/10 text-white/70 rounded transition-colors text-xs"
|
||||
>
|
||||
편집
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(part.idx)}
|
||||
className="p-1 hover:bg-red-500/20 text-red-400 rounded transition-colors"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{/* 새 항목 추가 행 */}
|
||||
{editingIdx === -1 && (
|
||||
<tr className="border-b border-white/5 bg-primary-500/10">
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.no || 0}
|
||||
onChange={(e) => setEditForm({ ...editForm, no: parseInt(e.target.value) || 0 })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemgroup || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemgroup: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="그룹"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemname || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemname: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="품명 *"
|
||||
required
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemmodel || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemmodel: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="모델"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemscale || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemscale: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="규격"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemunit || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemunit: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="단위"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.qty || 0}
|
||||
onChange={(e) => setEditForm({ ...editForm, qty: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none text-right"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.price || 0}
|
||||
onChange={(e) => setEditForm({ ...editForm, price: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none text-right"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<span className="text-white/90 text-xs font-medium">
|
||||
{getAmount(editForm.qty || 0, editForm.price || 0).toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemsupply || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemsupply: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="공급처"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="p-1 hover:bg-green-500/20 text-green-400 rounded transition-colors"
|
||||
title="저장"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
className="p-1 hover:bg-white/10 text-white/50 rounded transition-colors"
|
||||
title="취소"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 합계 */}
|
||||
{parts.length > 0 && (
|
||||
<div className="p-4 border-t border-white/10 bg-slate-900/50">
|
||||
<div className="flex justify-end gap-4 text-sm">
|
||||
<span className="text-white/70">
|
||||
총 <span className="text-white font-medium">{parts.length}</span>개 항목
|
||||
</span>
|
||||
<span className="text-white/70">
|
||||
합계: <span className="text-primary-400 font-medium">
|
||||
{parts.reduce((sum, part) => sum + getAmount(part.qty || 0, part.price || 0), 0).toLocaleString()}
|
||||
</span>원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { ProjectDetailDialog } from './ProjectDetailDialog';
|
||||
export { PartListDialog } from './PartListDialog';
|
||||
|
||||
Reference in New Issue
Block a user