- Create shared Spinner component with sm/md/lg sizes - Migrate 13 page-level spinners to shared Spinner - Promote EmptyState to shared component, adopt in MyShares and SessionHistory - Replace window.confirm with ConfirmDialog in 3 files - Fix PinnedFlow.tree_type to include maintenance, update emoji display - Verify sidebar unpin handler already correct (no-op) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
import { sessionsApi } from '@/api/sessions'
|
|
import { treesApi } from '@/api/trees'
|
|
import type { Session, TreeListItem } from '@/types'
|
|
import type { DateRange } from 'react-day-picker'
|
|
import { SessionFilters } from '@/components/session/SessionFilters'
|
|
import type { SessionFilterState } from '@/components/session/SessionFilters'
|
|
import { Spinner } from '@/components/common/Spinner'
|
|
import { EmptyState } from '@/components/common/EmptyState'
|
|
import { cn } from '@/lib/utils'
|
|
import { toast } from '@/lib/toast'
|
|
import { getSessionResumePath } from '@/lib/routing'
|
|
|
|
export function SessionHistoryPage() {
|
|
const navigate = useNavigate()
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
|
|
const [sessions, setSessions] = useState<Session[]>([])
|
|
const [trees, setTrees] = useState<TreeListItem[]>([])
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [filter, setFilter] = useState<'all' | 'completed' | 'active'>('all')
|
|
|
|
// Initialize filters from URL params
|
|
const [filters, setFilters] = useState<SessionFilterState>(() => {
|
|
const ticketNumber = searchParams.get('ticket') || ''
|
|
const clientName = searchParams.get('client') || ''
|
|
const treeName = searchParams.get('tree') || ''
|
|
const dateType = (searchParams.get('dateType') || 'started') as 'started' | 'completed'
|
|
|
|
const from = searchParams.get('from')
|
|
const to = searchParams.get('to')
|
|
const dateRange: DateRange | undefined =
|
|
from && to ? { from: new Date(from), to: new Date(to) } : undefined
|
|
|
|
return {
|
|
ticketNumber,
|
|
clientName,
|
|
treeName,
|
|
dateRange,
|
|
dateType,
|
|
}
|
|
})
|
|
|
|
// Load trees for filter dropdown
|
|
useEffect(() => {
|
|
const loadTrees = async () => {
|
|
try {
|
|
const treesData = await treesApi.list({})
|
|
setTrees(treesData)
|
|
} catch (err) {
|
|
console.error('Failed to load trees:', err)
|
|
}
|
|
}
|
|
loadTrees()
|
|
}, [])
|
|
|
|
// Load sessions when filters change
|
|
useEffect(() => {
|
|
loadSessions()
|
|
}, [filter, filters])
|
|
|
|
// Update URL params when filters change
|
|
useEffect(() => {
|
|
const params = new URLSearchParams()
|
|
|
|
if (filters.ticketNumber) params.set('ticket', filters.ticketNumber)
|
|
if (filters.clientName) params.set('client', filters.clientName)
|
|
if (filters.treeName) params.set('tree', filters.treeName)
|
|
if (filters.dateRange?.from) {
|
|
params.set('from', filters.dateRange.from.toISOString())
|
|
params.set('to', (filters.dateRange.to || filters.dateRange.from).toISOString())
|
|
params.set('dateType', filters.dateType)
|
|
}
|
|
|
|
setSearchParams(params, { replace: true })
|
|
}, [filters, setSearchParams])
|
|
|
|
const loadSessions = async () => {
|
|
setIsLoading(true)
|
|
try {
|
|
const params: Record<string, string | boolean> = {}
|
|
|
|
// Tab filter (all/active/completed)
|
|
if (filter !== 'all') {
|
|
params.completed = filter === 'completed'
|
|
}
|
|
|
|
// Search/filter params
|
|
if (filters.ticketNumber) {
|
|
params.ticket_number = filters.ticketNumber
|
|
}
|
|
if (filters.clientName) {
|
|
params.client_name = filters.clientName
|
|
}
|
|
if (filters.treeName) {
|
|
params.tree_name = filters.treeName
|
|
}
|
|
|
|
// Date range params
|
|
if (filters.dateRange?.from) {
|
|
const fromDate = filters.dateRange.from
|
|
const toDate = filters.dateRange.to || filters.dateRange.from
|
|
|
|
if (filters.dateType === 'started') {
|
|
params.started_after = fromDate.toISOString()
|
|
params.started_before = toDate.toISOString()
|
|
} else {
|
|
params.completed_after = fromDate.toISOString()
|
|
params.completed_before = toDate.toISOString()
|
|
}
|
|
}
|
|
|
|
const sessionsData = await sessionsApi.list(params)
|
|
setSessions(sessionsData)
|
|
} catch (err) {
|
|
toast.error('Failed to load sessions')
|
|
console.error(err)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleFilterChange = (newFilters: SessionFilterState) => {
|
|
setFilters(newFilters)
|
|
}
|
|
|
|
const handleClearFilters = () => {
|
|
setFilters({
|
|
ticketNumber: '',
|
|
clientName: '',
|
|
treeName: '',
|
|
dateRange: undefined,
|
|
dateType: 'started',
|
|
})
|
|
}
|
|
|
|
const formatDate = (dateString: string) => {
|
|
return new Date(dateString).toLocaleString()
|
|
}
|
|
|
|
const getTreeName = (session: Session): string => {
|
|
return session.tree_snapshot?.name || 'Unknown Tree'
|
|
}
|
|
|
|
const formatOutcomeLabel = (outcome: Session['outcome']): string => {
|
|
if (!outcome) return 'Not set'
|
|
return outcome === 'workaround'
|
|
? 'Workaround'
|
|
: outcome.charAt(0).toUpperCase() + outcome.slice(1)
|
|
}
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
|
<div className="mb-8">
|
|
<h1 className="text-2xl font-heading font-bold text-foreground sm:text-3xl">Session History</h1>
|
|
<p className="mt-2 text-muted-foreground">
|
|
Search and filter your troubleshooting sessions
|
|
</p>
|
|
</div>
|
|
|
|
{/* Filter Tabs */}
|
|
<div className="mb-6 flex gap-2 border-b border-border">
|
|
{(['all', 'active', 'completed'] as const).map((tab) => (
|
|
<button
|
|
key={tab}
|
|
onClick={() => setFilter(tab)}
|
|
className={cn(
|
|
'px-4 py-2 text-sm font-medium transition-colors',
|
|
filter === tab
|
|
? 'border-b-2 border-primary text-foreground'
|
|
: 'text-muted-foreground hover:text-foreground'
|
|
)}
|
|
>
|
|
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Search and Filter Controls */}
|
|
<div className="mb-6">
|
|
<SessionFilters
|
|
filters={filters}
|
|
onChange={handleFilterChange}
|
|
onClear={handleClearFilters}
|
|
trees={trees}
|
|
/>
|
|
</div>
|
|
|
|
{/* Loading State */}
|
|
{isLoading ? (
|
|
<div className="flex justify-center py-12">
|
|
<Spinner />
|
|
</div>
|
|
) : sessions.length === 0 ? (
|
|
<EmptyState
|
|
title="No sessions found"
|
|
description={filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from
|
|
? "Try adjusting your filters"
|
|
: "Complete a flow to see it here"}
|
|
action={
|
|
(filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from) ? (
|
|
<button onClick={handleClearFilters} className="text-foreground hover:underline text-sm">
|
|
Clear all filters
|
|
</button>
|
|
) : undefined
|
|
}
|
|
/>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{sessions.map((session) => (
|
|
<div
|
|
key={session.id}
|
|
className="bg-card border border-border rounded-xl p-4 transition-all hover:bg-accent/50"
|
|
>
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
<div className="flex-1">
|
|
{/* Status and Ticket/Client */}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span
|
|
className={cn(
|
|
'inline-block h-2.5 w-2.5 rounded-full',
|
|
session.completed_at ? 'bg-green-500' : 'bg-yellow-500'
|
|
)}
|
|
/>
|
|
<span className="font-medium text-foreground">
|
|
{session.ticket_number || 'No ticket'}
|
|
</span>
|
|
{session.client_name && (
|
|
<span className="rounded-full bg-accent px-2.5 py-0.5 text-xs font-medium text-foreground">
|
|
{session.client_name}
|
|
</span>
|
|
)}
|
|
{session.completed_at && (
|
|
<span
|
|
className={cn(
|
|
'rounded-full px-2.5 py-0.5 text-xs font-medium',
|
|
session.outcome === 'resolved' && 'bg-emerald-500/20 text-emerald-300',
|
|
session.outcome === 'workaround' && 'bg-amber-500/20 text-amber-300',
|
|
session.outcome === 'escalated' && 'bg-blue-500/20 text-blue-300',
|
|
session.outcome === 'unresolved' && 'bg-rose-500/20 text-rose-300',
|
|
!session.outcome && 'bg-accent text-muted-foreground'
|
|
)}
|
|
>
|
|
{formatOutcomeLabel(session.outcome)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tree Name */}
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
<span className="font-medium">Tree:</span> {getTreeName(session)}
|
|
</p>
|
|
|
|
{/* Timestamps */}
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
Started: {formatDate(session.started_at)}
|
|
{session.completed_at && (
|
|
<> · Completed: {formatDate(session.completed_at)}</>
|
|
)}
|
|
</p>
|
|
|
|
{/* Stats */}
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
{session.decisions.length} decision{session.decisions.length !== 1 ? 's' : ''} recorded
|
|
{session.scratchpad && session.scratchpad.trim() && (
|
|
<span> · Has notes</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => navigate(`/sessions/${session.id}`)}
|
|
className={cn(
|
|
'rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground',
|
|
'hover:bg-accent hover:text-foreground'
|
|
)}
|
|
>
|
|
View Details
|
|
</button>
|
|
{!session.completed_at && (
|
|
<button
|
|
onClick={() => navigate(getSessionResumePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })}
|
|
className={cn(
|
|
'rounded-md bg-gradient-brand px-3 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
|
'hover:opacity-90'
|
|
)}
|
|
>
|
|
Resume
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default SessionHistoryPage
|