summaryrefslogtreecommitdiff
path: root/lib/tbe-last/table/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/table/documents-sheet.tsx
parent265859d691a01cdcaaf9154f93c38765bc34df06 (diff)
(대표님) 작업사항 - rfqLast, tbeLast, pdfTron, userAuth
Diffstat (limited to 'lib/tbe-last/table/documents-sheet.tsx')
-rw-r--r--lib/tbe-last/table/documents-sheet.tsx543
1 files changed, 543 insertions, 0 deletions
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<number, CommentCount>
+
+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<number | null>(null)
+ const [reviewData, setReviewData] = React.useState<Record<number, {
+ reviewStatus: string
+ reviewComments: string
+ }>>({})
+ const [isSaving, setIsSaving] = React.useState<Record<number, boolean>>({})
+ const [commentCounts, setCommentCounts] = React.useState<CountMap>({}) // <-- 추가
+ const [countLoading, setCountLoading] = React.useState(false)
+ const router = useRouter()
+
+ 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 목록이 바뀔 때만
+
+ // 문서 초기 데이터 설정
+ React.useEffect(() => {
+ if (sessionDetail?.documents) {
+ const initialData: Record<number, any> = {}
+ 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 <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" />
+ }
+ }
+
+ // 필터링된 문서 목록
+ 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 (
+ <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"
+ />
+
+ <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>
+
+ {/* 문서 테이블 */}
+ {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 ? (
+ <TableRow>
+ <TableCell colSpan={8} className="text-center text-muted-foreground">
+ No documents found
+ </TableCell>
+ </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>
+ </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>
+ </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 (
+ <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}
+
+ <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 ? (
+ <>
+ <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={() => 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>
+ )}
+
+ <DropdownMenuItem onClick={() => handleDownload(doc)}>
+ <Download className="h-4 w-4 mr-2" />
+ 다운로드
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ </TableCell>
+ </TableRow>
+ ))
+ )}
+ </TableBody>
+ </Table>
+ </ScrollArea>
+ )}
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file