Complete Phase 2: Frontend implementation with React + TypeScript

Frontend Features:
- React 18 + Vite + TypeScript + Tailwind CSS + Zustand
- JWT authentication with automatic token refresh
- Tree library with search and category filtering
- Full tree navigation (decision/action/solution nodes)
- Session management with notes and completion
- Session history with export (Markdown/Text/HTML)
- ErrorBoundary for graceful error handling

Backend Fixes:
- CORS: Added port 5174 to allowed origins
- Sessions: Fixed JSONB datetime serialization (mode='json')

Documentation:
- Updated PROGRESS.md with Phase 2 completion
- Updated 03-DEVELOPMENT-ROADMAP.md with checked items
- Added PHASE-2.5-PERSONAL-BRANCHING.md spec

Seed Data:
- Added backend/scripts/seed_data.py with Password Reset tree

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-01-27 22:42:22 -05:00
parent 7d96807fb1
commit cd10ecd42c
51 changed files with 9014 additions and 111 deletions

View File

@@ -0,0 +1,118 @@
import { useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '@/store/authStore'
import { cn } from '@/lib/utils'
export function LoginPage() {
const navigate = useNavigate()
const location = useLocation()
const { login, isLoading, error, clearError } = useAuthStore()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [localError, setLocalError] = useState('')
const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/trees'
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLocalError('')
clearError()
if (!email || !password) {
setLocalError('Please enter both email and password')
return
}
try {
await login({ email, password })
navigate(from, { replace: true })
} catch {
// Error is set in the store
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight text-foreground">Apoklisis</h1>
<p className="mt-2 text-muted-foreground">Sign in to your account</p>
</div>
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
<div className="space-y-4 rounded-lg border border-border bg-card p-6 shadow-sm">
{(error || localError) && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{localError || error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-foreground">
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className={cn(
'mt-1 block w-full rounded-md border border-input bg-background px-3 py-2',
'text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
placeholder="you@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className={cn(
'mt-1 block w-full rounded-md border border-input bg-background px-3 py-2',
'text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
placeholder="••••••••••"
/>
</div>
<button
type="submit"
disabled={isLoading}
className={cn(
'w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50'
)}
>
{isLoading ? 'Signing in...' : 'Sign in'}
</button>
</div>
<p className="text-center text-sm text-muted-foreground">
Don't have an account?{' '}
<Link to="/register" className="font-medium text-primary hover:text-primary/90">
Register
</Link>
</p>
</form>
</div>
</div>
)
}
export default LoginPage

View File

@@ -0,0 +1,172 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/store/authStore'
import { cn } from '@/lib/utils'
export function RegisterPage() {
const navigate = useNavigate()
const { register, isLoading, error, clearError } = useAuthStore()
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [localError, setLocalError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLocalError('')
clearError()
if (!name || !email || !password) {
setLocalError('Please fill in all fields')
return
}
if (password !== confirmPassword) {
setLocalError('Passwords do not match')
return
}
if (password.length < 10) {
setLocalError('Password must be at least 10 characters')
return
}
try {
await register({ email, password, name })
navigate('/trees', { replace: true })
} catch {
// Error is set in the store
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight text-foreground">Apoklisis</h1>
<p className="mt-2 text-muted-foreground">Create your account</p>
</div>
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
<div className="space-y-4 rounded-lg border border-border bg-card p-6 shadow-sm">
{(error || localError) && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{localError || error}
</div>
)}
<div>
<label htmlFor="name" className="block text-sm font-medium text-foreground">
Full name
</label>
<input
id="name"
name="name"
type="text"
autoComplete="name"
required
value={name}
onChange={(e) => setName(e.target.value)}
className={cn(
'mt-1 block w-full rounded-md border border-input bg-background px-3 py-2',
'text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
placeholder="John Smith"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-foreground">
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className={cn(
'mt-1 block w-full rounded-md border border-input bg-background px-3 py-2',
'text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
placeholder="you@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className={cn(
'mt-1 block w-full rounded-md border border-input bg-background px-3 py-2',
'text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
placeholder="••••••••••"
/>
<p className="mt-1 text-xs text-muted-foreground">
Must be at least 10 characters
</p>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-foreground">
Confirm password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className={cn(
'mt-1 block w-full rounded-md border border-input bg-background px-3 py-2',
'text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
placeholder="••••••••••"
/>
</div>
<button
type="submit"
disabled={isLoading}
className={cn(
'w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50'
)}
>
{isLoading ? 'Creating account...' : 'Create account'}
</button>
</div>
<p className="text-center text-sm text-muted-foreground">
Already have an account?{' '}
<Link to="/login" className="font-medium text-primary hover:text-primary/90">
Sign in
</Link>
</p>
</form>
</div>
</div>
)
}
export default RegisterPage

View File

@@ -0,0 +1,206 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { sessionsApi } from '@/api'
import type { Session, SessionExport } from '@/types'
import { cn } from '@/lib/utils'
export function SessionDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [session, setSession] = useState<Session | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isExporting, setIsExporting] = useState(false)
const [exportFormat, setExportFormat] = useState<'markdown' | 'text' | 'html'>('markdown')
useEffect(() => {
if (id) {
loadSession()
}
}, [id])
const loadSession = async () => {
setIsLoading(true)
setError(null)
try {
const data = await sessionsApi.get(id!)
setSession(data)
} catch (err) {
setError('Failed to load session')
console.error(err)
} finally {
setIsLoading(false)
}
}
const handleExport = async () => {
if (!session) return
setIsExporting(true)
try {
const options: SessionExport = {
format: exportFormat,
include_timestamps: true,
include_tree_info: true,
}
const content = await sessionsApi.export(session.id, options)
// Create download
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `session-${session.ticket_number || session.id}.${exportFormat === 'markdown' ? 'md' : exportFormat === 'html' ? 'html' : 'txt'}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (err) {
console.error('Export failed:', err)
} finally {
setIsExporting(false)
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString()
}
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
if (error || !session) {
return (
<div className="container mx-auto px-4 py-8">
<div className="rounded-md bg-destructive/10 p-4 text-destructive">
{error || 'Session not found'}
</div>
<button
onClick={() => navigate('/sessions')}
className="mt-4 text-primary hover:underline"
>
Back to sessions
</button>
</div>
)
}
return (
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="mb-8 flex items-start justify-between">
<div>
<button
onClick={() => navigate('/sessions')}
className="mb-2 text-sm text-muted-foreground hover:text-foreground"
>
Back to sessions
</button>
<h1 className="text-3xl font-bold text-foreground">
{session.ticket_number || 'Session Details'}
</h1>
<div className="mt-2 flex items-center gap-4 text-sm text-muted-foreground">
<span
className={cn(
'flex items-center gap-1',
session.completed_at ? 'text-green-600' : 'text-yellow-600'
)}
>
<span
className={cn(
'h-2 w-2 rounded-full',
session.completed_at ? 'bg-green-500' : 'bg-yellow-500'
)}
/>
{session.completed_at ? 'Completed' : 'In Progress'}
</span>
{session.client_name && <span>Client: {session.client_name}</span>}
</div>
</div>
{/* Export */}
<div className="flex items-center gap-2">
<select
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value as typeof exportFormat)}
className={cn(
'rounded-md border border-input bg-background px-3 py-2 text-sm',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
>
<option value="markdown">Markdown</option>
<option value="text">Plain Text</option>
<option value="html">HTML</option>
</select>
<button
onClick={handleExport}
disabled={isExporting}
className={cn(
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90 disabled:opacity-50'
)}
>
{isExporting ? 'Exporting...' : 'Export'}
</button>
</div>
</div>
{/* Timeline */}
<div className="mb-8">
<h2 className="mb-4 text-lg font-semibold text-foreground">Decision Timeline</h2>
<div className="space-y-4">
<div className="flex items-center gap-3 text-sm">
<span className="h-3 w-3 rounded-full bg-primary" />
<span className="text-muted-foreground">
Session started: {formatDate(session.started_at)}
</span>
</div>
{session.decisions.map((decision, index) => (
<div key={index} className="ml-1 border-l-2 border-border pl-6">
<div className="relative">
<span className="absolute -left-[1.625rem] top-1 h-2 w-2 rounded-full bg-border" />
<div className="rounded-lg border border-border bg-card p-4">
{decision.question && (
<p className="font-medium text-card-foreground">{decision.question}</p>
)}
{decision.answer && (
<p className="mt-1 text-sm text-primary">Answer: {decision.answer}</p>
)}
{decision.action_performed && (
<p className="mt-1 text-sm text-muted-foreground">
Action: {decision.action_performed}
</p>
)}
{decision.notes && (
<p className="mt-2 rounded bg-muted/50 p-2 text-sm text-muted-foreground">
Notes: {decision.notes}
</p>
)}
<p className="mt-2 text-xs text-muted-foreground">
{formatDate(decision.timestamp)}
</p>
</div>
</div>
</div>
))}
{session.completed_at && (
<div className="flex items-center gap-3 text-sm">
<span className="h-3 w-3 rounded-full bg-green-500" />
<span className="text-green-600">
Session completed: {formatDate(session.completed_at)}
</span>
</div>
)}
</div>
</div>
</div>
)
}
export default SessionDetailPage

View File

@@ -0,0 +1,152 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { sessionsApi } from '@/api'
import type { Session } from '@/types'
import { cn } from '@/lib/utils'
export function SessionHistoryPage() {
const navigate = useNavigate()
const [sessions, setSessions] = useState<Session[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filter, setFilter] = useState<'all' | 'completed' | 'active'>('all')
useEffect(() => {
loadSessions()
}, [filter])
const loadSessions = async () => {
setIsLoading(true)
setError(null)
try {
const params = filter === 'all' ? {} : { completed: filter === 'completed' }
const sessionsData = await sessionsApi.list(params)
setSessions(sessionsData)
} catch (err) {
setError('Failed to load sessions')
console.error(err)
} finally {
setIsLoading(false)
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString()
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Session History</h1>
<p className="mt-2 text-muted-foreground">
View and manage 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-primary'
: 'text-muted-foreground hover:text-foreground'
)}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
))}
</div>
{/* Error State */}
{error && (
<div className="mb-6 rounded-md bg-destructive/10 p-4 text-destructive">
{error}
</div>
)}
{/* Loading State */}
{isLoading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : sessions.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
No sessions found.{' '}
<button
onClick={() => navigate('/trees')}
className="text-primary hover:underline"
>
Start a new session
</button>
</div>
) : (
<div className="space-y-4">
{sessions.map((session) => (
<div
key={session.id}
className="rounded-lg border border-border bg-card p-4 shadow-sm transition-shadow hover:shadow-md"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<span
className={cn(
'inline-block h-2 w-2 rounded-full',
session.completed_at ? 'bg-green-500' : 'bg-yellow-500'
)}
/>
<span className="font-medium text-card-foreground">
{session.ticket_number || 'No ticket'}
</span>
{session.client_name && (
<span className="text-muted-foreground">
· {session.client_name}
</span>
)}
</div>
<p className="mt-1 text-sm text-muted-foreground">
Started: {formatDate(session.started_at)}
{session.completed_at && (
<> · Completed: {formatDate(session.completed_at)}</>
)}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{session.decisions.length} decisions recorded
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => navigate(`/sessions/${session.id}`)}
className={cn(
'rounded-md border border-input px-3 py-1.5 text-sm font-medium',
'hover:bg-accent hover:text-accent-foreground'
)}
>
View Details
</button>
{!session.completed_at && (
<button
onClick={() => navigate(`/trees/${session.tree_id}/navigate`, { state: { sessionId: session.id } })}
className={cn(
'rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90'
)}
>
Resume
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}
export default SessionHistoryPage

View File

@@ -0,0 +1,168 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { treesApi } from '@/api'
import type { TreeListItem } from '@/types'
import { cn } from '@/lib/utils'
export function TreeLibraryPage() {
const navigate = useNavigate()
const [trees, setTrees] = useState<TreeListItem[]>([])
const [categories, setCategories] = useState<string[]>([])
const [selectedCategory, setSelectedCategory] = useState<string>('')
const [searchQuery, setSearchQuery] = useState('')
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
loadData()
}, [selectedCategory])
const loadData = async () => {
setIsLoading(true)
setError(null)
try {
const [treesData, categoriesData] = await Promise.all([
treesApi.list({ category: selectedCategory || undefined }),
treesApi.categories(),
])
setTrees(treesData)
setCategories(categoriesData)
} catch (err) {
setError('Failed to load trees')
console.error(err)
} finally {
setIsLoading(false)
}
}
const handleSearch = async () => {
if (!searchQuery.trim()) {
loadData()
return
}
setIsLoading(true)
setError(null)
try {
const results = await treesApi.search(searchQuery, selectedCategory || undefined)
setTrees(results)
} catch (err) {
setError('Search failed')
console.error(err)
} finally {
setIsLoading(false)
}
}
const handleStartSession = (treeId: string) => {
navigate(`/trees/${treeId}/navigate`)
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Decision Trees</h1>
<p className="mt-2 text-muted-foreground">
Select a troubleshooting tree to start a new session
</p>
</div>
{/* Search and Filter */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row">
<div className="flex flex-1 gap-2">
<input
type="text"
placeholder="Search trees..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className={cn(
'flex-1 rounded-md border border-input bg-background px-3 py-2',
'text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
/>
<button
onClick={handleSearch}
className={cn(
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90'
)}
>
Search
</button>
</div>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className={cn(
'rounded-md border border-input bg-background px-3 py-2',
'text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
>
<option value="">All Categories</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</select>
</div>
{/* Error State */}
{error && (
<div className="mb-6 rounded-md bg-destructive/10 p-4 text-destructive">
{error}
</div>
)}
{/* Loading State */}
{isLoading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : trees.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
No trees found. {searchQuery && 'Try adjusting your search.'}
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{trees.map((tree) => (
<div
key={tree.id}
className="rounded-lg border border-border bg-card p-6 shadow-sm transition-shadow hover:shadow-md"
>
<div className="mb-2 flex items-start justify-between">
<h3 className="font-semibold text-card-foreground">{tree.name}</h3>
{tree.category && (
<span className="rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
{tree.category}
</span>
)}
</div>
<p className="mb-4 text-sm text-muted-foreground line-clamp-2">
{tree.description || 'No description available'}
</p>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
v{tree.version} · {tree.usage_count} uses
</span>
<button
onClick={() => handleStartSession(tree.id)}
className={cn(
'rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90'
)}
>
Start Session
</button>
</div>
</div>
))}
</div>
)}
</div>
)
}
export default TreeLibraryPage

View File

@@ -0,0 +1,484 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate, useLocation } from 'react-router-dom'
import { treesApi, sessionsApi } from '@/api'
import type { Tree, Session, DecisionRecord, TreeStructure } from '@/types'
import { cn } from '@/lib/utils'
interface LocationState {
sessionId?: string
}
export function TreeNavigationPage() {
const { id: treeId } = useParams<{ id: string }>()
const navigate = useNavigate()
const location = useLocation()
const locationState = location.state as LocationState | undefined
const [tree, setTree] = useState<Tree | null>(null)
const [session, setSession] = useState<Session | null>(null)
const [currentNodeId, setCurrentNodeId] = useState<string>('root')
const [pathTaken, setPathTaken] = useState<string[]>(['root'])
const [decisions, setDecisions] = useState<DecisionRecord[]>([])
const [notes, setNotes] = useState<string>('')
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isCompleting, setIsCompleting] = useState(false)
// Session metadata
const [ticketNumber, setTicketNumber] = useState<string>('')
const [clientName, setClientName] = useState<string>('')
const [showMetadataForm, setShowMetadataForm] = useState(true)
useEffect(() => {
if (treeId) {
loadTreeAndSession()
}
}, [treeId])
const loadTreeAndSession = async () => {
setIsLoading(true)
setError(null)
try {
const treeData = await treesApi.get(treeId!)
setTree(treeData)
// If resuming a session
if (locationState?.sessionId) {
const sessionData = await sessionsApi.get(locationState.sessionId)
setSession(sessionData)
setPathTaken(sessionData.path_taken)
setCurrentNodeId(sessionData.path_taken[sessionData.path_taken.length - 1] || 'root')
setDecisions(sessionData.decisions as DecisionRecord[])
setTicketNumber(sessionData.ticket_number || '')
setClientName(sessionData.client_name || '')
setShowMetadataForm(false)
}
} catch (err) {
setError('Failed to load tree')
console.error(err)
} finally {
setIsLoading(false)
}
}
const startSession = async () => {
if (!tree) return
setIsLoading(true)
try {
const newSession = await sessionsApi.create({
tree_id: tree.id,
ticket_number: ticketNumber || undefined,
client_name: clientName || undefined,
})
setSession(newSession)
setShowMetadataForm(false)
} catch (err) {
setError('Failed to start session')
console.error(err)
} finally {
setIsLoading(false)
}
}
const findNode = (nodeId: string, structure: TreeStructure = tree?.tree_structure!): TreeStructure | null => {
if (structure.id === nodeId) return structure
if (structure.children) {
for (const child of structure.children) {
const found = findNode(nodeId, child)
if (found) return found
}
}
return null
}
const handleSelectOption = async (_optionId: string, optionLabel: string, nextNodeId: string) => {
if (!session || !tree) return
const currentNode = findNode(currentNodeId)
if (!currentNode) return
const newDecision: DecisionRecord = {
node_id: currentNodeId,
question: currentNode.question || null,
answer: optionLabel,
action_performed: null,
notes: notes || null,
automation_used: false,
timestamp: new Date().toISOString(),
attachments: [],
}
const newPath = [...pathTaken, nextNodeId]
const newDecisions = [...decisions, newDecision]
setPathTaken(newPath)
setDecisions(newDecisions)
setCurrentNodeId(nextNodeId)
setNotes('')
// Update session on backend
try {
await sessionsApi.update(session.id, {
path_taken: newPath,
decisions: newDecisions,
})
} catch (err) {
console.error('Failed to update session:', err)
}
}
const handleContinue = async (actionPerformed?: string) => {
if (!session || !tree) return
const currentNode = findNode(currentNodeId)
if (!currentNode || !currentNode.next_node_id) return
const newDecision: DecisionRecord = {
node_id: currentNodeId,
question: null,
answer: null,
action_performed: actionPerformed || currentNode.title || 'Action completed',
notes: notes || null,
automation_used: false,
timestamp: new Date().toISOString(),
attachments: [],
}
const newPath = [...pathTaken, currentNode.next_node_id]
const newDecisions = [...decisions, newDecision]
setPathTaken(newPath)
setDecisions(newDecisions)
setCurrentNodeId(currentNode.next_node_id)
setNotes('')
try {
await sessionsApi.update(session.id, {
path_taken: newPath,
decisions: newDecisions,
})
} catch (err) {
console.error('Failed to update session:', err)
}
}
const handleComplete = async () => {
if (!session) return
setIsCompleting(true)
setError(null)
try {
// Add final decision
const currentNode = findNode(currentNodeId)
if (currentNode) {
const finalDecision: DecisionRecord = {
node_id: currentNodeId,
question: null,
answer: null,
action_performed: currentNode.title || 'Session completed',
notes: notes || null,
automation_used: false,
timestamp: new Date().toISOString(),
attachments: [],
}
await sessionsApi.update(session.id, {
decisions: [...decisions, finalDecision],
})
}
await sessionsApi.complete(session.id)
navigate(`/sessions/${session.id}`)
} catch (err) {
console.error('Failed to complete session:', err)
setError('Failed to complete session. Check console for details.')
} finally {
setIsCompleting(false)
}
}
const handleGoBack = () => {
if (pathTaken.length <= 1) return
const newPath = pathTaken.slice(0, -1)
const newDecisions = decisions.slice(0, -1)
setPathTaken(newPath)
setDecisions(newDecisions)
setCurrentNodeId(newPath[newPath.length - 1])
}
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
if (error || !tree) {
return (
<div className="container mx-auto px-4 py-8">
<div className="rounded-md bg-destructive/10 p-4 text-destructive">
{error || 'Tree not found'}
</div>
<button
onClick={() => navigate('/trees')}
className="mt-4 text-primary hover:underline"
>
Back to trees
</button>
</div>
)
}
// Session metadata form
if (showMetadataForm) {
return (
<div className="container mx-auto max-w-lg px-4 py-8">
<h1 className="mb-2 text-2xl font-bold text-foreground">{tree.name}</h1>
<p className="mb-6 text-muted-foreground">{tree.description}</p>
<div className="space-y-4 rounded-lg border border-border bg-card p-6">
<h2 className="font-semibold text-card-foreground">Session Details</h2>
<p className="text-sm text-muted-foreground">
Optional: Add ticket and client info for easier tracking
</p>
<div>
<label className="block text-sm font-medium text-foreground">
Ticket Number
</label>
<input
type="text"
value={ticketNumber}
onChange={(e) => setTicketNumber(e.target.value)}
placeholder="e.g., INC0012345"
className={cn(
'mt-1 block w-full rounded-md border border-input bg-background px-3 py-2',
'text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground">
Client Name
</label>
<input
type="text"
value={clientName}
onChange={(e) => setClientName(e.target.value)}
placeholder="e.g., Acme Corp"
className={cn(
'mt-1 block w-full rounded-md border border-input bg-background px-3 py-2',
'text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
/>
</div>
<button
onClick={startSession}
className={cn(
'w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90'
)}
>
Start Troubleshooting
</button>
</div>
</div>
)
}
const currentNode = findNode(currentNodeId)
if (!currentNode) {
return (
<div className="container mx-auto px-4 py-8">
<div className="rounded-md bg-destructive/10 p-4 text-destructive">
Invalid tree structure
</div>
</div>
)
}
return (
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-foreground">{tree.name}</h1>
{(ticketNumber || clientName) && (
<p className="text-sm text-muted-foreground">
{ticketNumber && `Ticket: ${ticketNumber}`}
{ticketNumber && clientName && ' · '}
{clientName && `Client: ${clientName}`}
</p>
)}
</div>
<button
onClick={() => navigate('/sessions')}
className="text-sm text-muted-foreground hover:text-foreground"
>
Exit
</button>
</div>
{/* Breadcrumb */}
<div className="mb-6 flex items-center gap-2 overflow-x-auto text-sm">
{pathTaken.map((nodeId, index) => {
const node = findNode(nodeId)
return (
<span key={nodeId} className="flex items-center gap-2 whitespace-nowrap">
{index > 0 && <span className="text-muted-foreground"></span>}
<span
className={cn(
index === pathTaken.length - 1
? 'font-medium text-foreground'
: 'text-muted-foreground'
)}
>
{node?.question?.slice(0, 30) || node?.title?.slice(0, 30) || nodeId}
{((node?.question?.length || 0) > 30 || (node?.title?.length || 0) > 30) && '...'}
</span>
</span>
)
})}
</div>
{/* Current Node */}
<div className="rounded-lg border border-border bg-card p-6 shadow-sm">
{/* Decision Node */}
{currentNode.type === 'decision' && (
<>
<h2 className="mb-2 text-xl font-semibold text-card-foreground">
{currentNode.question}
</h2>
{currentNode.help_text && (
<p className="mb-4 text-sm text-muted-foreground">{currentNode.help_text}</p>
)}
<div className="mb-4 space-y-2">
{currentNode.options?.map((option) => (
<button
key={option.id}
onClick={() => handleSelectOption(option.id, option.label, option.next_node_id)}
className={cn(
'w-full rounded-md border border-input p-3 text-left transition-colors',
'hover:border-primary hover:bg-accent'
)}
>
{option.label}
</button>
))}
</div>
</>
)}
{/* Action Node */}
{currentNode.type === 'action' && (
<>
<h2 className="mb-2 text-xl font-semibold text-card-foreground">
{currentNode.title}
</h2>
<p className="mb-4 text-muted-foreground">{currentNode.description}</p>
{currentNode.commands && currentNode.commands.length > 0 && (
<div className="mb-4">
<p className="mb-2 text-sm font-medium text-foreground">Commands:</p>
<div className="space-y-1">
{currentNode.commands.map((cmd, index) => (
<code
key={index}
className="block rounded bg-muted p-2 text-sm font-mono"
>
{cmd}
</code>
))}
</div>
</div>
)}
{currentNode.expected_outcome && (
<p className="mb-4 text-sm text-muted-foreground">
<strong>Expected outcome:</strong> {currentNode.expected_outcome}
</p>
)}
{currentNode.next_node_id && (
<button
onClick={() => handleContinue()}
className={cn(
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90'
)}
>
Continue
</button>
)}
</>
)}
{/* Solution Node */}
{currentNode.type === 'solution' && (
<>
<div className="mb-4 flex items-center gap-2">
<span className="rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-800">
Solution
</span>
</div>
<h2 className="mb-2 text-xl font-semibold text-card-foreground">
{currentNode.title}
</h2>
<p className="mb-4 text-muted-foreground">{currentNode.description}</p>
{currentNode.resolution_steps && currentNode.resolution_steps.length > 0 && (
<div className="mb-4">
<p className="mb-2 text-sm font-medium text-foreground">Resolution steps:</p>
<ol className="list-inside list-decimal space-y-1 text-sm text-muted-foreground">
{currentNode.resolution_steps.map((step, index) => (
<li key={index}>{step}</li>
))}
</ol>
</div>
)}
<button
onClick={handleComplete}
disabled={isCompleting}
className={cn(
'rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white',
'hover:bg-green-700 disabled:opacity-50'
)}
>
{isCompleting ? 'Completing...' : 'Complete Session'}
</button>
</>
)}
{/* Notes */}
<div className="mt-6 border-t border-border pt-4">
<label className="block text-sm font-medium text-foreground">
Notes (optional)
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add any notes for this step..."
rows={2}
className={cn(
'mt-1 block w-full rounded-md border border-input bg-background px-3 py-2',
'text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
/>
</div>
{/* Back Button */}
{pathTaken.length > 1 && (
<button
onClick={handleGoBack}
className="mt-4 text-sm text-muted-foreground hover:text-foreground"
>
Go back
</button>
)}
</div>
</div>
)
}
export default TreeNavigationPage

View File

@@ -0,0 +1,6 @@
export { default as LoginPage } from './LoginPage'
export { default as RegisterPage } from './RegisterPage'
export { default as TreeLibraryPage } from './TreeLibraryPage'
export { default as TreeNavigationPage } from './TreeNavigationPage'
export { default as SessionHistoryPage } from './SessionHistoryPage'
export { default as SessionDetailPage } from './SessionDetailPage'