diff options
Diffstat (limited to 'lib/bidding/list/create-bidding-dialog.tsx')
| -rw-r--r-- | lib/bidding/list/create-bidding-dialog.tsx | 1674 |
1 files changed, 1674 insertions, 0 deletions
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<TabType>("basic") + + // 사양설명회 정보 상태 + const [specMeetingInfo, setSpecMeetingInfo] = React.useState<SpecificationMeetingInfo>({ + meetingDate: "", + meetingTime: "", + location: "", + address: "", + contactPerson: "", + contactPhone: "", + contactEmail: "", + agenda: "", + materials: "", + notes: "", + isRequired: false, + meetingFiles: [], // 사양설명회 첨부파일 + }) + + // PR 아이템들 상태 + const [prItems, setPrItems] = React.useState<PRItemInfo[]>([]) + + // 파일 첨부를 위해 선택된 아이템 ID + const [selectedItemForFile, setSelectedItemForFile] = React.useState<string | null>(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<CreateBiddingSchema>({ + 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<PRItemInfo>) => { + 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 ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + <DialogTrigger asChild> + <Button variant="default" size="sm"> + 신규 입찰 + </Button> + </DialogTrigger> + <DialogContent className="max-w-6xl h-[90vh] p-0 flex flex-col"> + {/* 고정 헤더 */} + <div className="flex-shrink-0 p-6 border-b"> + <DialogHeader> + <DialogTitle>신규 입찰 생성</DialogTitle> + <DialogDescription> + 새로운 입찰을 생성합니다. 단계별로 정보를 입력해주세요. + </DialogDescription> + </DialogHeader> + </div> + + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col flex-1 min-h-0" + id="create-bidding-form" + > + {/* 탭 영역 */} + <div className="flex-1 overflow-hidden"> + <Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col"> + <div className="px-6 pt-4"> + <TabsList className="grid w-full grid-cols-5"> + <TabsTrigger value="basic" className="relative"> + 기본 정보 + {!tabValidation.basic.isValid && ( + <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span> + )} + </TabsTrigger> + <TabsTrigger value="contract" className="relative"> + 계약 정보 + {!tabValidation.contract.isValid && ( + <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span> + )} + </TabsTrigger> + <TabsTrigger value="schedule" className="relative"> + 일정 & 회의 + {!tabValidation.schedule.isValid && ( + <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span> + )} + </TabsTrigger> + <TabsTrigger value="details">세부내역</TabsTrigger> + <TabsTrigger value="manager">담당자 & 기타</TabsTrigger> + </TabsList> + </div> + + <div className="flex-1 overflow-y-auto p-6"> + {/* 기본 정보 탭 */} + <TabsContent value="basic" className="mt-0 space-y-6"> + <Card> + <CardHeader> + <CardTitle>기본 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-6"> + {/* 프로젝트 선택 */} + <FormField + control={form.control} + name="projectId" + render={({ field }) => ( + <FormItem> + <FormLabel> + 프로젝트 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <ProjectSelector + selectedProjectId={field.value} + onProjectSelect={handleProjectSelect} + placeholder="프로젝트 선택..." + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <div className="grid grid-cols-2 gap-6"> + {/* 품목명 */} + <FormField + control={form.control} + name="itemName" + render={({ field }) => ( + <FormItem> + <FormLabel> + 품목명 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="품목명" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 리비전 */} + <FormField + control={form.control} + name="revision" + render={({ field }) => ( + <FormItem> + <FormLabel>리비전</FormLabel> + <FormControl> + <Input + type="number" + min="0" + {...field} + onChange={(e) => field.onChange(parseInt(e.target.value) || 0)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 입찰명 */} + <FormField + control={form.control} + name="title" + render={({ field }) => ( + <FormItem> + <FormLabel> + 입찰명 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="입찰명을 입력하세요" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 설명 */} + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명</FormLabel> + <FormControl> + <Textarea + placeholder="입찰에 대한 설명을 입력하세요" + rows={4} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </CardContent> + </Card> + </TabsContent> + + {/* 계약 정보 탭 */} + <TabsContent value="contract" className="mt-0 space-y-6"> + <Card> + <CardHeader> + <CardTitle>계약 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-6"> + <div className="grid grid-cols-2 gap-6"> + {/* 계약구분 */} + <FormField + control={form.control} + name="contractType" + render={({ field }) => ( + <FormItem> + <FormLabel> + 계약구분 <span className="text-red-500">*</span> + </FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="계약구분 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {Object.entries(contractTypeLabels).map(([value, label]) => ( + <SelectItem key={value} value={value}> + {label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 입찰유형 */} + <FormField + control={form.control} + name="biddingType" + render={({ field }) => ( + <FormItem> + <FormLabel> + 입찰유형 <span className="text-red-500">*</span> + </FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="입찰유형 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {Object.entries(biddingTypeLabels).map(([value, label]) => ( + <SelectItem key={value} value={value}> + {label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <div className="grid grid-cols-2 gap-6"> + {/* 낙찰수 */} + <FormField + control={form.control} + name="awardCount" + render={({ field }) => ( + <FormItem> + <FormLabel> + 낙찰수 <span className="text-red-500">*</span> + </FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="낙찰수 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {Object.entries(awardCountLabels).map(([value, label]) => ( + <SelectItem key={value} value={value}> + {label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 계약기간 */} + <FormField + control={form.control} + name="contractPeriod" + render={({ field }) => ( + <FormItem> + <FormLabel> + 계약기간 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="예: 계약일로부터 60일" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </CardContent> + </Card> + + <Card> + <CardHeader> + <CardTitle>가격 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-6"> + {/* 통화 */} + <FormField + control={form.control} + name="currency" + render={({ field }) => ( + <FormItem> + <FormLabel> + 통화 <span className="text-red-500">*</span> + </FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="통화 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="KRW">KRW (원)</SelectItem> + <SelectItem value="USD">USD (달러)</SelectItem> + <SelectItem value="EUR">EUR (유로)</SelectItem> + <SelectItem value="JPY">JPY (엔)</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <div className="grid grid-cols-3 gap-6"> + {/* 예산 */} + <FormField + control={form.control} + name="budget" + render={({ field }) => ( + <FormItem> + <FormLabel>예산</FormLabel> + <FormControl> + <Input + type="number" + step="0.01" + placeholder="0" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 내정가 */} + <FormField + control={form.control} + name="targetPrice" + render={({ field }) => ( + <FormItem> + <FormLabel>내정가</FormLabel> + <FormControl> + <Input + type="number" + step="0.01" + placeholder="0" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 최종입찰가 */} + <FormField + control={form.control} + name="finalBidPrice" + render={({ field }) => ( + <FormItem> + <FormLabel>최종입찰가</FormLabel> + <FormControl> + <Input + type="number" + step="0.01" + placeholder="0" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </CardContent> + </Card> + </TabsContent> + + {/* 일정 & 회의 탭 */} + <TabsContent value="schedule" className="mt-0 space-y-6"> + <Card> + <CardHeader> + <CardTitle>일정 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-6"> + <div className="grid grid-cols-2 gap-6"> + {/* 제출시작일시 */} + <FormField + control={form.control} + name="submissionStartDate" + render={({ field }) => ( + <FormItem> + <FormLabel> + 제출시작일시 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + type="datetime-local" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 제출마감일시 */} + <FormField + control={form.control} + name="submissionEndDate" + render={({ field }) => ( + <FormItem> + <FormLabel> + 제출마감일시 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + type="datetime-local" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </CardContent> + </Card> + + {/* 사양설명회 */} + <Card> + <CardHeader> + <CardTitle>사양설명회</CardTitle> + </CardHeader> + <CardContent className="space-y-6"> + <FormField + control={form.control} + name="hasSpecificationMeeting" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4"> + <div className="space-y-0.5"> + <FormLabel className="text-base"> + 사양설명회 실시 + </FormLabel> + <FormDescription> + 사양설명회를 실시할 경우 상세 정보를 입력하세요 + </FormDescription> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + </FormItem> + )} + /> + + {/* 사양설명회 정보 (조건부 표시) */} + {form.watch("hasSpecificationMeeting") && ( + <div className="space-y-6 p-4 border rounded-lg bg-muted/50"> + <div className="grid grid-cols-2 gap-4"> + <div> + <label className="text-sm font-medium"> + 회의일시 <span className="text-red-500">*</span> + </label> + <Input + type="datetime-local" + value={specMeetingInfo.meetingDate} + onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, meetingDate: e.target.value }))} + className={!specMeetingInfo.meetingDate ? 'border-red-200' : ''} + /> + {!specMeetingInfo.meetingDate && ( + <p className="text-sm text-red-500 mt-1">회의일시는 필수입니다</p> + )} + </div> + <div> + <label className="text-sm font-medium">회의시간</label> + <Input + placeholder="예: 14:00 ~ 16:00" + value={specMeetingInfo.meetingTime} + onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, meetingTime: e.target.value }))} + /> + </div> + </div> + + <div> + <label className="text-sm font-medium"> + 장소 <span className="text-red-500">*</span> + </label> + <Input + placeholder="회의 장소" + value={specMeetingInfo.location} + onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, location: e.target.value }))} + className={!specMeetingInfo.location ? 'border-red-200' : ''} + /> + {!specMeetingInfo.location && ( + <p className="text-sm text-red-500 mt-1">회의 장소는 필수입니다</p> + )} + </div> + + <div> + <label className="text-sm font-medium">주소</label> + <Textarea + placeholder="상세 주소" + value={specMeetingInfo.address} + onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, address: e.target.value }))} + /> + </div> + + <div className="grid grid-cols-3 gap-4"> + <div> + <label className="text-sm font-medium"> + 담당자 <span className="text-red-500">*</span> + </label> + <Input + placeholder="담당자명" + value={specMeetingInfo.contactPerson} + onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, contactPerson: e.target.value }))} + className={!specMeetingInfo.contactPerson ? 'border-red-200' : ''} + /> + {!specMeetingInfo.contactPerson && ( + <p className="text-sm text-red-500 mt-1">담당자는 필수입니다</p> + )} + </div> + <div> + <label className="text-sm font-medium">연락처</label> + <Input + placeholder="전화번호" + value={specMeetingInfo.contactPhone} + onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, contactPhone: e.target.value }))} + /> + </div> + <div> + <label className="text-sm font-medium">이메일</label> + <Input + type="email" + placeholder="이메일" + value={specMeetingInfo.contactEmail} + onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, contactEmail: e.target.value }))} + /> + </div> + </div> + + <div className="grid grid-cols-2 gap-4"> + <div> + <label className="text-sm font-medium">회의 안건</label> + <Textarea + placeholder="회의 안건" + value={specMeetingInfo.agenda} + onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, agenda: e.target.value }))} + /> + </div> + <div> + <label className="text-sm font-medium">준비물 & 특이사항</label> + <Textarea + placeholder="준비물 및 특이사항" + value={specMeetingInfo.materials} + onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, materials: e.target.value }))} + /> + </div> + </div> + + <div className="flex items-center space-x-2"> + <Switch + id="required-meeting" + checked={specMeetingInfo.isRequired} + onCheckedChange={(checked) => setSpecMeetingInfo(prev => ({ ...prev, isRequired: checked }))} + /> + <label htmlFor="required-meeting" className="text-sm font-medium"> + 필수 참석 + </label> + </div> + + {/* 사양설명회 첨부 파일 */} + <div className="space-y-4"> + <label className="text-sm font-medium">사양설명회 관련 첨부 파일</label> + <Dropzone + onDrop={addMeetingFiles} + accept={{ + 'application/pdf': ['.pdf'], + 'application/msword': ['.doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'image/*': ['.png', '.jpg', '.jpeg'], + }} + multiple + className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-gray-400 transition-colors" + > + <DropzoneZone> + <DropzoneUploadIcon /> + <DropzoneTitle>사양설명회 관련 문서 업로드</DropzoneTitle> + <DropzoneDescription> + 안내문, 도면, 자료 등을 업로드하세요 (PDF, Word, Excel, 이미지 파일 지원) + </DropzoneDescription> + </DropzoneZone> + <DropzoneInput /> + </Dropzone> + + {specMeetingInfo.meetingFiles.length > 0 && ( + <FileList className="mt-4"> + <FileListHeader> + <span>업로드된 파일 ({specMeetingInfo.meetingFiles.length})</span> + </FileListHeader> + {specMeetingInfo.meetingFiles.map((file, fileIndex) => ( + <FileListItem key={fileIndex}> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListSize>{file.size}</FileListSize> + </FileListInfo> + <FileListAction> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => removeMeetingFile(fileIndex)} + > + 삭제 + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + )} + </div> + </div> + )} + </CardContent> + </Card> + </TabsContent> + + {/* 세부내역 탭 */} + <TabsContent value="details" className="mt-0 space-y-6"> + <Card> + <CardHeader className="flex flex-row items-center justify-between"> + <div> + <CardTitle>세부내역 관리</CardTitle> + <p className="text-sm text-muted-foreground mt-1"> + PR 아이템 또는 수기 아이템을 추가하여 입찰 세부내역을 관리하세요 + </p> + </div> + <Button + type="button" + variant="outline" + onClick={addPRItem} + className="flex items-center gap-2" + > + <Plus className="h-4 w-4" /> + 아이템 추가 + </Button> + </CardHeader> + <CardContent className="space-y-6"> + {/* 아이템 테이블 */} + {prItems.length > 0 ? ( + <div className="space-y-4"> + <div className="border rounded-lg"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[60px]">대표</TableHead> + <TableHead className="w-[120px]">PR 번호</TableHead> + <TableHead className="w-[120px]">품목코드</TableHead> + <TableHead>품목정보</TableHead> + <TableHead className="w-[80px]">수량</TableHead> + <TableHead className="w-[80px]">단위</TableHead> + <TableHead className="w-[140px]">납품요청일</TableHead> + <TableHead className="w-[80px]">스펙파일</TableHead> + <TableHead className="w-[80px]">액션</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {prItems.map((item, index) => ( + <TableRow key={item.id}> + <TableCell> + <div className="flex justify-center"> + <Checkbox + checked={item.isRepresentative} + onCheckedChange={() => setRepresentativeItem(item.id)} + /> + </div> + </TableCell> + <TableCell> + <Input + placeholder="PR 번호" + value={item.prNumber} + onChange={(e) => updatePRItem(item.id, { prNumber: e.target.value })} + className="h-8" + /> + </TableCell> + <TableCell> + <Input + placeholder={`ITEM-${index + 1}`} + value={item.itemCode} + onChange={(e) => updatePRItem(item.id, { itemCode: e.target.value })} + className="h-8" + /> + </TableCell> + <TableCell> + <Input + placeholder="품목정보" + value={item.itemInfo} + onChange={(e) => updatePRItem(item.id, { itemInfo: e.target.value })} + className="h-8" + /> + </TableCell> + <TableCell> + <Input + type="number" + placeholder="수량" + value={item.quantity} + onChange={(e) => updatePRItem(item.id, { quantity: e.target.value })} + className="h-8" + /> + </TableCell> + <TableCell> + <Select + value={item.quantityUnit} + onValueChange={(value) => updatePRItem(item.id, { quantityUnit: value })} + > + <SelectTrigger className="h-8"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="EA">EA</SelectItem> + <SelectItem value="SET">SET</SelectItem> + <SelectItem value="LOT">LOT</SelectItem> + <SelectItem value="M">M</SelectItem> + <SelectItem value="M2">M²</SelectItem> + <SelectItem value="M3">M³</SelectItem> + </SelectContent> + </Select> + </TableCell> + <TableCell> + <Input + type="date" + value={item.requestedDeliveryDate} + onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })} + className="h-8" + /> + </TableCell> + <TableCell> + <div className="flex items-center gap-2"> + <Button + type="button" + variant={selectedItemForFile === item.id ? "default" : "outline"} + size="sm" + onClick={() => setSelectedItemForFile(selectedItemForFile === item.id ? null : item.id)} + className="h-8 w-8 p-0" + > + <Paperclip className="h-4 w-4" /> + </Button> + <span className="text-sm">{item.specFiles.length}</span> + </div> + </TableCell> + <TableCell> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => removePRItem(item.id)} + className="h-8 w-8 p-0" + > + <Trash2 className="h-4 w-4" /> + </Button> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + + {/* 대표 아이템 정보 표시 */} + {representativePrNumber && ( + <div className="flex items-center gap-2 p-3 bg-blue-50 border border-blue-200 rounded-lg"> + <CheckCircle2 className="h-4 w-4 text-blue-600" /> + <span className="text-sm text-blue-800"> + 대표 PR 번호: <strong>{representativePrNumber}</strong> + </span> + </div> + )} + + {/* 선택된 아이템의 파일 업로드 */} + {selectedItemForFile && ( + <div className="space-y-4 p-4 border rounded-lg bg-muted/50"> + {(() => { + const selectedItem = prItems.find(item => item.id === selectedItemForFile) + return ( + <> + <div className="flex items-center justify-between"> + <h6 className="font-medium text-sm"> + {selectedItem?.itemInfo || selectedItem?.itemCode || "선택된 아이템"}의 스펙 파일 + </h6> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => setSelectedItemForFile(null)} + > + 닫기 + </Button> + </div> + + <Dropzone + onDrop={(files) => addSpecFiles(selectedItemForFile, files)} + accept={{ + 'application/pdf': ['.pdf'], + 'application/msword': ['.doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + }} + multiple + className="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-gray-400 transition-colors" + > + <DropzoneZone> + <DropzoneUploadIcon /> + <DropzoneTitle>스펙 문서 업로드</DropzoneTitle> + <DropzoneDescription> + PDF, Word, Excel 파일을 드래그하거나 클릭하여 선택 + </DropzoneDescription> + </DropzoneZone> + <DropzoneInput /> + </Dropzone> + + {selectedItem && selectedItem.specFiles.length > 0 && ( + <FileList className="mt-4"> + <FileListHeader> + <span>업로드된 파일 ({selectedItem.specFiles.length})</span> + </FileListHeader> + {selectedItem.specFiles.map((file, fileIndex) => ( + <FileListItem key={fileIndex}> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListSize>{file.size}</FileListSize> + </FileListInfo> + <FileListAction> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => removeSpecFile(selectedItemForFile, fileIndex)} + > + 삭제 + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + )} + </> + ) + })()} + </div> + )} + </div> + ) : ( + <div className="text-center py-12 border-2 border-dashed border-gray-300 rounded-lg"> + <FileText className="h-12 w-12 text-gray-400 mx-auto mb-4" /> + <p className="text-gray-500 mb-2">아직 아이템이 없습니다</p> + <p className="text-sm text-gray-400 mb-4"> + PR 아이템이나 수기 아이템을 추가하여 입찰 세부내역을 작성하세요 + </p> + <Button + type="button" + variant="outline" + onClick={addPRItem} + className="flex items-center gap-2" + > + <Plus className="h-4 w-4" /> + 첫 번째 아이템 추가 + </Button> + </div> + )} + </CardContent> + </Card> + </TabsContent> + + {/* 담당자 & 기타 탭 */} + <TabsContent value="manager" className="mt-0 space-y-6"> + {/* 담당자 정보 */} + <Card> + <CardHeader> + <CardTitle>담당자 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-6"> + <FormField + control={form.control} + name="managerName" + render={({ field }) => ( + <FormItem> + <FormLabel>담당자명</FormLabel> + <FormControl> + <Input + placeholder="담당자명" + {...field} + /> + </FormControl> + <FormDescription> + 현재 로그인한 사용자 정보로 자동 설정됩니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <div className="grid grid-cols-2 gap-6"> + <FormField + control={form.control} + name="managerEmail" + render={({ field }) => ( + <FormItem> + <FormLabel>담당자 이메일</FormLabel> + <FormControl> + <Input + type="email" + placeholder="email@example.com" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="managerPhone" + render={({ field }) => ( + <FormItem> + <FormLabel>담당자 전화번호</FormLabel> + <FormControl> + <Input + placeholder="010-1234-5678" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </CardContent> + </Card> + + {/* 기타 설정 */} + <Card> + <CardHeader> + <CardTitle>기타 설정</CardTitle> + </CardHeader> + <CardContent className="space-y-6"> + <FormField + control={form.control} + name="isPublic" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4"> + <div className="space-y-0.5"> + <FormLabel className="text-base"> + 공개 입찰 + </FormLabel> + <FormDescription> + 공개 입찰 여부를 설정합니다 + </FormDescription> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="remarks" + render={({ field }) => ( + <FormItem> + <FormLabel>비고</FormLabel> + <FormControl> + <Textarea + placeholder="추가 메모나 특이사항을 입력하세요" + rows={4} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </CardContent> + </Card> + + {/* 입찰 생성 요약 */} + <Card> + <CardHeader> + <CardTitle>입찰 생성 요약</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-2 gap-4 text-sm"> + <div> + <span className="font-medium">프로젝트:</span> + <p className="text-muted-foreground"> + {form.watch("projectName") || "선택되지 않음"} + </p> + </div> + <div> + <span className="font-medium">입찰명:</span> + <p className="text-muted-foreground"> + {form.watch("title") || "입력되지 않음"} + </p> + </div> + <div> + <span className="font-medium">계약구분:</span> + <p className="text-muted-foreground"> + {contractTypeLabels[form.watch("contractType") as keyof typeof contractTypeLabels] || "선택되지 않음"} + </p> + </div> + <div> + <span className="font-medium">입찰유형:</span> + <p className="text-muted-foreground"> + {biddingTypeLabels[form.watch("biddingType") as keyof typeof biddingTypeLabels] || "선택되지 않음"} + </p> + </div> + <div> + <span className="font-medium">사양설명회:</span> + <p className="text-muted-foreground"> + {form.watch("hasSpecificationMeeting") ? "실시함" : "실시하지 않음"} + </p> + </div> + <div> + <span className="font-medium">대표 PR 번호:</span> + <p className="text-muted-foreground"> + {representativePrNumber || "설정되지 않음"} + </p> + </div> + <div> + <span className="font-medium">세부 아이템:</span> + <p className="text-muted-foreground"> + {prItems.length}개 아이템 + </p> + </div> + <div> + <span className="font-medium">사양설명회 파일:</span> + <p className="text-muted-foreground"> + {specMeetingInfo.meetingFiles.length}개 파일 + </p> + </div> + </div> + </CardContent> + </Card> + </TabsContent> + + </div> + </Tabs> + </div> + + {/* 고정 버튼 영역 */} + <div className="flex-shrink-0 border-t bg-background p-6"> + <div className="flex justify-between items-center"> + <div className="text-sm text-muted-foreground"> + {activeTab === "basic" && ( + <span> + 기본 정보를 입력하세요 + {!tabValidation.basic.isValid && ( + <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span> + )} + </span> + )} + {activeTab === "contract" && ( + <span> + 계약 및 가격 정보를 입력하세요 + {!tabValidation.contract.isValid && ( + <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span> + )} + </span> + )} + {activeTab === "schedule" && ( + <span> + 일정 및 사양설명회 정보를 입력하세요 + {!tabValidation.schedule.isValid && ( + <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span> + )} + </span> + )} + {activeTab === "details" && "세부내역 아이템을 관리하세요 (선택사항)"} + {activeTab === "manager" && "담당자 정보를 확인하고 입찰을 생성하세요"} + </div> + + <div className="flex gap-3"> + <Button + type="button" + variant="outline" + onClick={() => { + resetAllStates() + setOpen(false) + }} + disabled={isSubmitting} + > + 취소 + </Button> + + {/* 이전 버튼 (첫 번째 탭이 아닐 때) */} + {!isFirstTab && ( + <Button + type="button" + variant="outline" + onClick={goToPreviousTab} + disabled={isSubmitting} + className="flex items-center gap-2" + > + <ChevronLeft className="h-4 w-4" /> + 이전 + </Button> + )} + + {/* 다음/생성 버튼 */} + {isLastTab ? ( + // 마지막 탭: 입찰 생성 버튼 (submit) + <Button + type="submit" + disabled={isSubmitting} + className="flex items-center gap-2" + > + {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + 입찰 생성 + </Button> + ) : ( + // 이전 탭들: 다음 버튼 (일반 버튼) + <Button + type="button" + onClick={handleNextClick} + disabled={isSubmitting} + className="flex items-center gap-2" + > + 다음 + <ChevronRight className="h-4 w-4" /> + </Button> + )} + </div> + </div> + </div> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file |
