summaryrefslogtreecommitdiff
path: root/lib/general-contracts/detail/general-contract-items-table.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-11 11:20:42 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-11 11:20:42 +0000
commitee77f36b1ceece1236d45fba102c3ea410acebc1 (patch)
treee32f34faa5648bd04f57ced8811d120e773fb020 /lib/general-contracts/detail/general-contract-items-table.tsx
parent1b522f9d806b62d28a0e4072867efd3cd345cf06 (diff)
(최겸) 구매 계약 메인 및 상세 기능 개발(템플릿 연동 및 계약 전달 개발 필요)
Diffstat (limited to 'lib/general-contracts/detail/general-contract-items-table.tsx')
-rw-r--r--lib/general-contracts/detail/general-contract-items-table.tsx549
1 files changed, 549 insertions, 0 deletions
diff --git a/lib/general-contracts/detail/general-contract-items-table.tsx b/lib/general-contracts/detail/general-contract-items-table.tsx
new file mode 100644
index 00000000..23057cb7
--- /dev/null
+++ b/lib/general-contracts/detail/general-contract-items-table.tsx
@@ -0,0 +1,549 @@
+'use client'
+
+import * as React from 'react'
+import { Card, CardContent, CardHeader, CardTitle } 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 {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ Package,
+ Plus,
+ Trash2,
+ Calculator
+} from 'lucide-react'
+import { toast } from 'sonner'
+import { updateContractItems, getContractItems } from '../service'
+import { Save, LoaderIcon } from 'lucide-react'
+
+interface ContractItem {
+ id?: number
+ project: string
+ itemCode: string
+ itemInfo: string
+ specification: string
+ quantity: number
+ quantityUnit: string
+ contractDeliveryDate: string
+ contractUnitPrice: number
+ contractAmount: number
+ contractCurrency: string
+ isSelected?: boolean
+}
+
+interface ContractItemsTableProps {
+ contractId: number
+ items: ContractItem[]
+ onItemsChange: (items: ContractItem[]) => void
+ onTotalAmountChange: (total: number) => void
+ currency?: string
+ availableBudget?: number
+ readOnly?: boolean
+}
+
+export function ContractItemsTable({
+ contractId,
+ items,
+ onItemsChange,
+ onTotalAmountChange,
+ currency = 'USD',
+ availableBudget = 0,
+ readOnly = false
+}: ContractItemsTableProps) {
+ const [localItems, setLocalItems] = React.useState<ContractItem[]>(items)
+ const [isSaving, setIsSaving] = React.useState(false)
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [isEnabled, setIsEnabled] = React.useState(true)
+
+ // 초기 데이터 로드
+ React.useEffect(() => {
+ const loadItems = async () => {
+ try {
+ setIsLoading(true)
+ const fetchedItems = await getContractItems(contractId)
+ const formattedItems = fetchedItems.map(item => ({
+ id: item.id,
+ project: item.project || '',
+ itemCode: item.itemCode || '',
+ itemInfo: item.itemInfo || '',
+ specification: item.specification || '',
+ quantity: item.quantity || 0,
+ quantityUnit: item.quantityUnit || 'KG',
+ contractDeliveryDate: item.contractDeliveryDate || '',
+ contractUnitPrice: item.contractUnitPrice || 0,
+ contractAmount: item.contractAmount || 0,
+ contractCurrency: item.contractCurrency || currency,
+ isSelected: false
+ }))
+ setLocalItems(formattedItems)
+ onItemsChange(formattedItems)
+ } catch (error) {
+ console.error('Error loading contract items:', error)
+ // 기본 빈 배열로 설정
+ setLocalItems([])
+ onItemsChange([])
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadItems()
+ }, [contractId, currency, onItemsChange])
+
+ // 로컬 상태와 부모 상태 동기화 (초기 로드 후에는 부모 상태 우선)
+ React.useEffect(() => {
+ if (items.length > 0) {
+ setLocalItems(items)
+ }
+ }, [items])
+
+ const handleSaveItems = async () => {
+ try {
+ setIsSaving(true)
+
+ // validation 체크
+ const errors = []
+ localItems.forEach((item, index) => {
+ if (!item.project) errors.push(`${index + 1}번째 품목의 프로젝트`)
+ if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`)
+ if (!item.itemInfo) errors.push(`${index + 1}번째 품목의 Item 정보`)
+ if (!item.specification) errors.push(`${index + 1}번째 품목의 사양`)
+ 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)
+ 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: any) => {
+ 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 = {
+ project: '',
+ itemCode: '',
+ itemInfo: '',
+ specification: '',
+ quantity: 0,
+ quantityUnit: 'KG',
+ contractDeliveryDate: '',
+ contractUnitPrice: 0,
+ contractAmount: 0,
+ contractCurrency: currency,
+ 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 formatCurrency = (amount: number) => {
+ 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 (
+ <Accordion type="single" collapsible className="w-full">
+ <AccordionItem value="items">
+ <AccordionTrigger className="hover:no-underline">
+ <div className="flex items-center gap-2">
+ <Package className="w-5 h-5" />
+ <span>품목 정보</span>
+ <span className="text-sm text-gray-500">(로딩 중...)</span>
+ </div>
+ </AccordionTrigger>
+ <AccordionContent>
+ <div className="flex items-center justify-center py-8">
+ <LoaderIcon className="w-6 h-6 animate-spin mr-2" />
+ <span>품목 정보를 불러오는 중...</span>
+ </div>
+ </AccordionContent>
+ </AccordionItem>
+ </Accordion>
+ )
+ }
+
+ return (
+ <Accordion type="single" collapsible className="w-full">
+ <AccordionItem value="items">
+ <AccordionTrigger className="hover:no-underline">
+ <div className="flex items-center gap-3 w-full">
+ <Package className="w-5 h-5" />
+ <span className="font-medium">품목 정보</span>
+ <span className="text-sm text-gray-500">({localItems.length}개 품목)</span>
+ </div>
+ </AccordionTrigger>
+ <AccordionContent>
+ <Card>
+ <CardHeader>
+ {/* 체크박스 */}
+ <div className="flex items-center gap-2 mb-4">
+ <Checkbox
+ checked={isEnabled}
+ onCheckedChange={(checked) => setIsEnabled(checked as boolean)}
+ disabled={readOnly}
+ />
+ <span className="text-sm font-medium">품목 정보 활성화</span>
+ </div>
+
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <span className="text-sm text-gray-600">총 금액: {totalAmount.toLocaleString()} {currency}</span>
+ <span className="text-sm text-gray-600">총 수량: {totalQuantity.toLocaleString()}</span>
+ </div>
+ {!readOnly && (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={addRow}
+ disabled={!isEnabled}
+ className="flex items-center gap-2"
+ >
+ <Plus className="w-4 h-4" />
+ 행 추가
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={deleteSelectedRows}
+ disabled={!isEnabled}
+ className="flex items-center gap-2 text-red-600 hover:text-red-700"
+ >
+ <Trash2 className="w-4 h-4" />
+ 행 삭제
+ </Button>
+ <Button
+ onClick={handleSaveItems}
+ disabled={isSaving || !isEnabled}
+ className="flex items-center gap-2"
+ >
+ {isSaving ? (
+ <LoaderIcon className="w-4 h-4 animate-spin" />
+ ) : (
+ <Save className="w-4 h-4" />
+ )}
+ 품목정보 저장
+ </Button>
+ </div>
+ )}
+ </div>
+
+ {/* 요약 정보 */}
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
+ <div className="space-y-1">
+ <Label className="text-sm font-medium">총 계약금액</Label>
+ <div className="text-lg font-bold text-primary">
+ {formatCurrency(totalAmount)}
+ </div>
+ </div>
+ <div className="space-y-1">
+ <Label className="text-sm font-medium">가용예산</Label>
+ <div className="text-lg font-bold">
+ {formatCurrency(availableBudget)}
+ </div>
+ </div>
+ <div className="space-y-1">
+ <Label className="text-sm font-medium">가용예산 比 (금액차)</Label>
+ <div className={`text-lg font-bold ${amountDifference >= 0 ? 'text-green-600' : 'text-red-600'}`}>
+ {formatCurrency(amountDifference)}
+ </div>
+ </div>
+ <div className="space-y-1">
+ <Label className="text-sm font-medium">가용예산 比 (비율)</Label>
+ <div className={`text-lg font-bold ${budgetRatio <= 100 ? 'text-green-600' : 'text-red-600'}`}>
+ {budgetRatio.toFixed(1)}%
+ </div>
+ </div>
+ </div>
+ </CardHeader>
+
+ <CardContent>
+ <div className="overflow-x-auto">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-12">
+ {!readOnly && (
+ <Checkbox
+ checked={allSelected}
+ ref={(el) => {
+ if (el) (el as any).indeterminate = someSelected && !allSelected
+ }}
+ onCheckedChange={toggleSelectAll}
+ disabled={!isEnabled}
+ />
+ )}
+ </TableHead>
+ <TableHead>프로젝트</TableHead>
+ <TableHead>품목코드 (PKG No.)</TableHead>
+ <TableHead>Item 정보 (자재그룹 / 자재코드)</TableHead>
+ <TableHead>규격</TableHead>
+ <TableHead className="text-right">수량</TableHead>
+ <TableHead>수량단위</TableHead>
+ <TableHead>계약납기일</TableHead>
+ <TableHead className="text-right">계약단가</TableHead>
+ <TableHead className="text-right">계약금액</TableHead>
+ <TableHead>계약통화</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {localItems.map((item, index) => (
+ <TableRow key={index}>
+ <TableCell>
+ {!readOnly && (
+ <Checkbox
+ checked={item.isSelected || false}
+ onCheckedChange={(checked) =>
+ updateItem(index, 'isSelected', checked)
+ }
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell>
+ {readOnly ? (
+ <span>{item.project || '-'}</span>
+ ) : (
+ <Input
+ value={item.project}
+ onChange={(e) => updateItem(index, 'project', e.target.value)}
+ placeholder="프로젝트"
+ className="w-32"
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell>
+ {readOnly ? (
+ <span>{item.itemCode || '-'}</span>
+ ) : (
+ <Input
+ value={item.itemCode}
+ onChange={(e) => updateItem(index, 'itemCode', e.target.value)}
+ placeholder="품목코드"
+ className="w-32"
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell>
+ {readOnly ? (
+ <span>{item.itemInfo || '-'}</span>
+ ) : (
+ <Input
+ value={item.itemInfo}
+ onChange={(e) => updateItem(index, 'itemInfo', e.target.value)}
+ placeholder="Item 정보"
+ className="w-48"
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell>
+ {readOnly ? (
+ <span>{item.specification || '-'}</span>
+ ) : (
+ <Input
+ value={item.specification}
+ onChange={(e) => updateItem(index, 'specification', e.target.value)}
+ placeholder="규격"
+ className="w-32"
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell>
+ {readOnly ? (
+ <span className="text-right">{item.quantity.toLocaleString()}</span>
+ ) : (
+ <Input
+ type="number"
+ value={item.quantity}
+ onChange={(e) => updateItem(index, 'quantity', parseFloat(e.target.value) || 0)}
+ className="w-24 text-right"
+ placeholder="0"
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell>
+ {readOnly ? (
+ <span>{item.quantityUnit || '-'}</span>
+ ) : (
+ <Input
+ value={item.quantityUnit}
+ onChange={(e) => updateItem(index, 'quantityUnit', e.target.value)}
+ placeholder="단위"
+ className="w-16"
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell>
+ {readOnly ? (
+ <span>{item.contractDeliveryDate || '-'}</span>
+ ) : (
+ <Input
+ type="date"
+ value={item.contractDeliveryDate}
+ onChange={(e) => updateItem(index, 'contractDeliveryDate', e.target.value)}
+ className="w-36"
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell>
+ {readOnly ? (
+ <span className="text-right">{item.contractUnitPrice.toLocaleString()}</span>
+ ) : (
+ <Input
+ type="number"
+ value={item.contractUnitPrice}
+ onChange={(e) => updateItem(index, 'contractUnitPrice', parseFloat(e.target.value) || 0)}
+ className="w-24 text-right"
+ placeholder="0"
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell>
+ <div className="font-semibold text-primary text-right">
+ {formatCurrency(item.contractAmount)}
+ </div>
+ </TableCell>
+ <TableCell>
+ {readOnly ? (
+ <span>{item.contractCurrency || '-'}</span>
+ ) : (
+ <Input
+ value={item.contractCurrency}
+ onChange={(e) => updateItem(index, 'contractCurrency', e.target.value)}
+ placeholder="통화"
+ className="w-16"
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+
+ {/* 합계 행 */}
+ {localItems.length > 0 && (
+ <div className="mt-4">
+ <Table>
+ <TableBody>
+ <TableRow className="bg-muted/50 font-semibold">
+ <TableCell colSpan={5} className="text-center">
+ 합계
+ </TableCell>
+ <TableCell className="text-right">
+ {totalQuantity.toLocaleString()}
+ </TableCell>
+ <TableCell>
+ {localItems[0]?.quantityUnit || '-'}
+ </TableCell>
+ <TableCell></TableCell>
+ <TableCell className="text-right">
+ {totalUnitPrice.toLocaleString()}
+ </TableCell>
+ <TableCell className="text-right font-bold text-primary">
+ {formatCurrency(totalAmount)}
+ </TableCell>
+ <TableCell>
+ {currency}
+ </TableCell>
+ </TableRow>
+ </TableBody>
+ </Table>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ </AccordionContent>
+ </AccordionItem>
+ </Accordion>
+ )
+}