diff --git a/frontend/src/pages/ProceduralNavigationPage.tsx b/frontend/src/pages/ProceduralNavigationPage.tsx index 81500dae..26fc7781 100644 --- a/frontend/src/pages/ProceduralNavigationPage.tsx +++ b/frontend/src/pages/ProceduralNavigationPage.tsx @@ -1,9 +1,12 @@ import { useEffect, useState, useRef } from 'react' import { useParams, useNavigate, useLocation } from 'react-router-dom' -import { ChevronLeft, ChevronRight, ListOrdered, Settings2, X } from 'lucide-react' +import { ChevronLeft, ChevronRight, ListOrdered, Settings2, X, Plus } from 'lucide-react' import { treesApi } from '@/api/trees' import { sessionsApi } from '@/api/sessions' -import type { Tree, Session, ProceduralStep, DecisionRecord } from '@/types' +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' @@ -17,6 +20,9 @@ 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' interface StepState { notes: string @@ -24,6 +30,29 @@ interface StepState { 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() @@ -47,6 +76,15 @@ export function ProceduralNavigationPage() { 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) + // Get procedural steps from tree const getSteps = (): ProceduralStep[] => { if (!tree) return [] @@ -55,7 +93,7 @@ export function ProceduralNavigationPage() { } const steps = getSteps() - const procedureSteps = steps.filter((s) => s.type === 'procedure_step') + const procedureSteps = runtimeSteps.filter((s) => s.type === 'procedure_step') const completedStepIds = new Set( Array.from(stepStates.entries()) .filter(([, state]) => state.completedAt) @@ -63,7 +101,7 @@ export function ProceduralNavigationPage() { ) const estimatedTotalMinutes = procedureSteps.reduce( - (sum, step) => sum + (step.estimated_minutes || 0), + (sum, step) => sum + (('estimated_minutes' in step ? step.estimated_minutes : undefined) || 0), 0 ) @@ -159,6 +197,8 @@ export function ProceduralNavigationPage() { initialStates.set(step.id, { notes: '', verificationValue: '', completedAt: null }) } setStepStates(initialStates) + setRuntimeSteps(allSteps) + setSessionCustomSteps([]) } catch { toast.error('Failed to start session') } @@ -173,6 +213,13 @@ export function ProceduralNavigationPage() { // 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 allSteps) { initialStates.set(step.id, { notes: '', verificationValue: '', completedAt: null }) @@ -191,7 +238,7 @@ export function ProceduralNavigationPage() { setStepStates(initialStates) // Set current step to first incomplete step - const pSteps = allSteps.filter((s) => s.type === 'procedure_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 { @@ -303,6 +350,112 @@ export function ProceduralNavigationPage() { 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(currentStepIndex + 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 ( @@ -419,7 +572,7 @@ export function ProceduralNavigationPage() { {sidebarOpen && ( <> )} + + {/* Add custom step — only on current active incomplete non-custom step */} + {currentStep && !completedStepIds.has(currentStep.id) && !('isCustom' in currentStep && currentStep.isCustom) && ( +
+ +
+ )} + {session && currentStep && (
@@ -514,6 +681,27 @@ export function ProceduralNavigationPage() {
)} + + {/* 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} + /> + )} ) }