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/table/documents-sheet.tsx | |
| parent | 265859d691a01cdcaaf9154f93c38765bc34df06 (diff) | |
(대표님) 작업사항 - rfqLast, tbeLast, pdfTron, userAuth
Diffstat (limited to 'lib/tbe-last/table/documents-sheet.tsx')
| -rw-r--r-- | lib/tbe-last/table/documents-sheet.tsx | 543 |
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 |
