feat: custom step insertion in procedural flow sessions
Engineers can add custom steps inline during execution. Steps are persisted to session.custom_steps and restored on resume. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,12 @@
|
|||||||
import { useEffect, useState, useRef } from 'react'
|
import { useEffect, useState, useRef } from 'react'
|
||||||
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { ChevronLeft, ChevronRight, ListOrdered, Settings2, X } from 'lucide-react'
|
import { ChevronLeft, ChevronRight, ListOrdered, Settings2, X, Plus } from 'lucide-react'
|
||||||
import { treesApi } from '@/api/trees'
|
import { treesApi } from '@/api/trees'
|
||||||
import { sessionsApi } from '@/api/sessions'
|
import { sessionsApi } from '@/api/sessions'
|
||||||
import type { Tree, Session, ProceduralStep, DecisionRecord } from '@/types'
|
import { stepsApi } from '@/api/steps'
|
||||||
|
import type { Tree, Session, ProceduralStep, DecisionRecord, RuntimeStep, CustomProceduralStep } from '@/types'
|
||||||
|
import type { CustomStep } from '@/types/session'
|
||||||
|
import type { Step } from '@/types/step'
|
||||||
import { IntakeFormModal } from '@/components/procedural/IntakeFormModal'
|
import { IntakeFormModal } from '@/components/procedural/IntakeFormModal'
|
||||||
import { StepChecklist } from '@/components/procedural/StepChecklist'
|
import { StepChecklist } from '@/components/procedural/StepChecklist'
|
||||||
import { StepDetail } from '@/components/procedural/StepDetail'
|
import { StepDetail } from '@/components/procedural/StepDetail'
|
||||||
@@ -17,6 +20,9 @@ import { StepFeedback } from '@/components/session/StepFeedback'
|
|||||||
import { CSATModal } from '@/components/session/CSATModal'
|
import { CSATModal } from '@/components/session/CSATModal'
|
||||||
import { hasBeenRated } from '@/components/session/csatUtils'
|
import { hasBeenRated } from '@/components/session/csatUtils'
|
||||||
import { MaintenanceContextStrip } from '@/components/maintenance/MaintenanceContextStrip'
|
import { MaintenanceContextStrip } from '@/components/maintenance/MaintenanceContextStrip'
|
||||||
|
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
|
||||||
|
import type { CustomStepDraft } from '@/components/step-library/CustomStepModal'
|
||||||
|
import { PostStepActionModal } from '@/components/session/PostStepActionModal'
|
||||||
|
|
||||||
interface StepState {
|
interface StepState {
|
||||||
notes: string
|
notes: string
|
||||||
@@ -24,6 +30,29 @@ interface StepState {
|
|||||||
completedAt: string | null
|
completedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRuntimeSteps(baseSteps: ProceduralStep[], customSteps: CustomStep[]): RuntimeStep[] {
|
||||||
|
const result: RuntimeStep[] = [...baseSteps]
|
||||||
|
const sorted = [...customSteps].sort((a, b) => a.timestamp.localeCompare(b.timestamp))
|
||||||
|
for (const cs of sorted) {
|
||||||
|
const afterIdx = result.findIndex((s) => s.id === cs.inserted_after_node_id)
|
||||||
|
const insertAt = afterIdx >= 0 ? afterIdx + 1 : result.length
|
||||||
|
const runtimeCustom: CustomProceduralStep = {
|
||||||
|
id: cs.id,
|
||||||
|
type: 'procedure_step',
|
||||||
|
title: cs.step_data.title,
|
||||||
|
description: cs.step_data.content?.instructions,
|
||||||
|
content_type: 'action',
|
||||||
|
commands: cs.step_data.content?.commands?.map((c) => ({
|
||||||
|
code: c.command,
|
||||||
|
label: c.label,
|
||||||
|
})),
|
||||||
|
isCustom: true,
|
||||||
|
}
|
||||||
|
result.splice(insertAt, 0, runtimeCustom)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
export function ProceduralNavigationPage() {
|
export function ProceduralNavigationPage() {
|
||||||
const { id: treeId } = useParams<{ id: string }>()
|
const { id: treeId } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -47,6 +76,15 @@ export function ProceduralNavigationPage() {
|
|||||||
const [batchProgress, setBatchProgress] = useState<{ completed: number; total: number } | null>(null)
|
const [batchProgress, setBatchProgress] = useState<{ completed: number; total: number } | null>(null)
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
// Custom step state
|
||||||
|
const [runtimeSteps, setRuntimeSteps] = useState<RuntimeStep[]>([])
|
||||||
|
const [sessionCustomSteps, setSessionCustomSteps] = useState<CustomStep[]>([])
|
||||||
|
const [showCustomStepModal, setShowCustomStepModal] = useState(false)
|
||||||
|
const [showPostStepModal, setShowPostStepModal] = useState(false)
|
||||||
|
const [pendingCustomStep, setPendingCustomStep] = useState<Step | CustomStepDraft | null>(null)
|
||||||
|
const [pendingIsFromLibrary, setPendingIsFromLibrary] = useState(false)
|
||||||
|
const [isSavingStep, setIsSavingStep] = useState(false)
|
||||||
|
|
||||||
// Get procedural steps from tree
|
// Get procedural steps from tree
|
||||||
const getSteps = (): ProceduralStep[] => {
|
const getSteps = (): ProceduralStep[] => {
|
||||||
if (!tree) return []
|
if (!tree) return []
|
||||||
@@ -55,7 +93,7 @@ export function ProceduralNavigationPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const steps = getSteps()
|
const steps = getSteps()
|
||||||
const procedureSteps = steps.filter((s) => s.type === 'procedure_step')
|
const procedureSteps = runtimeSteps.filter((s) => s.type === 'procedure_step')
|
||||||
const completedStepIds = new Set(
|
const completedStepIds = new Set(
|
||||||
Array.from(stepStates.entries())
|
Array.from(stepStates.entries())
|
||||||
.filter(([, state]) => state.completedAt)
|
.filter(([, state]) => state.completedAt)
|
||||||
@@ -63,7 +101,7 @@ export function ProceduralNavigationPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const estimatedTotalMinutes = procedureSteps.reduce(
|
const estimatedTotalMinutes = procedureSteps.reduce(
|
||||||
(sum, step) => sum + (step.estimated_minutes || 0),
|
(sum, step) => sum + (('estimated_minutes' in step ? step.estimated_minutes : undefined) || 0),
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -159,6 +197,8 @@ export function ProceduralNavigationPage() {
|
|||||||
initialStates.set(step.id, { notes: '', verificationValue: '', completedAt: null })
|
initialStates.set(step.id, { notes: '', verificationValue: '', completedAt: null })
|
||||||
}
|
}
|
||||||
setStepStates(initialStates)
|
setStepStates(initialStates)
|
||||||
|
setRuntimeSteps(allSteps)
|
||||||
|
setSessionCustomSteps([])
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to start session')
|
toast.error('Failed to start session')
|
||||||
}
|
}
|
||||||
@@ -173,6 +213,13 @@ export function ProceduralNavigationPage() {
|
|||||||
|
|
||||||
// Initialize step states from session decisions
|
// Initialize step states from session decisions
|
||||||
const allSteps = getStepsFromTree(treeData)
|
const allSteps = getStepsFromTree(treeData)
|
||||||
|
|
||||||
|
// Initialize custom steps from session data
|
||||||
|
const customSteps = sessionData.custom_steps || []
|
||||||
|
setSessionCustomSteps(customSteps)
|
||||||
|
const hydrated = buildRuntimeSteps(allSteps, customSteps)
|
||||||
|
setRuntimeSteps(hydrated)
|
||||||
|
|
||||||
const initialStates = new Map<string, StepState>()
|
const initialStates = new Map<string, StepState>()
|
||||||
for (const step of allSteps) {
|
for (const step of allSteps) {
|
||||||
initialStates.set(step.id, { notes: '', verificationValue: '', completedAt: null })
|
initialStates.set(step.id, { notes: '', verificationValue: '', completedAt: null })
|
||||||
@@ -191,7 +238,7 @@ export function ProceduralNavigationPage() {
|
|||||||
setStepStates(initialStates)
|
setStepStates(initialStates)
|
||||||
|
|
||||||
// Set current step to first incomplete step
|
// Set current step to first incomplete step
|
||||||
const pSteps = allSteps.filter((s) => s.type === 'procedure_step')
|
const pSteps = hydrated.filter((s) => s.type === 'procedure_step')
|
||||||
const firstIncomplete = pSteps.findIndex((s) => !initialStates.get(s.id)?.completedAt)
|
const firstIncomplete = pSteps.findIndex((s) => !initialStates.get(s.id)?.completedAt)
|
||||||
setCurrentStepIndex(firstIncomplete >= 0 ? firstIncomplete : pSteps.length - 1)
|
setCurrentStepIndex(firstIncomplete >= 0 ? firstIncomplete : pSteps.length - 1)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -303,6 +350,112 @@ export function ProceduralNavigationPage() {
|
|||||||
setShowCsatModal(false)
|
setShowCsatModal(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleStepCreated = (step: Step | CustomStepDraft, isFromLibrary: boolean) => {
|
||||||
|
setPendingCustomStep(step)
|
||||||
|
setPendingIsFromLibrary(isFromLibrary)
|
||||||
|
setShowCustomStepModal(false)
|
||||||
|
setShowPostStepModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInsertCustomStep = async (step: Step | CustomStepDraft) => {
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
const currentStep = procedureSteps[currentStepIndex]
|
||||||
|
const insertedAfterId = currentStep?.id ?? ''
|
||||||
|
|
||||||
|
const runtimeCustom: CustomProceduralStep = {
|
||||||
|
id,
|
||||||
|
type: 'procedure_step',
|
||||||
|
title: step.title,
|
||||||
|
description: step.content?.instructions,
|
||||||
|
content_type: 'action',
|
||||||
|
commands: step.content?.commands?.map((c) => ({
|
||||||
|
code: c.command,
|
||||||
|
label: c.label,
|
||||||
|
})),
|
||||||
|
isCustom: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
setRuntimeSteps((prev) => {
|
||||||
|
const next = [...prev]
|
||||||
|
const globalIdx = next.findIndex((s) => s.id === insertedAfterId)
|
||||||
|
const insertAt = globalIdx >= 0 ? globalIdx + 1 : next.length
|
||||||
|
next.splice(insertAt, 0, runtimeCustom)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
setStepStates((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(id, { notes: '', verificationValue: '', completedAt: null })
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
const newCustomStep: CustomStep = {
|
||||||
|
id,
|
||||||
|
inserted_after_node_id: insertedAfterId,
|
||||||
|
step_data: step,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
const newCustomSteps = [...sessionCustomSteps, newCustomStep]
|
||||||
|
setSessionCustomSteps(newCustomSteps)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sessionsApi.update(session.id, { custom_steps: newCustomSteps })
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to save custom step')
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentStepIndex(currentStepIndex + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveForLater = async () => {
|
||||||
|
if (!pendingCustomStep || pendingIsFromLibrary) return
|
||||||
|
setIsSavingStep(true)
|
||||||
|
try {
|
||||||
|
await stepsApi.create({
|
||||||
|
title: pendingCustomStep.title,
|
||||||
|
step_type: pendingCustomStep.step_type,
|
||||||
|
content: pendingCustomStep.content,
|
||||||
|
visibility: 'private',
|
||||||
|
})
|
||||||
|
toast.success('Step saved to library')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to save step')
|
||||||
|
} finally {
|
||||||
|
setIsSavingStep(false)
|
||||||
|
setShowPostStepModal(false)
|
||||||
|
setPendingCustomStep(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUseNow = async () => {
|
||||||
|
if (!pendingCustomStep) return
|
||||||
|
setShowPostStepModal(false)
|
||||||
|
await handleInsertCustomStep(pendingCustomStep)
|
||||||
|
setPendingCustomStep(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBoth = async () => {
|
||||||
|
if (!pendingCustomStep || pendingIsFromLibrary) return
|
||||||
|
setIsSavingStep(true)
|
||||||
|
try {
|
||||||
|
await stepsApi.create({
|
||||||
|
title: pendingCustomStep.title,
|
||||||
|
step_type: pendingCustomStep.step_type,
|
||||||
|
content: pendingCustomStep.content,
|
||||||
|
visibility: 'private',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to save step to library')
|
||||||
|
} finally {
|
||||||
|
setIsSavingStep(false)
|
||||||
|
}
|
||||||
|
setShowPostStepModal(false)
|
||||||
|
await handleInsertCustomStep(pendingCustomStep)
|
||||||
|
setPendingCustomStep(null)
|
||||||
|
}
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -419,7 +572,7 @@ export function ProceduralNavigationPage() {
|
|||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<>
|
<>
|
||||||
<StepChecklist
|
<StepChecklist
|
||||||
steps={steps}
|
steps={runtimeSteps}
|
||||||
currentStepIndex={currentStepIndex}
|
currentStepIndex={currentStepIndex}
|
||||||
completedStepIds={completedStepIds}
|
completedStepIds={completedStepIds}
|
||||||
onStepClick={setCurrentStepIndex}
|
onStepClick={setCurrentStepIndex}
|
||||||
@@ -458,6 +611,20 @@ export function ProceduralNavigationPage() {
|
|||||||
isLast={currentStepIndex === procedureSteps.length - 1}
|
isLast={currentStepIndex === procedureSteps.length - 1}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Add custom step — only on current active incomplete non-custom step */}
|
||||||
|
{currentStep && !completedStepIds.has(currentStep.id) && !('isCustom' in currentStep && currentStep.isCustom) && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCustomStepModal(true)}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-border px-4 py-2.5 text-sm text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Step
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{session && currentStep && (
|
{session && currentStep && (
|
||||||
<div className="mt-3 flex justify-end">
|
<div className="mt-3 flex justify-end">
|
||||||
<StepFeedback stepId={currentStep.id} sessionId={session.id} />
|
<StepFeedback stepId={currentStep.id} sessionId={session.id} />
|
||||||
@@ -514,6 +681,27 @@ export function ProceduralNavigationPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Custom Step Modal */}
|
||||||
|
<CustomStepModal
|
||||||
|
isOpen={showCustomStepModal}
|
||||||
|
onClose={() => setShowCustomStepModal(false)}
|
||||||
|
onInsertStep={handleStepCreated}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Post Step Action Modal */}
|
||||||
|
{pendingCustomStep && (
|
||||||
|
<PostStepActionModal
|
||||||
|
isOpen={showPostStepModal}
|
||||||
|
onClose={() => { setShowPostStepModal(false); setPendingCustomStep(null) }}
|
||||||
|
step={pendingCustomStep}
|
||||||
|
onSaveForLater={handleSaveForLater}
|
||||||
|
onUseNow={handleUseNow}
|
||||||
|
onBoth={handleBoth}
|
||||||
|
isFromLibrary={pendingIsFromLibrary}
|
||||||
|
isSaving={isSavingStep}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user