Files
resolutionflow/frontend/src/pages/SessionDetailPage.tsx
Michael Chihlas 303a558432 refactor: replace hardcoded hex values with Tailwind semantic tokens
3,200+ hardcoded color values replaced with CSS variable-backed
Tailwind classes (bg-card, text-foreground, border-border, etc.).
Enables light mode via CSS variable swap. Only syntax highlighting
colors and intentional one-offs remain hardcoded (~15 values).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 04:34:35 -04:00

623 lines
24 KiB
TypeScript

import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Copy, Check, Eye, Save, Share2, CheckCircle2, AlertTriangle, ArrowUpRight, HelpCircle, Flag, Sparkles, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { sessionsApi } from '@/api/sessions'
import { stepsApi } from '@/api/steps'
import { treesApi } from '@/api/trees'
import { sessionToFlowApi } from '@/api/sessionToFlow'
import { ExportPreviewModal } from '@/components/session/ExportPreviewModal'
import { SaveSessionAsTreeModal } from '@/components/session/SaveSessionAsTreeModal'
import { analytics } from '@/lib/analytics'
import { ShareSessionModal } from '@/components/session/ShareSessionModal'
import { SessionOutcomeModal } from '@/components/session/SessionOutcomeModal'
import { SessionTimeline } from '@/components/session/SessionTimeline'
import { StepRatingModal } from '@/components/session/StepRatingModal'
import { ActionMenu } from '@/components/common/ActionMenu'
import type { MenuAction } from '@/components/common/ActionMenu'
import type { SessionOutcome } from '@/types'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import type { Session, SessionExport, SaveAsTreeRequest, Step, RedactionSummary } from '@/types'
import { hasRatedSession, markSessionRated } from '@/lib/sessionRatings'
import { Spinner } from '@/components/common/Spinner'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import { getTreeEditorPath } from '@/lib/routing'
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' | 'pdf'>(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[]>([])
const [showShareModal, setShowShareModal] = useState(false)
const [showOutcomeModal, setShowOutcomeModal] = useState(false)
const [isCompleting, setIsCompleting] = useState(false)
const [maxStepIndex, setMaxStepIndex] = useState<number | null>(null)
const [detailLevel, setDetailLevel] = useState<'standard' | 'full'>('standard')
const [includeSummary, setIncludeSummary] = useState(false)
const [redactionMode, setRedactionMode] = useState<'none' | 'mask'>('none')
const [redactionSummary, setRedactionSummary] = useState<RedactionSummary | null>(null)
const [isGeneratingFlow, setIsGeneratingFlow] = useState(false)
const [pdfLoading, setPdfLoading] = useState(false)
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 buildExportOptions = (overrides?: Partial<SessionExport>): SessionExport => ({
format: exportFormat,
include_timestamps: true,
include_tree_info: true,
...(maxStepIndex !== null && { max_step_index: maxStepIndex }),
detail_level: detailLevel,
include_summary: includeSummary,
redaction_mode: redactionMode,
...overrides,
})
const fetchExportContent = async () => {
if (!session) return null
const result = await sessionsApi.exportWithMeta(session.id, buildExportOptions())
setRedactionSummary(result.redactionSummary)
return result.content
}
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')
analytics.exportGenerated({ session_id: session?.id || '', format: exportFormat })
}
} 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 content = await sessionsApi.export(session.id, buildExportOptions({ format: 'psa' }))
if (content) {
await navigator.clipboard.writeText(content)
setCopiedPsa(true)
setTimeout(() => setCopiedPsa(false), 2000)
toast.success('Copied ticket notes to clipboard')
analytics.exportGenerated({ session_id: session.id, format: 'psa' })
}
} catch (err) {
console.error('Copy for ticket failed:', err)
toast.error('Failed to copy ticket notes')
}
}
const handleDownload = (content: string) => {
if (!session) return
analytics.exportGenerated({ session_id: session.id, format: exportFormat })
const blob = new Blob([content], { 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 handleToggleSummary = async (include: boolean) => {
setIncludeSummary(include)
if (!session) return
try {
const result = await sessionsApi.exportWithMeta(session.id, buildExportOptions({ include_summary: include }))
setExportContent(result.content)
setRedactionSummary(result.redactionSummary)
toast.success(include ? 'Summary added' : 'Summary removed')
} catch (err) {
console.error('Failed to re-fetch export:', err)
toast.error('Failed to update export')
setIncludeSummary(!include)
}
}
const handleToggleRedaction = async (enabled: boolean) => {
const mode = enabled ? 'mask' as const : 'none' as const
setRedactionMode(mode)
if (!session) return
try {
const result = await sessionsApi.exportWithMeta(session.id, buildExportOptions({ redaction_mode: mode }))
setExportContent(result.content)
setRedactionSummary(result.redactionSummary)
toast.success(enabled ? 'Sensitive data masked' : 'Redaction removed')
} catch (err) {
console.error('Failed to re-fetch export:', err)
toast.error('Failed to update export')
setRedactionMode(enabled ? 'none' : 'mask')
}
}
const handleDownloadPdf = async () => {
if (!session) return
setPdfLoading(true)
try {
const { apiClient } = await import('@/api/client')
const response = await apiClient.post(
`/sessions/${session.id}/export`,
{ format: 'pdf' },
{ responseType: 'blob' }
)
const url = URL.createObjectURL(response.data)
const a = document.createElement('a')
a.href = url
a.download = `session-export-${session.id}.pdf`
a.click()
URL.revokeObjectURL(url)
analytics.exportGenerated({ session_id: session.id, format: 'pdf' })
} catch (error) {
console.error('PDF export failed:', error)
toast.error('Failed to generate PDF export')
} finally {
setPdfLoading(false)
}
}
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 handleCompleteSession = async (data: { outcome: SessionOutcome; outcome_notes?: string; next_steps?: string }) => {
if (!session) return
setIsCompleting(true)
try {
const updated = await sessionsApi.complete(session.id, data)
setSession(updated)
setShowOutcomeModal(false)
toast.success('Session completed')
} catch {
toast.error('Failed to complete session')
} finally {
setIsCompleting(false)
}
}
const handleCreateFlowFromSession = async () => {
if (!session) return
setIsGeneratingFlow(true)
try {
const flowData = await sessionToFlowApi.generate(session.id)
analytics.aiFeatureUsed({ feature: 'session_to_flow' })
const tree = await treesApi.create({
name: flowData.name,
description: flowData.description,
tree_type: flowData.tree_type as import('@/types').TreeType,
tree_structure: flowData.tree_structure,
tags: flowData.tags,
})
toast.success('Flow generated! Opening editor...')
analytics.flowCreated({ flow_type: 'procedural', method: 'session_to_flow' })
navigate(getTreeEditorPath(tree.id, 'procedural'))
} catch (err) {
console.error('Failed to generate flow from session:', err)
toast.error('Failed to generate flow. Please try again.')
} finally {
setIsGeneratingFlow(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
})
)
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 formatDuration = (durationSeconds: number | null | undefined) => {
if (durationSeconds == null || durationSeconds < 0) return null
if (durationSeconds < 60) return `${durationSeconds}s`
const hours = Math.floor(durationSeconds / 3600)
const minutes = Math.floor((durationSeconds % 3600) / 60)
const seconds = durationSeconds % 60
if (hours > 0) return seconds > 0 ? `${hours}h ${minutes}m ${seconds}s` : `${hours}h ${minutes}m`
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`
}
const getTotalDuration = () => {
if (!session?.completed_at) return 'In progress'
const startedAtMs = new Date(session.started_at || Date.now()).getTime()
const completedAtMs = new Date(session.completed_at).getTime()
if (Number.isNaN(startedAtMs) || Number.isNaN(completedAtMs)) return 'Unknown'
const seconds = Math.max(0, Math.floor((completedAtMs - startedAtMs) / 1000))
return formatDuration(seconds) || '0s'
}
const outcomeLabel = session?.outcome
? session.outcome === 'workaround'
? 'Workaround'
: session.outcome.charAt(0).toUpperCase() + session.outcome.slice(1)
: null
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Spinner />
</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-foreground hover:underline"
>
Back to sessions
</button>
</div>
)
}
// Outcome display config
const OUTCOME_CONFIG: Record<string, { icon: React.ReactNode; color: string; bg: string; border: string }> = {
resolved: { icon: <CheckCircle2 className="h-5 w-5" />, color: 'text-emerald-400', bg: 'bg-emerald-500/10', border: 'border-emerald-500/20' },
workaround: { icon: <AlertTriangle className="h-5 w-5" />, color: 'text-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/20' },
escalated: { icon: <ArrowUpRight className="h-5 w-5" />, color: 'text-red-400', bg: 'bg-red-500/10', border: 'border-red-500/20' },
unresolved: { icon: <HelpCircle className="h-5 w-5" />, color: 'text-muted-foreground', bg: 'bg-muted', border: 'border-border' },
}
const outcomeConfig = session.outcome ? OUTCOME_CONFIG[session.outcome] : null
return (
<div className="overflow-y-auto h-full container mx-auto px-4 py-6 sm:px-6 sm:py-8">
{/* Back nav */}
<button
onClick={() => navigate('/sessions')}
className="mb-4 text-sm text-muted-foreground hover:text-foreground"
>
Back to sessions
</button>
{/* Page title row */}
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-heading font-bold text-foreground sm:text-3xl">
{session.ticket_number || 'Session Details'}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{session.tree_snapshot?.name}
{session.client_name && <> · Client: {session.client_name}</>}
{session.started_at && <>{' · '}{new Date(session.started_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}</>}
</p>
</div>
<ActionMenu
actions={[
{ label: 'Share', icon: Share2, onClick: () => setShowShareModal(true) },
...(session.completed_at ? [{ label: 'Save as Tree', icon: Save, onClick: () => setShowSaveAsTreeModal(true) }] as MenuAction[] : []),
]}
/>
</div>
{/* Session summary card */}
{session.completed_at && outcomeConfig ? (
<div className={cn('mb-6 rounded-xl border p-5', outcomeConfig.border, outcomeConfig.bg)}>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3">
<span className={outcomeConfig.color}>{outcomeConfig.icon}</span>
<div>
<div className="flex items-center gap-2">
<span className={cn('text-base font-semibold', outcomeConfig.color)}>{outcomeLabel}</span>
<span className="text-sm text-muted-foreground">· {getTotalDuration()}</span>
</div>
{session.outcome_notes && (
<p className="mt-1 text-sm text-muted-foreground">{session.outcome_notes}</p>
)}
{session.next_steps && (
<div className="mt-2">
<span className="font-sans text-xs text-[0.6875rem] uppercase tracking-wide text-muted-foreground">Next Steps</span>
<p className="mt-0.5 text-sm text-muted-foreground whitespace-pre-wrap">{session.next_steps}</p>
</div>
)}
</div>
</div>
{/* Primary action: Copy for Ticket */}
<Button onClick={handleCopyForTicket} className="shrink-0">
{copiedPsa ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
{copiedPsa ? 'Copied!' : 'Copy for Ticket'}
</Button>
</div>
</div>
) : null}
{/* Create Flow from Session — only for completed sessions */}
{session.completed_at && (
<div className="mb-4">
<Button
onClick={handleCreateFlowFromSession}
disabled={isGeneratingFlow}
className="bg-primary text-white font-semibold rounded-lg hover:brightness-110 active:scale-[0.98] disabled:opacity-60"
>
{isGeneratingFlow ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
{isGeneratingFlow ? 'Generating Flow...' : 'Create Flow from Session'}
</Button>
</div>
)}
{!session.completed_at ? (
/* In-progress banner */
<div className="mb-6 flex items-center justify-between gap-4 rounded-xl border border-amber-500/20 bg-amber-500/5 px-5 py-4">
<div className="flex items-center gap-3">
<Flag className="h-4 w-4 shrink-0 text-amber-400" />
<div>
<p className="text-sm font-medium text-amber-300">Session in progress</p>
<p className="text-xs text-muted-foreground">Set an outcome to finalize this session and generate documentation.</p>
</div>
</div>
<Button onClick={() => setShowOutcomeModal(true)} className="shrink-0">
Complete Session
</Button>
</div>
) : null}
{/* Export toolbar (secondary) */}
<div className="mb-6 flex flex-wrap items-center gap-2">
<select
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value as typeof exportFormat)}
aria-label="Export format"
className="rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
>
<option value="markdown">Markdown</option>
<option value="text">Plain Text</option>
<option value="html">HTML</option>
<option value="psa">PSA / Ticket Note</option>
<option value="pdf">PDF</option>
</select>
{session.decisions.length > 1 && (
<select
value={maxStepIndex ?? ''}
onChange={(e) => setMaxStepIndex(e.target.value ? Number(e.target.value) : null)}
aria-label="Export through step"
className="rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
>
<option value="">All steps</option>
{session.decisions.map((_, idx) => (
<option key={idx + 1} value={idx + 1}>Through step {idx + 1}</option>
))}
</select>
)}
<select
value={detailLevel}
onChange={(e) => setDetailLevel(e.target.value as 'standard' | 'full')}
aria-label="Detail level"
className="rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
>
<option value="standard">Standard</option>
<option value="full">Full Detail</option>
</select>
<button
onClick={handleCopy}
disabled={isExporting}
title="Copy to clipboard"
className="rounded-md border border-border bg-card p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
>
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
</button>
<Button
variant="secondary"
size="sm"
onClick={handlePreview}
disabled={isExporting}
>
<Eye className="h-4 w-4" />
{isExporting ? 'Loading...' : 'Preview'}
</Button>
{/* Copy for ticket (secondary position when session is complete) */}
{session.completed_at && (
<button
onClick={handleCopyForTicket}
className="flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
{copiedPsa ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
{copiedPsa ? 'Copied!' : 'Copy for Ticket'}
</button>
)}
</div>
{/* Timeline / Step Checklist */}
<SessionTimeline
decisions={session.decisions}
treeType={(session.tree_snapshot as unknown as Record<string, unknown>).tree_type as string}
startedAt={session.started_at || ''}
completedAt={session.completed_at}
/>
{/* Export Preview Modal */}
<ExportPreviewModal
isOpen={showPreview}
onClose={() => setShowPreview(false)}
content={exportContent || ''}
filename={getFilename()}
format={exportFormat}
onDownload={handleDownload}
onDownloadPdf={handleDownloadPdf}
pdfLoading={pdfLoading}
includeSummary={includeSummary}
onToggleSummary={handleToggleSummary}
redactionEnabled={redactionMode === 'mask'}
onToggleRedaction={handleToggleRedaction}
redactionSummary={redactionSummary}
/>
{/* 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}
/>
{/* Share Session Modal */}
<ShareSessionModal
sessionId={session.id}
sessionLabel={session.ticket_number || 'Session Details'}
isOpen={showShareModal}
onClose={() => setShowShareModal(false)}
/>
{/* Complete Session Modal (in-progress sessions) */}
<SessionOutcomeModal
isOpen={showOutcomeModal}
onClose={() => setShowOutcomeModal(false)}
onSubmit={handleCompleteSession}
isSubmitting={isCompleting}
/>
</div>
)
}
export default SessionDetailPage