summaryrefslogtreecommitdiff
path: root/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 00:32:31 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 00:32:31 +0000
commit20800b214145ee6056f94ca18fa1054f145eb977 (patch)
treeb5c8b27febe5b126e6d9ece115ea05eace33a020 /lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx
parente1344a5da1aeef8fbf0f33e1dfd553078c064ccc (diff)
(대표님) lib 파트 커밋
Diffstat (limited to 'lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx')
-rw-r--r--lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx512
1 files changed, 512 insertions, 0 deletions
diff --git a/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx b/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx
new file mode 100644
index 00000000..79524f58
--- /dev/null
+++ b/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx
@@ -0,0 +1,512 @@
+"use client"
+
+import * as React from "react"
+import { useState } from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { toast } from "sonner"
+import { Check, ChevronsUpDown, File, Upload, X } from "lucide-react"
+
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { ProcurementRfqsView } from "@/db/schema"
+import { addVendorToRfq } from "@/lib/procurement-rfqs/services"
+import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { cn } from "@/lib/utils"
+import { ScrollArea } from "@/components/ui/scroll-area"
+
+// 필수 필드를 위한 커스텀 레이블 컴포넌트
+const RequiredLabel = ({ children }: { children: React.ReactNode }) => (
+ <FormLabel>
+ {children} <span className="text-red-500">*</span>
+ </FormLabel>
+);
+
+// 폼 유효성 검증 스키마
+const vendorFormSchema = z.object({
+ vendorId: z.string().min(1, "벤더를 선택해주세요"),
+ currency: z.string().min(1, "통화를 선택해주세요"),
+ paymentTermsCode: z.string().min(1, "지불 조건을 선택해주세요"),
+ incotermsCode: z.string().min(1, "인코텀즈를 선택해주세요"),
+ incotermsDetail: z.string().optional(),
+ deliveryDate: z.string().optional(),
+ taxCode: z.string().optional(),
+ placeOfShipping: z.string().optional(),
+ placeOfDestination: z.string().optional(),
+ materialPriceRelatedYn: z.boolean().default(false),
+})
+
+type VendorFormValues = z.infer<typeof vendorFormSchema>
+
+interface AddVendorDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedRfq: ProcurementRfqsView | null
+ // 벤더 및 기타 옵션 데이터를 prop으로 받음
+ vendors?: { id: number; vendorName: string; vendorCode: string }[]
+ currencies?: { code: string; name: string }[]
+ paymentTerms?: { code: string; description: string }[]
+ incoterms?: { code: string; description: string }[]
+ onSuccess?: () => void
+ existingVendorIds?: number[]
+
+}
+
+export function AddVendorDialog({
+ open,
+ onOpenChange,
+ selectedRfq,
+ vendors = [],
+ currencies = [],
+ paymentTerms = [],
+ incoterms = [],
+ onSuccess,
+ existingVendorIds = [], // 기본값 빈 배열
+}: AddVendorDialogProps) {
+
+
+ const availableVendors = React.useMemo(() => {
+ return vendors.filter(vendor => !existingVendorIds.includes(vendor.id));
+ }, [vendors, existingVendorIds]);
+
+
+ // 파일 업로드 상태 관리
+ const [attachments, setAttachments] = useState<File[]>([])
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ // 벤더 선택을 위한 팝오버 상태
+ const [vendorOpen, setVendorOpen] = useState(false)
+
+ const form = useForm<VendorFormValues>({
+ resolver: zodResolver(vendorFormSchema),
+ defaultValues: {
+ vendorId: "",
+ currency: "",
+ paymentTermsCode: "",
+ incotermsCode: "",
+ incotermsDetail: "",
+ deliveryDate: "",
+ taxCode: "",
+ placeOfShipping: "",
+ placeOfDestination: "",
+ materialPriceRelatedYn: false,
+ },
+ })
+
+ // 폼 제출 핸들러
+ async function onSubmit(values: VendorFormValues) {
+ if (!selectedRfq) {
+ toast.error("선택된 RFQ가 없습니다")
+ return
+ }
+
+ try {
+ setIsSubmitting(true)
+
+ // FormData 생성
+ const formData = new FormData()
+ formData.append("rfqId", selectedRfq.id.toString())
+
+ // 폼 데이터 추가
+ Object.entries(values).forEach(([key, value]) => {
+ formData.append(key, value.toString())
+ })
+
+ // 첨부파일 추가
+ attachments.forEach((file, index) => {
+ formData.append(`attachment-${index}`, file)
+ })
+
+ // 서버 액션 호출
+ const result = await addVendorToRfq(formData)
+
+ if (result.success) {
+ toast.success("벤더가 성공적으로 추가되었습니다")
+ onOpenChange(false)
+ form.reset()
+ setAttachments([])
+ onSuccess?.()
+ } else {
+ toast.error(result.message || "벤더 추가 중 오류가 발생했습니다")
+ }
+ } catch (error) {
+ console.error("벤더 추가 오류:", error)
+ toast.error("벤더 추가 중 오류가 발생했습니다")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // 파일 업로드 핸들러
+ const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
+ if (event.target.files && event.target.files.length > 0) {
+ const newFiles = Array.from(event.target.files)
+ setAttachments((prev) => [...prev, ...newFiles])
+ }
+ }
+
+ // 파일 삭제 핸들러
+ const handleRemoveFile = (index: number) => {
+ setAttachments((prev) => prev.filter((_, i) => i !== index))
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ {/* 커스텀 DialogContent - 고정 헤더, 스크롤 가능한 콘텐츠, 고정 푸터 */}
+ <DialogContent className="sm:max-w-[600px] p-0 h-[85vh] flex flex-col overflow-hidden" style={{maxHeight:'85vh'}}>
+ {/* 고정 헤더 */}
+ <div className="p-6 border-b">
+ <DialogHeader>
+ <DialogTitle>벤더 추가</DialogTitle>
+ <DialogDescription>
+ {selectedRfq ? (
+ <>
+ <span className="font-medium">{selectedRfq.rfqCode}</span> RFQ에 벤더를 추가합니다.
+ </>
+ ) : (
+ "RFQ에 벤더를 추가합니다."
+ )}
+ </DialogDescription>
+ </DialogHeader>
+ </div>
+
+ {/* 스크롤 가능한 콘텐츠 영역 */}
+ <div className="flex-1 overflow-y-auto p-6">
+ <Form {...form}>
+ <form id="vendor-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ {/* 검색 가능한 벤더 선택 필드 */}
+ <FormField
+ control={form.control}
+ name="vendorId"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <RequiredLabel>벤더</RequiredLabel>
+ <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={vendorOpen}
+ className={cn(
+ "w-full justify-between",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value
+ ? vendors.find((vendor) => String(vendor.id) === field.value)
+ ? `${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorName} (${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorCode})`
+ : "벤더를 선택하세요"
+ : "벤더를 선택하세요"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="벤더 검색..." />
+ <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
+ <CommandList>
+ <ScrollArea className="h-60">
+ <CommandGroup>
+ {availableVendors.length > 0 ? (
+ availableVendors.map((vendor) => (
+ <CommandItem
+ key={vendor.id}
+ value={`${vendor.vendorName} ${vendor.vendorCode}`}
+ onSelect={() => {
+ form.setValue("vendorId", String(vendor.id), {
+ shouldValidate: true,
+ })
+ setVendorOpen(false)
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ String(vendor.id) === field.value
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {vendor.vendorName} ({vendor.vendorCode})
+ </CommandItem>
+ ))
+ ) : (
+ <CommandItem disabled>추가 가능한 벤더가 없습니다</CommandItem>
+ )}
+ </CommandGroup>
+ </ScrollArea>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="currency"
+ render={({ field }) => (
+ <FormItem>
+ <RequiredLabel>통화</RequiredLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="통화를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {currencies.map((currency) => (
+ <SelectItem key={currency.code} value={currency.code}>
+ {currency.name} ({currency.code})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="paymentTermsCode"
+ render={({ field }) => (
+ <FormItem>
+ <RequiredLabel>지불 조건</RequiredLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="지불 조건 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {paymentTerms.map((term) => (
+ <SelectItem key={term.code} value={term.code}>
+ {term.description}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="incotermsCode"
+ render={({ field }) => (
+ <FormItem>
+ <RequiredLabel>인코텀즈</RequiredLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="인코텀즈 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {incoterms.map((incoterm) => (
+ <SelectItem key={incoterm.code} value={incoterm.code}>
+ {incoterm.description}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 나머지 필드들은 동일하게 유지 */}
+ <FormField
+ control={form.control}
+ name="incotermsDetail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>인코텀즈 세부사항</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="인코텀즈 세부사항" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="deliveryDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>납품 예정일</FormLabel>
+ <FormControl>
+ <Input {...field} type="date" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="taxCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>세금 코드</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="세금 코드" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="placeOfShipping"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>선적지</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="선적지" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="placeOfDestination"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>도착지</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="도착지" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <FormField
+ control={form.control}
+ name="materialPriceRelatedYn"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
+ <FormControl>
+ <input
+ type="checkbox"
+ checked={field.value}
+ onChange={field.onChange}
+ className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel>하도급대금 연동제 여부</FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+
+ {/* 파일 업로드 섹션 */}
+ <div className="space-y-2">
+ <Label>첨부 파일</Label>
+ <div className="border rounded-md p-4">
+ <div className="flex items-center justify-center w-full">
+ <label
+ htmlFor="file-upload"
+ className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100"
+ >
+ <div className="flex flex-col items-center justify-center pt-5 pb-6">
+ <Upload className="w-8 h-8 mb-2 text-gray-500" />
+ <p className="mb-2 text-sm text-gray-500">
+ <span className="font-semibold">클릭하여 파일 업로드</span> 또는 파일을 끌어 놓으세요
+ </p>
+ <p className="text-xs text-gray-500">PDF, DOCX, XLSX, JPG, PNG (최대 10MB)</p>
+ </div>
+ <input
+ id="file-upload"
+ type="file"
+ className="hidden"
+ multiple
+ onChange={handleFileUpload}
+ />
+ </label>
+ </div>
+
+ {/* 업로드된 파일 목록 */}
+ {attachments.length > 0 && (
+ <div className="mt-4 space-y-2">
+ <h4 className="text-sm font-medium">업로드된 파일</h4>
+ <ul className="space-y-2">
+ {attachments.map((file, index) => (
+ <li
+ key={index}
+ className="flex items-center justify-between p-2 text-sm bg-gray-50 rounded-md"
+ >
+ <div className="flex items-center space-x-2">
+ <File className="w-4 h-4 text-gray-500" />
+ <span className="truncate max-w-[250px]">{file.name}</span>
+ <span className="text-gray-500 text-xs">
+ ({(file.size / 1024).toFixed(1)} KB)
+ </span>
+ </div>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRemoveFile(index)}
+ >
+ <X className="w-4 h-4 text-gray-500" />
+ </Button>
+ </li>
+ ))}
+ </ul>
+ </div>
+ )}
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+
+ {/* 고정 푸터 */}
+ <div className="p-6 border-t">
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ form="vendor-form"
+ disabled={isSubmitting}
+ >
+ {isSubmitting ? "처리 중..." : "벤더 추가"}
+ </Button>
+ </DialogFooter>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file