From 4c07f977c951cd99dd50d3bdaad0437e3dd55e6d Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Thu, 6 Nov 2025 20:01:16 +0900 Subject: (김준회) ITB/RFQ/일반견적: 발송시 첨부파일 있는 경우 '암호화해제 결재' 프로세스 타도록 변경 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/approval/handlers-registry.ts | 5 + ...\264\354\240\234 \354\213\240\354\262\255.html" | 180 ++++++ lib/rfq-last/approval-actions.ts | 155 +++++ lib/rfq-last/approval-handlers.ts | 151 +++++ lib/rfq-last/vendor/application-reason-dialog.tsx | 114 ++++ lib/rfq-last/vendor/avl-vendor-dialog.tsx | 628 ++++----------------- lib/rfq-last/vendor/rfq-vendor-table.tsx | 234 +++++++- 7 files changed, 957 insertions(+), 510 deletions(-) create mode 100644 "lib/approval/templates/\354\225\224\355\230\270\355\231\224\355\225\264\354\240\234 \354\213\240\354\262\255.html" create mode 100644 lib/rfq-last/approval-actions.ts create mode 100644 lib/rfq-last/approval-handlers.ts create mode 100644 lib/rfq-last/vendor/application-reason-dialog.tsx (limited to 'lib') 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/\354\225\224\355\230\270\355\231\224\355\225\264\354\240\234 \354\213\240\354\262\255.html" "b/lib/approval/templates/\354\225\224\355\230\270\355\231\224\355\225\264\354\240\234 \354\213\240\354\262\255.html" new file mode 100644 index 00000000..e2bcacff --- /dev/null +++ "b/lib/approval/templates/\354\225\224\355\230\270\355\231\224\355\225\264\354\240\234 \354\213\240\354\262\255.html" @@ -0,0 +1,180 @@ +
+ + + + + + + +
+ 암호화해제 신청 +
+ + +
+
+ 결재 사유 +
+
+ 첨부자료가 사외업체에 송부되므로, 정보보호 그룹의 보안지침에 따른 결재 + 요청 +
+
+ + + + + + + + + + + + + +
+ 파일 목록 +
+ {{파일 테이블}} +
+ + + + + + + + + + + + + +
+ 제출처 +
+ {{제출처}} +
+ + + + + + + + + + + + + +
+ 신청 사유 +
+ {{신청사유}} +
+
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 ( + + + + 암호화해제 신청 사유 + + 첨부파일이 사외업체에 송부되므로 신청 사유를 입력해주세요. + + + +
+ {/* 발송 정보 요약 */} +
+
+ 발송 대상 + {vendorCount}개 업체 +
+
+ 첨부파일 + {attachmentCount}개 +
+
+ + {/* 신청 사유 입력 */} +
+ +