diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-07 08:39:04 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-07 08:39:04 +0000 |
| commit | ba8cd44a0ed2c613a5f2cee06bfc9bd0f61f21c7 (patch) | |
| tree | 7fb626c184a1fa48b28bf83571dadca2306bd1b5 /components/bidding/manage | |
| parent | b0fe980376fcf1a19ff4b90851ca8b01f378fdc0 (diff) | |
(최겸) 입찰/견적 수정사항
Diffstat (limited to 'components/bidding/manage')
| -rw-r--r-- | components/bidding/manage/bidding-basic-info-editor.tsx | 1407 | ||||
| -rw-r--r-- | components/bidding/manage/bidding-companies-editor.tsx | 803 | ||||
| -rw-r--r-- | components/bidding/manage/bidding-detail-vendor-create-dialog.tsx | 437 | ||||
| -rw-r--r-- | components/bidding/manage/bidding-items-editor.tsx | 1143 | ||||
| -rw-r--r-- | components/bidding/manage/bidding-schedule-editor.tsx | 661 | ||||
| -rw-r--r-- | components/bidding/manage/create-pre-quote-rfq-dialog.tsx | 742 |
6 files changed, 5193 insertions, 0 deletions
diff --git a/components/bidding/manage/bidding-basic-info-editor.tsx b/components/bidding/manage/bidding-basic-info-editor.tsx new file mode 100644 index 00000000..d60c5d88 --- /dev/null +++ b/components/bidding/manage/bidding-basic-info-editor.tsx @@ -0,0 +1,1407 @@ +'use client' + +import * as React from 'react' +import { useForm } from 'react-hook-form' +import { ChevronRight, Upload, FileText, Eye, User, Building, Calendar, DollarSign } from 'lucide-react' +import { toast } from 'sonner' + +import { Button } from '@/components/ui/button' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Switch } from '@/components/ui/switch' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +// CreateBiddingInput 타입 정의가 없으므로 CreateBiddingSchema를 확장하여 사용합니다. +import { getBiddingById, updateBiddingBasicInfo, getBiddingConditions, getBiddingNotice, updateBiddingConditions } from '@/lib/bidding/service' +import { getBiddingNoticeTemplate } from '@/lib/bidding/service' +import { + getIncotermsForSelection, + getPaymentTermsForSelection, + getPlaceOfShippingForSelection, + getPlaceOfDestinationForSelection, +} from '@/lib/procurement-select/service' +import { TAX_CONDITIONS } from '@/lib/tax-conditions/types' +import { contractTypeLabels, biddingTypeLabels, awardCountLabels, biddingNoticeTypeLabels } from '@/db/schema' +import TiptapEditor from '@/components/qna/tiptap-editor' +import { PurchaseGroupCodeSelector } from '@/components/common/selectors/purchase-group-code' +import { ProcurementManagerSelector } from '@/components/common/selectors/procurement-manager' +import type { PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code/purchase-group-code-service' +import type { ProcurementManagerWithUser } from '@/components/common/selectors/procurement-manager/procurement-manager-service' +import { getBiddingDocuments, uploadBiddingDocument, deleteBiddingDocument } from '@/lib/bidding/detail/service' +import { downloadFile } from '@/lib/file-download' + +// 입찰 기본 정보 에디터 컴포넌트 +interface BiddingBasicInfo { + title?: string + description?: string + content?: string + noticeType?: string + contractType?: string + biddingType?: string + biddingTypeCustom?: string + awardCount?: string + budget?: string + finalBidPrice?: string + targetPrice?: string + prNumber?: string + contractStartDate?: string + contractEndDate?: string + submissionStartDate?: string + submissionEndDate?: string + evaluationDate?: string + hasSpecificationMeeting?: boolean + hasPrDocument?: boolean + currency?: string + purchasingOrganization?: string + bidPicName?: string + bidPicCode?: string + supplyPicName?: string + supplyPicCode?: string + requesterName?: string + remarks?: string +} + +interface BiddingBasicInfoEditorProps { + biddingId: number +} + +interface UploadedDocument { + id: number + biddingId: number + companyId: number | null + documentType: string + fileName: string + originalFileName: string + fileSize: number | null + filePath: string + title: string | null + description: string | null + uploadedAt: string + uploadedBy: string +} + +export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProps) { + const [isLoading, setIsLoading] = React.useState(true) + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [isLoadingTemplate, setIsLoadingTemplate] = React.useState(false) + const [noticeTemplate, setNoticeTemplate] = React.useState('') + + // 첨부파일 관련 상태 + const [shiAttachmentFiles, setShiAttachmentFiles] = React.useState<File[]>([]) + const [vendorAttachmentFiles, setVendorAttachmentFiles] = React.useState<File[]>([]) + const [existingDocuments, setExistingDocuments] = React.useState<UploadedDocument[]>([]) + const [isLoadingDocuments, setIsLoadingDocuments] = React.useState(false) + + // 담당자 selector 상태 + const [selectedBidPic, setSelectedBidPic] = React.useState<PurchaseGroupCodeWithUser | undefined>(undefined) + const [selectedSupplyPic, setSelectedSupplyPic] = React.useState<ProcurementManagerWithUser | undefined>(undefined) + + // 입찰 조건 관련 상태 + const [biddingConditions, setBiddingConditions] = React.useState({ + paymentTerms: '', + taxConditions: 'V1', + incoterms: 'DAP', + incotermsOption: '', + contractDeliveryDate: '', + shippingPort: '', + destinationPort: '', + isPriceAdjustmentApplicable: false, + sparePartOptions: '', + }) + + // Procurement 데이터 상태들 + const [paymentTermsOptions, setPaymentTermsOptions] = React.useState<Array<{ code: string; description: string }>>([]) + const [incotermsOptions, setIncotermsOptions] = React.useState<Array<{ code: string; description: string }>>([]) + const [shippingPlaces, setShippingPlaces] = React.useState<Array<{ code: string; description: string }>>([]) + const [destinationPlaces, setDestinationPlaces] = React.useState<Array<{ code: string; description: string }>>([]) + + const form = useForm<BiddingBasicInfo>({ + defaultValues: {} + }) + + + // 공고 템플릿 로드 - 현재 저장된 템플릿 우선 + const loadNoticeTemplate = React.useCallback(async (noticeType?: string) => { + setIsLoadingTemplate(true) + try { + // 먼저 현재 입찰에 저장된 템플릿이 있는지 확인 + const savedNotice = await getBiddingNotice(biddingId) + if (savedNotice && savedNotice.content) { + setNoticeTemplate(savedNotice.content) + const currentContent = form.getValues('content') + if (!currentContent || currentContent.trim() === '') { + form.setValue('content', savedNotice.content) + } + setIsLoadingTemplate(false) + return + } + + // 저장된 템플릿이 없으면 타입별 템플릿 로드 + if (noticeType) { + const template = await getBiddingNoticeTemplate(noticeType) + if (template) { + setNoticeTemplate(template.content || '') + const currentContent = form.getValues('content') + if (!currentContent || currentContent.trim() === '') { + form.setValue('content', template.content || '') + } + } else { + // 템플릿이 없으면 표준 템플릿 사용 + const defaultTemplate = await getBiddingNoticeTemplate('standard') + if (defaultTemplate) { + setNoticeTemplate(defaultTemplate.content) + const currentContent = form.getValues('content') + if (!currentContent || currentContent.trim() === '') { + form.setValue('content', defaultTemplate.content) + } + } + } + } + } catch (error) { + console.warn('Failed to load notice template:', error) + } finally { + setIsLoadingTemplate(false) + } + }, [biddingId, form]) + + // 데이터 로딩 + React.useEffect(() => { + const loadBiddingData = async () => { + setIsLoading(true) + try { + const bidding = await getBiddingById(biddingId) + if (bidding) { + // 타입 확장된 bidding 객체 + const biddingExtended = bidding as typeof bidding & { + content?: string | null + noticeType?: string | null + biddingTypeCustom?: string | null + awardCount?: string | null + requesterName?: string | null + } + + // 날짜를 문자열로 변환하는 헬퍼 + const formatDate = (date: unknown): string => { + if (!date) return '' + if (typeof date === 'string') return date.split('T')[0] + if (date instanceof Date) return date.toISOString().split('T')[0] + return '' + } + + const formatDateTime = (date: unknown): string => { + if (!date) return '' + if (typeof date === 'string') return date.slice(0, 16) + if (date instanceof Date) return date.toISOString().slice(0, 16) + return '' + } + + // 폼 데이터 설정 + form.reset({ + title: bidding.title || '', + description: bidding.description || '', + content: biddingExtended.content || '', + noticeType: biddingExtended.noticeType || '', + contractType: bidding.contractType || '', + biddingType: bidding.biddingType || '', + biddingTypeCustom: biddingExtended.biddingTypeCustom || '', + awardCount: biddingExtended.awardCount || (bidding.awardCount ? String(bidding.awardCount) : ''), + budget: bidding.budget ? bidding.budget.toString() : '', + finalBidPrice: bidding.finalBidPrice ? bidding.finalBidPrice.toString() : '', + targetPrice: bidding.targetPrice ? bidding.targetPrice.toString() : '', + prNumber: bidding.prNumber || '', + contractStartDate: formatDate(bidding.contractStartDate), + contractEndDate: formatDate(bidding.contractEndDate), + submissionStartDate: formatDateTime(bidding.submissionStartDate), + submissionEndDate: formatDateTime(bidding.submissionEndDate), + evaluationDate: formatDateTime(bidding.evaluationDate), + hasSpecificationMeeting: bidding.hasSpecificationMeeting || false, + hasPrDocument: bidding.hasPrDocument || false, + currency: bidding.currency || 'KRW', + purchasingOrganization: bidding.purchasingOrganization || '', + bidPicName: bidding.bidPicName || '', + bidPicCode: bidding.bidPicCode || '', + supplyPicName: bidding.supplyPicName || '', + supplyPicCode: bidding.supplyPicCode || '', + requesterName: biddingExtended.requesterName || '', + remarks: bidding.remarks || '', + }) + + // 입찰 조건 로드 + const conditions = await getBiddingConditions(biddingId) + if (conditions) { + setBiddingConditions({ + paymentTerms: conditions.paymentTerms || '', + taxConditions: conditions.taxConditions || 'V1', + incoterms: conditions.incoterms || 'DAP', + incotermsOption: conditions.incotermsOption || '', + contractDeliveryDate: conditions.contractDeliveryDate + ? new Date(conditions.contractDeliveryDate).toISOString().split('T')[0] + : '', + shippingPort: conditions.shippingPort || '', + destinationPort: conditions.destinationPort || '', + isPriceAdjustmentApplicable: conditions.isPriceAdjustmentApplicable || false, + sparePartOptions: conditions.sparePartOptions || '', + }) + } + + // Procurement 데이터 로드 + const [paymentTermsData, incotermsData, shippingData, destinationData] = await Promise.all([ + getPaymentTermsForSelection().catch(() => []), + getIncotermsForSelection().catch(() => []), + getPlaceOfShippingForSelection().catch(() => []), + getPlaceOfDestinationForSelection().catch(() => []), + ]) + setPaymentTermsOptions(paymentTermsData) + setIncotermsOptions(incotermsData) + setShippingPlaces(shippingData) + setDestinationPlaces(destinationData) + + // 공고 템플릿 로드 + await loadNoticeTemplate(biddingExtended.noticeType || undefined) + } else { + toast.error('입찰 정보를 찾을 수 없습니다.') + } + } catch (error) { + console.error('Error loading bidding data:', error) + toast.error('입찰 정보를 불러오는데 실패했습니다.') + } finally { + setIsLoading(false) + } + } + + loadBiddingData() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [biddingId, loadNoticeTemplate]) + + // 구매유형 변경 시 템플릿 자동 로드 + const noticeTypeValue = form.watch('noticeType') + React.useEffect(() => { + if (noticeTypeValue) { + loadNoticeTemplate(noticeTypeValue) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [noticeTypeValue]) + + // 기존 첨부파일 로드 + const loadExistingDocuments = async () => { + setIsLoadingDocuments(true) + try { + const docs = await getBiddingDocuments(biddingId) + const mappedDocs = docs.map((doc) => ({ + ...doc, + uploadedAt: doc.uploadedAt?.toString() || '', + uploadedBy: doc.uploadedBy || '' + })) + setExistingDocuments(mappedDocs) + } catch (error) { + console.error('Failed to load documents:', error) + toast.error('첨부파일 목록을 불러오는데 실패했습니다.') + } finally { + setIsLoadingDocuments(false) + } + } + + // 초기 로드 시 첨부파일도 함께 로드 + React.useEffect(() => { + if (biddingId) { + loadExistingDocuments() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [biddingId]) + + // SHI용 파일 첨부 핸들러 + const handleShiFileUpload = async (files: File[]) => { + try { + // 파일을 업로드하고 기존 문서 목록 갱신 + for (const file of files) { + const result = await uploadBiddingDocument( + biddingId, + file, + 'bid_attachment', + file.name, + 'SHI용 첨부파일', + '1' // TODO: 실제 사용자 ID 가져오기 + ) + if (result.success) { + toast.success(`${file.name} 업로드 완료`) + } + } + await loadExistingDocuments() + setShiAttachmentFiles([]) + } catch (error) { + console.error('Failed to upload SHI files:', error) + toast.error('파일 업로드에 실패했습니다.') + } + } + + const removeShiFile = (index: number) => { + setShiAttachmentFiles(prev => prev.filter((_, i) => i !== index)) + } + + // 협력업체용 파일 첨부 핸들러 + const handleVendorFileUpload = async (files: File[]) => { + try { + // 파일을 업로드하고 기존 문서 목록 갱신 + for (const file of files) { + const result = await uploadBiddingDocument( + biddingId, + file, + 'bid_attachment', + file.name, + '협력업체용 첨부파일', + '1' // TODO: 실제 사용자 ID 가져오기 + ) + if (result.success) { + toast.success(`${file.name} 업로드 완료`) + } + } + await loadExistingDocuments() + setVendorAttachmentFiles([]) + } catch (error) { + console.error('Failed to upload vendor files:', error) + toast.error('파일 업로드에 실패했습니다.') + } + } + + const removeVendorFile = (index: number) => { + setVendorAttachmentFiles(prev => prev.filter((_, i) => i !== index)) + } + + // 파일 삭제 + const handleDeleteDocument = async (documentId: number) => { + if (!confirm('이 파일을 삭제하시겠습니까?')) { + return + } + + try { + const result = await deleteBiddingDocument(documentId, biddingId, '1') // TODO: 실제 사용자 ID 가져오기 + if (result.success) { + toast.success('파일이 삭제되었습니다.') + await loadExistingDocuments() + } else { + toast.error(result.error || '파일 삭제에 실패했습니다.') + } + } catch (error) { + console.error('Failed to delete document:', error) + toast.error('파일 삭제에 실패했습니다.') + } + } + + // 파일 다운로드 + const handleDownloadDocument = async (document: UploadedDocument) => { + try { + await downloadFile(document.filePath, document.originalFileName, { + showToast: true + }) + } catch (error) { + console.error('Failed to download document:', error) + toast.error('파일 다운로드에 실패했습니다.') + } + } + + // 입찰담당자 선택 핸들러 + const handleBidPicSelect = (code: PurchaseGroupCodeWithUser) => { + setSelectedBidPic(code) + form.setValue('bidPicName', code.DISPLAY_NAME || '') + form.setValue('bidPicCode', code.PURCHASE_GROUP_CODE || '') + } + + // 조달담당자 선택 핸들러 + const handleSupplyPicSelect = (manager: ProcurementManagerWithUser) => { + setSelectedSupplyPic(manager) + form.setValue('supplyPicName', manager.DISPLAY_NAME || '') + form.setValue('supplyPicCode', manager.PROCUREMENT_MANAGER_CODE || '') + } + + // 파일 크기 포맷팅 + const formatFileSize = (bytes: number | null) => { + if (!bytes) return '-' + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + // 저장 처리 + const handleSave = async (data: BiddingBasicInfo) => { + setIsSubmitting(true) + try { + // 기본 정보 저장 + const result = await updateBiddingBasicInfo(biddingId, data, '1') // TODO: 실제 사용자 ID 가져오기 + + if (result.success) { + // 입찰 조건 저장 + const conditionsResult = await updateBiddingConditions(biddingId, { + paymentTerms: biddingConditions.paymentTerms, + taxConditions: biddingConditions.taxConditions, + incoterms: biddingConditions.incoterms, + incotermsOption: biddingConditions.incotermsOption, + contractDeliveryDate: biddingConditions.contractDeliveryDate || undefined, + shippingPort: biddingConditions.shippingPort, + destinationPort: biddingConditions.destinationPort, + isPriceAdjustmentApplicable: biddingConditions.isPriceAdjustmentApplicable, + sparePartOptions: biddingConditions.sparePartOptions, + }) + + if (conditionsResult.success) { + toast.success('입찰 기본 정보와 조건이 성공적으로 저장되었습니다.') + } else { + const errorMessage = 'error' in conditionsResult ? conditionsResult.error : '입찰 조건 저장에 실패했습니다.' + toast.error(errorMessage) + } + } else { + const errorMessage = 'error' in result ? result.error : '입찰 기본 정보 저장에 실패했습니다.' + toast.error(errorMessage) + } + } catch (error) { + console.error('Failed to save bidding basic info:', error) + toast.error('입찰 기본 정보 저장에 실패했습니다.') + } finally { + setIsSubmitting(false) + } + } + + 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> + ) + } + + return ( + <div className="space-y-6"> + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <FileText className="h-5 w-5" /> + 입찰 기본 정보 + </CardTitle> + <p className="text-sm text-muted-foreground"> + 입찰의 기본 정보를 수정할 수 있습니다. + </p> + </CardHeader> + <CardContent> + <Form {...form}> + <form onSubmit={form.handleSubmit(handleSave)} className="space-y-4"> + {/* 1행: 입찰명, PR번호, 입찰유형, 계약구분 */} + <div className="grid grid-cols-4 gap-4"> + <FormField control={form.control} name="title" render={({ field }) => ( + <FormItem> + <FormLabel>입찰명 <span className="text-red-500">*</span></FormLabel> + <FormControl> + <Input placeholder="입찰명을 입력하세요" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> + + <FormField control={form.control} name="prNumber" render={({ field }) => ( + <FormItem> + <FormLabel>PR 번호</FormLabel> + <FormControl> + <Input placeholder="PR 번호를 입력하세요" {...field} readOnly className="bg-muted" /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> + + <FormField control={form.control} name="biddingType" render={({ field }) => ( + <FormItem> + <FormLabel>입찰유형</FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="입찰유형 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {Object.entries(biddingTypeLabels).map(([value, label]) => ( + <SelectItem key={value} value={value}> + {label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} /> + + <FormField control={form.control} name="contractType" render={({ field }) => ( + <FormItem> + <FormLabel>계약구분</FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="계약구분 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {Object.entries(contractTypeLabels).map(([value, label]) => ( + <SelectItem key={value} value={value}> + {label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} /> + </div> + + {/* 기타 입찰유형 선택 시 직접입력 필드 */} + {form.watch('biddingType') === 'other' && ( + <div className="grid grid-cols-4 gap-4"> + <div></div> + <div></div> + <FormField control={form.control} name="biddingTypeCustom" render={({ field }) => ( + <FormItem> + <FormLabel>기타 입찰유형 <span className="text-red-500">*</span></FormLabel> + <FormControl> + <Input placeholder="직접 입력하세요" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> + <div></div> + </div> + )} + + {/* 2행: 예산, 실적가, 내정가, 낙찰수 */} + <div className="grid grid-cols-4 gap-4"> + <FormField control={form.control} name="budget" render={({ field }) => ( + <FormItem> + <FormLabel className="flex items-center gap-1"> + <DollarSign className="h-3 w-3" /> + 예산 + </FormLabel> + <FormControl> + <Input placeholder="예산 입력" {...field} readOnly className="bg-muted" /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> + + <FormField control={form.control} name="finalBidPrice" render={({ field }) => ( + <FormItem> + <FormLabel className="flex items-center gap-1"> + <DollarSign className="h-3 w-3" /> + 실적가 + </FormLabel> + <FormControl> + <Input placeholder="실적가" {...field} readOnly className="bg-muted" /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> + + <FormField control={form.control} name="targetPrice" render={({ field }) => ( + <FormItem> + <FormLabel className="flex items-center gap-1"> + <Eye className="h-3 w-3" /> + 내정가 + </FormLabel> + <FormControl> + <Input placeholder="내정가" {...field} readOnly className="bg-muted" /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> + + <FormField control={form.control} name="awardCount" render={({ field }) => ( + <FormItem> + <FormLabel>낙찰수</FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="낙찰수 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {Object.entries(awardCountLabels).map(([value, label]) => ( + <SelectItem key={value} value={value}> + {label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} /> + </div> + + {/* 3행: 입찰담당자, 조달담당자, 구매조직, 통화 */} + <div className="grid grid-cols-4 gap-4"> + <FormField control={form.control} name="bidPicName" render={({ field }) => ( + <FormItem> + <FormLabel className="flex items-center gap-1"> + <User className="h-3 w-3" /> + 입찰담당자 + </FormLabel> + <FormControl> + <PurchaseGroupCodeSelector + selectedCode={selectedBidPic} + onCodeSelect={(code) => { + handleBidPicSelect(code) + field.onChange(code.DISPLAY_NAME || '') + }} + placeholder="입찰담당자 선택" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> + + <FormField control={form.control} name="supplyPicName" render={({ field }) => ( + <FormItem> + <FormLabel className="flex items-center gap-1"> + <User className="h-3 w-3" /> + 조달담당자 + </FormLabel> + <FormControl> + <ProcurementManagerSelector + selectedManager={selectedSupplyPic} + onManagerSelect={(manager) => { + handleSupplyPicSelect(manager) + field.onChange(manager.DISPLAY_NAME || '') + }} + placeholder="조달담당자 선택" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> + + <FormField control={form.control} name="purchasingOrganization" render={({ field }) => ( + <FormItem> + <FormLabel className="flex items-center gap-1"> + <Building className="h-3 w-3" /> + 구매조직 + </FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="구매조직 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="조선">조선</SelectItem> + <SelectItem value="해양">해양</SelectItem> + <SelectItem value="기타">기타</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} /> + + <FormField control={form.control} name="currency" render={({ field }) => ( + <FormItem> + <FormLabel>통화</FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="통화 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="KRW">KRW (원)</SelectItem> + <SelectItem value="USD">USD (달러)</SelectItem> + <SelectItem value="EUR">EUR (유로)</SelectItem> + <SelectItem value="JPY">JPY (엔)</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} /> + </div> + + {/* 구매유형 필드 추가 */} + <div className="grid grid-cols-4 gap-4"> + <FormField control={form.control} name="noticeType" render={({ field }) => ( + <FormItem> + <FormLabel>구매유형 <span className="text-red-500">*</span></FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="구매유형 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {Object.entries(biddingNoticeTypeLabels).map(([value, label]) => ( + <SelectItem key={value} value={value}> + {label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} /> + <div></div> + <div></div> + <div></div> + </div> + + {/* 4행: 계약기간 시작/종료, 입찰서 제출 시작/마감 */} + <div className="grid grid-cols-4 gap-4"> + <FormField control={form.control} name="contractStartDate" render={({ field }) => ( + <FormItem> + <FormLabel className="flex items-center gap-1"> + <Calendar className="h-3 w-3" /> + 계약기간 시작 + </FormLabel> + <FormControl> + <Input type="date" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> + + <FormField control={form.control} name="contractEndDate" render={({ field }) => ( + <FormItem> + <FormLabel className="flex items-center gap-1"> + <Calendar className="h-3 w-3" /> + 계약기간 종료 + </FormLabel> + <FormControl> + <Input type="date" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> + + {/* <FormField control={form.control} name="submissionStartDate" render={({ field }) => ( + <FormItem> + <FormLabel>입찰서 제출 시작</FormLabel> + <FormControl> + <Input type="datetime-local" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> + + <FormField control={form.control} name="submissionEndDate" render={({ field }) => ( + <FormItem> + <FormLabel>입찰서 제출 마감</FormLabel> + <FormControl> + <Input type="datetime-local" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> */} + </div> + + {/* 5행: 개찰 일시, 사양설명회, PR문서 */} + {/* <div className="grid grid-cols-3 gap-4"> + <FormField control={form.control} name="evaluationDate" render={({ field }) => ( + <FormItem> + <FormLabel>개찰 일시</FormLabel> + <FormControl> + <Input type="datetime-local" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> */} + + {/* <FormField control={form.control} name="hasSpecificationMeeting" render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3"> + <div className="space-y-0.5"> + <FormLabel className="text-base">사양설명회</FormLabel> + <div className="text-sm text-muted-foreground"> + 사양설명회가 필요한 경우 체크 + </div> + </div> + <FormControl> + <Switch checked={field.value} onCheckedChange={field.onChange} /> + </FormControl> + </FormItem> + )} /> */} + + {/* <FormField control={form.control} name="hasPrDocument" render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3"> + <div className="space-y-0.5"> + <FormLabel className="text-base">PR 문서</FormLabel> + <div className="text-sm text-muted-foreground"> + PR 문서가 있는 경우 체크 + </div> + </div> + <FormControl> + <Switch checked={field.value} onCheckedChange={field.onChange} /> + </FormControl> + </FormItem> + )} /> */} + {/* </div> */} + + {/* 입찰개요 */} + <div className="pt-2"> + <FormField control={form.control} name="description" render={({ field }) => ( + <FormItem> + <FormLabel>입찰개요</FormLabel> + <FormControl> + <Textarea placeholder="입찰에 대한 설명을 입력하세요" rows={2} {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> + </div> + + {/* 비고 */} + <div className="pt-2"> + <FormField control={form.control} name="remarks" render={({ field }) => ( + <FormItem> + <FormLabel>비고</FormLabel> + <FormControl> + <Textarea placeholder="추가 사항이나 참고사항을 입력하세요" rows={3} {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> + </div> + + {/* 입찰 조건 */} + <div className="pt-4 border-t"> + <CardTitle className="text-lg mb-4">입찰 조건</CardTitle> + + {/* 1행: SHI 지급조건, SHI 매입부가가치세 */} + <div className="grid grid-cols-2 gap-4 mb-4"> + <div> + <FormLabel>SHI 지급조건 <span className="text-red-500">*</span></FormLabel> + <Select + value={biddingConditions.paymentTerms} + onValueChange={(value) => { + setBiddingConditions(prev => ({ + ...prev, + paymentTerms: value + })) + }} + > + <SelectTrigger> + <SelectValue placeholder="지급조건 선택" /> + </SelectTrigger> + <SelectContent> + {paymentTermsOptions.length > 0 ? ( + paymentTermsOptions.map((option) => ( + <SelectItem key={option.code} value={option.code}> + {option.code} {option.description && `(${option.description})`} + </SelectItem> + )) + ) : ( + <SelectItem value="loading" disabled> + 데이터를 불러오는 중... + </SelectItem> + )} + </SelectContent> + </Select> + </div> + + <div> + <FormLabel>SHI 매입부가가치세 <span className="text-red-500">*</span></FormLabel> + <Select + value={biddingConditions.taxConditions} + onValueChange={(value) => { + setBiddingConditions(prev => ({ + ...prev, + taxConditions: value + })) + }} + > + <SelectTrigger> + <SelectValue placeholder="세금조건 선택" /> + </SelectTrigger> + <SelectContent> + {TAX_CONDITIONS.map((condition) => ( + <SelectItem key={condition.code} value={condition.code}> + {condition.name} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + + {/* 2행: SHI 인도조건, SHI 인도조건2 */} + <div className="grid grid-cols-2 gap-4 mb-4"> + <div> + <FormLabel>SHI 인도조건 <span className="text-red-500">*</span></FormLabel> + <Select + value={biddingConditions.incoterms} + onValueChange={(value) => { + setBiddingConditions(prev => ({ + ...prev, + incoterms: value + })) + }} + > + <SelectTrigger> + <SelectValue placeholder="인코텀즈 선택" /> + </SelectTrigger> + <SelectContent> + {incotermsOptions.length > 0 ? ( + incotermsOptions.map((option) => ( + <SelectItem key={option.code} value={option.code}> + {option.code} {option.description && `(${option.description})`} + </SelectItem> + )) + ) : ( + <SelectItem value="loading" disabled> + 데이터를 불러오는 중... + </SelectItem> + )} + </SelectContent> + </Select> + </div> + + <div> + <FormLabel>SHI 인도조건2</FormLabel> + <Input + placeholder="인도조건 추가 정보" + value={biddingConditions.incotermsOption} + onChange={(e) => { + setBiddingConditions(prev => ({ + ...prev, + incotermsOption: e.target.value + })) + }} + /> + </div> + </div> + + {/* 3행: SHI 선적지, SHI 하역지 */} + <div className="grid grid-cols-2 gap-4 mb-4"> + <div> + <FormLabel>SHI 선적지</FormLabel> + <Select + value={biddingConditions.shippingPort} + onValueChange={(value) => { + setBiddingConditions(prev => ({ + ...prev, + shippingPort: value + })) + }} + > + <SelectTrigger> + <SelectValue placeholder="선적지 선택" /> + </SelectTrigger> + <SelectContent> + {shippingPlaces.length > 0 ? ( + shippingPlaces.map((place) => ( + <SelectItem key={place.code} value={place.code}> + {place.code} {place.description && `(${place.description})`} + </SelectItem> + )) + ) : ( + <SelectItem value="loading" disabled> + 데이터를 불러오는 중... + </SelectItem> + )} + </SelectContent> + </Select> + </div> + + <div> + <FormLabel>SHI 하역지</FormLabel> + <Select + value={biddingConditions.destinationPort} + onValueChange={(value) => { + setBiddingConditions(prev => ({ + ...prev, + destinationPort: value + })) + }} + > + <SelectTrigger> + <SelectValue placeholder="하역지 선택" /> + </SelectTrigger> + <SelectContent> + {destinationPlaces.length > 0 ? ( + destinationPlaces.map((place) => ( + <SelectItem key={place.code} value={place.code}> + {place.code} {place.description && `(${place.description})`} + </SelectItem> + )) + ) : ( + <SelectItem value="loading" disabled> + 데이터를 불러오는 중... + </SelectItem> + )} + </SelectContent> + </Select> + </div> + </div> + + {/* 4행: 계약 납품일, 연동제 적용 가능 */} + <div className="grid grid-cols-2 gap-4 mb-4"> + <div> + <FormLabel>계약 납품일</FormLabel> + <Input + type="date" + value={biddingConditions.contractDeliveryDate} + onChange={(e) => { + setBiddingConditions(prev => ({ + ...prev, + contractDeliveryDate: e.target.value + })) + }} + /> + </div> + + <div className="flex flex-row items-center justify-between rounded-lg border p-3"> + <div className="space-y-0.5"> + <FormLabel className="text-base">연동제 적용 가능</FormLabel> + <div className="text-sm text-muted-foreground"> + 연동제 적용 요건 여부 + </div> + </div> + <Switch + checked={biddingConditions.isPriceAdjustmentApplicable} + onCheckedChange={(checked) => { + setBiddingConditions(prev => ({ + ...prev, + isPriceAdjustmentApplicable: checked + })) + }} + /> + </div> + </div> + + {/* 5행: 스페어파트 옵션 */} + <div className="mb-4"> + <div> + <FormLabel>스페어파트 옵션</FormLabel> + <Textarea + placeholder="스페어파트 관련 옵션을 입력하세요" + value={biddingConditions.sparePartOptions} + onChange={(e) => { + setBiddingConditions(prev => ({ + ...prev, + sparePartOptions: e.target.value + })) + }} + rows={3} + /> + </div> + </div> + </div> + + {/* 입찰공고 내용 */} + <div className="pt-4 border-t"> + <CardTitle className="text-lg mb-4">입찰공고 내용</CardTitle> + <FormField control={form.control} name="content" render={({ field }) => ( + <FormItem> + <FormControl> + <div className="border rounded-lg"> + <TiptapEditor + content={field.value || noticeTemplate} + setContent={field.onChange} + disabled={isLoadingTemplate} + height="300px" + /> + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} /> + + {isLoadingTemplate && ( + <div className="flex items-center justify-center p-4 text-sm text-muted-foreground"> + <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2"></div> + 입찰공고 템플릿을 불러오는 중... + </div> + )} + </div> + + {/* 액션 버튼 */} + <div className="flex justify-end gap-4 pt-4"> + <Button type="submit" disabled={isSubmitting} className="flex items-center gap-2"> + {isSubmitting ? '저장 중...' : '저장'} + <ChevronRight className="h-4 w-4" /> + </Button> + </div> + </form> + </Form> + </CardContent> + </Card> + + {/* SHI용 첨부파일 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Upload className="h-5 w-5" /> + SHI용 첨부파일 + </CardTitle> + <p className="text-sm text-muted-foreground"> + SHI에서 제공하는 문서나 파일을 업로드하세요 + </p> + </CardHeader> + <CardContent className="space-y-4"> + <div className="border-2 border-dashed border-gray-300 rounded-lg p-6"> + <div className="text-center"> + <Upload className="h-12 w-12 text-gray-400 mx-auto mb-4" /> + <div className="space-y-2"> + <p className="text-sm text-gray-600"> + 파일을 드래그 앤 드롭하거나{' '} + <label className="text-blue-600 hover:text-blue-500 cursor-pointer"> + <input + type="file" + multiple + className="hidden" + onChange={(e) => { + const files = Array.from(e.target.files || []) + setShiAttachmentFiles(prev => [...prev, ...files]) + }} + accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg" + /> + 찾아보세요 + </label> + </p> + <p className="text-xs text-gray-500"> + PDF, DOC, DOCX, XLS, XLSX, PNG, JPG, JPEG 파일을 업로드할 수 있습니다 + </p> + </div> + </div> + </div> + + {shiAttachmentFiles.length > 0 && ( + <div className="space-y-2"> + <h4 className="text-sm font-medium">업로드 예정 파일</h4> + <div className="space-y-2"> + {shiAttachmentFiles.map((file, index) => ( + <div + key={index} + className="flex items-center justify-between p-3 bg-muted rounded-lg" + > + <div className="flex items-center gap-3"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <div> + <p className="text-sm font-medium">{file.name}</p> + <p className="text-xs text-muted-foreground"> + {(file.size / 1024 / 1024).toFixed(2)} MB + </p> + </div> + </div> + <div className="flex gap-2"> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => { + handleShiFileUpload([file]) + }} + > + 업로드 + </Button> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeShiFile(index)} + > + 제거 + </Button> + </div> + </div> + ))} + </div> + </div> + )} + + {/* 기존 문서 목록 */} + {isLoadingDocuments ? ( + <div className="flex items-center justify-center p-4 text-sm text-muted-foreground"> + <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2"></div> + 문서 목록을 불러오는 중... + </div> + ) : existingDocuments.length > 0 && ( + <div className="space-y-2"> + <h4 className="text-sm font-medium">업로드된 문서</h4> + <div className="space-y-2"> + {existingDocuments + .filter(doc => doc.description?.includes('SHI용') || doc.title?.includes('SHI')) + .map((doc) => ( + <div + key={doc.id} + className="flex items-center justify-between p-3 bg-muted rounded-lg" + > + <div className="flex items-center gap-3"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <div> + <p className="text-sm font-medium">{doc.originalFileName}</p> + <p className="text-xs text-muted-foreground"> + {formatFileSize(doc.fileSize)} • {new Date(doc.uploadedAt).toLocaleDateString('ko-KR')} + </p> + </div> + </div> + <div className="flex gap-2"> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => handleDownloadDocument(doc)} + > + 다운로드 + </Button> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => handleDeleteDocument(doc.id)} + > + 삭제 + </Button> + </div> + </div> + ))} + </div> + </div> + )} + </CardContent> + </Card> + + {/* 협력업체용 첨부파일 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Upload className="h-5 w-5" /> + 협력업체용 첨부파일 + </CardTitle> + <p className="text-sm text-muted-foreground"> + 협력업체에서 제공하는 문서나 파일을 업로드하세요 + </p> + </CardHeader> + <CardContent className="space-y-4"> + <div className="border-2 border-dashed border-gray-300 rounded-lg p-6"> + <div className="text-center"> + <Upload className="h-12 w-12 text-gray-400 mx-auto mb-4" /> + <div className="space-y-2"> + <p className="text-sm text-gray-600"> + 파일을 드래그 앤 드롭하거나{' '} + <label className="text-blue-600 hover:text-blue-500 cursor-pointer"> + <input + type="file" + multiple + className="hidden" + onChange={(e) => { + const files = Array.from(e.target.files || []) + setVendorAttachmentFiles(prev => [...prev, ...files]) + }} + accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg" + /> + 찾아보세요 + </label> + </p> + <p className="text-xs text-gray-500"> + PDF, DOC, DOCX, XLS, XLSX, PNG, JPG, JPEG 파일을 업로드할 수 있습니다 + </p> + </div> + </div> + </div> + + {vendorAttachmentFiles.length > 0 && ( + <div className="space-y-2"> + <h4 className="text-sm font-medium">업로드 예정 파일</h4> + <div className="space-y-2"> + {vendorAttachmentFiles.map((file, index) => ( + <div + key={index} + className="flex items-center justify-between p-3 bg-muted rounded-lg" + > + <div className="flex items-center gap-3"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <div> + <p className="text-sm font-medium">{file.name}</p> + <p className="text-xs text-muted-foreground"> + {(file.size / 1024 / 1024).toFixed(2)} MB + </p> + </div> + </div> + <div className="flex gap-2"> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => { + handleVendorFileUpload([file]) + }} + > + 업로드 + </Button> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeVendorFile(index)} + > + 제거 + </Button> + </div> + </div> + ))} + </div> + </div> + )} + + {/* 기존 문서 목록 */} + {existingDocuments.length > 0 && ( + <div className="space-y-2"> + <h4 className="text-sm font-medium">업로드된 문서</h4> + <div className="space-y-2"> + {existingDocuments + .filter(doc => doc.description?.includes('협력업체용') || !doc.description?.includes('SHI용')) + .map((doc) => ( + <div + key={doc.id} + className="flex items-center justify-between p-3 bg-muted rounded-lg" + > + <div className="flex items-center gap-3"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <div> + <p className="text-sm font-medium">{doc.originalFileName}</p> + <p className="text-xs text-muted-foreground"> + {formatFileSize(doc.fileSize)} • {new Date(doc.uploadedAt).toLocaleDateString('ko-KR')} + </p> + </div> + </div> + <div className="flex gap-2"> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => handleDownloadDocument(doc)} + > + 다운로드 + </Button> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => handleDeleteDocument(doc.id)} + > + 삭제 + </Button> + </div> + </div> + ))} + </div> + </div> + )} + </CardContent> + </Card> + </div> + ) +}
\ No newline at end of file diff --git a/components/bidding/manage/bidding-companies-editor.tsx b/components/bidding/manage/bidding-companies-editor.tsx new file mode 100644 index 00000000..1ce8b014 --- /dev/null +++ b/components/bidding/manage/bidding-companies-editor.tsx @@ -0,0 +1,803 @@ +'use client' + +import * as React from 'react' +import { Building, User, Plus, Trash2 } from 'lucide-react' +import { toast } from 'sonner' + +import { Button } from '@/components/ui/button' +import { + getBiddingVendors, + getBiddingCompanyContacts, + createBiddingCompanyContact, + deleteBiddingCompanyContact, + getVendorContactsByVendorId, + updateBiddingCompanyPriceAdjustmentQuestion +} from '@/lib/bidding/service' +import { deleteBiddingCompany } from '@/lib/bidding/pre-quote/service' +import { BiddingDetailVendorCreateDialog } from './bidding-detail-vendor-create-dialog' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Checkbox } from '@/components/ui/checkbox' +import { Loader2 } from 'lucide-react' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' + +interface QuotationVendor { + id: number // biddingCompanies.id + companyId?: number // vendors.id (벤더 ID) + vendorName: string + vendorCode: string + contactPerson?: string + contactEmail?: string + contactPhone?: string + quotationAmount?: number + currency: string + invitationStatus: string + isPriceAdjustmentApplicableQuestion?: boolean +} + +interface BiddingCompaniesEditorProps { + biddingId: number +} + +interface VendorContact { + id: number + vendorId: number + contactName: string + contactPosition: string | null + contactDepartment: string | null + contactTask: string | null + contactEmail: string + contactPhone: string | null + isPrimary: boolean +} + +interface BiddingCompanyContact { + id: number + biddingId: number + vendorId: number + contactName: string + contactEmail: string + contactNumber: string | null + createdAt: Date + updatedAt: Date +} + +export function BiddingCompaniesEditor({ biddingId }: BiddingCompaniesEditorProps) { + const [vendors, setVendors] = React.useState<QuotationVendor[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + const [addVendorDialogOpen, setAddVendorDialogOpen] = React.useState(false) + const [selectedVendor, setSelectedVendor] = React.useState<QuotationVendor | null>(null) + const [biddingCompanyContacts, setBiddingCompanyContacts] = React.useState<BiddingCompanyContact[]>([]) + const [isLoadingContacts, setIsLoadingContacts] = React.useState(false) + // 각 업체별 첫 번째 담당자 정보 저장 (vendorId -> 첫 번째 담당자) + const [vendorFirstContacts, setVendorFirstContacts] = React.useState<Map<number, BiddingCompanyContact>>(new Map()) + + // 담당자 추가 다이얼로그 + const [addContactDialogOpen, setAddContactDialogOpen] = React.useState(false) + const [newContact, setNewContact] = React.useState({ + contactName: '', + contactEmail: '', + contactNumber: '', + }) + const [addContactFromVendorDialogOpen, setAddContactFromVendorDialogOpen] = React.useState(false) + const [vendorContacts, setVendorContacts] = React.useState<VendorContact[]>([]) + const [isLoadingVendorContacts, setIsLoadingVendorContacts] = React.useState(false) + const [selectedContactFromVendor, setSelectedContactFromVendor] = React.useState<VendorContact | null>(null) + + // 업체 목록 다시 로딩 함수 + const reloadVendors = React.useCallback(async () => { + try { + const result = await getBiddingVendors(biddingId) + if (result.success && result.data) { + const vendorsList = result.data.map(v => ({ + ...v, + companyId: v.companyId || undefined, + vendorName: v.vendorName || '', + vendorCode: v.vendorCode || '', + contactPerson: v.contactPerson ?? undefined, + contactEmail: v.contactEmail ?? undefined, + contactPhone: v.contactPhone ?? undefined, + quotationAmount: v.quotationAmount ? parseFloat(v.quotationAmount) : undefined, + isPriceAdjustmentApplicableQuestion: v.isPriceAdjustmentApplicableQuestion ?? false, + })) + setVendors(vendorsList) + + // 각 업체별 첫 번째 담당자 정보 로드 + const firstContactsMap = new Map<number, BiddingCompanyContact>() + const contactPromises = vendorsList + .filter(v => v.companyId) + .map(async (vendor) => { + try { + const contactResult = await getBiddingCompanyContacts(biddingId, vendor.companyId!) + if (contactResult.success && contactResult.data && contactResult.data.length > 0) { + firstContactsMap.set(vendor.companyId!, contactResult.data[0]) + } + } catch (error) { + console.error(`Failed to load contact for vendor ${vendor.companyId}:`, error) + } + }) + + await Promise.all(contactPromises) + setVendorFirstContacts(firstContactsMap) + } + } catch (error) { + console.error('Failed to reload vendors:', error) + } + }, [biddingId]) + + // 데이터 로딩 + React.useEffect(() => { + const loadVendors = async () => { + setIsLoading(true) + try { + const result = await getBiddingVendors(biddingId) + if (result.success && result.data) { + const vendorsList = result.data.map(v => ({ + id: v.id, + companyId: v.companyId || undefined, + vendorName: v.vendorName || '', + vendorCode: v.vendorCode || '', + contactPerson: v.contactPerson !== null ? v.contactPerson : undefined, + contactEmail: v.contactEmail !== null ? v.contactEmail : undefined, + contactPhone: v.contactPhone !== null ? v.contactPhone : undefined, + quotationAmount: v.quotationAmount ? parseFloat(v.quotationAmount) : undefined, + currency: v.currency || 'KRW', + invitationStatus: v.invitationStatus, + isPriceAdjustmentApplicableQuestion: v.isPriceAdjustmentApplicableQuestion ?? false, + })) + setVendors(vendorsList) + + // 각 업체별 첫 번째 담당자 정보 로드 + const firstContactsMap = new Map<number, BiddingCompanyContact>() + const contactPromises = vendorsList + .filter(v => v.companyId) + .map(async (vendor) => { + try { + const contactResult = await getBiddingCompanyContacts(biddingId, vendor.companyId!) + if (contactResult.success && contactResult.data && contactResult.data.length > 0) { + firstContactsMap.set(vendor.companyId!, contactResult.data[0]) + } + } catch (error) { + console.error(`Failed to load contact for vendor ${vendor.companyId}:`, error) + } + }) + + await Promise.all(contactPromises) + setVendorFirstContacts(firstContactsMap) + } else { + toast.error(result.error || '업체 정보를 불러오는데 실패했습니다.') + setVendors([]) + } + } catch (error) { + console.error('Failed to load vendors:', error) + toast.error('업체 정보를 불러오는데 실패했습니다.') + setVendors([]) + } finally { + setIsLoading(false) + } + } + + loadVendors() + }, [biddingId]) + + // 업체 선택 핸들러 (단일 선택) + const handleVendorSelect = async (vendor: QuotationVendor) => { + // 이미 선택된 업체를 다시 클릭하면 선택 해제 + if (selectedVendor?.id === vendor.id) { + setSelectedVendor(null) + setBiddingCompanyContacts([]) + return + } + + // 새 업체 선택 + setSelectedVendor(vendor) + + // 선택한 업체의 담당자 목록 로딩 + if (vendor.companyId) { + setIsLoadingContacts(true) + try { + const result = await getBiddingCompanyContacts(biddingId, vendor.companyId) + if (result.success && result.data) { + setBiddingCompanyContacts(result.data) + } else { + toast.error(result.error || '담당자 목록을 불러오는데 실패했습니다.') + setBiddingCompanyContacts([]) + } + } catch (error) { + console.error('Failed to load contacts:', error) + toast.error('담당자 목록을 불러오는데 실패했습니다.') + setBiddingCompanyContacts([]) + } finally { + setIsLoadingContacts(false) + } + } + } + + // 업체 삭제 + const handleRemoveVendor = async (vendorId: number) => { + if (!confirm('정말로 이 업체를 삭제하시겠습니까?')) { + return + } + + try { + const result = await deleteBiddingCompany(vendorId) + if (result.success) { + toast.success('업체가 삭제되었습니다.') + // 업체 목록 다시 로딩 + await reloadVendors() + // 선택된 업체가 삭제된 경우 담당자 목록도 초기화 + if (selectedVendor?.id === vendorId) { + setSelectedVendor(null) + setBiddingCompanyContacts([]) + } + } else { + toast.error(result.error || '업체 삭제에 실패했습니다.') + } + } catch (error) { + console.error('Failed to remove vendor:', error) + toast.error('업체 삭제에 실패했습니다.') + } + } + + // 담당자 추가 (직접 입력) + const handleAddContact = async () => { + if (!selectedVendor || !selectedVendor.companyId) { + toast.error('업체를 선택해주세요.') + return + } + + if (!newContact.contactName || !newContact.contactEmail) { + toast.error('이름과 이메일은 필수입니다.') + return + } + + try { + const result = await createBiddingCompanyContact( + biddingId, + selectedVendor.companyId, + { + contactName: newContact.contactName, + contactEmail: newContact.contactEmail, + contactNumber: newContact.contactNumber || undefined, + } + ) + + if (result.success) { + toast.success('담당자가 추가되었습니다.') + setAddContactDialogOpen(false) + setNewContact({ contactName: '', contactEmail: '', contactNumber: '' }) + + // 담당자 목록 새로고침 + const contactsResult = await getBiddingCompanyContacts(biddingId, selectedVendor.companyId) + if (contactsResult.success && contactsResult.data) { + setBiddingCompanyContacts(contactsResult.data) + // 첫 번째 담당자 정보 업데이트 + if (contactsResult.data.length > 0) { + setVendorFirstContacts(prev => { + const newMap = new Map(prev) + newMap.set(selectedVendor.companyId!, contactsResult.data[0]) + return newMap + }) + } + } + } else { + toast.error(result.error || '담당자 추가에 실패했습니다.') + } + } catch (error) { + console.error('Failed to add contact:', error) + toast.error('담당자 추가에 실패했습니다.') + } + } + + // 담당자 추가 (벤더 목록에서 선택) + const handleOpenAddContactFromVendor = async () => { + if (!selectedVendor || !selectedVendor.companyId) { + toast.error('업체를 선택해주세요.') + return + } + + setIsLoadingVendorContacts(true) + setAddContactFromVendorDialogOpen(true) + setSelectedContactFromVendor(null) + + try { + const result = await getVendorContactsByVendorId(selectedVendor.companyId) + if (result.success && result.data) { + setVendorContacts(result.data) + } else { + toast.error(result.error || '벤더 담당자 목록을 불러오는데 실패했습니다.') + setVendorContacts([]) + } + } catch (error) { + console.error('Failed to load vendor contacts:', error) + toast.error('벤더 담당자 목록을 불러오는데 실패했습니다.') + setVendorContacts([]) + } finally { + setIsLoadingVendorContacts(false) + } + } + + // 벤더 담당자 선택 후 저장 + const handleAddContactFromVendor = async () => { + if (!selectedContactFromVendor || !selectedVendor || !selectedVendor.companyId) { + toast.error('담당자를 선택해주세요.') + return + } + + try { + const result = await createBiddingCompanyContact( + biddingId, + selectedVendor.companyId, + { + contactName: selectedContactFromVendor.contactName, + contactEmail: selectedContactFromVendor.contactEmail, + contactNumber: selectedContactFromVendor.contactPhone || undefined, + } + ) + + if (result.success) { + toast.success('담당자가 추가되었습니다.') + setAddContactFromVendorDialogOpen(false) + setSelectedContactFromVendor(null) + + // 담당자 목록 새로고침 + const contactsResult = await getBiddingCompanyContacts(biddingId, selectedVendor.companyId) + if (contactsResult.success && contactsResult.data) { + setBiddingCompanyContacts(contactsResult.data) + // 첫 번째 담당자 정보 업데이트 + if (contactsResult.data.length > 0) { + setVendorFirstContacts(prev => { + const newMap = new Map(prev) + newMap.set(selectedVendor.companyId!, contactsResult.data[0]) + return newMap + }) + } + } + } else { + toast.error(result.error || '담당자 추가에 실패했습니다.') + } + } catch (error) { + console.error('Failed to add contact:', error) + toast.error('담당자 추가에 실패했습니다.') + } + } + + // 담당자 삭제 + const handleDeleteContact = async (contactId: number) => { + if (!confirm('정말로 이 담당자를 삭제하시겠습니까?')) { + return + } + + try { + const result = await deleteBiddingCompanyContact(contactId) + if (result.success) { + toast.success('담당자가 삭제되었습니다.') + + // 담당자 목록 새로고침 + if (selectedVendor && selectedVendor.companyId) { + const contactsResult = await getBiddingCompanyContacts(biddingId, selectedVendor.companyId) + if (contactsResult.success && contactsResult.data) { + setBiddingCompanyContacts(contactsResult.data) + // 첫 번째 담당자 정보 업데이트 + if (contactsResult.data.length > 0) { + setVendorFirstContacts(prev => { + const newMap = new Map(prev) + newMap.set(selectedVendor.companyId!, contactsResult.data[0]) + return newMap + }) + } else { + // 담당자가 없으면 Map에서 제거 + setVendorFirstContacts(prev => { + const newMap = new Map(prev) + newMap.delete(selectedVendor.companyId!) + return newMap + }) + } + } + } + } else { + toast.error(result.error || '담당자 삭제에 실패했습니다.') + } + } catch (error) { + console.error('Failed to delete contact:', error) + toast.error('담당자 삭제에 실패했습니다.') + } + } + + // 연동제 적용요건 문의 체크박스 변경 + const handleTogglePriceAdjustmentQuestion = async (vendorId: number, checked: boolean) => { + try { + const result = await updateBiddingCompanyPriceAdjustmentQuestion(vendorId, checked) + if (result.success) { + // 로컬 상태 업데이트 + setVendors(prev => prev.map(v => + v.id === vendorId + ? { ...v, isPriceAdjustmentApplicableQuestion: checked } + : v + )) + + // 선택된 업체 정보도 업데이트 + if (selectedVendor?.id === vendorId) { + setSelectedVendor(prev => prev ? { ...prev, isPriceAdjustmentApplicableQuestion: checked } : null) + } + + // 담당자 목록 새로고침 (첫 번째 담당자 정보 업데이트를 위해) + if (selectedVendor && selectedVendor.companyId) { + const contactsResult = await getBiddingCompanyContacts(biddingId, selectedVendor.companyId) + if (contactsResult.success && contactsResult.data) { + setBiddingCompanyContacts(contactsResult.data) + if (contactsResult.data.length > 0) { + setVendorFirstContacts(prev => { + const newMap = new Map(prev) + newMap.set(selectedVendor.companyId!, contactsResult.data[0]) + return newMap + }) + } + } + } + } else { + toast.error(result.error || '연동제 적용요건 문의 여부 업데이트에 실패했습니다.') + } + } catch (error) { + console.error('Failed to update price adjustment question:', error) + toast.error('연동제 적용요건 문의 여부 업데이트에 실패했습니다.') + } + } + + 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> + ) + } + + return ( + <div className="space-y-6"> + {/* 참여 업체 목록 테이블 */} + <Card> + <CardHeader className="flex flex-row items-center justify-between"> + <div> + <CardTitle className="flex items-center gap-2"> + <Building className="h-5 w-5" /> + 참여 업체 목록 + </CardTitle> + <p className="text-sm text-muted-foreground mt-1"> + 입찰에 참여하는 업체들을 관리합니다. 업체를 선택하면 하단에 담당자 목록이 표시됩니다. + </p> + </div> + <Button onClick={() => setAddVendorDialogOpen(true)} className="flex items-center gap-2"> + <Plus className="h-4 w-4" /> + 업체 추가 + </Button> + </CardHeader> + <CardContent> + {vendors.length === 0 ? ( + <div className="text-center py-8 text-muted-foreground"> + 참여 업체가 없습니다. 업체를 추가해주세요. + </div> + ) : ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[50px]">선택</TableHead> + <TableHead>업체명</TableHead> + <TableHead>업체코드</TableHead> + <TableHead>담당자 이름</TableHead> + <TableHead>담당자 이메일</TableHead> + <TableHead>담당자 연락처</TableHead> + <TableHead>상태</TableHead> + <TableHead className="w-[180px]">연동제 적용요건 문의</TableHead> + <TableHead className="w-[100px]">작업</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {vendors.map((vendor) => ( + <TableRow + key={vendor.id} + className={`cursor-pointer hover:bg-muted/50 ${selectedVendor?.id === vendor.id ? 'bg-muted/50' : ''}`} + onClick={() => handleVendorSelect(vendor)} + > + <TableCell onClick={(e) => e.stopPropagation()}> + <Checkbox + checked={selectedVendor?.id === vendor.id} + onCheckedChange={() => handleVendorSelect(vendor)} + /> + </TableCell> + <TableCell className="font-medium">{vendor.vendorName}</TableCell> + <TableCell>{vendor.vendorCode}</TableCell> + <TableCell> + {vendor.companyId && vendorFirstContacts.has(vendor.companyId) + ? vendorFirstContacts.get(vendor.companyId)!.contactName + : '-'} + </TableCell> + <TableCell> + {vendor.companyId && vendorFirstContacts.has(vendor.companyId) + ? vendorFirstContacts.get(vendor.companyId)!.contactEmail + : '-'} + </TableCell> + <TableCell> + {vendor.companyId && vendorFirstContacts.has(vendor.companyId) + ? vendorFirstContacts.get(vendor.companyId)!.contactNumber || '-' + : '-'} + </TableCell> + + <TableCell> + <span className="px-2 py-1 rounded-full text-xs bg-blue-100 text-blue-800"> + {vendor.invitationStatus} + </span> + </TableCell> + <TableCell> + <div className="flex items-center gap-2"> + <Checkbox + checked={vendor.isPriceAdjustmentApplicableQuestion || false} + onCheckedChange={(checked) => + handleTogglePriceAdjustmentQuestion(vendor.id, checked as boolean) + } + /> + <span className="text-sm text-muted-foreground"> + {vendor.isPriceAdjustmentApplicableQuestion ? '예' : '아니오'} + </span> + </div> + </TableCell> + <TableCell> + <Button + variant="ghost" + size="sm" + onClick={() => handleRemoveVendor(vendor.id)} + className="text-red-600 hover:text-red-800" + > + <Trash2 className="h-4 w-4" /> + </Button> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + )} + </CardContent> + </Card> + + {/* 선택한 업체의 담당자 목록 테이블 */} + {selectedVendor && ( + <Card> + <CardHeader className="flex flex-row items-center justify-between"> + <div> + <CardTitle className="flex items-center gap-2"> + <User className="h-5 w-5" /> + {selectedVendor.vendorName} 담당자 목록 + </CardTitle> + <p className="text-sm text-muted-foreground mt-1"> + 선택한 업체의 선정된 담당자를 관리합니다. + </p> + </div> + <div className="flex gap-2"> + <Button + variant="outline" + onClick={handleOpenAddContactFromVendor} + className="flex items-center gap-2" + > + <User className="h-4 w-4" /> + 업체 담당자 추가 + </Button> + <Button + onClick={() => setAddContactDialogOpen(true)} + className="flex items-center gap-2" + > + <Plus className="h-4 w-4" /> + 담당자 수기 입력 + </Button> + </div> + </CardHeader> + <CardContent> + {isLoadingContacts ? ( + <div className="flex items-center justify-center py-8"> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + <span className="text-sm text-muted-foreground">담당자 목록을 불러오는 중...</span> + </div> + ) : biddingCompanyContacts.length === 0 ? ( + <div className="text-center py-8 text-muted-foreground"> + 등록된 담당자가 없습니다. 담당자를 추가해주세요. + </div> + ) : ( + <Table> + <TableHeader> + <TableRow> + <TableHead>이름</TableHead> + <TableHead>이메일</TableHead> + <TableHead>전화번호</TableHead> + <TableHead className="w-[100px]">작업</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {biddingCompanyContacts.map((biddingCompanyContact) => ( + <TableRow key={biddingCompanyContact.id}> + <TableCell className="font-medium">{biddingCompanyContact.contactName}</TableCell> + <TableCell>{biddingCompanyContact.contactEmail}</TableCell> + <TableCell>{biddingCompanyContact.contactNumber || '-'}</TableCell> + <TableCell> + <Button + variant="ghost" + size="sm" + onClick={() => handleDeleteContact(biddingCompanyContact.id)} + className="text-red-600 hover:text-red-800" + > + <Trash2 className="h-4 w-4" /> + </Button> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + )} + </CardContent> + </Card> + )} + + {/* 업체 추가 다이얼로그 */} + <BiddingDetailVendorCreateDialog + biddingId={biddingId} + open={addVendorDialogOpen} + onOpenChange={setAddVendorDialogOpen} + onSuccess={reloadVendors} + /> + + {/* 담당자 추가 다이얼로그 (직접 입력) */} + <Dialog open={addContactDialogOpen} onOpenChange={setAddContactDialogOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>담당자 추가</DialogTitle> + <DialogDescription> + 새로운 담당자 정보를 입력하세요. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 py-4"> + <div className="space-y-2"> + <Label htmlFor="contactName">이름 *</Label> + <Input + id="contactName" + value={newContact.contactName} + onChange={(e) => setNewContact(prev => ({ ...prev, contactName: e.target.value }))} + placeholder="담당자 이름" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="contactEmail">이메일 *</Label> + <Input + id="contactEmail" + type="email" + value={newContact.contactEmail} + onChange={(e) => setNewContact(prev => ({ ...prev, contactEmail: e.target.value }))} + placeholder="example@email.com" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="contactNumber">전화번호</Label> + <Input + id="contactNumber" + value={newContact.contactNumber} + onChange={(e) => setNewContact(prev => ({ ...prev, contactNumber: e.target.value }))} + placeholder="010-1234-5678" + /> + </div> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setAddContactDialogOpen(false) + setNewContact({ contactName: '', contactEmail: '', contactNumber: '' }) + }} + > + 취소 + </Button> + <Button onClick={handleAddContact}> + 추가 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* 벤더 담당자에서 추가 다이얼로그 */} + <Dialog open={addContactFromVendorDialogOpen} onOpenChange={setAddContactFromVendorDialogOpen}> + <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle> + {selectedVendor ? `${selectedVendor.vendorName} 벤더 담당자에서 선택` : '벤더 담당자 선택'} + </DialogTitle> + <DialogDescription> + 벤더에 등록된 담당자 목록에서 선택하세요. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 py-4"> + {isLoadingVendorContacts ? ( + <div className="flex items-center justify-center py-8"> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + <span className="text-sm text-muted-foreground">담당자 목록을 불러오는 중...</span> + </div> + ) : vendorContacts.length === 0 ? ( + <div className="text-center py-8 text-muted-foreground"> + 등록된 담당자가 없습니다. + </div> + ) : ( + <div className="space-y-2"> + {vendorContacts.map((contact) => ( + <div + key={contact.id} + className={`flex items-center justify-between p-4 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors ${ + selectedContactFromVendor?.id === contact.id ? 'bg-primary/10 border-primary' : '' + }`} + onClick={() => setSelectedContactFromVendor(contact)} + > + <div className="flex items-center gap-3 flex-1"> + <Checkbox + checked={selectedContactFromVendor?.id === contact.id} + onCheckedChange={() => setSelectedContactFromVendor(contact)} + className="shrink-0" + /> + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2"> + <span className="font-medium">{contact.contactName}</span> + {contact.isPrimary && ( + <span className="px-2 py-0.5 rounded-full text-xs bg-primary/10 text-primary"> + 주담당자 + </span> + )} + </div> + {contact.contactPosition && ( + <p className="text-sm text-muted-foreground">{contact.contactPosition}</p> + )} + <div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground"> + <span>{contact.contactEmail}</span> + {contact.contactPhone && <span>{contact.contactPhone}</span>} + </div> + </div> + </div> + </div> + ))} + </div> + )} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setAddContactFromVendorDialogOpen(false) + setSelectedContactFromVendor(null) + }} + > + 취소 + </Button> + <Button + onClick={handleAddContactFromVendor} + disabled={!selectedContactFromVendor || isLoadingVendorContacts} + > + 추가 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </div> + ) +}
\ No newline at end of file diff --git a/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx new file mode 100644 index 00000000..ed3e2be6 --- /dev/null +++ b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx @@ -0,0 +1,437 @@ +'use client' + +import * as React from 'react' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { ChevronsUpDown, Loader2, X, Plus } from 'lucide-react' +import { createBiddingDetailVendor } from '@/lib/bidding/detail/service' +import { searchVendorsForBidding } from '@/lib/bidding/service' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' + +interface BiddingDetailVendorCreateDialogProps { + biddingId: number + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +interface Vendor { + id: number + vendorName: string + vendorCode: string + status: string +} + +interface SelectedVendorWithQuestion { + vendor: Vendor + isPriceAdjustmentApplicableQuestion: boolean +} + +export function BiddingDetailVendorCreateDialog({ + biddingId, + open, + onOpenChange, + onSuccess +}: BiddingDetailVendorCreateDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [activeTab, setActiveTab] = React.useState('select') + + // Vendor 검색 상태 + const [vendorList, setVendorList] = React.useState<Vendor[]>([]) + const [selectedVendorsWithQuestion, setSelectedVendorsWithQuestion] = React.useState<SelectedVendorWithQuestion[]>([]) + const [vendorOpen, setVendorOpen] = React.useState(false) + + // 벤더 로드 + const loadVendors = React.useCallback(async () => { + try { + const result = await searchVendorsForBidding('', biddingId) // 빈 검색어로 모든 벤더 로드 + setVendorList(result || []) + } catch (error) { + console.error('Failed to load vendors:', error) + toast({ + title: '오류', + description: '벤더 목록을 불러오는데 실패했습니다.', + variant: 'destructive', + }) + setVendorList([]) + } + }, [biddingId, toast]) + + React.useEffect(() => { + if (open) { + loadVendors() + } + }, [open, loadVendors]) + + // 초기화 + React.useEffect(() => { + if (!open) { + setSelectedVendorsWithQuestion([]) + setActiveTab('select') + } + }, [open]) + + // 벤더 추가 + const handleAddVendor = (vendor: Vendor) => { + if (!selectedVendorsWithQuestion.find(v => v.vendor.id === vendor.id)) { + setSelectedVendorsWithQuestion([ + ...selectedVendorsWithQuestion, + { + vendor, + isPriceAdjustmentApplicableQuestion: false + } + ]) + } + setVendorOpen(false) + } + + // 벤더 제거 + const handleRemoveVendor = (vendorId: number) => { + setSelectedVendorsWithQuestion( + selectedVendorsWithQuestion.filter(v => v.vendor.id !== vendorId) + ) + } + + // 이미 선택된 벤더인지 확인 + const isVendorSelected = (vendorId: number) => { + return selectedVendorsWithQuestion.some(v => v.vendor.id === vendorId) + } + + // 연동제 적용요건 문의 체크박스 토글 + const handleTogglePriceAdjustmentQuestion = (vendorId: number, checked: boolean) => { + setSelectedVendorsWithQuestion(prev => + prev.map(item => + item.vendor.id === vendorId + ? { ...item, isPriceAdjustmentApplicableQuestion: checked } + : item + ) + ) + } + + const handleCreate = () => { + if (selectedVendorsWithQuestion.length === 0) { + toast({ + title: '오류', + description: '업체를 선택해주세요.', + variant: 'destructive', + }) + return + } + + // Tab 2로 이동하여 연동제 적용요건 문의를 확인하도록 유도 + if (activeTab === 'select') { + setActiveTab('question') + toast({ + title: '확인 필요', + description: '선택한 업체들의 연동제 적용요건 문의를 확인해주세요.', + }) + return + } + + startTransition(async () => { + let successCount = 0 + const errorMessages: string[] = [] + + for (const item of selectedVendorsWithQuestion) { + try { + const response = await createBiddingDetailVendor( + biddingId, + item.vendor.id, + item.isPriceAdjustmentApplicableQuestion + ) + + if (response.success) { + successCount++ + } else { + errorMessages.push(`${item.vendor.vendorName}: ${response.error}`) + } + } catch { + errorMessages.push(`${item.vendor.vendorName}: 처리 중 오류가 발생했습니다.`) + } + } + + if (successCount > 0) { + toast({ + title: '성공', + description: `${successCount}개의 업체가 성공적으로 추가되었습니다.${errorMessages.length > 0 ? ` ${errorMessages.length}개는 실패했습니다.` : ''}`, + }) + onOpenChange(false) + resetForm() + onSuccess() + } + + if (errorMessages.length > 0 && successCount === 0) { + toast({ + title: '오류', + description: `업체 추가에 실패했습니다: ${errorMessages.join(', ')}`, + variant: 'destructive', + }) + } + }) + } + + const resetForm = () => { + setSelectedVendorsWithQuestion([]) + setActiveTab('select') + } + + const selectedVendors = selectedVendorsWithQuestion.map(item => item.vendor) + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[90vh] p-0 flex flex-col"> + {/* 헤더 */} + <DialogHeader className="p-6 pb-0"> + <DialogTitle>협력업체 추가</DialogTitle> + <DialogDescription> + 입찰에 참여할 업체를 선택하고 연동제 적용요건 문의를 설정하세요. + </DialogDescription> + </DialogHeader> + + {/* 탭 네비게이션 */} + <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col px-6"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="select"> + 1. 입찰업체 선택 ({selectedVendors.length}개) + </TabsTrigger> + <TabsTrigger + value="question" + disabled={selectedVendors.length === 0} + > + 2. 연동제 적용요건 문의 + </TabsTrigger> + </TabsList> + + {/* Tab 1: 입찰업체 선택 */} + <TabsContent value="select" className="flex-1 overflow-y-auto mt-4 pb-4"> + <Card> + <CardHeader> + <CardTitle className="text-lg">업체 선택</CardTitle> + <CardDescription> + 입찰에 참여할 협력업체를 선택하세요. + </CardDescription> + </CardHeader> + <CardContent> + <div className="space-y-4"> + {/* 업체 추가 버튼 */} + <Popover open={vendorOpen} onOpenChange={setVendorOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={vendorOpen} + className="w-full justify-between" + disabled={vendorList.length === 0} + > + <span className="flex items-center gap-2"> + <Plus className="h-4 w-4" /> + 업체 선택하기 + </span> + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[500px] p-0" align="start"> + <Command> + <CommandInput placeholder="업체명 또는 코드로 검색..." /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {vendorList + .filter(vendor => !isVendorSelected(vendor.id)) + .map((vendor) => ( + <CommandItem + key={vendor.id} + value={`${vendor.vendorCode} ${vendor.vendorName}`} + onSelect={() => handleAddVendor(vendor)} + > + <div className="flex items-center gap-2 w-full"> + <Badge variant="outline" className="shrink-0"> + {vendor.vendorCode} + </Badge> + <span className="truncate">{vendor.vendorName}</span> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + + {/* 선택된 업체 목록 */} + {selectedVendors.length > 0 && ( + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <h4 className="text-sm font-medium">선택된 업체 ({selectedVendors.length}개)</h4> + </div> + <div className="space-y-2"> + {selectedVendors.map((vendor, index) => ( + <div + key={vendor.id} + className="flex items-center justify-between p-3 rounded-lg bg-secondary/50" + > + <div className="flex items-center gap-3"> + <span className="text-sm text-muted-foreground"> + {index + 1}. + </span> + <Badge variant="outline"> + {vendor.vendorCode} + </Badge> + <span className="text-sm font-medium"> + {vendor.vendorName} + </span> + </div> + <Button + variant="ghost" + size="sm" + onClick={() => handleRemoveVendor(vendor.id)} + className="h-8 w-8 p-0" + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + </div> + )} + + {selectedVendors.length === 0 && ( + <div className="text-center py-8 text-muted-foreground"> + <p className="text-sm">아직 선택된 업체가 없습니다.</p> + <p className="text-xs mt-1">위 버튼을 클릭하여 업체를 추가하세요.</p> + </div> + )} + </div> + </CardContent> + </Card> + </TabsContent> + + {/* Tab 2: 연동제 적용요건 문의 체크 */} + <TabsContent value="question" className="flex-1 overflow-y-auto mt-4 pb-4"> + <Card> + <CardHeader> + <CardTitle className="text-lg">연동제 적용요건 문의</CardTitle> + <CardDescription> + 선택한 업체별로 연동제 적용요건 문의 여부를 체크하세요. + </CardDescription> + </CardHeader> + <CardContent> + {selectedVendorsWithQuestion.length === 0 ? ( + <div className="text-center py-8 text-muted-foreground"> + <p className="text-sm">선택된 업체가 없습니다.</p> + <p className="text-xs mt-1">먼저 입찰업체 선택 탭에서 업체를 선택해주세요.</p> + </div> + ) : ( + <div className="space-y-4"> + {selectedVendorsWithQuestion.map((item, index) => ( + <div + key={item.vendor.id} + className="flex items-center justify-between p-4 rounded-lg border" + > + <div className="flex items-center gap-4 flex-1"> + <span className="text-sm text-muted-foreground w-8"> + {index + 1}. + </span> + <div className="flex-1"> + <div className="flex items-center gap-2 mb-1"> + <Badge variant="outline"> + {item.vendor.vendorCode} + </Badge> + <span className="font-medium">{item.vendor.vendorName}</span> + </div> + </div> + </div> + <div className="flex items-center gap-2"> + <Checkbox + id={`question-${item.vendor.id}`} + checked={item.isPriceAdjustmentApplicableQuestion} + onCheckedChange={(checked) => + handleTogglePriceAdjustmentQuestion(item.vendor.id, checked as boolean) + } + /> + <Label + htmlFor={`question-${item.vendor.id}`} + className="text-sm cursor-pointer" + > + 연동제 적용요건 문의 + </Label> + </div> + </div> + ))} + </div> + )} + </CardContent> + </Card> + </TabsContent> + </Tabs> + + {/* 푸터 */} + <DialogFooter className="p-6 pt-0 border-t"> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isPending} + > + 취소 + </Button> + {activeTab === 'select' ? ( + <Button + onClick={() => { + if (selectedVendors.length > 0) { + setActiveTab('question') + } else { + toast({ + title: '오류', + description: '업체를 선택해주세요.', + variant: 'destructive', + }) + } + }} + disabled={isPending || selectedVendors.length === 0} + > + 다음 단계 + </Button> + ) : ( + <Button + onClick={handleCreate} + disabled={isPending || selectedVendorsWithQuestion.length === 0} + > + {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {selectedVendorsWithQuestion.length > 0 + ? `${selectedVendorsWithQuestion.length}개 업체 추가` + : '업체 추가' + } + </Button> + )} + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file 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> + ) +} diff --git a/components/bidding/manage/bidding-schedule-editor.tsx b/components/bidding/manage/bidding-schedule-editor.tsx new file mode 100644 index 00000000..d64c16c0 --- /dev/null +++ b/components/bidding/manage/bidding-schedule-editor.tsx @@ -0,0 +1,661 @@ +'use client' + +import * as React from 'react' +import { Calendar, Save, RefreshCw, Clock, Send } from 'lucide-react' +import { updateBiddingSchedule, getBiddingById, getSpecificationMeetingDetailsAction } from '@/lib/bidding/service' +import { useSession } from 'next-auth/react' +import { useRouter } from 'next/navigation' + +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Switch } from '@/components/ui/switch' +import { BiddingInvitationDialog } from '@/lib/bidding/detail/table/bidding-invitation-dialog' +import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from '@/lib/bidding/pre-quote/service' +import { registerBidding } from '@/lib/bidding/detail/service' +import { useToast } from '@/hooks/use-toast' + +interface BiddingSchedule { + submissionStartDate?: string + submissionEndDate?: string + remarks?: string + isUrgent?: boolean + hasSpecificationMeeting?: boolean +} + +interface SpecificationMeetingInfo { + meetingDate: string + meetingTime: string + location: string + address: string + contactPerson: string + contactPhone: string + contactEmail: string + agenda: string + materials: string + notes: string + isRequired: boolean +} + +interface BiddingScheduleEditorProps { + biddingId: number +} + +interface VendorContractRequirement { + vendorId: number + vendorName: string + vendorCode?: string | null + vendorCountry?: string + vendorEmail?: string | null + contactPerson?: string | null + contactEmail?: string | null + ndaYn?: boolean + generalGtcYn?: boolean + projectGtcYn?: boolean + agreementYn?: boolean + biddingCompanyId: number + biddingId: number +} + +interface VendorWithContactInfo extends VendorContractRequirement { + contacts: Array<{ + id: number + contactName: string + contactEmail: string + contactPhone?: string | null + contactPosition?: string | null + contactDepartment?: string | null + }> + selectedMainEmail: string + additionalEmails: string[] + customEmails: Array<{ + id: string + email: string + name?: string + }> + hasExistingContracts: boolean +} + +interface BiddingInvitationData { + vendors: VendorWithContactInfo[] + generatedPdfs: Array<{ + key: string + buffer: number[] + fileName: string + }> + message?: string +} + +export function BiddingScheduleEditor({ biddingId }: BiddingScheduleEditorProps) { + const { data: session } = useSession() + const router = useRouter() + const { toast } = useToast() + const [schedule, setSchedule] = React.useState<BiddingSchedule>({}) + const [specMeetingInfo, setSpecMeetingInfo] = React.useState<SpecificationMeetingInfo>({ + meetingDate: '', + meetingTime: '', + location: '', + address: '', + contactPerson: '', + contactPhone: '', + contactEmail: '', + agenda: '', + materials: '', + notes: '', + isRequired: false, + }) + const [isLoading, setIsLoading] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [biddingInfo, setBiddingInfo] = React.useState<{ title: string; projectName?: string } | null>(null) + const [isBiddingInvitationDialogOpen, setIsBiddingInvitationDialogOpen] = React.useState(false) + const [selectedVendors, setSelectedVendors] = React.useState<VendorContractRequirement[]>([]) + + // 데이터 로딩 + React.useEffect(() => { + const loadSchedule = async () => { + setIsLoading(true) + try { + const bidding = await getBiddingById(biddingId) + if (bidding) { + // 입찰 정보 저장 + setBiddingInfo({ + title: bidding.title || '', + projectName: bidding.projectName || undefined, + }) + + // 날짜를 문자열로 변환하는 헬퍼 + const formatDateTime = (date: unknown): string => { + if (!date) return '' + if (typeof date === 'string') { + // 이미 datetime-local 형식인 경우 + if (date.includes('T')) { + return date.slice(0, 16) + } + return date + } + if (date instanceof Date) return date.toISOString().slice(0, 16) + return '' + } + + setSchedule({ + submissionStartDate: formatDateTime(bidding.submissionStartDate), + submissionEndDate: formatDateTime(bidding.submissionEndDate), + remarks: bidding.remarks || '', + isUrgent: bidding.isUrgent || false, + hasSpecificationMeeting: bidding.hasSpecificationMeeting || false, + }) + + // 사양설명회 정보 로드 + if (bidding.hasSpecificationMeeting) { + try { + const meetingDetails = await getSpecificationMeetingDetailsAction(biddingId) + if (meetingDetails.success && meetingDetails.data) { + const meeting = meetingDetails.data + setSpecMeetingInfo({ + meetingDate: meeting.meetingDate ? new Date(meeting.meetingDate).toISOString().slice(0, 16) : '', + meetingTime: meeting.meetingTime || '', + location: meeting.location || '', + address: meeting.address || '', + contactPerson: meeting.contactPerson || '', + contactPhone: meeting.contactPhone || '', + contactEmail: meeting.contactEmail || '', + agenda: meeting.agenda || '', + materials: meeting.materials || '', + notes: meeting.notes || '', + isRequired: meeting.isRequired || false, + }) + } + } catch (error) { + console.error('Failed to load specification meeting details:', error) + } + } + } + } catch (error) { + console.error('Failed to load schedule:', error) + toast({ + title: '오류', + description: '일정 정보를 불러오는데 실패했습니다.', + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + } + + loadSchedule() + }, [biddingId, toast]) + + // 선정된 업체들 조회 + const getSelectedVendors = React.useCallback(async (): Promise<VendorContractRequirement[]> => { + try { + const result = await getSelectedVendorsForBidding(biddingId) + if (result.success) { + // 타입 변환: null을 undefined로 변환 + return result.vendors.map((vendor): VendorContractRequirement => ({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + vendorCode: vendor.vendorCode ?? undefined, + vendorCountry: vendor.vendorCountry, + vendorEmail: vendor.vendorEmail ?? undefined, + contactPerson: vendor.contactPerson ?? undefined, + contactEmail: vendor.contactEmail ?? undefined, + ndaYn: vendor.ndaYn, + generalGtcYn: vendor.generalGtcYn, + projectGtcYn: vendor.projectGtcYn, + agreementYn: vendor.agreementYn, + biddingCompanyId: vendor.biddingCompanyId, + biddingId: vendor.biddingId, + })) + } else { + console.error('선정된 업체 조회 실패:', 'error' in result ? result.error : '알 수 없는 오류') + return [] + } + } catch (error) { + console.error('선정된 업체 조회 실패:', error) + return [] + } + }, [biddingId]) + + // 본입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회 + React.useEffect(() => { + if (isBiddingInvitationDialogOpen) { + getSelectedVendors().then(vendors => { + setSelectedVendors(vendors) + }) + } + }, [isBiddingInvitationDialogOpen, getSelectedVendors]) + + // 입찰 초대 발송 핸들러 + const handleBiddingInvitationSend = async (data: BiddingInvitationData) => { + try { + const userId = session?.user?.id?.toString() || '1' + + // 1. 기본계약 발송 + // sendBiddingBasicContracts에 필요한 형식으로 변환 + const vendorDataForContract = data.vendors.map(vendor => ({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + vendorCode: vendor.vendorCode || undefined, + vendorCountry: vendor.vendorCountry, + selectedMainEmail: vendor.selectedMainEmail, + additionalEmails: vendor.additionalEmails, + customEmails: vendor.customEmails, + contractRequirements: { + ndaYn: vendor.ndaYn || false, + generalGtcYn: vendor.generalGtcYn || false, + projectGtcYn: vendor.projectGtcYn || false, + agreementYn: vendor.agreementYn || false, + }, + biddingCompanyId: vendor.biddingCompanyId, + biddingId: vendor.biddingId, + hasExistingContracts: vendor.hasExistingContracts, + })) + + const contractResult = await sendBiddingBasicContracts( + biddingId, + vendorDataForContract, + data.generatedPdfs, + data.message + ) + + if (!contractResult.success) { + const errorMessage = 'message' in contractResult + ? contractResult.message + : 'error' in contractResult + ? contractResult.error + : '기본계약 발송에 실패했습니다.' + toast({ + title: '기본계약 발송 실패', + description: errorMessage, + variant: 'destructive', + }) + return + } + + // 2. 입찰 등록 진행 + const registerResult = await registerBidding(biddingId, userId) + + if (registerResult.success) { + toast({ + title: '본입찰 초대 완료', + description: '기본계약 발송 및 본입찰 초대가 완료되었습니다.', + }) + setIsBiddingInvitationDialogOpen(false) + router.refresh() + } else { + toast({ + title: '오류', + description: 'error' in registerResult ? registerResult.error : '입찰 등록에 실패했습니다.', + variant: 'destructive', + }) + } + } catch (error) { + console.error('본입찰 초대 실패:', error) + toast({ + title: '오류', + description: '본입찰 초대에 실패했습니다.', + variant: 'destructive', + }) + } + } + + const handleSave = async () => { + setIsSubmitting(true) + try { + const userId = session?.user?.id?.toString() || '1' + + // 사양설명회 정보 유효성 검사 + if (schedule.hasSpecificationMeeting) { + if (!specMeetingInfo.meetingDate || !specMeetingInfo.location || !specMeetingInfo.contactPerson) { + toast({ + title: '오류', + description: '사양설명회 필수 정보가 누락되었습니다. (회의일시, 장소, 담당자)', + variant: 'destructive', + }) + setIsSubmitting(false) + return + } + } + + const result = await updateBiddingSchedule( + biddingId, + schedule, + userId, + schedule.hasSpecificationMeeting ? specMeetingInfo : undefined + ) + + if (result.success) { + toast({ + title: '성공', + description: '일정 정보가 성공적으로 저장되었습니다.', + }) + } else { + toast({ + title: '오류', + description: 'error' in result ? result.error : '일정 정보 저장에 실패했습니다.', + variant: 'destructive', + }) + } + } catch (error) { + console.error('Failed to save schedule:', error) + toast({ + title: '오류', + description: '일정 정보 저장에 실패했습니다.', + variant: 'destructive', + }) + } finally { + setIsSubmitting(false) + } + } + + const handleScheduleChange = (field: keyof BiddingSchedule, value: string | boolean) => { + setSchedule(prev => ({ ...prev, [field]: value })) + + // 사양설명회 실시 여부가 false로 변경되면 상세 정보 초기화 + if (field === 'hasSpecificationMeeting' && value === false) { + setSpecMeetingInfo({ + meetingDate: '', + meetingTime: '', + location: '', + address: '', + contactPerson: '', + contactPhone: '', + contactEmail: '', + agenda: '', + materials: '', + notes: '', + isRequired: false, + }) + } + } + + 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> + ) + } + + return ( + <div className="space-y-6"> + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Calendar className="h-5 w-5" /> + 입찰 일정 관리 + </CardTitle> + <p className="text-sm text-muted-foreground mt-1"> + 입찰의 주요 일정들을 설정하고 관리합니다. + </p> + </CardHeader> + <CardContent className="space-y-6"> + {/* 입찰서 제출 기간 */} + <div className="space-y-4"> + <h3 className="text-lg font-medium flex items-center gap-2"> + <Clock className="h-4 w-4" /> + 입찰서 제출 기간 + </h3> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="submission-start">제출 시작일시</Label> + <Input + id="submission-start" + type="datetime-local" + value={schedule.submissionStartDate || ''} + onChange={(e) => handleScheduleChange('submissionStartDate', e.target.value)} + /> + </div> + <div className="space-y-2"> + <Label htmlFor="submission-end">제출 마감일시</Label> + <Input + id="submission-end" + type="datetime-local" + value={schedule.submissionEndDate || ''} + onChange={(e) => handleScheduleChange('submissionEndDate', e.target.value)} + /> + </div> + </div> + </div> + + {/* 긴급 여부 */} + <div className="flex flex-row items-center justify-between rounded-lg border p-4"> + <div className="space-y-0.5"> + <Label className="text-base">긴급여부</Label> + <p className="text-sm text-muted-foreground"> + 긴급 입찰로 표시할 경우 활성화하세요 + </p> + </div> + <Switch + checked={schedule.isUrgent || false} + onCheckedChange={(checked) => handleScheduleChange('isUrgent', checked)} + /> + </div> + + {/* 사양설명회 실시 여부 */} + <div className="flex flex-row items-center justify-between rounded-lg border p-4"> + <div className="space-y-0.5"> + <Label className="text-base">사양설명회 실시</Label> + <p className="text-sm text-muted-foreground"> + 사양설명회를 실시할 경우 상세 정보를 입력하세요 + </p> + </div> + <Switch + checked={schedule.hasSpecificationMeeting || false} + onCheckedChange={(checked) => handleScheduleChange('hasSpecificationMeeting', checked)} + /> + </div> + + {/* 사양설명회 상세 정보 */} + {schedule.hasSpecificationMeeting && ( + <div className="space-y-6 p-4 border rounded-lg bg-muted/50"> + <div className="grid grid-cols-2 gap-4"> + <div> + <Label>회의일시 <span className="text-red-500">*</span></Label> + <Input + type="datetime-local" + value={specMeetingInfo.meetingDate} + onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, meetingDate: e.target.value }))} + className={!specMeetingInfo.meetingDate ? 'border-red-200' : ''} + /> + {!specMeetingInfo.meetingDate && ( + <p className="text-sm text-red-500 mt-1">회의일시는 필수입니다</p> + )} + </div> + <div> + <Label>회의시간</Label> + <Input + placeholder="예: 14:00 ~ 16:00" + value={specMeetingInfo.meetingTime} + onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, meetingTime: e.target.value }))} + /> + </div> + </div> + <div> + <Label>장소 <span className="text-red-500">*</span></Label> + <Input + placeholder="회의 장소" + value={specMeetingInfo.location} + onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, location: e.target.value }))} + className={!specMeetingInfo.location ? 'border-red-200' : ''} + /> + {!specMeetingInfo.location && ( + <p className="text-sm text-red-500 mt-1">회의 장소는 필수입니다</p> + )} + </div> + <div> + <Label>주소</Label> + <Input + placeholder="회의 장소 주소" + value={specMeetingInfo.address} + onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, address: e.target.value }))} + /> + </div> + <div className="grid grid-cols-3 gap-4"> + <div> + <Label>담당자 <span className="text-red-500">*</span></Label> + <Input + placeholder="담당자명" + value={specMeetingInfo.contactPerson} + onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactPerson: e.target.value }))} + className={!specMeetingInfo.contactPerson ? 'border-red-200' : ''} + /> + {!specMeetingInfo.contactPerson && ( + <p className="text-sm text-red-500 mt-1">담당자는 필수입니다</p> + )} + </div> + <div> + <Label>연락처</Label> + <Input + placeholder="전화번호" + value={specMeetingInfo.contactPhone} + onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactPhone: e.target.value }))} + /> + </div> + <div> + <Label>이메일</Label> + <Input + type="email" + placeholder="이메일" + value={specMeetingInfo.contactEmail} + onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactEmail: e.target.value }))} + /> + </div> + </div> + <div> + <Label>안건</Label> + <Textarea + placeholder="회의 안건을 입력하세요" + value={specMeetingInfo.agenda} + onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, agenda: e.target.value }))} + rows={3} + /> + </div> + <div> + <Label>자료</Label> + <Textarea + placeholder="회의 자료 정보를 입력하세요" + value={specMeetingInfo.materials} + onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, materials: e.target.value }))} + rows={3} + /> + </div> + <div> + <Label>비고</Label> + <Textarea + placeholder="추가 사항을 입력하세요" + value={specMeetingInfo.notes} + onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, notes: e.target.value }))} + rows={3} + /> + </div> + </div> + )} + + {/* 비고 */} + <div className="space-y-4"> + <h3 className="text-lg font-medium">비고</h3> + <div className="space-y-2"> + <Label htmlFor="remarks">추가 사항</Label> + <Textarea + id="remarks" + value={schedule.remarks || ''} + onChange={(e) => handleScheduleChange('remarks', e.target.value)} + placeholder="일정에 대한 추가 설명이나 참고사항을 입력하세요" + rows={4} + /> + </div> + </div> + </CardContent> + </Card> + + {/* 일정 요약 카드 */} + <Card> + <CardHeader> + <CardTitle>일정 요약</CardTitle> + </CardHeader> + <CardContent> + <div className="space-y-2 text-sm"> + <div className="flex justify-between"> + <span className="font-medium">입찰서 제출 기간:</span> + <span> + {schedule.submissionStartDate && schedule.submissionEndDate + ? `${new Date(schedule.submissionStartDate).toLocaleString('ko-KR')} ~ ${new Date(schedule.submissionEndDate).toLocaleString('ko-KR')}` + : '미설정' + } + </span> + </div> + <div className="flex justify-between"> + <span className="font-medium">긴급여부:</span> + <span> + {schedule.isUrgent ? '예' : '아니오'} + </span> + </div> + <div className="flex justify-between"> + <span className="font-medium">사양설명회 실시:</span> + <span> + {schedule.hasSpecificationMeeting ? '예' : '아니오'} + </span> + </div> + {schedule.hasSpecificationMeeting && specMeetingInfo.meetingDate && ( + <div className="flex justify-between"> + <span className="font-medium">사양설명회 일시:</span> + <span> + {new Date(specMeetingInfo.meetingDate).toLocaleString('ko-KR')} + </span> + </div> + )} + </div> + </CardContent> + </Card> + + {/* 액션 버튼 */} + <div className="flex justify-between gap-4"> + <Button + variant="default" + onClick={() => setIsBiddingInvitationDialogOpen(true)} + disabled={!biddingInfo} + className="min-w-[120px]" + > + <Send className="w-4 h-4 mr-2" /> + 입찰공고 + </Button> + <div className="flex 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> + </div> + + {/* 입찰 초대 다이얼로그 */} + {biddingInfo && ( + <BiddingInvitationDialog + open={isBiddingInvitationDialogOpen} + onOpenChange={setIsBiddingInvitationDialogOpen} + vendors={selectedVendors} + biddingId={biddingId} + biddingTitle={biddingInfo.title} + onSend={handleBiddingInvitationSend} + /> + )} + </div> + ) +} + diff --git a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx new file mode 100644 index 00000000..88732deb --- /dev/null +++ b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx @@ -0,0 +1,742 @@ +"use client"
+
+import * as React from "react"
+import { useForm, useFieldArray } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { format } from "date-fns"
+import { CalendarIcon, Loader2, Trash2, PlusCircle } from "lucide-react"
+import { useSession } from "next-auth/react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { Calendar } from "@/components/ui/calendar"
+import { Badge } from "@/components/ui/badge"
+import { cn } from "@/lib/utils"
+import { toast } from "sonner"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Separator } from "@/components/ui/separator"
+import { createPreQuoteRfqAction, previewGeneralRfqCode } from "@/lib/bidding/service"
+import { MaterialGroupSelectorDialogSingle } from "@/components/common/material/material-group-selector-dialog-single"
+import { MaterialSearchItem } from "@/lib/material/material-group-service"
+import { MaterialSelectorDialogSingle } from "@/components/common/selectors/material/material-selector-dialog-single"
+import { MaterialSearchItem as SAPMaterialSearchItem } from "@/components/common/selectors/material/material-service"
+import { ProcurementManagerSelector } from "@/components/common/selectors/procurement-manager"
+import type { ProcurementManagerWithUser } from "@/components/common/selectors/procurement-manager/procurement-manager-service"
+
+// 아이템 스키마
+const itemSchema = z.object({
+ itemCode: z.string().optional(),
+ itemName: z.string().optional(),
+ materialCode: z.string().optional(),
+ materialName: z.string().optional(),
+ quantity: z.number().min(1, "수량은 1 이상이어야 합니다"),
+ uom: z.string().min(1, "단위를 입력해주세요"),
+ remark: z.string().optional(),
+})
+
+// 사전견적용 일반견적 생성 폼 스키마
+const createPreQuoteRfqSchema = z.object({
+ rfqType: z.string().min(1, "견적 종류를 선택해주세요"),
+ rfqTitle: z.string().min(1, "견적명을 입력해주세요"),
+ dueDate: z.date({
+ required_error: "제출마감일을 선택해주세요",
+ }),
+ picUserId: z.number().optional(),
+ projectId: z.number().optional(),
+ remark: z.string().optional(),
+ items: z.array(itemSchema).min(1, "최소 하나의 자재를 추가해주세요"),
+})
+
+type CreatePreQuoteRfqFormValues = z.infer<typeof createPreQuoteRfqSchema>
+
+interface CreatePreQuoteRfqDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ biddingId: number
+ biddingItems: Array<{
+ id: number
+ materialGroupNumber?: string | null
+ materialGroupInfo?: string | null
+ materialNumber?: string | null
+ materialInfo?: string | null
+ quantity?: string | null
+ quantityUnit?: string | null
+ totalWeight?: string | null
+ weightUnit?: string | null
+ }>
+ biddingConditions?: {
+ 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
+ onSuccess?: () => void
+}
+
+export function CreatePreQuoteRfqDialog({
+ open,
+ onOpenChange,
+ biddingId,
+ biddingItems,
+ biddingConditions,
+ onSuccess
+}: CreatePreQuoteRfqDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [previewCode, setPreviewCode] = React.useState("")
+ const [isLoadingPreview, setIsLoadingPreview] = React.useState(false)
+ const [selectedManager, setSelectedManager] = React.useState<ProcurementManagerWithUser | undefined>(undefined)
+ const { data: session } = useSession()
+
+ const userId = React.useMemo(() => {
+ return session?.user?.id ? Number(session.user.id) : null;
+ }, [session]);
+
+ // 입찰품목을 일반견적 아이템으로 매핑
+ const initialItems = React.useMemo(() => {
+ return biddingItems.map((item) => ({
+ itemCode: item.materialGroupNumber || "",
+ itemName: item.materialGroupInfo || "",
+ materialCode: item.materialNumber || "",
+ materialName: item.materialInfo || "",
+ quantity: item.quantity ? parseFloat(item.quantity) : 1,
+ uom: item.quantityUnit || item.weightUnit || "EA",
+ remark: "",
+ }))
+ }, [biddingItems])
+
+ const form = useForm<CreatePreQuoteRfqFormValues>({
+ resolver: zodResolver(createPreQuoteRfqSchema),
+ defaultValues: {
+ rfqType: "",
+ rfqTitle: "",
+ dueDate: undefined,
+ picUserId: undefined,
+ projectId: undefined,
+ remark: "",
+ items: initialItems.length > 0 ? initialItems : [
+ {
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ },
+ ],
+ },
+ })
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "items",
+ })
+
+ // 다이얼로그가 열릴 때 폼 초기화
+ React.useEffect(() => {
+ if (open) {
+ form.reset({
+ rfqType: "",
+ rfqTitle: "",
+ dueDate: undefined,
+ picUserId: undefined,
+ projectId: undefined,
+ remark: "",
+ items: initialItems.length > 0 ? initialItems : [
+ {
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ },
+ ],
+ })
+ setSelectedManager(undefined)
+ setPreviewCode("")
+ }
+ }, [open, initialItems, form])
+
+ // 견적담당자 선택 시 RFQ 코드 미리보기 생성
+ React.useEffect(() => {
+ if (!selectedManager?.user?.id) {
+ setPreviewCode("")
+ return
+ }
+
+ // 즉시 실행 함수 패턴 사용
+ (async () => {
+ setIsLoadingPreview(true)
+ try {
+ const code = await previewGeneralRfqCode(selectedManager.user!.id!)
+ setPreviewCode(code)
+ } catch (error) {
+ console.error("코드 미리보기 오류:", error)
+ setPreviewCode("")
+ } finally {
+ setIsLoadingPreview(false)
+ }
+ })()
+ }, [selectedManager])
+
+ // 견적 종류 변경
+ const handleRfqTypeChange = (value: string) => {
+ form.setValue("rfqType", value)
+ }
+
+ const handleCancel = () => {
+ form.reset({
+ rfqType: "",
+ rfqTitle: "",
+ dueDate: undefined,
+ picUserId: undefined,
+ projectId: undefined,
+ remark: "",
+ items: initialItems.length > 0 ? initialItems : [
+ {
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ },
+ ],
+ })
+ setSelectedManager(undefined)
+ setPreviewCode("")
+ onOpenChange(false)
+ }
+
+ const onSubmit = async (data: CreatePreQuoteRfqFormValues) => {
+ if (!userId) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+ if (!selectedManager?.user?.id) {
+ toast.error("견적담당자를 선택해주세요")
+ return
+ }
+
+ const picUserId = selectedManager.user.id
+
+ setIsLoading(true)
+
+ try {
+ // 서버 액션 호출 (입찰 조건 포함)
+ const result = await createPreQuoteRfqAction({
+ biddingId,
+ rfqType: data.rfqType,
+ rfqTitle: data.rfqTitle,
+ dueDate: data.dueDate,
+ picUserId,
+ projectId: data.projectId,
+ remark: data.remark || "",
+ items: data.items as Array<{
+ itemCode: string;
+ itemName: string;
+ materialCode?: string;
+ materialName?: string;
+ quantity: number;
+ uom: string;
+ remark?: string;
+ }>,
+ biddingConditions: biddingConditions || undefined,
+ createdBy: userId,
+ updatedBy: userId,
+ })
+
+ if (result.success) {
+ toast.success(result.message, {
+ description: result.data?.rfqCode ? `RFQ 코드: ${result.data.rfqCode}` : undefined,
+ })
+
+ // 다이얼로그 닫기
+ onOpenChange(false)
+
+ // 성공 콜백 실행
+ if (onSuccess) {
+ onSuccess()
+ }
+ } else {
+ toast.error(result.error || "사전견적용 일반견적 생성에 실패했습니다")
+ }
+
+ } catch (error) {
+ console.error('사전견적용 일반견적 생성 오류:', error)
+ toast.error("사전견적용 일반견적 생성에 실패했습니다", {
+ description: "알 수 없는 오류가 발생했습니다",
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 아이템 추가
+ const handleAddItem = () => {
+ append({
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ })
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl h-[90vh] flex flex-col">
+ {/* 고정된 헤더 */}
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle>사전견적용 일반견적 생성</DialogTitle>
+ <DialogDescription>
+ 입찰의 사전견적을 위한 일반견적을 생성합니다. 입찰품목이 자재정보로 매핑되어 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 스크롤 가능한 컨텐츠 영역 */}
+ <ScrollArea className="flex-1 px-1">
+ <Form {...form}>
+ <form id="createPreQuoteRfqForm" onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 py-2">
+
+ {/* 기본 정보 섹션 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">기본 정보</h3>
+
+ <div className="grid grid-cols-2 gap-4">
+ {/* 견적 종류 */}
+ <div className="space-y-2">
+ <FormField
+ control={form.control}
+ name="rfqType"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>
+ 견적 종류 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select onValueChange={handleRfqTypeChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="견적 종류 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="단가계약">단가계약</SelectItem>
+ <SelectItem value="매각계약">매각계약</SelectItem>
+ <SelectItem value="일반계약">일반계약</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 제출마감일 */}
+ <FormField
+ control={form.control}
+ name="dueDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>
+ 제출마감일 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "yyyy-MM-dd")
+ ) : (
+ <span>제출마감일을 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date < new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 견적명 */}
+ <FormField
+ control={form.control}
+ name="rfqTitle"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 견적명 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 입찰 사전견적용 일반견적"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 견적의 목적이나 내용을 간단명료하게 입력해주세요
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 프로젝트 선택 */}
+ <FormField
+ control={form.control}
+ name="projectId"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>프로젝트</FormLabel>
+ <FormControl>
+ {/* ProjectSelector는 별도 컴포넌트 필요 */}
+ <Input
+ placeholder="프로젝트 ID (선택사항)"
+ type="number"
+ {...field}
+ onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 담당자 정보 */}
+ <FormField
+ control={form.control}
+ name="picUserId"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>
+ 견적담당자 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <ProcurementManagerSelector
+ selectedManager={selectedManager}
+ onManagerSelect={(manager) => {
+ setSelectedManager(manager)
+ field.onChange(manager.user?.id)
+ }}
+ placeholder="견적담당자를 선택하세요"
+ />
+ </FormControl>
+ <FormDescription>
+ 사전견적용 일반견적의 담당자를 선택합니다
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {/* RFQ 코드 미리보기 */}
+ {previewCode && (
+ <div className="flex items-center gap-2">
+ <Badge variant="secondary" className="font-mono text-sm">
+ 예상 RFQ 코드: {previewCode}
+ </Badge>
+ {isLoadingPreview && (
+ <Loader2 className="h-3 w-3 animate-spin" />
+ )}
+ </div>
+ )}
+
+ {/* 비고 */}
+ <FormField
+ control={form.control}
+ name="remark"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="추가 비고사항을 입력하세요"
+ className="resize-none"
+ rows={3}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <Separator />
+
+ {/* 아이템 정보 섹션 */}
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <h3 className="text-lg font-semibold">자재 정보</h3>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={handleAddItem}
+ >
+ <PlusCircle className="mr-2 h-4 w-4" />
+ 자재 추가
+ </Button>
+ </div>
+
+ <div className="space-y-3">
+ {fields.map((field, index) => (
+ <div key={field.id} className="border rounded-lg p-3 bg-gray-50/50">
+ <div className="flex items-center justify-between mb-3">
+ <span className="text-sm font-medium text-gray-700">
+ 자재 #{index + 1}
+ </span>
+ {fields.length > 1 && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => remove(index)}
+ className="h-6 w-6 p-0 text-destructive hover:text-destructive"
+ >
+ <Trash2 className="h-3 w-3" />
+ </Button>
+ )}
+ </div>
+
+ {/* 자재그룹 선택 */}
+ <div className="mb-3">
+ <FormLabel className="text-xs">
+ 자재그룹(자재그룹명) <span className="text-red-500">*</span>
+ </FormLabel>
+ <div className="mt-1">
+ <MaterialGroupSelectorDialogSingle
+ triggerLabel="자재그룹 선택"
+ selectedMaterial={(() => {
+ const itemCode = form.watch(`items.${index}.itemCode`);
+ const itemName = form.watch(`items.${index}.itemName`);
+ if (itemCode && itemName) {
+ return {
+ materialGroupCode: itemCode,
+ materialGroupDescription: itemName,
+ displayText: `${itemCode} - ${itemName}`
+ } as MaterialSearchItem;
+ }
+ return null;
+ })()}
+ onMaterialSelect={(material) => {
+ form.setValue(`items.${index}.itemCode`, material?.materialGroupCode || '');
+ form.setValue(`items.${index}.itemName`, material?.materialGroupDescription || '');
+ }}
+ placeholder="자재그룹을 검색하세요..."
+ title="자재그룹 선택"
+ description="원하는 자재그룹을 검색하고 선택해주세요."
+ triggerVariant="outline"
+ />
+ </div>
+ </div>
+
+ {/* 자재코드 선택 */}
+ <div className="mb-3">
+ <FormLabel className="text-xs">
+ 자재코드(자재명)
+ </FormLabel>
+ <div className="mt-1">
+ <MaterialSelectorDialogSingle
+ triggerLabel="자재코드 선택"
+ selectedMaterial={(() => {
+ const materialCode = form.watch(`items.${index}.materialCode`);
+ const materialName = form.watch(`items.${index}.materialName`);
+ if (materialCode && materialName) {
+ return {
+ materialCode: materialCode,
+ materialName: materialName,
+ displayText: `${materialCode} - ${materialName}`
+ } as SAPMaterialSearchItem;
+ }
+ return null;
+ })()}
+ onMaterialSelect={(material) => {
+ form.setValue(`items.${index}.materialCode`, material?.materialCode || '');
+ form.setValue(`items.${index}.materialName`, material?.materialName || '');
+ }}
+ placeholder="자재코드를 검색하세요..."
+ title="자재코드 선택"
+ description="원하는 자재코드를 검색하고 선택해주세요."
+ triggerVariant="outline"
+ />
+ </div>
+ </div>
+
+ <div className="grid grid-cols-2 gap-3">
+ {/* 수량 */}
+ <FormField
+ control={form.control}
+ name={`items.${index}.quantity`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs">
+ 수량 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ min="1"
+ placeholder="1"
+ className="h-8 text-sm"
+ {...field}
+ onChange={(e) => field.onChange(Number(e.target.value))}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 단위 */}
+ <FormField
+ control={form.control}
+ name={`items.${index}.uom`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs">
+ 단위 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger className="h-8 text-sm">
+ <SelectValue placeholder="단위 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="EA">EA (Each)</SelectItem>
+ <SelectItem value="KG">KG (Kilogram)</SelectItem>
+ <SelectItem value="M">M (Meter)</SelectItem>
+ <SelectItem value="L">L (Liter)</SelectItem>
+ <SelectItem value="PC">PC (Piece)</SelectItem>
+ <SelectItem value="BOX">BOX (Box)</SelectItem>
+ <SelectItem value="SET">SET (Set)</SelectItem>
+ <SelectItem value="LOT">LOT (Lot)</SelectItem>
+ <SelectItem value="PCS">PCS (Pieces)</SelectItem>
+ <SelectItem value="TON">TON (Ton)</SelectItem>
+ <SelectItem value="G">G (Gram)</SelectItem>
+ <SelectItem value="ML">ML (Milliliter)</SelectItem>
+ <SelectItem value="CM">CM (Centimeter)</SelectItem>
+ <SelectItem value="MM">MM (Millimeter)</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 비고 */}
+ <div className="mt-3">
+ <FormField
+ control={form.control}
+ name={`items.${index}.remark`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs">비고</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="자재별 비고사항"
+ className="h-8 text-sm"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ </form>
+ </Form>
+ </ScrollArea>
+
+ {/* 고정된 푸터 */}
+ <DialogFooter className="flex-shrink-0">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleCancel}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ form="createPreQuoteRfqForm"
+ onClick={form.handleSubmit(onSubmit)}
+ disabled={isLoading}
+ >
+ {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ {isLoading ? "생성 중..." : "사전견적용 일반견적 생성"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
|
