import { useEffect, useState, useRef } from 'react' import { useParams, useNavigate, useLocation } from 'react-router-dom' import { ChevronLeft, ChevronRight, ListOrdered, Settings2, X, Plus } 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 } from '@/types' import type { CustomStep } from '@/types/session' import type { Step } from '@/types/step' import { IntakeFormModal } from '@/components/procedural/IntakeFormModal' 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' 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 [showIntakeForm, setShowIntakeForm] = useState(false) 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) // 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) // 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]) // 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 && !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) // If resuming an existing session if (locationState?.sessionId) { await resumeSession(treeData, locationState.sessionId) return } // Check if intake form exists if (treeData.intake_form && treeData.intake_form.length > 0) { setShowIntakeForm(true) } else { await startSession(id, {}) } } catch { toast.error('Failed to load flow') navigate('/trees') } finally { setIsLoading(false) } } const startSession = async (id: string, variables: Record) => { try { const newSession = await sessionsApi.create({ tree_id: id, session_variables: Object.keys(variables).length > 0 ? variables : undefined, }) setSession(newSession) setSessionVariables(variables) setShowIntakeForm(false) // Initialize step states const initialStates = new Map() const allSteps = getStepsFromTree(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 || {}) setShowIntakeForm(false) // 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 || [] } const handleIntakeSubmit = async (variables: Record) => { if (!treeId) return await startSession(treeId, variables) } 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 — 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) 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 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) } // Loading state if (isLoading) { return (
) } // Intake form modal if (showIntakeForm && tree) { return ( navigate('/trees')} /> ) } // 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 return (
{/* Top bar */}

{tree.name}

{/* Maintenance context strip */} {tree?.tree_type === 'maintenance' && session && ( )} {/* Main content */}
{/* Left sidebar - step checklist */}
{sidebarOpen && ( <> {/* View Parameters button */} {Object.keys(sessionVariables).length > 0 && (
)} )}
{/* Right panel - step detail */}
{currentStep && ( )} {/* 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) && (
)}
{/* 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" /> {/* Parameters popover */} {paramsOpen && (
setParamsOpen(false)} />

Project Parameters

{Object.entries(sessionVariables).map(([key, value]) => (
{key.replace(/_/g, ' ')} {value || 'N/A'}
))}
)} {/* Custom Step Modal */} setShowCustomStepModal(false)} onInsertStep={handleStepCreated} /> {/* Post Step Action Modal */} {pendingCustomStep && ( { setShowPostStepModal(false); setPendingCustomStep(null) }} step={pendingCustomStep} onSaveForLater={handleSaveForLater} onUseNow={handleUseNow} onBoth={handleBoth} isFromLibrary={pendingIsFromLibrary} isSaving={isSavingStep} /> )} {/* AI Copilot */} {treeId && ( <> setCopilotOpen(true)} /> setCopilotOpen(false)} treeId={treeId} sessionId={session?.id} currentNodeId={runtimeSteps[currentStepIndex]?.id} /> )}
) } export default ProceduralNavigationPage