/* ============================================================ shell.jsx — sidebar, topbar, command palette, login ============================================================ */ /* ── Branding bootstrap ───────────────────────────────────── Fetch /api/branding once, store in window.__branding, apply document.title and --color-brand CSS var immediately. ─────────────────────────────────────────────────────────── */ window.__branding = window.__branding || { company_name: 'k9.ms', product_name: 'attendance', logo_url: '', favicon_url: '', brand_color: '#6366f1', tagline: '', login_slogan: 'Sign in to your team.', email_footer: '', }; function _applyBranding(b) { window.__branding = b; if (b.product_name) document.title = b.product_name; if (b.brand_color) document.documentElement.style.setProperty('--color-brand', b.brand_color); if (b.favicon_url) { let link = document.querySelector("link[rel~='icon']"); if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); } link.href = b.favicon_url; } } (function _loadBranding() { // Use /api/branding/all (public) and map to the shell's legacy shape. fetch('/api/branding/all', { credentials: 'include' }) .then(r => r.ok ? r.json() : null) .then(b => { if (!b) return; _applyBranding({ product_name: b.brand_name || 'attendance', company_name: '', // single brand: header shows brand_name once + slogan logo_url: b.logo_dark_url || b.logo_light_url || '', favicon_url: b.favicon_url || '', brand_color: b.primary_color || '#6366f1', tagline: b.slogan || '', login_slogan: b.slogan || 'Sign in to your team.', email_footer: b.support_email || '', domain: b.domain || '', }); }) .catch(() => {}); })(); const NAV_GROUPS = [ { label: 'Activity', items: [ { id: 'today', label: 'Today', icon: 'today', tip: 'Live attendance & team status' }, { id: 'employees', label: 'Employees', icon: 'team', tip: 'Employee profiles & compliance' }, { id: 'pto', label: 'Requests', icon: 'pto', tip: 'PTO & leave request approvals' }, { id: 'monthly', label: 'Summary', icon: 'chart', tip: 'Monthly attendance summary' }, { id: 'reports', label: 'Reports', icon: 'report', tip: 'Charts, trends & export' }, { id: 'tasks', label: 'Tasks', icon: 'audit', tip: 'Track completed, blocked & stalled tasks' }, // Captures nav entry removed — automatic capture cadence runs in the // background; on-demand capture is surfaced inside Bot Behavior now. { id: 'support', label: 'Support', icon: 'alert', tip: 'Tickets & support stats' }, { id: 'oncall', label: 'On-call', icon: 'alert', tip: 'On-call rota & escalation' }, ]}, { label: 'Insight', items: [ { id: 'messages', label: 'Messages', icon: 'msg', tip: 'All standup messages & DM history' }, { id: 'audit', label: 'Audit log', icon: 'audit', tip: 'Admin actions & system audit trail' }, ]}, { label: 'Configure', items: [ { id: 'teams', label: 'Teams', icon: 'building', tip: 'Manage teams & default settings' }, { id: 'rooms', label: 'Rooms', icon: 'door', tip: 'Matrix room assignments' }, { id: 'bots', label: 'Bots', icon: 'ai', tip: 'Bot configuration & personas' }, { id: 'bot-behavior', label: 'Bot Behavior', icon: 'sliders', tip: 'Thresholds, keywords, templates & AI prompts' }, { id: 'providers', label: 'Integrations', icon: 'plug', tip: 'Slack, Microsoft Teams, WhatsApp and other third-party integrations' }, { id: 'server', label: 'Server', icon: 'server', tip: 'Server health & access logs' }, { id: 'help', label: 'Help / API', icon: 'info', tip: 'API reference & documentation' }, ]}, ]; function Sidebar({ active, setActive, collapsed, setCollapsed, me, counts, onSignOut }) { const [profileOpen, setProfileOpen] = useState(false); // Re-render sidebar avatar when theme changes (auto preference) const _useTheme = window.useTheme || (() => 'dark'); const _resolveAvatarUrl = window.resolveAvatarUrl || (() => null); _useTheme(); // subscribe to theme changes const sidebarAvatarUrl = _resolveAvatarUrl(me); return ( ); } // Topbar mic enable/disable. Reads/writes localStorage.voice_enabled and // dispatches 'voice-pref-change' so the Ask-AI widget can show/hide its // inline mic button. This is the SINGLE entry point for turning the mic on // site-wide — replaces the old floating "Voice off" pill. function MicToggle() { const [on, setOn] = useState(() => localStorage.getItem('voice_enabled') === '1'); useEffect(() => { const h = () => setOn(localStorage.getItem('voice_enabled') === '1'); window.addEventListener('voice-pref-change', h); return () => window.removeEventListener('voice-pref-change', h); }, []); const toggle = () => { const next = !on; if (next) localStorage.setItem('voice_enabled', '1'); else localStorage.removeItem('voice_enabled'); setOn(next); window.dispatchEvent(new CustomEvent('voice-pref-change', { detail: { enabled: next } })); }; return ( ); } function Clock() { const [t, setT] = useState(new Date()); const [tz, setTz] = useState(null); useEffect(() => { const id = setInterval(() => setT(new Date()), 1000); return () => clearInterval(id); }, []); useEffect(() => { let alive = true; fetch('/api/auth/me', { credentials: 'same-origin' }) .then(r => r.ok ? r.json() : null) .then(j => { if (alive && j && j.timezone) setTz(j.timezone); }) .catch(() => {}); return () => { alive = false; }; }, []); const tOpts = { hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:true, ...(tz ? { timeZone: tz } : {}) }; const dOpts = { weekday:'short', month:'short', day:'numeric', ...(tz ? { timeZone: tz } : {}) }; let offset = ''; if (tz) { try { const parts = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'shortOffset' }).formatToParts(t); const o = parts.find(p => p.type === 'timeZoneName'); offset = (o && o.value) ? o.value : tz; } catch (_) { offset = tz; } } return (
{t.toLocaleTimeString([], tOpts)}
{t.toLocaleDateString([], dOpts)}{offset ? ' · ' + offset : ''}
); } function Topbar({ pageTitle, pageCrumb, liveCount, totalCount, onCmdOpen, theme, setTheme, aesthetic, setAesthetic, density, setDensity, isAdmin, isManager }) { // Phase 1 — admin/manager Edit Mode toggle. Wraps the global useEditMode // hook so this top-bar button stays in sync with any other component that // flips the flag (e.g. keyboard shortcut in a future phase). const editMode = (typeof useEditMode === 'function') ? useEditMode() : false; const canSeeEditToggle = !!(isAdmin || isManager); const [qrOpen, setQrOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [brandingOpen, setBrandingOpen] = useState(false); const [sseFlash, setSseFlash] = useState(false); const [notifEnabled, setNotifEnabled] = useState(() => { try { return localStorage.getItem('notif_enabled') === 'true'; } catch { return false; } }); const [notifCount, setNotifCount] = useState(0); // PWA install prompt — capture beforeinstallprompt at topbar level (survives modal lifecycle) const [installPrompt, setInstallPrompt] = useState(null); const [isInstalled, setIsInstalled] = useState(() => window.matchMedia && window.matchMedia('(display-mode: standalone)').matches ); useEffect(() => { if (isInstalled) return; const handler = (e) => { e.preventDefault(); setInstallPrompt(e); }; const _appInstalledHandler = () => { setInstallPrompt(null); setIsInstalled(true); }; window.addEventListener('beforeinstallprompt', handler); window.addEventListener('appinstalled', _appInstalledHandler); return () => { window.removeEventListener('beforeinstallprompt', handler); window.removeEventListener('appinstalled', _appInstalledHandler); }; }, [isInstalled]); const doTopbarInstall = async () => { if (!installPrompt) return; installPrompt.prompt(); const { outcome } = await installPrompt.userChoice; if (outcome === 'accepted') { setInstallPrompt(null); setIsInstalled(true); } }; // Visit count toast — show on 3rd visit, once only const toast = useToast(); const visitToastFiredRef = useRef(false); useEffect(() => { if (visitToastFiredRef.current) return; if (localStorage.getItem('status_install_toast_dismissed') === 'true') return; visitToastFiredRef.current = true; let count = 0; try { count = parseInt(localStorage.getItem('status_visit_count') || '0', 10) || 0; } catch {} count += 1; try { localStorage.setItem('status_visit_count', String(count)); } catch {} if (count === 3) { setTimeout(() => { toast.push({ msg: 'Install Status as an app (beta) — press Cmd+Shift+A or use Settings > Install / QR code', kind: 'info', icon: '↓', duration: 10000, }); }, 2000); } }, []); // Keyboard shortcut Cmd+Shift+A → open QR useEffect(() => { const handler = (e) => { if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'A') { e.preventDefault(); setQrOpen(true); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, []); // Theme color overrides — load on mount, apply to CSS vars const [themeColors, setThemeColors] = useState({}); const [themeColorsLoading, setThemeColorsLoading] = useState(false); const themeColorKeys = ['on_time', 'late', 'absent', 'pto', 'ai']; const themeColorCssVar = { on_time: '--c-on-time', late: '--c-late', absent: '--c-absent', pto: '--c-pto', ai: '--c-ai' }; const themeColorLabel = { on_time: 'On time', late: 'Late', absent: 'Absent', pto: 'PTO', ai: 'AI accent' }; const themeColorDefault = { on_time: '#22c55e', late: '#f59e0b', absent: '#ef4444', pto: '#6366f1', ai: '#a855f7' }; useEffect(() => { fetch('/api/branding/theme', { credentials: 'include' }) .then(r => r.ok ? r.json() : {}) .then(data => { setThemeColors(data || {}); Object.entries(data || {}).forEach(([k, v]) => { if (themeColorCssVar[k]) document.documentElement.style.setProperty(themeColorCssVar[k], v); }); }) .catch(() => {}); }, []); const saveThemeColor = async (key, value) => { setThemeColors(prev => ({ ...prev, [key]: value })); if (themeColorCssVar[key]) document.documentElement.style.setProperty(themeColorCssVar[key], value); setThemeColorsLoading(true); try { await fetch('/api/branding/theme', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ [key]: value }), }); } catch {} setThemeColorsLoading(false); }; const clearThemeColor = async (key) => { setThemeColors(prev => { const n = {...prev}; delete n[key]; return n; }); if (themeColorCssVar[key]) document.documentElement.style.removeProperty(themeColorCssVar[key]); try { await fetch('/api/branding/theme', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ [key]: null }), }); } catch {} }; const toggleNotif = async () => { if (!('Notification' in window)) { alert('This browser does not support desktop notifications.'); return; } if (!notifEnabled) { const perm = await Notification.requestPermission(); if (perm === 'granted') { setNotifEnabled(true); try { localStorage.setItem('notif_enabled', 'true'); } catch {} setNotifCount(0); (typeof Notification !== 'undefined' && Notification.permission === 'granted') && new Notification('Attendance Bot', { body: 'Attendance alerts enabled.', icon: '/static/v2/logo.png' }); } } else { setNotifEnabled(false); try { localStorage.setItem('notif_enabled', 'false'); } catch {} } }; // SSE flash — briefly highlight the live chip when any SSE event arrives useEffect(() => { const handler = () => { setSseFlash(true); setTimeout(() => setSseFlash(false), 200); }; window.addEventListener('standup-bot-event', handler); // Also flash on attendance/audit SSE events dispatched by app.jsx window.addEventListener('attendance-updated', handler); return () => { window.removeEventListener('standup-bot-event', handler); window.removeEventListener('attendance-updated', handler); }; }, []); // Close settings popover on outside click useEffect(() => { if (!settingsOpen) return; const handler = (e) => { if (!e.target.closest('.settings-popover-wrap')) setSettingsOpen(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [settingsOpen]); // Listen for SSE attendance events and fire browser notifications when enabled useEffect(() => { const handler = (e) => { if (!notifEnabled) return; try { const detail = e.detail || {}; const name = detail.who || detail.display_name || 'Someone'; const kind = detail.kind || 'signed in'; setNotifCount(n => n + 1); (typeof Notification !== 'undefined' && Notification.permission === 'granted') && new Notification(`${name} ${kind}`, { body: new Date().toLocaleTimeString(), icon: '/static/v2/logo.png', silent: true }); } catch {} }; window.addEventListener('standup-bot-event', handler); return () => window.removeEventListener('standup-bot-event', handler); }, [notifEnabled]); return ( <>

{/* A14: crumb is clickable — jumps to the first page in that section */} { if (pageCrumb) e.currentTarget.style.textDecorationColor = 'var(--text-3)'; }} onMouseLeave={e => { e.currentTarget.style.textDecorationColor = 'transparent'; }} onClick={() => { const map = { Activity: 'today', Insight: 'messages', Configure: 'teams' }; const target = map[pageCrumb]; if (target) { try { window.dispatchEvent(new CustomEvent('nav-to', { detail: { page: target } })); } catch(_){} if (typeof window.__navigate === 'function') window.__navigate(target); else location.hash = '#' + target; } }} title={pageCrumb ? `Jump to ${pageCrumb}` : ''}> {pageCrumb} / {pageTitle}

{liveCount != null && (
{liveCount}/{totalCount} live
)} {canSeeEditToggle && ( )} {/* MicToggle removed — voice persona deprecated, Ask AI is chat-only now. */}
{settingsOpen && (
e.stopPropagation()}>
{(window.appT||(s=>s))('Display')}
{/* Interface language — mirrors the mobile app's English / العربية switch so an admin (or any user) can change the UI language here. Shares the same 'm_lang' preference as the phone; switching reloads so the new strings + RTL direction apply everywhere. */}
{(window.appT||(s=>s))('Language')}
{[{v:'en',l:'English'},{v:'ar',l:'العربية'}].map(o => { const cur = (window.appLang||(()=>'en'))(); const on = cur === o.v; return ( ); })}
{/* Density display options removed per request — the app uses a single fixed density. */} {/* Aesthetic picker removed — Editorial Calm (A+D hybrid) is the only direction. Theme toggle (light/dark) lives in its own section above. */}
{/* Theme color overrides — admin only */} {isAdmin && (
{(window.appT||(s=>s))('Status colors')} {themeColorsLoading && (saving…)}
{themeColorKeys.map(key => (
saveThemeColor(key, e.target.value)} style={{ width:28, height:22, border:'none', background:'none', cursor:'pointer', padding:0 }} title={themeColorLabel[key]} /> {themeColorLabel[key]} {themeColors[key] && ( )}
))}
)} {/* Voice assistant settings — admin only (V1-V10) */} {isAdmin && (
)} {/* Branding — admin only */} {isAdmin && (
)}
)}
{brandingOpen && setBrandingOpen(false)} onSaved={b => { _applyBranding(b); }} />} {/* Persistent PWA install button — only when browser offers install */} {installPrompt && !isInstalled && ( )}
{qrOpen && setQrOpen(false)} />} ); } /* ── Branding Modal (admin) ── */ function BrandingModal({ onClose, onSaved }) { const defaults = window.__branding || {}; const [form, setForm] = useState({ company_name: defaults.company_name || '', product_name: defaults.product_name || '', logo_url: defaults.logo_url || '', favicon_url: defaults.favicon_url || '', brand_color: defaults.brand_color || '#6366f1', tagline: defaults.tagline || '', login_slogan: defaults.login_slogan || '', email_footer: defaults.email_footer || '', }); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(''); const [logoUploading, setLogoUploading] = useState(false); const [faviconUploading, setFaviconUploading] = useState(false); const set = (k, v) => setForm(f => ({ ...f, [k]: v })); async function save() { setBusy(true); setMsg(''); try { const r = await fetch('/api/branding', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(form), }); if (!r.ok) { setMsg('Save failed: ' + (await r.text())); setBusy(false); return; } const updated = await r.json(); if (onSaved) onSaved(updated); setMsg('Saved!'); setTimeout(onClose, 800); } catch (e) { setMsg('Error: ' + e.message); } setBusy(false); } async function uploadFile(field, url, file) { const setter = field === 'logo_url' ? setLogoUploading : setFaviconUploading; setter(true); try { const fd = new FormData(); fd.append('file', file); const endpoint = field === 'logo_url' ? '/api/admin/branding/logo?mode=light' : '/api/admin/branding/favicon'; const r = await fetch(endpoint, { method: 'POST', credentials: 'include', body: fd }); if (!r.ok) { setMsg('Upload failed'); setter(false); return; } const json = await r.json(); set(field, json.url || url); setMsg('Uploaded!'); // #37: browsers cache favicon + logo aggressively, so the new // file isn't visible until the user hard-refreshes. Bust the // cached and any // by re-pointing them to the same URL with a timestamp query. try { const bust = '?t=' + Date.now(); document.querySelectorAll('link[rel~="icon"], link[rel="apple-touch-icon"]').forEach(el => { const base = (el.getAttribute('href') || '').split('?')[0]; if (base.startsWith('/api/branding/')) el.href = base + bust; }); document.querySelectorAll('img').forEach(el => { const base = (el.getAttribute('src') || '').split('?')[0]; if (base.startsWith('/api/branding/')) el.src = base + bust; }); // Tell other components on the page (Topbar, page-misc preview) // that branding changed, so they can re-fetch. window.dispatchEvent(new CustomEvent('branding-updated', { detail: { ts: Date.now() } })); } catch (_) { /* non-fatal */ } } catch (e) { setMsg('Upload error: ' + e.message); } setter(false); } const inp = (label, key, opts = {}) => (
set(key, e.target.value)} style={{ fontSize: 13, ...(opts.style || {}) }} />
); return (
e.target === e.currentTarget && onClose()}>
Branding
{inp('Company name', 'company_name', { placeholder: 'k9.ms' })} {inp('Product name', 'product_name', { placeholder: 'attendance' })} {inp('Tagline', 'tagline', { placeholder: 'e.g. Team standup made simple' })} {inp('Login slogan', 'login_slogan', { placeholder: 'Sign in to your team.' })} {inp('Email footer', 'email_footer', { placeholder: 'Powered by k9.ms Attendance' })}
set('brand_color', e.target.value)} style={{ width: 36, height: 30, border: 'none', background: 'none', cursor: 'pointer', padding: 0 }} /> set('brand_color', e.target.value)} placeholder="#6366f1" style={{ fontSize: 13, width: 110 }} />
set('logo_url', e.target.value)} placeholder="/api/branding/logo?mode=light" style={{ fontSize: 13, flex: 1 }} />
set('favicon_url', e.target.value)} placeholder="/api/branding/favicon" style={{ fontSize: 13, flex: 1 }} />
{msg &&
{msg}
}
); } /* ── QR Install Modal (D3/D6) ── */ function QrInstallModal({ onClose }) { const url = window.location.origin + '/'; const qrRef = useRef(null); // Render QR code using local QRCode.js lib (no external API call needed) useEffect(() => { if (!qrRef.current) return; if (typeof QRCode === 'undefined') return; qrRef.current.innerHTML = ''; new QRCode(qrRef.current, { text: url, width: 180, height: 180, colorDark: '#111318', colorLight: '#ffffff', correctLevel: QRCode.CorrectLevel.M, }); }, [url]); // Listen for browser beforeinstallprompt const [installPrompt, setInstallPrompt] = useState(null); useEffect(() => { const handler = (e) => { e.preventDefault(); setInstallPrompt(e); }; window.addEventListener('beforeinstallprompt', handler); return () => window.removeEventListener('beforeinstallprompt', handler); }, []); const doInstall = async () => { if (!installPrompt) return; installPrompt.prompt(); const { outcome } = await installPrompt.userChoice; if (outcome === 'accepted') { setInstallPrompt(null); onClose(); } }; // Close on Escape useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose]); return (
e.stopPropagation()} style={{ background: 'var(--bg-elev-1)', border: '1px solid var(--line-hi)', borderRadius: 'var(--r-lg)', width: 320, padding: 24, position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%,-50%)', boxShadow: '0 24px 80px -20px #000000c0', textAlign: 'center', }} >
Install / Open on mobile BETA
Scan with your phone to open the dashboard. Add to home screen for a native-app experience.
{url}
{installPrompt && ( )} {!installPrompt && (
To install: open in Chrome/Edge on Android, or Safari on iOS, then "Add to Home Screen."
)}
); } /* ============================================================ COMMAND PALETTE ============================================================ */ function CommandPalette({ open, onClose, setActive, employees, setTheme, theme, setAesthetic }) { const [q, setQ] = useState(''); const [idx, setIdx] = useState(0); const [helpResults, setHelpResults] = useState([]); const helpDebRef = useRef(null); // Detect "Help: " prefix const helpPrefix = 'help:'; const isHelpMode = q.trim().toLowerCase().startsWith(helpPrefix); const helpQ = isHelpMode ? q.trim().slice(helpPrefix.length).trim() : ''; // Fetch help results when in help mode useEffect(() => { if (!isHelpMode || !helpQ) { setHelpResults([]); return; } clearTimeout(helpDebRef.current); helpDebRef.current = setTimeout(async () => { try { const r = await fetch(`/api/help/search?q=${encodeURIComponent(helpQ)}`, { credentials: 'include' }); if (!r.ok) return; const data = await r.json(); setHelpResults(Array.isArray(data) ? data.slice(0, 8) : []); } catch { setHelpResults([]); } }, 200); return () => clearTimeout(helpDebRef.current); }, [helpQ, isHelpMode]); const items = useMemo(() => { const pages = NAV_GROUPS.flatMap(g => g.items.map(i => ({ ...i, kind:'page', section: g.label }))); const people = employees.slice(0, 30).map(e => ({ id: `emp-${e.id}`, kind:'person', label: e.display_name, hint: e.matrix_id, employee: e, icon: 'user' })); const actions = [ { id:'theme-dark', kind:'action', label:'Switch to dark theme', icon:'moon', do: () => setTheme('dark') }, { id:'theme-light', kind:'action', label:'Switch to light theme', icon:'sun', do: () => setTheme('light') }, // Aesthetic picker removed — Editorial Calm is the only direction. // Theme toggle (light/dark) stays accessible via the entries above. ]; return [...pages, ...people, ...actions]; }, [employees]); const filtered = useMemo(() => { if (isHelpMode) return []; if (!q.trim()) return items; const lc = q.toLowerCase(); return items.filter(i => (i.label || '').toLowerCase().includes(lc) || (i.hint || '').toLowerCase().includes(lc)); }, [q, items, isHelpMode]); const allVisible = isHelpMode ? helpResults.map(r => ({ id: `help-${r.slug}`, kind:'help', label: r.title, hint: r.category, slug: r.slug })) : filtered; useEffect(() => { setIdx(0); }, [q]); useEffect(() => { if (!open) return; const onKey = (e) => { if (e.key === 'Escape') onClose(); if (e.key === 'ArrowDown') { e.preventDefault(); setIdx(i => Math.min(i + 1, allVisible.length - 1)); } if (e.key === 'ArrowUp') { e.preventDefault(); setIdx(i => Math.max(i - 1, 0)); } if (e.key === 'Enter') { const sel = allVisible[idx]; if (!sel) return; if (sel.kind === 'page') setActive(sel.id); else if (sel.kind === 'action') sel.do(); else if (sel.kind === 'person') setActive('employees'); else if (sel.kind === 'help') { setActive('help'); } onClose(); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [open, allVisible, idx, onClose, setActive]); if (!open) return null; // group non-help items const grouped = filtered.reduce((acc, item) => { const k = item.kind === 'page' ? 'Pages' : item.kind === 'person' ? 'People' : 'Actions'; (acc[k] ||= []).push(item); return acc; }, {}); let runningIdx = -1; const renderItem = (item) => { runningIdx++; const isOn = runningIdx === idx; return (
{ if (item.kind === 'page') setActive(item.id); else if (item.kind === 'action') item.do(); else if (item.kind === 'person') setActive('employees'); else if (item.kind === 'help') setActive('help'); onClose(); }}> {item.label} {item.hint && {item.hint}}
); }; return (
e.stopPropagation()}>
setQ(e.target.value)} placeholder={isHelpMode ? 'Search help articles…' : 'Search pages, people, or run a command…'} /> esc
{isHelpMode ? ( <>
Help articles
{helpResults.length === 0 && helpQ && (
No articles found
Try a different query.
)} {helpResults.length === 0 && !helpQ && (
Type to search help
e.g. Help: sign in
)} {helpResults.map(r => ({id:`help-${r.slug}`, kind:'help', label:r.title, hint:r.category, slug:r.slug})).map(renderItem)} ) : ( <> {Object.entries(grouped).map(([section, list]) => (
{section}
{list.map(renderItem)}
))} {filtered.length === 0 && (
No results
Try "Help: your question" to search articles.
)} {/* Hint about Help: mode */} {q.trim() && (
Tip: type Help: {q.trim()} to search help articles
)} )}
); } /* ============================================================ LOGIN ============================================================ */ const LOGIN_LAST_ID_KEY = 'status_last_matrix_id'; function normalizeLoginMatrixId(value) { let s = (value || '').trim().toLowerCase(); if (!s || /\s/.test(s)) return ''; if (s.startsWith('@')) s = s.slice(1); if (!s.includes(':')) s = `${s}:k9.ms`; return `@${s}`; } function loginDeviceKey(matrixId) { const canonical = normalizeLoginMatrixId(matrixId); return canonical ? `status_device_${canonical}` : ''; } function apiErrorText(detail, fallback = 'Request failed') { const norm = (v) => { if (v == null) return ''; if (typeof v === 'string') return v; if (Array.isArray(v)) return v.map(norm).filter(Boolean).join('; '); if (typeof v === 'object') { const nested = v.msg || v.message || v.error || v.detail; if (nested && nested !== v) { const n = norm(nested); if (n) return n; } try { return JSON.stringify(v); } catch(_) { return String(v); } } return String(v); }; if (!detail) return fallback; const out = norm(detail); return out || fallback; } function LoginPage({ onLogin, theme, setTheme }) { const [step, setStep] = useState('id'); const [chatId, setChatId] = useState(() => localStorage.getItem(LOGIN_LAST_ID_KEY) || ''); const [code, setCode] = useState(''); const [pw, setPw] = useState(''); const [busy, setBusy] = useState(false); const [err, setErr] = useState(''); const [loginStats, setLoginStats] = useState(null); // Mobile + auto-trust detection. On Capacitor / true touch phones, we hide // the "Sign in with password" button and silently attempt the trusted- // device login on mount so returning users skip the OTP screen entirely. const isMobileSurface = (typeof window.M_isMobileSurface === 'function') ? !!window.M_isMobileSurface() : false; // Auth headers: the native Capacitor app announces itself so the backend // exempts it from the employee web-login block (employees use the app, not // the web dashboard). No header on mobile/desktop web → web stays gated. const authHeaders = () => { const h = { 'Content-Type': 'application/json' }; try { const cap = window.Capacitor; const native = cap && ( (typeof cap.isNativePlatform === 'function' && cap.isNativePlatform()) || (typeof cap.getPlatform === 'function' && cap.getPlatform() !== 'web') ); if (native) h['X-K9-Client'] = 'ios-app'; } catch (_) {} return h; }; const [autoTrying, setAutoTrying] = useState(() => { const last = localStorage.getItem(LOGIN_LAST_ID_KEY) || ''; return !!last; // attempt iff we know who you are }); useEffect(() => { let alive = true; fetch('/api/login/stats') .then(r => r.ok ? r.json() : null) .then(data => { if (alive) setLoginStats(data); }) .catch(() => {}); return () => { alive = false; }; }, []); const rememberDevice = (matrixId, token) => { const id = normalizeLoginMatrixId(matrixId); const key = loginDeviceKey(id); if (!id || !key || !token) return; const expiry = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(); localStorage.setItem(key, token); localStorage.setItem(`${key}_expiry`, expiry); localStorage.setItem(LOGIN_LAST_ID_KEY, id); }; const rememberedDevice = (matrixId) => { const id = normalizeLoginMatrixId(matrixId); const canonicalKey = loginDeviceKey(id); if (!id || !canonicalKey) return null; const keys = [canonicalKey]; const legacyKey = `standup_device_${id}`; if (legacyKey !== canonicalKey) keys.push(legacyKey); for (const key of keys) { const token = localStorage.getItem(key); const expiry = localStorage.getItem(`${key}_expiry`); if (!token || !expiry) continue; if (new Date(expiry) <= new Date()) { localStorage.removeItem(key); localStorage.removeItem(`${key}_expiry`); continue; } if (key !== canonicalKey) { localStorage.setItem(canonicalKey, token); localStorage.setItem(`${canonicalKey}_expiry`, expiry); localStorage.removeItem(key); localStorage.removeItem(`${key}_expiry`); } return { id, token, key: canonicalKey }; } return null; }; const loginWithDevice = async (matrixId) => { const remembered = rememberedDevice(matrixId); if (!remembered) return false; let r; try { r = await fetch('/api/auth/device-login', { method:'POST', credentials:'include', headers: authHeaders(), body: JSON.stringify({ matrix_id: remembered.id, device_token: remembered.token }), }); } catch (_) { // Network blip — keep the trust token so the next attempt can still // skip OTP. Never punish a transient failure with a re-login. return false; } if (!r.ok) { // Only forget the device when the server explicitly rejects the token // (401/403 = revoked/expired). For 5xx or anything else, keep trust so // the user isn't forced back to OTP by a temporary backend hiccup. if (r.status === 401 || r.status === 403) { localStorage.removeItem(remembered.key); localStorage.removeItem(`${remembered.key}_expiry`); } return false; } const data = await r.json(); // Refresh the stored 90-day expiry in lockstep with the server's sliding // window so an actively-used device never lets its token lapse → no re-OTP. if (data.device_token) rememberDevice(data.matrix_id || remembered.id, data.device_token); localStorage.setItem(LOGIN_LAST_ID_KEY, data.matrix_id || remembered.id); onLogin(data); return true; }; // Auto-try the trusted-device login on every cold start when we remember // who this device belongs to. Returning users never see the OTP screen — // they just land in the app. Only falls through to UI if the stored token // has expired or was server-side revoked. useEffect(() => { let cancelled = false; const tryAuto = async () => { const last = localStorage.getItem(LOGIN_LAST_ID_KEY) || ''; if (!last) { setAutoTrying(false); return; } try { const ok = await loginWithDevice(last); if (!cancelled && !ok) setAutoTrying(false); } catch (_) { if (!cancelled) setAutoTrying(false); } }; if (autoTrying) tryAuto(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const send = async () => { const id = normalizeLoginMatrixId(chatId); if (!id || busy) return; setBusy(true); setErr(''); try { if (await loginWithDevice(id)) return; const r = await fetch('/api/auth/request-otp', { method:'POST', credentials:'include', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ matrix_id: id }), }); if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(apiErrorText(j.detail ?? j.message, `Could not send code (${r.status})`)); } localStorage.setItem(LOGIN_LAST_ID_KEY, id); setStep('code'); } catch (e) { setErr(apiErrorText(e.message, 'Failed')); } finally { setBusy(false); } }; const verify = async () => { if (code.length < 6 || busy) return; setBusy(true); setErr(''); try { const r = await fetch('/api/auth/verify-otp', { method:'POST', credentials:'include', headers: authHeaders(), body: JSON.stringify({ matrix_id: normalizeLoginMatrixId(chatId), otp: code }), }); if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(apiErrorText(j.detail ?? j.message, 'Invalid code')); } const data = await r.json(); if (data.device_token) rememberDevice(data.matrix_id || chatId, data.device_token); onLogin(data); } catch (e) { setErr(apiErrorText(e.message, 'Failed')); } finally { setBusy(false); } }; const loginPassword = async () => { const id = normalizeLoginMatrixId(chatId); if (!id || !pw || busy) return; setBusy(true); setErr(''); try { const r = await fetch('/api/auth/login-password', { method:'POST', credentials:'include', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ matrix_id: id, password: pw }), }); if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(apiErrorText(j.detail ?? j.message, 'Invalid credentials')); } const data = await r.json(); localStorage.setItem(LOGIN_LAST_ID_KEY, data.matrix_id || id); onLogin(data); } catch (e) { setErr(apiErrorText(e.message, 'Failed')); } finally { setBusy(false); } }; // demo mosaic seed const tiles = useMemo(() => { const out = []; for (let i = 0; i < 64; i++) { const r = Math.random(); out.push(r > 0.85 ? 'err' : r > 0.7 ? 'warn' : r > 0.25 ? 'ok' : ''); } return out; }, []); const signedIn = loginStats?.signed_in_today ?? '—'; const totalUsers = loginStats?.total_users ?? '—'; const lateToday = loginStats?.late_today ?? '—'; const absentToday = loginStats?.absent_today ?? '—'; const blockersToday = loginStats?.active_blockers ?? '—'; // Quiet "welcome back" frame while the trusted-device login is in flight. // No form, no fields — just the brand. Either we get logged in silently // or we drop through to the normal OTP screen. if (autoTrying) { return (
{(window.appT||(s=>s))('welcome back…')}
{(window.appT||(s=>s))('signing you in')}
); } return (
logo{e.currentTarget.replaceWith(Object.assign(document.createElement('div'),{innerHTML:'S'}));}} />
{(window.__branding && window.__branding.product_name) || 'status'}
{(() => { const b = window.__branding || {}; if (b.tagline) return b.tagline; if (b.company_name && b.product_name && b.company_name !== b.product_name) return b.company_name + " / " + b.product_name; return b.product_name || ""; })()}
{(window.__branding && window.__branding.login_slogan) || (window.appT||(s=>s))('Sign in to your team.')}
{(window.appT||(s=>s))("We'll DM a 6-digit code to your K9 Chat ID. No passwords, no setup.")}
{err && (
{err}
)} {step === 'id' ? (
setChatId(e.target.value)} onKeyDown={e => e.key === 'Enter' && send()} style={{ fontSize: 14, padding: '10px 12px' }} />
{(window.appT||(s=>s))('Trusted devices stay signed in for 90 days')}
{/* Password login is a desktop-only fallback (App Review demo account). On Capacitor / mobile phones we hide it — OTP + trusted-device is the only auth path. */} {!isMobileSurface && ( )}
) : step === 'pw' ? (
setChatId(e.target.value)} onKeyDown={e => e.key === 'Enter' && loginPassword()} autoFocus style={{ fontSize: 14, padding: '10px 12px' }} />
setPw(e.target.value)} onKeyDown={e => e.key === 'Enter' && loginPassword()} style={{ fontSize: 14, padding: '10px 12px' }} />
) : (
{(window.appT||(s=>s))('Code sent to')} {chatId} — {(window.appT||(s=>s))('expires in 5 min')}
setCode(e.target.value.replace(/\D/g, ''))} onKeyDown={e => e.key === 'Enter' && verify()} placeholder="······" autoFocus />
{(window.appT||(s=>s))('Tip: enter')} 123456 {(window.appT||(s=>s))('to demo')}
)}
v1.4.2 · {new Date().toISOString().slice(0,10)}
{(window.appLang && window.appLang()==='ar' ? 'مباشر · اليوم، ' : 'live · today, ')}{new Date().toLocaleDateString([],{ month:'short', day:'numeric' })}
{(window.appLang && window.appLang()==='ar') ? <>تم تسجيل دخول {signedIn} من {totalUsers}.
{lateToday} متأخر · {absentToday} غائب · {blockersToday} عوائق. : <>{signedIn} of {totalUsers} signed in.
{lateToday} late · {absentToday} absent · {blockersToday} blockers.}
{tiles.map((t, i) =>
)}
{(window.appT||(s=>s))("One K9 room. One dashboard. Adaptive late-detection, AI roll-ups, payroll-ready exports — and a bot that's nicer than your old time-clock.")}
); } /* AvatarVariantRow — upload / remove one variant (dark or light) */ function AvatarVariantRow({ label, variant, currentUrl, onUploaded, onRemoved }) { const [preview, setPreview] = useState(currentUrl); const [busy, setBusy] = useState(false); const [err, setErr] = useState(''); const fileRef = React.useRef(); const onPick = (file) => { if (!file) return; setErr(''); const reader = new FileReader(); reader.onload = async (ev) => { setPreview(ev.target.result); setBusy(true); try { const fd = new FormData(); fd.append('file', file); const r = await fetch(`/api/users/me/avatar?variant=${variant}`, { method: 'POST', credentials: 'include', body: fd, }); if (!r.ok) { const j = await r.json().catch(()=>({})); throw new Error(j.detail || `${r.status}`); } const j = await r.json(); setPreview(j.url + '?t=' + Date.now()); onUploaded(variant, j.url); } catch(e) { setErr(e.message || String(e)); setPreview(currentUrl); } finally { setBusy(false); } }; reader.readAsDataURL(file); }; const onRemove = async () => { setBusy(true); setErr(''); try { const r = await fetch(`/api/users/me/avatar?variant=${variant}`, { method: 'DELETE', credentials: 'include' }); if (!r.ok) throw new Error(`${r.status}`); setPreview(null); onRemoved(variant); } catch(e) { setErr(e.message || String(e)); } finally { setBusy(false); } }; return (
{preview ? setPreview(null)}/> :
none
}
{label}
onPick(e.target.files[0])} /> {preview && }
{err &&
{err}
}
); } /* ─── Linked accounts section (CP6) ──────────────────────────────── */ function LinkedAccountsSection({ me, onMsg }) { const [accounts, setAccounts] = React.useState(null); // null = loading const [busy, setBusy] = React.useState(false); const toast = useToast ? useToast() : { push: () => {} }; const load = async () => { try { const r = await fetch('/api/users/me/external-accounts', { credentials: 'same-origin' }); if (r.ok) setAccounts(await r.json()); else setAccounts([]); } catch { setAccounts([]); } }; React.useEffect(() => { load(); }, []); const unlink = async (protocol) => { if (!confirm(`Unlink ${protocol} account?`)) return; setBusy(true); try { const r = await fetch(`/api/users/me/external/${protocol}`, { method: 'DELETE', credentials: 'same-origin', }); if (!r.ok) throw new Error(`${r.status}`); onMsg && onMsg(`${protocol} account unlinked`); load(); } catch(e) { onMsg && onMsg(`Unlink failed: ${e.message}`); } finally { setBusy(false); } }; const PROTO_LABELS = { matrix: 'K9 Chat', slack: 'Slack', teams: 'Microsoft Teams' }; return (
Linked accounts
Accounts linked to your profile for bot messaging. K9 Chat is always linked.
{accounts === null &&
Loading...
} {accounts && accounts.length === 0 && (
No external accounts linked yet.
)} {accounts && accounts.map(acct => (
{PROTO_LABELS[acct.protocol] || acct.protocol}
{acct.display_name || acct.external_id}
{acct.last_seen_at && (
Last seen {new Date(acct.last_seen_at).toLocaleDateString()}
)} {!acct.verified && (
Email-matched (not OAuth-verified)
)}
{acct.protocol !== 'matrix' && ( )}
))} {/* If no Slack account, show Link Slack button */} {accounts && !accounts.find(a => a.protocol === 'slack') && ( )}
); } function ProfileModal({ me, onClose }) { const [name, setName] = useState(me.display_name || ''); const [busy, setBusy] = useState(false); const [pwOld, setPwOld] = useState(''); const [pwNew, setPwNew] = useState(''); const [pwBusy, setPwBusy] = useState(false); const [msg, setMsg] = useState(''); // Avatar variant state — seeded from me (which now includes avatar_dark_url etc.) const [darkUrl, setDarkUrl] = useState(me.avatar_dark_url || null); const [lightUrl, setLightUrl] = useState(me.avatar_light_url || null); const [pref, setPref] = useState(me.avatar_preference || 'auto'); const [prefBusy, setPrefBusy] = useState(false); // Team selection const [team, setTeam] = useState(me.team || ''); const [teams, setTeams] = useState([]); const [teamBusy, setTeamBusy] = useState(false); // Work location const [workLocation, setWorkLocation] = useState(me.work_location || 'office'); const [remoteDays, setRemoteDays] = useState((me.remote_days || '').split(',').filter(Boolean)); const [locBusy, setLocBusy] = useState(false); // Contact fields const [cellPhone, setCellPhone] = useState(me.cell_phone || ''); const [altPhone, setAltPhone] = useState(me.alt_phone || ''); const [privateEmail, setPrivateEmail] = useState(me.private_email || ''); const [contactBusy, setContactBusy] = useState(false); // CP5 smart-routing: preferred DM channel const [dmProto, setDmProto] = useState(me.preferred_protocol || 'auto'); const [dmProtoBusy, setDmProtoBusy] = useState(false); useEffect(() => { fetch('/api/teams', { credentials: 'include' }) .then(r => r.ok ? r.json() : []) .then(data => setTeams(data)) .catch(() => {}); }, []); const saveTeam = async (newTeam) => { setTeamBusy(true); setMsg(''); try { const userId = me.user_id || me.id; const r = await fetch(`/api/employees/${userId}`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ team: newTeam || null }), }); if (!r.ok) throw new Error(await r.text()); setTeam(newTeam); setMsg('✓ Team saved'); if (window.STATUS_DATA?.ME) window.STATUS_DATA.ME.team = newTeam || null; } catch(e) { setMsg(`✗ ${e.message || e}`); } finally { setTeamBusy(false); } }; const saveWorkLocation = async () => { setLocBusy(true); setMsg(''); try { const userId = me.user_id || me.id; const r = await fetch(`/api/users/${userId}/work-location`, { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: workLocation, remote_days: workLocation === 'hybrid' ? remoteDays.join(',') : null, }), }); if (!r.ok) throw new Error(await r.text()); setMsg('Work location saved'); if (window.STATUS_DATA?.ME) { window.STATUS_DATA.ME.work_location = workLocation; window.STATUS_DATA.ME.remote_days = workLocation === 'hybrid' ? remoteDays.join(',') : null; } } catch(e) { setMsg(`Error: ${e.message || e}`); } finally { setLocBusy(false); } }; const saveName = async () => { setBusy(true); setMsg(''); try { const r = await fetch('/api/auth/me/profile', { method:'PUT', credentials:'include', headers:{'Content-Type':'application/json'}, body: JSON.stringify({display_name: name}), }); if (!r.ok) throw new Error(await r.text()); setMsg('✓ Name saved'); if (window.STATUS_DATA?.ME) window.STATUS_DATA.ME.display_name = name; } catch(e){ setMsg(`✗ ${e.message || e}`); } finally { setBusy(false); } }; const savePref = async (newPref) => { setPref(newPref); setPrefBusy(true); try { await fetch('/api/users/me/avatar-preference', { method:'PUT', credentials:'include', headers:{'Content-Type':'application/json'}, body: JSON.stringify({preference: newPref}), }); if (window.STATUS_DATA?.ME) window.STATUS_DATA.ME.avatar_preference = newPref; } catch(_) {} finally { setPrefBusy(false); } }; const onUploaded = (variant, url) => { if (variant === 'dark') { setDarkUrl(url); if (window.STATUS_DATA?.ME) window.STATUS_DATA.ME.avatar_dark_url = url; } if (variant === 'light') { setLightUrl(url); if (window.STATUS_DATA?.ME) window.STATUS_DATA.ME.avatar_light_url = url; } setMsg('✓ Avatar uploaded'); }; const onRemoved = (variant) => { if (variant === 'dark') { setDarkUrl(null); if (window.STATUS_DATA?.ME) window.STATUS_DATA.ME.avatar_dark_url = null; } if (variant === 'light') { setLightUrl(null); if (window.STATUS_DATA?.ME) window.STATUS_DATA.ME.avatar_light_url = null; } setMsg('✓ Avatar removed'); }; const saveContact = async () => { setContactBusy(true); setMsg(''); try { const userId = me.user_id || me.id; const r = await fetch(`/api/employees/${userId}/profile`, { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cell_phone: cellPhone || null, alt_phone: altPhone || null, private_email: privateEmail || null, }), }); if (!r.ok) { const j = await r.json().catch(()=>({})); throw new Error(j.detail || `${r.status}`); } setMsg('Contact saved'); if (window.STATUS_DATA?.ME) { window.STATUS_DATA.ME.cell_phone = cellPhone || null; window.STATUS_DATA.ME.alt_phone = altPhone || null; window.STATUS_DATA.ME.private_email = privateEmail || null; } } catch(e) { setMsg(`Error: ${e.message || e}`); } finally { setContactBusy(false); } }; const saveDmProto = async (newProto) => { setDmProtoBusy(true); setMsg(''); try { const userId = me.user_id || me.id; const r = await fetch(`/api/employees/${userId}/profile`, { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ preferred_protocol: newProto === 'auto' ? null : newProto }), }); if (!r.ok) { const j = await r.json().catch(()=>({})); throw new Error(j.detail || `${r.status}`); } setDmProto(newProto); setMsg('DM channel preference saved'); if (window.STATUS_DATA?.ME) window.STATUS_DATA.ME.preferred_protocol = newProto === 'auto' ? null : newProto; } catch(e) { setMsg(`Error: ${e.message || e}`); } finally { setDmProtoBusy(false); } }; const changePw = async () => { if (!pwNew || pwNew.length < 6) { setMsg('✗ New password must be at least 6 chars'); return; } setPwBusy(true); setMsg(''); try { const r = await fetch('/api/auth/change-password', { method:'POST', credentials:'include', headers:{'Content-Type':'application/json'}, body: JSON.stringify({old_password: pwOld, new_password: pwNew}), }); if (!r.ok) { const j = await r.json().catch(()=>({})); throw new Error(j.detail || `${r.status}`); } setMsg('✓ Password changed'); setPwOld(''); setPwNew(''); } catch(e){ setMsg(`✗ ${e.message || e}`); } finally { setPwBusy(false); } }; // Esc to close + body scroll lock useEffect(() => { const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; const onKey = (e) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', onKey); return () => { document.body.style.overflow = prev; document.removeEventListener('keydown', onKey); }; }, []); return (
e.stopPropagation()} style={{background:'var(--bg-elev-1)', border:'1px solid var(--line-hi)', borderRadius: 12, width:'100%', maxWidth: 520, maxHeight: '90vh', overflowY:'auto', padding: 24, boxShadow:'0 20px 60px -10px #000a'}}>
Your profile
{/* Avatar variants */}
Profile picture
Show variant
{['auto','dark','light'].map(v => ( ))}
Auto: uses dark variant in dark theme, light variant in light theme. Max 2 MB, PNG/JPG/WEBP.
Leave blank to use your K9 Chat profile picture automatically.
{/* Display name */}
setName(e.target.value)} style={{flex:1}}/>
{/* Team */}
{/* Work location */}
{workLocation === 'hybrid' && (
Remote days (tick days you work remotely)
{['Mon','Tue','Wed','Thu','Fri','Sat','Sun'].map((label, idx) => { const checked = remoteDays.includes(String(idx)); return ( ); })}
)}
{/* Contact */}
Contact
setCellPhone(e.target.value)}/>
setAltPhone(e.target.value)}/>
setPrivateEmail(e.target.value)}/>
{/* CP5 — Preferred DM channel */}
Preferred DM channel
Where the bot sends you direct messages. Auto uses your most recently active account.
{dmProtoBusy && Saving...}
{/* Linked accounts */} {/* Change password */}
Change password
setPwOld(e.target.value)}/> setPwNew(e.target.value)}/>
{msg &&
{msg}
}
); } /* ============================================================ BOTTOM TAB BAR — mobile only (<768px), fixed to viewport bottom Shows 5 key tabs: Today / Employees / Requests / Messages / Server ============================================================ */ const BOTTOM_TABS = [ { id: 'today', label: 'Today', icon: 'today' }, { id: 'employees', label: 'Employees', icon: 'team' }, { id: 'pto', label: 'Requests', icon: 'pto' }, { id: 'oncall', label: 'On-call', icon: 'clock' }, { id: 'support', label: 'Support', icon: 'info' }, ]; function BottomTabBar({ active, setActive }) { return ( <> ); } // B2: collapsible nav sections, per-group state persisted to localStorage. function NavSections({ collapsed, active, setActive, counts }) { const [openMap, setOpenMap] = useState(() => { try { const raw = localStorage.getItem('nav_section_open'); if (raw) return JSON.parse(raw); } catch (_) {} return Object.fromEntries(NAV_GROUPS.map(g => [g.label, true])); }); const persist = (next) => { setOpenMap(next); try { localStorage.setItem('nav_section_open', JSON.stringify(next)); } catch (_) {} }; const toggle = (label) => persist({ ...openMap, [label]: !openMap[label] }); // Gate adminOnly entries by current user's role. Admin tier includes // moderator + superadmin so promoted users see admin-only sidebar // items (Server, Bots, Bot Behavior, etc.). const meRole = String(((window.STATUS_DATA || {}).ME || {}).org_role || '').toLowerCase(); const isAdmin = meRole === 'admin' || meRole === 'owner' || meRole === 'moderator' || meRole === 'superadmin'; return ( ); } window.BottomTabBar = BottomTabBar; window.Sidebar = Sidebar; window.Topbar = Topbar; window.CommandPalette = CommandPalette; window.LoginPage = LoginPage; window.NAV_GROUPS = NAV_GROUPS;