summaryrefslogtreecommitdiff
path: root/components/bidding/manage/bidding-items-editor.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'components/bidding/manage/bidding-items-editor.tsx')
-rw-r--r--components/bidding/manage/bidding-items-editor.tsx1143
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>
+ )
+}