feat: maintenance flow UX redesign — batch status hub, context strip, detail page upgrades (#85)

- Add BatchStatusPage (/flows/:id/batches/:batchId): per-target Start/Resume/View cards, progress bar, 5s polling while in-progress, completion outcome summary
- Add BatchStatusCard: handles not-started/in-progress/complete states with step progress for in-progress targets
- Add ActiveBatchBanner: amber banner on detail page when a batch is running, links to BatchStatusPage
- Add MaintenanceContextStrip: amber strip in ProceduralNavigationPage for maintenance flows showing target name, batch progress (X/Y complete), and Back to Batch nav
- Update MaintenanceFlowDetailPage: active batch banner, clickable run history rows with mini progress dots and outcome summaries, Run button loading state, post-launch navigates to BatchStatusPage
- Update ProceduralNavigationPage: renders MaintenanceContextStrip between top bar and content when tree_type === 'maintenance'; fetches batch progress once on mount
- Add batch_id filter to GET /sessions backend endpoint and SessionListParams frontend type
- Add /flows/:id/batches/:batchId route to router

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-24 18:51:42 -05:00
parent 645e86301b
commit 8db34f07ee
10 changed files with 608 additions and 27 deletions

View File

@@ -275,6 +275,10 @@ navigate(`/trees/${newTree.id}/edit`)
**25. Claude API may wrap JSON responses in markdown fences:** When parsing AI-generated JSON, always strip ` ```json ... ``` ` fences before parsing. See `_strip_markdown_fences()` in `ai_tree_generator_service.py`.
**26. `sessionsApi.list` supports `batch_id` filter (added Feb 2026):** Both backend `GET /sessions` and frontend `SessionListParams` accept `batch_id` for querying all sessions in a maintenance batch. Use `sessionsApi.list({ batch_id })` to fetch batch-scoped sessions.
**27. Maintenance batch sessions are created all-at-once at launch:** All sessions in a batch exist immediately after `batchLaunchApi.launch()` with `batch_id` + `target_label` set. `started_at` is null until a user begins executing that target — there is no "pending session creation" state.
---
## RBAC & Permissions

View File

@@ -38,6 +38,7 @@ async def list_sessions(
client_name: Optional[str] = Query(None, description="Search by client name (partial match)"),
tree_name: Optional[str] = Query(None, description="Filter by tree name from snapshot"),
tree_id: Optional[UUID] = Query(None, description="Filter by tree ID"),
batch_id: Optional[UUID] = Query(None, description="Filter by batch ID (maintenance batch runs)"),
started_after: Optional[datetime] = Query(None, description="Filter sessions started after this datetime"),
started_before: Optional[datetime] = Query(None, description="Filter sessions started before this datetime"),
completed_after: Optional[datetime] = Query(None, description="Filter sessions completed after this datetime"),
@@ -73,6 +74,10 @@ async def list_sessions(
if tree_id:
query = query.where(Session.tree_id == tree_id)
# Batch ID filter
if batch_id:
query = query.where(Session.batch_id == batch_id)
# Date range filters
if started_after:
query = query.where(Session.started_at >= started_after)

View File

@@ -5,6 +5,7 @@ export interface SessionListParams {
page?: number
size?: number
tree_id?: string
batch_id?: string
completed?: boolean
ticket_number?: string
client_name?: string

View File

@@ -0,0 +1,46 @@
import { X, RefreshCw, ChevronRight } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import type { Session } from '@/types'
interface ActiveBatchBannerProps {
treeId: string
sessions: Session[]
onDismiss: () => void
}
export function ActiveBatchBanner({ treeId, sessions, onDismiss }: ActiveBatchBannerProps) {
const navigate = useNavigate()
// Find the most recently started in-progress batch
const inProgressSessions = sessions.filter(s => s.started_at && !s.completed_at && s.batch_id)
if (inProgressSessions.length === 0) return null
const batchId = inProgressSessions[0].batch_id!
// Count all sessions in this batch
const batchSessions = sessions.filter(s => s.batch_id === batchId)
const completed = batchSessions.filter(s => s.completed_at).length
const total = batchSessions.length
return (
<div className="flex items-center gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<RefreshCw className="h-4 w-4 shrink-0 animate-spin text-amber-400" />
<p className="flex-1 text-[0.875rem] text-amber-300">
Batch in progress · <span className="font-medium">{completed} of {total}</span> targets complete
</p>
<button
onClick={() => navigate(`/flows/${treeId}/batches/${batchId}`)}
className="flex items-center gap-1 text-[0.8125rem] font-medium text-amber-400 hover:text-amber-300 transition-colors"
>
View Batch
<ChevronRight className="h-3.5 w-3.5" />
</button>
<button
onClick={onDismiss}
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)
}

View File

@@ -0,0 +1,144 @@
import { CheckCircle, Circle, Loader2, Play, RotateCcw, Eye } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { cn } from '@/lib/utils'
import type { Session } from '@/types'
interface BatchStatusCardProps {
session: Session | null
targetLabel: string
treeId: string
batchId: string
}
function formatDuration(startedAt: string, completedAt: string): string {
const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime()
const totalSeconds = Math.floor(ms / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
if (minutes === 0) return `${seconds}s`
return `${minutes}m ${seconds}s`
}
const OUTCOME_LABELS: Record<string, string> = {
resolved: 'Resolved',
escalated: 'Escalated',
workaround: 'Workaround',
unresolved: 'Unresolved',
}
const OUTCOME_COLORS: Record<string, string> = {
resolved: 'text-emerald-400 bg-emerald-500/10',
escalated: 'text-red-400 bg-red-500/10',
workaround: 'text-amber-400 bg-amber-500/10',
unresolved: 'text-muted-foreground bg-muted',
}
export function BatchStatusCard({ session, targetLabel, treeId, batchId }: BatchStatusCardProps) {
const navigate = useNavigate()
const isComplete = !!session?.completed_at
const isInProgress = !!session?.started_at && !session.completed_at
const isNotStarted = !session || !session.started_at
// Derive step progress for in-progress sessions
const stepProgress = (() => {
if (!session || !isInProgress) return null
const snapshot = session.tree_snapshot as { steps?: { type: string }[] } | null
const totalSteps = snapshot?.steps?.filter(s => s.type === 'procedure_step').length ?? 0
const completedSteps = (session.decisions ?? []).filter(d => d.answer === 'completed').length
return totalSteps > 0 ? { completed: completedSteps, total: totalSteps } : null
})()
const handleStart = () => {
navigate(`/flows/${treeId}/navigate`, {
state: { targetLabel, batchId },
})
}
const handleResume = () => {
if (!session) return
navigate(`/flows/${treeId}/navigate`, {
state: { sessionId: session.id },
})
}
const handleView = () => {
if (!session) return
navigate(`/sessions/${session.id}`)
}
return (
<div className="flex items-center justify-between rounded-lg border border-border bg-card px-4 py-3 gap-4">
{/* Status indicator + target name */}
<div className="flex items-center gap-3 min-w-0">
{isComplete && (
<CheckCircle className="h-4 w-4 shrink-0 text-emerald-400" />
)}
{isInProgress && (
<Loader2 className="h-4 w-4 shrink-0 text-amber-400 animate-spin" />
)}
{isNotStarted && (
<Circle className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0">
<p className="text-[0.875rem] font-medium text-foreground truncate">{targetLabel}</p>
<div className="flex items-center gap-2 mt-0.5">
{isComplete && session?.outcome && (
<span className={cn(
"font-label text-[0.625rem] uppercase tracking-wide rounded-full px-2 py-0.5",
OUTCOME_COLORS[session.outcome] ?? OUTCOME_COLORS.unresolved
)}>
{OUTCOME_LABELS[session.outcome] ?? session.outcome}
</span>
)}
{isComplete && session?.started_at && session?.completed_at && (
<span className="text-[0.75rem] text-muted-foreground">
{formatDuration(session.started_at, session.completed_at)}
</span>
)}
{isInProgress && stepProgress && (
<span className="text-[0.75rem] text-amber-400">
Step {stepProgress.completed + 1} of {stepProgress.total}
</span>
)}
{isNotStarted && (
<span className="text-[0.75rem] text-muted-foreground">Not started</span>
)}
</div>
</div>
</div>
{/* Action button */}
<div className="shrink-0">
{isComplete && (
<button
onClick={handleView}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-[0.8125rem] text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Eye className="h-3.5 w-3.5" />
View
</button>
)}
{isInProgress && (
<button
onClick={handleResume}
className="flex items-center gap-1.5 rounded-lg bg-amber-500/10 border border-amber-500/30 px-3 py-1.5 text-[0.8125rem] text-amber-400 hover:bg-amber-500/20 transition-colors"
>
<RotateCcw className="h-3.5 w-3.5" />
Resume
</button>
)}
{isNotStarted && (
<button
onClick={handleStart}
className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-3 py-1.5 text-[0.8125rem] font-medium text-white shadow-sm shadow-primary/20 hover:opacity-90 transition-opacity"
>
<Play className="h-3.5 w-3.5" />
Start
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import { Wrench, ChevronLeft } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
interface MaintenanceContextStripProps {
treeId: string
targetLabel?: string | null
batchId?: string | null
batchProgress?: { completed: number; total: number } | null
}
export function MaintenanceContextStrip({
treeId,
targetLabel,
batchId,
batchProgress,
}: MaintenanceContextStripProps) {
const navigate = useNavigate()
return (
<div className="flex items-center gap-3 border-b border-amber-500/20 bg-amber-500/5 px-4 py-2 text-sm">
<Wrench className="h-3.5 w-3.5 shrink-0 text-amber-400" />
<span className="font-medium text-amber-300">
{targetLabel ? `Target: ${targetLabel}` : 'Manual Run'}
</span>
{batchProgress && (
<>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground text-[0.8125rem]">
{batchProgress.completed} / {batchProgress.total} complete
</span>
</>
)}
{batchId && (
<button
onClick={() => navigate(`/flows/${treeId}/batches/${batchId}`)}
className="ml-auto flex items-center gap-1 text-xs text-amber-400 hover:text-amber-300 transition-colors"
>
<ChevronLeft className="h-3 w-3" />
Back to Batch
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,209 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ChevronLeft, RefreshCw, Wrench } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { sessionsApi } from '@/api/sessions'
import { BatchStatusCard } from '@/components/maintenance/BatchStatusCard'
import { Spinner } from '@/components/common/Spinner'
import { cn } from '@/lib/utils'
import type { Tree, Session } from '@/types'
// Batch sessions are created with a shared batch_id and individual target_labels.
// Some targets may not have a session yet if created via a pre-populated target list
// where sessions were created all at once — in practice all sessions exist at batch
// launch time, so we group by target_label from the sessions we get back.
export default function BatchStatusPage() {
const { id: treeId, batchId } = useParams<{ id: string; batchId: string }>()
const navigate = useNavigate()
const [tree, setTree] = useState<Tree | null>(null)
const [sessions, setSessions] = useState<Session[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false)
const [batchDate, setBatchDate] = useState<Date | null>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const loadSessions = useCallback(async (showRefreshing = false) => {
if (!batchId) return
if (showRefreshing) setIsRefreshing(true)
try {
const data = await sessionsApi.list({ batch_id: batchId, size: 100 })
setSessions(Array.isArray(data) ? data : [])
if (data.length > 0 && data[0].started_at) {
setBatchDate(new Date(data[0].started_at))
}
} finally {
if (showRefreshing) setIsRefreshing(false)
}
}, [batchId])
// Initial load
useEffect(() => {
if (!treeId || !batchId) return
const load = async () => {
try {
const [treeData] = await Promise.all([
treesApi.get(treeId),
loadSessions(),
])
setTree(treeData)
} finally {
setIsLoading(false)
}
}
load()
}, [treeId, batchId, loadSessions])
// Polling: refresh every 5s while any session is in-progress
useEffect(() => {
const hasInProgress = sessions.some(s => s.started_at && !s.completed_at)
if (hasInProgress) {
pollRef.current = setInterval(() => loadSessions(), 5000)
} else {
if (pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
}
return () => {
if (pollRef.current) clearInterval(pollRef.current)
}
}, [sessions, loadSessions])
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Spinner size="sm" className="h-6 w-6 border-primary border-t-transparent" />
</div>
)
}
const total = sessions.length
const completed = sessions.filter(s => s.completed_at).length
const inProgress = sessions.filter(s => s.started_at && !s.completed_at).length
const allDone = total > 0 && completed === total
// Outcome summary for completion
const outcomeCounts = sessions.reduce<Record<string, number>>((acc, s) => {
if (s.outcome) acc[s.outcome] = (acc[s.outcome] ?? 0) + 1
return acc
}, {})
const progressPercent = total > 0 ? Math.round((completed / total) * 100) : 0
return (
<div className="container mx-auto max-w-3xl space-y-6 px-4 py-6 sm:px-6 sm:py-8">
{/* Breadcrumb */}
<div className="flex items-center justify-between">
<button
onClick={() => treeId && navigate(`/flows/${treeId}/maintenance`)}
className="flex items-center gap-1.5 text-[0.875rem] text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="h-4 w-4" />
{tree?.name ?? 'Maintenance Flow'}
</button>
<button
onClick={() => loadSessions(true)}
disabled={isRefreshing}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-[0.8125rem] text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-50"
>
<RefreshCw className={cn('h-3.5 w-3.5', isRefreshing && 'animate-spin')} />
Refresh
</button>
</div>
{/* Header */}
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-amber-500/10 text-amber-400">
<Wrench className="h-5 w-5" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">Batch Run</h1>
{batchDate && (
<p className="text-[0.875rem] text-muted-foreground">
{batchDate.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}
</p>
)}
</div>
</div>
{/* Progress bar */}
{total > 0 && (
<div className="rounded-xl border border-border bg-card p-5 space-y-3">
<div className="flex items-center justify-between text-[0.875rem]">
<span className="font-medium text-foreground">
{completed} of {total} complete
</span>
<span className={cn(
'font-label text-[0.6875rem] uppercase tracking-wide rounded-full px-2 py-0.5',
allDone
? 'text-emerald-400 bg-emerald-500/10'
: inProgress > 0
? 'text-amber-400 bg-amber-500/10'
: 'text-muted-foreground bg-muted'
)}>
{allDone ? 'Complete' : inProgress > 0 ? `${inProgress} in progress` : 'Not started'}
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
allDone ? 'bg-emerald-500' : 'bg-amber-500'
)}
style={{ width: `${progressPercent}%` }}
/>
</div>
{/* Completion summary */}
{allDone && Object.keys(outcomeCounts).length > 0 && (
<div className="flex flex-wrap gap-3 pt-1">
{outcomeCounts.resolved && (
<span className="text-[0.8125rem] text-emerald-400">{outcomeCounts.resolved} resolved</span>
)}
{outcomeCounts.escalated && (
<span className="text-[0.8125rem] text-red-400">{outcomeCounts.escalated} escalated</span>
)}
{outcomeCounts.workaround && (
<span className="text-[0.8125rem] text-amber-400">{outcomeCounts.workaround} workaround</span>
)}
{outcomeCounts.unresolved && (
<span className="text-[0.8125rem] text-muted-foreground">{outcomeCounts.unresolved} unresolved</span>
)}
</div>
)}
</div>
)}
{/* Target cards */}
<div className="space-y-2">
{sessions.length === 0 ? (
<p className="text-center text-[0.875rem] text-muted-foreground py-8">
No sessions found for this batch.
</p>
) : (
sessions
.sort((a, b) => {
// Sort: in-progress first, then not-started, then complete
const rank = (s: Session) => {
if (s.started_at && !s.completed_at) return 0
if (!s.started_at) return 1
return 2
}
return rank(a) - rank(b)
})
.map((session) => (
<BatchStatusCard
key={session.id}
session={session}
targetLabel={session.target_label ?? session.id}
treeId={treeId!}
batchId={batchId!}
/>
))
)}
</div>
</div>
)
}

View File

@@ -5,6 +5,7 @@ import { treesApi } from '@/api/trees'
import { sessionsApi } from '@/api/sessions'
import { maintenanceSchedulesApi } from '@/api/maintenanceSchedules'
import { BatchLaunchModal } from '@/components/maintenance/BatchLaunchModal'
import { ActiveBatchBanner } from '@/components/maintenance/ActiveBatchBanner'
import { Spinner } from '@/components/common/Spinner'
import { EmptyState } from '@/components/common/EmptyState'
import { PageHeader } from '@/components/common/PageHeader'
@@ -12,6 +13,13 @@ import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import type { Tree, MaintenanceSchedule, Session } from '@/types'
const OUTCOME_LABELS: Record<string, string> = {
resolved: 'resolved',
escalated: 'escalated',
workaround: 'workaround',
unresolved: 'unresolved',
}
export default function MaintenanceFlowDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
@@ -20,6 +28,8 @@ export default function MaintenanceFlowDetailPage() {
const [recentSessions, setRecentSessions] = useState<Session[]>([])
const [showBatchModal, setShowBatchModal] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [isRunning, setIsRunning] = useState(false)
const [bannerDismissed, setBannerDismissed] = useState(false)
useEffect(() => {
if (!id) return
@@ -33,7 +43,6 @@ export default function MaintenanceFlowDetailPage() {
}
setTree(treeData)
// Load recent sessions for this tree
try {
const sessionData = await sessionsApi.list({ tree_id: id, size: 30 })
setRecentSessions(Array.isArray(sessionData) ? sessionData : [])
@@ -41,7 +50,6 @@ export default function MaintenanceFlowDetailPage() {
// Sessions load is optional
}
// Try to load schedule (404 is fine)
try {
const sched = await maintenanceSchedulesApi.getForTree(id)
setSchedule(sched)
@@ -58,10 +66,21 @@ export default function MaintenanceFlowDetailPage() {
load()
}, [id, navigate])
const handleLaunched = (_batchId: string, count: number) => {
const handleLaunched = (batchId: string, _count: number) => {
setShowBatchModal(false)
toast.success(`${count} sessions created — view them in Sessions`)
navigate('/sessions')
setBannerDismissed(false)
// Reload sessions so banner picks up the new batch
if (id) {
sessionsApi.list({ tree_id: id, size: 30 })
.then(data => setRecentSessions(Array.isArray(data) ? data : []))
.catch(() => {})
}
navigate(`/flows/${id}/batches/${batchId}`)
}
const handleRun = () => {
setIsRunning(true)
navigate(`/flows/${id}/navigate`)
}
if (isLoading) {
@@ -100,8 +119,20 @@ export default function MaintenanceFlowDetailPage() {
}
const batches = Array.from(batchMap.entries()).slice(0, 10)
// Show banner only if there are in-progress batch sessions and it hasn't been dismissed
const hasActiveBatch = recentSessions.some(s => s.started_at && !s.completed_at && s.batch_id)
return (
<div className="container mx-auto max-w-4xl space-y-6 px-4 py-6 sm:px-6 sm:py-8">
{/* Active batch banner */}
{hasActiveBatch && !bannerDismissed && (
<ActiveBatchBanner
treeId={id!}
sessions={recentSessions}
onDismiss={() => setBannerDismissed(true)}
/>
)}
{/* Header */}
<PageHeader
title={tree.name}
@@ -122,10 +153,13 @@ export default function MaintenanceFlowDetailPage() {
Edit Flow
</button>
<button
onClick={() => navigate(`/flows/${id}/navigate`)}
className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
onClick={handleRun}
disabled={isRunning}
className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-70"
>
<Play className="h-3.5 w-3.5" />
{isRunning
? <Spinner size="sm" className="h-3.5 w-3.5 border-white border-t-transparent" />
: <Play className="h-3.5 w-3.5" />}
Run
</button>
<button
@@ -186,29 +220,87 @@ export default function MaintenanceFlowDetailPage() {
<p className="text-[0.875rem] text-muted-foreground">No runs yet. Launch a batch to get started.</p>
) : (
<div className="space-y-2">
{batches.map(([batchKey, sessions]) => {
const completed = sessions.filter(s => s.completed_at).length
const total = sessions.length
const date = sessions[0]?.started_at
{batches.map(([batchKey, batchSessions]) => {
const completed = batchSessions.filter(s => s.completed_at).length
const total = batchSessions.length
const isActive = batchSessions.some(s => s.started_at && !s.completed_at)
const date = batchSessions[0]?.started_at
const isSingleRun = !batchSessions[0]?.batch_id
// Outcome summary
const outcomeCounts = batchSessions.reduce<Record<string, number>>((acc, s) => {
if (s.outcome) acc[s.outcome] = (acc[s.outcome] ?? 0) + 1
return acc
}, {})
const outcomeParts = Object.entries(outcomeCounts)
.map(([k, v]) => `${v} ${OUTCOME_LABELS[k] ?? k}`)
// Mini progress dots (up to 8 shown)
const dotsToShow = Math.min(total, 8)
const dots = Array.from({ length: dotsToShow }, (_, i) => i < completed)
const extraDots = total > 8 ? total - 8 : 0
const handleRowClick = () => {
if (isSingleRun && batchSessions[0]) {
navigate(`/sessions/${batchSessions[0].id}`)
} else {
navigate(`/flows/${id}/batches/${batchKey}`)
}
}
return (
<div key={batchKey} className="flex items-center justify-between rounded-lg border border-border px-4 py-3">
<button
key={batchKey}
onClick={handleRowClick}
className="w-full flex items-center justify-between rounded-lg border border-border px-4 py-3 hover:bg-accent transition-colors text-left"
>
<div>
<p className="text-[0.875rem] font-medium text-foreground">
{total} target{total !== 1 ? 's' : ''}
</p>
{date && (
<p className="text-[0.8125rem] text-muted-foreground">
{new Date(date).toLocaleDateString()}
<div className="flex items-center gap-2">
{isActive && (
<span className="inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
)}
<p className="text-[0.875rem] font-medium text-foreground">
{isSingleRun ? 'Manual run' : `${total} target${total !== 1 ? 's' : ''}`}
</p>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
{date && (
<p className="text-[0.8125rem] text-muted-foreground">
{new Date(date).toLocaleDateString()}
</p>
)}
{outcomeParts.length > 0 && (
<p className="text-[0.8125rem] text-muted-foreground">
· {outcomeParts.join(' · ')}
</p>
)}
</div>
</div>
<span className={cn(
"font-label text-[0.75rem] uppercase tracking-wide",
completed === total ? "text-emerald-400" : "text-amber-400"
)}>
{completed}/{total} complete
</span>
</div>
<div className="flex items-center gap-2">
{!isSingleRun && total > 1 && (
<div className="flex items-center gap-0.5">
{dots.map((done, i) => (
<span
key={i}
className={cn(
'inline-block h-2 w-2 rounded-full',
done ? 'bg-emerald-400' : 'bg-muted'
)}
/>
))}
{extraDots > 0 && (
<span className="ml-1 text-[0.6875rem] text-muted-foreground">+{extraDots}</span>
)}
</div>
)}
<span className={cn(
"font-label text-[0.75rem] uppercase tracking-wide",
isActive ? "text-amber-400" : completed === total ? "text-emerald-400" : "text-muted-foreground"
)}>
{isActive ? 'In Progress' : `${completed}/${total}`}
</span>
</div>
</button>
)
})}
</div>

View File

@@ -16,6 +16,7 @@ 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'
interface StepState {
notes: string
@@ -43,6 +44,7 @@ export function ProceduralNavigationPage() {
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)
// Get procedural steps from tree
@@ -97,6 +99,19 @@ export function ProceduralNavigationPage() {
}
}, [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 {
@@ -382,6 +397,16 @@ export function ProceduralNavigationPage() {
</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 */}

View File

@@ -25,6 +25,7 @@ const TreeEditorPage = lazy(() => import('@/pages/TreeEditorPage'))
const ProceduralEditorPage = lazy(() => import('@/pages/ProceduralEditorPage'))
const ProceduralNavigationPage = lazy(() => import('@/pages/ProceduralNavigationPage'))
const MaintenanceFlowDetailPage = lazy(() => import('@/pages/MaintenanceFlowDetailPage'))
const BatchStatusPage = lazy(() => import('@/pages/BatchStatusPage'))
const SessionHistoryPage = lazy(() => import('@/pages/SessionHistoryPage'))
const SessionDetailPage = lazy(() => import('@/pages/SessionDetailPage'))
const MySharesPage = lazy(() => import('@/pages/MySharesPage'))
@@ -180,6 +181,14 @@ export const router = createBrowserRouter([
</Suspense>
),
},
{
path: 'flows/:id/batches/:batchId',
element: (
<Suspense fallback={<PageLoader />}>
<BatchStatusPage />
</Suspense>
),
},
{
path: 'trees/:id/navigate',
element: (