From ba8cd44a0ed2c613a5f2cee06bfc9bd0f61f21c7 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 7 Nov 2025 08:39:04 +0000 Subject: (최겸) 입찰/견적 수정사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bidding/manage/create-pre-quote-rfq-dialog.tsx | 742 +++++++++++++++++++++ 1 file changed, 742 insertions(+) create mode 100644 components/bidding/manage/create-pre-quote-rfq-dialog.tsx (limited to 'components/bidding/manage/create-pre-quote-rfq-dialog.tsx') diff --git a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx new file mode 100644 index 00000000..88732deb --- /dev/null +++ b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx @@ -0,0 +1,742 @@ +"use client" + +import * as React from "react" +import { useForm, useFieldArray } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { format } from "date-fns" +import { CalendarIcon, Loader2, Trash2, PlusCircle } from "lucide-react" +import { useSession } from "next-auth/react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + 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 { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Calendar } from "@/components/ui/calendar" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" +import { toast } from "sonner" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" +import { createPreQuoteRfqAction, previewGeneralRfqCode } from "@/lib/bidding/service" +import { MaterialGroupSelectorDialogSingle } from "@/components/common/material/material-group-selector-dialog-single" +import { MaterialSearchItem } from "@/lib/material/material-group-service" +import { MaterialSelectorDialogSingle } from "@/components/common/selectors/material/material-selector-dialog-single" +import { MaterialSearchItem as SAPMaterialSearchItem } from "@/components/common/selectors/material/material-service" +import { ProcurementManagerSelector } from "@/components/common/selectors/procurement-manager" +import type { ProcurementManagerWithUser } from "@/components/common/selectors/procurement-manager/procurement-manager-service" + +// 아이템 스키마 +const itemSchema = z.object({ + itemCode: z.string().optional(), + itemName: z.string().optional(), + materialCode: z.string().optional(), + materialName: z.string().optional(), + quantity: z.number().min(1, "수량은 1 이상이어야 합니다"), + uom: z.string().min(1, "단위를 입력해주세요"), + remark: z.string().optional(), +}) + +// 사전견적용 일반견적 생성 폼 스키마 +const createPreQuoteRfqSchema = z.object({ + rfqType: z.string().min(1, "견적 종류를 선택해주세요"), + rfqTitle: z.string().min(1, "견적명을 입력해주세요"), + dueDate: z.date({ + required_error: "제출마감일을 선택해주세요", + }), + picUserId: z.number().optional(), + projectId: z.number().optional(), + remark: z.string().optional(), + items: z.array(itemSchema).min(1, "최소 하나의 자재를 추가해주세요"), +}) + +type CreatePreQuoteRfqFormValues = z.infer + +interface CreatePreQuoteRfqDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + biddingId: number + biddingItems: Array<{ + id: number + materialGroupNumber?: string | null + materialGroupInfo?: string | null + materialNumber?: string | null + materialInfo?: string | null + quantity?: string | null + quantityUnit?: string | null + totalWeight?: string | null + weightUnit?: string | null + }> + biddingConditions?: { + paymentTerms?: string | null + taxConditions?: string | null + incoterms?: string | null + incotermsOption?: string | null + contractDeliveryDate?: string | null + shippingPort?: string | null + destinationPort?: string | null + isPriceAdjustmentApplicable?: boolean | null + sparePartOptions?: string | null + } | null + onSuccess?: () => void +} + +export function CreatePreQuoteRfqDialog({ + open, + onOpenChange, + biddingId, + biddingItems, + biddingConditions, + onSuccess +}: CreatePreQuoteRfqDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [previewCode, setPreviewCode] = React.useState("") + const [isLoadingPreview, setIsLoadingPreview] = React.useState(false) + const [selectedManager, setSelectedManager] = React.useState(undefined) + const { data: session } = useSession() + + const userId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : null; + }, [session]); + + // 입찰품목을 일반견적 아이템으로 매핑 + const initialItems = React.useMemo(() => { + return biddingItems.map((item) => ({ + itemCode: item.materialGroupNumber || "", + itemName: item.materialGroupInfo || "", + materialCode: item.materialNumber || "", + materialName: item.materialInfo || "", + quantity: item.quantity ? parseFloat(item.quantity) : 1, + uom: item.quantityUnit || item.weightUnit || "EA", + remark: "", + })) + }, [biddingItems]) + + const form = useForm({ + resolver: zodResolver(createPreQuoteRfqSchema), + defaultValues: { + rfqType: "", + rfqTitle: "", + dueDate: undefined, + picUserId: undefined, + projectId: undefined, + remark: "", + items: initialItems.length > 0 ? initialItems : [ + { + itemCode: "", + itemName: "", + materialCode: "", + materialName: "", + quantity: 1, + uom: "", + remark: "", + }, + ], + }, + }) + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "items", + }) + + // 다이얼로그가 열릴 때 폼 초기화 + React.useEffect(() => { + if (open) { + form.reset({ + rfqType: "", + rfqTitle: "", + dueDate: undefined, + picUserId: undefined, + projectId: undefined, + remark: "", + items: initialItems.length > 0 ? initialItems : [ + { + itemCode: "", + itemName: "", + materialCode: "", + materialName: "", + quantity: 1, + uom: "", + remark: "", + }, + ], + }) + setSelectedManager(undefined) + setPreviewCode("") + } + }, [open, initialItems, form]) + + // 견적담당자 선택 시 RFQ 코드 미리보기 생성 + React.useEffect(() => { + if (!selectedManager?.user?.id) { + setPreviewCode("") + return + } + + // 즉시 실행 함수 패턴 사용 + (async () => { + setIsLoadingPreview(true) + try { + const code = await previewGeneralRfqCode(selectedManager.user!.id!) + setPreviewCode(code) + } catch (error) { + console.error("코드 미리보기 오류:", error) + setPreviewCode("") + } finally { + setIsLoadingPreview(false) + } + })() + }, [selectedManager]) + + // 견적 종류 변경 + const handleRfqTypeChange = (value: string) => { + form.setValue("rfqType", value) + } + + const handleCancel = () => { + form.reset({ + rfqType: "", + rfqTitle: "", + dueDate: undefined, + picUserId: undefined, + projectId: undefined, + remark: "", + items: initialItems.length > 0 ? initialItems : [ + { + itemCode: "", + itemName: "", + materialCode: "", + materialName: "", + quantity: 1, + uom: "", + remark: "", + }, + ], + }) + setSelectedManager(undefined) + setPreviewCode("") + onOpenChange(false) + } + + const onSubmit = async (data: CreatePreQuoteRfqFormValues) => { + if (!userId) { + toast.error("로그인이 필요합니다") + return + } + + if (!selectedManager?.user?.id) { + toast.error("견적담당자를 선택해주세요") + return + } + + const picUserId = selectedManager.user.id + + setIsLoading(true) + + try { + // 서버 액션 호출 (입찰 조건 포함) + const result = await createPreQuoteRfqAction({ + biddingId, + rfqType: data.rfqType, + rfqTitle: data.rfqTitle, + dueDate: data.dueDate, + picUserId, + projectId: data.projectId, + remark: data.remark || "", + items: data.items as Array<{ + itemCode: string; + itemName: string; + materialCode?: string; + materialName?: string; + quantity: number; + uom: string; + remark?: string; + }>, + biddingConditions: biddingConditions || undefined, + createdBy: userId, + updatedBy: userId, + }) + + if (result.success) { + toast.success(result.message, { + description: result.data?.rfqCode ? `RFQ 코드: ${result.data.rfqCode}` : undefined, + }) + + // 다이얼로그 닫기 + onOpenChange(false) + + // 성공 콜백 실행 + if (onSuccess) { + onSuccess() + } + } else { + toast.error(result.error || "사전견적용 일반견적 생성에 실패했습니다") + } + + } catch (error) { + console.error('사전견적용 일반견적 생성 오류:', error) + toast.error("사전견적용 일반견적 생성에 실패했습니다", { + description: "알 수 없는 오류가 발생했습니다", + }) + } finally { + setIsLoading(false) + } + } + + // 아이템 추가 + const handleAddItem = () => { + append({ + itemCode: "", + itemName: "", + materialCode: "", + materialName: "", + quantity: 1, + uom: "", + remark: "", + }) + } + + return ( + + + {/* 고정된 헤더 */} + + 사전견적용 일반견적 생성 + + 입찰의 사전견적을 위한 일반견적을 생성합니다. 입찰품목이 자재정보로 매핑되어 있습니다. + + + + {/* 스크롤 가능한 컨텐츠 영역 */} + +
+ + + {/* 기본 정보 섹션 */} +
+

기본 정보

+ +
+ {/* 견적 종류 */} +
+ ( + + + 견적 종류 * + + + + + )} + /> +
+ + {/* 제출마감일 */} + ( + + + 제출마감일 * + + + + + + + + + + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + + + + + )} + /> +
+ + {/* 견적명 */} + ( + + + 견적명 * + + + + + + 견적의 목적이나 내용을 간단명료하게 입력해주세요 + + + + )} + /> + + {/* 프로젝트 선택 */} + ( + + 프로젝트 + + {/* ProjectSelector는 별도 컴포넌트 필요 */} + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + /> + + + + )} + /> + + {/* 담당자 정보 */} + ( + + + 견적담당자 * + + + { + setSelectedManager(manager) + field.onChange(manager.user?.id) + }} + placeholder="견적담당자를 선택하세요" + /> + + + 사전견적용 일반견적의 담당자를 선택합니다 + + + + )} + /> + {/* RFQ 코드 미리보기 */} + {previewCode && ( +
+ + 예상 RFQ 코드: {previewCode} + + {isLoadingPreview && ( + + )} +
+ )} + + {/* 비고 */} + ( + + 비고 + +