From 4ee8b24cfadf47452807fa2af801385ed60ab47c Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 15 Sep 2025 14:41:01 +0000 Subject: (대표님) 작업사항 - rfqLast, tbeLast, pdfTron, userAuth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/tbe-last/table/documents-sheet.tsx | 543 +++++++++++++++++++++++++++++++++ 1 file changed, 543 insertions(+) create mode 100644 lib/tbe-last/table/documents-sheet.tsx (limited to 'lib/tbe-last/table/documents-sheet.tsx') diff --git a/lib/tbe-last/table/documents-sheet.tsx b/lib/tbe-last/table/documents-sheet.tsx new file mode 100644 index 00000000..96e6e178 --- /dev/null +++ b/lib/tbe-last/table/documents-sheet.tsx @@ -0,0 +1,543 @@ +// lib/tbe-last/table/dialogs/documents-sheet.tsx + +"use client" + +import * as React from "react" +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription +} from "@/components/ui/sheet" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { ScrollArea } from "@/components/ui/scroll-area" +import { formatDate } from "@/lib/utils" +import { downloadFile, getFileInfo } from "@/lib/file-download" +import { + FileText, + Eye, + Download, + MoreHorizontal, + Filter, + MessageSquare, + CheckCircle, + XCircle, + Clock, + AlertCircle, + Save, +} from "lucide-react" +import { toast } from "sonner" +import { useRouter } from "next/navigation" + +interface DocumentsSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + sessionDetail: any + isLoading: boolean +} + +type CommentCount = { totalCount: number; openCount: number } +type CountMap = Record + +export function DocumentsSheet({ + open, + onOpenChange, + sessionDetail, + isLoading +}: DocumentsSheetProps) { + + console.log(sessionDetail, "sessionDetail") + + const [sourceFilter, setSourceFilter] = React.useState<"all" | "buyer" | "vendor">("all") + const [searchTerm, setSearchTerm] = React.useState("") + const [editingReviewId, setEditingReviewId] = React.useState(null) + const [reviewData, setReviewData] = React.useState>({}) + const [isSaving, setIsSaving] = React.useState>({}) + const [commentCounts, setCommentCounts] = React.useState({}) // <-- 추가 + const [countLoading, setCountLoading] = React.useState(false) + const router = useRouter() + + const allReviewIds = React.useMemo(() => { + const docs = sessionDetail?.documents ?? [] + const ids = new Set() + for (const d of docs) { + const id = Number(d?.documentReviewId) + if (Number.isFinite(id)) ids.add(id) + } + return Array.from(ids) + }, [sessionDetail?.documents]) + + React.useEffect(() => { + let aborted = false + ; (async () => { + if (allReviewIds.length === 0) { + setCommentCounts({}) + return + } + setCountLoading(true) + try { + // 너무 길어질 수 있으니 적당히 나눠서 호출(옵션) + const chunkSize = 100 + const chunks: number[][] = [] + for (let i = 0; i < allReviewIds.length; i += chunkSize) { + chunks.push(allReviewIds.slice(i, i + chunkSize)) + } + + const merged: CountMap = {} + for (const c of chunks) { + const qs = encodeURIComponent(c.join(",")) + const res = await fetch(`/api/pdftron-comments/xfdf/count?ids=${qs}`, { + credentials: "include", + cache: "no-store", + }) + if (!res.ok) throw new Error(`count api ${res.status}`) + const json = await res.json() + if (aborted) return + const data = (json?.data ?? {}) as Record + for (const [k, v] of Object.entries(data)) { + const idNum = Number(k) + if (Number.isFinite(idNum)) { + merged[idNum] = { totalCount: v.totalCount ?? 0, openCount: v.openCount ?? 0 } + } + } + } + if (!aborted) setCommentCounts(merged) + } catch (e) { + console.error("Failed to load comment counts", e) + } finally { + if (!aborted) setCountLoading(false) + } + })() + return () => { + aborted = true + } + }, [allReviewIds.join(",")]) // 의존성: id 목록이 바뀔 때만 + + // 문서 초기 데이터 설정 + React.useEffect(() => { + if (sessionDetail?.documents) { + const initialData: Record = {} + sessionDetail.documents.forEach((doc: any) => { + initialData[doc.documentReviewId] = { + reviewStatus: doc.reviewStatus || "미검토", + reviewComments: doc.reviewComments || "" + } + }) + setReviewData(initialData) + } + }, [sessionDetail]) + + // PDFtron 뷰어 열기 + const handleOpenPDFTron = (doc: any) => { + if (!doc.filePath) { + toast.error("파일 경로를 찾을 수 없습니다") + return + } + + const params = new URLSearchParams({ + filePath: doc.filePath, + documentId: doc.documentId.toString(), + documentReviewId: doc.documentReviewId?.toString() || '', + sessionId: sessionDetail?.session?.tbeSessionId?.toString() || '', + documentName: doc.documentName || '', + mode: 'review' + }) + + window.open(`/pdftron-viewer?${params.toString()}`, '_blank') + } + + // 파일이 PDFtron에서 열 수 있는지 확인 + const canOpenInPDFTron = (filePath: string) => { + if (!filePath) return false + const ext = filePath.split('.').pop()?.toLowerCase() + const supportedFormats = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'jpg', 'jpeg', 'png', 'tiff', 'bmp'] + return supportedFormats.includes(ext || '') + } + + // 파일 다운로드 + const handleDownload = async (doc: any) => { + if (!doc.filePath) { + toast.error("파일 경로를 찾을 수 없습니다") + return + } + + await downloadFile(doc.filePath, doc.originalFileName || doc.documentName, { + action: 'download', + showToast: true, + onError: (error) => { + console.error('Download error:', error) + } + }) + } + + // 리뷰 상태 저장 + 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' }, + body: JSON.stringify({ + reviewStatus: reviewData[reviewId]?.reviewStatus, + reviewComments: reviewData[reviewId]?.reviewComments + }) + }) + + if (!response.ok) throw new Error('Failed to save review') + + toast.success("리뷰 저장 완료") + router.refresh() + setEditingReviewId(null) + } catch (error) { + console.error('Save review error:', error) + toast.error("리뷰 저장 실패") + } finally { + setIsSaving({ ...isSaving, [reviewId]: false }) + } + } + + // 리뷰 상태 아이콘 + const getReviewStatusIcon = (status: string) => { + switch (status) { + case "승인": + return + case "반려": + return + case "보류": + return + default: + return + } + } + + // 필터링된 문서 목록 + 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 ( + doc.documentName?.toLowerCase().includes(searchLower) || + doc.documentType?.toLowerCase().includes(searchLower) || + doc.reviewComments?.toLowerCase().includes(searchLower) + ) + } + + return true + }) + }, [sessionDetail?.documents, sourceFilter, searchTerm]) + + return ( + + + + Documents & Review Management + + 문서 검토 및 코멘트 관리 + + + + {/* 필터 및 검색 */} +
+
+ + +
+ + 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} + + + )} +
+
+ + {/* 문서 테이블 */} + {isLoading ? ( +
Loading...
+ ) : ( + + + + + Source + Document Name + Type + Review Status + Comments + Review Notes + Uploaded + Actions + + + + {filteredDocuments.length === 0 ? ( + + + No documents found + + + ) : ( + filteredDocuments.map((doc: any) => ( + + + + {doc.documentSource} + + + + +
+ + {doc.documentName} +
+
+ + {doc.documentType} + + + {editingReviewId === doc.documentReviewId ? ( + + ) : ( +
+ {getReviewStatusIcon(reviewData[doc.documentReviewId]?.reviewStatus || doc.reviewStatus)} + + {reviewData[doc.documentReviewId]?.reviewStatus || doc.reviewStatus || "미검토"} + +
+ )} +
+ + + + {(() => { + 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 ( +
+ + + {counts.totalCount} + {counts.openCount > 0 && ( + + ({counts.openCount} open) + + )} + +
+ ) + })()} +
+ + + {editingReviewId === doc.documentReviewId ? ( +