refactor: dashboard design critique — eliminate redundancy, differentiate sections
- Remove GreetingStatStrip (duplicated PerformanceCards data) - Strip left-border accent from stat cards (AI slop pattern) - Redesign KnowledgeBaseCards: icon grid → compact row list with icon badges - Redesign TeamSummary: distinct inline-row layout, no longer identical twin - Differentiate hover: stat cards use subtle border-hover, sessions keep springy lift - Add loading skeletons to PerformanceCards, KnowledgeBaseCards, TeamSummary - Add error state to PerformanceCards - Extract timeAgo() to shared lib/timeAgo.ts (replaced 4 duplicates) - Fix Skeleton bg-brand-border (undefined CSS var) → border-default - Fix double text-xs text-[0.5625rem] class conflicts across dashboard Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,7 +34,7 @@
|
||||
|
||||
**Theme:** Dark mode primary (charcoal palette). Light mode planned but not yet implemented.
|
||||
|
||||
**Accent:** Ember orange (#f97316) — conveys urgency fitting a troubleshooting context. Used sparingly (max 5% of UI). Warning uses yellow (#eab308), not amber, to stay distinct.
|
||||
**Accent:** Electric blue (#60a5fa dark / #2563eb light) — conveys trust, precision, and reliability fitting a troubleshooting tool MSP engineers depend on during outages. Used sparingly (max 5% of UI). Warning uses amber (#fbbf24), info uses cyan (#67e8f9).
|
||||
|
||||
**Hard rules:** No glassmorphism, no gradient surfaces, no ambient orbs, no backdrop blur, no decorative shadows at rest. Elevation = lighter surface + border, not shadow.
|
||||
|
||||
|
||||
@@ -4,16 +4,7 @@ import { Clock, ArrowRight, Route, MessageCircle } from 'lucide-react'
|
||||
import { aiSessionsApi } from '@/api/aiSessions'
|
||||
import type { AISessionSummary } from '@/types/ai-session'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diffMs = Date.now() - new Date(dateStr).getTime()
|
||||
const minutes = Math.floor(diffMs / 60000)
|
||||
if (minutes < 1) return 'just now'
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
return `${Math.floor(hours / 24)}d ago`
|
||||
}
|
||||
import { timeAgo } from '@/lib/timeAgo'
|
||||
|
||||
export function ActiveFlowPilotSessions({ hideHeader = false }: { hideHeader?: boolean }) {
|
||||
const [sessions, setSessions] = useState<AISessionSummary[]>([])
|
||||
@@ -68,7 +59,7 @@ export function ActiveFlowPilotSessions({ hideHeader = false }: { hideHeader?: b
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'font-sans text-xs text-[0.5625rem] uppercase px-1.5 py-0.5 rounded',
|
||||
'font-sans text-[0.5625rem] uppercase px-1.5 py-0.5 rounded',
|
||||
session.confidence_tier === 'guided' && 'bg-emerald-400/10 text-emerald-400',
|
||||
session.confidence_tier === 'exploring' && 'bg-amber-400/10 text-amber-400',
|
||||
session.confidence_tier === 'discovery' && 'bg-blue-400/10 text-blue-400',
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Network, Code2, ListChecks, ArrowRight } from 'lucide-react'
|
||||
import { Network, Code2, ListChecks, ArrowRight, ChevronRight } from 'lucide-react'
|
||||
import { sidebarApi } from '@/api'
|
||||
import { Skeleton } from '@/components/ui/Skeleton'
|
||||
|
||||
export function KnowledgeBaseCards() {
|
||||
const navigate = useNavigate()
|
||||
const [flowCount, setFlowCount] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
sidebarApi.getStats()
|
||||
.then((stats) => setFlowCount(stats.tree_counts.total))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const items = [
|
||||
@@ -33,23 +36,35 @@ export function KnowledgeBaseCards() {
|
||||
Browse <ArrowRight size={10} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 divide-x" style={{ borderColor: 'var(--color-border-default)' }}>
|
||||
{items.map((item) => (
|
||||
<div className="py-1">
|
||||
{loading ? Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-5 py-3" style={{ borderBottom: i < 2 ? '1px solid var(--color-border-default)' : undefined }}>
|
||||
<Skeleton className="h-8 w-8 rounded-md shrink-0" />
|
||||
<Skeleton className="h-4 flex-1 max-w-24" />
|
||||
<Skeleton className="h-5 w-8" />
|
||||
</div>
|
||||
)) : items.map((item, i) => (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={() => navigate(item.href)}
|
||||
className="flex flex-col items-center gap-2 py-5 rounded-lg hover:bg-card-hover transition-all duration-350"
|
||||
style={{ transition: 'transform 350ms cubic-bezier(0.34, 1.56, 0.64, 1), background 200ms ease' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-4px)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)' }}
|
||||
className="flex w-full items-center gap-3 px-5 py-3 text-left hover:bg-[var(--color-bg-card-hover)] transition-colors group"
|
||||
style={{
|
||||
borderBottom: i < items.length - 1 ? '1px solid var(--color-border-default)' : undefined,
|
||||
}}
|
||||
>
|
||||
<item.icon size={20} style={{ color: item.color }} />
|
||||
<p className="font-heading text-xl font-extrabold text-foreground">{item.value}</p>
|
||||
<p className="font-sans text-xs text-[0.5625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
{item.label}
|
||||
</p>
|
||||
<span
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md"
|
||||
style={{ backgroundColor: `${item.color}15` }}
|
||||
>
|
||||
<item.icon size={15} style={{ color: item.color }} />
|
||||
</span>
|
||||
<span className="flex-1 text-sm font-medium text-foreground">{item.label}</span>
|
||||
<span className="font-heading text-base font-bold text-foreground tabular-nums mr-1">
|
||||
{item.value}
|
||||
</span>
|
||||
<ChevronRight size={14} className="text-muted-foreground opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all" />
|
||||
</button>
|
||||
))}
|
||||
)))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,16 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { aiSessionsApi } from '@/api/aiSessions'
|
||||
import type { AISessionSummary } from '@/types/ai-session'
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diffMs = Date.now() - new Date(dateStr).getTime()
|
||||
const minutes = Math.floor(diffMs / 60000)
|
||||
if (minutes < 1) return 'just now'
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
return `${Math.floor(hours / 24)}d ago`
|
||||
}
|
||||
import { timeAgo } from '@/lib/timeAgo'
|
||||
|
||||
export function PendingEscalations() {
|
||||
const [escalations, setEscalations] = useState<AISessionSummary[]>([])
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CheckCircle, Clock, TrendingUp, Timer } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { sidebarApi } from '@/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Skeleton } from '@/components/ui/Skeleton'
|
||||
|
||||
interface StatCard {
|
||||
label: string
|
||||
@@ -19,6 +20,8 @@ export function PerformanceCards() {
|
||||
const [resolved, setResolved] = useState(0)
|
||||
const [active, setActive] = useState(0)
|
||||
const [totalMinutes, setTotalMinutes] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
sidebarApi.getStats()
|
||||
@@ -27,9 +30,31 @@ export function PerformanceCards() {
|
||||
setActive(stats.active_count)
|
||||
setTotalMinutes(stats.total_session_minutes_today)
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(() => setError(true))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="card-flat p-4 space-y-2">
|
||||
<Skeleton className="h-3 w-2/3" />
|
||||
<Skeleton className="h-7 w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card-flat p-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">Unable to load performance data</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const avgMttr = resolved > 0 ? Math.round(totalMinutes / resolved) : 0
|
||||
|
||||
const cards: StatCard[] = [
|
||||
@@ -70,14 +95,13 @@ export function PerformanceCards() {
|
||||
<button
|
||||
key={card.label}
|
||||
onClick={() => navigate(card.href)}
|
||||
className="card-interactive p-4 text-left fade-in"
|
||||
className="card-flat p-4 text-left fade-in hover:border-[var(--color-border-hover)] transition-colors cursor-pointer"
|
||||
style={{
|
||||
animationDelay: `${400 + i * 60}ms`,
|
||||
borderLeft: `3px solid ${card.iconColor}`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="font-sans text-xs text-[0.5625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
<p className="font-sans text-[0.5625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
{card.label}
|
||||
</p>
|
||||
<card.icon size={14} style={{ color: card.iconColor }} />
|
||||
|
||||
@@ -3,18 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'
|
||||
import { CheckCircle, AlertTriangle, XCircle, ArrowRight, MessageCircle } from 'lucide-react'
|
||||
import { aiSessionsApi } from '@/api/aiSessions'
|
||||
import type { AISessionSummary } from '@/types/ai-session'
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diffMs = Date.now() - new Date(dateStr).getTime()
|
||||
const minutes = Math.floor(diffMs / 60000)
|
||||
if (minutes < 1) return 'just now'
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days === 1) return 'yesterday'
|
||||
return `${days}d ago`
|
||||
}
|
||||
import { timeAgo } from '@/lib/timeAgo'
|
||||
|
||||
const STATUS_CONFIG: Record<string, { icon: typeof CheckCircle; color: string }> = {
|
||||
resolved: { icon: CheckCircle, color: '#34d399' },
|
||||
|
||||
@@ -3,17 +3,20 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { Users, AlertTriangle, Activity, ArrowRight } from 'lucide-react'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { aiSessionsApi } from '@/api/aiSessions'
|
||||
import { Skeleton } from '@/components/ui/Skeleton'
|
||||
|
||||
export function TeamSummary() {
|
||||
const { isAccountOwner } = usePermissions()
|
||||
const navigate = useNavigate()
|
||||
const [escalationCount, setEscalationCount] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAccountOwner) return
|
||||
if (!isAccountOwner) { setLoading(false); return }
|
||||
aiSessionsApi.getEscalationQueue()
|
||||
.then((esc) => setEscalationCount(esc.length))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [isAccountOwner])
|
||||
|
||||
if (!isAccountOwner) return null
|
||||
@@ -38,23 +41,28 @@ export function TeamSummary() {
|
||||
Manage <ArrowRight size={10} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 divide-x" style={{ borderColor: 'var(--color-border-default)' }}>
|
||||
{items.map((item) => (
|
||||
<div className="p-4 space-y-3">
|
||||
{loading ? Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-3 py-2.5">
|
||||
<Skeleton className="h-4 w-4 rounded shrink-0" />
|
||||
<Skeleton className="h-4 flex-1 max-w-28" />
|
||||
<Skeleton className="h-5 w-8" />
|
||||
</div>
|
||||
)) : items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={() => navigate(item.href)}
|
||||
className="flex flex-col items-center gap-2 py-5 rounded-lg hover:bg-card-hover transition-all duration-350"
|
||||
style={{ transition: 'transform 350ms cubic-bezier(0.34, 1.56, 0.64, 1), background 200ms ease' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-4px)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)' }}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 hover:bg-[var(--color-bg-card-hover)] transition-colors group"
|
||||
>
|
||||
<item.icon size={20} style={{ color: item.color }} />
|
||||
<p className="font-heading text-xl font-extrabold text-foreground">{item.value}</p>
|
||||
<p className="font-sans text-xs text-[0.5625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
<item.icon size={15} style={{ color: item.color }} className="shrink-0" />
|
||||
<span className="flex-1 text-left text-[0.8125rem] text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
{item.label}
|
||||
</p>
|
||||
</span>
|
||||
<span className="font-heading text-lg font-bold text-foreground tabular-nums">
|
||||
{item.value}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
)))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -10,14 +10,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { notificationsApi } from '@/api/notifications'
|
||||
import type { AppNotification } from '@/types/notification'
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000)
|
||||
if (diff < 60) return 'just now'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
return `${Math.floor(diff / 86400)}d ago`
|
||||
}
|
||||
import { timeAgo } from '@/lib/timeAgo'
|
||||
|
||||
function EventIcon({ event }: { event: string }) {
|
||||
switch (event) {
|
||||
|
||||
@@ -6,7 +6,7 @@ export function Skeleton({ className, ...props }: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-pulse rounded-lg bg-brand-border',
|
||||
'animate-pulse rounded-lg bg-[var(--color-border-default)]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
14
frontend/src/lib/timeAgo.ts
Normal file
14
frontend/src/lib/timeAgo.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Formats a date string as a relative time (e.g., "5m ago", "2h ago", "yesterday").
|
||||
*/
|
||||
export function timeAgo(dateStr: string): string {
|
||||
const diffMs = Date.now() - new Date(dateStr).getTime()
|
||||
const minutes = Math.floor(diffMs / 60000)
|
||||
if (minutes < 1) return 'just now'
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days === 1) return 'yesterday'
|
||||
return `${days}d ago`
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { ActiveFlowPilotSessions } from '@/components/dashboard/ActiveFlowPilotS
|
||||
import { PerformanceCards } from '@/components/dashboard/PerformanceCards'
|
||||
import { KnowledgeBaseCards } from '@/components/dashboard/KnowledgeBaseCards'
|
||||
import { TeamSummary } from '@/components/dashboard/TeamSummary'
|
||||
import { GreetingStatStrip } from '@/components/dashboard/GreetingStatStrip'
|
||||
|
||||
function SectionLabel({ children, action }: { children: React.ReactNode; action?: React.ReactNode }) {
|
||||
return (
|
||||
@@ -37,18 +36,14 @@ export function QuickStartPage() {
|
||||
<div className="overflow-y-auto h-full">
|
||||
<PageMeta title="ResolutionFlow" />
|
||||
<div className="max-w-4xl mx-auto px-6 pt-12 pb-12">
|
||||
{/* Hero: Greeting + Stat Strip */}
|
||||
<div className="flex items-end justify-between mb-8 animate-fade-in-up">
|
||||
<div>
|
||||
<p className="font-sans text-xs uppercase tracking-[0.12em] text-muted-foreground mb-1">
|
||||
{dayOfWeek}, {formattedDate}
|
||||
</p>
|
||||
<h1 className="font-heading text-3xl sm:text-4xl font-extrabold tracking-tight text-[#f0f2f5] leading-tight">
|
||||
Good {greeting},<br className="hidden sm:block" />
|
||||
{firstName}.
|
||||
</h1>
|
||||
</div>
|
||||
<GreetingStatStrip />
|
||||
{/* Hero: Greeting */}
|
||||
<div className="mb-8 animate-fade-in-up">
|
||||
<p className="font-sans text-xs uppercase tracking-[0.12em] text-muted-foreground mb-1">
|
||||
{dayOfWeek}, {formattedDate}
|
||||
</p>
|
||||
<h1 className="font-heading text-3xl sm:text-4xl font-extrabold tracking-tight text-[#f0f2f5] leading-tight">
|
||||
Good {greeting}, {firstName}.
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Chat-style input */}
|
||||
|
||||
Reference in New Issue
Block a user