feat: add sidebar API client, stats bar, activity feed components
New components: SidebarStatsBar, SidebarActivityFeed, ActivityItem. New API client for sidebar stats endpoint. Pulse-dot CSS animation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,3 +23,4 @@ export { flowTransferApi } from './flowTransfer'
|
||||
export { kbAcceleratorApi } from './kbAccelerator'
|
||||
export { scriptsApi } from './scripts'
|
||||
export { integrationsApi, sessionPsaApi } from './integrations'
|
||||
export { sidebarApi } from './sidebar'
|
||||
|
||||
45
frontend/src/api/sidebar.ts
Normal file
45
frontend/src/api/sidebar.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface SidebarActiveSession {
|
||||
session_id: string
|
||||
tree_name: string
|
||||
tree_id: string
|
||||
tree_type: 'troubleshooting' | 'procedural' | 'maintenance'
|
||||
started_at: string
|
||||
ticket_number: string | null
|
||||
psa_ticket_id: string | null
|
||||
}
|
||||
|
||||
export interface SidebarRecentSession {
|
||||
session_id: string
|
||||
tree_name: string
|
||||
tree_id: string
|
||||
tree_type: 'troubleshooting' | 'procedural' | 'maintenance'
|
||||
completed_at: string
|
||||
}
|
||||
|
||||
export interface SidebarTreeCounts {
|
||||
total: number
|
||||
troubleshooting: number
|
||||
procedural: number
|
||||
maintenance: number
|
||||
}
|
||||
|
||||
export interface SidebarStatsResponse {
|
||||
resolved_today: number
|
||||
active_count: number
|
||||
total_session_minutes_today: number
|
||||
tree_counts: SidebarTreeCounts
|
||||
active_sessions: SidebarActiveSession[]
|
||||
recent_completions: SidebarRecentSession[]
|
||||
}
|
||||
|
||||
export const sidebarApi = {
|
||||
getStats: async (): Promise<SidebarStatsResponse> => {
|
||||
const tzOffset = new Date().getTimezoneOffset()
|
||||
const response = await apiClient.get<SidebarStatsResponse>(
|
||||
`/sessions/sidebar-stats?tz_offset=${tzOffset}`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
105
frontend/src/components/sidebar/ActivityItem.tsx
Normal file
105
frontend/src/components/sidebar/ActivityItem.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getTreeNavigatePath } from '@/lib/routing'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ActivityItemProps {
|
||||
sessionId: string
|
||||
treeName: string
|
||||
treeId: string
|
||||
treeType: 'troubleshooting' | 'procedural' | 'maintenance'
|
||||
status: 'active' | 'paused' | 'recent'
|
||||
ticketNumber?: string | null
|
||||
timestamp?: string | null
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateString: string): string {
|
||||
const now = Date.now()
|
||||
const then = new Date(dateString).getTime()
|
||||
const diffMinutes = Math.floor((now - then) / 60000)
|
||||
|
||||
if (diffMinutes < 1) return 'just now'
|
||||
if (diffMinutes < 60) return `${diffMinutes}m ago`
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
return 'yesterday'
|
||||
}
|
||||
|
||||
export function ActivityItem({
|
||||
sessionId,
|
||||
treeName,
|
||||
treeId,
|
||||
treeType,
|
||||
status,
|
||||
ticketNumber,
|
||||
timestamp,
|
||||
}: ActivityItemProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleClick = () => {
|
||||
navigate(getTreeNavigatePath(treeId, treeType), {
|
||||
state: { sessionId },
|
||||
})
|
||||
}
|
||||
|
||||
const isRecent = status === 'recent'
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-colors',
|
||||
'hover:bg-[rgba(255,255,255,0.03)]',
|
||||
isRecent ? 'text-[#6b7280] text-[0.72rem]' : 'text-[#e2e8f0] text-[0.8rem]'
|
||||
)}
|
||||
title={`${treeName}${ticketNumber ? ` (${ticketNumber})` : ''} — click to resume`}
|
||||
aria-label={
|
||||
status === 'active'
|
||||
? `Active session: ${treeName}`
|
||||
: status === 'paused'
|
||||
? `Paused session: ${treeName}`
|
||||
: `Recent session: ${treeName}`
|
||||
}
|
||||
>
|
||||
{/* Status dot */}
|
||||
{status === 'active' && (
|
||||
<span
|
||||
className="h-[7px] w-[7px] shrink-0 rounded-full"
|
||||
style={{
|
||||
background: '#34d399',
|
||||
boxShadow: '0 0 6px rgba(52,211,153,0.5)',
|
||||
animation: 'pulse-dot 2s ease-in-out infinite',
|
||||
}}
|
||||
aria-label="Active session"
|
||||
/>
|
||||
)}
|
||||
{status === 'paused' && (
|
||||
<span
|
||||
className="h-[7px] w-[7px] shrink-0 rounded-full"
|
||||
style={{
|
||||
background: '#f59e0b',
|
||||
boxShadow: '0 0 4px rgba(245,158,11,0.3)',
|
||||
}}
|
||||
aria-label="Paused session"
|
||||
/>
|
||||
)}
|
||||
{status === 'recent' && (
|
||||
<span className="h-1 w-1 shrink-0 rounded-full bg-[#3d4350]" />
|
||||
)}
|
||||
|
||||
{/* Flow name */}
|
||||
<span className="flex-1 truncate">{treeName}</span>
|
||||
|
||||
{/* Ticket number or timestamp */}
|
||||
{ticketNumber && !isRecent && (
|
||||
<span className="shrink-0 font-label text-[0.5625rem] text-[#60a5fa]">
|
||||
{ticketNumber}
|
||||
</span>
|
||||
)}
|
||||
{isRecent && timestamp && (
|
||||
<span className="shrink-0 font-label text-[0.5625rem] text-[#5a6170]">
|
||||
{formatRelativeTime(timestamp)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
80
frontend/src/components/sidebar/SidebarActivityFeed.tsx
Normal file
80
frontend/src/components/sidebar/SidebarActivityFeed.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Clock } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ActivityItem } from './ActivityItem'
|
||||
import type { SidebarActiveSession, SidebarRecentSession } from '@/api/sidebar'
|
||||
|
||||
interface SidebarActivityFeedProps {
|
||||
activeSessions: SidebarActiveSession[]
|
||||
recentCompletions: SidebarRecentSession[]
|
||||
totalActive: number
|
||||
}
|
||||
|
||||
export function SidebarActivityFeed({
|
||||
activeSessions,
|
||||
recentCompletions,
|
||||
totalActive,
|
||||
}: SidebarActivityFeedProps) {
|
||||
const navigate = useNavigate()
|
||||
const hasActivity = activeSessions.length > 0 || recentCompletions.length > 0
|
||||
|
||||
return (
|
||||
<div className="px-3 pb-1">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1 mb-0.5">
|
||||
<Clock size={10} style={{ color: '#34d399' }} />
|
||||
<span className="font-label text-[0.5625rem] uppercase tracking-[0.12em] text-[#5a6170]">
|
||||
Activity
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!hasActivity ? (
|
||||
<p className="px-2.5 py-2 text-xs text-muted-foreground">
|
||||
No activity today
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{/* Active sessions */}
|
||||
{activeSessions.map((session) => (
|
||||
<ActivityItem
|
||||
key={session.session_id}
|
||||
sessionId={session.session_id}
|
||||
treeName={session.tree_name}
|
||||
treeId={session.tree_id}
|
||||
treeType={session.tree_type}
|
||||
status="active"
|
||||
ticketNumber={session.ticket_number || session.psa_ticket_id}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Overflow link */}
|
||||
{totalActive > 5 && (
|
||||
<button
|
||||
onClick={() => navigate('/sessions')}
|
||||
className="w-full px-2.5 py-1 text-left text-[0.6875rem] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
View all in Sessions →
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Divider between active and recent */}
|
||||
{activeSessions.length > 0 && recentCompletions.length > 0 && (
|
||||
<div className="mx-2.5 my-1" style={{ height: '1px', background: 'rgba(255,255,255,0.03)' }} />
|
||||
)}
|
||||
|
||||
{/* Recent completions */}
|
||||
{recentCompletions.map((session) => (
|
||||
<ActivityItem
|
||||
key={session.session_id}
|
||||
sessionId={session.session_id}
|
||||
treeName={session.tree_name}
|
||||
treeId={session.tree_id}
|
||||
treeType={session.tree_type}
|
||||
status="recent"
|
||||
timestamp={session.completed_at}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
frontend/src/components/sidebar/SidebarStatsBar.tsx
Normal file
58
frontend/src/components/sidebar/SidebarStatsBar.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
interface SidebarStatsBarProps {
|
||||
resolved: number
|
||||
active: number
|
||||
sessionMinutes: number
|
||||
}
|
||||
|
||||
function formatDuration(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes}m`
|
||||
const h = Math.floor(minutes / 60)
|
||||
const m = minutes % 60
|
||||
return m > 0 ? `${h}h ${m}m` : `${h}h`
|
||||
}
|
||||
|
||||
export function SidebarStatsBar({ resolved, active, sessionMinutes }: SidebarStatsBarProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex gap-0.5 px-3 pt-2 pb-1"
|
||||
role="group"
|
||||
aria-label="Today's stats"
|
||||
>
|
||||
<div className="flex-1 rounded-md bg-[rgba(255,255,255,0.02)] px-1 py-1.5 text-center">
|
||||
<div
|
||||
className="font-label text-sm font-semibold leading-none"
|
||||
style={{ color: '#34d399' }}
|
||||
aria-label={`${resolved} resolved today`}
|
||||
>
|
||||
{resolved}
|
||||
</div>
|
||||
<div className="mt-1 font-label text-[7px] uppercase tracking-[0.1em] text-[#3d4350]">
|
||||
Resolved
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 rounded-md bg-[rgba(255,255,255,0.02)] px-1 py-1.5 text-center">
|
||||
<div
|
||||
className="font-label text-sm font-semibold leading-none"
|
||||
style={{ color: '#22d3ee' }}
|
||||
aria-label={`${active} active sessions`}
|
||||
>
|
||||
{active}
|
||||
</div>
|
||||
<div className="mt-1 font-label text-[7px] uppercase tracking-[0.1em] text-[#3d4350]">
|
||||
Active
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 rounded-md bg-[rgba(255,255,255,0.02)] px-1 py-1.5 text-center">
|
||||
<div
|
||||
className="font-label text-sm font-semibold leading-none text-muted-foreground"
|
||||
aria-label={`${formatDuration(sessionMinutes)} in session today`}
|
||||
>
|
||||
{formatDuration(sessionMinutes)}
|
||||
</div>
|
||||
<div className="mt-1 font-label text-[7px] uppercase tracking-[0.1em] text-[#3d4350]">
|
||||
In Session
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -153,6 +153,11 @@
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { box-shadow: 0 0 4px rgba(52,211,153,0.4); }
|
||||
50% { box-shadow: 0 0 8px rgba(52,211,153,0.7); }
|
||||
}
|
||||
|
||||
@keyframes stagger-fade-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
|
||||
Reference in New Issue
Block a user