summaryrefslogtreecommitdiff
path: root/lib/rfq-last/vendor
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rfq-last/vendor')
-rw-r--r--lib/rfq-last/vendor/avl-vendor-dialog.tsx634
-rw-r--r--lib/rfq-last/vendor/rfq-vendor-table.tsx15
2 files changed, 645 insertions, 4 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
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"