Files
resolutionflow/frontend/src/pages/SessionDetailPage.tsx
Michael Chihlas 0e0e3572f4 refactor: replace barrel imports with direct module imports for tree-shaking
Replace all `from '@/api'` barrel imports with direct imports from
specific module files (e.g. `from '@/api/trees'`) across 20 files.
This enables better tree-shaking so each page only bundles the API
modules it actually uses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 18:52:14 -05:00

437 lines
15 KiB
TypeScript

import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Copy, Check, Eye, Save } from 'lucide-react'
import { sessionsApi } from '@/api/sessions'
import { stepsApi } from '@/api/steps'
import { ExportPreviewModal } from '@/components/session/ExportPreviewModal'
import { SaveSessionAsTreeModal } from '@/components/session/SaveSessionAsTreeModal'
import { StepRatingModal } from '@/components/session/StepRatingModal'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import type { Session, SessionExport, SaveAsTreeRequest, Step } from '@/types'
import { hasRatedSession, markSessionRated } from '@/lib/sessionRatings'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
export function SessionDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { defaultExportFormat } = useUserPreferencesStore()
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' | 'psa'>(defaultExportFormat)
const [exportContent, setExportContent] = useState<string | null>(null)
const [showPreview, setShowPreview] = useState(false)
const [copied, setCopied] = useState(false)
const [copiedPsa, setCopiedPsa] = useState(false)
const [showSaveAsTreeModal, setShowSaveAsTreeModal] = useState(false)
const [isSavingTree, setIsSavingTree] = useState(false)
const [showRatingModal, setShowRatingModal] = useState(false)
const [isSavingRatings, setIsSavingRatings] = useState(false)
const [librarySteps, setLibrarySteps] = useState<Step[]>([])
useEffect(() => {
if (id) {
loadSession()
}
}, [id])
// Auto-show rating modal for completed sessions with library steps
useEffect(() => {
if (!session || !session.completed_at) return
// Check if already rated
if (hasRatedSession(session.id)) return
// Extract library steps from custom_steps
const stepsFromLibrary = session.custom_steps?.filter(
(customStep) => {
// Check if step_data is a Step (from library) by checking if it has an id
const stepData = customStep.step_data
return 'id' in stepData && stepData.id
}
) || []
if (stepsFromLibrary.length === 0) return
// Extract the Step objects
const steps = stepsFromLibrary.map((cs) => cs.step_data as Step)
setLibrarySteps(steps)
// Show modal after 1 second delay
const timer = setTimeout(() => {
setShowRatingModal(true)
}, 1000)
return () => clearTimeout(timer)
}, [session])
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' // psa and text both use .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)
toast.error('Failed to generate export preview')
} 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)
toast.success('Copied to clipboard')
}
} catch (err) {
console.error('Copy failed:', err)
toast.error('Failed to copy to clipboard')
} finally {
setIsExporting(false)
}
}
const handleCopyForTicket = async () => {
if (!session) return
try {
const options: SessionExport = {
format: 'psa',
include_timestamps: true,
include_tree_info: true,
}
const content = await sessionsApi.export(session.id, options)
if (content) {
await navigator.clipboard.writeText(content)
setCopiedPsa(true)
setTimeout(() => setCopiedPsa(false), 2000)
toast.success('Copied ticket notes to clipboard')
}
} catch (err) {
console.error('Copy for ticket failed:', err)
toast.error('Failed to copy ticket notes')
}
}
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 handleSaveAsTree = async (data: SaveAsTreeRequest) => {
if (!session) return
setIsSavingTree(true)
try {
const result = await sessionsApi.saveAsTree(session.id, data)
toast.success(result.message)
setShowSaveAsTreeModal(false)
// Navigate to tree editor with the new tree
navigate(`/trees/${result.tree_id}/edit`)
} catch (err) {
console.error('Failed to save session as tree:', err)
toast.error('Failed to save session as tree')
} finally {
setIsSavingTree(false)
}
}
const getDefaultTreeName = () => {
if (!session) return ''
const treeName = session.tree_snapshot?.name || 'Tree'
const ticket = session.ticket_number ? ` - ${session.ticket_number}` : ''
return `${treeName}${ticket}`
}
const handleSubmitRatings = async (ratings: Map<string, { rating: number; helpful: boolean | null; review: string }>) => {
if (!session) return
setIsSavingRatings(true)
try {
// Submit each rating individually
const ratingPromises = Array.from(ratings.entries()).map(([stepId, data]) =>
stepsApi.rate(stepId, {
rating: data.rating,
review_text: data.review || undefined,
was_helpful: data.helpful !== null ? data.helpful : undefined,
session_id: session.id,
is_verified_use: true
})
)
await Promise.all(ratingPromises)
toast.success(`Submitted ${ratings.size} rating${ratings.size > 1 ? 's' : ''}!`)
markSessionRated(session.id)
setShowRatingModal(false)
} catch (err) {
console.error('Failed to submit ratings:', err)
toast.error('Failed to submit ratings')
} finally {
setIsSavingRatings(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-white/20 border-t-white" />
</div>
)
}
if (error || !session) {
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<div className="rounded-md border border-red-400/20 bg-red-400/10 p-4 text-red-400">
{error || 'Session not found'}
</div>
<button
onClick={() => navigate('/sessions')}
className="mt-4 text-white hover:underline"
>
Back to sessions
</button>
</div>
)
}
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
{/* Header */}
<div className="mb-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<button
onClick={() => navigate('/sessions')}
className="mb-2 text-sm text-white/40 hover:text-white"
>
Back to sessions
</button>
<h1 className="text-2xl font-bold text-white sm:text-3xl">
{session.ticket_number || 'Session Details'}
</h1>
<div className="mt-2 flex items-center gap-4 text-sm text-white/40">
<span
className={cn(
'flex items-center gap-1',
session.completed_at ? 'text-emerald-400' : 'text-yellow-400'
)}
>
<span
className={cn(
'h-2.5 w-2.5 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>
{/* Actions */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
{/* Save as Tree - Only for completed sessions */}
{session.completed_at && (
<button
onClick={() => setShowSaveAsTreeModal(true)}
disabled={isSavingTree}
className={cn(
'flex items-center gap-2 rounded-md border border-white/10 bg-transparent px-4 py-2 text-sm font-medium text-white/60',
'hover:bg-white/10 hover:text-white disabled:opacity-50'
)}
>
<Save className="h-4 w-4" />
Save as Tree
</button>
)}
{/* Copy for Ticket */}
<button
onClick={handleCopyForTicket}
className={cn(
'flex items-center gap-2 rounded-md bg-white px-4 py-2 text-sm font-medium text-black',
'hover:bg-white/90'
)}
>
{copiedPsa ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
{copiedPsa ? 'Copied!' : 'Copy for Ticket'}
</button>
{/* Export Controls */}
<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(
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white sm:w-auto',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
)}
>
<option value="markdown">Markdown</option>
<option value="text">Plain Text</option>
<option value="html">HTML</option>
<option value="psa">PSA / Ticket Note</option>
</select>
<button
onClick={handleCopy}
disabled={isExporting}
title="Copy to clipboard"
className={cn(
'rounded-md border border-white/10 bg-transparent p-2 text-white/60',
'hover:bg-white/10 hover:text-white disabled:opacity-50'
)}
>
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
</button>
<button
onClick={handlePreview}
disabled={isExporting}
className={cn(
'flex items-center gap-2 rounded-md bg-white px-4 py-2 text-sm font-medium text-black',
'hover:bg-white/90 disabled:opacity-50'
)}
>
<Eye className="h-4 w-4" />
{isExporting ? 'Loading...' : 'Preview'}
</button>
</div>
</div>
</div>
</div>
{/* Timeline */}
<div className="mb-8">
<h2 className="mb-4 text-lg font-semibold text-white">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-white" />
<span className="text-white/40">
Session started: {formatDate(session.started_at)}
</span>
</div>
{session.decisions.map((decision, index) => (
<div key={index} className="ml-1 border-l-2 border-white/[0.06] pl-6">
<div className="relative">
<span className="absolute -left-[1.625rem] top-1 h-2 w-2 rounded-full bg-white/20" />
<div className="glass-card rounded-xl p-4">
{decision.question && (
<p className="font-medium text-white">{decision.question}</p>
)}
{decision.answer && (
<p className="mt-1 text-sm text-white">Answer: {decision.answer}</p>
)}
{decision.action_performed && (
<p className="mt-1 text-sm text-white/40">
Action: {decision.action_performed}
</p>
)}
{decision.notes && (
<p className="mt-2 rounded bg-white/5 p-2 text-sm text-white/40">
Notes: {decision.notes}
</p>
)}
<p className="mt-2 text-xs text-white/40">
{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-emerald-400">
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}
/>
{/* Save as Tree Modal */}
<SaveSessionAsTreeModal
isOpen={showSaveAsTreeModal}
onClose={() => setShowSaveAsTreeModal(false)}
onSave={handleSaveAsTree}
defaultTreeName={getDefaultTreeName()}
isSaving={isSavingTree}
/>
{/* Step Rating Modal */}
<StepRatingModal
isOpen={showRatingModal}
onClose={() => setShowRatingModal(false)}
onSubmit={handleSubmitRatings}
librarySteps={librarySteps}
isSaving={isSavingRatings}
/>
</div>
)
}
export default SessionDetailPage