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>
This commit was merged in pull request #84.
This commit is contained in:
chihlasm
2026-02-19 08:39:25 -05:00
committed by GitHub
parent 51243130e5
commit 9462d8b15a
15 changed files with 2217 additions and 157 deletions

View File

@@ -0,0 +1,68 @@
import { useState, useId, 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 generatedId = useId()
const sectionId = `section-${generatedId}`
const handleToggle = () => {
if (onToggle) {
onToggle()
} else {
setInternalExpanded(!internalExpanded)
}
}
return (
<div className="border-b border-border">
<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>
{isExpanded && (
<div id={sectionId} className="px-4 pb-4 pt-1">
{children}
</div>
)}
</div>
)
}

View File

@@ -6,15 +6,8 @@ export function IntakeFormBuilder() {
const { intakeForm, addField, removeField, updateField } = useProceduralEditorStore()
return (
<div className="bg-card border border-border rounded-2xl p-4 sm:p-6">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Intake Form</h2>
<span className="text-sm text-muted-foreground">
({intakeForm.length} field{intakeForm.length !== 1 ? 's' : ''})
</span>
</div>
<div>
<div className="mb-3 flex items-center justify-end">
<button
onClick={addField}
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"

View File

@@ -0,0 +1,241 @@
import { useState, useEffect } from 'react'
import { 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
onScheduleLoaded?: (schedule: MaintenanceSchedule | null, targetList: TargetList | null) => void
}
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, onScheduleLoaded }: MaintenanceScheduleSectionProps) {
const [schedule, setSchedule] = useState<MaintenanceSchedule | null>(null)
const [targetLists, setTargetLists] = useState<TargetList[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
// Draft form state (local UI only — not in store)
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)
// Hydrate form from existing schedule
hydrateFromSchedule(existing)
if (existing.target_list_id) {
setSelectedTargetListId(existing.target_list_id)
const tl = lists.find(l => l.id === existing.target_list_id) || null
onScheduleLoaded?.(existing, tl)
} else {
onScheduleLoaded?.(existing, null)
}
} catch {
// 404 = no schedule yet (valid state)
onScheduleLoaded?.(null, null)
}
}
} catch {
// Target lists may not load — non-critical
} finally {
setIsLoading(false)
}
}
load()
}, [treeId, onScheduleLoaded])
const hydrateFromSchedule = (s: MaintenanceSchedule) => {
const parts = s.cron_expression.split(' ')
const min = parseInt(parts[0] || '0', 10)
const hr = parseInt(parts[1] || '9', 10)
setMinute(min)
setHour(hr)
setTimezone(s.timezone)
// Detect frequency from cron
const dayOfMonth = parts[2]
const dayOfWeek = parts[4]
if (dayOfMonth === '1') {
setFrequency('monthly')
} else if (dayOfWeek === '*') {
setFrequency('daily')
} else if (dayOfWeek === '5') {
setFrequency('weekly-fri')
} else {
setFrequency('weekly-mon')
}
}
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)
const tl = targetLists.find(l => l.id === selectedTargetListId) || null
onScheduleLoaded?.(updated, tl)
toast.success('Schedule updated')
} else {
const created = await maintenanceSchedulesApi.create({
tree_id: treeId,
cron_expression: cronExpression,
timezone,
target_list_id: selectedTargetListId || undefined,
})
setSchedule(created)
const tl = targetLists.find(l => l.id === selectedTargetListId) || null
onScheduleLoaded?.(created, tl)
toast.success('Schedule created')
}
} catch {
toast.error('Failed to save schedule. The flow was saved successfully — you can retry the schedule.')
} finally {
setIsSaving(false)
}
}
if (isLoading) {
return (
<div className="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'
: 'cursor-not-allowed border border-border bg-card text-muted-foreground 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>
)
}

View File

@@ -1,4 +1,9 @@
import { useRef, useEffect, useCallback, type ReactNode } from 'react'
import { Plus, GripVertical, Trash2, ChevronDown, CheckCircle2, AlertTriangle, Info, Zap, Shield, SeparatorHorizontal } from 'lucide-react'
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
import type { DragEndEvent } from '@dnd-kit/core'
import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import type { StepContentType } from '@/types'
import { StepEditor } from './StepEditor'
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
@@ -11,6 +16,29 @@ const contentTypeConfig: Record<StepContentType, { icon: typeof Zap; color: stri
warning: { icon: AlertTriangle, color: 'text-yellow-400', label: 'Warning' },
}
function SortableStepWrapper({
id,
disabled,
children,
}: {
id: string
disabled?: boolean
children: (props: { dragHandleProps: Record<string, unknown> }) => ReactNode
}) {
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 && 'z-10 opacity-50')}>
{children({ dragHandleProps: { ...attributes, ...listeners } })}
</div>
)
}
export function StepList() {
const {
steps,
@@ -21,19 +49,62 @@ export function StepList() {
addSectionHeader,
removeStep,
updateStep,
moveStep,
} = useProceduralEditorStore()
const procedureSteps = steps.filter((s) => s.type === 'procedure_step')
const totalMinutes = steps
.filter(s => s.type === 'procedure_step' && s.estimated_minutes)
.reduce((sum, s) => sum + (s.estimated_minutes || 0), 0)
// Auto-scroll to new steps
const scrollTargetRef = useRef<HTMLDivElement>(null)
const prevStepCount = useRef(steps.length)
useEffect(() => {
if (steps.length > prevStepCount.current) {
setTimeout(() => {
scrollTargetRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, 50)
}
prevStepCount.current = steps.length
}, [steps.length])
// DnD setup
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])
// Sortable items: everything except procedure_end
const sortableItems = steps.filter(s => s.type !== 'procedure_end').map(s => s.id)
let stepCounter = 0
return (
<div className="bg-card border border-border rounded-2xl p-4 sm:p-6">
<div>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Steps</h2>
<span className="text-sm text-muted-foreground">
({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''})
({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''}
{totalMinutes > 0 ? ` \u00b7 ~${totalMinutes} min` : ''})
</span>
</div>
<div className="flex items-center gap-2">
@@ -54,138 +125,168 @@ export function StepList() {
</div>
</div>
<div className="space-y-2">
{steps.map((step) => {
if (step.type === 'procedure_end') {
return (
<div
key={step.id}
className="flex items-center gap-2 rounded-lg border border-dashed border-border bg-accent/50 px-3 py-2"
>
<CheckCircle2 className="h-4 w-4 text-emerald-400/50" />
<input
type="text"
value={step.title}
onChange={(e) => updateStep(step.id, { title: e.target.value })}
className="flex-1 bg-transparent text-sm text-muted-foreground focus:outline-none"
placeholder="Procedure Complete"
/>
<span className="text-[10px] text-muted-foreground">END</span>
</div>
)
}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sortableItems} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{steps.map((step) => {
if (step.type === 'procedure_end') {
// procedure_end: non-draggable, always last
return (
<div
key={step.id}
className="flex items-center gap-2 rounded-lg border border-dashed border-border bg-accent/50 px-3 py-2"
>
<CheckCircle2 className="h-4 w-4 text-emerald-400/50" />
<input
type="text"
value={step.title}
onChange={(e) => updateStep(step.id, { title: e.target.value })}
className="flex-1 bg-transparent text-sm text-muted-foreground focus:outline-none"
placeholder="Procedure Complete"
/>
<span className="text-[10px] text-muted-foreground">END</span>
</div>
)
}
// Section header rendering
if (step.type === 'section_header') {
const isExpanded = expandedStepId === step.id
// Section header rendering
if (step.type === 'section_header') {
const isExpanded = expandedStepId === step.id
if (isExpanded) {
return (
<SortableStepWrapper key={step.id} id={step.id} disabled>
{() => (
<div ref={scrollTargetRef}>
<StepEditor
step={step}
stepNumber={0}
onUpdate={(updates) => updateStep(step.id, updates)}
onCollapse={() => setExpandedStepId(null)}
availableVariables={intakeForm}
/>
</div>
)}
</SortableStepWrapper>
)
}
return (
<SortableStepWrapper key={step.id} id={step.id}>
{({ dragHandleProps }) => (
<div className="group flex items-center gap-2 border-b border-border pb-1 pt-3">
<button
type="button"
className="shrink-0 cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
aria-label={`Drag to reorder section: ${step.title || 'Untitled Section'}`}
{...dragHandleProps}
>
<GripVertical className="h-4 w-4" />
</button>
<span
className="min-w-0 flex-1 cursor-pointer text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-muted-foreground"
onClick={() => setExpandedStepId(step.id)}
>
{step.title || 'Untitled Section'}
</span>
<button
onClick={() => removeStep(step.id)}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
</SortableStepWrapper>
)
}
// Regular procedure step
stepCounter++
const stepNumber = stepCounter
const isExpanded = expandedStepId === step.id
const contentType = step.content_type || 'action'
const config = contentTypeConfig[contentType]
const Icon = config.icon
if (isExpanded) {
return (
<SortableStepWrapper key={step.id} id={step.id} disabled>
{() => (
<div ref={scrollTargetRef}>
<StepEditor
step={step}
stepNumber={stepNumber}
onUpdate={(updates) => updateStep(step.id, updates)}
onCollapse={() => setExpandedStepId(null)}
availableVariables={intakeForm}
/>
</div>
)}
</SortableStepWrapper>
)
}
if (isExpanded) {
return (
<div key={step.id}>
<StepEditor
step={step}
stepNumber={0}
onUpdate={(updates) => updateStep(step.id, updates)}
onCollapse={() => setExpandedStepId(null)}
availableVariables={intakeForm}
/>
</div>
<SortableStepWrapper key={step.id} id={step.id}>
{({ dragHandleProps }) => (
<div
className={cn(
'group flex items-center gap-2 rounded-xl border border-border px-3 py-2.5 transition-colors',
'hover:border-primary/30 hover:bg-accent/50'
)}
>
<button
type="button"
className="shrink-0 cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
aria-label={`Drag to reorder step ${stepNumber}: ${step.title || 'Untitled step'}`}
{...dragHandleProps}
>
<GripVertical className="h-4 w-4" />
</button>
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-xs font-medium text-muted-foreground">
{stepNumber}
</span>
<span className={cn('shrink-0', config.color)}>
<Icon className="h-3.5 w-3.5" />
</span>
<span
className="min-w-0 flex-1 cursor-pointer truncate text-sm text-foreground"
onClick={() => setExpandedStepId(step.id)}
>
{step.title || 'Untitled step'}
</span>
{step.estimated_minutes && (
<span className="shrink-0 text-[10px] text-muted-foreground">
~{step.estimated_minutes}m
</span>
)}
<button
onClick={() => setExpandedStepId(step.id)}
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
<button
onClick={() => removeStep(step.id)}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
</SortableStepWrapper>
)
}
return (
<div
key={step.id}
className="group flex items-center gap-2 border-b border-border pb-1 pt-3"
>
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground group-hover:text-muted-foreground" />
<span
className="min-w-0 flex-1 cursor-pointer text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-muted-foreground"
onClick={() => setExpandedStepId(step.id)}
>
{step.title || 'Untitled Section'}
</span>
<button
onClick={() => removeStep(step.id)}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)
}
// Regular procedure step
stepCounter++
const stepNumber = stepCounter
const isExpanded = expandedStepId === step.id
const contentType = step.content_type || 'action'
const config = contentTypeConfig[contentType]
const Icon = config.icon
if (isExpanded) {
return (
<div key={step.id}>
<StepEditor
step={step}
stepNumber={stepNumber}
onUpdate={(updates) => updateStep(step.id, updates)}
onCollapse={() => setExpandedStepId(null)}
availableVariables={intakeForm}
/>
</div>
)
}
return (
<div key={step.id}>
<div
className={cn(
'group flex items-center gap-2 rounded-xl border border-border px-3 py-2.5 transition-colors',
'hover:border-primary/30 hover:bg-accent/50'
)}
>
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground group-hover:text-muted-foreground" />
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-xs font-medium text-muted-foreground">
{stepNumber}
</span>
<span className={cn('shrink-0', config.color)}>
<Icon className="h-3.5 w-3.5" />
</span>
<span
className="min-w-0 flex-1 cursor-pointer truncate text-sm text-foreground"
onClick={() => setExpandedStepId(step.id)}
>
{step.title || 'Untitled step'}
</span>
{step.estimated_minutes && (
<span className="shrink-0 text-[10px] text-muted-foreground">
~{step.estimated_minutes}m
</span>
)}
<button
onClick={() => setExpandedStepId(step.id)}
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
<button
onClick={() => removeStep(step.id)}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
})}
</div>
})}
</div>
</SortableContext>
</DndContext>
{/* Add step button at bottom */}
<button
@@ -195,6 +296,8 @@ export function StepList() {
<Plus className="h-3.5 w-3.5" />
Add Step
</button>
<div ref={scrollTargetRef} />
</div>
)
}

View File

@@ -0,0 +1,24 @@
import type { MaintenanceSchedule, TargetList } from '@/types'
export function getScheduleSummary(schedule: MaintenanceSchedule | null, targetList?: TargetList | null): string {
if (!schedule) return 'No schedule configured'
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]
const dayOfMonth = parts[2]
let freqStr = 'Custom'
if (dayOfMonth === '1') {
freqStr = 'Monthly (1st)'
} else if (dayOfMonth === '*' && parts[3] === '*') {
if (dayOfWeek === '*') freqStr = 'Daily'
else if (dayOfWeek === '1') freqStr = 'Every Monday'
else if (dayOfWeek === '5') freqStr = 'Every Friday'
}
const targetStr = targetList ? ` \u00b7 ${targetList.targets.length} target${targetList.targets.length !== 1 ? 's' : ''}` : ''
return `${freqStr} at ${timeStr} ${schedule.timezone}${targetStr}`
}

View File

@@ -1,13 +1,18 @@
import { useEffect } from 'react'
import { useEffect, useState, useCallback } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { Save, ArrowLeft, ListOrdered, Wrench } from 'lucide-react'
import { Save, ArrowLeft, ListOrdered, Wrench, Settings, FileText, Calendar } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
import { CollapsibleEditorSection } from '@/components/procedural-editor/CollapsibleEditorSection'
import { IntakeFormBuilder } from '@/components/procedural-editor/IntakeFormBuilder'
import { MaintenanceScheduleSection } from '@/components/procedural-editor/MaintenanceScheduleSection'
import { getScheduleSummary } from '@/components/procedural-editor/scheduleUtils'
import { StepList } from '@/components/procedural-editor/StepList'
import { TagInput } from '@/components/common/TagInput'
import { toast } from '@/lib/toast'
import type { TreeType } from '@/types'
import type { TreeType, MaintenanceSchedule, TargetList } from '@/types'
type SectionKey = 'details' | 'intake' | 'schedule'
export function ProceduralEditorPage() {
const { id } = useParams<{ id: string }>()
@@ -22,6 +27,7 @@ export function ProceduralEditorPage() {
description,
tags,
isPublic,
intakeForm,
isDirty,
isSaving,
isLoading,
@@ -40,6 +46,24 @@ export function ProceduralEditorPage() {
const isMaintenance = treeType === 'maintenance'
const flowLabel = isMaintenance ? 'Maintenance Flow' : 'Procedure'
// Accordion state: only one section open at a time
const [expandedSection, setExpandedSection] = useState<SectionKey | null>(
isEditMode ? null : 'details'
)
// Schedule state for collapsed summary
const [schedule, setSchedule] = useState<MaintenanceSchedule | null>(null)
const [scheduleTargetList, setScheduleTargetList] = useState<TargetList | null>(null)
const toggleSection = useCallback((key: SectionKey) => {
setExpandedSection(prev => prev === key ? null : key)
}, [])
const handleScheduleLoaded = useCallback((s: MaintenanceSchedule | null, tl: TargetList | null) => {
setSchedule(s)
setScheduleTargetList(tl)
}, [])
// Load tree or init new
useEffect(() => {
if (isEditMode && id) {
@@ -47,6 +71,8 @@ export function ProceduralEditorPage() {
} else {
const urlType = searchParams.get('type')
initNew((urlType === 'maintenance' ? 'maintenance' : 'procedural') as TreeType)
// New flows: details expanded, or schedule for new maintenance
setExpandedSection(urlType === 'maintenance' ? 'schedule' : 'details')
}
return () => { reset() }
@@ -101,6 +127,19 @@ export function ProceduralEditorPage() {
}
}
// Summary strings for collapsed sections
const detailsSummary = [
name ? `"${name}"` : '"Untitled"',
tags.length > 0 ? `${tags.length} tag${tags.length !== 1 ? 's' : ''}` : 'No tags',
isPublic ? 'Public' : 'Private',
].join(' \u00b7 ')
const scheduleSummary = getScheduleSummary(schedule, scheduleTargetList)
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 ? ', \u2026' : ''}`
if (isLoading) {
return (
<div className="flex min-h-[50vh] items-center justify-center">
@@ -110,9 +149,9 @@ export function ProceduralEditorPage() {
}
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
{/* Header */}
<div className="mb-6 flex items-center justify-between sm:mb-8">
<div className="flex h-full flex-col overflow-hidden">
{/* Toolbar — sticky */}
<div className="flex shrink-0 items-center justify-between border-b border-border bg-card px-4 py-2">
<div className="flex items-center gap-3">
<button
onClick={() => navigate('/my-trees')}
@@ -124,8 +163,9 @@ export function ProceduralEditorPage() {
{isMaintenance
? <Wrench className="h-5 w-5 text-amber-400" />
: <ListOrdered className="h-5 w-5 text-muted-foreground" />}
<h1 className="text-xl font-bold text-foreground sm:text-2xl">
<h1 className="text-lg font-bold text-foreground">
{isEditMode ? `Edit ${flowLabel}` : `New ${flowLabel}`}
{name && <span className="ml-2 font-normal text-muted-foreground"> {name}</span>}
</h1>
</div>
</div>
@@ -144,7 +184,7 @@ export function ProceduralEditorPage() {
<button
onClick={() => handleSave('published')}
disabled={isSaving}
className="flex items-center gap-1.5 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-4 py-2 text-sm font-medium hover:opacity-90 disabled:opacity-50"
className="flex items-center gap-1.5 rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50"
>
<Save className="h-4 w-4" />
{isSaving ? 'Saving...' : 'Publish'}
@@ -152,11 +192,15 @@ export function ProceduralEditorPage() {
</div>
</div>
{/* Content */}
<div className="space-y-6">
{/* Metadata */}
<div className="bg-card border border-border rounded-xl p-4 sm:p-6">
<h2 className="mb-4 text-lg font-semibold text-foreground">Details</h2>
{/* Collapsible sections */}
<div className="shrink-0">
<CollapsibleEditorSection
title="Details"
icon={<Settings className="h-4 w-4" />}
summary={detailsSummary}
expanded={expandedSection === 'details'}
onToggle={() => toggleSection('details')}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-muted-foreground">Name</label>
@@ -199,12 +243,36 @@ export function ProceduralEditorPage() {
</div>
</div>
</div>
</div>
</CollapsibleEditorSection>
{/* Intake Form Builder */}
<IntakeFormBuilder />
<CollapsibleEditorSection
title="Intake Form"
icon={<FileText className="h-4 w-4" />}
summary={intakeSummary}
expanded={expandedSection === 'intake'}
onToggle={() => toggleSection('intake')}
>
<IntakeFormBuilder />
</CollapsibleEditorSection>
{/* Step List */}
{isMaintenance && (
<CollapsibleEditorSection
title="Schedule"
icon={<Calendar className="h-4 w-4" />}
summary={scheduleSummary}
expanded={expandedSection === 'schedule'}
onToggle={() => toggleSection('schedule')}
>
<MaintenanceScheduleSection
treeId={treeId}
onScheduleLoaded={handleScheduleLoaded}
/>
</CollapsibleEditorSection>
)}
</div>
{/* Step List — flex-1, scrolls independently */}
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
<StepList />
</div>
</div>