From ba8cd44a0ed2c613a5f2cee06bfc9bd0f61f21c7 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 7 Nov 2025 08:39:04 +0000 Subject: (최겸) 입찰/견적 수정사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bidding/manage/bidding-basic-info-editor.tsx | 1407 ++++++++++++++++++++ .../bidding/manage/bidding-companies-editor.tsx | 803 +++++++++++ .../manage/bidding-detail-vendor-create-dialog.tsx | 437 ++++++ components/bidding/manage/bidding-items-editor.tsx | 1143 ++++++++++++++++ .../bidding/manage/bidding-schedule-editor.tsx | 661 +++++++++ .../bidding/manage/create-pre-quote-rfq-dialog.tsx | 742 +++++++++++ 6 files changed, 5193 insertions(+) create mode 100644 components/bidding/manage/bidding-basic-info-editor.tsx create mode 100644 components/bidding/manage/bidding-companies-editor.tsx create mode 100644 components/bidding/manage/bidding-detail-vendor-create-dialog.tsx create mode 100644 components/bidding/manage/bidding-items-editor.tsx create mode 100644 components/bidding/manage/bidding-schedule-editor.tsx create mode 100644 components/bidding/manage/create-pre-quote-rfq-dialog.tsx (limited to 'components/bidding/manage') 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([]) + const [vendorAttachmentFiles, setVendorAttachmentFiles] = React.useState([]) + const [existingDocuments, setExistingDocuments] = React.useState([]) + const [isLoadingDocuments, setIsLoadingDocuments] = React.useState(false) + + // 담당자 selector 상태 + const [selectedBidPic, setSelectedBidPic] = React.useState(undefined) + const [selectedSupplyPic, setSelectedSupplyPic] = React.useState(undefined) + + // 입찰 조건 관련 상태 + const [biddingConditions, setBiddingConditions] = React.useState({ + paymentTerms: '', + taxConditions: 'V1', + incoterms: 'DAP', + incotermsOption: '', + contractDeliveryDate: '', + shippingPort: '', + destinationPort: '', + isPriceAdjustmentApplicable: false, + sparePartOptions: '', + }) + + // Procurement 데이터 상태들 + const [paymentTermsOptions, setPaymentTermsOptions] = React.useState>([]) + const [incotermsOptions, setIncotermsOptions] = React.useState>([]) + const [shippingPlaces, setShippingPlaces] = React.useState>([]) + const [destinationPlaces, setDestinationPlaces] = React.useState>([]) + + const form = useForm({ + 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 ( +
+
+ 입찰 정보를 불러오는 중... +
+ ) + } + + return ( +
+ + + + + 입찰 기본 정보 + +

+ 입찰의 기본 정보를 수정할 수 있습니다. +

+
+ +
+ + {/* 1행: 입찰명, PR번호, 입찰유형, 계약구분 */} +
+ ( + + 입찰명 * + + + + + + )} /> + + ( + + PR 번호 + + + + + + )} /> + + ( + + 입찰유형 + + + + )} /> + + ( + + 계약구분 + + + + )} /> +
+ + {/* 기타 입찰유형 선택 시 직접입력 필드 */} + {form.watch('biddingType') === 'other' && ( +
+
+
+ ( + + 기타 입찰유형 * + + + + + + )} /> +
+
+ )} + + {/* 2행: 예산, 실적가, 내정가, 낙찰수 */} +
+ ( + + + + 예산 + + + + + + + )} /> + + ( + + + + 실적가 + + + + + + + )} /> + + ( + + + + 내정가 + + + + + + + )} /> + + ( + + 낙찰수 + + + + )} /> +
+ + {/* 3행: 입찰담당자, 조달담당자, 구매조직, 통화 */} +
+ ( + + + + 입찰담당자 + + + { + handleBidPicSelect(code) + field.onChange(code.DISPLAY_NAME || '') + }} + placeholder="입찰담당자 선택" + /> + + + + )} /> + + ( + + + + 조달담당자 + + + { + handleSupplyPicSelect(manager) + field.onChange(manager.DISPLAY_NAME || '') + }} + placeholder="조달담당자 선택" + /> + + + + )} /> + + ( + + + + 구매조직 + + + + + )} /> + + ( + + 통화 + + + + )} /> +
+ + {/* 구매유형 필드 추가 */} +
+ ( + + 구매유형 * + + + + )} /> +
+
+
+
+ + {/* 4행: 계약기간 시작/종료, 입찰서 제출 시작/마감 */} +
+ ( + + + + 계약기간 시작 + + + + + + + )} /> + + ( + + + + 계약기간 종료 + + + + + + + )} /> + + {/* ( + + 입찰서 제출 시작 + + + + + + )} /> + + ( + + 입찰서 제출 마감 + + + + + + )} /> */} +
+ + {/* 5행: 개찰 일시, 사양설명회, PR문서 */} + {/*
+ ( + + 개찰 일시 + + + + + + )} /> */} + + {/* ( + +
+ 사양설명회 +
+ 사양설명회가 필요한 경우 체크 +
+
+ + + +
+ )} /> */} + + {/* ( + +
+ PR 문서 +
+ PR 문서가 있는 경우 체크 +
+
+ + + +
+ )} /> */} + {/*
*/} + + {/* 입찰개요 */} +
+ ( + + 입찰개요 + +