// lib/vendor-rfq-response/vendor-tbe-table/vendor-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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { ScrollArea } from "@/components/ui/scroll-area" import { formatDate } from "@/lib/utils" import { downloadFile } from "@/lib/file-download" import { FileText, Eye, Download, Filter, MessageSquare, CheckCircle, XCircle, Clock, AlertCircle, Upload, CheckCircle2, Loader2, AlertTriangle, Trash2, } from "lucide-react" import { toast } from "sonner" import { Dropzone, DropzoneZone, DropzoneTitle, DropzoneDescription, DropzoneInput, DropzoneUploadIcon, } from "@/components/ui/dropzone" import { FileList, FileListHeader, FileListItem, FileListIcon, FileListInfo, FileListName, FileListSize, FileListDescription, FileListAction, } from "@/components/ui/file-list" import { useRouter } from "next/navigation" interface VendorDocumentsSheetProps { open: boolean onOpenChange: (open: boolean) => void sessionDetail: any isLoading: boolean onUploadSuccess?: () => void } // 업로드 큐 type QueueItem = { id: string file: File status: "queued" | "uploading" | "done" | "error" progress: number error?: string } function makeId() { // Safari/구형 브라우저 대비 폴백 return (typeof crypto !== "undefined" && "randomUUID" in crypto) ? crypto.randomUUID() : Math.random().toString(36).slice(2) + Date.now().toString(36) } type CommentCount = { totalCount: number; openCount: number } type CountMap = Record export function VendorDocumentsSheet({ open, onOpenChange, sessionDetail, isLoading, onUploadSuccess, }: VendorDocumentsSheetProps) { const [sourceFilter, setSourceFilter] = React.useState<"all" | "buyer" | "vendor">("all") const [searchTerm, setSearchTerm] = React.useState("") const [queue, setQueue] = React.useState([]) const router = useRouter() const [commentCounts, setCommentCounts] = React.useState({}) // <-- 추가 const [countLoading, setCountLoading] = React.useState(false) console.log(sessionDetail, "sessionDetail") 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 목록이 바뀔 때만 // PDFTron 열기 const handleOpenPDFTron = (doc: any) => { if (!doc.filePath) { toast.error("파일 경로를 찾을 수 없습니다") return } const params = new URLSearchParams({ filePath: doc.filePath, documentId: String(doc.documentId ?? ""), documentReviewId: String(doc.documentReviewId ?? ""), sessionId: String(sessionDetail?.session?.tbeSessionId ?? ""), documentName: doc.documentName || "", mode: doc.documentSource === "vendor" ? "edit" : "comment", }) window.open(`/pdftron-viewer?${params.toString()}`, "_blank") } const canOpenInPDFTron = (filePath: string) => { console.log(filePath, "filePath") if (!filePath) return false const ext = filePath.split(".").pop()?.toLowerCase() const supported = ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "jpg", "jpeg", "png", "tiff", "bmp"] return !!ext && supported.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: (e) => console.error("Download error:", e), }) } // ---- 업로드 ---- const tbeSessionId = sessionDetail?.session?.tbeSessionId const endpoint = tbeSessionId ? `/api/partners/tbe/${tbeSessionId}/documents` : null const startUpload = React.useCallback((item: QueueItem) => { if (!endpoint) { toast.error("세션 정보가 준비되지 않았습니다. 잠시 후 다시 시도하세요.") setQueue((prev) => prev.map((q) => (q.id === item.id ? { ...q, status: "error", error: "세션 없음" } : q)) ) return } setQueue((prev) => prev.map((q) => (q.id === item.id ? { ...q, status: "uploading", progress: 0 } : q)) ) try { const fd = new FormData() fd.append("documentType", "설계") // 필수값 없이 기본값 fd.append("documentName", item.file.name.replace(/\.[^.]+$/, "")) fd.append("description", "") fd.append("file", item.file) const xhr = new XMLHttpRequest() xhr.withCredentials = true // 동일 출처라면 문제 없지만 안전하게 명시 xhr.upload.onprogress = (e) => { if (e.lengthComputable) { const pct = Math.round((e.loaded / e.total) * 100) setQueue((prev) => prev.map((q) => (q.id === item.id ? { ...q, progress: pct } : q)) ) } } xhr.onreadystatechange = () => { if (xhr.readyState === 4) { if (xhr.status >= 200 && xhr.status < 300) { setQueue((prev) => prev.map((q) => (q.id === item.id ? { ...q, status: "done", progress: 100 } : q)) ) toast.success(`업로드 완료: ${item.file.name}`) onUploadSuccess?.() } else { const err = (() => { try { return JSON.parse(xhr.responseText)?.error } catch { return null } })() || `서버 오류 (${xhr.status})` setQueue((prev) => prev.map((q) => (q.id === item.id ? { ...q, status: "error", error: err } : q)) ) toast.error(err) } } } xhr.open("POST", endpoint) // Content-Type 수동 지정 금지 (XHR이 multipart 경계 자동 설정) xhr.send(fd) if (xhr.status >= 200 && xhr.status < 300) { setQueue((prev) => prev.map((q) => (q.id === item.id ? { ...q, status: "done", progress: 100 } : q)) ) toast.success(`업로드 완료: ${item.file.name}`) onUploadSuccess?.() router.refresh() // ✅ 1.5초 뒤 자동 제거 (원하면 시간 조절) setTimeout(() => { setQueue((prev) => prev.filter(q => q.id !== item.id)) }, 1500) } } catch (e: any) { setQueue((prev) => prev.map((q) => (q.id === item.id ? { ...q, status: "error", error: e?.message || "업로드 실패" } : q)) ) toast.error(e?.message || "업로드 실패") } }, [endpoint, onUploadSuccess]) const lastBatchRef = React.useRef("") function batchSig(files: File[]) { return files.map(f => `${f.name}:${f.size}:${f.lastModified}`).join("|") } const handleDrop = React.useCallback((filesOrEvent: any) => { let files: File[] = [] if (Array.isArray(filesOrEvent)) { files = filesOrEvent } else if (filesOrEvent?.target?.files) { files = Array.from(filesOrEvent.target.files as FileList) } else if (filesOrEvent?.dataTransfer?.files) { files = Array.from(filesOrEvent.dataTransfer.files as FileList) } if (!files.length) return // 🔒 중복 배치 방지 const sig = batchSig(files) if (sig === lastBatchRef.current) return lastBatchRef.current = sig // 너무 오래 잠기지 않도록 약간 뒤에 초기화 setTimeout(() => { if (lastBatchRef.current === sig) lastBatchRef.current = "" }, 500) const items: QueueItem[] = files.map((f) => ({ id: makeId(), file: f, status: "queued", progress: 0, })) setQueue((prev) => [...items, ...prev]) items.forEach((it) => startUpload(it)) }, [startUpload]) const removeFromQueue = (id: string) => { setQueue((prev) => prev.filter((q) => q.id !== id)) } React.useEffect(() => { if (!open) { setQueue([]) lastBatchRef.current = "" } }, [open]) // ---- 목록 필터 ---- const filteredDocuments = React.useMemo(() => { const docs = sessionDetail?.documents ?? [] return docs.filter((doc: any) => { if (sourceFilter !== "all" && doc.documentSource !== sourceFilter) return false if (searchTerm) { const s = searchTerm.toLowerCase() return ( doc.documentName?.toLowerCase().includes(s) || doc.documentType?.toLowerCase().includes(s) ) } return true }) }, [sessionDetail?.documents, sourceFilter, searchTerm]) return ( Document Repository TBE 관련 문서 조회, 다운로드 및 업로드 {/* 업로드 안내 (세션 상태) */} {sessionDetail?.session?.sessionStatus !== "진행중" && (
현재 세션 상태가 {sessionDetail?.session?.sessionStatus} 입니다. 파일을 업로드하면 서버 정책에 따라 상태가 진행중으로 전환될 수 있어요.
)} {/* --- 드롭존 영역 --- */}
파일을 여기에 드롭하거나 클릭해서 선택하세요 PDF, Office, 이미지 등 대용량(최대 1GB)도 지원합니다 {/* 업로드 큐/진행상태 */} {queue.length > 0 && (
업로드 큐 {queue.map((q) => ( {q.status === "done" ? ( ) : q.status === "error" ? ( ) : ( )} {q.file.name} {q.status === "uploading" && ` · ${q.progress}%`} {q.status === "queued" && "대기 중"} {q.status === "uploading" && "업로드 중"} {q.status === "done" && "완료"} {q.status === "error" && (q.error || "실패")} {q.file.size} {(q.status === "done" || q.status === "error") && ( )} ))}
)}
{/* 필터 & 검색 */}
setSearchTerm(e.target.value)} className="max-w-sm" />
Total: {filteredDocuments.length} {sessionDetail?.documents && ( <> Buyer: {sessionDetail.documents.filter((d: any) => d.documentSource === "buyer").length} My Docs: {sessionDetail.documents.filter((d: any) => d.documentSource === "vendor").length} )}
{/* 문서 테이블 */} {isLoading ? (
Loading...
) : ( Source Document Name Type Review Status Comments Uploaded Actions {filteredDocuments.length === 0 ? ( No documents found ) : ( filteredDocuments.map((doc: any) => ( {doc.documentSource === "buyer" ? "Buyer" : "Vendor"}
{doc.documentName}
{doc.documentType} {doc.documentSource === "vendor" && doc.reviewStatus ? (
{(() => { switch (doc.reviewStatus) { case "승인": return case "반려": return case "보류": return default: return } })()} {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) )}
) })()}
{doc.uploadedAt ? formatDate(doc.uploadedAt, "KR") : doc.submittedAt ? formatDate(doc.submittedAt, "KR") : "-"}
{canOpenInPDFTron(doc.filePath) && ( )}
)) )}
)}
) }