diff options
Diffstat (limited to 'lib')
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> + |
