Spaces:
Running
Running
| // frontend/src/pages/History.jsx | |
| import React, { useState, useEffect } from "react"; | |
| import { motion, AnimatePresence } from "framer-motion"; | |
| import { | |
| FileText, | |
| Clock, | |
| CheckCircle2, | |
| ChevronRight, | |
| Download, | |
| Eye, | |
| Trash2, | |
| Search, | |
| Filter, | |
| Calendar, | |
| Upload, | |
| Cpu, | |
| TableProperties, | |
| MonitorPlay, | |
| TrendingUp, | |
| TrendingDown, | |
| Minus, | |
| AlertCircle, | |
| X, | |
| FileSpreadsheet, | |
| Table2, | |
| } from "lucide-react"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { | |
| Select, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from "@/components/ui/select"; | |
| import { | |
| DropdownMenu, | |
| DropdownMenuContent, | |
| DropdownMenuItem, | |
| DropdownMenuSeparator, | |
| DropdownMenuTrigger, | |
| } from "@/components/ui/dropdown-menu"; | |
| import { cn } from "@/lib/utils"; | |
| import { getHistory } from "@/services/api"; | |
| // minimal "toast" | |
| const toastSuccess = (msg) => { | |
| console.log(msg); | |
| }; | |
| const stageConfig = { | |
| uploading: { label: "Uploading", icon: Upload, color: "blue" }, | |
| aiAnalysis: { label: "AI Analysis", icon: Cpu, color: "violet" }, | |
| dataExtraction: { label: "Data Extraction", icon: TableProperties, color: "emerald" }, | |
| outputRendering: { label: "Output Rendering", icon: MonitorPlay, color: "amber" }, | |
| }; | |
| const variationConfig = { | |
| fast: { icon: TrendingDown, color: "text-emerald-500", label: "Faster than avg" }, | |
| normal: { icon: Minus, color: "text-slate-400", label: "Normal" }, | |
| slow: { icon: TrendingUp, color: "text-amber-500", label: "Slower than avg" }, | |
| error: { icon: AlertCircle, color: "text-red-500", label: "Error" }, | |
| skipped: { icon: Minus, color: "text-slate-300", label: "Skipped" }, | |
| }; | |
| export default function History() { | |
| const [searchQuery, setSearchQuery] = useState(""); | |
| const [selectedStatus, setSelectedStatus] = useState("all"); | |
| const [expandedReport, setExpandedReport] = useState(null); | |
| const [isExporting, setIsExporting] = useState(false); | |
| const [history, setHistory] = useState([]); | |
| const [isLoading, setIsLoading] = useState(true); | |
| const [error, setError] = useState(null); | |
| // Fetch history on component mount | |
| useEffect(() => { | |
| const fetchHistory = async () => { | |
| setIsLoading(true); | |
| setError(null); | |
| try { | |
| const data = await getHistory(); | |
| setHistory(data); | |
| } catch (err) { | |
| console.error("Failed to fetch history:", err); | |
| setError(err.message || "Failed to load history"); | |
| setHistory([]); // Fallback to empty array | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| fetchHistory(); | |
| }, []); | |
| const filteredHistory = history.filter((item) => { | |
| const matchesSearch = item.fileName?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false; | |
| const matchesStatus = selectedStatus === "all" || item.status === selectedStatus; | |
| return matchesSearch && matchesStatus; | |
| }); | |
| const formatTime = (ms) => { | |
| if (ms >= 1000) { | |
| return `${(ms / 1000).toFixed(2)}s`; | |
| } | |
| return `${ms}ms`; | |
| }; | |
| const formatTimeForExport = (ms) => { | |
| return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`; | |
| }; | |
| const formatDate = (dateString) => { | |
| const date = new Date(dateString); | |
| return date.toLocaleDateString("en-US", { | |
| month: "short", | |
| day: "numeric", | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| }); | |
| }; | |
| const formatDateForExport = (dateString) => { | |
| const date = new Date(dateString); | |
| return date.toISOString().replace("T", " ").slice(0, 19); | |
| }; | |
| const generateCSV = (data) => { | |
| const headers = [ | |
| "File Name", | |
| "File Type", | |
| "File Size", | |
| "Extracted At", | |
| "Status", | |
| "Confidence (%)", | |
| "Fields Extracted", | |
| "Total Time (ms)", | |
| "Upload Time (ms)", | |
| "Upload Status", | |
| "Upload Variation", | |
| "AI Analysis Time (ms)", | |
| "AI Analysis Status", | |
| "AI Analysis Variation", | |
| "Data Extraction Time (ms)", | |
| "Data Extraction Status", | |
| "Data Extraction Variation", | |
| "Output Rendering Time (ms)", | |
| "Output Rendering Status", | |
| "Output Rendering Variation", | |
| "Error Message", | |
| ]; | |
| const rows = data.map((item) => [ | |
| item.fileName, | |
| item.fileType, | |
| item.fileSize, | |
| formatDateForExport(item.extractedAt), | |
| item.status, | |
| item.confidence, | |
| item.fieldsExtracted, | |
| item.totalTime, | |
| item.stages.uploading.time, | |
| item.stages.uploading.status, | |
| item.stages.uploading.variation, | |
| item.stages.aiAnalysis.time, | |
| item.stages.aiAnalysis.status, | |
| item.stages.aiAnalysis.variation, | |
| item.stages.dataExtraction.time, | |
| item.stages.dataExtraction.status, | |
| item.stages.dataExtraction.variation, | |
| item.stages.outputRendering.time, | |
| item.stages.outputRendering.status, | |
| item.stages.outputRendering.variation, | |
| item.errorMessage || "", | |
| ]); | |
| const csvContent = [ | |
| headers.join(","), | |
| ...rows.map((row) => row.map((cell) => `"${cell}"`).join(",")), | |
| ].join("\n"); | |
| return csvContent; | |
| }; | |
| const downloadFile = (content, fileName, mimeType) => { | |
| const blob = new Blob([content], { type: mimeType }); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement("a"); | |
| link.href = url; | |
| link.download = fileName; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| URL.revokeObjectURL(url); | |
| }; | |
| const handleExportCSV = () => { | |
| setIsExporting(true); | |
| setTimeout(() => { | |
| const csvContent = generateCSV(filteredHistory); | |
| downloadFile( | |
| csvContent, | |
| `extraction_history_${new Date().toISOString().slice(0, 10)}.csv`, | |
| "text/csv;charset=utf-8;" | |
| ); | |
| toastSuccess("CSV exported successfully"); | |
| setIsExporting(false); | |
| }, 500); | |
| }; | |
| const generateExcelXML = (data) => { | |
| const headers = [ | |
| "File Name", | |
| "File Type", | |
| "File Size", | |
| "Extracted At", | |
| "Status", | |
| "Confidence (%)", | |
| "Fields Extracted", | |
| "Total Time (ms)", | |
| "Upload Time (ms)", | |
| "Upload Status", | |
| "Upload Variation", | |
| "AI Analysis Time (ms)", | |
| "AI Analysis Status", | |
| "AI Analysis Variation", | |
| "Data Extraction Time (ms)", | |
| "Data Extraction Status", | |
| "Data Extraction Variation", | |
| "Output Rendering Time (ms)", | |
| "Output Rendering Status", | |
| "Output Rendering Variation", | |
| "Error Message", | |
| ]; | |
| const rows = data.map((item) => [ | |
| item.fileName, | |
| item.fileType, | |
| item.fileSize, | |
| formatDateForExport(item.extractedAt), | |
| item.status, | |
| item.confidence, | |
| item.fieldsExtracted, | |
| item.totalTime, | |
| item.stages.uploading.time, | |
| item.stages.uploading.status, | |
| item.stages.uploading.variation, | |
| item.stages.aiAnalysis.time, | |
| item.stages.aiAnalysis.status, | |
| item.stages.aiAnalysis.variation, | |
| item.stages.dataExtraction.time, | |
| item.stages.dataExtraction.status, | |
| item.stages.dataExtraction.variation, | |
| item.stages.outputRendering.time, | |
| item.stages.outputRendering.status, | |
| item.stages.outputRendering.variation, | |
| item.errorMessage || "", | |
| ]); | |
| let xml = `<?xml version="1.0" encoding="UTF-8"?> | |
| <?mso-application progid="Excel.Sheet"?> | |
| <Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" | |
| xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet"> | |
| <Worksheet ss:Name="Extraction History"> | |
| <Table> | |
| <Row>`; | |
| headers.forEach((header) => { | |
| xml += `<Cell><Data ss:Type="String">${header}</Data></Cell>`; | |
| }); | |
| xml += `</Row>`; | |
| rows.forEach((row) => { | |
| xml += `<Row>`; | |
| row.forEach((cell) => { | |
| const type = typeof cell === "number" ? "Number" : "String"; | |
| xml += `<Cell><Data ss:Type="${type}">${cell}</Data></Cell>`; | |
| }); | |
| xml += `</Row>`; | |
| }); | |
| xml += `</Table></Worksheet></Workbook>`; | |
| return xml; | |
| }; | |
| const handleExportExcel = () => { | |
| setIsExporting(true); | |
| setTimeout(() => { | |
| const excelContent = generateExcelXML(filteredHistory); | |
| downloadFile( | |
| excelContent, | |
| `extraction_history_${new Date().toISOString().slice(0, 10)}.xls`, | |
| "application/vnd.ms-excel" | |
| ); | |
| toastSuccess("Excel file exported successfully"); | |
| setIsExporting(false); | |
| }, 500); | |
| }; | |
| const handleExportSingleReport = (item, format) => { | |
| if (format === "csv") { | |
| const csvContent = generateCSV([item]); | |
| downloadFile( | |
| csvContent, | |
| `${item.fileName.replace(/\.[^/.]+$/, "")}_report.csv`, | |
| "text/csv;charset=utf-8;" | |
| ); | |
| toastSuccess("Report exported as CSV"); | |
| } else { | |
| const excelContent = generateExcelXML([item]); | |
| downloadFile( | |
| excelContent, | |
| `${item.fileName.replace(/\.[^/.]+$/, "")}_report.xls`, | |
| "application/vnd.ms-excel" | |
| ); | |
| toastSuccess("Report exported as Excel"); | |
| } | |
| }; | |
| return ( | |
| <div className="min-h-screen bg-[#FAFAFA]"> | |
| {/* Header */} | |
| <header className="bg-white border-b border-slate-200/80 sticky top-0 z-40"> | |
| <div className="px-8 py-4"> | |
| <h1 className="text-xl font-bold text-slate-900 tracking-tight"> | |
| Extraction History | |
| </h1> | |
| <p className="text-sm text-slate-500 mt-0.5"> | |
| View detailed reports and performance metrics for all extractions | |
| </p> | |
| </div> | |
| </header> | |
| {/* Content */} | |
| <div className="p-8"> | |
| {/* Filters */} | |
| <div className="flex items-center gap-4 mb-6"> | |
| <div className="relative flex-1 max-w-md"> | |
| <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" /> | |
| <Input | |
| placeholder="Search by file name..." | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| className="pl-10 h-11 rounded-xl border-slate-200" | |
| /> | |
| </div> | |
| <Select | |
| value={selectedStatus} | |
| onValueChange={(value) => setSelectedStatus(value)} | |
| > | |
| <SelectTrigger className="w-40 h-11 rounded-xl border-slate-200"> | |
| <Filter className="h-4 w-4 mr-2 text-slate-400" /> | |
| <SelectValue placeholder="Status" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="all">All Status</SelectItem> | |
| <SelectItem value="completed">Completed</SelectItem> | |
| <SelectItem value="failed">Failed</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| {/* Export All Button */} | |
| <DropdownMenu> | |
| <DropdownMenuTrigger asChild> | |
| <Button | |
| className="h-11 px-4 rounded-xl bg-gradient-to-r from-indigo-600 to-violet-600 hover:from-indigo-700 hover:to-violet-700 shadow-lg shadow-indigo-500/25" | |
| disabled={isExporting || filteredHistory.length === 0} | |
| > | |
| {isExporting ? ( | |
| <motion.div | |
| animate={{ rotate: 360 }} | |
| transition={{ | |
| duration: 1, | |
| repeat: Infinity, | |
| ease: "linear", | |
| }} | |
| className="mr-2" | |
| > | |
| <Download className="h-4 w-4" /> | |
| </motion.div> | |
| ) : ( | |
| <Download className="h-4 w-4 mr-2" /> | |
| )} | |
| Export All | |
| </Button> | |
| </DropdownMenuTrigger> | |
| <DropdownMenuContent | |
| align="end" | |
| className="w-48 rounded-xl p-2" | |
| > | |
| <DropdownMenuItem | |
| className="rounded-lg cursor-pointer" | |
| onClick={handleExportCSV} | |
| > | |
| <Table2 className="h-4 w-4 mr-2 text-emerald-600" /> | |
| Export as CSV | |
| </DropdownMenuItem> | |
| <DropdownMenuItem | |
| className="rounded-lg cursor-pointer" | |
| onClick={handleExportExcel} | |
| > | |
| <FileSpreadsheet className="h-4 w-4 mr-2 text-green-600" /> | |
| Export as Excel | |
| </DropdownMenuItem> | |
| <DropdownMenuSeparator /> | |
| <div className="px-2 py-1.5 text-xs text-slate-500"> | |
| {filteredHistory.length} records will be exported | |
| </div> | |
| </DropdownMenuContent> | |
| </DropdownMenu> | |
| </div> | |
| {/* Stats Overview */} | |
| <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8"> | |
| {(() => { | |
| const total = history.length; | |
| const completed = history.filter((h) => h.status === "completed").length; | |
| const successRate = total > 0 ? ((completed / total) * 100).toFixed(1) : 0; | |
| const avgTime = history.length > 0 | |
| ? history.reduce((sum, h) => sum + (h.totalTime || 0), 0) / history.length | |
| : 0; | |
| const totalFields = history.reduce((sum, h) => sum + (h.fieldsExtracted || 0), 0); | |
| return [ | |
| { | |
| label: "Total Extractions", | |
| value: total.toString(), | |
| change: "", | |
| color: "indigo", | |
| }, | |
| { | |
| label: "Success Rate", | |
| value: `${successRate}%`, | |
| change: total > 0 ? `${completed}/${total} successful` : "No data", | |
| color: "emerald", | |
| }, | |
| { | |
| label: "Avg. Processing Time", | |
| value: avgTime >= 1000 ? `${(avgTime / 1000).toFixed(1)}s` : `${Math.round(avgTime)}ms`, | |
| change: "", | |
| color: "violet", | |
| }, | |
| { | |
| label: "Fields Extracted", | |
| value: totalFields.toLocaleString(), | |
| change: "", | |
| color: "amber", | |
| }, | |
| ].map((stat, index) => ( | |
| <motion.div | |
| key={stat.label} | |
| initial={{ opacity: 0, y: 20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: index * 0.1 }} | |
| className="bg-white rounded-2xl border border-slate-200 p-5" | |
| > | |
| <p className="text-sm text-slate-500 mb-1">{stat.label}</p> | |
| <p className="text-2xl font-bold text-slate-900">{stat.value}</p> | |
| <p className={`text-xs text-${stat.color}-600 mt-1`}> | |
| {stat.change} | |
| </p> | |
| </motion.div> | |
| )); | |
| })()} | |
| </div> | |
| {/* Loading State */} | |
| {isLoading && ( | |
| <div className="text-center py-16"> | |
| <motion.div | |
| animate={{ rotate: 360 }} | |
| transition={{ duration: 1, repeat: Infinity, ease: "linear" }} | |
| className="h-16 w-16 mx-auto rounded-2xl bg-indigo-100 flex items-center justify-center mb-4" | |
| > | |
| <Cpu className="h-8 w-8 text-indigo-600" /> | |
| </motion.div> | |
| <p className="text-slate-500">Loading extraction history...</p> | |
| </div> | |
| )} | |
| {/* History List */} | |
| {!isLoading && ( | |
| <div className="space-y-4"> | |
| {filteredHistory.map((item, index) => ( | |
| <motion.div | |
| key={item.id} | |
| initial={{ opacity: 0, y: 20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: index * 0.05 }} | |
| className="bg-white rounded-2xl border border-slate-200 overflow-hidden" | |
| > | |
| {/* Main Row */} | |
| <div | |
| className="p-5 cursor-pointer hover:bg-slate-50/50 transition-colors" | |
| onClick={() => | |
| setExpandedReport( | |
| expandedReport === item.id ? null : item.id | |
| ) | |
| } | |
| > | |
| <div className="flex items-center gap-4"> | |
| {/* File Icon */} | |
| <div | |
| className={cn( | |
| "h-12 w-12 rounded-xl flex items-center justify-center", | |
| item.status === "completed" ? "bg-indigo-50" : "bg-red-50" | |
| )} | |
| > | |
| <FileText | |
| className={cn( | |
| "h-6 w-6", | |
| item.status === "completed" | |
| ? "text-indigo-600" | |
| : "text-red-500" | |
| )} | |
| /> | |
| </div> | |
| {/* File Info */} | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center gap-2"> | |
| <h3 className="font-semibold text-slate-900 truncate"> | |
| {item.fileName} | |
| </h3> | |
| <Badge variant="secondary" className="text-xs"> | |
| {item.fileType} | |
| </Badge> | |
| </div> | |
| <div className="flex items-center gap-4 mt-1 text-sm text-slate-500"> | |
| <span>{item.fileSize}</span> | |
| <span className="flex items-center gap-1"> | |
| <Calendar className="h-3 w-3" /> | |
| {formatDate(item.extractedAt)} | |
| </span> | |
| </div> | |
| </div> | |
| {/* Stats */} | |
| <div className="hidden md:flex items-center gap-6"> | |
| <div className="text-center"> | |
| <p className="text-xs text-slate-400">Time</p> | |
| <p className="font-semibold text-slate-700"> | |
| {formatTime(item.totalTime)} | |
| </p> | |
| </div> | |
| <div className="text-center"> | |
| <p className="text-xs text-slate-400">Fields</p> | |
| <p className="font-semibold text-slate-700"> | |
| {item.fieldsExtracted} | |
| </p> | |
| </div> | |
| <div className="text-center"> | |
| <p className="text-xs text-slate-400">Confidence</p> | |
| <p | |
| className={cn( | |
| "font-semibold", | |
| item.confidence >= 95 | |
| ? "text-emerald-600" | |
| : item.confidence >= 90 | |
| ? "text-amber-600" | |
| : "text-red-600" | |
| )} | |
| > | |
| {item.confidence > 0 ? `${item.confidence}%` : "-"} | |
| </p> | |
| </div> | |
| </div> | |
| {/* Status & Actions */} | |
| <div className="flex items-center gap-3"> | |
| <Badge | |
| className={cn( | |
| "capitalize", | |
| item.status === "completed" | |
| ? "bg-emerald-50 text-emerald-700 border-emerald-200" | |
| : "bg-red-50 text-red-700 border-red-200" | |
| )} | |
| > | |
| {item.status === "completed" ? ( | |
| <CheckCircle2 className="h-3 w-3 mr-1" /> | |
| ) : ( | |
| <AlertCircle className="h-3 w-3 mr-1" /> | |
| )} | |
| {item.status} | |
| </Badge> | |
| <ChevronRight | |
| className={cn( | |
| "h-5 w-5 text-slate-400 transition-transform", | |
| expandedReport === item.id && "rotate-90" | |
| )} | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Expanded Report */} | |
| <AnimatePresence> | |
| {expandedReport === item.id && ( | |
| <motion.div | |
| initial={{ height: 0, opacity: 0 }} | |
| animate={{ height: "auto", opacity: 1 }} | |
| exit={{ height: 0, opacity: 0 }} | |
| transition={{ duration: 0.2 }} | |
| className="overflow-hidden" | |
| > | |
| <div className="px-5 pb-5 pt-2 border-t border-slate-100"> | |
| {/* Error Message */} | |
| {item.errorMessage && ( | |
| <div className="mb-4 p-4 bg-red-50 border border-red-100 rounded-xl"> | |
| <div className="flex items-center gap-2 text-red-700"> | |
| <AlertCircle className="h-4 w-4" /> | |
| <span className="font-medium">Error Details</span> | |
| </div> | |
| <p className="text-sm text-red-600 mt-1"> | |
| {item.errorMessage} | |
| </p> | |
| </div> | |
| )} | |
| {/* Performance Report Header */} | |
| <div className="flex items-center justify-between mb-4"> | |
| <h4 className="font-semibold text-slate-800"> | |
| Performance Report | |
| </h4> | |
| <div className="flex items-center gap-2"> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="h-8 text-xs" | |
| > | |
| <Eye className="h-3 w-3 mr-1" /> | |
| View Output | |
| </Button> | |
| <DropdownMenu> | |
| <DropdownMenuTrigger asChild> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| className="h-8 text-xs" | |
| > | |
| <Download className="h-3 w-3 mr-1" /> | |
| Export Report | |
| </Button> | |
| </DropdownMenuTrigger> | |
| <DropdownMenuContent | |
| align="end" | |
| className="w-44 rounded-xl p-2" | |
| > | |
| <DropdownMenuItem | |
| className="rounded-lg cursor-pointer text-xs" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| handleExportSingleReport(item, "csv"); | |
| }} | |
| > | |
| <Table2 className="h-3 w-3 mr-2 text-emerald-600" /> | |
| Download CSV | |
| </DropdownMenuItem> | |
| <DropdownMenuItem | |
| className="rounded-lg cursor-pointer text-xs" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| handleExportSingleReport(item, "excel"); | |
| }} | |
| > | |
| <FileSpreadsheet className="h-3 w-3 mr-2 text-green-600" /> | |
| Download Excel | |
| </DropdownMenuItem> | |
| </DropdownMenuContent> | |
| </DropdownMenu> | |
| </div> | |
| </div> | |
| {/* Stage Timing Cards */} | |
| <div className="grid grid-cols-1 md:grid-cols-4 gap-4"> | |
| {Object.entries(item.stages).map( | |
| ([stageKey, stageData]) => { | |
| const config = stageConfig[stageKey]; | |
| const variationInfo = | |
| variationConfig[stageData.variation]; | |
| const Icon = config.icon; | |
| const VariationIcon = variationInfo.icon; | |
| return ( | |
| <div | |
| key={stageKey} | |
| className={cn( | |
| "relative p-4 rounded-xl border", | |
| stageData.status === "completed" | |
| ? "bg-slate-50 border-slate-200" | |
| : stageData.status === "failed" | |
| ? "bg-red-50 border-red-200" | |
| : "bg-slate-50/50 border-slate-100" | |
| )} | |
| > | |
| <div className="flex items-center gap-2 mb-3"> | |
| <div | |
| className={cn( | |
| "h-8 w-8 rounded-lg flex items-center justify-center", | |
| `bg-${config.color}-100` | |
| )} | |
| > | |
| <Icon | |
| className={cn( | |
| "h-4 w-4", | |
| `text-${config.color}-600` | |
| )} | |
| /> | |
| </div> | |
| <span className="text-sm font-medium text-slate-700"> | |
| {config.label} | |
| </span> | |
| </div> | |
| <div className="flex items-end justify-between"> | |
| <div> | |
| <p | |
| className={cn( | |
| "text-2xl font-bold", | |
| stageData.status === "skipped" | |
| ? "text-slate-300" | |
| : stageData.status === "failed" | |
| ? "text-red-600" | |
| : "text-slate-900" | |
| )} | |
| > | |
| {stageData.status === "skipped" | |
| ? "-" | |
| : formatTime(stageData.time)} | |
| </p> | |
| {stageData.status !== "skipped" && ( | |
| <div className="flex items-center gap-1 mt-1"> | |
| <VariationIcon | |
| className={cn( | |
| "h-3 w-3", | |
| variationInfo.color | |
| )} | |
| /> | |
| <span | |
| className={cn( | |
| "text-xs", | |
| variationInfo.color | |
| )} | |
| > | |
| {variationInfo.label} | |
| </span> | |
| </div> | |
| )} | |
| </div> | |
| {stageData.status === "completed" && ( | |
| <CheckCircle2 className="h-5 w-5 text-emerald-500" /> | |
| )} | |
| {stageData.status === "failed" && ( | |
| <X className="h-5 w-5 text-red-500" /> | |
| )} | |
| </div> | |
| {/* Progress bar */} | |
| <div className="mt-3 h-1.5 bg-slate-200 rounded-full overflow-hidden"> | |
| <motion.div | |
| initial={{ width: 0 }} | |
| animate={{ | |
| width: | |
| stageData.status === "completed" | |
| ? "100%" | |
| : stageData.status === "failed" | |
| ? "60%" | |
| : "0%", | |
| }} | |
| transition={{ duration: 0.5, delay: 0.2 }} | |
| className={cn( | |
| "h-full rounded-full", | |
| stageData.status === "failed" | |
| ? "bg-red-500" | |
| : `bg-${config.color}-500` | |
| )} | |
| /> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| )} | |
| </div> | |
| {/* Total Time Summary */} | |
| <div className="mt-4 flex items-center justify-between p-4 bg-gradient-to-r from-indigo-50 to-violet-50 rounded-xl border border-indigo-100"> | |
| <div className="flex items-center gap-3"> | |
| <Clock className="h-5 w-5 text-indigo-600" /> | |
| <div> | |
| <p className="text-sm font-medium text-slate-700"> | |
| Total Processing Time | |
| </p> | |
| <p className="text-xs text-slate-500"> | |
| From upload to output ready | |
| </p> | |
| </div> | |
| </div> | |
| <div className="text-right"> | |
| <p className="text-2xl font-bold text-indigo-600"> | |
| {formatTime(item.totalTime)} | |
| </p> | |
| <p className="text-xs text-slate-500"> | |
| {item.status === "completed" | |
| ? "Completed successfully" | |
| : "Process failed"} | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </motion.div> | |
| ))} | |
| {filteredHistory.length === 0 && !error && ( | |
| <div className="text-center py-16"> | |
| <div className="h-20 w-20 mx-auto rounded-2xl bg-slate-100 flex items-center justify-center mb-4"> | |
| <FileText className="h-10 w-10 text-slate-300" /> | |
| </div> | |
| <p className="text-slate-500 mb-2"> | |
| {history.length === 0 | |
| ? "No extraction history yet" | |
| : "No extractions match your filters"} | |
| </p> | |
| {history.length === 0 && ( | |
| <p className="text-sm text-slate-400"> | |
| Upload a document to get started | |
| </p> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |