feat: add procedural flows with intake forms, navigation, and seed templates
Adds a new "procedural" tree type for linear step-by-step project workflows (domain controller setup, M365 onboarding, VPN config, etc). Includes intake form builder, two-panel step navigation, variable resolution, procedural exports, 3 seed templates, and UI rename from "Trees" to "Flows". Also archives 19 implemented plan docs and creates deferred features backlog. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
402
frontend/src/pages/ProceduralNavigationPage.tsx
Normal file
402
frontend/src/pages/ProceduralNavigationPage.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { ChevronLeft, ChevronRight, ListOrdered, Settings2, X } from 'lucide-react'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import type { Tree, Session, ProceduralStep, DecisionRecord } from '@/types'
|
||||
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 { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface StepState {
|
||||
notes: string
|
||||
verificationValue: string
|
||||
completedAt: string | null
|
||||
}
|
||||
|
||||
export function ProceduralNavigationPage() {
|
||||
const { id: treeId } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [tree, setTree] = useState<Tree | null>(null)
|
||||
const [session, setSession] = useState<Session | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showIntakeForm, setShowIntakeForm] = useState(false)
|
||||
const [sessionVariables, setSessionVariables] = useState<Record<string, string>>({})
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0)
|
||||
const [stepStates, setStepStates] = useState<Map<string, StepState>>(new Map())
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [completedAt, setCompletedAt] = useState<string>('')
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [paramsOpen, setParamsOpen] = useState(false)
|
||||
const [elapsedMinutes, setElapsedMinutes] = useState(0)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
// 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 = steps.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 + (step.estimated_minutes || 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])
|
||||
|
||||
const loadTree = async (id: string) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const treeData = await treesApi.get(id)
|
||||
if (treeData.tree_type !== 'procedural') {
|
||||
navigate(`/trees/${id}/navigate`, { replace: true })
|
||||
return
|
||||
}
|
||||
setTree(treeData)
|
||||
|
||||
// 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 procedure')
|
||||
navigate('/my-trees')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startSession = async (id: string, variables: Record<string, string>) => {
|
||||
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<string, StepState>()
|
||||
const allSteps = getStepsFromTree(tree!)
|
||||
for (const step of allSteps) {
|
||||
initialStates.set(step.id, { notes: '', verificationValue: '', completedAt: null })
|
||||
}
|
||||
setStepStates(initialStates)
|
||||
} catch {
|
||||
toast.error('Failed to start session')
|
||||
}
|
||||
}
|
||||
|
||||
const getStepsFromTree = (t: Tree): ProceduralStep[] => {
|
||||
const structure = t.tree_structure as unknown as { steps?: ProceduralStep[] }
|
||||
return structure.steps || []
|
||||
}
|
||||
|
||||
const handleIntakeSubmit = async (variables: Record<string, string>) => {
|
||||
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)
|
||||
} 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
|
||||
})
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-white/20 border-t-white" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Intake form modal
|
||||
if (showIntakeForm && tree) {
|
||||
return (
|
||||
<IntakeFormModal
|
||||
isOpen={true}
|
||||
fields={tree.intake_form || []}
|
||||
treeName={tree.name}
|
||||
onSubmit={handleIntakeSubmit}
|
||||
onCancel={() => navigate('/my-trees')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Completion summary
|
||||
if (isComplete && tree && session) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 sm:px-6">
|
||||
<CompletionSummary
|
||||
treeName={tree.name}
|
||||
steps={steps}
|
||||
completions={new Map(
|
||||
Array.from(stepStates.entries())
|
||||
.filter(([, s]) => 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('/my-trees')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// No session yet
|
||||
if (!session || !tree) return null
|
||||
|
||||
const currentStep = procedureSteps[currentStepIndex]
|
||||
const currentStepState = currentStep ? stepStates.get(currentStep.id) : undefined
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||
{/* Top bar */}
|
||||
<div className="border-b border-white/[0.06] px-4 py-3 sm:px-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="rounded-md p-1.5 text-white/40 hover:bg-white/10 hover:text-white lg:hidden"
|
||||
>
|
||||
{sidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
<ListOrdered className="h-5 w-5 text-white/40" />
|
||||
<h1 className="text-sm font-semibold text-white sm:text-base">{tree.name}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<ProgressBar
|
||||
currentStep={completedStepIds.size}
|
||||
totalSteps={procedureSteps.length}
|
||||
elapsedMinutes={elapsedMinutes}
|
||||
estimatedTotalMinutes={estimatedTotalMinutes || undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Left sidebar - step checklist */}
|
||||
<div
|
||||
className={cn(
|
||||
'border-r border-white/[0.06] bg-black/30 transition-all duration-200',
|
||||
sidebarOpen ? 'w-72 p-3' : 'w-0 overflow-hidden p-0'
|
||||
)}
|
||||
>
|
||||
{sidebarOpen && (
|
||||
<>
|
||||
<StepChecklist
|
||||
steps={steps}
|
||||
currentStepIndex={currentStepIndex}
|
||||
completedStepIds={completedStepIds}
|
||||
onStepClick={setCurrentStepIndex}
|
||||
/>
|
||||
|
||||
{/* View Parameters button */}
|
||||
{Object.keys(sessionVariables).length > 0 && (
|
||||
<div className="mt-3 border-t border-white/[0.06] pt-3">
|
||||
<button
|
||||
onClick={() => setParamsOpen(true)}
|
||||
className="flex w-full items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-xs text-white/40 hover:bg-white/[0.06] hover:text-white/60"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
View Parameters ({Object.keys(sessionVariables).length})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right panel - step detail */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-6">
|
||||
{currentStep && (
|
||||
<StepDetail
|
||||
step={currentStep}
|
||||
stepNumber={currentStepIndex + 1}
|
||||
totalSteps={procedureSteps.length}
|
||||
variables={sessionVariables}
|
||||
notes={currentStepState?.notes || ''}
|
||||
onNotesChange={handleStepNotesChange}
|
||||
verificationValue={currentStepState?.verificationValue || ''}
|
||||
onVerificationChange={handleVerificationChange}
|
||||
isCompleted={completedStepIds.has(currentStep.id)}
|
||||
onMarkComplete={handleMarkComplete}
|
||||
isLast={currentStepIndex === procedureSteps.length - 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parameters popover */}
|
||||
{paramsOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setParamsOpen(false)}
|
||||
/>
|
||||
<div className="relative w-full max-w-md rounded-2xl border border-white/10 bg-black/95 shadow-2xl backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-4">
|
||||
<h3 className="text-sm font-semibold text-white">Project Parameters</h3>
|
||||
<button
|
||||
onClick={() => setParamsOpen(false)}
|
||||
className="rounded-lg p-1 text-white/40 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[60vh] overflow-y-auto p-5">
|
||||
<div className="space-y-2">
|
||||
{Object.entries(sessionVariables).map(([key, value]) => (
|
||||
<div key={key} className="flex items-baseline justify-between gap-4 rounded-lg bg-white/[0.03] px-3 py-2">
|
||||
<span className="text-xs font-medium text-white/40">{key.replace(/_/g, ' ')}</span>
|
||||
<span className="text-right text-sm text-white/70">{value || 'N/A'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProceduralNavigationPage
|
||||
Reference in New Issue
Block a user