diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-12-01 06:20:50 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-12-01 06:20:50 +0000 |
| commit | 13c8b4e48f62c1f437b1a2b10731d092fea2a83f (patch) | |
| tree | 8cc4cadd060e66d2eccc5ce3d1398a8d699e0164 | |
| parent | 3c9a95332298450c7e0f75bfb08944439e1a3739 (diff) | |
(임수민) GTC 코멘트 수정
| -rw-r--r-- | db/schema/agreementComments.ts | 2 | ||||
| -rw-r--r-- | lib/basic-contract/agreement-comments/actions.ts | 150 | ||||
| -rw-r--r-- | lib/basic-contract/agreement-comments/agreement-comment-list.tsx | 104 |
3 files changed, 235 insertions, 21 deletions
diff --git a/db/schema/agreementComments.ts b/db/schema/agreementComments.ts index 56631b1f..d7bbd2cb 100644 --- a/db/schema/agreementComments.ts +++ b/db/schema/agreementComments.ts @@ -38,6 +38,8 @@ export const agreementComments = pgTable('agreement_comments', { // 상태 관리 isDeleted: boolean('is_deleted').notNull().default(false), + isSubmitted: boolean('is_submitted').notNull().default(false), // 제출 여부 + submittedAt: timestamp('submitted_at'), // 제출 일시 // 감사 정보 createdAt: timestamp('created_at').defaultNow().notNull(), 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) { @@ -336,6 +343,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: "코멘트 제출 중 오류가 발생했습니다.", + }; + } +} + +/** * 첨부파일 업로드 */ export async function uploadCommentAttachment( @@ -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<File[]>([]); // 첨부 대기 중인 파일들 const [isCompletingNegotiation, setIsCompletingNegotiation] = useState(false); + const [submittingComments, setSubmittingComments] = useState<Set<number>>(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({ <div className="space-y-3"> {/* 헤더: 작성자 정보 */} <div className="flex items-start justify-between"> - <div className="flex items-center space-x-2"> + <div className="flex items-center space-x-2 flex-1"> <Badge variant="outline" className={cn( @@ -552,18 +581,50 @@ export function AgreementCommentList({ {comment.authorName} </span> )} + {comment.isSubmitted && ( + <Badge + variant="outline" + className="bg-green-100 text-green-700 border-green-300 text-xs" + > + <Send className="h-3 w-3 mr-1" /> + 제출됨 + </Badge> + )} </div> - {!readOnly && isCommentOwner && ( - <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 className="flex items-center space-x-1"> + {!readOnly && isCommentOwner && !comment.isSubmitted && ( + <Button + variant="outline" + size="sm" + onClick={() => handleSubmitComment(comment.id)} + disabled={submittingComments.has(comment.id)} + className="h-7 text-xs bg-blue-50 text-blue-700 border-blue-300 hover:bg-blue-100" + > + {submittingComments.has(comment.id) ? ( + <> + <Loader2 className="h-3 w-3 mr-1 animate-spin" /> + 제출 중... + </> + ) : ( + <> + <Send className="h-3 w-3 mr-1" /> + 제출 + </> + )} + </Button> + )} + {!readOnly && isCommentOwner && ( + <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> {/* 코멘트 내용 */} @@ -667,16 +728,23 @@ export function AgreementCommentList({ </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() && ( + <div className="flex items-center space-x-3"> <span> - 수정일: {formatDateTime(comment.updatedAt, "KR")} + 작성일: {formatDateTime(comment.createdAt, "KR")} </span> - )} + {comment.updatedAt && comment.updatedAt.getTime() !== comment.createdAt.getTime() && ( + <span> + 수정일: {formatDateTime(comment.updatedAt, "KR")} + </span> + )} + {comment.isSubmitted && comment.submittedAt && ( + <span className="text-green-600 font-medium"> + 제출일: {formatDateTime(comment.submittedAt, "KR")} + </span> + )} + </div> </div> </div> </CardContent> |
