import { useEffect, useState, useCallback } from 'react' import { useParams, useNavigate, useSearchParams } from 'react-router-dom' import { Save, ArrowLeft, ListOrdered, Wrench, Settings, FileText, Calendar, Sparkles, Layers } from 'lucide-react' import { analytics } from '@/lib/analytics' import { Button } from '@/components/ui/Button' 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 { Spinner } from '@/components/common/Spinner' import { EditorAIPanel } from '@/components/editor-ai/EditorAIPanel' import { ContextMenu } from '@/components/common/ContextMenu' import { ValidationSummary } from '@/components/tree-editor/ValidationSummary' import { AIFixReviewModal } from '@/components/tree-editor/AIFixReviewModal' import { useEditorAI } from '@/hooks/useEditorAI' import { cn } from '@/lib/utils' import { toast } from '@/lib/toast' import type { TreeType, MaintenanceSchedule, TargetList, ProceduralStep, IntakeFormField, AIFixProposal } from '@/types' import type { ValidationError } from '@/store/treeEditorStore' type SectionKey = 'details' | 'intake' | 'schedule' export function ProceduralEditorPage() { const { id } = useParams<{ id: string }>() const [searchParams] = useSearchParams() const navigate = useNavigate() const isEditMode = !!id const { treeId, treeType, name, description, tags, isPublic, intakeForm, isDirty, isSaving, isLoading, initNew, loadTree, reset, setName, setDescription, setTags, setIsPublic, setIsSaving, markSaved, getTreeForSave, replaceSteps, } = useProceduralEditorStore() const steps = useProceduralEditorStore(s => s.steps) const validationErrors = useProceduralEditorStore((s) => s.validationErrors) const setExpandedStepId = useProceduralEditorStore((s) => s.setExpandedStepId) const updateStep = useProceduralEditorStore((s) => s.updateStep) const handleFlowUpdate = useCallback((workingTree: Record, metadata?: Record | null) => { const stepsData = workingTree.steps as ProceduralStep[] | undefined if (stepsData && Array.isArray(stepsData)) { // Intake form may be in working_tree or in metadata const intakeData = (workingTree.intake_form || metadata?.intake_form) as IntakeFormField[] | undefined replaceSteps(stepsData, intakeData) } }, [replaceSteps]) const editorAI = useEditorAI({ flowType: 'procedural', treeId: id, getFlowContext: useCallback(() => { return { name, description, steps: steps as unknown as Record[], intake_form: intakeForm, } }, [steps, intakeForm, name, description]), onFlowUpdate: handleFlowUpdate, }) const [isFixing, setIsFixing] = useState(false) const [fixProposals, setFixProposals] = useState(null) const isMaintenance = treeType === 'maintenance' const flowLabel = isMaintenance ? 'Maintenance Flow' : 'Procedure' // Accordion state: only one section open at a time const [expandedSection, setExpandedSection] = useState( isEditMode ? null : 'details' ) // Schedule state for collapsed summary const [schedule, setSchedule] = useState(null) const [scheduleTargetList, setScheduleTargetList] = useState(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) { loadExistingTree(id) } 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() } }, [id]) useEffect(() => { useProceduralEditorStore.getState().validate() }, [steps, intakeForm]) const loadExistingTree = async (treeId: string) => { try { const tree = await treesApi.get(treeId) if (tree.tree_type !== 'procedural' && tree.tree_type !== 'maintenance') { toast.error('This flow is not a procedural or maintenance flow') navigate('/trees') return } loadTree(tree) } catch { toast.error('Failed to load flow') navigate('/trees') } } const handleSelectStep = useCallback((stepId: string) => { // Guard against setExpandedStepId's toggle behavior — calling it with the same ID collapses the step const currentExpanded = useProceduralEditorStore.getState().expandedStepId if (currentExpanded !== stepId) { setExpandedStepId(stepId) } // Small delay to let expanded state render before scrolling setTimeout(() => { const el = document.querySelector(`[data-step-id="${stepId}"]`) if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }) }, 50) }, [setExpandedStepId]) const handleFixWithAI = async () => { const fixableErrors = validationErrors .filter((e) => e.severity === 'error' && e.stepId) .map((e) => ({ node_id: e.stepId!, message: e.message })) if (fixableErrors.length === 0) return setIsFixing(true) try { const result = await treesApi.fixTree({ tree_structure: { steps } as unknown as Record, tree_name: name, tree_type: treeType as 'procedural' | 'maintenance', validation_errors: fixableErrors, }) if (result.fixes.length > 0) { setFixProposals(result.fixes) } else { toast.info('AI could not generate fixes for these errors') } } catch { toast.error('Failed to generate AI fixes. Please try again.') } finally { setIsFixing(false) } } const handleApplyFix = (fix: AIFixProposal) => { updateStep(fix.target_node_id, fix.fixed_node as Partial) useProceduralEditorStore.getState().validate() } // AIFixReviewModal.onApplyAll is () => void — no arguments const handleApplyAllFixes = () => { if (!fixProposals) return fixProposals.forEach((fix) => { updateStep(fix.target_node_id, fix.fixed_node as Partial) }) useProceduralEditorStore.getState().validate() setFixProposals(null) } const handleCloseFixModal = () => setFixProposals(null) const handleSave = async (saveStatus?: 'draft' | 'published') => { if (!name.trim()) { toast.error(`Please enter a name for the ${flowLabel.toLowerCase()}`) return } // Block publish if there are validation errors const errors = useProceduralEditorStore.getState().validate() const hasErrors = errors.some((e) => e.severity === 'error') if (hasErrors && saveStatus === 'published') { toast.error('Please fix all errors before publishing') return } setIsSaving(true) try { const payload = getTreeForSave() if (saveStatus) { payload.status = saveStatus } if (isEditMode && treeId) { await treesApi.update(treeId, payload) markSaved() toast.success(`${flowLabel} saved`) } else { const created = await treesApi.create(payload) analytics.flowCreated({ flow_type: payload.tree_type || 'procedural', method: 'manual' }) markSaved() toast.success(`${flowLabel} created`) navigate(`/flows/${created.id}/edit`, { replace: true }) } } catch (err: unknown) { const message = err && typeof err === 'object' && 'response' in err ? (err as { response?: { data?: { detail?: string | { message?: string } } } }).response?.data?.detail : null const errorText = typeof message === 'string' ? message : typeof message === 'object' && message?.message ? message.message : `Failed to save ${flowLabel.toLowerCase()}` toast.error(errorText) } finally { setIsSaving(false) } } // Summary strings for collapsed sections const detailsSummary = [ tags.length > 0 ? `${tags.length} tag${tags.length !== 1 ? 's' : ''}` : 'No tags', isPublic ? 'Public' : 'Private', description ? `${description.slice(0, 40)}${description.length > 40 ? '\u2026' : ''}` : 'No description', ].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 (
) } return (
{/* Main content column */}
{/* Toolbar — sticky */}
{isMaintenance ? : } setName(e.target.value)} placeholder={`Untitled ${flowLabel}`} className="min-w-0 flex-1 bg-transparent text-sm font-semibold text-heading placeholder:text-muted-foreground focus:outline-none" /> {isDirty && ( )}
{/* Config zone */}
} summary={detailsSummary} expanded={expandedSection === 'details'} onToggle={() => toggleSection('details')} >