/* ============================================================
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 (