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:
206
frontend/src/pages/SessionDetailPage.tsx
Normal file
206
frontend/src/pages/SessionDetailPage.tsx
Normal 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
|
||||
Reference in New Issue
Block a user