diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-28 19:03:21 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-28 19:03:21 +0000 |
| commit | 5036cf2908792cef45f06256e71f10920f647f49 (patch) | |
| tree | 3116e7419e872d45025d1d48e6ddaffe2ba2dd38 /lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx | |
| parent | 7ae037e9c2fc0be1fe68cecb461c5e1e837cb0da (diff) | |
(김준회) 기술영업 조선 RFQ (SHI/벤더)
Diffstat (limited to 'lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx')
| -rw-r--r-- | lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx | 357 |
1 files changed, 357 insertions, 0 deletions
diff --git a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx new file mode 100644 index 00000000..b66f4d77 --- /dev/null +++ b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx @@ -0,0 +1,357 @@ +"use client" + +import * as React from "react" +import { useState, useEffect, useCallback } from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { toast } from "sonner" +import { Check, X, Search, Loader2 } from "lucide-react" +import { useSession } from "next-auth/react" + +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Form, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Badge } from "@/components/ui/badge" +import { addVendorsToTechSalesRfq } from "@/lib/techsales-rfq/service" +import { searchVendors } from "@/lib/vendors/service" + +// 폼 유효성 검증 스키마 - 간단화 +const vendorFormSchema = z.object({ + vendorIds: z.array(z.number()).min(1, "최소 하나의 벤더를 선택해주세요"), +}) + +type VendorFormValues = z.infer<typeof vendorFormSchema> + +// 기술영업 RFQ 타입 정의 +type TechSalesRfq = { + id: number + rfqCode: string | null + status: string + [key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any +} + +// 벤더 검색 결과 타입 (searchVendors 함수 반환 타입과 일치) +type VendorSearchResult = { + id: number + vendorName: string + vendorCode: string | null + status: string + country: string | null +} + +interface AddVendorDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + selectedRfq: TechSalesRfq | null + onSuccess?: () => void + existingVendorIds?: number[] +} + +export function AddVendorDialog({ + open, + onOpenChange, + selectedRfq, + onSuccess, + existingVendorIds = [], +}: AddVendorDialogProps) { + const { data: session } = useSession() + const [isSubmitting, setIsSubmitting] = useState(false) + const [searchTerm, setSearchTerm] = useState("") + const [searchResults, setSearchResults] = useState<VendorSearchResult[]>([]) + const [isSearching, setIsSearching] = useState(false) + const [hasSearched, setHasSearched] = useState(false) + // 선택된 벤더들을 별도로 관리하여 검색과 독립적으로 유지 + const [selectedVendorData, setSelectedVendorData] = useState<VendorSearchResult[]>([]) + + const form = useForm<VendorFormValues>({ + resolver: zodResolver(vendorFormSchema), + defaultValues: { + vendorIds: [], + }, + }) + + const selectedVendorIds = form.watch("vendorIds") + + // 검색 함수 (디바운스 적용) + const searchVendorsDebounced = useCallback( + async (term: string) => { + if (!term.trim()) { + setSearchResults([]) + setHasSearched(false) + return + } + + setIsSearching(true) + try { + const results = await searchVendors(term, 100) + // 이미 추가된 벤더 제외 + const filteredResults = results.filter(vendor => !existingVendorIds.includes(vendor.id)) + setSearchResults(filteredResults) + setHasSearched(true) + } catch (error) { + console.error("벤더 검색 오류:", error) + toast.error("벤더 검색 중 오류가 발생했습니다") + setSearchResults([]) + } finally { + setIsSearching(false) + } + }, + [existingVendorIds] + ) + + // 검색어 변경 시 디바운스 적용 + useEffect(() => { + const timer = setTimeout(() => { + searchVendorsDebounced(searchTerm) + }, 300) + + return () => clearTimeout(timer) + }, [searchTerm, searchVendorsDebounced]) + + // 벤더 선택/해제 핸들러 + const handleVendorToggle = (vendor: VendorSearchResult) => { + const currentIds = form.getValues("vendorIds") + const isSelected = currentIds.includes(vendor.id) + + if (isSelected) { + // 선택 해제 + const newIds = currentIds.filter(id => id !== vendor.id) + const newSelectedData = selectedVendorData.filter(v => v.id !== vendor.id) + form.setValue("vendorIds", newIds, { shouldValidate: true }) + setSelectedVendorData(newSelectedData) + } else { + // 선택 추가 + const newIds = [...currentIds, vendor.id] + const newSelectedData = [...selectedVendorData, vendor] + form.setValue("vendorIds", newIds, { shouldValidate: true }) + setSelectedVendorData(newSelectedData) + } + } + + // 선택된 벤더 제거 핸들러 + const handleRemoveVendor = (vendorId: number) => { + const currentIds = form.getValues("vendorIds") + const newIds = currentIds.filter(id => id !== vendorId) + const newSelectedData = selectedVendorData.filter(v => v.id !== vendorId) + form.setValue("vendorIds", newIds, { shouldValidate: true }) + setSelectedVendorData(newSelectedData) + } + + // 폼 제출 핸들러 + async function onSubmit(values: VendorFormValues) { + if (!selectedRfq) { + toast.error("선택된 RFQ가 없습니다") + return + } + + if (!session?.user?.id) { + toast.error("로그인이 필요합니다") + return + } + + try { + setIsSubmitting(true) + + // 서비스 함수 호출 + const result = await addVendorsToTechSalesRfq({ + rfqId: selectedRfq.id, + vendorIds: values.vendorIds, + createdBy: Number(session.user.id), + }) + + if (result.error) { + toast.error(result.error) + } else { + const successMessage = `${result.successCount}개의 벤더가 성공적으로 추가되었습니다` + const errorMessage = result.errorCount && result.errorCount > 0 ? ` (${result.errorCount}개 실패)` : "" + toast.success(successMessage + errorMessage) + + onOpenChange(false) + form.reset() + setSearchTerm("") + setSearchResults([]) + setHasSearched(false) + setSelectedVendorData([]) + onSuccess?.() + } + } catch (error) { + console.error("벤더 추가 오류:", error) + toast.error("벤더 추가 중 오류가 발생했습니다") + } finally { + setIsSubmitting(false) + } + } + + // 다이얼로그 닫기 시 폼 리셋 + React.useEffect(() => { + if (!open) { + form.reset() + setSearchTerm("") + setSearchResults([]) + setHasSearched(false) + setSelectedVendorData([]) + } + }, [open, form]) + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[700px] max-h-[80vh] flex flex-col"> + {/* 헤더 */} + <DialogHeader> + <DialogTitle>벤더 추가</DialogTitle> + <DialogDescription> + {selectedRfq ? ( + <> + <span className="font-medium">{selectedRfq.rfqCode}</span> RFQ에 벤더를 추가합니다. + </> + ) : ( + "RFQ에 벤더를 추가합니다." + )} + </DialogDescription> + </DialogHeader> + + {/* 콘텐츠 */} + <div className="flex-1 overflow-y-auto"> + <Form {...form}> + <form id="vendor-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + {/* 벤더 검색 필드 */} + <div className="space-y-2"> + <label className="text-sm font-medium">벤더 검색</label> + <div className="relative"> + <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="벤더명 또는 벤더코드로 검색..." + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + className="pl-10" + /> + {isSearching && ( + <Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" /> + )} + </div> + </div> + + {/* 검색 결과 */} + {hasSearched && ( + <div className="space-y-2"> + <div className="text-sm font-medium"> + 검색 결과 ({searchResults.length}개) + </div> + <ScrollArea className="h-60 border rounded-md"> + <div className="p-2 space-y-1"> + {searchResults.length > 0 ? ( + searchResults.map((vendor) => ( + <div + key={vendor.id} + className={`flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted ${ + selectedVendorIds.includes(vendor.id) ? "bg-muted" : "" + }`} + onClick={() => handleVendorToggle(vendor)} + > + <div className="flex items-center space-x-2 flex-1"> + <Check + className={`h-4 w-4 ${ + selectedVendorIds.includes(vendor.id) + ? "opacity-100" + : "opacity-0" + }`} + /> + <div className="flex-1"> + <div className="font-medium">{vendor.vendorName}</div> + <div className="text-sm text-muted-foreground"> + {vendor.vendorCode || 'N/A'} {vendor.country && `• ${vendor.country}`} + </div> + </div> + </div> + </div> + )) + ) : ( + <div className="text-center py-8 text-muted-foreground"> + 검색 결과가 없습니다 + </div> + )} + </div> + </ScrollArea> + </div> + )} + + {/* 검색 안내 메시지 */} + {!hasSearched && !searchTerm && ( + <div className="text-center py-8 text-muted-foreground border rounded-md"> + 벤더명 또는 벤더코드를 입력하여 검색해주세요 + </div> + )} + + {/* 선택된 벤더 목록 - 하단에 항상 표시 */} + <FormField + control={form.control} + name="vendorIds" + render={() => ( + <FormItem> + <div className="space-y-2"> + <FormLabel>선택된 벤더 ({selectedVendorData.length}개)</FormLabel> + <div className="min-h-[60px] p-3 border rounded-md bg-muted/50"> + {selectedVendorData.length > 0 ? ( + <div className="flex flex-wrap gap-2"> + {selectedVendorData.map((vendor) => ( + <Badge + key={vendor.id} + variant="secondary" + className="flex items-center gap-1" + > + {vendor.vendorName} ({vendor.vendorCode || 'N/A'}) + <X + className="h-3 w-3 cursor-pointer hover:text-destructive" + onClick={() => handleRemoveVendor(vendor.id)} + /> + </Badge> + ))} + </div> + ) : ( + <div className="flex items-center justify-center h-full text-sm text-muted-foreground"> + 선택된 벤더가 없습니다 + </div> + )} + </div> + </div> + <FormMessage /> + </FormItem> + )} + /> + + {/* 안내 메시지 */} + <div className="text-sm text-muted-foreground bg-muted p-3 rounded-md"> + {/* <p>• 검색은 ACTIVE 상태의 벤더만 대상으로 합니다.</p> */} + <p>• 선택된 벤더들은 Draft 상태로 추가됩니다.</p> + <p>• 벤더별 견적 정보는 추가 후 개별적으로 입력할 수 있습니다.</p> + <p>• 이미 추가된 벤더는 검색 결과에서 체크됩니다.</p> + </div> + </form> + </Form> + </div> + + {/* 푸터 */} + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + type="submit" + form="vendor-form" + disabled={isSubmitting || selectedVendorIds.length === 0} + > + {isSubmitting ? "처리 중..." : `${selectedVendorIds.length}개 벤더 추가`} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file |
