/* Sidebar + Topbar + small shared components */ const Sidebar = ({ lang, tenant, onPickTenant, route, onNavigate, alarmsCount, me, effectiveRole, onOpenTweaks }) => { const t = I18N[lang]; // Bestem den ekte rollen til brukeren — superadmin > tenant_admin > tenant_worker const isSuperadmin = !!me?.is_superadmin; const tenantRoles = new Set((me?.tenants || []).map(t => t.role)); const isTenantAdmin = !isSuperadmin && tenantRoles.has('tenant_admin'); // Bygg nav-listen basert på rolle. Workers ser kun det de trenger. const allItems = [ { k: 'platform', label: lang === 'no' ? 'Plattform' : 'Platform', icon: 'dashboard', roles: ['superadmin'] }, { k: 'overview', label: t.nav_overview, icon: 'dashboard', roles: ['superadmin', 'tenant_admin'] }, { k: 'operator', label: lang === 'no' ? 'Daglig drift' : 'Daily ops', icon: 'list', roles: ['superadmin', 'tenant_admin', 'worker'] }, { k: 'refill', label: lang === 'no' ? 'Påfylling' : 'Refill', icon: 'bottle', roles: ['superadmin', 'tenant_admin', 'worker'] }, { k: 'machines', label: t.nav_machines, icon: 'machine', roles: ['superadmin', 'tenant_admin'] }, { k: 'areas', label: lang === 'no' ? 'Områder' : 'Areas', icon: 'list', roles: ['superadmin', 'tenant_admin'] }, { k: 'map', label: lang === 'no' ? 'Kart' : 'Map', icon: 'list', roles: ['superadmin', 'tenant_admin', 'worker'] }, { k: 'transactions', label: t.nav_transactions, icon: 'transaction', roles: ['superadmin', 'tenant_admin'] }, { k: 'events', label: t.nav_events, icon: 'bell', badge: alarmsCount, badgeDanger: true, roles: ['superadmin', 'tenant_admin'] }, { k: 'analytics', label: lang === 'no' ? 'Statistikk' : 'Analytics', icon: 'chart', roles: ['superadmin', 'tenant_admin'] }, { k: 'bottles', label: t.nav_bottles, icon: 'bottle', roles: ['superadmin', 'tenant_admin'] }, { k: 'users', label: t.nav_users, icon: 'users', roles: ['superadmin', 'tenant_admin'] }, { k: 'activity', label: lang === 'no' ? 'Aktivitet' : 'Activity', icon: 'bell', roles: ['superadmin'] }, { k: 'settings', label: t.nav_settings, icon: 'settings', roles: ['superadmin', 'tenant_admin'] }, ]; let realRole = 'worker'; if (isSuperadmin) realRole = 'superadmin'; else if (isTenantAdmin) realRole = 'tenant_admin'; // effectiveRole — superadmin kan velge å ‚se som“ tenant_admin / worker const role = effectiveRole || realRole; const items = allItems.filter(item => item.roles.includes(role)); const [tenantOpen, setTenantOpen] = React.useState(false); return ( ); }; const Topbar = ({ lang, setLang, theme, setTheme, crumbs, onRefresh, onMenu, tenant, onNavigate }) => { const t = I18N[lang]; const [refreshing, setRefreshing] = React.useState(false); const handleRefresh = async () => { if (!onRefresh || refreshing) return; setRefreshing(true); try { await onRefresh(); } finally { setRefreshing(false); } }; // Søk (2026-09-05): filtrerer automatlista (valgt tenant) på navn/ID og // navigerer til automaten — før var feltet dekorativt uten handler. // ⌘K / Ctrl+K fokuserer feltet, piltaster + Enter velger, Esc lukker. const [q, setQ] = React.useState(''); const [searchOpen, setSearchOpen] = React.useState(false); const [hi, setHi] = React.useState(0); const searchRef = React.useRef(null); React.useEffect(() => { const onKey = (e) => { if ((e.metaKey || e.ctrlKey) && String(e.key).toLowerCase() === 'k') { e.preventDefault(); if (searchRef.current) { searchRef.current.focus(); searchRef.current.select(); } } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, []); const tenantMachines = (() => { const all = (window.DATA && DATA.machines) || []; return (tenant && tenant.id !== '__all__') ? all.filter(m => m.tenantId === tenant.id) : all; })(); const s = q.trim().toLowerCase(); const hits = !s ? [] : tenantMachines.filter(m => (m.location || '').toLowerCase().includes(s) || (m.id || '').toLowerCase().includes(s) || (m.name || '').toLowerCase().includes(s)).slice(0, 8); const go = (m) => { setQ(''); setSearchOpen(false); if (searchRef.current) searchRef.current.blur(); onNavigate && onNavigate({ page: 'machines', machineId: m.id }); }; const onSearchKey = (e) => { if (e.key === 'ArrowDown') { e.preventDefault(); setHi(h => Math.min(h + 1, Math.max(0, hits.length - 1))); } else if (e.key === 'ArrowUp') { e.preventDefault(); setHi(h => Math.max(h - 1, 0)); } else if (e.key === 'Enter') { if (hits[hi]) go(hits[hi]); } else if (e.key === 'Escape') { setQ(''); setSearchOpen(false); e.target.blur(); } }; // Varsel-bjelle (2026-09-05): de 5 siste varslene (ikke info) for valgt // tenant fra DATA.ALARMS — før var den dekorativ med permanent rød prikk. const [bellOpen, setBellOpen] = React.useState(false); const bellAlarms = (() => { const ids = new Set(tenantMachines.map(m => m.id)); const nameOf = (id) => (tenantMachines.find(m => m.id === id) || {}).location || id; return ((window.DATA && DATA.ALARMS) || []) .filter(a => ids.has(a.machineId) && a.sev !== 'info') .slice(0, 5) .map(a => ({ ...a, machineName: nameOf(a.machineId) })); })(); const popStyle = { position: 'absolute', top: '100%', marginTop: 4, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', boxShadow: 'var(--shadow-md)', zIndex: 40, }; return (
{/* Mobil: hamburger som åpner sidemenyen (skjult på desktop via CSS) */}
{crumbs.map((c, i) => ( {i > 0 && } {c.label} ))}
{ setQ(e.target.value); setHi(0); setSearchOpen(true); }} onFocus={() => setSearchOpen(true)} onBlur={() => setTimeout(() => setSearchOpen(false), 150)} onKeyDown={onSearchKey} /> ⌘K {searchOpen && s && (
{hits.length === 0 && (
{lang === 'no' ? 'Ingen automater matcher' : 'No machines match'}
)} {hits.map((m, i) => (
{ e.preventDefault(); go(m); }} onMouseEnter={() => setHi(i)} style={{ padding: '7px 10px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8, background: i === hi ? 'var(--accent-soft)' : 'transparent' }}> {m.location} {m.id}
))}
)}
{bellOpen && ( <>
setBellOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 39 }} />
{bellAlarms.length === 0 && (
{lang === 'no' ? 'Ingen aktive varsler' : 'No active alerts'}
)} {bellAlarms.map(a => (
{ setBellOpen(false); onNavigate && onNavigate({ page: 'machines', machineId: a.machineId }); }} style={{ padding: '8px 10px', borderBottom: '1px solid var(--border)', cursor: 'pointer', fontSize: 12 }}>
{a.machineName} {a.mins < 60 ? `${a.mins} min` : a.mins < 1440 ? `${Math.floor(a.mins / 60)} t` : `${Math.floor(a.mins / 1440)} d`}
{lang === 'no' ? a.msg_no : a.msg_en}
))}
)}
); }; /* --- Tiny widgets --- */ const Sparkline = ({ data, width = 80, height = 28, color = 'var(--accent)', filled = true }) => { if (!data || !data.length) return null; const min = Math.min(...data); const max = Math.max(...data); const range = max - min || 1; const step = width / (data.length - 1); const pts = data.map((v, i) => [i * step, height - ((v - min) / range) * (height - 4) - 2]); const d = pts.map((p, i) => (i === 0 ? `M${p[0]},${p[1]}` : `L${p[0]},${p[1]}`)).join(' '); const dFill = d + ` L${width},${height} L0,${height} Z`; return ( {filled && } ); }; const Donut = ({ size = 130, stroke = 18, segments }) => { const r = (size - stroke) / 2; const c = 2 * Math.PI * r; const total = segments.reduce((s, x) => s + x.value, 0) || 1; let acc = 0; // viewBox: CSS kan skalere donuten (mobil-laget setter .donut 110px) uten // at tegningen klippes — før ble den avskåret i ramma på smal skjerm. return ( {segments.map((seg, i) => { const len = (seg.value / total) * c; const dash = `${len} ${c - len}`; const offset = -acc; acc += len; return ( ); })} ); }; const StatusDot = ({ status }) => ; const Pill = ({ tone = 'empty', children, dot }) => ( {dot && } {children} ); Object.assign(window, { Sidebar, Topbar, Sparkline, Donut, StatusDot, Pill });