'use client' import * as React from 'react' import { Card, CardContent, CardHeader } from '@/components/ui/card' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { Package, Plus, Trash2, FileSpreadsheet, Save, LoaderIcon } from 'lucide-react' import { toast } from 'sonner' import { updateContractItems, getContractItems } from '../service' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { ProjectSelector } from '@/components/ProjectSelector' import { MaterialGroupSelectorDialogSingle } from '@/components/common/material/material-group-selector-dialog-single' import { MaterialSearchItem } from '@/lib/material/material-group-service' interface ContractItem { id?: number projectId?: number | null projectName?: string projectCode?: string itemCode: string itemInfo: string materialGroupCode?: string materialGroupDescription?: string specification: string quantity: number quantityUnit: string totalWeight: number weightUnit: string contractDeliveryDate: string contractUnitPrice: number contractAmount: number contractCurrency: string isSelected?: boolean [key: string]: unknown } interface ContractItemsTableProps { contractId: number items: ContractItem[] onItemsChange: (items: ContractItem[]) => void onTotalAmountChange: (total: number) => void availableBudget?: number readOnly?: boolean contractScope?: string // 계약확정범위 (단가/금액/물량) deliveryType?: string // 납기종류 (단일납기/분할납기) contractDeliveryDate?: string // 기본정보의 계약납기일 } // 통화 목록 const CURRENCIES = ["USD", "EUR", "KRW", "JPY", "CNY"]; // 수량 단위 목록 const QUANTITY_UNITS = ["KG", "TON", "EA", "M", "M2", "M3", "L", "ML", "G", "SET", "PCS"]; // 중량 단위 목록 const WEIGHT_UNITS = ["KG", "TON", "G", "LB", "OZ"]; export function ContractItemsTable({ contractId, items, onItemsChange, onTotalAmountChange, availableBudget = 0, readOnly = false, contractScope = '', deliveryType = '', contractDeliveryDate = '' }: ContractItemsTableProps) { // 계약확정범위에 따른 필드 활성화/비활성화 const isQuantityDisabled = contractScope === '단가' || contractScope === '물량' const isTotalAmountDisabled = contractScope === '단가' || contractScope === '물량' // 단일납기인 경우 납기일 필드 비활성화 및 기본값 설정 const isDeliveryDateDisabled = deliveryType === '단일납기' const [localItems, setLocalItems] = React.useState(items) const [isSaving, setIsSaving] = React.useState(false) const [isLoading, setIsLoading] = React.useState(false) const [isEnabled, setIsEnabled] = React.useState(true) const [showBatchInputDialog, setShowBatchInputDialog] = React.useState(false) const [batchInputData, setBatchInputData] = React.useState({ quantity: '', quantityUnit: 'EA', contractDeliveryDate: '', contractCurrency: 'KRW', contractUnitPrice: '' }) // 초기 데이터 로드 React.useEffect(() => { const loadItems = async () => { try { setIsLoading(true) const fetchedItems = await getContractItems(contractId) const formattedItems = fetchedItems.map(item => { // itemInfo에서 자재그룹 정보 파싱 (형식: "자재그룹코드 / 자재그룹명") let materialGroupCode = '' let materialGroupDescription = '' if (item.itemInfo) { const parts = item.itemInfo.split(' / ') if (parts.length >= 2) { materialGroupCode = parts[0].trim() materialGroupDescription = parts.slice(1).join(' / ').trim() } else if (parts.length === 1) { materialGroupCode = parts[0].trim() } } return { id: item.id, projectId: item.projectId || null, projectName: item.projectName || undefined, projectCode: item.projectCode || undefined, itemCode: item.itemCode || '', itemInfo: item.itemInfo || '', materialGroupCode: materialGroupCode || undefined, materialGroupDescription: materialGroupDescription || undefined, specification: item.specification || '', quantity: Number(item.quantity) || 0, quantityUnit: item.quantityUnit || 'EA', totalWeight: Number(item.totalWeight) || 0, weightUnit: item.weightUnit || 'KG', contractDeliveryDate: item.contractDeliveryDate || '', contractUnitPrice: Number(item.contractUnitPrice) || 0, contractAmount: Number(item.contractAmount) || 0, contractCurrency: item.contractCurrency || 'KRW', isSelected: false } }) as ContractItem[] setLocalItems(formattedItems as ContractItem[]) onItemsChange(formattedItems as ContractItem[]) } catch (error) { console.error('Error loading contract items:', error) // 기본 빈 배열로 설정 setLocalItems([]) onItemsChange([]) } finally { setIsLoading(false) } } loadItems() }, [contractId, onItemsChange]) // 로컬 상태와 부모 상태 동기화 (초기 로드 후에는 부모 상태 우선) React.useEffect(() => { if (items.length > 0) { setLocalItems(items) } }, [items]) const handleSaveItems = async () => { try { setIsSaving(true) // validation 체크 const errors: string[] = [] for (let index = 0; index < localItems.length; index++) { const item = localItems[index] if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`) if (!item.itemInfo) errors.push(`${index + 1}번째 품목의 Item 정보`) if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`) if (!item.contractUnitPrice || item.contractUnitPrice <= 0) errors.push(`${index + 1}번째 품목의 단가`) if (!item.contractDeliveryDate) errors.push(`${index + 1}번째 품목의 납기일`) } if (errors.length > 0) { toast.error(`다음 항목을 입력해주세요: ${errors.join(', ')}`) return } await updateContractItems(contractId, localItems as any) toast.success('품목정보가 저장되었습니다.') } catch (error) { console.error('Error saving contract items:', error) toast.error('품목정보 저장 중 오류가 발생했습니다.') } finally { setIsSaving(false) } } // 총 금액 계산 const totalAmount = localItems.reduce((sum, item) => sum + item.contractAmount, 0) const totalQuantity = localItems.reduce((sum, item) => sum + item.quantity, 0) const totalUnitPrice = localItems.reduce((sum, item) => sum + item.contractUnitPrice, 0) const amountDifference = availableBudget - totalAmount const budgetRatio = availableBudget > 0 ? (totalAmount / availableBudget) * 100 : 0 // 부모 컴포넌트에 총 금액 전달 React.useEffect(() => { onTotalAmountChange(totalAmount) }, [totalAmount, onTotalAmountChange]) // 아이템 업데이트 const updateItem = (index: number, field: keyof ContractItem, value: string | number | boolean | undefined) => { const updatedItems = [...localItems] updatedItems[index] = { ...updatedItems[index], [field]: value } // 단가나 수량이 변경되면 금액 자동 계산 if (field === 'contractUnitPrice' || field === 'quantity') { const item = updatedItems[index] updatedItems[index].contractAmount = item.contractUnitPrice * item.quantity } setLocalItems(updatedItems) onItemsChange(updatedItems) } // 행 추가 const addRow = () => { const newItem: ContractItem = { projectId: null, itemCode: '', itemInfo: '', materialGroupCode: '', materialGroupDescription: '', specification: '', quantity: 0, quantityUnit: 'EA', // 기본 수량 단위 totalWeight: 0, weightUnit: 'KG', // 기본 중량 단위 contractDeliveryDate: '', contractUnitPrice: 0, contractAmount: 0, contractCurrency: 'KRW', // 기본 통화 isSelected: false } const updatedItems = [...localItems, newItem] setLocalItems(updatedItems) onItemsChange(updatedItems) } // 선택된 행 삭제 const deleteSelectedRows = () => { const selectedIndices = localItems .map((item, index) => item.isSelected ? index : -1) .filter(index => index !== -1) if (selectedIndices.length === 0) { toast.error("삭제할 행을 선택해주세요.") return } const updatedItems = localItems.filter((_, index) => !selectedIndices.includes(index)) setLocalItems(updatedItems) onItemsChange(updatedItems) toast.success(`${selectedIndices.length}개 행이 삭제되었습니다.`) } // 전체 선택/해제 const toggleSelectAll = (checked: boolean) => { const updatedItems = localItems.map(item => ({ ...item, isSelected: checked })) setLocalItems(updatedItems) onItemsChange(updatedItems) } // 일괄입력 적용 const applyBatchInput = () => { if (localItems.length === 0) { toast.error('품목이 없습니다. 먼저 품목을 추가해주세요.') return } const updatedItems = localItems.map(item => { const updatedItem = { ...item } if (batchInputData.quantity) { updatedItem.quantity = parseFloat(batchInputData.quantity) || 0 } if (batchInputData.quantityUnit) { updatedItem.quantityUnit = batchInputData.quantityUnit } if (batchInputData.contractDeliveryDate) { updatedItem.contractDeliveryDate = batchInputData.contractDeliveryDate } if (batchInputData.contractCurrency) { updatedItem.contractCurrency = batchInputData.contractCurrency } if (batchInputData.contractUnitPrice) { updatedItem.contractUnitPrice = parseFloat(batchInputData.contractUnitPrice) || 0 // 단가가 변경되면 계약금액도 재계산 updatedItem.contractAmount = updatedItem.contractUnitPrice * updatedItem.quantity } return updatedItem }) setLocalItems(updatedItems) onItemsChange(updatedItems) setShowBatchInputDialog(false) toast.success('일괄입력이 적용되었습니다.') } // 통화 포맷팅 const formatCurrency = (amount: number, currency: string = 'KRW') => { return new Intl.NumberFormat('ko-KR', { style: 'currency', currency: currency, }).format(amount) } const allSelected = localItems.length > 0 && localItems.every(item => item.isSelected) const someSelected = localItems.some(item => item.isSelected) if (isLoading) { return (
품목 정보 (로딩 중...)
품목 정보를 불러오는 중...
) } return (
품목 정보 ({localItems.length}개 품목)
{/* 체크박스 */}
setIsEnabled(checked as boolean)} disabled={readOnly} /> 품목 정보 활성화
총 금액: {formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')} 총 수량: {totalQuantity.toLocaleString()}
{!readOnly && (
품목 정보 일괄입력
setBatchInputData(prev => ({ ...prev, quantity: e.target.value }))} placeholder="수량 입력 (선택사항)" />
setBatchInputData(prev => ({ ...prev, contractDeliveryDate: e.target.value }))} />
{ // Leading zero removal const val = e.target.value.replace(/^0+(?=[0-9])/, '') setBatchInputData(prev => ({ ...prev, contractUnitPrice: val })) }} placeholder="계약단가 입력 (선택사항)" />
)}
{/* 요약 정보 */} {/*
{isTotalAmountDisabled ? '-' : formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')}
{budgetRatio.toFixed(1)}%
*/}
{!readOnly && ( { if (el) (el as HTMLInputElement & { indeterminate?: boolean }).indeterminate = someSelected && !allSelected }} onCheckedChange={toggleSelectAll} disabled={!isEnabled} /> )} 프로젝트 품목코드 (PKG No.) 자재그룹 자재내역(자재그룹명) 규격 수량 수량단위 총 중량 중량단위 계약납기일 계약단가 계약금액 계약통화 {localItems.map((item, index) => ( {!readOnly && ( updateItem(index, 'isSelected', checked) } disabled={!isEnabled} /> )} {readOnly ? ( {item.projectCode && item.projectName ? `${item.projectCode} - ${item.projectName}` : '-'} ) : ( { updateItem(index, 'projectId', project.id) updateItem(index, 'projectName', project.projectName) updateItem(index, 'projectCode', project.projectCode) }} placeholder="프로젝트 선택" /> )} {readOnly ? ( {item.itemCode || '-'} ) : ( updateItem(index, 'itemCode', e.target.value)} placeholder="품목코드" className="h-8 text-sm" disabled={!isEnabled} /> )} {readOnly ? ( {item.materialGroupCode || '-'} ) : ( { if (material) { updateItem(index, 'materialGroupCode', material.materialGroupCode) updateItem(index, 'materialGroupDescription', material.materialGroupDescription) updateItem(index, 'itemInfo', `${material.materialGroupCode} / ${material.materialGroupDescription}`) } else { updateItem(index, 'materialGroupCode', '') updateItem(index, 'materialGroupDescription', '') updateItem(index, 'itemInfo', '') } }} title="자재그룹 선택" description="자재그룹을 검색하고 선택해주세요." /> )} {readOnly ? ( {item.materialGroupDescription || item.itemInfo || '-'} ) : ( updateItem(index, 'materialGroupDescription', e.target.value)} placeholder="자재그룹명" className="h-8 text-sm bg-muted/50" readOnly disabled={!isEnabled} /> )} {readOnly ? ( {item.specification || '-'} ) : ( updateItem(index, 'specification', e.target.value)} placeholder="규격" className="h-8 text-sm" disabled={!isEnabled} /> )} {/* {readOnly ? ( {item.quantity.toLocaleString()} ) : ( updateItem(index, 'quantity', parseFloat(e.target.value) || 0)} className="h-8 text-sm text-right" placeholder="0" disabled={!isEnabled || isQuantityDisabled} /> )} */} updateItem(index, 'quantity', parseFloat(e.target.value) || 0)} className="h-8 text-sm text-right" placeholder="0" disabled={!isEnabled} /> {readOnly ? ( {item.quantityUnit || '-'} ) : ( )} {readOnly ? ( {item.totalWeight.toLocaleString()} ) : ( updateItem(index, 'totalWeight', parseFloat(e.target.value) || 0)} className="h-8 text-sm text-right" placeholder="0" disabled={!isEnabled || isQuantityDisabled} /> )} {readOnly ? ( {item.weightUnit || '-'} ) : ( )} {readOnly ? ( {item.contractDeliveryDate || '-'} ) : ( updateItem(index, 'contractDeliveryDate', e.target.value)} className="h-8 text-sm" disabled={!isEnabled || isDeliveryDateDisabled} /> )} {readOnly ? ( {item.contractUnitPrice.toLocaleString()} ) : ( updateItem(index, 'contractUnitPrice', parseFloat(e.target.value) || 0)} className="h-8 text-sm text-right" placeholder="0" disabled={!isEnabled} /> )}
{formatCurrency(item.contractAmount)}
{readOnly ? ( {item.contractCurrency || '-'} ) : ( )}
))}
{/* 합계 정보 */} {localItems.length > 0 && (
총 수량 {totalQuantity.toLocaleString()} {localItems[0]?.quantityUnit || 'KG'}
총 단가 {formatCurrency(totalUnitPrice, localItems[0]?.contractCurrency || 'KRW')}
합계 금액 {formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')}
)}
) }