"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 { getIncotermsForSelection, getPaymentTermsForSelection, getPlaceOfShippingForSelection, getPlaceOfDestinationForSelection } from "@/lib/procurement-select/service" import { TAX_CONDITIONS } from "@/lib/tax-conditions/types" 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 materialDescription: string hasSpecDocument: boolean requestedDeliveryDate: string specFiles: File[] isRepresentative: boolean // 대표 아이템 여부 } // 탭 순서 정의 const TAB_ORDER = ["basic", "schedule", "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 [showCloseConfirmDialog, setShowCloseConfirmDialog] = React.useState(false) // 닫기 확인 다이얼로그 상태 // Procurement 데이터 상태들 const [paymentTermsOptions, setPaymentTermsOptions] = React.useState>([]) const [incotermsOptions, setIncotermsOptions] = React.useState>([]) const [shippingPlaces, setShippingPlaces] = React.useState>([]) const [destinationPlaces, setDestinationPlaces] = React.useState>([]) const [procurementLoading, setProcurementLoading] = React.useState(false) // 사양설명회 정보 상태 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: `pr-default`, prNumber: "", itemCode: "", itemInfo: "", quantity: "", quantityUnit: "EA", totalWeight: "", weightUnit: "KG", materialDescription: "", hasSpecDocument: false, requestedDeliveryDate: "", specFiles: [], isRepresentative: true, // 첫 번째 아이템은 대표 아이템 } ]) // 파일 첨부를 위해 선택된 아이템 ID const [selectedItemForFile, setSelectedItemForFile] = React.useState(null) // 입찰 조건 상태 (기본값 설정 포함) const [biddingConditions, setBiddingConditions] = React.useState({ paymentTerms: "", // 초기값 빈값, 데이터 로드 후 설정 taxConditions: "", // 초기값 빈값, 데이터 로드 후 설정 incoterms: "", // 초기값 빈값, 데이터 로드 후 설정 contractDeliveryDate: "", shippingPort: "", destinationPort: "", isPriceAdjustmentApplicable: false, sparePartOptions: "", }) // Procurement 데이터 로드 함수들 const loadPaymentTerms = React.useCallback(async () => { setProcurementLoading(true); try { const data = await getPaymentTermsForSelection(); setPaymentTermsOptions(data); // 기본값 설정 로직: P008이 있으면 P008로, 없으면 첫 번째 항목으로 설정 const setDefaultPaymentTerms = () => { const p008Exists = data.some(item => item.code === "P008"); if (p008Exists) { setBiddingConditions(prev => ({ ...prev, paymentTerms: "P008" })); } }; setDefaultPaymentTerms(); } catch (error) { console.error("Failed to load payment terms:", error); toast.error("결제조건 목록을 불러오는데 실패했습니다."); // 에러 시 기본값 초기화 if (biddingConditions.paymentTerms === "P008") { setBiddingConditions(prev => ({ ...prev, paymentTerms: "" })); } } finally { setProcurementLoading(false); } }, [biddingConditions.paymentTerms]); const loadIncoterms = React.useCallback(async () => { setProcurementLoading(true); try { const data = await getIncotermsForSelection(); setIncotermsOptions(data); // 기본값 설정 로직: DAP가 있으면 DAP로, 없으면 첫 번째 항목으로 설정 const setDefaultIncoterms = () => { const dapExists = data.some(item => item.code === "DAP"); if (dapExists) { setBiddingConditions(prev => ({ ...prev, incoterms: "DAP" })); } }; setDefaultIncoterms(); } catch (error) { console.error("Failed to load incoterms:", error); toast.error("운송조건 목록을 불러오는데 실패했습니다."); } finally { setProcurementLoading(false); } }, [biddingConditions.incoterms]); const loadShippingPlaces = React.useCallback(async () => { setProcurementLoading(true); try { const data = await getPlaceOfShippingForSelection(); setShippingPlaces(data); } catch (error) { console.error("Failed to load shipping places:", error); toast.error("선적지 목록을 불러오는데 실패했습니다."); } finally { setProcurementLoading(false); } }, []); const loadDestinationPlaces = React.useCallback(async () => { setProcurementLoading(true); try { const data = await getPlaceOfDestinationForSelection(); setDestinationPlaces(data); } catch (error) { console.error("Failed to load destination places:", error); toast.error("하역지 목록을 불러오는데 실패했습니다."); } finally { setProcurementLoading(false); } }, []); // 다이얼로그 열릴 때 procurement 데이터 로드 및 기본값 설정 React.useEffect(() => { if (open) { loadPaymentTerms(); loadIncoterms(); loadShippingPlaces(); loadDestinationPlaces(); // 세금조건 기본값 설정 (V1이 있는지 확인하고 설정) const v1Exists = TAX_CONDITIONS.some(item => item.code === "V1"); if (v1Exists) { setBiddingConditions(prev => ({ ...prev, taxConditions: "V1" })); } } }, [open, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces]) // 사양설명회 파일 추가 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", biddingTypeCustom: "", awardCount: "single", contractStartDate: "", contractEndDate: "", 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.title.trim() !== "", hasErrors: !!(formErrors.title) }, contract: { isValid: formValues.contractType && formValues.biddingType && formValues.awardCount && formValues.contractStartDate && formValues.contractEndDate && formValues.currency, hasErrors: !!(formErrors.contractType || formErrors.biddingType || formErrors.awardCount || formErrors.contractStartDate || formErrors.contractEndDate || 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: prItems.length > 0, 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]) // 대표 품목명 자동 계산 (첫 번째 PR 아이템의 itemInfo) const representativeItemName = React.useMemo(() => { const representativeItem = prItems.find(item => item.isRepresentative) return representativeItem?.itemInfo || "" }, [prItems]) // hasPrDocument 필드와 prNumber, itemName을 자동으로 업데이트 React.useEffect(() => { form.setValue("hasPrDocument", hasPrDocuments) form.setValue("prNumber", representativePrNumber) form.setValue("itemName", representativeItemName) }, [hasPrDocuments, representativePrNumber, representativeItemName, 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 ('phone' in session.user && session.user.phone) { form.setValue("managerPhone", session.user.phone as string) } } }, [session, form]) // PR 아이템 추가 const addPRItem = () => { const newItem: PRItemInfo = { id: `pr-${Math.random().toString(36).substr(2, 9)}`, prNumber: "", itemCode: "", itemInfo: "", quantity: "", quantityUnit: "EA", totalWeight: "", weightUnit: "KG", materialDescription: "", hasSpecDocument: false, requestedDeliveryDate: "", specFiles: [], isRepresentative: prItems.length === 0, // 첫 번째 아이템은 자동으로 대표 아이템 } setPrItems(prev => [...prev, newItem]) } // PR 아이템 제거 const removePRItem = (id: string) => { // 최소 하나의 아이템은 유지해야 함 if (prItems.length <= 1) { toast.error("최소 하나의 품목이 필요합니다.") return } 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("입찰 조건을 모두 입력해주세요 (지급조건, 세금조건, 운송조건, 계약납품일)") } else if (activeTab === "details") { 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 as { success: true; message: string }).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", biddingTypeCustom: "", awardCount: "single", contractStartDate: "", contractEndDate: "", submissionStartDate: "", submissionEndDate: "", hasSpecificationMeeting: false, prNumber: "", currency: "KRW", status: "bidding_generated", isPublic: false, managerName: "", managerEmail: "", managerPhone: "", remarks: "", }) // 추가 상태들 초기화 setSpecMeetingInfo({ meetingDate: "", meetingTime: "", location: "", address: "", contactPerson: "", contactPhone: "", contactEmail: "", agenda: "", materials: "", notes: "", isRequired: false, meetingFiles: [], }) setPrItems([ { id: `pr-default`, prNumber: "", itemCode: "", itemInfo: "", quantity: "", quantityUnit: "EA", totalWeight: "", weightUnit: "KG", materialDescription: "", hasSpecDocument: false, requestedDeliveryDate: "", specFiles: [], isRepresentative: true, // 첫 번째 아이템은 대표 아이템 } ]) 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) { // 닫으려 할 때 확인 창을 먼저 띄움 setShowCloseConfirmDialog(true) } else { // 열 때는 바로 적용 setOpen(nextOpen) } } // 닫기 확인 핸들러 const handleCloseConfirm = (confirmed: boolean) => { setShowCloseConfirmDialog(false) if (confirmed) { // 사용자가 "예"를 선택한 경우 실제로 닫기 resetAllStates() setOpen(false) } // "아니오"를 선택한 경우는 아무것도 하지 않음 (다이얼로그 유지) } // 입찰 생성 버튼 클릭 핸들러 추가 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 ( <> {/* 고정 헤더 */}
신규 입찰 생성 새로운 입찰을 생성합니다. 단계별로 정보를 입력해주세요.
{/* 탭 영역 */}
setActiveTab(value as TabType)} className="h-full flex flex-col">
{/* 기본 정보 탭 */} 기본 정보 및 계약 정보 {/* 프로젝트 선택 */} ( 프로젝트 )} /> {/*
*/} {/* 품목명 */} {/* ( 품목명 * )} /> */} {/* 리비전 */} {/* ( 리비전 field.onChange(parseInt(e.target.value) || 0)} /> )} /> */} {/*
*/} {/* 입찰명 */} ( 입찰명 * )} /> {/* 설명 */} ( 설명