From be5d5ab488ae875e7c56306403aba923e1784021 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Tue, 18 Nov 2025 10:33:51 +0000 Subject: (최겸) 기술영업 첨부파일 결재 등록 및 요구사항 개발 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/techsales-rfq/approval-actions.ts | 244 +++++++ lib/techsales-rfq/approval-handlers.ts | 313 +++++++++ lib/techsales-rfq/service.ts | 145 ++++- .../table/detail-table/rfq-detail-table.tsx | 127 ++++ .../tech-sales-rfq-attachments-sheet-copy-1118.tsx | 710 +++++++++++++++++++++ .../table/vendor-quotations-table.tsx | 6 +- 6 files changed, 1522 insertions(+), 23 deletions(-) create mode 100644 lib/techsales-rfq/approval-actions.ts create mode 100644 lib/techsales-rfq/approval-handlers.ts create mode 100644 lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet-copy-1118.tsx (limited to 'lib/techsales-rfq') diff --git a/lib/techsales-rfq/approval-actions.ts b/lib/techsales-rfq/approval-actions.ts new file mode 100644 index 00000000..175bca1d --- /dev/null +++ b/lib/techsales-rfq/approval-actions.ts @@ -0,0 +1,244 @@ +/** + * 기술영업 RFQ 발송 결재 서버 액션 + * + * DRM 파일이 있는 기술영업 RFQ를 발송할 때 결재를 거치는 서버 액션 + */ + +'use server'; + +import { ApprovalSubmissionSaga } from '@/lib/approval'; +import { mapTechSalesRfqSendToTemplateVariables } from './approval-handlers'; + +interface TechSalesRfqSendApprovalData { + // RFQ 기본 정보 + rfqId: number; + rfqCode?: string; + + // 발송 데이터 + vendorIds: number[]; + selectedContacts?: Array<{ + vendorId: number; + contactId: number; + contactEmail: string; + contactName: string; + }>; + drmAttachmentIds: number[]; + + // 첨부파일 정보 (파일명, 크기 등) + drmAttachments: Array<{ + fileName?: string | null; + fileSize?: number | null; + }>; + + // 신청 사유 + applicationReason: string; + + // 결재 정보 + currentUser: { + id: number; + epId: string | null; + name?: string; + email?: string; + }; + approvers?: string[]; // Knox EP ID 배열 +} + +/** + * 기술영업 RFQ 발송 결재 상신 (초기 발송) + * + * DRM 파일이 있는 경우 결재를 거쳐 RFQ를 발송합니다. + */ +export async function requestTechSalesRfqSendWithApproval(data: TechSalesRfqSendApprovalData) { + // 1. 입력 검증 + if (!data.currentUser.epId) { + throw new Error('Knox EP ID가 필요합니다. 시스템 관리자에게 문의하세요.'); + } + + if (!data.vendorIds || data.vendorIds.length === 0) { + throw new Error('발송할 벤더를 선택해주세요.'); + } + + if (!data.drmAttachmentIds || data.drmAttachmentIds.length === 0) { + throw new Error('DRM 첨부파일이 없습니다. 결재가 필요하지 않습니다.'); + } + + console.log('[TechSales RFQ Approval] Starting approval process for RFQ send'); + console.log('[TechSales RFQ Approval] RFQ ID:', data.rfqId); + console.log('[TechSales RFQ Approval] Vendors:', data.vendorIds.length); + console.log('[TechSales RFQ Approval] DRM Attachments:', data.drmAttachmentIds.length); + + try { + // 2. RFQ 상태를 "결재 진행중"으로 변경 + const db = (await import('@/db/db')).default; + const { techSalesRfqs, TECH_SALES_RFQ_STATUSES } = await import('@/db/schema/techSales'); + const { eq } = await import('drizzle-orm'); + + await db.update(techSalesRfqs) + .set({ + status: TECH_SALES_RFQ_STATUSES.APPROVAL_IN_PROGRESS, + updatedAt: new Date(), + }) + .where(eq(techSalesRfqs.id, data.rfqId)); + + console.log('[TechSales RFQ Approval] RFQ status updated to APPROVAL_IN_PROGRESS'); + + // 3. 벤더 이름 조회 + const { getTechSalesRfqVendors } = await import('./service'); + const vendorsResult = await getTechSalesRfqVendors(data.rfqId); + const vendorNames = vendorsResult.data?.filter(v => data.vendorIds.includes(v.vendorId)) + .map(v => v.vendorName) || []; + + // 4. 템플릿 변수 매핑 + const variables = await mapTechSalesRfqSendToTemplateVariables({ + attachments: data.drmAttachments, + vendorNames: vendorNames, + applicationReason: data.applicationReason, + }); + + // 5. 결재 상신용 payload 구성 + const approvalPayload = { + rfqId: data.rfqId, + rfqCode: data.rfqCode, + vendorIds: data.vendorIds, + selectedContacts: data.selectedContacts, + drmAttachmentIds: data.drmAttachmentIds, + currentUser: { + id: data.currentUser.id, + name: data.currentUser.name, + email: data.currentUser.email, + epId: data.currentUser.epId, + }, + }; + + // 6. Saga로 결재 상신 + const saga = new ApprovalSubmissionSaga( + 'tech_sales_rfq_send_with_drm', // 핸들러 키 + approvalPayload, // 결재 승인 후 실행될 데이터 + { + title: `암호화해제 신청 - ${data.rfqCode || 'RFQ'}`, + description: `${vendorNames.length}개 업체에 DRM 첨부파일 ${data.drmAttachmentIds.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('[TechSales RFQ Approval] ✅ Approval submitted successfully'); + console.log('[TechSales RFQ Approval] Approval ID:', result.approvalId); + console.log('[TechSales RFQ Approval] Pending Action ID:', result.pendingActionId); + + return { + success: true, + ...result, + message: `결재가 상신되었습니다. (결재 ID: ${result.approvalId})`, + }; + + } catch (error) { + console.error('[TechSales RFQ Approval] ❌ Failed to submit approval:', error); + throw new Error( + error instanceof Error + ? error.message + : '기술영업 RFQ 발송 결재 상신에 실패했습니다.' + ); + } +} + +/** + * 기술영업 RFQ 재발송 결재 상신 + * + * 이미 발송된 RFQ에 DRM 파일이 추가된 경우 재발송을 위한 결재 상신 + */ +export async function requestRfqResendWithDrmApproval(data: { + rfqId: number; + rfqCode?: string; + drmFiles: Array<{ + file: File; + attachmentType: string; + description?: string; + }>; + applicationReason: string; + currentUser: { + id: number; + epId: string | null; + name?: string; + email?: string; + }; + approvers?: string[]; +}) { + if (!data.currentUser.epId) { + throw new Error('Knox EP ID가 필요합니다.'); + } + + console.log('[RFQ Resend Approval] Starting resend approval process'); + console.log('[RFQ Resend Approval] RFQ ID:', data.rfqId); + console.log('[RFQ Resend Approval] DRM Files:', data.drmFiles.length); + + try { + // 템플릿 변수 매핑 + const variables = await mapTechSalesRfqSendToTemplateVariables({ + attachments: data.drmFiles.map(f => ({ + fileName: f.file.name, + fileSize: f.file.size, + })), + vendorNames: [], // 기존 벤더 목록은 후처리에서 조회 + applicationReason: data.applicationReason, + }); + + // 결재 payload 구성 + const approvalPayload = { + rfqId: data.rfqId, + rfqCode: data.rfqCode, + drmFiles: data.drmFiles, + currentUser: { + id: data.currentUser.id, + name: data.currentUser.name, + email: data.currentUser.email, + epId: data.currentUser.epId, + }, + }; + + // Saga로 결재 상신 + const saga = new ApprovalSubmissionSaga( + 'tech_sales_rfq_resend_with_drm', // 핸들러 키 + approvalPayload, + { + title: `DRM 파일 재발송 결재 - ${data.rfqCode || 'RFQ'}`, + description: `이미 발송된 RFQ에 ${data.drmFiles.length}개의 DRM 파일이 추가되어 재발송을 요청합니다.`, + templateName: '암호화해제 신청', + 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 Resend Approval] ✅ Resend approval submitted successfully'); + + return { + success: true, + ...result, + message: `재발송 결재가 상신되었습니다. (결재 ID: ${result.approvalId})`, + }; + + } catch (error) { + console.error('[RFQ Resend Approval] ❌ Failed to submit resend approval:', error); + throw new Error( + error instanceof Error + ? error.message + : 'RFQ 재발송 결재 상신에 실패했습니다.' + ); + } +} + diff --git a/lib/techsales-rfq/approval-handlers.ts b/lib/techsales-rfq/approval-handlers.ts new file mode 100644 index 00000000..6ffd9fb4 --- /dev/null +++ b/lib/techsales-rfq/approval-handlers.ts @@ -0,0 +1,313 @@ +/** + * 기술영업 RFQ 발송 결재 핸들러 + * + * DRM 파일이 있는 기술영업 RFQ 발송 시 결재 승인 후 실제 발송을 처리하는 핸들러 + */ + +'use server'; + +import db from '@/db/db'; +import { eq, and } from 'drizzle-orm'; +import { techSalesAttachments, techSalesRfqs, TECH_SALES_RFQ_STATUSES } from '@/db/schema/techSales'; +import { sendTechSalesRfqToVendors } from './service'; +import { decryptWithServerAction } from '@/components/drm/drmUtils'; +import { saveFile, deleteFile } from '@/lib/file-stroage'; + +/** + * 기술영업 RFQ 발송 핸들러 (결재 승인 후 자동 실행) + * + * @param payload - 결재 상신 시 저장한 RFQ 발송 데이터 + */ +export async function sendTechSalesRfqWithApprovalInternal(payload: { + rfqId: number; + rfqCode?: string; + vendorIds: number[]; + selectedContacts?: Array<{ + vendorId: number; + contactId: number; + contactEmail: string; + contactName: string; + }>; + drmAttachmentIds: number[]; + currentUser: { + id: string | number; + name?: string | null; + email?: string | null; + epId?: string | null; + }; +}) { + console.log('[TechSales RFQ Approval Handler] Starting RFQ send after approval'); + console.log('[TechSales RFQ Approval Handler] RFQ ID:', payload.rfqId); + console.log('[TechSales RFQ Approval Handler] Vendors count:', payload.vendorIds.length); + console.log('[TechSales RFQ Approval Handler] DRM Attachments count:', payload.drmAttachmentIds.length); + + try { + // 1. DRM 파일들 복호화 및 재저장 + const drmAttachments = await db.query.techSalesAttachments.findMany({ + where: and( + eq(techSalesAttachments.techSalesRfqId, payload.rfqId), + eq(techSalesAttachments.drmEncrypted, true) + ) + }); + + console.log(`[TechSales RFQ Approval Handler] Found ${drmAttachments.length} DRM files to decrypt`); + + for (const attachment of drmAttachments) { + try { + // DRM 파일 다운로드 + const fileResponse = await fetch(attachment.filePath); + if (!fileResponse.ok) { + console.error(`[TechSales RFQ Approval Handler] Failed to fetch file: ${attachment.filePath}`); + continue; + } + + const fileBlob = await fileResponse.blob(); + const file = new File([fileBlob], attachment.originalFileName); + + // DRM 복호화 + console.log(`[TechSales RFQ Approval Handler] Decrypting: ${attachment.originalFileName}`); + const decryptedBuffer = await decryptWithServerAction(file); + + // 복호화된 파일로 재저장 + const saveResult = await saveFile({ + file: new File([decryptedBuffer], attachment.originalFileName), + directory: `techsales-rfq/${payload.rfqId}`, + userId: String(payload.currentUser.id), + }); + + if (!saveResult.success) { + throw new Error(saveResult.error || '파일 저장 실패'); + } + + // 기존 파일 삭제 + await deleteFile(attachment.filePath); + + // DB 업데이트: drmEncrypted = false, filePath 업데이트 + await db.update(techSalesAttachments) + .set({ + drmEncrypted: false, + filePath: saveResult.publicPath!, + fileName: saveResult.fileName!, + }) + .where(eq(techSalesAttachments.id, attachment.id)); + + console.log(`[TechSales RFQ Approval Handler] ✅ Decrypted and saved: ${attachment.originalFileName}`); + } catch (error) { + console.error(`[TechSales RFQ Approval Handler] ❌ Failed to decrypt ${attachment.originalFileName}:`, error); + throw error; + } + } + + // 2. RFQ 상태를 "RFQ Sent"로 변경 + await db.update(techSalesRfqs) + .set({ + status: TECH_SALES_RFQ_STATUSES.RFQ_SENT, + updatedAt: new Date(), + }) + .where(eq(techSalesRfqs.id, payload.rfqId)); + + // 3. 실제 RFQ 발송 실행 + const sendResult = await sendTechSalesRfqToVendors({ + rfqId: payload.rfqId, + vendorIds: payload.vendorIds, + selectedContacts: payload.selectedContacts, + }); + + console.log('[TechSales RFQ Approval Handler] ✅ RFQ sent successfully after DRM decryption'); + + return { + success: true, + ...sendResult, + }; + } catch (error) { + console.error('[TechSales RFQ Approval Handler] ❌ Failed to send RFQ:', error); + throw new Error( + error instanceof Error + ? `RFQ 발송 실패: ${error.message}` + : 'RFQ 발송 중 오류가 발생했습니다.' + ); + } +} + +/** + * 기술영업 RFQ 재발송 핸들러 (결재 승인 후 자동 실행) + * + * 이미 발송된 RFQ에 DRM 파일이 추가된 경우 재발송 처리 + */ +export async function resendTechSalesRfqWithDrmInternal(payload: { + rfqId: number; + rfqCode?: string; + drmFiles: Array<{ + file: File; + attachmentType: string; + description?: string; + }>; + currentUser: { + id: string | number; + name?: string | null; + email?: string | null; + epId?: string | null; + }; +}) { + console.log('[TechSales RFQ Resend Handler] Starting DRM resend after approval'); + console.log('[TechSales RFQ Resend Handler] RFQ ID:', payload.rfqId); + console.log('[TechSales RFQ Resend Handler] DRM Files:', payload.drmFiles.length); + + try { + // 1. 새로 추가된 DRM 파일들 복호화 및 저장 + for (const drmFile of payload.drmFiles) { + try { + // DRM 복호화 + console.log(`[TechSales RFQ Resend Handler] Decrypting: ${drmFile.file.name}`); + const decryptedBuffer = await decryptWithServerAction(drmFile.file); + + // 복호화된 파일 저장 + const saveResult = await saveFile({ + file: new File([decryptedBuffer], drmFile.file.name), + directory: `techsales-rfq/${payload.rfqId}`, + userId: String(payload.currentUser.id), + }); + + if (!saveResult.success) { + throw new Error(saveResult.error || '파일 저장 실패'); + } + + // 기존 DRM 파일 레코드 찾기 및 업데이트 + const existingAttachment = await db.query.techSalesAttachments.findFirst({ + where: and( + eq(techSalesAttachments.techSalesRfqId, payload.rfqId), + eq(techSalesAttachments.originalFileName, drmFile.file.name), + eq(techSalesAttachments.drmEncrypted, true) + ) + }); + + if (existingAttachment) { + // 기존 파일 삭제 + await deleteFile(existingAttachment.filePath); + + // DB 업데이트: drmEncrypted = false, filePath 업데이트 + await db.update(techSalesAttachments) + .set({ + drmEncrypted: false, + filePath: saveResult.publicPath!, + fileName: saveResult.fileName!, + }) + .where(eq(techSalesAttachments.id, existingAttachment.id)); + } else { + // 새 레코드 생성 + await db.insert(techSalesAttachments).values({ + techSalesRfqId: payload.rfqId, + attachmentType: drmFile.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT", + fileName: saveResult.fileName!, + originalFileName: drmFile.file.name, + filePath: saveResult.publicPath!, + fileSize: decryptedBuffer.byteLength, + fileType: drmFile.file.type || undefined, + description: drmFile.description, + drmEncrypted: false, // DRM 해제됨 + createdBy: Number(payload.currentUser.id), + }); + } + + console.log(`[TechSales RFQ Resend Handler] ✅ Decrypted and saved: ${drmFile.file.name}`); + } catch (error) { + console.error(`[TechSales RFQ Resend Handler] ❌ Failed to decrypt ${drmFile.file.name}:`, error); + throw error; + } + } + + // 2. RFQ 상태를 "RFQ Sent"로 변경 + await db.update(techSalesRfqs) + .set({ + status: TECH_SALES_RFQ_STATUSES.RFQ_SENT, + updatedAt: new Date(), + }) + .where(eq(techSalesRfqs.id, payload.rfqId)); + + // 3. RFQ 재발송 실행 (기존에 할당된 모든 벤더에게) + const { getTechSalesRfqVendors } = await import('./service'); + const vendorsResult = await getTechSalesRfqVendors(payload.rfqId); + const vendorIds = vendorsResult.data?.map(v => v.vendorId) || []; + + const sendResult = await sendTechSalesRfqToVendors({ + rfqId: payload.rfqId, + vendorIds: vendorIds, + }); + + console.log('[TechSales RFQ Resend Handler] ✅ RFQ resent successfully after DRM decryption'); + + return { + success: true, + ...sendResult, + }; + } catch (error) { + console.error('[TechSales RFQ Resend Handler] ❌ Failed to resend RFQ:', error); + throw new Error( + error instanceof Error + ? `RFQ 재발송 실패: ${error.message}` + : 'RFQ 재발송 중 오류가 발생했습니다.' + ); + } +} + +/** + * 템플릿 변수 매핑 함수 + * 기술영업 RFQ 발송 정보를 결재 템플릿 변수로 변환 + */ +export async function mapTechSalesRfqSendToTemplateVariables(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/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index 9a198ee5..dc5950e0 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -36,8 +36,9 @@ import { sendEmail } from "../mail/sendEmail"; import { formatDate } from "../utils"; import { itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items"; import { techVendors, techVendorPossibleItems, techVendorContacts } from "@/db/schema/techVendors"; -import { deleteFile, saveDRMFile, saveFile } from "@/lib/file-stroage"; -import { decryptWithServerAction } from "@/components/drm/drmUtils"; +import { deleteFile, saveFile } from "@/lib/file-stroage"; +import { decryptWithServerAction, isDRMFile } from "@/components/drm/drmUtils"; +import { TECH_SALES_RFQ_STATUSES } from "@/db/schema/techSales"; // RFQ 아이템 정보 타입 interface RfqItemInfo { itemCode: string; @@ -558,15 +559,25 @@ export async function sendTechSalesRfqToVendors(input: { }; } - // 발송 가능한 상태인지 확인 - if (rfq.status !== "RFQ Vendor Assignned" && rfq.status !== "RFQ Sent") { + // 발송 가능한 상태인지 확인 (결재 진행중 상태는 제외) + if (rfq.status !== TECH_SALES_RFQ_STATUSES.RFQ_VENDOR_ASSIGNED && + rfq.status !== TECH_SALES_RFQ_STATUSES.RFQ_SENT && + rfq.status !== TECH_SALES_RFQ_STATUSES.APPROVAL_IN_PROGRESS) { return { success: false, message: "벤더가 할당된 RFQ 또는 이미 전송된 RFQ만 다시 전송할 수 있습니다", }; } + + // 결재 진행중 상태에서는 결재 승인 후처리 핸들러에서만 발송 가능 + if (rfq.status === TECH_SALES_RFQ_STATUSES.APPROVAL_IN_PROGRESS) { + return { + success: false, + message: "결재 진행 중인 RFQ는 결재 승인 후 자동으로 발송됩니다", + }; + } - const isResend = rfq.status === "RFQ Sent"; + const isResend = rfq.status === TECH_SALES_RFQ_STATUSES.RFQ_SENT; // 현재 사용자 정보 조회 const sender = await db.query.users.findFirst({ @@ -615,11 +626,39 @@ export async function sendTechSalesRfqToVendors(input: { }; } + // RFQ 첨부파일 중 DRM 파일 확인 + const attachments = await db.query.techSalesAttachments.findMany({ + where: eq(techSalesAttachments.techSalesRfqId, input.rfqId), + columns: { + id: true, + drmEncrypted: true, + fileName: true, + fileSize: true, + originalFileName: true, + } + }); + + const drmAttachments = attachments.filter(att => att.drmEncrypted === true); + + // DRM 파일이 있으면 결재 프로세스 필요 (이 함수는 직접 발송하지 않고 결재 필요 신호 반환) + if (drmAttachments.length > 0) { + return { + success: false, + requiresApproval: true, + message: "DRM 파일이 포함되어 있어 결재가 필요합니다", + drmAttachmentIds: drmAttachments.map(att => att.id), + drmAttachments: drmAttachments.map(att => ({ + fileName: att.originalFileName || att.fileName, + fileSize: att.fileSize, + })), + }; + } + // 트랜잭션 시작 await db.transaction(async (tx) => { // 1. RFQ 상태 업데이트 (최초 발송인 경우 rfqSendDate 설정) const updateData: Partial = { - status: "RFQ Sent", + status: TECH_SALES_RFQ_STATUSES.RFQ_SENT, sentBy: Number(session.user.id), updatedBy: Number(session.user.id), updatedAt: new Date(), @@ -1544,13 +1583,15 @@ export async function createTechSalesRfqAttachments(params: { await db.transaction(async (tx) => { for (const file of files) { - + // DRM 파일 검출 + const isDrmFile = await isDRMFile(file); - const saveResult = await saveDRMFile( + // saveFile로 변경 (DRM 복호화하지 않고 원본 저장) + const saveResult = await saveFile({ file, - decryptWithServerAction, - `techsales-rfq/${techSalesRfqId}` - ); + directory: `techsales-rfq/${techSalesRfqId}`, + userId: String(createdBy), + }); if (!saveResult.success) { throw new Error(saveResult.error || "파일 저장에 실패했습니다."); @@ -1566,6 +1607,7 @@ export async function createTechSalesRfqAttachments(params: { fileSize: file.size, fileType: file.type || undefined, description: description || undefined, + drmEncrypted: isDrmFile, // DRM 파일 여부 저장 createdBy, }).returning(); @@ -1652,6 +1694,39 @@ export async function getTechSalesRfqAttachmentsByType( } } +/** + * 벤더용 RFQ 첨부파일 조회 (DRM 해제된 파일만 반환) + */ +export async function getRfqAttachmentsForVendor(techSalesRfqId: number) { + unstable_noStore(); + try { + // DRM 해제된 파일만 조회 (drmEncrypted = false) + const attachments = await db.query.techSalesAttachments.findMany({ + where: and( + eq(techSalesAttachments.techSalesRfqId, techSalesRfqId), + eq(techSalesAttachments.drmEncrypted, false) + ), + orderBy: [desc(techSalesAttachments.createdAt)], + columns: { + id: true, + fileName: true, + originalFileName: true, + filePath: true, + fileSize: true, + fileType: true, + attachmentType: true, + description: true, + createdAt: true, + } + }); + + return { data: attachments, error: null }; + } catch (err) { + console.error("벤더용 기술영업 RFQ 첨부파일 조회 오류:", err); + return { data: [], error: getErrorMessage(err) }; + } +} + /** * 기술영업 RFQ 첨부파일 삭제 */ @@ -1834,17 +1909,23 @@ export async function processTechSalesRfqAttachments(params: { } // 2. 새 파일 업로드 처리 - if (newFiles.length > 0) { - for (const { file, attachmentType, description } of newFiles) { - const saveResult = await saveDRMFile( - file, - decryptWithServerAction, - `techsales-rfq/${techSalesRfqId}` - ); + if (newFiles.length > 0) { + const drmFiles: Array<{ file: File; attachmentType: string; description?: string }> = []; + + for (const { file, attachmentType, description } of newFiles) { + // DRM 파일 검출 + const isDrmFile = await isDRMFile(file); + + // saveFile로 변경 (DRM 복호화하지 않고 원본 저장) + const saveResult = await saveFile({ + file, + directory: `techsales-rfq/${techSalesRfqId}`, + userId: String(createdBy), + }); - if (!saveResult.success) { - throw new Error(saveResult.error || "파일 저장에 실패했습니다."); - } + if (!saveResult.success) { + throw new Error(saveResult.error || "파일 저장에 실패했습니다."); + } // DB에 첨부파일 레코드 생성 const [newAttachment] = await tx.insert(techSalesAttachments).values({ @@ -1856,10 +1937,22 @@ export async function processTechSalesRfqAttachments(params: { fileSize: file.size, fileType: file.type || undefined, description: description || undefined, + drmEncrypted: isDrmFile, // DRM 파일 여부 저장 createdBy, }).returning(); results.uploaded.push(newAttachment); + + // DRM 파일인 경우 목록에 추가 + if (isDrmFile) { + drmFiles.push({ file, attachmentType, description }); + } + } + + // RFQ 상태가 "RFQ Sent"이고 DRM 파일이 추가된 경우 재발송 결재 트리거 + if (rfq.status === TECH_SALES_RFQ_STATUSES.RFQ_SENT && drmFiles.length > 0) { + // 트랜잭션 외부에서 처리하기 위해 에러로 전달 + throw new Error("DRM_FILE_ADDED_TO_SENT_RFQ"); } } }); @@ -1878,6 +1971,16 @@ export async function processTechSalesRfqAttachments(params: { }; } catch (err) { console.error("기술영업 RFQ 첨부파일 일괄 처리 오류:", err); + + // DRM 파일 추가로 인한 재발송 결재 필요 에러 + if (err instanceof Error && err.message === "DRM_FILE_ADDED_TO_SENT_RFQ") { + return { + data: null, + error: "DRM_FILE_ADDED_TO_SENT_RFQ", + rfqId: techSalesRfqId, + }; + } + return { data: null, error: getErrorMessage(err) }; } } diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx index 8ce55d56..52758412 100644 --- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx @@ -22,6 +22,11 @@ import { TechSalesQuotationAttachmentsSheet, type QuotationAttachment } from ".. import type { QuotationInfo } from "./rfq-detail-column" import { VendorContactSelectionDialog } from "./vendor-contact-selection-dialog" import { QuotationContactsViewDialog } from "./quotation-contacts-view-dialog" +import { ApprovalPreviewDialog } from "@/lib/approval/client" +import { ApplicationReasonDialog } from "@/lib/rfq-last/vendor/application-reason-dialog" +import { requestTechSalesRfqSendWithApproval } from "@/lib/techsales-rfq/approval-actions" +import { mapTechSalesRfqSendToTemplateVariables } from "@/lib/techsales-rfq/approval-handlers" +import { useSession } from "next-auth/react" // 기본적인 RFQ 타입 정의 interface TechSalesRfq { @@ -48,6 +53,8 @@ interface RfqDetailTablesProps { export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps) { // console.log("selectedRfq", selectedRfq) + const session = useSession() + // 상태 관리 const [isLoading, setIsLoading] = useState(false) const [details, setDetails] = useState([]) @@ -89,6 +96,29 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps const [contactsDialogOpen, setContactsDialogOpen] = useState(false) const [selectedQuotationForContacts, setSelectedQuotationForContacts] = useState<{ id: number; vendorName?: string } | null>(null) + // 결재 관련 상태 관리 + const [showApplicationReasonDialog, setShowApplicationReasonDialog] = useState(false) + const [showApprovalPreview, setShowApprovalPreview] = useState(false) + const [approvalPreviewData, setApprovalPreviewData] = useState<{ + vendors: Array<{ + vendorId: number + vendorName: string + }> + drmAttachments: Array<{ + fileName?: string | null + fileSize?: number | null + }> + drmAttachmentIds: number[] + selectedContacts?: Array<{ + vendorId: number + contactId: number + contactEmail: string + contactName: string + }> + templateVariables?: Record + applicationReason?: string + } | null>(null) + // selectedRfq ID 메모이제이션 (객체 참조 변경 방지) const selectedRfqId = useMemo(() => selectedRfq?.id, [selectedRfq?.id]) @@ -239,6 +269,25 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps selectedContacts: selectedContacts }); + // DRM 파일이 있어서 결재가 필요한 경우 + if (!result.success && result.requiresApproval) { + // 결재 데이터 저장 + setApprovalPreviewData({ + vendors: selectedRows.map(row => ({ + vendorId: row.vendorId!, + vendorName: row.vendorName || "", + })), + drmAttachments: result.drmAttachments || [], + drmAttachmentIds: result.drmAttachmentIds || [], + selectedContacts: selectedContacts, + }); + + // 신청사유 입력 다이얼로그 표시 + setShowApplicationReasonDialog(true); + setIsSendingRfq(false); + return; + } + if (result.success) { toast.success(result.message || `${selectedContacts.length}명의 연락처에게 RFQ가 발송되었습니다.`); } else { @@ -412,6 +461,84 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps setContactsDialogOpen(true) }, []) + // 신청사유 입력 완료 핸들러 + const handleApplicationReasonConfirm = useCallback(async (reason: string) => { + if (!approvalPreviewData) { + toast.error("결재 데이터가 없습니다."); + return; + } + + try { + // 템플릿 변수 생성 (신청사유 포함) + const templateVariables = await mapTechSalesRfqSendToTemplateVariables({ + attachments: approvalPreviewData.drmAttachments, + 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 = useCallback(async (approvalData: { + approvers: string[]; + title: string; + description?: string; + }) => { + if (!approvalPreviewData || !selectedRfq || !session.data?.user) { + toast.error("결재 데이터가 없습니다."); + return; + } + + if (!session.data.user.epId) { + toast.error("Knox EP ID가 필요합니다."); + return; + } + + try { + const result = await requestTechSalesRfqSendWithApproval({ + rfqId: selectedRfq.id, + rfqCode: selectedRfq.rfqCode || undefined, + vendorIds: approvalPreviewData.vendors.map(v => v.vendorId), + selectedContacts: approvalPreviewData.selectedContacts, + drmAttachmentIds: approvalPreviewData.drmAttachmentIds, + drmAttachments: approvalPreviewData.drmAttachments, + applicationReason: approvalPreviewData.applicationReason || '', + currentUser: { + id: Number(session.data.user.id), + epId: session.data.user.epId || null, + name: session.data.user.name || undefined, + email: session.data.user.email || undefined, + }, + approvers: approvalData.approvers, + }); + + if (result.success) { + toast.success(result.message); + setShowApprovalPreview(false); + setApprovalPreviewData(null); + setSelectedRows([]); + await handleRefreshData(); + } + } catch (error) { + console.error("결재 상신 실패:", error); + toast.error(error instanceof Error ? error.message : "결재 상신에 실패했습니다."); + } + }, [approvalPreviewData, selectedRfq, session, handleRefreshData]); + // 칼럼 정의 - unreadMessages 상태 전달 (메모이제이션) const columns = useMemo(() => getRfqDetailColumns({ diff --git a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet-copy-1118.tsx b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet-copy-1118.tsx new file mode 100644 index 00000000..82f83b7c --- /dev/null +++ b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet-copy-1118.tsx @@ -0,0 +1,710 @@ +"use client" + +import * as React from "react" +import { z } from "zod" +import { useForm, useFieldArray } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, + SheetFooter, + SheetClose, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription +} from "@/components/ui/form" +import { Loader, Download, X, Eye, AlertCircle } from "lucide-react" +import { toast } from "sonner" +import { Badge } from "@/components/ui/badge" + +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone" +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, +} from "@/components/ui/file-list" + +import prettyBytes from "pretty-bytes" +import { formatDate } from "@/lib/utils" +import { processTechSalesRfqAttachments } from "@/lib/techsales-rfq/service" +import { useSession } from "next-auth/react" +import { ApprovalPreviewDialog } from "@/lib/approval/client" +import { ApplicationReasonDialog } from "@/lib/rfq-last/vendor/application-reason-dialog" +import { requestRfqResendWithDrmApproval } from "@/lib/techsales-rfq/approval-actions" +import { mapTechSalesRfqSendToTemplateVariables } from "@/lib/techsales-rfq/approval-handlers" + +const MAX_FILE_SIZE = 6e8 // 600MB + +/** 기존 첨부 파일 정보 (techSalesAttachments 테이블 구조) */ +export interface ExistingTechSalesAttachment { + id: number + techSalesRfqId: number + fileName: string + originalFileName: string + filePath: string + fileSize?: number + fileType?: string + attachmentType: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT" + description?: string + createdBy: number + createdAt: Date +} + +/** 새로 업로드할 파일 */ +const newUploadSchema = z.object({ + fileObj: z.any().optional(), // 실제 File + attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]).default("RFQ_COMMON"), + description: z.string().optional(), +}) + +/** 기존 첨부 (react-hook-form에서 관리) */ +const existingAttachSchema = z.object({ + id: z.number(), + techSalesRfqId: z.number(), + fileName: z.string(), + originalFileName: z.string(), + filePath: z.string(), + fileSize: z.number().optional(), + fileType: z.string().optional(), + attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]), + description: z.string().optional(), + createdBy: z.number(), + createdAt: z.custom(), +}) + +/** RHF 폼 전체 스키마 */ +const attachmentsFormSchema = z.object({ + techSalesRfqId: z.number().int(), + existing: z.array(existingAttachSchema), + newUploads: z.array(newUploadSchema), +}) + +type AttachmentsFormValues = z.infer + +// TechSalesRfq 타입 (간단 버전) +interface TechSalesRfq { + id: number + rfqCode: string | null + status: string + // 필요한 다른 필드들... +} + +interface TechSalesRfqAttachmentsSheetProps + extends React.ComponentPropsWithRef { + defaultAttachments?: ExistingTechSalesAttachment[] + rfq: TechSalesRfq | null + /** 첨부파일 타입 */ + attachmentType?: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT" + /** 읽기 전용 모드 (벤더용) */ + readOnly?: boolean + /** 업로드/삭제 후 상위 테이블에 attachmentCount 등을 업데이트하기 위한 콜백 */ + // onAttachmentsUpdated?: (rfqId: number, newAttachmentCount: number) => void + +} + +export function TechSalesRfqAttachmentsSheet({ + defaultAttachments = [], + // onAttachmentsUpdated, + rfq, + attachmentType = "RFQ_COMMON", + readOnly = false, + ...props +}: TechSalesRfqAttachmentsSheetProps) { + const [isPending, setIsPending] = React.useState(false) + const session = useSession() + + // 재발송 결재 관련 상태 + const [showResendApprovalDialog, setShowResendApprovalDialog] = React.useState(false) + const [showApplicationReasonDialog, setShowApplicationReasonDialog] = React.useState(false) + const [resendApprovalData, setResendApprovalData] = React.useState<{ + rfqId: number + drmFiles: Array<{ + file: File + attachmentType: string + description?: string + }> + } | null>(null) + const [approvalPreviewData, setApprovalPreviewData] = React.useState<{ + templateVariables: Record + applicationReason: string + } | null>(null) + + // 파일 다운로드 핸들러 + const handleDownloadClick = React.useCallback(async (filePath: string, fileName: string) => { + try { + const { downloadFile } = await import('@/lib/file-download') + await downloadFile(filePath, fileName, { + showToast: true, + onError: (error) => { + console.error('다운로드 오류:', error) + toast.error(error) + }, + onSuccess: (fileName, fileSize) => { + console.log(`다운로드 성공: ${fileName} (${fileSize} bytes)`) + } + }) + } catch (error) { + console.error('다운로드 오류:', error) + toast.error('파일 다운로드 중 오류가 발생했습니다.') + } + }, []) + // 첨부파일 타입별 제목과 설명 설정 + const attachmentConfig = React.useMemo(() => { + switch (attachmentType) { + case "TBE_RESULT": + return { + title: "TBE 결과 첨부파일", + description: "기술 평가(TBE) 결과 파일을 관리합니다.", + fileTypeLabel: "TBE 결과", + canEdit: !readOnly + } + case "CBE_RESULT": + return { + title: "CBE 결과 첨부파일", + description: "상업성 평가(CBE) 결과 파일을 관리합니다.", + fileTypeLabel: "CBE 결과", + canEdit: !readOnly + } + default: // RFQ_COMMON + return { + title: "RFQ 첨부파일", + description: readOnly ? "RFQ 공통 첨부파일을 조회합니다." : "RFQ 공통 첨부파일을 관리합니다.", + fileTypeLabel: "공통", + canEdit: !readOnly + } + } + }, [attachmentType, readOnly]) + + // // RFQ 상태에 따른 편집 가능 여부 결정 (readOnly prop이 true면 항상 false) + // const isEditable = React.useMemo(() => { + // if (!rfq) return false + // return attachmentConfig.canEdit + // }, [rfq, attachmentConfig.canEdit]) + + const form = useForm({ + resolver: zodResolver(attachmentsFormSchema), + defaultValues: { + techSalesRfqId: rfq?.id || 0, + existing: defaultAttachments.map(att => ({ + id: att.id, + techSalesRfqId: att.techSalesRfqId, + fileName: att.fileName, + originalFileName: att.originalFileName, + filePath: att.filePath, + fileSize: att.fileSize || undefined, + fileType: att.fileType || undefined, + attachmentType: att.attachmentType, + description: att.description || undefined, + createdBy: att.createdBy, + createdAt: att.createdAt, + })), + newUploads: [], + }, + }) + + // useFieldArray for existing and new uploads + const { + fields: existingFields, + remove: removeExisting, + } = useFieldArray({ + control: form.control, + name: "existing", + }) + + const { + fields: newUploadFields, + append: appendNewUpload, + remove: removeNewUpload, + } = useFieldArray({ + control: form.control, + name: "newUploads", + }) + + // Reset form when defaultAttachments changes + React.useEffect(() => { + if (defaultAttachments) { + form.reset({ + techSalesRfqId: rfq?.id || 0, + existing: defaultAttachments.map(att => ({ + id: att.id, + techSalesRfqId: att.techSalesRfqId, + fileName: att.fileName, + originalFileName: att.originalFileName, + filePath: att.filePath, + fileSize: att.fileSize || undefined, + fileType: att.fileType || undefined, + attachmentType: att.attachmentType, + description: att.description || undefined, + createdBy: att.createdBy, + createdAt: att.createdAt, + })), + newUploads: [], + }) + } + }, [defaultAttachments, rfq?.id, form]) + + // Handle dropzone accept + const handleDropAccepted = React.useCallback((acceptedFiles: File[]) => { + acceptedFiles.forEach((file) => { + appendNewUpload({ + fileObj: file, + attachmentType: "RFQ_COMMON", + description: "", + }) + }) + }, [appendNewUpload]) + + // Handle dropzone reject + const handleDropRejected = React.useCallback(() => { + toast.error("파일 크기가 너무 크거나 지원하지 않는 파일 형식입니다.") + }, []) + + // Handle remove existing attachment + const handleRemoveExisting = React.useCallback((index: number) => { + removeExisting(index) + }, [removeExisting]) + + // Handle form submission + const onSubmit = async (data: AttachmentsFormValues) => { + if (!rfq) { + toast.error("RFQ 정보를 찾을 수 없습니다.") + return + } + + setIsPending(true) + try { + // 삭제할 첨부파일 ID 수집 + const deleteAttachmentIds = defaultAttachments + .filter((original) => !data.existing.find(existing => existing.id === original.id)) + .map(attachment => attachment.id) + + // 새 파일 정보 수집 + const newFiles = data.newUploads + .filter(upload => upload.fileObj) + .map(upload => ({ + file: upload.fileObj as File, + attachmentType: attachmentType, + description: upload.description, + })) + + // 실제 API 호출 + const result = await processTechSalesRfqAttachments({ + techSalesRfqId: rfq.id, + newFiles, + deleteAttachmentIds, + createdBy: parseInt(session.data?.user.id || "0"), + }) + + if (result.error) { + // DRM 파일 추가로 인한 재발송 결재 필요 + if (result.error === "DRM_FILE_ADDED_TO_SENT_RFQ") { + // DRM 파일만 필터링 + const drmFiles = newFiles.filter((_, index) => { + // DRM 파일 검출은 서버에서 이미 완료되었으므로, 업로드된 파일 중 DRM 파일만 추출 + // 실제로는 서버에서 반환된 정보를 사용해야 하지만, 여기서는 업로드된 파일을 그대로 사용 + return true // 임시로 모든 새 파일을 DRM 파일로 간주 (실제로는 서버에서 필터링 필요) + }) + + setResendApprovalData({ + rfqId: rfq.id, + drmFiles: newFiles, // 모든 새 파일을 DRM 파일로 간주 + }) + setShowApplicationReasonDialog(true) + setIsPending(false) + return + } else { + toast.error(result.error) + return + } + } + + // 성공 메시지 표시 (업로드된 파일 수 포함) + const uploadedCount = newFiles.length + const deletedCount = deleteAttachmentIds.length + + let successMessage = "첨부파일이 저장되었습니다." + if (uploadedCount > 0 && deletedCount > 0) { + successMessage = `${uploadedCount}개 파일 업로드, ${deletedCount}개 파일 삭제 완료` + } else if (uploadedCount > 0) { + successMessage = `${uploadedCount}개 파일이 업로드되었습니다.` + } else if (deletedCount > 0) { + successMessage = `${deletedCount}개 파일이 삭제되었습니다.` + } + + toast.success(successMessage) + + // 다이얼로그 자동 닫기 + props.onOpenChange?.(false) + + // // 즉시 첨부파일 목록 새로고침 + // const refreshResult = await getTechSalesRfqAttachments(rfq.id) + // if (refreshResult.error) { + // console.error("첨부파일 목록 새로고침 실패:", refreshResult.error) + // toast.warning("첨부파일 목록 새로고침에 실패했습니다. 시트를 다시 열어주세요.") + // } else { + // // 새로운 첨부파일 목록으로 폼 업데이트 + // const refreshedAttachments = refreshResult.data.map(att => ({ + // id: att.id, + // techSalesRfqId: att.techSalesRfqId || rfq.id, + // fileName: att.fileName, + // originalFileName: att.originalFileName, + // filePath: att.filePath, + // fileSize: att.fileSize, + // fileType: att.fileType, + // attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT", + // description: att.description, + // createdBy: att.createdBy, + // createdAt: att.createdAt, + // })) + + // // 폼을 새로운 데이터로 리셋 (새 업로드 목록은 비움) + // form.reset({ + // techSalesRfqId: rfq.id, + // existing: refreshedAttachments.map(att => ({ + // ...att, + // fileSize: att.fileSize || undefined, + // fileType: att.fileType || undefined, + // description: att.description || undefined, + // })), + // newUploads: [], + // }) + + // // 즉시 UI 업데이트를 위한 추가 피드백 + // if (uploadedCount > 0) { + // toast.success("첨부파일 목록이 업데이트되었습니다.", { duration: 2000 }) + // } + // } + + // // 콜백으로 상위 컴포넌트에 변경사항 알림 + // const newAttachmentCount = refreshResult.error ? + // (data.existing.length + newFiles.length - deleteAttachmentIds.length) : + // refreshResult.data.length + // onAttachmentsUpdated?.(rfq.id, newAttachmentCount) + + } catch (error) { + console.error("첨부파일 저장 오류:", error) + toast.error("첨부파일 저장 중 오류가 발생했습니다.") + } finally { + setIsPending(false) + } + } + + // 신청사유 입력 완료 핸들러 + const handleApplicationReasonConfirm = React.useCallback(async (reason: string) => { + if (!resendApprovalData) { + toast.error("결재 데이터가 없습니다.") + return + } + + try { + // 템플릿 변수 생성 (신청사유 포함) + const templateVariables = await mapTechSalesRfqSendToTemplateVariables({ + attachments: resendApprovalData.drmFiles.map(f => ({ + fileName: f.file.name, + fileSize: f.file.size, + })), + vendorNames: [], // 기존 벤더 목록은 후처리에서 조회 + applicationReason: reason, + }) + + // 결재 미리보기 데이터 업데이트 + setApprovalPreviewData({ + templateVariables, + applicationReason: reason, + }) + + // 신청사유 다이얼로그 닫고 결재 미리보기 열기 + setShowApplicationReasonDialog(false) + setShowResendApprovalDialog(true) + } catch (error) { + console.error("템플릿 변수 생성 실패:", error) + toast.error("결재 문서 생성에 실패했습니다.") + } + }, [resendApprovalData]) + + // 결재 미리보기 확인 핸들러 + const handleApprovalConfirm = React.useCallback(async (approvalData: { + approvers: string[] + title: string + description?: string + }) => { + if (!resendApprovalData || !approvalPreviewData || !session?.data?.user) { + toast.error("결재 데이터가 없습니다.") + return + } + + try { + const result = await requestRfqResendWithDrmApproval({ + rfqId: resendApprovalData.rfqId, + rfqCode: rfq?.rfqCode || undefined, + drmFiles: resendApprovalData.drmFiles, + applicationReason: approvalPreviewData.applicationReason, + currentUser: { + id: Number(session.data.user.id), + epId: session.data.user.epId || null, + name: session.data.user.name || undefined, + email: session.data.user.email || undefined, + }, + approvers: approvalData.approvers, + }) + + if (result.success) { + toast.success(result.message) + setShowResendApprovalDialog(false) + setResendApprovalData(null) + setApprovalPreviewData(null) + props.onOpenChange?.(false) + } + } catch (error) { + console.error("재발송 결재 상신 실패:", error) + toast.error(error instanceof Error ? error.message : "재발송 결재 상신에 실패했습니다.") + } + }, [resendApprovalData, approvalPreviewData, session, rfq, props]) + + return ( + + + + {attachmentConfig.title} + +
RFQ: {rfq?.rfqCode || "N/A"}
+
{attachmentConfig.description}
+ {!attachmentConfig.canEdit && ( +
+ + 현재 상태에서는 편집할 수 없습니다 +
+ )} +
+
+ +
+ + {/* 1) Existing attachments */} +
+
+ 기존 첨부파일 ({existingFields.length}개) +
+ {existingFields.map((field, index) => { + const typeLabel = attachmentConfig.fileTypeLabel + const sizeText = field.fileSize ? prettyBytes(field.fileSize) : "알 수 없음" + const dateText = field.createdAt ? formatDate(field.createdAt, "KR") : "" + + return ( +
+
+
+

+ {field.originalFileName || field.fileName} +

+ + {typeLabel} + +
+

+ {sizeText} • {dateText} +

+ {field.description && ( +

+ {field.description} +

+ )} +
+ +
+ {/* Download button */} + {field.filePath && ( + + )} + {/* Remove button - 편집 가능할 때만 표시 */} + {attachmentConfig.canEdit && ( + + )} +
+
+ ) + })} +
+ + {/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */} + {attachmentConfig.canEdit ? ( + <> + + {({ maxSize }) => ( + ( + + 새 파일 업로드 + + + + +
+ +
+ 파일을 드래그하거나 클릭하세요 + + 최대 크기: {maxSize ? prettyBytes(maxSize) : "600MB"} + +
+
+
+ 파일을 여러 개 선택할 수 있습니다. + +
+ )} + /> + )} +
+ + {/* newUpload fields -> FileList */} + {newUploadFields.length > 0 && ( +
+
+ 새 파일 ({newUploadFields.length}개) +
+ + {newUploadFields.map((field, idx) => { + const fileObj = form.getValues(`newUploads.${idx}.fileObj`) + if (!fileObj) return null + + const fileName = fileObj.name + const fileSize = fileObj.size + return ( + + + + + {fileName} + + {prettyBytes(fileSize)} + + + removeNewUpload(idx)}> + + 제거 + + + + + ) + })} + +
+ )} + + ) : ( +
+
+ +

보기 모드에서는 파일 첨부를 할 수 없습니다.

+
+
+ )} + + + + + + {attachmentConfig.canEdit && ( + + )} + +
+ +
+ + {/* 신청사유 입력 다이얼로그 */} + {resendApprovalData && ( + + )} + + {/* 결재 미리보기 다이얼로그 */} + {resendApprovalData && session?.data?.user?.epId && approvalPreviewData && ( + + )} +
+ ) +} \ No newline at end of file diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx index 214e2b89..62d2c073 100644 --- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx +++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx @@ -234,8 +234,9 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab return } - // 실제 첨부파일 목록 조회 API 호출 - const result = await getTechSalesRfqAttachments(rfqId) + // 벤더용 첨부파일 목록 조회 API 호출 (DRM 해제된 파일만 반환) + const { getRfqAttachmentsForVendor } = await import("@/lib/techsales-rfq/service") + const result = await getRfqAttachmentsForVendor(rfqId) if (result.error) { toast.error(result.error) @@ -243,6 +244,7 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab } // API 응답을 ExistingTechSalesAttachment 형식으로 변환하고 RFQ_COMMON 타입만 필터링 + // getRfqAttachmentsForVendor는 이미 DRM 해제된 파일만 반환하므로 추가 필터링 불필요 const attachments: ExistingTechSalesAttachment[] = result.data .filter(att => att.attachmentType === "RFQ_COMMON") // 벤더는 RFQ_COMMON 타입만 조회 .map(att => ({ -- cgit v1.2.3