summaryrefslogtreecommitdiff
path: root/lib/rfq-last/vendor/send-rfq-dialog.tsx
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/send-rfq-dialog.tsx
parent39f12cb19f29cbc5568057e154e6adf4789ae736 (diff)
(대표님) RFQ-last, tbe-last, 기본계약 템플릿 내 견적,입찰,계약 추가, env.dev NAS_PATH 수정
Diffstat (limited to 'lib/rfq-last/vendor/send-rfq-dialog.tsx')
-rw-r--r--lib/rfq-last/vendor/send-rfq-dialog.tsx739
1 files changed, 673 insertions, 66 deletions
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