diff options
| author | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2025-11-10 11:25:19 +0900 |
|---|---|---|
| committer | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2025-11-10 11:25:19 +0900 |
| commit | a5501ad1d1cb836d2b2f84e9b0f06049e22c901e (patch) | |
| tree | 667ed8c5d6ec35b109190e9f976d66ae54def4ce /components/bidding/manage/bidding-items-editor.tsx | |
| parent | b0fe980376fcf1a19ff4b90851ca8b01f378fdc0 (diff) | |
| parent | f8a38907911d940cb2e8e6c9aa49488d05b2b578 (diff) | |
Merge remote-tracking branch 'origin/dujinkim' into master_homemaster
Diffstat (limited to 'components/bidding/manage/bidding-items-editor.tsx')
| -rw-r--r-- | components/bidding/manage/bidding-items-editor.tsx | 1143 |
1 files changed, 1143 insertions, 0 deletions
diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx new file mode 100644 index 00000000..96a8d2ae --- /dev/null +++ b/components/bidding/manage/bidding-items-editor.tsx @@ -0,0 +1,1143 @@ +'use client' + +import * as React from 'react' +import { Package, Plus, Trash2, Save, RefreshCw, FileText } from 'lucide-react' +import { getPRItemsForBidding } from '@/lib/bidding/detail/service' +import { updatePrItem } from '@/lib/bidding/detail/service' +import { toast } from 'sonner' +import { useSession } from 'next-auth/react' + +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Checkbox } from '@/components/ui/checkbox' +import { ProjectSelector } from '@/components/ProjectSelector' +import { MaterialGroupSelectorDialogSingle } from '@/components/common/material/material-group-selector-dialog-single' +import { MaterialSelectorDialogSingle } from '@/components/common/selectors/material/material-selector-dialog-single' +import { WbsCodeSingleSelector } from '@/components/common/selectors/wbs-code/wbs-code-single-selector' +import { CostCenterSingleSelector } from '@/components/common/selectors/cost-center/cost-center-single-selector' +import { GlAccountSingleSelector } from '@/components/common/selectors/gl-account/gl-account-single-selector' + +// PR 아이템 정보 타입 (create-bidding-dialog와 동일) +interface PRItemInfo { + id: number // 실제 DB ID + prNumber?: string | null + projectId?: number | null + projectInfo?: string | null + shi?: string | null + quantity?: string | null + quantityUnit?: string | null + totalWeight?: string | null + weightUnit?: string | null + materialDescription?: string | null + hasSpecDocument?: boolean + requestedDeliveryDate?: string | null + isRepresentative?: boolean // 대표 아이템 여부 + // 가격 정보 + annualUnitPrice?: string | null + currency?: string | null + // 자재 그룹 정보 (필수) + materialGroupNumber?: string | null + materialGroupInfo?: string | null + // 자재 정보 + materialNumber?: string | null + materialInfo?: string | null + // 단위 정보 + priceUnit?: string | null + purchaseUnit?: string | null + materialWeight?: string | null + // WBS 정보 + wbsCode?: string | null + wbsName?: string | null + // Cost Center 정보 + costCenterCode?: string | null + costCenterName?: string | null + // GL Account 정보 + glAccountCode?: string | null + glAccountName?: string | null + // 내정 정보 + targetUnitPrice?: string | null + targetAmount?: string | null + targetCurrency?: string | null + // 예산 정보 + budgetAmount?: string | null + budgetCurrency?: string | null + // 실적 정보 + actualAmount?: string | null + actualCurrency?: string | null +} + +interface BiddingItemsEditorProps { + biddingId: number +} + +import { removeBiddingItem, addPRItemForBidding, getBiddingById, getBiddingConditions } from '@/lib/bidding/service' +import { CreatePreQuoteRfqDialog } from './create-pre-quote-rfq-dialog' +import { Textarea } from '@/components/ui/textarea' +import { Label } from '@/components/ui/label' + +export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) { + const { data: session } = useSession() + const [items, setItems] = React.useState<PRItemInfo[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [quantityWeightMode, setQuantityWeightMode] = React.useState<'quantity' | 'weight'>('quantity') + const [costCenterDialogOpen, setCostCenterDialogOpen] = React.useState(false) + const [selectedItemForCostCenter, setSelectedItemForCostCenter] = React.useState<number | null>(null) + const [glAccountDialogOpen, setGlAccountDialogOpen] = React.useState(false) + const [selectedItemForGlAccount, setSelectedItemForGlAccount] = React.useState<number | null>(null) + const [wbsCodeDialogOpen, setWbsCodeDialogOpen] = React.useState(false) + const [selectedItemForWbs, setSelectedItemForWbs] = React.useState<number | null>(null) + const [tempIdCounter, setTempIdCounter] = React.useState(0) // 임시 ID 카운터 + const [deletedItemIds, setDeletedItemIds] = React.useState<Set<number>>(new Set()) // 삭제된 아이템 ID 추적 + const [preQuoteDialogOpen, setPreQuoteDialogOpen] = React.useState(false) + const [targetPriceCalculationCriteria, setTargetPriceCalculationCriteria] = React.useState('') + const [biddingPicUserId, setBiddingPicUserId] = React.useState<number | null>(null) + const [biddingConditions, setBiddingConditions] = React.useState<{ + paymentTerms?: string | null + taxConditions?: string | null + incoterms?: string | null + incotermsOption?: string | null + contractDeliveryDate?: string | null + shippingPort?: string | null + destinationPort?: string | null + isPriceAdjustmentApplicable?: boolean | null + sparePartOptions?: string | null + } | null>(null) + + // 초기 데이터 로딩 - 기존 품목이 있으면 자동으로 로드 + React.useEffect(() => { + const loadItems = async () => { + if (!biddingId) return + + setIsLoading(true) + try { + const prItems = await getPRItemsForBidding(biddingId) + + if (prItems && prItems.length > 0) { + const formattedItems: PRItemInfo[] = prItems.map((item) => ({ + id: item.id, + prNumber: item.prNumber || null, + projectId: item.projectId || null, + projectInfo: item.projectInfo || null, + shi: item.shi || null, + quantity: item.quantity ? item.quantity.toString() : null, + quantityUnit: item.quantityUnit || null, + totalWeight: item.totalWeight ? item.totalWeight.toString() : null, + weightUnit: item.weightUnit || null, + materialDescription: item.itemInfo || null, + hasSpecDocument: item.hasSpecDocument || false, + requestedDeliveryDate: item.requestedDeliveryDate ? new Date(item.requestedDeliveryDate).toISOString().split('T')[0] : null, + isRepresentative: false, // 첫 번째 아이템을 대표로 설정할 수 있음 + annualUnitPrice: item.annualUnitPrice ? item.annualUnitPrice.toString() : null, + currency: item.currency || 'KRW', + materialGroupNumber: item.materialGroupNumber || null, + materialGroupInfo: item.materialGroupInfo || null, + materialNumber: item.materialNumber || null, + materialInfo: item.materialInfo || null, + priceUnit: item.priceUnit || null, + purchaseUnit: item.purchaseUnit || null, + materialWeight: item.materialWeight ? item.materialWeight.toString() : null, + wbsCode: item.wbsCode || null, + wbsName: item.wbsName || null, + costCenterCode: item.costCenterCode || null, + costCenterName: item.costCenterName || null, + glAccountCode: item.glAccountCode || null, + glAccountName: item.glAccountName || null, + targetUnitPrice: item.targetUnitPrice ? item.targetUnitPrice.toString() : null, + targetAmount: item.targetAmount ? item.targetAmount.toString() : null, + targetCurrency: item.targetCurrency || 'KRW', + budgetAmount: item.budgetAmount ? item.budgetAmount.toString() : null, + budgetCurrency: item.budgetCurrency || 'KRW', + actualAmount: item.actualAmount ? item.actualAmount.toString() : null, + actualCurrency: item.actualCurrency || 'KRW', + })) + + // 첫 번째 아이템을 대표로 설정 + formattedItems[0].isRepresentative = true + + setItems(formattedItems) + setDeletedItemIds(new Set()) // 삭제 목록 초기화 + + // 기존 품목 로드 성공 알림 (조용히 표시, 선택적) + console.log(`기존 품목 ${formattedItems.length}개를 불러왔습니다.`) + } else { + // 품목이 없을 때는 빈 배열로 초기화 + setItems([]) + setDeletedItemIds(new Set()) + } + } catch (error) { + console.error('Failed to load items:', error) + toast.error('품목 정보를 불러오는데 실패했습니다.') + // 에러 발생 시에도 빈 배열로 초기화하여 UI가 깨지지 않도록 + setItems([]) + setDeletedItemIds(new Set()) + } finally { + setIsLoading(false) + } + } + + loadItems() + }, [biddingId]) + + // 입찰 정보 및 조건 로드 (사전견적 다이얼로그용) + React.useEffect(() => { + const loadBiddingInfo = async () => { + if (!biddingId) return + + try { + const [bidding, conditions] = await Promise.all([ + getBiddingById(biddingId), + getBiddingConditions(biddingId) + ]) + + if (bidding) { + setBiddingPicUserId(bidding.bidPicId || null) + setTargetPriceCalculationCriteria(bidding.targetPriceCalculationCriteria || '') + } + + if (conditions) { + setBiddingConditions(conditions) + } + } catch (error) { + console.error('Failed to load bidding info:', error) + } + } + + loadBiddingInfo() + }, [biddingId]) + + const handleSave = async () => { + setIsSubmitting(true) + try { + const userId = session?.user?.id?.toString() || '1' + let hasError = false + + // 모든 아이템을 upsert 처리 (id가 있으면 update, 없으면 insert) + for (const item of items) { + const targetAmount = calculateTargetAmount(item) + + let result + if (item.id > 0) { + // 기존 아이템 업데이트 + result = await updatePrItem(item.id, { + projectId: item.projectId || null, + projectInfo: item.projectInfo || null, + shi: item.shi || null, + materialGroupNumber: item.materialGroupNumber || null, + materialGroupInfo: item.materialGroupInfo || null, + materialNumber: item.materialNumber || null, + materialInfo: item.materialInfo || null, + quantity: item.quantity ? parseFloat(item.quantity) : null, + quantityUnit: item.quantityUnit || null, + totalWeight: item.totalWeight ? parseFloat(item.totalWeight) : null, + weightUnit: item.weightUnit || null, + priceUnit: item.priceUnit || null, + purchaseUnit: item.purchaseUnit || null, + materialWeight: item.materialWeight ? parseFloat(item.materialWeight) : null, + wbsCode: item.wbsCode || null, + wbsName: item.wbsName || null, + costCenterCode: item.costCenterCode || null, + costCenterName: item.costCenterName || null, + glAccountCode: item.glAccountCode || null, + glAccountName: item.glAccountName || null, + targetUnitPrice: item.targetUnitPrice ? parseFloat(item.targetUnitPrice) : null, + targetAmount: targetAmount ? parseFloat(targetAmount) : null, + targetCurrency: item.targetCurrency || 'KRW', + budgetAmount: item.budgetAmount ? parseFloat(item.budgetAmount) : null, + budgetCurrency: item.budgetCurrency || 'KRW', + actualAmount: item.actualAmount ? parseFloat(item.actualAmount) : null, + actualCurrency: item.actualCurrency || 'KRW', + requestedDeliveryDate: item.requestedDeliveryDate ? new Date(item.requestedDeliveryDate) : null, + currency: item.currency || 'KRW', + annualUnitPrice: item.annualUnitPrice ? parseFloat(item.annualUnitPrice) : null, + prNumber: item.prNumber || null, + hasSpecDocument: item.hasSpecDocument || false, + } as Parameters<typeof updatePrItem>[1], userId) + } else { + // 새 아이템 추가 (문자열 타입만 허용) + result = await addPRItemForBidding(biddingId, { + projectId: item.projectId ?? undefined, + projectInfo: item.projectInfo ?? null, + shi: item.shi ?? null, + materialGroupNumber: item.materialGroupNumber ?? null, + materialGroupInfo: item.materialGroupInfo ?? null, + materialNumber: item.materialNumber ?? null, + materialInfo: item.materialInfo ?? null, + quantity: item.quantity ?? null, + quantityUnit: item.quantityUnit ?? null, + totalWeight: item.totalWeight ?? null, + weightUnit: item.weightUnit ?? null, + priceUnit: item.priceUnit ?? null, + purchaseUnit: item.purchaseUnit ?? null, + materialWeight: item.materialWeight ?? null, + wbsCode: item.wbsCode ?? null, + wbsName: item.wbsName ?? null, + costCenterCode: item.costCenterCode ?? null, + costCenterName: item.costCenterName ?? null, + glAccountCode: item.glAccountCode ?? null, + glAccountName: item.glAccountName ?? null, + targetUnitPrice: item.targetUnitPrice ?? null, + targetAmount: targetAmount ?? null, + targetCurrency: item.targetCurrency || 'KRW', + budgetAmount: item.budgetAmount ?? null, + budgetCurrency: item.budgetCurrency || 'KRW', + actualAmount: item.actualAmount ?? null, + actualCurrency: item.actualCurrency || 'KRW', + requestedDeliveryDate: item.requestedDeliveryDate ?? null, + currency: item.currency || 'KRW', + annualUnitPrice: item.annualUnitPrice ?? null, + prNumber: item.prNumber ?? null, + hasSpecDocument: item.hasSpecDocument || false, + }) + } + + if (!result.success) { + hasError = true + } + } + + // 삭제된 아이템들 서버에서 삭제 + for (const deletedId of deletedItemIds) { + const result = await removeBiddingItem(deletedId) + if (!result.success) { + hasError = true + } + } + + + if (hasError) { + toast.error('일부 품목 정보 저장에 실패했습니다.') + } else { + // 내정가 산정 기준 별도 저장 (서버 액션으로 처리) + if (targetPriceCalculationCriteria.trim()) { + try { + const { updateTargetPriceCalculationCriteria } = await import('@/lib/bidding/service') + const criteriaResult = await updateTargetPriceCalculationCriteria(biddingId, targetPriceCalculationCriteria.trim(), userId) + if (!criteriaResult.success) { + console.warn('Failed to save target price calculation criteria:', criteriaResult.error) + } + } catch (error) { + console.error('Failed to save target price calculation criteria:', error) + } + } + + toast.success('품목 정보가 성공적으로 저장되었습니다.') + // 삭제 목록 초기화 + setDeletedItemIds(new Set()) + // 데이터 다시 로딩하여 최신 상태 반영 + const prItems = await getPRItemsForBidding(biddingId) + const formattedItems: PRItemInfo[] = prItems.map((item) => ({ + id: item.id, + prNumber: item.prNumber || null, + projectId: item.projectId || null, + projectInfo: item.projectInfo || null, + shi: item.shi || null, + quantity: item.quantity ? item.quantity.toString() : null, + quantityUnit: item.quantityUnit || null, + totalWeight: item.totalWeight ? item.totalWeight.toString() : null, + weightUnit: item.weightUnit || null, + materialDescription: item.itemInfo || null, + hasSpecDocument: item.hasSpecDocument || false, + requestedDeliveryDate: item.requestedDeliveryDate ? new Date(item.requestedDeliveryDate).toISOString().split('T')[0] : null, + isRepresentative: false, + annualUnitPrice: item.annualUnitPrice ? item.annualUnitPrice.toString() : null, + currency: item.currency || 'KRW', + materialGroupNumber: item.materialGroupNumber || null, + materialGroupInfo: item.materialGroupInfo || null, + materialNumber: item.materialNumber || null, + materialInfo: item.materialInfo || null, + priceUnit: item.priceUnit || null, + purchaseUnit: item.purchaseUnit || null, + materialWeight: item.materialWeight ? item.materialWeight.toString() : null, + wbsCode: item.wbsCode || null, + wbsName: item.wbsName || null, + costCenterCode: item.costCenterCode || null, + costCenterName: item.costCenterName || null, + glAccountCode: item.glAccountCode || null, + glAccountName: item.glAccountName || null, + targetUnitPrice: item.targetUnitPrice ? item.targetUnitPrice.toString() : null, + targetAmount: item.targetAmount ? item.targetAmount.toString() : null, + targetCurrency: item.targetCurrency || 'KRW', + budgetAmount: item.budgetAmount ? item.budgetAmount.toString() : null, + budgetCurrency: item.budgetCurrency || 'KRW', + actualAmount: item.actualAmount ? item.actualAmount.toString() : null, + actualCurrency: item.actualCurrency || 'KRW', + })) + + // 첫 번째 아이템을 대표로 설정 + if (formattedItems.length > 0) { + formattedItems[0].isRepresentative = true + } + + setItems(formattedItems) + } + } catch (error) { + console.error('Failed to save items:', error) + toast.error('품목 정보 저장에 실패했습니다.') + } finally { + setIsSubmitting(false) + } + } + + const handleAddItem = () => { + // 임시 ID 생성 (음수로 구분하여 실제 DB ID와 구분) + const tempId = -(tempIdCounter + 1) + setTempIdCounter(prev => prev + 1) + + // 즉시 UI에 새 아이템 추가 (서버 저장 없음) + const newItem: PRItemInfo = { + id: tempId, // 임시 ID + prNumber: null, + projectId: null, + projectInfo: null, + shi: null, + quantity: null, + quantityUnit: 'EA', + totalWeight: null, + weightUnit: 'KG', + materialDescription: null, + hasSpecDocument: false, + requestedDeliveryDate: null, + isRepresentative: items.length === 0, + annualUnitPrice: null, + currency: 'KRW', + materialGroupNumber: null, + materialGroupInfo: null, + materialNumber: null, + materialInfo: null, + priceUnit: null, + purchaseUnit: '1', + materialWeight: null, + wbsCode: null, + wbsName: null, + costCenterCode: null, + costCenterName: null, + glAccountCode: null, + glAccountName: null, + targetUnitPrice: null, + targetAmount: null, + targetCurrency: 'KRW', + budgetAmount: null, + budgetCurrency: 'KRW', + actualAmount: null, + actualCurrency: 'KRW', + } + + setItems((prev) => { + // 첫 번째 아이템이면 대표로 설정 + if (prev.length === 0) { + return [newItem] + } + return [...prev, newItem] + }) + } + + const handleRemoveItem = (itemId: number) => { + if (items.length <= 1) { + toast.error('최소 하나의 품목이 필요합니다.') + return + } + + // 실제 아이템인 경우 삭제 목록에 추가 (저장 시 서버에서 삭제됨) + if (itemId > 0) { + setDeletedItemIds(prev => new Set([...prev, itemId])) + } + + // UI에서 즉시 제거 + setItems((prev) => { + const filteredItems = prev.filter((item) => item.id !== itemId) + const removedItem = prev.find((item) => item.id === itemId) + if (removedItem?.isRepresentative && filteredItems.length > 0) { + filteredItems[0].isRepresentative = true + } + return filteredItems + }) + } + + const updatePRItem = (id: number, updates: Partial<PRItemInfo>) => { + setItems((prev) => + prev.map((item) => { + if (item.id === id) { + const updatedItem = { ...item, ...updates } + // 내정단가, 수량, 중량, 구매단위가 변경되면 내정금액 재계산 + if (updates.targetUnitPrice || updates.quantity || updates.totalWeight || updates.purchaseUnit) { + updatedItem.targetAmount = calculateTargetAmount(updatedItem) + } + return updatedItem + } + return item + }) + ) + } + + const setRepresentativeItem = (id: number) => { + setItems((prev) => + prev.map((item) => ({ + ...item, + isRepresentative: item.id === id, + })) + ) + } + + const handleQuantityWeightModeChange = (mode: 'quantity' | 'weight') => { + setQuantityWeightMode(mode) + } + + const calculateTargetAmount = (item: PRItemInfo) => { + const unitPrice = parseFloat(item.targetUnitPrice || '0') || 0 + const purchaseUnit = parseFloat(item.purchaseUnit || '1') || 1 + let amount = 0 + + if (quantityWeightMode === 'quantity') { + const quantity = parseFloat(item.quantity || '0') || 0 + amount = (quantity / purchaseUnit) * unitPrice + } else { + const weight = parseFloat(item.totalWeight || '0') || 0 + amount = (weight / purchaseUnit) * unitPrice + } + + return Math.floor(amount).toString() + } + + if (isLoading) { + return ( + <div className="flex items-center justify-center p-8"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div> + <span className="ml-2">품목 정보를 불러오는 중...</span> + </div> + ) + } + + // PR 아이템 테이블 렌더링 (create-bidding-dialog와 동일한 구조) + const renderPrItemsTable = () => { + return ( + <div className="border rounded-lg overflow-hidden"> + <div className="overflow-x-auto"> + <table className="w-full border-collapse"> + <thead className="bg-muted/50"> + <tr> + <th className="sticky left-0 z-10 bg-muted/50 border-r px-2 py-3 text-left text-xs font-medium min-w-[50px]"> + <span className="sr-only">대표</span> + </th> + <th className="sticky left-[50px] z-10 bg-muted/50 border-r px-3 py-3 text-left text-xs font-medium min-w-[40px]"> + # + </th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">프로젝트코드</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">프로젝트명</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재그룹코드 <span className="text-red-500">*</span></th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재그룹명 <span className="text-red-500">*</span></th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재코드</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재명</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">수량</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">단위</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">구매단위</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정단가</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정금액</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">내정통화</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">예산금액</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">예산통화</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">실적금액</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">실적통화</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">WBS코드</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">WBS명</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">코스트센터코드</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">코스트센터명</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">GL계정코드</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">GL계정명</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">납품요청일</th> + <th className="sticky right-0 z-10 bg-muted/50 border-l px-3 py-3 text-center text-xs font-medium min-w-[100px]"> + 액션 + </th> + </tr> + </thead> + <tbody> + {items.map((item, index) => ( + <tr key={item.id} className="border-t hover:bg-muted/30"> + <td className="sticky left-0 z-10 bg-background border-r px-2 py-2 text-center"> + <Checkbox + checked={item.isRepresentative} + onCheckedChange={() => setRepresentativeItem(item.id)} + disabled={items.length <= 1 && item.isRepresentative} + title="대표 아이템" + /> + </td> + <td className="sticky left-[50px] z-10 bg-background border-r px-3 py-2 text-xs text-muted-foreground"> + {index + 1} + </td> + <td className="border-r px-3 py-2"> + <ProjectSelector + selectedProjectId={item.projectId || null} + onProjectSelect={(project) => { + updatePRItem(item.id, { + projectId: project.id, + projectInfo: project.projectName + }) + }} + placeholder="프로젝트 선택" + /> + </td> + <td className="border-r px-3 py-2"> + <Input + placeholder="프로젝트명" + value={item.projectInfo || ''} + readOnly + className="h-8 text-xs bg-muted/50" + /> + </td> + <td className="border-r px-3 py-2"> + <MaterialGroupSelectorDialogSingle + triggerLabel={item.materialGroupNumber || "자재그룹 선택"} + triggerVariant="outline" + selectedMaterial={item.materialGroupNumber ? { + materialGroupCode: item.materialGroupNumber, + materialGroupDescription: item.materialGroupInfo || '', + displayText: `${item.materialGroupNumber} - ${item.materialGroupInfo || ''}` + } : null} + onMaterialSelect={(material) => { + if (material) { + updatePRItem(item.id, { + materialGroupNumber: material.materialGroupCode, + materialGroupInfo: material.materialGroupDescription + }) + } else { + updatePRItem(item.id, { + materialGroupNumber: '', + materialGroupInfo: '' + }) + } + }} + title="자재그룹 선택" + description="자재그룹을 검색하고 선택해주세요." + /> + </td> + <td className="border-r px-3 py-2"> + <Input + placeholder="자재그룹명" + value={item.materialGroupInfo || ''} + readOnly + className="h-8 text-xs bg-muted/50" + /> + </td> + <td className="border-r px-3 py-2"> + <MaterialSelectorDialogSingle + triggerLabel={item.materialNumber || "자재 선택"} + triggerVariant="outline" + selectedMaterial={item.materialNumber ? { + materialCode: item.materialNumber, + materialName: item.materialInfo || '', + displayText: `${item.materialNumber} - ${item.materialInfo || ''}` + } : null} + onMaterialSelect={(material) => { + if (material) { + updatePRItem(item.id, { + materialNumber: material.materialCode, + materialInfo: material.materialName + }) + } else { + updatePRItem(item.id, { + materialNumber: '', + materialInfo: '' + }) + } + }} + title="자재 선택" + description="자재를 검색하고 선택해주세요." + /> + </td> + <td className="border-r px-3 py-2"> + <Input + placeholder="자재명" + value={item.materialInfo || ''} + readOnly + className="h-8 text-xs bg-muted/50" + /> + </td> + <td className="border-r px-3 py-2"> + {quantityWeightMode === 'quantity' ? ( + <Input + type="number" + min="0" + placeholder="수량" + value={item.quantity || ''} + onChange={(e) => updatePRItem(item.id, { quantity: e.target.value })} + className="h-8 text-xs" + /> + ) : ( + <Input + type="number" + min="0" + placeholder="중량" + value={item.totalWeight || ''} + onChange={(e) => updatePRItem(item.id, { totalWeight: e.target.value })} + className="h-8 text-xs" + /> + )} + </td> + <td className="border-r px-3 py-2"> + {quantityWeightMode === 'quantity' ? ( + <Select + value={item.quantityUnit || 'EA'} + onValueChange={(value) => updatePRItem(item.id, { quantityUnit: value })} + > + <SelectTrigger className="h-8 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="EA">EA</SelectItem> + <SelectItem value="SET">SET</SelectItem> + <SelectItem value="LOT">LOT</SelectItem> + <SelectItem value="M">M</SelectItem> + <SelectItem value="M2">M²</SelectItem> + <SelectItem value="M3">M³</SelectItem> + </SelectContent> + </Select> + ) : ( + <Select + value={item.weightUnit || 'KG'} + onValueChange={(value) => updatePRItem(item.id, { weightUnit: value })} + > + <SelectTrigger className="h-8 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="KG">KG</SelectItem> + <SelectItem value="TON">TON</SelectItem> + <SelectItem value="G">G</SelectItem> + <SelectItem value="LB">LB</SelectItem> + </SelectContent> + </Select> + )} + </td> + <td className="border-r px-3 py-2"> + <Input + type="number" + min="1" + step="1" + placeholder="구매단위" + value={item.purchaseUnit || ''} + onChange={(e) => updatePRItem(item.id, { purchaseUnit: e.target.value })} + className="h-8 text-xs" + /> + </td> + <td className="border-r px-3 py-2"> + <Input + type="number" + min="0" + step="1" + placeholder="내정단가" + value={item.targetUnitPrice || ''} + onChange={(e) => updatePRItem(item.id, { targetUnitPrice: e.target.value })} + className="h-8 text-xs" + /> + </td> + <td className="border-r px-3 py-2"> + <Input + type="number" + min="0" + step="1" + placeholder="내정금액" + readOnly + value={item.targetAmount || ''} + className="h-8 text-xs bg-muted/50" + /> + </td> + <td className="border-r px-3 py-2"> + <Select + value={item.targetCurrency || 'KRW'} + onValueChange={(value) => updatePRItem(item.id, { targetCurrency: value })} + > + <SelectTrigger className="h-8 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="KRW">KRW</SelectItem> + <SelectItem value="USD">USD</SelectItem> + <SelectItem value="EUR">EUR</SelectItem> + <SelectItem value="JPY">JPY</SelectItem> + </SelectContent> + </Select> + </td> + <td className="border-r px-3 py-2"> + <Input + type="number" + min="0" + step="1" + placeholder="예산금액" + value={item.budgetAmount || ''} + onChange={(e) => updatePRItem(item.id, { budgetAmount: e.target.value })} + className="h-8 text-xs" + /> + </td> + <td className="border-r px-3 py-2"> + <Select + value={item.budgetCurrency || 'KRW'} + onValueChange={(value) => updatePRItem(item.id, { budgetCurrency: value })} + > + <SelectTrigger className="h-8 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="KRW">KRW</SelectItem> + <SelectItem value="USD">USD</SelectItem> + <SelectItem value="EUR">EUR</SelectItem> + <SelectItem value="JPY">JPY</SelectItem> + </SelectContent> + </Select> + </td> + <td className="border-r px-3 py-2"> + <Input + type="number" + min="0" + step="1" + placeholder="실적금액" + value={item.actualAmount || ''} + onChange={(e) => updatePRItem(item.id, { actualAmount: e.target.value })} + className="h-8 text-xs" + /> + </td> + <td className="border-r px-3 py-2"> + <Select + value={item.actualCurrency || 'KRW'} + onValueChange={(value) => updatePRItem(item.id, { actualCurrency: value })} + > + <SelectTrigger className="h-8 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="KRW">KRW</SelectItem> + <SelectItem value="USD">USD</SelectItem> + <SelectItem value="EUR">EUR</SelectItem> + <SelectItem value="JPY">JPY</SelectItem> + </SelectContent> + </Select> + </td> + <td className="border-r px-3 py-2"> + <Button + variant="outline" + onClick={() => { + setSelectedItemForWbs(item.id) + setWbsCodeDialogOpen(true) + }} + className="w-full justify-start h-8 text-xs" + > + {item.wbsCode ? ( + <span className="truncate"> + {`${item.wbsCode}${item.wbsName ? ` - ${item.wbsName}` : ''}`} + </span> + ) : ( + <span className="text-muted-foreground">WBS 코드 선택</span> + )} + </Button> + <WbsCodeSingleSelector + open={wbsCodeDialogOpen && selectedItemForWbs === item.id} + onOpenChange={(open) => { + setWbsCodeDialogOpen(open) + if (!open) setSelectedItemForWbs(null) + }} + selectedCode={item.wbsCode ? { + PROJ_NO: '', + WBS_ELMT: item.wbsCode, + WBS_ELMT_NM: item.wbsName || '', + WBS_LVL: '' + } : undefined} + onCodeSelect={(wbsCode) => { + updatePRItem(item.id, { + wbsCode: wbsCode.WBS_ELMT, + wbsName: wbsCode.WBS_ELMT_NM + }) + setWbsCodeDialogOpen(false) + setSelectedItemForWbs(null) + }} + title="WBS 코드 선택" + description="WBS 코드를 선택하세요" + showConfirmButtons={false} + /> + </td> + <td className="border-r px-3 py-2"> + <Input + placeholder="WBS명" + value={item.wbsName || ''} + readOnly + className="h-8 text-xs bg-muted/50" + /> + </td> + <td className="border-r px-3 py-2"> + <Button + variant="outline" + onClick={() => { + setSelectedItemForCostCenter(item.id) + setCostCenterDialogOpen(true) + }} + className="w-full justify-start h-8 text-xs" + > + {item.costCenterCode ? ( + <span className="truncate"> + {`${item.costCenterCode}${item.costCenterName ? ` - ${item.costCenterName}` : ''}`} + </span> + ) : ( + <span className="text-muted-foreground">코스트센터 선택</span> + )} + </Button> + <CostCenterSingleSelector + open={costCenterDialogOpen && selectedItemForCostCenter === item.id} + onOpenChange={(open) => { + setCostCenterDialogOpen(open) + if (!open) setSelectedItemForCostCenter(null) + }} + selectedCode={item.costCenterCode ? { + KOSTL: item.costCenterCode, + KTEXT: '', + LTEXT: item.costCenterName || '', + DATAB: '', + DATBI: '' + } : undefined} + onCodeSelect={(costCenter) => { + updatePRItem(item.id, { + costCenterCode: costCenter.KOSTL, + costCenterName: costCenter.LTEXT + }) + setCostCenterDialogOpen(false) + setSelectedItemForCostCenter(null) + }} + title="코스트센터 선택" + description="코스트센터를 선택하세요" + showConfirmButtons={false} + /> + </td> + <td className="border-r px-3 py-2"> + <Input + placeholder="코스트센터명" + value={item.costCenterName || ''} + readOnly + className="h-8 text-xs bg-muted/50" + /> + </td> + <td className="border-r px-3 py-2"> + <Button + variant="outline" + onClick={() => { + setSelectedItemForGlAccount(item.id) + setGlAccountDialogOpen(true) + }} + className="w-full justify-start h-8 text-xs" + > + {item.glAccountCode ? ( + <span className="truncate"> + {`${item.glAccountCode}${item.glAccountName ? ` - ${item.glAccountName}` : ''}`} + </span> + ) : ( + <span className="text-muted-foreground">GL계정 선택</span> + )} + </Button> + <GlAccountSingleSelector + open={glAccountDialogOpen && selectedItemForGlAccount === item.id} + onOpenChange={(open) => { + setGlAccountDialogOpen(open) + if (!open) setSelectedItemForGlAccount(null) + }} + selectedCode={item.glAccountCode ? { + SAKNR: item.glAccountCode, + FIPEX: '', + TEXT1: item.glAccountName || '' + } : undefined} + onCodeSelect={(glAccount) => { + updatePRItem(item.id, { + glAccountCode: glAccount.SAKNR, + glAccountName: glAccount.TEXT1 + }) + setGlAccountDialogOpen(false) + setSelectedItemForGlAccount(null) + }} + title="GL 계정 선택" + description="GL 계정을 선택하세요" + showConfirmButtons={false} + /> + </td> + <td className="border-r px-3 py-2"> + <Input + placeholder="GL계정명" + value={item.glAccountName || ''} + readOnly + className="h-8 text-xs bg-muted/50" + /> + </td> + <td className="border-r px-3 py-2"> + <Input + type="date" + value={item.requestedDeliveryDate || ''} + onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })} + className="h-8 text-xs" + /> + </td> + <td className="sticky right-0 z-10 bg-background border-l px-3 py-2"> + <div className="flex items-center justify-center gap-1"> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => handleRemoveItem(item.id)} + disabled={items.length <= 1} + className="h-7 w-7 p-0" + title="품목 삭제" + > + <Trash2 className="h-3.5 w-3.5" /> + </Button> + </div> + </td> + </tr> + ))} + </tbody> + </table> + </div> + </div> + ) + } + + return ( + <div className="space-y-6"> + <Card> + <CardHeader className="flex flex-row items-center justify-between"> + <div> + <CardTitle className="flex items-center gap-2"> + <Package className="h-5 w-5" /> + 입찰 품목 목록 + </CardTitle> + <p className="text-sm text-muted-foreground mt-1"> + 입찰 대상 품목들을 관리합니다. 최소 하나의 아이템이 필요하며, 자재그룹코드는 필수입니다 + </p> + <p className="text-xs text-amber-600 mt-1"> + 수량/단위 또는 중량/중량단위를 선택해서 입력하세요 + </p> + </div> + <div className="flex gap-2"> + <Button onClick={() => setPreQuoteDialogOpen(true)} variant="outline" className="flex items-center gap-2"> + <FileText className="h-4 w-4" /> + 사전견적 + </Button> + <Button onClick={handleAddItem} className="flex items-center gap-2"> + <Plus className="h-4 w-4" /> + 품목 추가 + </Button> + </div> + </CardHeader> + <CardContent className="space-y-6"> + {/* 내정가 산정 기준 입력 폼 */} + <div className="space-y-2"> + <Label htmlFor="targetPriceCalculationCriteria">내정가 산정 기준 (선택)</Label> + <Textarea + id="targetPriceCalculationCriteria" + placeholder="내정가 산정 기준을 입력하세요" + value={targetPriceCalculationCriteria} + onChange={(e) => setTargetPriceCalculationCriteria(e.target.value)} + rows={3} + className="resize-none" + /> + <p className="text-xs text-muted-foreground"> + 내정가를 산정한 기준이나 방법을 입력하세요 + </p> + </div> + <div className="flex items-center space-x-4 p-4 bg-muted rounded-lg"> + <div className="text-sm font-medium">계산 기준:</div> + <div className="flex items-center space-x-2"> + <input + type="radio" + id="quantity-mode" + name="quantityWeightMode" + checked={quantityWeightMode === 'quantity'} + onChange={() => handleQuantityWeightModeChange('quantity')} + className="h-4 w-4" + /> + <label htmlFor="quantity-mode" className="text-sm">수량 기준</label> + </div> + <div className="flex items-center space-x-2"> + <input + type="radio" + id="weight-mode" + name="quantityWeightMode" + checked={quantityWeightMode === 'weight'} + onChange={() => handleQuantityWeightModeChange('weight')} + className="h-4 w-4" + /> + <label htmlFor="weight-mode" className="text-sm">중량 기준</label> + </div> + </div> + <div className="space-y-4"> + {items.length > 0 ? ( + renderPrItemsTable() + ) : ( + <div className="text-center py-12 border-2 border-dashed border-gray-300 rounded-lg"> + <Package className="h-12 w-12 text-gray-400 mx-auto mb-4" /> + <p className="text-gray-500 mb-2">아직 품목이 없습니다</p> + <p className="text-sm text-gray-400 mb-4"> + 품목을 추가하여 입찰 세부내역을 작성하세요 + </p> + <Button + type="button" + variant="outline" + onClick={handleAddItem} + className="flex items-center gap-2 mx-auto" + > + <Plus className="h-4 w-4" /> + 첫 번째 품목 추가 + </Button> + </div> + )} + </div> + </CardContent> + </Card> + + {/* 액션 버튼 */} + <div className="flex justify-end gap-4"> + <Button + onClick={handleSave} + disabled={isSubmitting} + className="min-w-[120px]" + > + {isSubmitting ? ( + <> + <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> + 저장 중... + </> + ) : ( + <> + <Save className="w-4 h-4 mr-2" /> + 저장 + </> + )} + </Button> + </div> + + {/* 사전견적용 일반견적 생성 다이얼로그 */} + <CreatePreQuoteRfqDialog + open={preQuoteDialogOpen} + onOpenChange={setPreQuoteDialogOpen} + biddingId={biddingId} + biddingItems={items.map(item => ({ + id: item.id, + materialGroupNumber: item.materialGroupNumber || undefined, + materialGroupInfo: item.materialGroupInfo || undefined, + materialNumber: item.materialNumber || undefined, + materialInfo: item.materialInfo || undefined, + quantity: item.quantity || undefined, + quantityUnit: item.quantityUnit || undefined, + totalWeight: item.totalWeight || undefined, + weightUnit: item.weightUnit || undefined, + }))} + picUserId={biddingPicUserId} + biddingConditions={biddingConditions} + onSuccess={() => { + toast.success('사전견적용 일반견적이 생성되었습니다') + }} + /> + + </div> + ) +} |
