This commit is contained in:
backuppc
2025-12-05 17:33:12 +09:00
parent 8e8d1f91b4
commit 77f1ddab80
92 changed files with 4878 additions and 20435 deletions

View File

@@ -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 ? '업무일지 수정' : '업무일지 등록'}

View File

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

View File

@@ -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 */}

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

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

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

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

View File

@@ -1 +1,2 @@
export { ProjectDetailDialog } from './ProjectDetailDialog';
export { PartListDialog } from './PartListDialog';