feat(analytics): add flow quality table and PSA metrics panel
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
244
frontend/src/components/analytics/FlowQualityTable.tsx
Normal file
244
frontend/src/components/analytics/FlowQualityTable.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowUpDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
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'
|
||||
|
||||
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="glass-card-static 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 }[] = [
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="glass-card-static p-3 sm:p-5">
|
||||
<div className="mb-4">
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground">
|
||||
Flow Quality Scores
|
||||
</h3>
|
||||
<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) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="text-left py-2 px-2 font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground cursor-pointer select-none hover:text-foreground transition-colors"
|
||||
onClick={() => handleSort(col.key)}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{col.label}
|
||||
<ArrowUpDown
|
||||
size={10}
|
||||
className={cn(
|
||||
'transition-opacity',
|
||||
sortCol === col.key ? '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={`/trees/${flow.flow_id}/edit`}
|
||||
className="text-foreground hover:text-primary transition-colors font-medium truncate max-w-[200px]"
|
||||
>
|
||||
{flow.name}
|
||||
</Link>
|
||||
{needsAttention && (
|
||||
<span className="shrink-0 bg-amber-400/10 text-amber-400 font-label 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>
|
||||
)
|
||||
}
|
||||
182
frontend/src/components/analytics/PsaMetricsPanel.tsx
Normal file
182
frontend/src/components/analytics/PsaMetricsPanel.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { ArrowRight, ArrowDown } from 'lucide-react'
|
||||
import {
|
||||
ComposedChart, Area, Bar,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import type { EnhancedPsaMetrics, PsaFunnel } from '@/types/flowpilot-analytics'
|
||||
|
||||
interface PsaMetricsPanelProps {
|
||||
data: EnhancedPsaMetrics
|
||||
}
|
||||
|
||||
function funnelPct(from: number, to: number): string {
|
||||
if (from === 0) return '0%'
|
||||
return `${Math.round((to / from) * 100)}%`
|
||||
}
|
||||
|
||||
export default function PsaMetricsPanel({ data }: PsaMetricsPanelProps) {
|
||||
const hasActivity = data.total_time_entries > 0 || data.push_funnel.total_sessions > 0 || data.daily_trend.length > 0
|
||||
|
||||
if (!hasActivity) {
|
||||
return (
|
||||
<div className="glass-card-static p-3 sm:p-5">
|
||||
<div className="flex items-center justify-center min-h-[200px]">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No PSA activity data for this period. Link sessions to PSA tickets to see metrics.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Row 1 — Metric cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="glass-card-static p-4">
|
||||
<p className="text-gradient-brand font-heading text-2xl">{data.total_time_entries}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">logged to PSA</p>
|
||||
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-[#5a6170] mt-0.5">Time Entries</p>
|
||||
</div>
|
||||
<div className="glass-card-static p-4">
|
||||
<p className="text-gradient-brand font-heading text-2xl">{data.total_hours_logged.toFixed(1)}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">total hours tracked</p>
|
||||
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-[#5a6170] mt-0.5">Hours Logged</p>
|
||||
</div>
|
||||
<div className="glass-card-static p-4">
|
||||
<p className="text-gradient-brand font-heading text-2xl">{data.avg_hours_per_session.toFixed(2)}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">per resolved session</p>
|
||||
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-[#5a6170] mt-0.5">Avg Hours/Session</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2 — Push Success Funnel */}
|
||||
<FunnelCard funnel={data.push_funnel} />
|
||||
|
||||
{/* Row 3 — Daily Trend Chart */}
|
||||
{data.daily_trend.length > 0 && (
|
||||
<div className="glass-card-static p-3 sm:p-5">
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground mb-4">
|
||||
PSA Activity Trend
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<ComposedChart data={data.daily_trend}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fill: '#8891a0', fontSize: 10 }}
|
||||
tickFormatter={(d) => new Date(d).toLocaleDateString([], { month: 'short', day: 'numeric' })}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="entries"
|
||||
orientation="left"
|
||||
tick={{ fill: '#8891a0', fontSize: 10 }}
|
||||
label={{ value: 'Entries', angle: -90, position: 'insideLeft', style: { fill: '#8891a0', fontSize: 10 } }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="hours"
|
||||
orientation="right"
|
||||
tick={{ fill: '#8891a0', fontSize: 10 }}
|
||||
label={{ value: 'Hours', angle: 90, position: 'insideRight', style: { fill: '#8891a0', fontSize: 10 } }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: '#18191f',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: '#f8fafc' }}
|
||||
formatter={((value: number | undefined, name: string | undefined) => [
|
||||
name === 'hours' ? `${(value ?? 0).toFixed(1)}h` : (value ?? 0),
|
||||
name === 'hours' ? 'Hours' : 'Entries',
|
||||
]) as never}
|
||||
/>
|
||||
<Bar
|
||||
yAxisId="entries"
|
||||
dataKey="entries"
|
||||
fill="rgba(34,211,238,0.3)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Area
|
||||
yAxisId="hours"
|
||||
type="monotone"
|
||||
dataKey="hours"
|
||||
stroke="#22d3ee"
|
||||
fill="url(#psaHoursGradient)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="psaHoursGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#22d3ee" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#22d3ee" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FunnelCard({ funnel }: { funnel: PsaFunnel }) {
|
||||
const steps = [
|
||||
{ label: 'Sessions', count: funnel.total_sessions },
|
||||
{ label: 'Linked', count: funnel.linked_to_ticket },
|
||||
{ label: 'Doc Pushed', count: funnel.doc_pushed },
|
||||
{ label: 'Time Entry', count: funnel.time_entry_logged },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="glass-card-static p-3 sm:p-5">
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground mb-4">
|
||||
Documentation Push Funnel
|
||||
</h3>
|
||||
|
||||
{/* Desktop: horizontal */}
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
{steps.map((step, i) => (
|
||||
<div key={step.label} className="flex items-center gap-2 flex-1">
|
||||
<div className="bg-primary/5 border border-primary/20 rounded-lg p-3 text-center flex-1">
|
||||
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
{step.label}
|
||||
</p>
|
||||
<p className="text-lg font-heading text-foreground">{step.count}</p>
|
||||
</div>
|
||||
{i < steps.length - 1 && (
|
||||
<div className="flex flex-col items-center shrink-0 px-1">
|
||||
<ArrowRight size={14} className="text-muted-foreground" />
|
||||
<span className="text-[0.625rem] text-muted-foreground font-label">
|
||||
{funnelPct(steps[i].count, steps[i + 1].count)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile: vertical */}
|
||||
<div className="flex sm:hidden flex-col items-center gap-2">
|
||||
{steps.map((step, i) => (
|
||||
<div key={step.label} className="flex flex-col items-center gap-2 w-full">
|
||||
<div className="bg-primary/5 border border-primary/20 rounded-lg p-3 text-center w-full">
|
||||
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
{step.label}
|
||||
</p>
|
||||
<p className="text-lg font-heading text-foreground">{step.count}</p>
|
||||
</div>
|
||||
{i < steps.length - 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<ArrowDown size={14} className="text-muted-foreground" />
|
||||
<span className="text-[0.625rem] text-muted-foreground font-label">
|
||||
{funnelPct(steps[i].count, steps[i + 1].count)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user