Files
HartOMat/frontend/src/components/layout/Layout.tsx
T
Hartmut 7a1329958d feat(J): WebSocket live-events + replace polling + fix ffmpeg turntable timeout
- fix(render): ffmpeg overlay=0:0 -> overlay=0:0:shortest=1 to prevent hang on finite PNG sequences
- feat(ws): add core/websocket.py ConnectionManager + Redis Pub/Sub subscriber loop
- feat(ws): add /api/ws WebSocket endpoint with JWT query-param auth in main.py
- feat(ws): emit render_complete/failed + cad_processing_complete events from step_tasks.py
- feat(ws): emit order_status_change events from orders router
- feat(ws): add beat_tasks.py broadcast_queue_status task (every 10s via Redis __broadcast__)
- feat(frontend): add useWebSocket hook with auto-reconnect (exponential backoff, 25s ping)
- feat(frontend): add WebSocketContext + WebSocketProvider wrapping App
- refactor(frontend): remove polling from WorkerActivity (was 5s/3s) + OrderDetail (was 5s)
- refactor(frontend): reduce polling in Layout (8s->60s) + NotificationCenter (15s->60s)
- docs: add ffmpeg shortest=1 + WebSocket JWT auth learnings to LEARNINGS.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 20:49:34 +01:00

234 lines
8.8 KiB
TypeScript

import { Outlet, NavLink, useNavigate, Link } from 'react-router-dom'
import { LayoutDashboard, Package, Settings, LogOut, FlaskConical, Activity, Library, Plus, SlidersHorizontal, Building2, GitBranch, Image, BellRing, Receipt } from 'lucide-react'
import { useAuthStore } from '../../store/auth'
import { clsx } from 'clsx'
import { useQuery } from '@tanstack/react-query'
import { getWorkerActivity } from '../../api/worker'
import { listOrders } from '../../api/orders'
import NotificationCenter from './NotificationCenter'
const nav = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true },
{ to: '/orders', icon: Package, label: 'Orders' },
{ to: '/products', icon: Library, label: 'Products' },
{ to: '/materials', icon: FlaskConical, label: 'Materials' },
{ to: '/activity', icon: Activity, label: 'Activity' },
{ to: '/preferences', icon: SlidersHorizontal, label: 'Preferences' },
]
export default function Layout() {
const { user, logout } = useAuthStore()
const navigate = useNavigate()
const { data: activity } = useQuery({
queryKey: ['worker-activity'],
queryFn: getWorkerActivity,
refetchInterval: 60_000,
staleTime: 30_000,
})
const { data: draftOrders } = useQuery({
queryKey: ['orders', 'draft-count'],
queryFn: () => listOrders({ status: 'draft' }),
staleTime: 10_000,
refetchInterval: 30_000,
})
const draftCount = draftOrders?.length ?? 0
function handleLogout() {
logout()
navigate('/login')
}
return (
<div className="flex h-screen overflow-hidden bg-surface-alt">
{/* Sidebar */}
<aside className="w-60 flex-shrink-0 bg-surface border-r border-border-default flex flex-col">
<div className="p-5 border-b border-border-default">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-accent rounded flex items-center justify-center">
<span className="text-accent-text text-sm font-bold">S</span>
</div>
<div className="flex-1">
<p className="font-semibold text-content text-sm">Schaeffler</p>
<p className="text-xs text-content-muted">Automat</p>
</div>
<NotificationCenter />
</div>
</div>
<nav className="flex-1 p-3 space-y-1">
{/* New Order — primary CTA at the top */}
<Link
to="/orders/new"
className="flex items-center gap-2 px-3 py-2.5 mb-3 rounded-md text-sm font-semibold bg-accent text-accent-text hover:bg-accent-hover transition-colors shadow-sm"
>
<Plus size={18} />
New Order
</Link>
{nav.map(({ to, icon: Icon, label, end }) => {
const isActivity = to === '/activity'
const isOrders = to === '/orders'
const showSpinner = isActivity && ((activity?.active_count ?? 0) + (activity?.render_active_count ?? 0)) > 0
const showFailed = isActivity && !showSpinner && ((activity?.failed_count ?? 0) + (activity?.render_failed_count ?? 0)) > 0
const showDraftBadge = isOrders && draftCount > 0
return (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<Icon size={18} />
{label}
{showDraftBadge && (
<span className="ml-auto text-xs px-1.5 py-0.5 rounded-full bg-surface-muted text-content-muted font-semibold leading-none">
{draftCount}
</span>
)}
{showSpinner && (
<span className="ml-auto w-2 h-2 rounded-full bg-blue-500 animate-pulse" title="Processing…" />
)}
{showFailed && (
<span className="ml-auto w-2 h-2 rounded-full bg-red-500" title="Failed tasks" />
)}
</NavLink>
)
})}
{(user?.role === 'admin' || user?.role === 'project_manager') && (
<NavLink
to="/admin"
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<Settings size={18} />
Admin
</NavLink>
)}
{(user?.role === 'admin' || user?.role === 'project_manager') && (
<NavLink
to="/billing"
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<Receipt size={18} />
Billing
</NavLink>
)}
{(user?.role === 'admin' || user?.role === 'project_manager') && (
<NavLink
to="/media"
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<Image size={18} />
Media Browser
</NavLink>
)}
{(user?.role === 'admin' || user?.role === 'project_manager') && (
<NavLink
to="/workflows"
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<GitBranch size={18} />
Workflows
</NavLink>
)}
{user?.role === 'admin' && (
<NavLink
to="/notification-settings"
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<BellRing size={18} />
Notification Settings
</NavLink>
)}
{user?.role === 'admin' && (
<NavLink
to="/tenants"
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<Building2 size={18} />
Tenants
</NavLink>
)}
</nav>
<div className="p-3 border-t border-border-default space-y-1">
<div className="flex items-center gap-3 px-3 py-2">
<div className="w-7 h-7 bg-accent rounded-full flex items-center justify-center shrink-0">
<span className="text-accent-text text-xs font-bold">{user?.full_name?.[0] ?? 'U'}</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-content truncate">{user?.full_name}</p>
<p className="text-xs text-content-muted truncate">
{user?.role === 'project_manager' ? 'Project Manager' : user?.role}
</p>
</div>
</div>
<button
onClick={handleLogout}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-content-secondary hover:bg-surface-hover rounded-md transition-colors"
>
<LogOut size={16} />
Sign out
</button>
</div>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
)
}