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 | |
| parent | fd542b5ad4bf94b82d872f87b96aa2e7514ffbc3 (diff) | |
(대표님) 구매 RFQ AVL dialog 추가
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/rfq-last/service.ts | 269 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-items-dialog.tsx | 3 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/avl-vendor-dialog.tsx | 634 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/rfq-vendor-table.tsx | 15 |
4 files changed, 914 insertions, 7 deletions
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index 43943c71..98d0e750 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -3,7 +3,7 @@ import { revalidatePath, unstable_cache, unstable_noStore } from "next/cache"; import db from "@/db/db"; -import { paymentTerms, incoterms, rfqLastVendorQuotationItems, rfqLastVendorAttachments, rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView, vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView, vendorContacts, projects, basicContract, basicContractTemplates, rfqLastTbeSessions, rfqLastTbeDocumentReviews } from "@/db/schema"; +import { avlVendorInfo, paymentTerms, incoterms, rfqLastVendorQuotationItems, rfqLastVendorAttachments, rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView, vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView, vendorContacts, projects, basicContract, basicContractTemplates, rfqLastTbeSessions, rfqLastTbeDocumentReviews } from "@/db/schema"; import { sql, and, desc, asc, like, ilike, or, eq, SQL, count, gte, lte, isNotNull, ne, inArray } from "drizzle-orm"; import { filterColumns } from "@/lib/filter-columns"; import { GetRfqLastAttachmentsSchema, GetRfqsSchema } from "./validations"; @@ -4429,4 +4429,269 @@ export async function assignPicToRfqs({ rfqIds, picUserId }: AssignPicParams) { message: error instanceof Error ? error.message : "담당자 지정 중 오류가 발생했습니다." }; } -}
\ No newline at end of file +} + + +// AVL 벤더 정보 가져오기 +export async function getAvlVendorsForRfq(rfqId: number) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + throw new Error("인증이 필요합니다."); + } + + // 1. RFQ 정보 조회하여 프로젝트 코드 가져오기 + const rfqData = await db.select({ + projectId: rfqsLast.projectId, + projectCode: projects.projectCode, + }) + .from(rfqsLast) + .leftJoin(projects, eq(rfqsLast.projectId, projects.id)) + .where(eq(rfqsLast.id, rfqId)) + .limit(1); + + if (!rfqData[0]?.projectCode) { + return { + success: false, + error: "RFQ에 연결된 프로젝트 코드를 찾을 수 없습니다." + }; + } + + const projectCode = rfqData[0].projectCode; + + // 2. RFQ PR Items에서 major인 자재그룹 코드 가져오기 + const majorMaterials = await db.select({ + materialCategory: rfqPrItems.materialCategory, + }) + .from(rfqPrItems) + .where( + and( + eq(rfqPrItems.rfqsLastId, rfqId), + isTrue(rfqPrItems.majorYn) + ) + ) + .groupBy(rfqPrItems.materialCategory); + + if (majorMaterials.length === 0) { + return { + success: false, + error: "Major 자재그룹을 찾을 수 없습니다." + }; + } + + const materialGroupCodes = majorMaterials + .map(m => m.materialCategory) + .filter(Boolean); + + // 3. AVL 벤더 정보 조회 + const avlVendors = await db.select({ + id: avlVendorInfo.id, + vendorId: avlVendorInfo.vendorId, + vendorName: avlVendorInfo.vendorName, + vendorCode: avlVendorInfo.vendorCode, + avlVendorName: avlVendorInfo.avlVendorName, + tier: avlVendorInfo.tier, + headquarterLocation: avlVendorInfo.headquarterLocation, + manufacturingLocation: avlVendorInfo.manufacturingLocation, + materialGroupCode: avlVendorInfo.materialGroupCode, + materialGroupName: avlVendorInfo.materialGroupName, + packageName: avlVendorInfo.packageName, + isAgent: avlVendorInfo.isAgent, + hasAvl: avlVendorInfo.hasAvl, + isBlacklist: avlVendorInfo.isBlacklist, + isBcc: avlVendorInfo.isBcc, + remark: avlVendorInfo.remark, + }) + .from(avlVendorInfo) + .where( + and( + eq(avlVendorInfo.projectCode, projectCode), + // materialGroupCode가 materialGroupCodes 중 하나와 일치 + ...(materialGroupCodes.length > 0 + ? [sql`${avlVendorInfo.materialGroupCode} = ANY(${materialGroupCodes})`] + : [] + ) + ) + ); + + // 4. 이미 RFQ에 추가된 벤더 ID 조회 + const existingVendors = await db.select({ + vendorId: rfqLastDetails.vendorsId, + }) + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + isTrue(rfqLastDetails.isLatest) + ) + ); + + const existingVendorIds = existingVendors + .map(v => v.vendorId) + .filter(Boolean); + + // 5. 벤더 정보가 없는 AVL 레코드에 대해 실제 벤더 정보 조회 및 매칭 + const vendorsWithoutId = avlVendors.filter(v => !v.vendorId); + if (vendorsWithoutId.length > 0) { + // 벤더 이름과 코드로 실제 벤더 찾기 + const vendorNames = vendorsWithoutId.map(v => v.vendorName).filter(Boolean); + const vendorCodes = vendorsWithoutId.map(v => v.vendorCode).filter(Boolean); + + const actualVendors = await db.select({ + id: vendors.id, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + }) + .from(vendors) + .where( + or( + ...(vendorNames.length > 0 ? [sql`${vendors.vendorName} = ANY(${vendorNames})`] : []), + ...(vendorCodes.length > 0 ? [sql`${vendors.vendorCode} = ANY(${vendorCodes})`] : []) + ) + ); + + // AVL 레코드에 실제 벤더 ID 매칭 + avlVendors.forEach(avlVendor => { + if (!avlVendor.vendorId) { + const matchedVendor = actualVendors.find(v => + v.vendorName === avlVendor.vendorName || + v.vendorCode === avlVendor.vendorCode + ); + if (matchedVendor) { + avlVendor.vendorId = matchedVendor.id; + } + } + }); + } + + return { + success: true, + vendors: avlVendors, + existingVendorIds, + projectCode, + materialGroupCodes, + }; + + } catch (error) { + console.error("AVL 벤더 조회 오류:", error); + return { + success: false, + error: "AVL 벤더 정보를 가져오는 중 오류가 발생했습니다." + }; + } +} + +// AVL 벤더를 RFQ에 추가 +export async function addAvlVendorsToRfq({ + rfqId, + vendors, +}: { + rfqId: number; + vendors: Array<{ + vendorId: number; + vendorName: string; + vendorCode: string | null; + contractRequirements: { + agreementYn: boolean; + ndaYn: boolean; + gtcType: "general" | "project" | "none"; + }; + }>; +}) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + throw new Error("인증이 필요합니다."); + } + const userId = Number(session.user.id); + + // 이미 추가된 벤더 확인 + const existingDetails = await db.select({ + vendorId: rfqLastDetails.vendorsId, + }) + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + isTrue(rfqLastDetails.isLatest) + ) + ); + + const existingVendorIds = new Set( + existingDetails.map(d => d.vendorId).filter(Boolean) + ); + + // 추가할 벤더 필터링 + const vendorsToAdd = vendors.filter(v => !existingVendorIds.has(v.vendorId)); + + if (vendorsToAdd.length === 0) { + return { + success: true, + addedCount: 0, + skippedCount: vendors.length, + message: "모든 벤더가 이미 추가되어 있습니다." + }; + } + + // 벤더 추가 + const newDetails = await Promise.all( + vendorsToAdd.map(async (vendor) => { + const { contractRequirements } = vendor; + + // 벤더 정보 조회하여 위치 확인 + const vendorInfo = await db.select({ + country: vendors.country, + }) + .from(vendors) + .where(eq(vendors.id, vendor.vendorId)) + .limit(1); + + const isInternational = vendorInfo[0]?.country && + vendorInfo[0].country !== "KR" && + vendorInfo[0].country !== "한국"; + + return db.insert(rfqLastDetails).values({ + rfqsLastId: rfqId, + vendorsId: vendor.vendorId, + + // 기본계약 설정 + agreementYn: contractRequirements.agreementYn, + ndaYn: contractRequirements.ndaYn, + generalGtcYn: isInternational && contractRequirements.gtcType === "general", + projectGtcYn: isInternational && contractRequirements.gtcType === "project", + gtcType: isInternational ? contractRequirements.gtcType : "none", + + // 기본값 설정 + currency: "USD", + shortList: false, + returnYn: false, + materialPriceRelatedYn: false, + sparepartYn: false, + firstYn: false, + sendVersion: 0, + isLatest: true, + + createdAt: new Date(), + createdBy: userId, + updatedAt: new Date(), + updatedBy: userId, + }).returning(); + }) + ); + + return { + success: true, + addedCount: newDetails.length, + skippedCount: vendors.length - newDetails.length, + message: `${newDetails.length}개의 AVL 벤더가 추가되었습니다.`, + addedVendors: newDetails, + }; + + } catch (error) { + console.error("AVL 벤더 추가 오류:", error); + return { + success: false, + error: "AVL 벤더 추가 중 오류가 발생했습니다." + }; + } +} diff --git a/lib/rfq-last/table/rfq-items-dialog.tsx b/lib/rfq-last/table/rfq-items-dialog.tsx index daa692e9..6262ba9c 100644 --- a/lib/rfq-last/table/rfq-items-dialog.tsx +++ b/lib/rfq-last/table/rfq-items-dialog.tsx @@ -91,7 +91,8 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps setIsLoading(true) try { const result = await getRfqItemsAction(rfqData.id) - + console.log('RfqItemsDialog rfqId:', rfqData.id); + console.log('RfqItemsDialog items result:', result); if (result.success) { setItems(result.data) setStatistics(result.statistics) 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 diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index 0ebcecbd..ad89d1dc 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -71,6 +71,7 @@ import { DeleteVendorDialog } from "./delete-vendor-dialog"; import { useRouter } from "next/navigation" import { EditContractDialog } from "./edit-contract-dialog"; import { createFilterFn } from "@/components/client-data-table/table-filters"; +import { AvlVendorDialog } from "./avl-vendor-dialog"; // 타입 정의 interface RfqDetail { @@ -284,6 +285,12 @@ export function RfqVendorTable({ const [editContractVendor, setEditContractVendor] = React.useState<any | null>(null); const [isUpdatingShortList, setIsUpdatingShortList] = React.useState(false); + const [isAvlDialogOpen, setIsAvlDialogOpen] = React.useState(false); + + // AVL 연동 핸들러 + const handleAvlIntegration = React.useCallback(() => { + setIsAvlDialogOpen(true); + }, []); const router = useRouter() @@ -1472,17 +1479,17 @@ export function RfqVendorTable({ return ( <div className="flex items-center gap-2"> - {(rfqCode?.startsWith("I") || rfqCode?.startsWith("R")) && - + {(rfqCode?.startsWith("I") || rfqCode?.startsWith("R")) && ( <Button variant="outline" size="sm" + onClick={handleAvlIntegration} + className="border-purple-500 text-purple-600 hover:bg-purple-50" > <Link className="h-4 w-4 mr-2" /> AVL 연동 </Button> - } - + )} <Button variant="outline" |
