3,200+ hardcoded color values replaced with CSS variable-backed Tailwind classes (bg-card, text-foreground, border-border, etc.). Enables light mode via CSS variable swap. Only syntax highlighting colors and intentional one-offs remain hardcoded (~15 values). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
270 lines
9.2 KiB
TypeScript
270 lines
9.2 KiB
TypeScript
import { useState, useMemo } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import { ArrowUpDown, ArrowUp, ArrowDown, Info } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { getTreeEditorPath } from '@/lib/routing'
|
|
import type { FlowQualityResponse, FlowQualityRow } from '@/types/flowpilot-analytics'
|
|
|
|
interface FlowQualityTableProps {
|
|
data: FlowQualityResponse
|
|
}
|
|
|
|
type SortColumn = 'name' | 'usage_count' | 'success_rate' | 'last_matched_at' | 'avg_confidence' | 'quality_score'
|
|
type SortDir = 'asc' | 'desc'
|
|
|
|
const TYPE_LABELS: Record<string, { label: string; color: string }> = {
|
|
troubleshooting: { label: 'Troubleshooting', color: 'text-primary' },
|
|
procedural: { label: 'Project', color: 'text-amber-400' },
|
|
maintenance: { label: 'Maintenance', color: 'text-blue-400' },
|
|
}
|
|
|
|
function formatRelativeTime(dateStr: string | null): string {
|
|
if (!dateStr) return 'Never'
|
|
const diff = Date.now() - new Date(dateStr).getTime()
|
|
const minutes = Math.floor(diff / 60000)
|
|
if (minutes < 1) return 'Just now'
|
|
if (minutes < 60) return `${minutes}m ago`
|
|
const hours = Math.floor(minutes / 60)
|
|
if (hours < 24) return `${hours}h ago`
|
|
const days = Math.floor(hours / 24)
|
|
if (days < 30) return `${days}d ago`
|
|
const months = Math.floor(days / 30)
|
|
return `${months}mo ago`
|
|
}
|
|
|
|
function rateColor(value: number | null): string {
|
|
if (value === null) return 'text-muted-foreground'
|
|
if (value > 75) return 'text-emerald-400'
|
|
if (value >= 50) return 'text-amber-400'
|
|
return 'text-rose-500'
|
|
}
|
|
|
|
function barColor(score: number): string {
|
|
if (score > 0.7) return 'bg-emerald-400'
|
|
if (score >= 0.4) return 'bg-amber-400'
|
|
return 'bg-rose-500'
|
|
}
|
|
|
|
export default function FlowQualityTable({ data }: FlowQualityTableProps) {
|
|
const [sortCol, setSortCol] = useState<SortColumn>('quality_score')
|
|
const [sortDir, setSortDir] = useState<SortDir>('desc')
|
|
|
|
const topPerformerIds = useMemo(
|
|
() => new Set(data.top_performers.map((f) => f.flow_id)),
|
|
[data.top_performers],
|
|
)
|
|
const needsAttentionIds = useMemo(
|
|
() => new Set(data.needs_attention.map((f) => f.flow_id)),
|
|
[data.needs_attention],
|
|
)
|
|
|
|
const sorted = useMemo(() => {
|
|
const rows = [...data.flows]
|
|
rows.sort((a, b) => {
|
|
let aVal: number | string
|
|
let bVal: number | string
|
|
|
|
switch (sortCol) {
|
|
case 'name':
|
|
aVal = a.name.toLowerCase()
|
|
bVal = b.name.toLowerCase()
|
|
break
|
|
case 'usage_count':
|
|
aVal = a.usage_count
|
|
bVal = b.usage_count
|
|
break
|
|
case 'success_rate':
|
|
aVal = a.success_rate ?? -1
|
|
bVal = b.success_rate ?? -1
|
|
break
|
|
case 'last_matched_at':
|
|
aVal = a.last_matched_at ? new Date(a.last_matched_at).getTime() : 0
|
|
bVal = b.last_matched_at ? new Date(b.last_matched_at).getTime() : 0
|
|
break
|
|
case 'avg_confidence':
|
|
aVal = a.avg_confidence ?? -1
|
|
bVal = b.avg_confidence ?? -1
|
|
break
|
|
case 'quality_score':
|
|
default:
|
|
aVal = a.quality_score
|
|
bVal = b.quality_score
|
|
break
|
|
}
|
|
|
|
if (aVal < bVal) return sortDir === 'asc' ? -1 : 1
|
|
if (aVal > bVal) return sortDir === 'asc' ? 1 : -1
|
|
return 0
|
|
})
|
|
return rows
|
|
}, [data.flows, sortCol, sortDir])
|
|
|
|
function handleSort(col: SortColumn) {
|
|
if (sortCol === col) {
|
|
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
|
} else {
|
|
setSortCol(col)
|
|
setSortDir('desc')
|
|
}
|
|
}
|
|
|
|
if (data.flows.length === 0) {
|
|
return (
|
|
<div className="card-flat p-3 sm:p-5">
|
|
<div className="flex items-center justify-center min-h-[200px]">
|
|
<p className="text-sm text-muted-foreground">
|
|
No flows found for this account. Create your first flow to start tracking quality.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const columns: { key: SortColumn; label: string; title?: string }[] = [
|
|
{ key: 'name', label: 'Flow Name' },
|
|
{ key: 'usage_count', label: 'Usage' },
|
|
{ key: 'success_rate', label: 'Success Rate' },
|
|
{ key: 'last_matched_at', label: 'Last Used' },
|
|
{ key: 'avg_confidence', label: 'Avg Confidence' },
|
|
{ key: 'quality_score', label: 'Quality Score', title: 'Combines success rate (50%), AI confidence (30%), and recent usage (20%)' },
|
|
]
|
|
|
|
return (
|
|
<div className="card-flat p-3 sm:p-5">
|
|
<div className="mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-heading text-sm font-semibold text-foreground">
|
|
Flow Quality Scores
|
|
</h3>
|
|
<span className="group relative">
|
|
<Info size={14} className="text-muted-foreground cursor-help" />
|
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-64 p-2 rounded-lg bg-card border border-border text-xs text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
|
|
Quality score combines success rate (50%), AI confidence level (30%), and recent usage (20%). Higher is better.
|
|
</div>
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Performance and usage metrics for your troubleshooting flows
|
|
</p>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border">
|
|
{columns.map((col) => {
|
|
const isActive = sortCol === col.key
|
|
const SortIcon = isActive
|
|
? sortDir === 'asc' ? ArrowUp : ArrowDown
|
|
: ArrowUpDown
|
|
return (
|
|
<th
|
|
key={col.key}
|
|
className="text-left py-2 px-2 font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground cursor-pointer select-none hover:text-foreground transition-colors"
|
|
onClick={() => handleSort(col.key)}
|
|
title={col.title}
|
|
>
|
|
<span className="flex items-center gap-1">
|
|
{col.label}
|
|
<SortIcon
|
|
size={10}
|
|
className={cn(
|
|
'transition-opacity',
|
|
isActive ? 'opacity-100' : 'opacity-30',
|
|
)}
|
|
/>
|
|
</span>
|
|
</th>
|
|
)
|
|
})}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sorted.map((flow) => (
|
|
<FlowRow
|
|
key={flow.flow_id}
|
|
flow={flow}
|
|
isTopPerformer={topPerformerIds.has(flow.flow_id)}
|
|
needsAttention={needsAttentionIds.has(flow.flow_id)}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function FlowRow({
|
|
flow,
|
|
isTopPerformer,
|
|
needsAttention,
|
|
}: {
|
|
flow: FlowQualityRow
|
|
isTopPerformer: boolean
|
|
needsAttention: boolean
|
|
}) {
|
|
return (
|
|
<tr
|
|
className={cn(
|
|
'border-b border-border/50 hover:bg-card/30 transition-colors',
|
|
isTopPerformer && 'border-l-2 border-l-emerald-400',
|
|
needsAttention && 'border-l-2 border-l-rose-500',
|
|
)}
|
|
>
|
|
{/* Flow Name */}
|
|
<td className="py-2.5 px-2">
|
|
<div className="flex items-center gap-2">
|
|
<Link
|
|
to={getTreeEditorPath(flow.flow_id, flow.tree_type)}
|
|
className="text-foreground hover:text-primary transition-colors font-medium truncate max-w-[200px]"
|
|
>
|
|
{flow.name}
|
|
</Link>
|
|
<span className={cn('font-sans text-xs text-[0.5rem] uppercase tracking-wider ml-2 shrink-0', TYPE_LABELS[flow.tree_type]?.color || 'text-muted-foreground')}>
|
|
{TYPE_LABELS[flow.tree_type]?.label || flow.tree_type}
|
|
</span>
|
|
{needsAttention && (
|
|
<span className="shrink-0 bg-amber-400/10 text-amber-400 font-sans text-xs text-[0.625rem] px-1.5 py-0.5 rounded">
|
|
Needs attention
|
|
</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
|
|
{/* Usage */}
|
|
<td className="py-2.5 px-2 text-foreground">{flow.usage_count}</td>
|
|
|
|
{/* Success Rate */}
|
|
<td className={cn('py-2.5 px-2', rateColor(flow.success_rate))}>
|
|
{flow.success_rate !== null ? `${flow.success_rate.toFixed(1)}%` : '--'}
|
|
</td>
|
|
|
|
{/* Last Used */}
|
|
<td className="py-2.5 px-2 text-muted-foreground">
|
|
{formatRelativeTime(flow.last_matched_at)}
|
|
</td>
|
|
|
|
{/* Avg Confidence */}
|
|
<td className={cn('py-2.5 px-2', rateColor(flow.avg_confidence))}>
|
|
{flow.avg_confidence !== null ? `${flow.avg_confidence.toFixed(1)}%` : '--'}
|
|
</td>
|
|
|
|
{/* Quality Score */}
|
|
<td className="py-2.5 px-2">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-16 h-1.5 rounded-full bg-border overflow-hidden">
|
|
<div
|
|
className={cn('h-full rounded-full', barColor(flow.quality_score))}
|
|
style={{ width: `${flow.quality_score * 100}%` }}
|
|
/>
|
|
</div>
|
|
<span className="text-xs text-foreground">
|
|
{(flow.quality_score * 100).toFixed(0)}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
}
|