summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--db/schema/agreementComments.ts66
-rw-r--r--db/schema/index.ts1
-rw-r--r--lib/basic-contract/actions/check-gtc-comments.ts239
-rw-r--r--lib/basic-contract/agreement-comments/actions.ts510
-rw-r--r--lib/basic-contract/agreement-comments/agreement-comment-list.tsx517
-rw-r--r--lib/basic-contract/service.ts122
-rw-r--r--lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx14
-rw-r--r--lib/basic-contract/status-detail/basic-contracts-detail-table.tsx3
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx85
-rw-r--r--lib/basic-contract/vendor-table/update-vendor-document-status-button.tsx8
-rw-r--r--lib/basic-contract/viewer/GtcClausesComponent.tsx4
-rw-r--r--lib/basic-contract/viewer/clause-review-list.tsx475
-rw-r--r--lib/mail/templates/agreement-comment-notification.hbs155
13 files changed, 2106 insertions, 93 deletions
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<Array<{
+ id: string;
+ fileName: string;
+ filePath: string;
+ fileSize: number;
+ uploadedAt: Date;
+ }>>().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<Record<number, { gtcDocumentId: number | null; hasComments: boolean }>> {
+ const gtcData: Record<number, { gtcDocumentId: number | null; hasComments: boolean }> = {};
+
+ // 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<AgreementCommentData[]> {
+ 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<AgreementCommentData[]>([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isAdding, setIsAdding] = useState(false);
+ const [newComment, setNewComment] = useState('');
+ const [newAuthorName, setNewAuthorName] = useState('');
+ const [uploadingFiles, setUploadingFiles] = useState<Set<number>>(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 (
+ <div className={cn("h-full flex items-center justify-center", className)}>
+ <Loader2 className="h-8 w-8 animate-spin text-blue-500" />
+ </div>
+ );
+ }
+
+ return (
+ <div className={cn("h-full flex flex-col", className)}>
+ {/* 헤더 */}
+ <div className="flex-shrink-0 border-b bg-gray-50 p-3">
+ <div className="flex items-center justify-between mb-2">
+ <div className="flex items-center space-x-2">
+ <MessageSquare className="h-5 w-5 text-blue-500" />
+ <h3 className="font-semibold text-gray-800">협의 코멘트</h3>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200 h-8 px-3 text-sm flex items-center">
+ 총 {comments.length}개
+ </Badge>
+ {!readOnly && (
+ <Button
+ size="sm"
+ onClick={() => setIsAdding(true)}
+ disabled={isAdding}
+ className="h-8"
+ >
+ <Plus className="h-4 w-4 mr-1" />
+ 코멘트 추가
+ </Button>
+ )}
+ </div>
+ </div>
+
+ <p className="text-sm text-gray-600">
+ SHI와 협력업체 간 기본계약서 협의 내용을 작성하고 공유합니다.
+ </p>
+ </div>
+
+ {/* 코멘트 리스트 */}
+ <ScrollArea className="flex-1">
+ <div className="p-3 space-y-3">
+ {/* 새 코멘트 입력 폼 */}
+ {isAdding && !readOnly && (
+ <Card
+ className={cn(
+ "border-l-4",
+ currentUserType === 'SHI'
+ ? "border-l-blue-500 border-blue-200 bg-blue-50"
+ : "border-l-green-500 border-green-200 bg-green-50"
+ )}
+ >
+ <CardContent className="pt-4">
+ <div className="space-y-3">
+ <div>
+ <Label className="text-sm font-medium text-gray-700">
+ 작성자 유형
+ </Label>
+ <div className="mt-1.5 flex items-center space-x-2">
+ <Badge
+ variant="outline"
+ className={cn(
+ "px-3 py-1 font-semibold",
+ currentUserType === 'SHI'
+ ? "bg-blue-600 text-white border-blue-700"
+ : "bg-green-600 text-white border-green-700"
+ )}
+ >
+ {currentUserType === 'SHI' ? (
+ <>
+ <Building2 className="h-3.5 w-3.5 mr-1.5" />
+ SHI
+ </>
+ ) : (
+ <>
+ <User className="h-3.5 w-3.5 mr-1.5" />
+ Vendor
+ </>
+ )}
+ </Badge>
+ </div>
+ </div>
+
+ <div>
+ <Label htmlFor="authorName" className="text-sm font-medium text-gray-700">
+ 작성자 이름 (선택사항)
+ </Label>
+ <Input
+ id="authorName"
+ value={newAuthorName}
+ onChange={(e) => setNewAuthorName(e.target.value)}
+ placeholder="작성자 이름을 입력하세요..."
+ className="mt-1.5"
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="comment" className="text-sm font-medium text-gray-700">
+ 코멘트 *
+ </Label>
+ <Textarea
+ id="comment"
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ placeholder="협의하고 싶은 내용을 입력하세요..."
+ className="mt-1.5 min-h-[100px]"
+ />
+ </div>
+
+ <div className="flex items-center justify-end space-x-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ setIsAdding(false);
+ setNewComment('');
+ setNewAuthorName('');
+ }}
+ disabled={isSaving}
+ >
+ <X className="h-4 w-4 mr-1" />
+ 취소
+ </Button>
+ <Button
+ size="sm"
+ onClick={handleAddComment}
+ disabled={isSaving || !newComment.trim()}
+ >
+ {isSaving ? (
+ <Loader2 className="h-4 w-4 mr-1 animate-spin" />
+ ) : (
+ <Save className="h-4 w-4 mr-1" />
+ )}
+ 저장
+ </Button>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 기존 코멘트 목록 */}
+ {comments.length === 0 && !isAdding ? (
+ <div className="text-center py-8">
+ <MessageSquare className="h-12 w-12 text-gray-300 mx-auto mb-3" />
+ <p className="text-sm text-gray-500">
+ 아직 코멘트가 없습니다.
+ </p>
+ {!readOnly && (
+ <Button
+ variant="outline"
+ size="sm"
+ className="mt-3"
+ onClick={() => setIsAdding(true)}
+ >
+ <Plus className="h-4 w-4 mr-1" />
+ 첫 번째 코멘트 작성하기
+ </Button>
+ )}
+ </div>
+ ) : (
+ comments.map((comment) => (
+ <Card
+ key={comment.id}
+ className={cn(
+ "transition-all duration-200 hover:shadow-md",
+ comment.authorType === 'SHI'
+ ? "border-l-4 border-l-blue-500 border-blue-200 bg-blue-50/50"
+ : "border-l-4 border-l-green-500 border-green-200 bg-green-50/50"
+ )}
+ >
+ <CardContent className="pt-4">
+ <div className="space-y-3">
+ {/* 헤더: 작성자 정보 */}
+ <div className="flex items-start justify-between">
+ <div className="flex items-center space-x-2">
+ <Badge
+ variant="outline"
+ className={cn(
+ "px-3 py-1 font-semibold",
+ comment.authorType === 'SHI'
+ ? "bg-blue-600 text-white border-blue-700"
+ : "bg-green-600 text-white border-green-700"
+ )}
+ >
+ {comment.authorType === 'SHI' ? (
+ <>
+ <Building2 className="h-3.5 w-3.5 mr-1.5" />
+ SHI
+ </>
+ ) : (
+ <>
+ <User className="h-3.5 w-3.5 mr-1.5" />
+ Vendor
+ </>
+ )}
+ </Badge>
+ {comment.authorName && (
+ <span className="text-sm font-medium text-gray-700">
+ {comment.authorName}
+ </span>
+ )}
+ </div>
+
+ {!readOnly && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDeleteComment(comment.id)}
+ className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+
+ {/* 코멘트 내용 */}
+ <div className="bg-white rounded-md p-3 border border-gray-200">
+ <p className="text-sm text-gray-800 whitespace-pre-wrap leading-relaxed">
+ {comment.comment}
+ </p>
+ </div>
+
+ {/* 첨부파일 */}
+ {(comment.attachments.length > 0 || !readOnly) && (
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <Label className="text-xs font-medium text-gray-600 flex items-center">
+ <Paperclip className="h-3 w-3 mr-1" />
+ 첨부파일 {comment.attachments.length > 0 ? `(${comment.attachments.length})` : ''}
+ </Label>
+ {!readOnly && (
+ <label htmlFor={`file-${comment.id}`}>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ className="h-6 text-xs"
+ disabled={uploadingFiles.has(comment.id)}
+ onClick={() => document.getElementById(`file-${comment.id}`)?.click()}
+ >
+ {uploadingFiles.has(comment.id) ? (
+ <Loader2 className="h-3 w-3 mr-1 animate-spin" />
+ ) : (
+ <Upload className="h-3 w-3 mr-1" />
+ )}
+ 업로드
+ </Button>
+ <input
+ id={`file-${comment.id}`}
+ type="file"
+ className="hidden"
+ onChange={(e) => {
+ const file = e.target.files?.[0];
+ if (file) {
+ handleUploadAttachment(comment.id, file);
+ }
+ e.target.value = '';
+ }}
+ />
+ </label>
+ )}
+ </div>
+
+ {comment.attachments.length > 0 && (
+ <div className="space-y-1.5">
+ {comment.attachments.map((attachment) => (
+ <div
+ key={attachment.id}
+ className="flex items-center justify-between bg-white rounded p-2 border border-gray-200 hover:border-gray-300 transition-colors"
+ >
+ <div className="flex items-center space-x-2 flex-1 min-w-0">
+ <FileText className="h-4 w-4 text-blue-500 flex-shrink-0" />
+ <div className="flex-1 min-w-0">
+ <p className="text-sm text-gray-700 truncate">
+ {attachment.fileName}
+ </p>
+ <p className="text-xs text-gray-500">
+ {formatFileSize(attachment.fileSize)}
+ </p>
+ </div>
+ </div>
+ <div className="flex items-center space-x-1">
+ <Button
+ variant="ghost"
+ size="sm"
+ asChild
+ className="h-6 w-6 p-0 text-blue-500 hover:text-blue-700 hover:bg-blue-50"
+ >
+ <a href={attachment.filePath} download target="_blank" rel="noopener noreferrer">
+ <Download className="h-3 w-3" />
+ </a>
+ </Button>
+ {!readOnly && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDeleteAttachment(comment.id, attachment.id)}
+ className="h-6 w-6 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ )}
+
+ {/* 푸터: 작성일시 */}
+ <div className="flex items-center justify-between text-xs text-gray-500 pt-2 border-t border-gray-200">
+ <span>
+ 작성일: {formatDateTime(comment.createdAt, "KR")}
+ </span>
+ {comment.updatedAt && comment.updatedAt.getTime() !== comment.createdAt.getTime() && (
+ <span>
+ 수정일: {formatDateTime(comment.updatedAt, "KR")}
+ </span>
+ )}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ ))
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ );
+}
+
+
+
diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts
index 700c6f8f..d7b3edc8 100644
--- a/lib/basic-contract/service.ts
+++ b/lib/basic-contract/service.ts
@@ -2175,7 +2175,7 @@ export async function getVendorGtcData(contractId?: number): Promise<GtcVendorDa
const vendorDocument = vendorDocResult.length > 0 ? vendorDocResult[0] : {
id: null, // 벤더 문서가 아직 생성되지 않음
name: `GTC 검토 (벤더 ID: ${vendorId})`,
- description: "기본 GTC 조항 검토",
+ description: "기본 GTC 협의",
version: "1.0",
reviewStatus: "pending",
vendorId: vendorId,
@@ -3280,7 +3280,8 @@ function extractProjectCodeFromTemplateName(templateName: string): string | null
// 단일 contract에 대한 GTC 정보 확인
async function checkGTCCommentsForContract(
templateName: string,
- vendorId: number
+ vendorId: number,
+ basicContractId?: number
): Promise<{ gtcDocumentId: number | null; hasComments: boolean }> {
try {
const projectCode = extractProjectCodeFromTemplateName(templateName);
@@ -3316,6 +3317,7 @@ async function checkGTCCommentsForContract(
}
} else {
// Standard GTC인 경우 (general 포함하거나 project code가 없는 경우)
+ console.log(`🔍 [checkGTCCommentsForContract] Standard GTC 조회 중...`);
const standardGtcDoc = await db
.select({ id: gtcDocuments.id })
.from(gtcDocuments)
@@ -3329,15 +3331,49 @@ async function checkGTCCommentsForContract(
.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(gtcDocumentId,"gtcDocumentId")
+ console.log(`🎯 [checkGTCCommentsForContract] 최종 gtcDocumentId: ${gtcDocumentId}`)
// GTC Document를 찾지 못한 경우
+ 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 };
}
@@ -3388,53 +3424,53 @@ async function checkGTCCommentsForContract(
}
}
-// 전체 contract 리스트에 대해 GTC document ID와 comment 정보 수집
-export async function checkGTCCommentsForContracts(
- contracts: BasicContractView[]
-): Promise<Record<number, { gtcDocumentId: number | null; hasComments: boolean }>> {
- const gtcData: Record<number, { gtcDocumentId: number | null; hasComments: boolean }> = {};
+// // 전체 contract 리스트에 대해 GTC document ID와 comment 정보 수집
+// export async function checkGTCCommentsForContracts(
+// contracts: BasicContractView[]
+// ): Promise<Record<number, { gtcDocumentId: number | null; hasComments: boolean }>> {
+// const gtcData: Record<number, { gtcDocumentId: number | null; hasComments: boolean }> = {};
- // GTC가 포함된 contract만 필터링
- const gtcContracts = contracts.filter(contract =>
- contract.templateName?.includes('GTC')
- );
+// // GTC가 포함된 contract만 필터링
+// const gtcContracts = contracts.filter(contract =>
+// contract.templateName?.includes('GTC')
+// );
- if (gtcContracts.length === 0) {
- return gtcData;
- }
+// if (gtcContracts.length === 0) {
+// return gtcData;
+// }
- // Promise.all을 사용해서 병렬 처리
- const checkPromises = gtcContracts.map(async (contract) => {
- try {
- const result = await checkGTCCommentsForContract(
- contract.templateName!,
- contract.vendorId!
- );
+// // Promise.all을 사용해서 병렬 처리
+// const checkPromises = gtcContracts.map(async (contract) => {
+// try {
+// const result = await checkGTCCommentsForContract(
+// contract.templateName!,
+// contract.vendorId!
+// );
- 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
- };
- }
- });
+// 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);
+// const results = await Promise.all(checkPromises);
- // 결과를 Record 형태로 변환
- results.forEach(({ contractId, gtcDocumentId, hasComments }) => {
- gtcData[contractId] = { gtcDocumentId, hasComments };
- });
+// // 결과를 Record 형태로 변환
+// results.forEach(({ contractId, gtcDocumentId, hasComments }) => {
+// gtcData[contractId] = { gtcDocumentId, hasComments };
+// });
- return gtcData;
-}
+// return gtcData;
+// }
diff --git a/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx
index c6f82fc8..0dd33bcb 100644
--- a/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx
+++ b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx
@@ -218,9 +218,19 @@ export function getDetailColumns({
const handleOpenGTC = (e: React.MouseEvent) => {
e.stopPropagation()
- if (contractGtcData?.gtcDocumentId) {
- const gtcUrl = `/evcp/basic-contract/vendor-gtc/${contractGtcData.gtcDocumentId}?vendorId=${contract.vendorId}&vendorName=${encodeURIComponent(contract.vendorName || '')}&contractId=${contract.id}&templateId=${contract.templateId}`
+
+ // gtcDocumentId가 있으면 그걸 사용, 없으면 templateId 사용
+ const documentIdToUse = contractGtcData?.gtcDocumentId || contract.templateId
+
+ if (documentIdToUse && contract.vendorId) {
+ const gtcUrl = `/evcp/basic-contract/vendor-gtc/${documentIdToUse}?vendorId=${contract.vendorId}&vendorName=${encodeURIComponent(contract.vendorName || '')}&contractId=${contract.id}&templateId=${contract.templateId}`
window.open(gtcUrl, '_blank')
+ } else {
+ console.error('GTC 페이지를 열 수 없습니다:', {
+ gtcDocumentId: contractGtcData?.gtcDocumentId,
+ templateId: contract.templateId,
+ vendorId: contract.vendorId
+ })
}
}
diff --git a/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx b/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx
index 80c3352a..407463e4 100644
--- a/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx
+++ b/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx
@@ -9,7 +9,8 @@ import type {
DataTableRowAction,
} from "@/types/table"
import { getDetailColumns } from "./basic-contracts-detail-columns"
-import { getBasicContractsByTemplateId, checkGTCCommentsForContracts } from "@/lib/basic-contract/service"
+import { getBasicContractsByTemplateId } from "@/lib/basic-contract/service"
+import { checkGTCCommentsForContracts } from "@/lib/basic-contract/actions/check-gtc-comments"
import { BasicContractView } from "@/db/schema"
import { BasicContractDetailTableToolbarActions } from "./basic-contract-detail-table-toolbar-actions"
import { toast } from "sonner"
diff --git a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
index 47a371d9..3dc2c6fc 100644
--- a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
+++ b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
@@ -5,7 +5,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { formatDate } from "@/lib/utils";
-import { toast } from "sonner";
+import { useToast } from "@/hooks/use-toast";
import { cn } from "@/lib/utils";
import type { WebViewerInstance } from "@pdftron/webviewer";
import type { BasicContractView } from "@/db/schema";
@@ -62,6 +62,8 @@ export function BasicContractSignDialog({
open: externalOpen,
onOpenChange: externalOnOpenChange
}: BasicContractSignDialogProps) {
+ const { toast } = useToast();
+
// 내부 상태 (외부 제어가 없을 때 사용)
const [internalOpen, setInternalOpen] = React.useState(false);
const [selectedContract, setSelectedContract] = React.useState<BasicContractView | null>(null);
@@ -257,7 +259,7 @@ const canCompleteCurrentContract = React.useMemo(() => {
}
// "비밀유지 계약서"인 경우에만 추가 파일 가져오기
- if (selectedContract.templateName === "비밀유지 계약서") {
+ if (selectedContract.templateName === "비밀유지 계약서" && selectedContract.vendorId) {
setIsLoadingAttachments(true);
try {
const result = await getVendorAttachments(selectedContract.vendorId);
@@ -327,9 +329,10 @@ const canCompleteCurrentContract = React.useMemo(() => {
const signatureCompleted = signatureStatus[contractId] === true;
if (!signatureCompleted) {
- toast.error("계약서에 서명을 먼저 완료해주세요.", {
+ toast({
+ title: "계약서에 서명을 먼저 완료해주세요.",
description: "문서의 서명 필드에 서명해주세요.",
- icon: <Target className="h-5 w-5 text-blue-500" />
+ variant: "destructive"
});
return;
}
@@ -342,25 +345,28 @@ const canCompleteCurrentContract = React.useMemo(() => {
const signatureCompleted = signatureStatus[contractId] === true;
if (!surveyCompleted) {
- toast.error("준법 설문조사를 먼저 완료해주세요.", {
+ toast({
+ title: "준법 설문조사를 먼저 완료해주세요.",
description: "설문조사 탭에서 모든 필수 항목을 완료해주세요.",
- icon: <AlertCircle className="h-5 w-5 text-red-500" />
+ variant: "destructive"
});
return;
}
if (!gtcCompleted) {
- toast.error("GTC 조항에 코멘트가 있어 서명할 수 없습니다.", {
- description: "조항 검토 탭에서 모든 코멘트를 삭제하거나 협의를 완료해주세요.",
- icon: <AlertCircle className="h-5 w-5 text-red-500" />
+ toast({
+ title: "코멘트가 있어 서명할 수 없습니다.",
+ description: "협의 코멘트 탭에서 모든 코멘트를 삭제하거나 협의를 완료해주세요.",
+ variant: "destructive"
});
return;
}
if (!signatureCompleted) {
- toast.error("계약서에 서명을 먼저 완료해주세요.", {
+ toast({
+ title: "계약서에 서명을 먼저 완료해주세요.",
description: "문서의 서명 필드에 서명해주세요.",
- icon: <Target className="h-5 w-5 text-blue-500" />
+ variant: "destructive"
});
return;
}
@@ -406,9 +412,9 @@ const canCompleteCurrentContract = React.useMemo(() => {
)
);
- toast.success("최종승인이 완료되었습니다!", {
- description: `${selectedContract.templateName} - ${completedCount + 1}/${totalCount}개 완료`,
- icon: <CheckCircle2 className="h-5 w-5 text-green-500" />
+ toast({
+ title: "최종승인이 완료되었습니다!",
+ description: `${selectedContract.templateName} - ${completedCount + 1}/${totalCount}개 완료`
});
// 구매자 서명 완료 콜백 호출
@@ -420,15 +426,11 @@ const canCompleteCurrentContract = React.useMemo(() => {
const nextContract = getNextPendingContract();
if (nextContract) {
setSelectedContract(nextContract);
- // toast.info(`다음 계약서로 이동합니다`, {
- // description: nextContract.templateName,
- // icon: <ArrowRight className="h-4 w-4 text-blue-500" />
- // });
} else {
// 모든 계약서 완료시
- toast.success("🎉 모든 계약서 최종승인이 완료되었습니다!", {
- description: `총 ${totalCount}개 계약서 승인 완료`,
- icon: <Trophy className="h-5 w-5 text-yellow-500" />
+ toast({
+ title: "🎉 모든 계약서 최종승인이 완료되었습니다!",
+ description: `총 ${totalCount}개 계약서 승인 완료`
});
}
@@ -443,9 +445,10 @@ const canCompleteCurrentContract = React.useMemo(() => {
)
);
- toast.error("최종승인 처리 중 오류가 발생했습니다", {
+ toast({
+ title: "최종승인 처리 중 오류가 발생했습니다",
description: result.message,
- icon: <AlertCircle className="h-5 w-5 text-red-500" />
+ variant: "destructive"
});
}
} else {
@@ -479,24 +482,20 @@ const canCompleteCurrentContract = React.useMemo(() => {
)
);
- toast.success("계약서 서명이 완료되었습니다!", {
- description: `${selectedContract.templateName} - ${completedCount + 1}/${totalCount}개 완료`,
- icon: <CheckCircle2 className="h-5 w-5 text-green-500" />
+ toast({
+ title: "계약서 서명이 완료되었습니다!",
+ description: `${selectedContract.templateName} - ${completedCount + 1}/${totalCount}개 완료`
});
// 다음 미완료 계약서로 자동 이동
const nextContract = getNextPendingContract();
if (nextContract) {
setSelectedContract(nextContract);
- // toast.info(`다음 계약서로 이동합니다`, {
- // description: nextContract.templateName,
- // icon: <ArrowRight className="h-4 w-4 text-blue-500" />
- // });
} else {
// 모든 계약서 완료시
- toast.success("🎉 모든 계약서 서명이 완료되었습니다!", {
- description: `총 ${totalCount}개 계약서 서명 완료`,
- icon: <Trophy className="h-5 w-5 text-yellow-500" />
+ toast({
+ title: "🎉 모든 계약서 서명이 완료되었습니다!",
+ description: `총 ${totalCount}개 계약서 서명 완료`
});
}
@@ -511,9 +510,10 @@ const canCompleteCurrentContract = React.useMemo(() => {
)
);
- toast.error("서명 처리 중 오류가 발생했습니다", {
+ toast({
+ title: "서명 처리 중 오류가 발생했습니다",
description: result.error,
- icon: <AlertCircle className="h-5 w-5 text-red-500" />
+ variant: "destructive"
});
}
}
@@ -529,7 +529,10 @@ const canCompleteCurrentContract = React.useMemo(() => {
)
);
- toast.error("서명 처리 중 오류가 발생했습니다");
+ toast({
+ title: "서명 처리 중 오류가 발생했습니다",
+ variant: "destructive"
+ });
} finally {
setIsSubmitting(false);
}
@@ -545,9 +548,9 @@ const canCompleteCurrentContract = React.useMemo(() => {
? "모든 계약서 최종승인이 완료되었습니다!"
: "모든 계약서 서명이 완료되었습니다!";
- toast.success(successMessage, {
- description: "계약서 관리 페이지가 새고침됩니다.",
- icon: <Trophy className="h-5 w-5 text-yellow-500" />
+ toast({
+ title: successMessage,
+ description: "계약서 관리 페이지가 새고침됩니다."
});
};
@@ -776,7 +779,7 @@ const canCompleteCurrentContract = React.useMemo(() => {
{isGTCTemplate && (
<span className={`flex items-center ${hasGtcCompleted ? 'text-green-600' : 'text-red-600'}`}>
<CheckCircle2 className={`h-3 w-3 mr-1 ${hasGtcCompleted ? 'text-green-500' : 'text-red-500'}`} />
- 조항검토
+ 협의
</span>
)}
<span className={`flex items-center ${hasSignatureCompleted ? 'text-green-600' : 'text-gray-400'}`}>
@@ -952,7 +955,7 @@ const canCompleteCurrentContract = React.useMemo(() => {
{selectedContract.templateName?.includes('GTC') && (
<span className={`flex items-center ${(gtcCommentStatus[selectedContract.id]?.isComplete === true) ? 'text-green-600' : 'text-red-600'}`}>
<CheckCircle2 className={`h-3 w-3 mr-1 ${(gtcCommentStatus[selectedContract.id]?.isComplete === true) ? 'text-green-500' : 'text-red-500'}`} />
- 조항검토 {(gtcCommentStatus[selectedContract.id]?.isComplete === true) ? '완료' :
+ 협의 {(gtcCommentStatus[selectedContract.id]?.isComplete === true) ? '완료' :
`미완료 (코멘트 ${gtcCommentStatus[selectedContract.id]?.commentCount || 0}개)`}
</span>
)}
diff --git a/lib/basic-contract/vendor-table/update-vendor-document-status-button.tsx b/lib/basic-contract/vendor-table/update-vendor-document-status-button.tsx
index e6e22fda..9cdd6593 100644
--- a/lib/basic-contract/vendor-table/update-vendor-document-status-button.tsx
+++ b/lib/basic-contract/vendor-table/update-vendor-document-status-button.tsx
@@ -52,8 +52,8 @@ export function UpdateVendorDocumentStatusButton({
: "협의 완료를 취소하시겠습니까?"
// 상태에 따른 다이얼로그 설명
- const dialogDescription = newStatus === "approved"
- ? "협의가 완료 처리되면 협력업체가 계약서에 서명할 수 있게 됩니다. 모든 조항 검토가 완료되었는지 확인해주세요."
+ const dialogDescription = newStatus === "approved"
+ ? "협의가 완료 처리되면 협력업체가 계약서에 서명할 수 있게 됩니다. 모든 협의가 완료되었는지 확인해주세요."
: "협의 완료를 취소하면 협력업체가 계약서에 서명할 수 없게 됩니다. 추가 수정이 필요한 경우에만 취소해주세요."
// 상태에 따른 토스트 메시지
@@ -122,8 +122,8 @@ export function UpdateVendorDocumentStatusButton({
<strong>확인사항:</strong>
</p>
<ul className="text-sm text-blue-700 mt-1 ml-4 list-disc">
- <li>모든 필수 조항이 검토되었습니까?</li>
- <li>협력업체와의 협의 내용이 모두 반영되었습니까?</li>
+ <li>모든 협의 내용이 반영되었습니까?</li>
+ <li>협력업체와의 협의가 완료되었습니까?</li>
<li>법무팀 검토가 완료되었습니까?</li>
</ul>
</div>
diff --git a/lib/basic-contract/viewer/GtcClausesComponent.tsx b/lib/basic-contract/viewer/GtcClausesComponent.tsx
index 381e69dc..e44879ab 100644
--- a/lib/basic-contract/viewer/GtcClausesComponent.tsx
+++ b/lib/basic-contract/viewer/GtcClausesComponent.tsx
@@ -853,8 +853,8 @@ export function GtcClausesComponent({
)}
</h3>
{!compactMode && (
- <p className="text-sm text-gray-500 mt-0.5">
- {gtcData.vendorDocument.description || "GTC 조항 검토 및 협의"}
+ <p className="text-sm text-gray-500 mt-0.5">
+ {gtcData.vendorDocument.description || "GTC 협의"}
</p>
)}
</div>
diff --git a/lib/basic-contract/viewer/clause-review-list.tsx b/lib/basic-contract/viewer/clause-review-list.tsx
new file mode 100644
index 00000000..9b0c6271
--- /dev/null
+++ b/lib/basic-contract/viewer/clause-review-list.tsx
@@ -0,0 +1,475 @@
+"use client";
+
+import React, { useState, useCallback } 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,
+} from "lucide-react";
+import { cn, formatDateTime } from "@/lib/utils";
+
+export type ReviewCommentAuthorType = 'SHI' | 'Vendor';
+
+export interface ReviewComment {
+ id: string;
+ authorType: ReviewCommentAuthorType;
+ authorName?: string;
+ comment: string;
+ attachments?: ReviewAttachment[];
+ createdAt: Date;
+ updatedAt?: Date;
+}
+
+export interface ReviewAttachment {
+ id: string;
+ fileName: string;
+ filePath: string;
+ fileSize: number;
+ uploadedAt: Date;
+}
+
+export interface ClauseReviewListProps {
+ documentId?: number;
+ comments: ReviewComment[];
+ onAddComment?: (comment: Omit<ReviewComment, 'id' | 'createdAt'>) => Promise<void>;
+ onDeleteComment?: (commentId: string) => Promise<void>;
+ onUploadAttachment?: (commentId: string, file: File) => Promise<ReviewAttachment>;
+ onDeleteAttachment?: (commentId: string, attachmentId: string) => Promise<void>;
+ filterAuthorType?: ReviewCommentAuthorType | 'ALL';
+ readOnly?: boolean;
+ currentUserType?: ReviewCommentAuthorType;
+ className?: string;
+}
+
+export function ClauseReviewList({
+ documentId,
+ comments,
+ onAddComment,
+ onDeleteComment,
+ onUploadAttachment,
+ onDeleteAttachment,
+ filterAuthorType = 'ALL',
+ readOnly = false,
+ currentUserType = 'Vendor',
+ className,
+}: ClauseReviewListProps) {
+ const [isAdding, setIsAdding] = useState(false);
+ const [newComment, setNewComment] = useState('');
+ const [newAuthorName, setNewAuthorName] = useState('');
+ const [uploadingFiles, setUploadingFiles] = useState<Set<string>>(new Set());
+ const [isSaving, setIsSaving] = useState(false);
+
+ // 필터링된 코멘트
+ const filteredComments = React.useMemo(() => {
+ if (filterAuthorType === 'ALL') return comments;
+ return comments.filter(c => c.authorType === filterAuthorType);
+ }, [comments, filterAuthorType]);
+
+ // 코멘트 추가 핸들러
+ const handleAddComment = useCallback(async () => {
+ if (!newComment.trim()) {
+ toast.error("코멘트를 입력해주세요.");
+ return;
+ }
+
+ if (!onAddComment) {
+ toast.error("코멘트 추가 기능이 활성화되지 않았습니다.");
+ return;
+ }
+
+ setIsSaving(true);
+ try {
+ await onAddComment({
+ authorType: currentUserType,
+ authorName: newAuthorName.trim() || undefined,
+ comment: newComment.trim(),
+ attachments: [],
+ });
+
+ setNewComment('');
+ setNewAuthorName('');
+ setIsAdding(false);
+ toast.success("코멘트가 추가되었습니다.");
+ } catch (error) {
+ console.error('코멘트 추가 실패:', error);
+ toast.error("코멘트 추가에 실패했습니다.");
+ } finally {
+ setIsSaving(false);
+ }
+ }, [newComment, newAuthorName, currentUserType, onAddComment]);
+
+ // 코멘트 삭제 핸들러
+ const handleDeleteComment = useCallback(async (commentId: string) => {
+ if (!onDeleteComment) return;
+
+ try {
+ await onDeleteComment(commentId);
+ toast.success("코멘트가 삭제되었습니다.");
+ } catch (error) {
+ console.error('코멘트 삭제 실패:', error);
+ toast.error("코멘트 삭제에 실패했습니다.");
+ }
+ }, [onDeleteComment]);
+
+ // 첨부파일 업로드 핸들러
+ const handleUploadAttachment = useCallback(async (commentId: string, file: File) => {
+ if (!onUploadAttachment) {
+ toast.error("첨부파일 업로드 기능이 활성화되지 않았습니다.");
+ return;
+ }
+
+ setUploadingFiles(prev => new Set(prev).add(commentId));
+ try {
+ await onUploadAttachment(commentId, file);
+ toast.success(`${file.name}이(가) 업로드되었습니다.`);
+ } catch (error) {
+ console.error('파일 업로드 실패:', error);
+ toast.error("파일 업로드에 실패했습니다.");
+ } finally {
+ setUploadingFiles(prev => {
+ const next = new Set(prev);
+ next.delete(commentId);
+ return next;
+ });
+ }
+ }, [onUploadAttachment]);
+
+ // 첨부파일 삭제 핸들러
+ const handleDeleteAttachment = useCallback(async (commentId: string, attachmentId: string) => {
+ if (!onDeleteAttachment) return;
+
+ try {
+ await onDeleteAttachment(commentId, attachmentId);
+ toast.success("첨부파일이 삭제되었습니다.");
+ } catch (error) {
+ console.error('첨부파일 삭제 실패:', error);
+ toast.error("첨부파일 삭제에 실패했습니다.");
+ }
+ }, [onDeleteAttachment]);
+
+ // 파일 크기 포맷팅
+ 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';
+ };
+
+ return (
+ <div className={cn("h-full flex flex-col", className)}>
+ {/* 헤더 */}
+ <div className="flex-shrink-0 border-b bg-gray-50 p-3">
+ <div className="flex items-center justify-between mb-2">
+ <div className="flex items-center space-x-2">
+ <MessageSquare className="h-5 w-5 text-blue-500" />
+ <h3 className="font-semibold text-gray-800">협의 코멘트</h3>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
+ 총 {filteredComments.length}개
+ </Badge>
+ {!readOnly && (
+ <Button
+ size="sm"
+ onClick={() => setIsAdding(true)}
+ disabled={isAdding}
+ className="h-8"
+ >
+ <Plus className="h-4 w-4 mr-1" />
+ 코멘트 추가
+ </Button>
+ )}
+ </div>
+ </div>
+
+ {/* 안내 메시지 */}
+ <p className="text-sm text-gray-600">
+ {filterAuthorType === 'SHI'
+ ? 'SHI(삼성중공업) 코멘트만 표시됩니다.'
+ : filterAuthorType === 'Vendor'
+ ? '협력업체 코멘트만 표시됩니다.'
+ : '모든 코멘트를 표시합니다.'}
+ </p>
+ </div>
+
+ {/* 코멘트 리스트 */}
+ <ScrollArea className="flex-1">
+ <div className="p-3 space-y-3">
+ {/* 새 코멘트 입력 폼 */}
+ {isAdding && !readOnly && (
+ <Card className="border-blue-200 bg-blue-50">
+ <CardContent className="pt-4">
+ <div className="space-y-3">
+ <div>
+ <Label className="text-sm font-medium text-gray-700">
+ 작성자 유형
+ </Label>
+ <div className="mt-1.5 flex items-center space-x-2">
+ <Badge
+ variant="outline"
+ className={cn(
+ "px-3 py-1",
+ currentUserType === 'SHI'
+ ? "bg-blue-100 text-blue-700 border-blue-300"
+ : "bg-green-100 text-green-700 border-green-300"
+ )}
+ >
+ {currentUserType === 'SHI' ? (
+ <>
+ <Building2 className="h-3 w-3 mr-1" />
+ SHI
+ </>
+ ) : (
+ <>
+ <User className="h-3 w-3 mr-1" />
+ 협력업체
+ </>
+ )}
+ </Badge>
+ </div>
+ </div>
+
+ <div>
+ <Label htmlFor="authorName" className="text-sm font-medium text-gray-700">
+ 작성자 이름 (선택사항)
+ </Label>
+ <Input
+ id="authorName"
+ value={newAuthorName}
+ onChange={(e) => setNewAuthorName(e.target.value)}
+ placeholder="작성자 이름을 입력하세요..."
+ className="mt-1.5"
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="comment" className="text-sm font-medium text-gray-700">
+ 코멘트 *
+ </Label>
+ <Textarea
+ id="comment"
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ placeholder="코멘트를 입력하세요..."
+ className="mt-1.5 min-h-[100px]"
+ />
+ </div>
+
+ <div className="flex items-center justify-end space-x-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ setIsAdding(false);
+ setNewComment('');
+ setNewAuthorName('');
+ }}
+ disabled={isSaving}
+ >
+ <X className="h-4 w-4 mr-1" />
+ 취소
+ </Button>
+ <Button
+ size="sm"
+ onClick={handleAddComment}
+ disabled={isSaving || !newComment.trim()}
+ >
+ {isSaving ? (
+ <Loader2 className="h-4 w-4 mr-1 animate-spin" />
+ ) : (
+ <Save className="h-4 w-4 mr-1" />
+ )}
+ 저장
+ </Button>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 기존 코멘트 목록 */}
+ {filteredComments.length === 0 ? (
+ <div className="text-center py-8">
+ <MessageSquare className="h-12 w-12 text-gray-300 mx-auto mb-3" />
+ <p className="text-sm text-gray-500">
+ {isAdding
+ ? "첫 번째 코멘트를 작성해보세요."
+ : "아직 코멘트가 없습니다."}
+ </p>
+ </div>
+ ) : (
+ filteredComments.map((comment) => (
+ <Card
+ key={comment.id}
+ className={cn(
+ "transition-all duration-200 hover:shadow-md",
+ comment.authorType === 'SHI'
+ ? "border-blue-200 bg-blue-50/50"
+ : "border-green-200 bg-green-50/50"
+ )}
+ >
+ <CardContent className="pt-4">
+ <div className="space-y-3">
+ {/* 헤더: 작성자 정보 */}
+ <div className="flex items-start justify-between">
+ <div className="flex items-center space-x-2">
+ <Badge
+ variant="outline"
+ className={cn(
+ "px-2 py-0.5",
+ comment.authorType === 'SHI'
+ ? "bg-blue-100 text-blue-700 border-blue-300"
+ : "bg-green-100 text-green-700 border-green-300"
+ )}
+ >
+ {comment.authorType === 'SHI' ? (
+ <>
+ <Building2 className="h-3 w-3 mr-1" />
+ SHI
+ </>
+ ) : (
+ <>
+ <User className="h-3 w-3 mr-1" />
+ 협력업체
+ </>
+ )}
+ </Badge>
+ {comment.authorName && (
+ <span className="text-sm font-medium text-gray-700">
+ {comment.authorName}
+ </span>
+ )}
+ </div>
+
+ {!readOnly && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDeleteComment(comment.id)}
+ className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+
+ {/* 코멘트 내용 */}
+ <div className="bg-white rounded-md p-3 border border-gray-200">
+ <p className="text-sm text-gray-800 whitespace-pre-wrap leading-relaxed">
+ {comment.comment}
+ </p>
+ </div>
+
+ {/* 첨부파일 */}
+ {(comment.attachments && comment.attachments.length > 0) || (!readOnly && onUploadAttachment) ? (
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <Label className="text-xs font-medium text-gray-600 flex items-center">
+ <Paperclip className="h-3 w-3 mr-1" />
+ 첨부파일 {comment.attachments?.length ? `(${comment.attachments.length})` : ''}
+ </Label>
+ {!readOnly && onUploadAttachment && (
+ <label htmlFor={`file-${comment.id}`}>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ className="h-6 text-xs"
+ disabled={uploadingFiles.has(comment.id)}
+ onClick={() => document.getElementById(`file-${comment.id}`)?.click()}
+ >
+ {uploadingFiles.has(comment.id) ? (
+ <Loader2 className="h-3 w-3 mr-1 animate-spin" />
+ ) : (
+ <Upload className="h-3 w-3 mr-1" />
+ )}
+ 업로드
+ </Button>
+ <input
+ id={`file-${comment.id}`}
+ type="file"
+ className="hidden"
+ onChange={(e) => {
+ const file = e.target.files?.[0];
+ if (file) {
+ handleUploadAttachment(comment.id, file);
+ }
+ e.target.value = '';
+ }}
+ />
+ </label>
+ )}
+ </div>
+
+ {comment.attachments && comment.attachments.length > 0 && (
+ <div className="space-y-1.5">
+ {comment.attachments.map((attachment) => (
+ <div
+ key={attachment.id}
+ className="flex items-center justify-between bg-white rounded p-2 border border-gray-200 hover:border-gray-300 transition-colors"
+ >
+ <div className="flex items-center space-x-2 flex-1 min-w-0">
+ <FileText className="h-4 w-4 text-blue-500 flex-shrink-0" />
+ <div className="flex-1 min-w-0">
+ <p className="text-sm text-gray-700 truncate">
+ {attachment.fileName}
+ </p>
+ <p className="text-xs text-gray-500">
+ {formatFileSize(attachment.fileSize)}
+ </p>
+ </div>
+ </div>
+ {!readOnly && onDeleteAttachment && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDeleteAttachment(comment.id, attachment.id)}
+ className="h-6 w-6 p-0 text-red-500 hover:text-red-700 hover:bg-red-50 flex-shrink-0"
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ ) : null}
+
+ {/* 푸터: 작성일시 */}
+ <div className="flex items-center justify-between text-xs text-gray-500 pt-2 border-t border-gray-200">
+ <span>
+ 작성일: {formatDateTime(comment.createdAt, "KR")}
+ </span>
+ {comment.updatedAt && comment.updatedAt.getTime() !== comment.createdAt.getTime() && (
+ <span>
+ 수정일: {formatDateTime(comment.updatedAt, "KR")}
+ </span>
+ )}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ ))
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ );
+}
+
diff --git a/lib/mail/templates/agreement-comment-notification.hbs b/lib/mail/templates/agreement-comment-notification.hbs
new file mode 100644
index 00000000..67ccbdd4
--- /dev/null
+++ b/lib/mail/templates/agreement-comment-notification.hbs
@@ -0,0 +1,155 @@
+<!DOCTYPE html>
+<html lang="ko">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>GTC 기본계약서 협의 코멘트 알림</title>
+ <style>
+ body {
+ font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ line-height: 1.6;
+ color: #333;
+ max-width: 600px;
+ margin: 0 auto;
+ padding: 20px;
+ background-color: #f4f4f4;
+ }
+ .container {
+ background-color: #ffffff;
+ border-radius: 8px;
+ padding: 30px;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ }
+ .header {
+ text-align: center;
+ padding-bottom: 20px;
+ border-bottom: 2px solid #0066cc;
+ margin-bottom: 30px;
+ }
+ .header h1 {
+ color: #0066cc;
+ margin: 0;
+ font-size: 24px;
+ }
+ .content {
+ margin-bottom: 30px;
+ }
+ .info-box {
+ background-color: #f8f9fa;
+ border-left: 4px solid #0066cc;
+ padding: 15px;
+ margin: 20px 0;
+ }
+ .info-box h3 {
+ margin-top: 0;
+ color: #0066cc;
+ font-size: 16px;
+ }
+ .comment-box {
+ background-color: #fff;
+ border: 1px solid #dee2e6;
+ border-radius: 4px;
+ padding: 15px;
+ margin: 15px 0;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ }
+ .button {
+ display: inline-block;
+ padding: 12px 30px;
+ background-color: #0066cc;
+ color: #ffffff !important;
+ text-decoration: none;
+ border-radius: 4px;
+ font-weight: bold;
+ text-align: center;
+ margin: 20px 0;
+ }
+ .button:hover {
+ background-color: #0052a3;
+ }
+ .footer {
+ text-align: center;
+ padding-top: 20px;
+ border-top: 1px solid #dee2e6;
+ margin-top: 30px;
+ font-size: 12px;
+ color: #6c757d;
+ }
+ .footer a {
+ color: #0066cc;
+ text-decoration: none;
+ }
+ .label {
+ font-weight: bold;
+ color: #495057;
+ display: inline-block;
+ min-width: 100px;
+ }
+ .value {
+ color: #212529;
+ }
+ .highlight {
+ background-color: #fff3cd;
+ padding: 2px 4px;
+ border-radius: 2px;
+ }
+ </style>
+</head>
+<body>
+ <div class="container">
+ <div class="header">
+ <h1>📝 GTC 기본계약서 협의 알림</h1>
+ </div>
+
+ <div class="content">
+ <p>{{recipientName}}님, 안녕하세요.</p>
+
+ <p>
+ <strong class="highlight">{{authorType}}</strong>의 <strong>{{authorName}}</strong>님이
+ GTC 기본계약서에 새로운 협의 코멘트를 작성했습니다.
+ </p>
+
+ <div class="info-box">
+ <h3>📋 계약서 정보</h3>
+ <p>
+ <span class="label">계약서 템플릿:</span>
+ <span class="value">{{templateName}}</span>
+ </p>
+ {{#if vendorName}}
+ <p>
+ <span class="label">협력업체:</span>
+ <span class="value">{{vendorName}}</span>
+ </p>
+ {{/if}}
+ </div>
+
+ <div class="info-box">
+ <h3>💬 작성된 코멘트</h3>
+ <div class="comment-box">{{comment}}</div>
+ </div>
+
+ <p style="text-align: center;">
+ <a href="{{contractUrl}}" class="button">
+ 협의 내용 확인하기 →
+ </a>
+ </p>
+
+ <p style="color: #6c757d; font-size: 14px;">
+ 💡 협의 내용을 확인하시고 답변 또는 추가 코멘트를 작성해 주시기 바랍니다.
+ </p>
+ </div>
+
+ <div class="footer">
+ <p>
+ 본 메일은 발신 전용 메일입니다. 문의사항이 있으시면
+ <a href="{{systemUrl}}">eVCP 시스템</a>을 통해 문의해 주시기 바랍니다.
+ </p>
+ <p>© {{currentYear}} Samsung Heavy Industries. All rights reserved.</p>
+ </div>
+ </div>
+</body>
+</html>
+
+
+