diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-16 09:20:58 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-16 09:20:58 +0000 |
| commit | 6c11fccc84f4c84fa72ee01f9caad9f76f35cea2 (patch) | |
| tree | fa88d10ea7d21fe6b59ed0c1569856a73d56547a /lib/tbe-last/table/documents-sheet.tsx | |
| parent | 14e3990aba7e1ad1cdd0965cbd167c50230cbfbf (diff) | |
(대표님, 최겸) 계약, 업로드 관련, 메뉴처리, 입찰, 프리쿼트, rfqLast관련, tbeLast관련
Diffstat (limited to 'lib/tbe-last/table/documents-sheet.tsx')
| -rw-r--r-- | lib/tbe-last/table/documents-sheet.tsx | 599 |
1 files changed, 335 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 |
