diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-15 04:40:22 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-15 04:40:22 +0000 |
| commit | c5002d77087b256599b174ada611621657fcc523 (patch) | |
| tree | 515aab399709755cf3d57d9927e2d81467dea700 /lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx | |
| parent | 9f3b8915ab20f177edafd3c4a4cc1ca0da0fc766 (diff) | |
(최겸) 기술영업 조선,해양RFQ 수정
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 | 271 |
1 files changed, 194 insertions, 77 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 index b66f4d77..3574111f 100644 --- a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx +++ b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx @@ -6,7 +6,7 @@ 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 { Check, X, Search, Loader2, Star } from "lucide-react" import { useSession } from "next-auth/react" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" @@ -15,8 +15,8 @@ import { Form, FormField, FormItem, FormLabel, FormMessage } from "@/components/ 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" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { addTechVendorsToTechSalesRfq, getTechSalesRfqCandidateVendors, searchTechVendors } from "@/lib/techsales-rfq/service" // 폼 유효성 검증 스키마 - 간단화 const vendorFormSchema = z.object({ @@ -33,13 +33,15 @@ type TechSalesRfq = { [key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any } -// 벤더 검색 결과 타입 (searchVendors 함수 반환 타입과 일치) +// 벤더 검색 결과 타입 (techVendor 기반) type VendorSearchResult = { id: number vendorName: string vendorCode: string | null status: string country: string | null + techVendorType?: string | null + matchedItemCount?: number // 후보 벤더 정보 } interface AddVendorDialogProps { @@ -61,10 +63,14 @@ export function AddVendorDialog({ const [isSubmitting, setIsSubmitting] = useState(false) const [searchTerm, setSearchTerm] = useState("") const [searchResults, setSearchResults] = useState<VendorSearchResult[]>([]) + const [candidateVendors, setCandidateVendors] = useState<VendorSearchResult[]>([]) const [isSearching, setIsSearching] = useState(false) + const [isLoadingCandidates, setIsLoadingCandidates] = useState(false) const [hasSearched, setHasSearched] = useState(false) + const [hasCandidatesLoaded, setHasCandidatesLoaded] = useState(false) // 선택된 벤더들을 별도로 관리하여 검색과 독립적으로 유지 const [selectedVendorData, setSelectedVendorData] = useState<VendorSearchResult[]>([]) + const [activeTab, setActiveTab] = useState("candidates") const form = useForm<VendorFormValues>({ resolver: zodResolver(vendorFormSchema), @@ -75,7 +81,32 @@ export function AddVendorDialog({ const selectedVendorIds = form.watch("vendorIds") - // 검색 함수 (디바운스 적용) + // 후보 벤더 로드 함수 + const loadCandidateVendors = useCallback(async () => { + if (!selectedRfq?.id) return + + setIsLoadingCandidates(true) + try { + const result = await getTechSalesRfqCandidateVendors(selectedRfq.id) + if (result.error) { + toast.error(result.error) + setCandidateVendors([]) + } else { + // 이미 추가된 벤더 제외 + const filteredCandidates = result.data?.filter(vendor => !existingVendorIds.includes(vendor.id)) || [] + setCandidateVendors(filteredCandidates) + } + setHasCandidatesLoaded(true) + } catch (error) { + console.error("후보 벤더 로드 오류:", error) + toast.error("후보 벤더를 불러오는 중 오류가 발생했습니다") + setCandidateVendors([]) + } finally { + setIsLoadingCandidates(false) + } + }, [selectedRfq?.id, existingVendorIds]) + + // 벤더 검색 함수 (techVendor 기반) const searchVendorsDebounced = useCallback( async (term: string) => { if (!term.trim()) { @@ -86,9 +117,15 @@ export function AddVendorDialog({ setIsSearching(true) try { - const results = await searchVendors(term, 100) + // 선택된 RFQ의 타입을 기반으로 벤더 검색 + const rfqType = selectedRfq?.rfqCode?.includes("SHIP") ? "SHIP" : + selectedRfq?.rfqCode?.includes("TOP") ? "TOP" : + selectedRfq?.rfqCode?.includes("HULL") ? "HULL" : undefined; + + const results = await searchTechVendors(term, 100, rfqType) + // 이미 추가된 벤더 제외 - const filteredResults = results.filter(vendor => !existingVendorIds.includes(vendor.id)) + const filteredResults = results.filter((vendor: VendorSearchResult) => !existingVendorIds.includes(vendor.id)) setSearchResults(filteredResults) setHasSearched(true) } catch (error) { @@ -111,6 +148,13 @@ export function AddVendorDialog({ return () => clearTimeout(timer) }, [searchTerm, searchVendorsDebounced]) + // 다이얼로그 열릴 때 후보 벤더 로드 + useEffect(() => { + if (open && selectedRfq?.id && !hasCandidatesLoaded) { + loadCandidateVendors() + } + }, [open, selectedRfq?.id, hasCandidatesLoaded, loadCandidateVendors]) + // 벤더 선택/해제 핸들러 const handleVendorToggle = (vendor: VendorSearchResult) => { const currentIds = form.getValues("vendorIds") @@ -155,8 +199,8 @@ export function AddVendorDialog({ try { setIsSubmitting(true) - // 서비스 함수 호출 - const result = await addVendorsToTechSalesRfq({ + // 새로운 서비스 함수 호출 + const result = await addTechVendorsToTechSalesRfq({ rfqId: selectedRfq.id, vendorIds: values.vendorIds, createdBy: Number(session.user.id), @@ -165,15 +209,16 @@ export function AddVendorDialog({ 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) + const successCount = result.data?.length || 0 + toast.success(`${successCount}개의 벤더가 성공적으로 추가되었습니다`) onOpenChange(false) form.reset() setSearchTerm("") setSearchResults([]) + setCandidateVendors([]) setHasSearched(false) + setHasCandidatesLoaded(false) setSelectedVendorData([]) onSuccess?.() } @@ -191,14 +236,69 @@ export function AddVendorDialog({ form.reset() setSearchTerm("") setSearchResults([]) + setCandidateVendors([]) setHasSearched(false) + setHasCandidatesLoaded(false) setSelectedVendorData([]) + setActiveTab("candidates") } }, [open, form]) + // 벤더 목록 렌더링 함수 + const renderVendorList = (vendors: VendorSearchResult[], showMatchCount = false) => ( + <ScrollArea className="h-60 border rounded-md"> + <div className="p-2 space-y-1"> + {vendors.length > 0 ? ( + vendors.map((vendor, index) => ( + <div + key={`${vendor.id}-${index}`} // 고유한 키 생성 + 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="flex items-center gap-2"> + <span className="font-medium">{vendor.vendorName}</span> + {showMatchCount && vendor.matchedItemCount && vendor.matchedItemCount > 0 && ( + <Badge variant="secondary" className="text-xs flex items-center gap-1"> + <Star className="h-3 w-3" /> + {vendor.matchedItemCount}개 매칭 + </Badge> + )} + {vendor.techVendorType && ( + <Badge variant="outline" className="text-xs"> + {vendor.techVendorType} + </Badge> + )} + </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"> + {showMatchCount ? "매칭되는 후보 벤더가 없습니다" : "검색 결과가 없습니다"} + </div> + )} + </div> + </ScrollArea> + ) + return ( <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[700px] max-h-[80vh] flex flex-col"> + <DialogContent className="sm:max-w-[800px] max-h-[80vh] flex flex-col"> {/* 헤더 */} <DialogHeader> <DialogTitle>벤더 추가</DialogTitle> @@ -217,73 +317,91 @@ export function AddVendorDialog({ <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> + {/* 탭 메뉴 */} + <Tabs value={activeTab} onValueChange={setActiveTab}> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="candidates"> + 후보 벤더 ({candidateVendors.length}) + </TabsTrigger> + <TabsTrigger value="search"> + 벤더 검색 + </TabsTrigger> + </TabsList> - {/* 검색 결과 */} - {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"> - 검색 결과가 없습니다 + {/* 후보 벤더 탭 */} + <TabsContent value="candidates" className="space-y-4"> + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <label className="text-sm font-medium">추천 후보 벤더</label> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => { + setHasCandidatesLoaded(false) + loadCandidateVendors() + }} + disabled={isLoadingCandidates} + > + {isLoadingCandidates ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : ( + "새로고침" + )} + </Button> + </div> + + {isLoadingCandidates ? ( + <div className="h-60 border rounded-md flex items-center justify-center"> + <div className="flex items-center gap-2"> + <Loader2 className="h-4 w-4 animate-spin" /> + <span>후보 벤더를 불러오는 중...</span> </div> + </div> + ) : ( + renderVendorList(candidateVendors, true) + )} + + <div className="text-xs text-muted-foreground bg-blue-50 p-2 rounded"> + 💡 RFQ 아이템과 매칭되는 벤더들이 매칭 아이템 수가 많은 순으로 표시됩니다. + </div> + </div> + </TabsContent> + + {/* 벤더 검색 탭 */} + <TabsContent value="search" 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> - </ScrollArea> - </div> - )} + </div> - {/* 검색 안내 메시지 */} - {!hasSearched && !searchTerm && ( - <div className="text-center py-8 text-muted-foreground border rounded-md"> - 벤더명 또는 벤더코드를 입력하여 검색해주세요 - </div> - )} + {/* 검색 결과 */} + {hasSearched ? ( + <div className="space-y-2"> + <div className="text-sm font-medium"> + 검색 결과 ({searchResults.length}개) + </div> + {renderVendorList(searchResults)} + </div> + ) : ( + <div className="text-center py-8 text-muted-foreground border rounded-md"> + 벤더명 또는 벤더코드를 입력하여 검색해주세요 + </div> + )} + </TabsContent> + </Tabs> {/* 선택된 벤더 목록 - 하단에 항상 표시 */} <FormField @@ -324,10 +442,9 @@ export function AddVendorDialog({ {/* 안내 메시지 */} <div className="text-sm text-muted-foreground bg-muted p-3 rounded-md"> - {/* <p>• 검색은 ACTIVE 상태의 벤더만 대상으로 합니다.</p> */} + <p>• 후보 벤더는 RFQ 아이템 코드와 매칭되는 기술영업 벤더들입니다.</p> <p>• 선택된 벤더들은 Draft 상태로 추가됩니다.</p> <p>• 벤더별 견적 정보는 추가 후 개별적으로 입력할 수 있습니다.</p> - <p>• 이미 추가된 벤더는 검색 결과에서 체크됩니다.</p> </div> </form> </Form> |
