- Add theme store with light/dark/system modes and ThemeToggle component - Prevent flash of wrong theme on initial load via inline script - Add ExportPreviewModal for previewing session exports before download - Add copy-to-clipboard functionality to session export - Implement keyboard shortcuts for tree navigation (1-9 options, Esc back, Enter continue) - Display keyboard hints in tree navigation UI - Fix findNode to safely handle undefined structure parameter - Update page title to "Apoklisis" Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
267 lines
8.7 KiB
TypeScript
267 lines
8.7 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
import { Copy, Check, Eye } from 'lucide-react'
|
|
import { sessionsApi } from '@/api'
|
|
import { ExportPreviewModal } from '@/components/session/ExportPreviewModal'
|
|
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')
|
|
const [exportContent, setExportContent] = useState<string | null>(null)
|
|
const [showPreview, setShowPreview] = useState(false)
|
|
const [copied, setCopied] = useState(false)
|
|
|
|
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 getFilename = () => {
|
|
if (!session) return 'export.txt'
|
|
const ext = exportFormat === 'markdown' ? 'md' : exportFormat === 'html' ? 'html' : 'txt'
|
|
return `session-${session.ticket_number || session.id}.${ext}`
|
|
}
|
|
|
|
const fetchExportContent = async () => {
|
|
if (!session) return null
|
|
const options: SessionExport = {
|
|
format: exportFormat,
|
|
include_timestamps: true,
|
|
include_tree_info: true,
|
|
}
|
|
return await sessionsApi.export(session.id, options)
|
|
}
|
|
|
|
const handlePreview = async () => {
|
|
setIsExporting(true)
|
|
try {
|
|
const content = await fetchExportContent()
|
|
if (content) {
|
|
setExportContent(content)
|
|
setShowPreview(true)
|
|
}
|
|
} catch (err) {
|
|
console.error('Export failed:', err)
|
|
} finally {
|
|
setIsExporting(false)
|
|
}
|
|
}
|
|
|
|
const handleCopy = async () => {
|
|
setIsExporting(true)
|
|
try {
|
|
const content = await fetchExportContent()
|
|
if (content) {
|
|
await navigator.clipboard.writeText(content)
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 2000)
|
|
}
|
|
} catch (err) {
|
|
console.error('Copy failed:', err)
|
|
} finally {
|
|
setIsExporting(false)
|
|
}
|
|
}
|
|
|
|
const handleDownload = () => {
|
|
if (!exportContent || !session) return
|
|
const blob = new Blob([exportContent], { type: 'text/plain' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = getFilename()
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
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)}
|
|
aria-label="Export format"
|
|
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={handleCopy}
|
|
disabled={isExporting}
|
|
title="Copy to clipboard"
|
|
className={cn(
|
|
'rounded-md border border-input bg-background p-2 text-muted-foreground',
|
|
'hover:bg-accent hover:text-accent-foreground disabled:opacity-50'
|
|
)}
|
|
>
|
|
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
|
</button>
|
|
<button
|
|
onClick={handlePreview}
|
|
disabled={isExporting}
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
|
'hover:bg-primary/90 disabled:opacity-50'
|
|
)}
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
{isExporting ? 'Loading...' : 'Preview'}
|
|
</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>
|
|
|
|
{/* Export Preview Modal */}
|
|
<ExportPreviewModal
|
|
isOpen={showPreview}
|
|
onClose={() => setShowPreview(false)}
|
|
content={exportContent || ''}
|
|
filename={getFilename()}
|
|
format={exportFormat}
|
|
onDownload={handleDownload}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default SessionDetailPage
|