summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--db/schema/agreementComments.ts2
-rw-r--r--lib/basic-contract/agreement-comments/actions.ts150
-rw-r--r--lib/basic-contract/agreement-comments/agreement-comment-list.tsx104
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>