"use client" import * as React from "react" import { useRouter } from "next/navigation" import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { Loader2, Plus, Trash2, FileText, Paperclip, CheckCircle2, ChevronRight, ChevronLeft } from "lucide-react" import { toast } from "sonner" import { useSession } from "next-auth/react" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogTrigger, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription, } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table" import { Dropzone, DropzoneDescription, DropzoneInput, DropzoneTitle, DropzoneUploadIcon, DropzoneZone, } from "@/components/ui/dropzone" import { FileList, FileListAction, FileListDescription, FileListHeader, FileListIcon, FileListInfo, FileListItem, FileListName, FileListSize, } from "@/components/ui/file-list" import { Checkbox } from "@/components/ui/checkbox" import { createBidding, type CreateBiddingInput } from "@/lib/bidding/service" import { createBiddingSchema, type CreateBiddingSchema } from "@/lib/bidding/validation" import { biddingStatusLabels, contractTypeLabels, biddingTypeLabels, awardCountLabels } from "@/db/schema" import { ProjectSelector } from "@/components/ProjectSelector" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog" // 사양설명회 정보 타입 interface SpecificationMeetingInfo { meetingDate: string meetingTime: string location: string address: string contactPerson: string contactPhone: string contactEmail: string agenda: string materials: string notes: string isRequired: boolean meetingFiles: File[] // 사양설명회 첨부파일 } // PR 아이템 정보 타입 interface PRItemInfo { id: string // 임시 ID for UI prNumber: string itemCode: string // 기존 itemNumber에서 변경 itemInfo: string quantity: string quantityUnit: string totalWeight: string weightUnit: string requestedDeliveryDate: string specFiles: File[] isRepresentative: boolean // 대표 아이템 여부 } // 탭 순서 정의 const TAB_ORDER = ["basic", "contract", "schedule", "conditions", "details", "manager"] as const type TabType = typeof TAB_ORDER[number] export function CreateBiddingDialog() { const router = useRouter() const [isSubmitting, setIsSubmitting] = React.useState(false) const { data: session } = useSession() const [open, setOpen] = React.useState(false) const [activeTab, setActiveTab] = React.useState("basic") const [showSuccessDialog, setShowSuccessDialog] = React.useState(false) // 추가 const [createdBiddingId, setCreatedBiddingId] = React.useState(null) // 추가 // 사양설명회 정보 상태 const [specMeetingInfo, setSpecMeetingInfo] = React.useState({ meetingDate: "", meetingTime: "", location: "", address: "", contactPerson: "", contactPhone: "", contactEmail: "", agenda: "", materials: "", notes: "", isRequired: false, meetingFiles: [], // 사양설명회 첨부파일 }) // PR 아이템들 상태 const [prItems, setPrItems] = React.useState([]) // 파일 첨부를 위해 선택된 아이템 ID const [selectedItemForFile, setSelectedItemForFile] = React.useState(null) // 입찰 조건 상태 const [biddingConditions, setBiddingConditions] = React.useState({ paymentTerms: "", taxConditions: "", incoterms: "", contractDeliveryDate: "", shippingPort: "", destinationPort: "", isPriceAdjustmentApplicable: false, sparePartOptions: "", }) // 사양설명회 파일 추가 const addMeetingFiles = (files: File[]) => { setSpecMeetingInfo(prev => ({ ...prev, meetingFiles: [...prev.meetingFiles, ...files] })) } // 사양설명회 파일 제거 const removeMeetingFile = (fileIndex: number) => { setSpecMeetingInfo(prev => ({ ...prev, meetingFiles: prev.meetingFiles.filter((_, index) => index !== fileIndex) })) } // PR 문서 첨부 여부 자동 계산 const hasPrDocuments = React.useMemo(() => { return prItems.some(item => item.prNumber.trim() !== "" || item.specFiles.length > 0) }, [prItems]) const form = useForm({ resolver: zodResolver(createBiddingSchema), defaultValues: { revision: 0, projectId: 0, // 임시 기본값, validation에서 체크 projectName: "", itemName: "", title: "", description: "", content: "", contractType: "general", biddingType: "equipment", awardCount: "single", contractPeriod: "", submissionStartDate: "", submissionEndDate: "", hasSpecificationMeeting: false, prNumber: "", currency: "KRW", budget: "", targetPrice: "", finalBidPrice: "", status: "bidding_generated", isPublic: false, managerName: "", managerEmail: "", managerPhone: "", remarks: "", }, }) // 현재 탭 인덱스 계산 const currentTabIndex = TAB_ORDER.indexOf(activeTab) const isLastTab = currentTabIndex === TAB_ORDER.length - 1 const isFirstTab = currentTabIndex === 0 // 다음/이전 탭으로 이동 const goToNextTab = () => { if (!isLastTab) { setActiveTab(TAB_ORDER[currentTabIndex + 1]) } } const goToPreviousTab = () => { if (!isFirstTab) { setActiveTab(TAB_ORDER[currentTabIndex - 1]) } } // 탭별 validation 상태 체크 const getTabValidationState = React.useCallback(() => { const formValues = form.getValues() const formErrors = form.formState.errors return { basic: { isValid: formValues.projectId > 0 && formValues.itemName.trim() !== "" && formValues.title.trim() !== "", hasErrors: !!(formErrors.projectId || formErrors.itemName || formErrors.title) }, contract: { isValid: formValues.contractType && formValues.biddingType && formValues.awardCount && formValues.contractPeriod.trim() !== "" && formValues.currency, hasErrors: !!(formErrors.contractType || formErrors.biddingType || formErrors.awardCount || formErrors.contractPeriod || formErrors.currency) }, schedule: { isValid: formValues.submissionStartDate && formValues.submissionEndDate && (!formValues.hasSpecificationMeeting || (specMeetingInfo.meetingDate && specMeetingInfo.location && specMeetingInfo.contactPerson)), hasErrors: !!(formErrors.submissionStartDate || formErrors.submissionEndDate) }, conditions: { isValid: biddingConditions.paymentTerms.trim() !== "" && biddingConditions.taxConditions.trim() !== "" && biddingConditions.incoterms.trim() !== "" && biddingConditions.contractDeliveryDate.trim() !== "" && biddingConditions.shippingPort.trim() !== "" && biddingConditions.destinationPort.trim() !== "", hasErrors: false }, details: { isValid: true, // 세부내역은 선택사항 hasErrors: false }, manager: { isValid: true, // 담당자 정보는 자동 설정되므로 항상 유효 hasErrors: !!(formErrors.managerName || formErrors.managerEmail || formErrors.managerPhone) } } }, [form, specMeetingInfo.meetingDate, specMeetingInfo.location, specMeetingInfo.contactPerson, biddingConditions]) const tabValidation = getTabValidationState() // 현재 탭이 유효한지 확인 const isCurrentTabValid = () => { const validation = tabValidation[activeTab as keyof typeof tabValidation] return validation?.isValid ?? true } // 대표 PR 번호 자동 계산 const representativePrNumber = React.useMemo(() => { const representativeItem = prItems.find(item => item.isRepresentative) return representativeItem?.prNumber || "" }, [prItems]) // hasPrDocument 필드와 prNumber를 자동으로 업데이트 React.useEffect(() => { form.setValue("hasPrDocument", hasPrDocuments) form.setValue("prNumber", representativePrNumber) }, [hasPrDocuments, representativePrNumber, form]) // 세션 정보로 담당자 정보 자동 채우기 React.useEffect(() => { if (session?.user) { // 담당자명 설정 if (session.user.name) { form.setValue("managerName", session.user.name) // 사양설명회 담당자도 동일하게 설정 setSpecMeetingInfo(prev => ({ ...prev, contactPerson: session.user.name || "", contactEmail: session.user.email || "", })) } // 담당자 이메일 설정 if (session.user.email) { form.setValue("managerEmail", session.user.email) } // 담당자 전화번호는 세션에 있다면 설정 (보통 세션에 전화번호는 없지만, 있다면) if (session.user.phone) { form.setValue("managerPhone", session.user.phone) } } }, [session, form]) // PR 아이템 추가 const addPRItem = () => { const newItem: PRItemInfo = { id: `pr-${Date.now()}`, prNumber: "", itemCode: "", itemInfo: "", quantity: "", quantityUnit: "EA", totalWeight: "", weightUnit: "KG", requestedDeliveryDate: "", specFiles: [], isRepresentative: prItems.length === 0, // 첫 번째 아이템은 자동으로 대표 아이템 } setPrItems(prev => [...prev, newItem]) } // PR 아이템 제거 const removePRItem = (id: string) => { setPrItems(prev => { const filteredItems = prev.filter(item => item.id !== id) // 만약 대표 아이템을 삭제했다면, 첫 번째 아이템을 대표로 설정 const removedItem = prev.find(item => item.id === id) if (removedItem?.isRepresentative && filteredItems.length > 0) { filteredItems[0].isRepresentative = true } return filteredItems }) // 파일 첨부 중인 아이템이면 선택 해제 if (selectedItemForFile === id) { setSelectedItemForFile(null) } } // PR 아이템 업데이트 const updatePRItem = (id: string, updates: Partial) => { setPrItems(prev => prev.map(item => item.id === id ? { ...item, ...updates } : item )) } // 대표 아이템 설정 (하나만 선택 가능) const setRepresentativeItem = (id: string) => { setPrItems(prev => prev.map(item => ({ ...item, isRepresentative: item.id === id }))) } // 스펙 파일 추가 const addSpecFiles = (itemId: string, files: File[]) => { updatePRItem(itemId, { specFiles: [...(prItems.find(item => item.id === itemId)?.specFiles || []), ...files] }) // 파일 추가 후 선택 해제 setSelectedItemForFile(null) } // 스펙 파일 제거 const removeSpecFile = (itemId: string, fileIndex: number) => { const item = prItems.find(item => item.id === itemId) if (item) { const newFiles = item.specFiles.filter((_, index) => index !== fileIndex) updatePRItem(itemId, { specFiles: newFiles }) } } // ✅ 프로젝트 선택 핸들러 const handleProjectSelect = React.useCallback((project: { id: number; code: string; name: string } | null) => { if (project) { form.setValue("projectId", project.id) } else { form.setValue("projectId", 0) } }, [form]) // 다음 버튼 클릭 핸들러 const handleNextClick = () => { // 현재 탭 validation 체크 if (!isCurrentTabValid()) { // 특정 탭별 에러 메시지 if (activeTab === "basic") { toast.error("기본 정보를 모두 입력해주세요 (프로젝트, 품목명, 입찰명)") } else if (activeTab === "contract") { toast.error("계약 정보를 모두 입력해주세요") } else if (activeTab === "schedule") { if (form.watch("hasSpecificationMeeting")) { toast.error("사양설명회 필수 정보를 입력해주세요 (회의일시, 장소, 담당자)") } else { toast.error("제출 시작일시와 마감일시를 입력해주세요") } } else if (activeTab === "conditions") { toast.error("입찰 조건을 모두 입력해주세요 (지급조건, 세금조건, 운송조건, 계약납품일, 선적지, 도착지)") } return } goToNextTab() } // 폼 제출 async function onSubmit(data: CreateBiddingSchema) { // 사양설명회 필수값 검증 if (data.hasSpecificationMeeting) { const requiredFields = [ { field: specMeetingInfo.meetingDate, name: "회의일시" }, { field: specMeetingInfo.location, name: "회의 장소" }, { field: specMeetingInfo.contactPerson, name: "담당자" } ] const missingFields = requiredFields.filter(item => !item.field.trim()) if (missingFields.length > 0) { toast.error(`사양설명회 필수 정보가 누락되었습니다: ${missingFields.map(f => f.name).join(", ")}`) setActiveTab("schedule") return } } setIsSubmitting(true) try { const userId = session?.user?.id?.toString() || "1" // 추가 데이터 준비 const extendedData = { ...data, hasPrDocument: hasPrDocuments, // 자동 계산된 값 사용 prNumber: representativePrNumber, // 대표 아이템의 PR 번호 사용 specificationMeeting: data.hasSpecificationMeeting ? { ...specMeetingInfo, meetingFiles: specMeetingInfo.meetingFiles } : null, prItems: prItems.length > 0 ? prItems : [], biddingConditions: biddingConditions, } const result = await createBidding(extendedData, userId) if (result.success) { toast.success(result.message || "입찰이 성공적으로 생성되었습니다.") setOpen(false) router.refresh() // 생성된 입찰 상세페이지로 이동할지 묻기 if (result.success && 'data' in result && result.data?.id) { setCreatedBiddingId(result.data.id) setShowSuccessDialog(true) } } else { const errorMessage = result.success === false && 'error' in result ? result.error : "입찰 생성에 실패했습니다." toast.error(errorMessage) } } catch (error) { console.error("Error creating bidding:", error) toast.error("입찰 생성 중 오류가 발생했습니다.") } finally { setIsSubmitting(false) } } // 폼 및 상태 초기화 함수 const resetAllStates = React.useCallback(() => { // 폼 초기화 form.reset({ revision: 0, projectId: 0, projectName: "", itemName: "", title: "", description: "", content: "", contractType: "general", biddingType: "equipment", awardCount: "single", contractPeriod: "", submissionStartDate: "", submissionEndDate: "", hasSpecificationMeeting: false, prNumber: "", currency: "KRW", budget: "", targetPrice: "", finalBidPrice: "", status: "bidding_generated", isPublic: false, managerName: "", managerEmail: "", managerPhone: "", remarks: "", }) // 추가 상태들 초기화 setSpecMeetingInfo({ meetingDate: "", meetingTime: "", location: "", address: "", contactPerson: "", contactPhone: "", contactEmail: "", agenda: "", materials: "", notes: "", isRequired: false, meetingFiles: [], }) setPrItems([]) setSelectedItemForFile(null) setBiddingConditions({ paymentTerms: "", taxConditions: "", incoterms: "", contractDeliveryDate: "", shippingPort: "", destinationPort: "", isPriceAdjustmentApplicable: false, sparePartOptions: "", }) setActiveTab("basic") setShowSuccessDialog(false) // 추가 setCreatedBiddingId(null) // 추가 }, [form]) // 다이얼로그 핸들러 function handleDialogOpenChange(nextOpen: boolean) { if (!nextOpen) { resetAllStates() } setOpen(nextOpen) } // 입찰 생성 버튼 클릭 핸들러 추가 const handleCreateBidding = () => { // 마지막 탭 validation 체크 if (!isCurrentTabValid()) { toast.error("필수 정보를 모두 입력해주세요.") return } // 수동으로 폼 제출 form.handleSubmit(onSubmit)() } // 성공 다이얼로그 핸들러들 const handleNavigateToDetail = () => { if (createdBiddingId) { router.push(`/evcp/bid/${createdBiddingId}`) } setShowSuccessDialog(false) setCreatedBiddingId(null) } const handleStayOnPage = () => { setShowSuccessDialog(false) setCreatedBiddingId(null) } return ( <> {/* 고정 헤더 */}
신규 입찰 생성 새로운 입찰을 생성합니다. 단계별로 정보를 입력해주세요.
{/* 탭 영역 */}
{/* 기본 정보 탭 */} 기본 정보 {/* 프로젝트 선택 */} ( 프로젝트 * )} />
{/* 품목명 */} ( 품목명 * )} /> {/* 리비전 */} ( 리비전 field.onChange(parseInt(e.target.value) || 0)} /> )} />
{/* 입찰명 */} ( 입찰명 * )} /> {/* 설명 */} ( 설명