* 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>
322 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|