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/create/bidding-create-dialog.tsx | 1281 ++++++++++++++++++++ 1 file changed, 1281 insertions(+) create mode 100644 components/bidding/create/bidding-create-dialog.tsx (limited to 'components/bidding/create/bidding-create-dialog.tsx') diff --git a/components/bidding/create/bidding-create-dialog.tsx b/components/bidding/create/bidding-create-dialog.tsx new file mode 100644 index 00000000..4ef403c9 --- /dev/null +++ b/components/bidding/create/bidding-create-dialog.tsx @@ -0,0 +1,1281 @@ +'use client' + +import * as React from 'react' +import { UseFormReturn } from 'react-hook-form' +import { ChevronRight, Upload, FileText, Eye, User, Building, Calendar, DollarSign, Plus } 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 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 { getBiddingNoticeTemplate } from '@/lib/bidding/service' +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 { createBidding } from '@/lib/bidding/service' + +interface BiddingCreateDialogProps { + form: UseFormReturn + onSuccess?: () => void +} + +export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProps) { + 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 [noticeTemplate, setNoticeTemplate] = React.useState('') + const [isLoadingTemplate, setIsLoadingTemplate] = React.useState(false) + + // -- 데이터 로딩 및 상태 동기화 로직 + 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) { + 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') + } + + // 초기 표준 템플릿 로드 + const loadInitialTemplate = async () => { + try { + const standardTemplate = await getBiddingNoticeTemplate('standard') + if (standardTemplate) { + console.log('standardTemplate', standardTemplate) + setNoticeTemplate(standardTemplate.content) + form.setValue('content', standardTemplate.content) + } + } catch (error) { + console.error('Failed to load initial template:', error) + toast.error('기본 템플릿을 불러오는데 실패했습니다.') + } + } + loadInitialTemplate() + } + }, [isOpen, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces, form]) + + // 입찰공고 템플릿 로딩 + const noticeTypeValue = form.watch('noticeType') + const selectedNoticeType = React.useMemo(() => noticeTypeValue, [noticeTypeValue]) + + React.useEffect(() => { + const loadNoticeTemplate = async () => { + if (selectedNoticeType) { + setIsLoadingTemplate(true) + try { + const template = await getBiddingNoticeTemplate(selectedNoticeType) + if (template) { + setNoticeTemplate(template.content) + // 폼의 content 필드도 업데이트 + form.setValue('content', template.content) + } else { + // 템플릿이 없으면 표준 템플릿 사용 + const defaultTemplate = await getBiddingNoticeTemplate('standard') + if (defaultTemplate) { + setNoticeTemplate(defaultTemplate.content) + form.setValue('content', defaultTemplate.content) + } + } + } catch (error) { + console.error('Failed to load notice template:', error) + toast.error('입찰공고 템플릿을 불러오는데 실패했습니다.') + } finally { + setIsLoadingTemplate(false) + } + } + } + + loadNoticeTemplate() + }, [selectedNoticeType, 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 + } + + // 첨부파일 정보 설정 (실제로는 파일 업로드 후 저장해야 함) + const attachments = shiAttachmentFiles.map((file, index) => ({ + id: `shi_${Date.now()}_${index}`, + fileName: file.name, + fileSize: file.size, + filePath: '', // 실제 업로드 후 경로 + uploadedAt: new Date().toISOString(), + type: 'shi' as const, + })) + + const vendorAttachments = vendorAttachmentFiles.map((file, index) => ({ + id: `vendor_${Date.now()}_${index}`, + fileName: file.name, + fileSize: file.size, + filePath: '', // 실제 업로드 후 경로 + uploadedAt: new Date().toISOString(), + type: 'vendor' as const, + })) + + // sparePartOptions가 undefined인 경우 빈 문자열로 설정 + const biddingData: CreateBiddingInput = { + ...data, + attachments, + vendorAttachments, + 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, '1') // 실제로는 현재 사용자 ID + + if (result.success) { + toast.success("입찰이 성공적으로 생성되었습니다.") + setIsOpen(false) + form.reset() + setShiAttachmentFiles([]) + setVendorAttachmentFiles([]) + setSelectedBidPic(undefined) + setSelectedSupplyPic(undefined) + setNoticeTemplate('') + 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) + setNoticeTemplate('') + 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 지급조건 * + + + + + + )} + /> +
+ + {/* 5행: SHI 인도조건, SHI 인도조건2 */} +
+ ( + + SHI 인도조건 * + + + + )} + /> + + ( + + SHI 인도조건2 + + { + setBiddingConditions(prev => ({ + ...prev, + incotermsOption: e.target.value + })) + field.onChange(e.target.value) + }} + /> + + + + )} + /> +
+ + {/* 6행: SHI 매입부가가치세, SHI 선적지 */} +
+ ( + + SHI 매입부가가치세 * + + + + + + )} + /> + + ( + + SHI 선적지 + + + + )} + /> +
+ + {/* 7행: SHI 하역지, 계약 납품일 */} +
+ ( + + SHI 하역지 + + + + )} + /> + + ( + + 계약 납품일 + + { + setBiddingConditions(prev => ({ + ...prev, + contractDeliveryDate: e.target.value + })) + field.onChange(e.target.value) + }} + /> + + + + )} + /> +
+ + {/* 8행: 계약기간 시작/종료, 진행상태, 구매조직 */} +
+ ( + + + + 계약기간 시작 + + + + + + + )} + /> + + ( + + + + 계약기간 종료 + + + + + + + )} + /> + + ( + + + + 진행상태 + + + + + + + )} + /> + + ( + + + + 구매조직 + + + + + )} + /> +
+ + {/* 9행: 구매요청자, 구매유형, 통화, 스페어파트 옵션 */} +
+ ( + + + + 구매요청자 + + + + + + + )} + /> + + ( + + 구매유형 * + + + + )} + /> + + ( + + 통화 * + + + + )} + /> + + ( + + 스페어파트 옵션 + +