589 lines
26 KiB
TypeScript
589 lines
26 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import {
|
|
ClipboardList,
|
|
Search,
|
|
RefreshCw,
|
|
Plus,
|
|
Save,
|
|
Trash2,
|
|
X,
|
|
DollarSign,
|
|
} from 'lucide-react';
|
|
import { comms } from '@/communication';
|
|
import { PartListItem } from '@/types';
|
|
import { useSearchParams } from 'react-router-dom';
|
|
|
|
export function PartList() {
|
|
const [searchParams] = useSearchParams();
|
|
const projectIdx = parseInt(searchParams.get('idx') || '0');
|
|
const projectName = searchParams.get('name') || '';
|
|
|
|
const [parts, setParts] = useState<PartListItem[]>([]);
|
|
const [filteredParts, setFilteredParts] = useState<PartListItem[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [searchKey, setSearchKey] = useState('');
|
|
const [editingIdx, setEditingIdx] = useState<number | null>(null);
|
|
const [editForm, setEditForm] = useState<Partial<PartListItem>>({});
|
|
const [showSummary, setShowSummary] = useState(false);
|
|
|
|
// 데이터 로드
|
|
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);
|
|
setFilteredParts(result.Data);
|
|
} else {
|
|
console.error('[PartList] 실패:', result.Message);
|
|
alert(result.Message || '파트리스트 로드 실패');
|
|
}
|
|
} catch (error) {
|
|
console.error('파트리스트 로드 실패:', error);
|
|
alert('파트리스트 로드 중 오류: ' + error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (projectIdx > 0) {
|
|
loadParts();
|
|
}
|
|
}, [projectIdx]);
|
|
|
|
// 검색
|
|
useEffect(() => {
|
|
if (!searchKey.trim()) {
|
|
setFilteredParts(parts);
|
|
return;
|
|
}
|
|
|
|
const search = searchKey.toLowerCase();
|
|
const filtered = parts.filter((part) => {
|
|
return (
|
|
part.itemsid?.toLowerCase().includes(search) ||
|
|
part.itemname?.toLowerCase().includes(search) ||
|
|
part.itemmodel?.toLowerCase().includes(search)
|
|
);
|
|
});
|
|
setFilteredParts(filtered);
|
|
}, [searchKey, parts]);
|
|
|
|
// 편집 시작
|
|
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 || '',
|
|
'', // 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: '',
|
|
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;
|
|
|
|
// 합계 계산
|
|
const totalAmount = filteredParts.reduce((sum, part) => sum + getAmount(part.qty || 0, part.price || 0), 0);
|
|
|
|
// 그룹별 합계
|
|
const groupSummary = filteredParts.reduce((acc, part) => {
|
|
const group = part.itemgroup || '미분류';
|
|
if (!acc[group]) {
|
|
acc[group] = { count: 0, amount: 0 };
|
|
}
|
|
acc[group].count++;
|
|
acc[group].amount += getAmount(part.qty || 0, part.price || 0);
|
|
return acc;
|
|
}, {} as Record<string, { count: number; amount: number }>);
|
|
|
|
return (
|
|
<div className="p-4 space-y-4">
|
|
{/* 헤더 */}
|
|
<div className="glass-effect rounded-xl p-4">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3">
|
|
<ClipboardList className="w-6 h-6 text-amber-400" />
|
|
<div>
|
|
<h1 className="text-xl font-bold text-white">파트리스트</h1>
|
|
<p className="text-sm text-white/60">{projectName}</p>
|
|
</div>
|
|
<span className="text-white/50 text-sm">({filteredParts.length}건)</span>
|
|
</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>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 검색 */}
|
|
<div className="flex items-center gap-2 bg-white/5 rounded-lg px-3 py-2">
|
|
<Search className="w-4 h-4 text-white/50" />
|
|
<input
|
|
type="text"
|
|
value={searchKey}
|
|
onChange={(e) => setSearchKey(e.target.value)}
|
|
placeholder="SID, 품명, 모델로 검색..."
|
|
className="flex-1 bg-transparent text-white placeholder-white/30 focus:outline-none text-sm"
|
|
/>
|
|
{searchKey && (
|
|
<button onClick={() => setSearchKey('')} className="text-white/50 hover:text-white/70">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 테이블 */}
|
|
<div className="glass-effect rounded-xl overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
{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">
|
|
<thead className="bg-slate-700/50 sticky top-0 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 w-24">SID</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-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>
|
|
{filteredParts.length === 0 && !loading ? (
|
|
<tr>
|
|
<td colSpan={11} className="px-2 py-8 text-center text-white/40 text-sm">
|
|
{searchKey ? '검색 결과가 없습니다.' : '등록된 파트가 없습니다.'}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredParts.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.itemsid || ''}
|
|
onChange={(e) => setEditForm({ ...editForm, itemsid: 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.itemsid || ''}</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.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.itemsid || ''}
|
|
onChange={(e) => setEditForm({ ...editForm, itemsid: 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="SID"
|
|
/>
|
|
</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.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>
|
|
</div>
|
|
|
|
{/* 하단 정보 */}
|
|
<div className="flex gap-4">
|
|
{/* 좌측: 비용 요약 */}
|
|
<div className="glass-effect rounded-xl p-4 flex-1">
|
|
<button
|
|
onClick={() => setShowSummary(!showSummary)}
|
|
className="flex items-center gap-2 text-amber-400 hover:text-amber-300 mb-3"
|
|
>
|
|
<DollarSign className="w-5 h-5" />
|
|
<span className="font-medium">비용 요약</span>
|
|
</button>
|
|
|
|
{showSummary && (
|
|
<div className="space-y-2">
|
|
{Object.entries(groupSummary).map(([group, data]) => (
|
|
<div key={group} className="flex items-center justify-between text-sm border-b border-white/5 pb-2">
|
|
<span className="text-white/70">{group}</span>
|
|
<div className="flex items-center gap-4">
|
|
<span className="text-white/50 text-xs">{data.count}건</span>
|
|
<span className="text-primary-400 font-medium">{data.amount.toLocaleString()}원</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 우측: 합계 */}
|
|
<div className="glass-effect rounded-xl p-4 min-w-[300px]">
|
|
<div className="flex justify-between items-center mb-2">
|
|
<span className="text-white/70 text-sm">총 항목</span>
|
|
<span className="text-white font-medium">{filteredParts.length}개</span>
|
|
</div>
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-white/70 text-sm">총 금액</span>
|
|
<span className="text-amber-400 font-bold text-lg">{totalAmount.toLocaleString()}원</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|