import { useEffect, useState, useRef } from 'react' import { useParams, useNavigate, useLocation } from 'react-router-dom' import { ChevronLeft, ChevronRight, ListOrdered, Settings2, X, Plus, Check, AlertCircle } from 'lucide-react' import { treesApi } from '@/api/trees' import { sessionsApi } from '@/api/sessions' import { stepsApi } from '@/api/steps' import type { Tree, Session, ProceduralStep, DecisionRecord, RuntimeStep, CustomProceduralStep, IntakeFormField } from '@/types' import type { CustomStep, FallbackStepRecord } from '@/types/session' import { FallbackSteps } from '@/components/procedural/FallbackSteps' import type { Step } from '@/types/step' import { StepChecklist } from '@/components/procedural/StepChecklist' import { StepDetail } from '@/components/procedural/StepDetail' import { ProgressBar } from '@/components/procedural/ProgressBar' import { CompletionSummary } from '@/components/procedural/CompletionSummary' import { ConfirmDialog } from '@/components/common/ConfirmDialog' import { Spinner } from '@/components/common/Spinner' import { cn } from '@/lib/utils' import { toast } from '@/lib/toast' import { StepFeedback } from '@/components/session/StepFeedback' import { CSATModal } from '@/components/session/CSATModal' import { hasBeenRated } from '@/components/session/csatUtils' import { MaintenanceContextStrip } from '@/components/maintenance/MaintenanceContextStrip' import { CustomStepModal } from '@/components/step-library/CustomStepModal' import type { CustomStepDraft } from '@/components/step-library/CustomStepModal' import { PostStepActionModal } from '@/components/session/PostStepActionModal' import { CopilotPanel } from '@/components/copilot/CopilotPanel' import { CopilotToggle } from '@/components/copilot/CopilotToggle' import { SupportingDataPanel } from '@/components/session/SupportingDataPanel' import { integrationsApi, sessionPsaApi } from '@/api/integrations' import { TicketPickerModal } from '@/components/session/TicketPickerModal' import { TicketLinkIndicator } from '@/components/session/TicketLinkIndicator' import { UpdateTicketModal } from '@/components/session/UpdateTicketModal' import type { PSATicketInfo } from '@/types/integrations' import { addRecentFlow } from '@/lib/recentFlows' import { analytics } from '@/lib/analytics' import { useTicketContext } from '@/hooks/useTicketContext' import { TicketContextPanel } from '@/components/session/TicketContextPanel' interface StepState { notes: string verificationValue: string completedAt: string | null } function buildRuntimeSteps(baseSteps: ProceduralStep[], customSteps: CustomStep[]): RuntimeStep[] { const result: RuntimeStep[] = [...baseSteps] const sorted = [...customSteps].sort((a, b) => a.timestamp.localeCompare(b.timestamp)) for (const cs of sorted) { const afterIdx = result.findIndex((s) => s.id === cs.inserted_after_node_id) const insertAt = afterIdx >= 0 ? afterIdx + 1 : result.length const runtimeCustom: CustomProceduralStep = { id: cs.id, type: 'procedure_step', title: cs.step_data.title, description: cs.step_data.content?.instructions, content_type: 'action', commands: cs.step_data.content?.commands?.map((c) => ({ code: c.command, label: c.label, })), isCustom: true, } result.splice(insertAt, 0, runtimeCustom) } return result } export function ProceduralNavigationPage() { const { id: treeId } = useParams<{ id: string }>() const navigate = useNavigate() const location = useLocation() const locationState = location.state as { sessionId?: string } | undefined const [tree, setTree] = useState(null) const [session, setSession] = useState(null) const [isLoading, setIsLoading] = useState(true) const [sessionVariables, setSessionVariables] = useState>({}) const [currentStepIndex, setCurrentStepIndex] = useState(0) const [stepStates, setStepStates] = useState>(new Map()) const [isComplete, setIsComplete] = useState(false) const [completedAt, setCompletedAt] = useState('') const [showExitConfirm, setShowExitConfirm] = useState(false) const [sidebarOpen, setSidebarOpen] = useState(true) const [paramsOpen, setParamsOpen] = useState(false) const [showCsatModal, setShowCsatModal] = useState(false) const [elapsedMinutes, setElapsedMinutes] = useState(0) const [batchProgress, setBatchProgress] = useState<{ completed: number; total: number } | null>(null) const timerRef = useRef | null>(null) // Fallback step decisions const [fallbackDecisions, setFallbackDecisions] = useState([]) // Custom step state const [runtimeSteps, setRuntimeSteps] = useState([]) const [sessionCustomSteps, setSessionCustomSteps] = useState([]) const [showCustomStepModal, setShowCustomStepModal] = useState(false) const [showPostStepModal, setShowPostStepModal] = useState(false) const [pendingCustomStep, setPendingCustomStep] = useState(null) const [pendingIsFromLibrary, setPendingIsFromLibrary] = useState(false) const [isSavingStep, setIsSavingStep] = useState(false) const [copilotOpen, setCopilotOpen] = useState(false) // PSA ticket context const { context: ticketContext, loading: ticketContextLoading, error: ticketContextError, refresh: refreshTicketContext } = useTicketContext( session?.psa_ticket_id, session?.psa_connection_id ) // PSA ticket link state const [hasConnection, setHasConnection] = useState(false) const [showTicketPicker, setShowTicketPicker] = useState(false) const [showUpdateModal, setShowUpdateModal] = useState(false) const [psaTicketInfo, setPsaTicketInfo] = useState(null) // Editable variables panel state const [editingVarName, setEditingVarName] = useState(null) const [editingVarValue, setEditingVarValue] = useState('') const [showUnfilledWarning, setShowUnfilledWarning] = useState(false) // Get intake form fields from tree snapshot or tree const intakeFields: IntakeFormField[] = (() => { if (tree?.intake_form && tree.intake_form.length > 0) return tree.intake_form // Fallback: check tree snapshot on session const snapshot = session?.tree_snapshot as Record | undefined if (snapshot?.intake_form && Array.isArray(snapshot.intake_form)) { return snapshot.intake_form as IntakeFormField[] } return [] })() // Get procedural steps from tree const getSteps = (): ProceduralStep[] => { if (!tree) return [] const structure = tree.tree_structure as unknown as { steps?: ProceduralStep[] } return structure.steps || [] } const steps = getSteps() const procedureSteps = runtimeSteps.filter((s) => s.type === 'procedure_step') const completedStepIds = new Set( Array.from(stepStates.entries()) .filter(([, state]) => state.completedAt) .map(([id]) => id) ) const estimatedTotalMinutes = procedureSteps.reduce( (sum, step) => sum + (('estimated_minutes' in step ? step.estimated_minutes : undefined) || 0), 0 ) // Load tree useEffect(() => { if (!treeId) return loadTree(treeId) return () => { if (timerRef.current) clearInterval(timerRef.current) } }, [treeId]) // Check for PSA connection on mount useEffect(() => { integrationsApi.getConnection() .then((conn) => setHasConnection(!!conn)) .catch(() => setHasConnection(false)) }, []) const handleTicketLinked = (linkedTicketId: string, ticket: PSATicketInfo) => { setPsaTicketInfo(ticket) setSession((prev) => prev ? { ...prev, psa_ticket_id: linkedTicketId } : prev) setShowTicketPicker(false) toast.success(`Linked to CW #${linkedTicketId}`) } const handleTicketUnlink = async () => { if (!session) return try { await sessionPsaApi.linkTicket(session.id, null) setSession((prev) => prev ? { ...prev, psa_ticket_id: null } : prev) setPsaTicketInfo(null) toast.success('Ticket unlinked') } catch { toast.error('Failed to unlink ticket') } } // Parse backend timestamp — ensure UTC if no timezone info const parseTimestamp = (ts: string) => { if (!ts.endsWith('Z') && !ts.includes('+') && !/\d{2}:\d{2}$/.test(ts.slice(-5))) { return new Date(ts + 'Z') } return new Date(ts) } // Elapsed time timer useEffect(() => { if (session && session.started_at && !isComplete) { const calcElapsed = () => { const start = parseTimestamp(session.started_at!).getTime() setElapsedMinutes(Math.max(0, Math.floor((Date.now() - start) / 60000))) } calcElapsed() timerRef.current = setInterval(calcElapsed, 30000) } return () => { if (timerRef.current) clearInterval(timerRef.current) } }, [session, isComplete]) // Fetch batch progress once when session loads (maintenance flows only) useEffect(() => { if (!session?.batch_id) return sessionsApi.list({ batch_id: session.batch_id, size: 100 }) .then(data => { if (Array.isArray(data) && data.length > 0) { const completed = data.filter(s => s.completed_at).length setBatchProgress({ completed, total: data.length }) } }) .catch(() => {}) }, [session?.batch_id]) const loadTree = async (id: string) => { setIsLoading(true) try { const treeData = await treesApi.get(id) if (treeData.tree_type !== 'procedural' && treeData.tree_type !== 'maintenance') { navigate(`/trees/${id}/navigate`, { replace: true }) return } setTree(treeData) addRecentFlow({ id: treeData.id, name: treeData.name, tree_type: treeData.tree_type }) analytics.flowViewed({ flow_id: treeData.id, flow_type: treeData.tree_type, flow_name: treeData.name }) // If resuming an existing session if (locationState?.sessionId) { await resumeSession(treeData, locationState.sessionId) return } // Start session immediately — no intake form modal // Variables will be filled inline during execution await startSession(id, {}, treeData) } catch { toast.error('Failed to load flow') navigate('/trees') } finally { setIsLoading(false) } } const startSession = async (id: string, variables: Record, treeData?: Tree) => { try { const newSession = await sessionsApi.create({ tree_id: id, session_variables: Object.keys(variables).length > 0 ? variables : undefined, }) setSession(newSession) analytics.sessionStarted({ session_id: newSession.id, flow_id: id, flow_type: treeData?.tree_type || 'procedural' }) setSessionVariables(variables) // Initialize step states — use passed treeData since `tree` state may not have committed yet const initialStates = new Map() const allSteps = getStepsFromTree(treeData || tree!) for (const step of allSteps) { initialStates.set(step.id, { notes: '', verificationValue: '', completedAt: null }) } setStepStates(initialStates) setRuntimeSteps(allSteps) setSessionCustomSteps([]) } catch { toast.error('Failed to start session') } } const resumeSession = async (treeData: Tree, sessionId: string) => { try { const sessionData = await sessionsApi.get(sessionId) setSession(sessionData) setSessionVariables(sessionData.session_variables || {}) // Initialize step states from session decisions const allSteps = getStepsFromTree(treeData) // Initialize custom steps from session data const customSteps = sessionData.custom_steps || [] setSessionCustomSteps(customSteps) const hydrated = buildRuntimeSteps(allSteps, customSteps) setRuntimeSteps(hydrated) const initialStates = new Map() for (const step of hydrated) { if (step.type === 'procedure_step') { initialStates.set(step.id, { notes: '', verificationValue: '', completedAt: null }) } } // Hydrate completed steps from decisions for (const decision of sessionData.decisions || []) { if (decision.answer === 'completed' && initialStates.has(decision.node_id)) { initialStates.set(decision.node_id, { notes: decision.notes || '', verificationValue: decision.command_output || '', completedAt: decision.exited_at || decision.timestamp, }) } } setStepStates(initialStates) // Set current step to first incomplete step const pSteps = hydrated.filter((s) => s.type === 'procedure_step') const firstIncomplete = pSteps.findIndex((s) => !initialStates.get(s.id)?.completedAt) setCurrentStepIndex(firstIncomplete >= 0 ? firstIncomplete : pSteps.length - 1) } catch { toast.error('Failed to resume session') navigate('/trees') } } const getStepsFromTree = (t: Tree): ProceduralStep[] => { const structure = t.tree_structure as unknown as { steps?: ProceduralStep[] } return structure.steps || [] } // Handle inline variable submission (from StepDetail or variables panel) const handleVariableSubmit = async (variableName: string, value: string) => { if (!session) return // Optimistic update const newVars = { ...sessionVariables, [variableName]: value } setSessionVariables(newVars) try { await sessionsApi.updateVariables(session.id, { [variableName]: value }) } catch { // Revert on failure setSessionVariables(sessionVariables) toast.error('Failed to save variable') } } const handleMarkComplete = async () => { if (!session || procedureSteps.length === 0) return const currentStep = procedureSteps[currentStepIndex] if (!currentStep) return const now = new Date().toISOString() // Update step state setStepStates((prev) => { const next = new Map(prev) const existing = next.get(currentStep.id) || { notes: '', verificationValue: '', completedAt: null } next.set(currentStep.id, { ...existing, completedAt: now }) return next }) // Create a decision record for this step const stepState = stepStates.get(currentStep.id) const decision: DecisionRecord = { node_id: currentStep.id, question: currentStep.title, answer: 'completed', action_performed: currentStep.description || null, notes: stepState?.notes || null, command_output: stepState?.verificationValue || null, automation_used: false, timestamp: now, entered_at: null, exited_at: now, duration_seconds: null, attachments: [], } try { const updatedDecisions = [...(session.decisions || []), decision] await sessionsApi.update(session.id, { decisions: updatedDecisions, path_taken: [...(session.path_taken || []), currentStep.id], }) setSession((prev) => prev ? { ...prev, decisions: updatedDecisions, path_taken: [...(prev.path_taken || []), currentStep.id], } : prev) // Move to next step or complete if (currentStepIndex >= procedureSteps.length - 1) { // Last step — check for unfilled required variables const unfilledReqVars = intakeFields.filter( f => f.required && !sessionVariables[f.variable_name]?.trim() ) if (unfilledReqVars.length > 0 && !showUnfilledWarning) { // Show warning but don't block setShowUnfilledWarning(true) return } setShowUnfilledWarning(false) // Complete the procedure const completedTime = new Date().toISOString() await sessionsApi.complete(session.id, { outcome: 'resolved', outcome_notes: `Procedure completed. ${procedureSteps.length} steps finished.`, }) setCompletedAt(completedTime) setIsComplete(true) analytics.sessionCompleted({ session_id: session.id, flow_type: tree?.tree_type || 'procedural', outcome: 'resolved' }) if (!hasBeenRated(session.id)) { setShowCsatModal(true) } } else { setCurrentStepIndex(currentStepIndex + 1) } } catch { toast.error('Failed to save progress') } } const handleStepNotesChange = (notes: string) => { const currentStep = procedureSteps[currentStepIndex] if (!currentStep) return setStepStates((prev) => { const next = new Map(prev) const existing = next.get(currentStep.id) || { notes: '', verificationValue: '', completedAt: null } next.set(currentStep.id, { ...existing, notes }) return next }) } const handleVerificationChange = (value: string) => { const currentStep = procedureSteps[currentStepIndex] if (!currentStep) return setStepStates((prev) => { const next = new Map(prev) const existing = next.get(currentStep.id) || { notes: '', verificationValue: '', completedAt: null } next.set(currentStep.id, { ...existing, verificationValue: value }) return next }) } const handleCsatClose = () => { setShowCsatModal(false) } const handleFallbackComplete = ( parentStepId: string, fallbackStepId: string, notes: string | null, outcome: 'resolved' | 'not_resolved' | 'skipped' ) => { const record: FallbackStepRecord = { parent_step_id: parentStepId, fallback_step_id: fallbackStepId, completed_at: new Date().toISOString(), notes, outcome, } setFallbackDecisions((prev) => [...prev, record]) } const handleStepCreated = (step: Step | CustomStepDraft, isFromLibrary: boolean) => { setPendingCustomStep(step) setPendingIsFromLibrary(isFromLibrary) setShowCustomStepModal(false) setShowPostStepModal(true) } const handleInsertCustomStep = async (step: Step | CustomStepDraft) => { if (!session) return const id = crypto.randomUUID() const currentStep = procedureSteps[currentStepIndex] const insertedAfterId = currentStep?.id ?? '' const runtimeCustom: CustomProceduralStep = { id, type: 'procedure_step', title: step.title, description: step.content?.instructions, content_type: 'action', commands: step.content?.commands?.map((c) => ({ code: c.command, label: c.label, })), isCustom: true, } setRuntimeSteps((prev) => { const next = [...prev] const globalIdx = next.findIndex((s) => s.id === insertedAfterId) const insertAt = globalIdx >= 0 ? globalIdx + 1 : next.length next.splice(insertAt, 0, runtimeCustom) return next }) setStepStates((prev) => { const next = new Map(prev) next.set(id, { notes: '', verificationValue: '', completedAt: null }) return next }) const newCustomStep: CustomStep = { id, inserted_after_node_id: insertedAfterId, step_data: step, timestamp: new Date().toISOString(), } const newCustomSteps = [...sessionCustomSteps, newCustomStep] setSessionCustomSteps(newCustomSteps) try { await sessionsApi.update(session.id, { custom_steps: newCustomSteps }) } catch { toast.error('Failed to save custom step') } setCurrentStepIndex(prev => prev + 1) } const handleSaveForLater = async () => { if (!pendingCustomStep || pendingIsFromLibrary) return setIsSavingStep(true) try { await stepsApi.create({ title: pendingCustomStep.title, step_type: pendingCustomStep.step_type, content: pendingCustomStep.content, visibility: 'private', }) toast.success('Step saved to library') } catch { toast.error('Failed to save step') } finally { setIsSavingStep(false) setShowPostStepModal(false) setPendingCustomStep(null) } } const handleUseNow = async () => { if (!pendingCustomStep) return setShowPostStepModal(false) await handleInsertCustomStep(pendingCustomStep) setPendingCustomStep(null) } const handleBoth = async () => { if (!pendingCustomStep || pendingIsFromLibrary) return setIsSavingStep(true) try { await stepsApi.create({ title: pendingCustomStep.title, step_type: pendingCustomStep.step_type, content: pendingCustomStep.content, visibility: 'private', }) } catch { toast.error('Failed to save step to library') } finally { setIsSavingStep(false) } setShowPostStepModal(false) await handleInsertCustomStep(pendingCustomStep) setPendingCustomStep(null) } // Variables panel: start editing const startEditingVar = (varName: string) => { setEditingVarName(varName) setEditingVarValue(sessionVariables[varName] || '') } // Variables panel: save edit const saveEditingVar = () => { if (editingVarName && editingVarValue.trim()) { handleVariableSubmit(editingVarName, editingVarValue.trim()) } setEditingVarName(null) setEditingVarValue('') } // Loading state if (isLoading) { return (
) } // Completion summary if (isComplete && tree && session) { return (
s.completedAt) .map(([id, s]) => [id, { stepId: id, notes: s.notes, verificationValue: s.verificationValue, completedAt: s.completedAt!, }]) )} variables={sessionVariables} startedAt={session.started_at || ''} completedAt={completedAt} onExport={() => navigate(`/sessions/${session.id}`)} onClose={() => navigate('/trees')} />
) } // No session yet if (!session || !tree) return null const currentStep = procedureSteps[currentStepIndex] const currentStepState = currentStep ? stepStates.get(currentStep.id) : undefined // Count unfilled required variables const unfilledRequired = intakeFields.filter( f => f.required && !sessionVariables[f.variable_name]?.trim() ).length return (
{/* Top bar */}

{tree.name}

{session && (
setShowTicketPicker(true)} onUnlink={handleTicketUnlink} onUpdateClick={session.psa_ticket_id ? () => setShowUpdateModal(true) : undefined} ticketInfo={psaTicketInfo} />
)}
{/* Maintenance context strip */} {tree?.tree_type === 'maintenance' && session && ( )} {/* Main content */}
{/* Left sidebar - step checklist */}
{sidebarOpen && ( <> {/* PSA Ticket Context Panel */} {session?.psa_ticket_id && (
)} {/* Session Variables button */} {intakeFields.length > 0 && (
)} )}
{/* Right panel - step detail + copilot */}
{currentStep && ( )} {/* Fallback steps — shown when step has fallback alternatives */} {currentStep && !('isCustom' in currentStep && currentStep.isCustom) && 'fallback_steps' in currentStep && ( d.parent_step_id === currentStep.id && d.outcome === 'resolved') .map((d) => d.fallback_step_id) )} onComplete={(fallbackStepId, notes, outcome) => handleFallbackComplete(currentStep.id, fallbackStepId, notes, outcome) } /> )} {/* Add custom step — only on current active incomplete non-custom step */} {currentStep && !completedStepIds.has(currentStep.id) && !('isCustom' in currentStep && currentStep.isCustom) && (
)} {session && currentStep && !('isCustom' in currentStep && currentStep.isCustom) && (
)} {/* Supporting Data */} {session && (
)}
{/* AI Copilot - in-flow panel */} {treeId && copilotOpen && ( setCopilotOpen(false)} treeId={treeId} sessionId={session?.id} currentNodeId={runtimeSteps[currentStepIndex]?.id} /> )}
{/* CSAT Modal */} {session && ( )} setShowExitConfirm(false)} onConfirm={() => navigate('/trees')} title="Exit Session" message="You have progress in this session. Are you sure you want to exit? Your progress will not be saved." confirmLabel="Exit" /> setShowUnfilledWarning(false)} onConfirm={handleMarkComplete} title="Unfilled Variables" message={`${intakeFields.filter(f => f.required && !sessionVariables[f.variable_name]?.trim()).length} required variable(s) are still empty. You can fill them now via the Session Variables panel, or complete anyway. Unfilled variables will appear as blank in exports.`} confirmLabel="Complete Anyway" /> {/* Session Variables Panel (editable) */} {paramsOpen && (
setParamsOpen(false)} />

Session Variables

{intakeFields .sort((a, b) => a.display_order - b.display_order) .map((field) => { const value = sessionVariables[field.variable_name] || '' const isFilled = !!value.trim() const isEditing = editingVarName === field.variable_name return (
{isFilled ? ( ) : ( )} {field.label} {field.required && *}
{!isEditing && ( )}
{isEditing ? (
{field.field_type === 'select' && field.options?.length ? ( ) : field.field_type === 'textarea' ? (