diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-19 09:43:03 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-19 09:43:03 +0000 |
| commit | b99e57a028703c8f3d9526c47bc51774490f4546 (patch) | |
| tree | 732d5dc1130d163c59a7f31dbcc81fe2ca86b8c2 /lib/rfq-last/vendor/avl-vendor-dialog.tsx | |
| parent | fd542b5ad4bf94b82d872f87b96aa2e7514ffbc3 (diff) | |
(대표님) 구매 RFQ AVL dialog 추가
Diffstat (limited to 'lib/rfq-last/vendor/avl-vendor-dialog.tsx')
| -rw-r--r-- | lib/rfq-last/vendor/avl-vendor-dialog.tsx | 634 |
1 files changed, 634 insertions, 0 deletions
diff --git a/lib/rfq-last/vendor/avl-vendor-dialog.tsx b/lib/rfq-last/vendor/avl-vendor-dialog.tsx new file mode 100644 index 00000000..2efd96b9 --- /dev/null +++ b/lib/rfq-last/vendor/avl-vendor-dialog.tsx @@ -0,0 +1,634 @@ +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Checkbox } from "@/components/ui/checkbox"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Loader2, + X, + FileText, + Shield, + Globe, + Settings, + Link, + CheckCircle, + Info, + AlertCircle, + Building2 +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { getAvlVendorsForRfq, addAvlVendorsToRfq } from "../service"; + +interface AvlVendor { + id: number; + vendorId: number | null; + vendorName: string; + vendorCode: string | null; + avlVendorName: string; + tier: string | null; + headquarterLocation: string | null; + manufacturingLocation: string | null; + materialGroupCode: string; + materialGroupName: string | null; + packageName: string | null; + isAgent: boolean; + hasAvl: boolean; + isBlacklist: boolean; + isBcc: boolean; + remark: string | null; +} + +interface VendorContract { + vendorId: number; + agreementYn: boolean; + ndaYn: boolean; + gtcType: "general" | "project" | "none"; +} + +interface AvlVendorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rfqId: number; + rfqCode?: string; + onSuccess: () => void; +} + +export function AvlVendorDialog({ + open, + onOpenChange, + rfqId, + rfqCode, + onSuccess, +}: AvlVendorDialogProps) { + const [isLoading, setIsLoading] = React.useState(false); + const [isLoadingAvl, setIsLoadingAvl] = React.useState(false); + const [avlVendors, setAvlVendors] = React.useState<AvlVendor[]>([]); + const [selectedVendorIds, setSelectedVendorIds] = React.useState<Set<number>>(new Set()); + const [activeTab, setActiveTab] = React.useState<"vendors" | "contracts">("vendors"); + const [vendorContracts, setVendorContracts] = React.useState<VendorContract[]>([]); + const [existingVendorIds, setExistingVendorIds] = React.useState<Set<number>>(new Set()); + + // 일괄 적용용 기본값 + const [defaultContract, setDefaultContract] = React.useState({ + agreementYn: true, + ndaYn: true, + gtcType: "none" as "general" | "project" | "none" + }); + + // AVL 벤더 로드 + const loadAvlVendors = React.useCallback(async () => { + setIsLoadingAvl(true); + try { + const result = await getAvlVendorsForRfq(rfqId); + if (result.success && result.vendors) { + setAvlVendors(result.vendors); + + // 이미 RFQ에 추가된 벤더 ID 설정 + const existingIds = new Set(result.existingVendorIds || []); + setExistingVendorIds(existingIds); + + // AVL에서 가져온 모든 벤더를 기본 선택 (이미 추가된 것 제외) + const defaultSelected = new Set( + result.vendors + .filter(v => v.vendorId && !existingIds.has(v.vendorId)) + .map(v => v.vendorId!) + ); + setSelectedVendorIds(defaultSelected); + + // 초기 계약 설정 + const initialContracts = result.vendors + .filter(v => v.vendorId && defaultSelected.has(v.vendorId)) + .map(v => { + const isInternational = v.headquarterLocation && + v.headquarterLocation !== "KR" && + v.headquarterLocation !== "한국"; + return { + vendorId: v.vendorId!, + agreementYn: true, + ndaYn: true, + gtcType: isInternational ? "general" : "none" as const + }; + }); + setVendorContracts(initialContracts); + + if (result.vendors.length === 0) { + toast.info("해당 프로젝트와 자재그룹에 대한 AVL 벤더가 없습니다."); + } + } else { + toast.error(result.error || "AVL 데이터를 불러오는데 실패했습니다."); + } + } catch (error) { + console.error("Failed to load AVL vendors:", error); + toast.error("AVL 데이터를 불러오는데 실패했습니다."); + } finally { + setIsLoadingAvl(false); + } + }, [rfqId]); + + // 다이얼로그 열릴 때 데이터 로드 + React.useEffect(() => { + if (open) { + loadAvlVendors(); + } + }, [open, loadAvlVendors]); + + // 초기화 + React.useEffect(() => { + if (!open) { + setAvlVendors([]); + setSelectedVendorIds(new Set()); + setVendorContracts([]); + setActiveTab("vendors"); + setDefaultContract({ + agreementYn: true, + ndaYn: true, + gtcType: "none" + }); + } + }, [open]); + + // 벤더 선택 토글 + const toggleVendorSelection = (vendorId: number) => { + const newSelection = new Set(selectedVendorIds); + if (newSelection.has(vendorId)) { + newSelection.delete(vendorId); + setVendorContracts(contracts => + contracts.filter(c => c.vendorId !== vendorId) + ); + } else { + newSelection.add(vendorId); + const vendor = avlVendors.find(v => v.vendorId === vendorId); + if (vendor) { + const isInternational = vendor.headquarterLocation && + vendor.headquarterLocation !== "KR" && + vendor.headquarterLocation !== "한국"; + setVendorContracts(contracts => [ + ...contracts, + { + vendorId, + agreementYn: defaultContract.agreementYn, + ndaYn: defaultContract.ndaYn, + gtcType: isInternational ? defaultContract.gtcType : "none" + } + ]); + } + } + setSelectedVendorIds(newSelection); + }; + + // 개별 벤더의 계약 설정 업데이트 + const updateVendorContract = (vendorId: number, field: string, value: any) => { + setVendorContracts(contracts => + contracts.map(c => + c.vendorId === vendorId ? { ...c, [field]: value } : c + ) + ); + }; + + // 모든 벤더에 일괄 적용 + const applyToAll = () => { + setVendorContracts(contracts => + contracts.map(c => { + const vendor = avlVendors.find(v => v.vendorId === c.vendorId); + const isInternational = vendor?.headquarterLocation && + vendor.headquarterLocation !== "KR" && + vendor.headquarterLocation !== "한국"; + return { + ...c, + agreementYn: defaultContract.agreementYn, + ndaYn: defaultContract.ndaYn, + gtcType: isInternational ? defaultContract.gtcType : "none" + }; + }) + ); + toast.success("모든 벤더에 기본계약 설정이 적용되었습니다."); + }; + + // 제출 처리 + const handleSubmit = async () => { + if (selectedVendorIds.size === 0) { + toast.error("최소 1개 이상의 벤더를 선택해주세요."); + return; + } + + setIsLoading(true); + try { + const selectedVendors = avlVendors.filter(v => + v.vendorId && selectedVendorIds.has(v.vendorId) + ); + + const result = await addAvlVendorsToRfq({ + rfqId, + vendors: selectedVendors.map(v => ({ + vendorId: v.vendorId!, + vendorName: v.vendorName, + vendorCode: v.vendorCode, + contractRequirements: vendorContracts.find(c => c.vendorId === v.vendorId) || { + agreementYn: true, + ndaYn: true, + gtcType: "none" as const + } + })) + }); + + if (result.success) { + toast.success( + <div> + <p>{result.addedCount}개의 AVL 벤더가 추가되었습니다.</p> + {result.skippedCount && result.skippedCount > 0 && ( + <p className="text-sm text-muted-foreground mt-1"> + {result.skippedCount}개는 이미 추가되어 있어 건너뛰었습니다. + </p> + )} + </div> + ); + onSuccess(); + onOpenChange(false); + } else { + toast.error(result.error || "벤더 추가에 실패했습니다."); + } + } catch (error) { + console.error("Submit error:", error); + toast.error("오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + // 선택 가능한 벤더 필터링 + const selectableVendors = avlVendors.filter(v => v.vendorId); + const selectedVendors = selectableVendors.filter(v => selectedVendorIds.has(v.vendorId!)); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-5xl max-h-[90vh] p-0 flex flex-col"> + <DialogHeader className="p-6 pb-0"> + <DialogTitle className="flex items-center gap-2"> + <Link className="h-5 w-5 text-primary" /> + AVL 벤더 연동 + </DialogTitle> + <DialogDescription> + 프로젝트 AVL에 등록된 벤더를 RFQ에 추가합니다. 선택된 벤더에게 견적 요청을 발송할 수 있습니다. + </DialogDescription> + </DialogHeader> + + {isLoadingAvl ? ( + <div className="flex-1 flex items-center justify-center p-8"> + <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> + </div> + ) : ( + <Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)} className="flex-1 flex flex-col min-h-0"> + <TabsList className="mx-6 grid w-fit grid-cols-2"> + <TabsTrigger value="vendors"> + 1. AVL 벤더 선택 + {selectedVendorIds.size > 0 && ( + <Badge variant="secondary" className="ml-2"> + {selectedVendorIds.size} + </Badge> + )} + </TabsTrigger> + <TabsTrigger value="contracts" disabled={selectedVendorIds.size === 0}> + 2. 기본계약 설정 + </TabsTrigger> + </TabsList> + + <TabsContent value="vendors" className="flex-1 flex flex-col px-6 py-4 overflow-hidden min-h-0"> + {avlVendors.length === 0 ? ( + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + 해당 프로젝트와 자재그룹에 대한 AVL 벤더가 없습니다. + </AlertDescription> + </Alert> + ) : ( + <Card className="flex flex-col flex-1 min-h-0"> + <CardHeader> + <CardTitle className="text-lg flex items-center justify-between"> + <span>AVL 벤더 목록</span> + <Badge variant="outline"> + 총 {avlVendors.length}개 업체 + </Badge> + </CardTitle> + <CardDescription> + AVL에서 자동으로 가져온 벤더입니다. 필요한 벤더를 선택하세요. + </CardDescription> + </CardHeader> + <CardContent className="flex-1 min-h-0"> + <ScrollArea className="h-[400px] pr-4"> + <div className="space-y-2"> + {avlVendors.map((vendor) => { + const isDisabled = !vendor.vendorId || existingVendorIds.has(vendor.vendorId); + const isSelected = vendor.vendorId && selectedVendorIds.has(vendor.vendorId); + const isInternational = vendor.headquarterLocation && + vendor.headquarterLocation !== "KR" && + vendor.headquarterLocation !== "한국"; + + return ( + <div + key={vendor.id} + className={cn( + "flex items-center justify-between p-3 rounded-lg border", + isDisabled && "opacity-50 bg-muted/30", + isSelected && !isDisabled && "bg-primary/5 border-primary/30" + )} + > + <div className="flex items-center gap-3 flex-1"> + <Checkbox + checked={isSelected} + onCheckedChange={() => vendor.vendorId && toggleVendorSelection(vendor.vendorId)} + disabled={isDisabled} + /> + + <div className="flex items-center gap-2 flex-1"> + <Building2 className="h-4 w-4 text-muted-foreground" /> + <div className="flex flex-col"> + <div className="flex items-center gap-2"> + <span className="font-medium">{vendor.vendorName}</span> + {vendor.vendorCode && ( + <Badge variant="outline" className="text-xs"> + {vendor.vendorCode} + </Badge> + )} + {existingVendorIds.has(vendor.vendorId!) && ( + <Badge variant="secondary" className="text-xs"> + <CheckCircle className="h-3 w-3 mr-1" /> + 추가됨 + </Badge> + )} + </div> + <div className="flex items-center gap-2 mt-1"> + {vendor.tier && ( + <Badge variant="outline" className="text-xs"> + 등급: {vendor.tier} + </Badge> + )} + {isInternational ? ( + <Badge variant="secondary" className="text-xs"> + <Globe className="h-3 w-3 mr-1" /> + {vendor.headquarterLocation} + </Badge> + ) : ( + <Badge variant="default" className="text-xs"> + 국내 + </Badge> + )} + {vendor.materialGroupName && ( + <span className="text-xs text-muted-foreground"> + {vendor.materialGroupName} + </span> + )} + {vendor.isAgent && ( + <Badge variant="warning" className="text-xs"> + Agent + </Badge> + )} + </div> + </div> + </div> + + <div className="flex items-center gap-1"> + {vendor.hasAvl && ( + <Badge variant="success" className="text-xs"> + AVL + </Badge> + )} + {vendor.isBcc && ( + <Badge variant="outline" className="text-xs"> + BCC + </Badge> + )} + {vendor.isBlacklist && ( + <Badge variant="destructive" className="text-xs"> + Blacklist + </Badge> + )} + </div> + </div> + </div> + ); + })} + </div> + </ScrollArea> + </CardContent> + </Card> + )} + </TabsContent> + + <TabsContent value="contracts" className="flex-1 flex flex-col px-6 py-4 overflow-hidden min-h-0"> + <div className="flex-1 overflow-y-auto space-y-4 min-h-0"> + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base flex items-center gap-2"> + <Settings className="h-4 w-4" /> + 일괄 적용 설정 + </CardTitle> + <CardDescription> + 모든 벤더에 동일한 설정을 적용할 수 있습니다. + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <div className="flex items-center space-x-2"> + <Checkbox + id="default-agreement" + checked={defaultContract.agreementYn} + onCheckedChange={(checked) => + setDefaultContract({ ...defaultContract, agreementYn: !!checked }) + } + /> + <label htmlFor="default-agreement" className="text-sm font-medium"> + 기술자료 제공 동의 + </label> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + id="default-nda" + checked={defaultContract.ndaYn} + onCheckedChange={(checked) => + setDefaultContract({ ...defaultContract, ndaYn: !!checked }) + } + /> + <label htmlFor="default-nda" className="text-sm font-medium"> + 비밀유지 계약 (NDA) + </label> + </div> + </div> + <div className="space-y-2"> + <Label className="text-sm">GTC (국외 업체용)</Label> + <RadioGroup + value={defaultContract.gtcType} + onValueChange={(value: any) => + setDefaultContract({ ...defaultContract, gtcType: value }) + } + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="none" id="default-gtc-none" /> + <label htmlFor="default-gtc-none" className="text-sm">없음</label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="general" id="default-gtc-general" /> + <label htmlFor="default-gtc-general" className="text-sm">General GTC</label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="project" id="default-gtc-project" /> + <label htmlFor="default-gtc-project" className="text-sm">Project GTC</label> + </div> + </RadioGroup> + </div> + </div> + <Button + variant="secondary" + size="sm" + onClick={applyToAll} + className="w-full" + > + 모든 벤더에 적용 + </Button> + </CardContent> + </Card> + + <Card className="flex flex-col min-h-0"> + <CardHeader className="pb-3"> + <CardTitle className="text-base">개별 벤더 기본계약 설정</CardTitle> + <CardDescription> + 각 벤더별로 다른 기본계약을 요구할 수 있습니다. + </CardDescription> + </CardHeader> + <CardContent className="flex-1 min-h-0"> + <ScrollArea className="h-[250px] pr-4"> + <div className="space-y-4"> + {selectedVendors.map((vendor) => { + const contract = vendorContracts.find(c => c.vendorId === vendor.vendorId); + const isInternational = vendor.headquarterLocation && + vendor.headquarterLocation !== "KR" && + vendor.headquarterLocation !== "한국"; + + return ( + <div key={vendor.id} className="border rounded-lg p-4 space-y-3"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + {vendor.vendorCode && ( + <Badge variant="outline">{vendor.vendorCode}</Badge> + )} + <span className="font-medium">{vendor.vendorName}</span> + <Badge + variant={isInternational ? "secondary" : "default"} + className="text-xs" + > + {vendor.headquarterLocation || "미지정"} + </Badge> + </div> + </div> + + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <div className="flex items-center space-x-2"> + <Checkbox + checked={contract?.agreementYn || false} + onCheckedChange={(checked) => + vendor.vendorId && updateVendorContract(vendor.vendorId, "agreementYn", !!checked) + } + /> + <label className="text-sm">기술자료 제공</label> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + checked={contract?.ndaYn || false} + onCheckedChange={(checked) => + vendor.vendorId && updateVendorContract(vendor.vendorId, "ndaYn", !!checked) + } + /> + <label className="text-sm">NDA</label> + </div> + </div> + + {isInternational && vendor.vendorId && ( + <div className="space-y-1"> + <Label className="text-xs">GTC</Label> + <RadioGroup + value={contract?.gtcType || "none"} + onValueChange={(value) => + updateVendorContract(vendor.vendorId!, "gtcType", value) + } + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="none" id={`${vendor.vendorId}-none`} /> + <label htmlFor={`${vendor.vendorId}-none`} className="text-xs">없음</label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="general" id={`${vendor.vendorId}-general`} /> + <label htmlFor={`${vendor.vendorId}-general`} className="text-xs">General</label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="project" id={`${vendor.vendorId}-project`} /> + <label htmlFor={`${vendor.vendorId}-project`} className="text-xs">Project</label> + </div> + </RadioGroup> + </div> + )} + + {!isInternational && ( + <div className="text-xs text-muted-foreground"> + 국내 업체 - GTC 불필요 + </div> + )} + </div> + </div> + ); + })} + </div> + </ScrollArea> + </CardContent> + </Card> + </div> + </TabsContent> + </Tabs> + )} + + <DialogFooter className="p-6 pt-0 border-t"> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + {activeTab === "vendors" && selectedVendorIds.size > 0 && ( + <Button onClick={() => setActiveTab("contracts")}> + 다음: 기본계약 설정 + </Button> + )} + {activeTab === "contracts" && ( + <Button + onClick={handleSubmit} + disabled={isLoading || selectedVendorIds.size === 0} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {selectedVendorIds.size > 0 + ? `${selectedVendorIds.size}개 AVL 벤더 추가` + : 'AVL 벤더 추가' + } + </Button> + )} + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file |
