diff options
Diffstat (limited to 'lib/tbe-last/vendor')
| -rw-r--r-- | lib/tbe-last/vendor/tbe-table-columns.tsx | 335 | ||||
| -rw-r--r-- | lib/tbe-last/vendor/tbe-table.tsx | 222 | ||||
| -rw-r--r-- | lib/tbe-last/vendor/vendor-comment-dialog.tsx | 313 | ||||
| -rw-r--r-- | lib/tbe-last/vendor/vendor-document-upload-dialog.tsx | 326 | ||||
| -rw-r--r-- | lib/tbe-last/vendor/vendor-documents-sheet.tsx | 602 | ||||
| -rw-r--r-- | lib/tbe-last/vendor/vendor-evaluation-view-dialog.tsx | 250 | ||||
| -rw-r--r-- | lib/tbe-last/vendor/vendor-pr-items-dialog.tsx | 253 |
7 files changed, 2301 insertions, 0 deletions
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 |
