"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" // 사양설명회 정보 타입 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 requestedDeliveryDate: string specFiles: File[] isRepresentative: boolean // 대표 아이템 여부 } // 탭 순서 정의 const TAB_ORDER = ["basic", "contract", "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 [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 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) }, details: { isValid: true, // 세부내역은 선택사항 hasErrors: false }, manager: { isValid: true, // 담당자 정보는 자동 설정되므로 항상 유효 hasErrors: !!(formErrors.managerName || formErrors.managerEmail || formErrors.managerPhone) } } }, [form, specMeetingInfo.meetingDate, specMeetingInfo.location, specMeetingInfo.contactPerson]) 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", 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) form.setValue("projectName", `${project.code} (${project.name})`) } else { form.setValue("projectId", 0) form.setValue("projectName", "") } }, [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("제출 시작일시와 마감일시를 입력해주세요") } } 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 : [], } const result = await createBidding(extendedData, userId) if (result.success) { toast.success(result.message) setOpen(false) router.refresh() // 생성된 입찰 상세페이지로 이동할지 묻기 if (result.data?.id) { setTimeout(() => { if (confirm("생성된 입찰의 상세페이지로 이동하시겠습니까?")) { router.push(`/admin/biddings/${result.data.id}`) } }, 500) } } else { toast.error(result.error || "입찰 생성에 실패했습니다.") } } 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) setActiveTab("basic") }, [form]) // 다이얼로그 핸들러 function handleDialogOpenChange(nextOpen: boolean) { if (!nextOpen) { resetAllStates() } setOpen(nextOpen) } return ( {/* 고정 헤더 */}
신규 입찰 생성 새로운 입찰을 생성합니다. 단계별로 정보를 입력해주세요.
{/* 탭 영역 */}
기본 정보 {!tabValidation.basic.isValid && ( )} 계약 정보 {!tabValidation.contract.isValid && ( )} 일정 & 회의 {!tabValidation.schedule.isValid && ( )} 세부내역 담당자 & 기타
{/* 기본 정보 탭 */} 기본 정보 {/* 프로젝트 선택 */} ( 프로젝트 * )} />
{/* 품목명 */} ( 품목명 * )} /> {/* 리비전 */} ( 리비전 field.onChange(parseInt(e.target.value) || 0)} /> )} />
{/* 입찰명 */} ( 입찰명 * )} /> {/* 설명 */} ( 설명