From 8db34f07ee7059b403628b8aaa6839e84146a520 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 24 Feb 2026 18:51:42 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20maintenance=20flow=20UX=20redesign=20?= =?UTF-8?q?=E2=80=94=20batch=20status=20hub,=20context=20strip,=20detail?= =?UTF-8?q?=20page=20upgrades=20(#85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 4 + backend/app/api/endpoints/sessions.py | 5 + frontend/src/api/sessions.ts | 1 + .../maintenance/ActiveBatchBanner.tsx | 46 ++++ .../maintenance/BatchStatusCard.tsx | 144 ++++++++++++ .../maintenance/MaintenanceContextStrip.tsx | 46 ++++ frontend/src/pages/BatchStatusPage.tsx | 209 ++++++++++++++++++ .../src/pages/MaintenanceFlowDetailPage.tsx | 146 +++++++++--- .../src/pages/ProceduralNavigationPage.tsx | 25 +++ frontend/src/router.tsx | 9 + 10 files changed, 608 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/maintenance/ActiveBatchBanner.tsx create mode 100644 frontend/src/components/maintenance/BatchStatusCard.tsx create mode 100644 frontend/src/components/maintenance/MaintenanceContextStrip.tsx create mode 100644 frontend/src/pages/BatchStatusPage.tsx diff --git a/CLAUDE.md b/CLAUDE.md index d19f2bfa..fc937870 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index 12b3f67e..5e7b9c5f 100644 --- a/backend/app/api/endpoints/sessions.py +++ b/backend/app/api/endpoints/sessions.py @@ -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) diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index ffda6080..69f6605f 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -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 diff --git a/frontend/src/components/maintenance/ActiveBatchBanner.tsx b/frontend/src/components/maintenance/ActiveBatchBanner.tsx new file mode 100644 index 00000000..8ea28da1 --- /dev/null +++ b/frontend/src/components/maintenance/ActiveBatchBanner.tsx @@ -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 ( +
+ +

+ Batch in progress · {completed} of {total} targets complete +

+ + +
+ ) +} diff --git a/frontend/src/components/maintenance/BatchStatusCard.tsx b/frontend/src/components/maintenance/BatchStatusCard.tsx new file mode 100644 index 00000000..62ce004e --- /dev/null +++ b/frontend/src/components/maintenance/BatchStatusCard.tsx @@ -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 = { + resolved: 'Resolved', + escalated: 'Escalated', + workaround: 'Workaround', + unresolved: 'Unresolved', +} + +const OUTCOME_COLORS: Record = { + 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 ( +
+ {/* Status indicator + target name */} +
+ {isComplete && ( + + )} + {isInProgress && ( + + )} + {isNotStarted && ( + + )} + +
+

{targetLabel}

+
+ {isComplete && session?.outcome && ( + + {OUTCOME_LABELS[session.outcome] ?? session.outcome} + + )} + {isComplete && session?.started_at && session?.completed_at && ( + + {formatDuration(session.started_at, session.completed_at)} + + )} + {isInProgress && stepProgress && ( + + Step {stepProgress.completed + 1} of {stepProgress.total} + + )} + {isNotStarted && ( + Not started + )} +
+
+
+ + {/* Action button */} +
+ {isComplete && ( + + )} + {isInProgress && ( + + )} + {isNotStarted && ( + + )} +
+
+ ) +} diff --git a/frontend/src/components/maintenance/MaintenanceContextStrip.tsx b/frontend/src/components/maintenance/MaintenanceContextStrip.tsx new file mode 100644 index 00000000..a1039976 --- /dev/null +++ b/frontend/src/components/maintenance/MaintenanceContextStrip.tsx @@ -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 ( +
+ + + {targetLabel ? `Target: ${targetLabel}` : 'Manual Run'} + + + {batchProgress && ( + <> + · + + {batchProgress.completed} / {batchProgress.total} complete + + + )} + + {batchId && ( + + )} +
+ ) +} diff --git a/frontend/src/pages/BatchStatusPage.tsx b/frontend/src/pages/BatchStatusPage.tsx new file mode 100644 index 00000000..c6a0de1e --- /dev/null +++ b/frontend/src/pages/BatchStatusPage.tsx @@ -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(null) + const [sessions, setSessions] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [isRefreshing, setIsRefreshing] = useState(false) + const [batchDate, setBatchDate] = useState(null) + const pollRef = useRef | 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 ( +
+ +
+ ) + } + + 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>((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 ( +
+ {/* Breadcrumb */} +
+ + +
+ + {/* Header */} +
+
+ +
+
+

Batch Run

+ {batchDate && ( +

+ {batchDate.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })} +

+ )} +
+
+ + {/* Progress bar */} + {total > 0 && ( +
+
+ + {completed} of {total} complete + + 0 + ? 'text-amber-400 bg-amber-500/10' + : 'text-muted-foreground bg-muted' + )}> + {allDone ? 'Complete' : inProgress > 0 ? `${inProgress} in progress` : 'Not started'} + +
+
+
+
+ + {/* Completion summary */} + {allDone && Object.keys(outcomeCounts).length > 0 && ( +
+ {outcomeCounts.resolved && ( + {outcomeCounts.resolved} resolved + )} + {outcomeCounts.escalated && ( + {outcomeCounts.escalated} escalated + )} + {outcomeCounts.workaround && ( + {outcomeCounts.workaround} workaround + )} + {outcomeCounts.unresolved && ( + {outcomeCounts.unresolved} unresolved + )} +
+ )} +
+ )} + + {/* Target cards */} +
+ {sessions.length === 0 ? ( +

+ No sessions found for this batch. +

+ ) : ( + 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) => ( + + )) + )} +
+
+ ) +} diff --git a/frontend/src/pages/MaintenanceFlowDetailPage.tsx b/frontend/src/pages/MaintenanceFlowDetailPage.tsx index 1b5c7303..d6c66080 100644 --- a/frontend/src/pages/MaintenanceFlowDetailPage.tsx +++ b/frontend/src/pages/MaintenanceFlowDetailPage.tsx @@ -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 = { + 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([]) 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 (
+ {/* Active batch banner */} + {hasActiveBatch && !bannerDismissed && ( + setBannerDismissed(true)} + /> + )} + {/* Header */}
+
+ {!isSingleRun && total > 1 && ( +
+ {dots.map((done, i) => ( + + ))} + {extraDots > 0 && ( + +{extraDots} + )} +
+ )} + + {isActive ? 'In Progress' : `${completed}/${total}`} + +
+ ) })}
diff --git a/frontend/src/pages/ProceduralNavigationPage.tsx b/frontend/src/pages/ProceduralNavigationPage.tsx index 8f416d2a..81500dae 100644 --- a/frontend/src/pages/ProceduralNavigationPage.tsx +++ b/frontend/src/pages/ProceduralNavigationPage.tsx @@ -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 | 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() { + {/* Maintenance context strip */} + {tree?.tree_type === 'maintenance' && session && ( + + )} + {/* Main content */}
{/* Left sidebar - step checklist */} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 18f99a8e..3d497ea3 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -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([ ), }, + { + path: 'flows/:id/batches/:batchId', + element: ( + }> + + + ), + }, { path: 'trees/:id/navigate', element: (