From 5036cf2908792cef45f06256e71f10920f647f49 Mon Sep 17 00:00:00 2001
From: dujinkim <dujin.kim@dtsolution.co.kr>
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<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
-- 
cgit v1.2.3

