diff options
| author | joonhoekim <26rote@gmail.com> | 2025-05-29 05:12:36 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-29 05:37:04 +0000 |
| commit | e484964b1d78cedabbe182c789a8e4c9b53e29d3 (patch) | |
| tree | d18133dde99e6feb773c95d04f7e79715ab24252 /lib/techsales-rfq | |
| parent | 37f55540833c2d5894513eca9fc8f7c6233fc2d2 (diff) | |
(김준회) 기술영업 조선 RFQ 파일첨부 및 채팅 기능 구현 / menuConfig 수정 (벤더 기술영업)
Diffstat (limited to 'lib/techsales-rfq')
| -rw-r--r-- | lib/techsales-rfq/repository.ts | 23 | ||||
| -rw-r--r-- | lib/techsales-rfq/service.ts | 1008 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/create-rfq-dialog.tsx | 244 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx | 17 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx | 91 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/detail-table/update-vendor-sheet.tsx | 449 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx | 22 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/rfq-table-column.tsx | 178 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/rfq-table.tsx | 117 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx | 540 | ||||
| -rw-r--r-- | lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx | 165 | ||||
| -rw-r--r-- | lib/techsales-rfq/vendor-response/detail/communication-tab.tsx | 292 | ||||
| -rw-r--r-- | lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx | 55 | ||||
| -rw-r--r-- | lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx | 72 |
14 files changed, 2330 insertions, 943 deletions
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 |
