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