diff options
| author | joonhoekim <26rote@gmail.com> | 2025-11-06 20:01:16 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-11-06 20:01:16 +0900 |
| commit | 4c07f977c951cd99dd50d3bdaad0437e3dd55e6d (patch) | |
| tree | 77c0db24854ed1ecde821d4a79543e82fa75eaed | |
| parent | 1b843e0a7ea55c64992f55033b30037239ff67f5 (diff) | |
(김준회) ITB/RFQ/일반견적: 발송시 첨부파일 있는 경우 '암호화해제 결재' 프로세스 타도록 변경
| -rw-r--r-- | lib/approval/handlers-registry.ts | 5 | ||||
| -rw-r--r-- | lib/approval/templates/암호화해제 신청.html | 180 | ||||
| -rw-r--r-- | lib/rfq-last/approval-actions.ts | 155 | ||||
| -rw-r--r-- | lib/rfq-last/approval-handlers.ts | 151 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/application-reason-dialog.tsx | 114 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/avl-vendor-dialog.tsx | 628 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/rfq-vendor-table.tsx | 234 |
7 files changed, 957 insertions, 510 deletions
diff --git a/lib/approval/handlers-registry.ts b/lib/approval/handlers-registry.ts index 7f3261bc..a92c5ce5 100644 --- a/lib/approval/handlers-registry.ts +++ b/lib/approval/handlers-registry.ts @@ -44,6 +44,11 @@ export async function initializeApprovalHandlers() { // const { approveContractInternal } = await import('@/lib/contract/handlers'); // registerActionHandler('contract_approval', approveContractInternal); + // 6. RFQ 발송 핸들러 (첨부파일이 있는 경우) + const { sendRfqWithApprovalInternal } = await import('@/lib/rfq-last/approval-handlers'); + // RFQ 발송 핸들러 등록 (결재 승인 후 실행될 함수 sendRfqWithApprovalInternal) + registerActionHandler('rfq_send_with_attachments', sendRfqWithApprovalInternal); + // ... 추가 핸들러 등록 console.log('[Approval Handlers] All handlers registered successfully'); diff --git a/lib/approval/templates/암호화해제 신청.html b/lib/approval/templates/암호화해제 신청.html new file mode 100644 index 00000000..e2bcacff --- /dev/null +++ b/lib/approval/templates/암호화해제 신청.html @@ -0,0 +1,180 @@ +<div + style=" + max-width: 800px; + margin: 0 auto; + font-family: 'Segoe UI', 'Malgun Gothic', sans-serif; + color: #333; + line-height: 1.6; + " +> + <!-- 헤더 --> + <table + style=" + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; + border: 2px solid #000; + " + > + <thead> + <tr> + <th + style=" + background-color: #fff; + color: #000; + padding: 20px; + text-align: center; + font-size: 24px; + font-weight: 700; + " + > + 암호화해제 신청 + </th> + </tr> + </thead> + </table> + + <!-- 결재 사유 --> + <div + style=" + background-color: #f5f5f5; + padding: 15px; + border: 1px solid #ccc; + margin-bottom: 20px; + " + > + <div style="font-weight: 600; color: #333; margin-bottom: 8px"> + 결재 사유 + </div> + <div style="color: #333"> + 첨부자료가 사외업체에 송부되므로, 정보보호 그룹의 보안지침에 따른 결재 + 요청 + </div> + </div> + + <!-- 파일 목록 --> + <table + style=" + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; + border: 1px solid #666; + " + > + <thead> + <tr> + <th + style=" + background-color: #333; + color: #fff; + padding: 12px; + text-align: left; + font-size: 16px; + font-weight: 600; + border-bottom: 1px solid #666; + " + > + 파일 목록 + </th> + </tr> + </thead> + <tbody> + <tr> + <td + style=" + background-color: #fff; + color: #333; + padding: 15px; + border: 1px solid #ccc; + " + > + {{파일 테이블}} + </td> + </tr> + </tbody> + </table> + + <!-- 제출처 --> + <table + style=" + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; + border: 1px solid #666; + " + > + <thead> + <tr> + <th + style=" + background-color: #333; + color: #fff; + padding: 12px; + text-align: left; + font-size: 16px; + font-weight: 600; + border-bottom: 1px solid #666; + " + > + 제출처 + </th> + </tr> + </thead> + <tbody> + <tr> + <td + style=" + background-color: #fff; + color: #333; + padding: 15px; + border: 1px solid #ccc; + " + > + {{제출처}} + </td> + </tr> + </tbody> + </table> + + <!-- 신청 사유 --> + <table + style=" + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; + border: 1px solid #666; + " + > + <thead> + <tr> + <th + style=" + background-color: #333; + color: #fff; + padding: 12px; + text-align: left; + font-size: 16px; + font-weight: 600; + border-bottom: 1px solid #666; + " + > + 신청 사유 + </th> + </tr> + </thead> + <tbody> + <tr> + <td + style=" + background-color: #fff; + color: #333; + padding: 20px; + border: 1px solid #ccc; + " + > + {{신청사유}} + </td> + </tr> + </tbody> + </table> +</div> diff --git a/lib/rfq-last/approval-actions.ts b/lib/rfq-last/approval-actions.ts new file mode 100644 index 00000000..be435931 --- /dev/null +++ b/lib/rfq-last/approval-actions.ts @@ -0,0 +1,155 @@ +/** + * RFQ 발송 결재 서버 액션 + * + * 첨부파일이 있는 RFQ를 발송할 때 결재를 거치는 서버 액션 + */ + +'use server'; + +import { ApprovalSubmissionSaga } from '@/lib/approval'; +import { mapRfqSendToTemplateVariables } from './approval-handlers'; + +interface RfqSendApprovalData { + // RFQ 기본 정보 + rfqId: number; + rfqCode?: string; + + // 발송 데이터 + 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; + }>; + attachmentIds: number[]; + + // 첨부파일 정보 (파일명, 크기 등) + attachments: Array<{ + fileName?: string | null; + fileSize?: number | null; + }>; + + message?: string; + generatedPdfs?: Array<{ + key: string; + buffer: number[]; + fileName: string; + }>; + hasToSendEmail?: boolean; + + // 신청 사유 + applicationReason: string; + + // 결재 정보 + currentUser: { + id: number; + epId: string | null; + name?: string; + email?: string; + }; + approvers?: string[]; // Knox EP ID 배열 +} + +/** + * RFQ 발송 결재 상신 + * + * 첨부파일이 있는 경우 결재를 거쳐 RFQ를 발송합니다. + */ +export async function requestRfqSendWithApproval(data: RfqSendApprovalData) { + // 1. 입력 검증 + if (!data.currentUser.epId) { + throw new Error('Knox EP ID가 필요합니다. 시스템 관리자에게 문의하세요.'); + } + + if (!data.vendors || data.vendors.length === 0) { + throw new Error('발송할 벤더를 선택해주세요.'); + } + + if (!data.attachmentIds || data.attachmentIds.length === 0) { + throw new Error('첨부파일이 없습니다. 결재가 필요하지 않습니다.'); + } + + console.log('[RFQ Approval] Starting approval process for RFQ send'); + console.log('[RFQ Approval] RFQ ID:', data.rfqId); + console.log('[RFQ Approval] Vendors:', data.vendors.length); + console.log('[RFQ Approval] Attachments:', data.attachmentIds.length); + + try { + // 2. 템플릿 변수 매핑 + const variables = await mapRfqSendToTemplateVariables({ + attachments: data.attachments, + vendorNames: data.vendors.map(v => v.vendorName), + applicationReason: data.applicationReason, + }); + + // 3. 결재 상신용 payload 구성 + // ⚠️ cronjob 환경에서 실행되므로 currentUser 정보를 포함해야 함 + const approvalPayload = { + rfqId: data.rfqId, + rfqCode: data.rfqCode, + vendors: data.vendors, + attachmentIds: data.attachmentIds, + message: data.message, + generatedPdfs: data.generatedPdfs, + hasToSendEmail: data.hasToSendEmail, + currentUser: { + id: data.currentUser.id, + name: data.currentUser.name, + email: data.currentUser.email, + epId: data.currentUser.epId, + }, + }; + + // 4. Saga로 결재 상신 + const saga = new ApprovalSubmissionSaga( + 'rfq_send_with_attachments', // 핸들러 키 + approvalPayload, // 결재 승인 후 실행될 데이터 + { + title: `암호화해제 신청 - ${data.rfqCode || 'RFQ'}`, + description: `${data.vendors.length}개 업체에 첨부파일 ${data.attachmentIds.length}개를 포함한 암호화해제 신청`, + templateName: '암호화해제 신청', // DB에 있어야 함 + variables, + approvers: data.approvers, + currentUser: { + id: data.currentUser.id, + epId: data.currentUser.epId, + email: data.currentUser.email, + }, + } + ); + + const result = await saga.execute(); + + console.log('[RFQ Approval] ✅ Approval submitted successfully'); + console.log('[RFQ Approval] Approval ID:', result.approvalId); + console.log('[RFQ Approval] Pending Action ID:', result.pendingActionId); + + return { + success: true, + ...result, + message: `결재가 상신되었습니다. (결재 ID: ${result.approvalId})`, + }; + + } catch (error) { + console.error('[RFQ Approval] ❌ Failed to submit approval:', error); + throw new Error( + error instanceof Error + ? error.message + : 'RFQ 발송 결재 상신에 실패했습니다.' + ); + } +} + diff --git a/lib/rfq-last/approval-handlers.ts b/lib/rfq-last/approval-handlers.ts new file mode 100644 index 00000000..ed082a8b --- /dev/null +++ b/lib/rfq-last/approval-handlers.ts @@ -0,0 +1,151 @@ +/** + * RFQ 발송 결재 핸들러 + * + * 첨부파일이 있는 RFQ 발송 시 결재 승인 후 실제 발송을 처리하는 핸들러 + */ + +'use server'; + +import { sendRfqToVendors } from './service'; + +/** + * RFQ 발송 핸들러 (결재 승인 후 자동 실행) + * + * @param payload - 결재 상신 시 저장한 RFQ 발송 데이터 + */ +export async function sendRfqWithApprovalInternal(payload: { + rfqId: number; + rfqCode?: string; + 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; + }>; + attachmentIds: number[]; + message?: string; + generatedPdfs?: Array<{ + key: string; + buffer: number[]; + fileName: string; + }>; + hasToSendEmail?: boolean; + currentUser: { // cronjob 환경을 위한 필수 정보 + id: string | number; + name?: string | null; + email?: string | null; + epId?: string | null; + }; +}) { + console.log('[RFQ Approval Handler] Starting RFQ send after approval'); + console.log('[RFQ Approval Handler] RFQ ID:', payload.rfqId); + console.log('[RFQ Approval Handler] Vendors count:', payload.vendors.length); + console.log('[RFQ Approval Handler] Attachments count:', payload.attachmentIds.length); + console.log('[RFQ Approval Handler] User ID:', payload.currentUser.id); + + try { + // 결재 승인 후 실제 RFQ 발송 처리 + // currentUser를 payload에서 전달 (cronjob 환경에서는 session 없음) + const result = await sendRfqToVendors({ + rfqId: payload.rfqId, + rfqCode: payload.rfqCode, + vendors: payload.vendors, + attachmentIds: payload.attachmentIds, + message: payload.message, + generatedPdfs: payload.generatedPdfs, + hasToSendEmail: payload.hasToSendEmail ?? true, + currentUser: payload.currentUser, // ✅ payload의 currentUser 전달 + }); + + console.log('[RFQ Approval Handler] ✅ RFQ sent successfully'); + console.log('[RFQ Approval Handler] Total sent:', result.totalSent); + console.log('[RFQ Approval Handler] Total contracts:', result.totalContracts); + + return { + success: true, + ...result, + }; + } catch (error) { + console.error('[RFQ Approval Handler] ❌ Failed to send RFQ:', error); + throw new Error( + error instanceof Error + ? `RFQ 발송 실패: ${error.message}` + : 'RFQ 발송 중 오류가 발생했습니다.' + ); + } +} + +/** + * 템플릿 변수 매핑 함수 + * RFQ 발송 정보를 결재 템플릿 변수로 변환 + */ +export async function mapRfqSendToTemplateVariables(data: { + attachments: Array<{ + fileName?: string | null; + fileSize?: number | null; + }>; + vendorNames: string[]; + applicationReason: string; +}) { + const { htmlTableConverter, htmlListConverter } = await import('@/lib/approval/template-utils'); + + // 파일 크기를 읽기 쉬운 형식으로 변환 + const formatFileSize = (bytes?: number | null): string => { + if (!bytes || bytes === 0) return '-'; + + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(2)} ${units[unitIndex]}`; + }; + + // 첨부파일 테이블 데이터 준비 (순번, 파일명, 파일크기만) + const attachmentTableData = data.attachments.map((att, index) => ({ + '순번': String(index + 1), + '파일명': att.fileName || '-', + '파일 크기': formatFileSize(att.fileSize), + })); + + // 첨부파일 테이블 HTML 생성 + const attachmentTableHtml = await htmlTableConverter( + attachmentTableData.length > 0 ? attachmentTableData : [], + [ + { key: '순번', label: '순번' }, + { key: '파일명', label: '파일명' }, + { key: '파일 크기', label: '파일 크기' }, + ] + ); + + // 제출처 (벤더 이름들) HTML 생성 + const vendorListHtml = await htmlListConverter( + data.vendorNames.length > 0 + ? data.vendorNames + : ['제출처가 없습니다.'] + ); + + return { + '파일 테이블': attachmentTableHtml, + '제출처': vendorListHtml, + '신청사유': data.applicationReason || '사유 없음', + }; +} + diff --git a/lib/rfq-last/vendor/application-reason-dialog.tsx b/lib/rfq-last/vendor/application-reason-dialog.tsx new file mode 100644 index 00000000..61f818d9 --- /dev/null +++ b/lib/rfq-last/vendor/application-reason-dialog.tsx @@ -0,0 +1,114 @@ +"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 { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; + +interface ApplicationReasonDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (reason: string) => void; + vendorCount: number; + attachmentCount: number; +} + +/** + * 암호화해제 신청 사유 입력 다이얼로그 + * + * RFQ 발송 시 첨부파일이 있는 경우, 결재 미리보기 전에 + * 사용자가 신청 사유를 입력하도록 하는 다이얼로그 + */ +export function ApplicationReasonDialog({ + open, + onOpenChange, + onConfirm, + vendorCount, + attachmentCount, +}: ApplicationReasonDialogProps) { + const [reason, setReason] = React.useState(""); + + // 다이얼로그가 닫힐 때 초기화 + React.useEffect(() => { + if (!open) { + setReason(""); + } + }, [open]); + + const handleConfirm = () => { + if (!reason.trim()) { + return; + } + onConfirm(reason); + onOpenChange(false); + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[600px]"> + <DialogHeader> + <DialogTitle>암호화해제 신청 사유</DialogTitle> + <DialogDescription> + 첨부파일이 사외업체에 송부되므로 신청 사유를 입력해주세요. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 py-4"> + {/* 발송 정보 요약 */} + <div className="rounded-lg border bg-muted/50 p-4 space-y-2"> + <div className="flex items-center justify-between text-sm"> + <span className="text-muted-foreground">발송 대상</span> + <span className="font-medium">{vendorCount}개 업체</span> + </div> + <div className="flex items-center justify-between text-sm"> + <span className="text-muted-foreground">첨부파일</span> + <span className="font-medium">{attachmentCount}개</span> + </div> + </div> + + {/* 신청 사유 입력 */} + <div className="space-y-2"> + <Label htmlFor="reason"> + 신청 사유 <span className="text-red-500">*</span> + </Label> + <Textarea + id="reason" + placeholder="예) RFQ 발송을 위한 기술 도면 전달 예) 견적 요청을 위한 사양서 공유" + value={reason} + onChange={(e) => setReason(e.target.value)} + rows={5} + className="resize-none" + /> + <p className="text-xs text-muted-foreground"> + 신청 사유는 결재 문서에 포함됩니다. + </p> + </div> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + > + 취소 + </Button> + <Button + onClick={handleConfirm} + disabled={!reason.trim()} + > + 다음 단계로 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + diff --git a/lib/rfq-last/vendor/avl-vendor-dialog.tsx b/lib/rfq-last/vendor/avl-vendor-dialog.tsx index 2efd96b9..67a71cc5 100644 --- a/lib/rfq-last/vendor/avl-vendor-dialog.tsx +++ b/lib/rfq-last/vendor/avl-vendor-dialog.tsx @@ -10,37 +10,28 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Checkbox } from "@/components/ui/checkbox"; -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { - Loader2, - X, - FileText, - Shield, - Globe, - Settings, - Link, +import { + Loader2, + Eye, + Globe, CheckCircle, - Info, - AlertCircle, - Building2 + Building2, + Info } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; -import { getAvlVendorsForRfq, addAvlVendorsToRfq } from "../service"; +import { getAvlVendorsForRfq } from "../service"; interface AvlVendor { id: number; vendorId: number | null; - vendorName: string; + vendorName: string | null; vendorCode: string | null; - avlVendorName: string; + avlVendorName: string | null; tier: string | null; headquarterLocation: string | null; manufacturingLocation: string | null; @@ -54,79 +45,35 @@ interface AvlVendor { remark: string | null; } -interface VendorContract { - vendorId: number; - agreementYn: boolean; - ndaYn: boolean; - gtcType: "general" | "project" | "none"; -} +// 더 이상 계약 인터페이스 필요 없음 - 참조용으로만 사용 interface AvlVendorDialogProps { open: boolean; onOpenChange: (open: boolean) => void; rfqId: number; - rfqCode?: string; - onSuccess: () => void; } export function AvlVendorDialog({ open, onOpenChange, rfqId, - rfqCode, - onSuccess, }: AvlVendorDialogProps) { - const [isLoading, setIsLoading] = React.useState(false); const [isLoadingAvl, setIsLoadingAvl] = React.useState(false); const [avlVendors, setAvlVendors] = React.useState<AvlVendor[]>([]); - const [selectedVendorIds, setSelectedVendorIds] = React.useState<Set<number>>(new Set()); - const [activeTab, setActiveTab] = React.useState<"vendors" | "contracts">("vendors"); - const [vendorContracts, setVendorContracts] = React.useState<VendorContract[]>([]); const [existingVendorIds, setExistingVendorIds] = React.useState<Set<number>>(new Set()); - - // 일괄 적용용 기본값 - const [defaultContract, setDefaultContract] = React.useState({ - agreementYn: true, - ndaYn: true, - gtcType: "none" as "general" | "project" | "none" - }); - // AVL 벤더 로드 + // AVL 벤더 로드 (참조용) const loadAvlVendors = React.useCallback(async () => { setIsLoadingAvl(true); try { const result = await getAvlVendorsForRfq(rfqId); if (result.success && result.vendors) { - setAvlVendors(result.vendors); - - // 이미 RFQ에 추가된 벤더 ID 설정 + setAvlVendors(result.vendors as AvlVendor[]); + + // 이미 RFQ에 추가된 벤더 ID 설정 (참조용) const existingIds = new Set(result.existingVendorIds || []); setExistingVendorIds(existingIds); - - // AVL에서 가져온 모든 벤더를 기본 선택 (이미 추가된 것 제외) - const defaultSelected = new Set( - result.vendors - .filter(v => v.vendorId && !existingIds.has(v.vendorId)) - .map(v => v.vendorId!) - ); - setSelectedVendorIds(defaultSelected); - - // 초기 계약 설정 - const initialContracts = result.vendors - .filter(v => v.vendorId && defaultSelected.has(v.vendorId)) - .map(v => { - const isInternational = v.headquarterLocation && - v.headquarterLocation !== "KR" && - v.headquarterLocation !== "한국"; - return { - vendorId: v.vendorId!, - agreementYn: true, - ndaYn: true, - gtcType: isInternational ? "general" : "none" as const - }; - }); - setVendorContracts(initialContracts); - + if (result.vendors.length === 0) { toast.info("해당 프로젝트와 자재그룹에 대한 AVL 벤더가 없습니다."); } @@ -152,139 +99,22 @@ export function AvlVendorDialog({ React.useEffect(() => { if (!open) { setAvlVendors([]); - setSelectedVendorIds(new Set()); - setVendorContracts([]); - setActiveTab("vendors"); - setDefaultContract({ - agreementYn: true, - ndaYn: true, - gtcType: "none" - }); + setExistingVendorIds(new Set()); } }, [open]); - // 벤더 선택 토글 - const toggleVendorSelection = (vendorId: number) => { - const newSelection = new Set(selectedVendorIds); - if (newSelection.has(vendorId)) { - newSelection.delete(vendorId); - setVendorContracts(contracts => - contracts.filter(c => c.vendorId !== vendorId) - ); - } else { - newSelection.add(vendorId); - const vendor = avlVendors.find(v => v.vendorId === vendorId); - if (vendor) { - const isInternational = vendor.headquarterLocation && - vendor.headquarterLocation !== "KR" && - vendor.headquarterLocation !== "한국"; - setVendorContracts(contracts => [ - ...contracts, - { - vendorId, - agreementYn: defaultContract.agreementYn, - ndaYn: defaultContract.ndaYn, - gtcType: isInternational ? defaultContract.gtcType : "none" - } - ]); - } - } - setSelectedVendorIds(newSelection); - }; - - // 개별 벤더의 계약 설정 업데이트 - const updateVendorContract = (vendorId: number, field: string, value: any) => { - setVendorContracts(contracts => - contracts.map(c => - c.vendorId === vendorId ? { ...c, [field]: value } : c - ) - ); - }; - - // 모든 벤더에 일괄 적용 - const applyToAll = () => { - setVendorContracts(contracts => - contracts.map(c => { - const vendor = avlVendors.find(v => v.vendorId === c.vendorId); - const isInternational = vendor?.headquarterLocation && - vendor.headquarterLocation !== "KR" && - vendor.headquarterLocation !== "한국"; - return { - ...c, - agreementYn: defaultContract.agreementYn, - ndaYn: defaultContract.ndaYn, - gtcType: isInternational ? defaultContract.gtcType : "none" - }; - }) - ); - toast.success("모든 벤더에 기본계약 설정이 적용되었습니다."); - }; - - // 제출 처리 - const handleSubmit = async () => { - if (selectedVendorIds.size === 0) { - toast.error("최소 1개 이상의 벤더를 선택해주세요."); - return; - } - - setIsLoading(true); - try { - const selectedVendors = avlVendors.filter(v => - v.vendorId && selectedVendorIds.has(v.vendorId) - ); - - const result = await addAvlVendorsToRfq({ - rfqId, - vendors: selectedVendors.map(v => ({ - vendorId: v.vendorId!, - vendorName: v.vendorName, - vendorCode: v.vendorCode, - contractRequirements: vendorContracts.find(c => c.vendorId === v.vendorId) || { - agreementYn: true, - ndaYn: true, - gtcType: "none" as const - } - })) - }); - - if (result.success) { - toast.success( - <div> - <p>{result.addedCount}개의 AVL 벤더가 추가되었습니다.</p> - {result.skippedCount && result.skippedCount > 0 && ( - <p className="text-sm text-muted-foreground mt-1"> - {result.skippedCount}개는 이미 추가되어 있어 건너뛰었습니다. - </p> - )} - </div> - ); - onSuccess(); - onOpenChange(false); - } else { - toast.error(result.error || "벤더 추가에 실패했습니다."); - } - } catch (error) { - console.error("Submit error:", error); - toast.error("오류가 발생했습니다."); - } finally { - setIsLoading(false); - } - }; - - // 선택 가능한 벤더 필터링 - const selectableVendors = avlVendors.filter(v => v.vendorId); - const selectedVendors = selectableVendors.filter(v => selectedVendorIds.has(v.vendorId!)); + // 더 이상 핸들러 함수나 필터링이 필요 없음 - 참조용으로만 사용 return ( <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-5xl max-h-[90vh] p-0 flex flex-col"> + <DialogContent className="max-w-4xl max-h-[90vh] p-0 flex flex-col"> <DialogHeader className="p-6 pb-0"> <DialogTitle className="flex items-center gap-2"> - <Link className="h-5 w-5 text-primary" /> - AVL 벤더 연동 + <Eye className="h-5 w-5 text-primary" /> + AVL 벤더 목록 조회 </DialogTitle> <DialogDescription> - 프로젝트 AVL에 등록된 벤더를 RFQ에 추가합니다. 선택된 벤더에게 견적 요청을 발송할 수 있습니다. + 동일한 자재그룹코드를 다루는 AVL 등록 벤더들의 목록을 확인할 수 있습니다. </DialogDescription> </DialogHeader> @@ -293,340 +123,132 @@ export function AvlVendorDialog({ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> </div> ) : ( - <Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)} className="flex-1 flex flex-col min-h-0"> - <TabsList className="mx-6 grid w-fit grid-cols-2"> - <TabsTrigger value="vendors"> - 1. AVL 벤더 선택 - {selectedVendorIds.size > 0 && ( - <Badge variant="secondary" className="ml-2"> - {selectedVendorIds.size} - </Badge> - )} - </TabsTrigger> - <TabsTrigger value="contracts" disabled={selectedVendorIds.size === 0}> - 2. 기본계약 설정 - </TabsTrigger> - </TabsList> - - <TabsContent value="vendors" className="flex-1 flex flex-col px-6 py-4 overflow-hidden min-h-0"> - {avlVendors.length === 0 ? ( - <Alert> - <AlertCircle className="h-4 w-4" /> - <AlertDescription> - 해당 프로젝트와 자재그룹에 대한 AVL 벤더가 없습니다. - </AlertDescription> - </Alert> - ) : ( - <Card className="flex flex-col flex-1 min-h-0"> - <CardHeader> - <CardTitle className="text-lg flex items-center justify-between"> - <span>AVL 벤더 목록</span> - <Badge variant="outline"> - 총 {avlVendors.length}개 업체 - </Badge> - </CardTitle> - <CardDescription> - AVL에서 자동으로 가져온 벤더입니다. 필요한 벤더를 선택하세요. - </CardDescription> - </CardHeader> - <CardContent className="flex-1 min-h-0"> - <ScrollArea className="h-[400px] pr-4"> - <div className="space-y-2"> - {avlVendors.map((vendor) => { - const isDisabled = !vendor.vendorId || existingVendorIds.has(vendor.vendorId); - const isSelected = vendor.vendorId && selectedVendorIds.has(vendor.vendorId); - const isInternational = vendor.headquarterLocation && - vendor.headquarterLocation !== "KR" && - vendor.headquarterLocation !== "한국"; - - return ( - <div - key={vendor.id} - className={cn( - "flex items-center justify-between p-3 rounded-lg border", - isDisabled && "opacity-50 bg-muted/30", - isSelected && !isDisabled && "bg-primary/5 border-primary/30" - )} - > - <div className="flex items-center gap-3 flex-1"> - <Checkbox - checked={isSelected} - onCheckedChange={() => vendor.vendorId && toggleVendorSelection(vendor.vendorId)} - disabled={isDisabled} - /> - - <div className="flex items-center gap-2 flex-1"> - <Building2 className="h-4 w-4 text-muted-foreground" /> - <div className="flex flex-col"> - <div className="flex items-center gap-2"> - <span className="font-medium">{vendor.vendorName}</span> - {vendor.vendorCode && ( - <Badge variant="outline" className="text-xs"> - {vendor.vendorCode} - </Badge> - )} - {existingVendorIds.has(vendor.vendorId!) && ( - <Badge variant="secondary" className="text-xs"> - <CheckCircle className="h-3 w-3 mr-1" /> - 추가됨 - </Badge> - )} - </div> - <div className="flex items-center gap-2 mt-1"> - {vendor.tier && ( - <Badge variant="outline" className="text-xs"> - 등급: {vendor.tier} - </Badge> - )} - {isInternational ? ( - <Badge variant="secondary" className="text-xs"> - <Globe className="h-3 w-3 mr-1" /> - {vendor.headquarterLocation} - </Badge> - ) : ( - <Badge variant="default" className="text-xs"> - 국내 - </Badge> - )} - {vendor.materialGroupName && ( - <span className="text-xs text-muted-foreground"> - {vendor.materialGroupName} - </span> - )} - {vendor.isAgent && ( - <Badge variant="warning" className="text-xs"> - Agent - </Badge> - )} - </div> - </div> - </div> + <div className="flex-1 flex flex-col px-6 py-4 overflow-hidden min-h-0"> + {avlVendors.length === 0 ? ( + <Alert> + <Info className="h-4 w-4" /> + <AlertDescription> + 해당 프로젝트와 자재그룹에 대한 AVL 벤더가 없습니다. + </AlertDescription> + </Alert> + ) : ( + <Card className="flex flex-col flex-1 min-h-0"> + <CardHeader> + <CardTitle className="text-lg flex items-center justify-between"> + <span>AVL 등록 벤더 목록</span> + <Badge variant="outline"> + 총 {avlVendors.length}개 업체 + </Badge> + </CardTitle> + <CardDescription> + 동일한 자재그룹코드를 다루는 AVL 등록 벤더들의 정보입니다. + </CardDescription> + </CardHeader> + <CardContent className="flex-1 min-h-0"> + <ScrollArea className="h-[500px] pr-4"> + <div className="space-y-3"> + {avlVendors.map((vendor) => { + const isInCurrentRfq = vendor.vendorId && existingVendorIds.has(vendor.vendorId); + const isInternational = vendor.headquarterLocation && + vendor.headquarterLocation !== "KR" && + vendor.headquarterLocation !== "한국"; - <div className="flex items-center gap-1"> - {vendor.hasAvl && ( - <Badge variant="success" className="text-xs"> - AVL + return ( + <div + key={vendor.id} + className={cn( + "flex items-center justify-between p-4 rounded-lg border bg-card", + isInCurrentRfq && "bg-blue-50 border-blue-200" + )} + > + <div className="flex items-center gap-3 flex-1"> + <Building2 className="h-5 w-5 text-muted-foreground flex-shrink-0" /> + <div className="flex flex-col flex-1 min-w-0"> + <div className="flex items-center gap-2 flex-wrap"> + <span className="font-medium">{vendor.vendorName}</span> + {vendor.vendorCode && ( + <Badge variant="outline" className="text-xs"> + {vendor.vendorCode} + </Badge> + )} + {isInCurrentRfq && ( + <Badge variant="default" className="text-xs bg-blue-600"> + <CheckCircle className="h-3 w-3 mr-1" /> + RFQ 참여중 </Badge> )} - {vendor.isBcc && ( + </div> + <div className="flex items-center gap-2 mt-1 flex-wrap"> + {vendor.tier && ( <Badge variant="outline" className="text-xs"> - BCC + 등급: {vendor.tier} </Badge> )} - {vendor.isBlacklist && ( - <Badge variant="destructive" className="text-xs"> - Blacklist + {isInternational ? ( + <Badge variant="secondary" className="text-xs"> + <Globe className="h-3 w-3 mr-1" /> + {vendor.headquarterLocation} + </Badge> + ) : ( + <Badge variant="default" className="text-xs"> + 국내 </Badge> )} - </div> - </div> - </div> - ); - })} - </div> - </ScrollArea> - </CardContent> - </Card> - )} - </TabsContent> - - <TabsContent value="contracts" className="flex-1 flex flex-col px-6 py-4 overflow-hidden min-h-0"> - <div className="flex-1 overflow-y-auto space-y-4 min-h-0"> - <Card> - <CardHeader className="pb-3"> - <CardTitle className="text-base flex items-center gap-2"> - <Settings className="h-4 w-4" /> - 일괄 적용 설정 - </CardTitle> - <CardDescription> - 모든 벤더에 동일한 설정을 적용할 수 있습니다. - </CardDescription> - </CardHeader> - <CardContent className="space-y-4"> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <div className="flex items-center space-x-2"> - <Checkbox - id="default-agreement" - checked={defaultContract.agreementYn} - onCheckedChange={(checked) => - setDefaultContract({ ...defaultContract, agreementYn: !!checked }) - } - /> - <label htmlFor="default-agreement" className="text-sm font-medium"> - 기술자료 제공 동의 - </label> - </div> - <div className="flex items-center space-x-2"> - <Checkbox - id="default-nda" - checked={defaultContract.ndaYn} - onCheckedChange={(checked) => - setDefaultContract({ ...defaultContract, ndaYn: !!checked }) - } - /> - <label htmlFor="default-nda" className="text-sm font-medium"> - 비밀유지 계약 (NDA) - </label> - </div> - </div> - <div className="space-y-2"> - <Label className="text-sm">GTC (국외 업체용)</Label> - <RadioGroup - value={defaultContract.gtcType} - onValueChange={(value: any) => - setDefaultContract({ ...defaultContract, gtcType: value }) - } - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="none" id="default-gtc-none" /> - <label htmlFor="default-gtc-none" className="text-sm">없음</label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="general" id="default-gtc-general" /> - <label htmlFor="default-gtc-general" className="text-sm">General GTC</label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="project" id="default-gtc-project" /> - <label htmlFor="default-gtc-project" className="text-sm">Project GTC</label> - </div> - </RadioGroup> - </div> - </div> - <Button - variant="secondary" - size="sm" - onClick={applyToAll} - className="w-full" - > - 모든 벤더에 적용 - </Button> - </CardContent> - </Card> - - <Card className="flex flex-col min-h-0"> - <CardHeader className="pb-3"> - <CardTitle className="text-base">개별 벤더 기본계약 설정</CardTitle> - <CardDescription> - 각 벤더별로 다른 기본계약을 요구할 수 있습니다. - </CardDescription> - </CardHeader> - <CardContent className="flex-1 min-h-0"> - <ScrollArea className="h-[250px] pr-4"> - <div className="space-y-4"> - {selectedVendors.map((vendor) => { - const contract = vendorContracts.find(c => c.vendorId === vendor.vendorId); - const isInternational = vendor.headquarterLocation && - vendor.headquarterLocation !== "KR" && - vendor.headquarterLocation !== "한국"; - - return ( - <div key={vendor.id} className="border rounded-lg p-4 space-y-3"> - <div className="flex items-center justify-between"> - <div className="flex items-center gap-2"> - {vendor.vendorCode && ( - <Badge variant="outline">{vendor.vendorCode}</Badge> + {vendor.materialGroupName && ( + <span className="text-xs text-muted-foreground"> + {vendor.materialGroupName} + </span> + )} + {vendor.isAgent && ( + <Badge variant="secondary" className="text-xs"> + Agent + </Badge> )} - <span className="font-medium">{vendor.vendorName}</span> - <Badge - variant={isInternational ? "secondary" : "default"} - className="text-xs" - > - {vendor.headquarterLocation || "미지정"} - </Badge> - </div> - </div> - - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <div className="flex items-center space-x-2"> - <Checkbox - checked={contract?.agreementYn || false} - onCheckedChange={(checked) => - vendor.vendorId && updateVendorContract(vendor.vendorId, "agreementYn", !!checked) - } - /> - <label className="text-sm">기술자료 제공</label> - </div> - <div className="flex items-center space-x-2"> - <Checkbox - checked={contract?.ndaYn || false} - onCheckedChange={(checked) => - vendor.vendorId && updateVendorContract(vendor.vendorId, "ndaYn", !!checked) - } - /> - <label className="text-sm">NDA</label> - </div> </div> - - {isInternational && vendor.vendorId && ( - <div className="space-y-1"> - <Label className="text-xs">GTC</Label> - <RadioGroup - value={contract?.gtcType || "none"} - onValueChange={(value) => - updateVendorContract(vendor.vendorId!, "gtcType", value) - } - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="none" id={`${vendor.vendorId}-none`} /> - <label htmlFor={`${vendor.vendorId}-none`} className="text-xs">없음</label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="general" id={`${vendor.vendorId}-general`} /> - <label htmlFor={`${vendor.vendorId}-general`} className="text-xs">General</label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="project" id={`${vendor.vendorId}-project`} /> - <label htmlFor={`${vendor.vendorId}-project`} className="text-xs">Project</label> - </div> - </RadioGroup> - </div> - )} - - {!isInternational && ( - <div className="text-xs text-muted-foreground"> - 국내 업체 - GTC 불필요 + {vendor.remark && ( + <div className="text-xs text-muted-foreground mt-1"> + {vendor.remark} </div> )} </div> </div> - ); - })} - </div> - </ScrollArea> - </CardContent> - </Card> - </div> - </TabsContent> - </Tabs> + + <div className="flex items-center gap-1 flex-shrink-0"> + {vendor.hasAvl && ( + <Badge variant="default" className="text-xs bg-green-600"> + <CheckCircle className="h-3 w-3 mr-1" /> + AVL + </Badge> + )} + {vendor.isBcc && ( + <Badge variant="outline" className="text-xs"> + BCC + </Badge> + )} + {vendor.isBlacklist && ( + <Badge variant="destructive" className="text-xs"> + Blacklist + </Badge> + )} + </div> + </div> + ); + })} + </div> + </ScrollArea> + </CardContent> + </Card> + )} + </div> )} <DialogFooter className="p-6 pt-0 border-t"> <Button variant="outline" onClick={() => onOpenChange(false)} - disabled={isLoading} > - 취소 + 닫기 </Button> - {activeTab === "vendors" && selectedVendorIds.size > 0 && ( - <Button onClick={() => setActiveTab("contracts")}> - 다음: 기본계약 설정 - </Button> - )} - {activeTab === "contracts" && ( - <Button - onClick={handleSubmit} - disabled={isLoading || selectedVendorIds.size === 0} - > - {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - {selectedVendorIds.size > 0 - ? `${selectedVendorIds.size}개 AVL 벤더 추가` - : 'AVL 벤더 추가' - } - </Button> - )} </DialogFooter> </DialogContent> </Dialog> diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index 29aa5f09..fc98785d 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -58,6 +58,11 @@ import { AddVendorDialog } from "./add-vendor-dialog"; import { BatchUpdateConditionsDialog } from "./batch-update-conditions-dialog"; import { SendRfqDialog } from "./send-rfq-dialog"; import { CancelVendorResponseDialog } from "./cancel-vendor-response-dialog"; +import { ApprovalPreviewDialog } from "@/lib/approval/client"; +import { requestRfqSendWithApproval } from "../approval-actions"; +import { mapRfqSendToTemplateVariables } from "../approval-handlers"; +import { useSession } from "next-auth/react"; +import { ApplicationReasonDialog } from "./application-reason-dialog"; import { getRfqSendData, @@ -293,6 +298,22 @@ export function RfqVendorTable({ vendorName: string; } | null>(null); const [isCancelDialogOpen, setIsCancelDialogOpen] = React.useState(false); + + // 결재 관련 상태 + const [showApplicationReasonDialog, setShowApplicationReasonDialog] = React.useState(false); + const [showApprovalPreview, setShowApprovalPreview] = React.useState(false); + const [approvalPreviewData, setApprovalPreviewData] = React.useState<{ + vendors: any[]; + attachments: any[]; + attachmentIds: number[]; + message?: string; + generatedPdfs?: any[]; + hasToSendEmail?: boolean; + templateVariables?: Record<string, string>; + applicationReason?: string; + } | null>(null); + + const { data: session } = useSession(); // AVL 연동 핸들러 const handleAvlIntegration = React.useCallback(() => { @@ -419,6 +440,57 @@ export function RfqVendorTable({ return; } + const hasAttachments = rfqSendData.attachments && rfqSendData.attachments.length > 0; + + // 🔹 첨부파일이 있는 경우: 결재 프로세스 시작 + if (hasAttachments) { + // Knox EP ID 확인 + if (!session?.user?.epId) { + toast.error("Knox EP ID가 없습니다. 시스템 관리자에게 문의하세요."); + setIsLoadingSendData(false); + return; + } + + // 첨부파일 정보 변환 + const attachmentsForApproval = rfqSendData.attachments.map((att: any) => ({ + fileName: att.fileName, + fileSize: att.fileSize, + })); + + // 결재 데이터 임시 저장 (신청사유 입력 전) + setApprovalPreviewData({ + vendors: vendorEmailInfos.map(v => ({ + vendorId: v.vendorId, + vendorName: v.vendorName, + vendorCode: v.vendorCode, + vendorCountry: v.vendorCountry, + selectedMainEmail: v.primaryEmail || v.vendorEmail || '', + additionalEmails: [], + customEmails: [], + currency: v.currency, + contractRequirements: { + ndaYn: v.ndaYn || false, + generalGtcYn: v.generalGtcYn || false, + projectGtcYn: v.projectGtcYn || false, + agreementYn: v.agreementYn || false, + }, + isResend: v.sendVersion ? v.sendVersion > 0 : false, + sendVersion: v.sendVersion, + })), + attachments: attachmentsForApproval, + attachmentIds: rfqSendData.attachments.map((att: any) => att.id), + message: undefined, + generatedPdfs: undefined, + hasToSendEmail: true, + }); + + // 신청사유 입력 다이얼로그 먼저 열기 + setShowApplicationReasonDialog(true); + setIsLoadingSendData(false); + return; + } + + // 🔹 첨부파일이 없는 경우: 기존 로직 (바로 발송) // 다이얼로그 데이터 설정 setSendDialogData({ rfqInfo: rfqSendData.rfqInfo, @@ -450,7 +522,82 @@ export function RfqVendorTable({ } finally { setIsLoadingSendData(false); } - }, [selectedRows, rfqId]); + }, [selectedRows, rfqId, session]); + + // 신청사유 입력 완료 핸들러 + const handleApplicationReasonConfirm = React.useCallback(async (reason: string) => { + if (!approvalPreviewData) { + toast.error("결재 데이터가 없습니다."); + return; + } + + try { + // 템플릿 변수 생성 (신청사유 포함) + const templateVariables = await mapRfqSendToTemplateVariables({ + attachments: approvalPreviewData.attachments, + vendorNames: approvalPreviewData.vendors.map(v => v.vendorName), + applicationReason: reason, + }); + + // 결재 미리보기 데이터 업데이트 + setApprovalPreviewData({ + ...approvalPreviewData, + templateVariables, + applicationReason: reason, + }); + + // 신청사유 다이얼로그 닫고 결재 미리보기 열기 + setShowApplicationReasonDialog(false); + setShowApprovalPreview(true); + } catch (error) { + console.error("템플릿 변수 생성 실패:", error); + toast.error("결재 문서 생성에 실패했습니다."); + } + }, [approvalPreviewData]); + + // 결재 미리보기 확인 핸들러 + const handleApprovalConfirm = React.useCallback(async (approvalData: { + approvers: string[]; + title: string; + description?: string; + }) => { + if (!approvalPreviewData || !session?.user) { + toast.error("결재 데이터가 없습니다."); + return; + } + + try { + const result = await requestRfqSendWithApproval({ + rfqId, + rfqCode, + vendors: approvalPreviewData.vendors, + attachmentIds: approvalPreviewData.attachmentIds, + attachments: approvalPreviewData.attachments, + message: approvalPreviewData.message, + generatedPdfs: approvalPreviewData.generatedPdfs, + hasToSendEmail: approvalPreviewData.hasToSendEmail, + applicationReason: approvalPreviewData.applicationReason || '', + currentUser: { + id: Number(session.user.id), + epId: session.user.epId || null, + name: session.user.name || undefined, + email: session.user.email || undefined, + }, + approvers: approvalData.approvers, + }); + + if (result.success) { + toast.success(result.message); + setShowApprovalPreview(false); + setApprovalPreviewData(null); + setSelectedRows([]); + router.refresh(); + } + } catch (error) { + console.error("결재 상신 실패:", error); + toast.error(error instanceof Error ? error.message : "결재 상신에 실패했습니다."); + } + }, [approvalPreviewData, session, rfqId, rfqCode, router]); // RFQ 발송 핸들러 const handleSendRfq = React.useCallback(async (data: { @@ -589,6 +736,53 @@ export function RfqVendorTable({ return; } + const hasAttachments = rfqSendData.attachments && rfqSendData.attachments.length > 0; + + // 🔹 첨부파일이 있는 경우: 결재 프로세스 + if (hasAttachments) { + if (!session?.user?.epId) { + toast.error("Knox EP ID가 없습니다. 시스템 관리자에게 문의하세요."); + setIsLoadingSendData(false); + return; + } + + const attachmentsForApproval = rfqSendData.attachments.map((att: any) => ({ + fileName: att.fileName, + fileSize: att.fileSize, + })); + + setApprovalPreviewData({ + vendors: vendorEmailInfos.map(v => ({ + vendorId: v.vendorId, + vendorName: v.vendorName, + vendorCode: v.vendorCode, + vendorCountry: v.vendorCountry, + selectedMainEmail: v.primaryEmail || v.vendorEmail || '', + additionalEmails: [], + customEmails: [], + currency: v.currency, + contractRequirements: { + ndaYn: v.ndaYn || false, + generalGtcYn: v.generalGtcYn || false, + projectGtcYn: v.projectGtcYn || false, + agreementYn: v.agreementYn || false, + }, + isResend: v.sendVersion ? v.sendVersion > 0 : false, + sendVersion: v.sendVersion, + })), + attachments: attachmentsForApproval, + attachmentIds: rfqSendData.attachments.map((att: any) => att.id), + message: undefined, + generatedPdfs: undefined, + hasToSendEmail: true, + }); + + setShowApplicationReasonDialog(true); + setIsLoadingSendData(false); + return; + } + + // 🔹 첨부파일이 없는 경우: 기존 로직 setSendDialogData({ rfqInfo: rfqSendData.rfqInfo, attachments: rfqSendData.attachments || [], @@ -1843,16 +2037,11 @@ export function RfqVendorTable({ /> )} - {/* AVL 벤더 연동 다이얼로그 */} + {/* AVL 벤더 조회 다이얼로그 */} <AvlVendorDialog open={isAvlDialogOpen} onOpenChange={setIsAvlDialogOpen} rfqId={rfqId} - rfqCode={rfqCode} - onSuccess={() => { - setIsAvlDialogOpen(false); - router.refresh(); - }} /> {/* 연동제 정보 다이얼로그 */} @@ -1888,6 +2077,37 @@ export function RfqVendorTable({ toast.success("RFQ 취소가 완료되었습니다."); }} /> + + {/* 신청사유 입력 다이얼로그 */} + {approvalPreviewData && ( + <ApplicationReasonDialog + open={showApplicationReasonDialog} + onOpenChange={setShowApplicationReasonDialog} + onConfirm={handleApplicationReasonConfirm} + vendorCount={approvalPreviewData.vendors.length} + attachmentCount={approvalPreviewData.attachmentIds.length} + /> + )} + + {/* 결재 미리보기 다이얼로그 */} + {approvalPreviewData && session?.user?.epId && approvalPreviewData.templateVariables && ( + <ApprovalPreviewDialog + open={showApprovalPreview} + onOpenChange={setShowApprovalPreview} + templateName="암호화해제 신청" + variables={approvalPreviewData.templateVariables} + title={`암호화해제 신청 - ${rfqCode || 'RFQ'}`} + currentUser={{ + id: Number(session.user.id), + epId: session.user.epId, + name: session.user.name || undefined, + email: session.user.email || undefined, + }} + onConfirm={handleApprovalConfirm} + allowTitleEdit={false} + allowDescriptionEdit={false} + /> + )} </> ); }
\ No newline at end of file |
