feat: close sessions from history page with inline popover
Add ability to close active sessions directly from the Session History page via an inline popover with outcome selection and optional notes. Adds two new outcomes: cancelled and resolved_externally. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { StaggerList } from '@/components/common/StaggerList'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import type { Session, TreeListItem } from '@/types'
|
||||
import type { Session, TreeListItem, SessionOutcome } from '@/types'
|
||||
import type { DateRange } from 'react-day-picker'
|
||||
import { SessionFilters } from '@/components/session/SessionFilters'
|
||||
import type { SessionFilterState } from '@/components/session/SessionFilters'
|
||||
@@ -24,6 +24,13 @@ export function SessionHistoryPage() {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [filter, setFilter] = useState<'all' | 'completed' | 'active' | 'prepared'>('all')
|
||||
|
||||
// Close session popover state
|
||||
const [closingSessionId, setClosingSessionId] = useState<string | null>(null)
|
||||
const [closeOutcome, setCloseOutcome] = useState<SessionOutcome | ''>('')
|
||||
const [closeNotes, setCloseNotes] = useState('')
|
||||
const [closeLoading, setCloseLoading] = useState(false)
|
||||
const closePopoverRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Initialize filters from URL params
|
||||
const [filters, setFilters] = useState<SessionFilterState>(() => {
|
||||
const ticketNumber = searchParams.get('ticket') || ''
|
||||
@@ -147,6 +154,46 @@ export function SessionHistoryPage() {
|
||||
})
|
||||
}
|
||||
|
||||
const handleCloseSession = useCallback(async () => {
|
||||
if (!closingSessionId || !closeOutcome) return
|
||||
setCloseLoading(true)
|
||||
try {
|
||||
await sessionsApi.complete(closingSessionId, {
|
||||
outcome: closeOutcome,
|
||||
outcome_notes: closeNotes || undefined,
|
||||
})
|
||||
setSessions(prev =>
|
||||
prev.map(s =>
|
||||
s.id === closingSessionId
|
||||
? { ...s, completed_at: new Date().toISOString(), outcome: closeOutcome, outcome_notes: closeNotes || null }
|
||||
: s
|
||||
)
|
||||
)
|
||||
toast.success('Session closed')
|
||||
setClosingSessionId(null)
|
||||
setCloseOutcome('')
|
||||
setCloseNotes('')
|
||||
} catch {
|
||||
toast.error('Failed to close session')
|
||||
} finally {
|
||||
setCloseLoading(false)
|
||||
}
|
||||
}, [closingSessionId, closeOutcome, closeNotes])
|
||||
|
||||
// Close popover on click outside
|
||||
useEffect(() => {
|
||||
if (!closingSessionId) return
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (closePopoverRef.current && !closePopoverRef.current.contains(e.target as Node)) {
|
||||
setClosingSessionId(null)
|
||||
setCloseOutcome('')
|
||||
setCloseNotes('')
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [closingSessionId])
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString()
|
||||
}
|
||||
@@ -157,9 +204,15 @@ export function SessionHistoryPage() {
|
||||
|
||||
const formatOutcomeLabel = (outcome: Session['outcome']): string => {
|
||||
if (!outcome) return 'Not set'
|
||||
return outcome === 'workaround'
|
||||
? 'Workaround'
|
||||
: outcome.charAt(0).toUpperCase() + outcome.slice(1)
|
||||
const labels: Record<string, string> = {
|
||||
resolved: 'Resolved',
|
||||
escalated: 'Escalated',
|
||||
workaround: 'Workaround',
|
||||
unresolved: 'Unresolved',
|
||||
cancelled: 'Cancelled',
|
||||
resolved_externally: 'Resolved Externally',
|
||||
}
|
||||
return labels[outcome] ?? outcome
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -254,6 +307,8 @@ export function SessionHistoryPage() {
|
||||
session.outcome === 'workaround' && 'bg-amber-500/20 text-amber-300',
|
||||
session.outcome === 'escalated' && 'bg-blue-500/20 text-blue-300',
|
||||
session.outcome === 'unresolved' && 'bg-rose-500/20 text-rose-300',
|
||||
session.outcome === 'cancelled' && 'bg-zinc-500/20 text-zinc-300',
|
||||
session.outcome === 'resolved_externally' && 'bg-cyan-500/20 text-cyan-300',
|
||||
!session.outcome && 'bg-accent text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
@@ -285,7 +340,7 @@ export function SessionHistoryPage() {
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/sessions/${session.id}`)}
|
||||
className={cn(
|
||||
@@ -295,16 +350,92 @@ export function SessionHistoryPage() {
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
{!session.completed_at && (
|
||||
<button
|
||||
onClick={() => navigate(getSessionResumePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })}
|
||||
className={cn(
|
||||
'rounded-md bg-gradient-brand px-3 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90'
|
||||
)}
|
||||
{!session.completed_at && session.started_at && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setClosingSessionId(closingSessionId === session.id ? null : session.id)
|
||||
setCloseOutcome('')
|
||||
setCloseNotes('')
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground',
|
||||
closingSessionId === session.id && 'bg-accent text-foreground'
|
||||
)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(getSessionResumePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })}
|
||||
className={cn(
|
||||
'rounded-md bg-gradient-brand px-3 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90'
|
||||
)}
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Close Session Popover */}
|
||||
{closingSessionId === session.id && (
|
||||
<div
|
||||
ref={closePopoverRef}
|
||||
className="absolute right-0 top-full z-20 mt-2 w-72 rounded-xl border border-border bg-card p-4 shadow-xl"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
<p className="text-sm font-heading font-medium text-foreground mb-3">Close Session</p>
|
||||
|
||||
<label className="block text-xs font-label text-muted-foreground mb-1">Outcome</label>
|
||||
<select
|
||||
value={closeOutcome}
|
||||
onChange={(e) => setCloseOutcome(e.target.value as SessionOutcome)}
|
||||
title="Session outcome"
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none mb-3"
|
||||
>
|
||||
<option value="">Select outcome...</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="escalated">Escalated</option>
|
||||
<option value="workaround">Workaround</option>
|
||||
<option value="unresolved">Unresolved</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="resolved_externally">Resolved Externally</option>
|
||||
</select>
|
||||
|
||||
<label className="block text-xs font-label text-muted-foreground mb-1">Notes (optional)</label>
|
||||
<textarea
|
||||
value={closeNotes}
|
||||
onChange={(e) => setCloseNotes(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Add closure notes..."
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none mb-3"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setClosingSessionId(null)
|
||||
setCloseOutcome('')
|
||||
setCloseNotes('')
|
||||
}}
|
||||
className="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCloseSession}
|
||||
disabled={!closeOutcome || closeLoading}
|
||||
className={cn(
|
||||
'rounded-lg px-4 py-1.5 text-sm font-medium shadow-lg shadow-primary/20 transition-opacity',
|
||||
closeOutcome
|
||||
? 'bg-gradient-brand text-[#101114] hover:opacity-90'
|
||||
: 'bg-gradient-brand text-[#101114] opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{closeLoading ? 'Closing...' : 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TreeStructure } from './tree'
|
||||
import type { Step, StepContent } from './step'
|
||||
|
||||
export type SessionOutcome = 'resolved' | 'escalated' | 'workaround' | 'unresolved'
|
||||
export type SessionOutcome = 'resolved' | 'escalated' | 'workaround' | 'unresolved' | 'cancelled' | 'resolved_externally'
|
||||
|
||||
export interface DecisionRecord {
|
||||
node_id: string
|
||||
|
||||
Reference in New Issue
Block a user