Files
resolutionflow/frontend/src/pages/BatchStatusPage.tsx
chihlasm 5095b0d8df refactor: adopt shared Input/Textarea components (#101)
* refactor: adopt shared Input/Textarea components across 15 files

Replace 42 raw <input>/<textarea> elements with <Input>/<Textarea>
from components/ui/. Consistent focus states, error handling, and
styling across all form fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: replace hardcoded rgba/hex colors with Tailwind tokens

- rgba(255,255,255,0.xx) → bg-white/[0.xx], border-white/[0.xx]
- rgba(6,182,212,0.3) → border-primary/30 (focus states)
- #0a0a0a → bg-background
- Inline style hex colors → var(--color-primary), var(--color-brand-gradient-to)
- 28 files updated, zero hardcoded rgba() patterns remaining

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add PageMeta to 16 pages for SEO and proper browser tab titles

Public pages (Login, Register, Forgot/Reset Password, Verify Email,
Survey Thank You) get descriptions for SEO. Authenticated pages
(Dashboard, Flow Library, My Flows, Session History, AI Assistant,
Account Settings, Step Library, My Shares, Feedback, Guides) get
proper tab titles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add page transitions and staggered list animations

- ViewTransitionOutlet: wraps Outlet with fade-in-up animation keyed
  to route path. Sidebar/topbar stay still, only content area animates.
- StaggerList: reusable component that cascades children with
  incremental delay (50ms default). Pure CSS via @utility stagger-item.
- Applied stagger to TreeGridView, MyTreesPage cards, SessionHistoryPage.
- New stagger-fade-in keyframe in @theme block.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: ViewTransitionOutlet needs h-full for React Flow canvas

The wrapper div broke the height chain needed by TreeEditorPage's
h-full layout, causing React Flow canvas to collapse to zero height.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: main-content flex layout for tree editor + scrollable pages

Main content area is now flex-col so the ViewTransitionOutlet wrapper
gets an explicit computed height via flex-1 min-h-0. This makes h-full
resolve correctly in the tree editor (React Flow canvas) while still
allowing overflow-y-auto scrolling for normal pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve ESLint errors in Button and Skeleton components

- Button: suppress react-refresh/only-export-components for buttonVariants re-export
- Skeleton: replace empty interface with type alias, replace Math.random() with static widths array

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add PageMeta, animation classes, and layout fixes to remaining pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:12:21 -04:00

227 lines
8.5 KiB
TypeScript

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 [loadError, setLoadError] = useState<string | null>(null)
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 : [])
setLoadError(null)
if (data.length > 0 && data[0].started_at) {
setBatchDate(new Date(data[0].started_at))
}
} catch {
setLoadError('Failed to load batch sessions')
} 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)
} catch {
setLoadError('Failed to load batch data')
} 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="overflow-y-auto h-full 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">
{loadError ? (
<div className="rounded-lg border border-red-400/20 bg-red-400/10 p-4 text-center">
<p className="text-sm text-red-400 mb-3">{loadError}</p>
<button
onClick={() => loadSessions(true)}
className="rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
Try again
</button>
</div>
) : 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>
)
}