From c92bd1b8caa6ddabe6acee42018262febd5d91fb Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 19 Nov 2025 06:15:43 +0000 Subject: (임수민) 기본계약 코멘트, 법무검토 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/basic-contract/agreement-comments/actions.ts | 242 +++++++++++++++++---- .../agreement-comments/agreement-comment-list.tsx | 231 +++++++++++++++++--- 2 files changed, 405 insertions(+), 68 deletions(-) (limited to 'lib/basic-contract/agreement-comments') diff --git a/lib/basic-contract/agreement-comments/actions.ts b/lib/basic-contract/agreement-comments/actions.ts index 13db2fc6..c4ded36e 100644 --- a/lib/basic-contract/agreement-comments/actions.ts +++ b/lib/basic-contract/agreement-comments/actions.ts @@ -56,16 +56,17 @@ export async function getAgreementComments( if (comment.attachments) { try { + const attachmentData = comment.attachments; // 문자열인 경우 파싱 시도 - if (typeof comment.attachments === 'string') { - const trimmed = comment.attachments.trim(); + if (typeof attachmentData === 'string') { + const trimmed = String(attachmentData).trim(); if (trimmed && trimmed !== '') { attachments = JSON.parse(trimmed); } } // 이미 배열인 경우 그대로 사용 - else if (Array.isArray(comment.attachments)) { - attachments = comment.attachments; + else if (Array.isArray(attachmentData)) { + attachments = attachmentData as AgreementCommentAttachment[]; } } catch (parseError) { console.warn(`⚠️ [getAgreementComments] 코멘트 ${comment.id}의 attachments 파싱 실패:`, parseError); @@ -90,12 +91,14 @@ export async function getAgreementComments( } /** - * 코멘트 추가 + * 코멘트 추가 (파일 첨부 포함) */ export async function addAgreementComment(data: { basicContractId: number; comment: string; authorName?: string; + files?: File[]; + shouldSendEmail?: boolean; // 이메일 발송 여부 (제출할 때만 true) }): Promise<{ success: boolean; data?: AgreementCommentData; error?: string }> { try { const session = await getServerSession(authOptions); @@ -152,6 +155,34 @@ export async function addAgreementComment(data: { templateName = template?.templateName || null; } + // 파일 업로드 처리 (있을 경우) + const uploadedAttachments: AgreementCommentAttachment[] = []; + if (data.files && data.files.length > 0) { + for (const file of data.files) { + try { + const saveResult = await saveFile({ + file, + directory: "agreement-comments", + originalName: file.name, + userId: user.id?.toString(), + }); + + if (saveResult.success && saveResult.publicPath) { + uploadedAttachments.push({ + id: crypto.randomUUID(), + fileName: file.name, + filePath: saveResult.publicPath, + fileSize: file.size, + uploadedAt: new Date(), + }); + } + } catch (fileError) { + console.error(`파일 업로드 실패 (${file.name}):`, fileError); + // 개별 파일 실패는 무시하고 계속 진행 + } + } + } + // 코멘트 저장 const [newComment] = await db .insert(agreementComments) @@ -162,24 +193,27 @@ export async function addAgreementComment(data: { authorVendorId: isVendor ? user.companyId : null, authorName: data.authorName || user.name, comment: data.comment, - attachments: JSON.stringify([]), - }) + attachments: uploadedAttachments.length > 0 ? JSON.stringify(uploadedAttachments) : JSON.stringify([]), + } as any) .returning(); - // 이메일 알림 발송 - try { - await sendCommentNotificationEmail({ - comment: newComment, - contract, - vendor, - requester, - templateName, - authorType, - authorName: data.authorName || user.name, - }); - } catch (emailError) { - console.error("이메일 발송 실패:", emailError); - // 이메일 실패는 코멘트 저장 성공에 영향을 주지 않음 + // 이메일 알림 발송 (shouldSendEmail이 true일 때만) + if (data.shouldSendEmail) { + try { + await sendCommentNotificationEmail({ + comment: newComment, + contract, + vendor, + requester, + templateName, + authorType, + authorName: data.authorName || user.name, + attachmentCount: uploadedAttachments.length, + }); + } catch (emailError) { + console.error("이메일 발송 실패:", emailError); + // 이메일 실패는 코멘트 저장 성공에 영향을 주지 않음 + } } // 계약서 상태 업데이트 (협의중으로 변경) @@ -194,7 +228,7 @@ export async function addAgreementComment(data: { data: { ...newComment, authorType: newComment.authorType as AgreementCommentAuthorType, - attachments: [], + attachments: uploadedAttachments, } as AgreementCommentData, }; } catch (error) { @@ -246,20 +280,25 @@ export async function deleteAgreementComment( isDeleted: true, deletedAt: new Date(), updatedAt: new Date(), - }) + } as any) .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); + try { + const attachmentsStr = typeof comment.attachments === 'string' + ? comment.attachments + : JSON.stringify(comment.attachments); + const attachments: AgreementCommentAttachment[] = JSON.parse(attachmentsStr); + for (const attachment of attachments) { + try { + await deleteFile(attachment.filePath); + } catch (fileError) { + console.error("파일 삭제 실패:", fileError); + } } + } catch (parseError) { + console.error("첨부파일 파싱 실패:", parseError); } } @@ -322,9 +361,17 @@ export async function uploadCommentAttachment( }; // 기존 첨부파일에 추가 - const existingAttachments: AgreementCommentAttachment[] = comment.attachments - ? JSON.parse(comment.attachments) - : []; + let existingAttachments: AgreementCommentAttachment[] = []; + if (comment.attachments) { + try { + const attachmentsStr = typeof comment.attachments === 'string' + ? comment.attachments + : JSON.stringify(comment.attachments); + existingAttachments = JSON.parse(attachmentsStr); + } catch (parseError) { + console.error("기존 첨부파일 파싱 실패:", parseError); + } + } existingAttachments.push(newAttachment); // DB 업데이트 @@ -333,7 +380,7 @@ export async function uploadCommentAttachment( .set({ attachments: JSON.stringify(existingAttachments), updatedAt: new Date(), - }) + } as any) .where(eq(agreementComments.id, commentId)); revalidateTag(`agreement-comments-${comment.basicContractId}`); @@ -372,9 +419,19 @@ export async function deleteCommentAttachment( } // 첨부파일 목록에서 제거 - const attachments: AgreementCommentAttachment[] = comment.attachments - ? JSON.parse(comment.attachments) - : []; + 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); + return { success: false, error: "첨부파일 정보를 읽을 수 없습니다." }; + } + } + const targetAttachment = attachments.find((a) => a.id === attachmentId); if (!targetAttachment) { @@ -389,7 +446,7 @@ export async function deleteCommentAttachment( .set({ attachments: JSON.stringify(updatedAttachments), updatedAt: new Date(), - }) + } as any) .where(eq(agreementComments.id, commentId)); // 파일 시스템에서 삭제 @@ -449,8 +506,9 @@ async function sendCommentNotificationEmail(params: { templateName: string | null; authorType: AgreementCommentAuthorType; authorName: string; + attachmentCount?: number; }) { - const { comment, contract, vendor, requester, templateName, authorType, authorName } = params; + const { comment, contract, vendor, requester, templateName, authorType, authorName, attachmentCount = 0 } = params; // 수신자 결정 let recipientEmail: string | undefined; @@ -478,7 +536,7 @@ async function sendCommentNotificationEmail(params: { // 이메일 발송 await sendEmail({ to: recipientEmail, - subject: `[eVCP] GTC 기본계약서 협의 코멘트 - ${templateName || '기본계약서'}`, + subject: `[eVCP] GTC 기본계약서 협의 코멘트 제출 - ${templateName || '기본계약서'}`, template: "agreement-comment-notification", context: { language: "ko", @@ -488,6 +546,7 @@ async function sendCommentNotificationEmail(params: { comment: comment.comment, templateName: templateName || '기본계약서', vendorName: vendor?.vendorName || '', + attachmentCount, 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(), @@ -495,6 +554,107 @@ async function sendCommentNotificationEmail(params: { }); } +/** + * 협의 완료 처리 + */ +export async function completeNegotiation( + basicContractId: number +): Promise<{ success: boolean; error?: string }> { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return { success: false, error: "인증이 필요합니다." }; + } + + // 기본계약서 정보 조회 + const [contract] = await db + .select() + .from(basicContract) + .where(eq(basicContract.id, basicContractId)) + .limit(1); + + if (!contract) { + return { success: false, error: "계약서를 찾을 수 없습니다." }; + } + + // 협의 완료 상태로 업데이트 + await db + .update(basicContract) + .set({ + negotiationCompletedAt: new Date(), + updatedAt: new Date(), + } as any) + .where(eq(basicContract.id, basicContractId)); + + // 벤더 정보 조회 (이메일 발송용) + 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; + } + + // 이메일 알림 발송 + try { + if (requester) { + await sendEmail({ + to: requester.email || '', + subject: `[eVCP] GTC 기본계약서 협의 완료 - ${templateName || '기본계약서'}`, + template: "negotiation-complete-notification", + context: { + language: "ko", + recipientName: requester.name || "담당자", + 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(), + }, + }); + } + } catch (emailError) { + console.error("이메일 발송 실패:", emailError); + // 이메일 실패는 협의 완료 처리 성공에 영향을 주지 않음 + } + + // 캐시 무효화 + revalidateTag(`agreement-comments-${basicContractId}`); + revalidateTag(`basic-contracts`); + + return { success: true }; + } catch (error) { + console.error("협의 완료 처리 실패:", error); + return { + success: false, + error: "협의 완료 처리 중 오류가 발생했습니다.", + }; + } +} + /** * 계약서 협의 상태 업데이트 * 실제로 DB 상태를 변경하지 않고, gtcData 조회 시 agreementComments 존재 여부로 판단 diff --git a/lib/basic-contract/agreement-comments/agreement-comment-list.tsx b/lib/basic-contract/agreement-comments/agreement-comment-list.tsx index 8b9cdbea..bad5aee5 100644 --- a/lib/basic-contract/agreement-comments/agreement-comment-list.tsx +++ b/lib/basic-contract/agreement-comments/agreement-comment-list.tsx @@ -22,6 +22,8 @@ import { Building2, Loader2, Download, + Send, + CheckCircle2, } from "lucide-react"; import { cn, formatDateTime } from "@/lib/utils"; import { @@ -30,6 +32,7 @@ import { deleteAgreementComment, uploadCommentAttachment, deleteCommentAttachment, + completeNegotiation, type AgreementCommentData, type AgreementCommentAuthorType, } from "./actions"; @@ -40,6 +43,8 @@ export interface AgreementCommentListProps { readOnly?: boolean; className?: string; onCommentCountChange?: (count: number) => void; + isNegotiationCompleted?: boolean; // 협의 완료 여부 + onNegotiationComplete?: () => void; // 협의 완료 콜백 } export function AgreementCommentList({ @@ -48,6 +53,8 @@ export function AgreementCommentList({ readOnly = false, className, onCommentCountChange, + isNegotiationCompleted = false, + onNegotiationComplete, }: AgreementCommentListProps) { const [comments, setComments] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -56,6 +63,8 @@ export function AgreementCommentList({ const [newAuthorName, setNewAuthorName] = useState(''); const [uploadingFiles, setUploadingFiles] = useState>(new Set()); const [isSaving, setIsSaving] = useState(false); + const [pendingFiles, setPendingFiles] = useState([]); // 첨부 대기 중인 파일들 + const [isCompletingNegotiation, setIsCompletingNegotiation] = useState(false); // 코멘트 로드 const loadComments = useCallback(async () => { @@ -77,8 +86,8 @@ export function AgreementCommentList({ loadComments(); }, [basicContractId]); // loadComments 대신 basicContractId만 의존 - // 코멘트 추가 핸들러 - const handleAddComment = useCallback(async () => { + // 코멘트 추가 핸들러 (저장만 - 이메일 발송 없음) + const handleAddComment = useCallback(async (shouldSendEmail: boolean = false) => { if (!newComment.trim()) { toast.error("코멘트를 입력해주세요."); return; @@ -90,13 +99,22 @@ export function AgreementCommentList({ basicContractId, comment: newComment.trim(), authorName: newAuthorName.trim() || undefined, + files: pendingFiles, + shouldSendEmail, // 이메일 발송 여부 전달 }); if (result.success) { setNewComment(''); setNewAuthorName(''); + setPendingFiles([]); setIsAdding(false); - toast.success("코멘트가 추가되었습니다."); + + if (shouldSendEmail) { + toast.success("코멘트가 제출되었으며 상대방에게 이메일이 발송되었습니다."); + } else { + toast.success("코멘트가 저장되었습니다."); + } + await loadComments(); // 목록 새로고침 } else { toast.error(result.error || "코멘트 추가에 실패했습니다."); @@ -107,7 +125,22 @@ export function AgreementCommentList({ } finally { setIsSaving(false); } - }, [newComment, newAuthorName, basicContractId]); // loadComments 제거 + }, [newComment, newAuthorName, basicContractId, pendingFiles]); // pendingFiles 추가 + + // 파일 선택 핸들러 + const handleFileSelect = useCallback((e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length > 0) { + setPendingFiles(prev => [...prev, ...files]); + toast.success(`${files.length}개의 파일이 추가되었습니다.`); + } + e.target.value = ''; // 파일 입력 초기화 + }, []); + + // 대기 중인 파일 삭제 핸들러 + const handleRemovePendingFile = useCallback((index: number) => { + setPendingFiles(prev => prev.filter((_, i) => i !== index)); + }, []); // 코멘트 삭제 핸들러 const handleDeleteComment = useCallback(async (commentId: number) => { @@ -172,6 +205,31 @@ export function AgreementCommentList({ } }, []); // loadComments 제거 + // 협의 완료 핸들러 + const handleCompleteNegotiation = useCallback(async () => { + if (!confirm("협의를 완료하시겠습니까?\n협의 완료 후에는 법무검토 요청이 가능합니다.")) { + return; + } + + setIsCompletingNegotiation(true); + try { + const result = await completeNegotiation(basicContractId); + if (result.success) { + toast.success("협의가 완료되었습니다. 이제 법무검토 요청이 가능합니다."); + if (onNegotiationComplete) { + onNegotiationComplete(); + } + } else { + toast.error(result.error || "협의 완료 처리에 실패했습니다."); + } + } catch (error) { + console.error('협의 완료 실패:', error); + toast.error("협의 완료 처리 중 오류가 발생했습니다."); + } finally { + setIsCompletingNegotiation(false); + } + }, [basicContractId, onNegotiationComplete]); + // 파일 크기 포맷팅 const formatFileSize = (bytes: number): string => { if (bytes < 1024) return bytes + ' B'; @@ -195,27 +253,51 @@ export function AgreementCommentList({

협의 코멘트

+ {isNegotiationCompleted && ( + + 협의 완료 + + )}
총 {comments.length}개 - {!readOnly && ( - + {!readOnly && !isNegotiationCompleted && ( + <> + + {comments.length > 0 && currentUserType === 'SHI' && ( + + )} + )}

- SHI와 협력업체 간 기본계약서 협의 내용을 작성하고 공유합니다. + {isNegotiationCompleted + ? "협의가 완료되었습니다. 법무검토 요청이 가능합니다." + : "SHI와 협력업체 간 기본계약서 협의 내용을 작성하고 공유합니다."}

@@ -223,7 +305,7 @@ export function AgreementCommentList({
{/* 새 코멘트 입력 폼 */} - {isAdding && !readOnly && ( + {isAdding && !readOnly && !isNegotiationCompleted && (
-
+ {/* 파일 첨부 영역 */} +
+
+ + +
+ + {/* 대기 중인 파일 목록 */} + {pendingFiles.length > 0 && ( +
+ {pendingFiles.map((file, index) => ( +
+
+ +
+

+ {file.name} +

+

+ {formatFileSize(file.size)} +

+
+
+ +
+ ))} +
+ )} + +

+ 💡 파일을 먼저 추가한 후 저장 또는 제출 버튼을 눌러주세요. +

+
+ +
+
@@ -326,9 +490,11 @@ export function AgreementCommentList({

- 아직 코멘트가 없습니다. + {isNegotiationCompleted + ? "협의가 완료되어 더 이상 코멘트를 추가할 수 없습니다." + : "아직 코멘트가 없습니다."}

- {!readOnly && ( + {!readOnly && !isNegotiationCompleted && ( - {!readOnly && ( + {!readOnly && isCommentOwner && (
-- cgit v1.2.3