summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/basic-contract/agreement-comments/actions.ts242
-rw-r--r--lib/basic-contract/agreement-comments/agreement-comment-list.tsx231
-rw-r--r--lib/basic-contract/service.ts128
-rw-r--r--lib/basic-contract/sslvw-service.ts191
-rw-r--r--lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx225
-rw-r--r--lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx84
-rw-r--r--lib/basic-contract/status-detail/basic-contracts-detail-table.tsx2
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx1
-rw-r--r--lib/basic-contract/viewer/SurveyComponent.tsx56
-rw-r--r--lib/basic-contract/viewer/basic-contract-sign-viewer.tsx20
-rw-r--r--lib/mail/templates/legal-review-request.hbs81
-rw-r--r--lib/mail/templates/negotiation-complete-notification.hbs77
12 files changed, 1221 insertions, 117 deletions
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(),
@@ -496,6 +555,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<AgreementCommentData[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -56,6 +63,8 @@ export function AgreementCommentList({
const [newAuthorName, setNewAuthorName] = useState('');
const [uploadingFiles, setUploadingFiles] = useState<Set<number>>(new Set());
const [isSaving, setIsSaving] = useState(false);
+ const [pendingFiles, setPendingFiles] = useState<File[]>([]); // 첨부 대기 중인 파일들
+ 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<HTMLInputElement>) => {
+ 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({
<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>
+ {isNegotiationCompleted && (
+ <Badge className="bg-green-500 text-white border-green-600">
+ 협의 완료
+ </Badge>
+ )}
</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>
+ {!readOnly && !isNegotiationCompleted && (
+ <>
+ <Button
+ size="sm"
+ onClick={() => setIsAdding(true)}
+ disabled={isAdding}
+ className="h-8"
+ >
+ <Plus className="h-4 w-4 mr-1" />
+ 코멘트 추가
+ </Button>
+ {comments.length > 0 && currentUserType === 'SHI' && (
+ <Button
+ size="sm"
+ onClick={handleCompleteNegotiation}
+ disabled={isCompletingNegotiation}
+ className="h-8 bg-green-600 hover:bg-green-700"
+ >
+ {isCompletingNegotiation ? (
+ <Loader2 className="h-4 w-4 mr-1 animate-spin" />
+ ) : (
+ <CheckCircle2 className="h-4 w-4 mr-1" />
+ )}
+ 협의 완료
+ </Button>
+ )}
+ </>
)}
</div>
</div>
<p className="text-sm text-gray-600">
- SHI와 협력업체 간 기본계약서 협의 내용을 작성하고 공유합니다.
+ {isNegotiationCompleted
+ ? "협의가 완료되었습니다. 법무검토 요청이 가능합니다."
+ : "SHI와 협력업체 간 기본계약서 협의 내용을 작성하고 공유합니다."}
</p>
</div>
@@ -223,7 +305,7 @@ export function AgreementCommentList({
<ScrollArea className="flex-1">
<div className="p-3 space-y-3">
{/* 새 코멘트 입력 폼 */}
- {isAdding && !readOnly && (
+ {isAdding && !readOnly && !isNegotiationCompleted && (
<Card
className={cn(
"border-l-4",
@@ -289,7 +371,74 @@ export function AgreementCommentList({
/>
</div>
- <div className="flex items-center justify-end space-x-2">
+ {/* 파일 첨부 영역 */}
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <Label className="text-sm font-medium text-gray-700 flex items-center">
+ <Paperclip className="h-4 w-4 mr-1" />
+ 첨부파일 {pendingFiles.length > 0 ? `(${pendingFiles.length})` : ''}
+ </Label>
+ <label htmlFor="new-comment-files">
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ className="h-8"
+ onClick={() => document.getElementById('new-comment-files')?.click()}
+ disabled={isSaving}
+ >
+ <Upload className="h-3 w-3 mr-1" />
+ 파일 추가
+ </Button>
+ <input
+ id="new-comment-files"
+ type="file"
+ multiple
+ className="hidden"
+ onChange={handleFileSelect}
+ accept="*/*"
+ />
+ </label>
+ </div>
+
+ {/* 대기 중인 파일 목록 */}
+ {pendingFiles.length > 0 && (
+ <div className="space-y-1.5 max-h-32 overflow-y-auto">
+ {pendingFiles.map((file, index) => (
+ <div
+ key={`${file.name}-${index}`}
+ className="flex items-center justify-between bg-white rounded p-2 border border-gray-200"
+ >
+ <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">
+ {file.name}
+ </p>
+ <p className="text-xs text-gray-500">
+ {formatFileSize(file.size)}
+ </p>
+ </div>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRemovePendingFile(index)}
+ 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>
+ )}
+
+ <p className="text-xs text-gray-500">
+ 💡 파일을 먼저 추가한 후 저장 또는 제출 버튼을 눌러주세요.
+ </p>
+ </div>
+
+ <div className="flex items-center justify-end gap-2 pt-2 border-t">
<Button
variant="outline"
size="sm"
@@ -297,6 +446,7 @@ export function AgreementCommentList({
setIsAdding(false);
setNewComment('');
setNewAuthorName('');
+ setPendingFiles([]);
}}
disabled={isSaving}
>
@@ -304,8 +454,9 @@ export function AgreementCommentList({
취소
</Button>
<Button
+ variant="outline"
size="sm"
- onClick={handleAddComment}
+ onClick={() => handleAddComment(false)}
disabled={isSaving || !newComment.trim()}
>
{isSaving ? (
@@ -315,6 +466,19 @@ export function AgreementCommentList({
)}
저장
</Button>
+ <Button
+ size="sm"
+ onClick={() => handleAddComment(true)}
+ disabled={isSaving || !newComment.trim()}
+ className="bg-blue-600 hover:bg-blue-700"
+ >
+ {isSaving ? (
+ <Loader2 className="h-4 w-4 mr-1 animate-spin" />
+ ) : (
+ <Send className="h-4 w-4 mr-1" />
+ )}
+ 제출 (메일발송)
+ </Button>
</div>
</div>
</CardContent>
@@ -326,9 +490,11 @@ export function AgreementCommentList({
<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">
- 아직 코멘트가 없습니다.
+ {isNegotiationCompleted
+ ? "협의가 완료되어 더 이상 코멘트를 추가할 수 없습니다."
+ : "아직 코멘트가 없습니다."}
</p>
- {!readOnly && (
+ {!readOnly && !isNegotiationCompleted && (
<Button
variant="outline"
size="sm"
@@ -341,7 +507,11 @@ export function AgreementCommentList({
)}
</div>
) : (
- comments.map((comment) => (
+ comments.map((comment) => {
+ // 현재 사용자가 이 코멘트의 작성자인지 확인
+ const isCommentOwner = comment.authorType === currentUserType;
+
+ return (
<Card
key={comment.id}
className={cn(
@@ -384,7 +554,7 @@ export function AgreementCommentList({
)}
</div>
- {!readOnly && (
+ {!readOnly && isCommentOwner && (
<Button
variant="ghost"
size="sm"
@@ -404,14 +574,14 @@ export function AgreementCommentList({
</div>
{/* 첨부파일 */}
- {(comment.attachments.length > 0 || !readOnly) && (
+ {(comment.attachments.length > 0 || (!readOnly && isCommentOwner)) && (
<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 && (
+ {!readOnly && isCommentOwner && (
<label htmlFor={`file-${comment.id}`}>
<Button
type="button"
@@ -469,11 +639,17 @@ export function AgreementCommentList({
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">
+ <a
+ href={attachment.filePath}
+ download={attachment.fileName}
+ target="_blank"
+ rel="noopener noreferrer"
+ title={`${attachment.fileName} 다운로드`}
+ >
<Download className="h-3 w-3" />
</a>
</Button>
- {!readOnly && (
+ {!readOnly && isCommentOwner && (
<Button
variant="ghost"
size="sm"
@@ -505,7 +681,8 @@ export function AgreementCommentList({
</div>
</CardContent>
</Card>
- ))
+ );
+ })
)}
</div>
</ScrollArea>
diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts
index eb3d49f5..55ac149e 100644
--- a/lib/basic-contract/service.ts
+++ b/lib/basic-contract/service.ts
@@ -2784,6 +2784,134 @@ export async function requestLegalReviewAction(
}
}
+/**
+ * SSLVW 데이터로부터 법무검토 상태 업데이트
+ * @param sslvwData 선택된 SSLVW 데이터 배열
+ * @param selectedContractIds 선택된 계약서 ID 배열
+ * @returns 성공 여부 및 메시지
+ */
+export async function updateLegalReviewStatusFromSSLVW(
+ sslvwData: Array<{ VEND_CD?: string; PRGS_STAT_DSC?: string; [key: string]: any }>,
+ selectedContractIds: number[]
+): Promise<{ success: boolean; message: string; updatedCount: number; errors: string[] }> {
+ try {
+ console.log(`[updateLegalReviewStatusFromSSLVW] SSLVW 데이터로부터 법무검토 상태 업데이트 시작`)
+
+ if (!sslvwData || sslvwData.length === 0) {
+ return {
+ success: false,
+ message: 'SSLVW 데이터가 없습니다.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ if (!selectedContractIds || selectedContractIds.length === 0) {
+ return {
+ success: false,
+ message: '선택된 계약서가 없습니다.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ // 선택된 계약서 정보 조회
+ const selectedContracts = await db
+ .select({
+ id: basicContractView.id,
+ vendorCode: basicContractView.vendorCode,
+ legalReviewStatus: basicContractView.legalReviewStatus
+ })
+ .from(basicContractView)
+ .where(inArray(basicContractView.id, selectedContractIds))
+
+ let updatedCount = 0
+ const errors: string[] = []
+
+ // 각 SSLVW 데이터에 대해 처리
+ for (const sslvwItem of sslvwData) {
+ const vendorCode = sslvwItem.VEND_CD || sslvwItem.vendorCode
+ const prgsStatDsc = sslvwItem.PRGS_STAT_DSC || sslvwItem.prgsStatDsc
+
+ if (!vendorCode || !prgsStatDsc) {
+ errors.push(`벤더코드 또는 상태 정보가 없습니다: ${JSON.stringify(sslvwItem)}`)
+ continue
+ }
+
+ // 해당 벤더의 선택된 계약서들 찾기
+ const contractsToUpdate = selectedContracts.filter(contract =>
+ contract.vendorCode === vendorCode
+ )
+
+ if (contractsToUpdate.length === 0) {
+ console.log(`벤더 ${vendorCode}의 선택된 계약서가 없음`)
+ continue
+ }
+
+ // PRGS_STAT_DSC를 legalWorks.status로 매핑
+ const statusMapping: Record<string, string> = {
+ '신규등록': '신규등록',
+ '검토요청': '검토요청',
+ '담당자배정': '담당자배정',
+ '검토중': '검토중',
+ '답변완료': '답변완료',
+ '재검토요청': '재검토요청',
+ '보류': '보류',
+ '취소': '취소'
+ }
+
+ const mappedStatus = statusMapping[prgsStatDsc] || prgsStatDsc
+
+ // 각 계약서의 legalWorks 상태 업데이트
+ for (const contract of contractsToUpdate) {
+ try {
+ const updateResult = await db
+ .update(legalWorks)
+ .set({
+ status: mappedStatus,
+ updatedAt: new Date()
+ })
+ .where(eq(legalWorks.basicContractId, contract.id))
+ .returning({ id: legalWorks.id })
+
+ if (updateResult.length > 0) {
+ console.log(`법무작업 상태 업데이트: 계약서 ${contract.id}, 상태 ${mappedStatus}`)
+ updatedCount++
+ } else {
+ console.log(`법무작업 레코드 없음: 계약서 ${contract.id}`)
+ errors.push(`계약서 ${contract.id}: 법무작업 레코드가 없습니다`)
+ }
+ } catch (contractError) {
+ console.error(`계약서 ${contract.id} 상태 업데이트 실패:`, contractError)
+ errors.push(`계약서 ${contract.id}: 업데이트 실패`)
+ }
+ }
+ }
+
+ const message = updatedCount > 0
+ ? `${updatedCount}건의 계약서 법무검토 상태가 업데이트되었습니다.`
+ : '업데이트된 계약서가 없습니다.'
+
+ console.log(`[updateLegalReviewStatusFromSSLVW] 완료: ${message}`)
+
+ return {
+ success: updatedCount > 0,
+ message,
+ updatedCount,
+ errors
+ }
+
+ } catch (error) {
+ console.error('[updateLegalReviewStatusFromSSLVW] 오류:', error)
+ return {
+ success: false,
+ message: '법무검토 상태 업데이트 중 오류가 발생했습니다.',
+ updatedCount: 0,
+ errors: [error instanceof Error ? error.message : '알 수 없는 오류']
+ }
+ }
+}
+
export async function resendContractsAction(contractIds: number[]) {
try {
// 세션 확인
diff --git a/lib/basic-contract/sslvw-service.ts b/lib/basic-contract/sslvw-service.ts
index 9650d43a..5a35fb99 100644
--- a/lib/basic-contract/sslvw-service.ts
+++ b/lib/basic-contract/sslvw-service.ts
@@ -1,6 +1,11 @@
"use server"
import { oracleKnex } from '@/lib/oracle-db/db'
+import db from '@/db/db'
+import { basicContract, agreementComments } from '@/db/schema'
+import { eq, inArray, and } from 'drizzle-orm'
+import { revalidateTag } from 'next/cache'
+import { sendEmail } from '@/lib/mail/sendEmail'
// SSLVW_PUR_INQ_REQ 테이블 데이터 타입 (실제 테이블 구조에 맞게 조정 필요)
export interface SSLVWPurInqReq {
@@ -39,10 +44,28 @@ export async function getSSLVWPurInqReqData(): Promise<{
console.log('📋 [getSSLVWPurInqReqData] SSLVW_PUR_INQ_REQ 테이블 조회 시작...')
const result = await oracleKnex.raw(`
- SELECT *
+ SELECT
+ ID,
+ PRGS_STAT_DSC,
+ REQ_DT,
+ REQ_NO,
+ REQ_TIT,
+ REQ_CONT,
+ VEND_CD,
+ VEND_NM,
+ CNTR_CTGR_DSC,
+ CNTR_AMT,
+ CNTR_STRT_DT,
+ CNTR_END_DT,
+ RPLY_DT,
+ RPLY_CONT,
+ RPLY_USER_NM,
+ RPLY_USER_ID,
+ CREATED_AT,
+ UPDATED_AT
FROM SSLVW_PUR_INQ_REQ
WHERE ROWNUM < 100
- ORDER BY 1
+ ORDER BY REQ_DT DESC
`)
// Oracle raw query의 결과는 rows 배열에 들어있음
@@ -80,3 +103,167 @@ export async function getSSLVWPurInqReqData(): Promise<{
}
}
}
+
+/**
+ * 법무검토 요청
+ * @param contractIds 계약서 ID 배열
+ * @returns 성공 여부 및 메시지
+ */
+export async function requestLegalReview(contractIds: number[]): Promise<{
+ success: boolean
+ message: string
+ requested: number
+ skipped: number
+ errors: string[]
+}> {
+ console.log(`📋 [requestLegalReview] 법무검토 요청 시작: ${contractIds.length}건`)
+
+ if (!contractIds || contractIds.length === 0) {
+ return {
+ success: false,
+ message: '선택된 계약서가 없습니다.',
+ requested: 0,
+ skipped: 0,
+ errors: []
+ }
+ }
+
+ try {
+ // 계약서 정보 조회
+ const contracts = await db
+ .select()
+ .from(basicContract)
+ .where(inArray(basicContract.id, contractIds))
+
+ if (!contracts || contracts.length === 0) {
+ return {
+ success: false,
+ message: '계약서를 찾을 수 없습니다.',
+ requested: 0,
+ skipped: 0,
+ errors: []
+ }
+ }
+
+ let requestedCount = 0
+ let skippedCount = 0
+ const errors: string[] = []
+
+ for (const contract of contracts) {
+ try {
+ // 유효성 검사
+ if (contract.legalReviewRequestedAt) {
+ console.log(`⚠️ [requestLegalReview] 계약서 ${contract.id}: 이미 법무검토 요청됨`)
+ skippedCount++
+ errors.push(`${contract.id}: 이미 법무검토 요청됨`)
+ continue
+ }
+
+ // 협의 완료 여부 확인
+ // 1. 협의 완료됨 (negotiationCompletedAt 있음) → 가능
+ // 2. 협의 없음 (코멘트 없음) → 가능
+ // 3. 협의 중 (negotiationCompletedAt 없고 코멘트 있음) → 불가
+
+ if (!contract.negotiationCompletedAt) {
+ // 협의 완료되지 않은 경우, 코멘트 존재 여부 확인
+ // 삭제되지 않은 코멘트가 있으면 협의 중이므로 불가
+ const comments = await db
+ .select()
+ .from(agreementComments)
+ .where(
+ and(
+ eq(agreementComments.basicContractId, contract.id),
+ eq(agreementComments.isDeleted, false)
+ )
+ )
+ .limit(1);
+
+ // 삭제되지 않은 코멘트가 있으면 협의 중이므로 불가
+ if (comments.length > 0) {
+ console.log(`⚠️ [requestLegalReview] 계약서 ${contract.id}: 협의 진행 중`)
+ skippedCount++
+ errors.push(`${contract.id}: 협의가 진행 중입니다`)
+ continue
+ }
+
+ // 코멘트가 없으면 협의 없음으로 간주하고 가능
+ console.log(`ℹ️ [requestLegalReview] 계약서 ${contract.id}: 협의 없음, 법무검토 요청 가능`)
+ }
+
+ // 법무검토 요청 상태로 업데이트
+ await db
+ .update(basicContract)
+ .set({
+ legalReviewRequestedAt: new Date(),
+ updatedAt: new Date(),
+ } as any)
+ .where(eq(basicContract.id, contract.id))
+
+ requestedCount++
+ console.log(`✅ [requestLegalReview] 계약서 ${contract.id}: 법무검토 요청 완료`)
+
+ // 법무팀에 이메일 알림 발송 (선택사항)
+ try {
+ // TODO: 법무팀 이메일 주소를 설정에서 가져오기
+ const legalTeamEmail = process.env.LEGAL_TEAM_EMAIL || 'legal@example.com'
+
+ await sendEmail({
+ to: legalTeamEmail,
+ subject: `[eVCP] 기본계약서 법무검토 요청 - ${contract.id}`,
+ template: 'legal-review-request',
+ context: {
+ language: 'ko',
+ contractId: contract.id,
+ vendorName: contract.vendorId || '업체명 없음',
+ 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(`⚠️ [requestLegalReview] 이메일 발송 실패 (계약서 ${contract.id}):`, emailError)
+ // 이메일 실패는 법무검토 요청 성공에 영향을 주지 않음
+ }
+
+ } catch (error) {
+ console.error(`❌ [requestLegalReview] 계약서 ${contract.id} 처리 실패:`, error)
+ errors.push(`${contract.id}: 처리 중 오류 발생`)
+ skippedCount++
+ }
+ }
+
+ // 캐시 무효화
+ revalidateTag('basic-contracts')
+
+ const totalProcessed = requestedCount + skippedCount
+ let message = ''
+
+ if (requestedCount === contracts.length) {
+ message = `${requestedCount}건의 계약서에 대한 법무검토가 요청되었습니다.`
+ } else if (requestedCount > 0) {
+ message = `${requestedCount}건 요청 완료, ${skippedCount}건 건너뜀`
+ } else {
+ message = `모든 계약서를 건너뛰었습니다. (${skippedCount}건)`
+ }
+
+ console.log(`✅ [requestLegalReview] 법무검토 요청 완료: ${message}`)
+
+ return {
+ success: requestedCount > 0,
+ message,
+ requested: requestedCount,
+ skipped: skippedCount,
+ errors
+ }
+
+ } catch (error) {
+ console.error('❌ [requestLegalReview] 법무검토 요청 실패:', error)
+ return {
+ success: false,
+ message: '법무검토 요청 중 오류가 발생했습니다.',
+ requested: 0,
+ skipped: contractIds.length,
+ errors: [error instanceof Error ? error.message : '알 수 없는 오류']
+ }
+ }
+}
diff --git a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
index c71be9d1..3e965fac 100644
--- a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
+++ b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
@@ -2,7 +2,7 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
-import { Download, FileDown, Mail, CheckCircle, AlertTriangle, Send, Check, FileSignature } from "lucide-react"
+import { Download, FileDown, Mail, CheckCircle, AlertTriangle, Send, Check, FileSignature, FileText, ExternalLink, Globe } from "lucide-react"
import { exportTableToExcel } from "@/lib/export"
import { downloadFile } from "@/lib/file-download"
@@ -18,15 +18,16 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
-import { prepareFinalApprovalAction, quickFinalApprovalAction, resendContractsAction } from "../service"
+import { prepareFinalApprovalAction, quickFinalApprovalAction, resendContractsAction, updateLegalReviewStatusFromSSLVW } from "../service"
import { BasicContractSignDialog } from "../vendor-table/basic-contract-sign-dialog"
import { SSLVWPurInqReqDialog } from "@/components/common/legal/sslvw-pur-inq-req-dialog"
interface BasicContractDetailTableToolbarActionsProps {
table: Table<BasicContractView>
+ gtcData?: Record<number, { gtcDocumentId: number | null; hasComments: boolean }>
}
-export function BasicContractDetailTableToolbarActions({ table }: BasicContractDetailTableToolbarActionsProps) {
+export function BasicContractDetailTableToolbarActions({ table, gtcData = {} }: BasicContractDetailTableToolbarActionsProps) {
// 선택된 행들 가져오기
const selectedRows = table.getSelectedRowModel().rows
const hasSelectedRows = selectedRows.length > 0
@@ -34,6 +35,7 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD
// 다이얼로그 상태
const [resendDialog, setResendDialog] = React.useState(false)
const [finalApproveDialog, setFinalApproveDialog] = React.useState(false)
+ const [legalReviewDialog, setLegalReviewDialog] = React.useState(false)
const [loading, setLoading] = React.useState(false)
const [buyerSignDialog, setBuyerSignDialog] = React.useState(false)
const [contractsToSign, setContractsToSign] = React.useState<any[]>([])
@@ -56,6 +58,42 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD
return true;
});
+ // 법무검토 요청 가능 여부
+ // 1. 협의 완료됨 (negotiationCompletedAt 있음) OR
+ // 2. 협의 없음 (코멘트 없음, hasComments: false)
+ // 협의 중 (negotiationCompletedAt 없고 코멘트 있음)은 불가
+ const canRequestLegalReview = hasSelectedRows && selectedRows.some(row => {
+ const contract = row.original;
+ // 이미 법무검토 요청된 계약서는 제외
+ if (contract.legalReviewRequestedAt) {
+ return false;
+ }
+ // 이미 최종승인 완료된 계약서는 제외
+ if (contract.completedAt) {
+ return false;
+ }
+
+ // 협의 완료된 경우 → 가능
+ if (contract.negotiationCompletedAt) {
+ return true;
+ }
+
+ // 협의 완료되지 않은 경우
+ // GTC 템플릿인 경우 코멘트 존재 여부 확인
+ if (contract.templateName?.includes('GTC')) {
+ const contractGtcData = gtcData[contract.id];
+ // 코멘트가 없으면 가능 (협의 없음)
+ if (contractGtcData && !contractGtcData.hasComments) {
+ return true;
+ }
+ // 코멘트가 있으면 불가 (협의 중)
+ return false;
+ }
+
+ // GTC가 아닌 경우는 협의 완료 여부만 확인
+ return false;
+ });
+
// 필터링된 계약서들 계산
const resendContracts = selectedRows.map(row => row.original)
@@ -75,6 +113,40 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD
!contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt
);
+ // 법무검토 요청 가능한 계약서들
+ const legalReviewContracts = selectedRows
+ .map(row => row.original)
+ .filter(contract => {
+ // 이미 법무검토 요청됨
+ if (contract.legalReviewRequestedAt) {
+ return false;
+ }
+ // 이미 최종승인 완료됨
+ if (contract.completedAt) {
+ return false;
+ }
+
+ // 협의 완료된 경우
+ if (contract.negotiationCompletedAt) {
+ return true;
+ }
+
+ // 협의 완료되지 않은 경우
+ // GTC 템플릿인 경우 코멘트 없으면 가능
+ if (contract.templateName?.includes('GTC')) {
+ const contractGtcData = gtcData[contract.id];
+ // 코멘트가 없으면 가능 (협의 없음)
+ if (contractGtcData && !contractGtcData.hasComments) {
+ return true;
+ }
+ // 코멘트가 있으면 불가 (협의 중)
+ return false;
+ }
+
+ // GTC가 아닌 경우는 협의 완료 여부만 확인
+ return false;
+ });
+
// 대량 재발송
const handleBulkResend = async () => {
if (!hasSelectedRows) {
@@ -252,6 +324,42 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD
toast.success("모든 계약서의 최종승인이 완료되었습니다!")
}
+ // SSLVW 데이터 선택 확인 핸들러
+ const handleSSLVWConfirm = async (selectedSSLVWData: any[]) => {
+ if (!selectedSSLVWData || selectedSSLVWData.length === 0) {
+ toast.error("선택된 데이터가 없습니다.")
+ return
+ }
+
+ try {
+ setLoading(true)
+
+ // 선택된 계약서 ID들 추출
+ const selectedContractIds = selectedRows.map(row => row.original.id)
+
+ // 서버 액션 호출
+ const result = await updateLegalReviewStatusFromSSLVW(selectedSSLVWData, selectedContractIds)
+
+ if (result.success) {
+ toast.success(result.message)
+ // 테이블 데이터 갱신
+ table.toggleAllPageRowsSelected(false)
+ } else {
+ toast.error(result.message)
+ }
+
+ if (result.errors && result.errors.length > 0) {
+ toast.warning(`일부 처리 실패: ${result.errors.join(', ')}`)
+ }
+
+ } catch (error) {
+ console.error('SSLVW 확인 처리 실패:', error)
+ toast.error('법무검토 상태 업데이트 중 오류가 발생했습니다.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
// 빠른 승인 (서명 없이)
const confirmQuickApproval = async () => {
setLoading(true)
@@ -275,6 +383,45 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD
}
}
+ // 법무검토 요청 링크 목록
+ const legalReviewLinks = [
+ {
+ id: 'domestic-contract',
+ label: '국내계약',
+ url: 'http://60.101.208.95:8080/#/pjt/register-inquiry/domestic-contract',
+ description: '삼성중공업 법무관리시스템 - 국내계약'
+ },
+ {
+ id: 'domestic-advice',
+ label: '국내자문',
+ url: 'http://60.101.208.95:8080/#/pjt/register-inquiry/domestic-advice',
+ description: '삼성중공업 법무관리시스템 - 국내자문'
+ },
+ {
+ id: 'overseas-contract',
+ label: '해외계약',
+ url: 'http://60.101.208.95:8080/#/pjt/register-inquiry/overseas-contract',
+ description: '삼성중공업 법무관리시스템 - 해외계약'
+ },
+ {
+ id: 'overseas-advice',
+ label: '해외자문',
+ url: 'http://60.101.208.95:8080/#/pjt/register-inquiry/overseas-advice',
+ description: '삼성중공업 법무관리시스템 - 해외자문'
+ }
+ ]
+
+ // 법무검토 요청
+ const handleRequestLegalReview = () => {
+ setLegalReviewDialog(true)
+ }
+
+ // 법무검토 링크 클릭 핸들러
+ const handleLegalReviewLinkClick = (url: string) => {
+ window.open(url, '_blank', 'noopener,noreferrer')
+ setLegalReviewDialog(false)
+ }
+
return (
<>
<div className="flex items-center gap-2">
@@ -314,7 +461,21 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD
</Button>
{/* 법무검토 버튼 (SSLVW 데이터 조회) */}
- <SSLVWPurInqReqDialog />
+ <SSLVWPurInqReqDialog onConfirm={handleSSLVWConfirm} />
+
+ {/* 법무검토 요청 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRequestLegalReview}
+ className="gap-2"
+ title="법무검토 요청 링크 선택"
+ >
+ <FileText className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ 법무검토 요청
+ </span>
+ </Button>
{/* 최종승인 버튼 */}
<Button
@@ -413,6 +574,62 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD
</DialogContent>
</Dialog>
+ {/* 법무검토 요청 다이얼로그 */}
+ <Dialog open={legalReviewDialog} onOpenChange={setLegalReviewDialog}>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <FileText className="size-5" />
+ 법무검토 요청
+ </DialogTitle>
+ <DialogDescription>
+ 법무검토 요청 유형을 선택하세요. 선택한 링크가 새 창에서 열립니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div className="flex items-start gap-3 p-4 bg-blue-50 border border-blue-200 rounded-lg">
+ <Globe className="size-5 text-blue-600 flex-shrink-0 mt-0.5" />
+ <div>
+ <div className="font-medium text-blue-800">삼성중공업 법무관리시스템</div>
+ <div className="text-sm text-blue-700 mt-1">
+ 아래 링크 중 해당하는 유형을 선택하여 법무검토를 요청하세요.
+ </div>
+ </div>
+ </div>
+
+ <div className="space-y-2">
+ {legalReviewLinks.map((link) => (
+ <button
+ key={link.id}
+ onClick={() => handleLegalReviewLinkClick(link.url)}
+ className="w-full flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-blue-300 hover:bg-blue-50 transition-colors text-left group"
+ >
+ <div className="flex-1">
+ <div className="font-medium text-gray-900 group-hover:text-blue-700">
+ {link.label}
+ </div>
+ <div className="text-sm text-gray-500 mt-1">
+ {link.description}
+ </div>
+ </div>
+ <ExternalLink className="size-5 text-gray-400 group-hover:text-blue-600 flex-shrink-0 ml-4" />
+ </button>
+ ))}
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setLegalReviewDialog(false)}
+ >
+ 닫기
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
{/* 최종승인 다이얼로그 */}
<Dialog open={finalApproveDialog} onOpenChange={setFinalApproveDialog}>
<DialogContent className="max-w-2xl">
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 0dd33bcb..5a875541 100644
--- a/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx
+++ b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx
@@ -373,49 +373,57 @@ export function getDetailColumns({
minSize: 130,
},
- // 법무검토 요청일
+ // 법무검토 상태
{
- accessorKey: "legalReviewRequestedAt",
+ accessorKey: "legalReviewStatus",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="법무검토 요청" />
+ <DataTableColumnHeaderSimple column={column} title="법무검토 상태" />
),
cell: ({ row }) => {
- const date = row.getValue("legalReviewRequestedAt") as Date | null
- return date ? (
- <div className="text-sm text-purple-600">
- <div className="font-medium">요청됨</div>
- <div className="text-xs">{formatDateTime(date, "KR")}</div>
- </div>
- ) : (
- <div className="text-sm text-gray-400">-</div>
- )
- },
- minSize: 140,
- },
-
- // 법무검토 완료일
- {
- accessorKey: "legalReviewCompletedAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="법무검토 완료" />
- ),
- cell: ({ row }) => {
- const date = row.getValue("legalReviewCompletedAt") as Date | null
+ const status = row.getValue("legalReviewStatus") as string | null
const requestedDate = row.getValue("legalReviewRequestedAt") as Date | null
-
- return date ? (
- <div className="text-sm text-indigo-600">
- <div className="font-medium">완료</div>
- <div className="text-xs">{formatDateTime(date, "KR")}</div>
- </div>
- ) : requestedDate ? (
- <div className="text-sm text-orange-500">
- <div className="font-medium">진행중</div>
- <div className="text-xs">검토 대기</div>
- </div>
- ) : (
- <div className="text-sm text-gray-400">-</div>
- )
+ const completedDate = row.getValue("legalReviewCompletedAt") as Date | null
+
+ // 법무검토 상태 우선, 없으면 기존 로직으로 판단
+ if (status) {
+ const statusColors: Record<string, string> = {
+ '신규등록': 'text-blue-600',
+ '검토요청': 'text-purple-600',
+ '담당자배정': 'text-orange-600',
+ '검토중': 'text-yellow-600',
+ '답변완료': 'text-green-600',
+ '재검토요청': 'text-red-600',
+ '보류': 'text-gray-500',
+ '취소': 'text-red-700'
+ }
+
+ return (
+ <div className={`text-sm ${statusColors[status] || 'text-gray-600'}`}>
+ <div className="font-medium">{status}</div>
+ </div>
+ )
+ }
+
+ // legalWorks에 데이터가 없는 경우 기존 로직 사용
+ if (completedDate) {
+ return (
+ <div className="text-sm text-green-600">
+ <div className="font-medium">완료</div>
+ <div className="text-xs">{formatDateTime(completedDate, "KR")}</div>
+ </div>
+ )
+ } else if (requestedDate) {
+ return (
+ <div className="text-sm text-orange-600">
+ <div className="font-medium">진행중</div>
+ <div className="text-xs">검토 대기</div>
+ </div>
+ )
+ } else {
+ return (
+ <div className="text-sm text-gray-400">-</div>
+ )
+ }
},
minSize: 140,
},
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 407463e4..0df46066 100644
--- a/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx
+++ b/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx
@@ -151,7 +151,7 @@ export function BasicContractsDetailTable({ templateId, promises }: BasicContrac
table={table}
filterFields={advancedFilterFields}
>
- <BasicContractDetailTableToolbarActions table={table} />
+ <BasicContractDetailTableToolbarActions table={table} gtcData={gtcData} />
</DataTableAdvancedToolbar>
</DataTable>
)
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 3dc2c6fc..662d7ea9 100644
--- a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
+++ b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
@@ -914,6 +914,7 @@ const canCompleteCurrentContract = React.useMemo(() => {
}
mode={mode}
t={t}
+ negotiationCompletedAt={(selectedContract as any).negotiationCompletedAt || null}
/>
</div>
diff --git a/lib/basic-contract/viewer/SurveyComponent.tsx b/lib/basic-contract/viewer/SurveyComponent.tsx
index 8662155e..950519ad 100644
--- a/lib/basic-contract/viewer/SurveyComponent.tsx
+++ b/lib/basic-contract/viewer/SurveyComponent.tsx
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { useForm, useWatch, Controller } from "react-hook-form";
-import { Loader2, FileText, ClipboardList, AlertTriangle, CheckCircle2, Upload, ChevronDown, ChevronUp } from "lucide-react";
+import { Loader2, FileText, ClipboardList, AlertTriangle, CheckCircle2, Upload, ChevronDown, ChevronUp, Download } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -40,6 +40,8 @@ interface SurveyComponentProps {
onSurveyDataUpdate: (data: any) => void;
onLoadSurveyTemplate: () => void;
setActiveTab: (tab: string) => void;
+ contractFilePath?: string; // 계약서 파일 경로 추가
+ contractFileName?: string; // 계약서 파일 이름 추가
}
export const SurveyComponent: React.FC<SurveyComponentProps> = ({
@@ -51,7 +53,9 @@ export const SurveyComponent: React.FC<SurveyComponentProps> = ({
onSurveyComplete,
onSurveyDataUpdate,
onLoadSurveyTemplate,
- setActiveTab
+ setActiveTab,
+ contractFilePath,
+ contractFileName
}) => {
const [uploadedFiles, setUploadedFiles] = useState<Record<number, File[]>>({});
const [existingResponse, setExistingResponse] = useState<ExistingResponse | null>(null);
@@ -321,6 +325,40 @@ export const SurveyComponent: React.FC<SurveyComponentProps> = ({
);
}, [control, visibleQuestions]);
+ // 계약서 다운로드 핸들러
+ const handleDownloadContract = useCallback(async () => {
+ if (!contractFilePath) {
+ toast.error("다운로드할 파일이 없습니다.");
+ return;
+ }
+
+ try {
+ // 파일 경로를 API 경로로 변환
+ const normalizedPath = contractFilePath.startsWith('/')
+ ? contractFilePath.substring(1)
+ : contractFilePath;
+ const encodedPath = normalizedPath.split('/').map(part => encodeURIComponent(part)).join('/');
+ const apiFilePath = `/api/files/${encodedPath}`;
+
+ // 파일 경로에서 실제 파일명 추출
+ const actualFileName = contractFileName || contractFilePath.split('/').pop() || '계약서.pdf';
+
+ // 다운로드 링크 생성 및 클릭
+ const link = document.createElement('a');
+ link.href = apiFilePath;
+ link.download = actualFileName;
+ link.target = '_blank';
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ toast.success("파일 다운로드가 시작되었습니다.");
+ } catch (error) {
+ console.error("파일 다운로드 실패:", error);
+ toast.error("파일 다운로드에 실패했습니다.");
+ }
+ }, [contractFilePath, contractFileName]);
+
// 설문조사 완료 핸들러
const handleSurveyComplete = useCallback(async () => {
console.log('🎯 설문조사 완료 시도');
@@ -543,6 +581,20 @@ export const SurveyComponent: React.FC<SurveyComponentProps> = ({
</CardTitle>
<div className="flex items-center space-x-3">
+ {/* Word 파일 다운로드 버튼 */}
+ {contractFilePath && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleDownloadContract}
+ className="h-8 text-xs"
+ title="계약서 문서 다운로드"
+ >
+ <Download className="h-3 w-3 mr-1" />
+ 문서 다운로드
+ </Button>
+ )}
+
{/* 컴팩트 진행률 표시 */}
<div className="flex items-center space-x-2">
<div className="w-20 bg-gray-200 rounded-full h-1.5">
diff --git a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
index 75862506..7f5fa027 100644
--- a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
+++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
@@ -53,6 +53,7 @@ interface BasicContractSignViewerProps {
) => void;
mode?: 'vendor' | 'buyer'; // 추가된 mode prop
t?: (key: string) => string;
+ negotiationCompletedAt?: Date | null; // 협의 완료 시간 추가
}
// 자동 서명 필드 생성을 위한 타입 정의
@@ -692,6 +693,7 @@ export function BasicContractSignViewer({
onGtcCommentStatusChange,
mode = 'vendor', // 기본값 vendor
t = (key: string) => key,
+ negotiationCompletedAt,
}: BasicContractSignViewerProps) {
const { toast } = useToast();
@@ -1383,6 +1385,8 @@ export function BasicContractSignViewer({
onSurveyDataUpdate={setSurveyData}
onLoadSurveyTemplate={loadSurveyTemplate}
setActiveTab={setActiveTab}
+ contractFilePath={filePath}
+ contractFileName={filePath ? filePath.split('/').pop() : undefined}
/>
</div>
@@ -1396,10 +1400,15 @@ export function BasicContractSignViewer({
basicContractId={contractId}
currentUserType={mode === 'vendor' ? 'Vendor' : 'SHI'}
readOnly={false}
+ isNegotiationCompleted={!!negotiationCompletedAt}
+ onNegotiationComplete={() => {
+ // 협의 완료 후 상태 갱신이 필요한 경우 처리
+ console.log('협의 완료됨');
+ }}
onCommentCountChange={(count) => {
const hasComments = count > 0;
const reviewStatus = hasComments ? 'negotiating' : 'draft';
- const isComplete = false;
+ const isComplete = !!negotiationCompletedAt;
setGtcCommentStatus({ hasComments, commentCount: count, reviewStatus, isComplete });
onGtcCommentStatusChange?.(hasComments, count, reviewStatus, isComplete);
}}
@@ -1574,6 +1583,8 @@ export function BasicContractSignViewer({
onSurveyDataUpdate={setSurveyData}
onLoadSurveyTemplate={loadSurveyTemplate}
setActiveTab={setActiveTab}
+ contractFilePath={filePath}
+ contractFileName={filePath ? filePath.split('/').pop() : undefined}
/>
</div>
@@ -1585,12 +1596,17 @@ export function BasicContractSignViewer({
basicContractId={contractId}
currentUserType={mode === 'vendor' ? 'Vendor' : 'SHI'}
readOnly={false}
+ isNegotiationCompleted={!!negotiationCompletedAt}
+ onNegotiationComplete={() => {
+ // 협의 완료 후 상태 갱신이 필요한 경우 처리
+ console.log('협의 완료됨');
+ }}
onCommentCountChange={(count) => {
handleGtcCommentStatusChange?.(
count > 0,
count,
count > 0 ? 'negotiating' : 'draft',
- false
+ !!negotiationCompletedAt
);
}}
/>
diff --git a/lib/mail/templates/legal-review-request.hbs b/lib/mail/templates/legal-review-request.hbs
new file mode 100644
index 00000000..f49d05f7
--- /dev/null
+++ b/lib/mail/templates/legal-review-request.hbs
@@ -0,0 +1,81 @@
+<!DOCTYPE html>
+<html lang="ko">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>기본계약서 법무검토 요청</title>
+</head>
+<body style="margin: 0; padding: 0; font-family: 'Malgun Gothic', '맑은 고딕', Arial, sans-serif; background-color: #f4f4f4;">
+ <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;">
+ <tr>
+ <td style="padding: 20px 0;">
+ <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
+ <!-- Header -->
+ <tr>
+ <td style="background: linear-gradient(135deg, #2193b0 0%, #6dd5ed 100%); padding: 30px; text-align: center; border-radius: 8px 8px 0 0;">
+ <h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: bold;">
+ 📋 법무검토 요청
+ </h1>
+ </td>
+ </tr>
+
+ <!-- Content -->
+ <tr>
+ <td style="padding: 40px 30px;">
+ <p style="margin: 0 0 20px 0; font-size: 16px; line-height: 1.6; color: #333333;">
+ 법무팀 담당자님께,
+ </p>
+
+ <p style="margin: 0 0 20px 0; font-size: 15px; line-height: 1.6; color: #555555;">
+ <strong>{{vendorName}}</strong>의 기본계약서(ID: {{contractId}})에 대한 법무검토가 요청되었습니다.
+ </p>
+
+ <div style="background-color: #e7f3ff; border-left: 4px solid #2193b0; padding: 15px; margin: 20px 0; border-radius: 4px;">
+ <p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
+ <strong style="color: #2193b0;">계약서 정보</strong>
+ </p>
+ <p style="margin: 0; font-size: 13px; color: #555555; line-height: 1.8;">
+ • 계약서 ID: <strong>{{contractId}}</strong><br>
+ • 협력업체: <strong>{{vendorName}}</strong><br>
+ • 상태: <strong>협의 완료 / 법무검토 대기</strong>
+ </p>
+ </div>
+
+ <!-- CTA Button -->
+ <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 30px 0;">
+ <tr>
+ <td style="text-align: center;">
+ <a href="{{contractUrl}}" style="display: inline-block; padding: 14px 40px; background: linear-gradient(135deg, #2193b0 0%, #6dd5ed 100%); color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: bold; font-size: 16px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
+ 계약서 검토하기
+ </a>
+ </td>
+ </tr>
+ </table>
+
+ <p style="margin: 20px 0 0 0; font-size: 13px; line-height: 1.6; color: #777777;">
+ 빠른 시일 내에 검토 부탁드립니다.
+ </p>
+ </td>
+ </tr>
+
+ <!-- Footer -->
+ <tr>
+ <td style="background-color: #f8f9fa; padding: 20px 30px; border-radius: 0 0 8px 8px; border-top: 1px solid #e9ecef;">
+ <p style="margin: 0 0 10px 0; font-size: 12px; color: #999999; text-align: center;">
+ 본 메일은 발신 전용입니다. 회신하지 마세요.
+ </p>
+ <p style="margin: 0; font-size: 12px; color: #999999; text-align: center;">
+ © {{currentYear}} eVCP. All rights reserved.
+ </p>
+ <p style="margin: 10px 0 0 0; font-size: 12px; text-align: center;">
+ <a href="{{systemUrl}}" style="color: #2193b0; text-decoration: none;">eVCP 바로가기</a>
+ </p>
+ </td>
+ </tr>
+ </table>
+ </td>
+ </tr>
+ </table>
+</body>
+</html>
+
diff --git a/lib/mail/templates/negotiation-complete-notification.hbs b/lib/mail/templates/negotiation-complete-notification.hbs
new file mode 100644
index 00000000..d82d312f
--- /dev/null
+++ b/lib/mail/templates/negotiation-complete-notification.hbs
@@ -0,0 +1,77 @@
+<!DOCTYPE html>
+<html lang="ko">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>GTC 기본계약서 협의 완료</title>
+</head>
+<body style="margin: 0; padding: 0; font-family: 'Malgun Gothic', '맑은 고딕', Arial, sans-serif; background-color: #f4f4f4;">
+ <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;">
+ <tr>
+ <td style="padding: 20px 0;">
+ <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
+ <!-- Header -->
+ <tr>
+ <td style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; text-align: center; border-radius: 8px 8px 0 0;">
+ <h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: bold;">
+ ✅ 협의가 완료되었습니다
+ </h1>
+ </td>
+ </tr>
+
+ <!-- Content -->
+ <tr>
+ <td style="padding: 40px 30px;">
+ <p style="margin: 0 0 20px 0; font-size: 16px; line-height: 1.6; color: #333333;">
+ 안녕하세요, <strong>{{recipientName}}</strong>님
+ </p>
+
+ <p style="margin: 0 0 20px 0; font-size: 15px; line-height: 1.6; color: #555555;">
+ <strong>{{vendorName}}</strong>와(과)의 <strong>{{templateName}}</strong> 협의가 완료되었습니다.
+ </p>
+
+ <div style="background-color: #f8f9fa; border-left: 4px solid #28a745; padding: 15px; margin: 20px 0; border-radius: 4px;">
+ <p style="margin: 0; font-size: 14px; color: #666666;">
+ <strong style="color: #28a745;">✓ 협의 완료</strong><br>
+ 이제 법무검토 요청이 가능합니다.
+ </p>
+ </div>
+
+ <!-- CTA Button -->
+ <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 30px 0;">
+ <tr>
+ <td style="text-align: center;">
+ <a href="{{contractUrl}}" style="display: inline-block; padding: 14px 40px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: bold; font-size: 16px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
+ 계약서 확인하기
+ </a>
+ </td>
+ </tr>
+ </table>
+
+ <p style="margin: 20px 0 0 0; font-size: 13px; line-height: 1.6; color: #777777;">
+ 궁금하신 사항이 있으시면 언제든지 문의해 주세요.
+ </p>
+ </td>
+ </tr>
+
+ <!-- Footer -->
+ <tr>
+ <td style="background-color: #f8f9fa; padding: 20px 30px; border-radius: 0 0 8px 8px; border-top: 1px solid #e9ecef;">
+ <p style="margin: 0 0 10px 0; font-size: 12px; color: #999999; text-align: center;">
+ 본 메일은 발신 전용입니다. 회신하지 마세요.
+ </p>
+ <p style="margin: 0; font-size: 12px; color: #999999; text-align: center;">
+ © {{currentYear}} eVCP. All rights reserved.
+ </p>
+ <p style="margin: 10px 0 0 0; font-size: 12px; text-align: center;">
+ <a href="{{systemUrl}}" style="color: #667eea; text-decoration: none;">eVCP 바로가기</a>
+ </p>
+ </td>
+ </tr>
+ </table>
+ </td>
+ </tr>
+ </table>
+</body>
+</html>
+