* 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>
304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
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'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
const contentTypeConfig: Record<StepContentType, { icon: typeof Zap; color: string; label: string }> = {
|
|
action: { icon: Zap, color: 'text-blue-400', label: 'Action' },
|
|
informational: { icon: Info, color: 'text-muted-foreground', label: 'Info' },
|
|
verification: { icon: CheckCircle2, color: 'text-emerald-400', label: 'Verify' },
|
|
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,
|
|
intakeForm,
|
|
expandedStepId,
|
|
setExpandedStepId,
|
|
addStep,
|
|
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>
|
|
<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' : ''}
|
|
{totalMinutes > 0 ? ` \u00b7 ~${totalMinutes} min` : ''})
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<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>
|
|
<button
|
|
onClick={() => addStep()}
|
|
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"
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
Add Step
|
|
</button>
|
|
</div>
|
|
</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
|
|
|
|
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>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<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>
|
|
)
|
|
})}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
|
|
{/* Add step button at bottom */}
|
|
<button
|
|
onClick={() => addStep()}
|
|
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-border py-2 text-sm text-muted-foreground transition-colors hover:border-primary/30 hover:text-muted-foreground"
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
Add Step
|
|
</button>
|
|
|
|
<div ref={scrollTargetRef} />
|
|
</div>
|
|
)
|
|
}
|