diff options
Diffstat (limited to 'lib/b-rfq/initial/add-initial-rfq-dialog.tsx')
| -rw-r--r-- | lib/b-rfq/initial/add-initial-rfq-dialog.tsx | 534 |
1 files changed, 534 insertions, 0 deletions
diff --git a/lib/b-rfq/initial/add-initial-rfq-dialog.tsx b/lib/b-rfq/initial/add-initial-rfq-dialog.tsx new file mode 100644 index 00000000..d0924be2 --- /dev/null +++ b/lib/b-rfq/initial/add-initial-rfq-dialog.tsx @@ -0,0 +1,534 @@ +// add-initial-rfq-dialog.tsx +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { Plus, Check, ChevronsUpDown, Search, Building, CalendarIcon } from "lucide-react" +import { toast } from "sonner" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Checkbox } from "@/components/ui/checkbox" +import { cn, formatDate } from "@/lib/utils" +import { addInitialRfqRecord, getIncotermsForSelection, getVendorsForSelection } from "../service" +import { Calendar } from "@/components/ui/calendar" + +// Initial RFQ 추가 폼 스키마 +const addInitialRfqSchema = z.object({ + vendorId: z.number({ + required_error: "벤더를 선택해주세요.", + }), + initialRfqStatus: z.enum(["DRAFT", "Init. RFQ Sent", "S/L Decline", "Init. RFQ Answered"], { + required_error: "초기 RFQ 상태를 선택해주세요.", + }).default("DRAFT"), + dueDate: z.date({ + required_error: "마감일을 선택해주세요.", + }), + validDate: z.date().optional(), + incotermsCode: z.string().optional(), + gtc: z.string().optional(), + gtcValidDate: z.string().optional(), + classification: z.string().optional(), + sparepart: z.string().optional(), + shortList: z.boolean().default(false), + returnYn: z.boolean().default(false), + cpRequestYn: z.boolean().default(false), + prjectGtcYn: z.boolean().default(false), + returnRevision: z.number().default(0), +}) + +type AddInitialRfqFormData = z.infer<typeof addInitialRfqSchema> + +interface Vendor { + id: number + vendorName: string + vendorCode: string + country: string + status: string +} + +interface AddInitialRfqDialogProps { + rfqId: number + onSuccess?: () => void +} + +export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogProps) { + const [open, setOpen] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [vendors, setVendors] = React.useState<Vendor[]>([]) + const [vendorsLoading, setVendorsLoading] = React.useState(false) + const [vendorSearchOpen, setVendorSearchOpen] = React.useState(false) + const [incoterms, setIncoterms] = React.useState<Incoterm[]>([]) + const [incotermsLoading, setIncotermsLoading] = React.useState(false) + const [incotermsSearchOpen, setIncotermsSearchOpen] = React.useState(false) + + const form = useForm<AddInitialRfqFormData>({ + resolver: zodResolver(addInitialRfqSchema), + defaultValues: { + initialRfqStatus: "DRAFT", + shortList: false, + returnYn: false, + cpRequestYn: false, + prjectGtcYn: false, + returnRevision: 0, + }, + }) + + // 벤더 목록 로드 + const loadVendors = React.useCallback(async () => { + setVendorsLoading(true) + try { + const vendorList = await getVendorsForSelection() + setVendors(vendorList) + } catch (error) { + console.error("Failed to load vendors:", error) + toast.error("벤더 목록을 불러오는데 실패했습니다.") + } finally { + setVendorsLoading(false) + } + }, []) + + // Incoterms 목록 로드 + const loadIncoterms = React.useCallback(async () => { + setIncotermsLoading(true) + try { + const incotermsList = await getIncotermsForSelection() + setIncoterms(incotermsList) + } catch (error) { + console.error("Failed to load incoterms:", error) + toast.error("Incoterms 목록을 불러오는데 실패했습니다.") + } finally { + setIncotermsLoading(false) + } + }, []) + + // 다이얼로그 열릴 때 벤더 목록 로드 + React.useEffect(() => { + if (open) { + if (vendors.length === 0) { + loadVendors() + } + if (incoterms.length === 0) { + loadIncoterms() + } + } + }, [open, vendors.length, incoterms.length, loadVendors, loadIncoterms]) + + // 다이얼로그 닫기 핸들러 + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen && !isSubmitting) { + form.reset() + } + setOpen(newOpen) + } + + // 폼 제출 + const onSubmit = async (data: AddInitialRfqFormData) => { + setIsSubmitting(true) + + try { + const result = await addInitialRfqRecord({ + ...data, + rfqId, + }) + + if (result.success) { + toast.success(result.message || "초기 RFQ가 성공적으로 추가되었습니다.") + form.reset() + handleOpenChange(false) + onSuccess?.() + } else { + toast.error(result.message || "초기 RFQ 추가에 실패했습니다.") + } + + } catch (error) { + console.error("Submit error:", error) + toast.error("초기 RFQ 추가 중 오류가 발생했습니다.") + } finally { + setIsSubmitting(false) + } + } + + // 선택된 벤더 정보 + const selectedVendor = vendors.find(vendor => vendor.id === form.watch("vendorId")) + const selectedIncoterm = incoterms.find(incoterm => incoterm.code === form.watch("incotermsCode")) + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Plus className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">초기 RFQ 추가</span> + </Button> + </DialogTrigger> + + <DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>초기 RFQ 추가</DialogTitle> + <DialogDescription> + 새로운 벤더를 대상으로 하는 초기 RFQ를 추가합니다. + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + {/* 벤더 선택 */} + <FormField + control={form.control} + name="vendorId" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>벤더 선택 *</FormLabel> + <Popover open={vendorSearchOpen} onOpenChange={setVendorSearchOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={vendorSearchOpen} + className="justify-between" + disabled={vendorsLoading} + > + {selectedVendor ? ( + <div className="flex items-center gap-2"> + <Building className="h-4 w-4" /> + <span className="truncate"> + {selectedVendor.vendorName} ({selectedVendor.vendorCode}) + </span> + </div> + ) : ( + <span className="text-muted-foreground"> + {vendorsLoading ? "로딩 중..." : "벤더를 선택하세요"} + </span> + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput + placeholder="벤더명 또는 코드로 검색..." + className="h-9" + /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {vendors.map((vendor) => ( + <CommandItem + key={vendor.id} + value={`${vendor.vendorName} ${vendor.vendorCode}`} + onSelect={() => { + field.onChange(vendor.id) + setVendorSearchOpen(false) + }} + > + <div className="flex items-center gap-2 w-full"> + <Building className="h-4 w-4" /> + <div className="flex-1 min-w-0"> + <div className="font-medium truncate"> + {vendor.vendorName} + </div> + <div className="text-sm text-muted-foreground"> + {vendor.vendorCode} • {vendor.country} + </div> + </div> + <Check + className={cn( + "ml-auto h-4 w-4", + vendor.id === field.value ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + {/* 날짜 필드들 */} + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="dueDate" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>견적 마감일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + formatDate(field.value,"KR") + ) : ( + <span>견적 유효일을 선택하세요</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="validDate" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>견적 유효일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + formatDate(field.value,"KR") + ) : ( + <span>견적 유효일을 선택하세요</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* Incoterms 및 GTC */} + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="vendorId" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>Incoterms *</FormLabel> + <Popover open={incotermsSearchOpen} onOpenChange={setIncotermsSearchOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={incotermsSearchOpen} + className="justify-between" + disabled={incotermsLoading} + > + {selectedIncoterm ? ( + <div className="flex items-center gap-2"> + <Building className="h-4 w-4" /> + <span className="truncate"> + {selectedIncoterm.code} ({selectedIncoterm.description}) + </span> + </div> + ) : ( + <span className="text-muted-foreground"> + {incotermsLoading ? "로딩 중..." : "인코텀즈를 선택하세요"} + </span> + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput + placeholder="코드 또는 내용으로 검색..." + className="h-9" + /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {incoterms.map((incoterm) => ( + <CommandItem + key={incoterm.id} + value={`${incoterm.code} ${incoterm.description}`} + onSelect={() => { + field.onChange(vendor.id) + setVendorSearchOpen(false) + }} + > + <div className="flex items-center gap-2 w-full"> + <div className="font-medium truncate"> + {incoterm.code} {incoterm.description} + </div> + <Check + className={cn( + "ml-auto h-4 w-4", + incoterm.id === field.value ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* GTC 정보 */} + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="gtc" + render={({ field }) => ( + <FormItem> + <FormLabel>GTC</FormLabel> + <FormControl> + <Input placeholder="GTC 정보" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="gtcValidDate" + render={({ field }) => ( + <FormItem> + <FormLabel>GTC 유효일</FormLabel> + <FormControl> + <Input placeholder="GTC 유효일" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 분류 정보 */} + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="classification" + render={({ field }) => ( + <FormItem> + <FormLabel>선급</FormLabel> + <FormControl> + <Input placeholder="선급" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="sparepart" + render={({ field }) => ( + <FormItem> + <FormLabel>Spare part</FormLabel> + <FormControl> + <Input placeholder="예비부품 정보" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => handleOpenChange(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting ? "추가 중..." : "추가"} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +} + + |
