import { create } from 'zustand' import { temporal } from 'zundo' import { immer } from 'zustand/middleware/immer' import type { Tree, IntakeFormField, IntakeFieldType, ProceduralStep, ProceduralTreeStructure, TreeType } from '@/types' export interface ProceduralValidationError { stepId?: string field?: string message: string severity: 'error' | 'warning' } const generateId = () => crypto.randomUUID() const FIELD_TYPE_PREFIX: Record = { text: 'text', textarea: 'textarea', number: 'number', ip_address: 'ip', email: 'email', url: 'url', select: 'select', multi_select: 'multiselect', checkbox: 'checkbox', password: 'password', } function generateVariableName(fieldType: IntakeFieldType, existingFields: IntakeFormField[]): string { const prefix = FIELD_TYPE_PREFIX[fieldType] || 'field' const count = existingFields.filter((f) => f.field_type === fieldType).length return `${prefix}_${count + 1}` } function createDefaultStep(index: number): ProceduralStep { return { id: generateId(), type: 'procedure_step', title: `Step ${index + 1}`, description: '', content_type: 'action', notes_enabled: true, } } function createEndStep(): ProceduralStep { return { id: generateId(), type: 'procedure_end', title: 'Procedure Complete', } } function createSectionHeader(title: string): ProceduralStep { return { id: generateId(), type: 'section_header', title, } } function createDefaultField(index: number, existingFields: IntakeFormField[]): IntakeFormField { return { variable_name: generateVariableName('text', existingFields), label: `Field ${index + 1}`, field_type: 'text', required: true, display_order: index + 1, } } interface ProceduralEditorState { // Tree metadata treeId: string | null treeType: TreeType name: string description: string categoryId: string | null tags: string[] isPublic: boolean status: 'draft' | 'published' // Procedural data steps: ProceduralStep[] intakeForm: IntakeFormField[] // UI state selectedStepId: string | null expandedStepId: string | null isDirty: boolean isLoading: boolean isSaving: boolean validationErrors: ProceduralValidationError[] // Actions - Init initNew: (type?: TreeType) => void loadTree: (tree: Tree) => void reset: () => void // Actions - Metadata setTreeType: (treeType: TreeType) => void setName: (name: string) => void setDescription: (description: string) => void setCategoryId: (categoryId: string | null) => void setTags: (tags: string[]) => void setIsPublic: (isPublic: boolean) => void setStatus: (status: 'draft' | 'published') => void // Actions - Steps addStep: (afterIndex?: number) => void addSectionHeader: (afterIndex?: number) => void removeStep: (stepId: string) => void updateStep: (stepId: string, updates: Partial) => void moveStep: (fromIndex: number, toIndex: number) => void setSelectedStepId: (stepId: string | null) => void setExpandedStepId: (stepId: string | null) => void // Actions - Intake Form addField: () => void removeField: (index: number) => void updateField: (index: number, updates: Partial) => void moveField: (fromIndex: number, toIndex: number) => void // Actions - Validation validate: () => ProceduralValidationError[] clearValidation: () => void // Actions - AI Integration replaceSteps: (steps: ProceduralStep[], intakeForm?: IntakeFormField[]) => void // Actions - Save setIsSaving: (saving: boolean) => void markSaved: () => void getTreeForSave: () => { name: string description: string tree_type: TreeType tree_structure: ProceduralTreeStructure intake_form: IntakeFormField[] | undefined category_id: string | null tags: string[] is_public: boolean status: 'draft' | 'published' } } export const useProceduralEditorStore = create()( temporal( immer((set, get) => ({ // Initial state treeId: null, treeType: 'procedural' as TreeType, name: '', description: '', categoryId: null, tags: [], isPublic: false, status: 'draft' as const, steps: [], intakeForm: [], selectedStepId: null, expandedStepId: null, isDirty: false, isLoading: false, isSaving: false, validationErrors: [], // Init initNew: (type?: TreeType) => { set((state) => { state.treeId = null state.treeType = type || 'procedural' state.name = '' state.description = '' state.categoryId = null state.tags = [] state.isPublic = false state.status = 'draft' state.steps = [createDefaultStep(0), createEndStep()] state.intakeForm = [] state.selectedStepId = null state.expandedStepId = null state.isDirty = false state.isLoading = false state.isSaving = false }) }, loadTree: (tree: Tree) => { const structure = tree.tree_structure as unknown as ProceduralTreeStructure set((state) => { state.treeId = tree.id state.treeType = tree.tree_type state.name = tree.name state.description = tree.description || '' state.categoryId = tree.category_id state.tags = tree.tags || [] state.isPublic = tree.is_public state.status = tree.status state.steps = structure.steps || [createDefaultStep(0), createEndStep()] state.intakeForm = tree.intake_form || [] state.selectedStepId = null state.expandedStepId = null state.isDirty = false state.isLoading = false state.isSaving = false }) }, reset: () => { set((state) => { state.treeId = null state.treeType = 'procedural' state.name = '' state.description = '' state.categoryId = null state.tags = [] state.isPublic = false state.status = 'draft' state.steps = [] state.intakeForm = [] state.selectedStepId = null state.expandedStepId = null state.isDirty = false state.isLoading = false state.isSaving = false }) }, // Metadata setTreeType: (treeType) => set((state) => { state.treeType = treeType }), setName: (name) => set((state) => { state.name = name; state.isDirty = true }), setDescription: (description) => set((state) => { state.description = description; state.isDirty = true }), setCategoryId: (categoryId) => set((state) => { state.categoryId = categoryId; state.isDirty = true }), setTags: (tags) => set((state) => { state.tags = tags; state.isDirty = true }), setIsPublic: (isPublic) => set((state) => { state.isPublic = isPublic; state.isDirty = true }), setStatus: (status) => set((state) => { state.status = status; state.isDirty = true }), // Steps addStep: (afterIndex) => { set((state) => { // Find the insert position (before the end step) const endIndex = state.steps.findIndex((s) => s.type === 'procedure_end') const insertAt = afterIndex !== undefined ? Math.min(afterIndex + 1, endIndex >= 0 ? endIndex : state.steps.length) : (endIndex >= 0 ? endIndex : state.steps.length) const newStep = createDefaultStep(insertAt) state.steps.splice(insertAt, 0, newStep) state.expandedStepId = newStep.id state.isDirty = true }) }, addSectionHeader: (afterIndex) => { set((state) => { const endIndex = state.steps.findIndex((s) => s.type === 'procedure_end') const insertAt = afterIndex !== undefined ? Math.min(afterIndex + 1, endIndex >= 0 ? endIndex : state.steps.length) : (endIndex >= 0 ? endIndex : state.steps.length) const newHeader = createSectionHeader('New Section') state.steps.splice(insertAt, 0, newHeader) state.expandedStepId = newHeader.id state.isDirty = true }) }, removeStep: (stepId) => { set((state) => { const index = state.steps.findIndex((s) => s.id === stepId) if (index === -1) return // Don't remove the end step if (state.steps[index].type === 'procedure_end') return // Don't remove if it's the only procedure_step (section headers can always be removed) if (state.steps[index].type === 'procedure_step') { const stepCount = state.steps.filter((s) => s.type === 'procedure_step').length if (stepCount <= 1) return } state.steps.splice(index, 1) if (state.selectedStepId === stepId) state.selectedStepId = null if (state.expandedStepId === stepId) state.expandedStepId = null state.isDirty = true }) }, updateStep: (stepId, updates) => { set((state) => { const step = state.steps.find((s) => s.id === stepId) if (step) { Object.assign(step, updates) state.isDirty = true } }) }, moveStep: (fromIndex, toIndex) => { set((state) => { // Don't move the end step if (state.steps[fromIndex]?.type === 'procedure_end') return // Don't move past the end step const endIndex = state.steps.findIndex((s) => s.type === 'procedure_end') if (toIndex >= endIndex) return const [moved] = state.steps.splice(fromIndex, 1) state.steps.splice(toIndex, 0, moved) state.isDirty = true }) }, setSelectedStepId: (stepId) => set((state) => { state.selectedStepId = stepId }), setExpandedStepId: (stepId) => set((state) => { state.expandedStepId = state.expandedStepId === stepId ? null : stepId }), // Intake Form addField: () => { set((state) => { const newField = createDefaultField(state.intakeForm.length, state.intakeForm) state.intakeForm.push(newField) state.isDirty = true }) }, removeField: (index) => { set((state) => { if (index < 0 || index >= state.intakeForm.length) return state.intakeForm.splice(index, 1) // Reorder display_order state.intakeForm.forEach((f, i) => { f.display_order = i + 1 }) state.isDirty = true }) }, updateField: (index, updates) => { set((state) => { const field = state.intakeForm[index] if (field) { // If field_type changed, auto-generate a new variable name if (updates.field_type && updates.field_type !== field.field_type) { const otherFields = state.intakeForm.filter((_, i) => i !== index) updates.variable_name = generateVariableName(updates.field_type, otherFields) } Object.assign(field, updates) state.isDirty = true } }) }, moveField: (fromIndex, toIndex) => { set((state) => { const [moved] = state.intakeForm.splice(fromIndex, 1) state.intakeForm.splice(toIndex, 0, moved) // Reorder display_order state.intakeForm.forEach((f, i) => { f.display_order = i + 1 }) state.isDirty = true }) }, // Validation validate: () => { const state = get() const errors: ProceduralValidationError[] = [] // Rule 1: Name required if (!state.name || state.name.trim() === '') { errors.push({ message: 'Flow name is required', severity: 'error' }) } // Exclude ghost/suggestion steps from validation const realSteps = state.steps.filter((s: ProceduralStep) => !(s as ProceduralStep & { _suggestion?: boolean })._suggestion) // Rule 2: Must have at least one step if (realSteps.length === 0) { errors.push({ message: 'Flow must have at least one step', severity: 'error' }) } else { // Rule 3: Must have exactly one procedure_end step, and it must be last. // NOTE: procedure_end errors intentionally omit stepId so they render as // non-navigable in ValidationSummary (there is no data-step-id on the end step). const endSteps = realSteps.filter((s: ProceduralStep) => s.type === 'procedure_end') if (endSteps.length === 0) { errors.push({ message: 'Flow must have a completion step at the end', severity: 'error' }) } else if (endSteps.length > 1) { errors.push({ message: `Flow has ${endSteps.length} completion steps — only one is allowed`, severity: 'error', }) } else if (realSteps[realSteps.length - 1]?.type !== 'procedure_end') { errors.push({ message: 'Completion step must be the last step', severity: 'error', }) } // Rule 4: Each procedure_step must have a non-empty title realSteps .filter((s: ProceduralStep) => s.type === 'procedure_step') .forEach((s: ProceduralStep) => { if (!s.title || s.title.trim() === '') { errors.push({ stepId: s.id, field: 'title', message: 'Step is missing a title', severity: 'error', }) } }) // Rule 5: No duplicate step IDs const seen = new Set() realSteps.forEach((s: ProceduralStep) => { if (seen.has(s.id)) { errors.push({ stepId: s.id, message: `Duplicate step ID: ${s.id}`, severity: 'error' }) } seen.add(s.id) }) // Rule 6: section_header with no procedure_steps following it (warning) realSteps.forEach((s: ProceduralStep, idx: number) => { if (s.type === 'section_header') { const next = realSteps[idx + 1] if (!next || next.type === 'section_header' || next.type === 'procedure_end') { errors.push({ stepId: s.id, message: 'Section header has no steps beneath it', severity: 'warning', }) } } }) } // Rule 7: Intake form validation if (state.intakeForm && state.intakeForm.length > 0) { const varNames = new Set() state.intakeForm.forEach((field: IntakeFormField) => { if (!field.variable_name || field.variable_name.trim() === '') { errors.push({ field: 'intake_form', message: `Intake field "${field.label || 'unnamed'}" is missing a variable name`, severity: 'error', }) } else { if (varNames.has(field.variable_name)) { errors.push({ field: 'intake_form', message: `Duplicate intake field variable name: ${field.variable_name}`, severity: 'error', }) } varNames.add(field.variable_name) } if ( (field.field_type === 'select' || field.field_type === 'multi_select') && (!field.options || field.options.length === 0) ) { errors.push({ field: 'intake_form', message: `Intake field "${field.label}" needs at least one option`, severity: 'error', }) } }) } set((state) => { state.validationErrors = errors }) return errors }, clearValidation: () => { set((state) => { state.validationErrors = [] }) }, // AI Integration replaceSteps: (steps, intakeForm) => { set((state) => { state.steps = steps if (intakeForm) state.intakeForm = intakeForm state.isDirty = true }) }, // Save setIsSaving: (saving) => set((state) => { state.isSaving = saving }), markSaved: () => set((state) => { state.isDirty = false }), getTreeForSave: () => { const state = get() return { name: state.name, description: state.description, tree_type: state.treeType, tree_structure: { steps: state.steps }, intake_form: state.intakeForm.length > 0 ? state.intakeForm : undefined, category_id: state.categoryId, tags: state.tags, is_public: state.isPublic, status: state.status, } }, })), { limit: 50 } ) ) export default useProceduralEditorStore