feat: session detail page — completion action + outcome summary card
- In-progress sessions: amber banner with "Complete Session" button opens SessionOutcomeModal to set outcome/notes/next-steps and finalize - Completed sessions: colored outcome summary card (icon + outcome label + duration + notes + next steps) replaces dense header metadata; "Copy for Ticket" promoted to primary action inside the card - Export toolbar de-emphasized to secondary row of smaller controls below the summary card Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,17 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { Copy, Check, Eye, Save, Share2 } from 'lucide-react'
|
import { Copy, Check, Eye, Save, Share2, CheckCircle2, AlertTriangle, ArrowUpRight, HelpCircle, Flag } from 'lucide-react'
|
||||||
import { sessionsApi } from '@/api/sessions'
|
import { sessionsApi } from '@/api/sessions'
|
||||||
import { stepsApi } from '@/api/steps'
|
import { stepsApi } from '@/api/steps'
|
||||||
import { ExportPreviewModal } from '@/components/session/ExportPreviewModal'
|
import { ExportPreviewModal } from '@/components/session/ExportPreviewModal'
|
||||||
import { SaveSessionAsTreeModal } from '@/components/session/SaveSessionAsTreeModal'
|
import { SaveSessionAsTreeModal } from '@/components/session/SaveSessionAsTreeModal'
|
||||||
import { ShareSessionModal } from '@/components/session/ShareSessionModal'
|
import { ShareSessionModal } from '@/components/session/ShareSessionModal'
|
||||||
|
import { SessionOutcomeModal } from '@/components/session/SessionOutcomeModal'
|
||||||
import { SessionTimeline } from '@/components/session/SessionTimeline'
|
import { SessionTimeline } from '@/components/session/SessionTimeline'
|
||||||
import { StepRatingModal } from '@/components/session/StepRatingModal'
|
import { StepRatingModal } from '@/components/session/StepRatingModal'
|
||||||
import { ActionMenu } from '@/components/common/ActionMenu'
|
import { ActionMenu } from '@/components/common/ActionMenu'
|
||||||
import type { MenuAction } from '@/components/common/ActionMenu'
|
import type { MenuAction } from '@/components/common/ActionMenu'
|
||||||
|
import type { SessionOutcome } from '@/types'
|
||||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||||
import type { Session, SessionExport, SaveAsTreeRequest, Step, RedactionSummary } from '@/types'
|
import type { Session, SessionExport, SaveAsTreeRequest, Step, RedactionSummary } from '@/types'
|
||||||
import { hasRatedSession, markSessionRated } from '@/lib/sessionRatings'
|
import { hasRatedSession, markSessionRated } from '@/lib/sessionRatings'
|
||||||
@@ -36,6 +38,8 @@ export function SessionDetailPage() {
|
|||||||
const [isSavingRatings, setIsSavingRatings] = useState(false)
|
const [isSavingRatings, setIsSavingRatings] = useState(false)
|
||||||
const [librarySteps, setLibrarySteps] = useState<Step[]>([])
|
const [librarySteps, setLibrarySteps] = useState<Step[]>([])
|
||||||
const [showShareModal, setShowShareModal] = useState(false)
|
const [showShareModal, setShowShareModal] = useState(false)
|
||||||
|
const [showOutcomeModal, setShowOutcomeModal] = useState(false)
|
||||||
|
const [isCompleting, setIsCompleting] = useState(false)
|
||||||
const [maxStepIndex, setMaxStepIndex] = useState<number | null>(null)
|
const [maxStepIndex, setMaxStepIndex] = useState<number | null>(null)
|
||||||
const [detailLevel, setDetailLevel] = useState<'standard' | 'full'>('standard')
|
const [detailLevel, setDetailLevel] = useState<'standard' | 'full'>('standard')
|
||||||
const [includeSummary, setIncludeSummary] = useState(false)
|
const [includeSummary, setIncludeSummary] = useState(false)
|
||||||
@@ -227,6 +231,21 @@ export function SessionDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 getDefaultTreeName = () => {
|
const getDefaultTreeName = () => {
|
||||||
if (!session) return ''
|
if (!session) return ''
|
||||||
const treeName = session.tree_snapshot?.name || 'Tree'
|
const treeName = session.tree_snapshot?.name || 'Tree'
|
||||||
@@ -310,159 +329,157 @@ export function SessionDetailPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||||
{/* Header */}
|
{/* Back nav */}
|
||||||
<div className="mb-8">
|
<button
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
onClick={() => navigate('/sessions')}
|
||||||
<div>
|
className="mb-4 text-sm text-muted-foreground hover:text-foreground"
|
||||||
<button
|
>
|
||||||
onClick={() => navigate('/sessions')}
|
← Back to sessions
|
||||||
className="mb-2 text-sm text-muted-foreground hover:text-foreground"
|
</button>
|
||||||
>
|
|
||||||
← Back to sessions
|
{/* Page title row */}
|
||||||
</button>
|
<div className="mb-6 flex items-start justify-between gap-4">
|
||||||
<h1 className="text-2xl font-heading font-bold text-foreground sm:text-3xl">
|
<div>
|
||||||
{session.ticket_number || 'Session Details'}
|
<h1 className="text-2xl font-heading font-bold text-foreground sm:text-3xl">
|
||||||
</h1>
|
{session.ticket_number || 'Session Details'}
|
||||||
<div className="mt-2 flex items-center gap-4 text-sm text-muted-foreground">
|
</h1>
|
||||||
<span
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
className={cn(
|
{session.tree_snapshot?.name}
|
||||||
'flex items-center gap-1',
|
{session.client_name && <> · Client: {session.client_name}</>}
|
||||||
session.completed_at ? 'text-emerald-400' : 'text-yellow-400'
|
{' · '}{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-label 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>
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<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-accent px-2 py-0.5 text-xs text-foreground">
|
|
||||||
Duration: {getTotalDuration()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{outcomeLabel && (
|
|
||||||
<span className="rounded-full bg-accent px-2 py-0.5 text-xs text-foreground">
|
|
||||||
Outcome: {outcomeLabel}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{session.outcome_notes && (
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">Outcome Notes: {session.outcome_notes}</p>
|
|
||||||
)}
|
|
||||||
{session.next_steps && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<span className="text-sm 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>
|
||||||
</div>
|
{/* Primary action: Copy for Ticket */}
|
||||||
|
|
||||||
{/* 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
|
<button
|
||||||
onClick={handleCopyForTicket}
|
onClick={handleCopyForTicket}
|
||||||
className={cn(
|
className="flex shrink-0 items-center gap-2 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
|
||||||
'flex items-center gap-2 rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
|
||||||
'hover:opacity-90'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{copiedPsa ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
{copiedPsa ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||||
{copiedPsa ? 'Copied!' : 'Copy for Ticket'}
|
{copiedPsa ? 'Copied!' : 'Copy for Ticket'}
|
||||||
</button>
|
</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-border bg-card px-3 py-2 text-sm text-foreground sm:w-auto',
|
|
||||||
'focus:border-primary focus:outline-none 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>
|
|
||||||
</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-border bg-card px-3 py-2 text-sm text-foreground',
|
|
||||||
'focus:border-primary focus:outline-none 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={cn(
|
|
||||||
'rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
|
||||||
'focus:border-primary focus:outline-none 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={cn(
|
|
||||||
'rounded-md border border-border bg-card p-2 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
|
|
||||||
onClick={handlePreview}
|
|
||||||
disabled={isExporting}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
|
||||||
'hover:opacity-90 disabled:opacity-50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
{isExporting ? 'Loading...' : 'Preview'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
|
||||||
|
>
|
||||||
|
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-none 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>
|
||||||
|
</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-none 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-none 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
|
||||||
|
onClick={handlePreview}
|
||||||
|
disabled={isExporting}
|
||||||
|
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 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Timeline / Step Checklist */}
|
{/* Timeline / Step Checklist */}
|
||||||
@@ -513,6 +530,14 @@ export function SessionDetailPage() {
|
|||||||
isOpen={showShareModal}
|
isOpen={showShareModal}
|
||||||
onClose={() => setShowShareModal(false)}
|
onClose={() => setShowShareModal(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Complete Session Modal (in-progress sessions) */}
|
||||||
|
<SessionOutcomeModal
|
||||||
|
isOpen={showOutcomeModal}
|
||||||
|
onClose={() => setShowOutcomeModal(false)}
|
||||||
|
onSubmit={handleCompleteSession}
|
||||||
|
isSubmitting={isCompleting}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user