Files
resolutionflow/frontend/src/pages/MaintenanceFlowDetailPage.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

322 lines
12 KiB
TypeScript

import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Wrench, Calendar, Play, Settings, Clock, CheckCircle, AlertCircle } from 'lucide-react'
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'
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()
const [tree, setTree] = useState<Tree | null>(null)
const [schedule, setSchedule] = useState<MaintenanceSchedule | null>(null)
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
const load = async () => {
try {
const treeData = await treesApi.get(id)
if (treeData.tree_type !== 'maintenance') {
toast.error('This page is only for maintenance flows')
navigate('/trees?type=maintenance')
return
}
setTree(treeData)
try {
const sessionData = await sessionsApi.list({ tree_id: id, size: 30 })
setRecentSessions(Array.isArray(sessionData) ? sessionData : [])
} catch {
// Sessions load is optional
}
try {
const sched = await maintenanceSchedulesApi.getForTree(id)
setSchedule(sched)
} catch {
// No schedule yet is fine
}
} catch {
toast.error('Failed to load maintenance flow')
navigate('/trees?type=maintenance')
} finally {
setIsLoading(false)
}
}
load()
}, [id, navigate])
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const handleLaunched = (batchId: string, _count: number) => {
setShowBatchModal(false)
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) {
return (
<div className="flex h-64 items-center justify-center">
<Spinner size="sm" className="h-6 w-6 border-primary border-t-transparent" />
</div>
)
}
if (!tree) {
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<EmptyState
title="Maintenance flow not found"
description="This flow is unavailable or you do not have access."
action={(
<button
onClick={() => navigate('/trees?type=maintenance')}
className="rounded-md border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
Back to Maintenance Flows
</button>
)}
/>
</div>
)
}
// Group sessions by batch_id for run history
const batchMap = new Map<string, Session[]>()
for (const s of recentSessions) {
const key = s.batch_id ?? s.id
const existing = batchMap.get(key) ?? []
batchMap.set(key, [...existing, s])
}
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="overflow-y-auto h-full 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}
description={tree.description || undefined}
icon={(
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-500/10 text-amber-400">
<Wrench className="h-5 w-5" />
</div>
)}
titleClassName="text-xl font-semibold"
action={(
<div className="flex gap-2">
<button
onClick={() => navigate(`/flows/${id}/edit`)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-[0.875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
>
<Settings className="h-3.5 w-3.5" />
Edit Flow
</button>
<button
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"
>
{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
onClick={() => setShowBatchModal(true)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-[0.875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
>
Batch Launch
</button>
</div>
)}
/>
{/* Schedule Panel */}
<div className="rounded-xl border border-border bg-card p-5">
<div className="flex items-center gap-2 mb-3">
<Calendar className="h-4 w-4 text-muted-foreground" />
<h2 className="font-semibold text-foreground">Schedule</h2>
</div>
{schedule ? (
<div className="space-y-2 text-[0.875rem]">
<div className="flex flex-wrap items-center gap-2">
<span className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-label text-[0.625rem] uppercase tracking-wide",
schedule.is_active
? "bg-emerald-500/10 text-emerald-400"
: "bg-muted text-muted-foreground"
)}>
{schedule.is_active
? <CheckCircle className="h-3 w-3" />
: <AlertCircle className="h-3 w-3" />}
{schedule.is_active ? 'Active' : 'Paused'}
</span>
<code className="rounded bg-accent px-1.5 py-0.5 text-[0.8125rem] text-foreground">
{schedule.cron_expression}
</code>
<span className="text-muted-foreground">({schedule.timezone})</span>
</div>
{schedule.next_run_at && (
<p className="text-muted-foreground">
Next run: {new Date(schedule.next_run_at).toLocaleString()}
</p>
)}
</div>
) : (
<p className="text-[0.875rem] text-muted-foreground">
No schedule configured. Sessions can still be launched manually via Batch Launch.
</p>
)}
</div>
{/* Run History */}
<div className="rounded-xl border border-border bg-card p-5">
<div className="flex items-center gap-2 mb-4">
<Clock className="h-4 w-4 text-muted-foreground" />
<h2 className="font-semibold text-foreground">Run History</h2>
</div>
{batches.length === 0 ? (
<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, 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 (
<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>
<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>
<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>
)}
</div>
{showBatchModal && (
<BatchLaunchModal
treeId={id!}
treeName={tree.name}
onClose={() => setShowBatchModal(false)}
onLaunched={handleLaunched}
/>
)}
</div>
)
}