From 13c8b4e48f62c1f437b1a2b10731d092fea2a83f Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 1 Dec 2025 06:20:50 +0000 Subject: (임수민) GTC 코멘트 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/basic-contract/agreement-comments/actions.ts | 150 ++++++++++++++++++++- .../agreement-comments/agreement-comment-list.tsx | 104 +++++++++++--- 2 files changed, 233 insertions(+), 21 deletions(-) (limited to 'lib') diff --git a/lib/basic-contract/agreement-comments/actions.ts b/lib/basic-contract/agreement-comments/actions.ts index 32e9ce4c..bfcc68cf 100644 --- a/lib/basic-contract/agreement-comments/actions.ts +++ b/lib/basic-contract/agreement-comments/actions.ts @@ -3,7 +3,7 @@ import { revalidateTag } from "next/cache"; import db from "@/db/db"; import { eq, and, desc, inArray, sql, isNotNull, ne } from "drizzle-orm"; -import { agreementComments, basicContract, vendors, users } from "@/db/schema"; +import { agreementComments, basicContract, basicContractTemplates, vendors, users } from "@/db/schema"; import { saveFile, deleteFile } from "@/lib/file-stroage"; import { sendEmail } from "@/lib/mail/sendEmail"; import { getServerSession } from "next-auth/next"; @@ -28,6 +28,8 @@ export interface AgreementCommentData { authorName: string | null; comment: string; attachments: AgreementCommentAttachment[]; + isSubmitted: boolean; + submittedAt: Date | null; createdAt: Date; updatedAt: Date; } @@ -98,6 +100,8 @@ export async function getAgreementComments( ...comment, authorType: comment.authorType as AgreementCommentAuthorType, attachments, + isSubmitted: comment.isSubmitted || false, + submittedAt: comment.submittedAt || null, } as AgreementCommentData; }); @@ -163,7 +167,6 @@ export async function addAgreementComment(data: { } // 템플릿 이름 조회 - const { basicContractTemplates } = await import("@/db/schema"); let templateName: string | null = null; if (contract.templateId) { const [template] = await db @@ -213,6 +216,8 @@ export async function addAgreementComment(data: { authorName: data.authorName || user.name, comment: data.comment, attachments: uploadedAttachments.length > 0 ? JSON.stringify(uploadedAttachments) : JSON.stringify([]), + isSubmitted: data.shouldSendEmail || false, // 제출 여부 설정 + submittedAt: data.shouldSendEmail ? new Date() : null, // 제출 시 제출일시 설정 } as any) .returning(); @@ -248,6 +253,8 @@ export async function addAgreementComment(data: { ...newComment, authorType: newComment.authorType as AgreementCommentAuthorType, attachments: uploadedAttachments, + isSubmitted: newComment.isSubmitted || false, + submittedAt: newComment.submittedAt || null, } as AgreementCommentData, }; } catch (error) { @@ -335,6 +342,144 @@ export async function deleteAgreementComment( } } +/** + * 코멘트 제출 (이메일 발송) + */ +export async function submitAgreementComment( + 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: "코멘트를 찾을 수 없습니다." }; + } + + // 이미 제출된 코멘트인지 확인 + if (comment.isSubmitted) { + return { success: false, error: "이미 제출된 코멘트입니다." }; + } + + // 권한 확인 (작성자만 제출 가능) + const user = session.user as any; + const isVendor = !!user.companyId; + const canSubmit = + (isVendor && comment.authorVendorId === user.companyId) || + (!isVendor && comment.authorUserId === parseInt(user.id)); + + if (!canSubmit) { + return { success: false, error: "제출 권한이 없습니다." }; + } + + // 기본계약서 정보 조회 (이메일 발송을 위해) + const [contract] = await db + .select() + .from(basicContract) + .where(eq(basicContract.id, comment.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); + } + + // 템플릿 이름 조회 + 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; + } + + // 첨부파일 정보 파싱 + let attachments: AgreementCommentAttachment[] = []; + if (comment.attachments) { + try { + const attachmentsStr = typeof comment.attachments === 'string' + ? comment.attachments + : JSON.stringify(comment.attachments); + attachments = JSON.parse(attachmentsStr); + } catch (parseError) { + console.error("첨부파일 파싱 실패:", parseError); + } + } + + // 코멘트 제출 상태 업데이트 + await db + .update(agreementComments) + .set({ + isSubmitted: true, + submittedAt: new Date(), + updatedAt: new Date(), + } as any) + .where(eq(agreementComments.id, commentId)); + + // 이메일 알림 발송 + try { + await sendCommentNotificationEmail({ + comment: { + ...comment, + isSubmitted: true, + submittedAt: new Date(), + }, + contract, + vendor, + requester, + templateName, + authorType: comment.authorType as AgreementCommentAuthorType, + authorName: comment.authorName || user.name, + attachmentCount: attachments.length, + }); + } catch (emailError) { + console.error("이메일 발송 실패:", emailError); + // 이메일 실패는 제출 성공에 영향을 주지 않음 + } + + // 캐시 무효화: 코멘트 목록 + 기본계약서 목록 + revalidateTag(`agreement-comments-${comment.basicContractId}`); + revalidateTag(`basic-contracts`); // 기본계약서 목록 새로고침 + + return { success: true }; + } catch (error) { + console.error("코멘트 제출 실패:", error); + return { + success: false, + error: "코멘트 제출 중 오류가 발생했습니다.", + }; + } +} + /** * 첨부파일 업로드 */ @@ -677,7 +822,6 @@ export async function completeNegotiation( } // 템플릿 이름 조회 - const { basicContractTemplates } = await import("@/db/schema"); let templateName: string | null = null; if (contract.templateId) { const [template] = await db diff --git a/lib/basic-contract/agreement-comments/agreement-comment-list.tsx b/lib/basic-contract/agreement-comments/agreement-comment-list.tsx index bad5aee5..fc64eab3 100644 --- a/lib/basic-contract/agreement-comments/agreement-comment-list.tsx +++ b/lib/basic-contract/agreement-comments/agreement-comment-list.tsx @@ -30,6 +30,7 @@ import { getAgreementComments, addAgreementComment, deleteAgreementComment, + submitAgreementComment, uploadCommentAttachment, deleteCommentAttachment, completeNegotiation, @@ -65,6 +66,7 @@ export function AgreementCommentList({ const [isSaving, setIsSaving] = useState(false); const [pendingFiles, setPendingFiles] = useState([]); // 첨부 대기 중인 파일들 const [isCompletingNegotiation, setIsCompletingNegotiation] = useState(false); + const [submittingComments, setSubmittingComments] = useState>(new Set()); // 제출 중인 코멘트 ID // 코멘트 로드 const loadComments = useCallback(async () => { @@ -205,6 +207,33 @@ export function AgreementCommentList({ } }, []); // loadComments 제거 + // 코멘트 제출 핸들러 + const handleSubmitComment = useCallback(async (commentId: number) => { + if (!confirm("이 코멘트를 제출하시겠습니까?\n제출 시 상대방에게 이메일이 발송됩니다.")) { + return; + } + + setSubmittingComments(prev => new Set(prev).add(commentId)); + try { + const result = await submitAgreementComment(commentId); + if (result.success) { + toast.success("코멘트가 제출되었으며 상대방에게 이메일이 발송되었습니다."); + await loadComments(); // 목록 새로고침 + } else { + toast.error(result.error || "코멘트 제출에 실패했습니다."); + } + } catch (error) { + console.error('코멘트 제출 실패:', error); + toast.error("코멘트 제출에 실패했습니다."); + } finally { + setSubmittingComments(prev => { + const next = new Set(prev); + next.delete(commentId); + return next; + }); + } + }, []); // loadComments 제거 + // 협의 완료 핸들러 const handleCompleteNegotiation = useCallback(async () => { if (!confirm("협의를 완료하시겠습니까?\n협의 완료 후에는 법무검토 요청이 가능합니다.")) { @@ -525,7 +554,7 @@ export function AgreementCommentList({
{/* 헤더: 작성자 정보 */}
-
+
)} + {comment.isSubmitted && ( + + + 제출됨 + + )}
- {!readOnly && isCommentOwner && ( - - )} +
+ {!readOnly && isCommentOwner && !comment.isSubmitted && ( + + )} + {!readOnly && isCommentOwner && ( + + )} +
{/* 코멘트 내용 */} @@ -667,16 +728,23 @@ export function AgreementCommentList({
)} - {/* 푸터: 작성일시 */} + {/* 푸터: 작성일시 및 제출일시 */}
- - 작성일: {formatDateTime(comment.createdAt, "KR")} - - {comment.updatedAt && comment.updatedAt.getTime() !== comment.createdAt.getTime() && ( +
- 수정일: {formatDateTime(comment.updatedAt, "KR")} + 작성일: {formatDateTime(comment.createdAt, "KR")} - )} + {comment.updatedAt && comment.updatedAt.getTime() !== comment.createdAt.getTime() && ( + + 수정일: {formatDateTime(comment.updatedAt, "KR")} + + )} + {comment.isSubmitted && comment.submittedAt && ( + + 제출일: {formatDateTime(comment.submittedAt, "KR")} + + )} +
-- cgit v1.2.3