From e484964b1d78cedabbe182c789a8e4c9b53e29d3 Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Thu, 29 May 2025 05:12:36 +0000
Subject: (김준회) 기술영업 조선 RFQ 파일첨부 및 채팅 기능 구현 / menuConfig
수정 (벤더 기술영업)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../templates/tech-sales-quotation-accepted-ko.hbs | 112 +++
.../templates/tech-sales-quotation-rejected-ko.hbs | 117 +++
.../tech-sales-quotation-submitted-manager-ko.hbs | 125 +++
.../tech-sales-quotation-submitted-vendor-ko.hbs | 122 +++
lib/techsales-rfq/repository.ts | 23 +-
lib/techsales-rfq/service.ts | 1008 +++++++++++++++++++-
lib/techsales-rfq/table/create-rfq-dialog.tsx | 244 ++++-
.../table/detail-table/rfq-detail-column.tsx | 17 +-
.../table/detail-table/rfq-detail-table.tsx | 91 +-
.../table/detail-table/update-vendor-sheet.tsx | 449 ---------
.../detail-table/vendor-communication-drawer.tsx | 22 +-
lib/techsales-rfq/table/rfq-table-column.tsx | 178 ++--
lib/techsales-rfq/table/rfq-table.tsx | 117 ++-
.../table/tech-sales-rfq-attachments-sheet.tsx | 540 +++++++++++
.../vendor-response/buyer-communication-drawer.tsx | 165 +++-
.../vendor-response/detail/communication-tab.tsx | 292 +++---
.../table/vendor-quotations-table-columns.tsx | 55 +-
.../table/vendor-quotations-table.tsx | 72 +-
18 files changed, 2806 insertions(+), 943 deletions(-)
create mode 100644 lib/mail/templates/tech-sales-quotation-accepted-ko.hbs
create mode 100644 lib/mail/templates/tech-sales-quotation-rejected-ko.hbs
create mode 100644 lib/mail/templates/tech-sales-quotation-submitted-manager-ko.hbs
create mode 100644 lib/mail/templates/tech-sales-quotation-submitted-vendor-ko.hbs
delete mode 100644 lib/techsales-rfq/table/detail-table/update-vendor-sheet.tsx
create mode 100644 lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx
(limited to 'lib')
diff --git a/lib/mail/templates/tech-sales-quotation-accepted-ko.hbs b/lib/mail/templates/tech-sales-quotation-accepted-ko.hbs
new file mode 100644
index 00000000..b36b4473
--- /dev/null
+++ b/lib/mail/templates/tech-sales-quotation-accepted-ko.hbs
@@ -0,0 +1,112 @@
+{{> header logoUrl=logoUrl }}
+
+
+ 견적 선택 안내 - RFQ NO. : #{{rfq.code}}
+
+
+
+ 안녕하세요, {{vendor.name}}님.
+
+
+
+ 축하드립니다!
+ 귀하께서 제출하신 견적이 선택되었습니다.
+
+
+
+ 본 견적은 프로젝트 수주 과정에서 참조될 예정입니다. 견적의 선택이 추후 계약을 보장하지는 않는다는 점을 유의해 주시기 바랍니다.
+
+
+
+
가. 선택된 견적서 정보
+
+ 1) RFQ 번호 : {{rfq.code}}
+
+
+ 2) 프로젝트 : {{project.name}}
+
* 프로젝트 코드 : {{rfq.projectCode}}
+ {{#if project.sector}}
+
* 부문 : {{project.sector}}
+ {{/if}}
+ {{#if project.shipCount}}
+
* 척수 : {{project.shipCount}}척
+ {{/if}}
+ {{#if project.ownerName}}
+
* 선주 : {{project.ownerName}}
+ {{/if}}
+ {{#if project.className}}
+
* 선급 : {{project.className}}
+ {{/if}}
+ {{#if project.shipModelName}}
+
* 선형 : {{project.shipModelName}}
+ {{/if}}
+
+
+ {{#if series}}
+
+ * 시리즈별 K/L 일정 (Keel Laying Quarter)
+ {{#each series}}
+
- {{sersNo}}호선: {{klQuarter}}
+ {{/each}}
+
+ {{/if}}
+
+ 3) 자재명 : {{rfq.title}}
+ {{#if rfq.materialCode}}
+
* 자재그룹 코드 : {{rfq.materialCode}}
+ {{/if}}
+
+
+ 4) 선택된 견적 금액 : {{quotation.currency}} {{quotation.totalPrice}}
+
+
+ 5) 견적 유효기간 : {{quotation.validUntil}}
+
+
+ 6) 선택일시 : {{quotation.acceptedAt}}
+
+ {{#if quotation.remark}}
+
+ 7) 견적서 특이사항
+
{{quotation.remark}}
+
+ {{/if}}
+
+
+
+
다. 담당자 연락처
+
+ 기술영업 담당자
+
* 담당자 : {{manager.name}}
+
* 이메일 : {{manager.email}}
+
+
+
+
+
라. 유의사항
+
+ 1) 유의사항
+
본 견적은 프로젝트 수주 과정에서 참조될 예정입니다. 견적의 선택이 추후 계약을 보장하지는 않는다는 점을 유의해 주시기 바랍니다.
+
+
+ 2) 기밀 유지
+
프로젝트 관련 모든 정보는 기밀로 관리해 주시기 바랍니다.
+
+
+ 3) 협력 관계
+
성공적인 프로젝트 완수를 위해 적극적인 협력을 부탁드립니다.
+
+
+
+
+
+ 기술영업에 제출된 견적 목록 확인
+
+
+
+
+ 견적 제출에 감사드리며, 앞으로도 좋은 협력 관계를 기대합니다.
+ {{companyName}} 기술영업팀
+
+
+{{> footer logoUrl=logoUrl companyName=companyName year=year }}
\ No newline at end of file
diff --git a/lib/mail/templates/tech-sales-quotation-rejected-ko.hbs b/lib/mail/templates/tech-sales-quotation-rejected-ko.hbs
new file mode 100644
index 00000000..58a08c7b
--- /dev/null
+++ b/lib/mail/templates/tech-sales-quotation-rejected-ko.hbs
@@ -0,0 +1,117 @@
+{{> header logoUrl=logoUrl }}
+
+
+ 견적 검토 결과 안내 - RFQ NO. : #{{rfq.code}}
+
+
+
+ 안녕하세요, {{vendor.name}}님.
+
+
+
+ 귀하께서 제출해주신 기술영업 견적서에 대한 검토 결과를 안내드립니다.
+
+
+
+ 아쉽게도 이번 건에서는 다른 업체의 견적이 선택되었습니다.
+ 귀중한 시간을 할애하여 기술영업 견적서를 작성해 주신 점에 대해 깊이 감사드립니다.
+ 본 기술영업 견적이 향후 실제 계약을 위한 구매 부서의 견적 요청을 제한하지 않는다는 점을 말씀드립니다.
+
+
+
+
가. 검토 대상 견적서 정보
+
+ 1) RFQ 번호 : {{rfq.code}}
+
+
+ 2) 프로젝트 : {{project.name}}
+
* 프로젝트 코드 : {{rfq.projectCode}}
+ {{#if project.sector}}
+
* 부문 : {{project.sector}}
+ {{/if}}
+ {{#if project.shipCount}}
+
* 척수 : {{project.shipCount}}척
+ {{/if}}
+ {{#if project.ownerName}}
+
* 선주 : {{project.ownerName}}
+ {{/if}}
+ {{#if project.className}}
+
* 선급 : {{project.className}}
+ {{/if}}
+ {{#if project.shipModelName}}
+
* 선형 : {{project.shipModelName}}
+ {{/if}}
+
+
+ {{#if series}}
+
+ * 시리즈별 K/L 일정 (Keel Laying Quarter)
+ {{#each series}}
+
- {{sersNo}}호선: {{klQuarter}}
+ {{/each}}
+
+ {{/if}}
+
+ 3) 자재명 : {{rfq.title}}
+ {{#if rfq.materialCode}}
+
* 자재그룹 코드 : {{rfq.materialCode}}
+ {{/if}}
+
+
+ 4) 제출하신 견적 금액 : {{quotation.currency}} {{quotation.totalPrice}}
+
+
+ 5) 견적 유효기간 : {{quotation.validUntil}}
+
+ {{#if quotation.remark}}
+
+ 6) 제출하신 특이사항
+
{{quotation.remark}}
+
+ {{/if}}
+
+
+
+
나. 검토 결과
+
+ 1) 결과 : 미선정
+
이번 RFQ에서는 다른 업체가 선정되었습니다.
+
+ {{#if quotation.rejectionReason}}
+
+ 2) 참고사항
+
{{quotation.rejectionReason}}
+
+ {{/if}}
+
+ 3) 감사 인사
+
* 귀사의 견적서 응답에 진심으로 감사드립니다.
+
+
+
+
+
라. 담당자 연락처
+
+ 기술영업 담당자
+
* 담당자 : {{manager.name}}
+
* 이메일 : {{manager.email}}
+
+
+ 문의 사항
+
* 견적 관련 피드백이나 향후 협력 방안에 대해 문의하시기 바랍니다.
+
* 새로운 사업 기회나 기술 제휴에 대해서도 언제든 연락 주시기 바랍니다.
+
+
+
+
+
+ 기술영업 수신견적 목록 보기
+
+
+
+
+ 다시 한번 견적서 제출에 감사드리며, 향후 좋은 협력 기회가 있기를 기대합니다.
+ {{companyName}} 기술영업
+
+
+{{> footer logoUrl=logoUrl companyName=companyName year=year }}
\ No newline at end of file
diff --git a/lib/mail/templates/tech-sales-quotation-submitted-manager-ko.hbs b/lib/mail/templates/tech-sales-quotation-submitted-manager-ko.hbs
new file mode 100644
index 00000000..4cd078c1
--- /dev/null
+++ b/lib/mail/templates/tech-sales-quotation-submitted-manager-ko.hbs
@@ -0,0 +1,125 @@
+{{> header logoUrl=logoUrl }}
+
+
+ 견적서 접수 알림 - RFQ NO. : #{{rfq.code}}
+
+
+
+ 안녕하세요, {{manager.name}}님.
+
+
+
+ {{vendor.name}}에서 견적서를 제출했습니다.
+
+
+
+ 견적서 검토 후 선택 여부를 결정해 주시기 바랍니다.
+ 시스템에서 견적서 상세 내용을 확인하실 수 있습니다.
+
+
+
+
가. 접수된 견적서 정보
+
+ 1) RFQ 번호 : {{rfq.code}}
+
+
+ 2) 프로젝트 : {{project.name}}
+
* 프로젝트 코드 : {{rfq.projectCode}}
+ {{#if project.sector}}
+
* 부문 : {{project.sector}}
+ {{/if}}
+ {{#if project.shipCount}}
+
* 척수 : {{project.shipCount}}척
+ {{/if}}
+ {{#if project.ownerName}}
+
* 선주 : {{project.ownerName}}
+ {{/if}}
+ {{#if project.className}}
+
* 선급 : {{project.className}}
+ {{/if}}
+ {{#if project.shipModelName}}
+
* 선형 : {{project.shipModelName}}
+ {{/if}}
+
+
+ {{#if series}}
+
+ * 시리즈별 K/L 일정 (Keel Laying Quarter)
+ {{#each series}}
+
- {{sersNo}}호선: {{klQuarter}}
+ {{/each}}
+
+ {{/if}}
+
+ 3) 자재명 : {{rfq.title}}
+ {{#if rfq.materialCode}}
+
* 자재그룹 코드 : {{rfq.materialCode}}
+ {{/if}}
+
+
+ 4) 제출 벤더
+
* 벤더명 : {{vendor.name}}
+ {{#if vendor.code}}
+
* 벤더코드 : {{vendor.code}}
+ {{/if}}
+
+
+ 5) 견적 금액 : {{quotation.currency}} {{quotation.totalPrice}}
+
+
+ 6) 견적 유효기간 : {{quotation.validUntil}}
+
+
+ 7) 제출일시 : {{quotation.submittedAt}}
+
+ {{#if quotation.remark}}
+
+ 8) 벤더 특이사항
+
{{quotation.remark}}
+
+ {{/if}}
+
+
+
+
나. 검토 및 선택 안내
+
+ 1) 견적서 검토
+
* 시스템에 접속하여 견적서 상세 내용을 확인하실 수 있습니다.
+
* 견적 비교 기능을 통해 다른 벤더들과 비교 검토가 가능합니다.
+
+
+ 2) 견적 선택/거절
+
* 검토 완료 후 시스템에서 견적 선택 또는 거절 처리를 해주시기 바랍니다.
+
* 선택/거절 시 벤더에게 자동으로 결과 통보 이메일이 발송됩니다.
+
+
+ 3) 추가 문의
+
* 견적 내용에 대해 추가 문의사항이 있는 경우 벤더와 직접 커뮤니케이션하실 수 있습니다.
+
* 시스템의 메시지 기능을 이용해 주시기 바랍니다.
+
+
+
+
+
다. 처리 기한 안내
+
+ 1) 견적 유효기간 : {{quotation.validUntil}}
+
견적 유효기간 내에 검토 및 선택을 완료해 주시기 바랍니다.
+
+
+ 2) 신속한 처리 요청
+
* 벤더가 제출한 견적서에 대해 신속한 검토를 부탁드립니다.
+
* 지연 시 벤더에게 별도 안내가 필요할 수 있습니다.
+
+
+
+
+
+ 견적서 검토하기
+
+
+
+
+ {{companyName}} 기술영업시스템에서 자동 발송된 메일입니다.
+
+
+{{> footer logoUrl=logoUrl companyName=companyName year=year }}
\ No newline at end of file
diff --git a/lib/mail/templates/tech-sales-quotation-submitted-vendor-ko.hbs b/lib/mail/templates/tech-sales-quotation-submitted-vendor-ko.hbs
new file mode 100644
index 00000000..0bc234c7
--- /dev/null
+++ b/lib/mail/templates/tech-sales-quotation-submitted-vendor-ko.hbs
@@ -0,0 +1,122 @@
+{{> header logoUrl=logoUrl }}
+
+
+ 견적서 제출 완료 확인서 - RFQ NO. : #{{rfq.code}}
+
+
+
+ 안녕하세요, {{vendor.name}}님.
+
+
+
+ 귀하께서 제출하신 견적서가 성공적으로 접수되었음을 확인드립니다.
+
+
+
+ 제출해주신 견적서는 당사 기술영업 담당자가 검토 후 결과를 안내드릴 예정입니다.
+ 견적 검토 과정에서 추가 문의사항이 있을 경우 별도로 연락드리겠습니다.
+
+
+
+
가. 제출된 견적서 정보
+
+ 1) RFQ 번호 : {{rfq.code}}
+
+
+ 2) 프로젝트 : {{project.name}}
+
* 프로젝트 코드 : {{rfq.projectCode}}
+ {{#if project.sector}}
+
* 부문 : {{project.sector}}
+ {{/if}}
+ {{#if project.shipCount}}
+
* 척수 : {{project.shipCount}}척
+ {{/if}}
+ {{#if project.ownerName}}
+
* 선주 : {{project.ownerName}}
+ {{/if}}
+ {{#if project.className}}
+
* 선급 : {{project.className}}
+ {{/if}}
+ {{#if project.shipModelName}}
+
* 선형 : {{project.shipModelName}}
+ {{/if}}
+
+
+ {{#if series}}
+
+ * 시리즈별 K/L 일정 (Keel Laying Quarter)
+ {{#each series}}
+
- {{sersNo}}호선: {{klQuarter}}
+ {{/each}}
+
+ {{/if}}
+
+ 3) 자재명 : {{rfq.title}}
+ {{#if rfq.materialCode}}
+
* 자재그룹 코드 : {{rfq.materialCode}}
+ {{/if}}
+
+
+ 4) 견적 금액 : {{quotation.currency}} {{quotation.totalPrice}}
+
+
+ 5) 견적 유효기간 : {{quotation.validUntil}}
+
+
+ 6) 제출일시 : {{quotation.submittedAt}}
+
+ {{#if quotation.remark}}
+
+ 7) 특이사항
+
{{quotation.remark}}
+
+ {{/if}}
+
+
+
+
나. 다음 단계 안내
+
+ 1) 견적 검토 과정
+
* 당사 기술영업 담당자가 제출하신 견적서를 검토합니다.
+
* 검토 과정에서 추가 자료나 설명이 필요한 경우 연락드리겠습니다.
+
+
+ 2) 결과 통보
+
* 견적 검토 완료 후 선택 여부를 이메일로 안내드립니다.
+
* 선택되신 경우 후속 절차에 대해 별도 안내드리겠습니다.
+
+
+ 3) 문의사항
+
* 담당자 : {{manager.name}}
+
* 이메일 : {{manager.email}}
+
+
+
+
+
다. 유의사항
+
+ 1) 견적서 제출이 완료되었습니다.
+
견적서 수정이 필요한 경우 담당자에게 문의하시기 바랍니다.
+
+
+ 2) 견적 유효기간을 준수해 주시기 바랍니다.
+
유효기간 만료 전 견적 선택이 이루어지지 않을 경우, 재견적을 요청할 수 있습니다.
+
+
+ 3) 제출하신 견적서는 기밀로 관리됩니다.
+
견적 정보는 당사 내부 검토 목적으로만 사용됩니다.
+
+
+
+
+
+ 견적서 현황 확인하기
+
+
+
+
+ 감사합니다.
+ {{companyName}} 기술영업팀
+
+
+{{> footer logoUrl=logoUrl companyName=companyName year=year }}
\ No newline at end of file
diff --git a/lib/techsales-rfq/repository.ts b/lib/techsales-rfq/repository.ts
index 260eef19..8a579427 100644
--- a/lib/techsales-rfq/repository.ts
+++ b/lib/techsales-rfq/repository.ts
@@ -3,9 +3,9 @@
import {
techSalesRfqs,
techSalesVendorQuotations,
- items,
vendors,
- users
+ users,
+ itemShipbuilding
} from "@/db/schema";
import {
asc,
@@ -81,7 +81,7 @@ export async function selectTechSalesRfqsWithJoin(
id: techSalesRfqs.id,
rfqCode: techSalesRfqs.rfqCode,
itemId: techSalesRfqs.itemId,
- itemName: items.itemName,
+ itemName: itemShipbuilding.itemList,
materialCode: techSalesRfqs.materialCode,
// 날짜 및 상태 정보
@@ -132,7 +132,7 @@ export async function selectTechSalesRfqsWithJoin(
)`,
})
.from(techSalesRfqs)
- .leftJoin(items, sql`${techSalesRfqs.itemId} = ${items.id}`)
+ .leftJoin(itemShipbuilding, sql`split_part(${techSalesRfqs.materialCode}, ',', 1) = ${itemShipbuilding.itemCode}`)
.leftJoin(sql`${users} AS created_user`, sql`${techSalesRfqs.createdBy} = created_user.id`)
.leftJoin(sql`${users} AS updated_user`, sql`${techSalesRfqs.updatedBy} = updated_user.id`)
.leftJoin(sql`${users} AS sent_user`, sql`${techSalesRfqs.sentBy} = sent_user.id`);
@@ -159,7 +159,7 @@ export async function countTechSalesRfqsWithJoin(
const res = await tx
.select({ count: count() })
.from(techSalesRfqs)
- .leftJoin(items, sql`${techSalesRfqs.itemId} = ${items.id}`)
+ .leftJoin(itemShipbuilding, sql`split_part(${techSalesRfqs.materialCode}, ',', 1) = ${itemShipbuilding.itemCode}`)
.where(where ?? undefined);
return res[0]?.count ?? 0;
}
@@ -211,7 +211,7 @@ export async function selectTechSalesVendorQuotationsWithJoin(
// 프로젝트 정보
materialCode: techSalesRfqs.materialCode,
itemId: techSalesRfqs.itemId,
- itemName: items.itemName,
+ itemName: itemShipbuilding.itemList,
// 프로젝트 핵심 정보
pspid: sql`${techSalesRfqs.projectSnapshot}->>'pspid'`,
@@ -221,14 +221,14 @@ export async function selectTechSalesVendorQuotationsWithJoin(
// 첨부파일 개수
attachmentCount: sql`(
SELECT COUNT(*)
- FROM tech_sales_rfq_comment_attachments
- WHERE tech_sales_rfq_comment_attachments.quotation_id = ${techSalesVendorQuotations.id}
+ FROM tech_sales_attachments
+ WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
)`,
})
.from(techSalesVendorQuotations)
.leftJoin(techSalesRfqs, sql`${techSalesVendorQuotations.rfqId} = ${techSalesRfqs.id}`)
.leftJoin(vendors, sql`${techSalesVendorQuotations.vendorId} = ${vendors.id}`)
- .leftJoin(items, sql`${techSalesRfqs.itemId} = ${items.id}`)
+ .leftJoin(itemShipbuilding, sql`split_part(${techSalesRfqs.materialCode}, ',', 1) = ${itemShipbuilding.itemCode}`)
.leftJoin(sql`${users} AS created_user`, sql`${techSalesVendorQuotations.createdBy} = created_user.id`)
.leftJoin(sql`${users} AS updated_user`, sql`${techSalesVendorQuotations.updatedBy} = updated_user.id`);
@@ -256,6 +256,7 @@ export async function countTechSalesVendorQuotationsWithJoin(
.from(techSalesVendorQuotations)
.leftJoin(techSalesRfqs, sql`${techSalesVendorQuotations.rfqId} = ${techSalesRfqs.id}`)
.leftJoin(vendors, sql`${techSalesVendorQuotations.vendorId} = ${vendors.id}`)
+ .leftJoin(itemShipbuilding, sql`split_part(${techSalesRfqs.materialCode}, ',', 1) = ${itemShipbuilding.itemCode}`)
.where(where ?? undefined);
return res[0]?.count ?? 0;
}
@@ -286,7 +287,7 @@ export async function selectTechSalesDashboardWithJoin(
// 아이템 정보
itemId: techSalesRfqs.itemId,
- itemName: items.itemName,
+ itemName: itemShipbuilding.itemList,
// 프로젝트 정보
pspid: sql`${techSalesRfqs.projectSnapshot}->>'pspid'`,
@@ -363,7 +364,7 @@ export async function selectTechSalesDashboardWithJoin(
createdByName: sql`created_user.name`,
})
.from(techSalesRfqs)
- .leftJoin(items, sql`${techSalesRfqs.itemId} = ${items.id}`)
+ .leftJoin(itemShipbuilding, sql`split_part(${techSalesRfqs.materialCode}, ',', 1) = ${itemShipbuilding.itemCode}`)
.leftJoin(sql`${users} AS created_user`, sql`${techSalesRfqs.createdBy} = created_user.id`);
// where 조건 적용
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts
index 88fef4b7..7be91092 100644
--- a/lib/techsales-rfq/service.ts
+++ b/lib/techsales-rfq/service.ts
@@ -4,10 +4,11 @@ import { unstable_noStore, revalidateTag, revalidatePath } from "next/cache";
import db from "@/db/db";
import {
techSalesRfqs,
- techSalesVendorQuotations,
+ techSalesVendorQuotations,
+ techSalesAttachments,
items,
users,
- TECH_SALES_QUOTATION_STATUSES
+ techSalesRfqComments
} from "@/db/schema";
import { and, desc, eq, ilike, or, sql, ne } from "drizzle-orm";
import { unstable_cache } from "@/lib/unstable-cache";
@@ -166,7 +167,7 @@ export async function createTechSalesRfq(input: {
// 각 자재그룹 코드별로 RFQ 생성
for (const materialCode of input.materialGroupCodes) {
- // RFQ 코드 생성 (임시로 타임스탬프 기반)
+ // RFQ 코드 생성
const rfqCode = await generateRfqCodes(tx, 1);
// 기본 due date 설정 (7일 후)
@@ -1238,6 +1239,19 @@ export async function submitTechSalesVendorQuotation(data: {
.where(eq(techSalesVendorQuotations.id, data.id))
.returning()
+ // 메일 발송 (백그라운드에서 실행)
+ if (result[0]) {
+ // 벤더에게 견적 제출 확인 메일 발송
+ sendQuotationSubmittedNotificationToVendor(data.id).catch(error => {
+ console.error("벤더 견적 제출 확인 메일 발송 실패:", error);
+ });
+
+ // 담당자에게 견적 접수 알림 메일 발송
+ sendQuotationSubmittedNotificationToManager(data.id).catch(error => {
+ console.error("담당자 견적 접수 알림 메일 발송 실패:", error);
+ });
+ }
+
// 캐시 무효화
revalidateTag("techSalesVendorQuotations")
revalidatePath(`/partners/techsales/rfq-ship/${data.id}`)
@@ -1385,6 +1399,12 @@ export async function getVendorQuotations(input: {
itemName: items.itemName,
// 프로젝트 정보 (JSON에서 추출)
projNm: sql`${techSalesRfqs.projectSnapshot}->>'projNm'`,
+ // 첨부파일 개수
+ attachmentCount: sql`(
+ SELECT COUNT(*)
+ FROM tech_sales_attachments
+ WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ )`,
})
.from(techSalesVendorQuotations)
.leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
@@ -1491,6 +1511,34 @@ export async function acceptTechSalesVendorQuotation(quotationId: number) {
return quotation
})
+ // 메일 발송 (백그라운드에서 실행)
+ // 선택된 벤더에게 견적 선택 알림 메일 발송
+ sendQuotationAcceptedNotification(quotationId).catch(error => {
+ console.error("벤더 견적 선택 알림 메일 발송 실패:", error);
+ });
+
+ // 거절된 견적들에 대한 알림 메일 발송 - 트랜잭션 완료 후 별도로 처리
+ setTimeout(async () => {
+ try {
+ const rejectedQuotations = await db.query.techSalesVendorQuotations.findMany({
+ where: and(
+ eq(techSalesVendorQuotations.rfqId, result.rfqId),
+ ne(techSalesVendorQuotations.id, quotationId),
+ eq(techSalesVendorQuotations.status, "Rejected")
+ ),
+ columns: { id: true }
+ });
+
+ for (const rejectedQuotation of rejectedQuotations) {
+ sendQuotationRejectedNotification(rejectedQuotation.id).catch(error => {
+ console.error("벤더 견적 거절 알림 메일 발송 실패:", error);
+ });
+ }
+ } catch (error) {
+ console.error("거절된 견적 알림 메일 발송 중 오류:", error);
+ }
+ }, 1000); // 1초 후 실행
+
// 캐시 무효화
revalidateTag("techSalesVendorQuotations")
revalidateTag(`techSalesRfq-${result.rfqId}`)
@@ -1525,6 +1573,11 @@ export async function rejectTechSalesVendorQuotation(quotationId: number, reject
throw new Error("견적을 찾을 수 없습니다")
}
+ // 메일 발송 (백그라운드에서 실행)
+ sendQuotationRejectedNotification(quotationId).catch(error => {
+ console.error("벤더 견적 거절 알림 메일 발송 실패:", error);
+ });
+
// 캐시 무효화
revalidateTag("techSalesVendorQuotations")
revalidateTag(`techSalesRfq-${result[0].rfqId}`)
@@ -1537,4 +1590,953 @@ export async function rejectTechSalesVendorQuotation(quotationId: number, reject
error: error instanceof Error ? error.message : "벤더 견적 거절에 실패했습니다"
}
}
+}
+
+/**
+ * 기술영업 RFQ 첨부파일 생성 (파일 업로드)
+ */
+export async function createTechSalesRfqAttachments(params: {
+ techSalesRfqId: number
+ files: File[]
+ createdBy: number
+ attachmentType?: "RFQ_COMMON" | "VENDOR_SPECIFIC"
+ description?: string
+}) {
+ unstable_noStore();
+ try {
+ const { techSalesRfqId, files, createdBy, attachmentType = "RFQ_COMMON", description } = params;
+
+ if (!files || files.length === 0) {
+ return { data: null, error: "업로드할 파일이 없습니다." };
+ }
+
+ // RFQ 존재 확인
+ const rfq = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, techSalesRfqId),
+ columns: { id: true, status: true }
+ });
+
+ if (!rfq) {
+ return { data: null, error: "RFQ를 찾을 수 없습니다." };
+ }
+
+ // 편집 가능한 상태 확인
+ if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) {
+ return { data: null, error: "현재 상태에서는 첨부파일을 추가할 수 없습니다." };
+ }
+
+ const results: typeof techSalesAttachments.$inferSelect[] = [];
+
+ // 트랜잭션으로 처리
+ await db.transaction(async (tx) => {
+ const path = await import("path");
+ const fs = await import("fs/promises");
+ const { randomUUID } = await import("crypto");
+
+ // 파일 저장 디렉토리 생성
+ const rfqDir = path.join(process.cwd(), "public", "techsales-rfq", String(techSalesRfqId));
+ await fs.mkdir(rfqDir, { recursive: true });
+
+ for (const file of files) {
+ const ab = await file.arrayBuffer();
+ const buffer = Buffer.from(ab);
+
+ // 고유 파일명 생성
+ const uniqueName = `${randomUUID()}-${file.name}`;
+ const relativePath = path.join("techsales-rfq", String(techSalesRfqId), uniqueName);
+ const absolutePath = path.join(process.cwd(), "public", relativePath);
+
+ // 파일 저장
+ await fs.writeFile(absolutePath, buffer);
+
+ // DB에 첨부파일 레코드 생성
+ const [newAttachment] = await tx.insert(techSalesAttachments).values({
+ techSalesRfqId,
+ attachmentType,
+ fileName: uniqueName,
+ originalFileName: file.name,
+ filePath: "/" + relativePath.replace(/\\/g, "/"),
+ fileSize: file.size,
+ fileType: file.type || undefined,
+ description: description || undefined,
+ createdBy,
+ }).returning();
+
+ results.push(newAttachment);
+ }
+ });
+
+ // 캐시 무효화
+ revalidateTag("techSalesRfqs");
+ revalidateTag(`techSalesRfq-${techSalesRfqId}`);
+ revalidatePath("/evcp/budgetary-tech-sales-ship");
+
+ return { data: results, error: null };
+ } catch (err) {
+ console.error("기술영업 RFQ 첨부파일 생성 오류:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ 첨부파일 조회
+ */
+export async function getTechSalesRfqAttachments(techSalesRfqId: number) {
+ unstable_noStore();
+ try {
+ const attachments = await db.query.techSalesAttachments.findMany({
+ where: eq(techSalesAttachments.techSalesRfqId, techSalesRfqId),
+ orderBy: [desc(techSalesAttachments.createdAt)],
+ with: {
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ });
+
+ return { data: attachments, error: null };
+ } catch (err) {
+ console.error("기술영업 RFQ 첨부파일 조회 오류:", err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ 첨부파일 삭제
+ */
+export async function deleteTechSalesRfqAttachment(attachmentId: number) {
+ unstable_noStore();
+ try {
+ // 첨부파일 정보 조회
+ const attachment = await db.query.techSalesAttachments.findFirst({
+ where: eq(techSalesAttachments.id, attachmentId),
+ });
+
+ if (!attachment) {
+ return { data: null, error: "첨부파일을 찾을 수 없습니다." };
+ }
+
+ // RFQ 상태 확인
+ const rfq = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, attachment.techSalesRfqId!), // Non-null assertion since we know it exists
+ columns: { id: true, status: true }
+ });
+
+ if (!rfq) {
+ return { data: null, error: "RFQ를 찾을 수 없습니다." };
+ }
+
+ // 편집 가능한 상태 확인
+ if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) {
+ return { data: null, error: "현재 상태에서는 첨부파일을 삭제할 수 없습니다." };
+ }
+
+ // 트랜잭션으로 처리
+ const result = await db.transaction(async (tx) => {
+ // DB에서 레코드 삭제
+ const deletedAttachment = await tx.delete(techSalesAttachments)
+ .where(eq(techSalesAttachments.id, attachmentId))
+ .returning();
+
+ // 파일 시스템에서 파일 삭제
+ try {
+ const path = await import("path");
+ const fs = await import("fs/promises");
+
+ const absolutePath = path.join(process.cwd(), "public", attachment.filePath);
+ await fs.unlink(absolutePath);
+ } catch (fileError) {
+ console.warn("파일 삭제 실패:", fileError);
+ // 파일 삭제 실패는 심각한 오류가 아니므로 계속 진행
+ }
+
+ return deletedAttachment[0];
+ });
+
+ // 캐시 무효화
+ revalidateTag("techSalesRfqs");
+ revalidateTag(`techSalesRfq-${attachment.techSalesRfqId}`);
+ revalidatePath("/evcp/budgetary-tech-sales-ship");
+
+ return { data: result, error: null };
+ } catch (err) {
+ console.error("기술영업 RFQ 첨부파일 삭제 오류:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ 첨부파일 일괄 처리 (업로드 + 삭제)
+ */
+export async function processTechSalesRfqAttachments(params: {
+ techSalesRfqId: number
+ newFiles: { file: File; attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC"; description?: string }[]
+ deleteAttachmentIds: number[]
+ createdBy: number
+}) {
+ unstable_noStore();
+ try {
+ const { techSalesRfqId, newFiles, deleteAttachmentIds, createdBy } = params;
+
+ // RFQ 존재 및 상태 확인
+ const rfq = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, techSalesRfqId),
+ columns: { id: true, status: true }
+ });
+
+ if (!rfq) {
+ return { data: null, error: "RFQ를 찾을 수 없습니다." };
+ }
+
+ if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) {
+ return { data: null, error: "현재 상태에서는 첨부파일을 수정할 수 없습니다." };
+ }
+
+ const results = {
+ uploaded: [] as typeof techSalesAttachments.$inferSelect[],
+ deleted: [] as typeof techSalesAttachments.$inferSelect[],
+ };
+
+ await db.transaction(async (tx) => {
+ const path = await import("path");
+ const fs = await import("fs/promises");
+ const { randomUUID } = await import("crypto");
+
+ // 1. 삭제할 첨부파일 처리
+ if (deleteAttachmentIds.length > 0) {
+ const attachmentsToDelete = await tx.query.techSalesAttachments.findMany({
+ where: sql`${techSalesAttachments.id} IN (${deleteAttachmentIds.join(',')})`
+ });
+
+ for (const attachment of attachmentsToDelete) {
+ // DB에서 레코드 삭제
+ const [deletedAttachment] = await tx.delete(techSalesAttachments)
+ .where(eq(techSalesAttachments.id, attachment.id))
+ .returning();
+
+ results.deleted.push(deletedAttachment);
+
+ // 파일 시스템에서 파일 삭제
+ try {
+ const absolutePath = path.join(process.cwd(), "public", attachment.filePath);
+ await fs.unlink(absolutePath);
+ } catch (fileError) {
+ console.warn("파일 삭제 실패:", fileError);
+ }
+ }
+ }
+
+ // 2. 새 파일 업로드 처리
+ if (newFiles.length > 0) {
+ const rfqDir = path.join(process.cwd(), "public", "techsales-rfq", String(techSalesRfqId));
+ await fs.mkdir(rfqDir, { recursive: true });
+
+ for (const { file, attachmentType, description } of newFiles) {
+ const ab = await file.arrayBuffer();
+ const buffer = Buffer.from(ab);
+
+ // 고유 파일명 생성
+ const uniqueName = `${randomUUID()}-${file.name}`;
+ const relativePath = path.join("techsales-rfq", String(techSalesRfqId), uniqueName);
+ const absolutePath = path.join(process.cwd(), "public", relativePath);
+
+ // 파일 저장
+ await fs.writeFile(absolutePath, buffer);
+
+ // DB에 첨부파일 레코드 생성
+ const [newAttachment] = await tx.insert(techSalesAttachments).values({
+ techSalesRfqId,
+ attachmentType,
+ fileName: uniqueName,
+ originalFileName: file.name,
+ filePath: "/" + relativePath.replace(/\\/g, "/"),
+ fileSize: file.size,
+ fileType: file.type || undefined,
+ description: description || undefined,
+ createdBy,
+ }).returning();
+
+ results.uploaded.push(newAttachment);
+ }
+ }
+ });
+
+ // 캐시 무효화
+ revalidateTag("techSalesRfqs");
+ revalidateTag(`techSalesRfq-${techSalesRfqId}`);
+ revalidatePath("/evcp/budgetary-tech-sales-ship");
+
+ return {
+ data: results,
+ error: null,
+ message: `${results.uploaded.length}개 업로드, ${results.deleted.length}개 삭제 완료`
+ };
+ } catch (err) {
+ console.error("기술영업 RFQ 첨부파일 일괄 처리 오류:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+// ========================================
+// 메일 발송 관련 함수들
+// ========================================
+
+/**
+ * 벤더 견적 제출 확인 메일 발송 (벤더용)
+ */
+export async function sendQuotationSubmittedNotificationToVendor(quotationId: number) {
+ try {
+ // 견적서 정보 조회
+ const quotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ with: {
+ rfq: {
+ with: {
+ item: true,
+ biddingProject: true,
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ },
+ vendor: {
+ columns: {
+ id: true,
+ vendorName: true,
+ vendorCode: true,
+ }
+ }
+ }
+ });
+
+ if (!quotation || !quotation.rfq || !quotation.vendor) {
+ console.error("견적서 또는 관련 정보를 찾을 수 없습니다");
+ return { success: false, error: "견적서 정보를 찾을 수 없습니다" };
+ }
+
+ // 벤더 사용자들 조회
+ const vendorUsers = await db.query.users.findMany({
+ where: eq(users.companyId, quotation.vendor.id),
+ columns: {
+ id: true,
+ email: true,
+ name: true,
+ language: true
+ }
+ });
+
+ const vendorEmails = vendorUsers
+ .filter(user => user.email)
+ .map(user => user.email)
+ .join(", ");
+
+ if (!vendorEmails) {
+ console.warn(`벤더 ID ${quotation.vendor.id}에 등록된 이메일 주소가 없습니다`);
+ return { success: false, error: "벤더 이메일 주소가 없습니다" };
+ }
+
+ // 프로젝트 정보 준비
+ const projectInfo = (quotation.rfq.projectSnapshot as Record) || {};
+
+ // 시리즈 정보 처리
+ const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({
+ sersNo: series.sersNo,
+ klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '',
+ scDt: series.scDt,
+ lcDt: series.lcDt,
+ dlDt: series.dlDt,
+ dockNo: series.dockNo,
+ dockNm: series.dockNm,
+ projNo: series.projNo,
+ post1: series.post1,
+ })) : [];
+
+ // 이메일 컨텍스트 구성
+ const emailContext = {
+ language: vendorUsers[0]?.language || "ko",
+ quotation: {
+ id: quotation.id,
+ currency: quotation.currency,
+ totalPrice: quotation.totalPrice,
+ validUntil: quotation.validUntil,
+ submittedAt: quotation.submittedAt,
+ remark: quotation.remark,
+ },
+ rfq: {
+ id: quotation.rfq.id,
+ code: quotation.rfq.rfqCode,
+ title: quotation.rfq.item?.itemName || '',
+ projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '',
+ projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '',
+ dueDate: quotation.rfq.dueDate,
+ materialCode: quotation.rfq.materialCode,
+ description: quotation.rfq.remark,
+ },
+ vendor: {
+ id: quotation.vendor.id,
+ code: quotation.vendor.vendorCode,
+ name: quotation.vendor.vendorName,
+ },
+ project: {
+ name: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '',
+ sector: projectInfo.sector || quotation.rfq.biddingProject?.sector || '',
+ shipCount: projectInfo.projMsrm || 0,
+ ownerName: projectInfo.kunnrNm || '',
+ className: projectInfo.cls1Nm || '',
+ shipModelName: projectInfo.pmodelNm || '',
+ },
+ series: seriesInfo,
+ manager: {
+ name: quotation.rfq.createdByUser?.name || '',
+ email: quotation.rfq.createdByUser?.email || '',
+ },
+ systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners',
+ companyName: 'Samsung Heavy Industries',
+ year: new Date().getFullYear(),
+ };
+
+ // 이메일 발송
+ await sendEmail({
+ to: vendorEmails,
+ subject: `[견적 제출 확인] ${quotation.rfq.rfqCode} - ${quotation.rfq.item?.itemName || '견적 요청'}`,
+ template: 'tech-sales-quotation-submitted-vendor-ko',
+ context: emailContext,
+ });
+
+ console.log(`벤더 견적 제출 확인 메일 발송 완료: ${vendorEmails}`);
+ return { success: true };
+ } catch (error) {
+ console.error("벤더 견적 제출 확인 메일 발송 오류:", error);
+ return { success: false, error: "메일 발송 중 오류가 발생했습니다" };
+ }
+}
+
+/**
+ * 벤더 견적 접수 알림 메일 발송 (담당자용)
+ */
+export async function sendQuotationSubmittedNotificationToManager(quotationId: number) {
+ try {
+ // 견적서 정보 조회
+ const quotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ with: {
+ rfq: {
+ with: {
+ item: true,
+ biddingProject: true,
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ },
+ vendor: {
+ columns: {
+ id: true,
+ vendorName: true,
+ vendorCode: true,
+ }
+ }
+ }
+ });
+
+ if (!quotation || !quotation.rfq || !quotation.vendor) {
+ console.error("견적서 또는 관련 정보를 찾을 수 없습니다");
+ return { success: false, error: "견적서 정보를 찾을 수 없습니다" };
+ }
+
+ const manager = quotation.rfq.createdByUser;
+ if (!manager?.email) {
+ console.warn("담당자 이메일 주소가 없습니다");
+ return { success: false, error: "담당자 이메일 주소가 없습니다" };
+ }
+
+ // 프로젝트 정보 준비
+ const projectInfo = (quotation.rfq.projectSnapshot as Record) || {};
+
+ // 시리즈 정보 처리
+ const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({
+ sersNo: series.sersNo,
+ klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '',
+ scDt: series.scDt,
+ lcDt: series.lcDt,
+ dlDt: series.dlDt,
+ dockNo: series.dockNo,
+ dockNm: series.dockNm,
+ projNo: series.projNo,
+ post1: series.post1,
+ })) : [];
+
+ // 이메일 컨텍스트 구성
+ const emailContext = {
+ language: "ko",
+ quotation: {
+ id: quotation.id,
+ currency: quotation.currency,
+ totalPrice: quotation.totalPrice,
+ validUntil: quotation.validUntil,
+ submittedAt: quotation.submittedAt,
+ remark: quotation.remark,
+ },
+ rfq: {
+ id: quotation.rfq.id,
+ code: quotation.rfq.rfqCode,
+ title: quotation.rfq.item?.itemName || '',
+ projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '',
+ projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '',
+ dueDate: quotation.rfq.dueDate,
+ materialCode: quotation.rfq.materialCode,
+ description: quotation.rfq.remark,
+ },
+ vendor: {
+ id: quotation.vendor.id,
+ code: quotation.vendor.vendorCode,
+ name: quotation.vendor.vendorName,
+ },
+ project: {
+ name: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '',
+ sector: projectInfo.sector || quotation.rfq.biddingProject?.sector || '',
+ shipCount: projectInfo.projMsrm || 0,
+ ownerName: projectInfo.kunnrNm || '',
+ className: projectInfo.cls1Nm || '',
+ shipModelName: projectInfo.pmodelNm || '',
+ },
+ series: seriesInfo,
+ manager: {
+ name: manager.name || '',
+ email: manager.email,
+ },
+ systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/evcp',
+ companyName: 'Samsung Heavy Industries',
+ year: new Date().getFullYear(),
+ };
+
+ // 이메일 발송
+ await sendEmail({
+ to: manager.email,
+ subject: `[견적 접수 알림] ${quotation.vendor.vendorName}에서 ${quotation.rfq.rfqCode} 견적서를 제출했습니다`,
+ template: 'tech-sales-quotation-submitted-manager-ko',
+ context: emailContext,
+ });
+
+ console.log(`담당자 견적 접수 알림 메일 발송 완료: ${manager.email}`);
+ return { success: true };
+ } catch (error) {
+ console.error("담당자 견적 접수 알림 메일 발송 오류:", error);
+ return { success: false, error: "메일 발송 중 오류가 발생했습니다" };
+ }
+}
+
+/**
+ * 벤더 견적 선택 알림 메일 발송
+ */
+export async function sendQuotationAcceptedNotification(quotationId: number) {
+ try {
+ // 견적서 정보 조회
+ const quotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ with: {
+ rfq: {
+ with: {
+ item: true,
+ biddingProject: true,
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ },
+ vendor: {
+ columns: {
+ id: true,
+ vendorName: true,
+ vendorCode: true,
+ }
+ }
+ }
+ });
+
+ if (!quotation || !quotation.rfq || !quotation.vendor) {
+ console.error("견적서 또는 관련 정보를 찾을 수 없습니다");
+ return { success: false, error: "견적서 정보를 찾을 수 없습니다" };
+ }
+
+ // 벤더 사용자들 조회
+ const vendorUsers = await db.query.users.findMany({
+ where: eq(users.companyId, quotation.vendor.id),
+ columns: {
+ id: true,
+ email: true,
+ name: true,
+ language: true
+ }
+ });
+
+ const vendorEmails = vendorUsers
+ .filter(user => user.email)
+ .map(user => user.email)
+ .join(", ");
+
+ if (!vendorEmails) {
+ console.warn(`벤더 ID ${quotation.vendor.id}에 등록된 이메일 주소가 없습니다`);
+ return { success: false, error: "벤더 이메일 주소가 없습니다" };
+ }
+
+ // 프로젝트 정보 준비
+ const projectInfo = (quotation.rfq.projectSnapshot as Record) || {};
+
+ // 시리즈 정보 처리
+ const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({
+ sersNo: series.sersNo,
+ klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '',
+ scDt: series.scDt,
+ lcDt: series.lcDt,
+ dlDt: series.dlDt,
+ dockNo: series.dockNo,
+ dockNm: series.dockNm,
+ projNo: series.projNo,
+ post1: series.post1,
+ })) : [];
+
+ // 이메일 컨텍스트 구성
+ const emailContext = {
+ language: vendorUsers[0]?.language || "ko",
+ quotation: {
+ id: quotation.id,
+ currency: quotation.currency,
+ totalPrice: quotation.totalPrice,
+ validUntil: quotation.validUntil,
+ acceptedAt: quotation.acceptedAt,
+ remark: quotation.remark,
+ },
+ rfq: {
+ id: quotation.rfq.id,
+ code: quotation.rfq.rfqCode,
+ title: quotation.rfq.item?.itemName || '',
+ projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '',
+ projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '',
+ dueDate: quotation.rfq.dueDate,
+ materialCode: quotation.rfq.materialCode,
+ description: quotation.rfq.remark,
+ },
+ vendor: {
+ id: quotation.vendor.id,
+ code: quotation.vendor.vendorCode,
+ name: quotation.vendor.vendorName,
+ },
+ project: {
+ name: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '',
+ sector: projectInfo.sector || quotation.rfq.biddingProject?.sector || '',
+ shipCount: projectInfo.projMsrm || 0,
+ ownerName: projectInfo.kunnrNm || '',
+ className: projectInfo.cls1Nm || '',
+ shipModelName: projectInfo.pmodelNm || '',
+ },
+ series: seriesInfo,
+ manager: {
+ name: quotation.rfq.createdByUser?.name || '',
+ email: quotation.rfq.createdByUser?.email || '',
+ },
+ systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners',
+ companyName: 'Samsung Heavy Industries',
+ year: new Date().getFullYear(),
+ };
+
+ // 이메일 발송
+ await sendEmail({
+ to: vendorEmails,
+ subject: `[견적 선택 알림] ${quotation.rfq.rfqCode} - 귀하의 견적이 선택되었습니다`,
+ template: 'tech-sales-quotation-accepted-ko',
+ context: emailContext,
+ });
+
+ console.log(`벤더 견적 선택 알림 메일 발송 완료: ${vendorEmails}`);
+ return { success: true };
+ } catch (error) {
+ console.error("벤더 견적 선택 알림 메일 발송 오류:", error);
+ return { success: false, error: "메일 발송 중 오류가 발생했습니다" };
+ }
+}
+
+/**
+ * 벤더 견적 거절 알림 메일 발송
+ */
+export async function sendQuotationRejectedNotification(quotationId: number) {
+ try {
+ // 견적서 정보 조회
+ const quotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ with: {
+ rfq: {
+ with: {
+ item: true,
+ biddingProject: true,
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ },
+ vendor: {
+ columns: {
+ id: true,
+ vendorName: true,
+ vendorCode: true,
+ }
+ }
+ }
+ });
+
+ if (!quotation || !quotation.rfq || !quotation.vendor) {
+ console.error("견적서 또는 관련 정보를 찾을 수 없습니다");
+ return { success: false, error: "견적서 정보를 찾을 수 없습니다" };
+ }
+
+ // 벤더 사용자들 조회
+ const vendorUsers = await db.query.users.findMany({
+ where: eq(users.companyId, quotation.vendor.id),
+ columns: {
+ id: true,
+ email: true,
+ name: true,
+ language: true
+ }
+ });
+
+ const vendorEmails = vendorUsers
+ .filter(user => user.email)
+ .map(user => user.email)
+ .join(", ");
+
+ if (!vendorEmails) {
+ console.warn(`벤더 ID ${quotation.vendor.id}에 등록된 이메일 주소가 없습니다`);
+ return { success: false, error: "벤더 이메일 주소가 없습니다" };
+ }
+
+ // 프로젝트 정보 준비
+ const projectInfo = (quotation.rfq.projectSnapshot as Record) || {};
+
+ // 시리즈 정보 처리
+ const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({
+ sersNo: series.sersNo,
+ klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '',
+ scDt: series.scDt,
+ lcDt: series.lcDt,
+ dlDt: series.dlDt,
+ dockNo: series.dockNo,
+ dockNm: series.dockNm,
+ projNo: series.projNo,
+ post1: series.post1,
+ })) : [];
+
+ // 이메일 컨텍스트 구성
+ const emailContext = {
+ language: vendorUsers[0]?.language || "ko",
+ quotation: {
+ id: quotation.id,
+ currency: quotation.currency,
+ totalPrice: quotation.totalPrice,
+ validUntil: quotation.validUntil,
+ rejectionReason: quotation.rejectionReason,
+ remark: quotation.remark,
+ },
+ rfq: {
+ id: quotation.rfq.id,
+ code: quotation.rfq.rfqCode,
+ title: quotation.rfq.item?.itemName || '',
+ projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '',
+ projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '',
+ dueDate: quotation.rfq.dueDate,
+ materialCode: quotation.rfq.materialCode,
+ description: quotation.rfq.remark,
+ },
+ vendor: {
+ id: quotation.vendor.id,
+ code: quotation.vendor.vendorCode,
+ name: quotation.vendor.vendorName,
+ },
+ project: {
+ name: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '',
+ sector: projectInfo.sector || quotation.rfq.biddingProject?.sector || '',
+ shipCount: projectInfo.projMsrm || 0,
+ ownerName: projectInfo.kunnrNm || '',
+ className: projectInfo.cls1Nm || '',
+ shipModelName: projectInfo.pmodelNm || '',
+ },
+ series: seriesInfo,
+ manager: {
+ name: quotation.rfq.createdByUser?.name || '',
+ email: quotation.rfq.createdByUser?.email || '',
+ },
+ systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners',
+ companyName: 'Samsung Heavy Industries',
+ year: new Date().getFullYear(),
+ };
+
+ // 이메일 발송
+ await sendEmail({
+ to: vendorEmails,
+ subject: `[견적 검토 결과] ${quotation.rfq.rfqCode} - 견적 검토 결과를 안내드립니다`,
+ template: 'tech-sales-quotation-rejected-ko',
+ context: emailContext,
+ });
+
+ console.log(`벤더 견적 거절 알림 메일 발송 완료: ${vendorEmails}`);
+ return { success: true };
+ } catch (error) {
+ console.error("벤더 견적 거절 알림 메일 발송 오류:", error);
+ return { success: false, error: "메일 발송 중 오류가 발생했습니다" };
+ }
+}
+
+// ==================== Vendor Communication 관련 ====================
+
+export interface TechSalesAttachment {
+ id: number
+ fileName: string
+ fileSize: number
+ fileType: string | null // <- null 허용
+ filePath: string
+ uploadedAt: Date
+}
+
+export interface TechSalesComment {
+ id: number
+ rfqId: number
+ vendorId: number | null // null 허용으로 변경
+ userId?: number | null // null 허용으로 변경
+ content: string
+ isVendorComment: boolean | null // null 허용으로 변경
+ createdAt: Date
+ updatedAt: Date
+ userName?: string | null // null 허용으로 변경
+ vendorName?: string | null // null 허용으로 변경
+ attachments: TechSalesAttachment[]
+ isRead: boolean | null // null 허용으로 변경
+}
+
+/**
+ * 특정 RFQ와 벤더 간의 커뮤니케이션 메시지를 가져오는 서버 액션
+ *
+ * @param rfqId RFQ ID
+ * @param vendorId 벤더 ID
+ * @returns 코멘트 목록
+ */
+export async function fetchTechSalesVendorComments(rfqId: number, vendorId?: number): Promise {
+ if (!vendorId) {
+ return []
+ }
+
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다")
+ }
+
+ // 코멘트 쿼리
+ const comments = await db.query.techSalesRfqComments.findMany({
+ where: and(
+ eq(techSalesRfqComments.rfqId, rfqId),
+ eq(techSalesRfqComments.vendorId, vendorId)
+ ),
+ orderBy: [techSalesRfqComments.createdAt],
+ with: {
+ user: {
+ columns: {
+ name: true
+ }
+ },
+ vendor: {
+ columns: {
+ vendorName: true
+ }
+ },
+ attachments: true,
+ }
+ })
+
+ // 결과 매핑
+ return comments.map(comment => ({
+ id: comment.id,
+ rfqId: comment.rfqId,
+ vendorId: comment.vendorId,
+ userId: comment.userId || undefined,
+ content: comment.content,
+ isVendorComment: comment.isVendorComment,
+ createdAt: comment.createdAt,
+ updatedAt: comment.updatedAt,
+ userName: comment.user?.name,
+ vendorName: comment.vendor?.vendorName,
+ isRead: comment.isRead,
+ attachments: comment.attachments.map(att => ({
+ id: att.id,
+ fileName: att.fileName,
+ fileSize: att.fileSize,
+ fileType: att.fileType,
+ filePath: att.filePath,
+ uploadedAt: att.uploadedAt
+ }))
+ }))
+ } catch (error) {
+ console.error('techSales 벤더 코멘트 가져오기 오류:', error)
+ throw error
+ }
+}
+
+/**
+ * 코멘트를 읽음 상태로 표시하는 서버 액션
+ *
+ * @param rfqId RFQ ID
+ * @param vendorId 벤더 ID
+ */
+export async function markTechSalesMessagesAsRead(rfqId: number, vendorId?: number): Promise {
+ if (!vendorId) {
+ return
+ }
+
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다")
+ }
+
+ // 벤더가 작성한 읽지 않은 코멘트 업데이트
+ await db.update(techSalesRfqComments)
+ .set({ isRead: true })
+ .where(
+ and(
+ eq(techSalesRfqComments.rfqId, rfqId),
+ eq(techSalesRfqComments.vendorId, vendorId),
+ eq(techSalesRfqComments.isVendorComment, true),
+ eq(techSalesRfqComments.isRead, false)
+ )
+ )
+
+ // 캐시 무효화
+ revalidateTag(`tech-sales-rfq-${rfqId}-comments`)
+ } catch (error) {
+ console.error('techSales 메시지 읽음 표시 오류:', error)
+ throw error
+ }
}
\ No newline at end of file
diff --git a/lib/techsales-rfq/table/create-rfq-dialog.tsx b/lib/techsales-rfq/table/create-rfq-dialog.tsx
index cc652b44..5faa3a0b 100644
--- a/lib/techsales-rfq/table/create-rfq-dialog.tsx
+++ b/lib/techsales-rfq/table/create-rfq-dialog.tsx
@@ -197,20 +197,60 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
throw new Error("로그인이 필요합니다")
}
- // 자재코드(item_code) 배열을 materialGroupCodes로 전달
- const result = await createTechSalesRfq({
- biddingProjectId: data.biddingProjectId,
- materialGroupCodes: data.materialCodes, // item_code를 자재코드로 사용
- createdBy: Number(session.user.id),
- dueDate: data.dueDate,
+ // 선택된 아이템들을 아이템명(itemList)으로 그룹핑
+ const groupedItems = selectedItems.reduce((groups, item) => {
+ const actualItemName = item.itemList // 실제 조선 아이템명
+ if (!actualItemName) {
+ throw new Error(`아이템 "${item.itemCode}"의 아이템명(itemList)이 없습니다.`)
+ }
+ if (!groups[actualItemName]) {
+ groups[actualItemName] = []
+ }
+ groups[actualItemName].push(item)
+ return groups
+ }, {} as Record)
+
+ const rfqGroups = Object.entries(groupedItems).map(([actualItemName, items]) => {
+ const itemCodes = items.map(item => item.itemCode) // 자재그룹코드들
+ const joinedItemCodes = itemCodes.join(',')
+ return {
+ actualItemName,
+ items,
+ itemCodes,
+ joinedItemCodes,
+ codeLength: joinedItemCodes.length,
+ isOverLimit: joinedItemCodes.length > 255
+ }
})
+
+ // 255자 초과 그룹 확인
+ const overLimitGroups = rfqGroups.filter(group => group.isOverLimit)
+ if (overLimitGroups.length > 0) {
+ const groupNames = overLimitGroups.map(g => `"${g.actualItemName}" (${g.codeLength}자)`).join(', ')
+ throw new Error(`다음 아이템 그룹의 자재코드가 255자를 초과합니다: ${groupNames}`)
+ }
+
+ // 각 그룹별로 RFQ 생성
+ const createPromises = rfqGroups.map(group =>
+ createTechSalesRfq({
+ biddingProjectId: data.biddingProjectId,
+ materialGroupCodes: [group.joinedItemCodes], // 그룹화된 자재코드들
+ createdBy: Number(session.user.id),
+ dueDate: data.dueDate,
+ })
+ )
+
+ const results = await Promise.all(createPromises)
- if (result.error) {
- throw new Error(result.error)
+ // 오류 확인
+ const errors = results.filter(result => result.error)
+ if (errors.length > 0) {
+ throw new Error(errors.map(e => e.error).join(', '))
}
// 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시
- toast.success(`${result.data?.length || 0}개의 RFQ가 성공적으로 생성되었습니다`)
+ const totalRfqs = results.reduce((sum, result) => sum + (result.data?.length || 0), 0)
+ toast.success(`${rfqGroups.length}개 아이템 그룹으로 총 ${totalRfqs}개의 RFQ가 성공적으로 생성되었습니다`)
setIsDialogOpen(false)
form.reset({
biddingProjectId: undefined,
@@ -423,38 +463,45 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
아이템을 불러오는 중...
) : availableItems.length > 0 ? (
- availableItems.map((item) => {
- const isSelected = selectedItems.some(selected => selected.id === item.id)
- return (
- handleItemToggle(item)}
- >
-
- {isSelected ? (
-
- ) : (
-
+ [...availableItems]
+ .sort((a, b) => {
+ // itemList 기준으로 정렬 (없는 경우 itemName 사용, 둘 다 없으면 맨 뒤로)
+ const aName = a.itemList || a.itemName || 'zzz'
+ const bName = b.itemList || b.itemName || 'zzz'
+ return aName.localeCompare(bName, 'ko', { numeric: true })
+ })
+ .map((item) => {
+ const isSelected = selectedItems.some(selected => selected.id === item.id)
+ return (
+
-
- {item.itemList || item.itemName}
-
-
- {item.itemCode} • {item.description || '설명 없음'}
-
-
- 공종: {item.workType} • 선종: {item.shipTypes}
+ onClick={() => handleItemToggle(item)}
+ >
+
+ {isSelected ? (
+
+ ) : (
+
+ )}
+
+
+ {item.itemList || item.itemName || '아이템명 없음'}
+
+
+ {item.itemCode} • {item.description || '설명 없음'}
+
+
+ 공종: {item.workType} • 선종: {item.shipTypes}
+
-
- )
- })
+ )
+ })
) : (
{itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"}
@@ -480,7 +527,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
variant="secondary"
className="flex items-center gap-1"
>
- {item.itemList || item.itemName} ({item.itemCode})
+ {item.itemList || item.itemName || '아이템명 없음'} ({item.itemCode})
handleRemoveItem(item.id)}
@@ -498,19 +545,93 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
)}
/>
+
+ {/* RFQ 그룹핑 미리보기 */}
+ {selectedItems.length > 0 && (
+
+
생성될 RFQ 그룹 미리보기
+
+ {(() => {
+ // 아이템명(itemList)으로 그룹핑
+ const groupedItems = selectedItems.reduce((groups, item) => {
+ const actualItemName = item.itemList // 실제 조선 아이템명
+ if (!actualItemName) {
+ return groups // itemList가 없는 경우 제외
+ }
+ if (!groups[actualItemName]) {
+ groups[actualItemName] = []
+ }
+ groups[actualItemName].push(item)
+ return groups
+ }, {} as Record
)
+
+ const rfqGroups = Object.entries(groupedItems).map(([actualItemName, items]) => {
+ const itemCodes = items.map(item => item.itemCode) // 자재그룹코드들
+ const joinedItemCodes = itemCodes.join(',')
+ return {
+ actualItemName,
+ items,
+ itemCodes,
+ joinedItemCodes,
+ codeLength: joinedItemCodes.length,
+ isOverLimit: joinedItemCodes.length > 255
+ }
+ })
+
+ return (
+
+
+ 총 {rfqGroups.length}개의 RFQ가 생성됩니다 (아이템명별로 그룹핑)
+
+ {rfqGroups.map((group, index) => (
+
+
+
+ RFQ #{index + 1}: {group.actualItemName}
+
+
+ {group.itemCodes.length}개 자재코드 ({group.codeLength}/255자)
+
+
+
+ 자재코드: {group.joinedItemCodes}
+
+ {group.isOverLimit && (
+
+ ⚠️ 자재코드 길이가 255자를 초과합니다. 일부 아이템을 제거해주세요.
+
+ )}
+
+ 포함된 아이템: {group.items.map(item => `${item.itemCode}`).join(', ')}
+
+
+ ))}
+
+ )
+ })()}
+
+
+ )}
)}
{/* 안내 메시지 */}
- {selectedProject && (
+ {/* {selectedProject && (
• 공종별 조선 아이템을 선택하세요.
-
• 선택된 아이템의 자재코드(item_code)별로 개별 RFQ가 생성됩니다.
-
• 아이템 코드가 자재 그룹 코드로 사용됩니다.
+
• 같은 아이템명의 다른 자재코드들은 하나의 RFQ로 그룹핑됩니다.
+
• 그룹핑된 자재코드들은 comma로 구분되어 저장됩니다.
+
• 자재코드 길이는 최대 255자까지 가능합니다.
• 마감일은 벤더가 견적을 제출해야 하는 날짜입니다.
- )}
+ )} */}
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
index c4a7edde..cfae0bd7 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
@@ -14,10 +14,11 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Ellipsis, MessageCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
+import { useRouter } from "next/navigation";
export interface DataTableRowAction
{
row: Row;
- type: "delete" | "update" | "communicate";
+ type: "delete" | "communicate";
}
// 벤더 견적 데이터 타입 정의
@@ -232,6 +233,13 @@ export function getRfqDetailColumns({
cell: function Cell({ row }) {
const vendorId = row.original.vendorId;
const unreadCount = vendorId ? unreadMessages[vendorId] || 0 : 0;
+ const router = useRouter();
+
+ const handleViewDetails = () => {
+ if (vendorId) {
+ router.push(`/ko/evcp/vendors/${vendorId}/info`);
+ }
+ };
return (
@@ -269,9 +277,12 @@ export function getRfqDetailColumns({
setRowAction({ row, type: "update" })}
+ onClick={handleViewDetails}
+ disabled={!vendorId}
+ className="gap-2"
>
- 벤더 수정
+ {/* */}
+ 벤더 상세정보
setRowAction({ row, type: "delete" })}
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 4f8ac37b..a2f012ad 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
@@ -12,11 +12,10 @@ import { toast } from "sonner"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
-import { Loader2, UserPlus, BarChart2, Send, Trash2, MessageCircle } from "lucide-react"
+import { Loader2, UserPlus, BarChart2, Send, Trash2 } from "lucide-react"
import { ClientDataTable } from "@/components/client-data-table/data-table"
import { AddVendorDialog } from "./add-vendor-dialog"
import { DeleteVendorDialog } from "./delete-vendor-dialog"
-import { UpdateVendorSheet } from "./update-vendor-sheet"
import { VendorCommunicationDrawer } from "./vendor-communication-drawer"
import { VendorQuotationComparisonDialog } from "./vendor-quotation-comparison-dialog"
@@ -41,28 +40,6 @@ interface RfqDetailTablesProps {
maxHeight?: string | number
}
-// 데이터 타입 정의
-interface Vendor {
- id: number;
- vendorName: string;
- vendorCode: string;
- // 기타 필요한 벤더 속성들
-}
-
-interface Currency {
- code: string;
- name: string;
-}
-
-interface PaymentTerm {
- code: string;
- description: string;
-}
-
-interface Incoterm {
- code: string;
- description: string;
-}
export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps) {
// console.log("selectedRfq", selectedRfq)
@@ -71,14 +48,9 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
const [isLoading, setIsLoading] = useState(false)
const [details, setDetails] = useState([])
const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false)
- const [updateSheetOpen, setUpdateSheetOpen] = React.useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false)
const [selectedDetail, setSelectedDetail] = React.useState(null)
- const [vendors, setVendors] = React.useState([])
- const [currencies, setCurrencies] = React.useState([])
- const [paymentTerms, setPaymentTerms] = React.useState([])
- const [incoterms, setIncoterms] = React.useState([])
const [isAdddialogLoading, setIsAdddialogLoading] = useState(false)
const [rowAction, setRowAction] = React.useState | null>(null)
@@ -159,21 +131,6 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
const handleAddVendor = useCallback(async () => {
try {
setIsAdddialogLoading(true)
-
- // TODO: 기술영업용 벤더, 통화, 지불조건, 인코텀즈 데이터 로드 함수 구현 필요
- // const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([
- // fetchVendors(),
- // fetchCurrencies(),
- // fetchPaymentTerms(),
- // fetchIncoterms()
- // ])
-
- // 임시 데이터
- setVendors([])
- setCurrencies([])
- setPaymentTerms([])
- setIncoterms([])
-
setVendorDialogOpen(true)
} catch (error) {
console.error("데이터 로드 오류:", error)
@@ -417,39 +374,16 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
return;
}
- // 다른 액션들은 기존과 동일하게 처리
- setIsAdddialogLoading(true);
-
- // TODO: 필요한 데이터 로드 (벤더, 통화, 지불조건, 인코텀즈)
- // const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([
- // fetchVendors(),
- // fetchCurrencies(),
- // fetchPaymentTerms(),
- // fetchIncoterms()
- // ]);
-
- // 임시 데이터
- setVendors([]);
- setCurrencies([]);
- setPaymentTerms([]);
- setIncoterms([]);
-
- // 이제 데이터가 로드되었으므로 필요한 작업 수행
- if (rowAction.type === "update") {
- setSelectedDetail(rowAction.row.original);
- setUpdateSheetOpen(true);
- } else if (rowAction.type === "delete") {
+ // 삭제 액션인 경우
+ if (rowAction.type === "delete") {
setSelectedDetail(rowAction.row.original);
setDeleteDialogOpen(true);
+ setRowAction(null);
+ return;
}
} catch (error) {
- console.error("데이터 로드 오류:", error);
- toast.error("데이터를 불러오는 중 오류가 발생했습니다");
- } finally {
- // communicate 타입이 아닌 경우에만 로딩 상태 변경
- if (rowAction && rowAction.type !== "communicate") {
- setIsAdddialogLoading(false);
- }
+ console.error("액션 처리 오류:", error);
+ toast.error("작업을 처리하는 중 오류가 발생했습니다");
}
};
@@ -615,17 +549,6 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
onSuccess={handleRefreshData}
/>
-
-
-
-// 데이터 타입 정의
-interface Vendor {
- id: number;
- vendorName: string;
- vendorCode: string;
-}
-
-interface Currency {
- code: string;
- name: string;
-}
-
-interface PaymentTerm {
- code: string;
- description: string;
-}
-
-interface Incoterm {
- code: string;
- description: string;
-}
-
-interface UpdateRfqDetailSheetProps
- extends React.ComponentPropsWithRef {
- detail: RfqDetailView | null;
- vendors: Vendor[];
- currencies: Currency[];
- paymentTerms: PaymentTerm[];
- incoterms: Incoterm[];
- onSuccess?: () => void;
-}
-
-export function UpdateVendorSheet({
- detail,
- vendors,
- currencies,
- paymentTerms,
- incoterms,
- onSuccess,
- ...props
-}: UpdateRfqDetailSheetProps) {
- const [isUpdatePending, startUpdateTransition] = React.useTransition()
- const [vendorOpen, setVendorOpen] = React.useState(false)
-
- const form = useForm({
- resolver: zodResolver(updateRfqDetailSchema),
- defaultValues: {
- vendorId: detail?.vendorName ? String(vendors.find(v => v.vendorName === detail.vendorName)?.id || "") : "",
- currency: detail?.currency || "",
- paymentTermsCode: detail?.paymentTermsCode || "",
- incotermsCode: detail?.incotermsCode || "",
- incotermsDetail: detail?.incotermsDetail || "",
- deliveryDate: detail?.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "",
- taxCode: detail?.taxCode || "",
- placeOfShipping: detail?.placeOfShipping || "",
- placeOfDestination: detail?.placeOfDestination || "",
- materialPriceRelatedYn: detail?.materialPriceRelatedYn || false,
- },
- })
-
- // detail이 변경될 때 form 값 업데이트
- React.useEffect(() => {
- if (detail) {
- const vendorId = vendors.find(v => v.vendorName === detail.vendorName)?.id
-
- form.reset({
- vendorId: vendorId ? String(vendorId) : "",
- currency: detail.currency || "",
- paymentTermsCode: detail.paymentTermsCode || "",
- incotermsCode: detail.incotermsCode || "",
- incotermsDetail: detail.incotermsDetail || "",
- deliveryDate: detail.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "",
- taxCode: detail.taxCode || "",
- placeOfShipping: detail.placeOfShipping || "",
- placeOfDestination: detail.placeOfDestination || "",
- materialPriceRelatedYn: detail.materialPriceRelatedYn || false,
- })
- }
- }, [detail, form, vendors])
-
- function onSubmit(values: UpdateRfqDetailFormValues) {
- if (!detail) return
-
- startUpdateTransition(async () => {
- try {
- const result = await updateRfqDetail(detail.detailId, values)
-
- if (!result.success) {
- toast.error(result.message || "수정 중 오류가 발생했습니다")
- return
- }
-
- props.onOpenChange?.(false)
- toast.success("RFQ 벤더 정보가 수정되었습니다")
- onSuccess?.()
- } catch (error) {
- console.error("RFQ 벤더 수정 오류:", error)
- toast.error("수정 중 오류가 발생했습니다")
- }
- })
- }
-
- return (
-
-
-
- RFQ 벤더 정보 수정
-
- 벤더 정보를 수정하고 저장하세요
-
-
-
-
-
-
-
-
-
- 취소
-
-
-
- {isUpdatePending && (
-
- )}
- 저장
-
-
-
-
- )
-}
\ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx b/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx
index 51ef7b38..958cc8d1 100644
--- a/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx
+++ b/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx
@@ -37,7 +37,7 @@ import {
} from "@/components/ui/dialog"
import { formatDateTime } from "@/lib/utils"
import { formatFileSize } from "@/lib/utils" // formatFileSize 유틸리티 임포트
-import { fetchVendorComments, markMessagesAsRead } from "@/lib/procurement-rfqs/services"
+import { fetchTechSalesVendorComments, markTechSalesMessagesAsRead } from "@/lib/techsales-rfq/service"
// 타입 정의
interface Comment {
@@ -59,7 +59,7 @@ interface Attachment {
id: number;
fileName: string;
fileSize: number;
- fileType: string;
+ fileType: string | null;
filePath: string;
uploadedAt: Date;
}
@@ -99,8 +99,8 @@ async function sendComment(params: {
});
}
- // API 엔드포인트 구성
- const url = `/api/procurement-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
+ // API 엔드포인트 구성 - techSales용으로 변경
+ const url = `/api/tech-sales-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
// API 호출
const response = await fetch(url, {
@@ -169,11 +169,11 @@ export function VendorCommunicationDrawer({
setIsLoading(true);
// Server Action을 사용하여 코멘트 데이터 가져오기
- const commentsData = await fetchVendorComments(selectedRfq.id, selectedVendor.vendorId || 0);
+ const commentsData = await fetchTechSalesVendorComments(selectedRfq.id, selectedVendor.vendorId || 0);
setComments(commentsData as Comment[]); // 구체적인 타입으로 캐스팅
// Server Action을 사용하여 읽지 않은 메시지를 읽음 상태로 변경
- await markMessagesAsRead(selectedRfq.id, selectedVendor.vendorId || 0);
+ await markTechSalesMessagesAsRead(selectedRfq.id, selectedVendor.vendorId || 0);
} catch (error) {
console.error("코멘트 로드 오류:", error);
toast.error("메시지를 불러오는 중 오류가 발생했습니다");
@@ -269,15 +269,15 @@ export function VendorCommunicationDrawer({
const renderAttachmentPreviewDialog = () => {
if (!selectedAttachment) return null;
- const isImage = selectedAttachment.fileType.startsWith("image/");
- const isPdf = selectedAttachment.fileType.includes("pdf");
+ const isImage = selectedAttachment.fileType?.startsWith("image/");
+ const isPdf = selectedAttachment.fileType?.includes("pdf");
return (