Implement session outcomes, step timing, and live timer fixes
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import apiClient from './client'
|
||||
import type { Session, SessionCreate, SessionUpdate, SessionExport, SaveAsTreeRequest, SaveAsTreeResponse } from '@/types'
|
||||
import type { Session, SessionCreate, SessionUpdate, SessionExport, SaveAsTreeRequest, SaveAsTreeResponse, SessionComplete } from '@/types'
|
||||
|
||||
export interface SessionListParams {
|
||||
page?: number
|
||||
@@ -44,8 +44,8 @@ export const sessionsApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async complete(id: string): Promise<Session> {
|
||||
const response = await apiClient.post<Session>(`/sessions/${id}/complete`)
|
||||
async complete(id: string, data: SessionComplete): Promise<Session> {
|
||||
const response = await apiClient.post<Session>(`/sessions/${id}/complete`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
|
||||
122
frontend/src/components/session/SessionOutcomeModal.tsx
Normal file
122
frontend/src/components/session/SessionOutcomeModal.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SessionOutcome } from '@/types'
|
||||
|
||||
interface SessionOutcomeModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: { outcome: SessionOutcome; outcome_notes?: string }) => Promise<void>
|
||||
isSubmitting?: boolean
|
||||
}
|
||||
|
||||
const OUTCOME_OPTIONS: Array<{ value: SessionOutcome; label: string; description: string }> = [
|
||||
{ value: 'resolved', label: 'Resolved', description: 'Issue fully resolved in this session.' },
|
||||
{ value: 'workaround', label: 'Workaround', description: 'Temporary fix applied, root cause remains.' },
|
||||
{ value: 'escalated', label: 'Escalated', description: 'Handed off to another engineer/team.' },
|
||||
{ value: 'unresolved', label: 'Unresolved', description: 'No fix or workaround identified yet.' },
|
||||
]
|
||||
|
||||
export function SessionOutcomeModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
}: SessionOutcomeModalProps) {
|
||||
const [outcome, setOutcome] = useState<SessionOutcome>('resolved')
|
||||
const [outcomeNotes, setOutcomeNotes] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
setOutcome('resolved')
|
||||
setOutcomeNotes('')
|
||||
}, [isOpen])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await onSubmit({
|
||||
outcome,
|
||||
outcome_notes: outcomeNotes.trim() || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Session Outcome"
|
||||
footer={(
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className={cn(
|
||||
'rounded-md bg-white px-4 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? 'Completing...' : 'Complete Session'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-white/70">
|
||||
Select the session outcome before completion.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{OUTCOME_OPTIONS.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={cn(
|
||||
'block cursor-pointer rounded-lg border border-white/10 p-3 transition-colors',
|
||||
outcome === option.value ? 'border-white/30 bg-white/10' : 'hover:bg-white/[0.04]'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="session-outcome"
|
||||
value={option.value}
|
||||
checked={outcome === option.value}
|
||||
onChange={() => setOutcome(option.value)}
|
||||
className="mt-1 h-4 w-4"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{option.label}</p>
|
||||
<p className="text-xs text-white/50">{option.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white">Outcome Notes (optional)</label>
|
||||
<textarea
|
||||
value={outcomeNotes}
|
||||
onChange={(e) => setOutcomeNotes(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Add context for this outcome..."
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border border-white/10 bg-black/50 px-3 py-2',
|
||||
'text-sm text-white placeholder:text-white/40',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export { PostStepActionModal } from './PostStepActionModal'
|
||||
export { ContinuationModal, type DescendantNode } from './ContinuationModal'
|
||||
export { ForkTreeModal } from './ForkTreeModal'
|
||||
export { ScratchpadSidebar } from './ScratchpadSidebar'
|
||||
export { SessionOutcomeModal } from './SessionOutcomeModal'
|
||||
|
||||
@@ -19,9 +19,10 @@ interface UseCustomStepFlowParams {
|
||||
setPathTaken: (path: string[]) => void
|
||||
setDecisions: (decisions: DecisionRecord[]) => void
|
||||
setNotes: (notes: string) => void
|
||||
setIsCompleting: (completing: boolean) => void
|
||||
setError: (error: string | null) => void
|
||||
onEnterNode: (enteredAtIso: string) => void
|
||||
isCompleting: boolean
|
||||
onRequestCompletion: (completionDecision: DecisionRecord, source: 'custom') => void
|
||||
}
|
||||
|
||||
export function useCustomStepFlow({
|
||||
@@ -36,9 +37,10 @@ export function useCustomStepFlow({
|
||||
setPathTaken,
|
||||
setDecisions,
|
||||
setNotes,
|
||||
setIsCompleting,
|
||||
setError,
|
||||
onEnterNode,
|
||||
isCompleting,
|
||||
onRequestCompletion,
|
||||
}: UseCustomStepFlowParams) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -112,9 +114,11 @@ export function useCustomStepFlow({
|
||||
|
||||
// Navigate back to a previously-created custom step from the decision node
|
||||
const handleNavigateToCustomStep = (customStep: CustomStep) => {
|
||||
const enteredAt = new Date().toISOString()
|
||||
const newPath = [...pathTaken, customStep.id]
|
||||
setPathTaken(newPath)
|
||||
setCurrentNodeId(customStep.id)
|
||||
onEnterNode(enteredAt)
|
||||
}
|
||||
|
||||
// Called when CustomStepModal submits - show action modal instead of inserting directly
|
||||
@@ -169,6 +173,7 @@ export function useCustomStepFlow({
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
|
||||
const decisionTimestamp = new Date().toISOString()
|
||||
const newDecision: DecisionRecord = {
|
||||
node_id: customStep.id,
|
||||
question: null,
|
||||
@@ -176,7 +181,10 @@ export function useCustomStepFlow({
|
||||
action_performed: `Custom Step: ${pendingStep.title}`,
|
||||
notes: pendingStep.content.instructions || null,
|
||||
automation_used: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp: decisionTimestamp,
|
||||
entered_at: decisionTimestamp,
|
||||
exited_at: decisionTimestamp,
|
||||
duration_seconds: 0,
|
||||
attachments: []
|
||||
}
|
||||
|
||||
@@ -188,6 +196,7 @@ export function useCustomStepFlow({
|
||||
setDecisions(newDecisions)
|
||||
setPathTaken(newPath)
|
||||
setCurrentNodeId(customStep.id)
|
||||
onEnterNode(decisionTimestamp)
|
||||
|
||||
await sessionsApi.update(session.id, {
|
||||
path_taken: newPath,
|
||||
@@ -236,9 +245,11 @@ export function useCustomStepFlow({
|
||||
const handleContinueToDescendant = async () => {
|
||||
if (!pendingContinuationNodeId || !session) return
|
||||
|
||||
const enteredAt = new Date().toISOString()
|
||||
const newPath = [...pathTaken, pendingContinuationNodeId]
|
||||
setPathTaken(newPath)
|
||||
setCurrentNodeId(pendingContinuationNodeId)
|
||||
onEnterNode(enteredAt)
|
||||
setNotes('')
|
||||
setPendingContinuationNodeId(null)
|
||||
|
||||
@@ -259,38 +270,18 @@ export function useCustomStepFlow({
|
||||
const handleCustomBranchComplete = async () => {
|
||||
if (!session) return
|
||||
|
||||
setIsCompleting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const completionDecision: DecisionRecord = {
|
||||
node_id: currentNodeId,
|
||||
question: null,
|
||||
answer: null,
|
||||
action_performed: 'Custom Branch Completed',
|
||||
notes: notes || 'Issue resolved via custom troubleshooting steps',
|
||||
automation_used: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
attachments: []
|
||||
}
|
||||
|
||||
await sessionsApi.update(session.id, {
|
||||
decisions: [...decisions, completionDecision]
|
||||
})
|
||||
|
||||
await sessionsApi.complete(session.id)
|
||||
|
||||
if (customSteps.length > 0) {
|
||||
setShowForkModal(true)
|
||||
} else {
|
||||
navigate(`/sessions/${session.id}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to complete session:', err)
|
||||
setError('Failed to complete session. Please try again.')
|
||||
} finally {
|
||||
setIsCompleting(false)
|
||||
const completionDecision: DecisionRecord = {
|
||||
node_id: currentNodeId,
|
||||
question: null,
|
||||
answer: null,
|
||||
action_performed: 'Custom Branch Completed',
|
||||
notes: notes || 'Issue resolved via custom troubleshooting steps',
|
||||
automation_used: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
attachments: []
|
||||
}
|
||||
onRequestCompletion(completionDecision, 'custom')
|
||||
}
|
||||
|
||||
// Fork tree with custom branch
|
||||
|
||||
@@ -5,12 +5,23 @@ export function useSessionTimer(startedAt: string | undefined | null): string |
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Always clear any previous interval before (re)initializing.
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
|
||||
if (!startedAt) {
|
||||
setElapsed(null)
|
||||
return
|
||||
}
|
||||
|
||||
const startTime = new Date(startedAt).getTime()
|
||||
const parsedStartTime = new Date(startedAt).getTime()
|
||||
// If the server timestamp is invalid or ahead of the local clock, fall back to "now"
|
||||
// so the timer still starts ticking immediately for the user.
|
||||
const startTime = Number.isNaN(parsedStartTime) || parsedStartTime > Date.now()
|
||||
? Date.now()
|
||||
: parsedStartTime
|
||||
|
||||
const tick = () => {
|
||||
const diff = Math.max(0, Math.floor((Date.now() - startTime) / 1000))
|
||||
|
||||
@@ -237,6 +237,31 @@ export function SessionDetailPage() {
|
||||
return new Date(dateString).toLocaleString()
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -292,7 +317,20 @@ export function SessionDetailPage() {
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
@@ -401,6 +439,11 @@ export function SessionDetailPage() {
|
||||
Notes: {decision.notes}
|
||||
</p>
|
||||
)}
|
||||
{decision.duration_seconds != null && (
|
||||
<p className="mt-2 text-xs text-white/50">
|
||||
Duration: {formatDuration(decision.duration_seconds)}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-white/40">
|
||||
{formatDate(decision.timestamp)}
|
||||
</p>
|
||||
|
||||
@@ -140,6 +140,13 @@ export function SessionHistoryPage() {
|
||||
return session.tree_snapshot?.name || 'Unknown Tree'
|
||||
}
|
||||
|
||||
const formatOutcomeLabel = (outcome: Session['outcome']): string => {
|
||||
if (!outcome) return 'Not set'
|
||||
return outcome === 'workaround'
|
||||
? 'Workaround'
|
||||
: outcome.charAt(0).toUpperCase() + outcome.slice(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="mb-8">
|
||||
@@ -226,6 +233,20 @@ export function SessionHistoryPage() {
|
||||
{session.client_name}
|
||||
</span>
|
||||
)}
|
||||
{session.completed_at && (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
session.outcome === 'resolved' && 'bg-emerald-500/20 text-emerald-300',
|
||||
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 && 'bg-white/10 text-white/70'
|
||||
)}
|
||||
>
|
||||
{formatOutcomeLabel(session.outcome)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tree Name */}
|
||||
|
||||
@@ -5,11 +5,11 @@ import { sessionsApi } from '@/api/sessions'
|
||||
import { useTreeNavigationShortcuts } from '@/hooks/useKeyboardShortcuts'
|
||||
import { useCustomStepFlow } from '@/hooks/useCustomStepFlow'
|
||||
import { useSessionTimer } from '@/hooks/useSessionTimer'
|
||||
import type { Tree, Session, DecisionRecord, TreeStructure } from '@/types'
|
||||
import type { Tree, Session, DecisionRecord, TreeStructure, SessionOutcome } from '@/types'
|
||||
import { cn, safeGetItem, safeSetItem } from '@/lib/utils'
|
||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
|
||||
import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar } from '@/components/session'
|
||||
import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, SessionOutcomeModal } from '@/components/session'
|
||||
import { Plus, CheckCircle, ArrowRight, Clock } from 'lucide-react'
|
||||
|
||||
interface LocationState {
|
||||
@@ -18,6 +18,8 @@ interface LocationState {
|
||||
prefillTicketNumber?: string
|
||||
}
|
||||
|
||||
type CompletionSource = 'standard' | 'custom'
|
||||
|
||||
export function TreeNavigationPage() {
|
||||
const { id: treeId } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
@@ -29,10 +31,14 @@ export function TreeNavigationPage() {
|
||||
const [currentNodeId, setCurrentNodeId] = useState<string>('root')
|
||||
const [pathTaken, setPathTaken] = useState<string[]>(['root'])
|
||||
const [decisions, setDecisions] = useState<DecisionRecord[]>([])
|
||||
const [currentStepEnteredAt, setCurrentStepEnteredAt] = useState<string>(new Date().toISOString())
|
||||
const [notes, setNotes] = useState<string>('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isCompleting, setIsCompleting] = useState(false)
|
||||
const [showOutcomeModal, setShowOutcomeModal] = useState(false)
|
||||
const [pendingCompletionDecision, setPendingCompletionDecision] = useState<DecisionRecord | null>(null)
|
||||
const [completionSource, setCompletionSource] = useState<CompletionSource>('standard')
|
||||
|
||||
// Session metadata (prefill from Repeat Last Session)
|
||||
const [ticketNumber, setTicketNumber] = useState<string>(locationState?.prefillTicketNumber || '')
|
||||
@@ -59,6 +65,42 @@ export function TreeNavigationPage() {
|
||||
return null
|
||||
}
|
||||
|
||||
const calculateDurationSeconds = (enteredAtIso: string, exitedAtIso: string): number => {
|
||||
const enteredAtMs = new Date(enteredAtIso).getTime()
|
||||
const exitedAtMs = new Date(exitedAtIso).getTime()
|
||||
if (Number.isNaN(enteredAtMs) || Number.isNaN(exitedAtMs)) return 0
|
||||
return Math.max(0, Math.floor((exitedAtMs - enteredAtMs) / 1000))
|
||||
}
|
||||
|
||||
const deriveCurrentStepEnteredAt = (sessionData: Session): string => {
|
||||
if (!sessionData.decisions || sessionData.decisions.length === 0) {
|
||||
return sessionData.started_at
|
||||
}
|
||||
const lastDecision = sessionData.decisions[sessionData.decisions.length - 1]
|
||||
return lastDecision.exited_at || lastDecision.timestamp || sessionData.started_at
|
||||
}
|
||||
|
||||
const openCompletionModal = (completionDecision: DecisionRecord, source: CompletionSource) => {
|
||||
const exitedAt = new Date().toISOString()
|
||||
const enteredAt = currentStepEnteredAt || session?.started_at || exitedAt
|
||||
setPendingCompletionDecision({
|
||||
...completionDecision,
|
||||
timestamp: exitedAt,
|
||||
entered_at: enteredAt,
|
||||
exited_at: exitedAt,
|
||||
duration_seconds: calculateDurationSeconds(enteredAt, exitedAt),
|
||||
})
|
||||
setCompletionSource(source)
|
||||
setShowOutcomeModal(true)
|
||||
}
|
||||
|
||||
const closeOutcomeModal = () => {
|
||||
if (isCompleting) return
|
||||
setShowOutcomeModal(false)
|
||||
setPendingCompletionDecision(null)
|
||||
setCompletionSource('standard')
|
||||
}
|
||||
|
||||
// Custom step flow (creation, post-step actions, continuation, branching, forking)
|
||||
const customStepFlow = useCustomStepFlow({
|
||||
tree,
|
||||
@@ -72,9 +114,12 @@ export function TreeNavigationPage() {
|
||||
setPathTaken,
|
||||
setDecisions,
|
||||
setNotes,
|
||||
setIsCompleting,
|
||||
setError,
|
||||
onEnterNode: setCurrentStepEnteredAt,
|
||||
isCompleting,
|
||||
onRequestCompletion: (completionDecision) => {
|
||||
openCompletionModal(completionDecision, 'custom')
|
||||
},
|
||||
})
|
||||
|
||||
const handleScratchpadSave = async (content: string) => {
|
||||
@@ -102,6 +147,7 @@ export function TreeNavigationPage() {
|
||||
setPathTaken(sessionData.path_taken)
|
||||
setCurrentNodeId(sessionData.path_taken[sessionData.path_taken.length - 1] || 'root')
|
||||
setDecisions(sessionData.decisions as DecisionRecord[])
|
||||
setCurrentStepEnteredAt(deriveCurrentStepEnteredAt(sessionData))
|
||||
customStepFlow.initCustomSteps(sessionData.custom_steps || [])
|
||||
setTicketNumber(sessionData.ticket_number || '')
|
||||
setClientName(sessionData.client_name || '')
|
||||
@@ -125,6 +171,7 @@ export function TreeNavigationPage() {
|
||||
client_name: clientName || undefined,
|
||||
})
|
||||
setSession(newSession)
|
||||
setCurrentStepEnteredAt(newSession.started_at)
|
||||
setShowMetadataForm(false)
|
||||
// Save for "Repeat Last Session"
|
||||
safeSetItem('last-session', JSON.stringify({
|
||||
@@ -147,6 +194,8 @@ export function TreeNavigationPage() {
|
||||
const node = findNode(currentNodeId, tree.tree_structure)
|
||||
if (!node) return
|
||||
|
||||
const exitedAt = new Date().toISOString()
|
||||
const enteredAt = currentStepEnteredAt || session.started_at || exitedAt
|
||||
const newDecision: DecisionRecord = {
|
||||
node_id: currentNodeId,
|
||||
question: node.question || null,
|
||||
@@ -154,7 +203,10 @@ export function TreeNavigationPage() {
|
||||
action_performed: null,
|
||||
notes: notes || null,
|
||||
automation_used: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp: exitedAt,
|
||||
entered_at: enteredAt,
|
||||
exited_at: exitedAt,
|
||||
duration_seconds: calculateDurationSeconds(enteredAt, exitedAt),
|
||||
attachments: [],
|
||||
}
|
||||
|
||||
@@ -164,6 +216,7 @@ export function TreeNavigationPage() {
|
||||
setPathTaken(newPath)
|
||||
setDecisions(newDecisions)
|
||||
setCurrentNodeId(nextNodeId)
|
||||
setCurrentStepEnteredAt(exitedAt)
|
||||
setNotes('')
|
||||
|
||||
try {
|
||||
@@ -182,6 +235,8 @@ export function TreeNavigationPage() {
|
||||
const node = findNode(currentNodeId, tree.tree_structure)
|
||||
if (!node || !node.next_node_id) return
|
||||
|
||||
const exitedAt = new Date().toISOString()
|
||||
const enteredAt = currentStepEnteredAt || session.started_at || exitedAt
|
||||
const newDecision: DecisionRecord = {
|
||||
node_id: currentNodeId,
|
||||
question: null,
|
||||
@@ -189,7 +244,10 @@ export function TreeNavigationPage() {
|
||||
action_performed: actionPerformed || node.title || 'Action completed',
|
||||
notes: notes || null,
|
||||
automation_used: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp: exitedAt,
|
||||
entered_at: enteredAt,
|
||||
exited_at: exitedAt,
|
||||
duration_seconds: calculateDurationSeconds(enteredAt, exitedAt),
|
||||
attachments: [],
|
||||
}
|
||||
|
||||
@@ -199,6 +257,7 @@ export function TreeNavigationPage() {
|
||||
setPathTaken(newPath)
|
||||
setDecisions(newDecisions)
|
||||
setCurrentNodeId(node.next_node_id)
|
||||
setCurrentStepEnteredAt(exitedAt)
|
||||
setNotes('')
|
||||
|
||||
try {
|
||||
@@ -213,28 +272,44 @@ export function TreeNavigationPage() {
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!session || !tree) return
|
||||
const node = findNode(currentNodeId, tree.tree_structure)
|
||||
if (!node) return
|
||||
const completionDecision: DecisionRecord = {
|
||||
node_id: currentNodeId,
|
||||
question: null,
|
||||
answer: null,
|
||||
action_performed: node.title || 'Session completed',
|
||||
notes: notes || null,
|
||||
automation_used: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
attachments: [],
|
||||
}
|
||||
openCompletionModal(completionDecision, 'standard')
|
||||
}
|
||||
|
||||
const handleSubmitOutcome = async (data: { outcome: SessionOutcome; outcome_notes?: string }) => {
|
||||
if (!session) return
|
||||
setIsCompleting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const node = findNode(currentNodeId, tree.tree_structure)
|
||||
if (node) {
|
||||
const finalDecision: DecisionRecord = {
|
||||
node_id: currentNodeId,
|
||||
question: null,
|
||||
answer: null,
|
||||
action_performed: node.title || 'Session completed',
|
||||
notes: notes || null,
|
||||
automation_used: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
attachments: [],
|
||||
}
|
||||
let finalDecisions = decisions
|
||||
if (pendingCompletionDecision) {
|
||||
finalDecisions = [...decisions, pendingCompletionDecision]
|
||||
setDecisions(finalDecisions)
|
||||
await sessionsApi.update(session.id, {
|
||||
decisions: [...decisions, finalDecision],
|
||||
decisions: finalDecisions,
|
||||
})
|
||||
}
|
||||
|
||||
await sessionsApi.complete(session.id)
|
||||
navigate(`/sessions/${session.id}`)
|
||||
await sessionsApi.complete(session.id, data)
|
||||
|
||||
setShowOutcomeModal(false)
|
||||
setPendingCompletionDecision(null)
|
||||
if (completionSource === 'custom' && customStepFlow.customSteps.length > 0) {
|
||||
customStepFlow.setShowForkModal(true)
|
||||
} else {
|
||||
navigate(`/sessions/${session.id}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to complete session:', err)
|
||||
setError('Failed to complete session. Check console for details.')
|
||||
@@ -250,6 +325,7 @@ export function TreeNavigationPage() {
|
||||
setPathTaken(newPath)
|
||||
setDecisions(newDecisions)
|
||||
setCurrentNodeId(newPath[newPath.length - 1])
|
||||
setCurrentStepEnteredAt(new Date().toISOString())
|
||||
}
|
||||
|
||||
// Compute current node for keyboard shortcuts (must be before any returns for hooks rules)
|
||||
@@ -763,6 +839,13 @@ export function TreeNavigationPage() {
|
||||
onFork={customStepFlow.handleForkTree}
|
||||
onSkip={customStepFlow.handleSkipFork}
|
||||
/>
|
||||
|
||||
<SessionOutcomeModal
|
||||
isOpen={showOutcomeModal}
|
||||
onClose={closeOutcomeModal}
|
||||
onSubmit={handleSubmitOutcome}
|
||||
isSubmitting={isCompleting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TreeStructure } from './tree'
|
||||
import type { Step, StepContent } from './step'
|
||||
|
||||
export type SessionOutcome = 'resolved' | 'escalated' | 'workaround' | 'unresolved'
|
||||
|
||||
export interface DecisionRecord {
|
||||
node_id: string
|
||||
question: string | null
|
||||
@@ -9,6 +11,9 @@ export interface DecisionRecord {
|
||||
notes: string | null
|
||||
automation_used: boolean
|
||||
timestamp: string
|
||||
entered_at?: string | null
|
||||
exited_at?: string | null
|
||||
duration_seconds?: number | null
|
||||
attachments: string[]
|
||||
}
|
||||
|
||||
@@ -45,6 +50,8 @@ export interface Session {
|
||||
custom_steps: CustomStep[]
|
||||
started_at: string
|
||||
completed_at: string | null
|
||||
outcome: SessionOutcome | null
|
||||
outcome_notes: string | null
|
||||
ticket_number: string | null
|
||||
client_name: string | null
|
||||
exported: boolean
|
||||
@@ -72,6 +79,11 @@ export interface SessionExport {
|
||||
include_tree_info?: boolean
|
||||
}
|
||||
|
||||
export interface SessionComplete {
|
||||
outcome: SessionOutcome
|
||||
outcome_notes?: string
|
||||
}
|
||||
|
||||
// Navigation state for active session
|
||||
export interface SessionNavigationState {
|
||||
activeSession: Session | null
|
||||
|
||||
Reference in New Issue
Block a user