setTree(treeData) hasn't committed when startSession runs immediately after, so tree is still null and getStepsFromTree returns []. This caused the step detail area to render empty on new session start. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
885 lines
34 KiB
TypeScript
885 lines
34 KiB
TypeScript
import { useEffect, useState, useRef } from 'react'
|
|
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
|
import { ChevronLeft, ChevronRight, ListOrdered, Settings2, X, Plus, Check, AlertCircle } from 'lucide-react'
|
|
import { treesApi } from '@/api/trees'
|
|
import { sessionsApi } from '@/api/sessions'
|
|
import { stepsApi } from '@/api/steps'
|
|
import type { Tree, Session, ProceduralStep, DecisionRecord, RuntimeStep, CustomProceduralStep, IntakeFormField } from '@/types'
|
|
import type { CustomStep } from '@/types/session'
|
|
import type { Step } from '@/types/step'
|
|
import { StepChecklist } from '@/components/procedural/StepChecklist'
|
|
import { StepDetail } from '@/components/procedural/StepDetail'
|
|
import { ProgressBar } from '@/components/procedural/ProgressBar'
|
|
import { CompletionSummary } from '@/components/procedural/CompletionSummary'
|
|
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
|
import { Spinner } from '@/components/common/Spinner'
|
|
import { cn } from '@/lib/utils'
|
|
import { toast } from '@/lib/toast'
|
|
import { StepFeedback } from '@/components/session/StepFeedback'
|
|
import { CSATModal } from '@/components/session/CSATModal'
|
|
import { hasBeenRated } from '@/components/session/csatUtils'
|
|
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'
|
|
import { CopilotPanel } from '@/components/copilot/CopilotPanel'
|
|
import { CopilotToggle } from '@/components/copilot/CopilotToggle'
|
|
|
|
interface StepState {
|
|
notes: string
|
|
verificationValue: string
|
|
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() {
|
|
const { id: treeId } = useParams<{ id: string }>()
|
|
const navigate = useNavigate()
|
|
const location = useLocation()
|
|
const locationState = location.state as { sessionId?: string } | undefined
|
|
|
|
const [tree, setTree] = useState<Tree | null>(null)
|
|
const [session, setSession] = useState<Session | null>(null)
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [sessionVariables, setSessionVariables] = useState<Record<string, string>>({})
|
|
const [currentStepIndex, setCurrentStepIndex] = useState(0)
|
|
const [stepStates, setStepStates] = useState<Map<string, StepState>>(new Map())
|
|
const [isComplete, setIsComplete] = useState(false)
|
|
const [completedAt, setCompletedAt] = useState<string>('')
|
|
const [showExitConfirm, setShowExitConfirm] = useState(false)
|
|
const [sidebarOpen, setSidebarOpen] = useState(true)
|
|
const [paramsOpen, setParamsOpen] = useState(false)
|
|
const [showCsatModal, setShowCsatModal] = useState(false)
|
|
const [elapsedMinutes, setElapsedMinutes] = useState(0)
|
|
const [batchProgress, setBatchProgress] = useState<{ completed: number; total: number } | 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)
|
|
const [copilotOpen, setCopilotOpen] = useState(false)
|
|
|
|
// Editable variables panel state
|
|
const [editingVarName, setEditingVarName] = useState<string | null>(null)
|
|
const [editingVarValue, setEditingVarValue] = useState('')
|
|
const [showUnfilledWarning, setShowUnfilledWarning] = useState(false)
|
|
|
|
// Get intake form fields from tree snapshot or tree
|
|
const intakeFields: IntakeFormField[] = (() => {
|
|
if (tree?.intake_form && tree.intake_form.length > 0) return tree.intake_form
|
|
// Fallback: check tree snapshot on session
|
|
const snapshot = session?.tree_snapshot as Record<string, unknown> | undefined
|
|
if (snapshot?.intake_form && Array.isArray(snapshot.intake_form)) {
|
|
return snapshot.intake_form as IntakeFormField[]
|
|
}
|
|
return []
|
|
})()
|
|
|
|
// Get procedural steps from tree
|
|
const getSteps = (): ProceduralStep[] => {
|
|
if (!tree) return []
|
|
const structure = tree.tree_structure as unknown as { steps?: ProceduralStep[] }
|
|
return structure.steps || []
|
|
}
|
|
|
|
const steps = getSteps()
|
|
const procedureSteps = runtimeSteps.filter((s) => s.type === 'procedure_step')
|
|
const completedStepIds = new Set(
|
|
Array.from(stepStates.entries())
|
|
.filter(([, state]) => state.completedAt)
|
|
.map(([id]) => id)
|
|
)
|
|
|
|
const estimatedTotalMinutes = procedureSteps.reduce(
|
|
(sum, step) => sum + (('estimated_minutes' in step ? step.estimated_minutes : undefined) || 0),
|
|
0
|
|
)
|
|
|
|
// Load tree
|
|
useEffect(() => {
|
|
if (!treeId) return
|
|
loadTree(treeId)
|
|
return () => {
|
|
if (timerRef.current) clearInterval(timerRef.current)
|
|
}
|
|
}, [treeId])
|
|
|
|
// Parse backend timestamp — ensure UTC if no timezone info
|
|
const parseTimestamp = (ts: string) => {
|
|
if (!ts.endsWith('Z') && !ts.includes('+') && !/\d{2}:\d{2}$/.test(ts.slice(-5))) {
|
|
return new Date(ts + 'Z')
|
|
}
|
|
return new Date(ts)
|
|
}
|
|
|
|
// Elapsed time timer
|
|
useEffect(() => {
|
|
if (session && session.started_at && !isComplete) {
|
|
const calcElapsed = () => {
|
|
const start = parseTimestamp(session.started_at!).getTime()
|
|
setElapsedMinutes(Math.max(0, Math.floor((Date.now() - start) / 60000)))
|
|
}
|
|
calcElapsed()
|
|
timerRef.current = setInterval(calcElapsed, 30000)
|
|
}
|
|
return () => {
|
|
if (timerRef.current) clearInterval(timerRef.current)
|
|
}
|
|
}, [session, isComplete])
|
|
|
|
// Fetch batch progress once when session loads (maintenance flows only)
|
|
useEffect(() => {
|
|
if (!session?.batch_id) return
|
|
sessionsApi.list({ batch_id: session.batch_id, size: 100 })
|
|
.then(data => {
|
|
if (Array.isArray(data) && data.length > 0) {
|
|
const completed = data.filter(s => s.completed_at).length
|
|
setBatchProgress({ completed, total: data.length })
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
}, [session?.batch_id])
|
|
|
|
const loadTree = async (id: string) => {
|
|
setIsLoading(true)
|
|
try {
|
|
const treeData = await treesApi.get(id)
|
|
if (treeData.tree_type !== 'procedural' && treeData.tree_type !== 'maintenance') {
|
|
navigate(`/trees/${id}/navigate`, { replace: true })
|
|
return
|
|
}
|
|
setTree(treeData)
|
|
|
|
// If resuming an existing session
|
|
if (locationState?.sessionId) {
|
|
await resumeSession(treeData, locationState.sessionId)
|
|
return
|
|
}
|
|
|
|
// Start session immediately — no intake form modal
|
|
// Variables will be filled inline during execution
|
|
await startSession(id, {}, treeData)
|
|
} catch {
|
|
toast.error('Failed to load flow')
|
|
navigate('/trees')
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
const startSession = async (id: string, variables: Record<string, string>, treeData?: Tree) => {
|
|
try {
|
|
const newSession = await sessionsApi.create({
|
|
tree_id: id,
|
|
session_variables: Object.keys(variables).length > 0 ? variables : undefined,
|
|
})
|
|
setSession(newSession)
|
|
setSessionVariables(variables)
|
|
|
|
// Initialize step states — use passed treeData since `tree` state may not have committed yet
|
|
const initialStates = new Map<string, StepState>()
|
|
const allSteps = getStepsFromTree(treeData || tree!)
|
|
for (const step of allSteps) {
|
|
initialStates.set(step.id, { notes: '', verificationValue: '', completedAt: null })
|
|
}
|
|
setStepStates(initialStates)
|
|
setRuntimeSteps(allSteps)
|
|
setSessionCustomSteps([])
|
|
} catch {
|
|
toast.error('Failed to start session')
|
|
}
|
|
}
|
|
|
|
const resumeSession = async (treeData: Tree, sessionId: string) => {
|
|
try {
|
|
const sessionData = await sessionsApi.get(sessionId)
|
|
setSession(sessionData)
|
|
setSessionVariables(sessionData.session_variables || {})
|
|
|
|
// Initialize step states from session decisions
|
|
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>()
|
|
for (const step of hydrated) {
|
|
if (step.type === 'procedure_step') {
|
|
initialStates.set(step.id, { notes: '', verificationValue: '', completedAt: null })
|
|
}
|
|
}
|
|
|
|
// Hydrate completed steps from decisions
|
|
for (const decision of sessionData.decisions || []) {
|
|
if (decision.answer === 'completed' && initialStates.has(decision.node_id)) {
|
|
initialStates.set(decision.node_id, {
|
|
notes: decision.notes || '',
|
|
verificationValue: decision.command_output || '',
|
|
completedAt: decision.exited_at || decision.timestamp,
|
|
})
|
|
}
|
|
}
|
|
setStepStates(initialStates)
|
|
|
|
// Set current step to first incomplete step
|
|
const pSteps = hydrated.filter((s) => s.type === 'procedure_step')
|
|
const firstIncomplete = pSteps.findIndex((s) => !initialStates.get(s.id)?.completedAt)
|
|
setCurrentStepIndex(firstIncomplete >= 0 ? firstIncomplete : pSteps.length - 1)
|
|
} catch {
|
|
toast.error('Failed to resume session')
|
|
navigate('/trees')
|
|
}
|
|
}
|
|
|
|
const getStepsFromTree = (t: Tree): ProceduralStep[] => {
|
|
const structure = t.tree_structure as unknown as { steps?: ProceduralStep[] }
|
|
return structure.steps || []
|
|
}
|
|
|
|
// Handle inline variable submission (from StepDetail or variables panel)
|
|
const handleVariableSubmit = async (variableName: string, value: string) => {
|
|
if (!session) return
|
|
|
|
// Optimistic update
|
|
const newVars = { ...sessionVariables, [variableName]: value }
|
|
setSessionVariables(newVars)
|
|
|
|
try {
|
|
await sessionsApi.updateVariables(session.id, { [variableName]: value })
|
|
} catch {
|
|
// Revert on failure
|
|
setSessionVariables(sessionVariables)
|
|
toast.error('Failed to save variable')
|
|
}
|
|
}
|
|
|
|
const handleMarkComplete = async () => {
|
|
if (!session || procedureSteps.length === 0) return
|
|
|
|
const currentStep = procedureSteps[currentStepIndex]
|
|
if (!currentStep) return
|
|
|
|
const now = new Date().toISOString()
|
|
|
|
// Update step state
|
|
setStepStates((prev) => {
|
|
const next = new Map(prev)
|
|
const existing = next.get(currentStep.id) || { notes: '', verificationValue: '', completedAt: null }
|
|
next.set(currentStep.id, { ...existing, completedAt: now })
|
|
return next
|
|
})
|
|
|
|
// Create a decision record for this step
|
|
const stepState = stepStates.get(currentStep.id)
|
|
const decision: DecisionRecord = {
|
|
node_id: currentStep.id,
|
|
question: currentStep.title,
|
|
answer: 'completed',
|
|
action_performed: currentStep.description || null,
|
|
notes: stepState?.notes || null,
|
|
command_output: stepState?.verificationValue || null,
|
|
automation_used: false,
|
|
timestamp: now,
|
|
entered_at: null,
|
|
exited_at: now,
|
|
duration_seconds: null,
|
|
attachments: [],
|
|
}
|
|
|
|
try {
|
|
const updatedDecisions = [...(session.decisions || []), decision]
|
|
await sessionsApi.update(session.id, {
|
|
decisions: updatedDecisions,
|
|
path_taken: [...(session.path_taken || []), currentStep.id],
|
|
})
|
|
|
|
setSession((prev) => prev ? {
|
|
...prev,
|
|
decisions: updatedDecisions,
|
|
path_taken: [...(prev.path_taken || []), currentStep.id],
|
|
} : prev)
|
|
|
|
// Move to next step or complete
|
|
if (currentStepIndex >= procedureSteps.length - 1) {
|
|
// Last step — check for unfilled required variables
|
|
const unfilledReqVars = intakeFields.filter(
|
|
f => f.required && !sessionVariables[f.variable_name]?.trim()
|
|
)
|
|
if (unfilledReqVars.length > 0 && !showUnfilledWarning) {
|
|
// Show warning but don't block
|
|
setShowUnfilledWarning(true)
|
|
return
|
|
}
|
|
setShowUnfilledWarning(false)
|
|
|
|
// Complete the procedure
|
|
const completedTime = new Date().toISOString()
|
|
await sessionsApi.complete(session.id, {
|
|
outcome: 'resolved',
|
|
outcome_notes: `Procedure completed. ${procedureSteps.length} steps finished.`,
|
|
})
|
|
setCompletedAt(completedTime)
|
|
setIsComplete(true)
|
|
if (!hasBeenRated(session.id)) {
|
|
setShowCsatModal(true)
|
|
}
|
|
} else {
|
|
setCurrentStepIndex(currentStepIndex + 1)
|
|
}
|
|
} catch {
|
|
toast.error('Failed to save progress')
|
|
}
|
|
}
|
|
|
|
const handleStepNotesChange = (notes: string) => {
|
|
const currentStep = procedureSteps[currentStepIndex]
|
|
if (!currentStep) return
|
|
setStepStates((prev) => {
|
|
const next = new Map(prev)
|
|
const existing = next.get(currentStep.id) || { notes: '', verificationValue: '', completedAt: null }
|
|
next.set(currentStep.id, { ...existing, notes })
|
|
return next
|
|
})
|
|
}
|
|
|
|
const handleVerificationChange = (value: string) => {
|
|
const currentStep = procedureSteps[currentStepIndex]
|
|
if (!currentStep) return
|
|
setStepStates((prev) => {
|
|
const next = new Map(prev)
|
|
const existing = next.get(currentStep.id) || { notes: '', verificationValue: '', completedAt: null }
|
|
next.set(currentStep.id, { ...existing, verificationValue: value })
|
|
return next
|
|
})
|
|
}
|
|
|
|
const handleCsatClose = () => {
|
|
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(prev => prev + 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)
|
|
}
|
|
|
|
// Variables panel: start editing
|
|
const startEditingVar = (varName: string) => {
|
|
setEditingVarName(varName)
|
|
setEditingVarValue(sessionVariables[varName] || '')
|
|
}
|
|
|
|
// Variables panel: save edit
|
|
const saveEditingVar = () => {
|
|
if (editingVarName && editingVarValue.trim()) {
|
|
handleVariableSubmit(editingVarName, editingVarValue.trim())
|
|
}
|
|
setEditingVarName(null)
|
|
setEditingVarValue('')
|
|
}
|
|
|
|
// Loading state
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex min-h-[50vh] items-center justify-center">
|
|
<Spinner />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Completion summary
|
|
if (isComplete && tree && session) {
|
|
return (
|
|
<div className="container mx-auto px-4 py-8 sm:px-6">
|
|
<CompletionSummary
|
|
treeName={tree.name}
|
|
steps={steps}
|
|
completions={new Map(
|
|
Array.from(stepStates.entries())
|
|
.filter(([, s]) => s.completedAt)
|
|
.map(([id, s]) => [id, {
|
|
stepId: id,
|
|
notes: s.notes,
|
|
verificationValue: s.verificationValue,
|
|
completedAt: s.completedAt!,
|
|
}])
|
|
)}
|
|
variables={sessionVariables}
|
|
startedAt={session.started_at || ''}
|
|
completedAt={completedAt}
|
|
onExport={() => navigate(`/sessions/${session.id}`)}
|
|
onClose={() => navigate('/trees')}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// No session yet
|
|
if (!session || !tree) return null
|
|
|
|
const currentStep = procedureSteps[currentStepIndex]
|
|
const currentStepState = currentStep ? stepStates.get(currentStep.id) : undefined
|
|
|
|
// Count unfilled required variables
|
|
const unfilledRequired = intakeFields.filter(
|
|
f => f.required && !sessionVariables[f.variable_name]?.trim()
|
|
).length
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
|
{/* Top bar */}
|
|
<div className="border-b border-border px-4 py-3 sm:px-6">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
|
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground lg:hidden"
|
|
>
|
|
{sidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
</button>
|
|
<ListOrdered className="h-5 w-5 text-muted-foreground" />
|
|
<h1 className="text-sm font-semibold text-foreground sm:text-base">{tree.name}</h1>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
if (currentStepIndex > 0) {
|
|
setShowExitConfirm(true)
|
|
} else {
|
|
navigate('/trees')
|
|
}
|
|
}}
|
|
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
|
>
|
|
<X className="mr-1 inline h-3.5 w-3.5" />
|
|
Exit
|
|
</button>
|
|
</div>
|
|
<div className="mt-2">
|
|
<ProgressBar
|
|
currentStep={completedStepIds.size}
|
|
totalSteps={procedureSteps.length}
|
|
elapsedMinutes={elapsedMinutes}
|
|
estimatedTotalMinutes={estimatedTotalMinutes || undefined}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Maintenance context strip */}
|
|
{tree?.tree_type === 'maintenance' && session && (
|
|
<MaintenanceContextStrip
|
|
treeId={treeId!}
|
|
targetLabel={session.target_label}
|
|
batchId={session.batch_id}
|
|
batchProgress={batchProgress}
|
|
/>
|
|
)}
|
|
|
|
{/* Main content */}
|
|
<div className="flex min-h-0 flex-1 overflow-hidden">
|
|
{/* Left sidebar - step checklist */}
|
|
<div
|
|
className={cn(
|
|
'min-h-0 border-r border-border bg-card transition-all duration-200',
|
|
sidebarOpen ? 'w-72 overflow-y-auto p-3' : 'w-0 overflow-hidden p-0'
|
|
)}
|
|
>
|
|
{sidebarOpen && (
|
|
<>
|
|
<StepChecklist
|
|
steps={runtimeSteps}
|
|
currentStepIndex={currentStepIndex}
|
|
completedStepIds={completedStepIds}
|
|
onStepClick={setCurrentStepIndex}
|
|
/>
|
|
|
|
{/* Session Variables button */}
|
|
{intakeFields.length > 0 && (
|
|
<div className="mt-3 border-t border-border pt-3">
|
|
<button
|
|
onClick={() => setParamsOpen(true)}
|
|
className="flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-xs text-muted-foreground hover:bg-accent hover:text-muted-foreground"
|
|
>
|
|
<Settings2 className="h-3.5 w-3.5" />
|
|
Session Variables
|
|
{unfilledRequired > 0 && (
|
|
<span className="ml-auto flex h-4 w-4 items-center justify-center rounded-full bg-amber-400/20 text-[0.6rem] font-bold text-amber-400">
|
|
{unfilledRequired}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right panel - step detail + copilot */}
|
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain p-4 sm:p-6">
|
|
{currentStep && (
|
|
<StepDetail
|
|
step={currentStep}
|
|
stepNumber={currentStepIndex + 1}
|
|
totalSteps={procedureSteps.length}
|
|
variables={sessionVariables}
|
|
notes={currentStepState?.notes || ''}
|
|
onNotesChange={handleStepNotesChange}
|
|
verificationValue={currentStepState?.verificationValue || ''}
|
|
onVerificationChange={handleVerificationChange}
|
|
isCompleted={completedStepIds.has(currentStep.id)}
|
|
onMarkComplete={handleMarkComplete}
|
|
isLast={currentStepIndex === procedureSteps.length - 1}
|
|
intakeFields={intakeFields}
|
|
onVariableSubmit={handleVariableSubmit}
|
|
/>
|
|
)}
|
|
|
|
{/* 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 && !('isCustom' in currentStep && currentStep.isCustom) && (
|
|
<div className="mt-3 flex justify-end">
|
|
<StepFeedback stepId={currentStep.id} sessionId={session.id} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* AI Copilot - in-flow panel */}
|
|
{treeId && copilotOpen && (
|
|
<CopilotPanel
|
|
isOpen={copilotOpen}
|
|
onClose={() => setCopilotOpen(false)}
|
|
treeId={treeId}
|
|
sessionId={session?.id}
|
|
currentNodeId={runtimeSteps[currentStepIndex]?.id}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* CSAT Modal */}
|
|
{session && (
|
|
<CSATModal
|
|
isOpen={showCsatModal}
|
|
onClose={handleCsatClose}
|
|
sessionId={session.id}
|
|
/>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
isOpen={showExitConfirm}
|
|
onClose={() => setShowExitConfirm(false)}
|
|
onConfirm={() => navigate('/trees')}
|
|
title="Exit Session"
|
|
message="You have progress in this session. Are you sure you want to exit? Your progress will not be saved."
|
|
confirmLabel="Exit"
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
isOpen={showUnfilledWarning}
|
|
onClose={() => setShowUnfilledWarning(false)}
|
|
onConfirm={handleMarkComplete}
|
|
title="Unfilled Variables"
|
|
message={`${intakeFields.filter(f => f.required && !sessionVariables[f.variable_name]?.trim()).length} required variable(s) are still empty. You can fill them now via the Session Variables panel, or complete anyway. Unfilled variables will appear as blank in exports.`}
|
|
confirmLabel="Complete Anyway"
|
|
/>
|
|
|
|
{/* Session Variables Panel (editable) */}
|
|
{paramsOpen && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
<div
|
|
className="absolute inset-0 bg-background/60 backdrop-blur-xs"
|
|
onClick={() => setParamsOpen(false)}
|
|
/>
|
|
<div className="relative w-full max-w-md rounded-2xl border border-border bg-card shadow-2xl backdrop-blur-xs">
|
|
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
|
<h3 className="text-sm font-semibold text-foreground">Session Variables</h3>
|
|
<button
|
|
onClick={() => setParamsOpen(false)}
|
|
className="rounded-lg p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
<div className="max-h-[60vh] overflow-y-auto p-5">
|
|
<div className="space-y-2">
|
|
{intakeFields
|
|
.sort((a, b) => a.display_order - b.display_order)
|
|
.map((field) => {
|
|
const value = sessionVariables[field.variable_name] || ''
|
|
const isFilled = !!value.trim()
|
|
const isEditing = editingVarName === field.variable_name
|
|
|
|
return (
|
|
<div
|
|
key={field.variable_name}
|
|
className={cn(
|
|
'rounded-lg border px-3 py-2.5',
|
|
isFilled ? 'border-border bg-accent' : 'border-cyan-500/20 bg-cyan-500/5'
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
{isFilled ? (
|
|
<Check className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
|
|
) : (
|
|
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-cyan-400" />
|
|
)}
|
|
<span className="text-xs font-medium text-muted-foreground truncate">
|
|
{field.label}
|
|
{field.required && <span className="ml-1 text-amber-400">*</span>}
|
|
</span>
|
|
</div>
|
|
{!isEditing && (
|
|
<button
|
|
onClick={() => startEditingVar(field.variable_name)}
|
|
className="shrink-0 rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-card hover:text-foreground"
|
|
>
|
|
{isFilled ? 'Edit' : 'Fill'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{isEditing ? (
|
|
<div className="mt-2">
|
|
{field.field_type === 'select' && field.options?.length ? (
|
|
<select
|
|
value={editingVarValue}
|
|
onChange={(e) => setEditingVarValue(e.target.value)}
|
|
autoFocus
|
|
className="w-full rounded-md border border-cyan-500/30 bg-card px-2.5 py-1.5 text-sm text-foreground focus:border-cyan-400 focus:outline-hidden focus:ring-1 focus:ring-cyan-400/30"
|
|
>
|
|
<option value="">{field.placeholder || 'Select...'}</option>
|
|
{field.options.map((opt) => (
|
|
<option key={opt} value={opt}>{opt}</option>
|
|
))}
|
|
</select>
|
|
) : field.field_type === 'textarea' ? (
|
|
<textarea
|
|
value={editingVarValue}
|
|
onChange={(e) => setEditingVarValue(e.target.value)}
|
|
autoFocus
|
|
rows={3}
|
|
placeholder={field.placeholder}
|
|
className="w-full rounded-md border border-cyan-500/30 bg-card px-2.5 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-cyan-400 focus:outline-hidden focus:ring-1 focus:ring-cyan-400/30"
|
|
/>
|
|
) : (
|
|
<input
|
|
type={field.field_type === 'number' ? 'number' : field.field_type === 'email' ? 'email' : 'text'}
|
|
value={editingVarValue}
|
|
onChange={(e) => setEditingVarValue(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') saveEditingVar() }}
|
|
autoFocus
|
|
placeholder={field.placeholder}
|
|
className="w-full rounded-md border border-cyan-500/30 bg-card px-2.5 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-cyan-400 focus:outline-hidden focus:ring-1 focus:ring-cyan-400/30"
|
|
/>
|
|
)}
|
|
{field.help_text && (
|
|
<p className="mt-1 text-[0.625rem] text-muted-foreground">{field.help_text}</p>
|
|
)}
|
|
<div className="mt-2 flex justify-end gap-2">
|
|
<button
|
|
onClick={() => { setEditingVarName(null); setEditingVarValue('') }}
|
|
className="rounded px-2.5 py-1 text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={saveEditingVar}
|
|
disabled={!editingVarValue.trim()}
|
|
className="rounded bg-gradient-brand px-2.5 py-1 text-xs font-medium text-[#101114] disabled:opacity-40"
|
|
>
|
|
Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : isFilled ? (
|
|
<p className="mt-1 text-sm text-foreground truncate">{value}</p>
|
|
) : null}
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{/* Show any extra variables not in intake form */}
|
|
{Object.entries(sessionVariables)
|
|
.filter(([key]) => !intakeFields.some(f => f.variable_name === key))
|
|
.map(([key, value]) => (
|
|
<div key={key} className="flex items-baseline justify-between gap-4 rounded-lg bg-accent px-3 py-2">
|
|
<span className="text-xs font-medium text-muted-foreground">{key.replace(/_/g, ' ')}</span>
|
|
<span className="text-right text-sm text-muted-foreground">{value || 'N/A'}</span>
|
|
</div>
|
|
))}
|
|
</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}
|
|
/>
|
|
)}
|
|
|
|
{/* AI Copilot toggle button */}
|
|
{treeId && (
|
|
<CopilotToggle isOpen={copilotOpen} onToggle={() => setCopilotOpen(true)} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default ProceduralNavigationPage
|