Files
resolutionflow/docs/plans/2026-02-19-procedural-editor-redesign-impl.md
chihlasm dff53e55bb feat: restructure procedural editor with collapsible sections and fixed-height layout
Convert scrolling document layout to fixed-height editor with accordion-mode
collapsible sections for Details and Intake Form. Step list now gets all
remaining height with independent scrolling. Add CollapsibleEditorSection
component with ARIA attributes (aria-expanded, aria-controls).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 03:15:00 -05:00

28 KiB

Procedural Editor Redesign Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Restructure the procedural/maintenance flow editor with collapsible sections, fixed-height layout, drag-to-reorder steps, and maintenance schedule management.

Architecture: Convert ProceduralEditorPage from a scrolling document to a fixed-height editor. Details and Intake Form become collapsible one-liners. Step list gets all remaining height with independent scrolling. Add @dnd-kit drag-to-reorder on steps. Maintenance flows get an inline schedule section.

Tech Stack: React 19, TypeScript, Tailwind CSS, Zustand (proceduralEditorStore), @dnd-kit/core + @dnd-kit/sortable (already installed), existing maintenance schedule APIs.


Phase 1: CollapsibleEditorSection Component

Task 1: Create CollapsibleEditorSection

Files:

  • Create: frontend/src/components/procedural-editor/CollapsibleEditorSection.tsx

Context: This is a reusable wrapper that shows a one-line summary when collapsed and reveals full content when expanded. Used by Details, Intake Form, and Maintenance Schedule sections. See the design doc at docs/plans/2026-02-19-procedural-editor-redesign-design.md for the spec.

Step 1: Create the component

// frontend/src/components/procedural-editor/CollapsibleEditorSection.tsx
import { useState, type ReactNode } from 'react'
import { ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'

interface CollapsibleEditorSectionProps {
  title: string
  icon: ReactNode
  summary: string
  expanded?: boolean
  onToggle?: () => void
  defaultExpanded?: boolean
  children: ReactNode
}

export function CollapsibleEditorSection({
  title,
  icon,
  summary,
  expanded: controlledExpanded,
  onToggle,
  defaultExpanded = false,
  children,
}: CollapsibleEditorSectionProps) {
  const [internalExpanded, setInternalExpanded] = useState(defaultExpanded)
  const isExpanded = controlledExpanded ?? internalExpanded
  const sectionId = `section-${title.toLowerCase().replace(/\s+/g, '-')}`

  const handleToggle = () => {
    if (onToggle) {
      onToggle()
    } else {
      setInternalExpanded(!internalExpanded)
    }
  }

  return (
    <div className="border-b border-border">
      {/* Collapsed header — always visible */}
      <button
        type="button"
        onClick={handleToggle}
        aria-expanded={isExpanded}
        aria-controls={sectionId}
        className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50"
      >
        <ChevronRight
          className={cn(
            'h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200',
            isExpanded && 'rotate-90'
          )}
        />
        <span className="shrink-0 text-muted-foreground">{icon}</span>
        <span className="text-sm font-medium text-foreground">{title}</span>
        {!isExpanded && (
          <span className="min-w-0 truncate text-sm text-muted-foreground">
             {summary}
          </span>
        )}
      </button>

      {/* Expanded content */}
      {isExpanded && (
        <div id={sectionId} className="px-4 pb-4 pt-1">
          {children}
        </div>
      )}
    </div>
  )
}

Step 2: Verify it builds

Run: cd frontend && npm run build 2>&1 | tail -5 Expected: built in success message (component is created but not imported anywhere yet)

Step 3: Commit

git add frontend/src/components/procedural-editor/CollapsibleEditorSection.tsx
git commit -m "feat: add CollapsibleEditorSection component for procedural editor"

Phase 2: Layout Restructure

Task 2: Convert ProceduralEditorPage to Fixed-Height Editor

Files:

  • Modify: frontend/src/pages/ProceduralEditorPage.tsx

Context: The page currently uses container mx-auto px-4 py-6 with vertical scrolling. We need to convert it to flex flex-col h-full overflow-hidden so the step list can scroll independently. The toolbar becomes a sticky header, and Details + Intake Form become collapsible sections.

Reference the existing troubleshooting editor pattern: frontend/src/pages/TreeEditorPage.tsx lines 409-410 for the flex h-full flex-col overflow-hidden pattern.

Step 1: Read the current file

Read: frontend/src/pages/ProceduralEditorPage.tsx Understand the full structure before making changes.

Step 2: Restructure the layout

Replace the entire render return (from return ( to the closing )) with the new fixed-height layout. The key changes:

  1. Outer wrapper: <div className="flex h-full flex-col overflow-hidden"> (replaces container mx-auto)
  2. Toolbar: sticky <div className="flex items-center justify-between border-b border-border bg-card px-4 py-2"> containing the back button, title, and save/publish buttons
  3. Collapsible sections zone: <div className="shrink-0"> containing CollapsibleEditorSection wrappers for Details and IntakeFormBuilder
  4. Step list zone: <div className="flex-1 min-h-0 overflow-y-auto px-4 py-4"> containing StepList

Import CollapsibleEditorSection at the top:

import { CollapsibleEditorSection } from '@/components/procedural-editor/CollapsibleEditorSection'

The Details collapsible section needs a summary string. Build it from store state:

const detailsSummary = [
  name ? `"${name}"` : '"Untitled"',
  tags.length > 0 ? `${tags.length} tag${tags.length !== 1 ? 's' : ''}` : 'No tags',
  isPublic ? 'Public' : 'Private',
].join(' · ')

The IntakeFormBuilder collapsible section needs a summary too. Read the intakeForm array from the store (add it to destructuring if not already there) and build:

const intakeSummary = intakeForm.length === 0
  ? 'No fields defined'
  : `${intakeForm.length} field${intakeForm.length !== 1 ? 's' : ''}: ${intakeForm.map(f => f.label || f.variable_name).slice(0, 4).join(', ')}${intakeForm.length > 4 ? ', ...' : ''}`

For the Details section content inside CollapsibleEditorSection, move the existing form fields (name input, description textarea, tags input, public checkbox) directly as children.

The Details section should defaultExpanded={!isEditMode} so new flows start with Details expanded (name is required), while existing flows start collapsed.

The Intake Form section should defaultExpanded={false} always.

Step 3: Verify it builds

Run: cd frontend && npm run build 2>&1 | tail -5 Expected: Clean build

Step 4: Commit

git add frontend/src/pages/ProceduralEditorPage.tsx
git commit -m "feat: restructure procedural editor to fixed-height layout with collapsible sections"

Phase 3: Step List Improvements

Task 3: Add Empty State and Step Count Header

Files:

  • Modify: frontend/src/components/procedural-editor/StepList.tsx

Context: The step list needs: (1) a step count + total estimated time in the header, (2) auto-scroll to new steps. Note: the store enforces minimum one procedure_step, so remove any "0 steps" UI paths. The step list's outer card wrapper should be removed since ProceduralEditorPage now provides the scrolling container — StepList should just render its header and step items directly.

Step 1: Read the current file

Read: frontend/src/components/procedural-editor/StepList.tsx

Step 2: Modify the header and add empty state

Remove the outer <div className="bg-card border border-border rounded-2xl p-4 sm:p-6"> card wrapper. The step list now renders directly in the scrolling container.

Update the header to show step count + total estimated time:

const totalMinutes = steps
  .filter(s => s.type === 'procedure_step' && s.estimated_minutes)
  .reduce((sum, s) => sum + (s.estimated_minutes || 0), 0)

// In the header:
<span className="text-sm text-muted-foreground">
  ({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''}
  {totalMinutes > 0 ? ` · ~${totalMinutes} min` : ''})
</span>

Add empty state after the header, before the step map:

{procedureSteps.length === 0 && (
  <div className="flex flex-col items-center justify-center py-16 text-center">
    <Shield className="mb-3 h-10 w-10 text-muted-foreground/50" />
    <h3 className="mb-1 text-sm font-medium text-foreground">Add your first step</h3>
    <p className="mb-4 max-w-xs text-xs text-muted-foreground">
      Steps define the actions engineers follow during this procedure.
    </p>
    <div className="flex items-center gap-2">
      <button
        onClick={() => addStep()}
        className="flex items-center gap-1.5 rounded-md bg-gradient-brand px-3 py-1.5 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
      >
        <Plus className="h-3.5 w-3.5" />
        Add Step
      </button>
      <button
        onClick={() => addSectionHeader()}
        className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
      >
        <SeparatorHorizontal className="h-3.5 w-3.5" />
        Add Section
      </button>
    </div>
  </div>
)}

Step 3: Add auto-scroll to new step

When a new step is added, the list should scroll to show it. Add a ref and useEffect:

import { useRef, useEffect } from 'react'

const listEndRef = useRef<HTMLDivElement>(null)
const prevStepCount = useRef(steps.length)

useEffect(() => {
  if (steps.length > prevStepCount.current) {
    listEndRef.current?.scrollIntoView({ behavior: 'smooth' })
  }
  prevStepCount.current = steps.length
}, [steps.length])

// At the bottom of the step list, before the Add Step button:
<div ref={listEndRef} />

Step 4: Verify it builds

Run: cd frontend && npm run build 2>&1 | tail -5 Expected: Clean build

Step 5: Commit

git add frontend/src/components/procedural-editor/StepList.tsx
git commit -m "feat: add empty state, step count header, and auto-scroll to step list"

Phase 4: Drag-to-Reorder Steps

Task 4: Add @dnd-kit Drag-to-Reorder to StepList

Files:

  • Modify: frontend/src/components/procedural-editor/StepList.tsx

Context: @dnd-kit is already installed (@dnd-kit/core@^6.3.1, @dnd-kit/sortable@^10.0.0). There's an existing pattern in frontend/src/components/step-library/CategoryRow.tsx using useSortable and in frontend/src/pages/admin/AdminCategoriesPage.tsx using DndContext + SortableContext. The store already has moveStep(fromIndex, toIndex). Reorder is array-index based only — do NOT recalculate display_order. procedure_end must remain non-draggable and always last. Drag handles must have accessible labels and keyboard support (Enter/Space to pick up, arrow keys to move, Escape to cancel).

Step 1: Read the existing @dnd-kit pattern

Read: frontend/src/components/step-library/CategoryRow.tsx (lines 1-50 for the useSortable pattern) Read: frontend/src/pages/admin/AdminCategoriesPage.tsx (lines 1-10 for imports, lines 110-140 for handleDragEnd)

Step 2: Add DndContext to StepList

Add imports at the top of StepList.tsx:

import {
  DndContext,
  closestCenter,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
  type DragEndEvent,
} from '@dnd-kit/core'
import {
  SortableContext,
  sortableKeyboardCoordinates,
  verticalListSortingStrategy,
  useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'

Add sensors and drag handler before the return:

const sensors = useSensors(
  useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
  useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)

const handleDragEnd = useCallback((event: DragEndEvent) => {
  const { active, over } = event
  if (!over || active.id === over.id) return

  const oldIndex = steps.findIndex(s => s.id === active.id)
  const newIndex = steps.findIndex(s => s.id === over.id)
  if (oldIndex === -1 || newIndex === -1) return

  // Don't allow moving past the procedure_end step
  const endIndex = steps.findIndex(s => s.type === 'procedure_end')
  if (newIndex >= endIndex) return

  moveStep(oldIndex, newIndex)
}, [steps, moveStep])

Wrap the step list <div className="space-y-2"> with DndContext and SortableContext:

<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
  <SortableContext items={steps.filter(s => s.type !== 'procedure_end').map(s => s.id)} strategy={verticalListSortingStrategy}>
    <div className="space-y-2">
      {steps.map((step) => {
        // ... existing rendering logic
      })}
    </div>
  </SortableContext>
</DndContext>

Step 3: Make each step card sortable

For each step card (procedure_step collapsed, section_header collapsed), extract the card div into a SortableStepCard wrapper or apply useSortable inline.

The simplest approach: create a small wrapper component inside StepList.tsx:

function SortableStepWrapper({ id, children, disabled }: { id: string; children: ReactNode; disabled?: boolean }) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled })

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
  }

  return (
    <div ref={setNodeRef} style={style} className={cn(isDragging && 'opacity-50 z-10')}>
      {/* Pass drag handle props to children via render prop or context */}
      {typeof children === 'function'
        ? children({ dragHandleProps: { ...attributes, ...listeners } })
        : children}
    </div>
  )
}

Actually, a simpler approach: apply useSortable directly to each card's GripVertical button. Wrap each step's outer <div> with ref={setNodeRef} and pass {...attributes} {...listeners} to the GripVertical button.

For collapsed procedure_step cards (the main case), the existing <GripVertical> button at line 148 gets the sortable props:

// Replace the GripVertical span/button:
<button
  type="button"
  className="shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground"
  {...attributes}
  {...listeners}
>
  <GripVertical className="h-4 w-4" />
</button>

For expanded steps (StepEditor), disable sorting (disabled: true in useSortable) since the user is editing.

The procedure_end step should NOT be in the SortableContext items array (already excluded above) and should not have useSortable applied.

Step 4: Verify it builds

Run: cd frontend && npm run build 2>&1 | tail -5 Expected: Clean build

Step 5: Commit

git add frontend/src/components/procedural-editor/StepList.tsx
git commit -m "feat: add drag-to-reorder steps with @dnd-kit"

Phase 5: Maintenance Schedule Section

Task 5: Create MaintenanceScheduleSection

Files:

  • Create: frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx

Context: This component renders only for maintenance flows. Schedule is NOT part of tree_structure — it's persisted through separate maintenance schedule API endpoints. Two-stage save: tree first, then schedule. If schedule save fails, tree save remains successful — show actionable error and preserve schedule draft state. It uses the existing maintenanceSchedulesApi from frontend/src/api/maintenanceSchedules.ts and targetListsApi from frontend/src/api/targetLists.ts. Types are in frontend/src/types/maintenance.ts. Schedule draft state should be local UI state, not stored in proceduralEditorStore.

Read these files first:

  • frontend/src/api/maintenanceSchedules.ts
  • frontend/src/api/targetLists.ts
  • frontend/src/types/maintenance.ts

Step 1: Create the component

// frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx
import { useState, useEffect } from 'react'
import { Calendar, Clock } from 'lucide-react'
import { maintenanceSchedulesApi } from '@/api/maintenanceSchedules'
import { targetListsApi } from '@/api/targetLists'
import type { MaintenanceSchedule, TargetList } from '@/types'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'

interface MaintenanceScheduleSectionProps {
  treeId: string | null  // null for new flows
}

const FREQUENCY_OPTIONS = [
  { value: 'daily', label: 'Daily', cron: (hour: number, min: number) => `${min} ${hour} * * *` },
  { value: 'weekly-mon', label: 'Weekly (Monday)', cron: (hour: number, min: number) => `${min} ${hour} * * 1` },
  { value: 'weekly-fri', label: 'Weekly (Friday)', cron: (hour: number, min: number) => `${min} ${hour} * * 5` },
  { value: 'monthly', label: 'Monthly (1st)', cron: (hour: number, min: number) => `${min} ${hour} 1 * *` },
] as const

const TIMEZONE_OPTIONS = [
  'UTC',
  'America/New_York',
  'America/Chicago',
  'America/Denver',
  'America/Los_Angeles',
  'Europe/London',
  'Europe/Berlin',
  'Asia/Tokyo',
  'Australia/Sydney',
]

export function MaintenanceScheduleSection({ treeId }: MaintenanceScheduleSectionProps) {
  const [schedule, setSchedule] = useState<MaintenanceSchedule | null>(null)
  const [targetLists, setTargetLists] = useState<TargetList[]>([])
  const [isLoading, setIsLoading] = useState(false)
  const [isSaving, setIsSaving] = useState(false)

  // Form state
  const [frequency, setFrequency] = useState('weekly-mon')
  const [hour, setHour] = useState(9)
  const [minute, setMinute] = useState(0)
  const [timezone, setTimezone] = useState('UTC')
  const [selectedTargetListId, setSelectedTargetListId] = useState<string>('')

  // Load existing schedule and target lists
  useEffect(() => {
    const load = async () => {
      setIsLoading(true)
      try {
        const lists = await targetListsApi.list()
        setTargetLists(lists)

        if (treeId) {
          try {
            const existing = await maintenanceSchedulesApi.getForTree(treeId)
            setSchedule(existing)
            if (existing.target_list_id) {
              setSelectedTargetListId(existing.target_list_id)
            }
          } catch {
            // No schedule yet — that's fine
          }
        }
      } catch {
        // Target lists may not load — non-critical
      } finally {
        setIsLoading(false)
      }
    }
    load()
  }, [treeId])

  const handleSaveSchedule = async () => {
    if (!treeId) {
      toast.error('Save the flow first before configuring a schedule')
      return
    }

    setIsSaving(true)
    try {
      const freqOption = FREQUENCY_OPTIONS.find(f => f.value === frequency)
      const cronExpression = freqOption?.cron(hour, minute) ?? `${minute} ${hour} * * 1`

      if (schedule) {
        const updated = await maintenanceSchedulesApi.update(schedule.id, {
          cron_expression: cronExpression,
          timezone,
          target_list_id: selectedTargetListId || undefined,
        })
        setSchedule(updated)
        toast.success('Schedule updated')
      } else {
        const created = await maintenanceSchedulesApi.create({
          tree_id: treeId,
          cron_expression: cronExpression,
          timezone,
          target_list_id: selectedTargetListId || undefined,
        })
        setSchedule(created)
        toast.success('Schedule created')
      }
    } catch {
      toast.error('Failed to save schedule')
    } finally {
      setIsSaving(false)
    }
  }

  const selectedTargetList = targetLists.find(tl => tl.id === selectedTargetListId)

  if (isLoading) {
    return (
      <div className="px-4 py-3 text-sm text-muted-foreground">Loading schedule...</div>
    )
  }

  return (
    <div className="space-y-4">
      {/* Frequency */}
      <div>
        <label className="mb-1 block text-sm font-medium text-muted-foreground">Frequency</label>
        <select
          value={frequency}
          onChange={(e) => setFrequency(e.target.value)}
          className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
        >
          {FREQUENCY_OPTIONS.map(opt => (
            <option key={opt.value} value={opt.value}>{opt.label}</option>
          ))}
        </select>
      </div>

      {/* Time */}
      <div className="grid grid-cols-2 gap-4">
        <div>
          <label className="mb-1 block text-sm font-medium text-muted-foreground">Hour</label>
          <input
            type="number"
            min={0}
            max={23}
            value={hour}
            onChange={(e) => setHour(Number(e.target.value))}
            className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
          />
        </div>
        <div>
          <label className="mb-1 block text-sm font-medium text-muted-foreground">Minute</label>
          <input
            type="number"
            min={0}
            max={59}
            value={minute}
            onChange={(e) => setMinute(Number(e.target.value))}
            className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
          />
        </div>
      </div>

      {/* Timezone */}
      <div>
        <label className="mb-1 block text-sm font-medium text-muted-foreground">Timezone</label>
        <select
          value={timezone}
          onChange={(e) => setTimezone(e.target.value)}
          className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
        >
          {TIMEZONE_OPTIONS.map(tz => (
            <option key={tz} value={tz}>{tz}</option>
          ))}
        </select>
      </div>

      {/* Target List */}
      {targetLists.length > 0 && (
        <div>
          <label className="mb-1 block text-sm font-medium text-muted-foreground">Target List (optional)</label>
          <select
            value={selectedTargetListId}
            onChange={(e) => setSelectedTargetListId(e.target.value)}
            className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
          >
            <option value="">None  manual targets only</option>
            {targetLists.map(tl => (
              <option key={tl.id} value={tl.id}>{tl.name} ({tl.targets.length} targets)</option>
            ))}
          </select>
        </div>
      )}

      {/* Save button */}
      <button
        onClick={handleSaveSchedule}
        disabled={isSaving || !treeId}
        className={cn(
          'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium',
          treeId
            ? 'bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90'
            : 'bg-card border border-border text-muted-foreground cursor-not-allowed opacity-50'
        )}
      >
        <Clock className="h-4 w-4" />
        {isSaving ? 'Saving...' : schedule ? 'Update Schedule' : 'Create Schedule'}
      </button>
      {!treeId && (
        <p className="text-xs text-muted-foreground">Save the flow first to configure a schedule.</p>
      )}
    </div>
  )
}

Step 2: Build a summary string helper

Add this exported function at the bottom of the file (used by ProceduralEditorPage for the collapsible section summary):

export function getScheduleSummary(schedule: MaintenanceSchedule | null, targetList?: TargetList | null): string {
  if (!schedule) return 'No schedule configured'

  // Parse cron for human-readable display
  const parts = schedule.cron_expression.split(' ')
  const min = parts[0] ?? '0'
  const hour = parts[1] ?? '0'
  const timeStr = `${hour.padStart(2, '0')}:${min.padStart(2, '0')}`

  const dayOfWeek = parts[4]
  let freqStr = 'Custom'
  if (parts[2] === '*' && parts[3] === '*') {
    if (dayOfWeek === '*') freqStr = 'Daily'
    else if (dayOfWeek === '1') freqStr = 'Every Monday'
    else if (dayOfWeek === '5') freqStr = 'Every Friday'
  } else if (parts[2] === '1') {
    freqStr = 'Monthly (1st)'
  }

  const targetStr = targetList ? ` · ${targetList.targets.length} targets` : ''
  return `${freqStr} at ${timeStr} ${schedule.timezone}${targetStr}`
}

Step 3: Verify it builds

Run: cd frontend && npm run build 2>&1 | tail -5 Expected: Clean build

Step 4: Commit

git add frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx
git commit -m "feat: add MaintenanceScheduleSection with schedule builder and summary"

Phase 6: Wire Maintenance Schedule into ProceduralEditorPage

Task 6: Add Schedule Section to ProceduralEditorPage

Files:

  • Modify: frontend/src/pages/ProceduralEditorPage.tsx

Context: The MaintenanceScheduleSection should appear as a third collapsible section, only for maintenance flows. It needs access to treeId (from store) and renders between Intake Form and the step list.

Step 1: Read the current ProceduralEditorPage

Read: frontend/src/pages/ProceduralEditorPage.tsx

Step 2: Add imports and the schedule section

Add imports:

import { MaintenanceScheduleSection, getScheduleSummary } from '@/components/procedural-editor/MaintenanceScheduleSection'
import { Calendar } from 'lucide-react'

Add the schedule state (load existing schedule for summary):

import { useState, useEffect } from 'react'
import { maintenanceSchedulesApi } from '@/api/maintenanceSchedules'
import { targetListsApi } from '@/api/targetLists'
import type { MaintenanceSchedule, TargetList } from '@/types'

// Inside the component:
const [schedule, setSchedule] = useState<MaintenanceSchedule | null>(null)
const [scheduleTargetList, setScheduleTargetList] = useState<TargetList | null>(null)

useEffect(() => {
  if (!isMaintenance || !treeId) return
  const loadSchedule = async () => {
    try {
      const s = await maintenanceSchedulesApi.getForTree(treeId)
      setSchedule(s)
      if (s.target_list_id) {
        const tl = await targetListsApi.get(s.target_list_id)
        setScheduleTargetList(tl)
      }
    } catch {
      // No schedule — fine
    }
  }
  loadSchedule()
}, [isMaintenance, treeId])

const scheduleSummary = getScheduleSummary(schedule, scheduleTargetList)

Add the collapsible schedule section after the Intake Form section and before the step list:

{isMaintenance && (
  <CollapsibleEditorSection
    title="Schedule"
    icon={<Calendar className="h-4 w-4" />}
    summary={scheduleSummary}
    defaultExpanded={!isEditMode || !schedule}
  >
    <MaintenanceScheduleSection treeId={treeId} />
  </CollapsibleEditorSection>
)}

Step 3: Verify it builds

Run: cd frontend && npm run build 2>&1 | tail -5 Expected: Clean build

Step 4: Commit

git add frontend/src/pages/ProceduralEditorPage.tsx
git commit -m "feat: wire maintenance schedule section into procedural editor"

Phase 7: Final Verification

Task 7: Build and Manual Testing Checklist

Step 1: Run full build

Run: cd frontend && npm run build 2>&1 | tail -10 Expected: Clean build with no TypeScript or lint errors

Step 2: Manual testing checklist

Start the dev server: cd frontend && npm run dev

Test each scenario:

Procedural flow — new:

  • Navigate to /flows/new?type=procedural
  • Page uses fixed-height layout (no page scrolling)
  • Details section is expanded by default (name field visible)
  • Intake Form section is collapsed, shows "No fields defined"
  • No Schedule section visible (procedural, not maintenance)
  • Step list shows empty state with "Add your first step"
  • Click "Add Step" — new step appears and auto-expands
  • Step list scrolls independently from the toolbar
  • Fill in name, collapse Details — summary shows "Name" · No tags · Private

Procedural flow — existing:

  • Edit an existing procedural flow
  • Details section is collapsed with rich summary
  • Intake Form section is collapsed with field names
  • Steps visible immediately with count and estimated time
  • Drag a step by its grip handle — reorders correctly
  • Expanded step cannot be dragged
  • Section headers can be dragged
  • procedure_end step stays at the bottom (cannot be dragged above it)

Maintenance flow — new:

  • Navigate to /flows/new?type=maintenance
  • Schedule section is visible and expanded
  • Can select frequency, time, timezone
  • "Save the flow first" message shown (no treeId yet)
  • After saving, schedule can be created

Maintenance flow — existing with schedule:

  • Schedule section shows collapsed summary: "Every Monday at 09:00 UTC · 5 targets"
  • Expand to modify schedule
  • Update saves correctly

Step 3: Commit any fixes found during testing

git add -A
git commit -m "fix: address issues found during manual testing"

Step 4: Final commit message for the complete feature

If all tests pass and no fixes needed, the branch is ready for PR.