From 1a6774d195b5fb9e3547f3268bf3527a8718c9bf Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 17 Nov 2025 08:43:00 +0000 Subject: (임수민) GTC 기본계약 코멘트 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/schema/agreementComments.ts | 66 +++ db/schema/index.ts | 1 + lib/basic-contract/actions/check-gtc-comments.ts | 239 ++++++++++ lib/basic-contract/agreement-comments/actions.ts | 510 ++++++++++++++++++++ .../agreement-comments/agreement-comment-list.tsx | 517 +++++++++++++++++++++ lib/basic-contract/service.ts | 122 +++-- .../basic-contracts-detail-columns.tsx | 14 +- .../status-detail/basic-contracts-detail-table.tsx | 3 +- .../vendor-table/basic-contract-sign-dialog.tsx | 85 ++-- .../update-vendor-document-status-button.tsx | 8 +- lib/basic-contract/viewer/GtcClausesComponent.tsx | 4 +- lib/basic-contract/viewer/clause-review-list.tsx | 475 +++++++++++++++++++ .../templates/agreement-comment-notification.hbs | 155 ++++++ 13 files changed, 2106 insertions(+), 93 deletions(-) create mode 100644 db/schema/agreementComments.ts create mode 100644 lib/basic-contract/actions/check-gtc-comments.ts create mode 100644 lib/basic-contract/agreement-comments/actions.ts create mode 100644 lib/basic-contract/agreement-comments/agreement-comment-list.tsx create mode 100644 lib/basic-contract/viewer/clause-review-list.tsx create mode 100644 lib/mail/templates/agreement-comment-notification.hbs diff --git a/db/schema/agreementComments.ts b/db/schema/agreementComments.ts new file mode 100644 index 00000000..56631b1f --- /dev/null +++ b/db/schema/agreementComments.ts @@ -0,0 +1,66 @@ +import { pgTable, text, timestamp, integer, varchar, boolean, jsonb } from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; +import { basicContract } from './basicContractDocumnet'; +import { users } from './users'; +import { vendors } from './vendors'; + +/** + * GTC 기본 계약서 협의 코멘트 테이블 + * SHI와 Vendor 간의 계약서 협의 내용을 저장 + */ +export const agreementComments = pgTable('agreement_comments', { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + + // 어떤 기본계약서에 대한 코멘트인지 + basicContractId: integer('basic_contract_id') + .references(() => basicContract.id) + .notNull(), + + // 작성자 구분 (SHI or Vendor) + authorType: text('author_type').notNull(), // 'SHI' | 'Vendor' + + // 작성자 정보 + authorUserId: integer('author_user_id').references(() => users.id), + authorVendorId: integer('author_vendor_id').references(() => vendors.id), + authorName: varchar('author_name', { length: 255 }), // 선택적 작성자 이름 + + // 코멘트 내용 + comment: text('comment').notNull(), + + // 첨부파일 정보 (JSON 배열로 저장) + attachments: jsonb('attachments').$type>().default([]), + + // 상태 관리 + isDeleted: boolean('is_deleted').notNull().default(false), + + // 감사 정보 + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + deletedAt: timestamp('deleted_at'), +}); + +// Relations 정의 +export const agreementCommentsRelations = relations(agreementComments, ({ one }) => ({ + basicContract: one(basicContract, { + fields: [agreementComments.basicContractId], + references: [basicContract.id], + }), + authorUser: one(users, { + fields: [agreementComments.authorUserId], + references: [users.id], + }), + authorVendor: one(vendors, { + fields: [agreementComments.authorVendorId], + references: [vendors.id], + }), +})); + +export type AgreementComment = typeof agreementComments.$inferSelect; +export type NewAgreementComment = typeof agreementComments.$inferInsert; + diff --git a/db/schema/index.ts b/db/schema/index.ts index 85258371..1155740b 100644 --- a/db/schema/index.ts +++ b/db/schema/index.ts @@ -13,6 +13,7 @@ export * from './tasks'; export * from './logs'; export * from './basicContractDocumnet'; export * from './procurementRFQ'; +export * from './agreementComments'; export * from './setting'; export * from './techSales'; export * from './ocr'; diff --git a/lib/basic-contract/actions/check-gtc-comments.ts b/lib/basic-contract/actions/check-gtc-comments.ts new file mode 100644 index 00000000..1ce998bc --- /dev/null +++ b/lib/basic-contract/actions/check-gtc-comments.ts @@ -0,0 +1,239 @@ +"use server"; + +import db from "@/db/db"; +import { eq, and, desc, isNull, isNotNull, ne, or } from "drizzle-orm"; +import { + gtcDocuments, + gtcVendorDocuments, + gtcVendorClauses, + gtcNegotiationHistory, + projects, + BasicContractView +} from "@/db/schema"; + +/** + * 템플릿 이름에서 프로젝트 코드 추출 + */ +function extractProjectCodeFromTemplateName(templateName: string): string | null { + // "SN2319 GTC" -> "SN2319" + // "General GTC" -> null + const words = templateName.trim().split(/\s+/); + if (words.length === 0) return null; + + const firstWord = words[0]; + if (firstWord.toLowerCase() === 'general' || firstWord.toLowerCase() === 'gtc') { + return null; + } + + // 프로젝트 코드는 보통 첫 번째 단어 + if (words.length > 1 && words[words.length - 1].toLowerCase() === 'gtc') { + return words[words.length - 1]; + } + + return null; +} + +/** + * 단일 contract에 대한 GTC 정보 확인 + */ +async function checkGTCCommentsForContract( + templateName: string, + vendorId: number, + basicContractId?: number +): Promise<{ gtcDocumentId: number | null; hasComments: boolean }> { + try { + const projectCode = extractProjectCodeFromTemplateName(templateName); + let gtcDocumentId: number | null = null; + + console.log(projectCode,"projectCode") + + // 1. GTC Document ID 찾기 + if (projectCode && projectCode.trim() !== '') { + // Project GTC인 경우 + const project = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.code, projectCode.trim())) + .limit(1) + + if (project.length > 0) { + const projectGtcDoc = await db + .select({ id: gtcDocuments.id }) + .from(gtcDocuments) + .where( + and( + eq(gtcDocuments.projectId, project[0].id), + eq(gtcDocuments.isActive, true) + ) + ) + .orderBy(desc(gtcDocuments.revision)) + .limit(1) + + if (projectGtcDoc.length > 0) { + gtcDocumentId = projectGtcDoc[0].id + } + } + } else { + // Standard GTC인 경우 (general 포함하거나 project code가 없는 경우) + console.log(`🔍 [checkGTCCommentsForContract] Standard GTC 조회 중...`); + const standardGtcDoc = await db + .select({ id: gtcDocuments.id }) + .from(gtcDocuments) + .where( + and( + eq(gtcDocuments.type, "standard"), + eq(gtcDocuments.isActive, true), + isNull(gtcDocuments.projectId) + ) + ) + .orderBy(desc(gtcDocuments.revision)) + .limit(1) + + console.log(`📊 [checkGTCCommentsForContract] Standard GTC 조회 결과: ${standardGtcDoc.length}개`); + + if (standardGtcDoc.length > 0) { + gtcDocumentId = standardGtcDoc[0].id + console.log(`✅ [checkGTCCommentsForContract] Standard GTC 찾음: ${gtcDocumentId}`); + } else { + console.log(`❌ [checkGTCCommentsForContract] Standard GTC 없음 - gtc_documents 테이블에 standard 타입의 활성 문서가 없습니다`); + } + } + + console.log(`🎯 [checkGTCCommentsForContract] 최종 gtcDocumentId: ${gtcDocumentId}`) + + // 🔥 중요: basicContractId가 있으면 먼저 agreement_comments 테이블 확인 + // gtcDocumentId가 없어도 새로운 코멘트 시스템은 작동해야 함 + if (basicContractId) { + console.log(`🔍 [checkGTCCommentsForContract] basicContractId: ${basicContractId} 로 코멘트 조회`); + const { agreementComments } = await import("@/db/schema"); + const newComments = await db + .select({ id: agreementComments.id }) + .from(agreementComments) + .where( + and( + eq(agreementComments.basicContractId, basicContractId), + eq(agreementComments.isDeleted, false) + ) + ) + .limit(1); + + console.log(`📊 [checkGTCCommentsForContract] basicContractId ${basicContractId}: 코멘트 ${newComments.length}개 발견`); + + if (newComments.length > 0) { + console.log(`✅ [checkGTCCommentsForContract] basicContractId ${basicContractId}: hasComments = true 반환`); + return { + gtcDocumentId, // null일 수 있음 + hasComments: true + }; + } + + console.log(`⚠️ [checkGTCCommentsForContract] basicContractId ${basicContractId}: agreementComments 없음`); + } + + // GTC Document를 찾지 못한 경우 (기존 방식도 체크할 수 없음) + if (!gtcDocumentId) { + console.log(`⚠️ [checkGTCCommentsForContract] gtcDocumentId null - 기존 방식 체크 불가`); + return { gtcDocumentId: null, hasComments: false }; + } + + // 2-2. 기존 방식: gtcDocumentId로 해당 벤더의 vendor documents 찾기 + const vendorDocuments = await db + .select({ id: gtcVendorDocuments.id }) + .from(gtcVendorDocuments) + .where( + and( + eq(gtcVendorDocuments.baseDocumentId, gtcDocumentId), + eq(gtcVendorDocuments.vendorId, vendorId), + eq(gtcVendorDocuments.isActive, true) + ) + ) + .limit(1) + + if (vendorDocuments.length === 0) { + return { gtcDocumentId, hasComments: false }; + } + + // vendor document에 연결된 clauses에서 negotiation history 확인 + const commentsExist = await db + .select({ count: gtcNegotiationHistory.id }) + .from(gtcNegotiationHistory) + .innerJoin( + gtcVendorClauses, + eq(gtcNegotiationHistory.vendorClauseId, gtcVendorClauses.id) + ) + .where( + and( + eq(gtcVendorClauses.vendorDocumentId, vendorDocuments[0].id), + eq(gtcVendorClauses.isActive, true), + isNotNull(gtcNegotiationHistory.comment), + ne(gtcNegotiationHistory.comment, '') + ) + ) + .limit(1) + + return { + gtcDocumentId, + hasComments: commentsExist.length > 0 + }; + + } catch (error) { + console.error('Error checking GTC comments for contract:', error); + return { gtcDocumentId: null, hasComments: false }; + } +} + +/** + * 전체 contract 리스트에 대해 GTC document ID와 comment 정보 수집 + */ +export async function checkGTCCommentsForContracts( + contracts: BasicContractView[] +): Promise> { + const gtcData: Record = {}; + + // GTC가 포함된 contract만 필터링 + const gtcContracts = contracts.filter(contract => + contract.templateName?.includes('GTC') + ); + + if (gtcContracts.length === 0) { + return gtcData; + } + + // Promise.all을 사용해서 병렬 처리 + const checkPromises = gtcContracts.map(async (contract) => { + try { + console.log(`🔄 [checkGTCCommentsForContracts] 계약서 ${contract.id} 체크 시작 - 템플릿: ${contract.templateName}, 벤더: ${contract.vendorName} (${contract.vendorId})`); + const result = await checkGTCCommentsForContract( + contract.templateName!, + contract.vendorId!, + contract.id // basicContractId 전달 + ); + + console.log(`📌 [checkGTCCommentsForContracts] 계약서 ${contract.id} 결과 - hasComments: ${result.hasComments}, gtcDocumentId: ${result.gtcDocumentId}`); + + return { + contractId: contract.id, + gtcDocumentId: result.gtcDocumentId, + hasComments: result.hasComments + }; + } catch (error) { + console.error(`Error checking GTC for contract ${contract.id}:`, error); + return { + contractId: contract.id, + gtcDocumentId: null, + hasComments: false + }; + } + }); + + const results = await Promise.all(checkPromises); + + // 결과를 Record 형태로 변환 + results.forEach(({ contractId, gtcDocumentId, hasComments }) => { + gtcData[contractId] = { gtcDocumentId, hasComments }; + }); + + return gtcData; +} + + diff --git a/lib/basic-contract/agreement-comments/actions.ts b/lib/basic-contract/agreement-comments/actions.ts new file mode 100644 index 00000000..13db2fc6 --- /dev/null +++ b/lib/basic-contract/agreement-comments/actions.ts @@ -0,0 +1,510 @@ +"use server"; + +import { revalidateTag } from "next/cache"; +import db from "@/db/db"; +import { eq, and, desc } from "drizzle-orm"; +import { agreementComments, basicContract, vendors, users } from "@/db/schema"; +import { saveFile, deleteFile } from "@/lib/file-stroage"; +import { sendEmail } from "@/lib/mail/sendEmail"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; + +export type AgreementCommentAuthorType = 'SHI' | 'Vendor'; + +export interface AgreementCommentAttachment { + id: string; + fileName: string; + filePath: string; + fileSize: number; + uploadedAt: Date; +} + +export interface AgreementCommentData { + id: number; + basicContractId: number; + authorType: AgreementCommentAuthorType; + authorUserId: number | null; + authorVendorId: number | null; + authorName: string | null; + comment: string; + attachments: AgreementCommentAttachment[]; + createdAt: Date; + updatedAt: Date; +} + +/** + * 특정 기본계약서의 모든 코멘트 조회 + */ +export async function getAgreementComments( + basicContractId: number +): Promise { + try { + const comments = await db + .select() + .from(agreementComments) + .where( + and( + eq(agreementComments.basicContractId, basicContractId), + eq(agreementComments.isDeleted, false) + ) + ) + .orderBy(desc(agreementComments.createdAt)); + + const mappedComments: AgreementCommentData[] = comments.map((comment) => { + // attachments 안전하게 파싱 + let attachments: AgreementCommentAttachment[] = []; + + if (comment.attachments) { + try { + // 문자열인 경우 파싱 시도 + if (typeof comment.attachments === 'string') { + const trimmed = comment.attachments.trim(); + if (trimmed && trimmed !== '') { + attachments = JSON.parse(trimmed); + } + } + // 이미 배열인 경우 그대로 사용 + else if (Array.isArray(comment.attachments)) { + attachments = comment.attachments; + } + } catch (parseError) { + console.warn(`⚠️ [getAgreementComments] 코멘트 ${comment.id}의 attachments 파싱 실패:`, parseError); + console.warn(` attachments 값:`, comment.attachments); + attachments = []; // 파싱 실패 시 빈 배열로 설정 + } + } + + return { + ...comment, + authorType: comment.authorType as AgreementCommentAuthorType, + attachments, + } as AgreementCommentData; + }); + + + return mappedComments; + } catch (error) { + console.error("코멘트 조회 실패:", error); + throw new Error("코멘트 조회 중 오류가 발생했습니다."); + } +} + +/** + * 코멘트 추가 + */ +export async function addAgreementComment(data: { + basicContractId: number; + comment: string; + authorName?: string; +}): Promise<{ success: boolean; data?: AgreementCommentData; error?: string }> { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return { success: false, error: "인증이 필요합니다." }; + } + + // 사용자 정보로부터 authorType 결정 + // companyId가 있으면 Vendor, 없으면 SHI + const user = session.user as any; + const isVendor = !!user.companyId; + const authorType: AgreementCommentAuthorType = isVendor ? 'Vendor' : 'SHI'; + + // 기본계약서 정보 조회 (이메일 발송을 위해) + const [contract] = await db + .select() + .from(basicContract) + .where(eq(basicContract.id, data.basicContractId)) + .limit(1); + + if (!contract) { + return { success: false, error: "계약서를 찾을 수 없습니다." }; + } + + // 벤더 정보 조회 (이메일 발송용) + let vendor: any = null; + if (contract.vendorId) { + [vendor] = await db + .select() + .from(vendors) + .where(eq(vendors.id, contract.vendorId)) + .limit(1); + } + + // 요청자 정보 조회 (이메일 발송용) + let requester: any = null; + if (contract.requestedBy) { + [requester] = await db + .select() + .from(users) + .where(eq(users.id, contract.requestedBy)) + .limit(1); + } + + // 템플릿 이름 조회 + const { basicContractTemplates } = await import("@/db/schema"); + let templateName: string | null = null; + if (contract.templateId) { + const [template] = await db + .select() + .from(basicContractTemplates) + .where(eq(basicContractTemplates.id, contract.templateId)) + .limit(1); + templateName = template?.templateName || null; + } + + // 코멘트 저장 + const [newComment] = await db + .insert(agreementComments) + .values({ + basicContractId: data.basicContractId, + authorType, + authorUserId: isVendor ? null : parseInt(user.id), + authorVendorId: isVendor ? user.companyId : null, + authorName: data.authorName || user.name, + comment: data.comment, + attachments: JSON.stringify([]), + }) + .returning(); + + // 이메일 알림 발송 + try { + await sendCommentNotificationEmail({ + comment: newComment, + contract, + vendor, + requester, + templateName, + authorType, + authorName: data.authorName || user.name, + }); + } catch (emailError) { + console.error("이메일 발송 실패:", emailError); + // 이메일 실패는 코멘트 저장 성공에 영향을 주지 않음 + } + + // 계약서 상태 업데이트 (협의중으로 변경) + await updateContractNegotiationStatus(data.basicContractId); + + // 캐시 무효화: 코멘트 목록 + 기본계약서 목록 + revalidateTag(`agreement-comments-${data.basicContractId}`); + revalidateTag(`basic-contracts`); // 기본계약서 목록 새로고침 + + return { + success: true, + data: { + ...newComment, + authorType: newComment.authorType as AgreementCommentAuthorType, + attachments: [], + } as AgreementCommentData, + }; + } catch (error) { + console.error("코멘트 추가 실패:", error); + return { + success: false, + error: "코멘트 추가 중 오류가 발생했습니다.", + }; + } +} + +/** + * 코멘트 삭제 + */ +export async function deleteAgreementComment( + commentId: number +): Promise<{ success: boolean; error?: string }> { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return { success: false, error: "인증이 필요합니다." }; + } + + // 코멘트 조회 + const [comment] = await db + .select() + .from(agreementComments) + .where(eq(agreementComments.id, commentId)); + + if (!comment) { + return { success: false, error: "코멘트를 찾을 수 없습니다." }; + } + + // 권한 확인 (작성자만 삭제 가능) + const user = session.user as any; + const isVendor = !!user.companyId; + const canDelete = + (isVendor && comment.authorVendorId === user.companyId) || + (!isVendor && comment.authorUserId === parseInt(user.id)); + + if (!canDelete) { + return { success: false, error: "삭제 권한이 없습니다." }; + } + + // Soft delete + await db + .update(agreementComments) + .set({ + isDeleted: true, + deletedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(agreementComments.id, commentId)); + + // 첨부파일이 있으면 파일 시스템에서도 삭제 + if (comment.attachments) { + const attachments: AgreementCommentAttachment[] = JSON.parse( + comment.attachments + ); + for (const attachment of attachments) { + try { + await deleteFile(attachment.filePath); + } catch (fileError) { + console.error("파일 삭제 실패:", fileError); + } + } + } + + // 캐시 무효화: 코멘트 목록 + 기본계약서 목록 + revalidateTag(`agreement-comments-${comment.basicContractId}`); + revalidateTag(`basic-contracts`); // 기본계약서 목록 새로고침 + + return { success: true }; + } catch (error) { + console.error("코멘트 삭제 실패:", error); + return { + success: false, + error: "코멘트 삭제 중 오류가 발생했습니다.", + }; + } +} + +/** + * 첨부파일 업로드 + */ +export async function uploadCommentAttachment( + commentId: number, + file: File +): Promise<{ success: boolean; data?: AgreementCommentAttachment; error?: string }> { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return { success: false, error: "인증이 필요합니다." }; + } + + // 코멘트 조회 + const [comment] = await db + .select() + .from(agreementComments) + .where(eq(agreementComments.id, commentId)); + + if (!comment) { + return { success: false, error: "코멘트를 찾을 수 없습니다." }; + } + + // 파일 저장 + const saveResult = await saveFile({ + file, + directory: "agreement-comments", + originalName: file.name, + userId: (session.user as any).id?.toString(), + }); + + if (!saveResult.success) { + return { success: false, error: saveResult.error }; + } + + // 첨부파일 정보 생성 + const newAttachment: AgreementCommentAttachment = { + id: crypto.randomUUID(), + fileName: file.name, + filePath: saveResult.publicPath!, + fileSize: file.size, + uploadedAt: new Date(), + }; + + // 기존 첨부파일에 추가 + const existingAttachments: AgreementCommentAttachment[] = comment.attachments + ? JSON.parse(comment.attachments) + : []; + existingAttachments.push(newAttachment); + + // DB 업데이트 + await db + .update(agreementComments) + .set({ + attachments: JSON.stringify(existingAttachments), + updatedAt: new Date(), + }) + .where(eq(agreementComments.id, commentId)); + + revalidateTag(`agreement-comments-${comment.basicContractId}`); + + return { success: true, data: newAttachment }; + } catch (error) { + console.error("첨부파일 업로드 실패:", error); + return { + success: false, + error: "첨부파일 업로드 중 오류가 발생했습니다.", + }; + } +} + +/** + * 첨부파일 삭제 + */ +export async function deleteCommentAttachment( + commentId: number, + attachmentId: string +): Promise<{ success: boolean; error?: string }> { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return { success: false, error: "인증이 필요합니다." }; + } + + // 코멘트 조회 + const [comment] = await db + .select() + .from(agreementComments) + .where(eq(agreementComments.id, commentId)); + + if (!comment) { + return { success: false, error: "코멘트를 찾을 수 없습니다." }; + } + + // 첨부파일 목록에서 제거 + const attachments: AgreementCommentAttachment[] = comment.attachments + ? JSON.parse(comment.attachments) + : []; + const targetAttachment = attachments.find((a) => a.id === attachmentId); + + if (!targetAttachment) { + return { success: false, error: "첨부파일을 찾을 수 없습니다." }; + } + + const updatedAttachments = attachments.filter((a) => a.id !== attachmentId); + + // DB 업데이트 + await db + .update(agreementComments) + .set({ + attachments: JSON.stringify(updatedAttachments), + updatedAt: new Date(), + }) + .where(eq(agreementComments.id, commentId)); + + // 파일 시스템에서 삭제 + try { + await deleteFile(targetAttachment.filePath); + } catch (fileError) { + console.error("파일 삭제 실패:", fileError); + } + + revalidateTag(`agreement-comments-${comment.basicContractId}`); + + return { success: true }; + } catch (error) { + console.error("첨부파일 삭제 실패:", error); + return { + success: false, + error: "첨부파일 삭제 중 오류가 발생했습니다.", + }; + } +} + +/** + * 협의 상태 확인 (코멘트가 있는지) + */ +export async function checkNegotiationStatus( + basicContractId: number +): Promise<{ hasComments: boolean; commentCount: number }> { + try { + const comments = await db + .select() + .from(agreementComments) + .where( + and( + eq(agreementComments.basicContractId, basicContractId), + eq(agreementComments.isDeleted, false) + ) + ); + + return { + hasComments: comments.length > 0, + commentCount: comments.length, + }; + } catch (error) { + console.error("협의 상태 확인 실패:", error); + return { hasComments: false, commentCount: 0 }; + } +} + +/** + * 이메일 알림 발송 + */ +async function sendCommentNotificationEmail(params: { + comment: typeof agreementComments.$inferSelect; + contract: typeof basicContract.$inferSelect; + vendor: typeof vendors.$inferSelect | null; + requester: typeof users.$inferSelect | null; + templateName: string | null; + authorType: AgreementCommentAuthorType; + authorName: string; +}) { + const { comment, contract, vendor, requester, templateName, authorType, authorName } = params; + + // 수신자 결정 + let recipientEmail: string | undefined; + let recipientName: string | undefined; + + if (authorType === 'Vendor') { + // 벤더가 작성한 경우 -> SHI 담당자에게 발송 + if (requester) { + recipientEmail = requester.email || undefined; + recipientName = requester.name || undefined; + } + } else { + // SHI가 작성한 경우 -> 벤더에게 발송 + if (vendor) { + recipientEmail = vendor.email || undefined; + recipientName = vendor.vendorName || undefined; + } + } + + if (!recipientEmail) { + console.warn("수신자 이메일을 찾을 수 없습니다."); + return; + } + + // 이메일 발송 + await sendEmail({ + to: recipientEmail, + subject: `[eVCP] GTC 기본계약서 협의 코멘트 - ${templateName || '기본계약서'}`, + template: "agreement-comment-notification", + context: { + language: "ko", + recipientName: recipientName || "담당자", + authorName, + authorType: authorType === 'SHI' ? '삼성중공업' : '협력업체', + comment: comment.comment, + templateName: templateName || '기본계약서', + vendorName: vendor?.vendorName || '', + contractUrl: `${process.env.NEXT_PUBLIC_APP_URL}/evcp/basic-contract/${contract.id}`, + systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'https://evcp.com', + currentYear: new Date().getFullYear(), + }, + }); +} + +/** + * 계약서 협의 상태 업데이트 + * 실제로 DB 상태를 변경하지 않고, gtcData 조회 시 agreementComments 존재 여부로 판단 + */ +async function updateContractNegotiationStatus(basicContractId: number) { + // agreementComments 테이블에 코멘트가 있으면 + // checkGTCCommentsForContract 함수에서 자동으로 hasComments: true 반환 + // 별도 상태 업데이트 불필요 + console.log(`✅ 계약서 ${basicContractId} 협의 코멘트 추가됨 - 목록에서 자동 반영`); +} + + + diff --git a/lib/basic-contract/agreement-comments/agreement-comment-list.tsx b/lib/basic-contract/agreement-comments/agreement-comment-list.tsx new file mode 100644 index 00000000..8b9cdbea --- /dev/null +++ b/lib/basic-contract/agreement-comments/agreement-comment-list.tsx @@ -0,0 +1,517 @@ +"use client"; + +import React, { useState, useCallback, useEffect } from 'react'; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; +import { + MessageSquare, + Plus, + Trash2, + Paperclip, + X, + Save, + Upload, + FileText, + User, + Building2, + Loader2, + Download, +} from "lucide-react"; +import { cn, formatDateTime } from "@/lib/utils"; +import { + getAgreementComments, + addAgreementComment, + deleteAgreementComment, + uploadCommentAttachment, + deleteCommentAttachment, + type AgreementCommentData, + type AgreementCommentAuthorType, +} from "./actions"; + +export interface AgreementCommentListProps { + basicContractId: number; + currentUserType?: AgreementCommentAuthorType; + readOnly?: boolean; + className?: string; + onCommentCountChange?: (count: number) => void; +} + +export function AgreementCommentList({ + basicContractId, + currentUserType = 'Vendor', + readOnly = false, + className, + onCommentCountChange, +}: AgreementCommentListProps) { + const [comments, setComments] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isAdding, setIsAdding] = useState(false); + const [newComment, setNewComment] = useState(''); + const [newAuthorName, setNewAuthorName] = useState(''); + const [uploadingFiles, setUploadingFiles] = useState>(new Set()); + const [isSaving, setIsSaving] = useState(false); + + // 코멘트 로드 + const loadComments = useCallback(async () => { + try { + setIsLoading(true); + const data = await getAgreementComments(basicContractId); + setComments(data); + onCommentCountChange?.(data.length); + } catch (error) { + console.error('코멘트 로드 실패:', error); + toast.error("코멘트를 불러오는데 실패했습니다."); + } finally { + setIsLoading(false); + } + }, [basicContractId]); // onCommentCountChange를 dependency에서 제거 + + // 초기 로드 + useEffect(() => { + loadComments(); + }, [basicContractId]); // loadComments 대신 basicContractId만 의존 + + // 코멘트 추가 핸들러 + const handleAddComment = useCallback(async () => { + if (!newComment.trim()) { + toast.error("코멘트를 입력해주세요."); + return; + } + + setIsSaving(true); + try { + const result = await addAgreementComment({ + basicContractId, + comment: newComment.trim(), + authorName: newAuthorName.trim() || undefined, + }); + + if (result.success) { + setNewComment(''); + setNewAuthorName(''); + setIsAdding(false); + toast.success("코멘트가 추가되었습니다."); + await loadComments(); // 목록 새로고침 + } else { + toast.error(result.error || "코멘트 추가에 실패했습니다."); + } + } catch (error) { + console.error('코멘트 추가 실패:', error); + toast.error("코멘트 추가에 실패했습니다."); + } finally { + setIsSaving(false); + } + }, [newComment, newAuthorName, basicContractId]); // loadComments 제거 + + // 코멘트 삭제 핸들러 + const handleDeleteComment = useCallback(async (commentId: number) => { + if (!confirm("이 코멘트를 삭제하시겠습니까?")) { + return; + } + + try { + const result = await deleteAgreementComment(commentId); + if (result.success) { + toast.success("코멘트가 삭제되었습니다."); + await loadComments(); // 목록 새로고침 + } else { + toast.error(result.error || "코멘트 삭제에 실패했습니다."); + } + } catch (error) { + console.error('코멘트 삭제 실패:', error); + toast.error("코멘트 삭제에 실패했습니다."); + } + }, []); // loadComments 제거 + + // 첨부파일 업로드 핸들러 + const handleUploadAttachment = useCallback(async (commentId: number, file: File) => { + setUploadingFiles(prev => new Set(prev).add(commentId)); + try { + const result = await uploadCommentAttachment(commentId, file); + if (result.success) { + toast.success(`${file.name}이(가) 업로드되었습니다.`); + await loadComments(); // 목록 새로고침 + } else { + toast.error(result.error || "파일 업로드에 실패했습니다."); + } + } catch (error) { + console.error('파일 업로드 실패:', error); + toast.error("파일 업로드에 실패했습니다."); + } finally { + setUploadingFiles(prev => { + const next = new Set(prev); + next.delete(commentId); + return next; + }); + } + }, []); // loadComments 제거 + + // 첨부파일 삭제 핸들러 + const handleDeleteAttachment = useCallback(async (commentId: number, attachmentId: string) => { + if (!confirm("이 첨부파일을 삭제하시겠습니까?")) { + return; + } + + try { + const result = await deleteCommentAttachment(commentId, attachmentId); + if (result.success) { + toast.success("첨부파일이 삭제되었습니다."); + await loadComments(); // 목록 새로고침 + } else { + toast.error(result.error || "첨부파일 삭제에 실패했습니다."); + } + } catch (error) { + console.error('첨부파일 삭제 실패:', error); + toast.error("첨부파일 삭제에 실패했습니다."); + } + }, []); // loadComments 제거 + + // 파일 크기 포맷팅 + const formatFileSize = (bytes: number): string => { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* 헤더 */} +
+
+
+ +

협의 코멘트

+
+
+ + 총 {comments.length}개 + + {!readOnly && ( + + )} +
+
+ +

+ SHI와 협력업체 간 기본계약서 협의 내용을 작성하고 공유합니다. +

+
+ + {/* 코멘트 리스트 */} + +
+ {/* 새 코멘트 입력 폼 */} + {isAdding && !readOnly && ( + + +
+
+ +
+ + {currentUserType === 'SHI' ? ( + <> + + SHI + + ) : ( + <> + + Vendor + + )} + +
+
+ +
+ + setNewAuthorName(e.target.value)} + placeholder="작성자 이름을 입력하세요..." + className="mt-1.5" + /> +
+ +
+ +