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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
46
frontend/src/components/maintenance/ActiveBatchBanner.tsx
Normal file
46
frontend/src/components/maintenance/ActiveBatchBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
144
frontend/src/components/maintenance/BatchStatusCard.tsx
Normal file
144
frontend/src/components/maintenance/BatchStatusCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
209
frontend/src/pages/BatchStatusPage.tsx
Normal file
209
frontend/src/pages/BatchStatusPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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: (
|
||||
|
||||
Reference in New Issue
Block a user