Files
resolutionflow/docs/plans/2026-02-19-procedural-editor-redesign-impl.md
chihlasm 9462d8b15a feat: procedural editor redesign with collapsible sections and DnD (#84)
* docs: add procedural/maintenance editor redesign design

Collapsible sections, fixed-height layout, drag-to-reorder steps,
maintenance schedule section, and step list UX improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add procedural editor redesign implementation plan

7 tasks across 7 phases: collapsible sections, fixed-height layout,
step list improvements, drag-to-reorder, maintenance schedule section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 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>

* feat: add step count with time estimate header and auto-scroll to new steps

Remove outer card wrapper from StepList (now rendered in scrolling container).
Header shows total estimated minutes when steps have time estimates. Auto-scrolls
to newly added steps using ref + scrollIntoView.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add drag-to-reorder steps with @dnd-kit

Wrap step list in DndContext + SortableContext. Each step/section header
gets a SortableStepWrapper with useSortable. Drag handles have accessible
labels and keyboard support. procedure_end stays non-draggable and always
last. Expanded steps are disabled for dragging. Array-index reorder only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add MaintenanceScheduleSection with schedule builder and summary

Schedule draft state is local UI only (not in store). Hydrates form from
existing schedule on load. Includes getScheduleSummary helper for collapsed
section display. Two-stage save: tree first, schedule second. Schedule
failure shows actionable error without rolling back tree save.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: wire maintenance schedule section into procedural editor

Add collapsible Schedule section for maintenance flows with accordion
integration. Schedule summary shows frequency, time, and target count
when collapsed. New maintenance flows default to schedule section expanded.
Two-stage save preserved: tree saved first, schedule managed independently.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve lint issues in maintenance schedule and editor page

Move getScheduleSummary to scheduleUtils.ts to satisfy react-refresh
only-export-components rule. Add onScheduleLoaded to useEffect deps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add design and implementation revision documents

Revision docs correct original plans: schedule persistence via API
endpoints (not tree_structure), array-index reorder (no display_order),
store minimum-one-step invariant, accordion mode, ARIA requirements,
and two-stage save orchestration with failure handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: auto-seed PR environments with SEED_ON_DEPLOY flag

Release command now runs migrations + seeds test users when
SEED_ON_DEPLOY=true. Tree seeding runs as a background task
on startup via HTTP API. Everything is idempotent and non-fatal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add httpx to requirements for PR environment seeding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: seed all flow types (v2, procedural, maintenance) on deploy

Runs seed_trees, seed_trees_v2, seed_procedural_flows, and
seed_maintenance_flows sequentially as background tasks when
SEED_ON_DEPLOY=true. Each script failure is non-fatal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: trigger redeploy for full seed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:39:25 -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.