diff options
Diffstat (limited to 'components/bidding/manage/bidding-basic-info-editor.tsx')
| -rw-r--r-- | components/bidding/manage/bidding-basic-info-editor.tsx | 1407 |
1 files changed, 1407 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 |
