summaryrefslogtreecommitdiff
path: root/lib/tbe-last
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-15 14:41:01 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-15 14:41:01 +0000
commit4ee8b24cfadf47452807fa2af801385ed60ab47c (patch)
treee1d1fb029f0cf5519c517494bf9a545505c35700 /lib/tbe-last
parent265859d691a01cdcaaf9154f93c38765bc34df06 (diff)
(대표님) 작업사항 - rfqLast, tbeLast, pdfTron, userAuth
Diffstat (limited to 'lib/tbe-last')
-rw-r--r--lib/tbe-last/service.ts231
-rw-r--r--lib/tbe-last/table/documents-sheet.tsx543
-rw-r--r--lib/tbe-last/table/evaluation-dialog.tsx432
-rw-r--r--lib/tbe-last/table/pr-items-dialog.tsx83
-rw-r--r--lib/tbe-last/table/session-detail-dialog.tsx103
-rw-r--r--lib/tbe-last/table/tbe-last-table-columns.tsx214
-rw-r--r--lib/tbe-last/table/tbe-last-table.tsx279
-rw-r--r--lib/tbe-last/vendor-tbe-service.ts355
-rw-r--r--lib/tbe-last/vendor/tbe-table-columns.tsx335
-rw-r--r--lib/tbe-last/vendor/tbe-table.tsx222
-rw-r--r--lib/tbe-last/vendor/vendor-comment-dialog.tsx313
-rw-r--r--lib/tbe-last/vendor/vendor-document-upload-dialog.tsx326
-rw-r--r--lib/tbe-last/vendor/vendor-documents-sheet.tsx602
-rw-r--r--lib/tbe-last/vendor/vendor-evaluation-view-dialog.tsx250
-rw-r--r--lib/tbe-last/vendor/vendor-pr-items-dialog.tsx253
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