summaryrefslogtreecommitdiff
path: root/lib/tbe-last/table
diff options
context:
space:
mode:
Diffstat (limited to 'lib/tbe-last/table')
-rw-r--r--lib/tbe-last/table/documents-sheet.tsx599
-rw-r--r--lib/tbe-last/table/email-documents-dialog.tsx334
2 files changed, 669 insertions, 264 deletions
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