summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/mail/templates/tech-sales-rfq-invite-ko.hbs27
-rw-r--r--lib/mail/templates/vendor-invitation.hbs2
-rw-r--r--lib/techsales-rfq/approval-actions.ts244
-rw-r--r--lib/techsales-rfq/approval-handlers.ts313
-rw-r--r--lib/techsales-rfq/service.ts145
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx127
-rw-r--r--lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet-copy-1118.tsx710
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx6
8 files changed, 1532 insertions, 42 deletions
diff --git a/lib/mail/templates/tech-sales-rfq-invite-ko.hbs b/lib/mail/templates/tech-sales-rfq-invite-ko.hbs
index 37521960..2604e223 100644
--- a/lib/mail/templates/tech-sales-rfq-invite-ko.hbs
+++ b/lib/mail/templates/tech-sales-rfq-invite-ko.hbs
@@ -55,9 +55,8 @@
<h2 style="font-size:20px; margin-bottom:12px;">가. 거래조건</h2>
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
<strong>1) 프로젝트 : {{project.name}}</strong>
- <br>* 프로젝트 코드 : {{rfq.projectCode}}
- {{#if project.sector}}
- <br>* 부문 : {{project.sector}}
+ {{#if project.shipType}}
+ <br>* 선종 : {{project.shipType}}
{{/if}}
{{#if project.shipCount}}
<br>* 척수 : {{project.shipCount}}척
@@ -76,40 +75,32 @@
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
<strong>2) 자재명</strong>
{{#each items}}
- <br>* {{itemList}} ({{itemCode}})
- {{#if workType}}<br> - 작업유형: {{workType}}{{/if}}
- {{#if shipType}}<br> - 선종: {{shipType}}{{/if}}
+ <br>* {{itemList}}
{{/each}}
- {{#if rfq.materialCode}}
- <br>* 자재그룹 코드 : {{rfq.materialCode}}
- {{/if}}
</p>
{{else}}
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
<strong>2) 자재명 : {{rfq.title}}</strong>
- {{#if rfq.materialCode}}
- <br>* 자재그룹 코드 : {{rfq.materialCode}}
- {{/if}}
- {{#if project.shipType}}
- <br>* 선종 : {{project.shipType}}
- {{/if}}
</p>
{{/if}}
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
- <strong>3) Spec, & Scope of Supply : 첨부 사양서 참조</strong>
+ <strong>3) Spec, & Scope of Supply : 시스템 내 첨부 사양서 또는 본문 참조</strong>
</p>
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
<strong>4) Class / Flag : {{#if project.className}}{{project.className}}{{else}}첨부 사양서 참조{{/if}}</strong>
</p>
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
- <strong>5) 예상 납기일 : 프로젝트 일정에 따라 결정</strong>
+ <strong>5) 예상 납기일 :</strong>
+ <br>* K/L Date : 2027.3Q 기준 프로젝트 일정에 따라 결정
<br>* 상세 납기는 조선소 스케쥴에 따라 변경될 수 있으며, 당사 기술영업/조달/현업/생산 부서와 협의하여 결정됨.
<br>* 안전보건에 관한 사항을 고려하여 납기(또는 계약기간)의 적정성을 검토하고,
<br>안전보건확보를 위해 납기의 조정이 필요한 경우 기간 조정을 신청하시기 바랍니다.
<br>* (당사 사업장 내에서 수행하는 작업이 포함된 계약)
</p>
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
- <strong>6) Warranty : 선박 인도 후 12개월 시점까지 납품한 "자재" 또는 "용역"이 계약 내용과 동일함을 보증하며,</strong>
+ <strong>6) Warranty :</strong>
+ <br>* 별도 요구 기간이 있을 경우 그 기준을 준수할 것
+ <br>* 별도 요구 기간이 없을 경우, 선박 인도 후 12개월 시점까지 납품한 "자재" 또는 "용역"이 계약 내용과 동일함을 보증하며,
<br>Repair 시 6개월 추가되나, 총 인도 후 18개월을 넘지 않음.
</p>
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
diff --git a/lib/mail/templates/vendor-invitation.hbs b/lib/mail/templates/vendor-invitation.hbs
index d80f223a..193fcaa0 100644
--- a/lib/mail/templates/vendor-invitation.hbs
+++ b/lib/mail/templates/vendor-invitation.hbs
@@ -44,7 +44,7 @@
</p>
<div style="background-color: #f3f4f6; border-radius: 4px; padding: 15px; margin: 20px 0;">
- <p style="font-size:16px; margin:4px 0;">등록을 완료하려면 아래 버튼을 클릭해 주세요. 이 버튼을 클릭하면 계정을 설정하고 필요한 정보를 제공할 수 있는 보안 등록 포털로 이동합니다.</p>
+ <p style="font-size:16px; margin:4px 0;"><strong>신규가입 진행:</strong> 등록을 완료하려면 아래 버튼을 클릭해 주세요. 이 버튼을 클릭하면 계정을 설정하고 필요한 정보를 제공할 수 있는 보안 등록 포털로 이동합니다.</p>
</div>
<p style="text-align: center;">
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<typeof techSalesRfqs.$inferInsert> = {
- 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();
@@ -1653,6 +1695,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 첨부파일 삭제
*/
export async function deleteTechSalesRfqAttachment(attachmentId: number) {
@@ -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<RfqDetailView[]>([])
@@ -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<string, string>
+ 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<Date>(),
+})
+
+/** RHF 폼 전체 스키마 */
+const attachmentsFormSchema = z.object({
+ techSalesRfqId: z.number().int(),
+ existing: z.array(existingAttachSchema),
+ newUploads: z.array(newUploadSchema),
+})
+
+type AttachmentsFormValues = z.infer<typeof attachmentsFormSchema>
+
+// TechSalesRfq 타입 (간단 버전)
+interface TechSalesRfq {
+ id: number
+ rfqCode: string | null
+ status: string
+ // 필요한 다른 필드들...
+}
+
+interface TechSalesRfqAttachmentsSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ 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<string, string>
+ 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<AttachmentsFormValues>({
+ 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 (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>{attachmentConfig.title}</SheetTitle>
+ <SheetDescription>
+ <div>RFQ: {rfq?.rfqCode || "N/A"}</div>
+ <div className="mt-1">{attachmentConfig.description}</div>
+ {!attachmentConfig.canEdit && (
+ <div className="mt-2 flex items-center gap-2 text-amber-600">
+ <AlertCircle className="h-4 w-4" />
+ <span className="text-sm">현재 상태에서는 편집할 수 없습니다</span>
+ </div>
+ )}
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-1 flex-col gap-6">
+ {/* 1) Existing attachments */}
+ <div className="grid gap-4">
+ <h6 className="font-semibold leading-none tracking-tight">
+ 기존 첨부파일 ({existingFields.length}개)
+ </h6>
+ {existingFields.map((field, index) => {
+ const typeLabel = attachmentConfig.fileTypeLabel
+ const sizeText = field.fileSize ? prettyBytes(field.fileSize) : "알 수 없음"
+ const dateText = field.createdAt ? formatDate(field.createdAt, "KR") : ""
+
+ return (
+ <div key={field.id} className="flex items-start justify-between p-3 border rounded-md gap-3">
+ <div className="flex-1 min-w-0 overflow-hidden">
+ <div className="flex items-center gap-2 mb-1 flex-wrap">
+ <p className="text-sm font-medium break-words leading-tight">
+ {field.originalFileName || field.fileName}
+ </p>
+ <Badge variant="outline" className="text-xs shrink-0">
+ {typeLabel}
+ </Badge>
+ </div>
+ <p className="text-xs text-muted-foreground">
+ {sizeText} • {dateText}
+ </p>
+ {field.description && (
+ <p className="text-xs text-muted-foreground mt-1 break-words">
+ {field.description}
+ </p>
+ )}
+ </div>
+
+ <div className="flex items-center gap-1 shrink-0">
+ {/* Download button */}
+ {field.filePath && (
+ <Button
+ variant="ghost"
+ size="icon"
+ type="button"
+ className="h-8 w-8"
+ onClick={() => handleDownloadClick(field.filePath, field.originalFileName || field.fileName)}
+ title="다운로드"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ )}
+ {/* Remove button - 편집 가능할 때만 표시 */}
+ {attachmentConfig.canEdit && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => handleRemoveExisting(index)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ )
+ })}
+ </div>
+
+ {/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */}
+ {attachmentConfig.canEdit ? (
+ <>
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={handleDropRejected}
+ >
+ {({ maxSize }) => (
+ <FormField
+ control={form.control}
+ name="newUploads"
+ render={() => (
+ <FormItem>
+ <FormLabel>새 파일 업로드</FormLabel>
+ <DropzoneZone className="flex justify-center">
+ <FormControl>
+ <DropzoneInput />
+ </FormControl>
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 드래그하거나 클릭하세요</DropzoneTitle>
+ <DropzoneDescription>
+ 최대 크기: {maxSize ? prettyBytes(maxSize) : "600MB"}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ <FormDescription>파일을 여러 개 선택할 수 있습니다.</FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+ </Dropzone>
+
+ {/* newUpload fields -> FileList */}
+ {newUploadFields.length > 0 && (
+ <div className="grid gap-4">
+ <h6 className="font-semibold leading-none tracking-tight">
+ 새 파일 ({newUploadFields.length}개)
+ </h6>
+ <FileList>
+ {newUploadFields.map((field, idx) => {
+ const fileObj = form.getValues(`newUploads.${idx}.fileObj`)
+ if (!fileObj) return null
+
+ const fileName = fileObj.name
+ const fileSize = fileObj.size
+ return (
+ <FileListItem key={field.id}>
+ <FileListHeader>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{fileName}</FileListName>
+ <FileListDescription>
+ {prettyBytes(fileSize)}
+ </FileListDescription>
+ </FileListInfo>
+ <FileListAction onClick={() => removeNewUpload(idx)}>
+ <X />
+ <span className="sr-only">제거</span>
+ </FileListAction>
+ </FileListHeader>
+
+ </FileListItem>
+ )
+ })}
+ </FileList>
+ </div>
+ )}
+ </>
+ ) : (
+ <div className="p-3 bg-muted rounded-md flex items-center justify-center">
+ <div className="text-center text-sm text-muted-foreground">
+ <Eye className="h-4 w-4 mx-auto mb-2" />
+ <p>보기 모드에서는 파일 첨부를 할 수 없습니다.</p>
+ </div>
+ </div>
+ )}
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ {attachmentConfig.canEdit ? "취소" : "닫기"}
+ </Button>
+ </SheetClose>
+ {attachmentConfig.canEdit && (
+ <Button
+ type="submit"
+ disabled={
+ isPending ||
+ (
+ form.getValues().newUploads.length === 0 &&
+ form.getValues().existing.length === defaultAttachments.length &&
+ form.getValues().existing.every(existing =>
+ defaultAttachments.some(original => original.id === existing.id)
+ )
+ )
+ }
+ >
+ {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
+ {isPending ? "저장 중..." : "저장"}
+ </Button>
+ )}
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+
+ {/* 신청사유 입력 다이얼로그 */}
+ {resendApprovalData && (
+ <ApplicationReasonDialog
+ open={showApplicationReasonDialog}
+ onOpenChange={setShowApplicationReasonDialog}
+ onConfirm={handleApplicationReasonConfirm}
+ vendorCount={0} // 재발송이므로 기존 벤더에게 발송
+ attachmentCount={resendApprovalData.drmFiles.length}
+ />
+ )}
+
+ {/* 결재 미리보기 다이얼로그 */}
+ {resendApprovalData && session?.data?.user?.epId && approvalPreviewData && (
+ <ApprovalPreviewDialog
+ open={showResendApprovalDialog}
+ onOpenChange={setShowResendApprovalDialog}
+ templateName="암호화해제 신청"
+ variables={approvalPreviewData.templateVariables}
+ title={`DRM 파일 재발송 결재 - ${rfq?.rfqCode || 'RFQ'}`}
+ currentUser={{
+ id: Number(session.data.user.id),
+ epId: session.data.user.epId,
+ name: session.data.user.name || undefined,
+ email: session.data.user.email || undefined,
+ }}
+ onConfirm={handleApprovalConfirm}
+ allowTitleEdit={false}
+ />
+ )}
+ </Sheet>
+ )
+} \ 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 => ({