summaryrefslogtreecommitdiff
path: root/lib/rfq-last/vendor
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-14 05:28:01 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-14 05:28:01 +0000
commit675b4e3d8ffcb57a041db285417d81e61284d900 (patch)
tree254f3d6a6c0ce39ae8fba35618f3810e08945f19 /lib/rfq-last/vendor
parent39f12cb19f29cbc5568057e154e6adf4789ae736 (diff)
(대표님) RFQ-last, tbe-last, 기본계약 템플릿 내 견적,입찰,계약 추가, env.dev NAS_PATH 수정
Diffstat (limited to 'lib/rfq-last/vendor')
-rw-r--r--lib/rfq-last/vendor/add-vendor-dialog.tsx438
-rw-r--r--lib/rfq-last/vendor/delete-vendor-dialog.tsx124
-rw-r--r--lib/rfq-last/vendor/edit-contract-dialog.tsx237
-rw-r--r--lib/rfq-last/vendor/rfq-vendor-table.tsx463
-rw-r--r--lib/rfq-last/vendor/send-rfq-dialog.tsx739
-rw-r--r--lib/rfq-last/vendor/vendor-detail-dialog.tsx695
6 files changed, 2494 insertions, 202 deletions
diff --git a/lib/rfq-last/vendor/add-vendor-dialog.tsx b/lib/rfq-last/vendor/add-vendor-dialog.tsx
index d8745298..8566763f 100644
--- a/lib/rfq-last/vendor/add-vendor-dialog.tsx
+++ b/lib/rfq-last/vendor/add-vendor-dialog.tsx
@@ -24,7 +24,7 @@ import {
PopoverContent,
PopoverTrigger
} from "@/components/ui/popover";
-import { Check, ChevronsUpDown, Loader2, X, Plus } from "lucide-react";
+import { Check, ChevronsUpDown, Loader2, X, Plus, FileText, Shield, Globe, Settings } from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { addVendorsToRfq } from "../service";
@@ -34,6 +34,17 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { ScrollArea } from "@/components/ui/scroll-area";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Info } from "lucide-react";
+import { Checkbox } from "@/components/ui/checkbox";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Separator } from "@/components/ui/separator";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+
+interface VendorContract {
+ vendorId: number;
+ agreementYn: boolean;
+ ndaYn: boolean;
+ gtcType: "general" | "project" | "none";
+}
interface AddVendorDialogProps {
open: boolean;
@@ -52,6 +63,17 @@ export function AddVendorDialog({
const [vendorOpen, setVendorOpen] = React.useState(false);
const [vendorList, setVendorList] = React.useState<any[]>([]);
const [selectedVendors, setSelectedVendors] = React.useState<any[]>([]);
+ const [activeTab, setActiveTab] = React.useState<"vendors" | "contracts">("vendors");
+
+ // 각 벤더별 기본계약 요구사항 상태
+ const [vendorContracts, setVendorContracts] = React.useState<VendorContract[]>([]);
+
+ // 일괄 적용용 기본값
+ const [defaultContract, setDefaultContract] = React.useState({
+ agreementYn: true,
+ ndaYn: true,
+ gtcType: "none" as "general" | "project" | "none"
+ });
// 벤더 로드
const loadVendors = React.useCallback(async () => {
@@ -76,13 +98,33 @@ export function AddVendorDialog({
React.useEffect(() => {
if (!open) {
setSelectedVendors([]);
+ setVendorContracts([]);
+ setActiveTab("vendors");
+ setDefaultContract({
+ agreementYn: true,
+ ndaYn: true,
+ gtcType: "none"
+ });
}
}, [open]);
// 벤더 추가
const handleAddVendor = (vendor: any) => {
if (!selectedVendors.find(v => v.id === vendor.id)) {
- setSelectedVendors([...selectedVendors, vendor]);
+ const updatedVendors = [...selectedVendors, vendor];
+ setSelectedVendors(updatedVendors);
+
+ // 해당 벤더의 기본계약 설정 추가
+ const isInternational = vendor.country && vendor.country !== "KR" && vendor.country !== "한국";
+ setVendorContracts([
+ ...vendorContracts,
+ {
+ vendorId: vendor.id,
+ agreementYn: defaultContract.agreementYn,
+ ndaYn: defaultContract.ndaYn,
+ gtcType: isInternational ? defaultContract.gtcType : "none"
+ }
+ ]);
}
setVendorOpen(false);
};
@@ -90,9 +132,36 @@ export function AddVendorDialog({
// 벤더 제거
const handleRemoveVendor = (vendorId: number) => {
setSelectedVendors(selectedVendors.filter(v => v.id !== vendorId));
+ setVendorContracts(vendorContracts.filter(c => c.vendorId !== vendorId));
};
- // 제출 처리 - 벤더만 추가
+ // 개별 벤더의 계약 설정 업데이트
+ 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 = selectedVendors.find(v => v.id === c.vendorId);
+ const isInternational = vendor?.country && vendor.country !== "KR" && vendor.country !== "한국";
+ return {
+ ...c,
+ agreementYn: defaultContract.agreementYn,
+ ndaYn: defaultContract.ndaYn,
+ gtcType: isInternational ? defaultContract.gtcType : "none"
+ };
+ })
+ );
+ toast.success("모든 벤더에 기본계약 설정이 적용되었습니다.");
+ };
+
+ // 제출 처리
const handleSubmit = async () => {
if (selectedVendors.length === 0) {
toast.error("최소 1개 이상의 벤더를 선택해주세요.");
@@ -102,18 +171,32 @@ export function AddVendorDialog({
setIsLoading(true);
try {
- const vendorIds = selectedVendors.map(v => v.id);
- const result = await addVendorsToRfq({
- rfqId,
- vendorIds,
- // 기본값으로 벤더만 추가 (상세 조건은 나중에 일괄 입력)
- conditions: null,
- });
+ // 각 벤더별로 개별 추가
+ const results = await Promise.all(
+ selectedVendors.map(async (vendor) => {
+ const contract = vendorContracts.find(c => c.vendorId === vendor.id);
+ return addVendorsToRfq({
+ rfqId,
+ vendorIds: [vendor.id],
+ conditions: null,
+ contractRequirements: contract || defaultContract
+ });
+ })
+ );
+
+ // 결과 확인
+ const successCount = results.filter(r => r.success).length;
+ const failedCount = results.length - successCount;
- if (result.success) {
+ if (successCount > 0) {
toast.success(
<div>
- <p>{selectedVendors.length}개 벤더가 추가되었습니다.</p>
+ <p>{successCount}개 벤더가 추가되었습니다.</p>
+ {failedCount > 0 && (
+ <p className="text-sm text-destructive mt-1">
+ {failedCount}개 벤더 추가 실패
+ </p>
+ )}
<p className="text-sm text-muted-foreground mt-1">
벤더 목록에서 '정보 일괄 입력' 버튼으로 조건을 설정하세요.
</p>
@@ -122,7 +205,7 @@ export function AddVendorDialog({
onSuccess();
onOpenChange(false);
} else {
- toast.error(result.error || "벤더 추가에 실패했습니다.");
+ toast.error("벤더 추가에 실패했습니다.");
}
} catch (error) {
console.error("Submit error:", error);
@@ -137,38 +220,41 @@ export function AddVendorDialog({
return selectedVendors.some(v => v.id === vendorId);
};
+ // 선택된 벤더가 있고 계약 탭으로 이동 가능한지
+ const canProceedToContracts = selectedVendors.length > 0;
+
return (
<Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-2xl max-h-[80vh] p-0 flex flex-col">
+ <DialogContent className="max-w-4xl max-h-[90vh] p-0 flex flex-col">
{/* 헤더 */}
<DialogHeader className="p-6 pb-0">
<DialogTitle>벤더 추가</DialogTitle>
<DialogDescription>
- 견적 요청을 보낼 벤더를 선택하세요. 조건 설정은 추가 후 일괄로 진행할 수 있습니다.
+ 견적 요청을 보낼 벤더를 선택하고 각 벤더별 기본계약 요구사항을 설정하세요.
</DialogDescription>
</DialogHeader>
- {/* 컨텐츠 영역 */}
- <div className="flex-1 px-6 py-4 overflow-y-auto">
- <div className="space-y-4">
- {/* 안내 메시지 */}
- <Alert>
- <Info className="h-4 w-4" />
- <AlertDescription>
- 여기서는 벤더만 선택합니다. 납기일, 결제조건 등의 상세 정보는 벤더 추가 후
- '정보 일괄 입력' 기능으로 한 번에 설정할 수 있습니다.
- </AlertDescription>
- </Alert>
-
- {/* 벤더 선택 카드 */}
+ {/* 탭 */}
+ <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. 벤더 선택
+ {selectedVendors.length > 0 && (
+ <Badge variant="secondary" className="ml-2">
+ {selectedVendors.length}
+ </Badge>
+ )}
+ </TabsTrigger>
+ <TabsTrigger value="contracts" disabled={!canProceedToContracts}>
+ 2. 기본계약 설정
+ </TabsTrigger>
+ </TabsList>
+
+ {/* 벤더 선택 탭 */}
+ <TabsContent value="vendors" className="flex-1 flex flex-col px-6 py-4 overflow-y-auto min-h-0">
<Card>
<CardHeader>
- <div className="flex items-center justify-between">
- <CardTitle className="text-lg">벤더 선택</CardTitle>
- <Badge variant="outline" className="ml-2">
- {selectedVendors.length}개 선택됨
- </Badge>
- </div>
+ <CardTitle className="text-lg">벤더 선택</CardTitle>
<CardDescription>
RFQ를 발송할 벤더를 선택하세요. 여러 개 선택 가능합니다.
</CardDescription>
@@ -196,11 +282,11 @@ export function AddVendorDialog({
<Command>
<CommandInput placeholder="벤더명 또는 코드로 검색..." />
<CommandList
- onWheel={(e) => {
- e.stopPropagation(); // 이벤트 전파 차단
- const target = e.currentTarget;
- target.scrollTop += e.deltaY; // 직접 스크롤 처리
- }}
+ onWheel={(e) => {
+ e.stopPropagation();
+ const target = e.currentTarget;
+ target.scrollTop += e.deltaY;
+ }}
>
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
<CommandGroup>
@@ -218,9 +304,12 @@ export function AddVendorDialog({
</Badge>
<span className="truncate">{vendor.vendorName}</span>
{vendor.country && (
- <span className="text-xs text-muted-foreground ml-auto">
+ <Badge
+ variant={vendor.country === "KR" || vendor.country === "한국" ? "default" : "secondary"}
+ className="ml-auto"
+ >
{vendor.country}
- </span>
+ </Badge>
)}
</div>
</CommandItem>
@@ -234,41 +323,46 @@ export function AddVendorDialog({
{/* 선택된 벤더 목록 */}
{selectedVendors.length > 0 && (
<div className="space-y-2">
- <Label className="text-sm text-muted-foreground">선택된 벤더 목록</Label>
- <ScrollArea className="h-[200px] w-full rounded-md border p-4">
- <div className="space-y-2">
- {selectedVendors.map((vendor, index) => (
- <div
- key={vendor.id}
- className="flex items-center justify-between p-2 rounded-lg bg-secondary/50"
- >
- <div className="flex items-center gap-2">
- <span className="text-sm text-muted-foreground">
- {index + 1}.
- </span>
- <Badge variant="outline">
- {vendor.vendorCode}
+
+ <div className="space-y-2">
+ {selectedVendors.map((vendor, index) => (
+ <div
+ key={vendor.id}
+ className="flex items-center justify-between p-2 rounded-lg bg-secondary/50"
+ >
+ <div className="flex items-center gap-2">
+ <span className="text-sm text-muted-foreground">
+ {index + 1}.
+ </span>
+ <Badge variant="outline">
+ {vendor.vendorCode}
+ </Badge>
+ <span className="text-sm font-medium">
+ {vendor.vendorName}
+ </span>
+ {vendor.country && (
+ <Badge
+ variant={vendor.country === "KR" || vendor.country === "한국" ? "default" : "secondary"}
+ className="text-xs"
+ >
+ {vendor.country}
</Badge>
- <span className="text-sm font-medium">
- {vendor.vendorName}
- </span>
- </div>
- <Button
- variant="ghost"
- size="sm"
- onClick={() => handleRemoveVendor(vendor.id)}
- className="h-8 w-8 p-0"
- >
- <X className="h-4 w-4" />
- </Button>
+ )}
</div>
- ))}
- </div>
- </ScrollArea>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRemoveVendor(vendor.id)}
+ className="h-8 w-8 p-0"
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ ))}
+ </div>
</div>
)}
- {/* 벤더가 없는 경우 메시지 */}
{selectedVendors.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm">아직 선택된 벤더가 없습니다.</p>
@@ -278,8 +372,177 @@ export function AddVendorDialog({
</div>
</CardContent>
</Card>
- </div>
- </div>
+ </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.id);
+ const isInternational = vendor.country && vendor.country !== "KR" && vendor.country !== "한국";
+
+ 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">
+ <Badge variant="outline">{vendor.vendorCode}</Badge>
+ <span className="font-medium">{vendor.vendorName}</span>
+ <Badge
+ variant={isInternational ? "secondary" : "default"}
+ className="text-xs"
+ >
+ {vendor.country || "미지정"}
+ </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) =>
+ updateVendorContract(vendor.id, "agreementYn", !!checked)
+ }
+ />
+ <label className="text-sm">기술자료 제공</label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ checked={contract?.ndaYn || false}
+ onCheckedChange={(checked) =>
+ updateVendorContract(vendor.id, "ndaYn", !!checked)
+ }
+ />
+ <label className="text-sm">NDA</label>
+ </div>
+ </div>
+
+ {isInternational && (
+ <div className="space-y-1">
+ <Label className="text-xs">GTC</Label>
+ <RadioGroup
+ value={contract?.gtcType || "none"}
+ onValueChange={(value) =>
+ updateVendorContract(vendor.id, "gtcType", value)
+ }
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="none" id={`${vendor.id}-none`} />
+ <label htmlFor={`${vendor.id}-none`} className="text-xs">없음</label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="general" id={`${vendor.id}-general`} />
+ <label htmlFor={`${vendor.id}-general`} className="text-xs">General</label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="project" id={`${vendor.id}-project`} />
+ <label htmlFor={`${vendor.id}-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">
@@ -290,16 +553,25 @@ export function AddVendorDialog({
>
취소
</Button>
- <Button
- onClick={handleSubmit}
- disabled={isLoading || selectedVendors.length === 0}
- >
- {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- {selectedVendors.length > 0
- ? `${selectedVendors.length}개 벤더 추가`
- : '벤더 추가'
- }
- </Button>
+ {activeTab === "vendors" && canProceedToContracts && (
+ <Button
+ onClick={() => setActiveTab("contracts")}
+ >
+ 다음: 기본계약 설정
+ </Button>
+ )}
+ {activeTab === "contracts" && (
+ <Button
+ onClick={handleSubmit}
+ disabled={isLoading || selectedVendors.length === 0}
+ >
+ {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ {selectedVendors.length > 0
+ ? `${selectedVendors.length}개 벤더 추가`
+ : '벤더 추가'
+ }
+ </Button>
+ )}
</DialogFooter>
</DialogContent>
</Dialog>
diff --git a/lib/rfq-last/vendor/delete-vendor-dialog.tsx b/lib/rfq-last/vendor/delete-vendor-dialog.tsx
new file mode 100644
index 00000000..7634509e
--- /dev/null
+++ b/lib/rfq-last/vendor/delete-vendor-dialog.tsx
@@ -0,0 +1,124 @@
+// components/delete-vendor-dialog.tsx
+"use client";
+
+import * as React from "react";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { AlertTriangle, Loader2 } from "lucide-react";
+import { toast } from "sonner";
+import { deleteRfqVendor } from "../service";
+
+interface DeleteVendorDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ rfqId: number;
+ vendorData: {
+ detailId: number;
+ vendorId: number;
+ vendorName: string;
+ vendorCode?: string | null;
+ hasQuotation: boolean; // quotationStatus가 있는지 여부
+ };
+ onSuccess?: () => void;
+}
+
+export function DeleteVendorDialog({
+ open,
+ onOpenChange,
+ rfqId,
+ vendorData,
+ onSuccess,
+}: DeleteVendorDialogProps) {
+ const [isDeleting, setIsDeleting] = React.useState(false);
+
+ const handleDelete = async () => {
+ // quotationStatus가 있으면 삭제 불가 (추가 보호)
+ if (vendorData.hasQuotation) {
+ toast.error("견적서가 제출된 벤더는 삭제할 수 없습니다.");
+ return;
+ }
+
+ try {
+ setIsDeleting(true);
+
+ const result = await deleteRfqVendor({
+ rfqId,
+ detailId: vendorData.detailId,
+ vendorId: vendorData.vendorId,
+ });
+
+ if (result.success) {
+ toast.success(result.message || "벤더가 삭제되었습니다.");
+ onSuccess?.();
+ onOpenChange(false);
+ } else {
+ toast.error(result.message || "삭제에 실패했습니다.");
+ }
+ } catch (error) {
+ console.error("벤더 삭제 실패:", error);
+ toast.error("삭제 중 오류가 발생했습니다.");
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ return (
+ <AlertDialog open={open} onOpenChange={onOpenChange}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle className="flex items-center gap-2">
+ <AlertTriangle className="h-5 w-5 text-destructive" />
+ 벤더 삭제 확인
+ </AlertDialogTitle>
+ <AlertDialogDescription asChild>
+ <div className="space-y-2">
+ <p>
+ <strong>{vendorData.vendorName}</strong>
+ {vendorData.vendorCode && ` (${vendorData.vendorCode})`}을(를)
+ RFQ 목록에서 삭제하시겠습니까?
+ </p>
+
+ {vendorData.hasQuotation && (
+ <div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
+ <p className="font-semibold">⚠️ 주의: 견적서가 제출된 벤더입니다.</p>
+ <p>견적서가 제출된 벤더는 삭제할 수 없습니다.</p>
+ </div>
+ )}
+
+ {!vendorData.hasQuotation && (
+ <p className="text-sm text-muted-foreground">
+ 이 작업은 되돌릴 수 없습니다. 삭제 후에는 해당 벤더의 모든 RFQ 관련 정보가 제거됩니다.
+ </p>
+ )}
+ </div>
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleDelete}
+ disabled={isDeleting || vendorData.hasQuotation}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {isDeleting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 삭제 중...
+ </>
+ ) : (
+ "삭제"
+ )}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ );
+} \ No newline at end of file
diff --git a/lib/rfq-last/vendor/edit-contract-dialog.tsx b/lib/rfq-last/vendor/edit-contract-dialog.tsx
new file mode 100644
index 00000000..62b851fa
--- /dev/null
+++ b/lib/rfq-last/vendor/edit-contract-dialog.tsx
@@ -0,0 +1,237 @@
+"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 { Checkbox } from "@/components/ui/checkbox";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Separator } from "@/components/ui/separator";
+import { Badge } from "@/components/ui/badge";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { FileText, Shield, Globe, Info, Loader2 } from "lucide-react";
+import { toast } from "sonner";
+import { updateVendorContractRequirements } from "../service";
+
+interface EditContractDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ rfqId: number;
+ vendor: {
+ detailId: number;
+ vendorId: number;
+ vendorName: string;
+ vendorCode?: string;
+ vendorCountry?: string;
+ agreementYn?: boolean;
+ ndaYn?: boolean;
+ generalGtcYn?: boolean;
+ projectGtcYn?: boolean;
+ gtcType?: "general" | "project" | "none";
+ };
+ onSuccess: () => void;
+}
+
+export function EditContractDialog({
+ open,
+ onOpenChange,
+ rfqId,
+ vendor,
+ onSuccess,
+}: EditContractDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false);
+
+ // 기본계약 상태
+ const [contractAgreement, setContractAgreement] = React.useState(false);
+ const [contractNDA, setContractNDA] = React.useState(false);
+ const [contractGTC, setContractGTC] = React.useState<"general" | "project" | "none">("none");
+
+ // 국외 업체 확인
+ const isInternational = React.useMemo(() => {
+ return vendor?.vendorCountry &&
+ vendor.vendorCountry !== "KR" &&
+ vendor.vendorCountry !== "한국";
+ }, [vendor]);
+
+ // 초기값 설정
+ React.useEffect(() => {
+ if (open && vendor) {
+ setContractAgreement(vendor.agreementYn || false);
+ setContractNDA(vendor.ndaYn || false);
+
+ // GTC 타입 결정
+ if (vendor.gtcType) {
+ setContractGTC(vendor.gtcType);
+ } else if (vendor.generalGtcYn) {
+ setContractGTC("general");
+ } else if (vendor.projectGtcYn) {
+ setContractGTC("project");
+ } else {
+ setContractGTC("none");
+ }
+ }
+ }, [open, vendor]);
+
+ // 제출 처리
+ const handleSubmit = async () => {
+ setIsLoading(true);
+
+ try {
+ const result = await updateVendorContractRequirements({
+ rfqId,
+ detailId: vendor.detailId,
+ contractRequirements: {
+ agreementYn: contractAgreement,
+ ndaYn: contractNDA,
+ gtcType: isInternational ? contractGTC : "none",
+ },
+ });
+
+ if (result.success) {
+ toast.success("기본계약 요구사항이 업데이트되었습니다.");
+ onSuccess();
+ onOpenChange(false);
+ } else {
+ toast.error(result.error || "업데이트에 실패했습니다.");
+ }
+ } catch (error) {
+ console.error("Update error:", error);
+ toast.error("오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle>기본계약 수정</DialogTitle>
+ <DialogDescription>
+ <div className="flex items-center gap-2 mt-2">
+ <Badge variant="outline">{vendor?.vendorCode}</Badge>
+ <span className="text-sm font-medium">{vendor?.vendorName}</span>
+ {vendor?.vendorCountry && (
+ <Badge
+ variant={isInternational ? "secondary" : "default"}
+ className="text-xs"
+ >
+ {vendor.vendorCountry}
+ </Badge>
+ )}
+ </div>
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4 py-4">
+ {/* 필수 계약 */}
+ <div className="space-y-3">
+ <Label className="text-sm font-semibold">필수 계약</Label>
+ <div className="space-y-2">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="edit-agreement"
+ checked={contractAgreement}
+ onCheckedChange={(checked) => setContractAgreement(!!checked)}
+ />
+ <label
+ htmlFor="edit-agreement"
+ className="flex items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+ >
+ <FileText className="h-4 w-4 text-blue-500" />
+ 기술자료 제공 동의
+ </label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="edit-nda"
+ checked={contractNDA}
+ onCheckedChange={(checked) => setContractNDA(!!checked)}
+ />
+ <label
+ htmlFor="edit-nda"
+ className="flex items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+ >
+ <Shield className="h-4 w-4 text-green-500" />
+ 비밀유지 계약 (NDA)
+ </label>
+ </div>
+ </div>
+ </div>
+
+ {/* GTC 선택 (국외 업체만) */}
+ {isInternational && (
+ <>
+ <Separator />
+ <div className="space-y-3">
+ <div className="flex items-center justify-between">
+ <Label className="text-sm font-semibold flex items-center gap-2">
+ <Globe className="h-4 w-4" />
+ GTC (General Terms & Conditions)
+ </Label>
+ <Badge variant="outline" className="text-xs">
+ 국외 업체
+ </Badge>
+ </div>
+ <RadioGroup
+ value={contractGTC}
+ onValueChange={(value: any) => setContractGTC(value)}
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="none" id="edit-gtc-none" />
+ <label htmlFor="edit-gtc-none" className="text-sm">
+ GTC 요구하지 않음
+ </label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="general" id="edit-gtc-general" />
+ <label htmlFor="edit-gtc-general" className="text-sm">
+ General GTC
+ </label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="project" id="edit-gtc-project" />
+ <label htmlFor="edit-gtc-project" className="text-sm">
+ Project GTC
+ </label>
+ </div>
+ </RadioGroup>
+ </div>
+ </>
+ )}
+
+ {/* 국내 업체 안내 */}
+ {!isInternational && (
+ <Alert>
+ <Info className="h-4 w-4" />
+ <AlertDescription>
+ 국내 업체는 GTC가 적용되지 않습니다.
+ </AlertDescription>
+ </Alert>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button onClick={handleSubmit} disabled={isLoading}>
+ {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ 저장
+ </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 b2ea7588..830fd448 100644
--- a/lib/rfq-last/vendor/rfq-vendor-table.tsx
+++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx
@@ -25,7 +25,9 @@ import {
Package,
MapPin,
Info,
- Loader2
+ Loader2,
+ Router,
+ Shield
} from "lucide-react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
@@ -52,14 +54,18 @@ import { toast } from "sonner";
import { AddVendorDialog } from "./add-vendor-dialog";
import { BatchUpdateConditionsDialog } from "./batch-update-conditions-dialog";
import { SendRfqDialog } from "./send-rfq-dialog";
-// import { VendorDetailDialog } from "./vendor-detail-dialog";
-// import { sendRfqToVendors } from "@/app/actions/rfq/send-rfq.action";
+
import {
getRfqSendData,
getSelectedVendorsWithEmails,
+ sendRfqToVendors,
type RfqSendData,
type VendorEmailInfo
} from "../service"
+import { VendorResponseDetailDialog } from "./vendor-detail-dialog";
+import { DeleteVendorDialog } from "./delete-vendor-dialog";
+import { useRouter } from "next/navigation"
+import { EditContractDialog } from "./edit-contract-dialog";
// 타입 정의
interface RfqDetail {
@@ -91,20 +97,64 @@ interface RfqDetail {
sparepartDescription?: string | null;
updatedAt?: Date | null;
updatedByUserName?: string | null;
+ emailSentAt: string | null;
+ emailSentTo: string | null; // JSON string
+ emailResentCount: number;
+ lastEmailSentAt: string | null;
+ emailStatus: string | null;
}
interface VendorResponse {
id: number;
- vendorId: number;
- status: "초대됨" | "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소";
+ rfqsLastId: number;
+ rfqLastDetailsId: number;
responseVersion: number;
isLatest: boolean;
- submittedAt: Date | null;
- totalAmount: number | null;
- currency: string | null;
- vendorDeliveryDate: Date | null;
- quotedItemCount?: number;
- attachmentCount?: number;
+ status: "초대됨" | "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소";
+ vendor: {
+ id: number;
+ code: string | null;
+ name: string;
+ email: string;
+ };
+ submission: {
+ submittedAt: Date | null;
+ submittedBy: string | null;
+ submittedByName: string | null;
+ };
+ pricing: {
+ totalAmount: number | null;
+ currency: string | null;
+ vendorCurrency: string | null;
+ };
+ vendorTerms: {
+ paymentTermsCode: string | null;
+ incotermsCode: string | null;
+ deliveryDate: Date | null;
+ contractDuration: string | null;
+ };
+ additionalRequirements: {
+ firstArticle: {
+ required: boolean | null;
+ acceptance: boolean | null;
+ };
+ sparePart: {
+ required: boolean | null;
+ acceptance: boolean | null;
+ };
+ };
+ counts: {
+ quotedItems: number;
+ attachments: number;
+ };
+ remarks: {
+ general: string | null;
+ technical: string | null;
+ };
+ timestamps: {
+ createdAt: string;
+ updatedAt: string;
+ };
}
// Props 타입 정의
@@ -178,7 +228,7 @@ const mergeVendorData = (
): (RfqDetail & { response?: VendorResponse; rfqCode?: string })[] => {
return rfqDetails.map(detail => {
const response = vendorResponses.find(
- r => r.vendorId === detail.vendorId && r.isLatest
+ r => r.vendor.id === detail.vendorId && r.isLatest
);
return { ...detail, response, rfqCode };
});
@@ -208,6 +258,14 @@ export function RfqVendorTable({
const [selectedVendor, setSelectedVendor] = React.useState<any | null>(null);
const [isSendDialogOpen, setIsSendDialogOpen] = React.useState(false);
const [isLoadingSendData, setIsLoadingSendData] = React.useState(false);
+ const [deleteVendorData, setDeleteVendorData] = React.useState<{
+ detailId: number;
+ vendorId: number;
+ vendorName: string;
+ vendorCode?: string | null;
+ hasResponse?: boolean;
+ responseStatus?: string | null;
+ } | null>(null);
const [sendDialogData, setSendDialogData] = React.useState<{
rfqInfo: RfqSendData['rfqInfo'] | null;
@@ -219,12 +277,19 @@ export function RfqVendorTable({
selectedVendors: [],
});
+ const [editContractVendor, setEditContractVendor] = React.useState<any | null>(null);
+
+
+ const router = useRouter()
+
// 데이터 병합
const mergedData = React.useMemo(
() => mergeVendorData(rfqDetails, vendorResponses, rfqCode),
[rfqDetails, vendorResponses, rfqCode]
);
+ console.log(mergedData, "mergedData")
+
// 일괄 발송 핸들러
const handleBulkSend = React.useCallback(async () => {
if (selectedRows.length === 0) {
@@ -277,6 +342,11 @@ export function RfqVendorTable({
contactsByPosition: v.contactsByPosition || {},
primaryEmail: v.primaryEmail,
currency: v.currency,
+ ndaYn: v.ndaYn,
+ generalGtcYn: v.generalGtcYn,
+ projectGtcYn: v.projectGtcYn,
+ agreementYn: v.agreementYn,
+ sendVersion: v.sendVersion
})),
});
@@ -297,25 +367,38 @@ export function RfqVendorTable({
vendorName: string;
vendorCode?: string | null;
vendorCountry?: string | null;
- vendorEmail?: string | null;
+ selectedMainEmail: string;
+ additionalEmails: string[];
+ customEmails?: Array<{ email: string; name?: string }>;
currency?: string | null;
- additionalRecipients: string[];
+ contractRequirements?: {
+ ndaYn: boolean;
+ generalGtcYn: boolean;
+ projectGtcYn: boolean;
+ agreementYn: boolean;
+ projectCode?: string;
+ };
+ isResend: boolean;
+ sendVersion?: number;
}>;
attachments: number[];
message?: string;
+ generatedPdfs?: Array<{ // 타입 추가
+ key: string;
+ buffer: number[];
+ fileName: string;
+ }>;
}) => {
try {
// 서버 액션 호출
- // const result = await sendRfqToVendors({
- // rfqId,
- // rfqCode,
- // vendors: data.vendors,
- // attachmentIds: data.attachments,
- // message: data.message,
- // });
-
- // 임시 성공 처리
- console.log("RFQ 발송 데이터:", data);
+ const result = await sendRfqToVendors({
+ rfqId,
+ rfqCode,
+ vendors: data.vendors,
+ attachmentIds: data.attachments,
+ message: data.message,
+ generatedPdfs: data.generatedPdfs,
+ });
// 성공 후 처리
setSelectedRows([]);
@@ -324,14 +407,23 @@ export function RfqVendorTable({
attachments: [],
selectedVendors: [],
});
+
+ // 기본계약 생성 결과 표시
+ if (result.contractResults && result.contractResults.length > 0) {
+ const totalContracts = result.contractResults.reduce((acc, r) => acc + r.totalCreated, 0);
+ toast.success(`${data.vendors.length}개 업체에 RFQ를 발송하고 ${totalContracts}개의 기본계약을 생성했습니다.`);
+ } else {
+ toast.success(`${data.vendors.length}개 업체에 RFQ를 발송했습니다.`);
+ }
- toast.success(`${data.vendors.length}개 업체에 RFQ를 발송했습니다.`);
+ // 페이지 새로고침
+ router.refresh();
} catch (error) {
console.error("RFQ 발송 실패:", error);
toast.error("RFQ 발송에 실패했습니다.");
throw error;
}
- }, [rfqId, rfqCode]);
+ }, [rfqId, rfqCode, router]);
// 액션 처리
const handleAction = React.useCallback(async (action: string, vendor: any) => {
@@ -344,7 +436,7 @@ export function RfqVendorTable({
// 개별 RFQ 발송
try {
setIsLoadingSendData(true);
-
+
const [rfqSendData, vendorEmailInfos] = await Promise.all([
getRfqSendData(rfqId),
getSelectedVendorsWithEmails(rfqId, [vendor.vendorId])
@@ -369,6 +461,11 @@ export function RfqVendorTable({
contactsByPosition: v.contactsByPosition || {},
primaryEmail: v.primaryEmail,
currency: v.currency,
+ ndaYn: v.ndaYn,
+ generalGtcYn: v.generalGtcYn,
+ projectGtcYn: v.projectGtcYn,
+ agreementYn: v.agreementYn,
+ sendVersion: v.sendVersion,
})),
});
@@ -385,10 +482,29 @@ export function RfqVendorTable({
toast.info("수정 기능은 준비중입니다.");
break;
+ case "edit-contract":
+ // 기본계약 수정
+ setEditContractVendor(vendor);
+ break;
+
case "delete":
- if (confirm(`${vendor.vendorName}을(를) 삭제하시겠습니까?`)) {
- toast.success(`${vendor.vendorName}이(가) 삭제되었습니다.`);
+ // quotationStatus 체크
+ const hasQuotation = !!vendor.quotationStatus;
+
+ if (hasQuotation) {
+ // 견적서가 있으면 즉시 에러 토스트 표시
+ toast.error("이미 발송된 벤더는 삭제할 수 없습니다.");
+ return;
}
+
+ // 삭제 다이얼로그 열기
+ setDeleteVendorData({
+ detailId: vendor.detailId,
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ vendorCode: vendor.vendorCode,
+ hasQuotation: hasQuotation,
+ });
break;
case "response-detail":
@@ -486,12 +602,188 @@ export function RfqVendorTable({
},
size: 100,
},
+
{
- accessorKey: "basicContract",
- header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약" />,
- cell: ({ row }) => row.original.basicContract || "-",
- size: 100,
+ accessorKey: "contractRequirements",
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약 요청" />,
+ cell: ({ row }) => {
+ const vendor = row.original;
+ const isKorean = vendor.vendorCountry === "KR" || vendor.vendorCountry === "한국";
+
+ // 기본계약 상태 확인
+ const requirements = [];
+
+ // 필수 계약들
+ if (vendor.agreementYn) {
+ requirements.push({
+ name: "기술자료",
+ icon: <FileText className="h-3 w-3" />,
+ color: "text-blue-500"
+ });
+ }
+
+ if (vendor.ndaYn) {
+ requirements.push({
+ name: "NDA",
+ icon: <Shield className="h-3 w-3" />,
+ color: "text-green-500"
+ });
+ }
+
+ // GTC (국외 업체만)
+ if (!isKorean) {
+ if (vendor.generalGtcYn || vendor.gtcType === "general") {
+ requirements.push({
+ name: "General GTC",
+ icon: <Globe className="h-3 w-3" />,
+ color: "text-purple-500"
+ });
+ } else if (vendor.projectGtcYn || vendor.gtcType === "project") {
+ requirements.push({
+ name: "Project GTC",
+ icon: <Globe className="h-3 w-3" />,
+ color: "text-indigo-500"
+ });
+ }
+ }
+
+ if (requirements.length === 0) {
+ return <span className="text-xs text-muted-foreground">없음</span>;
+ }
+
+ return (
+ <div className="flex flex-wrap gap-1">
+ {requirements.map((req, idx) => (
+ <TooltipProvider key={idx}>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Badge variant="outline" className="text-xs px-1.5 py-0">
+ <span className={cn("mr-1", req.color)}>
+ {req.icon}
+ </span>
+ {req.name}
+ </Badge>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p className="text-xs">
+ {req.name === "기술자료" && "기술자료 제공 동의서"}
+ {req.name === "NDA" && "비밀유지 계약서"}
+ {req.name === "General GTC" && "일반 거래 약관"}
+ {req.name === "Project GTC" && "프로젝트별 거래 약관"}
+ </p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ ))}
+ </div>
+ );
+ },
+ size: 150,
},
+
+ {
+ accessorKey: "sendVersion",
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="발송 회차" />,
+ cell: ({ row }) => {
+ const version = row.original.sendVersion;
+
+
+ return <span>{version}</span>;
+ },
+ size: 80,
+ },
+ {
+ accessorKey: "emailStatus",
+ header: "이메일 상태",
+ cell: ({ row }) => {
+ const response = row.original;
+ const emailSentAt = response?.emailSentAt;
+ const emailResentCount = response?.emailResentCount || 0;
+ const emailStatus = response?.emailStatus;
+ const status = response?.status;
+
+ if (!emailSentAt) {
+ return (
+ <Badge variant="outline" className="bg-gray-50">
+ <Mail className="h-3 w-3 mr-1" />
+ 미발송
+ </Badge>
+ );
+ }
+
+ // 이메일 상태 표시 (failed인 경우 특별 처리)
+ const getEmailStatusBadge = () => {
+ if (emailStatus === "failed") {
+ return (
+ <Badge variant="destructive">
+ <XCircle className="h-3 w-3 mr-1" />
+ 발송 실패
+ </Badge>
+ );
+ }
+ return (
+ <Badge variant={status === "제출완료" ? "success" : "default"}>
+ {getStatusIcon(status || "")}
+ {status}
+ </Badge>
+ );
+ };
+
+ // emailSentTo JSON 파싱
+ let recipients = { to: [], cc: [], sentBy: "" };
+ try {
+ if (response?.email?.emailSentTo) {
+ recipients = JSON.parse(response.email.emailSentTo);
+ }
+ } catch (e) {
+ console.error("Failed to parse emailSentTo", e);
+ }
+
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <div className="flex flex-col gap-1">
+ {getEmailStatusBadge()}
+ {emailResentCount > 1 && (
+ <Badge variant="secondary" className="text-xs">
+ 재발송 {emailResentCount - 1}회
+ </Badge>
+ )}
+ </div>
+ </TooltipTrigger>
+ <TooltipContent>
+ <div className="space-y-1">
+ <p>최초 발송: {format(new Date(emailSentAt), "yyyy-MM-dd HH:mm")}</p>
+ {response?.email?.lastEmailSentAt && (
+ <p>최근 발송: {format(new Date(response.email.lastEmailSentAt), "yyyy-MM-dd HH:mm")}</p>
+ )}
+ {recipients.to.length > 0 && (
+ <p>수신자: {recipients.to.join(", ")}</p>
+ )}
+ {recipients.cc.length > 0 && (
+ <p>참조: {recipients.cc.join(", ")}</p>
+ )}
+ {recipients.sentBy && (
+ <p>발신자: {recipients.sentBy}</p>
+ )}
+ {emailStatus === "failed" && (
+ <p className="text-red-500 font-semibold">⚠️ 이메일 발송 실패</p>
+ )}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ );
+ },
+ size: 120,
+ },
+ // {
+ // accessorKey: "basicContract",
+ // header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약" />,
+ // cell: ({ row }) => row.original.basicContract || "-",
+ // size: 100,
+ // },
{
accessorKey: "currency",
header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="요청 통화" />,
@@ -641,20 +933,23 @@ export function RfqVendorTable({
size: 120,
},
{
- accessorKey: "response.submittedAt",
+ accessorKey: "response.submission.submittedAt",
header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="참여여부 (회신일)" />,
cell: ({ row }) => {
- const submittedAt = row.original.response?.submittedAt;
+ const participationRepliedAt = row.original.response?.attend?.participationRepliedAt;
- if (!submittedAt) {
- return <Badge variant="outline">미참여</Badge>;
+ if (!participationRepliedAt) {
+ return <Badge variant="outline">미응답</Badge>;
}
+
+ const participationStatus = row.original.response?.attend?.participationStatus;
+
return (
<div className="flex flex-col gap-0.5">
- <Badge variant="default" className="text-xs">참여</Badge>
+ <Badge variant="default" className="text-xs">{participationStatus}</Badge>
<span className="text-xs text-muted-foreground">
- {format(new Date(submittedAt), "MM-dd")}
+ {format(new Date(participationRepliedAt), "yyyy-MM-dd")}
</span>
</div>
);
@@ -665,7 +960,7 @@ export function RfqVendorTable({
id: "responseDetail",
header: "회신상세",
cell: ({ row }) => {
- const hasResponse = !!row.original.response?.submittedAt;
+ const hasResponse = !!row.original.response?.submission?.submittedAt;
if (!hasResponse) {
return <span className="text-muted-foreground text-xs">-</span>;
@@ -731,6 +1026,10 @@ export function RfqVendorTable({
cell: ({ row }) => {
const vendor = row.original;
const hasResponse = !!vendor.response;
+ const emailSentAt = vendor.response?.email?.emailSentAt;
+ const emailResentCount = vendor.response?.email?.emailResentCount || 0;
+ const hasQuotation = !!vendor.quotationStatus;
+ const isKorean = vendor.vendorCountry === "KR" || vendor.vendorCountry === "한국";
return (
<DropdownMenu>
@@ -747,8 +1046,33 @@ export function RfqVendorTable({
<Eye className="mr-2 h-4 w-4" />
상세보기
</DropdownMenuItem>
- {!hasResponse && (
- <DropdownMenuItem
+
+ {/* 기본계약 수정 메뉴 추가 */}
+ <DropdownMenuItem onClick={() => handleAction("edit-contract", vendor)}>
+ <FileText className="mr-2 h-4 w-4" />
+ 기본계약 수정
+ </DropdownMenuItem>
+
+ {emailSentAt && (
+ <>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onClick={() => handleAction("resend", vendor)}
+ disabled={isLoadingSendData}
+ >
+ <RefreshCw className="mr-2 h-4 w-4" />
+ 이메일 재발송
+ {emailResentCount > 0 && (
+ <Badge variant="outline" className="ml-2 text-xs">
+ {emailResentCount}
+ </Badge>
+ )}
+ </DropdownMenuItem>
+ </>
+ )}
+
+ {!emailSentAt && (
+ <DropdownMenuItem
onClick={() => handleAction("send", vendor)}
disabled={isLoadingSendData}
>
@@ -756,24 +1080,28 @@ export function RfqVendorTable({
RFQ 발송
</DropdownMenuItem>
)}
- <DropdownMenuItem onClick={() => handleAction("edit", vendor)}>
- <Edit className="mr-2 h-4 w-4" />
- 조건 수정
- </DropdownMenuItem>
+
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleAction("delete", vendor)}
- className="text-red-600"
+ className={cn(
+ "text-red-600",
+ hasQuotation && "opacity-50 cursor-not-allowed"
+ )}
+ disabled={hasQuotation}
>
<Trash2 className="mr-2 h-4 w-4" />
삭제
+ {hasQuotation && (
+ <span className="ml-2 text-xs">(불가)</span>
+ )}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
size: 60,
- },
+ }
], [handleAction, rfqCode, isLoadingSendData]);
const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [
@@ -850,7 +1178,7 @@ export function RfqVendorTable({
) : (
<>
<Send className="h-4 w-4 mr-2" />
- 선택 발송 ({selectedRows.length})
+ RFQ 발송 ({selectedRows.length})
</>
)}
</Button>
@@ -924,14 +1252,43 @@ export function RfqVendorTable({
/>
{/* 벤더 상세 다이얼로그 */}
- {/* {selectedVendor && (
- <VendorDetailDialog
+ {selectedVendor && (
+ <VendorResponseDetailDialog
open={!!selectedVendor}
onOpenChange={(open) => !open && setSelectedVendor(null)}
- vendor={selectedVendor}
+ data={selectedVendor}
+ rfqId={rfqId}
+ />
+ )}
+
+ {/* 삭제 다이얼로그 추가 */}
+ {deleteVendorData && (
+ <DeleteVendorDialog
+ open={!!deleteVendorData}
+ onOpenChange={(open) => !open && setDeleteVendorData(null)}
rfqId={rfqId}
+ vendorData={deleteVendorData}
+ onSuccess={() => {
+ setDeleteVendorData(null);
+ router.refresh();
+ // 데이터 새로고침
+ }}
/>
- )} */}
+ )}
+
+ {/* 기본계약 수정 다이얼로그 - 새로 추가 */}
+ {editContractVendor && (
+ <EditContractDialog
+ open={!!editContractVendor}
+ onOpenChange={(open) => !open && setEditContractVendor(null)}
+ rfqId={rfqId}
+ vendor={editContractVendor}
+ onSuccess={() => {
+ setEditContractVendor(null);
+ router.refresh();
+ }}
+ />
+ )}
</>
);
} \ No newline at end of file
diff --git a/lib/rfq-last/vendor/send-rfq-dialog.tsx b/lib/rfq-last/vendor/send-rfq-dialog.tsx
index 9d88bdc9..619ea749 100644
--- a/lib/rfq-last/vendor/send-rfq-dialog.tsx
+++ b/lib/rfq-last/vendor/send-rfq-dialog.tsx
@@ -39,7 +39,9 @@ import {
Building,
ChevronDown,
ChevronRight,
- UserPlus
+ UserPlus,
+ Shield,
+ Globe
} from "lucide-react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
@@ -74,6 +76,25 @@ import {
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Progress } from "@/components/ui/progress";
+
+interface ContractToGenerate {
+ vendorId: number;
+ vendorName: string;
+ type: string;
+ templateName: string;
+}
+
// 타입 정의
interface ContactDetail {
id: number;
@@ -102,6 +123,15 @@ interface Vendor {
contactsByPosition?: Record<string, ContactDetail[]>;
primaryEmail?: string | null;
currency?: string | null;
+
+ // 기본계약 정보
+ ndaYn?: boolean;
+ generalGtcYn?: boolean;
+ projectGtcYn?: boolean;
+ agreementYn?: boolean;
+
+ // 발송 정보
+ sendVersion?: number;
}
interface Attachment {
@@ -149,9 +179,29 @@ interface SendRfqDialogProps {
rfqInfo: RfqInfo;
attachments?: Attachment[];
onSend: (data: {
- vendors: VendorWithRecipients[];
+ vendors: Array<{
+ vendorId: number;
+ vendorName: string;
+ vendorCode?: string | null;
+ vendorCountry?: string | null;
+ selectedMainEmail: string;
+ additionalEmails: string[];
+ customEmails?: Array<{ email: string; name?: string }>;
+ currency?: string | null;
+ contractRequirements?: {
+ ndaYn: boolean;
+ generalGtcYn: boolean;
+ projectGtcYn: boolean;
+ agreementYn: boolean;
+ projectCode?: string;
+ };
+ isResend: boolean;
+ sendVersion?: number;
+ contractsSkipped?: boolean;
+ }>;
attachments: number[];
message?: string;
+ generatedPdfs?: Array<{ key: string; buffer: number[]; fileName: string }>;
}) => Promise<void>;
}
@@ -175,49 +225,6 @@ const getAttachmentIcon = (type: string) => {
}
};
-// 파일 크기 포맷
-const formatFileSize = (bytes?: number) => {
- if (!bytes) return "0 KB";
- const kb = bytes / 1024;
- const mb = kb / 1024;
- if (mb >= 1) return `${mb.toFixed(2)} MB`;
- return `${kb.toFixed(2)} KB`;
-};
-
-// 포지션별 아이콘
-const getPositionIcon = (position?: string | null) => {
- if (!position) return <User className="h-3 w-3" />;
-
- const lowerPosition = position.toLowerCase();
- if (lowerPosition.includes('대표') || lowerPosition.includes('ceo')) {
- return <Building2 className="h-3 w-3" />;
- }
- if (lowerPosition.includes('영업') || lowerPosition.includes('sales')) {
- return <Briefcase className="h-3 w-3" />;
- }
- if (lowerPosition.includes('기술') || lowerPosition.includes('tech')) {
- return <Package className="h-3 w-3" />;
- }
- return <User className="h-3 w-3" />;
-};
-
-// 포지션별 색상
-const getPositionColor = (position?: string | null) => {
- if (!position) return 'default';
-
- const lowerPosition = position.toLowerCase();
- if (lowerPosition.includes('대표') || lowerPosition.includes('ceo')) {
- return 'destructive';
- }
- if (lowerPosition.includes('영업') || lowerPosition.includes('sales')) {
- return 'success';
- }
- if (lowerPosition.includes('기술') || lowerPosition.includes('tech')) {
- return 'secondary';
- }
- return 'default';
-};
-
export function SendRfqDialog({
open,
onOpenChange,
@@ -226,6 +233,7 @@ export function SendRfqDialog({
attachments = [],
onSend,
}: SendRfqDialogProps) {
+
const [isSending, setIsSending] = React.useState(false);
const [vendorsWithRecipients, setVendorsWithRecipients] = React.useState<VendorWithRecipients[]>([]);
const [selectedAttachments, setSelectedAttachments] = React.useState<number[]>([]);
@@ -233,6 +241,118 @@ export function SendRfqDialog({
const [expandedVendors, setExpandedVendors] = React.useState<number[]>([]);
const [customEmailInputs, setCustomEmailInputs] = React.useState<Record<number, { email: string; name: string }>>({});
const [showCustomEmailForm, setShowCustomEmailForm] = React.useState<Record<number, boolean>>({});
+ const [showResendConfirmDialog, setShowResendConfirmDialog] = React.useState(false);
+ const [resendVendorsInfo, setResendVendorsInfo] = React.useState<{ count: number; names: string[] }>({ count: 0, names: [] });
+
+ const [isGeneratingPdfs, setIsGeneratingPdfs] = React.useState(false);
+ const [pdfGenerationProgress, setPdfGenerationProgress] = React.useState(0);
+ const [currentGeneratingContract, setCurrentGeneratingContract] = React.useState("");
+ const [generatedPdfs, setGeneratedPdfs] = React.useState<Map<string, { buffer: number[], fileName: string }>>(new Map());
+
+ // 재전송 시 기본계약 스킵 옵션 - 업체별 관리
+ const [skipContractsForVendor, setSkipContractsForVendor] = React.useState<Record<number, boolean>>({});
+
+ const generateContractPdf = async (
+ vendor: VendorWithRecipients,
+ contractType: string,
+ templateName: string
+ ): Promise<{ buffer: number[], fileName: string }> => {
+ try {
+ // 1. 템플릿 데이터 준비 (서버 액션 호출)
+ const prepareResponse = await fetch("/api/contracts/prepare-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ templateName,
+ vendorId: vendor.vendorId,
+ }),
+ });
+
+ if (!prepareResponse.ok) {
+ throw new Error("템플릿 준비 실패");
+ }
+
+ const { template, templateData } = await prepareResponse.json();
+
+ // 2. 템플릿 파일 다운로드
+ const templateResponse = await fetch("/api/contracts/get-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ templatePath: template.filePath }),
+ });
+
+ const templateBlob = await templateResponse.blob();
+ const templateFile = new window.File([templateBlob], "template.docx", {
+ type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ });
+
+ // 3. PDFtron WebViewer로 PDF 변환
+ const pdfBuffer = await convertToPdfWithWebViewer(templateFile, templateData);
+
+ const fileName = `${contractType}_${vendor.vendorCode || vendor.vendorId}_${Date.now()}.pdf`;
+
+ return {
+ buffer: Array.from(pdfBuffer), // Uint8Array를 일반 배열로 변환
+ fileName
+ };
+ } catch (error) {
+ console.error(`PDF 생성 실패 (${vendor.vendorName} - ${contractType}):`, error);
+ throw error;
+ }
+ };
+
+ // PDFtron WebViewer 변환 함수
+ const convertToPdfWithWebViewer = async (
+ templateFile: File,
+ templateData: Record<string, string>
+ ): Promise<Uint8Array> => {
+ const { default: WebViewer } = await import("@pdftron/webviewer");
+
+ const tempDiv = document.createElement('div');
+ tempDiv.style.display = 'none';
+ tempDiv.style.position = 'absolute';
+ tempDiv.style.top = '-9999px';
+ tempDiv.style.left = '-9999px';
+ tempDiv.style.width = '1px';
+ tempDiv.style.height = '1px';
+ document.body.appendChild(tempDiv);
+
+ try {
+ const instance = await WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ enableOfficeEditing: true,
+ },
+ tempDiv
+ );
+
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ const { Core } = instance;
+ const { createDocument } = Core;
+
+ const templateDoc = await createDocument(templateFile, {
+ filename: templateFile.name,
+ extension: 'docx',
+ });
+
+ await templateDoc.applyTemplateValues(templateData);
+ await new Promise(resolve => setTimeout(resolve, 3000));
+
+ const fileData = await templateDoc.getFileData();
+ const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' });
+
+ instance.UI.dispose();
+ return new Uint8Array(pdfBuffer);
+
+ } finally {
+ if (tempDiv.parentNode) {
+ document.body.removeChild(tempDiv);
+ }
+ }
+ };
// 초기화
React.useEffect(() => {
@@ -254,6 +374,15 @@ export function SendRfqDialog({
// 초기화
setCustomEmailInputs({});
setShowCustomEmailForm({});
+
+ // 재전송 업체들의 기본계약 스킵 옵션 초기화 (기본값: false - 재생성)
+ const skipOptions: Record<number, boolean> = {};
+ selectedVendors.forEach(v => {
+ if (v.sendVersion && v.sendVersion > 0) {
+ skipOptions[v.vendorId] = false; // 기본값은 재생성
+ }
+ });
+ setSkipContractsForVendor(skipOptions);
}
}, [open, selectedVendors, attachments]);
@@ -378,11 +507,145 @@ export function SendRfqDialog({
);
};
- // 전송 처리
- const handleSend = async () => {
+ // 실제 발송 처리 함수 (재발송 확인 후 또는 바로 실행)
+ const proceedWithSend = React.useCallback(async () => {
try {
setIsSending(true);
+
+ // 기본계약이 필요한 계약서 목록 수집
+ const contractsToGenerate: ContractToGenerate[] = [];
+
+ for (const vendor of vendorsWithRecipients) {
+ // 재전송 업체이고 해당 업체의 스킵 옵션이 켜져 있으면 계약서 생성 건너뛰기
+ const isResendVendor = vendor.sendVersion && vendor.sendVersion > 0;
+ if (isResendVendor && skipContractsForVendor[vendor.vendorId]) {
+ continue; // 이 벤더의 계약서 생성을 스킵
+ }
+
+ if (vendor.ndaYn) {
+ contractsToGenerate.push({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ type: "NDA",
+ templateName: "비밀"
+ });
+ }
+ if (vendor.generalGtcYn) {
+ contractsToGenerate.push({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ type: "General_GTC",
+ templateName: "General GTC"
+ });
+ }
+ if (vendor.projectGtcYn && rfqInfo?.projectCode) {
+ contractsToGenerate.push({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ type: "Project_GTC",
+ templateName: rfqInfo.projectCode
+ });
+ }
+ if (vendor.agreementYn) {
+ contractsToGenerate.push({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ type: "기술자료",
+ templateName: "기술"
+ });
+ }
+ }
+
+ let pdfsMap = new Map<string, { buffer: number[], fileName: string }>();
+ // PDF 생성이 필요한 경우
+ if (contractsToGenerate.length > 0) {
+ setIsGeneratingPdfs(true);
+ setPdfGenerationProgress(0);
+
+ try {
+ let completed = 0;
+
+ for (const contract of contractsToGenerate) {
+ setCurrentGeneratingContract(`${contract.vendorName} - ${contract.type}`);
+
+ const vendor = vendorsWithRecipients.find(v => v.vendorId === contract.vendorId);
+ if (!vendor) continue;
+
+ const pdf = await generateContractPdf(vendor, contract.type, contract.templateName);
+ pdfsMap.set(`${contract.vendorId}_${contract.type}_${contract.templateName}`, pdf);
+
+ completed++;
+ setPdfGenerationProgress((completed / contractsToGenerate.length) * 100);
+
+ await new Promise(resolve => setTimeout(resolve, 100));
+ }
+
+ setGeneratedPdfs(pdfsMap); // UI 업데이트용
+ } catch (error) {
+ console.error("PDF 생성 실패:", error);
+ toast.error("기본계약서 생성에 실패했습니다.");
+ setIsGeneratingPdfs(false);
+ setPdfGenerationProgress(0);
+ return;
+ }
+ }
+
+ // RFQ 발송 - pdfsMap을 직접 사용
+ setIsGeneratingPdfs(false);
+ setIsSending(true);
+
+ await onSend({
+ vendors: vendorsWithRecipients.map(v => ({
+ vendorId: v.vendorId,
+ vendorName: v.vendorName,
+ vendorCode: v.vendorCode,
+ vendorCountry: v.vendorCountry,
+ selectedMainEmail: v.selectedMainEmail,
+ additionalEmails: v.additionalEmails,
+ customEmails: v.customEmails.map(c => ({ email: c.email, name: c.name })),
+ currency: v.currency,
+ contractRequirements: {
+ ndaYn: v.ndaYn || false,
+ generalGtcYn: v.generalGtcYn || false,
+ projectGtcYn: v.projectGtcYn || false,
+ agreementYn: v.agreementYn || false,
+ projectCode: v.projectGtcYn ? rfqInfo?.projectCode : undefined,
+ },
+ isResend: (v.sendVersion || 0) > 0,
+ sendVersion: v.sendVersion,
+ contractsSkipped: ((v.sendVersion || 0) > 0) && skipContractsForVendor[v.vendorId],
+ })),
+ attachments: selectedAttachments,
+ message: additionalMessage,
+ // 생성된 PDF 데이터 추가
+ generatedPdfs: Array.from(pdfsMap.entries()).map(([key, data]) => ({
+ key,
+ ...data
+ })),
+ });
+
+ toast.success(
+ `${vendorsWithRecipients.length}개 업체에 RFQ를 발송했습니다.` +
+ (contractsToGenerate.length > 0 ? ` ${contractsToGenerate.length}개의 기본계약서가 포함되었습니다.` : '')
+ );
+ onOpenChange(false);
+
+ } catch (error) {
+ console.error("RFQ 발송 실패:", error);
+ toast.error("RFQ 발송에 실패했습니다.");
+ } finally {
+ setIsSending(false);
+ setIsGeneratingPdfs(false);
+ setPdfGenerationProgress(0);
+ setCurrentGeneratingContract("");
+ setSkipContractsForVendor({}); // 초기화
+ }
+}, [vendorsWithRecipients, selectedAttachments, additionalMessage, onSend, onOpenChange, rfqInfo, skipContractsForVendor]);
+
+ // 전송 처리
+ const handleSend = async () => {
+ try {
// 유효성 검사
const vendorsWithoutEmail = vendorsWithRecipients.filter(v => !v.selectedMainEmail);
if (vendorsWithoutEmail.length > 0) {
@@ -395,22 +658,23 @@ export function SendRfqDialog({
return;
}
- await onSend({
- vendors: vendorsWithRecipients.map(v => ({
- ...v,
- additionalRecipients: v.additionalEmails,
- })),
- attachments: selectedAttachments,
- message: additionalMessage,
- });
+ // 재발송 업체 확인
+ const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0);
+ if (resendVendors.length > 0) {
+ // AlertDialog를 표시하기 위해 상태 설정
+ setResendVendorsInfo({
+ count: resendVendors.length,
+ names: resendVendors.map(v => v.vendorName)
+ });
+ setShowResendConfirmDialog(true);
+ return; // 여기서 일단 중단하고 다이얼로그 응답을 기다림
+ }
- toast.success(`${vendorsWithRecipients.length}개 업체에 RFQ를 발송했습니다.`);
- onOpenChange(false);
+ // 재발송 업체가 없으면 바로 진행
+ await proceedWithSend();
} catch (error) {
- console.error("RFQ 발송 실패:", error);
- toast.error("RFQ 발송에 실패했습니다.");
- } finally {
- setIsSending(false);
+ console.error("RFQ 발송 준비 실패:", error);
+ toast.error("RFQ 발송 준비에 실패했습니다.");
}
};
@@ -437,6 +701,35 @@ export function SendRfqDialog({
{/* ScrollArea 대신 div 사용 */}
<div className="flex-1 overflow-y-auto px-1" style={{ maxHeight: 'calc(90vh - 200px)' }}>
<div className="space-y-6 pr-4">
+ {/* 재발송 경고 메시지 - 재발송 업체가 있을 때만 표시 */}
+ {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0) && (
+ <Alert className="border-yellow-500 bg-yellow-50">
+ <AlertCircle className="h-4 w-4 text-yellow-600" />
+ <AlertTitle className="text-yellow-800">재발송 경고</AlertTitle>
+ <AlertDescription className="text-yellow-700 space-y-3">
+ <ul className="list-disc list-inside space-y-1">
+ <li>재발송 대상 업체의 기존 견적 데이터가 초기화됩니다.</li>
+ <li>업체는 새로운 버전의 견적서를 작성해야 합니다.</li>
+ <li>이전에 제출한 견적서는 더 이상 유효하지 않습니다.</li>
+ </ul>
+
+ {/* 기본계약 재발송 정보 */}
+ <div className="mt-3 pt-3 border-t border-yellow-400">
+ <div className="space-y-2">
+ <p className="text-sm font-medium flex items-center gap-2">
+ <FileText className="h-4 w-4" />
+ 기본계약서 재발송 설정
+ </p>
+ <p className="text-xs text-yellow-600">
+ 각 재발송 업체별로 기본계약서 재생성 여부를 선택할 수 있습니다.
+ 아래 표에서 업체별로 설정해주세요.
+ </p>
+ </div>
+ </div>
+ </AlertDescription>
+ </Alert>
+ )}
+
{/* RFQ 정보 섹션 */}
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-medium">
@@ -521,6 +814,40 @@ export function SendRfqDialog({
<tr>
<th className="text-left p-2 text-xs font-medium">No.</th>
<th className="text-left p-2 text-xs font-medium">업체명</th>
+ <th className="text-left p-2 text-xs font-medium">기본계약</th>
+ <th className="text-left p-2 text-xs font-medium">
+ <div className="flex items-center gap-2">
+ <span>계약서 재발송</span>
+ {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0) && (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-5 px-1 text-xs"
+ onClick={() => {
+ const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0);
+ const allChecked = resendVendors.every(v => !skipContractsForVendor[v.vendorId]);
+ const newSkipOptions: Record<number, boolean> = {};
+ resendVendors.forEach(v => {
+ newSkipOptions[v.vendorId] = allChecked;
+ });
+ setSkipContractsForVendor(newSkipOptions);
+ }}
+ >
+ {Object.values(skipContractsForVendor).every(v => v) ? "전체 재생성" :
+ Object.values(skipContractsForVendor).every(v => !v) ? "전체 유지" : "전체 유지"}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ 재발송 업체 전체 선택/해제
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )}
+ </div>
+ </th>
<th className="text-left p-2 text-xs font-medium">주 수신자</th>
<th className="text-left p-2 text-xs font-medium">CC</th>
<th className="text-left p-2 text-xs font-medium">작업</th>
@@ -559,13 +886,41 @@ export function SendRfqDialog({
const ccEmails = allEmails.filter(e => e.value !== vendor.selectedMainEmail);
const selectedMainEmailInfo = allEmails.find(e => e.value === vendor.selectedMainEmail);
const isFormOpen = showCustomEmailForm[vendor.vendorId];
+ const isResend = vendor.sendVersion && vendor.sendVersion > 0;
+
+ // 기본계약 요구사항 확인
+ const contracts = [];
+ if (vendor.ndaYn) contracts.push({ name: "NDA", icon: <Shield className="h-3 w-3" /> });
+ if (vendor.generalGtcYn) contracts.push({ name: "General GTC", icon: <Globe className="h-3 w-3" /> });
+ if (vendor.projectGtcYn) contracts.push({ name: "Project GTC", icon: <Building className="h-3 w-3" /> });
+ if (vendor.agreementYn) contracts.push({ name: "기술자료", icon: <FileText className="h-3 w-3" /> });
return (
<React.Fragment key={vendor.vendorId}>
<tr className="border-b hover:bg-muted/20">
<td className="p-2">
- <div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-xs font-medium">
- {index + 1}
+ <div className="flex items-center gap-1">
+ <div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-xs font-medium">
+ {index + 1}
+ </div>
+ {isResend && (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <Badge variant="warning" className="text-xs">
+ 재발송
+ </Badge>
+ </TooltipTrigger>
+ <TooltipContent>
+ <div className="space-y-1">
+ <p className="font-semibold">⚠️ 재발송 경고</p>
+ <p className="text-xs">발송 회차: {vendor.sendVersion + 1}회차</p>
+ <p className="text-xs text-yellow-600">기존 견적 데이터가 초기화됩니다</p>
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )}
</div>
</td>
<td className="p-2">
@@ -582,6 +937,86 @@ export function SendRfqDialog({
</div>
</td>
<td className="p-2">
+ {contracts.length > 0 ? (
+ <div className="flex flex-wrap gap-1">
+ {/* 재전송이고 스킵 옵션이 켜져 있으면 표시 */}
+ {isResend && skipContractsForVendor[vendor.vendorId] ? (
+ <Badge variant="secondary" className="text-xs px-1">
+ <CheckCircle className="h-3 w-3 mr-1 text-green-500" />
+ <span>기존 계약서 유지</span>
+ </Badge>
+ ) : (
+ contracts.map((contract, idx) => (
+ <TooltipProvider key={idx}>
+ <Tooltip>
+ <TooltipTrigger>
+ <Badge variant="outline" className="text-xs px-1">
+ {contract.icon}
+ <span className="ml-1">{contract.name}</span>
+ {isResend && !skipContractsForVendor[vendor.vendorId] && (
+ <RefreshCw className="h-3 w-3 ml-1 text-orange-500" />
+ )}
+ </Badge>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p className="text-xs">
+ {contract.name === "NDA" && "비밀유지 계약서 요청"}
+ {contract.name === "General GTC" && "일반 거래약관 요청"}
+ {contract.name === "Project GTC" && `프로젝트 거래약관 요청 (${rfqInfo?.projectCode})`}
+ {contract.name === "기술자료" && "기술자료 제공 동의서 요청"}
+ {isResend && !skipContractsForVendor[vendor.vendorId] && (
+ <span className="block mt-1 text-orange-400">⚠️ 재생성됨</span>
+ )}
+ </p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ ))
+ )}
+ </div>
+ ) : (
+ <span className="text-xs text-muted-foreground">없음</span>
+ )}
+ </td>
+ <td className="p-2">
+ {isResend && contracts.length > 0 ? (
+ <div className="flex items-center justify-center">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <div className="flex items-center gap-2">
+ <Checkbox
+ checked={!skipContractsForVendor[vendor.vendorId]}
+ onCheckedChange={(checked) => {
+ setSkipContractsForVendor(prev => ({
+ ...prev,
+ [vendor.vendorId]: !checked
+ }));
+ }}
+ // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600"
+ />
+ <span className="text-xs">
+ {skipContractsForVendor[vendor.vendorId] ? "유지" : "재생성"}
+ </span>
+ </div>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p className="text-xs">
+ {skipContractsForVendor[vendor.vendorId]
+ ? "기존 계약서를 그대로 유지합니다"
+ : "기존 계약서를 삭제하고 새로 생성합니다"}
+ </p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ ) : (
+ <span className="text-xs text-muted-foreground text-center block">
+ {isResend ? "계약서 없음" : "-"}
+ </span>
+ )}
+ </td>
+ <td className="p-2">
<Select
value={vendor.selectedMainEmail}
onValueChange={(value) => handleMainEmailChange(vendor.vendorId, value)}
@@ -676,7 +1111,7 @@ export function SendRfqDialog({
{/* 인라인 수신자 추가 폼 - 한 줄 레이아웃 */}
{isFormOpen && (
<tr className="bg-muted/10 border-b">
- <td colSpan={5} className="p-4">
+ <td colSpan={7} className="p-4">
<div className="space-y-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-sm font-medium">
@@ -871,6 +1306,29 @@ export function SendRfqDialog({
onChange={(e) => setAdditionalMessage(e.target.value)}
/>
</div>
+
+ {/* PDF 생성 진행 상황 표시 */}
+ {isGeneratingPdfs && (
+ <Alert className="border-blue-500 bg-blue-50">
+ <div className="space-y-3">
+ <div className="flex items-center gap-2">
+ <RefreshCw className="h-4 w-4 animate-spin text-blue-600" />
+ <AlertTitle className="text-blue-800">기본계약서 생성 중</AlertTitle>
+ </div>
+ <AlertDescription>
+ <div className="space-y-2">
+ <p className="text-sm text-blue-700">{currentGeneratingContract}</p>
+ <Progress value={pdfGenerationProgress} className="h-2" />
+ <p className="text-xs text-blue-600">
+ {Math.round(pdfGenerationProgress)}% 완료
+ </p>
+ </div>
+ </AlertDescription>
+ </div>
+ </Alert>
+ )}
+
+
</div>
</div>
@@ -892,9 +1350,14 @@ export function SendRfqDialog({
</Button>
<Button
onClick={handleSend}
- disabled={isSending || selectedAttachments.length === 0}
+ disabled={isSending || isGeneratingPdfs || selectedAttachments.length === 0}
>
- {isSending ? (
+ {isGeneratingPdfs ? (
+ <>
+ <RefreshCw className="h-4 w-4 mr-2 animate-spin" />
+ 계약서 생성중... ({Math.round(pdfGenerationProgress)}%)
+ </>
+ ) : isSending ? (
<>
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
발송중...
@@ -908,6 +1371,150 @@ export function SendRfqDialog({
</Button>
</DialogFooter>
</DialogContent>
+
+ {/* 재발송 확인 다이얼로그 */}
+ <AlertDialog open={showResendConfirmDialog} onOpenChange={setShowResendConfirmDialog}>
+ <AlertDialogContent className="max-w-2xl">
+ <AlertDialogHeader>
+ <AlertDialogTitle className="flex items-center gap-2">
+ <AlertCircle className="h-5 w-5 text-yellow-600" />
+ 재발송 확인
+ </AlertDialogTitle>
+ <AlertDialogDescription asChild>
+ <div className="space-y-4">
+ <p className="text-sm">
+ <span className="font-semibold text-yellow-700">{resendVendorsInfo.count}개 업체</span>가 재발송 대상입니다.
+ </p>
+
+ {/* 재발송 대상 업체 목록 및 계약서 설정 */}
+ <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
+ <p className="text-sm font-medium text-yellow-800 mb-3">재발송 대상 업체 및 계약서 설정:</p>
+ <div className="space-y-2">
+ {vendorsWithRecipients
+ .filter(v => v.sendVersion && v.sendVersion > 0)
+ .map(vendor => {
+ const contracts = [];
+ if (vendor.ndaYn) contracts.push("NDA");
+ if (vendor.generalGtcYn) contracts.push("General GTC");
+ if (vendor.projectGtcYn) contracts.push("Project GTC");
+ if (vendor.agreementYn) contracts.push("기술자료");
+
+ return (
+ <div key={vendor.vendorId} className="flex items-center justify-between p-2 bg-white rounded border border-yellow-100">
+ <div className="flex items-center gap-3">
+ <span className="w-1.5 h-1.5 bg-yellow-600 rounded-full" />
+ <div>
+ <span className="text-sm font-medium text-yellow-900">{vendor.vendorName}</span>
+ {contracts.length > 0 && (
+ <div className="text-xs text-yellow-700 mt-0.5">
+ 계약서: {contracts.join(", ")}
+ </div>
+ )}
+ </div>
+ </div>
+ {contracts.length > 0 && (
+ <div className="flex items-center gap-2">
+ <Checkbox
+ checked={!skipContractsForVendor[vendor.vendorId]}
+ onCheckedChange={(checked) => {
+ setSkipContractsForVendor(prev => ({
+ ...prev,
+ [vendor.vendorId]: !checked
+ }));
+ }}
+ className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600"
+ />
+ <span className="text-xs text-yellow-800">
+ {skipContractsForVendor[vendor.vendorId] ? "계약서 유지" : "계약서 재생성"}
+ </span>
+ </div>
+ )}
+ </div>
+ );
+ })}
+ </div>
+
+ {/* 전체 선택 버튼 */}
+ {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0 &&
+ (v.ndaYn || v.generalGtcYn || v.projectGtcYn || v.agreementYn)) && (
+ <div className="mt-3 pt-3 border-t border-yellow-300 flex justify-end gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0);
+ const newSkipOptions: Record<number, boolean> = {};
+ resendVendors.forEach(v => {
+ newSkipOptions[v.vendorId] = true; // 모두 유지
+ });
+ setSkipContractsForVendor(newSkipOptions);
+ }}
+ >
+ 전체 계약서 유지
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0);
+ const newSkipOptions: Record<number, boolean> = {};
+ resendVendors.forEach(v => {
+ newSkipOptions[v.vendorId] = false; // 모두 재생성
+ });
+ setSkipContractsForVendor(newSkipOptions);
+ }}
+ >
+ 전체 계약서 재생성
+ </Button>
+ </div>
+ )}
+ </div>
+
+ {/* 경고 메시지 */}
+ <Alert className="border-red-200 bg-red-50">
+ <AlertCircle className="h-4 w-4 text-red-600" />
+ <AlertTitle className="text-red-800">중요 안내사항</AlertTitle>
+ <AlertDescription className="text-red-700 space-y-2">
+ <ul className="list-disc list-inside space-y-1 text-sm">
+ <li>기존에 작성된 견적 데이터가 <strong>모두 초기화</strong>됩니다.</li>
+ <li>업체는 처음부터 새로 견적서를 작성해야 합니다.</li>
+ <li>이전에 제출한 견적서는 더 이상 유효하지 않습니다.</li>
+ {Object.entries(skipContractsForVendor).some(([vendorId, skip]) => !skip &&
+ vendorsWithRecipients.find(v => v.vendorId === Number(vendorId))) && (
+ <li className="text-orange-700 font-medium">
+ ⚠️ 선택한 업체의 기존 기본계약서가 <strong>삭제</strong>되고 새로운 계약서가 발송됩니다.
+ </li>
+ )}
+ <li>이 작업은 <strong>취소할 수 없습니다</strong>.</li>
+ </ul>
+ </AlertDescription>
+ </Alert>
+
+ <p className="text-sm text-muted-foreground">
+ 재발송을 진행하시겠습니까?
+ </p>
+ </div>
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel onClick={() => {
+ setShowResendConfirmDialog(false);
+ // 취소 시 옵션은 유지 (사용자가 설정한 상태 그대로)
+ }}>
+ 취소
+ </AlertDialogCancel>
+ <AlertDialogAction
+ onClick={() => {
+ setShowResendConfirmDialog(false);
+ proceedWithSend();
+ }}
+ >
+ <RefreshCw className="h-4 w-4 mr-2" />
+ 재발송 진행
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
</Dialog>
);
} \ No newline at end of file
diff --git a/lib/rfq-last/vendor/vendor-detail-dialog.tsx b/lib/rfq-last/vendor/vendor-detail-dialog.tsx
index e69de29b..e4c78656 100644
--- a/lib/rfq-last/vendor/vendor-detail-dialog.tsx
+++ b/lib/rfq-last/vendor/vendor-detail-dialog.tsx
@@ -0,0 +1,695 @@
+"use client";
+
+import * as React from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Separator } from "@/components/ui/separator";
+import {
+ Building2,
+ Calendar,
+ DollarSign,
+ FileText,
+ Package,
+ Globe,
+ MapPin,
+ Clock,
+ CheckCircle,
+ XCircle,
+ AlertCircle,
+ Download,
+ Eye,
+ User,
+ Mail,
+ Phone,
+ CreditCard,
+ Truck,
+ Shield,
+ Paperclip,
+ Info,
+ Edit,
+} from "lucide-react";
+import { format } from "date-fns";
+import { ko } from "date-fns/locale";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+
+// Props 타입 정의
+interface VendorResponseDetailDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ data: any; // mergedData의 row
+ rfqId: number;
+}
+
+// 상태별 설정
+const getStatusConfig = (status: string) => {
+ switch (status) {
+ case "초대됨":
+ return {
+ icon: <Mail className="h-4 w-4" />,
+ color: "text-blue-600",
+ bgColor: "bg-blue-50",
+ variant: "secondary" as const,
+ };
+ case "작성중":
+ return {
+ icon: <Clock className="h-4 w-4" />,
+ color: "text-yellow-600",
+ bgColor: "bg-yellow-50",
+ variant: "outline" as const,
+ };
+ case "제출완료":
+ return {
+ icon: <CheckCircle className="h-4 w-4" />,
+ color: "text-green-600",
+ bgColor: "bg-green-50",
+ variant: "default" as const,
+ };
+ case "수정요청":
+ return {
+ icon: <AlertCircle className="h-4 w-4" />,
+ color: "text-orange-600",
+ bgColor: "bg-orange-50",
+ variant: "warning" as const,
+ };
+ case "최종확정":
+ return {
+ icon: <Shield className="h-4 w-4" />,
+ color: "text-indigo-600",
+ bgColor: "bg-indigo-50",
+ variant: "success" as const,
+ };
+ case "취소":
+ return {
+ icon: <XCircle className="h-4 w-4" />,
+ color: "text-red-600",
+ bgColor: "bg-red-50",
+ variant: "destructive" as const,
+ };
+ default:
+ return {
+ icon: <Info className="h-4 w-4" />,
+ color: "text-gray-600",
+ bgColor: "bg-gray-50",
+ variant: "outline" as const,
+ };
+ }
+};
+
+export function VendorResponseDetailDialog({
+ open,
+ onOpenChange,
+ data,
+ rfqId,
+}: VendorResponseDetailDialogProps) {
+ if (!data) return null;
+
+ const response = data.response;
+ const statusConfig = getStatusConfig(response?.status || "초대됨");
+ const hasSubmitted = !!response?.submission?.submittedAt;
+
+ // 이메일 발송 정보 파싱
+ let emailRecipients = { to: [], cc: [], sentBy: "" };
+ try {
+ if (data.emailSentTo) {
+ emailRecipients = JSON.parse(data.emailSentTo);
+ }
+ } catch (e) {
+ console.error("Failed to parse emailSentTo");
+ }
+
+ // 견적 아이템 (실제로는 response.quotationItems에서 가져옴)
+ const quotationItems = response?.quotationItems || [];
+
+ // 첨부파일 (실제로는 response.attachments에서 가져옴)
+ const attachments = response?.attachments || [];
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <div className="flex items-center justify-between">
+ <div>
+ <DialogTitle className="text-xl font-bold">
+ 벤더 응답 상세
+ </DialogTitle>
+ <DialogDescription className="mt-1">
+ {data.vendorName} ({data.vendorCode || "코드없음"}) - {data.rfqCode}
+ </DialogDescription>
+ </div>
+ <div className="flex items-center gap-2">
+ {/* {onEdit && (
+ <Button variant="outline" size="sm" onClick={onEdit}>
+ <Edit className="h-4 w-4 mr-2" />
+ 수정
+ </Button>
+ )} */}
+ </div>
+ </div>
+ </DialogHeader>
+
+ <Tabs defaultValue="overview" className="mt-4">
+ <TabsList className="grid w-full grid-cols-4">
+ <TabsTrigger value="overview">개요</TabsTrigger>
+ <TabsTrigger value="quotation">견적정보</TabsTrigger>
+ <TabsTrigger value="items">품목상세</TabsTrigger>
+ <TabsTrigger value="attachments">첨부파일</TabsTrigger>
+ </TabsList>
+
+ {/* 개요 탭 */}
+ <TabsContent value="overview" className="space-y-4">
+ {/* 상태 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-base">응답 상태</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-3">
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">현재 상태</span>
+ <Badge variant={statusConfig.variant}>
+ {statusConfig.icon}
+ <span className="ml-1">{response?.status || "초대됨"}</span>
+ </Badge>
+ </div>
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">응답 버전</span>
+ <span className="font-medium">v{response?.responseVersion || 1}</span>
+ </div>
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">Short List</span>
+ <Badge variant={data.shortList ? "default" : "outline"}>
+ {data.shortList ? "선정" : "대기"}
+ </Badge>
+ </div>
+ </div>
+ <div className="space-y-3">
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">제출일시</span>
+ <span className="text-sm">
+ {response?.submission?.submittedAt
+ ? format(new Date(response.submission.submittedAt), "yyyy-MM-dd HH:mm", { locale: ko })
+ : "-"}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">제출자</span>
+ <span className="text-sm">{response?.submission?.submittedByName || "-"}</span>
+ </div>
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">최종 수정일</span>
+ <span className="text-sm">
+ {data.updatedAt
+ ? format(new Date(data.updatedAt), "yyyy-MM-dd HH:mm", { locale: ko })
+ : "-"}
+ </span>
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 벤더 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-base">벤더 정보</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-3">
+ <div className="flex items-center gap-2">
+ <Building2 className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm text-muted-foreground">업체명</span>
+ <span className="font-medium ml-auto">{data.vendorName}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <Package className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm text-muted-foreground">업체코드</span>
+ <span className="font-medium ml-auto">{data.vendorCode || "-"}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <Globe className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm text-muted-foreground">국가</span>
+ <Badge variant={data.vendorCountry === "KR" ? "default" : "secondary"} className="ml-auto">
+ {data.vendorCountry}
+ </Badge>
+ </div>
+ </div>
+ <div className="space-y-3">
+ <div className="flex items-center gap-2">
+ <Mail className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm text-muted-foreground">이메일</span>
+ <span className="text-sm ml-auto">{response?.vendor?.email || "-"}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <Shield className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm text-muted-foreground">업체분류</span>
+ <span className="font-medium ml-auto">{data.vendorCategory || "-"}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <Info className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm text-muted-foreground">AVL 등급</span>
+ <span className="font-medium ml-auto">{data.vendorGrade || "-"}</span>
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 이메일 발송 정보 */}
+ {data.emailSentAt && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-base">이메일 발송 정보</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-3">
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">최초 발송일시</span>
+ <span className="text-sm">
+ {format(new Date(data.emailSentAt), "yyyy-MM-dd HH:mm", { locale: ko })}
+ </span>
+ </div>
+ {data.lastEmailSentAt && data.emailResentCount > 1 && (
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">최근 재발송일시</span>
+ <span className="text-sm">
+ {format(new Date(data.lastEmailSentAt), "yyyy-MM-dd HH:mm", { locale: ko })}
+ <Badge variant="secondary" className="ml-2">
+ 재발송 {data.emailResentCount - 1}회
+ </Badge>
+ </span>
+ </div>
+ )}
+ {emailRecipients.to.length > 0 && (
+ <div className="flex items-start justify-between">
+ <span className="text-sm text-muted-foreground">수신자</span>
+ <span className="text-sm text-right">{emailRecipients.to.join(", ")}</span>
+ </div>
+ )}
+ {emailRecipients.cc.length > 0 && (
+ <div className="flex items-start justify-between">
+ <span className="text-sm text-muted-foreground">참조</span>
+ <span className="text-sm text-right">{emailRecipients.cc.join(", ")}</span>
+ </div>
+ )}
+ {emailRecipients.sentBy && (
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">발신자</span>
+ <span className="text-sm">{emailRecipients.sentBy}</span>
+ </div>
+ )}
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">발송 상태</span>
+ <Badge variant={data.emailStatus === "failed" ? "destructive" : "default"}>
+ {data.emailStatus === "failed" ? "발송 실패" : "발송 완료"}
+ </Badge>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ </TabsContent>
+
+ {/* 견적정보 탭 */}
+ <TabsContent value="quotation" className="space-y-4">
+ {/* 요청 조건 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-base">요청 조건</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-3">
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">통화</span>
+ <Badge variant="outline">{data.currency}</Badge>
+ </div>
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">지급조건</span>
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <span className="font-medium">{data.paymentTermsCode}</span>
+ </TooltipTrigger>
+ {data.paymentTermsDescription && (
+ <TooltipContent>
+ <p>{data.paymentTermsDescription}</p>
+ </TooltipContent>
+ )}
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">인코텀즈</span>
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <span className="font-medium">{data.incotermsCode}</span>
+ </TooltipTrigger>
+ {data.incotermsDescription && (
+ <TooltipContent>
+ <p>{data.incotermsDescription}</p>
+ {data.incotermsDetail && <p className="text-xs">{data.incotermsDetail}</p>}
+ </TooltipContent>
+ )}
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">Tax</span>
+ <span className="font-medium">{data.taxCode || "-"}</span>
+ </div>
+ </div>
+ <div className="space-y-3">
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">납기일</span>
+ <span className="text-sm">
+ {data.deliveryDate
+ ? format(new Date(data.deliveryDate), "yyyy-MM-dd")
+ : "-"}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">계약기간</span>
+ <span className="text-sm">{data.contractDuration || "-"}</span>
+ </div>
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">선적지</span>
+ <span className="text-sm">{data.placeOfShipping || "-"}</span>
+ </div>
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">도착지</span>
+ <span className="text-sm">{data.placeOfDestination || "-"}</span>
+ </div>
+ </div>
+ </div>
+
+ {/* 추가 조건 */}
+ <Separator className="my-4" />
+ <div className="space-y-3">
+ {data.firstYn && (
+ <div className="flex items-start justify-between">
+ <div className="flex items-center gap-2">
+ <Badge variant="outline">초도품</Badge>
+ <span className="text-sm text-muted-foreground">요구사항</span>
+ </div>
+ <span className="text-sm text-right max-w-xs">{data.firstDescription || "초도품 제출 필요"}</span>
+ </div>
+ )}
+ {data.sparepartYn && (
+ <div className="flex items-start justify-between">
+ <div className="flex items-center gap-2">
+ <Badge variant="outline">스페어파트</Badge>
+ <span className="text-sm text-muted-foreground">요구사항</span>
+ </div>
+ <span className="text-sm text-right max-w-xs">{data.sparepartDescription || "스페어파트 제공 필요"}</span>
+ </div>
+ )}
+ {data.materialPriceRelatedYn && (
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Badge variant="outline">연동제</Badge>
+ <span className="text-sm text-muted-foreground">적용</span>
+ </div>
+ <span className="text-sm">적용</span>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 벤더 제안 조건 (제출된 경우) */}
+ {hasSubmitted && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-base">벤더 제안 조건</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-3">
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">제안 통화</span>
+ <Badge variant={response?.pricing?.vendorCurrency === data.currency ? "outline" : "default"}>
+ {response?.pricing?.vendorCurrency || data.currency}
+ </Badge>
+ </div>
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">제안 지급조건</span>
+ <span className="font-medium">
+ {response?.vendorTerms?.paymentTermsCode || data.paymentTermsCode}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">제안 인코텀즈</span>
+ <span className="font-medium">
+ {response?.vendorTerms?.incotermsCode || data.incotermsCode}
+ </span>
+ </div>
+ </div>
+ <div className="space-y-3">
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">제안 납기일</span>
+ <span className="text-sm">
+ {response?.vendorTerms?.deliveryDate
+ ? format(new Date(response.vendorTerms.deliveryDate), "yyyy-MM-dd")
+ : data.deliveryDate
+ ? format(new Date(data.deliveryDate), "yyyy-MM-dd")
+ : "-"}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">총 견적금액</span>
+ <span className="font-bold text-lg">
+ {response?.pricing?.totalAmount
+ ? new Intl.NumberFormat("ko-KR", {
+ style: "currency",
+ currency: response.pricing.vendorCurrency || data.currency,
+ }).format(response.pricing.totalAmount)
+ : "-"}
+ </span>
+ </div>
+ </div>
+ </div>
+
+ {/* 벤더 추가 응답 */}
+ {(response?.additionalRequirements?.firstArticle?.acceptance ||
+ response?.additionalRequirements?.sparePart?.acceptance) && (
+ <>
+ <Separator className="my-4" />
+ <div className="space-y-3">
+ {response?.additionalRequirements?.firstArticle?.acceptance && (
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">초도품 수용여부</span>
+ <Badge
+ variant={
+ response.additionalRequirements.firstArticle.acceptance === "수용"
+ ? "default"
+ : response.additionalRequirements.firstArticle.acceptance === "부분수용"
+ ? "secondary"
+ : "destructive"
+ }
+ >
+ {response.additionalRequirements.firstArticle.acceptance}
+ </Badge>
+ </div>
+ )}
+ {response?.additionalRequirements?.sparePart?.acceptance && (
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">스페어파트 수용여부</span>
+ <Badge
+ variant={
+ response.additionalRequirements.sparePart.acceptance === "수용"
+ ? "default"
+ : response.additionalRequirements.sparePart.acceptance === "부분수용"
+ ? "secondary"
+ : "destructive"
+ }
+ >
+ {response.additionalRequirements.sparePart.acceptance}
+ </Badge>
+ </div>
+ )}
+ </div>
+ </>
+ )}
+
+ {/* 벤더 비고 */}
+ {(response?.remarks?.general || response?.remarks?.technical) && (
+ <>
+ <Separator className="my-4" />
+ <div className="space-y-3">
+ {response?.remarks?.general && (
+ <div>
+ <span className="text-sm text-muted-foreground">일반 비고</span>
+ <p className="mt-1 text-sm">{response.remarks.general}</p>
+ </div>
+ )}
+ {response?.remarks?.technical && (
+ <div>
+ <span className="text-sm text-muted-foreground">기술 제안</span>
+ <p className="mt-1 text-sm">{response.remarks.technical}</p>
+ </div>
+ )}
+ </div>
+ </>
+ )}
+ </CardContent>
+ </Card>
+ )}
+ </TabsContent>
+
+ {/* 품목상세 탭 */}
+ <TabsContent value="items" className="space-y-4">
+ {quotationItems.length > 0 ? (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-base">견적 품목 상세</CardTitle>
+ <CardDescription>
+ 총 {quotationItems.length}개 품목
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>PR No.</TableHead>
+ <TableHead>자재코드</TableHead>
+ <TableHead>자재명</TableHead>
+ <TableHead className="text-right">수량</TableHead>
+ <TableHead>단위</TableHead>
+ <TableHead className="text-right">단가</TableHead>
+ <TableHead className="text-right">금액</TableHead>
+ <TableHead>납기일</TableHead>
+ <TableHead>제조사</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {quotationItems.map((item: any) => (
+ <TableRow key={item.id}>
+ <TableCell className="font-mono text-xs">{item.prNo}</TableCell>
+ <TableCell className="font-mono text-xs">{item.materialCode}</TableCell>
+ <TableCell className="text-xs">{item.materialDescription}</TableCell>
+ <TableCell className="text-right">{item.quantity}</TableCell>
+ <TableCell>{item.uom}</TableCell>
+ <TableCell className="text-right">
+ {new Intl.NumberFormat("ko-KR").format(item.unitPrice)}
+ </TableCell>
+ <TableCell className="text-right font-medium">
+ {new Intl.NumberFormat("ko-KR").format(item.totalPrice)}
+ </TableCell>
+ <TableCell>
+ {item.vendorDeliveryDate
+ ? format(new Date(item.vendorDeliveryDate), "MM-dd")
+ : "-"}
+ </TableCell>
+ <TableCell>{item.manufacturer || "-"}</TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </CardContent>
+ </Card>
+ ) : (
+ <Card>
+ <CardContent className="pt-6">
+ <div className="text-center text-muted-foreground">
+ 아직 제출된 견적 품목이 없습니다.
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ </TabsContent>
+
+ {/* 첨부파일 탭 */}
+ <TabsContent value="attachments" className="space-y-4">
+ {attachments.length > 0 ? (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-base">첨부파일</CardTitle>
+ <CardDescription>
+ 총 {attachments.length}개 파일
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-2">
+ {attachments.map((file: any) => (
+ <div
+ key={file.id}
+ className="flex items-center justify-between p-3 border rounded-lg hover:bg-accent"
+ >
+ <div className="flex items-center gap-3">
+ <Paperclip className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm font-medium">{file.originalFileName}</p>
+ <p className="text-xs text-muted-foreground">
+ {file.attachmentType} • {file.fileSize ? `${(file.fileSize / 1024).toFixed(2)} KB` : "크기 미상"}
+ {file.description && ` • ${file.description}`}
+ </p>
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => {
+ // 파일 미리보기 로직
+ console.log("Preview file:", file.filePath);
+ }}
+ >
+ <Eye className="h-4 w-4" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => {
+ // 파일 다운로드 로직
+ window.open(file.filePath, "_blank");
+ }}
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ </CardContent>
+ </Card>
+ ) : (
+ <Card>
+ <CardContent className="pt-6">
+ <div className="text-center text-muted-foreground">
+ 아직 제출된 첨부파일이 없습니다.
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ </TabsContent>
+ </Tabs>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file