From 5036cf2908792cef45f06256e71f10920f647f49 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 28 May 2025 19:03:21 +0000 Subject: (김준회) 기술영업 조선 RFQ (SHI/벤더) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table/detail-table/add-vendor-dialog.tsx | 357 +++++++++++++++++++++ 1 file changed, 357 insertions(+) create mode 100644 lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx (limited to 'lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx') 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 + +// 기술영업 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([]) + const [isSearching, setIsSearching] = useState(false) + const [hasSearched, setHasSearched] = useState(false) + // 선택된 벤더들을 별도로 관리하여 검색과 독립적으로 유지 + const [selectedVendorData, setSelectedVendorData] = useState([]) + + const form = useForm({ + 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 ( + + + {/* 헤더 */} + + 벤더 추가 + + {selectedRfq ? ( + <> + {selectedRfq.rfqCode} RFQ에 벤더를 추가합니다. + + ) : ( + "RFQ에 벤더를 추가합니다." + )} + + + + {/* 콘텐츠 */} +
+
+ + {/* 벤더 검색 필드 */} +
+ +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> + {isSearching && ( + + )} +
+
+ + {/* 검색 결과 */} + {hasSearched && ( +
+
+ 검색 결과 ({searchResults.length}개) +
+ +
+ {searchResults.length > 0 ? ( + searchResults.map((vendor) => ( +
handleVendorToggle(vendor)} + > +
+ +
+
{vendor.vendorName}
+
+ {vendor.vendorCode || 'N/A'} {vendor.country && `• ${vendor.country}`} +
+
+
+
+ )) + ) : ( +
+ 검색 결과가 없습니다 +
+ )} +
+
+
+ )} + + {/* 검색 안내 메시지 */} + {!hasSearched && !searchTerm && ( +
+ 벤더명 또는 벤더코드를 입력하여 검색해주세요 +
+ )} + + {/* 선택된 벤더 목록 - 하단에 항상 표시 */} + ( + +
+ 선택된 벤더 ({selectedVendorData.length}개) +
+ {selectedVendorData.length > 0 ? ( +
+ {selectedVendorData.map((vendor) => ( + + {vendor.vendorName} ({vendor.vendorCode || 'N/A'}) + handleRemoveVendor(vendor.id)} + /> + + ))} +
+ ) : ( +
+ 선택된 벤더가 없습니다 +
+ )} +
+
+ +
+ )} + /> + + {/* 안내 메시지 */} +
+ {/*

• 검색은 ACTIVE 상태의 벤더만 대상으로 합니다.

*/} +

• 선택된 벤더들은 Draft 상태로 추가됩니다.

+

• 벤더별 견적 정보는 추가 후 개별적으로 입력할 수 있습니다.

+

• 이미 추가된 벤더는 검색 결과에서 체크됩니다.

+
+ + +
+ + {/* 푸터 */} + + + + +
+
+ ) +} \ No newline at end of file -- cgit v1.2.3