diff options
Diffstat (limited to 'lib/tbe-last')
| -rw-r--r-- | lib/tbe-last/service.ts | 132 | ||||
| -rw-r--r-- | lib/tbe-last/table/documents-sheet.tsx | 599 | ||||
| -rw-r--r-- | lib/tbe-last/table/email-documents-dialog.tsx | 334 |
3 files changed, 801 insertions, 264 deletions
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<Record<number, boolean>>({}) - const [commentCounts, setCommentCounts] = React.useState<CountMap>({}) // <-- 추가 + const [commentCounts, setCommentCounts] = React.useState<CountMap>({}) const [countLoading, setCountLoading] = React.useState(false) + + // 새로 추가된 state들 + const [selectedDocuments, setSelectedDocuments] = React.useState<Set<number>>(new Set()) + const [emailDialogOpen, setEmailDialogOpen] = React.useState(false) + const router = useRouter() + // ... (기존 useEffect와 함수들은 그대로 유지) + const allReviewIds = React.useMemo(() => { const docs = sessionDetail?.documents ?? [] const ids = new Set<number>() @@ -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<number, any> = {} @@ -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 ( - <Sheet open={open} onOpenChange={onOpenChange}> - <SheetContent className="w-[1200px] sm:w-[1200px] max-w-[90vw]" style={{ width: 1200, maxWidth: "90vw" }}> - <SheetHeader> - <SheetTitle>Documents & Review Management</SheetTitle> - <SheetDescription> - 문서 검토 및 코멘트 관리 - </SheetDescription> - </SheetHeader> - - {/* 필터 및 검색 */} - <div className="flex items-center gap-4 mt-4 mb-4"> - <div className="flex items-center gap-2"> - <Filter className="h-4 w-4 text-muted-foreground" /> - <Select value={sourceFilter} onValueChange={(value: any) => setSourceFilter(value)}> - <SelectTrigger className="w-[150px]"> - <SelectValue placeholder="Filter by source" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="all">All Documents</SelectItem> - <SelectItem value="buyer">Buyer Documents</SelectItem> - <SelectItem value="vendor">Vendor Documents</SelectItem> - </SelectContent> - </Select> - </div> + const allSelected = filteredDocuments.length > 0 && + filteredDocuments.every((doc: any) => selectedDocuments.has(doc.documentReviewId)) + const someSelected = filteredDocuments.some((doc: any) => + selectedDocuments.has(doc.documentReviewId)) && !allSelected - <Input - placeholder="Search documents..." - value={searchTerm} - onChange={(e) => setSearchTerm(e.target.value)} - className="max-w-sm" - /> - - <div className="ml-auto flex items-center gap-2 text-sm text-muted-foreground"> - <Badge variant="outline"> - Total: {filteredDocuments.length} - </Badge> - {sessionDetail?.documents && ( - <> - <Badge variant="secondary"> - Buyer: {sessionDetail.documents.filter((d: any) => d.documentSource === "buyer").length} - </Badge> - <Badge variant="secondary"> - Vendor: {sessionDetail.documents.filter((d: any) => d.documentSource === "vendor").length} - </Badge> - </> + return ( + <> + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="w-[1200px] sm:w-[1200px] max-w-[90vw]" style={{ width: 1200, maxWidth: "90vw" }}> + <SheetHeader> + <SheetTitle>Documents & Review Management</SheetTitle> + <SheetDescription> + 문서 검토 및 코멘트 관리 + </SheetDescription> + </SheetHeader> + + {/* 필터 및 검색 */} + <div className="flex items-center gap-4 mt-4 mb-4"> + <div className="flex items-center gap-2"> + <Filter className="h-4 w-4 text-muted-foreground" /> + <Select value={sourceFilter} onValueChange={(value: any) => setSourceFilter(value)}> + <SelectTrigger className="w-[150px]"> + <SelectValue placeholder="Filter by source" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">All Documents</SelectItem> + <SelectItem value="buyer">Buyer Documents</SelectItem> + <SelectItem value="vendor">Vendor Documents</SelectItem> + </SelectContent> + </Select> + </div> + + <Input + placeholder="Search documents..." + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + className="max-w-sm" + /> + + {/* 이메일 보내기 버튼 추가 */} + {selectedDocuments.size > 0 && ( + <Button + onClick={() => setEmailDialogOpen(true)} + variant="default" + size="sm" + className="ml-2" + > + <Mail className="h-4 w-4 mr-2" /> + Send Email ({selectedDocuments.size}) + </Button> )} + + <div className="ml-auto flex items-center gap-2 text-sm text-muted-foreground"> + <Badge variant="outline"> + Total: {filteredDocuments.length} + </Badge> + {sessionDetail?.documents && ( + <> + <Badge variant="secondary"> + Buyer: {sessionDetail.documents.filter((d: any) => d.documentSource === "buyer").length} + </Badge> + <Badge variant="secondary"> + Vendor: {sessionDetail.documents.filter((d: any) => d.documentSource === "vendor").length} + </Badge> + </> + )} + </div> </div> - </div> - - {/* 문서 테이블 */} - {isLoading ? ( - <div className="p-8 text-center">Loading...</div> - ) : ( - <ScrollArea className="h-[calc(100vh-250px)]"> - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-[100px]">Source</TableHead> - <TableHead>Document Name</TableHead> - <TableHead className="w-[100px]">Type</TableHead> - <TableHead className="w-[120px]">Review Status</TableHead> - <TableHead className="w-[120px]">Comments</TableHead> - <TableHead className="w-[200px]">Review Notes</TableHead> - <TableHead className="w-[120px]">Uploaded</TableHead> - <TableHead className="w-[100px] text-right">Actions</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {filteredDocuments.length === 0 ? ( + + {/* 문서 테이블 */} + {isLoading ? ( + <div className="p-8 text-center">Loading...</div> + ) : ( + <ScrollArea className="h-[calc(100vh-250px)]"> + <Table> + <TableHeader> <TableRow> - <TableCell colSpan={8} className="text-center text-muted-foreground"> - No documents found - </TableCell> + <TableHead className="w-[50px]"> + <Checkbox + checked={allSelected} + indeterminate={someSelected} + onCheckedChange={handleSelectAll} + /> + </TableHead> + <TableHead className="w-[100px]">Source</TableHead> + <TableHead>Document Name</TableHead> + <TableHead className="w-[100px]">Type</TableHead> + <TableHead className="w-[120px]">Review Status</TableHead> + <TableHead className="w-[120px]">Comments</TableHead> + <TableHead className="w-[200px]">Review Notes</TableHead> + <TableHead className="w-[120px]">Uploaded</TableHead> + <TableHead className="w-[100px] text-right">Actions</TableHead> </TableRow> - ) : ( - filteredDocuments.map((doc: any) => ( - <TableRow key={doc.documentReviewId}> - <TableCell> - <Badge variant={doc.documentSource === "buyer" ? "default" : "secondary"}> - {doc.documentSource} - </Badge> - </TableCell> - - <TableCell> - <div className="flex items-center gap-2"> - <FileText className="h-4 w-4 text-muted-foreground" /> - <span className="font-medium">{doc.documentName}</span> - </div> + </TableHeader> + <TableBody> + {filteredDocuments.length === 0 ? ( + <TableRow> + <TableCell colSpan={9} className="text-center text-muted-foreground"> + No documents found </TableCell> - - <TableCell>{doc.documentType}</TableCell> - - <TableCell> - {editingReviewId === doc.documentReviewId ? ( - <Select - value={reviewData[doc.documentReviewId]?.reviewStatus || "미검토"} - onValueChange={(value) => { - setReviewData({ - ...reviewData, - [doc.documentReviewId]: { - ...reviewData[doc.documentReviewId], - reviewStatus: value - } - }) - }} - > - <SelectTrigger className="w-[110px] h-8"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="미검토">미검토</SelectItem> - <SelectItem value="검토중">검토중</SelectItem> - <SelectItem value="승인">승인</SelectItem> - <SelectItem value="반려">반려</SelectItem> - <SelectItem value="보류">보류</SelectItem> - </SelectContent> - </Select> - ) : ( - <div className="flex items-center gap-1"> - {getReviewStatusIcon(reviewData[doc.documentReviewId]?.reviewStatus || doc.reviewStatus)} - <span className="text-sm"> - {reviewData[doc.documentReviewId]?.reviewStatus || doc.reviewStatus || "미검토"} - </span> + </TableRow> + ) : ( + filteredDocuments.map((doc: any) => ( + <TableRow key={doc.documentReviewId}> + <TableCell> + <Checkbox + checked={selectedDocuments.has(doc.documentReviewId)} + onCheckedChange={(checked) => + handleSelectDocument(doc.documentReviewId, checked as boolean) + } + /> + </TableCell> + + <TableCell> + <Badge variant={doc.documentSource === "buyer" ? "default" : "secondary"}> + {doc.documentSource} + </Badge> + </TableCell> + + <TableCell> + <div className="flex items-center gap-2"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <span className="font-medium">{doc.documentName}</span> </div> - )} - </TableCell> - - - <TableCell> - {(() => { - const id = Number(doc.documentReviewId) - const counts = Number.isFinite(id) ? commentCounts[id] : undefined - if (countLoading && !counts) { - return <span className="text-xs text-muted-foreground">Loading…</span> - } - if (!counts || counts.totalCount === 0) { - return <span className="text-muted-foreground text-xs">-</span> - } - return ( + </TableCell> + + <TableCell>{doc.documentType}</TableCell> + + <TableCell> + {editingReviewId === doc.documentReviewId ? ( + <Select + value={reviewData[doc.documentReviewId]?.reviewStatus || "미검토"} + onValueChange={(value) => { + setReviewData({ + ...reviewData, + [doc.documentReviewId]: { + ...reviewData[doc.documentReviewId], + reviewStatus: value + } + }) + }} + > + <SelectTrigger className="w-[110px] h-8"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="미검토">미검토</SelectItem> + <SelectItem value="검토중">검토중</SelectItem> + <SelectItem value="승인">승인</SelectItem> + <SelectItem value="반려">반려</SelectItem> + <SelectItem value="보류">보류</SelectItem> + </SelectContent> + </Select> + ) : ( <div className="flex items-center gap-1"> - <MessageSquare className="h-3 w-3" /> - <span className="text-xs"> - {counts.totalCount} - {counts.openCount > 0 && ( - <span className="text-orange-600 ml-1"> - ({counts.openCount} open) - </span> - )} + {getReviewStatusIcon(reviewData[doc.documentReviewId]?.reviewStatus || doc.reviewStatus)} + <span className="text-sm"> + {reviewData[doc.documentReviewId]?.reviewStatus || doc.reviewStatus || "미검토"} </span> </div> - ) - })()} - </TableCell> - - <TableCell> - {editingReviewId === doc.documentReviewId ? ( - <Textarea - value={reviewData[doc.documentReviewId]?.reviewComments || ""} - onChange={(e) => { - setReviewData({ - ...reviewData, - [doc.documentReviewId]: { - ...reviewData[doc.documentReviewId], - reviewComments: e.target.value - } - }) - }} - placeholder="리뷰 코멘트 입력..." - className="min-h-[60px] text-xs" - /> - ) : ( - <p className="text-xs text-muted-foreground truncate max-w-[200px]" - title={reviewData[doc.documentReviewId]?.reviewComments || doc.reviewComments}> - {reviewData[doc.documentReviewId]?.reviewComments || doc.reviewComments || "-"} - </p> - )} - </TableCell> - - <TableCell> - <span className="text-xs text-muted-foreground"> - {doc.uploadedAt ? formatDate(doc.uploadedAt, "KR") : - doc.submittedAt ? formatDate(doc.submittedAt, "KR") : "-"} - </span> - </TableCell> + )} + </TableCell> + + <TableCell> + {(() => { + const id = Number(doc.documentReviewId) + const counts = Number.isFinite(id) ? commentCounts[id] : undefined + if (countLoading && !counts) { + return <span className="text-xs text-muted-foreground">Loading…</span> + } + if (!counts || counts.totalCount === 0) { + return <span className="text-muted-foreground text-xs">-</span> + } + return ( + <div className="flex items-center gap-1"> + <MessageSquare className="h-3 w-3" /> + <span className="text-xs"> + {counts.totalCount} + {counts.openCount > 0 && ( + <span className="text-orange-600 ml-1"> + ({counts.openCount} open) + </span> + )} + </span> + </div> + ) + })()} + </TableCell> + + <TableCell> + {editingReviewId === doc.documentReviewId ? ( + <Textarea + value={reviewData[doc.documentReviewId]?.reviewComments || ""} + onChange={(e) => { + setReviewData({ + ...reviewData, + [doc.documentReviewId]: { + ...reviewData[doc.documentReviewId], + reviewComments: e.target.value + } + }) + }} + placeholder="리뷰 코멘트 입력..." + className="min-h-[60px] text-xs" + /> + ) : ( + <p className="text-xs text-muted-foreground truncate max-w-[200px]" + title={reviewData[doc.documentReviewId]?.reviewComments || doc.reviewComments}> + {reviewData[doc.documentReviewId]?.reviewComments || doc.reviewComments || "-"} + </p> + )} + </TableCell> + + <TableCell> + <span className="text-xs text-muted-foreground"> + {doc.uploadedAt ? formatDate(doc.uploadedAt, "KR") : + doc.submittedAt ? formatDate(doc.submittedAt, "KR") : "-"} + </span> + </TableCell> + + <TableCell className="text-right"> + <div className="flex items-center justify-end gap-1"> + {canOpenInPDFTron(doc.filePath) ? ( + <Button + size="sm" + variant="ghost" + onClick={() => handleOpenPDFTron(doc)} + className="h-8 px-2" + > + <Eye className="h-4 w-4" /> + </Button> + ) : null} - <TableCell className="text-right"> - <div className="flex items-center justify-end gap-1"> - {canOpenInPDFTron(doc.filePath) ? ( <Button size="sm" variant="ghost" - onClick={() => handleOpenPDFTron(doc)} + onClick={() => handleDownload(doc)} className="h-8 px-2" > - <Eye className="h-4 w-4" /> + <Download className="h-4 w-4" /> </Button> - ) : null} - - <Button - size="sm" - variant="ghost" - onClick={() => handleDownload(doc)} - className="h-8 px-2" - > - <Download className="h-4 w-4" /> - </Button> - - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" size="sm" className="h-8 px-2"> - <MoreHorizontal className="h-4 w-4" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - {editingReviewId === doc.documentReviewId ? ( - <> + + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="sm" className="h-8 px-2"> + <MoreHorizontal className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + {editingReviewId === doc.documentReviewId ? ( + <> + <DropdownMenuItem + onClick={() => handleSaveReview(doc)} + disabled={isSaving[doc.documentReviewId]} + > + <Save className="h-4 w-4 mr-2" /> + {isSaving[doc.documentReviewId] ? "저장 중..." : "저장"} + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => { + setEditingReviewId(null) + setReviewData({ + ...reviewData, + [doc.documentReviewId]: { + reviewStatus: doc.reviewStatus || "미검토", + reviewComments: doc.reviewComments || "" + } + }) + }} + > + <XCircle className="h-4 w-4 mr-2" /> + 취소 + </DropdownMenuItem> + </> + ) : ( <DropdownMenuItem - onClick={() => handleSaveReview(doc)} - disabled={isSaving[doc.documentReviewId]} + onClick={() => setEditingReviewId(doc.documentReviewId)} > - <Save className="h-4 w-4 mr-2" /> - {isSaving[doc.documentReviewId] ? "저장 중..." : "저장"} + <MessageSquare className="h-4 w-4 mr-2" /> + 리뷰 편집 </DropdownMenuItem> - <DropdownMenuItem - onClick={() => { - setEditingReviewId(null) - // 원래 값으로 복원 - setReviewData({ - ...reviewData, - [doc.documentReviewId]: { - reviewStatus: doc.reviewStatus || "미검토", - reviewComments: doc.reviewComments || "" - } - }) - }} - > - <XCircle className="h-4 w-4 mr-2" /> - 취소 + )} + + {canOpenInPDFTron(doc.filePath) && ( + <DropdownMenuItem onClick={() => handleOpenPDFTron(doc)}> + <Eye className="h-4 w-4 mr-2" /> + PDFTron에서 보기 </DropdownMenuItem> - </> - ) : ( - <DropdownMenuItem - onClick={() => setEditingReviewId(doc.documentReviewId)} - > - <MessageSquare className="h-4 w-4 mr-2" /> - 리뷰 편집 - </DropdownMenuItem> - )} + )} - {canOpenInPDFTron(doc.filePath) && ( - <DropdownMenuItem onClick={() => handleOpenPDFTron(doc)}> - <Eye className="h-4 w-4 mr-2" /> - PDFTron에서 보기 + <DropdownMenuItem onClick={() => handleDownload(doc)}> + <Download className="h-4 w-4 mr-2" /> + 다운로드 </DropdownMenuItem> - )} - - <DropdownMenuItem onClick={() => handleDownload(doc)}> - <Download className="h-4 w-4 mr-2" /> - 다운로드 - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - </div> - </TableCell> - </TableRow> - )) - )} - </TableBody> - </Table> - </ScrollArea> - )} - </SheetContent> - </Sheet> + </DropdownMenuContent> + </DropdownMenu> + </div> + </TableCell> + </TableRow> + )) + )} + </TableBody> + </Table> + </ScrollArea> + )} + </SheetContent> + </Sheet> + + {/* 이메일 전송 다이얼로그 */} + <EmailDocumentsDialog + open={emailDialogOpen} + onOpenChange={setEmailDialogOpen} + selectedDocuments={getSelectedDocumentDetails()} + sessionDetail={sessionDetail} + onSuccess={() => { + setSelectedDocuments(new Set()) + setEmailDialogOpen(false) + }} + /> + </> ) }
\ No newline at end of file diff --git a/lib/tbe-last/table/email-documents-dialog.tsx b/lib/tbe-last/table/email-documents-dialog.tsx new file mode 100644 index 00000000..415cd428 --- /dev/null +++ b/lib/tbe-last/table/email-documents-dialog.tsx @@ -0,0 +1,334 @@ +// lib/tbe-last/table/dialogs/email-documents-dialog.tsx + +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + FileText, + X, + Plus, + Mail, + Loader2, + AlertCircle, +} from "lucide-react" +import { toast } from "sonner" +import { sendDocumentsEmail } from "../service" + +interface EmailDocumentsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + selectedDocuments: any[] + sessionDetail: any + onSuccess?: () => void +} + +export function EmailDocumentsDialog({ + open, + onOpenChange, + selectedDocuments, + sessionDetail, + onSuccess +}: EmailDocumentsDialogProps) { + const [recipients, setRecipients] = React.useState<string[]>([]) + const [currentEmail, setCurrentEmail] = React.useState("") + const [ccRecipients, setCcRecipients] = React.useState<string[]>([]) + const [currentCc, setCurrentCc] = React.useState("") + const [comments, setComments] = React.useState("") + const [isLoading, setIsLoading] = React.useState(false) + + // 이메일 유효성 검사 + const validateEmail = (email: string) => { + const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return re.test(email) + } + + // 수신자 추가 + const handleAddRecipient = () => { + if (currentEmail && validateEmail(currentEmail)) { + if (!recipients.includes(currentEmail)) { + setRecipients([...recipients, currentEmail]) + setCurrentEmail("") + } else { + toast.error("이미 추가된 이메일입니다") + } + } else { + toast.error("올바른 이메일 주소를 입력하세요") + } + } + + // CC 수신자 추가 + const handleAddCc = () => { + if (currentCc && validateEmail(currentCc)) { + if (!ccRecipients.includes(currentCc)) { + setCcRecipients([...ccRecipients, currentCc]) + setCurrentCc("") + } else { + toast.error("이미 추가된 이메일입니다") + } + } else { + toast.error("올바른 이메일 주소를 입력하세요") + } + } + + // 수신자 제거 + const removeRecipient = (email: string) => { + setRecipients(recipients.filter(r => r !== email)) + } + + // CC 수신자 제거 + const removeCc = (email: string) => { + setCcRecipients(ccRecipients.filter(r => r !== email)) + } + + // 이메일 전송 + const handleSendEmail = async () => { + if (recipients.length === 0) { + toast.error("최소 한 명의 수신자를 추가하세요") + return + } + + if (selectedDocuments.length === 0) { + toast.error("선택된 문서가 없습니다") + return + } + + setIsLoading(true) + + try { + const result = await sendDocumentsEmail({ + to: recipients, + cc: ccRecipients.length > 0 ? ccRecipients : undefined, + documents: selectedDocuments.map(doc => ({ + documentId: doc.documentId, + documentReviewId: doc.documentReviewId, + documentName: doc.documentName, + filePath: doc.filePath, + documentType: doc.documentType, + documentSource: doc.documentSource, + reviewStatus: doc.reviewStatus, + })), + comments, + sessionInfo: { + sessionId: sessionDetail?.session?.tbeSessionId, + sessionTitle: sessionDetail?.session?.title, + buyerName: sessionDetail?.session?.buyerName, + vendorName: sessionDetail?.session?.vendorName, + } + }) + + if (result.success) { + toast.success("이메일이 성공적으로 전송되었습니다") + onSuccess?.() + onOpenChange(false) + + // 초기화 + setRecipients([]) + setCcRecipients([]) + setComments("") + setCurrentEmail("") + setCurrentCc("") + } else { + throw new Error(result.error || "이메일 전송 실패") + } + } catch (error) { + console.error("Email send error:", error) + toast.error(error instanceof Error ? error.message : "이메일 전송 중 오류가 발생했습니다") + } finally { + setIsLoading(false) + } + } + + // 파일 크기 포맷 + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i] + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>Send Documents via Email</DialogTitle> + <DialogDescription> + 선택한 {selectedDocuments.length}개의 문서를 이메일로 전송합니다 + </DialogDescription> + </DialogHeader> + + <div className="grid gap-4 py-4"> + {/* 수신자 입력 */} + <div className="grid gap-2"> + <Label htmlFor="recipients">수신자 (To) *</Label> + <div className="flex gap-2"> + <Input + id="recipients" + type="email" + placeholder="이메일 주소 입력" + value={currentEmail} + onChange={(e) => setCurrentEmail(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleAddRecipient() + } + }} + /> + <Button + type="button" + size="sm" + onClick={handleAddRecipient} + variant="outline" + > + <Plus className="h-4 w-4" /> + </Button> + </div> + <div className="flex flex-wrap gap-2 mt-2"> + {recipients.map((email) => ( + <Badge key={email} variant="secondary" className="gap-1"> + {email} + <X + className="h-3 w-3 cursor-pointer hover:text-destructive" + onClick={() => removeRecipient(email)} + /> + </Badge> + ))} + </div> + </div> + + {/* CC 입력 */} + <div className="grid gap-2"> + <Label htmlFor="cc">참조 (CC)</Label> + <div className="flex gap-2"> + <Input + id="cc" + type="email" + placeholder="이메일 주소 입력 (선택사항)" + value={currentCc} + onChange={(e) => setCurrentCc(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleAddCc() + } + }} + /> + <Button + type="button" + size="sm" + onClick={handleAddCc} + variant="outline" + > + <Plus className="h-4 w-4" /> + </Button> + </div> + <div className="flex flex-wrap gap-2 mt-2"> + {ccRecipients.map((email) => ( + <Badge key={email} variant="secondary" className="gap-1"> + {email} + <X + className="h-3 w-3 cursor-pointer hover:text-destructive" + onClick={() => removeCc(email)} + /> + </Badge> + ))} + </div> + </div> + + {/* 코멘트 입력 */} + <div className="grid gap-2"> + <Label htmlFor="comments">메시지</Label> + <Textarea + id="comments" + placeholder="추가 메시지를 입력하세요 (선택사항)" + value={comments} + onChange={(e) => setComments(e.target.value)} + rows={4} + /> + </div> + + {/* 첨부 파일 목록 */} + <div className="grid gap-2"> + <Label>첨부 파일 ({selectedDocuments.length}개)</Label> + <ScrollArea className="h-[200px] w-full rounded-md border p-4"> + <div className="space-y-2"> + {selectedDocuments.map((doc, index) => ( + <div key={doc.documentReviewId} className="flex items-center gap-2 p-2 rounded-md bg-muted/50"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <div className="flex-1 min-w-0"> + <p className="text-sm font-medium truncate">{doc.documentName}</p> + <div className="flex items-center gap-2 text-xs text-muted-foreground"> + <span>{doc.documentType}</span> + <span>•</span> + <Badge variant={doc.documentSource === "buyer" ? "default" : "secondary"} className="text-xs"> + {doc.documentSource} + </Badge> + {doc.reviewStatus && ( + <> + <span>•</span> + <span>{doc.reviewStatus}</span> + </> + )} + </div> + </div> + </div> + ))} + </div> + </ScrollArea> + </div> + + {/* 경고 메시지 */} + {selectedDocuments.some(doc => doc.reviewStatus === "반려") && ( + <div className="flex items-start gap-2 p-3 rounded-md bg-destructive/10 text-destructive"> + <AlertCircle className="h-4 w-4 mt-0.5" /> + <p className="text-sm"> + 반려된 문서가 포함되어 있습니다. 계속 진행하시겠습니까? + </p> + </div> + )} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button + onClick={handleSendEmail} + disabled={isLoading || recipients.length === 0} + > + {isLoading ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 전송 중... + </> + ) : ( + <> + <Mail className="mr-2 h-4 w-4" /> + 이메일 전송 + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file |
