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 { kbAcceleratorApi } from './kbAccelerator'
|
||||||
export { scriptsApi } from './scripts'
|
export { scriptsApi } from './scripts'
|
||||||
export { integrationsApi, sessionPsaApi } from './integrations'
|
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); }
|
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 {
|
@keyframes stagger-fade-in {
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
|||||||
Reference in New Issue
Block a user