* 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>
812 lines
28 KiB
Markdown
812 lines
28 KiB
Markdown
# 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**
|
|
|
|
```tsx
|
|
// 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**
|
|
|
|
```bash
|
|
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:
|
|
```tsx
|
|
import { CollapsibleEditorSection } from '@/components/procedural-editor/CollapsibleEditorSection'
|
|
```
|
|
|
|
The Details collapsible section needs a summary string. Build it from store state:
|
|
```tsx
|
|
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:
|
|
```tsx
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
```tsx
|
|
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:
|
|
```tsx
|
|
{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:
|
|
|
|
```tsx
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
```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:
|
|
```tsx
|
|
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:
|
|
```tsx
|
|
<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:
|
|
|
|
```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:
|
|
```tsx
|
|
// 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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```tsx
|
|
// 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):
|
|
|
|
```tsx
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
```tsx
|
|
import { MaintenanceScheduleSection, getScheduleSummary } from '@/components/procedural-editor/MaintenanceScheduleSection'
|
|
import { Calendar } from 'lucide-react'
|
|
```
|
|
|
|
Add the schedule state (load existing schedule for summary):
|
|
```tsx
|
|
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:
|
|
```tsx
|
|
{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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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.
|