/* ============================================================ primitives.jsx — shared building blocks ============================================================ */ const { useState, useEffect, useRef, useMemo, useCallback } = React; const D = window.STATUS_DATA; /* ============================================================ Phase 1 — admin Edit Mode (cross-component coordination) ============================================================ No module system here, so we use a window-level flag plus a custom event so any page/component can opt-in via useEditMode(). - window.__editMode__: boolean - 'edit-mode-change' CustomEvent fires whenever toggled - useEditMode() returns the current bool and re-renders on change - setEditMode(bool) flips the flag globally + persists to localStorage */ function setEditMode(on) { const v = !!on; window.__editMode__ = v; try { localStorage.setItem('admin_edit_mode', v ? '1' : '0'); } catch (_) {} window.dispatchEvent(new CustomEvent('edit-mode-change', { detail: v })); } // Restore from localStorage on first load. try { if (window.__editMode__ === undefined) { window.__editMode__ = localStorage.getItem('admin_edit_mode') === '1'; } } catch (_) { window.__editMode__ = false; } function useEditMode() { const [on, setOn] = useState(() => !!window.__editMode__); useEffect(() => { const handler = (e) => setOn(!!e.detail); window.addEventListener('edit-mode-change', handler); return () => window.removeEventListener('edit-mode-change', handler); }, []); return on; } /* canEditUser(meRole, meTeam, targetTeam) Mirrors backend _can_edit_user: admin → any, manager → same team. */ function canEditUser(meRole, meTeam, targetTeam) { const r = (meRole || '').toLowerCase(); if (r === 'admin') return true; if (r !== 'manager') return false; if (!meTeam) return false; return (meTeam || '') === (targetTeam || ''); } /* showUndoToast({ token, label, onUndone }) Pops a 90-second toast with an Undo button. Calls POST /api/admin/undo/{token}. On success, fires `onUndone` so the caller can refresh state. Multiple toasts stack vertically. */ function _ensureToastContainer() { let c = document.getElementById('admin-undo-toasts'); if (c) return c; c = document.createElement('div'); c.id = 'admin-undo-toasts'; c.style.cssText = 'position:fixed;bottom:24px;right:24px;z-index:10000;display:flex;flex-direction:column;gap:8px;pointer-events:none;'; document.body.appendChild(c); return c; } function showUndoToast({ token, label, onUndone }) { if (!token) return; const root = _ensureToastContainer(); const el = document.createElement('div'); el.style.cssText = 'pointer-events:auto;background:var(--bg-elev-2,#222);color:var(--text,#fff);padding:10px 14px;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.3);display:flex;align-items:center;gap:12px;font-size:13px;min-width:280px;border:1px solid var(--line,#333);'; el.innerHTML = `${label || (window.appT||(s=>s))('Change saved.')}`; const btn = document.createElement('button'); btn.textContent = (window.appT||(s=>s))('Undo'); btn.style.cssText = 'background:var(--brand,#4af);color:#fff;border:none;padding:4px 10px;border-radius:4px;cursor:pointer;font-weight:600;'; btn.onclick = async () => { btn.disabled = true; btn.textContent = '…'; try { const r = await fetch(`/api/admin/undo/${encodeURIComponent(token)}`, { method: 'POST', credentials: 'same-origin', }); if (!r.ok) throw new Error(`HTTP ${r.status}`); btn.textContent = '✓'; if (typeof onUndone === 'function') onUndone(); setTimeout(() => el.remove(), 800); } catch (e) { btn.textContent = (window.appT||(s=>s))('Failed'); btn.style.background = 'var(--err,#e44)'; } }; el.appendChild(btn); root.appendChild(el); // Auto-dismiss after 90 s. setTimeout(() => { try { el.remove(); } catch (_) {} }, 90000); } /* Inline-editable cell that swaps in an on click when edit mode is on. - kind: 'time' | 'datetime' | 'text' | 'select' - options (when kind='select'): array of {value, label} - formatter: (value) => string for display - onSave(newValue) → Promise; should resolve when persisted - readOnly: hard override (e.g. when caller can't edit this user) When edit mode is OFF or readOnly is true, renders the formatted value as plain text. */ function EditableCell({ value, onSave, kind = 'text', options, formatter, readOnly, placeholder }) { const editMode = useEditMode(); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(''); const [busy, setBusy] = useState(false); const display = formatter ? formatter(value) : (value ?? '—'); if (!editMode || readOnly) { return {display}; } const start = () => { setDraft(value == null ? '' : String(value)); setEditing(true); }; const cancel = () => { setEditing(false); setDraft(''); }; const commit = async () => { if (busy) return; setBusy(true); try { await onSave(draft); setEditing(false); } catch (e) { // Leave the input open so the user can retry. } finally { setBusy(false); } }; const onKey = (e) => { if (e.key === 'Enter') commit(); else if (e.key === 'Escape') cancel(); }; if (editing) { if (kind === 'select') { return ( ); } return ( setDraft(e.target.value)} onBlur={commit} onKeyDown={onKey} disabled={busy} /> ); } return ( s))("Click to edit")} style={{ cursor:'pointer', borderBottom:'1px dashed var(--text-3,#888)', paddingBottom:1 }}> {display} ); } /* useTheme — returns 'dark' or 'light' by watching documentElement[data-theme]. Re-renders callers when theme toggles (sun/moon button). */ function useTheme() { const [theme, setTheme] = useState(() => document.documentElement.dataset.theme || 'dark' ); useEffect(() => { const el = document.documentElement; const observer = new MutationObserver(() => { setTheme(el.dataset.theme || 'dark'); }); observer.observe(el, { attributes: true, attributeFilter: ['data-theme'] }); return () => observer.disconnect(); }, []); return theme; } /* resolveAvatarUrl — pick the right variant URL for a person object. Falls back: variant → other variant → legacy avatar_url → null (initials). person shape: { avatar_dark_url, avatar_light_url, avatar_preference, avatar_url } */ function resolveAvatarUrl(person) { if (!person) return null; const dark = person.avatar_dark_url || null; const light = person.avatar_light_url || null; const legacy = person.avatar_url || null; // Priority 2: Synapse-sourced avatar (fallback when no custom upload) const synapse = person.synapse_avatar_url || null; const pref = person.avatar_preference || 'auto'; let variant; if (pref === 'dark') variant = 'dark'; else if (pref === 'light') variant = 'light'; else { // auto — check current theme variant = (document.documentElement.dataset.theme || 'dark') === 'dark' ? 'dark' : 'light'; } // P0 #1: prefer the real Synapse photo over the locally-generated data: // URL initials; data: URLs are fallback avatars produced when no real // image was uploaded, so they lose to the chat.k9.ms profile photo. const isInitialsDataUrl = (s) => typeof s === 'string' && s.startsWith('data:image/'); const realLegacy = isInitialsDataUrl(legacy) ? null : legacy; if (variant === 'dark') return dark || light || synapse || realLegacy || legacy || null; if (variant === 'light') return light || dark || synapse || realLegacy || legacy || null; return synapse || realLegacy || legacy || null; } /* Avatar */ function Avatar({ person, size = 28, status, showPip = true }) { if (!person) return null; // Re-render when theme changes (affects 'auto' preference avatars) const theme = useTheme(); const [c1, c2] = person.avatar || ['#3b66f5', '#1d3286']; const inits = person.initials || (person.display_name || '?').slice(0, 1); const pipColor = status === 'present' ? 'var(--ok)' : status === 'late' ? 'var(--warn)' : status === 'absent' ? 'var(--err)' : 'var(--text-3)'; const avatarUrl = resolveAvatarUrl(person); if (avatarUrl) { return (
{showPip && status && }
); } return (
{inits} {showPip && status && }
); } /* Person cell (avatar + name + K9 ID) */ function PersonCell({ person, status, sub, onClick }) { // #35 fix: default behavior is to open the user's profile drawer. // Every call site rendering a PersonCell now opens the user when // clicked — Today's roster, Top 10, late list, on-call rotation, // etc. Call sites that need a custom action still pass onClick. const _handleClick = onClick || ((ev) => { try { ev.stopPropagation(); } catch (_) {} try { window.dispatchEvent(new CustomEvent('request-open-user', { detail: { user_id: person?.id ?? person?.user_id ?? null, matrix_id: person?.matrix_id || null, display_name: person?.display_name || null, }, })); } catch (_) {} }); return (
{person.display_name}
{sub || person.matrix_id}
); } /* Badge */ function Badge({ kind = 'mute', dot = false, children, style }) { return ( {dot && } {children} ); } function StatusBadge({ status }) { if (status === 'present') return {(window.appT||(s=>s))('present')}; if (status === 'late') return {(window.appT||(s=>s))('late')}; if (status === 'absent') return {(window.appT||(s=>s))('absent')}; // 'signed-off' = day complete (user signed in earlier and signed off). // Distinct from 'present' (which now means "still at the desk"). if (status === 'signed-off') return {(window.appT||(s=>s))('signed off')}; if (status === 'late-off') return {(window.appT||(s=>s))('late · signed off')}; if (status === 'pto') return {(window.appT||(s=>s))('pto')}; if (status === 'pending') return {(window.appT||(s=>s))('pending')}; if (status === 'approved')return {(window.appT||(s=>s))('approved')}; if (status === 'denied') return {(window.appT||(s=>s))('denied')}; if (status === 'active') return {(window.appT||(s=>s))('active')}; if (status === 'inactive')return {(window.appT||(s=>s))('inactive')}; return {status}; } /* Section header #5: optional `tip` shows a small (i) glyph next to the title that reveals an on-hover tooltip explaining what the section measures. Call sites that omit `tip` keep the existing layout. */ function SectionHeader({ title, sub, right, tip }) { return (
{title} {tip && ( s))('Help')} style={{ marginLeft: 6, fontSize: 11, opacity: 0.7, cursor: 'help', display: 'inline-block', position: 'relative', borderRadius: '50%', width: 15, height: 15, lineHeight: '15px', textAlign: 'center', border: '1px solid currentColor', verticalAlign: 'middle', fontWeight: 600, }} > ? )}
{sub &&
{sub}
}
{right &&
{right}
}
); } /* Page header */ function PageHeader({ title, meta, right }) { return (

{title}

{meta &&
{meta}
}
{right &&
{right}
}
); } /* KPI card */ function KPI({ label, value, denom, trend, color, accent, sparkData, icon }) { const fillColor = color || 'var(--brand)'; const max = sparkData ? Math.max(...sparkData, 1) : 1; const pct = denom ? Math.min(100, (Number(value) / Number(denom)) * 100) : null; return (
{label}
{icon && {icon}}
{value} {denom != null && / {denom}}
{trend && (
{trend.dir === 'up' ? '↑' : trend.dir === 'down' ? '↓' : '·'} {trend.value} {trend.label}
)} {sparkData && (