diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-15 14:41:01 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-15 14:41:01 +0000 |
| commit | 4ee8b24cfadf47452807fa2af801385ed60ab47c (patch) | |
| tree | e1d1fb029f0cf5519c517494bf9a545505c35700 /lib/tbe-last/vendor/vendor-documents-sheet.tsx | |
| parent | 265859d691a01cdcaaf9154f93c38765bc34df06 (diff) | |
(대표님) 작업사항 - rfqLast, tbeLast, pdfTron, userAuth
Diffstat (limited to 'lib/tbe-last/vendor/vendor-documents-sheet.tsx')
| -rw-r--r-- | lib/tbe-last/vendor/vendor-documents-sheet.tsx | 602 |
1 files changed, 602 insertions, 0 deletions
diff --git a/lib/tbe-last/vendor/vendor-documents-sheet.tsx b/lib/tbe-last/vendor/vendor-documents-sheet.tsx new file mode 100644 index 00000000..775d18cd --- /dev/null +++ b/lib/tbe-last/vendor/vendor-documents-sheet.tsx @@ -0,0 +1,602 @@ +// 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<number, CommentCount> + +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<QueueItem[]>([]) + const router = useRouter() + const [commentCounts, setCommentCounts] = React.useState<CountMap>({}) // <-- 추가 + const [countLoading, setCountLoading] = React.useState(false) + + + console.log(sessionDetail, "sessionDetail") + + const allReviewIds = React.useMemo(() => { + const docs = sessionDetail?.documents ?? [] + const ids = new Set<number>() + 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<string, { totalCount: number; openCount: number }> + 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<string>("") + + 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 ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="w-[1200px] sm:w-[1200px] max-w-[90vw]" style={{ width: 1200, maxWidth: "90vw" }}> + <SheetHeader> + <SheetTitle>Document Repository</SheetTitle> + <SheetDescription>TBE 관련 문서 조회, 다운로드 및 업로드</SheetDescription> + </SheetHeader> + + {/* 업로드 안내 (세션 상태) */} + {sessionDetail?.session?.sessionStatus !== "진행중" && ( + <div className="mt-3 mb-3 rounded-md border border-dashed p-3 text-sm text-muted-foreground"> + 현재 세션 상태가 <b>{sessionDetail?.session?.sessionStatus}</b> 입니다. + 파일을 업로드하면 서버 정책에 따라 상태가 <b>진행중</b>으로 전환될 수 있어요. + </div> + )} + + {/* --- 드롭존 영역 --- */} + <div className="mb-4 rounded-lg border border-dashed"> + <Dropzone onDrop={handleDrop}> + <DropzoneZone className="py-8"> + <DropzoneUploadIcon /> + <DropzoneTitle>파일을 여기에 드롭하거나 클릭해서 선택하세요</DropzoneTitle> + <DropzoneDescription> + PDF, Office, 이미지 등 대용량(최대 1GB)도 지원합니다 + </DropzoneDescription> + <DropzoneInput + accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.tiff,.bmp" + multiple + /> + </DropzoneZone> + </Dropzone> + + {/* 업로드 큐/진행상태 */} + {queue.length > 0 && ( + <div className="p-3"> + <FileList> + <FileListHeader>업로드 큐</FileListHeader> + {queue.map((q) => ( + <FileListItem key={q.id}> + <FileListIcon> + {q.status === "done" ? ( + <CheckCircle2 className="h-5 w-5" /> + ) : q.status === "error" ? ( + <AlertTriangle className="h-5 w-5" /> + ) : ( + <Loader2 className="h-5 w-5 animate-spin" /> + )} + </FileListIcon> + <FileListInfo> + <FileListName> + {q.file.name} + {q.status === "uploading" && ` · ${q.progress}%`} + </FileListName> + <FileListDescription> + {q.status === "queued" && "대기 중"} + {q.status === "uploading" && "업로드 중"} + {q.status === "done" && "완료"} + {q.status === "error" && (q.error || "실패")} + </FileListDescription> + </FileListInfo> + <FileListSize>{q.file.size}</FileListSize> + <FileListAction> + {(q.status === "done" || q.status === "error") && ( + <Button + variant="ghost" + size="icon" + onClick={() => removeFromQueue(q.id)} + title="제거" + > + <Trash2 className="h-4 w-4" /> + </Button> + )} + </FileListAction> + </FileListItem> + ))} + </FileList> + </div> + )} + </div> + + {/* 필터 & 검색 */} + <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={(v: any) => setSourceFilter(v)}> + <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">My Documents</SelectItem> + </SelectContent> + </Select> + </div> + + <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"> + My Docs: {sessionDetail.documents.filter((d: any) => d.documentSource === "vendor").length} + </Badge> + </> + )} + </div> + </div> + + {/* 문서 테이블 */} + {isLoading ? ( + <div className="p-8 text-center">Loading...</div> + ) : ( + <ScrollArea className="h-[calc(100vh-250px)]"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[80px]">Source</TableHead> + <TableHead>Document Name</TableHead> + <TableHead className="w-[120px]">Type</TableHead> + <TableHead className="w-[100px]">Review Status</TableHead> + <TableHead className="w-[120px]">Comments</TableHead> + <TableHead className="w-[150px]">Uploaded</TableHead> + <TableHead className="w-[100px] text-right">Actions</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {filteredDocuments.length === 0 ? ( + <TableRow> + <TableCell colSpan={7} className="text-center text-muted-foreground"> + No documents found + </TableCell> + </TableRow> + ) : ( + filteredDocuments.map((doc: any) => ( + <TableRow key={doc.documentReviewId || doc.documentId}> + <TableCell> + <Badge variant={doc.documentSource === "buyer" ? "default" : "secondary"}> + {doc.documentSource === "buyer" ? "Buyer" : "Vendor"} + </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> + <span className="text-sm">{doc.documentType}</span> + </TableCell> + + <TableCell> + {doc.documentSource === "vendor" && doc.reviewStatus ? ( + <div className="flex items-center gap-1"> + {(() => { + switch (doc.reviewStatus) { + case "승인": return <CheckCircle className="h-4 w-4 text-green-600" /> + case "반려": return <XCircle className="h-4 w-4 text-red-600" /> + case "보류": return <AlertCircle className="h-4 w-4 text-yellow-600" /> + default: return <Clock className="h-4 w-4 text-gray-400" /> + } + })()} + <span className="text-sm">{doc.reviewStatus}</span> + </div> + ) : ( + <span className="text-sm text-muted-foreground">-</span> + )} + </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> + <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" + title={"View & Comment"} + > + <Eye className="h-4 w-4" /> + </Button> + )} + + <Button + size="sm" + variant="ghost" + onClick={() => handleDownload(doc)} + className="h-8 px-2" + title="Download document" + > + <Download className="h-4 w-4" /> + </Button> + </div> + </TableCell> + </TableRow> + )) + )} + </TableBody> + </Table> + </ScrollArea> + )} + </SheetContent> + </Sheet> + ) +} |
