summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-05-29 05:12:36 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-05-29 05:37:04 +0000
commite484964b1d78cedabbe182c789a8e4c9b53e29d3 (patch)
treed18133dde99e6feb773c95d04f7e79715ab24252
parent37f55540833c2d5894513eca9fc8f7c6233fc2d2 (diff)
(김준회) 기술영업 조선 RFQ 파일첨부 및 채팅 기능 구현 / menuConfig 수정 (벤더 기술영업)
-rw-r--r--app/api/tech-sales-rfq-download/route.ts85
-rw-r--r--app/api/tech-sales-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts259
-rw-r--r--config/menuConfig.ts22
-rw-r--r--lib/mail/templates/tech-sales-quotation-accepted-ko.hbs112
-rw-r--r--lib/mail/templates/tech-sales-quotation-rejected-ko.hbs117
-rw-r--r--lib/mail/templates/tech-sales-quotation-submitted-manager-ko.hbs125
-rw-r--r--lib/mail/templates/tech-sales-quotation-submitted-vendor-ko.hbs122
-rw-r--r--lib/techsales-rfq/repository.ts23
-rw-r--r--lib/techsales-rfq/service.ts1008
-rw-r--r--lib/techsales-rfq/table/create-rfq-dialog.tsx244
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx17
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx91
-rw-r--r--lib/techsales-rfq/table/detail-table/update-vendor-sheet.tsx449
-rw-r--r--lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx22
-rw-r--r--lib/techsales-rfq/table/rfq-table-column.tsx178
-rw-r--r--lib/techsales-rfq/table/rfq-table.tsx117
-rw-r--r--lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx540
-rw-r--r--lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx165
-rw-r--r--lib/techsales-rfq/vendor-response/detail/communication-tab.tsx292
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx55
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx72
21 files changed, 3151 insertions, 964 deletions
diff --git a/app/api/tech-sales-rfq-download/route.ts b/app/api/tech-sales-rfq-download/route.ts
new file mode 100644
index 00000000..b9dd14d1
--- /dev/null
+++ b/app/api/tech-sales-rfq-download/route.ts
@@ -0,0 +1,85 @@
+import { NextRequest } from "next/server"
+import { join } from "path"
+import { readFile } from "fs/promises"
+
+export async function GET(request: NextRequest) {
+ try {
+ const searchParams = request.nextUrl.searchParams
+ const filePath = searchParams.get("path")
+
+ if (!filePath) {
+ return new Response("File path is required", { status: 400 })
+ }
+
+ // 보안: 경로 조작 방지
+ if (filePath.includes("..") || !filePath.startsWith("/techsales-rfq/")) {
+ return new Response("Invalid file path", { status: 400 })
+ }
+
+ // 파일 경로 구성 (public 폴더 기준)
+ const fullPath = join(process.cwd(), "public", filePath)
+
+ try {
+ // 파일 읽기
+ const fileBuffer = await readFile(fullPath)
+
+ // 파일명 추출
+ const fileName = filePath.split("/").pop() || "download"
+
+ // MIME 타입 결정
+ const ext = fileName.split(".").pop()?.toLowerCase()
+ let contentType = "application/octet-stream"
+
+ switch (ext) {
+ case "pdf":
+ contentType = "application/pdf"
+ break
+ case "doc":
+ contentType = "application/msword"
+ break
+ case "docx":
+ contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ break
+ case "xls":
+ contentType = "application/vnd.ms-excel"
+ break
+ case "xlsx":
+ contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ break
+ case "jpg":
+ case "jpeg":
+ contentType = "image/jpeg"
+ break
+ case "png":
+ contentType = "image/png"
+ break
+ case "gif":
+ contentType = "image/gif"
+ break
+ case "txt":
+ contentType = "text/plain"
+ break
+ case "zip":
+ contentType = "application/zip"
+ break
+ default:
+ contentType = "application/octet-stream"
+ }
+
+ // 응답 헤더 설정
+ const headers = new Headers({
+ "Content-Type": contentType,
+ "Content-Disposition": `attachment; filename="${encodeURIComponent(fileName)}"`,
+ "Content-Length": fileBuffer.length.toString(),
+ })
+
+ return new Response(fileBuffer, { headers })
+ } catch (fileError) {
+ console.error("File read error:", fileError)
+ return new Response("File not found", { status: 404 })
+ }
+ } catch (error) {
+ console.error("Download error:", error)
+ return new Response("Internal server error", { status: 500 })
+ }
+} \ No newline at end of file
diff --git a/app/api/tech-sales-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts b/app/api/tech-sales-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts
new file mode 100644
index 00000000..187e4e4f
--- /dev/null
+++ b/app/api/tech-sales-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts
@@ -0,0 +1,259 @@
+import { NextRequest, NextResponse } from "next/server"
+
+import db from '@/db/db';
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+
+import { techSalesRfqComments, techSalesRfqCommentAttachments, users } from "@/db/schema"
+import { revalidateTag } from "next/cache"
+import { eq, and } from "drizzle-orm"
+
+// 파일 저장을 위한 유틸리티
+import { writeFile, mkdir } from 'fs/promises'
+import { join } from 'path'
+import crypto from 'crypto'
+
+/**
+ * 코멘트 조회 API 엔드포인트
+ */
+export async function GET(
+ request: NextRequest,
+ { params }: { params: { rfqId: string; vendorId: string } }
+) {
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json(
+ { success: false, message: "인증이 필요합니다" },
+ { status: 401 }
+ )
+ }
+
+ const rfqId = parseInt(params.rfqId)
+ const vendorId = parseInt(params.vendorId)
+
+ // 유효성 검사
+ if (isNaN(rfqId) || isNaN(vendorId)) {
+ return NextResponse.json(
+ { success: false, message: "유효하지 않은 매개변수입니다" },
+ { status: 400 }
+ )
+ }
+
+ // 코멘트 조회 (첨부파일 별도 조회)
+ const comments = await db
+ .select({
+ id: techSalesRfqComments.id,
+ rfqId: techSalesRfqComments.rfqId,
+ vendorId: techSalesRfqComments.vendorId,
+ userId: techSalesRfqComments.userId,
+ content: techSalesRfqComments.content,
+ isVendorComment: techSalesRfqComments.isVendorComment,
+ createdAt: techSalesRfqComments.createdAt,
+ updatedAt: techSalesRfqComments.updatedAt,
+ isRead: techSalesRfqComments.isRead,
+ userName: users.name,
+ })
+ .from(techSalesRfqComments)
+ .leftJoin(users, eq(techSalesRfqComments.userId, users.id))
+ .where(
+ and(
+ eq(techSalesRfqComments.rfqId, rfqId),
+ eq(techSalesRfqComments.vendorId, vendorId)
+ )
+ )
+ .orderBy(techSalesRfqComments.createdAt);
+
+ // 각 코멘트의 첨부파일 조회
+ const formattedComments = await Promise.all(
+ comments.map(async (comment) => {
+ const attachments = await db
+ .select({
+ id: techSalesRfqCommentAttachments.id,
+ fileName: techSalesRfqCommentAttachments.fileName,
+ fileSize: techSalesRfqCommentAttachments.fileSize,
+ fileType: techSalesRfqCommentAttachments.fileType,
+ filePath: techSalesRfqCommentAttachments.filePath,
+ uploadedAt: techSalesRfqCommentAttachments.uploadedAt,
+ })
+ .from(techSalesRfqCommentAttachments)
+ .where(eq(techSalesRfqCommentAttachments.commentId, comment.id));
+
+ return {
+ ...comment,
+ attachments,
+ };
+ })
+ );
+
+ return NextResponse.json({
+ success: true,
+ data: formattedComments
+ })
+ } catch (error) {
+ console.error("techSales 코멘트 조회 오류:", error)
+ return NextResponse.json(
+ { success: false, message: "코멘트 조회 중 오류가 발생했습니다" },
+ { status: 500 }
+ )
+ }
+}
+
+/**
+ * 코멘트 생성 API 엔드포인트
+ */
+export async function POST(
+ request: NextRequest,
+ { params }: { params: { rfqId: string; vendorId: string } }
+) {
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json(
+ { success: false, message: "인증이 필요합니다" },
+ { status: 401 }
+ )
+ }
+
+ const rfqId = parseInt(params.rfqId)
+ const vendorId = parseInt(params.vendorId)
+
+ // 유효성 검사
+ if (isNaN(rfqId) || isNaN(vendorId)) {
+ return NextResponse.json(
+ { success: false, message: "유효하지 않은 매개변수입니다" },
+ { status: 400 }
+ )
+ }
+
+ // FormData 파싱
+ const formData = await request.formData()
+ const content = formData.get("content") as string
+ const isVendorComment = formData.get("isVendorComment") === "true"
+ const files = formData.getAll("attachments") as File[]
+
+ console.log("POST 요청 파라미터:", { rfqId, vendorId, content, isVendorComment, filesCount: files.length });
+
+ if (!content && files.length === 0) {
+ return NextResponse.json(
+ { success: false, message: "내용이나 첨부파일이 필요합니다" },
+ { status: 400 }
+ )
+ }
+
+ // 세션 사용자 ID 확인
+ if (!session.user.id) {
+ return NextResponse.json(
+ { success: false, message: "사용자 ID를 찾을 수 없습니다" },
+ { status: 400 }
+ )
+ }
+
+ // 코멘트 생성
+ console.log("코멘트 생성 시도:", {
+ rfqId,
+ vendorId,
+ userId: parseInt(session.user.id),
+ content,
+ isVendorComment,
+ });
+
+ const [comment] = await db
+ .insert(techSalesRfqComments)
+ .values({
+ rfqId,
+ vendorId,
+ userId: parseInt(session.user.id),
+ content,
+ isVendorComment,
+ isRead: !isVendorComment, // 본인 메시지는 읽음 처리
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning()
+
+ console.log("코멘트 생성 성공:", comment);
+
+ // 첨부파일 처리
+ const attachments = []
+ if (files.length > 0) {
+ console.log("첨부파일 처리 시작:", files.length);
+
+ // 디렉토리 생성
+ const uploadDir = join(process.cwd(), "public", `tech-sales-rfq-${rfqId}`, `vendor-${vendorId}`, `comment-${comment.id}`)
+ await mkdir(uploadDir, { recursive: true })
+
+ // 각 파일 저장
+ for (const file of files) {
+ const buffer = Buffer.from(await file.arrayBuffer())
+ const filename = `${Date.now()}-${crypto.randomBytes(8).toString("hex")}-${file.name.replace(/[^a-zA-Z0-9.-]/g, "_")}`
+ const filePath = join(uploadDir, filename)
+
+ // 파일 쓰기
+ await writeFile(filePath, buffer)
+
+ // DB에 첨부파일 정보 저장
+ const [attachment] = await db
+ .insert(techSalesRfqCommentAttachments)
+ .values({
+ rfqId,
+ commentId: comment.id,
+ fileName: file.name,
+ fileSize: file.size,
+ fileType: file.type,
+ filePath: `/tech-sales-rfq-${rfqId}/vendor-${vendorId}/comment-${comment.id}/${filename}`,
+ isVendorUpload: isVendorComment,
+ uploadedBy: parseInt(session.user.id),
+ vendorId,
+ uploadedAt: new Date(),
+ })
+ .returning()
+
+ attachments.push({
+ id: attachment.id,
+ fileName: attachment.fileName,
+ fileSize: attachment.fileSize,
+ fileType: attachment.fileType,
+ filePath: attachment.filePath,
+ uploadedAt: attachment.uploadedAt
+ })
+ }
+
+ console.log("첨부파일 처리 완료:", attachments.length);
+ }
+
+ // 캐시 무효화
+ revalidateTag(`tech-sales-rfq-${rfqId}-comments`)
+
+ // 응답 데이터 구성
+ const responseData = {
+ id: comment.id,
+ rfqId: comment.rfqId,
+ vendorId: comment.vendorId,
+ userId: comment.userId,
+ content: comment.content,
+ isVendorComment: comment.isVendorComment,
+ createdAt: comment.createdAt,
+ updatedAt: comment.updatedAt,
+ userName: session.user.name,
+ attachments,
+ isRead: comment.isRead
+ }
+
+ console.log("응답 데이터:", responseData);
+
+ return NextResponse.json({
+ success: true,
+ data: { comment: responseData }
+ })
+ } catch (error) {
+ console.error("techSales 코멘트 생성 오류:", error)
+ console.error("Error stack:", error instanceof Error ? error.stack : "Unknown error");
+ return NextResponse.json(
+ { success: false, message: "코멘트 생성 중 오류가 발생했습니다", error: error instanceof Error ? error.message : "Unknown error" },
+ { status: 500 }
+ )
+ }
+} \ No newline at end of file
diff --git a/config/menuConfig.ts b/config/menuConfig.ts
index 8dadb5a7..cc529b29 100644
--- a/config/menuConfig.ts
+++ b/config/menuConfig.ts
@@ -304,7 +304,6 @@ export const mainNavVendor: MenuSection[] = [
{
title: "구매 관리",
- useGrouping: true,
items: [
{
title: "기본 계약 서명",
@@ -312,28 +311,9 @@ export const mainNavVendor: MenuSection[] = [
description: "기본 계약서 및 관련 문서에 대한 서명",
},
{
- title: "기술영업 - 조선 RFQ",
+ title: "기술영업 - 조선 통합 RFQ",
href: `/partners/techsales/rfq-ship`,
description: "견적 요청에 대한 응답 작성",
- group: "기술영업"
- },
- {
- title: "기술영업 - 해양 RFQ",
- href: `/partners/rfq-tech`,
- description: "견적 요청에 대한 응답 작성",
- group: "기술영업"
- },
- {
- title: "기술영업 - 해양 TBE",
- href: `/partners/tbe-tech`,
- description: "TBE 요청에 대한 응답 작성",
- group: "기술영업"
- },
- {
- title: "기술영업 - 해양 CBE",
- href: `/partners/cbe-tech`,
- description: "CBE 요청에 대한 응답 작성",
- group: "기술영업"
},
{
title: "RFQ",
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 }}
+
+<h1 style="font-size:28px; line-height:40px; margin-bottom:16px;">
+ 견적 선택 안내 - RFQ NO. : #{{rfq.code}}
+</h1>
+
+<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
+ 안녕하세요, <strong>{{vendor.name}}</strong>님.
+</p>
+
+<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
+ <strong style="color: #163CC4;">축하드립니다!</strong>
+ 귀하께서 제출하신 견적이 선택되었습니다.
+</p>
+
+<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
+ 본 견적은 프로젝트 수주 과정에서 참조될 예정입니다. 견적의 선택이 추후 계약을 보장하지는 않는다는 점을 유의해 주시기 바랍니다.
+</p>
+
+<div style="margin-bottom:24px;">
+ <h2 style="font-size:20px; margin-bottom:12px;">가. 선택된 견적서 정보</h2>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>1) RFQ 번호 : {{rfq.code}}</strong>
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>2) 프로젝트 : {{project.name}}</strong>
+ <br>* 프로젝트 코드 : {{rfq.projectCode}}
+ {{#if project.sector}}
+ <br>* 부문 : {{project.sector}}
+ {{/if}}
+ {{#if project.shipCount}}
+ <br>* 척수 : {{project.shipCount}}척
+ {{/if}}
+ {{#if project.ownerName}}
+ <br>* 선주 : {{project.ownerName}}
+ {{/if}}
+ {{#if project.className}}
+ <br>* 선급 : {{project.className}}
+ {{/if}}
+ {{#if project.shipModelName}}
+ <br>* 선형 : {{project.shipModelName}}
+ {{/if}}
+ </p>
+
+ {{#if series}}
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>* 시리즈별 K/L 일정 (Keel Laying Quarter)</strong>
+ {{#each series}}
+ <br> - {{sersNo}}호선: {{klQuarter}}
+ {{/each}}
+ </p>
+ {{/if}}
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>3) 자재명 : {{rfq.title}}</strong>
+ {{#if rfq.materialCode}}
+ <br>* 자재그룹 코드 : {{rfq.materialCode}}
+ {{/if}}
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>4) 선택된 견적 금액 : {{quotation.currency}} {{quotation.totalPrice}}</strong>
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>5) 견적 유효기간 : {{quotation.validUntil}}</strong>
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>6) 선택일시 : {{quotation.acceptedAt}}</strong>
+ </p>
+ {{#if quotation.remark}}
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>7) 견적서 특이사항</strong>
+ <br>{{quotation.remark}}
+ </p>
+ {{/if}}
+</div>
+
+<div style="margin-bottom:24px;">
+ <h2 style="font-size:20px; margin-bottom:12px;">다. 담당자 연락처</h2>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>기술영업 담당자</strong>
+ <br>* 담당자 : {{manager.name}}
+ <br>* 이메일 : {{manager.email}}
+ </p>
+</div>
+
+<div style="margin-bottom:24px;">
+ <h2 style="font-size:20px; margin-bottom:12px;">라. 유의사항</h2>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>1) 유의사항</strong>
+ <br>본 견적은 프로젝트 수주 과정에서 참조될 예정입니다. 견적의 선택이 추후 계약을 보장하지는 않는다는 점을 유의해 주시기 바랍니다.
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>2) 기밀 유지</strong>
+ <br>프로젝트 관련 모든 정보는 기밀로 관리해 주시기 바랍니다.
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>3) 협력 관계</strong>
+ <br>성공적인 프로젝트 완수를 위해 적극적인 협력을 부탁드립니다.
+ </p>
+</div>
+
+<p>
+ <a href="{{systemUrl}}" target="_blank" style="display: inline-block; width: 250px; padding: 12px 20px; background-color: #163CC4; color: #ffffff !important; text-decoration: none; border-radius: 8px; text-align: center; line-height: 28px;">
+ 기술영업에 제출된 견적 목록 확인
+ </a>
+</p>
+
+<p style="font-size:14px; line-height:24px; margin-top:24px; color: #666;">
+ 견적 제출에 감사드리며, 앞으로도 좋은 협력 관계를 기대합니다.<br>
+ {{companyName}} 기술영업팀
+</p>
+
+{{> 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 }}
+
+<h1 style="font-size:28px; line-height:40px; margin-bottom:16px;">
+ 견적 검토 결과 안내 - RFQ NO. : #{{rfq.code}}
+</h1>
+
+<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
+ 안녕하세요, <strong>{{vendor.name}}</strong>님.
+</p>
+
+<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
+ 귀하께서 제출해주신 기술영업 견적서에 대한 검토 결과를 안내드립니다.
+</p>
+
+<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
+ 아쉽게도 이번 건에서는 다른 업체의 견적이 선택되었습니다.
+ 귀중한 시간을 할애하여 기술영업 견적서를 작성해 주신 점에 대해 깊이 감사드립니다.
+ <strong>본 기술영업 견적이 향후 실제 계약을 위한 구매 부서의 견적 요청을 제한하지 않는다는 점을 말씀드립니다.</strong>
+</p>
+
+<div style="margin-bottom:24px;">
+ <h2 style="font-size:20px; margin-bottom:12px;">가. 검토 대상 견적서 정보</h2>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>1) RFQ 번호 : {{rfq.code}}</strong>
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>2) 프로젝트 : {{project.name}}</strong>
+ <br>* 프로젝트 코드 : {{rfq.projectCode}}
+ {{#if project.sector}}
+ <br>* 부문 : {{project.sector}}
+ {{/if}}
+ {{#if project.shipCount}}
+ <br>* 척수 : {{project.shipCount}}척
+ {{/if}}
+ {{#if project.ownerName}}
+ <br>* 선주 : {{project.ownerName}}
+ {{/if}}
+ {{#if project.className}}
+ <br>* 선급 : {{project.className}}
+ {{/if}}
+ {{#if project.shipModelName}}
+ <br>* 선형 : {{project.shipModelName}}
+ {{/if}}
+ </p>
+
+ {{#if series}}
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>* 시리즈별 K/L 일정 (Keel Laying Quarter)</strong>
+ {{#each series}}
+ <br> - {{sersNo}}호선: {{klQuarter}}
+ {{/each}}
+ </p>
+ {{/if}}
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>3) 자재명 : {{rfq.title}}</strong>
+ {{#if rfq.materialCode}}
+ <br>* 자재그룹 코드 : {{rfq.materialCode}}
+ {{/if}}
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>4) 제출하신 견적 금액 : {{quotation.currency}} {{quotation.totalPrice}}</strong>
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>5) 견적 유효기간 : {{quotation.validUntil}}</strong>
+ </p>
+ {{#if quotation.remark}}
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>6) 제출하신 특이사항</strong>
+ <br>{{quotation.remark}}
+ </p>
+ {{/if}}
+</div>
+
+<div style="margin-bottom:24px;">
+ <h2 style="font-size:20px; margin-bottom:12px;">나. 검토 결과</h2>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>1) 결과 : 미선정</strong>
+ <br>이번 RFQ에서는 다른 업체가 선정되었습니다.
+ </p>
+ {{#if quotation.rejectionReason}}
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>2) 참고사항</strong>
+ <br>{{quotation.rejectionReason}}
+ </p>
+ {{/if}}
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>3) 감사 인사</strong>
+ <br>* 귀사의 견적서 응답에 진심으로 감사드립니다.
+ </p>
+</div>
+
+<div style="margin-bottom:24px;">
+ <h2 style="font-size:20px; margin-bottom:12px;">라. 담당자 연락처</h2>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>기술영업 담당자</strong>
+ <br>* 담당자 : {{manager.name}}
+ <br>* 이메일 : {{manager.email}}
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>문의 사항</strong>
+ <br>* 견적 관련 피드백이나 향후 협력 방안에 대해 문의하시기 바랍니다.
+ <br>* 새로운 사업 기회나 기술 제휴에 대해서도 언제든 연락 주시기 바랍니다.
+ </p>
+</div>
+
+<p>
+ <a href="{{systemUrl}}" target="_blank" style="display: inline-block; width: 250px; padding: 12px 20px; background-color: #163CC4; color: #ffffff !important; text-decoration: none; border-radius: 8px; text-align: center; line-height: 28px;">
+ 기술영업 수신견적 목록 보기
+ </a>
+</p>
+
+<p style="font-size:14px; line-height:24px; margin-top:24px; color: #666;">
+ 다시 한번 견적서 제출에 감사드리며, 향후 좋은 협력 기회가 있기를 기대합니다.<br>
+ {{companyName}} 기술영업
+</p>
+
+{{> 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 }}
+
+<h1 style="font-size:28px; line-height:40px; margin-bottom:16px;">
+ 견적서 접수 알림 - RFQ NO. : #{{rfq.code}}
+</h1>
+
+<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
+ 안녕하세요, <strong>{{manager.name}}</strong>님.
+</p>
+
+<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
+ <strong>{{vendor.name}}</strong>에서 견적서를 제출했습니다.
+</p>
+
+<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
+ 견적서 검토 후 선택 여부를 결정해 주시기 바랍니다.
+ 시스템에서 견적서 상세 내용을 확인하실 수 있습니다.
+</p>
+
+<div style="margin-bottom:24px;">
+ <h2 style="font-size:20px; margin-bottom:12px;">가. 접수된 견적서 정보</h2>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>1) RFQ 번호 : {{rfq.code}}</strong>
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>2) 프로젝트 : {{project.name}}</strong>
+ <br>* 프로젝트 코드 : {{rfq.projectCode}}
+ {{#if project.sector}}
+ <br>* 부문 : {{project.sector}}
+ {{/if}}
+ {{#if project.shipCount}}
+ <br>* 척수 : {{project.shipCount}}척
+ {{/if}}
+ {{#if project.ownerName}}
+ <br>* 선주 : {{project.ownerName}}
+ {{/if}}
+ {{#if project.className}}
+ <br>* 선급 : {{project.className}}
+ {{/if}}
+ {{#if project.shipModelName}}
+ <br>* 선형 : {{project.shipModelName}}
+ {{/if}}
+ </p>
+
+ {{#if series}}
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>* 시리즈별 K/L 일정 (Keel Laying Quarter)</strong>
+ {{#each series}}
+ <br> - {{sersNo}}호선: {{klQuarter}}
+ {{/each}}
+ </p>
+ {{/if}}
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>3) 자재명 : {{rfq.title}}</strong>
+ {{#if rfq.materialCode}}
+ <br>* 자재그룹 코드 : {{rfq.materialCode}}
+ {{/if}}
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>4) 제출 벤더</strong>
+ <br>* 벤더명 : {{vendor.name}}
+ {{#if vendor.code}}
+ <br>* 벤더코드 : {{vendor.code}}
+ {{/if}}
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>5) 견적 금액 : {{quotation.currency}} {{quotation.totalPrice}}</strong>
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>6) 견적 유효기간 : {{quotation.validUntil}}</strong>
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>7) 제출일시 : {{quotation.submittedAt}}</strong>
+ </p>
+ {{#if quotation.remark}}
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>8) 벤더 특이사항</strong>
+ <br>{{quotation.remark}}
+ </p>
+ {{/if}}
+</div>
+
+<div style="margin-bottom:24px;">
+ <h2 style="font-size:20px; margin-bottom:12px;">나. 검토 및 선택 안내</h2>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>1) 견적서 검토</strong>
+ <br>* 시스템에 접속하여 견적서 상세 내용을 확인하실 수 있습니다.
+ <br>* 견적 비교 기능을 통해 다른 벤더들과 비교 검토가 가능합니다.
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>2) 견적 선택/거절</strong>
+ <br>* 검토 완료 후 시스템에서 견적 선택 또는 거절 처리를 해주시기 바랍니다.
+ <br>* 선택/거절 시 벤더에게 자동으로 결과 통보 이메일이 발송됩니다.
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>3) 추가 문의</strong>
+ <br>* 견적 내용에 대해 추가 문의사항이 있는 경우 벤더와 직접 커뮤니케이션하실 수 있습니다.
+ <br>* 시스템의 메시지 기능을 이용해 주시기 바랍니다.
+ </p>
+</div>
+
+<div style="margin-bottom:24px;">
+ <h2 style="font-size:20px; margin-bottom:12px;">다. 처리 기한 안내</h2>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>1) 견적 유효기간 : {{quotation.validUntil}}</strong>
+ <br>견적 유효기간 내에 검토 및 선택을 완료해 주시기 바랍니다.
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>2) 신속한 처리 요청</strong>
+ <br>* 벤더가 제출한 견적서에 대해 신속한 검토를 부탁드립니다.
+ <br>* 지연 시 벤더에게 별도 안내가 필요할 수 있습니다.
+ </p>
+</div>
+
+<p>
+ <a href="{{systemUrl}}" target="_blank" style="display: inline-block; width: 250px; padding: 12px 20px; background-color: #163CC4; color: #ffffff !important; text-decoration: none; border-radius: 8px; text-align: center; line-height: 28px;">
+ 견적서 검토하기
+ </a>
+</p>
+
+<p style="font-size:14px; line-height:24px; margin-top:24px; color: #666;">
+ {{companyName}} 기술영업시스템에서 자동 발송된 메일입니다.
+</p>
+
+{{> 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 }}
+
+<h1 style="font-size:28px; line-height:40px; margin-bottom:16px;">
+ 견적서 제출 완료 확인서 - RFQ NO. : #{{rfq.code}}
+</h1>
+
+<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
+ 안녕하세요, <strong>{{vendor.name}}</strong>님.
+</p>
+
+<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
+ 귀하께서 제출하신 견적서가 성공적으로 접수되었음을 확인드립니다.
+</p>
+
+<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
+ 제출해주신 견적서는 당사 기술영업 담당자가 검토 후 결과를 안내드릴 예정입니다.
+ 견적 검토 과정에서 추가 문의사항이 있을 경우 별도로 연락드리겠습니다.
+</p>
+
+<div style="margin-bottom:24px;">
+ <h2 style="font-size:20px; margin-bottom:12px;">가. 제출된 견적서 정보</h2>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>1) RFQ 번호 : {{rfq.code}}</strong>
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>2) 프로젝트 : {{project.name}}</strong>
+ <br>* 프로젝트 코드 : {{rfq.projectCode}}
+ {{#if project.sector}}
+ <br>* 부문 : {{project.sector}}
+ {{/if}}
+ {{#if project.shipCount}}
+ <br>* 척수 : {{project.shipCount}}척
+ {{/if}}
+ {{#if project.ownerName}}
+ <br>* 선주 : {{project.ownerName}}
+ {{/if}}
+ {{#if project.className}}
+ <br>* 선급 : {{project.className}}
+ {{/if}}
+ {{#if project.shipModelName}}
+ <br>* 선형 : {{project.shipModelName}}
+ {{/if}}
+ </p>
+
+ {{#if series}}
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>* 시리즈별 K/L 일정 (Keel Laying Quarter)</strong>
+ {{#each series}}
+ <br> - {{sersNo}}호선: {{klQuarter}}
+ {{/each}}
+ </p>
+ {{/if}}
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>3) 자재명 : {{rfq.title}}</strong>
+ {{#if rfq.materialCode}}
+ <br>* 자재그룹 코드 : {{rfq.materialCode}}
+ {{/if}}
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>4) 견적 금액 : {{quotation.currency}} {{quotation.totalPrice}}</strong>
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>5) 견적 유효기간 : {{quotation.validUntil}}</strong>
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>6) 제출일시 : {{quotation.submittedAt}}</strong>
+ </p>
+ {{#if quotation.remark}}
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>7) 특이사항</strong>
+ <br>{{quotation.remark}}
+ </p>
+ {{/if}}
+</div>
+
+<div style="margin-bottom:24px;">
+ <h2 style="font-size:20px; margin-bottom:12px;">나. 다음 단계 안내</h2>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>1) 견적 검토 과정</strong>
+ <br>* 당사 기술영업 담당자가 제출하신 견적서를 검토합니다.
+ <br>* 검토 과정에서 추가 자료나 설명이 필요한 경우 연락드리겠습니다.
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>2) 결과 통보</strong>
+ <br>* 견적 검토 완료 후 선택 여부를 이메일로 안내드립니다.
+ <br>* 선택되신 경우 후속 절차에 대해 별도 안내드리겠습니다.
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>3) 문의사항</strong>
+ <br>* 담당자 : {{manager.name}}
+ <br>* 이메일 : {{manager.email}}
+ </p>
+</div>
+
+<div style="margin-bottom:24px;">
+ <h2 style="font-size:20px; margin-bottom:12px;">다. 유의사항</h2>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>1) 견적서 제출이 완료되었습니다.</strong>
+ <br>견적서 수정이 필요한 경우 담당자에게 문의하시기 바랍니다.
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>2) 견적 유효기간을 준수해 주시기 바랍니다.</strong>
+ <br>유효기간 만료 전 견적 선택이 이루어지지 않을 경우, 재견적을 요청할 수 있습니다.
+ </p>
+ <p style="font-size:16px; line-height:24px; margin-bottom:8px;">
+ <strong>3) 제출하신 견적서는 기밀로 관리됩니다.</strong>
+ <br>견적 정보는 당사 내부 검토 목적으로만 사용됩니다.
+ </p>
+</div>
+
+<p>
+ <a href="{{systemUrl}}" target="_blank" style="display: inline-block; width: 250px; padding: 12px 20px; background-color: #163CC4; color: #ffffff !important; text-decoration: none; border-radius: 8px; text-align: center; line-height: 28px;">
+ 견적서 현황 확인하기
+ </a>
+</p>
+
+<p style="font-size:14px; line-height:24px; margin-top:24px; color: #666;">
+ 감사합니다.<br>
+ {{companyName}} 기술영업팀
+</p>
+
+{{> 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<string>`${techSalesRfqs.projectSnapshot}->>'pspid'`,
@@ -221,14 +221,14 @@ export async function selectTechSalesVendorQuotationsWithJoin(
// 첨부파일 개수
attachmentCount: sql<number>`(
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<string>`${techSalesRfqs.projectSnapshot}->>'pspid'`,
@@ -363,7 +364,7 @@ export async function selectTechSalesDashboardWithJoin(
createdByName: sql<string>`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<string>`${techSalesRfqs.projectSnapshot}->>'projNm'`,
+ // 첨부파일 개수
+ attachmentCount: sql<number>`(
+ 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<string, any>) || {};
+
+ // 시리즈 정보 처리
+ 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<string, any>) || {};
+
+ // 시리즈 정보 처리
+ 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<string, any>) || {};
+
+ // 시리즈 정보 처리
+ 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<string, any>) || {};
+
+ // 시리즈 정보 처리
+ 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<TechSalesComment[]> {
+ 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<void> {
+ 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<string, typeof selectedItems>)
+
+ 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) {
아이템을 불러오는 중...
</div>
) : availableItems.length > 0 ? (
- availableItems.map((item) => {
- const isSelected = selectedItems.some(selected => selected.id === item.id)
- return (
- <div
- key={item.id}
- className={cn(
- "flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted",
- isSelected && "bg-muted"
- )}
- onClick={() => handleItemToggle(item)}
- >
- <div className="flex items-center space-x-2 flex-1">
- {isSelected ? (
- <CheckSquare className="h-4 w-4" />
- ) : (
- <Square className="h-4 w-4" />
+ [...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 (
+ <div
+ key={item.id}
+ className={cn(
+ "flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted",
+ isSelected && "bg-muted"
)}
- <div className="flex-1">
- <div className="font-medium">
- {item.itemList || item.itemName}
- </div>
- <div className="text-sm text-muted-foreground">
- {item.itemCode} • {item.description || '설명 없음'}
- </div>
- <div className="text-xs text-muted-foreground">
- 공종: {item.workType} • 선종: {item.shipTypes}
+ onClick={() => handleItemToggle(item)}
+ >
+ <div className="flex items-center space-x-2 flex-1">
+ {isSelected ? (
+ <CheckSquare className="h-4 w-4" />
+ ) : (
+ <Square className="h-4 w-4" />
+ )}
+ <div className="flex-1">
+ <div className="font-medium">
+ {item.itemList || item.itemName || '아이템명 없음'}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {item.itemCode} • {item.description || '설명 없음'}
+ </div>
+ <div className="text-xs text-muted-foreground">
+ 공종: {item.workType} • 선종: {item.shipTypes}
+ </div>
</div>
</div>
</div>
- </div>
- )
- })
+ )
+ })
) : (
<div className="text-center py-8 text-muted-foreground">
{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})
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => handleRemoveItem(item.id)}
@@ -498,19 +545,93 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
</FormItem>
)}
/>
+
+ {/* RFQ 그룹핑 미리보기 */}
+ {selectedItems.length > 0 && (
+ <div className="space-y-3">
+ <FormLabel>생성될 RFQ 그룹 미리보기</FormLabel>
+ <div className="border rounded-md p-3 bg-background">
+ {(() => {
+ // 아이템명(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<string, typeof selectedItems>)
+
+ 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 (
+ <div className="space-y-3">
+ <div className="text-sm text-muted-foreground">
+ 총 {rfqGroups.length}개의 RFQ가 생성됩니다 (아이템명별로 그룹핑)
+ </div>
+ {rfqGroups.map((group, index) => (
+ <div
+ key={group.actualItemName}
+ className={cn(
+ "p-3 border rounded-md space-y-2",
+ group.isOverLimit && "border-destructive bg-destructive/5"
+ )}
+ >
+ <div className="flex items-center justify-between">
+ <div className="font-medium">
+ RFQ #{index + 1}: {group.actualItemName}
+ </div>
+ <Badge variant={group.isOverLimit ? "destructive" : "secondary"}>
+ {group.itemCodes.length}개 자재코드 ({group.codeLength}/255자)
+ </Badge>
+ </div>
+ <div className="text-sm text-muted-foreground">
+ 자재코드: {group.joinedItemCodes}
+ </div>
+ {group.isOverLimit && (
+ <div className="text-sm text-destructive">
+ ⚠️ 자재코드 길이가 255자를 초과합니다. 일부 아이템을 제거해주세요.
+ </div>
+ )}
+ <div className="text-xs text-muted-foreground">
+ 포함된 아이템: {group.items.map(item => `${item.itemCode}`).join(', ')}
+ </div>
+ </div>
+ ))}
+ </div>
+ )
+ })()}
+ </div>
+ </div>
+ )}
</div>
</div>
)}
{/* 안내 메시지 */}
- {selectedProject && (
+ {/* {selectedProject && (
<div className="text-sm text-muted-foreground bg-muted p-3 rounded-md">
<p>• 공종별 조선 아이템을 선택하세요.</p>
- <p>• 선택된 아이템의 자재코드(item_code)별로 개별 RFQ가 생성됩니다.</p>
- <p>• 아이템 코드가 자재 그룹 코드로 사용됩니다.</p>
+ <p>• <strong>같은 아이템명의 다른 자재코드들은 하나의 RFQ로 그룹핑됩니다.</strong></p>
+ <p>• 그룹핑된 자재코드들은 comma로 구분되어 저장됩니다.</p>
+ <p>• 자재코드 길이는 최대 255자까지 가능합니다.</p>
<p>• 마감일은 벤더가 견적을 제출해야 하는 날짜입니다.</p>
</div>
- )}
+ )} */}
<div className="flex justify-end space-x-2 pt-4">
<Button
@@ -523,9 +644,44 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
</Button>
<Button
type="submit"
- disabled={isProcessing || !selectedProject || selectedItems.length === 0}
+ disabled={
+ isProcessing ||
+ !selectedProject ||
+ selectedItems.length === 0 ||
+ // 255자 초과 그룹이 있는지 확인
+ (() => {
+ const groupedItems = selectedItems.reduce((groups, item) => {
+ const actualItemName = item.itemList // 실제 조선 아이템명
+ if (!actualItemName) {
+ return groups // itemList가 없는 경우 제외
+ }
+ if (!groups[actualItemName]) {
+ groups[actualItemName] = []
+ }
+ groups[actualItemName].push(item.itemCode)
+ return groups
+ }, {} as Record<string, string[]>)
+
+ return Object.values(groupedItems).some(itemCodes => itemCodes.join(',').length > 255)
+ })()
+ }
>
- {isProcessing ? "처리 중..." : `${selectedItems.length}개 자재코드로 생성하기`}
+ {isProcessing ? "처리 중..." : (() => {
+ const groupedItems = selectedItems.reduce((groups, item) => {
+ const actualItemName = item.itemList // 실제 조선 아이템명
+ if (!actualItemName) {
+ return groups // itemList가 없는 경우 제외
+ }
+ if (!groups[actualItemName]) {
+ groups[actualItemName] = []
+ }
+ groups[actualItemName].push(item.itemCode)
+ return groups
+ }, {} as Record<string, string[]>)
+
+ const groupCount = Object.keys(groupedItems).length
+ return `${groupCount}개 아이템 그룹으로 생성하기`
+ })()}
</Button>
</div>
</form>
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<TData> {
row: Row<TData>;
- 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 (
<div className="text-right flex items-center justify-end gap-1">
@@ -269,9 +277,12 @@ export function getRfqDetailColumns({
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[160px]">
<DropdownMenuItem
- onClick={() => setRowAction({ row, type: "update" })}
+ onClick={handleViewDetails}
+ disabled={!vendorId}
+ className="gap-2"
>
- 벤더 수정
+ {/* <Eye className="h-4 w-4" /> */}
+ 벤더 상세정보
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => 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<RfqDetailView[]>([])
const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false)
- const [updateSheetOpen, setUpdateSheetOpen] = React.useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false)
const [selectedDetail, setSelectedDetail] = React.useState<RfqDetailView | null>(null)
- const [vendors, setVendors] = React.useState<Vendor[]>([])
- const [currencies, setCurrencies] = React.useState<Currency[]>([])
- const [paymentTerms, setPaymentTerms] = React.useState<PaymentTerm[]>([])
- const [incoterms, setIncoterms] = React.useState<Incoterm[]>([])
const [isAdddialogLoading, setIsAdddialogLoading] = useState(false)
const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDetailView> | 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}
/>
- <UpdateVendorSheet
- open={updateSheetOpen}
- onOpenChange={setUpdateSheetOpen}
- detail={selectedDetail}
- vendors={vendors}
- currencies={currencies}
- paymentTerms={paymentTerms}
- incoterms={incoterms}
- onSuccess={handleRefreshData}
- />
-
<DeleteVendorDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
diff --git a/lib/techsales-rfq/table/detail-table/update-vendor-sheet.tsx b/lib/techsales-rfq/table/detail-table/update-vendor-sheet.tsx
deleted file mode 100644
index 0399f4df..00000000
--- a/lib/techsales-rfq/table/detail-table/update-vendor-sheet.tsx
+++ /dev/null
@@ -1,449 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Check, ChevronsUpDown, Loader } from "lucide-react"
-import { useForm } from "react-hook-form"
-import { toast } from "sonner"
-import { z } from "zod"
-
-import { cn } from "@/lib/utils"
-import { Button } from "@/components/ui/button"
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
-} from "@/components/ui/command"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import {
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import { Checkbox } from "@/components/ui/checkbox"
-import { ScrollArea } from "@/components/ui/scroll-area"
-
-import { RfqDetailView } from "./rfq-detail-column"
-import { updateRfqDetail } from "@/lib/procurement-rfqs/services"
-
-// 폼 유효성 검증 스키마
-const updateRfqDetailSchema = z.object({
- vendorId: z.string().min(1, "벤더를 선택해주세요"),
- currency: z.string().min(1, "통화를 선택해주세요"),
- paymentTermsCode: z.string().min(1, "지불 조건을 선택해주세요"),
- incotermsCode: z.string().min(1, "인코텀즈를 선택해주세요"),
- incotermsDetail: z.string().optional(),
- deliveryDate: z.string().optional(),
- taxCode: z.string().optional(),
- placeOfShipping: z.string().optional(),
- placeOfDestination: z.string().optional(),
- materialPriceRelatedYn: z.boolean().default(false),
-})
-
-type UpdateRfqDetailFormValues = z.infer<typeof updateRfqDetailSchema>
-
-// 데이터 타입 정의
-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<typeof Sheet> {
- 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<UpdateRfqDetailFormValues>({
- 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 (
- <Sheet {...props}>
- <SheetContent className="flex w-full flex-col gap-6 sm:max-w-xl">
- <SheetHeader className="text-left">
- <SheetTitle>RFQ 벤더 정보 수정</SheetTitle>
- <SheetDescription>
- 벤더 정보를 수정하고 저장하세요
- </SheetDescription>
- </SheetHeader>
- <ScrollArea className="flex-1 pr-4">
- <Form {...form}>
- <form
- id="update-rfq-detail-form"
- onSubmit={form.handleSubmit(onSubmit)}
- className="flex flex-col gap-4"
- >
- {/* 검색 가능한 벤더 선택 필드 */}
- <FormField
- control={form.control}
- name="vendorId"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>벤더 <span className="text-red-500">*</span></FormLabel>
- <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={vendorOpen}
- className={cn(
- "w-full justify-between",
- !field.value && "text-muted-foreground"
- )}
- >
- {field.value
- ? vendors.find((vendor) => String(vendor.id) === field.value)
- ? `${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorName} (${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorCode})`
- : "벤더를 선택하세요"
- : "벤더를 선택하세요"}
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-[400px] p-0">
- <Command>
- <CommandInput placeholder="벤더 검색..." />
- <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
- <ScrollArea className="h-60">
- <CommandGroup>
- {vendors.map((vendor) => (
- <CommandItem
- key={vendor.id}
- value={`${vendor.vendorName} ${vendor.vendorCode}`}
- onSelect={() => {
- form.setValue("vendorId", String(vendor.id), {
- shouldValidate: true,
- })
- setVendorOpen(false)
- }}
- >
- <Check
- className={cn(
- "mr-2 h-4 w-4",
- String(vendor.id) === field.value
- ? "opacity-100"
- : "opacity-0"
- )}
- />
- {vendor.vendorName} ({vendor.vendorCode})
- </CommandItem>
- ))}
- </CommandGroup>
- </ScrollArea>
- </Command>
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="currency"
- render={({ field }) => (
- <FormItem>
- <FormLabel>통화 <span className="text-red-500">*</span></FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="통화를 선택하세요" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {currencies.map((currency) => (
- <SelectItem key={currency.code} value={currency.code}>
- {currency.name} ({currency.code})
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="paymentTermsCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>지불 조건 <span className="text-red-500">*</span></FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="지불 조건 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {paymentTerms.map((term) => (
- <SelectItem key={term.code} value={term.code}>
- {term.description}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="incotermsCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>인코텀즈 <span className="text-red-500">*</span></FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="인코텀즈 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {incoterms.map((incoterm) => (
- <SelectItem key={incoterm.code} value={incoterm.code}>
- {incoterm.description}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- <FormField
- control={form.control}
- name="incotermsDetail"
- render={({ field }) => (
- <FormItem>
- <FormLabel>인코텀즈 세부사항</FormLabel>
- <FormControl>
- <Input {...field} placeholder="인코텀즈 세부사항" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="deliveryDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>납품 예정일</FormLabel>
- <FormControl>
- <Input {...field} type="date" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="taxCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>세금 코드</FormLabel>
- <FormControl>
- <Input {...field} placeholder="세금 코드" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="placeOfShipping"
- render={({ field }) => (
- <FormItem>
- <FormLabel>선적지</FormLabel>
- <FormControl>
- <Input {...field} placeholder="선적지" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="placeOfDestination"
- render={({ field }) => (
- <FormItem>
- <FormLabel>도착지</FormLabel>
- <FormControl>
- <Input {...field} placeholder="도착지" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- <FormField
- control={form.control}
- name="materialPriceRelatedYn"
- render={({ field }) => (
- <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
- <FormControl>
- <Checkbox
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- <div className="space-y-1 leading-none">
- <FormLabel>자재 가격 관련 여부</FormLabel>
- </div>
- </FormItem>
- )}
- />
- </form>
- </Form>
- </ScrollArea>
- <SheetFooter className="gap-2 pt-2 sm:space-x-0">
- <SheetClose asChild>
- <Button type="button" variant="outline">
- 취소
- </Button>
- </SheetClose>
- <Button
- type="submit"
- form="update-rfq-detail-form"
- disabled={isUpdatePending}
- >
- {isUpdatePending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- 저장
- </Button>
- </SheetFooter>
- </SheetContent>
- </Sheet>
- )
-} \ 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 (
<Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
- {getFileIcon(selectedAttachment.fileType)}
+ {getFileIcon(selectedAttachment.fileType || '')}
{selectedAttachment.fileName}
</DialogTitle>
<DialogDescription>
@@ -300,7 +300,7 @@ export function VendorCommunicationDrawer({
/>
) : (
<div className="flex flex-col items-center gap-4 p-8">
- {getFileIcon(selectedAttachment.fileType)}
+ {getFileIcon(selectedAttachment.fileType || '')}
<p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p>
<Button
variant="outline"
@@ -398,7 +398,7 @@ export function VendorCommunicationDrawer({
className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer"
onClick={() => handleAttachmentPreview(attachment)}
>
- {getFileIcon(attachment.fileType)}
+ {getFileIcon(attachment.fileType || '')}
<span className="flex-1 truncate">{attachment.fileName}</span>
<span className="text-xs opacity-70">
{formatFileSize(attachment.fileSize)}
diff --git a/lib/techsales-rfq/table/rfq-table-column.tsx b/lib/techsales-rfq/table/rfq-table-column.tsx
index caaa1c97..125e800b 100644
--- a/lib/techsales-rfq/table/rfq-table-column.tsx
+++ b/lib/techsales-rfq/table/rfq-table-column.tsx
@@ -6,10 +6,8 @@ import { formatDate, formatDateTime } from "@/lib/utils"
import { Checkbox } from "@/components/ui/checkbox"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
import { DataTableRowAction } from "@/types/table"
-import { Check, Pencil, X, Info } from "lucide-react"
+import { Info, Paperclip } from "lucide-react"
import { Button } from "@/components/ui/button"
-import { toast } from "sonner"
-import { Input } from "@/components/ui/input"
// 기본적인 RFQ 타입 정의 (rfq-table.tsx 파일과 일치해야 함)
type TechSalesRfq = {
@@ -32,8 +30,8 @@ type TechSalesRfq = {
updatedByName: string
sentBy: number | null
sentByName: string | null
- projectSnapshot: any
- seriesSnapshot: any
+ projectSnapshot: Record<string, unknown>
+ seriesSnapshot: Record<string, unknown>
pspid: string
projNm: string
sector: string
@@ -42,28 +40,22 @@ type TechSalesRfq = {
attachmentCount: number
quotationCount: number
// 나머지 필드는 사용할 때마다 추가
- [key: string]: any
+ [key: string]: unknown
}
+// 프로젝트 상세정보 타입 추가를 위한 확장
+// interface ExtendedDataTableRowAction<TData> extends DataTableRowAction<TData> {
+// type: DataTableRowAction<TData>["type"] | "project-detail"
+// }
+
interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechSalesRfq> | null>>;
- // 상태와 상태 설정 함수를 props로 받음
- editingCell: EditingCellState | null;
- setEditingCell: (state: EditingCellState | null) => void;
- updateRemark: (rfqId: number, remark: string) => Promise<void>;
-}
-
-export interface EditingCellState {
- rowId: string | number;
- value: string;
+ openAttachmentsSheet: (rfqId: number) => void;
}
-
export function getColumns({
setRowAction,
- editingCell,
- setEditingCell,
- updateRemark,
+ openAttachmentsSheet,
}: GetColumnsProps): ColumnDef<TechSalesRfq>[] {
return [
{
@@ -81,7 +73,7 @@ export function getColumns({
// Then select just this row
row.toggleSelected(true)
// Trigger the same action that was in the "Select" button
- setRowAction({ row, type: "select" })
+ setRowAction({ row, type: "select" as const })
} else {
// Just deselect this row
row.toggleSelected(false)
@@ -142,7 +134,10 @@ export function getColumns({
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="자재명" />
),
- cell: ({ row }) => <div>{row.getValue("itemName")}</div>,
+ cell: ({ row }) => {
+ const itemName = row.getValue("itemName");
+ return <div>{itemName || "자재명 없음"}</div>;
+ },
meta: {
excelHeader: "자재명"
},
@@ -211,6 +206,48 @@ export function getColumns({
size: 80,
},
{
+ id: "attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="첨부파일" />
+ ),
+ cell: ({ row }) => {
+ const rfq = row.original
+ const attachmentCount = rfq.attachmentCount || 0
+
+ const handleClick = () => {
+ openAttachmentsSheet(rfq.id)
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ attachmentCount > 0 ? `View ${attachmentCount} attachments` : "Add attachments"
+ }
+ >
+ <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {attachmentCount > 0 && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
+ {attachmentCount}
+ </span>
+ )}
+ <span className="sr-only">
+ {attachmentCount > 0 ? `${attachmentCount} 첨부파일` : "첨부파일 추가"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ enableResizing: true,
+ size: 80,
+ meta: {
+ excelHeader: "첨부파일"
+ },
+ },
+ {
accessorKey: "rfqSendDate",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="RFQ 전송일" />
@@ -283,103 +320,6 @@ export function getColumns({
enableResizing: true,
size: 160,
},
- // {
- // accessorKey: "remark",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="비고" />
- // ),
- // cell: ({ row }) => {
- // const id = row.original.id;
- // const value = row.getValue("remark") as string;
-
- // const isEditing =
- // editingCell?.rowId === row.id &&
- // editingCell.value !== undefined;
-
- // const startEditing = () => {
- // setEditingCell({
- // rowId: row.id,
- // value: value || ""
- // });
- // };
-
- // const cancelEditing = () => {
- // setEditingCell(null);
- // };
-
- // const saveChanges = async () => {
- // if (!editingCell) return;
-
- // try {
- // await updateRemark(id, editingCell.value);
- // setEditingCell(null);
- // } catch (error) {
- // toast.error("비고 업데이트 중 오류가 발생했습니다.");
- // console.error("Error updating remark:", error);
- // }
- // };
-
- // const handleKeyDown = (e: React.KeyboardEvent) => {
- // if (e.key === "Enter") {
- // saveChanges();
- // } else if (e.key === "Escape") {
- // cancelEditing();
- // }
- // };
-
- // if (isEditing) {
- // return (
- // <div className="flex items-center gap-1">
- // <Input
- // value={editingCell?.value || ""}
- // onChange={(e) => setEditingCell({
- // rowId: row.id,
- // value: e.target.value
- // })}
- // onKeyDown={handleKeyDown}
- // autoFocus
- // className="h-8 w-full"
- // />
- // <Button
- // variant="ghost"
- // size="icon"
- // onClick={saveChanges}
- // className="h-8 w-8"
- // >
- // <Check className="h-4 w-4" />
- // </Button>
- // <Button
- // variant="ghost"
- // size="icon"
- // onClick={cancelEditing}
- // className="h-8 w-8"
- // >
- // <X className="h-4 w-4" />
- // </Button>
- // </div>
- // );
- // }
-
- // return (
- // <div className="flex items-center gap-1 group">
- // <span className="truncate">{value || ""}</span>
- // <Button
- // variant="ghost"
- // size="icon"
- // onClick={startEditing}
- // className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
- // >
- // <Pencil className="h-3 w-3" />
- // </Button>
- // </div>
- // );
- // },
- // meta: {
- // excelHeader: "비고"
- // },
- // enableResizing: true,
- // size: 200,
- // },
{
id: "actions",
header: ({ column }) => (
@@ -390,7 +330,7 @@ export function getColumns({
<Button
variant="ghost"
size="sm"
- onClick={() => setRowAction({ row, type: "project-detail" })}
+ onClick={() => setRowAction({ row, type: "view" as const })}
className="h-8 px-2 gap-1"
>
<Info className="h-4 w-4" />
diff --git a/lib/techsales-rfq/table/rfq-table.tsx b/lib/techsales-rfq/table/rfq-table.tsx
index 3139b1a3..496d7901 100644
--- a/lib/techsales-rfq/table/rfq-table.tsx
+++ b/lib/techsales-rfq/table/rfq-table.tsx
@@ -16,11 +16,11 @@ import {
import { useDataTable } from "@/hooks/use-data-table"
import { DataTable } from "@/components/data-table/data-table"
-import { getColumns, EditingCellState } from "./rfq-table-column"
-import { useEffect, useCallback, useMemo } from "react"
+import { getColumns } from "./rfq-table-column"
+import { useEffect, useMemo } from "react"
import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
import { RFQTableToolbarActions } from "./rfq-table-toolbar-actions"
-import { getTechSalesRfqsWithJoin } from "@/lib/techsales-rfq/service"
+import { getTechSalesRfqsWithJoin, getTechSalesRfqAttachments } from "@/lib/techsales-rfq/service"
import { toast } from "sonner"
import { useTablePresets } from "@/components/data-table/use-table-presets"
import { TablePresetManager } from "@/components/data-table/data-table-preset"
@@ -28,6 +28,7 @@ import { RfqDetailTables } from "./detail-table/rfq-detail-table"
import { cn } from "@/lib/utils"
import { ProjectDetailDialog } from "./project-detail-dialog"
import { RFQFilterSheet } from "./rfq-filter-sheet"
+import { TechSalesRfqAttachmentsSheet, ExistingTechSalesAttachment } from "./tech-sales-rfq-attachments-sheet"
// 기본적인 RFQ 타입 정의 (repository selectTechSalesRfqsWithJoin 반환 타입에 맞춤)
interface TechSalesRfq {
@@ -50,8 +51,8 @@ interface TechSalesRfq {
updatedByName: string
sentBy: number | null
sentByName: string | null
- projectSnapshot: any
- seriesSnapshot: any
+ projectSnapshot: Record<string, unknown>
+ seriesSnapshot: Record<string, unknown>
pspid: string
projNm: string
sector: string
@@ -61,7 +62,7 @@ interface TechSalesRfq {
quotationCount: number
// 필요에 따라 다른 필드들 추가
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- [key: string]: any
+ [key: string]: unknown
}
interface RFQListTableProps {
@@ -87,6 +88,11 @@ export function RFQListTable({
const [isProjectDetailOpen, setIsProjectDetailOpen] = React.useState(false)
const [projectDetailRfq, setProjectDetailRfq] = React.useState<TechSalesRfq | null>(null)
+ // 첨부파일 시트 상태
+ const [attachmentsOpen, setAttachmentsOpen] = React.useState(false)
+ const [selectedRfqForAttachments, setSelectedRfqForAttachments] = React.useState<TechSalesRfq | null>(null)
+ const [attachmentsDefault, setAttachmentsDefault] = React.useState<ExistingTechSalesAttachment[]>([])
+
// 패널 collapse 상태
const [panelHeight, setPanelHeight] = React.useState<number>(55)
@@ -112,7 +118,6 @@ export function RFQListTable({
const tableData = promiseData
const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechSalesRfq> | null>(null)
- const [editingCell, setEditingCell] = React.useState<EditingCellState | null>(null)
// 초기 설정 정의
const initialSettings = React.useMemo(() => ({
@@ -148,20 +153,6 @@ export function RFQListTable({
getCurrentSettings,
} = useTablePresets<TechSalesRfq>('rfq-list-table', initialSettings)
- // 비고 업데이트 함수
- const updateRemark = useCallback(async (rfqId: number, remark: string) => {
- try {
- // 기술영업 RFQ 비고 업데이트 함수 구현 필요
- // const result = await updateTechSalesRfqRemark(rfqId, remark);
- console.log("Update remark for RFQ:", rfqId, "with:", remark);
-
- toast.success("비고가 업데이트되었습니다");
- } catch (error) {
- console.error("비고 업데이트 오류:", error);
- toast.error("업데이트 중 오류가 발생했습니다");
- }
- }, [])
-
// 조회 버튼 클릭 핸들러
const handleSearch = () => {
setIsFilterPanelOpen(false)
@@ -205,7 +196,7 @@ export function RFQListTable({
quotationCount: rfqData.quotationCount,
});
break;
- case "project-detail":
+ case "view":
// 프로젝트 상세정보 다이얼로그 열기
const projectRfqData = rowAction.row.original;
setProjectDetailRfq({
@@ -228,8 +219,8 @@ export function RFQListTable({
updatedByName: projectRfqData.updatedByName,
sentBy: projectRfqData.sentBy,
sentByName: projectRfqData.sentByName,
- projectSnapshot: projectRfqData.projectSnapshot,
- seriesSnapshot: projectRfqData.seriesSnapshot,
+ projectSnapshot: projectRfqData.projectSnapshot || {},
+ seriesSnapshot: projectRfqData.seriesSnapshot || {},
pspid: projectRfqData.pspid,
projNm: projectRfqData.projNm,
sector: projectRfqData.sector,
@@ -251,14 +242,75 @@ export function RFQListTable({
}
}, [rowAction])
+ // 첨부파일 시트 열기 함수
+ const openAttachmentsSheet = React.useCallback(async (rfqId: number) => {
+ try {
+ // 선택된 RFQ 찾기
+ const rfq = tableData?.data?.find(r => r.id === rfqId)
+ if (!rfq) {
+ toast.error("RFQ를 찾을 수 없습니다.")
+ return
+ }
+
+ // 실제 첨부파일 목록 조회 API 호출
+ const result = await getTechSalesRfqAttachments(rfqId)
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ // API 응답을 ExistingTechSalesAttachment 형식으로 변환
+ const attachments: ExistingTechSalesAttachment[] = result.data.map(att => ({
+ id: att.id,
+ techSalesRfqId: att.techSalesRfqId || rfqId, // null인 경우 rfqId 사용
+ fileName: att.fileName,
+ originalFileName: att.originalFileName,
+ filePath: att.filePath,
+ fileSize: att.fileSize || undefined,
+ fileType: att.fileType || undefined,
+ attachmentType: att.attachmentType as "RFQ_COMMON" | "VENDOR_SPECIFIC",
+ description: att.description || undefined,
+ createdBy: att.createdBy,
+ createdAt: att.createdAt,
+ }))
+
+ setAttachmentsDefault(attachments)
+ setSelectedRfqForAttachments({
+ ...rfq,
+ projectSnapshot: rfq.projectSnapshot || {},
+ seriesSnapshot: rfq.seriesSnapshot || {},
+ })
+ setAttachmentsOpen(true)
+ } catch (error) {
+ console.error("첨부파일 조회 오류:", error)
+ toast.error("첨부파일 조회 중 오류가 발생했습니다.")
+ }
+ }, [tableData?.data])
+
+ // 첨부파일 업데이트 콜백
+ const handleAttachmentsUpdated = React.useCallback((rfqId: number, newAttachmentCount: number) => {
+ // TODO: 실제로는 테이블 데이터를 다시 조회하거나 상태를 업데이트해야 함
+ // 현재는 로그만 출력하고 토스트 메시지로 피드백 제공
+ console.log(`RFQ ${rfqId}의 첨부파일 개수가 ${newAttachmentCount}개로 업데이트됨`)
+
+ // 성공 피드백 (중복되지 않도록 짧은 지연 후 표시)
+ setTimeout(() => {
+ toast.success(`첨부파일 개수가 업데이트되었습니다. (${newAttachmentCount}개)`, {
+ duration: 3000
+ })
+ }, 500)
+
+ // TODO: 나중에 실제 테이블 데이터 업데이트 로직 구현
+ // 예: setTableData() 또는 데이터 재조회
+ }, [])
+
const columns = React.useMemo(
() => getColumns({
setRowAction,
- editingCell,
- setEditingCell,
- updateRemark
+ openAttachmentsSheet
}),
- [editingCell, setEditingCell, updateRemark]
+ [openAttachmentsSheet]
)
// 고급 필터 필드 정의
@@ -519,6 +571,15 @@ export function RFQListTable({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
selectedRfq={projectDetailRfq as any}
/>
+
+ {/* 첨부파일 관리 시트 */}
+ <TechSalesRfqAttachmentsSheet
+ open={attachmentsOpen}
+ onOpenChange={setAttachmentsOpen}
+ defaultAttachments={attachmentsDefault}
+ rfq={selectedRfqForAttachments}
+ onAttachmentsUpdated={handleAttachmentsUpdated}
+ />
</div>
)
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx
new file mode 100644
index 00000000..ecdf6d81
--- /dev/null
+++ b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx
@@ -0,0 +1,540 @@
+"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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+
+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, getTechSalesRfqAttachments } from "@/lib/techsales-rfq/service"
+
+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" | "VENDOR_SPECIFIC"
+ description?: string
+ createdBy: number
+ createdAt: Date
+}
+
+/** 새로 업로드할 파일 */
+const newUploadSchema = z.object({
+ fileObj: z.any().optional(), // 실제 File
+ attachmentType: z.enum(["RFQ_COMMON", "VENDOR_SPECIFIC"]).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", "VENDOR_SPECIFIC"]),
+ 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
+ /** 업로드/삭제 후 상위 테이블에 attachmentCount 등을 업데이트하기 위한 콜백 */
+ onAttachmentsUpdated?: (rfqId: number, newAttachmentCount: number) => void
+ /** 강제 읽기 전용 모드 (파트너/벤더용) */
+ readOnly?: boolean
+}
+
+export function TechSalesRfqAttachmentsSheet({
+ defaultAttachments = [],
+ onAttachmentsUpdated,
+ rfq,
+ readOnly = false,
+ ...props
+}: TechSalesRfqAttachmentsSheetProps) {
+ const [isPending, setIsPending] = React.useState(false)
+
+ // RFQ 상태에 따른 편집 가능 여부 결정 (readOnly prop이 true면 항상 false)
+ const isEditable = React.useMemo(() => {
+ if (!rfq || readOnly) return false
+ // RFQ Created, RFQ Vendor Assignned 상태에서만 편집 가능
+ return ["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)
+ }, [rfq, readOnly])
+
+ 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: upload.attachmentType,
+ description: upload.description,
+ }))
+
+ // 실제 API 호출
+ const result = await processTechSalesRfqAttachments({
+ techSalesRfqId: rfq.id,
+ newFiles,
+ deleteAttachmentIds,
+ createdBy: 1, // TODO: 실제 사용자 ID로 변경
+ })
+
+ if (result.error) {
+ 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)
+
+ // 즉시 첨부파일 목록 새로고침
+ 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" | "VENDOR_SPECIFIC",
+ 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)
+ }
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>첨부파일 관리</SheetTitle>
+ <SheetDescription>
+ RFQ: {rfq?.rfqCode || "N/A"}
+ {!isEditable && (
+ <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 = field.attachmentType === "RFQ_COMMON" ? "공통" : "벤더별"
+ const sizeText = field.fileSize ? prettyBytes(field.fileSize) : "알 수 없음"
+ const dateText = field.createdAt ? formatDate(field.createdAt) : ""
+
+ 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 && (
+ <a
+ href={`/api/tech-sales-rfq-download?path=${encodeURIComponent(field.filePath)}`}
+ download={field.originalFileName || field.fileName}
+ className="inline-block"
+ >
+ <Button variant="ghost" size="icon" type="button" className="h-8 w-8">
+ <Download className="h-4 w-4" />
+ </Button>
+ </a>
+ )}
+ {/* Remove button - 편집 가능할 때만 표시 */}
+ {isEditable && (
+ <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 - 편집 가능할 때만 표시 */}
+ {isEditable ? (
+ <>
+ <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>
+
+ {/* 파일별 설정 */}
+ <div className="px-4 pb-3 space-y-3">
+ <FormField
+ control={form.control}
+ name={`newUploads.${idx}.attachmentType`}
+ render={({ field: formField }) => (
+ <FormItem>
+ <FormLabel className="text-xs">파일 타입</FormLabel>
+ <Select onValueChange={formField.onChange} defaultValue={formField.value}>
+ <FormControl>
+ <SelectTrigger className="h-8">
+ <SelectValue placeholder="파일 타입 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="RFQ_COMMON">공통 파일</SelectItem>
+ {/* <SelectItem value="VENDOR_SPECIFIC">벤더별 파일</SelectItem> */}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </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">
+ {isEditable ? "취소" : "닫기"}
+ </Button>
+ </SheetClose>
+ {isEditable && (
+ <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>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx b/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx
index 69ba0363..c8a0efc2 100644
--- a/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx
+++ b/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx
@@ -38,31 +38,30 @@ import {
} from "@/components/ui/dialog"
import { formatDateTime, formatFileSize } from "@/lib/utils"
import { useSession } from "next-auth/react"
-import { fetchBuyerVendorComments } from "../services"
// 타입 정의
-interface Comment {
- 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: Attachment[];
- isRead: boolean | null // null 허용으로 변경
+export interface TechSalesAttachment {
+ id: number
+ fileName: string
+ fileSize: number
+ fileType: string | null
+ filePath: string
+ uploadedAt: Date
}
-interface Attachment {
- id: number;
- fileName: string;
- fileSize: number;
- fileType: string | null; // null 허용으로 변경
- filePath: string;
- uploadedAt: Date;
+export interface TechSalesComment {
+ id: number
+ rfqId: number
+ vendorId: number | null
+ userId?: number | null
+ content: string
+ isVendorComment: boolean | null
+ createdAt: Date
+ updatedAt: Date
+ userName?: string | null
+ vendorName?: string | null
+ attachments: TechSalesAttachment[]
+ isRead: boolean | null
}
// 프롭스 정의
@@ -73,15 +72,61 @@ interface BuyerCommunicationDrawerProps {
id: number;
rfqId: number;
vendorId: number;
- quotationCode: string;
+ quotationCode: string | null;
rfq?: {
- rfqCode: string;
+ rfqCode: string | null;
};
} | null;
onSuccess?: () => void;
}
-
+// 클라이언트에서 API를 통해 코멘트를 가져오는 함수
+export async function fetchTechSalesVendorCommentsClient(rfqId: number, vendorId: number): Promise<TechSalesComment[]> {
+ const response = await fetch(`/api/tech-sales-rfqs/${rfqId}/vendors/${vendorId}/comments`);
+
+ if (!response.ok) {
+ throw new Error(`API 요청 실패: ${response.status}`);
+ }
+
+ const result = await response.json();
+
+ if (!result.success) {
+ throw new Error(result.message || '코멘트 조회 중 오류가 발생했습니다');
+ }
+
+ // API 응답 타입 정의
+ interface ApiComment {
+ id: number;
+ rfqId: number;
+ vendorId: number | null;
+ userId?: number | null;
+ content: string;
+ isVendorComment: boolean | null;
+ createdAt: string;
+ updatedAt: string;
+ userName?: string | null;
+ vendorName?: string | null;
+ isRead: boolean | null;
+ attachments: Array<{
+ id: number;
+ fileName: string;
+ fileSize: number;
+ fileType: string | null;
+ filePath: string;
+ uploadedAt: string;
+ }>;
+ }
+
+ return result.data.map((comment: ApiComment) => ({
+ ...comment,
+ createdAt: new Date(comment.createdAt),
+ updatedAt: new Date(comment.updatedAt),
+ attachments: comment.attachments.map((att) => ({
+ ...att,
+ uploadedAt: new Date(att.uploadedAt)
+ }))
+ }));
+}
// 벤더 코멘트 전송 함수
export function sendVendorCommentClient(params: {
@@ -89,7 +134,7 @@ export function sendVendorCommentClient(params: {
vendorId: number;
content: string;
attachments?: File[];
-}): Promise<Comment> {
+}): Promise<TechSalesComment> {
// 폼 데이터 생성 (파일 첨부를 위해)
const formData = new FormData();
formData.append('rfqId', params.rfqId.toString());
@@ -104,8 +149,10 @@ export function sendVendorCommentClient(params: {
});
}
- // API 엔드포인트 구성 (벤더 API 경로)
- const url = `/api/procurement-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
+ // API 엔드포인트 구성 (techsales API 경로)
+ const url = `/api/tech-sales-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
+
+ console.log("API 요청 시작:", { url, params });
// API 호출
return fetch(url, {
@@ -113,22 +160,65 @@ export function sendVendorCommentClient(params: {
body: formData, // multipart/form-data 형식 사용
})
.then(response => {
+ console.log("API 응답 상태:", response.status);
+
if (!response.ok) {
return response.text().then(text => {
+ console.error("API 에러 응답:", text);
throw new Error(`API 요청 실패: ${response.status} ${text}`);
});
}
return response.json();
})
.then(result => {
+ console.log("API 응답 데이터:", result);
+
if (!result.success || !result.data) {
throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다');
}
- return result.data.comment;
+
+ // API 응답 타입 정의
+ interface ApiAttachment {
+ id: number;
+ fileName: string;
+ fileSize: number;
+ fileType: string | null;
+ filePath: string;
+ uploadedAt: string;
+ }
+
+ interface ApiCommentResponse {
+ id: number;
+ rfqId: number;
+ vendorId: number | null;
+ userId?: number | null;
+ content: string;
+ isVendorComment: boolean | null;
+ createdAt: string;
+ updatedAt: string;
+ userName?: string | null;
+ isRead: boolean | null;
+ attachments: ApiAttachment[];
+ }
+
+ const commentData = result.data.comment as ApiCommentResponse;
+
+ return {
+ ...commentData,
+ createdAt: new Date(commentData.createdAt),
+ updatedAt: new Date(commentData.updatedAt),
+ attachments: commentData.attachments.map((att) => ({
+ ...att,
+ uploadedAt: new Date(att.uploadedAt)
+ }))
+ };
+ })
+ .catch(error => {
+ console.error("클라이언트 API 호출 에러:", error);
+ throw error;
});
}
-
export function BuyerCommunicationDrawer({
open,
onOpenChange,
@@ -139,7 +229,7 @@ export function BuyerCommunicationDrawer({
const { data: session } = useSession();
// 상태 관리
- const [comments, setComments] = useState<Comment[]>([]);
+ const [comments, setComments] = useState<TechSalesComment[]>([]);
const [newComment, setNewComment] = useState("");
const [attachments, setAttachments] = useState<File[]>([]);
const [isLoading, setIsLoading] = useState(false);
@@ -149,7 +239,7 @@ export function BuyerCommunicationDrawer({
// 첨부파일 관련 상태
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
- const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null);
+ const [selectedAttachment, setSelectedAttachment] = useState<TechSalesAttachment | null>(null);
// 드로어가 열릴 때 데이터 로드
useEffect(() => {
@@ -173,7 +263,7 @@ export function BuyerCommunicationDrawer({
setIsLoading(true);
// API를 사용하여 코멘트 데이터 가져오기
- const commentsData = await fetchBuyerVendorComments(quotation.rfqId, quotation.vendorId);
+ const commentsData = await fetchTechSalesVendorCommentsClient(quotation.rfqId, quotation.vendorId);
setComments(commentsData);
// 읽음 상태 처리는 API 측에서 처리되는 것으로 가정
@@ -239,19 +329,20 @@ export function BuyerCommunicationDrawer({
};
// 첨부파일 미리보기
- const handleAttachmentPreview = (attachment: Attachment) => {
+ const handleAttachmentPreview = (attachment: TechSalesAttachment) => {
setSelectedAttachment(attachment);
setPreviewDialogOpen(true);
};
// 첨부파일 다운로드
- const handleAttachmentDownload = (attachment: Attachment) => {
+ const handleAttachmentDownload = (attachment: TechSalesAttachment) => {
// 실제 다운로드 구현
window.open(attachment.filePath, '_blank');
};
// 파일 아이콘 선택
- const getFileIcon = (fileType: string) => {
+ const getFileIcon = (fileType: string | null) => {
+ if (!fileType) return <File className="h-5 w-5 text-gray-500" />;
if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />;
if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />;
if (fileType.includes("spreadsheet") || fileType.includes("excel"))
@@ -265,8 +356,8 @@ export function BuyerCommunicationDrawer({
const renderAttachmentPreviewDialog = () => {
if (!selectedAttachment) return null;
- const isImage = selectedAttachment.fileType.startsWith("image/");
- const isPdf = selectedAttachment.fileType.includes("pdf");
+ const isImage = selectedAttachment.fileType?.startsWith("image/") || false;
+ const isPdf = selectedAttachment.fileType?.includes("pdf") || false;
return (
<Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
diff --git a/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx b/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx
index 0332232c..3f2a5280 100644
--- a/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx
+++ b/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx
@@ -1,21 +1,22 @@
"use client"
import * as React from "react"
-import { useState } from "react"
+import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
-import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { ScrollArea } from "@/components/ui/scroll-area"
-import { Separator } from "@/components/ui/separator"
-import { Avatar, AvatarFallback } from "@/components/ui/avatar"
-import { Send, MessageCircle } from "lucide-react"
-import { formatDateTime } from "@/lib/utils"
-import { toast } from "sonner"
+import { Skeleton } from "@/components/ui/skeleton"
+import { MessageSquare, Paperclip } from "lucide-react"
+import { fetchTechSalesVendorCommentsClient, TechSalesComment } from "../buyer-communication-drawer"
+import { BuyerCommunicationDrawer } from "../buyer-communication-drawer"
interface CommunicationTabProps {
quotation: {
id: number
+ rfqId: number
+ vendorId: number
+ quotationCode: string | null
rfq: {
id: number
rfqCode: string | null
@@ -31,100 +32,73 @@ interface CommunicationTabProps {
}
}
-// 임시 코멘트 데이터 (실제로는 API에서 가져와야 함)
-const MOCK_COMMENTS = [
- {
- id: 1,
- content: "안녕하세요. 해당 자재에 대한 견적 요청 드립니다. 납기일은 언제까지 가능한지 문의드립니다.",
- createdAt: new Date("2024-01-15T09:00:00"),
- author: {
- name: "김구매",
- email: "buyer@company.com",
- role: "구매담당자"
- }
- },
- {
- id: 2,
- content: "안녕하세요. 견적 요청 확인했습니다. 해당 자재의 경우 약 2주 정도의 제작 기간이 필요합니다. 상세한 견적은 내일까지 제출하겠습니다.",
- createdAt: new Date("2024-01-15T14:30:00"),
- author: {
- name: "이벤더",
- email: "vendor@supplier.com",
- role: "벤더"
- }
- },
- {
- id: 3,
- content: "감사합니다. 추가로 품질 인증서도 함께 제출 가능한지 확인 부탁드립니다.",
- createdAt: new Date("2024-01-16T10:15:00"),
- author: {
- name: "김구매",
- email: "buyer@company.com",
- role: "구매담당자"
- }
- }
-]
-
export function CommunicationTab({ quotation }: CommunicationTabProps) {
- const [newComment, setNewComment] = useState("")
- const [isLoading, setIsLoading] = useState(false)
- const [comments, setComments] = useState(MOCK_COMMENTS)
+ const [comments, setComments] = useState<TechSalesComment[]>([]);
+ const [unreadCount, setUnreadCount] = useState(0);
+ const [loadingComments, setLoadingComments] = useState(false);
+ const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false);
- const handleSendComment = async () => {
- if (!newComment.trim()) {
- toast.error("메시지를 입력해주세요.")
- return
+ // 컴포넌트 마운트 시 메시지 미리 로드
+ useEffect(() => {
+ if (quotation) {
+ loadCommunicationData();
}
+ }, [quotation]);
- setIsLoading(true)
+ // 메시지 데이터 로드 함수
+ const loadCommunicationData = async () => {
try {
- // TODO: API 호출로 코멘트 전송
- const newCommentData = {
- id: comments.length + 1,
- content: newComment,
- createdAt: new Date(),
- author: {
- name: "현재사용자", // 실제로는 세션에서 가져와야 함
- email: "current@user.com",
- role: "벤더"
- }
- }
-
- setComments([...comments, newCommentData])
- setNewComment("")
- toast.success("메시지가 전송되었습니다.")
- } catch {
- toast.error("메시지 전송 중 오류가 발생했습니다.")
+ setLoadingComments(true);
+ const commentsData = await fetchTechSalesVendorCommentsClient(quotation.rfqId, quotation.vendorId);
+ setComments(commentsData);
+
+ // 읽지 않은 메시지 수 계산 (구매자가 보낸 메시지 중 읽지 않은 것)
+ const unread = commentsData.filter(
+ comment => !comment.isVendorComment && !comment.isRead
+ ).length;
+ setUnreadCount(unread);
+ } catch (error) {
+ console.error("메시지 데이터 로드 오류:", error);
} finally {
- setIsLoading(false)
+ setLoadingComments(false);
}
- }
+ };
- const getAuthorInitials = (name: string) => {
- return name
- .split(" ")
- .map(word => word[0])
- .join("")
- .toUpperCase()
- .slice(0, 2)
- }
-
- const getRoleBadgeVariant = (role: string) => {
- return role === "구매담당자" ? "default" : "secondary"
- }
+ // 커뮤니케이션 드로어가 닫힐 때 데이터 새로고침
+ const handleCommunicationDrawerChange = (open: boolean) => {
+ setCommunicationDrawerOpen(open);
+ if (!open) {
+ loadCommunicationData(); // 드로어가 닫힐 때 데이터 새로고침
+ }
+ };
return (
<div className="h-full flex flex-col">
{/* 헤더 */}
<Card className="mb-4">
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <MessageCircle className="h-5 w-5" />
- 커뮤니케이션
- </CardTitle>
- <CardDescription>
- RFQ {quotation.rfq?.rfqCode || "미할당"}에 대한 구매담당자와의 커뮤니케이션
- </CardDescription>
+ <CardHeader className="flex flex-row items-center justify-between">
+ <div>
+ <CardTitle className="flex items-center gap-2">
+ <MessageSquare className="h-5 w-5" />
+ 커뮤니케이션
+ {unreadCount > 0 && (
+ <Badge variant="destructive" className="ml-2">
+ 새 메시지 {unreadCount}
+ </Badge>
+ )}
+ </CardTitle>
+ <CardDescription>
+ RFQ {quotation.rfq?.rfqCode || "미할당"}에 대한 구매담당자와의 커뮤니케이션
+ </CardDescription>
+ </div>
+ <Button
+ onClick={() => setCommunicationDrawerOpen(true)}
+ variant="outline"
+ size="sm"
+ >
+ <MessageSquare className="h-4 w-4 mr-2" />
+ {unreadCount > 0 ? "새 메시지 확인" : "메시지 보내기"}
+ </Button>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
@@ -135,81 +109,101 @@ export function CommunicationTab({ quotation }: CommunicationTabProps) {
</CardContent>
</Card>
- {/* 메시지 목록 */}
+ {/* 메시지 미리보기 */}
<Card className="flex-1 flex flex-col min-h-0">
<CardHeader>
<CardTitle className="text-lg">메시지 ({comments.length})</CardTitle>
</CardHeader>
- <CardContent className="flex-1 flex flex-col min-h-0">
- <ScrollArea className="flex-1 pr-4">
- <div className="space-y-4">
- {comments.length === 0 ? (
- <div className="text-center py-8 text-muted-foreground">
- <MessageCircle className="h-12 w-12 mx-auto mb-4 opacity-50" />
- <p>아직 메시지가 없습니다.</p>
- <p className="text-sm">첫 번째 메시지를 보내보세요.</p>
+ <CardContent>
+ {loadingComments ? (
+ <div className="flex items-center justify-center p-8">
+ <div className="text-center">
+ <Skeleton className="h-4 w-32 mx-auto mb-2" />
+ <Skeleton className="h-4 w-48 mx-auto" />
+ </div>
+ </div>
+ ) : comments.length === 0 ? (
+ <div className="min-h-[200px] flex flex-col items-center justify-center text-center p-8">
+ <div className="max-w-md">
+ <div className="mx-auto bg-primary/10 rounded-full w-12 h-12 flex items-center justify-center mb-4">
+ <MessageSquare className="h-6 w-6 text-primary" />
</div>
- ) : (
- comments.map((comment) => (
- <div key={comment.id} className="flex gap-3">
- <Avatar className="h-8 w-8 mt-1">
- <AvatarFallback className="text-xs">
- {getAuthorInitials(comment.author.name)}
- </AvatarFallback>
- </Avatar>
- <div className="flex-1 space-y-2">
- <div className="flex items-center gap-2">
- <span className="font-medium text-sm">{comment.author.name}</span>
- <Badge variant={getRoleBadgeVariant(comment.author.role)} className="text-xs">
- {comment.author.role}
- </Badge>
+ <h3 className="text-lg font-medium mb-2">아직 메시지가 없습니다</h3>
+ <p className="text-muted-foreground mb-4">
+ 견적서에 대한 질문이나 의견이 있으신가요? 구매자와 메시지를 주고받으세요.
+ </p>
+ <Button
+ onClick={() => setCommunicationDrawerOpen(true)}
+ className="mx-auto"
+ >
+ 메시지 보내기
+ </Button>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {/* 최근 메시지 3개 미리보기 */}
+ <div className="space-y-2">
+ <h3 className="text-sm font-medium">최근 메시지</h3>
+ <ScrollArea className="h-[250px] rounded-md border p-4">
+ {comments.slice(-3).map(comment => (
+ <div
+ key={comment.id}
+ className={`p-3 mb-3 rounded-lg ${!comment.isVendorComment && !comment.isRead
+ ? 'bg-primary/10 border-l-4 border-primary'
+ : 'bg-muted/50'
+ }`}
+ >
+ <div className="flex justify-between items-center mb-1">
+ <span className="text-sm font-medium">
+ {comment.isVendorComment
+ ? '나'
+ : comment.userName || '구매 담당자'}
+ </span>
<span className="text-xs text-muted-foreground">
- {formatDateTime(comment.createdAt)}
+ {new Date(comment.createdAt).toLocaleDateString()}
</span>
</div>
- <div className="bg-muted p-3 rounded-lg text-sm">
- {comment.content}
- </div>
+ <p className="text-sm line-clamp-2">{comment.content}</p>
+ {comment.attachments.length > 0 && (
+ <div className="mt-1 text-xs text-muted-foreground">
+ <Paperclip className="h-3 w-3 inline mr-1" />
+ 첨부파일 {comment.attachments.length}개
+ </div>
+ )}
</div>
- </div>
- ))
- )}
- </div>
- </ScrollArea>
-
- <Separator className="my-4" />
+ ))}
+ </ScrollArea>
+ </div>
- {/* 새 메시지 입력 */}
- <div className="space-y-3">
- <Textarea
- placeholder="메시지를 입력하세요..."
- value={newComment}
- onChange={(e) => setNewComment(e.target.value)}
- rows={3}
- className="resize-none"
- onKeyDown={(e) => {
- if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
- e.preventDefault()
- handleSendComment()
- }
- }}
- />
- <div className="flex justify-between items-center">
- <div className="text-xs text-muted-foreground">
- Ctrl + Enter로 빠른 전송
+ <div className="flex justify-center">
+ <Button
+ onClick={() => setCommunicationDrawerOpen(true)}
+ className="w-full"
+ >
+ 전체 메시지 보기 ({comments.length}개)
+ </Button>
</div>
- <Button
- onClick={handleSendComment}
- disabled={isLoading || !newComment.trim()}
- size="sm"
- >
- <Send className="h-4 w-4 mr-2" />
- 전송
- </Button>
</div>
- </div>
+ )}
</CardContent>
</Card>
+
+ {/* 커뮤니케이션 드로어 */}
+ <BuyerCommunicationDrawer
+ open={communicationDrawerOpen}
+ onOpenChange={handleCommunicationDrawerChange}
+ quotation={{
+ id: quotation.id,
+ rfqId: quotation.rfqId,
+ vendorId: quotation.vendorId,
+ quotationCode: quotation.quotationCode,
+ rfq: quotation.rfq ? {
+ rfqCode: quotation.rfq.rfqCode
+ } : undefined
+ }}
+ onSuccess={loadCommunicationData}
+ />
</div>
)
} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
index 5c6971cc..109698ea 100644
--- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
+++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
@@ -2,7 +2,7 @@
import * as React from "react"
import { type ColumnDef } from "@tanstack/react-table"
-import { Edit } from "lucide-react"
+import { Edit, Paperclip } from "lucide-react"
import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
@@ -31,13 +31,15 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations {
quotationVersion?: number | null;
rejectionReason?: string | null;
acceptedAt?: Date | null;
+ attachmentCount?: number;
}
interface GetColumnsProps {
router: AppRouterInstance;
+ openAttachmentsSheet: (rfqId: number) => void;
}
-export function getColumns({ router }: GetColumnsProps): ColumnDef<QuotationWithRfqCode>[] {
+export function getColumns({ router, openAttachmentsSheet }: GetColumnsProps): ColumnDef<QuotationWithRfqCode>[] {
return [
{
id: "select",
@@ -163,6 +165,55 @@ export function getColumns({ router }: GetColumnsProps): ColumnDef<QuotationWith
enableHiding: true,
},
{
+ id: "attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="첨부파일" />
+ ),
+ cell: ({ row }) => {
+ const quotation = row.original
+ const attachmentCount = quotation.attachmentCount || 0
+
+ const handleClick = () => {
+ openAttachmentsSheet(quotation.rfqId)
+ }
+
+ return (
+ <div className="w-20">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ attachmentCount > 0 ? `View ${attachmentCount} attachments` : "No attachments"
+ }
+ >
+ <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {attachmentCount > 0 && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
+ {attachmentCount}
+ </span>
+ )}
+ <span className="sr-only">
+ {attachmentCount > 0 ? `${attachmentCount} 첨부파일` : "첨부파일 없음"}
+ </span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{attachmentCount > 0 ? `${attachmentCount}개 첨부파일 보기` : "첨부파일 없음"}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ )
+ },
+ enableSorting: false,
+ enableHiding: true,
+ },
+ {
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="상태" />
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 63d4674b..e1b82579 100644
--- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
+++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
@@ -9,6 +9,9 @@ import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-adv
import { TechSalesVendorQuotations, TECH_SALES_QUOTATION_STATUSES, TECH_SALES_QUOTATION_STATUS_CONFIG } from "@/db/schema"
import { useRouter } from "next/navigation"
import { getColumns } from "./vendor-quotations-table-columns"
+import { TechSalesRfqAttachmentsSheet, ExistingTechSalesAttachment } from "../../table/tech-sales-rfq-attachments-sheet"
+import { getTechSalesRfqAttachments } from "@/lib/techsales-rfq/service"
+import { toast } from "sonner"
interface QuotationWithRfqCode extends TechSalesVendorQuotations {
rfqCode?: string;
@@ -18,13 +21,14 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations {
itemName?: string;
projNm?: string;
quotationCode?: string | null;
- quotationVersion?: number | null;
+ quotationVersion: number | null;
rejectionReason?: string | null;
acceptedAt?: Date | null;
+ attachmentCount?: number;
}
interface VendorQuotationsTableProps {
- promises: Promise<[{ data: any[], pageCount: number, total?: number }]>;
+ promises: Promise<[{ data: QuotationWithRfqCode[], pageCount: number, total?: number }]>;
}
export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps) {
@@ -34,16 +38,68 @@ export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps)
const [{ data, pageCount }] = React.use(promises);
const router = useRouter();
+
+ // 첨부파일 시트 상태
+ const [attachmentsOpen, setAttachmentsOpen] = React.useState(false)
+ const [selectedRfqForAttachments, setSelectedRfqForAttachments] = React.useState<{ id: number; rfqCode: string | null; status: string } | null>(null)
+ const [attachmentsDefault, setAttachmentsDefault] = React.useState<ExistingTechSalesAttachment[]>([])
// 데이터 안정성을 위한 메모이제이션 - 핵심 속성만 비교
const stableData = React.useMemo(() => {
return data;
}, [data.length, data.map(item => `${item.id}-${item.status}-${item.updatedAt}`).join(',')]);
+ // 첨부파일 시트 열기 함수
+ const openAttachmentsSheet = React.useCallback(async (rfqId: number) => {
+ try {
+ // RFQ 정보 조회 (data에서 rfqId에 해당하는 데이터 찾기)
+ const quotationWithRfq = data.find(item => item.rfqId === rfqId)
+ if (!quotationWithRfq) {
+ toast.error("RFQ 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ // 실제 첨부파일 목록 조회 API 호출
+ const result = await getTechSalesRfqAttachments(rfqId)
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ // API 응답을 ExistingTechSalesAttachment 형식으로 변환
+ const attachments: ExistingTechSalesAttachment[] = result.data.map(att => ({
+ id: att.id,
+ techSalesRfqId: att.techSalesRfqId || rfqId,
+ fileName: att.fileName,
+ originalFileName: att.originalFileName,
+ filePath: att.filePath,
+ fileSize: att.fileSize || undefined,
+ fileType: att.fileType || undefined,
+ attachmentType: att.attachmentType as "RFQ_COMMON" | "VENDOR_SPECIFIC",
+ description: att.description || undefined,
+ createdBy: att.createdBy,
+ createdAt: att.createdAt,
+ }))
+
+ setAttachmentsDefault(attachments)
+ setSelectedRfqForAttachments({
+ id: rfqId,
+ rfqCode: quotationWithRfq.rfqCode || null,
+ status: quotationWithRfq.rfqStatus || "Unknown"
+ })
+ setAttachmentsOpen(true)
+ } catch (error) {
+ console.error("첨부파일 조회 오류:", error)
+ toast.error("첨부파일 조회 중 오류가 발생했습니다.")
+ }
+ }, [data])
+
// 테이블 컬럼 정의 - router는 안정적이므로 한 번만 생성
const columns = React.useMemo(() => getColumns({
router,
- }), [router]);
+ openAttachmentsSheet,
+ }), [router, openAttachmentsSheet]);
// 필터 필드 - 중앙화된 상태 상수 사용
const filterFields = React.useMemo<DataTableFilterField<QuotationWithRfqCode>[]>(() => [
@@ -138,6 +194,16 @@ export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps)
</DataTableAdvancedToolbar>
</DataTable>
</div>
+
+ {/* 첨부파일 관리 시트 (읽기 전용) */}
+ <TechSalesRfqAttachmentsSheet
+ open={attachmentsOpen}
+ onOpenChange={setAttachmentsOpen}
+ defaultAttachments={attachmentsDefault}
+ rfq={selectedRfqForAttachments}
+ onAttachmentsUpdated={() => {}} // 읽기 전용이므로 빈 함수
+ readOnly={true} // 벤더 쪽에서는 항상 읽기 전용
+ />
</div>
);
} \ No newline at end of file