* feat: add session sharing types, API client, and utilities Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add SessionTimeline and ActionMenu reusable components SessionTimeline extracts timeline/checklist rendering from SessionDetailPage into a reusable component for both authenticated and public session views. ActionMenu provides a dropdown action menu with keyboard/click-outside dismiss. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add ShareSessionModal and integrate into SessionDetailPage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add Share Progress popover to TreeNavigationPage Replace the single "Copy for Ticket" button with a "Share Progress" popover that offers three actions: Copy Progress Summary (existing PSA export flow), Copy Share Link (auto-creates account-only share if needed), and Manage Share Links (opens ShareSessionModal). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add public SharedSessionPage with tree preview Add the public-facing shared session page at /share/:shareToken that renders shared sessions without authentication. Includes error handling for 401 (redirect to login), 403 (access denied), 404 (not found), and 410 (expired). The page features a minimal header, session metadata, SessionTimeline component, and a new SharedSessionTreePreview component that renders the decision tree structure with the path taken highlighted. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add My Shares management page with nav link Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address code review issues in session sharing - Add useCallback for loadShares in ShareSessionModal (React hook deps) - Use TreeStructure type instead of Record<string, unknown> for type safety - Fix login redirect format to match LoginPage's expected state shape Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add focused tests for session sharing utilities and API Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve tree_structure type compatibility for shared session views - Use TreeStructure & Record<string, unknown> intersection for JSONB flexibility - Add explicit cast in SharedSessionTreePreview for recursive node rendering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add session sharing learnings to CLAUDE.md Add gotchas #12 (TreeStructure vs Tree types) and #13 (login redirect state format), note about npm run build strictness, and public route pattern to Common Tasks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: procedural editor UX improvements Add URL intake field type, fix variable name editing collapsing fields (index-based keys/updates), auto-generate variable names by field type, add section header as first-class step type, and simplify step editor with "More Options" collapsible for advanced fields. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: allow section_header step type in validation, improve tag input - Add 'section_header' to VALID_STEP_TYPES in backend validation so procedural flows with section headers can be published - Replace procedural editor's inline tag input with TagInput component (supports autocomplete, Tab, comma, semicolon, and paste splitting) - Add semicolon delimiter support to TagInput component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add type-aware routing for procedural flows Centralizes tree navigation routing via getTreeNavigatePath helper. Fixes all pages to route procedural sessions to /flows/:id/navigate instead of /trees/:id/navigate. Adds safety redirect in troubleshooting navigator and resume support in procedural navigator. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove unused index prop from IntakeFieldEditor Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
521 lines
19 KiB
TypeScript
521 lines
19 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
import { Copy, Check, Eye, Save, Share2 } 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 { ShareSessionModal } from '@/components/session/ShareSessionModal'
|
|
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 { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
|
import type { Session, SessionExport, SaveAsTreeRequest, Step, RedactionSummary } 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[]>([])
|
|
const [showShareModal, setShowShareModal] = 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)
|
|
|
|
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')
|
|
}
|
|
} 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')
|
|
}
|
|
} catch (err) {
|
|
console.error('Copy for ticket failed:', err)
|
|
toast.error('Failed to copy ticket notes')
|
|
}
|
|
}
|
|
|
|
const handleDownload = (content: string) => {
|
|
if (!session) return
|
|
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 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 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).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">
|
|
<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>}
|
|
{session.completed_at && (
|
|
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white">
|
|
Duration: {getTotalDuration()}
|
|
</span>
|
|
)}
|
|
{outcomeLabel && (
|
|
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white">
|
|
Outcome: {outcomeLabel}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{session.outcome_notes && (
|
|
<p className="mt-2 text-sm text-white/60">Outcome Notes: {session.outcome_notes}</p>
|
|
)}
|
|
{session.next_steps && (
|
|
<div className="mt-2">
|
|
<span className="text-sm text-white/40">Next Steps:</span>
|
|
<p className="mt-0.5 text-sm text-white/60 whitespace-pre-wrap">{session.next_steps}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
<ActionMenu
|
|
actions={[
|
|
{
|
|
label: 'Share',
|
|
icon: Share2,
|
|
onClick: () => setShowShareModal(true),
|
|
},
|
|
...(session.completed_at ? [{
|
|
label: 'Save as Tree',
|
|
icon: Save,
|
|
onClick: () => setShowSaveAsTreeModal(true),
|
|
}] as MenuAction[] : []),
|
|
]}
|
|
/>
|
|
|
|
{/* 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>
|
|
{session.decisions.length > 1 && (
|
|
<select
|
|
value={maxStepIndex ?? ''}
|
|
onChange={(e) => setMaxStepIndex(e.target.value ? Number(e.target.value) : null)}
|
|
aria-label="Export through step"
|
|
className={cn(
|
|
'rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
|
|
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/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={cn(
|
|
'rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
|
|
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
|
)}
|
|
>
|
|
<option value="standard">Standard</option>
|
|
<option value="full">Full Detail</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 / 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}
|
|
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)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default SessionDetailPage
|