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>
311 lines
10 KiB
TypeScript
311 lines
10 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { Loader2, Star } from 'lucide-react'
|
|
import {
|
|
AreaChart,
|
|
Area,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
} from 'recharts'
|
|
import { analyticsApi } from '@/api'
|
|
import { cn } from '@/lib/utils'
|
|
import type { FlowAnalyticsResponse, AnalyticsPeriod } from '@/types'
|
|
|
|
const CHART_COLORS = {
|
|
resolved: '#34d399',
|
|
escalated: '#f87171',
|
|
workaround: '#fbbf24',
|
|
unresolved: '#94a3b8',
|
|
}
|
|
|
|
const PERIOD_OPTIONS: { value: AnalyticsPeriod; label: string }[] = [
|
|
{ value: '7d', label: 'Last 7 days' },
|
|
{ value: '30d', label: 'Last 30 days' },
|
|
{ value: '90d', label: 'Last 90 days' },
|
|
]
|
|
|
|
interface FlowAnalyticsPanelProps {
|
|
treeId: string
|
|
}
|
|
|
|
export function FlowAnalyticsPanel({ treeId }: FlowAnalyticsPanelProps) {
|
|
const [period, setPeriod] = useState<AnalyticsPeriod>('30d')
|
|
const [data, setData] = useState<FlowAnalyticsResponse | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState(false)
|
|
|
|
useEffect(() => {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setLoading(true)
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setError(false)
|
|
analyticsApi
|
|
.getFlowAnalytics(treeId, period)
|
|
.then(setData)
|
|
.catch(() => {
|
|
setError(true)
|
|
setData(null)
|
|
})
|
|
.finally(() => setLoading(false))
|
|
}, [treeId, period])
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error || !data) {
|
|
return (
|
|
<div className="text-center py-12 text-muted-foreground">
|
|
No analytics data available for this flow.
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const { summary, avg_csat, total_ratings, time_series, step_feedback, recent_comments } = data
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Period selector */}
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-lg font-semibold text-foreground">Flow Analytics</h3>
|
|
<select
|
|
value={period}
|
|
onChange={(e) => setPeriod(e.target.value as AnalyticsPeriod)}
|
|
className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:outline-hidden focus:ring-1 focus:ring-ring"
|
|
>
|
|
{PERIOD_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Summary stat cards */}
|
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
<StatCard label="Sessions" value={summary.total_sessions.toLocaleString()} />
|
|
<StatCard
|
|
label="Completion"
|
|
value={`${(summary.completion_rate * 100).toFixed(0)}%`}
|
|
/>
|
|
<StatCard
|
|
label="Median Time"
|
|
value={`${summary.median_duration_minutes} min`}
|
|
/>
|
|
<StatCard
|
|
label="CSAT"
|
|
value={avg_csat != null ? `${avg_csat.toFixed(1)}/5` : 'N/A'}
|
|
subtitle={total_ratings > 0 ? `${total_ratings} rating${total_ratings !== 1 ? 's' : ''}` : undefined}
|
|
/>
|
|
</div>
|
|
|
|
{/* Area chart - Sessions over time */}
|
|
{time_series.length > 0 && (
|
|
<div className="bg-card border border-border rounded-xl p-4">
|
|
<p className="text-sm font-semibold text-foreground mb-3">Sessions Over Time</p>
|
|
<ResponsiveContainer width="100%" height={180}>
|
|
<AreaChart data={time_series}>
|
|
<CartesianGrid
|
|
strokeDasharray="3 3"
|
|
stroke="var(--color-border)"
|
|
vertical={false}
|
|
/>
|
|
<XAxis
|
|
dataKey="date"
|
|
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 11 }}
|
|
tickLine={false}
|
|
axisLine={{ stroke: 'var(--color-border)' }}
|
|
tickFormatter={(value) => {
|
|
const d = new Date(String(value))
|
|
return `${d.getMonth() + 1}/${d.getDate()}`
|
|
}}
|
|
/>
|
|
<YAxis
|
|
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 11 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
allowDecimals={false}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: 'var(--color-card)',
|
|
border: '1px solid var(--color-border)',
|
|
borderRadius: '8px',
|
|
color: 'var(--color-foreground)',
|
|
fontSize: '13px',
|
|
}}
|
|
labelFormatter={(value) => {
|
|
const d = new Date(String(value))
|
|
return d.toLocaleDateString(undefined, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
})
|
|
}}
|
|
/>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="resolved"
|
|
stackId="1"
|
|
stroke={CHART_COLORS.resolved}
|
|
fill={CHART_COLORS.resolved}
|
|
fillOpacity={0.3}
|
|
/>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="escalated"
|
|
stackId="1"
|
|
stroke={CHART_COLORS.escalated}
|
|
fill={CHART_COLORS.escalated}
|
|
fillOpacity={0.3}
|
|
/>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="workaround"
|
|
stackId="1"
|
|
stroke={CHART_COLORS.workaround}
|
|
fill={CHART_COLORS.workaround}
|
|
fillOpacity={0.3}
|
|
/>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="unresolved"
|
|
stackId="1"
|
|
stroke={CHART_COLORS.unresolved}
|
|
fill={CHART_COLORS.unresolved}
|
|
fillOpacity={0.3}
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
|
|
{/* Chart legend */}
|
|
<div className="flex items-center gap-4 mt-2 justify-center">
|
|
{Object.entries(CHART_COLORS).map(([key, color]) => (
|
|
<div key={key} className="flex items-center gap-1.5">
|
|
<div
|
|
className="h-2 w-2 rounded-full"
|
|
style={{ backgroundColor: color }}
|
|
/>
|
|
<span className="text-xs text-muted-foreground capitalize">
|
|
{key}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Step Feedback Table with Dropoff Metrics */}
|
|
{step_feedback.length > 0 && (
|
|
<div className="bg-card border border-border rounded-xl p-4">
|
|
<p className="text-sm font-semibold text-foreground mb-3">Step Performance</p>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border">
|
|
<th className="text-left py-2 pr-4 text-foreground font-medium">Step</th>
|
|
<th className="text-right py-2 pr-4 text-foreground font-medium">Visits</th>
|
|
<th className="text-right py-2 pr-4 text-foreground font-medium">Dropoffs</th>
|
|
<th className="text-right py-2 text-foreground font-medium">Dropoff Rate</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{step_feedback.map((step) => (
|
|
<tr
|
|
key={step.node_id}
|
|
className={cn(
|
|
'border-b border-border last:border-0',
|
|
step.dropoff_rate > 0.2 && 'bg-red-400/5'
|
|
)}
|
|
>
|
|
<td className="py-2 pr-4 text-muted-foreground truncate max-w-[200px]">
|
|
{step.node_title}
|
|
</td>
|
|
<td className="py-2 pr-4 text-right text-muted-foreground">
|
|
{step.visit_count}
|
|
</td>
|
|
<td className="py-2 pr-4 text-right text-muted-foreground">
|
|
{step.dropoff_count}
|
|
</td>
|
|
<td
|
|
className={cn(
|
|
'py-2 text-right font-medium',
|
|
step.dropoff_rate > 0.2 ? 'text-red-400' : 'text-muted-foreground'
|
|
)}
|
|
>
|
|
{(step.dropoff_rate * 100).toFixed(1)}%
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Recent Comments (Anonymous) */}
|
|
{recent_comments.length > 0 && (
|
|
<div className="bg-card border border-border rounded-xl p-4">
|
|
<p className="text-sm font-semibold text-foreground mb-3">Recent Feedback</p>
|
|
<div className="space-y-3">
|
|
{recent_comments.map((item, i) => (
|
|
<div
|
|
key={i}
|
|
className="flex items-start gap-3 border-b border-border/50 pb-3 last:border-0 last:pb-0"
|
|
>
|
|
<div className="flex items-center gap-0.5 shrink-0 pt-0.5">
|
|
{[1, 2, 3, 4, 5].map((v) => (
|
|
<Star
|
|
key={v}
|
|
size={12}
|
|
className={cn(
|
|
v <= item.rating
|
|
? 'fill-yellow-400 text-yellow-400'
|
|
: 'fill-none text-muted-foreground'
|
|
)}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="min-w-0">
|
|
{item.comment && (
|
|
<p className="text-sm text-foreground">{item.comment}</p>
|
|
)}
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{new Date(item.created_at).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function StatCard({
|
|
label,
|
|
value,
|
|
subtitle,
|
|
}: {
|
|
label: string
|
|
value: string | number
|
|
subtitle?: string
|
|
}) {
|
|
return (
|
|
<div className="bg-card border border-border rounded-xl p-4">
|
|
<p className="text-xs text-muted-foreground">{label}</p>
|
|
<p className="text-xl font-bold text-foreground mt-1">{value}</p>
|
|
{subtitle && (
|
|
<p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|