summaryrefslogtreecommitdiff
path: root/lib/tbe-last/vendor/vendor-documents-sheet.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-15 14:41:01 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-15 14:41:01 +0000
commit4ee8b24cfadf47452807fa2af801385ed60ab47c (patch)
treee1d1fb029f0cf5519c517494bf9a545505c35700 /lib/tbe-last/vendor/vendor-documents-sheet.tsx
parent265859d691a01cdcaaf9154f93c38765bc34df06 (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.tsx602
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>
+ )
+}