'use client' import * as React from 'react' import { UseFormReturn } from 'react-hook-form' import { ChevronRight, Upload, FileText, Eye, User, Building, Calendar, DollarSign, Plus, Check, ChevronsUpDown } 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' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '@/components/ui/command' import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover' import { cn } from '@/lib/utils' import type { CreateBiddingSchema } from '@/lib/bidding/validation' import { contractTypeLabels, biddingTypeLabels, awardCountLabels, biddingNoticeTypeLabels } from '@/db/schema' import { getIncotermsForSelection, getPaymentTermsForSelection, getPlaceOfShippingForSelection, getPlaceOfDestinationForSelection, } from '@/lib/procurement-select/service' import { TAX_CONDITIONS } from '@/lib/tax-conditions/types' import { Dropzone, DropzoneDescription, DropzoneInput, DropzoneTitle, DropzoneTrigger, DropzoneUploadIcon, DropzoneZone, } from "@/components/ui/dropzone" 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 { createBidding } from '@/lib/bidding/service' import { useSession } from 'next-auth/react' import { useRouter } from 'next/navigation' interface BiddingCreateDialogProps { form: UseFormReturn onSuccess?: () => void } export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProps) { const { data: session } = useSession() const router = useRouter() const userId = session?.user?.id ? Number(session.user.id) : null; const [isOpen, setIsOpen] = React.useState(false) const [isSubmitting, setIsSubmitting] = React.useState(false) const [paymentTermsOptions, setPaymentTermsOptions] = React.useState>([]) const [incotermsOptions, setIncotermsOptions] = React.useState>([]) const [shippingPlaces, setShippingPlaces] = React.useState>([]) const [destinationPlaces, setDestinationPlaces] = React.useState>([]) const [biddingConditions, setBiddingConditions] = React.useState({ paymentTerms: '', taxConditions: 'V1', incoterms: 'DAP', incotermsOption: '', contractDeliveryDate: '', shippingPort: '', destinationPort: '', isPriceAdjustmentApplicable: false, sparePartOptions: '', }) // 구매요청자 정보 (현재 사용자) // React.useEffect(() => { // // 실제로는 현재 로그인한 사용자의 정보를 가져와야 함 // // 임시로 기본값 설정 // form.setValue('requesterName', '김두진') // 실제로는 API에서 가져와야 함 // }, [form]) const [shiAttachmentFiles, setShiAttachmentFiles] = React.useState([]) const [vendorAttachmentFiles, setVendorAttachmentFiles] = React.useState([]) // 담당자 selector 상태 const [selectedBidPic, setSelectedBidPic] = React.useState(undefined) const [selectedSupplyPic, setSelectedSupplyPic] = React.useState(undefined) // -- 데이터 로딩 및 상태 동기화 로직 const loadPaymentTerms = React.useCallback(async () => { try { const data = await getPaymentTermsForSelection() setPaymentTermsOptions(data) const p008Exists = data.some((item) => item.code === 'P008') if (p008Exists) { setBiddingConditions((prev) => ({ ...prev, paymentTerms: 'P008' })) form.setValue('biddingConditions.paymentTerms', 'P008') } } catch (error) { console.error('Failed to load payment terms:', error) toast.error('결제조건 목록을 불러오는데 실패했습니다.') } }, [form]) const loadIncoterms = React.useCallback(async () => { try { const data = await getIncotermsForSelection() setIncotermsOptions(data) const dapExists = data.some((item) => item.code === 'DAP') if (dapExists) { setBiddingConditions((prev) => ({ ...prev, incoterms: 'DAP' })) form.setValue('biddingConditions.incoterms', 'DAP') } } catch (error) { console.error('Failed to load incoterms:', error) toast.error('운송조건 목록을 불러오는데 실패했습니다.') } }, [form]) const loadShippingPlaces = React.useCallback(async () => { try { const data = await getPlaceOfShippingForSelection() setShippingPlaces(data) } catch (error) { console.error('Failed to load shipping places:', error) toast.error('선적지 목록을 불러오는데 실패했습니다.') } }, []) const loadDestinationPlaces = React.useCallback(async () => { try { const data = await getPlaceOfDestinationForSelection() setDestinationPlaces(data) } catch (error) { console.error('Failed to load destination places:', error) toast.error('하역지 목록을 불러오는데 실패했습니다.') } }, []) React.useEffect(() => { if (isOpen) { if (userId && session?.user?.name) { // 현재 사용자의 정보를 임시로 입찰담당자로 설정 form.setValue('bidPicName', session.user.name) form.setValue('bidPicId', userId) // userCode는 현재 세션에 없으므로 이름으로 설정 (실제로는 API에서 가져와야 함) // form.setValue('bidPicCode', session.user.name) } loadPaymentTerms() loadIncoterms() loadShippingPlaces() loadDestinationPlaces() const v1Exists = TAX_CONDITIONS.some((item) => item.code === 'V1') if (v1Exists) { setBiddingConditions((prev) => ({ ...prev, taxConditions: 'V1' })) form.setValue('biddingConditions.taxConditions', 'V1') } } }, [isOpen, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces, form]) // SHI용 파일 첨부 핸들러 const handleShiFileUpload = (event: React.ChangeEvent) => { const files = Array.from(event.target.files || []) setShiAttachmentFiles(prev => [...prev, ...files]) } const removeShiFile = (index: number) => { setShiAttachmentFiles(prev => prev.filter((_, i) => i !== index)) } // 협력업체용 파일 첨부 핸들러 const handleVendorFileUpload = (event: React.ChangeEvent) => { const files = Array.from(event.target.files || []) setVendorAttachmentFiles(prev => [...prev, ...files]) } const removeVendorFile = (index: number) => { setVendorAttachmentFiles(prev => prev.filter((_, i) => i !== index)) } // 입찰담당자 선택 핸들러 const handleBidPicSelect = (code: PurchaseGroupCodeWithUser) => { setSelectedBidPic(code) form.setValue('bidPicName', code.DISPLAY_NAME || '') form.setValue('bidPicCode', code.PURCHASE_GROUP_CODE || '') // ID도 저장 (실제로는 사용자 ID가 필요) if (code.user) { form.setValue('bidPicId', code.user.id || undefined) } } // 조달담당자 선택 핸들러 const handleSupplyPicSelect = (manager: ProcurementManagerWithUser) => { setSelectedSupplyPic(manager) form.setValue('supplyPicName', manager.DISPLAY_NAME || '') form.setValue('supplyPicCode', manager.PROCUREMENT_MANAGER_CODE || '') // ID도 저장 (실제로는 사용자 ID가 필요) if (manager.user) { form.setValue('supplyPicId', manager.user.id || undefined) } } const handleSubmit = async (data: CreateBiddingSchema) => { setIsSubmitting(true) try { // 폼 validation 실행 const isFormValid = await form.trigger() if (!isFormValid) { toast.error('필수 정보를 모두 입력해주세요.') return } // 첨부파일 정보 설정 // sparePartOptions가 undefined인 경우 빈 문자열로 설정 const biddingData = { ...data, attachments: shiAttachmentFiles, // 실제 파일 객체들 전달 vendorAttachments: vendorAttachmentFiles, // 실제 파일 객체들 전달 biddingConditions: { ...data.biddingConditions, sparePartOptions: data.biddingConditions.sparePartOptions || '', incotermsOption: data.biddingConditions.incotermsOption || '', contractDeliveryDate: data.biddingConditions.contractDeliveryDate || '', shippingPort: data.biddingConditions.shippingPort || '', destinationPort: data.biddingConditions.destinationPort || '', }, } const result = await createBidding(biddingData, userId?.toString() || '') // 실제로는 현재 사용자 ID if (result.success) { toast.success("입찰이 성공적으로 생성되었습니다.") // 생성된 입찰의 상세 페이지로 이동 if ('data' in result && result.data?.id) { router.push(`/evcp/bid/${result.data.id}/info`) } setIsOpen(false) form.reset() setShiAttachmentFiles([]) setVendorAttachmentFiles([]) setSelectedBidPic(undefined) setSelectedSupplyPic(undefined) if (onSuccess) { onSuccess() } } else { toast.error((result as { success: false; error: string }).error || "입찰 생성에 실패했습니다.") } } catch (error) { console.error("Failed to create bidding:", error) toast.error("입찰 생성 중 오류가 발생했습니다.") } finally { setIsSubmitting(false) } } const handleOpenChange = (open: boolean) => { setIsOpen(open) if (!open) { // 다이얼로그 닫을 때 폼 초기화 form.reset() setShiAttachmentFiles([]) setVendorAttachmentFiles([]) setSelectedBidPic(undefined) setSelectedSupplyPic(undefined) setBiddingConditions({ paymentTerms: '', taxConditions: 'V1', incoterms: 'DAP', incotermsOption: '', contractDeliveryDate: '', shippingPort: '', destinationPort: '', isPriceAdjustmentApplicable: false, sparePartOptions: '', }) } } return ( 입찰 신규생성 새로운 입찰을 생성합니다. 기본 정보와 입찰 조건을 설정하세요.
{/* 통합된 기본 정보 및 입찰 조건 카드 */} 기본 정보 및 입찰 조건 {/* 1행: 입찰명, 낙찰업체 수, 입찰유형, 계약구분 */}
( 입찰명 * )} /> ( 낙찰업체 수* )} /> ( 입찰유형 * )} /> ( 계약구분 * )} />
{/* 기타 입찰유형 선택 시 직접입력 필드 */} {form.watch('biddingType') === 'other' && (
( 기타 입찰유형 * )} />
)} {/* 2행: 예산, 실적가, 내정가, P/R번호 (조회용) */}
( 예산 )} /> ( 실적가 )} /> ( 내정가 )} /> ( P/R번호 )} />
{/* 3행: 입찰담당자, 조달담당자 */}
( 입찰담당자 * { handleBidPicSelect(code) field.onChange(code.DISPLAY_NAME || '') }} placeholder="입찰담당자 선택" /> )} /> ( 조달담당자 { handleSupplyPicSelect(manager) field.onChange(manager.DISPLAY_NAME || '') }} placeholder="조달담당자 선택" /> )} />
{/* 4행: 하도급법적용여부, SHI 지급조건 */}
( 하도급법적용여부
{ setBiddingConditions(prev => ({ ...prev, isPriceAdjustmentApplicable: checked })) field.onChange(checked) }} /> 하도급법 적용여부
)} /> ( SHI 지급조건 * 검색 결과가 없습니다. {paymentTermsOptions.map((option) => ( { setBiddingConditions(prev => ({ ...prev, paymentTerms: option.code })) field.onChange(option.code) }} > {option.code} {option.description && `(${option.description})`} ))} )} />
{/* 5행: SHI 인도조건, SHI 인도조건2 */}
( SHI 인도조건 * 검색 결과가 없습니다. {incotermsOptions.map((option) => ( { setBiddingConditions(prev => ({ ...prev, incoterms: option.code })) field.onChange(option.code) }} > {option.code} {option.description && `(${option.description})`} ))} )} /> ( SHI 인도조건2 { setBiddingConditions(prev => ({ ...prev, incotermsOption: e.target.value })) field.onChange(e.target.value) }} /> )} />
{/* 6행: SHI 매입부가가치세, SHI 선적지 */}
( SHI 매입부가가치세 * 검색 결과가 없습니다. {TAX_CONDITIONS.map((condition) => ( { setBiddingConditions(prev => ({ ...prev, taxConditions: condition.code })) field.onChange(condition.code) }} > {condition.name} ))} )} /> ( SHI 선적지 검색 결과가 없습니다. {shippingPlaces.map((place) => ( { setBiddingConditions(prev => ({ ...prev, shippingPort: place.code })) field.onChange(place.code) }} > {place.code} {place.description && `(${place.description})`} ))} )} />
{/* 7행: SHI 하역지, 계약 납품일 */}
( SHI 하역지 검색 결과가 없습니다. {destinationPlaces.map((place) => ( { setBiddingConditions(prev => ({ ...prev, destinationPort: place.code })) field.onChange(place.code) }} > {place.code} {place.description && `(${place.description})`} ))} )} /> {/* ( 계약 납품일 { setBiddingConditions(prev => ({ ...prev, contractDeliveryDate: e.target.value })) field.onChange(e.target.value) }} min="1900-01-01" max="2100-12-31" /> )} /> */}
{/* 8행: 계약기간 시작/종료, 진행상태, 구매조직 */}
( 계약기간 시작 )} /> ( 계약기간 종료 )} /> ( 진행상태 )} /> ( 구매조직 * )} />
{/* 9행: 구매요청자, 구매유형, 통화, 스페어파트 옵션 */}
( 구매요청자 )} /> ( 구매유형* )} /> ( 통화 * )} /> ( 스페어파트 옵션