475 lines
17 KiB
TypeScript
475 lines
17 KiB
TypeScript
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<string, unknown>, metadata?: Record<string, unknown> | 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<string, unknown>[],
|
|
intake_form: intakeForm,
|
|
}
|
|
}, [steps, intakeForm, name, description]),
|
|
onFlowUpdate: handleFlowUpdate,
|
|
})
|
|
|
|
const [isFixing, setIsFixing] = useState(false)
|
|
const [fixProposals, setFixProposals] = useState<AIFixProposal[] | null>(null)
|
|
|
|
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) {
|
|
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]) // eslint-disable-line react-hooks/exhaustive-deps -- editor init is keyed to route id; store actions are stable
|
|
|
|
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<string, unknown>,
|
|
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<ProceduralStep>)
|
|
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<ProceduralStep>)
|
|
})
|
|
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 (
|
|
<div className="flex min-h-[50vh] items-center justify-center">
|
|
<Spinner />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full overflow-hidden">
|
|
{/* Main content column */}
|
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
|
{/* Toolbar — sticky */}
|
|
<div className="flex shrink-0 items-center gap-3 border-b border-border bg-sidebar px-4 py-2.5">
|
|
<button
|
|
onClick={() => navigate('/trees')}
|
|
className="shrink-0 rounded p-1.5 text-muted-foreground transition-colors hover:bg-white/[0.08] hover:text-foreground"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</button>
|
|
|
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
{isMaintenance
|
|
? <Wrench className="h-4 w-4 shrink-0 text-amber-400" />
|
|
: <ListOrdered className="h-4 w-4 shrink-0 text-muted-foreground" />}
|
|
<input
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => 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 && (
|
|
<span
|
|
title="Unsaved changes"
|
|
className="h-1.5 w-1.5 shrink-0 rounded-full bg-amber-400"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<button
|
|
onClick={() => editorAI.isOpen ? editorAI.closePanel() : editorAI.openPanel()}
|
|
title="Toggle AI Assist panel"
|
|
className={cn(
|
|
'flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium transition-colors',
|
|
editorAI.isOpen
|
|
? 'border-primary/30 bg-accent-dim text-primary'
|
|
: 'text-muted-foreground hover:bg-white/[0.08] hover:text-foreground'
|
|
)}
|
|
>
|
|
<Sparkles className="h-4 w-4" />
|
|
AI Assist
|
|
</button>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => handleSave('draft')}
|
|
disabled={isSaving}
|
|
>
|
|
Save Draft
|
|
</Button>
|
|
<Button
|
|
onClick={() => handleSave('published')}
|
|
loading={isSaving}
|
|
>
|
|
<Save className="h-4 w-4" />
|
|
{isSaving ? 'Saving...' : 'Publish'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Config zone */}
|
|
<div className="shrink-0 border-b border-border bg-card">
|
|
<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">Description</label>
|
|
<textarea
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="Brief description of this procedure..."
|
|
rows={2}
|
|
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-muted-foreground">Tags</label>
|
|
<TagInput tags={tags} onChange={setTags} />
|
|
</div>
|
|
|
|
<div className="flex items-end pb-1">
|
|
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<input
|
|
type="checkbox"
|
|
checked={isPublic}
|
|
onChange={(e) => setIsPublic(e.target.checked)}
|
|
className="rounded border-border"
|
|
/>
|
|
Public (visible to all users)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CollapsibleEditorSection>
|
|
|
|
<CollapsibleEditorSection
|
|
title="Intake Form"
|
|
icon={<FileText className="h-4 w-4" />}
|
|
summary={intakeSummary}
|
|
expanded={expandedSection === 'intake'}
|
|
onToggle={() => toggleSection('intake')}
|
|
>
|
|
<IntakeFormBuilder />
|
|
</CollapsibleEditorSection>
|
|
|
|
{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 canvas */}
|
|
<div className="min-h-0 flex-1 overflow-y-auto bg-page">
|
|
<div className="px-5 py-5">
|
|
{validationErrors.length > 0 && (
|
|
<div className="mb-4">
|
|
<ValidationSummary
|
|
errors={validationErrors.map((e): ValidationError => ({
|
|
nodeId: e.stepId,
|
|
field: e.field,
|
|
message: e.message,
|
|
severity: e.severity,
|
|
}))}
|
|
onSelectNode={handleSelectStep}
|
|
onFixWithAI={handleFixWithAI}
|
|
isFixing={isFixing}
|
|
itemLabel="step"
|
|
/>
|
|
</div>
|
|
)}
|
|
<StepList onStepContextMenu={editorAI.openContextMenu} />
|
|
</div>
|
|
</div>
|
|
|
|
{editorAI.contextMenu && (
|
|
<ContextMenu
|
|
position={editorAI.contextMenu.position}
|
|
items={[
|
|
{
|
|
id: 'generate-steps',
|
|
label: 'Generate Steps After',
|
|
icon: <Sparkles className="h-4 w-4" />,
|
|
onClick: () => editorAI.triggerAction(
|
|
editorAI.contextMenu!.nodeId,
|
|
'add_steps',
|
|
`Generate steps after this step`
|
|
),
|
|
},
|
|
{
|
|
id: 'expand-step',
|
|
label: 'Expand Step',
|
|
icon: <Layers className="h-4 w-4" />,
|
|
onClick: () => editorAI.triggerAction(
|
|
editorAI.contextMenu!.nodeId,
|
|
'quick_action',
|
|
`Expand this step into detailed substeps`
|
|
),
|
|
},
|
|
]}
|
|
onClose={editorAI.closeContextMenu}
|
|
/>
|
|
)}
|
|
</div>{/* end main content column */}
|
|
|
|
<EditorAIPanel
|
|
isOpen={editorAI.isOpen}
|
|
onClose={editorAI.closePanel}
|
|
focalNode={null}
|
|
flowName={name}
|
|
flowType={isMaintenance ? 'maintenance' : 'procedural'}
|
|
nodeCount={steps.length}
|
|
messages={editorAI.messages}
|
|
input={editorAI.input}
|
|
onInputChange={editorAI.setInput}
|
|
onSend={editorAI.sendMessage}
|
|
isLoading={editorAI.isLoading}
|
|
suggestions={editorAI.suggestions}
|
|
/>
|
|
|
|
{fixProposals && (
|
|
<AIFixReviewModal
|
|
fixes={fixProposals}
|
|
onApply={handleApplyFix}
|
|
onApplyAll={handleApplyAllFixes}
|
|
onClose={handleCloseFixModal}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default ProceduralEditorPage
|