diff options
Diffstat (limited to 'lib/tbe-last')
| -rw-r--r-- | lib/tbe-last/service.ts | 231 | ||||
| -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 | ||||
| -rw-r--r-- | lib/tbe-last/vendor-tbe-service.ts | 355 | ||||
| -rw-r--r-- | lib/tbe-last/vendor/tbe-table-columns.tsx | 335 | ||||
| -rw-r--r-- | lib/tbe-last/vendor/tbe-table.tsx | 222 | ||||
| -rw-r--r-- | lib/tbe-last/vendor/vendor-comment-dialog.tsx | 313 | ||||
| -rw-r--r-- | lib/tbe-last/vendor/vendor-document-upload-dialog.tsx | 326 | ||||
| -rw-r--r-- | lib/tbe-last/vendor/vendor-documents-sheet.tsx | 602 | ||||
| -rw-r--r-- | lib/tbe-last/vendor/vendor-evaluation-view-dialog.tsx | 250 | ||||
| -rw-r--r-- | lib/tbe-last/vendor/vendor-pr-items-dialog.tsx | 253 |
15 files changed, 4150 insertions, 391 deletions
diff --git a/lib/tbe-last/service.ts b/lib/tbe-last/service.ts index 760f66ac..d9046524 100644 --- a/lib/tbe-last/service.ts +++ b/lib/tbe-last/service.ts @@ -6,10 +6,11 @@ import db from "@/db/db"; import { and, desc, asc, eq, sql, or, isNull, isNotNull, ne, inArray } from "drizzle-orm"; import { tbeLastView, tbeDocumentsView } from "@/db/schema"; import { rfqPrItems } from "@/db/schema/rfqLast"; -import { rfqLastTbeDocumentReviews, rfqLastTbePdftronComments, rfqLastTbeVendorDocuments } from "@/db/schema"; +import { rfqLastTbeDocumentReviews, rfqLastTbePdftronComments, rfqLastTbeVendorDocuments,rfqLastTbeSessions } from "@/db/schema"; import { filterColumns } from "@/lib/filter-columns"; import { GetTBELastSchema } from "./validations"; - +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" // ========================================== // 1. TBE 세션 목록 조회 // ========================================== @@ -87,8 +88,8 @@ export async function getAllTBELast(input: GetTBELastSchema) { // 2. TBE 세션 상세 조회 // ========================================== export async function getTBESessionDetail(sessionId: number) { - return unstable_cache( - async () => { + // return unstable_cache( + // async () => { // 세션 기본 정보 const [session] = await db .select() @@ -153,13 +154,13 @@ export async function getTBESessionDetail(sessionId: number) { prItems, documents: documentsWithComments, }; - }, - [`tbe-session-${sessionId}`], - { - revalidate: 60, - tags: [`tbe-session-${sessionId}`], - } - )(); + // }, + // [`tbe-session-${sessionId}`], + // { + // revalidate: 60, + // tags: [`tbe-session-${sessionId}`], + // } + // )(); } // ========================================== @@ -190,25 +191,6 @@ export async function getDocumentComments(documentReviewId: number) { return comments; } -// ========================================== -// 4. TBE 평가 결과 업데이트 -// ========================================== -export async function updateTBEEvaluation( - sessionId: number, - data: { - evaluationResult: "pass" | "conditional_pass" | "non_pass"; - conditionalRequirements?: string; - technicalSummary?: string; - commercialSummary?: string; - overallRemarks?: string; - } -) { - // 실제 업데이트 로직 - // await db.update(rfqLastTbeSessions)... - - // 캐시 무효화 - return { success: true }; -} // ========================================== // 5. 벤더 문서 업로드 @@ -244,4 +226,193 @@ export async function uploadVendorDocument( .returning(); return document; +} + +interface UpdateEvaluationData { + evaluationResult?: "Acceptable" | "Acceptable with Comment" | "Not Acceptable" + conditionalRequirements?: string + conditionsFulfilled?: boolean + technicalSummary?: string + commercialSummary?: string + overallRemarks?: string + approvalRemarks?: string + status?: "준비중" | "진행중" | "검토중" | "보류" | "완료" | "취소" +} + +export async function updateTbeEvaluation( + tbeSessionId: number, + data: UpdateEvaluationData +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return { success: false, error: "인증이 필요합니다" } + } + + const userId = typeof session.user.id === 'string' ? parseInt(session.user.id) : session.user.id + + // 현재 TBE 세션 조회 + const [currentTbeSession] = await db + .select() + .from(rfqLastTbeSessions) + .where(eq(rfqLastTbeSessions.id, tbeSessionId)) + .limit(1) + + if (!currentTbeSession) { + return { success: false, error: "TBE 세션을 찾을 수 없습니다" } + } + + // 업데이트 데이터 준비 + const updateData: any = { + updatedBy: userId, + updatedAt: new Date() + } + + // 평가 결과 관련 필드 + if (data.evaluationResult !== undefined) { + updateData.evaluationResult = data.evaluationResult + } + + // 조건부 승인 관련 (Acceptable with Comment인 경우) + if (data.evaluationResult === "Acceptable with Comment") { + if (data.conditionalRequirements !== undefined) { + updateData.conditionalRequirements = data.conditionalRequirements + } + if (data.conditionsFulfilled !== undefined) { + updateData.conditionsFulfilled = data.conditionsFulfilled + } + } else if (data.evaluationResult === "Acceptable") { + // Acceptable인 경우 조건부 필드 초기화 + updateData.conditionalRequirements = null + updateData.conditionsFulfilled = true + } else if (data.evaluationResult === "Not Acceptable") { + // Not Acceptable인 경우 조건부 필드 초기화 + updateData.conditionalRequirements = null + updateData.conditionsFulfilled = false + } + + // 평가 요약 필드 + if (data.technicalSummary !== undefined) { + updateData.technicalSummary = data.technicalSummary + } + if (data.commercialSummary !== undefined) { + updateData.commercialSummary = data.commercialSummary + } + if (data.overallRemarks !== undefined) { + updateData.overallRemarks = data.overallRemarks + } + + // 승인 관련 필드 + if (data.approvalRemarks !== undefined) { + updateData.approvalRemarks = data.approvalRemarks + updateData.approvedBy = userId + updateData.approvedAt = new Date() + } + + // 상태 업데이트 + if (data.status !== undefined) { + updateData.status = data.status + + // 완료 상태로 변경 시 종료일 설정 + if (data.status === "완료") { + updateData.actualEndDate = new Date() + } + } + + // TBE 세션 업데이트 + const [updated] = await db + .update(rfqLastTbeSessions) + .set(updateData) + .where(eq(rfqLastTbeSessions.id, tbeSessionId)) + .returning() + + // 캐시 초기화 + revalidateTag(`tbe-session-${tbeSessionId}`) + revalidateTag(`tbe-sessions`) + + // RFQ 관련 캐시도 초기화 + if (currentTbeSession.rfqsLastId) { + revalidateTag(`rfq-${currentTbeSession.rfqsLastId}`) + } + + return { + success: true, + data: updated, + message: "평가가 성공적으로 저장되었습니다" + } + + } catch (error) { + console.error("Failed to update TBE evaluation:", error) + return { + success: false, + error: error instanceof Error ? error.message : "평가 저장에 실패했습니다" + } + } +} + +export async function getTbeVendorDocuments(tbeSessionId: number) { + + try { + const documents = await db + .select({ + id: rfqLastTbeVendorDocuments.id, + documentName: rfqLastTbeVendorDocuments.originalFileName, + documentType: rfqLastTbeVendorDocuments.documentType, + fileName: rfqLastTbeVendorDocuments.fileName, + fileSize: rfqLastTbeVendorDocuments.fileSize, + fileType: rfqLastTbeVendorDocuments.fileType, + documentNo: rfqLastTbeVendorDocuments.documentNo, + revisionNo: rfqLastTbeVendorDocuments.revisionNo, + issueDate: rfqLastTbeVendorDocuments.issueDate, + description: rfqLastTbeVendorDocuments.description, + submittedAt: rfqLastTbeVendorDocuments.submittedAt, + // 검토 정보는 rfqLastTbeDocumentReviews에서 가져옴 + reviewStatus: rfqLastTbeDocumentReviews.reviewStatus, + reviewComments: rfqLastTbeDocumentReviews.reviewComments, + reviewedAt: rfqLastTbeDocumentReviews.reviewedAt, + requiresRevision: rfqLastTbeDocumentReviews.requiresRevision, + technicalCompliance: rfqLastTbeDocumentReviews.technicalCompliance, + qualityAcceptable: rfqLastTbeDocumentReviews.qualityAcceptable, + }) + .from(rfqLastTbeVendorDocuments) + .leftJoin( + rfqLastTbeDocumentReviews, + and( + eq(rfqLastTbeDocumentReviews.vendorAttachmentId, rfqLastTbeVendorDocuments.id), + eq(rfqLastTbeDocumentReviews.documentSource, "vendor") + ) + ) + .where(eq(rfqLastTbeVendorDocuments.tbeSessionId, tbeSessionId)) + .orderBy(rfqLastTbeVendorDocuments.submittedAt) + + // 문서 정보 매핑 (reviewStatus는 이미 한글로 저장되어 있음) + const mappedDocuments = documents.map(doc => ({ + ...doc, + reviewStatus: doc.reviewStatus || "미검토", // null인 경우 기본값 + reviewRequired: doc.requiresRevision || false, // UI 호환성을 위해 필드명 매핑 + })) + + return { + success: true, + documents: mappedDocuments, + } + } catch (error) { + console.error("Failed to fetch vendor documents:", error) + return { + success: false, + error: "벤더 문서를 불러오는데 실패했습니다", + documents: [], + } + } +} +// 리뷰 상태 매핑 함수 +function mapReviewStatus(status: string | null): string { + const statusMap: Record<string, string> = { + "pending": "미검토", + "reviewing": "검토중", + "approved": "승인", + "rejected": "반려", + } + + return status ? (statusMap[status] || status) : "미검토" }
\ No newline at end of file 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 diff --git a/lib/tbe-last/vendor-tbe-service.ts b/lib/tbe-last/vendor-tbe-service.ts new file mode 100644 index 00000000..8335eb4f --- /dev/null +++ b/lib/tbe-last/vendor-tbe-service.ts @@ -0,0 +1,355 @@ +// lib/vendor-rfq-response/vendor-tbe-service-simplified.ts + +'use server' + +import { unstable_cache } from "next/cache" +import db from "@/db/db" +import { and, desc, asc, eq, sql, or } from "drizzle-orm" +import { tbeLastView, rfqLastTbeSessions } from "@/db/schema" +import { rfqPrItems } from "@/db/schema/rfqLast" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { revalidateTag } from "next/cache" +// ========================================== +// 간단한 벤더 Q&A 타입 정의 +// ========================================== +export interface VendorQuestion { + id: string // UUID + category: "general" | "technical" | "commercial" | "delivery" | "quality" | "document" | "clarification" + question: string + askedAt: string + askedBy: number + askedByName?: string + answer?: string + answeredAt?: string + answeredBy?: number + answeredByName?: string + status: "open" | "answered" | "closed" + priority?: "high" | "normal" | "low" + attachments?: string[] // 파일 경로들 +} + +// ========================================== +// 1. 벤더용 TBE 세션 목록 조회 (기존 뷰 활용) +// ========================================== +export async function getTBEforVendor( + input: any, + vendorId: number +) { + return unstable_cache( + async () => { + const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) + const limit = input.perPage ?? 10 + + // 벤더 필터링 + const vendorWhere = eq(tbeLastView.vendorId, vendorId) + + // 데이터 조회 + const [rows, total] = await db.transaction(async (tx) => { + const data = await tx + .select() + .from(tbeLastView) + .where(vendorWhere) + .orderBy(desc(tbeLastView.createdAt)) + .offset(offset) + .limit(limit) + + const [{ count }] = await tx + .select({ count: sql<number>`count(*)`.as("count") }) + .from(tbeLastView) + .where(vendorWhere) + + return [data, Number(count)] + }) + + const pageCount = Math.ceil(total / limit) + return { data: rows, pageCount } + }, + [`vendor-tbe-sessions-${vendorId}`, JSON.stringify(input)], + { + revalidate: 60, + tags: [`vendor-tbe-sessions-${vendorId}`], + } + )() +} + +// ========================================== +// 2. 벤더 질문/코멘트 추가 (기존 필드 활용) +// ========================================== +export async function addVendorQuestion( + sessionId: number, + vendorId: number, + question: Omit<VendorQuestion, "id" | "askedAt"> +) { + const session = await getServerSession(authOptions) + if (!session?.user) { + throw new Error("인증이 필요합니다") + } + + const userId = typeof session.user.id === 'string' ? parseInt(session.user.id) : session.user.id + + // 권한 체크 + const [tbeSession] = await db + .select() + .from(rfqLastTbeSessions) + .where( + and( + eq(rfqLastTbeSessions.id, sessionId), + eq(rfqLastTbeSessions.vendorId, vendorId) + ) + ) + .limit(1) + + if (!tbeSession) { + throw new Error("권한이 없습니다") + } + + // 기존 질문 로그 가져오기 + const existingQuestions = tbeSession.vendorQuestionsLog || [] + + // 새 질문 추가 + const newQuestion: VendorQuestion = { + id: crypto.randomUUID(), + ...question, + askedAt: new Date().toISOString(), + askedBy: userId, + status: "open" + } + + // 업데이트 + const [updated] = await db + .update(rfqLastTbeSessions) + .set({ + vendorQuestionsLog: [...existingQuestions, newQuestion], + vendorRemarks: tbeSession.vendorRemarks + ? `${tbeSession.vendorRemarks}\n\n[${new Date().toLocaleString()}] ${question.question}` + : `[${new Date().toLocaleString()}] ${question.question}`, + updatedAt: new Date(), + updatedBy: userId + }) + .where(eq(rfqLastTbeSessions.id, sessionId)) + .returning() + + // 캐시 무효화 + revalidateTag(`vendor-tbe-sessions-${vendorId}`) + revalidateTag(`tbe-session-${sessionId}`) + + return newQuestion +} + +// ========================================== +// 3. 구매자가 답변 추가 +// ========================================== +export async function answerVendorQuestion( + sessionId: number, + questionId: string, + answer: string +) { + const session = await getServerSession(authOptions) + if (!session?.user) { + throw new Error("인증이 필요합니다") + } + + const userId = typeof session.user.id === 'string' ? parseInt(session.user.id) : session.user.id + + // TBE 세션 조회 + const [tbeSession] = await db + .select() + .from(rfqLastTbeSessions) + .where(eq(rfqLastTbeSessions.id, sessionId)) + .limit(1) + + if (!tbeSession) { + throw new Error("세션을 찾을 수 없습니다") + } + + // 질문 로그 업데이트 + const questions = (tbeSession.vendorQuestionsLog || []) as VendorQuestion[] + const updatedQuestions = questions.map(q => { + if (q.id === questionId) { + return { + ...q, + answer, + answeredAt: new Date().toISOString(), + answeredBy: userId, + status: "answered" as const + } + } + return q + }) + + // 업데이트 + const [updated] = await db + .update(rfqLastTbeSessions) + .set({ + vendorQuestionsLog: updatedQuestions, + updatedAt: new Date(), + updatedBy: userId + }) + .where(eq(rfqLastTbeSessions.id, sessionId)) + .returning() + + // 캐시 무효화 + revalidateTag(`tbe-session-${sessionId}`) + + return updated +} + +// ========================================== +// 4. 벤더 질문 목록 조회 +// ========================================== +export async function getVendorQuestions( + sessionId: number, + vendorId: number +): Promise<VendorQuestion[]> { + // 권한 체크 + const [tbeSession] = await db + .select() + .from(rfqLastTbeSessions) + .where( + and( + eq(rfqLastTbeSessions.id, sessionId), + eq(rfqLastTbeSessions.vendorId, vendorId) + ) + ) + .limit(1) + + if (!tbeSession) { + return [] + } + + return (tbeSession.vendorQuestionsLog || []) as VendorQuestion[] +} + +// ========================================== +// 5. 벤더 의견 업데이트 (간단한 텍스트) +// ========================================== +export async function updateVendorRemarks( + sessionId: number, + vendorId: number, + remarks: string +) { + const session = await getServerSession(authOptions) + if (!session?.user) { + throw new Error("인증이 필요합니다") + } + + const userId = typeof session.user.id === 'string' ? parseInt(session.user.id) : session.user.id + + // 권한 체크 + const [tbeSession] = await db + .select() + .from(rfqLastTbeSessions) + .where( + and( + eq(rfqLastTbeSessions.id, sessionId), + eq(rfqLastTbeSessions.vendorId, vendorId) + ) + ) + .limit(1) + + if (!tbeSession) { + throw new Error("권한이 없습니다") + } + + // 업데이트 + const [updated] = await db + .update(rfqLastTbeSessions) + .set({ + vendorRemarks: remarks, + updatedAt: new Date(), + updatedBy: userId + }) + .where(eq(rfqLastTbeSessions.id, sessionId)) + .returning() + + // 캐시 무효화 + revalidateTag(`vendor-tbe-sessions-${vendorId}`) + revalidateTag(`tbe-session-${sessionId}`) + + return updated +} + +// ========================================== +// 6. 통계 조회 +// ========================================== +export async function getVendorQuestionStats(sessionId: number) { + const [tbeSession] = await db + .select() + .from(rfqLastTbeSessions) + .where(eq(rfqLastTbeSessions.id, sessionId)) + .limit(1) + + if (!tbeSession) { + return { + total: 0, + open: 0, + answered: 0, + closed: 0 + } + } + + const questions = (tbeSession.vendorQuestionsLog || []) as VendorQuestion[] + + return { + total: questions.length, + open: questions.filter(q => q.status === "open").length, + answered: questions.filter(q => q.status === "answered").length, + closed: questions.filter(q => q.status === "closed").length, + highPriority: questions.filter(q => q.priority === "high").length + } +} + + +// ========================================== +// 6. PR 아이템 조회 (벤더용) +// ========================================== +export async function getVendorPrItems( + rfqId: number + ) { + + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("로그인이 필요합니다."); + } + + const vendorId = session.user.companyId + + // RFQ가 해당 벤더의 것인지 체크 + const [tbeSession] = await db + .select() + .from(tbeLastView) + .where( + and( + eq(tbeLastView.rfqId, rfqId), + eq(tbeLastView.vendorId, vendorId) + ) + ) + .limit(1) + + if (!tbeSession) { + return [] + } + + // PR 아이템 조회 + const prItems = await db + .select({ + id: rfqPrItems.id, + prNo: rfqPrItems.prNo, + prItem: rfqPrItems.prItem, + materialCode: rfqPrItems.materialCode, + materialCategory: rfqPrItems.materialCategory, + materialDescription: rfqPrItems.materialDescription, + size: rfqPrItems.size, + quantity: rfqPrItems.quantity, + uom: rfqPrItems.uom, + deliveryDate: rfqPrItems.deliveryDate, + majorYn: rfqPrItems.majorYn, + remarks: rfqPrItems.remark, + }) + .from(rfqPrItems) + .where(eq(rfqPrItems.rfqsLastId, rfqId)) + .orderBy(desc(rfqPrItems.majorYn), asc(rfqPrItems.prItem)) + + return prItems + }
\ No newline at end of file diff --git a/lib/tbe-last/vendor/tbe-table-columns.tsx b/lib/tbe-last/vendor/tbe-table-columns.tsx new file mode 100644 index 00000000..6e40fe27 --- /dev/null +++ b/lib/tbe-last/vendor/tbe-table-columns.tsx @@ -0,0 +1,335 @@ +// lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx + +"use client" + +import * as React from "react" +import { type ColumnDef } from "@tanstack/react-table" +import { FileText, ListChecks, Eye } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { formatDate } from "@/lib/utils" +import { TbeLastView } from "@/db/schema" + +interface GetColumnsProps { + onOpenEvaluationView: (session: TbeLastView) => void; + onOpenDocuments: (sessionId: number) => void; + onOpenPrItems: (rfqId: number) => void; +} + +export function getColumns({ + onOpenEvaluationView, + onOpenDocuments, + onOpenPrItems, +}: GetColumnsProps): ColumnDef<TbeLastView>[] { + + const columns: ColumnDef<TbeLastView>[] = [ + // Select Column + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + + // TBE Session Code + { + accessorKey: "sessionCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="TBE Code" /> + ), + cell: ({ row }) => { + const sessionCode = row.original.sessionCode; + return ( + <span className="font-medium">{sessionCode}</span> + ); + }, + size: 120, + }, + + // RFQ Code + { + accessorKey: "rfqCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ Code" /> + ), + cell: ({ row }) => row.original.rfqCode, + size: 120, + }, + + // RFQ Title + { + 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; + if (!date) return "-"; + + const daysUntilDue = Math.floor((new Date(date).getTime() - Date.now()) / (1000 * 60 * 60 * 24)); + const isOverdue = daysUntilDue < 0; + const isUrgent = daysUntilDue <= 3 && daysUntilDue >= 0; + + return ( + <div className="flex flex-col"> + <span className={`text-sm ${isOverdue ? 'text-red-600' : isUrgent ? 'text-orange-600' : ''}`}> + {formatDate(date, "KR")} + </span> + {isOverdue && ( + <span className="text-xs text-red-600">Overdue</span> + )} + {isUrgent && ( + <span className="text-xs text-orange-600">{daysUntilDue}일 남음</span> + )} + </div> + ); + }, + size: 100, + }, + + // Package Info + { + accessorKey: "packageNo", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Package" /> + ), + 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 + { + accessorKey: "projectCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Project" /> + ), + cell: ({ row }) => { + const projectCode = row.original.projectCode; + const projectName = row.original.projectName; + + if (!projectCode) return "-"; + + return ( + <div className="flex flex-col"> + <span className="font-medium">{projectCode}</span> + {projectName && ( + <span className="text-xs text-muted-foreground">{projectName}</span> + )} + </div> + ); + }, + size: 150, + }, + + // 구매담당자 + { + accessorKey: "picName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="구매담당자" /> + ), + cell: ({ row }) => row.original.picName || "-", + size: 120, + }, + + // TBE Status + { + accessorKey: "sessionStatus", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Status" /> + ), + cell: ({ row }) => { + const status = row.original.sessionStatus; + + let variant: "default" | "secondary" | "outline" | "destructive" = "outline"; + + switch (status) { + case "준비중": + variant = "outline"; + break; + case "진행중": + variant = "default"; + break; + case "검토중": + variant = "secondary"; + break; + case "완료": + variant = "default"; + break; + case "보류": + variant = "destructive"; + break; + } + + return <Badge variant={variant}>{status}</Badge>; + }, + size: 100, + }, + + // Evaluation Result + { + accessorKey: "evaluationResult", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Evaluation" /> + ), + cell: ({ row }) => { + const result = row.original.evaluationResult; + const session = row.original; + + if (!result) { + return ( + <Badge variant="outline" className="text-muted-foreground"> + Pending + </Badge> + ); + } + + let variant: "default" | "secondary" | "destructive" = "default"; + let displayText = result; + + switch (result) { + case "Acceptable": + variant = "default"; + displayText = "Acceptable"; + break; + case "Acceptable with Comment": + variant = "secondary"; + displayText = "Conditional"; + break; + case "Not Acceptable": + variant = "destructive"; + displayText = "Not Acceptable"; + break; + } + + return ( + <div className="flex items-center gap-1"> + <Badge variant={variant}>{displayText}</Badge> + {result && ( + <Button + variant="ghost" + size="sm" + className="h-6 w-6 p-0" + onClick={() => onOpenEvaluationView(session)} + title="View evaluation details" + > + <Eye className="h-3 w-3" /> + </Button> + )} + </div> + ); + }, + size: 150, + }, + + // PR Items + { + id: "prItems", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="PR Items" /> + ), + cell: ({ row }) => { + const rfqId = row.original.rfqId; + const totalCount = row.original.prItemsCount; + const majorCount = row.original.majorItemsCount; + + return ( + <Button + variant="ghost" + size="sm" + className="h-8 px-2" + onClick={() => onOpenPrItems(rfqId)} + > + <ListChecks className="h-4 w-4 mr-1" /> + <span className="text-xs"> + {totalCount} ({majorCount}) + </span> + </Button> + ); + }, + size: 100, + enableSorting: false, + }, + + // Documents (클릭하면 Documents Sheet 열림) + { + id: "documents", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Documents" /> + ), + cell: ({ row }) => { + const sessionId = row.original.tbeSessionId; + const buyerDocs = Number(row.original.buyerDocumentsCount); + const vendorDocs = Number(row.original.vendorDocumentsCount); + const totalDocs = buyerDocs + vendorDocs; + const status = row.original.sessionStatus; + + // 진행중 상태면 강조 + const isActive = status === "진행중"; + + return ( + <Button + variant={isActive ? "default" : "ghost"} + size="sm" + className="h-8 px-2" + onClick={() => onOpenDocuments(sessionId)} + title={isActive ? "문서 관리 (업로드/코멘트 가능)" : "문서 조회"} + > + <FileText className="h-4 w-4 mr-1" /> + <span className="text-xs"> + {totalDocs} (B:{buyerDocs}/V:{vendorDocs}) + </span> + </Button> + ); + }, + size: 140, + enableSorting: false, + }, + ]; + + return columns; +}
\ No newline at end of file diff --git a/lib/tbe-last/vendor/tbe-table.tsx b/lib/tbe-last/vendor/tbe-table.tsx new file mode 100644 index 00000000..d7ee0a06 --- /dev/null +++ b/lib/tbe-last/vendor/tbe-table.tsx @@ -0,0 +1,222 @@ +// lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx + +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +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" +import { getColumns } from "./tbe-table-columns" +import { TbeLastView } from "@/db/schema" +import { getTBESessionDetail } from "@/lib/tbe-last/service" +import { Button } from "@/components/ui/button" +import { Download, RefreshCw, Upload } from "lucide-react" +import { exportTableToExcel } from "@/lib/export" + +// Import Vendor-specific Dialogs +import { VendorDocumentUploadDialog } from "./vendor-document-upload-dialog" +import { VendorQADialog } from "./vendor-comment-dialog" +import { VendorDocumentsSheet } from "./vendor-documents-sheet" +import { VendorPrItemsDialog } from "./vendor-pr-items-dialog" +import { getTBEforVendor } from "../vendor-tbe-service" + +interface TbeVendorTableProps { + promises: Promise<[ + Awaited<ReturnType<typeof getTBEforVendor>>, + ]> +} + +export function TbeVendorTable({ promises }: TbeVendorTableProps) { + const router = useRouter() + const [{ data, pageCount }] = React.use(promises) + + // Dialog states + const [documentUploadOpen, setDocumentUploadOpen] = React.useState(false) + const [qaDialogOpen, setQADialogOpen] = React.useState(false) + const [evaluationViewOpen, setEvaluationViewOpen] = React.useState(false) + const [documentsOpen, setDocumentsOpen] = React.useState(false) + const [prItemsOpen, setPrItemsOpen] = React.useState(false) + + const [selectedSessionId, setSelectedSessionId] = React.useState<number | null>(null) + const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null) + const [selectedSession, setSelectedSession] = React.useState<TbeLastView | null>(null) + const [sessionDetail, setSessionDetail] = React.useState<any>(null) + const [isLoadingDetail, setIsLoadingDetail] = React.useState(false) + + // Load session detail when needed + const loadSessionDetail = React.useCallback(async (sessionId: number) => { + setIsLoadingDetail(true) + try { + const detail = await getTBESessionDetail(sessionId) + setSessionDetail(detail) + } catch (error) { + console.error("Failed to load session detail:", error) + } finally { + setIsLoadingDetail(false) + } + }, []) + + // Handlers + const handleOpenDocumentUpload = React.useCallback((sessionId: number) => { + setSelectedSessionId(sessionId) + setDocumentUploadOpen(true) + loadSessionDetail(sessionId) + }, [loadSessionDetail]) + + const handleOpenComment = React.useCallback((sessionId: number) => { + setSelectedSessionId(sessionId) + setQADialogOpen(true) + loadSessionDetail(sessionId) + }, [loadSessionDetail]) + + const handleOpenEvaluationView = React.useCallback((session: TbeLastView) => { + setSelectedSession(session) + setEvaluationViewOpen(true) + loadSessionDetail(session.tbeSessionId) + }, [loadSessionDetail]) + + const handleOpenDocuments = React.useCallback((sessionId: number) => { + setSelectedSessionId(sessionId) + setDocumentsOpen(true) + loadSessionDetail(sessionId) + }, [loadSessionDetail]) + + const handleOpenPrItems = React.useCallback((rfqId: number) => { + setSelectedRfqId(rfqId) + setPrItemsOpen(true) + }, []) + + const handleRefresh = React.useCallback(() => { + router.refresh() + }, [router]) + + // Table columns + const columns = React.useMemo( + () => + getColumns({ + onOpenDocumentUpload: handleOpenDocumentUpload, + onOpenComment: handleOpenComment, + onOpenEvaluationView: handleOpenEvaluationView, + onOpenDocuments: handleOpenDocuments, + onOpenPrItems: handleOpenPrItems, + }), + [handleOpenDocumentUpload, handleOpenComment, handleOpenEvaluationView, handleOpenDocuments, handleOpenPrItems] + ) + + // Filter fields + const filterFields: DataTableAdvancedFilterField<TbeLastView>[] = [ + { + id: "sessionStatus", + label: "Status", + type: "select", + options: [ + { label: "준비중", value: "준비중" }, + { label: "진행중", value: "진행중" }, + { label: "검토중", value: "검토중" }, + { label: "보류", value: "보류" }, + { label: "완료", value: "완료" }, + ], + }, + { + id: "evaluationResult", + label: "Result", + type: "select", + options: [ + { label: "Pass", value: "pass" }, + { label: "Conditional Pass", value: "conditional_pass" }, + { label: "Non-Pass", value: "non_pass" }, + { label: "Pending", value: "pending" }, + ], + }, + ] + + // Data table + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.tbeSessionId), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={filterFields} + shallow={false} + > + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={handleRefresh} + className="gap-2" + > + <RefreshCw className="size-4" /> + <span>Refresh</span> + </Button> + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "vendor-tbe-sessions", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" /> + <span>Export</span> + </Button> + </div> + </DataTableAdvancedToolbar> + </DataTable> + + {/* Document Upload Dialog */} + <VendorDocumentUploadDialog + open={documentUploadOpen} + onOpenChange={setDocumentUploadOpen} + sessionId={selectedSessionId} + sessionDetail={sessionDetail} + onUploadSuccess={handleRefresh} + /> + + {/* Q&A Dialog */} + <VendorQADialog + open={qaDialogOpen} + onOpenChange={setQADialogOpen} + sessionId={selectedSessionId} + sessionDetail={sessionDetail} + onQuestionSubmit={handleRefresh} + /> + + {/* Documents Sheet */} + <VendorDocumentsSheet + open={documentsOpen} + onOpenChange={setDocumentsOpen} + sessionDetail={sessionDetail} + isLoading={isLoadingDetail} + /> + + {/* PR Items Dialog */} + <VendorPrItemsDialog + open={prItemsOpen} + onOpenChange={setPrItemsOpen} + rfqId={selectedRfqId} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/tbe-last/vendor/vendor-comment-dialog.tsx b/lib/tbe-last/vendor/vendor-comment-dialog.tsx new file mode 100644 index 00000000..8aa8d97c --- /dev/null +++ b/lib/tbe-last/vendor/vendor-comment-dialog.tsx @@ -0,0 +1,313 @@ +// lib/vendor-rfq-response/vendor-tbe-table/vendor-qa-dialog.tsx + +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { toast } from "sonner" +import { + MessageSquare, + Send, + Loader2, + Clock, + CheckCircle, + AlertCircle +} from "lucide-react" +import { formatDate } from "@/lib/utils" + +interface VendorQuestion { + id: string + category: string + question: string + askedAt: string + askedBy: number + askedByName?: string + answer?: string + answeredAt?: string + answeredBy?: number + answeredByName?: string + status: "open" | "answered" | "closed" + priority?: "high" | "normal" | "low" +} + +interface VendorQADialogProps { + open: boolean + onOpenChange: (open: boolean) => void + sessionId: number | null + sessionDetail: any + onQuestionSubmit: () => void +} + +export function VendorQADialog({ + open, + onOpenChange, + sessionId, + sessionDetail, + onQuestionSubmit +}: VendorQADialogProps) { + + const [category, setCategory] = React.useState("general") + const [priority, setPriority] = React.useState("normal") + const [question, setQuestion] = React.useState("") + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [questions, setQuestions] = React.useState<VendorQuestion[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + + // Load questions when dialog opens + React.useEffect(() => { + if (open && sessionId) { + loadQuestions() + } + }, [open, sessionId]) + + const loadQuestions = async () => { + if (!sessionId) return + + setIsLoading(true) + try { + const response = await fetch(`/api/tbe/sessions/${sessionId}/vendor-questions`) + if (response.ok) { + const data = await response.json() + setQuestions(data) + } + } catch (error) { + console.error("Failed to load questions:", error) + } finally { + setIsLoading(false) + } + } + + // Submit question + const handleSubmit = async () => { + if (!sessionId || !question.trim()) { + toast.error("질문을 입력해주세요") + return + } + + setIsSubmitting(true) + + try { + const response = await fetch(`/api/tbe/sessions/${sessionId}/vendor-questions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + category, + question, + priority + }) + }) + + if (!response.ok) throw new Error("Failed to submit question") + + toast.success("질문이 제출되었습니다") + + // Reset form + setCategory("general") + setPriority("normal") + setQuestion("") + + // Reload questions + await loadQuestions() + + // Callback + onQuestionSubmit() + + } catch (error) { + console.error("Question submission error:", error) + toast.error("질문 제출 중 오류가 발생했습니다") + } finally { + setIsSubmitting(false) + } + } + + // Get status icon + const getStatusIcon = (status: string) => { + switch (status) { + case "answered": + return <CheckCircle className="h-4 w-4 text-green-600" /> + case "closed": + return <CheckCircle className="h-4 w-4 text-gray-600" /> + default: + return <Clock className="h-4 w-4 text-orange-600" /> + } + } + + // Get priority color + const getPriorityColor = (priority?: string) => { + switch (priority) { + case "high": + return "destructive" + case "low": + return "secondary" + default: + return "default" + } + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-3xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>Q&A with Buyer</DialogTitle> + <DialogDescription> + {sessionDetail?.session?.sessionCode} - 구매자에게 질문하기 + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* Previous Questions */} + {questions.length > 0 && ( + <div> + <Label className="text-sm font-medium mb-2">Previous Q&A</Label> + <ScrollArea className="h-[200px] border rounded-lg p-3"> + <div className="space-y-3"> + {questions.map(q => ( + <div key={q.id} className="border-b pb-3 last:border-0"> + <div className="flex items-start justify-between mb-2"> + <div className="flex items-center gap-2"> + {getStatusIcon(q.status)} + <Badge variant="outline" className="text-xs"> + {q.category} + </Badge> + {q.priority && q.priority !== "normal" && ( + <Badge variant={getPriorityColor(q.priority)} className="text-xs"> + {q.priority} + </Badge> + )} + </div> + <span className="text-xs text-muted-foreground"> + {formatDate(q.askedAt, "KR")} + </span> + </div> + + <div className="space-y-2"> + <div className="text-sm"> + <strong>Q:</strong> {q.question} + </div> + + {q.answer && ( + <div className="text-sm text-muted-foreground ml-4"> + <strong>A:</strong> {q.answer} + <span className="text-xs ml-2"> + ({formatDate(q.answeredAt!, "KR")}) + </span> + </div> + )} + </div> + </div> + ))} + </div> + </ScrollArea> + </div> + )} + + {/* New Question Form */} + <div className="space-y-3"> + <div className="grid grid-cols-2 gap-3"> + <div> + <Label htmlFor="category">Category</Label> + <Select value={category} onValueChange={setCategory}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="general">일반 문의</SelectItem> + <SelectItem value="technical">기술 관련</SelectItem> + <SelectItem value="commercial">상업 조건</SelectItem> + <SelectItem value="delivery">납기 관련</SelectItem> + <SelectItem value="quality">품질 관련</SelectItem> + <SelectItem value="document">문서 관련</SelectItem> + <SelectItem value="clarification">명확화 요청</SelectItem> + </SelectContent> + </Select> + </div> + + <div> + <Label htmlFor="priority">Priority</Label> + <Select value={priority} onValueChange={setPriority}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="high">High</SelectItem> + <SelectItem value="normal">Normal</SelectItem> + <SelectItem value="low">Low</SelectItem> + </SelectContent> + </Select> + </div> + </div> + + <div> + <Label htmlFor="question">Your Question</Label> + <Textarea + id="question" + value={question} + onChange={(e) => setQuestion(e.target.value)} + placeholder="구매자에게 질문할 내용을 입력하세요..." + className="min-h-[100px]" + disabled={isSubmitting} + /> + </div> + + <div className="flex justify-end gap-2"> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + onClick={handleSubmit} + disabled={!question.trim() || isSubmitting} + > + {isSubmitting ? ( + <> + <Loader2 className="h-4 w-4 mr-2 animate-spin" /> + 제출 중... + </> + ) : ( + <> + <Send className="h-4 w-4 mr-2" /> + 질문 제출 + </> + )} + </Button> + </div> + </div> + + {/* Info Box */} + <div className="p-3 bg-muted/50 rounded-lg"> + <div className="flex items-start gap-2"> + <AlertCircle className="h-4 w-4 text-muted-foreground mt-0.5" /> + <p className="text-xs text-muted-foreground"> + 제출된 질문은 구매담당자가 확인 후 답변을 제공합니다. + 긴급한 질문은 Priority를 High로 설정해주세요. + </p> + </div> + </div> + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/tbe-last/vendor/vendor-document-upload-dialog.tsx b/lib/tbe-last/vendor/vendor-document-upload-dialog.tsx new file mode 100644 index 00000000..c6f6c3d5 --- /dev/null +++ b/lib/tbe-last/vendor/vendor-document-upload-dialog.tsx @@ -0,0 +1,326 @@ +// lib/vendor-rfq-response/vendor-tbe-table/vendor-document-upload-dialog.tsx + +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { toast } from "sonner" +import { Upload, FileText, X, Loader2 } from "lucide-react" +import { uploadVendorDocument } from "@/lib/tbe-last/service" + +interface VendorDocumentUploadDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + sessionId: number | null + sessionDetail: any + onUploadSuccess: () => void +} + +interface FileUpload { + id: string + file: File + documentType: string + description: string + status: "pending" | "uploading" | "success" | "error" + errorMessage?: string +} + +export function VendorDocumentUploadDialog({ + open, + onOpenChange, + sessionId, + sessionDetail, + onUploadSuccess +}: VendorDocumentUploadDialogProps) { + + const [files, setFiles] = React.useState<FileUpload[]>([]) + const [isUploading, setIsUploading] = React.useState(false) + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // Document types for vendor + const documentTypes = [ + { value: "technical_spec", label: "Technical Specification" }, + { value: "compliance_cert", label: "Compliance Certificate" }, + { value: "test_report", label: "Test Report" }, + { value: "drawing", label: "Drawing" }, + { value: "datasheet", label: "Datasheet" }, + { value: "quality_doc", label: "Quality Document" }, + { value: "warranty", label: "Warranty Document" }, + { value: "other", label: "Other" }, + ] + + // Handle file selection + const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { + const selectedFiles = Array.from(e.target.files || []) + + const newFiles: FileUpload[] = selectedFiles.map(file => ({ + id: Math.random().toString(36).substr(2, 9), + file, + documentType: "technical_spec", + description: "", + status: "pending" as const + })) + + setFiles(prev => [...prev, ...newFiles]) + + // Reset input + if (fileInputRef.current) { + fileInputRef.current.value = "" + } + } + + // Remove file + const handleRemoveFile = (id: string) => { + setFiles(prev => prev.filter(f => f.id !== id)) + } + + // Update file details + const handleUpdateFile = (id: string, field: keyof FileUpload, value: string) => { + setFiles(prev => prev.map(f => + f.id === id ? { ...f, [field]: value } : f + )) + } + + // Upload all files + const handleUploadAll = async () => { + if (!sessionId || files.length === 0) return + + setIsUploading(true) + + try { + for (const fileUpload of files) { + if (fileUpload.status === "success") continue + + // Update status to uploading + setFiles(prev => prev.map(f => + f.id === fileUpload.id ? { ...f, status: "uploading" } : f + )) + + try { + // Create FormData for upload + const formData = new FormData() + formData.append("file", fileUpload.file) + formData.append("sessionId", sessionId.toString()) + formData.append("documentType", fileUpload.documentType) + formData.append("description", fileUpload.description) + + // Upload file (API call) + const response = await fetch("/api/tbe/vendor-documents/upload", { + method: "POST", + body: formData + }) + + if (!response.ok) throw new Error("Upload failed") + + // Update status to success + setFiles(prev => prev.map(f => + f.id === fileUpload.id ? { ...f, status: "success" } : f + )) + + } catch (error) { + // Update status to error + setFiles(prev => prev.map(f => + f.id === fileUpload.id ? { + ...f, + status: "error", + errorMessage: error instanceof Error ? error.message : "Upload failed" + } : f + )) + } + } + + // Check if all files uploaded successfully + const allSuccess = files.every(f => f.status === "success") + + if (allSuccess) { + toast.success("모든 문서가 업로드되었습니다") + onUploadSuccess() + onOpenChange(false) + setFiles([]) + } else { + const failedCount = files.filter(f => f.status === "error").length + toast.error(`${failedCount}개 문서 업로드 실패`) + } + + } catch (error) { + console.error("Upload error:", error) + toast.error("문서 업로드 중 오류가 발생했습니다") + } finally { + setIsUploading(false) + } + } + + // Get file size in readable format + const formatFileSize = (bytes: number) => { + if (bytes < 1024) return bytes + " B" + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB" + return (bytes / (1024 * 1024)).toFixed(1) + " MB" + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>Upload Documents for TBE</DialogTitle> + <DialogDescription> + {sessionDetail?.session?.sessionCode} - Technical Bid Evaluation 문서 업로드 + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* File Upload Area */} + <div className="border-2 border-dashed rounded-lg p-6 text-center"> + <Upload className="h-12 w-12 mx-auto text-muted-foreground mb-2" /> + <p className="text-sm text-muted-foreground mb-2"> + 파일을 드래그하거나 클릭하여 선택하세요 + </p> + <Input + ref={fileInputRef} + type="file" + multiple + onChange={handleFileSelect} + className="hidden" + accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.zip" + /> + <Button + variant="outline" + onClick={() => fileInputRef.current?.click()} + disabled={isUploading} + > + 파일 선택 + </Button> + </div> + + {/* Selected Files List */} + {files.length > 0 && ( + <ScrollArea className="h-[300px] border rounded-lg p-4"> + <div className="space-y-4"> + {files.map(fileUpload => ( + <div key={fileUpload.id} className="border rounded-lg p-4 space-y-3"> + <div className="flex items-start justify-between"> + <div className="flex items-center gap-2"> + <FileText className="h-5 w-5 text-muted-foreground" /> + <div> + <p className="font-medium text-sm">{fileUpload.file.name}</p> + <p className="text-xs text-muted-foreground"> + {formatFileSize(fileUpload.file.size)} + </p> + </div> + </div> + <div className="flex items-center gap-2"> + {fileUpload.status === "uploading" && ( + <Loader2 className="h-4 w-4 animate-spin" /> + )} + {fileUpload.status === "success" && ( + <Badge variant="default">Uploaded</Badge> + )} + {fileUpload.status === "error" && ( + <Badge variant="destructive">Failed</Badge> + )} + <Button + variant="ghost" + size="sm" + onClick={() => handleRemoveFile(fileUpload.id)} + disabled={isUploading} + > + <X className="h-4 w-4" /> + </Button> + </div> + </div> + + {fileUpload.status !== "success" && ( + <> + <div className="grid grid-cols-2 gap-3"> + <div> + <Label className="text-xs">Document Type</Label> + <Select + value={fileUpload.documentType} + onValueChange={(value) => handleUpdateFile(fileUpload.id, "documentType", value)} + disabled={isUploading} + > + <SelectTrigger className="h-8 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {documentTypes.map(type => ( + <SelectItem key={type.value} value={type.value}> + {type.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + + <div> + <Label className="text-xs">Description (Optional)</Label> + <Textarea + value={fileUpload.description} + onChange={(e) => handleUpdateFile(fileUpload.id, "description", e.target.value)} + placeholder="문서에 대한 설명을 입력하세요..." + className="min-h-[60px] text-xs" + disabled={isUploading} + /> + </div> + </> + )} + + {fileUpload.errorMessage && ( + <p className="text-xs text-red-600">{fileUpload.errorMessage}</p> + )} + </div> + ))} + </div> + </ScrollArea> + )} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isUploading} + > + 취소 + </Button> + <Button + onClick={handleUploadAll} + disabled={files.length === 0 || isUploading} + > + {isUploading ? ( + <> + <Loader2 className="h-4 w-4 mr-2 animate-spin" /> + 업로드 중... + </> + ) : ( + <> + <Upload className="h-4 w-4 mr-2" /> + 업로드 ({files.filter(f => f.status !== "success").length}개) + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file 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> + ) +} diff --git a/lib/tbe-last/vendor/vendor-evaluation-view-dialog.tsx b/lib/tbe-last/vendor/vendor-evaluation-view-dialog.tsx new file mode 100644 index 00000000..d20646b6 --- /dev/null +++ b/lib/tbe-last/vendor/vendor-evaluation-view-dialog.tsx @@ -0,0 +1,250 @@ +// lib/vendor-rfq-response/vendor-tbe-table/vendor-evaluation-view-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 { ScrollArea } from "@/components/ui/scroll-area" +import { + CheckCircle, + XCircle, + AlertCircle, + FileText, + Package, + DollarSign, + MessageSquare +} from "lucide-react" +import { formatDate } from "@/lib/utils" + +interface VendorEvaluationViewDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + selectedSession: any + sessionDetail: any +} + +export function VendorEvaluationViewDialog({ + open, + onOpenChange, + selectedSession, + sessionDetail +}: VendorEvaluationViewDialogProps) { + + // Get evaluation icon + const getEvaluationIcon = (result: string | null) => { + switch (result) { + case "pass": + return <CheckCircle className="h-5 w-5 text-green-600" /> + case "conditional_pass": + return <AlertCircle className="h-5 w-5 text-yellow-600" /> + case "non_pass": + return <XCircle className="h-5 w-5 text-red-600" /> + default: + return null + } + } + + // Get result display text + const getResultDisplay = (result: string | null) => { + switch (result) { + case "pass": + return { text: "Pass", variant: "default" as const } + case "conditional_pass": + return { text: "Conditional Pass", variant: "secondary" as const } + case "non_pass": + return { text: "Non-Pass", variant: "destructive" as const } + default: + return { text: "Pending", variant: "outline" as const } + } + } + + const resultDisplay = getResultDisplay(selectedSession?.evaluationResult) + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>TBE Evaluation Result</DialogTitle> + <DialogDescription> + {selectedSession?.sessionCode} - Technical Bid Evaluation 결과 + </DialogDescription> + </DialogHeader> + + <ScrollArea className="h-[500px] pr-4"> + <div className="space-y-6"> + {/* Overall Result */} + <div className="border rounded-lg p-4"> + <div className="flex items-center justify-between mb-3"> + <h3 className="font-medium">Overall Evaluation Result</h3> + <div className="flex items-center gap-2"> + {getEvaluationIcon(selectedSession?.evaluationResult)} + <Badge variant={resultDisplay.variant} className="text-sm"> + {resultDisplay.text} + </Badge> + </div> + </div> + + {selectedSession?.evaluationResult === "conditional_pass" && ( + <div className="mt-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg"> + <p className="text-sm font-medium text-yellow-800 dark:text-yellow-200 mb-2"> + Conditions to be fulfilled: + </p> + <p className="text-sm text-yellow-700 dark:text-yellow-300"> + {sessionDetail?.session?.conditionalRequirements || "조건부 요구사항이 명시되지 않았습니다."} + </p> + {sessionDetail?.session?.conditionsFulfilled !== undefined && ( + <div className="mt-2"> + <Badge variant={sessionDetail.session.conditionsFulfilled ? "default" : "outline"}> + {sessionDetail.session.conditionsFulfilled ? "Conditions Fulfilled" : "Pending Fulfillment"} + </Badge> + </div> + )} + </div> + )} + + {selectedSession?.evaluationResult === "non_pass" && ( + <div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg"> + <p className="text-sm text-red-700 dark:text-red-300"> + 기술 평가 기준을 충족하지 못했습니다. 자세한 내용은 아래 평가 요약을 참고해주세요. + </p> + </div> + )} + </div> + + {/* Technical Summary */} + {sessionDetail?.session?.technicalSummary && ( + <div className="border rounded-lg p-4"> + <div className="flex items-center gap-2 mb-3"> + <Package className="h-5 w-5 text-muted-foreground" /> + <h3 className="font-medium">Technical Evaluation Summary</h3> + </div> + <p className="text-sm text-muted-foreground whitespace-pre-wrap"> + {sessionDetail.session.technicalSummary} + </p> + </div> + )} + + {/* Commercial Summary */} + {sessionDetail?.session?.commercialSummary && ( + <div className="border rounded-lg p-4"> + <div className="flex items-center gap-2 mb-3"> + <DollarSign className="h-5 w-5 text-muted-foreground" /> + <h3 className="font-medium">Commercial Evaluation Summary</h3> + </div> + <p className="text-sm text-muted-foreground whitespace-pre-wrap"> + {sessionDetail.session.commercialSummary} + </p> + </div> + )} + + {/* Overall Remarks */} + {sessionDetail?.session?.overallRemarks && ( + <div className="border rounded-lg p-4"> + <div className="flex items-center gap-2 mb-3"> + <MessageSquare className="h-5 w-5 text-muted-foreground" /> + <h3 className="font-medium">Overall Remarks</h3> + </div> + <p className="text-sm text-muted-foreground whitespace-pre-wrap"> + {sessionDetail.session.overallRemarks} + </p> + </div> + )} + + {/* Approval Information */} + {sessionDetail?.session?.approvedAt && ( + <div className="border rounded-lg p-4"> + <div className="flex items-center gap-2 mb-3"> + <FileText className="h-5 w-5 text-muted-foreground" /> + <h3 className="font-medium">Approval Information</h3> + </div> + <div className="grid grid-cols-2 gap-4 text-sm"> + <div> + <p className="text-muted-foreground">Approved By</p> + <p className="font-medium">{sessionDetail.session.approvedBy || "-"}</p> + </div> + <div> + <p className="text-muted-foreground">Approved Date</p> + <p className="font-medium"> + {formatDate(sessionDetail.session.approvedAt, "KR")} + </p> + </div> + {sessionDetail.session.approvalRemarks && ( + <div className="col-span-2"> + <p className="text-muted-foreground mb-1">Approval Remarks</p> + <p className="font-medium">{sessionDetail.session.approvalRemarks}</p> + </div> + )} + </div> + </div> + )} + + {/* Session Information */} + <div className="border rounded-lg p-4"> + <h3 className="font-medium mb-3">Session Information</h3> + <div className="grid grid-cols-2 gap-4 text-sm"> + <div> + <p className="text-muted-foreground">Status</p> + <Badge>{selectedSession?.sessionStatus}</Badge> + </div> + <div> + <p className="text-muted-foreground">Created Date</p> + <p className="font-medium"> + {selectedSession?.createdAt ? formatDate(selectedSession.createdAt, "KR") : "-"} + </p> + </div> + {sessionDetail?.session?.actualStartDate && ( + <div> + <p className="text-muted-foreground">Start Date</p> + <p className="font-medium"> + {formatDate(sessionDetail.session.actualStartDate, "KR")} + </p> + </div> + )} + {sessionDetail?.session?.actualEndDate && ( + <div> + <p className="text-muted-foreground">End Date</p> + <p className="font-medium"> + {formatDate(sessionDetail.session.actualEndDate, "KR")} + </p> + </div> + )} + </div> + </div> + + {/* Next Steps (for vendor) */} + {selectedSession?.evaluationResult && ( + <div className="border rounded-lg p-4 bg-muted/50"> + <h3 className="font-medium mb-3">Next Steps</h3> + {selectedSession.evaluationResult === "pass" && ( + <p className="text-sm text-muted-foreground"> + 기술 평가를 통과하셨습니다. 상업 협상 단계로 진행될 예정입니다. + 구매담당자가 추가 안내를 제공할 것입니다. + </p> + )} + {selectedSession.evaluationResult === "conditional_pass" && ( + <p className="text-sm text-muted-foreground"> + 조건부 통과되었습니다. 명시된 조건을 충족하신 후 최종 승인을 받으실 수 있습니다. + 조건 충족을 위한 추가 문서나 설명을 제출해주세요. + </p> + )} + {selectedSession.evaluationResult === "non_pass" && ( + <p className="text-sm text-muted-foreground"> + 안타깝게도 이번 기술 평가를 통과하지 못하셨습니다. + 평가 요약 내용을 참고하시어 향후 입찰에 반영해주시기 바랍니다. + </p> + )} + </div> + )} + </div> + </ScrollArea> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/tbe-last/vendor/vendor-pr-items-dialog.tsx b/lib/tbe-last/vendor/vendor-pr-items-dialog.tsx new file mode 100644 index 00000000..e4b03e6d --- /dev/null +++ b/lib/tbe-last/vendor/vendor-pr-items-dialog.tsx @@ -0,0 +1,253 @@ +// lib/vendor-rfq-response/vendor-tbe-table/vendor-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 { Button } from "@/components/ui/button" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { formatDate } from "@/lib/utils" +import { Download, Package, AlertCircle } from "lucide-react" +import { toast } from "sonner" +import { exportDataToExcel } from "@/lib/export-to-excel" +import { getVendorPrItems } from "../vendor-tbe-service" + +interface VendorPrItemsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + rfqId: number | null +} + +interface PrItem { + id: number + prNo: string + prItem: string + materialCode: string + materialDescription: string + size?: string + quantity: number + uom: string + deliveryDate?: string + majorYn: boolean + specifications?: string + remarks?: string +} + +export function VendorPrItemsDialog({ + open, + onOpenChange, + rfqId +}: VendorPrItemsDialogProps) { + + const [prItems, setPrItems] = React.useState<PrItem[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + + // Load PR items when dialog opens + React.useEffect(() => { + if (open && rfqId) { + loadPrItems() + } + }, [open, rfqId]) + + const loadPrItems = async () => { + if (!rfqId) return + + setIsLoading(true) + try { + const data = await getVendorPrItems(rfqId) + + setPrItems(data) + + } catch (error) { + console.error("Failed to load PR items:", error) + toast.error("Error loading PR items") + } finally { + setIsLoading(false) + } + } + + // Export to Excel + const handleExport = async () => { + if (prItems.length === 0) { + toast.error("No items to export") + return + } + + try { + // Prepare data for export + const exportData = prItems.map(item => ({ + "PR No": item.prNo || "-", + "PR Item": item.prItem || "-", + "Material Code": item.materialCode || "-", + "Description": item.materialDescription || "-", + "Size": item.size || "-", + "Quantity": item.quantity, + "Unit": item.uom || "-", + "Delivery Date": item.deliveryDate ? formatDate(item.deliveryDate, "KR") : "-", + "Major Item": item.majorYn ? "Yes" : "No", + "Specifications": item.specifications || "-", + "Remarks": item.remarks || "-" + })) + + // Export using new utility + await exportDataToExcel(exportData, { + filename: `pr-items-${rfqId}`, + sheetName: "PR Items", + autoFilter: true, + freezeHeader: true + }) + + toast.success("Excel file exported successfully") + } catch (error) { + console.error("Export error:", error) + toast.error("Failed to export Excel file") + } + } + + // Statistics + const statistics = React.useMemo(() => { + const totalItems = prItems.length + const majorItems = prItems.filter(item => item.majorYn).length + const minorItems = totalItems - majorItems + + return { totalItems, majorItems, minorItems } + }, [prItems]) + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-6xl max-h-[80vh]"> + <DialogHeader> + <div className="flex items-center justify-between"> + <div> + <DialogTitle>Purchase Request Items</DialogTitle> + <DialogDescription> + RFQ에 포함된 구매 요청 아이템 목록 + </DialogDescription> + </div> + <Button + variant="outline" + size="sm" + onClick={handleExport} + disabled={prItems.length === 0} + > + <Download className="h-4 w-4 mr-2" /> + Export + </Button> + </div> + </DialogHeader> + + {/* Statistics */} + <div className="flex items-center gap-4 py-2"> + <Badge variant="outline" className="flex items-center gap-1"> + <Package className="h-3 w-3" /> + Total: {statistics.totalItems} + </Badge> + <Badge variant="default" className="flex items-center gap-1"> + <AlertCircle className="h-3 w-3" /> + Major: {statistics.majorItems} + </Badge> + <Badge variant="secondary"> + Minor: {statistics.minorItems} + </Badge> + </div> + + {/* PR Items Table */} + {isLoading ? ( + <div className="p-8 text-center">Loading PR items...</div> + ) : prItems.length === 0 ? ( + <div className="p-8 text-center text-muted-foreground"> + No PR items available + </div> + ) : ( + <ScrollArea className="h-[400px]"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[100px]">PR No</TableHead> + <TableHead className="w-[80px]">Item</TableHead> + <TableHead className="w-[120px]">Material Code</TableHead> + <TableHead>Description</TableHead> + <TableHead className="w-[80px]">Size</TableHead> + <TableHead className="w-[80px] text-right">Qty</TableHead> + <TableHead className="w-[60px]">Unit</TableHead> + <TableHead className="w-[100px]">Delivery</TableHead> + <TableHead className="w-[80px] text-center">Major</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {prItems.map((item) => ( + <TableRow key={item.id}> + <TableCell className="font-medium">{item.prNo || "-"}</TableCell> + <TableCell>{item.prItem || "-"}</TableCell> + <TableCell> + <span className="font-mono text-xs">{item.materialCode || "-"}</span> + </TableCell> + <TableCell> + <div> + <p className="text-sm">{item.materialDescription || "-"}</p> + {item.remarks && ( + <p className="text-xs text-muted-foreground mt-1"> + {item.remarks} + </p> + )} + </div> + </TableCell> + <TableCell>{item.size || "-"}</TableCell> + <TableCell className="text-right font-medium"> + {item.quantity.toLocaleString()} + </TableCell> + <TableCell>{item.uom || "-"}</TableCell> + <TableCell> + {item.deliveryDate ? ( + <span className="text-sm"> + {formatDate(item.deliveryDate, "KR")} + </span> + ) : ( + <span className="text-muted-foreground">-</span> + )} + </TableCell> + <TableCell className="text-center"> + {item.majorYn ? ( + <Badge variant="default" className="text-xs"> + Major + </Badge> + ) : ( + <Badge variant="outline" className="text-xs"> + Minor + </Badge> + )} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </ScrollArea> + )} + + {/* Footer Note */} + <div className="mt-4 p-3 bg-muted/50 rounded-lg"> + <p className="text-xs text-muted-foreground"> + <strong>Note:</strong> Major items은 기술 평가의 주요 대상이며, + 모든 기술 요구사항을 충족해야 합니다. + 각 아이템의 세부 사양은 RFQ 문서를 참조해주세요. + </p> + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file |
