Files
Groupware/Project/frontend/src/pages/PartList.tsx
backuppc 77f1ddab80 ..
2025-12-05 17:33:12 +09:00

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