'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 문서가 있는 경우 체크
)} /> */} {/*
*/} {/* 입찰개요 */}
( 입찰개요