diff options
Diffstat (limited to 'lib/tbe-last/table')
| -rw-r--r-- | lib/tbe-last/table/documents-sheet.tsx | 543 | ||||
| -rw-r--r-- | lib/tbe-last/table/evaluation-dialog.tsx | 432 | ||||
| -rw-r--r-- | lib/tbe-last/table/pr-items-dialog.tsx | 83 | ||||
| -rw-r--r-- | lib/tbe-last/table/session-detail-dialog.tsx | 103 | ||||
| -rw-r--r-- | lib/tbe-last/table/tbe-last-table-columns.tsx | 214 | ||||
| -rw-r--r-- | lib/tbe-last/table/tbe-last-table.tsx | 279 |
6 files changed, 1293 insertions, 361 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 diff --git a/lib/tbe-last/table/evaluation-dialog.tsx b/lib/tbe-last/table/evaluation-dialog.tsx new file mode 100644 index 00000000..ac1d923b --- /dev/null +++ b/lib/tbe-last/table/evaluation-dialog.tsx @@ -0,0 +1,432 @@ +// lib/tbe-last/table/dialogs/evaluation-dialog.tsx + +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Textarea } from "@/components/ui/textarea" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Checkbox } from "@/components/ui/checkbox" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { TbeLastView } from "@/db/schema" +import { toast } from "sonner" +import { updateTbeEvaluation ,getTbeVendorDocuments} from "../service" +import { + FileText, + CheckCircle, + XCircle, + AlertCircle, + Clock, + Loader2, + Info +} from "lucide-react" + +// 폼 스키마 +const evaluationSchema = z.object({ + evaluationResult: z.enum(["Acceptable", "Acceptable with Comment", "Not Acceptable"], { + required_error: "평가 결과를 선택해주세요", + }), + conditionalRequirements: z.string().optional(), + conditionsFulfilled: z.boolean().default(false), + overallRemarks: z.string().optional(), +}) + +type EvaluationFormValues = z.infer<typeof evaluationSchema> + +interface EvaluationDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + selectedSession: TbeLastView | null + onSuccess?: () => void +} + +export function EvaluationDialog({ + open, + onOpenChange, + selectedSession, + onSuccess +}: EvaluationDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [isLoadingDocs, setIsLoadingDocs] = React.useState(false) + const [vendorDocuments, setVendorDocuments] = React.useState<any[]>([]) + + const form = useForm<EvaluationFormValues>({ + resolver: zodResolver(evaluationSchema), + defaultValues: { + evaluationResult: undefined, + conditionalRequirements: "", + conditionsFulfilled: false, + overallRemarks: "", + }, + }) + + const watchEvaluationResult = form.watch("evaluationResult") + const isFormValid = form.formState.isValid + + // 벤더 문서 리뷰 상태 가져오기 + React.useEffect(() => { + if (open && selectedSession?.tbeSessionId) { + fetchVendorDocuments() + + // 기존 평가 데이터가 있으면 폼에 설정 + if (selectedSession.evaluationResult) { + form.reset({ + evaluationResult: selectedSession.evaluationResult as any, + conditionalRequirements: selectedSession.conditionalRequirements || "", + conditionsFulfilled: selectedSession.conditionsFulfilled || false, + overallRemarks: selectedSession.overallRemarks || "", + }) + } else { + // 기존 평가 데이터가 없으면 초기화 + form.reset({ + evaluationResult: undefined, + conditionalRequirements: "", + conditionsFulfilled: false, + overallRemarks: "", + }) + } + } else if (!open) { + // 다이얼로그가 닫힐 때 폼 리셋 + form.reset({ + evaluationResult: undefined, + conditionalRequirements: "", + conditionsFulfilled: false, + overallRemarks: "", + }) + setVendorDocuments([]) + } + }, [open, selectedSession]) + + const fetchVendorDocuments = async () => { + if (!selectedSession?.tbeSessionId) return + + setIsLoadingDocs(true) + try { + // 서버 액션 호출 + const result = await getTbeVendorDocuments(selectedSession.tbeSessionId) + + if (result.success) { + setVendorDocuments(result.documents || []) + } else { + console.error("Failed to fetch vendor documents:", result.error) + toast.error(result.error || "벤더 문서 정보를 불러오는데 실패했습니다") + } + } catch (error) { + console.error("Failed to fetch vendor documents:", error) + toast.error("벤더 문서 정보를 불러오는데 실패했습니다") + } finally { + setIsLoadingDocs(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" /> + case "검토완료": + return <CheckCircle className="h-4 w-4 text-blue-600" /> + case "검토중": + return <Clock className="h-4 w-4 text-orange-600" /> + default: + return <Clock className="h-4 w-4 text-gray-400" /> + } + } + + const getReviewStatusVariant = (status: string): any => { + switch (status) { + case "승인": + return "default" + case "반려": + return "destructive" + case "재검토필요": + return "secondary" + case "검토완료": + return "outline" + default: + return "outline" + } + } + + const onSubmit = async (values: EvaluationFormValues) => { + if (!selectedSession?.tbeSessionId) return + + // 벤더 문서가 없는 경우 경고 + if (vendorDocuments.length === 0 && !isLoadingDocs) { + const confirmed = window.confirm( + "검토된 벤더 문서가 없습니다. 그래도 평가를 진행하시겠습니까?" + ) + if (!confirmed) return + } + + setIsLoading(true) + try { + // 서버 액션 호출 + const result = await updateTbeEvaluation(selectedSession.tbeSessionId, { + ...values, + status: "완료", // 평가 완료 시 상태 업데이트 + }) + + if (result.success) { + toast.success("평가가 성공적으로 저장되었습니다") + form.reset() + onOpenChange(false) + onSuccess?.() + } else { + toast.error(result.error || "평가 저장에 실패했습니다") + } + } catch (error) { + console.error("Failed to save evaluation:", error) + toast.error("평가 저장 중 오류가 발생했습니다") + } finally { + setIsLoading(false) + } + } + + const allDocumentsApproved = vendorDocuments.length > 0 && + vendorDocuments.every((doc: any) => doc.reviewStatus === "승인" || doc.reviewStatus === "검토완료") + + const hasRejectedDocuments = vendorDocuments.some((doc: any) => doc.reviewStatus === "반려") + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-3xl max-h-[90vh]"> + <DialogHeader> + <DialogTitle>TBE 결과 입력</DialogTitle> + <DialogDescription> + {selectedSession?.sessionCode} - {selectedSession?.vendorName} + </DialogDescription> + </DialogHeader> + + <div className="overflow-y-auto max-h-[calc(90vh-200px)] pr-4"> + <div className="space-y-6"> + {/* 벤더 문서 검토 현황 */} + <div className="space-y-3"> + <h3 className="text-sm font-semibold">벤더 문서 검토 현황</h3> + + {isLoadingDocs ? ( + <div className="flex items-center justify-center py-4"> + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + <span className="text-sm text-muted-foreground">문서 정보 로딩 중...</span> + </div> + ) : vendorDocuments.length === 0 ? ( + <Alert> + <Info className="h-4 w-4" /> + <AlertDescription> + 검토할 벤더 문서가 없습니다. + </AlertDescription> + </Alert> + ) : ( + <div className="space-y-2"> + {vendorDocuments.map((doc: any) => ( + <div key={doc.id} className="flex items-center justify-between p-3 border rounded-lg"> + <div className="flex items-center gap-3"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <div> + <p className="text-sm font-medium">{doc.documentName}</p> + <p className="text-xs text-muted-foreground">{doc.documentType}</p> + </div> + </div> + <div className="flex items-center gap-2"> + {getReviewStatusIcon(doc.reviewStatus)} + <Badge variant={getReviewStatusVariant(doc.reviewStatus)}> + {doc.reviewStatus} + </Badge> + </div> + </div> + ))} + + {/* 문서 검토 상태 요약 */} + <div className="mt-3 p-3 bg-muted rounded-lg"> + <div className="flex items-center justify-between text-sm"> + <span>전체 문서: {vendorDocuments.length}개</span> + <div className="flex items-center gap-4"> + <span className="text-green-600"> + 승인: {vendorDocuments.filter(d => d.reviewStatus === "승인").length} + </span> + <span className="text-red-600"> + 반려: {vendorDocuments.filter(d => d.reviewStatus === "반려").length} + </span> + <span className="text-gray-600"> + 미검토: {vendorDocuments.filter(d => d.reviewStatus === "미검토").length} + </span> + </div> + </div> + + {hasRejectedDocuments && ( + <Alert className="mt-2" variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + 반려된 문서가 있습니다. 평가 결과를 "Not Acceptable"로 설정하는 것을 권장합니다. + </AlertDescription> + </Alert> + )} + + {!allDocumentsApproved && !hasRejectedDocuments && ( + <Alert className="mt-2"> + <Info className="h-4 w-4" /> + <AlertDescription> + 모든 문서 검토가 완료되지 않았습니다. + </AlertDescription> + </Alert> + )} + </div> + </div> + )} + </div> + + {/* 평가 폼 */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="evaluationResult" + render={({ field }) => ( + <FormItem> + <FormLabel> + 평가 결과 <span className="text-red-500">*</span> + </FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="평가 결과를 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="Acceptable">Acceptable</SelectItem> + <SelectItem value="Acceptable with Comment">Acceptable with Comment</SelectItem> + <SelectItem value="Not Acceptable">Not Acceptable</SelectItem> + </SelectContent> + </Select> + <FormDescription> + 최종 평가 결과를 선택합니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* 조건부 승인 필드 */} + {watchEvaluationResult === "Acceptable with Comment" && ( + <> + <FormField + control={form.control} + name="conditionalRequirements" + render={({ field }) => ( + <FormItem> + <FormLabel>조건부 요구사항</FormLabel> + <FormControl> + <Textarea + placeholder="조건부 승인에 필요한 요구사항을 입력하세요..." + className="min-h-[100px]" + {...field} + /> + </FormControl> + <FormDescription> + 벤더가 충족해야 할 조건을 명확히 기술합니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="conditionsFulfilled" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div className="space-y-1 leading-none"> + <FormLabel> + 조건 충족 확인 + </FormLabel> + <FormDescription> + 벤더가 요구 조건을 모두 충족했는지 확인합니다. + </FormDescription> + </div> + </FormItem> + )} + /> + </> + )} + + {/* 평가 요약 - 종합 의견만 */} + <FormField + control={form.control} + name="overallRemarks" + render={({ field }) => ( + <FormItem> + <FormLabel>종합 의견</FormLabel> + <FormControl> + <Textarea + placeholder="종합적인 평가 의견을 입력하세요..." + className="min-h-[100px]" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </form> + </Form> + </div> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + onClick={form.handleSubmit(onSubmit)} + disabled={isLoading || !isFormValid} + > + {isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />} + 평가 저장 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/tbe-last/table/pr-items-dialog.tsx b/lib/tbe-last/table/pr-items-dialog.tsx new file mode 100644 index 00000000..780d4b5b --- /dev/null +++ b/lib/tbe-last/table/pr-items-dialog.tsx @@ -0,0 +1,83 @@ +// lib/tbe-last/table/dialogs/pr-items-dialog.tsx + +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { formatDate } from "@/lib/utils" + +interface PrItemsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + sessionDetail: any + isLoading: boolean +} + +export function PrItemsDialog({ + open, + onOpenChange, + sessionDetail, + isLoading +}: PrItemsDialogProps) { + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>PR Items</DialogTitle> + <DialogDescription> + Purchase Request items for this RFQ + </DialogDescription> + </DialogHeader> + {isLoading ? ( + <div className="p-8 text-center">Loading...</div> + ) : sessionDetail?.prItems ? ( + <div className="border rounded-lg"> + <table className="w-full text-sm"> + <thead> + <tr className="border-b bg-muted/50"> + <th className="text-left p-2">PR No</th> + <th className="text-left p-2">Material Code</th> + <th className="text-left p-2">Description</th> + <th className="text-left p-2">Size</th> + <th className="text-left p-2">Qty</th> + <th className="text-left p-2">Unit</th> + <th className="text-left p-2">Delivery</th> + <th className="text-left p-2">Major</th> + </tr> + </thead> + <tbody> + {sessionDetail.prItems.map((item: any) => ( + <tr key={item.id} className="border-b hover:bg-muted/20"> + <td className="p-2">{item.prNo}</td> + <td className="p-2">{item.materialCode}</td> + <td className="p-2">{item.materialDescription}</td> + <td className="p-2">{item.size || "-"}</td> + <td className="p-2 text-right">{item.quantity}</td> + <td className="p-2">{item.uom}</td> + <td className="p-2"> + {item.deliveryDate ? formatDate(item.deliveryDate, "KR") : "-"} + </td> + <td className="p-2 text-center"> + {item.majorYn && <Badge variant="default">Major</Badge>} + </td> + </tr> + ))} + </tbody> + </table> + </div> + ) : ( + <div className="p-8 text-center text-muted-foreground"> + No PR items available + </div> + )} + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/tbe-last/table/session-detail-dialog.tsx b/lib/tbe-last/table/session-detail-dialog.tsx new file mode 100644 index 00000000..ae5add41 --- /dev/null +++ b/lib/tbe-last/table/session-detail-dialog.tsx @@ -0,0 +1,103 @@ +// lib/tbe-last/table/dialogs/session-detail-dialog.tsx + +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { formatDate } from "@/lib/utils" + +interface SessionDetailDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + sessionDetail: any + isLoading: boolean +} + +export function SessionDetailDialog({ + open, + onOpenChange, + sessionDetail, + isLoading +}: SessionDetailDialogProps) { + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>TBE Session Detail</DialogTitle> + <DialogDescription> + {sessionDetail?.session?.sessionCode} - {sessionDetail?.session?.vendorName} + </DialogDescription> + </DialogHeader> + {isLoading ? ( + <div className="p-8 text-center">Loading...</div> + ) : sessionDetail ? ( + <div className="space-y-4"> + {/* Session info */} + <div className="grid grid-cols-2 gap-4"> + <div> + <p className="text-sm font-medium">RFQ Code</p> + <p className="text-sm text-muted-foreground">{sessionDetail.session.rfqCode}</p> + </div> + <div> + <p className="text-sm font-medium">Status</p> + <Badge>{sessionDetail.session.sessionStatus}</Badge> + </div> + <div> + <p className="text-sm font-medium">Project</p> + <p className="text-sm text-muted-foreground"> + {sessionDetail.session.projectCode} - {sessionDetail.session.projectName} + </p> + </div> + <div> + <p className="text-sm font-medium">Package</p> + <p className="text-sm text-muted-foreground"> + {sessionDetail.session.packageNo} - {sessionDetail.session.packageName} + </p> + </div> + </div> + + {/* PR Items */} + {sessionDetail.prItems?.length > 0 && ( + <div> + <h3 className="font-medium mb-2">PR Items</h3> + <div className="border rounded-lg"> + <table className="w-full text-sm"> + <thead> + <tr className="border-b"> + <th className="text-left p-2">PR No</th> + <th className="text-left p-2">Material Code</th> + <th className="text-left p-2">Description</th> + <th className="text-left p-2">Qty</th> + <th className="text-left p-2">Delivery</th> + </tr> + </thead> + <tbody> + {sessionDetail.prItems.map((item: any) => ( + <tr key={item.id} className="border-b"> + <td className="p-2">{item.prNo}</td> + <td className="p-2">{item.materialCode}</td> + <td className="p-2">{item.materialDescription}</td> + <td className="p-2">{item.quantity} {item.uom}</td> + <td className="p-2"> + {item.deliveryDate ? formatDate(item.deliveryDate, "KR") : "-"} + </td> + </tr> + ))} + </tbody> + </table> + </div> + </div> + )} + </div> + ) : null} + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/tbe-last/table/tbe-last-table-columns.tsx b/lib/tbe-last/table/tbe-last-table-columns.tsx index 71b3acde..726d8925 100644 --- a/lib/tbe-last/table/tbe-last-table-columns.tsx +++ b/lib/tbe-last/table/tbe-last-table-columns.tsx @@ -4,7 +4,7 @@ import * as React from "react" import { type ColumnDef } from "@tanstack/react-table" -import { FileText, MessageSquare, Package, ListChecks } from "lucide-react" +import { FileText, Package, ListChecks } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" @@ -77,72 +77,64 @@ export function getColumns({ size: 120, }, - // RFQ Info Group + // RFQ Code { - id: "rfqInfo", - header: "RFQ Information", - columns: [ - { - accessorKey: "rfqCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ Code" /> - ), - cell: ({ row }) => row.original.rfqCode, - size: 120, - }, - { - accessorKey: "rfqTitle", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ Title" /> - ), - cell: ({ row }) => row.original.rfqTitle || "-", - size: 200, - }, - { - accessorKey: "rfqDueDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Due Date" /> - ), - cell: ({ row }) => { - const date = row.original.rfqDueDate; - return date ? formatDate(date, "KR") : "-"; - }, - size: 100, - }, - ], + accessorKey: "rfqCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ Code" /> + ), + cell: ({ row }) => row.original.rfqCode, + size: 120, }, - // Package Info + // RFQ Title { - id: "packageInfo", - header: "Package", - columns: [ - { - accessorKey: "packageNo", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Package No" /> - ), - cell: ({ row }) => { - const packageNo = row.original.packageNo; - const packageName = row.original.packageName; - - if (!packageNo) return "-"; - - return ( - <div className="flex flex-col"> - <span className="font-medium">{packageNo}</span> - {packageName && ( - <span className="text-xs text-muted-foreground">{packageName}</span> - )} - </div> - ); - }, - size: 150, - }, - ], + accessorKey: "rfqTitle", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ Title" /> + ), + cell: ({ row }) => row.original.rfqTitle || "-", + size: 200, + }, + + // RFQ Due Date + { + accessorKey: "rfqDueDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Due Date" /> + ), + cell: ({ row }) => { + const date = row.original.rfqDueDate; + return date ? formatDate(date, "KR") : "-"; + }, + size: 100, + }, + + // Package No + { + accessorKey: "packageNo", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Package No" /> + ), + cell: ({ row }) => { + const packageNo = row.original.packageNo; + const packageName = row.original.packageName; + + if (!packageNo) return "-"; + + return ( + <div className="flex flex-col"> + <span className="font-medium">{packageNo}</span> + {packageName && ( + <span className="text-xs text-muted-foreground">{packageName}</span> + )} + </div> + ); + }, + size: 150, }, - // Project Info + // Project { accessorKey: "projectCode", header: ({ column }) => ( @@ -166,28 +158,44 @@ export function getColumns({ size: 150, }, - // Vendor Info + // Vendor Code { - id: "vendorInfo", - header: "Vendor", - columns: [ - { - accessorKey: "vendorCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Vendor Code" /> - ), - cell: ({ row }) => row.original.vendorCode || "-", - size: 100, - }, - { - accessorKey: "vendorName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Vendor Name" /> - ), - cell: ({ row }) => row.original.vendorName, - size: 200, - }, - ], + accessorKey: "vendorCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Vendor Code" /> + ), + cell: ({ row }) => row.original.vendorCode || "-", + size: 100, + }, + + // Vendor Name + { + accessorKey: "vendorName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Vendor Name" /> + ), + cell: ({ row }) => row.original.vendorName, + size: 200, + }, + + // 구매담당자 (PIC Name) + { + accessorKey: "picName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="구매담당자" /> + ), + cell: ({ row }) => row.original.picName || "-", + size: 120, + }, + + // 설계담당자 (Engineering PIC Name) + { + accessorKey: "EngPicName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="설계담당자" /> + ), + cell: ({ row }) => row.original.EngPicName || "-", + size: 120, }, // TBE Status @@ -239,7 +247,7 @@ export function getColumns({ <Button variant="outline" size="sm" - onClick={() => onOpenEvaluation(session)} + onClick={() => onOpenEvaluation(session )} > 평가입력 </Button> @@ -314,9 +322,9 @@ export function getColumns({ ), cell: ({ row }) => { const sessionId = row.original.tbeSessionId; - const buyerDocs = row.original.buyerDocumentsCount; - const vendorDocs = row.original.vendorDocumentsCount; - const reviewedDocs = row.original.reviewedDocumentsCount; + const buyerDocs = Number(row.original.buyerDocumentsCount); + const vendorDocs = Number(row.original.vendorDocumentsCount); + const reviewedDocs = Number(row.original.reviewedDocumentsCount); const totalDocs = buyerDocs + vendorDocs; return ( @@ -336,40 +344,6 @@ export function getColumns({ size: 100, enableSorting: false, }, - - // Comments - { - id: "comments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Comments" /> - ), - cell: ({ row }) => { - const sessionId = row.original.tbeSessionId; - const totalComments = row.original.totalCommentsCount; - const unresolvedComments = row.original.unresolvedCommentsCount; - - return ( - <Button - variant="ghost" - size="sm" - className="h-8 px-2 relative" - onClick={() => onOpenDocuments(sessionId)} - > - <MessageSquare className="h-4 w-4" /> - {totalComments > 0 && ( - <Badge - variant={unresolvedComments > 0 ? "destructive" : "secondary"} - className="absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem]" - > - {unresolvedComments > 0 ? unresolvedComments : totalComments} - </Badge> - )} - </Button> - ); - }, - size: 80, - enableSorting: false, - }, ]; return columns; diff --git a/lib/tbe-last/table/tbe-last-table.tsx b/lib/tbe-last/table/tbe-last-table.tsx index 64707e4e..a9328bdf 100644 --- a/lib/tbe-last/table/tbe-last-table.tsx +++ b/lib/tbe-last/table/tbe-last-table.tsx @@ -4,7 +4,7 @@ import * as React from "react" import { useRouter } from "next/navigation" -import { type DataTableFilterField } from "@/types/table" +import { type DataTableAdvancedFilterField } from "@/types/table" import { useDataTable } from "@/hooks/use-data-table" import { DataTable } from "@/components/data-table/data-table" import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" @@ -14,24 +14,12 @@ import { getAllTBELast, getTBESessionDetail } from "@/lib/tbe-last/service" import { Button } from "@/components/ui/button" import { Download, RefreshCw } from "lucide-react" import { exportTableToExcel } from "@/lib/export" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription -} from "@/components/ui/dialog" -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetDescription -} from "@/components/ui/sheet" -import { Badge } from "@/components/ui/badge" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { ScrollArea } from "@/components/ui/scroll-area" -import { formatDate } from "@/lib/utils" + +// Import Dialogs and Sheets +import { SessionDetailDialog } from "./session-detail-dialog" +import { DocumentsSheet } from "./documents-sheet" +import { PrItemsDialog } from "./pr-items-dialog" +import { EvaluationDialog } from "./evaluation-dialog" interface TbeLastTableProps { promises: Promise<[ @@ -43,6 +31,8 @@ export function TbeLastTable({ promises }: TbeLastTableProps) { const router = useRouter() const [{ data, pageCount }] = React.use(promises) + console.log(data,"data") + // Dialog states const [sessionDetailOpen, setSessionDetailOpen] = React.useState(false) const [documentsOpen, setDocumentsOpen] = React.useState(false) @@ -90,6 +80,8 @@ export function TbeLastTable({ promises }: TbeLastTableProps) { const handleOpenEvaluation = React.useCallback((session: TbeLastView) => { setSelectedSession(session) setEvaluationOpen(true) + loadSessionDetail(session.rfqId) + }, []) const handleRefresh = React.useCallback(() => { @@ -109,7 +101,7 @@ export function TbeLastTable({ promises }: TbeLastTableProps) { ) // Filter fields - const filterFields: DataTableFilterField<TbeLastView>[] = [ + const filterFields: DataTableAdvancedFilterField<TbeLastView>[] = [ { id: "sessionStatus", label: "Status", @@ -144,7 +136,7 @@ export function TbeLastTable({ promises }: TbeLastTableProps) { enableAdvancedFilter: true, initialState: { sorting: [{ id: "createdAt", desc: true }], - columnPinning: { right: ["documents", "comments"] }, + columnPinning: { right: ["documents"] }, }, getRowId: (originalRow) => String(originalRow.tbeSessionId), shallow: false, @@ -188,232 +180,37 @@ export function TbeLastTable({ promises }: TbeLastTableProps) { </DataTable> {/* Session Detail Dialog */} - <Dialog open={sessionDetailOpen} onOpenChange={setSessionDetailOpen}> - <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> - <DialogHeader> - <DialogTitle>TBE Session Detail</DialogTitle> - <DialogDescription> - {sessionDetail?.session?.sessionCode} - {sessionDetail?.session?.vendorName} - </DialogDescription> - </DialogHeader> - {isLoadingDetail ? ( - <div className="p-8 text-center">Loading...</div> - ) : sessionDetail ? ( - <div className="space-y-4"> - {/* Session info */} - <div className="grid grid-cols-2 gap-4"> - <div> - <p className="text-sm font-medium">RFQ Code</p> - <p className="text-sm text-muted-foreground">{sessionDetail.session.rfqCode}</p> - </div> - <div> - <p className="text-sm font-medium">Status</p> - <Badge>{sessionDetail.session.sessionStatus}</Badge> - </div> - <div> - <p className="text-sm font-medium">Project</p> - <p className="text-sm text-muted-foreground"> - {sessionDetail.session.projectCode} - {sessionDetail.session.projectName} - </p> - </div> - <div> - <p className="text-sm font-medium">Package</p> - <p className="text-sm text-muted-foreground"> - {sessionDetail.session.packageNo} - {sessionDetail.session.packageName} - </p> - </div> - </div> - - {/* PR Items */} - {sessionDetail.prItems?.length > 0 && ( - <div> - <h3 className="font-medium mb-2">PR Items</h3> - <div className="border rounded-lg"> - <table className="w-full text-sm"> - <thead> - <tr className="border-b"> - <th className="text-left p-2">PR No</th> - <th className="text-left p-2">Material Code</th> - <th className="text-left p-2">Description</th> - <th className="text-left p-2">Qty</th> - <th className="text-left p-2">Delivery</th> - </tr> - </thead> - <tbody> - {sessionDetail.prItems.map((item: any) => ( - <tr key={item.id} className="border-b"> - <td className="p-2">{item.prNo}</td> - <td className="p-2">{item.materialCode}</td> - <td className="p-2">{item.materialDescription}</td> - <td className="p-2">{item.quantity} {item.uom}</td> - <td className="p-2"> - {item.deliveryDate ? formatDate(item.deliveryDate, "KR") : "-"} - </td> - </tr> - ))} - </tbody> - </table> - </div> - </div> - )} - </div> - ) : null} - </DialogContent> - </Dialog> + <SessionDetailDialog + open={sessionDetailOpen} + onOpenChange={setSessionDetailOpen} + sessionDetail={sessionDetail} + isLoading={isLoadingDetail} + /> {/* Documents Sheet */} - <Sheet open={documentsOpen} onOpenChange={setDocumentsOpen}> - <SheetContent className="w-[600px] sm:w-[800px]"> - <SheetHeader> - <SheetTitle>Documents & Comments</SheetTitle> - <SheetDescription> - Review documents and PDFTron comments - </SheetDescription> - </SheetHeader> - - {isLoadingDetail ? ( - <div className="p-8 text-center">Loading...</div> - ) : sessionDetail?.documents ? ( - <Tabs defaultValue="buyer" className="mt-4"> - <TabsList className="grid w-full grid-cols-2"> - <TabsTrigger value="buyer">Buyer Documents</TabsTrigger> - <TabsTrigger value="vendor">Vendor Documents</TabsTrigger> - </TabsList> - - <TabsContent value="buyer"> - <ScrollArea className="h-[calc(100vh-200px)]"> - <div className="space-y-2"> - {sessionDetail.documents - .filter((doc: any) => doc.documentSource === "buyer") - .map((doc: any) => ( - <div key={doc.documentId} className="border rounded-lg p-3"> - <div className="flex items-start justify-between"> - <div className="flex-1"> - <p className="font-medium text-sm">{doc.documentName}</p> - <p className="text-xs text-muted-foreground"> - Type: {doc.documentType} | Status: {doc.reviewStatus} - </p> - </div> - <div className="flex items-center gap-2"> - {doc.comments.totalCount > 0 && ( - <Badge variant={doc.comments.openCount > 0 ? "destructive" : "secondary"}> - {doc.comments.openCount}/{doc.comments.totalCount} comments - </Badge> - )} - <Button size="sm" variant="outline"> - View in PDFTron - </Button> - </div> - </div> - </div> - ))} - </div> - </ScrollArea> - </TabsContent> - - <TabsContent value="vendor"> - <ScrollArea className="h-[calc(100vh-200px)]"> - <div className="space-y-2"> - {sessionDetail.documents - .filter((doc: any) => doc.documentSource === "vendor") - .map((doc: any) => ( - <div key={doc.documentId} className="border rounded-lg p-3"> - <div className="flex items-start justify-between"> - <div className="flex-1"> - <p className="font-medium text-sm">{doc.documentName}</p> - <p className="text-xs text-muted-foreground"> - Type: {doc.documentType} | Status: {doc.reviewStatus} - </p> - {doc.submittedAt && ( - <p className="text-xs text-muted-foreground"> - Submitted: {formatDate(doc.submittedAt, "KR")} - </p> - )} - </div> - <div className="flex items-center gap-2"> - <Button size="sm" variant="outline"> - Download - </Button> - <Button size="sm" variant="outline"> - Review - </Button> - </div> - </div> - </div> - ))} - </div> - </ScrollArea> - </TabsContent> - </Tabs> - ) : null} - </SheetContent> - </Sheet> + <DocumentsSheet + open={documentsOpen} + onOpenChange={setDocumentsOpen} + sessionDetail={sessionDetail} + isLoading={isLoadingDetail} + /> {/* PR Items Dialog */} - <Dialog open={prItemsOpen} onOpenChange={setPrItemsOpen}> - <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> - <DialogHeader> - <DialogTitle>PR Items</DialogTitle> - <DialogDescription> - Purchase Request items for this RFQ - </DialogDescription> - </DialogHeader> - {sessionDetail?.prItems && ( - <div className="border rounded-lg"> - <table className="w-full text-sm"> - <thead> - <tr className="border-b bg-muted/50"> - <th className="text-left p-2">PR No</th> - <th className="text-left p-2">Material Code</th> - <th className="text-left p-2">Description</th> - <th className="text-left p-2">Size</th> - <th className="text-left p-2">Qty</th> - <th className="text-left p-2">Unit</th> - <th className="text-left p-2">Delivery</th> - <th className="text-left p-2">Major</th> - </tr> - </thead> - <tbody> - {sessionDetail.prItems.map((item: any) => ( - <tr key={item.id} className="border-b hover:bg-muted/20"> - <td className="p-2">{item.prNo}</td> - <td className="p-2">{item.materialCode}</td> - <td className="p-2">{item.materialDescription}</td> - <td className="p-2">{item.size || "-"}</td> - <td className="p-2 text-right">{item.quantity}</td> - <td className="p-2">{item.uom}</td> - <td className="p-2"> - {item.deliveryDate ? formatDate(item.deliveryDate, "KR") : "-"} - </td> - <td className="p-2 text-center"> - {item.majorYn && <Badge variant="default">Major</Badge>} - </td> - </tr> - ))} - </tbody> - </table> - </div> - )} - </DialogContent> - </Dialog> + <PrItemsDialog + open={prItemsOpen} + onOpenChange={setPrItemsOpen} + sessionDetail={sessionDetail} + isLoading={isLoadingDetail} + /> {/* Evaluation Dialog */} - <Dialog open={evaluationOpen} onOpenChange={setEvaluationOpen}> - <DialogContent> - <DialogHeader> - <DialogTitle>TBE Evaluation</DialogTitle> - <DialogDescription> - Enter evaluation result for {selectedSession?.sessionCode} - </DialogDescription> - </DialogHeader> - <div className="space-y-4 mt-4"> - {/* Evaluation form would go here */} - <p className="text-sm text-muted-foreground"> - Evaluation form to be implemented... - </p> - </div> - </DialogContent> - </Dialog> + <EvaluationDialog + open={evaluationOpen} + onOpenChange={setEvaluationOpen} + selectedSession={selectedSession} + sessionDetail={sessionDetail} + + /> </> ) }
\ No newline at end of file |
