From cbb4c7fe0b94459162ad5e998bc05cd293e0ff96 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 11 Aug 2025 09:02:00 +0000 Subject: (대표님) 입찰, EDP 변경사항 대응, spreadJS 오류 수정, 벤더실사 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bidding/list/create-bidding-dialog.tsx | 1674 ++++++++++++++++++++++++++++ 1 file changed, 1674 insertions(+) create mode 100644 lib/bidding/list/create-bidding-dialog.tsx (limited to 'lib/bidding/list/create-bidding-dialog.tsx') diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx new file mode 100644 index 00000000..683f6aff --- /dev/null +++ b/lib/bidding/list/create-bidding-dialog.tsx @@ -0,0 +1,1674 @@ +"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)} + /> + + + + )} + /> +
+ + {/* 입찰명 */} + ( + + + 입찰명 * + + + + + + + )} + /> + + {/* 설명 */} + ( + + 설명 + +