feat: initial commit

This commit is contained in:
2026-03-05 22:12:38 +01:00
commit bce762a783
380 changed files with 51955 additions and 0 deletions
+153
View File
@@ -0,0 +1,153 @@
import { Outlet, NavLink, useNavigate, Link } from 'react-router-dom'
import { LayoutDashboard, Package, Settings, LogOut, FlaskConical, Activity, Library, Plus, SlidersHorizontal } 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: 8000,
staleTime: 4000,
})
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>
)}
</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>
)
}
@@ -0,0 +1,235 @@
import { useState, useRef, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Bell, Send, PlayCircle, CheckCircle, XCircle, Image, AlertTriangle, X,
} from 'lucide-react'
import { clsx } from 'clsx'
import {
getNotifications, getUnreadCount, markAsRead, markOneAsRead,
type Notification,
} from '../../api/notifications'
const ACTION_CONFIG: Record<string, { icon: typeof Bell; label: (d: Record<string, unknown> | null) => string; color: string }> = {
'order.submitted': {
icon: Send,
label: (d) => `Order ${d?.order_number ?? '?'} submitted`,
color: 'text-blue-500',
},
'order.processing': {
icon: PlayCircle,
label: (d) => `Order ${d?.order_number ?? '?'} is processing`,
color: 'text-yellow-500',
},
'order.completed': {
icon: CheckCircle,
label: (d) => `Order ${d?.order_number ?? '?'} completed`,
color: 'text-status-success-text',
},
'order.rejected': {
icon: XCircle,
label: (d) => `Order ${d?.order_number ?? '?'} was rejected`,
color: 'text-red-500',
},
'render.completed': {
icon: Image,
label: (d) => `Render done: ${d?.product_name ?? 'unknown'}${d?.output_type ?? ''}`,
color: 'text-status-success-text',
},
'render.failed': {
icon: AlertTriangle,
label: (d) => `Render failed: ${d?.product_name ?? 'unknown'}${d?.output_type ?? ''}`,
color: 'text-red-500',
},
'excel.import_warnings': {
icon: AlertTriangle,
label: (d) => `Excel '${d?.filename ?? '?'}' had ${d?.warning_count ?? '?'} warning(s)`,
color: 'text-amber-500',
},
'excel.import_error': {
icon: XCircle,
label: (d) => `Excel parse failed: ${d?.filename ?? '?'}`,
color: 'text-red-500',
},
'excel.finalize_error': {
icon: XCircle,
label: (d) => `Order creation failed: ${d?.filename ?? '?'}`,
color: 'text-red-500',
},
}
function relativeTime(ts: string): string {
const diff = Date.now() - new Date(ts).getTime()
const seconds = Math.floor(diff / 1000)
if (seconds < 60) return 'just now'
const minutes = Math.floor(seconds / 60)
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)
return `${days}d ago`
}
export default function NotificationCenter() {
const [open, setOpen] = useState(false)
const bellRef = useRef<HTMLButtonElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const navigate = useNavigate()
const qc = useQueryClient()
const { data: unreadCount = 0 } = useQuery({
queryKey: ['notifications', 'unread-count'],
queryFn: getUnreadCount,
refetchInterval: 15_000,
staleTime: 5_000,
})
const { data } = useQuery({
queryKey: ['notifications', 'list'],
queryFn: () => getNotifications({ limit: 20 }),
enabled: open,
staleTime: 5_000,
})
const markAllMutation = useMutation({
mutationFn: () => markAsRead(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notifications'] })
},
})
const markOneMutation = useMutation({
mutationFn: (id: string) => markOneAsRead(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notifications'] })
},
})
// Click-outside to close
useEffect(() => {
if (!open) return
function handleClick(e: MouseEvent) {
if (
dropdownRef.current && !dropdownRef.current.contains(e.target as Node) &&
bellRef.current && !bellRef.current.contains(e.target as Node)
) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
function handleNotificationClick(n: Notification) {
if (!n.read_at) markOneMutation.mutate(n.id)
if (n.entity_type === 'order' && n.entity_id) {
navigate(`/orders/${n.entity_id}`)
}
setOpen(false)
}
// Position dropdown relative to bell button
const bellRect = bellRef.current?.getBoundingClientRect()
return (
<>
<button
ref={bellRef}
onClick={() => setOpen((v) => !v)}
className="relative p-1.5 rounded-md hover:bg-surface-hover transition-colors"
title="Notifications"
>
<Bell size={18} className="text-content-secondary" />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 flex items-center justify-center rounded-full bg-red-500 text-white text-[10px] font-bold leading-none">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{open && bellRect && createPortal(
<div
ref={dropdownRef}
className="fixed z-[9999] w-80 max-h-[28rem] rounded-lg shadow-xl border flex flex-col"
style={{
top: bellRect.bottom + 6,
left: Math.max(8, bellRect.left - 240),
backgroundColor: 'var(--color-bg-surface)',
borderColor: 'var(--color-border)',
}}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border-light">
<span className="text-sm font-semibold text-content">Notifications</span>
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={() => markAllMutation.mutate()}
className="text-xs text-accent hover:underline"
>
Mark all as read
</button>
)}
<button onClick={() => setOpen(false)} className="p-0.5 hover:bg-surface-hover rounded" title="Close notifications">
<X size={14} className="text-content-muted" />
</button>
</div>
</div>
{/* List */}
<div className="flex-1 overflow-y-auto">
{!data?.items.length && (
<div className="py-8 text-center text-sm text-content-muted">No notifications</div>
)}
{data?.items.map((n) => {
const cfg = ACTION_CONFIG[n.action] ?? {
icon: Bell,
label: () => n.action,
color: 'text-content-secondary',
}
const Icon = cfg.icon
return (
<button
key={n.id}
onClick={() => handleNotificationClick(n)}
className={clsx(
'w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-surface-hover transition-colors border-b border-border-light',
!n.read_at && 'bg-status-info-bg',
)}
>
<Icon size={16} className={clsx('mt-0.5 shrink-0', cfg.color)} />
<div className="flex-1 min-w-0">
<p className={clsx('text-sm', !n.read_at ? 'font-medium text-content' : 'text-content-secondary')}>
{cfg.label(n.details)}
</p>
{n.details?.error && (
<p className="mt-1 text-xs text-red-600 font-mono bg-red-50 rounded px-1.5 py-0.5 truncate">
{String(n.details.error)}
</p>
)}
<p className="text-xs text-content-muted mt-0.5">{relativeTime(n.timestamp)}</p>
</div>
{!n.read_at && (
<span className="mt-1.5 w-2 h-2 rounded-full bg-blue-500 shrink-0" />
)}
</button>
)
})}
</div>
{/* Footer */}
<div className="px-4 py-2 border-t border-border-light text-center">
<button
onClick={() => { navigate('/notifications'); setOpen(false) }}
className="text-xs text-accent hover:underline"
>
View all notifications
</button>
</div>
</div>,
document.body,
)}
</>
)
}
@@ -0,0 +1,63 @@
import { Sun, Monitor, Moon } from 'lucide-react'
import { clsx } from 'clsx'
import { useThemeStore, ACCENT_PRESETS, type ThemeMode } from '../../store/theme'
const MODES: { key: ThemeMode; icon: typeof Sun; label: string }[] = [
{ key: 'light', icon: Sun, label: 'Light' },
{ key: 'system', icon: Monitor, label: 'System' },
{ key: 'dark', icon: Moon, label: 'Dark' },
]
export default function ThemePreferences() {
const { mode, accent, setMode, setAccent } = useThemeStore()
return (
<div className="px-3 py-2 space-y-2">
{/* Mode row */}
<div className="flex items-center gap-2">
<span className="text-xs text-content-muted w-12 shrink-0">Theme</span>
<div className="flex gap-0.5 bg-surface-alt rounded-md p-0.5 border border-border-light">
{MODES.map(({ key, icon: Icon, label }) => (
<button
key={key}
onClick={() => setMode(key)}
title={label}
className={clsx(
'flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors',
mode === key
? 'bg-surface text-content shadow-sm'
: 'text-content-muted hover:text-content-secondary',
)}
>
<Icon size={12} />
{label}
</button>
))}
</div>
</div>
{/* Accent row */}
<div className="flex items-center gap-2">
<span className="text-xs text-content-muted w-12 shrink-0">Accent</span>
<div className="flex gap-2">
{ACCENT_PRESETS.map(({ key, label, hex }) => (
<button
key={key}
onClick={() => setAccent(key)}
title={label}
className={clsx(
'w-5 h-5 rounded-full transition-all',
accent === key ? 'scale-125' : 'hover:scale-110',
)}
style={{
backgroundColor: hex,
outline: accent === key ? `2px solid ${hex}` : undefined,
outlineOffset: accent === key ? '2px' : undefined,
}}
/>
))}
</div>
</div>
</div>
)
}