From 6c11fccc84f4c84fa72ee01f9caad9f76f35cea2 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Tue, 16 Sep 2025 09:20:58 +0000 Subject: (대표님, 최겸) 계약, 업로드 관련, 메뉴처리, 입찰, 프리쿼트, rfqLast관련, tbeLast관련 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/tbe-last/service.ts | 132 ++++++ lib/tbe-last/table/documents-sheet.tsx | 599 ++++++++++++++------------ lib/tbe-last/table/email-documents-dialog.tsx | 334 ++++++++++++++ 3 files changed, 801 insertions(+), 264 deletions(-) create mode 100644 lib/tbe-last/table/email-documents-dialog.tsx (limited to 'lib/tbe-last') diff --git a/lib/tbe-last/service.ts b/lib/tbe-last/service.ts index d9046524..32d5a5f5 100644 --- a/lib/tbe-last/service.ts +++ b/lib/tbe-last/service.ts @@ -11,6 +11,10 @@ import { filterColumns } from "@/lib/filter-columns"; import { GetTBELastSchema } from "./validations"; import { getServerSession } from "next-auth" import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import path from "path" +import fs from "fs/promises" +import { sendEmail } from "../mail/sendEmail"; + // ========================================== // 1. TBE 세션 목록 조회 // ========================================== @@ -415,4 +419,132 @@ function mapReviewStatus(status: string | null): string { } return status ? (statusMap[status] || status) : "미검토" +} + + +interface DocumentInfo { + documentId: number + documentReviewId: number + documentName: string + filePath: string + documentType: string + documentSource: string + reviewStatus?: string +} + +interface SessionInfo { + sessionId: number + sessionTitle: string + buyerName: string + vendorName: string +} + +interface SendDocumentsEmailParams { + to: string[] + cc?: string[] + documents: DocumentInfo[] + comments?: string + sessionInfo: SessionInfo +} + +export async function sendDocumentsEmail({ + to, + cc, + documents, + comments, + sessionInfo +}: SendDocumentsEmailParams) { + try { + // 사용자 인증 확인 + const session = await getServerSession(authOptions) + if (!session?.user) { + return { success: false, error: "인증이 필요합니다" } + } + + + // 첨부 파일 준비 + const attachments = await Promise.all( + documents.map(async (doc) => { + try { + // 실제 파일 경로 구성 (프로젝트 구조에 맞게 조정 필요) + + const isDev = process.env.NODE_ENV === 'development'; + + const filePath = isDev ? path.join(process.cwd(), 'public', doc.filePath) + :path.join(`${process.env.NAS_PATH}`, doc.filePath) + + // 파일 존재 확인 + await fs.access(filePath) + + // 파일 읽기 + const content = await fs.readFile(filePath) + + return { + filename: doc.documentName, + content: content, + encoding: 'base64' + } + } catch (error) { + console.error(`Failed to attach file: ${doc.documentName}`, error) + // 파일을 찾을 수 없는 경우 건너뛰기 + return null + } + }) + ) + + // null 값 필터링 + const validAttachments = attachments.filter(att => att !== null) + + // 이메일 전송 + const result = await sendEmail({ + to: to.join(", "), + cc: cc?.join(", "), + template: "document-share", // 템플릿 이름 + context: { + senderName: session.user.name || "TBE User", + senderEmail: session.user.email, + sessionTitle: sessionInfo.sessionTitle, + sessionId: sessionInfo.sessionId, + buyerName: sessionInfo.buyerName, + vendorName: sessionInfo.vendorName, + documentCount: documents.length, + documents: documents.map(doc => ({ + name: doc.documentName, + type: doc.documentType, + source: doc.documentSource, + reviewStatus: doc.reviewStatus || "미검토", + reviewStatusClass: getReviewStatusClass(doc.reviewStatus), + })), + comments: comments || "", + hasComments: !!comments, + language: "ko", // 한국어로 설정 + year: new Date().getFullYear(), + }, + attachments: validAttachments as any + }) + + return { success: true, data: result } + } catch (error) { + console.error("Failed to send documents email:", error) + return { + success: false, + error: error instanceof Error ? error.message : "Failed to send email" + } + } +} + +// 리뷰 상태에 따른 CSS 클래스 반환 +function getReviewStatusClass(status?: string): string { + switch (status) { + case "승인": + return "approved" + case "반려": + return "rejected" + case "보류": + return "pending" + case "검토중": + return "reviewing" + default: + return "unreviewed" + } } \ No newline at end of file diff --git a/lib/tbe-last/table/documents-sheet.tsx b/lib/tbe-last/table/documents-sheet.tsx index 96e6e178..ac0dc739 100644 --- a/lib/tbe-last/table/documents-sheet.tsx +++ b/lib/tbe-last/table/documents-sheet.tsx @@ -36,6 +36,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { ScrollArea } from "@/components/ui/scroll-area" +import { Checkbox } from "@/components/ui/checkbox" import { formatDate } from "@/lib/utils" import { downloadFile, getFileInfo } from "@/lib/file-download" import { @@ -50,9 +51,11 @@ import { Clock, AlertCircle, Save, + Mail, } from "lucide-react" import { toast } from "sonner" import { useRouter } from "next/navigation" +import { EmailDocumentsDialog } from "./email-documents-dialog" interface DocumentsSheetProps { open: boolean @@ -81,10 +84,17 @@ export function DocumentsSheet({ reviewComments: string }>>({}) const [isSaving, setIsSaving] = React.useState>({}) - const [commentCounts, setCommentCounts] = React.useState({}) // <-- 추가 + const [commentCounts, setCommentCounts] = React.useState({}) const [countLoading, setCountLoading] = React.useState(false) + + // 새로 추가된 state들 + const [selectedDocuments, setSelectedDocuments] = React.useState>(new Set()) + const [emailDialogOpen, setEmailDialogOpen] = React.useState(false) + const router = useRouter() + // ... (기존 useEffect와 함수들은 그대로 유지) + const allReviewIds = React.useMemo(() => { const docs = sessionDetail?.documents ?? [] const ids = new Set() @@ -104,7 +114,6 @@ export function DocumentsSheet({ } setCountLoading(true) try { - // 너무 길어질 수 있으니 적당히 나눠서 호출(옵션) const chunkSize = 100 const chunks: number[][] = [] for (let i = 0; i < allReviewIds.length; i += chunkSize) { @@ -139,9 +148,8 @@ export function DocumentsSheet({ return () => { aborted = true } - }, [allReviewIds.join(",")]) // 의존성: id 목록이 바뀔 때만 + }, [allReviewIds.join(",")]) - // 문서 초기 데이터 설정 React.useEffect(() => { if (sessionDetail?.documents) { const initialData: Record = {} @@ -155,7 +163,6 @@ export function DocumentsSheet({ } }, [sessionDetail]) - // PDFtron 뷰어 열기 const handleOpenPDFTron = (doc: any) => { if (!doc.filePath) { toast.error("파일 경로를 찾을 수 없습니다") @@ -174,7 +181,6 @@ export function DocumentsSheet({ window.open(`/pdftron-viewer?${params.toString()}`, '_blank') } - // 파일이 PDFtron에서 열 수 있는지 확인 const canOpenInPDFTron = (filePath: string) => { if (!filePath) return false const ext = filePath.split('.').pop()?.toLowerCase() @@ -182,7 +188,6 @@ export function DocumentsSheet({ return supportedFormats.includes(ext || '') } - // 파일 다운로드 const handleDownload = async (doc: any) => { if (!doc.filePath) { toast.error("파일 경로를 찾을 수 없습니다") @@ -198,13 +203,11 @@ export function DocumentsSheet({ }) } - // 리뷰 상태 저장 const handleSaveReview = async (doc: any) => { const reviewId = doc.documentReviewId setIsSaving({ ...isSaving, [reviewId]: true }) try { - // API 호출하여 리뷰 상태 저장 const response = await fetch(`/api/document-reviews/${reviewId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -227,7 +230,6 @@ export function DocumentsSheet({ } } - // 리뷰 상태 아이콘 const getReviewStatusIcon = (status: string) => { switch (status) { case "승인": @@ -241,17 +243,40 @@ export function DocumentsSheet({ } } - // 필터링된 문서 목록 + // 문서 선택 관련 함수들 + const handleSelectDocument = (documentId: number, checked: boolean) => { + const newSelected = new Set(selectedDocuments) + if (checked) { + newSelected.add(documentId) + } else { + newSelected.delete(documentId) + } + setSelectedDocuments(newSelected) + } + + const handleSelectAll = (checked: boolean) => { + if (checked) { + const allIds = new Set(filteredDocuments.map((doc: any) => doc.documentReviewId)) + setSelectedDocuments(allIds) + } else { + setSelectedDocuments(new Set()) + } + } + + const getSelectedDocumentDetails = () => { + return filteredDocuments.filter((doc: any) => + selectedDocuments.has(doc.documentReviewId) + ) + } + const filteredDocuments = React.useMemo(() => { if (!sessionDetail?.documents) return [] return sessionDetail.documents.filter((doc: any) => { - // Source 필터 if (sourceFilter !== "all" && doc.documentSource !== sourceFilter) { return false } - // 검색어 필터 if (searchTerm) { const searchLower = searchTerm.toLowerCase() return ( @@ -265,279 +290,325 @@ export function DocumentsSheet({ }) }, [sessionDetail?.documents, sourceFilter, searchTerm]) - return ( - - - - Documents & Review Management - - 문서 검토 및 코멘트 관리 - - - - {/* 필터 및 검색 */} -
-
- - -
+ const allSelected = filteredDocuments.length > 0 && + filteredDocuments.every((doc: any) => selectedDocuments.has(doc.documentReviewId)) + const someSelected = filteredDocuments.some((doc: any) => + selectedDocuments.has(doc.documentReviewId)) && !allSelected - setSearchTerm(e.target.value)} - className="max-w-sm" - /> - -
- - Total: {filteredDocuments.length} - - {sessionDetail?.documents && ( - <> - - Buyer: {sessionDetail.documents.filter((d: any) => d.documentSource === "buyer").length} - - - Vendor: {sessionDetail.documents.filter((d: any) => d.documentSource === "vendor").length} - - + return ( + <> + + + + Documents & Review Management + + 문서 검토 및 코멘트 관리 + + + + {/* 필터 및 검색 */} +
+
+ + +
+ + setSearchTerm(e.target.value)} + className="max-w-sm" + /> + + {/* 이메일 보내기 버튼 추가 */} + {selectedDocuments.size > 0 && ( + )} + +
+ + Total: {filteredDocuments.length} + + {sessionDetail?.documents && ( + <> + + Buyer: {sessionDetail.documents.filter((d: any) => d.documentSource === "buyer").length} + + + Vendor: {sessionDetail.documents.filter((d: any) => d.documentSource === "vendor").length} + + + )} +
-
- - {/* 문서 테이블 */} - {isLoading ? ( -
Loading...
- ) : ( - - - - - Source - Document Name - Type - Review Status - Comments - Review Notes - Uploaded - Actions - - - - {filteredDocuments.length === 0 ? ( + + {/* 문서 테이블 */} + {isLoading ? ( +
Loading...
+ ) : ( + +
+ - - No documents found - + + + + Source + Document Name + Type + Review Status + Comments + Review Notes + Uploaded + Actions - ) : ( - filteredDocuments.map((doc: any) => ( - - - - {doc.documentSource} - - - - -
- - {doc.documentName} -
+
+ + {filteredDocuments.length === 0 ? ( + + + No documents found - - {doc.documentType} - - - {editingReviewId === doc.documentReviewId ? ( - - ) : ( -
- {getReviewStatusIcon(reviewData[doc.documentReviewId]?.reviewStatus || doc.reviewStatus)} - - {reviewData[doc.documentReviewId]?.reviewStatus || doc.reviewStatus || "미검토"} - + + ) : ( + filteredDocuments.map((doc: any) => ( + + + + handleSelectDocument(doc.documentReviewId, checked as boolean) + } + /> + + + + + {doc.documentSource} + + + + +
+ + {doc.documentName}
- )} -
- - - - {(() => { - const id = Number(doc.documentReviewId) - const counts = Number.isFinite(id) ? commentCounts[id] : undefined - if (countLoading && !counts) { - return Loading… - } - if (!counts || counts.totalCount === 0) { - return - - } - return ( + + + {doc.documentType} + + + {editingReviewId === doc.documentReviewId ? ( + + ) : (
- - - {counts.totalCount} - {counts.openCount > 0 && ( - - ({counts.openCount} open) - - )} + {getReviewStatusIcon(reviewData[doc.documentReviewId]?.reviewStatus || doc.reviewStatus)} + + {reviewData[doc.documentReviewId]?.reviewStatus || doc.reviewStatus || "미검토"}
- ) - })()} -
- - - {editingReviewId === doc.documentReviewId ? ( -