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,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