diff options
Diffstat (limited to 'lib')
23 files changed, 3455 insertions, 541 deletions
diff --git a/lib/approval/handlers-registry.ts b/lib/approval/handlers-registry.ts index a92c5ce5..7aec3ae5 100644 --- a/lib/approval/handlers-registry.ts +++ b/lib/approval/handlers-registry.ts @@ -49,6 +49,21 @@ export async function initializeApprovalHandlers() { // RFQ 발송 핸들러 등록 (결재 승인 후 실행될 함수 sendRfqWithApprovalInternal) registerActionHandler('rfq_send_with_attachments', sendRfqWithApprovalInternal); + // 7. 기술영업 RFQ 발송 핸들러 (DRM 파일이 있는 경우) + const { + sendTechSalesRfqWithApprovalInternal, + resendTechSalesRfqWithDrmInternal + } = await import('@/lib/techsales-rfq/approval-handlers'); + // 기술영업 RFQ 발송 핸들러 등록 (결재 승인 후 실행될 함수 sendTechSalesRfqWithApprovalInternal) + registerActionHandler('tech_sales_rfq_send_with_drm', sendTechSalesRfqWithApprovalInternal); + // 기술영업 RFQ 재발송 핸들러 등록 (결재 승인 후 실행될 함수 resendTechSalesRfqWithDrmInternal) + registerActionHandler('tech_sales_rfq_resend_with_drm', resendTechSalesRfqWithDrmInternal); + + // 8. 입찰초대 핸들러 + const { requestBiddingInvitationInternal } = await import('@/lib/bidding/handlers'); + // 입찰초대 핸들러 등록 (결재 승인 후 실행될 함수 requestBiddingInvitationInternal) + registerActionHandler('bidding_invitation', requestBiddingInvitationInternal); + // ... 추가 핸들러 등록 console.log('[Approval Handlers] All handlers registered successfully'); diff --git a/lib/approval/templates/입찰초대 결재.html b/lib/approval/templates/입찰초대 결재.html new file mode 100644 index 00000000..d22b9322 --- /dev/null +++ b/lib/approval/templates/입찰초대 결재.html @@ -0,0 +1,805 @@ +<div + style=" + max-width: 1000px; + margin: 0 auto; + font-family: 'Malgun Gothic', 'Segoe UI', sans-serif; + font-size: 14px; + color: #333; + line-height: 1.5; + border: 1px solid #666; /* 전체적인 테두리 추가 */ + " +> + <!-- 1. 제목 --> + <table + style=" + width: 100%; + border-collapse: collapse; + margin-bottom: 0px; + border-bottom: 2px solid #000; + " + > + <thead> + <tr> + <th + style=" + background-color: #fff; + color: #000; + padding: 15px; + text-align: center; + font-size: 20px; + font-weight: 700; + " + > + 입찰 결재 요청서 ({{제목}}) + </th> + </tr> + </thead> + </table> + + <!-- 2. 입찰 기본 정보 및 개요 --> + <table + style=" + width: 100%; + border-collapse: collapse; + margin-bottom: 15px; + " + > + <thead> + <tr> + <th + colspan="4" + style=" + background-color: #333; + color: #fff; + padding: 10px; + text-align: left; + font-size: 15px; + font-weight: 600; + border-bottom: 1px solid #666; + " + > + ■ 입찰 기본 정보 + </th> + </tr> + </thead> + <tbody> + <!-- 1행 --> + <tr> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + width: 15%; + border: 1px solid #ccc; + text-align: center; + " + > + 입찰명 + </td> + <td + style=" + padding: 8px 10px; + width: 35%; + border: 1px solid #ccc; + " + > + {{입찰명}} + </td> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + width: 15%; + border: 1px solid #ccc; + text-align: center; + " + > + 입찰번호 + </td> + <td + style=" + padding: 8px 10px; + width: 35%; + border: 1px solid #ccc; + " + > + {{입찰번호}} + </td> + </tr> + <!-- 2행 --> + <tr> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + border: 1px solid #ccc; + text-align: center; + " + > + 낙찰업체수 + </td> + <td + style=" + padding: 8px 10px; + border: 1px solid #ccc; + " + > + {{낙찰업체수}} + </td> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + border: 1px solid #ccc; + text-align: center; + " + > + 계약구분 + </td> + <td + style=" + padding: 8px 10px; + border: 1px solid #ccc; + " + > + {{계약구분}} + </td> + </tr> + <!-- 3행 --> + <tr> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + border: 1px solid #ccc; + text-align: center; + " + > + P/R번호 + </td> + <td + style=" + padding: 8px 10px; + border: 1px solid #ccc; + " + > + {{P/R번호}} + </td> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + border: 1px solid #ccc; + text-align: center; + " + > + 예산 + </td> + <td + style=" + padding: 8px 10px; + border: 1px solid #ccc; + " + > + {{예산}} + </td> + </tr> + <!-- 4행 --> + <tr> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + border: 1px solid #ccc; + text-align: center; + " + > + 내정가 + </td> + <td + style=" + padding: 8px 10px; + border: 1px solid #ccc; + " + > + {{내정가}} + </td> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + border: 1px solid #ccc; + text-align: center; + " + > + 입찰요청 시스템 + </td> + <td + style=" + padding: 8px 10px; + border: 1px solid #ccc; + " + > + eVCP + </td> + </tr> + <!-- 5행 --> + <tr> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + border: 1px solid #ccc; + text-align: center; + " + > + 입찰담당자 + </td> + <td + style=" + padding: 8px 10px; + border: 1px solid #ccc; + " + > + {{입찰담당자}} + </td> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + border: 1px solid #ccc; + text-align: center; + " + > + 내정가 산정 기준 + </td> + <td + style=" + padding: 8px 10px; + border: 1px solid #ccc; + " + > + {{내정가_산정_기준}} + </td> + </tr> + <!-- 6행: 입찰 개요 --> + <tr> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + border: 1px solid #ccc; + text-align: center; + " + > + 입찰 개요 + </td> + <td + colspan="3" + style=" + padding: 8px 10px; + height: 80px; + border: 1px solid #ccc; + vertical-align: top; + " + > + {{입찰개요}} + </td> + </tr> + <!-- 7행: 입찰 공고문 --> + <tr> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + border: 1px solid #ccc; + text-align: center; + " + > + 입찰 공고문 + </td> + <td + colspan="3" + style=" + padding: 8px 10px; + height: 80px; + border: 1px solid #ccc; + vertical-align: top; + " + > + {{입찰공고문}} + </td> + </tr> + </tbody> + </table> + + <!-- 3. 입찰 대상 협력사 --> + <table + style=" + width: 100%; + border-collapse: collapse; + margin-bottom: 15px; + " + > + <thead> + <tr> + <th + colspan="6" + style=" + background-color: #333; + color: #fff; + padding: 10px; + text-align: left; + font-size: 15px; + font-weight: 600; + border-bottom: 1px solid #666; + " + > + ■ 입찰 대상 협력사 + </th> + </tr> + <tr> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 5%; + " + > + 순번 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 15%; + " + > + 협력사 코드 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 25%; + " + > + 협력사명 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 15%; + " + > + 담당자 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 25%; + " + > + 이메일 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 15%; + " + > + 전화번호 + </th> + </tr> + </thead> + <tbody> + <!-- 데이터 행 (반복 영역) --> + <tr> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">1</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{협력사_코드_1}}</td> + <td style="padding: 8px 10px; border: 1px solid #ccc;">{{협력사명_1}}</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{담당자_1}}</td> + <td style="padding: 8px 10px; border: 1px solid #ccc;">{{이메일_1}}</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{전화번호_1}}</td> + </tr> + <!-- ... 추가 협력사 정보 행 ... --> + <tr> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">2</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{협력사_코드_2}}</td> + <td style="padding: 8px 10px; border: 1px solid #ccc;">{{협력사명_2}}</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{담당자_2}}</td> + <td style="padding: 8px 10px; border: 1px solid #ccc;">{{이메일_2}}</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{전화번호_2}}</td> + </tr> + <!-- /데이터 행 --> + </tbody> + </table> + + <!-- 4. 입찰 일정 계획 --> + <table + style=" + width: 100%; + border-collapse: collapse; + margin-bottom: 15px; + " + > + <thead> + <tr> + <th + colspan="4" + style=" + background-color: #333; + color: #fff; + padding: 10px; + text-align: left; + font-size: 15px; + font-weight: 600; + border-bottom: 1px solid #666; + " + > + ■ 입찰 일정 계획 + </th> + </tr> + <tr> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 25%; + " + > + 구분 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 15%; + " + > + 실행 여부 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 30%; + " + > + 시작 예정 일시 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 30%; + " + > + 종료 예정 일시 + </th> + </tr> + </thead> + <tbody> + <tr> + <td + style=" + padding: 8px 10px; + border: 1px solid #ccc; + text-align: center; + font-weight: 600; + " + > + 사양 설명회 + </td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;"> + {{사양설명회_실행여부}} + </td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;"> + {{사양설명회_시작예정일시}} + </td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;"> + {{사양설명회_종료예정일시}} + </td> + </tr> + <tr> + <td + style=" + padding: 8px 10px; + border: 1px solid #ccc; + text-align: center; + font-weight: 600; + " + > + 입찰서 제출 기간 + </td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;"> + {{입찰서제출기간_실행여부}} + </td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;"> + {{입찰서제출기간_시작예정일시}} + </td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;"> + {{입찰서제출기간_종료예정일시}} + </td> + </tr> + </tbody> + </table> + + <!-- 5. 입찰 대상 자재 정보 --> + <table + style=" + width: 100%; + border-collapse: collapse; + margin-bottom: 15px; + " + > + <thead> + <tr> + <th + colspan="15" + style=" + background-color: #333; + color: #fff; + padding: 10px; + text-align: left; + font-size: 15px; + font-weight: 600; + border-bottom: 1px solid #666; + " + > + ■ 입찰 대상 자재 정보 (총 {{대상_자재_수}} 건) + </th> + </tr> + <tr style="font-size: 12px;"> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + " + > + 순번 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + " + > + 프로젝트 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + " + > + 자재그룹 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + " + > + 자재그룹명 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + " + > + 자재코드 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + " + > + 자재코드명 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + " + > + 수량 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + " + > + 구매단위 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + " + > + 내정단가 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + " + > + 수량단위 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + " + > + 총중량 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + " + > + 중량단위 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + " + > + 예산 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + " + > + 내정금액 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + " + > + 통화 + </th> + </tr> + </thead> + <tbody> + <!-- 데이터 행 (반복 영역) --> + <tr style="font-size: 12px;"> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">1</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{프로젝트_1}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재그룹_1}}</td> + <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재그룹명_1}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재코드_1}}</td> + <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재코드명_1}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{수량_1}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{구매단위_1}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{내정단가_1}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{수량단위_1}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{총중량_1}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{중량단위_1}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{예산_1}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{내정금액_1}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{통화_1}}</td> + </tr> + <tr style="font-size: 12px;"> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">2</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{프로젝트_2}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재그룹_2}}</td> + <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재그룹명_2}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재코드_2}}</td> + <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재코드명_2}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{수량_2}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{구매단위_2}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{내정단가_2}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{수량단위_2}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{총중량_2}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{중량단위_2}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{예산_2}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{내정금액_2}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{통화_2}}</td> + </tr> + <!-- /데이터 행 --> + </tbody> + </table> +</div>
\ No newline at end of file diff --git a/lib/basic-contract/sslvw-service.ts b/lib/basic-contract/sslvw-service.ts new file mode 100644 index 00000000..9650d43a --- /dev/null +++ b/lib/basic-contract/sslvw-service.ts @@ -0,0 +1,82 @@ +"use server" + +import { oracleKnex } from '@/lib/oracle-db/db' + +// SSLVW_PUR_INQ_REQ 테이블 데이터 타입 (실제 테이블 구조에 맞게 조정 필요) +export interface SSLVWPurInqReq { + [key: string]: string | number | Date | null | undefined +} + +// 테스트 환경용 폴백 데이터 +const FALLBACK_TEST_DATA: SSLVWPurInqReq[] = [ + { + id: 1, + request_number: 'REQ001', + status: 'PENDING', + created_date: new Date('2025-01-01'), + description: '테스트 요청 1' + }, + { + id: 2, + request_number: 'REQ002', + status: 'APPROVED', + created_date: new Date('2025-01-02'), + description: '테스트 요청 2' + } +] + +/** + * SSLVW_PUR_INQ_REQ 테이블 전체 조회 + * @returns 테이블 데이터 배열 + */ +export async function getSSLVWPurInqReqData(): Promise<{ + success: boolean + data: SSLVWPurInqReq[] + error?: string + isUsingFallback?: boolean +}> { + try { + console.log('📋 [getSSLVWPurInqReqData] SSLVW_PUR_INQ_REQ 테이블 조회 시작...') + + const result = await oracleKnex.raw(` + SELECT * + FROM SSLVW_PUR_INQ_REQ + WHERE ROWNUM < 100 + ORDER BY 1 + `) + + // Oracle raw query의 결과는 rows 배열에 들어있음 + const rows = (result.rows || result) as Array<Record<string, unknown>> + + console.log(`✅ [getSSLVWPurInqReqData] 조회 성공 - ${rows.length}건`) + + // 데이터 타입 변환 (필요에 따라 조정) + const cleanedResult = rows.map((item) => { + const convertedItem: SSLVWPurInqReq = {} + for (const [key, value] of Object.entries(item)) { + if (value instanceof Date) { + convertedItem[key] = value + } else if (value === null) { + convertedItem[key] = null + } else { + convertedItem[key] = String(value) + } + } + return convertedItem + }) + + return { + success: true, + data: cleanedResult, + isUsingFallback: false + } + } catch (error) { + console.error('❌ [getSSLVWPurInqReqData] 오류:', error) + console.log('🔄 [getSSLVWPurInqReqData] 폴백 테스트 데이터 사용') + return { + success: true, + data: FALLBACK_TEST_DATA, + isUsingFallback: true + } + } +} diff --git a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx index 37ae135c..c71be9d1 100644 --- a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx +++ b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" -import { Download, FileDown, Mail, Scale, CheckCircle, AlertTriangle, Send, Gavel, Check, FileSignature } from "lucide-react" +import { Download, FileDown, Mail, CheckCircle, AlertTriangle, Send, Check, FileSignature } from "lucide-react" import { exportTableToExcel } from "@/lib/export" import { downloadFile } from "@/lib/file-download" @@ -18,11 +18,9 @@ import { DialogTitle, } from "@/components/ui/dialog" import { Badge } from "@/components/ui/badge" -import { Textarea } from "@/components/ui/textarea" -import { Label } from "@/components/ui/label" -import { Separator } from "@/components/ui/separator" -import { prepareFinalApprovalAction, quickFinalApprovalAction, requestLegalReviewAction, resendContractsAction } from "../service" +import { prepareFinalApprovalAction, quickFinalApprovalAction, resendContractsAction } from "../service" import { BasicContractSignDialog } from "../vendor-table/basic-contract-sign-dialog" +import { SSLVWPurInqReqDialog } from "@/components/common/legal/sslvw-pur-inq-req-dialog" interface BasicContractDetailTableToolbarActionsProps { table: Table<BasicContractView> @@ -35,10 +33,8 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD // 다이얼로그 상태 const [resendDialog, setResendDialog] = React.useState(false) - const [legalReviewDialog, setLegalReviewDialog] = React.useState(false) const [finalApproveDialog, setFinalApproveDialog] = React.useState(false) const [loading, setLoading] = React.useState(false) - const [reviewNote, setReviewNote] = React.useState("") const [buyerSignDialog, setBuyerSignDialog] = React.useState(false) const [contractsToSign, setContractsToSign] = React.useState<any[]>([]) @@ -49,10 +45,6 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD const canBulkResend = hasSelectedRows - const canRequestLegalReview = hasSelectedRows && selectedRows.some(row => - row.original.legalReviewRequired && !row.original.legalReviewRequestedAt - ) - const canFinalApprove = hasSelectedRows && selectedRows.some(row => { const contract = row.original; if (contract.completedAt !== null || !contract.signedFilePath) { @@ -67,10 +59,6 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD // 필터링된 계약서들 계산 const resendContracts = selectedRows.map(row => row.original) - const legalReviewContracts = selectedRows - .map(row => row.original) - .filter(contract => contract.legalReviewRequired && !contract.legalReviewRequestedAt) - const finalApproveContracts = selectedRows .map(row => row.original) .filter(contract => { @@ -204,15 +192,6 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD }) } - // 법무검토 요청 - const handleLegalReviewRequest = async () => { - if (!canRequestLegalReview) { - toast.error("법무검토 요청 가능한 계약서를 선택해주세요") - return - } - setLegalReviewDialog(true) - } - // 최종승인 const handleFinalApprove = async () => { if (!canFinalApprove) { @@ -241,26 +220,6 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD } } - // 법무검토 요청 확인 - const confirmLegalReview = async () => { - setLoading(true) - try { - // TODO: 서버액션 호출 - await requestLegalReviewAction(legalReviewContracts.map(c => c.id), reviewNote) - - console.log("법무검토 요청:", legalReviewContracts, "메모:", reviewNote) - toast.success(`${legalReviewContracts.length}건의 법무검토 요청을 완료했습니다`) - setLegalReviewDialog(false) - setReviewNote("") - table.toggleAllPageRowsSelected(false) // 선택 해제 - } catch (error) { - toast.error("법무검토 요청 중 오류가 발생했습니다") - console.error(error) - } finally { - setLoading(false) - } - } - // 최종승인 확인 (수정됨) const confirmFinalApprove = async () => { setLoading(true) @@ -354,25 +313,8 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD </span> </Button> - {/* 법무검토 요청 버튼 */} - <Button - variant="outline" - size="sm" - onClick={handleLegalReviewRequest} - disabled={!canRequestLegalReview} - className="gap-2" - title={!hasSelectedRows - ? "계약서를 선택해주세요" - : !canRequestLegalReview - ? "법무검토 요청 가능한 계약서가 없습니다" - : `${legalReviewContracts.length}건 법무검토 요청` - } - > - <Scale className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline"> - 법무검토 {hasSelectedRows ? `(${selectedRows.length})` : ''} - </span> - </Button> + {/* 법무검토 버튼 (SSLVW 데이터 조회) */} + <SSLVWPurInqReqDialog /> {/* 최종승인 버튼 */} <Button @@ -471,73 +413,6 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD </DialogContent> </Dialog> - {/* 법무검토 요청 다이얼로그 */} - <Dialog open={legalReviewDialog} onOpenChange={setLegalReviewDialog}> - <DialogContent className="max-w-2xl"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <Gavel className="size-5" /> - 법무검토 요청 - </DialogTitle> - <DialogDescription> - 선택한 {legalReviewContracts.length}건의 계약서에 대한 법무검토를 요청합니다. - </DialogDescription> - </DialogHeader> - - <div className="space-y-4"> - <div className="max-h-48 overflow-y-auto"> - <div className="space-y-3"> - {legalReviewContracts.map((contract, index) => ( - <div key={contract.id} className="flex items-center justify-between p-3 bg-blue-50 rounded-lg"> - <div className="flex-1"> - <div className="font-medium">{contract.vendorName || '업체명 없음'}</div> - <div className="text-sm text-gray-500"> - {contract.vendorCode || '코드 없음'} | {contract.templateName || '템플릿명 없음'} - </div> - </div> - <Badge variant="secondary">{contract.status}</Badge> - </div> - ))} - </div> - </div> - - <Separator /> - - <div className="space-y-2"> - <Label htmlFor="review-note">검토 요청 메모 (선택사항)</Label> - <Textarea - id="review-note" - placeholder="법무팀에게 전달할 특별한 요청사항이나 검토 포인트를 입력해주세요..." - value={reviewNote} - onChange={(e) => setReviewNote(e.target.value)} - rows={3} - /> - </div> - </div> - - <DialogFooter> - <Button - variant="outline" - onClick={() => { - setLegalReviewDialog(false) - setReviewNote("") - }} - disabled={loading} - > - 취소 - </Button> - <Button - onClick={confirmLegalReview} - disabled={loading} - className="gap-2" - > - <Gavel className="size-4" /> - {loading ? "요청 중..." : `${legalReviewContracts.length}건 검토요청`} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - {/* 최종승인 다이얼로그 */} <Dialog open={finalApproveDialog} onOpenChange={setFinalApproveDialog}> <DialogContent className="max-w-2xl"> diff --git a/lib/bidding/approval-actions.ts b/lib/bidding/approval-actions.ts new file mode 100644 index 00000000..3a82b08f --- /dev/null +++ b/lib/bidding/approval-actions.ts @@ -0,0 +1,243 @@ +/** + * 입찰초대 관련 결재 서버 액션 + * + * ✅ 베스트 프랙티스: + * - 'use server' 지시어 포함 (서버 액션) + * - UI에서 호출하는 진입점 함수들 + * - withApproval()을 사용하여 결재 프로세스 시작 + * - 템플릿 변수 준비 및 입력 검증 + * - 핸들러(Internal)에는 최소 데이터만 전달 + */ + +'use server'; + +import { ApprovalSubmissionSaga } from '@/lib/approval'; +import { mapBiddingInvitationToTemplateVariables } from './handlers'; +import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils'; + +/** + * 입찰초대 결재를 거쳐 입찰등록을 처리하는 서버 액션 + * + * ✅ 사용법 (클라이언트 컴포넌트에서): + * ```typescript + * const result = await requestBiddingInvitationWithApproval({ + * biddingId: 123, + * vendors: [...], + * message: "입찰 초대 메시지", + * currentUser: { id: 1, epId: 'EP001', email: 'user@example.com' }, + * approvers: ['EP002', 'EP003'] + * }); + * + * if (result.status === 'pending_approval') { + * toast.success(`입찰초대 결재가 상신되었습니다. (ID: ${result.approvalId})`); + * } + * ``` + */ +/** + * 입찰초대 결재를 위한 공통 데이터 준비 헬퍼 함수 + */ +export async function prepareBiddingApprovalData(data: { + biddingId: number; + vendors: Array<{ + vendorId: number; + vendorName: string; + vendorCode?: string | null; + vendorCountry?: string; + vendorEmail?: string | null; + contactPerson?: string | null; + contactEmail?: string | null; + ndaYn?: boolean; + generalGtcYn?: boolean; + projectGtcYn?: boolean; + agreementYn?: boolean; + biddingCompanyId: number; + biddingId: number; + }>; + message?: string; +}) { + // 1. 입찰 정보 조회 (템플릿 변수용) + debugLog('[BiddingInvitationApproval] 입찰 정보 조회 시작'); + const { default: db } = await import('@/db/db'); + const { biddings, prItemsForBidding } = await import('@/db/schema'); + const { eq } = await import('drizzle-orm'); + + const biddingInfo = await db + .select({ + id: biddings.id, + title: biddings.title, + biddingNumber: biddings.biddingNumber, + projectName: biddings.projectName, + itemName: biddings.itemName, + biddingType: biddings.biddingType, + bidPicName: biddings.bidPicName, + supplyPicName: biddings.supplyPicName, + submissionStartDate: biddings.submissionStartDate, + submissionEndDate: biddings.submissionEndDate, + hasSpecificationMeeting: biddings.hasSpecificationMeeting, + isUrgent: biddings.isUrgent, + remarks: biddings.remarks, + targetPrice: biddings.targetPrice, + }) + .from(biddings) + .where(eq(biddings.id, data.biddingId)) + .limit(1); + + if (biddingInfo.length === 0) { + debugError('[BiddingInvitationApproval] 입찰 정보를 찾을 수 없음'); + throw new Error('입찰 정보를 찾을 수 없습니다'); + } + + const bidding = biddingInfo[0]; + + // 입찰 대상 자재 정보 조회 + const biddingItemsInfo = await db + .select({ + id: prItemsForBidding.id, + projectName: prItemsForBidding.projectInfo, + materialGroup: prItemsForBidding.materialGroupNumber, + materialGroupName: prItemsForBidding.materialGroupInfo, + materialCode: prItemsForBidding.materialNumber, + materialCodeName: prItemsForBidding.materialInfo, + quantity: prItemsForBidding.quantity, + purchasingUnit: prItemsForBidding.purchaseUnit, + targetUnitPrice: prItemsForBidding.targetUnitPrice, + quantityUnit: prItemsForBidding.quantityUnit, + totalWeight: prItemsForBidding.totalWeight, + weightUnit: prItemsForBidding.weightUnit, + budget: prItemsForBidding.budgetAmount, + targetAmount: prItemsForBidding.targetAmount, + currency: prItemsForBidding.targetCurrency, + }) + .from(prItemsForBidding) + .where(eq(prItemsForBidding.biddingId, data.biddingId)); + + debugLog('[BiddingInvitationApproval] 입찰 정보 조회 완료', { + biddingId: bidding.id, + title: bidding.title, + itemCount: biddingItemsInfo.length, + }); + + // 2. 템플릿 변수 매핑 + debugLog('[BiddingInvitationApproval] 템플릿 변수 매핑 시작'); + const requestedAt = new Date(); + const { mapBiddingInvitationToTemplateVariables } = await import('./handlers'); + const variables = await mapBiddingInvitationToTemplateVariables({ + bidding, + biddingItems: biddingItemsInfo, + vendors: data.vendors, + message: data.message, + requestedAt, + }); + debugLog('[BiddingInvitationApproval] 템플릿 변수 매핑 완료', { + variableKeys: Object.keys(variables), + }); + + return { + bidding, + biddingItems: biddingItemsInfo, + variables, + }; +} + +export async function requestBiddingInvitationWithApproval(data: { + biddingId: number; + vendors: Array<{ + vendorId: number; + vendorName: string; + vendorCode?: string | null; + vendorCountry?: string; + vendorEmail?: string | null; + contactPerson?: string | null; + contactEmail?: string | null; + ndaYn?: boolean; + generalGtcYn?: boolean; + projectGtcYn?: boolean; + agreementYn?: boolean; + biddingCompanyId: number; + biddingId: number; + }>; + message?: string; + currentUser: { id: number; epId: string | null; email?: string }; + approvers?: string[]; // Knox EP ID 배열 (결재선) +}) { + debugLog('[BiddingInvitationApproval] 입찰초대 결재 서버 액션 시작', { + biddingId: data.biddingId, + vendorCount: data.vendors.length, + userId: data.currentUser.id, + hasEpId: !!data.currentUser.epId, + }); + + // 1. 입력 검증 + if (!data.currentUser.epId) { + debugError('[BiddingInvitationApproval] Knox EP ID 없음'); + throw new Error('Knox EP ID가 필요합니다'); + } + + if (data.vendors.length === 0) { + debugError('[BiddingInvitationApproval] 선정된 업체 없음'); + throw new Error('입찰 초대할 업체를 선택해주세요'); + } + + // 2. 입찰 상태를 결재 진행중으로 변경 + debugLog('[BiddingInvitationApproval] 입찰 상태 변경 시작'); + const { default: db } = await import('@/db/db'); + const { biddings, biddingCompanies, prItemsForBidding } = await import('@/db/schema'); + const { eq } = await import('drizzle-orm'); + + await db + .update(biddings) + .set({ + status: 'approval_pending', // 결재 진행중 상태 + updatedBy: data.currentUser.epId, + updatedAt: new Date() + }) + .where(eq(biddings.id, data.biddingId)); + + debugLog('[BiddingInvitationApproval] 입찰 상태 변경 완료', { + biddingId: data.biddingId, + newStatus: 'approval_pending' + }); + + // 3. 결재 데이터 준비 + const { bidding, biddingItems: biddingItemsInfo, variables } = await prepareBiddingApprovalData({ + biddingId: data.biddingId, + vendors: data.vendors, + message: data.message, + }); + + // 4. 결재 워크플로우 시작 (Saga 패턴) + debugLog('[BiddingInvitationApproval] ApprovalSubmissionSaga 생성'); + const saga = new ApprovalSubmissionSaga( + // actionType: 핸들러를 찾을 때 사용할 키 + 'bidding_invitation', + + // actionPayload: 결재 승인 후 핸들러에 전달될 데이터 (최소 데이터만) + { + biddingId: data.biddingId, + vendors: data.vendors, + message: data.message, + currentUserId: data.currentUser.id, // ✅ 결재 승인 후 핸들러 실행 시 필요 + }, + + // approvalConfig: 결재 상신 정보 (템플릿 포함) + { + title: `입찰초대 - ${bidding.title}`, + description: `${bidding.title} 입찰 초대 결재`, + templateName: '입찰초대 결재', // 한국어 템플릿명 + variables, // 치환할 변수들 + approvers: data.approvers, + currentUser: data.currentUser, + } + ); + + debugLog('[BiddingInvitationApproval] Saga 실행 시작'); + const result = await saga.execute(); + + debugSuccess('[BiddingInvitationApproval] 입찰초대 결재 워크플로우 완료', { + approvalId: result.approvalId, + pendingActionId: result.pendingActionId, + status: result.status, + }); + + return result; +} diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx index 6f35405d..80e50119 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx @@ -76,17 +76,21 @@ export function getBiddingDetailVendorColumns({ cell: ({ row }) => { const hasAmount = row.original.quotationAmount && Number(row.original.quotationAmount) > 0 return ( - <div className="text-right font-mono"> + <div className="text-right font-mono font-bold"> {hasAmount ? ( - <button - onClick={() => onViewQuotationHistory?.(row.original)} - className="text-primary hover:text-primary/80 hover:underline cursor-pointer" - title="품목별 견적 상세 보기" - > - {Number(row.original.quotationAmount).toLocaleString()} {row.original.currency} - </button> + <> + <button + onClick={() => onViewQuotationHistory?.(row.original)} + className="text-primary hover:text-primary/80 hover:underline cursor-pointer" + title="품목별 견적 상세 보기" + > + <span className="border-b-2 border-primary"> + {Number(row.original.quotationAmount).toLocaleString()} {row.original.currency} + </span> + </button> + </> ) : ( - <span className="text-muted-foreground">- {row.original.currency}</span> + <span className="text-muted-foreground border-b-2 border-dashed font-bold">- {row.original.currency}</span> )} </div> ) diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx index c1677ae7..f2c23de9 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { useRouter } from "next/navigation" import { useTransition } from "react" import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign, RotateCw } from "lucide-react" import { registerBidding, markAsDisposal, createRebidding } from "@/lib/bidding/detail/service" import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from "@/lib/bidding/pre-quote/service" @@ -37,6 +38,7 @@ export function BiddingDetailVendorToolbarActions({ const [isPricesDialogOpen, setIsPricesDialogOpen] = React.useState(false) const [isBiddingInvitationDialogOpen, setIsBiddingInvitationDialogOpen] = React.useState(false) const [selectedVendors, setSelectedVendors] = React.useState<any[]>([]) + const [isRoundIncreaseDialogOpen, setIsRoundIncreaseDialogOpen] = React.useState(false) // 본입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회 React.useEffect(() => { @@ -176,6 +178,32 @@ export function BiddingDetailVendorToolbarActions({ }) } + const handleRoundIncreaseWithNavigation = () => { + startTransition(async () => { + const result = await increaseRoundOrRebid(bidding.id, userId, 'round_increase') + + if (result.success) { + toast({ + title: "성공", + description: result.message, + }) + // 새로 생성된 입찰의 상세 페이지로 이동 + if (result.biddingId) { + router.push(`/evcp/bid/${result.biddingId}`) + } else { + router.push(`/evcp/bid`) + } + onSuccess() + } else { + toast({ + title: "오류", + description: result.error || "차수증가 중 오류가 발생했습니다.", + variant: 'destructive', + }) + } + }) + } + return ( <> <div className="flex items-center gap-2"> @@ -185,7 +213,7 @@ export function BiddingDetailVendorToolbarActions({ <Button variant="outline" size="sm" - onClick={handleRoundIncrease} + onClick={() => setIsRoundIncreaseDialogOpen(true)} disabled={isPending} > <RotateCw className="mr-2 h-4 w-4" /> @@ -250,6 +278,35 @@ export function BiddingDetailVendorToolbarActions({ onSuccess={onSuccess} /> + {/* 차수증가 확인 다이얼로그 */} + <Dialog open={isRoundIncreaseDialogOpen} onOpenChange={setIsRoundIncreaseDialogOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>차수증가 확인</DialogTitle> + <DialogDescription> + 입찰을 차수증가 처리하시겠습니까? 차수증가 후 새로운 입찰 화면으로 이동합니다. + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button + variant="outline" + onClick={() => setIsRoundIncreaseDialogOpen(false)} + > + 아니오 + </Button> + <Button + onClick={async () => { + setIsRoundIncreaseDialogOpen(false) + await handleRoundIncreaseWithNavigation() + }} + disabled={isPending} + > + 예 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </> ) } diff --git a/lib/bidding/failure/biddings-failure-table.tsx b/lib/bidding/failure/biddings-failure-table.tsx index c80021ea..43020322 100644 --- a/lib/bidding/failure/biddings-failure-table.tsx +++ b/lib/bidding/failure/biddings-failure-table.tsx @@ -20,6 +20,7 @@ import { } from "@/db/schema"
import { BiddingsClosureDialog } from "./biddings-closure-dialog"
import { Button } from "@/components/ui/button"
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { FileX, RefreshCw, Undo2 } from "lucide-react"
import { bidClosureAction, cancelDisposalAction } from "@/lib/bidding/actions"
import { increaseRoundOrRebid } from "@/lib/bidding/service"
@@ -85,6 +86,8 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { const [isCompact, setIsCompact] = React.useState<boolean>(false)
const [biddingClosureDialogOpen, setBiddingClosureDialogOpen] = React.useState(false)
const [selectedBidding, setSelectedBidding] = React.useState<BiddingFailureItem | null>(null)
+ const [isRebidDialogOpen, setIsRebidDialogOpen] = React.useState(false)
+ const [selectedBiddingForRebid, setSelectedBiddingForRebid] = React.useState<BiddingFailureItem | null>(null)
const { toast } = useToast()
const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingFailureItem> | null>(null)
@@ -103,9 +106,14 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { setSelectedBidding(rowAction.row.original)
switch (rowAction.type) {
+ case "view":
+ // 상세 페이지로 이동
+ router.push(`/evcp/bid/${rowAction.row.original.id}/info`)
+ break
case "rebid":
- // 재입찰
- handleRebid(rowAction.row.original)
+ // 재입찰 팝업 열기
+ setSelectedBiddingForRebid(rowAction.row.original)
+ setIsRebidDialogOpen(true)
break
case "closure":
// 폐찰
@@ -199,7 +207,7 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { setSelectedBidding(null)
}, [])
- const handleRebid = React.useCallback(async (bidding: BiddingFailureItem) => {
+ const handleRebidWithNavigation = React.useCallback(async (bidding: BiddingFailureItem) => {
if (!session?.user?.id) {
toast({
title: "오류",
@@ -215,10 +223,14 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { if (result.success) {
toast({
title: "성공",
- description: result.message,
+ description: (result as any).message || "재입찰이 완료되었습니다.",
})
- // 페이지 새로고침
- router.refresh()
+ // 새로 생성된 입찰의 상세 페이지로 이동
+ if ((result as any).biddingId) {
+ router.push(`/evcp/bid/${(result as any).biddingId}`)
+ } else {
+ router.refresh()
+ }
} else {
toast({
title: "오류",
@@ -352,7 +364,8 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { return
}
const bidding = selectedRows[0].original
- handleRebid(bidding)
+ setSelectedBiddingForRebid(bidding)
+ setIsRebidDialogOpen(true)
}}
disabled={table.getFilteredSelectedRowModel().rows.length !== 1 ||
(table.getFilteredSelectedRowModel().rows.length === 1 &&
@@ -407,7 +420,7 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { {/* 폐찰 다이얼로그 */}
{selectedBidding && session?.user?.id && (
- <BidClosureDialog
+ <BiddingsClosureDialog
open={biddingClosureDialogOpen}
onOpenChange={handleBiddingClosureDialogClose}
bidding={selectedBidding}
@@ -418,6 +431,40 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { }}
/>
)}
+
+ {/* 재입찰 확인 다이얼로그 */}
+ <Dialog open={isRebidDialogOpen} onOpenChange={setIsRebidDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>재입찰 확인</DialogTitle>
+ <DialogDescription>
+ 입찰을 재입찰 처리하시겠습니까? 재입찰 후 새로운 입찰 화면으로 이동합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setIsRebidDialogOpen(false)
+ setSelectedBiddingForRebid(null)
+ }}
+ >
+ 아니오
+ </Button>
+ <Button
+ onClick={async () => {
+ if (selectedBiddingForRebid) {
+ await handleRebidWithNavigation(selectedBiddingForRebid)
+ }
+ setIsRebidDialogOpen(false)
+ setSelectedBiddingForRebid(null)
+ }}
+ >
+ 예
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
</>
)
}
diff --git a/lib/bidding/handlers.ts b/lib/bidding/handlers.ts new file mode 100644 index 00000000..fc2951d4 --- /dev/null +++ b/lib/bidding/handlers.ts @@ -0,0 +1,283 @@ +/** + * 입찰초대 관련 결재 액션 핸들러 + * + * ✅ 베스트 프랙티스: + * - 'use server' 지시어 없음 (순수 비즈니스 로직만) + * - 결재 승인 후 실행될 최소한의 데이터만 처리 + * - DB 조작 및 실제 비즈니스 로직만 포함 + */ + +import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils'; + +/** + * 입찰초대 핸들러 (결재 승인 후 실행됨) + * + * ✅ Internal 함수: 결재 워크플로우에서 자동 호출됨 (직접 호출 금지) + * + * @param payload - withApproval()에서 전달한 actionPayload (최소 데이터만) + */ +export async function requestBiddingInvitationInternal(payload: { + biddingId: number; + vendors: Array<{ + vendorId: number; + vendorName: string; + vendorCode?: string | null; + vendorCountry?: string; + vendorEmail?: string | null; + contactPerson?: string | null; + contactEmail?: string | null; + ndaYn?: boolean; + generalGtcYn?: boolean; + projectGtcYn?: boolean; + agreementYn?: boolean; + biddingCompanyId: number; + biddingId: number; + }>; + message?: string; + currentUserId: number; // ✅ 결재 상신한 사용자 ID +}) { + debugLog('[BiddingInvitationHandler] 입찰초대 핸들러 시작', { + biddingId: payload.biddingId, + vendorCount: payload.vendors.length, + currentUserId: payload.currentUserId, + }); + + // ✅ userId 검증: 핸들러에서 userId가 없으면 잘못된 상황 (예외 처리) + if (!payload.currentUserId || payload.currentUserId <= 0) { + const errorMessage = 'currentUserId가 없습니다. actionPayload에 currentUserId가 포함되지 않았습니다.'; + debugError('[BiddingInvitationHandler]', errorMessage); + throw new Error(errorMessage); + } + + try { + // 1. 기본계약 발송 + const { sendBiddingBasicContracts } = await import('@/lib/bidding/pre-quote/service'); + + const vendorDataForContract = payload.vendors.map(vendor => ({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + vendorCode: vendor.vendorCode || undefined, + vendorCountry: vendor.vendorCountry, + selectedMainEmail: vendor.vendorEmail || '', + additionalEmails: [], + customEmails: [], + contractRequirements: { + ndaYn: vendor.ndaYn || false, + generalGtcYn: vendor.generalGtcYn || false, + projectGtcYn: vendor.projectGtcYn || false, + agreementYn: vendor.agreementYn || false, + }, + biddingCompanyId: vendor.biddingCompanyId, + biddingId: vendor.biddingId, + hasExistingContracts: false, // 결재 후처리에서는 기존 계약 확인 생략 + })); + + const contractResult = await sendBiddingBasicContracts( + payload.biddingId, + vendorDataForContract, + [], // generatedPdfs - 결재 템플릿이므로 PDF는 빈 배열 + payload.message + ); + + if (!contractResult.success) { + debugError('[BiddingInvitationHandler] 기본계약 발송 실패', contractResult.error); + throw new Error(contractResult.error || '기본계약 발송에 실패했습니다.'); + } + + debugLog('[BiddingInvitationHandler] 기본계약 발송 완료'); + + // 2. 입찰 등록 진행 (상태를 bidding_opened로 변경) + const { registerBidding } = await import('@/lib/bidding/detail/service'); + + const registerResult = await registerBidding(payload.biddingId, payload.currentUserId.toString()); + + if (!registerResult.success) { + debugError('[BiddingInvitationHandler] 입찰 등록 실패', registerResult.error); + throw new Error(registerResult.error || '입찰 등록에 실패했습니다.'); + } + + debugSuccess('[BiddingInvitationHandler] 입찰초대 완료', { + biddingId: payload.biddingId, + vendorCount: payload.vendors.length, + message: registerResult.message, + }); + + return { + success: true, + biddingId: payload.biddingId, + vendorCount: payload.vendors.length, + message: `기본계약 발송 및 본입찰 초대가 완료되었습니다.`, + }; + } catch (error) { + debugError('[BiddingInvitationHandler] 입찰초대 중 에러', error); + throw error; + } +} + +/** + * 입찰초대 데이터를 결재 템플릿 변수로 매핑 + * + * @param payload - 입찰초대 데이터 + * @returns 템플릿 변수 객체 (Record<string, string>) + */ +export async function mapBiddingInvitationToTemplateVariables(payload: { + bidding: { + id: number; + title: string; + biddingNumber: string; + projectName?: string; + itemName?: string; + biddingType: string; + bidPicName?: string; + supplyPicName?: string; + submissionStartDate?: Date; + submissionEndDate?: Date; + hasSpecificationMeeting?: boolean; + isUrgent?: boolean; + remarks?: string; + targetPrice?: number; + }; + biddingItems: Array<{ + id: number; + projectName?: string; + materialGroup?: string; + materialGroupName?: string; + materialCode?: string; + materialCodeName?: string; + quantity?: number; + purchasingUnit?: string; + targetUnitPrice?: number; + quantityUnit?: string; + totalWeight?: number; + weightUnit?: string; + budget?: number; + targetAmount?: number; + currency?: string; + }>; + vendors: Array<{ + vendorId: number; + vendorName: string; + vendorCode?: string | null; + vendorCountry?: string; + vendorEmail?: string | null; + contactPerson?: string | null; + contactEmail?: string | null; + }>; + message?: string; + requestedAt: Date; +}): Promise<Record<string, string>> { + const { bidding, biddingItems, vendors, message, requestedAt } = payload; + + // 제목 + const title = bidding.title || '입찰'; + + // 입찰명 + const biddingTitle = bidding.title || ''; + + // 입찰번호 + const biddingNumber = bidding.biddingNumber || ''; + + // 낙찰업체수 + const winnerCount = '1'; // 기본값, 실제로는 bidding 설정에서 가져와야 함 + + // 계약구분 + const contractType = bidding.biddingType || ''; + + // P/R번호 - bidding 테이블에 없으므로 빈 값 + const prNumber = ''; + + // 예산 + const budget = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : ''; + + // 내정가 + const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : ''; + + // 입찰요청 시스템 + const requestSystem = 'eVCP'; + + // 입찰담당자 + const biddingManager = bidding.bidPicName || bidding.supplyPicName || ''; + + // 내정가 산정 기준 - bidding 테이블에 없으므로 빈 값 + const targetPriceBasis = ''; + + // 입찰 개요 + const biddingOverview = bidding.itemName || message || ''; + + // 입찰 공고문 + const biddingNotice = message || ''; + + // 입찰담당자 (중복이지만 템플릿에 맞춤) + const biddingManagerDup = bidding.bidPicName || bidding.supplyPicName || ''; + + // 협력사 정보들 + const vendorVariables: Record<string, string> = {}; + vendors.forEach((vendor, index) => { + const num = index + 1; + vendorVariables[`협력사_코드_${num}`] = vendor.vendorCode || ''; + vendorVariables[`협력사명_${num}`] = vendor.vendorName || ''; + vendorVariables[`담당자_${num}`] = vendor.contactPerson || ''; + vendorVariables[`이메일_${num}`] = vendor.contactEmail || vendor.vendorEmail || ''; + vendorVariables[`전화번호_${num}`] = ''; // 연락처 정보가 없으므로 빈 값 + }); + + // 사양설명회 정보 + const hasSpecMeeting = bidding.hasSpecificationMeeting ? '예' : '아니오'; + const specMeetingStart = bidding.submissionStartDate ? bidding.submissionStartDate.toLocaleString('ko-KR') : ''; + const specMeetingEnd = bidding.submissionEndDate ? bidding.submissionEndDate.toLocaleString('ko-KR') : ''; + const specMeetingStartDup = specMeetingStart; + const specMeetingEndDup = specMeetingEnd; + + // 입찰서제출기간 정보 + const submissionPeriodExecution = '예'; // 입찰 기간이 있으므로 예 + const submissionPeriodStart = bidding.submissionStartDate ? bidding.submissionStartDate.toLocaleString('ko-KR') : ''; + const submissionPeriodEnd = bidding.submissionEndDate ? bidding.submissionEndDate.toLocaleString('ko-KR') : ''; + + // 대상 자재 수 + const targetMaterialCount = biddingItems.length.toString(); + + // 자재 정보들 + const materialVariables: Record<string, string> = {}; + biddingItems.forEach((item, index) => { + const num = index + 1; + materialVariables[`프로젝트_${num}`] = item.projectName || ''; + materialVariables[`자재그룹_${num}`] = item.materialGroup || ''; + materialVariables[`자재그룹명_${num}`] = item.materialGroupName || ''; + materialVariables[`자재코드_${num}`] = item.materialCode || ''; + materialVariables[`자재코드명_${num}`] = item.materialCodeName || ''; + materialVariables[`수량_${num}`] = item.quantity ? item.quantity.toLocaleString() : ''; + materialVariables[`구매단위_${num}`] = item.purchasingUnit || ''; + materialVariables[`내정단가_${num}`] = item.targetUnitPrice ? item.targetUnitPrice.toLocaleString() : ''; + materialVariables[`수량단위_${num}`] = item.quantityUnit || ''; + materialVariables[`총중량_${num}`] = item.totalWeight ? item.totalWeight.toLocaleString() : ''; + materialVariables[`중량단위_${num}`] = item.weightUnit || ''; + materialVariables[`예산_${num}`] = item.budget ? item.budget.toLocaleString() : ''; + materialVariables[`내정금액_${num}`] = item.targetAmount ? item.targetAmount.toLocaleString() : ''; + materialVariables[`통화_${num}`] = item.currency || ''; + }); + + return { + 제목: title, + 입찰명: biddingTitle, + 입찰번호: biddingNumber, + 낙찰업체수: winnerCount, + 계약구분: contractType, + 'P/R번호': prNumber, + 예산: budget, + 내정가: targetPrice, + 입찰요청_시스템: requestSystem, + 입찰담당자: biddingManager, + 내정가_산정_기준: targetPriceBasis, + 입찰개요: biddingOverview, + 입찰공고문: biddingNotice, + ...vendorVariables, + 사양설명회_실행여부: hasSpecMeeting, + 사양설명회_시작예정일시: specMeetingStart, + 사양설명회_종료예정일시: specMeetingEnd, + 입찰서제출기간_실행여부: submissionPeriodExecution, + 입찰서제출기간_시작예정일시: submissionPeriodStart, + 입찰서제출기간_종료예정일시: submissionPeriodEnd, + 대상_자재_수: targetMaterialCount, + ...materialVariables, + }; +} diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx index 92b2fe42..48c32302 100644 --- a/lib/bidding/list/biddings-table-columns.tsx +++ b/lib/bidding/list/biddings-table-columns.tsx @@ -96,11 +96,11 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef cell: ({ row }) => ( <div className="font-mono text-sm"> {row.original.biddingNumber} - {row.original.revision > 0 && ( + {/* {row.original.revision > 0 && ( <span className="ml-1 text-xs text-muted-foreground"> Rev.{row.original.revision} </span> - )} + )} */} </div> ), size: 120, @@ -137,16 +137,15 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />, cell: ({ row }) => ( <div className="truncate max-w-[200px]" title={row.original.title}> - {/* <Button + <Button variant="link" - className="p-0 h-auto text-left justify-start font-bold underline" + className="p-0 h-auto font-bold underline" onClick={() => setRowAction({ row, type: "view" })} > <div className="whitespace-pre-line"> {row.original.title} </div> - </Button> */} - {row.original.title} + </Button> </div> ), size: 200, diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index cbeeb24a..68ae016e 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -3177,22 +3177,28 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u } } - // 2. 입찰번호 파싱 및 차수 증가 - const currentBiddingNumber = existingBidding.biddingNumber - - // 현재 입찰번호에서 차수 추출 (예: E00025-02 -> 02) - const match = currentBiddingNumber.match(/-(\d+)$/) - let currentRound = match ? parseInt(match[1]) : 1 - + // 2. 입찰번호 생성 (타입에 따라 다르게 처리) let newBiddingNumber: string - if (currentRound >= 3) { - // -03 이상이면 새로운 번호 생성 + if (type === 'rebidding') { + // 재입찰: 완전히 새로운 입찰번호 생성 newBiddingNumber = await generateBiddingNumber(existingBidding.contractType, userId, tx) } else { - // -02까지는 차수만 증가 - const baseNumber = currentBiddingNumber.split('-')[0] - newBiddingNumber = `${baseNumber}-${String(currentRound + 1).padStart(2, '0')}` + // 차수증가: 기존 입찰번호에서 차수 증가 + const currentBiddingNumber = existingBidding.biddingNumber + + // 현재 입찰번호에서 차수 추출 (예: E00025-02 -> 02) + const match = currentBiddingNumber.match(/-(\d+)$/) + let currentRound = match ? parseInt(match[1]) : 1 + + if (currentRound >= 3) { + // -03 이상이면 새로운 번호 생성 + newBiddingNumber = await generateBiddingNumber(existingBidding.contractType, userId, tx) + } else { + // -02까지는 차수만 증가 + const baseNumber = currentBiddingNumber.split('-')[0] + newBiddingNumber = `${baseNumber}-${String(currentRound + 1).padStart(2, '0')}` + } } // 3. 새로운 입찰 생성 (기존 정보 복제) @@ -3200,7 +3206,7 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u .insert(biddings) .values({ biddingNumber: newBiddingNumber, - originalBiddingNumber: null, // 원입찰번호는 단순 정보이므로 null + originalBiddingNumber: existingBidding.biddingNumber, // 원입찰번호 설정 revision: 0, biddingSourceType: existingBidding.biddingSourceType, @@ -3419,26 +3425,36 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u }) } } - // 8. 입찰공고문 정보 복제 (있는 경우) - if (existingBidding.hasBiddingNotice) { - const [existingNotice] = await tx - .select() - .from(biddingNoticeTemplate) - .where(eq(biddingNoticeTemplate.biddingId, biddingId)) - .limit(1) - if (existingNotice) { - await tx - .insert(biddingNoticeTemplate) - .values({ - biddingId: newBidding.id, - title: existingNotice.title, - content: existingNotice.content, - }) - } + // 9. 기존 입찰 상태 변경 (타입에 따라 다르게 설정) + await tx + .update(biddings) + .set({ + status: type === 'round_increase' ? 'round_increase' : 'rebidding', + updatedBy: userName, + updatedAt: new Date(), + }) + .where(eq(biddings.id, biddingId)) + + // 10. 입찰공고문 정보 복제 (있는 경우) + const [existingNotice] = await tx + .select() + .from(biddingNoticeTemplate) + .where(eq(biddingNoticeTemplate.biddingId, biddingId)) + .limit(1) + + if (existingNotice) { + await tx + .insert(biddingNoticeTemplate) + .values({ + biddingId: newBidding.id, + title: existingNotice.title, + content: existingNotice.content, + }) } revalidatePath('/bidding') + revalidatePath(`/bidding/${biddingId}`) // 기존 입찰 페이지도 갱신 revalidatePath(`/bidding/${newBidding.id}`) return { 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/rfq-last/shared/rfq-items-dialog.tsx b/lib/rfq-last/shared/rfq-items-dialog.tsx index f3095c98..e4f71e79 100644 --- a/lib/rfq-last/shared/rfq-items-dialog.tsx +++ b/lib/rfq-last/shared/rfq-items-dialog.tsx @@ -328,11 +328,11 @@ export function RfqItemsDialog({ <TableCell> <div className="flex flex-col items-center gap-1"> <span className="text-xs font-mono">#{index + 1}</span> - {item.majorYn && ( + {/* {item.majorYn && ( <Badge variant="default" className="text-xs px-1 py-0"> 주요 </Badge> - )} + )} */} </div> </TableCell> <TableCell> diff --git a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx index abd2b516..8c70b8dd 100644 --- a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx +++ b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx @@ -322,7 +322,7 @@ export default function VendorResponseEditor({ if (errors.quotationItems) { toast.error("견적 품목 정보를 확인해주세요. 모든 품목의 단가와 총액을 입력해야 합니다.") } else { - toast.error("입력 정보를 확인해주세요.") + toast.error("기본계약 또는 상업조건 정보를 확인해주세요.") } } } diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index 2ee2cb73..577ae492 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -1539,10 +1539,10 @@ export function RfqVendorTable({ )} {/* 기본계약 수정 메뉴 추가 */} - <DropdownMenuItem onClick={() => handleAction("edit-contract", vendor)}> + {/* <DropdownMenuItem onClick={() => handleAction("edit-contract", vendor)}> <FileText className="mr-2 h-4 w-4" /> 기본계약 수정 - </DropdownMenuItem> + </DropdownMenuItem> */} {emailSentAt && ( <> @@ -1824,9 +1824,6 @@ export function RfqVendorTable({ <Plus className="h-4 w-4 mr-2" /> 벤더 추가 </Button> - - {selectedRows.length > 0 && ( - <> {/* 정보 일괄 입력 버튼 - 취소되지 않은 벤더만 */} <Button variant="outline" @@ -1837,7 +1834,8 @@ export function RfqVendorTable({ <Settings2 className="h-4 w-4 mr-2" /> 협력업체 조건 설정 ({nonCancelledRows.length}) </Button> - + {selectedRows.length > 0 && ( + <> {/* RFQ 발송 버튼 - 취소되지 않은 벤더만 */} <Button variant="outline" 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 => ({
diff --git a/lib/vendor-regular-registrations/repository.ts b/lib/vendor-regular-registrations/repository.ts index 314afb6c..3713f628 100644 --- a/lib/vendor-regular-registrations/repository.ts +++ b/lib/vendor-regular-registrations/repository.ts @@ -1,307 +1,307 @@ -import db from "@/db/db";
-import {
- vendorRegularRegistrations,
- vendors,
- vendorAttachments,
- vendorInvestigationAttachments,
- basicContract,
- basicContractTemplates,
- vendorPQSubmissions,
- vendorInvestigations,
- vendorBusinessContacts,
- vendorAdditionalInfo,
-} from "@/db/schema";
-import { eq, desc, and, sql, inArray } from "drizzle-orm";
-import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig";
-
-export async function getVendorRegularRegistrations(
-): Promise<VendorRegularRegistration[]> {
- try {
- // DB 레코드 기준으로 정규업체등록 데이터를 가져옴
- const registrations = await db
- .select({
- // 정규업체등록 정보
- id: vendorRegularRegistrations.id,
- vendorId: vendorRegularRegistrations.vendorId,
- status: vendorRegularRegistrations.status,
- potentialCode: vendorRegularRegistrations.potentialCode,
- majorItems: vendorRegularRegistrations.majorItems,
- registrationRequestDate: vendorRegularRegistrations.registrationRequestDate,
- assignedDepartment: vendorRegularRegistrations.assignedDepartment,
- assignedUser: vendorRegularRegistrations.assignedUser,
- remarks: vendorRegularRegistrations.remarks,
- // 새로 추가된 필드들
- safetyQualificationContent: vendorRegularRegistrations.safetyQualificationContent,
- gtcSkipped: vendorRegularRegistrations.gtcSkipped,
- // 벤더 기본 정보
- businessNumber: vendors.taxId,
- companyName: vendors.vendorName,
- establishmentDate: vendors.createdAt,
- representative: vendors.representativeName,
- // 국가 정보 추가
- country: vendors.country,
- })
- .from(vendorRegularRegistrations)
- .innerJoin(vendors, eq(vendorRegularRegistrations.vendorId, vendors.id))
- .orderBy(desc(vendorRegularRegistrations.createdAt));
-
- // 벤더 ID 배열 생성
- const vendorIds = registrations.map(r => r.vendorId);
-
- // 벤더 첨부파일 정보 조회 - 벤더별로 그룹화
- const vendorAttachmentsList = vendorIds.length > 0 ? await db
- .select()
- .from(vendorAttachments)
- .where(inArray(vendorAttachments.vendorId, vendorIds)) : [];
-
- // 실사 첨부파일 정보 조회 - 실사 ID를 통해 벤더 ID 매핑
- const investigationAttachmentsList = vendorIds.length > 0 ? await db
- .select({
- vendorId: vendorInvestigations.vendorId,
- attachmentId: vendorInvestigationAttachments.id,
- fileName: vendorInvestigationAttachments.fileName,
- attachmentType: vendorInvestigationAttachments.attachmentType,
- createdAt: vendorInvestigationAttachments.createdAt,
- })
- .from(vendorInvestigationAttachments)
- .innerJoin(vendorInvestigations, eq(vendorInvestigationAttachments.investigationId, vendorInvestigations.id))
- .where(inArray(vendorInvestigations.vendorId, vendorIds)) : [];
-
- // 기본 계약 정보 조회 (템플릿별로 가장 최신 것만)
- const basicContractsList = vendorIds.length > 0 ? await db
- .select({
- vendorId: basicContract.vendorId,
- templateId: basicContract.templateId,
- status: basicContract.status,
- templateName: basicContractTemplates.templateName,
- createdAt: basicContract.createdAt,
- filePath: basicContract.filePath,
- fileName: basicContract.fileName,
- })
- .from(basicContract)
- .leftJoin(basicContractTemplates, eq(basicContract.templateId, basicContractTemplates.id))
- .where(inArray(basicContract.vendorId, vendorIds))
- .orderBy(desc(basicContract.createdAt)) : [];
-
- // 추가정보 입력 상태 조회 (업무담당자 정보)
- const businessContactsList = vendorIds.length > 0 ? await db
- .select({
- vendorId: vendorBusinessContacts.vendorId,
- contactType: vendorBusinessContacts.contactType,
- })
- .from(vendorBusinessContacts)
- .where(inArray(vendorBusinessContacts.vendorId, vendorIds)) : [];
-
- // 추가정보 테이블 조회
- const additionalInfoList = vendorIds.length > 0 ? await db
- .select({
- vendorId: vendorAdditionalInfo.vendorId,
- })
- .from(vendorAdditionalInfo)
- .where(inArray(vendorAdditionalInfo.vendorId, vendorIds)) : [];
-
- // 각 등록 레코드별로 데이터를 매핑하여 결과 반환
- return registrations.map((registration) => {
- // 벤더별 첨부파일 필터링
- const vendorFiles = vendorAttachmentsList.filter(att => att.vendorId === registration.vendorId);
- const investigationFiles = investigationAttachmentsList.filter(att => att.vendorId === registration.vendorId);
- const allVendorContracts = basicContractsList.filter(contract => contract.vendorId === registration.vendorId);
- const vendorContacts = businessContactsList.filter(contact => contact.vendorId === registration.vendorId);
- const vendorAdditionalInfoData = additionalInfoList.filter(info => info.vendorId === registration.vendorId);
-
- // 기술자료 동의서, 비밀유지계약서 제외 필터링
- const filteredContracts = allVendorContracts.filter(contract => {
- const templateName = contract.templateName?.toLowerCase() || '';
- return !templateName.includes('기술자료') && !templateName.includes('비밀유지');
- });
-
- // 템플릿명 기준으로 가장 최신 계약만 유지 (중복 제거)
- const vendorContracts = filteredContracts.reduce((acc, contract) => {
- const existing = acc.find(c => c.templateName === contract.templateName);
- if (!existing || (contract.createdAt && existing.createdAt && contract.createdAt > existing.createdAt)) {
- // 기존에 같은 템플릿명이 없거나, 더 최신인 경우 추가/교체
- return acc.filter(c => c.templateName !== contract.templateName).concat(contract);
- }
- return acc;
- }, [] as typeof filteredContracts);
-
- // 문서 제출 현황 - 국가별 요구사항 적용
- const isForeign = registration.country !== 'KR';
- const documentSubmissionsStatus = {
- businessRegistration: vendorFiles.some(f => f.attachmentType === "BUSINESS_REGISTRATION"),
- creditEvaluation: vendorFiles.some(f => f.attachmentType === "CREDIT_REPORT"),
- bankCopy: isForeign ? vendorFiles.some(f => f.attachmentType === "BANK_ACCOUNT_COPY") : true, // 내자는 통장사본 불필요
- auditResult: investigationFiles.length > 0, // 실사 첨부파일이 하나라도 있으면 true
- };
-
- // 문서별 파일 정보 (다운로드용)
- const documentFiles = {
- businessRegistration: vendorFiles.filter(f => f.attachmentType === "BUSINESS_REGISTRATION"),
- creditEvaluation: vendorFiles.filter(f => f.attachmentType === "CREDIT_REPORT"),
- bankCopy: vendorFiles.filter(f => f.attachmentType === "BANK_ACCOUNT_COPY"),
- auditResult: investigationFiles,
- };
-
- // 계약 동의 현황 - 실제 기본 계약 데이터 기반으로 단순화
- const contractAgreementsStatus = {
- cp: vendorContracts.some(c => c.status === "COMPLETED") ? "completed" : "not_submitted",
- gtc: registration.gtcSkipped ? "completed" : (vendorContracts.some(c => c.templateName?.includes("GTC") && c.status === "COMPLETED") ? "completed" : "not_submitted"),
- standardSubcontract: vendorContracts.some(c => c.templateName?.includes("표준하도급") && c.status === "COMPLETED") ? "completed" : "not_submitted",
- safetyHealth: vendorContracts.some(c => c.templateName?.includes("안전보건") && c.status === "COMPLETED") ? "completed" : "not_submitted",
- ethics: vendorContracts.some(c => c.templateName?.includes("윤리") && c.status === "COMPLETED") ? "completed" : "not_submitted",
- domesticCredit: vendorContracts.some(c => c.templateName?.includes("내국신용장") && c.status === "COMPLETED") ? "completed" : "not_submitted",
- };
-
- // 추가정보 입력 완료 여부 - 5개 필수 업무담당자 타입 + 추가정보 테이블 모두 입력되었는지 확인
- const requiredContactTypes = ["sales", "design", "delivery", "quality", "tax_invoice"];
- const contactsCompleted = requiredContactTypes.every(type =>
- vendorContacts.some(contact => contact.contactType === type)
- );
- const additionalInfoTableCompleted = vendorAdditionalInfoData.length > 0;
- const additionalInfoCompleted = contactsCompleted && additionalInfoTableCompleted;
-
- // 모든 조건 충족 여부 확인
- const allDocumentsSubmitted = Object.values(documentSubmissionsStatus).every(status => status === true);
- // 진행현황과 dialog에서 VENDOR_SIGNED도 완료로 간주하므로, 조건충족 체크도 동일하게 처리
- const allContractsCompleted = vendorContracts.length > 0 && vendorContracts.every(c => c.status === "COMPLETED" || c.status === "VENDOR_SIGNED");
- const safetyQualificationCompleted = !!registration.safetyQualificationContent;
-
- // 모든 조건이 충족되면 status를 "approval_ready"(조건충족)로 자동 변경
- const shouldUpdateStatus = allDocumentsSubmitted && allContractsCompleted && safetyQualificationCompleted && additionalInfoCompleted;
-
- // 현재 상태가 조건충족이 아닌데 모든 조건이 충족되면 상태 업데이트
- // 단, 결재진행중(pending_approval) 또는 등록요청완료(registration_completed), 등록실패(registration_failed) 상태인 경우 무시
- if (shouldUpdateStatus && registration.status !== "approval_ready" && registration.status !== "pending_approval" && registration.status !== "registration_completed" && registration.status !== "registration_failed") {
- // 비동기 업데이트 (백그라운드에서 실행)
- updateVendorRegularRegistration(registration.id, {
- status: "approval_ready"
- }).catch(error => {
- console.error(`상태 자동 업데이트 실패 (벤더 ID: ${registration.vendorId}):`, error);
- });
- }
-
- return {
- id: registration.id,
- vendorId: registration.vendorId,
- status: registration.status || "audit_pass",
- potentialCode: registration.potentialCode,
- businessNumber: registration.businessNumber || "",
- companyName: registration.companyName || "",
- majorItems: registration.majorItems,
- establishmentDate: registration.establishmentDate?.toISOString() || null,
- representative: registration.representative,
- country: registration.country,
- documentSubmissions: documentSubmissionsStatus,
- documentFiles: documentFiles, // 파일 정보 추가
- contractAgreements: contractAgreementsStatus,
- // 새로 추가된 필드들
- safetyQualificationContent: registration.safetyQualificationContent,
- gtcSkipped: registration.gtcSkipped || false,
- additionalInfo: additionalInfoCompleted,
- // 기본계약 정보
- basicContracts: vendorContracts.map((contract: any) => ({
- templateId: contract.templateId,
- templateName: contract.templateName,
- status: contract.status,
- createdAt: contract.createdAt,
- filePath: contract.filePath,
- fileName: contract.fileName,
- })),
- registrationRequestDate: registration.registrationRequestDate || null,
- assignedDepartment: registration.assignedDepartment,
- assignedUser: registration.assignedUser,
- remarks: registration.remarks,
- };
- });
- } catch (error) {
- console.error("Error fetching vendor regular registrations:", error);
- throw new Error("정규업체 등록 목록을 가져오는 중 오류가 발생했습니다.");
- }
-}
-
-export async function createVendorRegularRegistration(data: {
- vendorId: number;
- status?: string;
- potentialCode?: string;
- majorItems?: string;
- assignedDepartment?: string;
- assignedDepartmentCode?: string;
- assignedUser?: string;
- assignedUserCode?: string;
- remarks?: string;
- safetyQualificationContent?: string;
- gtcSkipped?: boolean;
-}) {
- try {
- const [registration] = await db
- .insert(vendorRegularRegistrations)
- .values({
- vendorId: data.vendorId,
- status: data.status || "under_review",
- potentialCode: data.potentialCode,
- majorItems: data.majorItems,
- assignedDepartment: data.assignedDepartment,
- assignedDepartmentCode: data.assignedDepartmentCode,
- assignedUser: data.assignedUser,
- assignedUserCode: data.assignedUserCode,
- remarks: data.remarks,
- safetyQualificationContent: data.safetyQualificationContent,
- gtcSkipped: data.gtcSkipped || false,
- })
- .returning();
-
- return registration;
- } catch (error) {
- console.error("Error creating vendor regular registration:", error);
- throw new Error("정규업체 등록을 생성하는 중 오류가 발생했습니다.");
- }
-}
-
-export async function updateVendorRegularRegistration(
- id: number,
- data: Partial<{
- status: string;
- potentialCode: string;
- majorItems: string;
- registrationRequestDate: string;
- assignedDepartment: string;
- assignedDepartmentCode: string;
- assignedUser: string;
- assignedUserCode: string;
- remarks: string;
- safetyQualificationContent: string;
- gtcSkipped: boolean;
- }>
-) {
- try {
- const [registration] = await db
- .update(vendorRegularRegistrations)
- .set({
- ...data,
- updatedAt: new Date(),
- })
- .where(eq(vendorRegularRegistrations.id, id))
- .returning();
-
- return registration;
- } catch (error) {
- console.error("Error updating vendor regular registration:", error);
- throw new Error("정규업체 등록을 업데이트하는 중 오류가 발생했습니다.");
- }
-}
-
-export async function getVendorRegularRegistrationById(id: number) {
- try {
- const [registration] = await db
- .select()
- .from(vendorRegularRegistrations)
- .where(eq(vendorRegularRegistrations.id, id));
-
- return registration;
- } catch (error) {
- console.error("Error fetching vendor regular registration by id:", error);
- throw new Error("정규업체 등록 정보를 가져오는 중 오류가 발생했습니다.");
- }
-}
-
-
+import db from "@/db/db"; +import { + vendorRegularRegistrations, + vendors, + vendorAttachments, + vendorInvestigationAttachments, + basicContract, + basicContractTemplates, + vendorPQSubmissions, + vendorInvestigations, + vendorBusinessContacts, + vendorAdditionalInfo, +} from "@/db/schema"; +import { eq, desc, and, sql, inArray } from "drizzle-orm"; +import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig"; + +export async function getVendorRegularRegistrations( +): Promise<VendorRegularRegistration[]> { + try { + // DB 레코드 기준으로 정규업체등록 데이터를 가져옴 + const registrations = await db + .select({ + // 정규업체등록 정보 + id: vendorRegularRegistrations.id, + vendorId: vendorRegularRegistrations.vendorId, + status: vendorRegularRegistrations.status, + potentialCode: vendorRegularRegistrations.potentialCode, + majorItems: vendorRegularRegistrations.majorItems, + registrationRequestDate: vendorRegularRegistrations.registrationRequestDate, + assignedDepartment: vendorRegularRegistrations.assignedDepartment, + assignedUser: vendorRegularRegistrations.assignedUser, + remarks: vendorRegularRegistrations.remarks, + // 새로 추가된 필드들 + safetyQualificationContent: vendorRegularRegistrations.safetyQualificationContent, + gtcSkipped: vendorRegularRegistrations.gtcSkipped, + // 벤더 기본 정보 + businessNumber: vendors.taxId, + companyName: vendors.vendorName, + establishmentDate: vendors.createdAt, + representative: vendors.representativeName, + // 국가 정보 추가 + country: vendors.country, + }) + .from(vendorRegularRegistrations) + .innerJoin(vendors, eq(vendorRegularRegistrations.vendorId, vendors.id)) + .orderBy(desc(vendorRegularRegistrations.createdAt)); + + // 벤더 ID 배열 생성 + const vendorIds = registrations.map(r => r.vendorId); + + // 벤더 첨부파일 정보 조회 - 벤더별로 그룹화 + const vendorAttachmentsList = vendorIds.length > 0 ? await db + .select() + .from(vendorAttachments) + .where(inArray(vendorAttachments.vendorId, vendorIds)) : []; + + // 실사 첨부파일 정보 조회 - 실사 ID를 통해 벤더 ID 매핑 + const investigationAttachmentsList = vendorIds.length > 0 ? await db + .select({ + vendorId: vendorInvestigations.vendorId, + attachmentId: vendorInvestigationAttachments.id, + fileName: vendorInvestigationAttachments.fileName, + attachmentType: vendorInvestigationAttachments.attachmentType, + createdAt: vendorInvestigationAttachments.createdAt, + }) + .from(vendorInvestigationAttachments) + .innerJoin(vendorInvestigations, eq(vendorInvestigationAttachments.investigationId, vendorInvestigations.id)) + .where(inArray(vendorInvestigations.vendorId, vendorIds)) : []; + + // 기본 계약 정보 조회 (템플릿별로 가장 최신 것만) + const basicContractsList = vendorIds.length > 0 ? await db + .select({ + vendorId: basicContract.vendorId, + templateId: basicContract.templateId, + status: basicContract.status, + templateName: basicContractTemplates.templateName, + createdAt: basicContract.createdAt, + filePath: basicContract.filePath, + fileName: basicContract.fileName, + }) + .from(basicContract) + .leftJoin(basicContractTemplates, eq(basicContract.templateId, basicContractTemplates.id)) + .where(inArray(basicContract.vendorId, vendorIds)) + .orderBy(desc(basicContract.createdAt)) : []; + + // 추가정보 입력 상태 조회 (업무담당자 정보) + const businessContactsList = vendorIds.length > 0 ? await db + .select({ + vendorId: vendorBusinessContacts.vendorId, + contactType: vendorBusinessContacts.contactType, + }) + .from(vendorBusinessContacts) + .where(inArray(vendorBusinessContacts.vendorId, vendorIds)) : []; + + // 추가정보 테이블 조회 + const additionalInfoList = vendorIds.length > 0 ? await db + .select({ + vendorId: vendorAdditionalInfo.vendorId, + }) + .from(vendorAdditionalInfo) + .where(inArray(vendorAdditionalInfo.vendorId, vendorIds)) : []; + + // 각 등록 레코드별로 데이터를 매핑하여 결과 반환 + return registrations.map((registration) => { + // 벤더별 첨부파일 필터링 + const vendorFiles = vendorAttachmentsList.filter(att => att.vendorId === registration.vendorId); + const investigationFiles = investigationAttachmentsList.filter(att => att.vendorId === registration.vendorId); + const allVendorContracts = basicContractsList.filter(contract => contract.vendorId === registration.vendorId); + const vendorContacts = businessContactsList.filter(contact => contact.vendorId === registration.vendorId); + const vendorAdditionalInfoData = additionalInfoList.filter(info => info.vendorId === registration.vendorId); + + // 기술자료 동의서, 비밀유지계약서 제외 필터링 + const filteredContracts = allVendorContracts.filter(contract => { + const templateName = contract.templateName?.toLowerCase() || ''; + return !templateName.includes('기술자료') && !templateName.includes('비밀유지'); + }); + + // 템플릿명 기준으로 가장 최신 계약만 유지 (중복 제거) + const vendorContracts = filteredContracts.reduce((acc, contract) => { + const existing = acc.find(c => c.templateName === contract.templateName); + if (!existing || (contract.createdAt && existing.createdAt && contract.createdAt > existing.createdAt)) { + // 기존에 같은 템플릿명이 없거나, 더 최신인 경우 추가/교체 + return acc.filter(c => c.templateName !== contract.templateName).concat(contract); + } + return acc; + }, [] as typeof filteredContracts); + + // 문서 제출 현황 - 국가별 요구사항 적용 + const isForeign = registration.country !== 'KR'; + const documentSubmissionsStatus = { + businessRegistration: vendorFiles.some(f => f.attachmentType === "BUSINESS_REGISTRATION"), + creditEvaluation: vendorFiles.some(f => f.attachmentType === "CREDIT_REPORT"), + bankCopy: isForeign ? vendorFiles.some(f => f.attachmentType === "BANK_ACCOUNT_COPY") : true, // 내자는 통장사본 불필요 + auditResult: investigationFiles.length > 0, // 실사 첨부파일이 하나라도 있으면 true + }; + + // 문서별 파일 정보 (다운로드용) + const documentFiles = { + businessRegistration: vendorFiles.filter(f => f.attachmentType === "BUSINESS_REGISTRATION"), + creditEvaluation: vendorFiles.filter(f => f.attachmentType === "CREDIT_REPORT"), + bankCopy: vendorFiles.filter(f => f.attachmentType === "BANK_ACCOUNT_COPY"), + auditResult: investigationFiles, + }; + + // 계약 동의 현황 - 실제 기본 계약 데이터 기반으로 단순화 + const contractAgreementsStatus = { + cp: vendorContracts.some(c => c.status === "COMPLETED") ? "completed" : "not_submitted", + gtc: registration.gtcSkipped ? "completed" : (vendorContracts.some(c => c.templateName?.includes("GTC") && c.status === "COMPLETED") ? "completed" : "not_submitted"), + standardSubcontract: vendorContracts.some(c => c.templateName?.includes("표준하도급") && c.status === "COMPLETED") ? "completed" : "not_submitted", + safetyHealth: vendorContracts.some(c => c.templateName?.includes("안전보건") && c.status === "COMPLETED") ? "completed" : "not_submitted", + ethics: vendorContracts.some(c => c.templateName?.includes("윤리") && c.status === "COMPLETED") ? "completed" : "not_submitted", + domesticCredit: vendorContracts.some(c => c.templateName?.includes("내국신용장") && c.status === "COMPLETED") ? "completed" : "not_submitted", + }; + + // 추가정보 입력 완료 여부 - 5개 필수 업무담당자 타입 + 추가정보 테이블 모두 입력되었는지 확인 + const requiredContactTypes = ["sales", "design", "delivery", "quality", "tax_invoice"]; + const contactsCompleted = requiredContactTypes.every(type => + vendorContacts.some(contact => contact.contactType === type) + ); + const additionalInfoTableCompleted = vendorAdditionalInfoData.length > 0; + const additionalInfoCompleted = contactsCompleted && additionalInfoTableCompleted; + + // 모든 조건 충족 여부 확인 + const allDocumentsSubmitted = Object.values(documentSubmissionsStatus).every(status => status === true); + // 진행현황과 dialog에서 VENDOR_SIGNED도 완료로 간주하므로, 조건충족 체크도 동일하게 처리 + const allContractsCompleted = vendorContracts.length > 0 && vendorContracts.every(c => c.status === "COMPLETED" || c.status === "VENDOR_SIGNED"); + const safetyQualificationCompleted = !!registration.safetyQualificationContent; + + // 모든 조건이 충족되면 status를 "approval_ready"(조건충족)로 자동 변경 + const shouldUpdateStatus = allDocumentsSubmitted && allContractsCompleted && safetyQualificationCompleted && additionalInfoCompleted; + + // 현재 상태가 조건충족이 아닌데 모든 조건이 충족되면 상태 업데이트 + // 단, 결재진행중(pending_approval) 또는 등록요청완료(registration_completed), 등록실패(registration_failed) 상태인 경우 무시 + if (shouldUpdateStatus && registration.status !== "approval_ready" && registration.status !== "pending_approval" && registration.status !== "registration_completed" && registration.status !== "registration_failed") { + // 비동기 업데이트 (백그라운드에서 실행) + updateVendorRegularRegistration(registration.id, { + status: "approval_ready" + }).catch(error => { + console.error(`상태 자동 업데이트 실패 (벤더 ID: ${registration.vendorId}):`, error); + }); + } + + return { + id: registration.id, + vendorId: registration.vendorId, + status: registration.status || "audit_pass", + potentialCode: registration.potentialCode, + businessNumber: registration.businessNumber || "", + companyName: registration.companyName || "", + majorItems: registration.majorItems, + establishmentDate: registration.establishmentDate?.toISOString() || null, + representative: registration.representative, + country: registration.country, + documentSubmissions: documentSubmissionsStatus, + documentFiles: documentFiles, // 파일 정보 추가 + contractAgreements: contractAgreementsStatus, + // 새로 추가된 필드들 + safetyQualificationContent: registration.safetyQualificationContent, + gtcSkipped: registration.gtcSkipped || false, + additionalInfo: additionalInfoCompleted, + // 기본계약 정보 + basicContracts: vendorContracts.map((contract: any) => ({ + templateId: contract.templateId, + templateName: contract.templateName, + status: contract.status, + createdAt: contract.createdAt, + filePath: contract.filePath, + fileName: contract.fileName, + })), + registrationRequestDate: registration.registrationRequestDate || null, + assignedDepartment: registration.assignedDepartment, + assignedUser: registration.assignedUser, + remarks: registration.remarks, + }; + }); + } catch (error) { + console.error("Error fetching vendor regular registrations:", error); + throw new Error("정규업체 등록 목록을 가져오는 중 오류가 발생했습니다."); + } +} + +export async function createVendorRegularRegistration(data: { + vendorId: number; + status?: string; + potentialCode?: string; + majorItems?: string; + assignedDepartment?: string; + assignedDepartmentCode?: string; + assignedUser?: string; + assignedUserCode?: string; + remarks?: string; + safetyQualificationContent?: string; + gtcSkipped?: boolean; +}) { + try { + const [registration] = await db + .insert(vendorRegularRegistrations) + .values({ + vendorId: data.vendorId, + status: data.status || "under_review", + potentialCode: data.potentialCode, + majorItems: data.majorItems, + assignedDepartment: data.assignedDepartment, + assignedDepartmentCode: data.assignedDepartmentCode, + assignedUser: data.assignedUser, + assignedUserCode: data.assignedUserCode, + remarks: data.remarks, + safetyQualificationContent: data.safetyQualificationContent, + gtcSkipped: data.gtcSkipped || false, + }) + .returning(); + + return registration; + } catch (error) { + console.error("Error creating vendor regular registration:", error); + throw new Error("정규업체 등록을 생성하는 중 오류가 발생했습니다."); + } +} + +export async function updateVendorRegularRegistration( + id: number, + data: Partial<{ + status: string; + potentialCode: string; + majorItems: string; + registrationRequestDate: string; + assignedDepartment: string; + assignedDepartmentCode: string; + assignedUser: string; + assignedUserCode: string; + remarks: string; + safetyQualificationContent: string; + gtcSkipped: boolean; + }> +) { + try { + const [registration] = await db + .update(vendorRegularRegistrations) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(eq(vendorRegularRegistrations.id, id)) + .returning(); + + return registration; + } catch (error) { + console.error("Error updating vendor regular registration:", error); + throw new Error("정규업체 등록을 업데이트하는 중 오류가 발생했습니다."); + } +} + +export async function getVendorRegularRegistrationById(id: number) { + try { + const [registration] = await db + .select() + .from(vendorRegularRegistrations) + .where(eq(vendorRegularRegistrations.id, id)); + + return registration; + } catch (error) { + console.error("Error fetching vendor regular registration by id:", error); + throw new Error("정규업체 등록 정보를 가져오는 중 오류가 발생했습니다."); + } +} + + |
