// App shell: left sidebar + glass top bar + content area.
(() => {
const { IconButton, Avatar, Badge } = window.TechyFuelOSDesignSystem_be0222;
// API timestamps are UTC "YYYY-MM-DD HH:MM:SS" with no zone; a bare new Date()
// reads them as local, showing the wrong time. Mark as UTC, then format local.
function fmtNotifTime(ts) {
if (!ts) return '';
let s = String(ts).replace(' ', 'T');
if (!/[zZ]|[+-]\d\d:?\d\d$/.test(s)) s += 'Z';
const d = new Date(s);
return isNaN(d) ? '' : d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
// ── Notification & ring sounds (WebAudio — no external files, CSP-safe) ──────
// A shared AudioContext, unlocked on the first user gesture (browsers block
// autoplay until then). playDing = short two-note chime for any new
// notification (message/task/etc). startRinging/stopRinging = a repeating
// ring tone for an incoming call, looping until the call is answered or ends.
const TFSound = (() => {
let ctx = null;
let ringTimer = null;
function ac() {
if (!ctx) { try { ctx = new (window.AudioContext || window.webkitAudioContext)(); } catch {} }
if (ctx && ctx.state === 'suspended') { try { ctx.resume(); } catch {} }
return ctx;
}
// Unlock audio on first interaction so later programmatic sounds are allowed.
function unlock() { ac(); window.removeEventListener('pointerdown', unlock); window.removeEventListener('keydown', unlock); }
window.addEventListener('pointerdown', unlock);
window.addEventListener('keydown', unlock);
function tone(freq, start, dur, gain) {
const c = ac(); if (!c) return;
const o = c.createOscillator(), g = c.createGain();
o.type = 'sine'; o.frequency.value = freq;
o.connect(g); g.connect(c.destination);
const t = c.currentTime + start;
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(gain, t + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.start(t); o.stop(t + dur + 0.02);
}
function playDing() { try { tone(880, 0, 0.16, 0.18); tone(1174, 0.13, 0.22, 0.16); } catch {} }
// One "brring": a pair of pulses, like a phone ring.
function ringOnce() { try { tone(480, 0, 0.4, 0.2); tone(620, 0.15, 0.4, 0.18); } catch {} }
function startRinging() {
if (ringTimer) return;
ringOnce();
ringTimer = setInterval(ringOnce, 2600); // repeat until stopped
}
function stopRinging() { if (ringTimer) { clearInterval(ringTimer); ringTimer = null; } }
return { playDing, startRinging, stopRinging };
})();
if (typeof window !== 'undefined') window.TFSound = TFSound;
const TF_NAV = [
{ group: 'Workspace', items: [
{ id: 'dashboard', label: 'Dashboard', icon: 'layout-dashboard' },
{ id: 'tasks', label: 'Tasks', icon: 'circle-check-big' },
{ id: 'projects', label: 'Projects', icon: 'folder-kanban' },
{ id: 'calendar', label: 'Calendar', icon: 'calendar-days' },
{ id: 'my-attendance', label: 'My Attendance', icon: 'clock' },
]},
{ group: 'Clients', items: [
{ id: 'crm', label: 'Client CRM', icon: 'contact', adminOnly: true },
{ id: 'pipeline', label: 'Sales pipeline', icon: 'filter', adminOnly: true },
{ id: 'portal', label: 'Client portal', icon: 'panel-left-open', adminOnly: true },
]},
{ group: 'Marketing', items: [
{ id: 'content', label: 'Content', icon: 'calendar-clock' },
{ id: 'ads', label: 'Meta Ads', icon: 'megaphone', adminOnly: true },
]},
{ group: 'Communications', items: [
{ id: 'email', label: 'Email', icon: 'mail' },
{ id: 'chat', label: 'Team Chat', icon: 'message-square' },
{ id: 'announcements', label: 'Announcements', icon: 'megaphone' },
]},
{ group: 'Business', items: [
{ id: 'finance', label: 'Finance', icon: 'wallet', ownerAdminOnly: true },
{ id: 'reports', label: 'Reports', icon: 'chart-line', ownerAdminOnly: true },
{ id: 'docs', label: 'Docs & Files', icon: 'file-text' },
{ id: 'team', label: 'Team', icon: 'users' },
{ id: 'employees', label: 'Employees', icon: 'id-card', adminOnly: true },
{ id: 'approvals', label: 'Approvals', icon: 'check-check' },
{ id: 'automations', label: 'Automations', icon: 'zap', adminOnly: true },
{ id: 'activity', label: 'Activity Log', icon: 'activity', adminOnly: true },
{ id: 'integrations', label: 'Integrations', icon: 'plug', adminOnly: true },
{ id: 'workspace', label: 'Workspace', icon: 'briefcase', adminOnly: true },
{ id: 'settings', label: 'Settings', icon: 'settings' },
{ id: 'super-admin', label: 'Super Admin', icon: 'shield', superAdminOnly: true },
]},
];
function readTFSettings() {
try { return JSON.parse(localStorage.getItem('tf_settings') || '{}'); } catch { return {}; }
}
function SidebarItem({ item, active, onClick }) {
const [hover, setHover] = React.useState(false);
return (
setHover(true)} onMouseLeave={() => setHover(false)}
style={{
display: 'flex', alignItems: 'center', gap: 10, width: '100%', textAlign: 'left',
padding: '8px 10px', borderRadius: 'var(--radius-md)', border: 'none', cursor: 'pointer',
fontFamily: 'var(--font-sans)', fontSize: 'var(--text-base)', fontWeight: 'var(--fw-medium)',
background: active ? 'var(--blue-50)' : hover ? 'var(--slate-100)' : 'transparent',
color: active ? 'var(--blue-700)' : 'var(--text-body)',
transition: 'background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out)',
}}
>
{item.label}
{item.badge && (
{item.badge}
)}
);
}
function Sidebar({ active, onNavigate, hiddenIds, mobileOpen, onCloseMobile, isSuperAdmin }) {
const s0 = readTFSettings();
const [agencyName, setAgencyName] = React.useState(s0.agencyName || '');
const [logoUrl, setLogoUrl] = React.useState(s0.logoUrl || '');
const [teamCount, setTeamCount] = React.useState(null);
const [taskBadge, setTaskBadge] = React.useState(null);
const [emailBadge, setEmailBadge] = React.useState(null);
const [chatBadge, setChatBadge] = React.useState(null);
const [annBadge, setAnnBadge] = React.useState(null);
// Workspace switcher: a user invited into another agency belongs to more than
// one workspace, but before this there was no way to switch — they were stuck
// in whichever workspace was active and never saw the tasks/messages waiting
// for them in the other one. This lists all their workspaces and switches.
const [workspaces, setWorkspaces] = React.useState([]);
const [wsOpen, setWsOpen] = React.useState(false);
React.useEffect(() => {
if (window.API) {
(async () => {
try {
if (window.API.getWorkspaces) {
const { data: ws } = await window.API.getWorkspaces();
if (Array.isArray(ws)) setWorkspaces(ws);
}
} catch {}
try {
const { data: team } = await window.API.getTeam();
if (Array.isArray(team)) setTeamCount(team.length);
} catch {}
try {
const { data: tasks } = await window.API.getTasks();
if (Array.isArray(tasks)) {
const open = tasks.filter(t => t.status !== 'done' && t.status !== 'completed').length;
setTaskBadge(open > 0 ? String(open) : null);
}
} catch {}
})();
}
async function checkEmailUnread() {
try {
const n = await window.getTotalUnreadEmailCount();
setEmailBadge(n > 0 ? String(n) : null);
} catch {}
}
checkEmailUnread();
const emailTimer = setInterval(checkEmailUnread, 60000);
// Team Chat nav badge: number of unread chat/DM notifications, so an
// unopened new message shows a count on the sidebar the same way Tasks does.
async function checkChat() {
if (!window.API || !window.API.getNotifications || !localStorage.getItem('tf_auth_token')) return;
try {
const { data } = await window.API.getNotifications(window.TFMyMemberId || undefined, 50);
const n = Array.isArray(data) ? data.filter(x => x.type === 'chat' && !x.read).length : 0;
setChatBadge(n > 0 ? String(n) : null);
} catch {}
}
checkChat();
const chatTimer = setInterval(checkChat, 20000);
// Announcements nav badge: count of unread 'announcement' notifications.
async function checkAnnouncements() {
if (!window.API || !window.API.getNotifications || !localStorage.getItem('tf_auth_token')) return;
try {
const { data } = await window.API.getNotifications(window.TFMyMemberId || undefined, 50);
const n = Array.isArray(data) ? data.filter(x => x.type === 'announcement' && !x.read).length : 0;
setAnnBadge(n > 0 ? String(n) : null);
} catch {}
}
checkAnnouncements();
const annTimer = setInterval(checkAnnouncements, 30000);
// Opening the Announcements screen clears its badge immediately.
window.TFClearAnnouncementsBadge = () => setAnnBadge(null);
const onAnnSeen = () => setAnnBadge(null);
window.addEventListener('tf-announcements-opened', onAnnSeen);
// When the user opens Team Chat / reads notifications, clear the badge fast.
const onChatSeen = () => setChatBadge(null);
window.addEventListener('tf-chat-opened', onChatSeen);
window.addEventListener('tf-notifs-read', onChatSeen);
function onSettingsChange() {
const s = readTFSettings();
setAgencyName(s.agencyName || '');
setLogoUrl(s.logoUrl || '');
}
window.addEventListener('tf-settings-saved', onSettingsChange);
window.addEventListener('storage', onSettingsChange);
return () => {
window.removeEventListener('tf-settings-saved', onSettingsChange);
window.removeEventListener('storage', onSettingsChange);
window.removeEventListener('tf-chat-opened', onChatSeen);
window.removeEventListener('tf-notifs-read', onChatSeen);
window.removeEventListener('tf-announcements-opened', onAnnSeen);
clearInterval(emailTimer);
clearInterval(chatTimer);
clearInterval(annTimer);
};
}, []);
const displayName = agencyName || 'My Agency';
// Owner/admin/manager see the full business/admin nav; a plain member (or
// guest) only gets their own workspace + communication screens — no revenue,
// clients, reports or other admin surfaces. Super admins always see all.
// team_lead runs delivery for their team, so it belongs with the admin-ish
// group for nav purposes (CRM, employees) but NOT with finance below.
const isAdminish = isSuperAdmin || ['owner', 'admin', 'manager', 'team_lead'].includes(window.TFMyRole);
// Finance/invoices is owner+admin only — a manager sees employee management
// but NOT invoices or payroll (payroll tab is gated inside Employees.jsx).
// Finance nav (invoices, revenue, reports). The dedicated 'finance' role sees
// these; project managers and team leads deliberately do not.
const isOwnerAdmin = isSuperAdmin || ['owner', 'admin', 'finance'].includes(window.TFMyRole);
const navWithBadge = TF_NAV.map(g => ({
...g,
items: g.items
.filter(it => !(hiddenIds || []).includes(it.id))
.filter(it => !it.superAdminOnly || isSuperAdmin)
.filter(it => !it.ownerAdminOnly || isOwnerAdmin)
.filter(it => !it.adminOnly || isAdminish)
.map(it => it.id === 'tasks' ? { ...it, badge: taskBadge || undefined } : it.id === 'email' ? { ...it, badge: emailBadge || undefined } : it.id === 'chat' ? { ...it, badge: chatBadge || undefined } : it.id === 'announcements' ? { ...it, badge: annBadge || undefined } : it),
})).filter(g => g.items.length > 0);
return (
<>
{mobileOpen &&
}
>
);
}
// Quick self check-in / check-out shown in the top bar for every logged-in
// user who is in the employee directory (matched by email server-side). Hidden
// for anyone not linked to an employee record.
function AttendanceHeaderButton() {
const [att, setAtt] = React.useState(null);
const [linked, setLinked] = React.useState(true);
const [busy, setBusy] = React.useState(false);
const [loaded, setLoaded] = React.useState(false);
const [open, setOpen] = React.useState(false);
const ref = React.useRef(null);
React.useEffect(() => {
if (!window.API || !window.API.getMyAttendance) { setLinked(false); return; }
(async () => {
try { const r = await window.API.getMyAttendance(); setAtt(r.data || null); setLinked(r.linked !== false); }
catch { setLinked(false); }
finally { setLoaded(true); }
})();
}, []);
React.useEffect(() => {
function onOut(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }
document.addEventListener('mousedown', onOut);
return () => document.removeEventListener('mousedown', onOut);
}, []);
async function act(fn) {
if (busy || !window.API || !fn) return;
setBusy(true);
try {
const r = await fn();
if (r.data) setAtt(r.data); else if (r.message) alert(r.message);
} catch { alert('Could not update attendance. Please try again.'); }
setBusy(false);
}
if (!loaded || !linked) return null;
const checkedIn = !!att?.check_in;
const checkedOut = !!att?.check_out;
const onBreak = !checkedOut && Array.isArray(att?.breaks) && att.breaks.some(b => b && b.in && !b.out);
const fmtT = (t) => { if (!t) return '—'; try { return new Date(String(t).replace(' ', 'T') + (String(t).includes('Z') ? '' : 'Z')).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); } catch { return '—'; } };
const dotColor = checkedOut ? 'var(--green-500)' : onBreak ? 'var(--amber-500)' : checkedIn ? 'var(--blue-500)' : 'var(--slate-400)';
const btn = (label, bg, fn) => (
act(fn)} disabled={busy} style={{
flex: 1, height: 38, borderRadius: 'var(--radius-md)', border: 'none', cursor: busy ? 'wait' : 'pointer',
background: bg, color: '#fff', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-xs)', fontWeight: 'var(--fw-bold)' }}>
{busy ? '…' : label}
);
return (
{/* Opens a dropdown — never acts on the plain tap. */}
setOpen(o => !o)} title="Attendance" style={{
display: 'inline-flex', alignItems: 'center', gap: 7, height: 36, padding: '0 12px', borderRadius: 'var(--radius-md)',
border: '1px solid var(--border-subtle)', background: open ? 'var(--slate-100)' : 'transparent', cursor: 'pointer',
color: 'var(--text-body)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-sm)', fontWeight: 'var(--fw-semibold)' }}>
Attendance
{open && (
Today's attendance
{checkedIn ? `Checked in: ${fmtT(att.check_in)}` : 'Not checked in yet'}
{onBreak ? <>On break > : ''}
{(att?.break_minutes ? true : false) ? <> {`Break: ${att.break_minutes} min`}> : ''}
{checkedOut ? <> {`Checked out: ${fmtT(att.check_out)}`}> : ''}
{(checkedOut && att?.worked_hours) ? <> {`Worked: ${Number(att.worked_hours)}h`}> : ''}
{att?.is_late ? <>Marked late > : ''}
{checkedOut ? (
Done for today
{btn('Undo checkout', 'var(--slate-600)', window.API.selfUndoCheckout)}
{btn('Check in again', 'var(--green-600)', window.API.selfCheckInAgain)}
) : !checkedIn ? (
{btn('Check in', 'var(--green-600)', window.API.selfCheckIn)}
) : onBreak ? (
{btn('End break', 'var(--amber-600)', window.API.selfBreakOut)}{btn('Check out', 'var(--slate-800)', window.API.selfCheckOut)}
) : (
{btn('Start break', 'var(--amber-600)', window.API.selfBreakIn)}{btn('Check out', 'var(--slate-800)', window.API.selfCheckOut)}
)}
{/* Link to the full My Attendance page (history + break sessions). */}
{ setOpen(false); try { window.TFNavigate && window.TFNavigate('my-attendance'); } catch {} }}
style={{ width: '100%', marginTop: 10, height: 34, background: 'transparent', border: '1px solid var(--border-subtle)', borderRadius: 'var(--radius-md)', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, color: 'var(--text-body)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-xs)', fontWeight: 'var(--fw-semibold)' }}>
My attendance
)}
);
}
function TopBar({ title, crumb, onOpenAI, onQuickAdd, onNavigate, authUser, onSignOut, onToggleNav }) {
const s0 = readTFSettings();
const [agencyName, setAgencyName] = React.useState(s0.agencyName || '');
const [searchOpen, setSearchOpen] = React.useState(false);
const [notifOpen, setNotifOpen] = React.useState(false);
const [avatarOpen, setAvatarOpen] = React.useState(false);
const [notifs, setNotifs] = React.useState([]);
const [unreadCount, setUnreadCount] = React.useState(0);
const notifRef = React.useRef(null);
const avatarRef = React.useRef(null);
// Offline / pending-sync indicator. Driven by the api client's
// `tf-offline-change` events (and native online/offline), so it reflects both
// "no connection" and "N writes queued waiting to sync".
const [offlineState, setOfflineState] = React.useState({
online: typeof navigator !== 'undefined' ? navigator.onLine : true,
pending: (typeof window !== 'undefined' && window.__tfOfflinePending) || 0,
});
React.useEffect(() => {
function apply(e) {
const d = (e && e.detail) || {};
setOfflineState({
online: 'online' in d ? d.online : navigator.onLine,
pending: 'pending' in d ? d.pending : (window.__tfOfflinePending || 0),
});
}
function onNet() { apply({ detail: { online: navigator.onLine } }); }
window.addEventListener('tf-offline-change', apply);
window.addEventListener('online', onNet);
window.addEventListener('offline', onNet);
return () => {
window.removeEventListener('tf-offline-change', apply);
window.removeEventListener('online', onNet);
window.removeEventListener('offline', onNet);
};
}, []);
React.useEffect(() => {
function onKey(e) {
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); setSearchOpen(true); }
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
React.useEffect(() => {
function onSettingsChange() {
const s = readTFSettings();
setAgencyName(s.agencyName || '');
}
window.addEventListener('tf-settings-saved', onSettingsChange);
window.addEventListener('storage', onSettingsChange);
return () => {
window.removeEventListener('tf-settings-saved', onSettingsChange);
window.removeEventListener('storage', onSettingsChange);
};
}, []);
React.useEffect(() => {
function onClickOutside(e) {
if (notifRef.current && !notifRef.current.contains(e.target)) setNotifOpen(false);
if (avatarRef.current && !avatarRef.current.contains(e.target)) setAvatarOpen(false);
}
document.addEventListener('mousedown', onClickOutside);
return () => document.removeEventListener('mousedown', onClickOutside);
}, []);
const [emailUnread, setEmailUnread] = React.useState(0);
const prevUnreadRef = React.useRef(null); // to detect a NEW notification
const [incomingCall, setIncomingCall] = React.useState(null); // {id, channel_id, channelName, type}
const dismissedCallsRef = React.useRef(new Set()); // calls the user dismissed
// Poll unread count + play a sound when it goes UP (new notification).
React.useEffect(() => {
async function checkUnread() {
// Skip while signed out — otherwise the poll keeps hitting the API
// with no token and logs a 401 every minute after logout.
if (!window.API || !localStorage.getItem('tf_auth_token')) return;
try {
const count = await window.API.getUnreadCount(window.TFMyMemberId || undefined);
// A rise in the unread count means a new notification arrived — chime.
// First poll (prev === null) just seeds the baseline, no sound.
if (prevUnreadRef.current !== null && count > prevUnreadRef.current) {
try { window.TFSound && window.TFSound.playDing(); } catch {}
}
prevUnreadRef.current = count;
setUnreadCount(count);
} catch {}
}
async function checkEmailUnread() {
try { setEmailUnread(await window.getTotalUnreadEmailCount()); } catch {}
}
checkUnread();
checkEmailUnread();
const t = setInterval(checkUnread, 20000);
const te = setInterval(checkEmailUnread, 60000);
return () => { clearInterval(t); clearInterval(te); };
}, []);
// Poll for incoming calls (started by someone else in my channels) and RING.
React.useEffect(() => {
let stopped = false;
async function checkCalls() {
if (!window.API || !window.API.getActiveCalls || !localStorage.getItem('tf_auth_token')) return;
try {
const { data } = await window.API.getActiveCalls();
const calls = Array.isArray(data) ? data.filter(c => !dismissedCallsRef.current.has(c.id)) : [];
const call = calls[0];
if (call) {
if (stopped) return;
setIncomingCall(prev => {
if (!prev || prev.id !== call.id) { try { window.TFSound.startRinging(); } catch {} }
return {
id: call.id,
channel_id: call.channel_id,
channelName: call.caller_label || (call.channel && call.channel.name) || 'Team',
type: call.type || 'audio',
};
});
} else {
// No active call → stop any ringing and clear the banner.
setIncomingCall(prev => { if (prev) { try { window.TFSound.stopRinging(); } catch {} } return null; });
}
} catch {}
}
checkCalls();
const ct = setInterval(checkCalls, 4000); // ring responsiveness
return () => { stopped = true; clearInterval(ct); try { window.TFSound.stopRinging(); } catch {} };
}, []);
function answerIncomingCall() {
const c = incomingCall;
try { window.TFSound.stopRinging(); } catch {}
setIncomingCall(null);
if (c) {
// Take them to Team Chat + open that channel's call. TeamChat listens for
// this to auto-open the call modal on the ringing channel.
try { localStorage.setItem('tf_pending_call', JSON.stringify({ channelId: c.channel_id, channelName: c.channelName, type: c.type })); } catch {}
if (onNavigate) onNavigate('chat');
try { window.dispatchEvent(new CustomEvent('tf-answer-call', { detail: { channelId: c.channel_id, channelName: c.channelName, type: c.type } })); } catch {}
}
}
function dismissIncomingCall() {
if (incomingCall) dismissedCallsRef.current.add(incomingCall.id);
try { window.TFSound.stopRinging(); } catch {}
setIncomingCall(null);
}
function openNotifs() {
setNotifOpen(o => !o);
setAvatarOpen(false);
if (!notifOpen && window.API) {
(async () => {
try {
const myId = window.TFMyMemberId;
// Real notifications from DB
const { data: dbNotifs } = await window.API.getNotifications(myId || undefined, 20);
// Also grab overdue/upcoming tasks as smart notifications
const { data: tasks } = await window.API.getTasks();
const now = new Date();
const taskNotifs = (Array.isArray(tasks) ? tasks : [])
.filter(t => t.status !== 'done' && t.due_date)
.map(t => ({ id: 'task-' + t.id, type: new Date(t.due_date) < now ? 'task_overdue' : 'task_due', title: t.title, body: new Date(t.due_date) < now ? 'Overdue' : 'Due soon', _date: t.due_date, read: false, link_screen: 'tasks' }))
.filter(t => t.type === 'task_overdue' || (new Date(t._date) - now) < 48 * 3600 * 1000)
.sort((a, b) => new Date(a._date) - new Date(b._date))
.slice(0, 5);
const emailNotif = emailUnread > 0 ? [{ id: 'email-unread', type: 'email', title: `${emailUnread} new email${emailUnread === 1 ? '' : 's'}`, body: 'In your inbox', read: false, link_screen: 'email' }] : [];
const combined = [...emailNotif, ...(dbNotifs || []), ...taskNotifs].slice(0, 15);
setNotifs(combined);
if (myId) await window.API.markAllRead(myId);
setUnreadCount(0);
try { window.dispatchEvent(new Event('tf-notifs-read')); } catch {}
} catch {}
})();
}
}
// Open a notification's target. The key part: a notification can belong to a
// DIFFERENT workspace than the one currently active (notifications span all
// your workspaces). Navigating without switching would land you in the active
// workspace where that task/message doesn't exist — the "click karta hoon par
// chat/task nahi dikhta" bug, in EVERY workspace. So if the notification's
// workspace differs from the active one, switch first, then reload into the
// right screen; the pending-screen is picked up after reload.
async function openNotification(n, fallbackScreen) {
const screen = n.link_screen || fallbackScreen || 'dashboard';
const targetWs = n.workspace_id;
const activeWs = window.TFActiveWorkspaceId;
try {
if (targetWs && activeWs && targetWs !== activeWs && window.API && window.API.switchWorkspace) {
// Remember where to go after the reload the workspace switch triggers.
try {
localStorage.setItem('tf_active_screen', screen);
if (n.type === 'chat' && n.link_id) localStorage.setItem('tf_open_channel', n.link_id);
} catch {}
await window.API.switchWorkspace(targetWs);
location.reload();
return;
}
} catch {}
// Same workspace (or no switch available): just navigate, and if it's a chat
// notification tell TeamChat which channel to open.
if (n.type === 'chat' && n.link_id) {
try { localStorage.setItem('tf_open_channel', n.link_id); } catch {}
try { window.dispatchEvent(new CustomEvent('tf-open-channel', { detail: { channelId: n.link_id } })); } catch {}
}
if (onNavigate) onNavigate(screen);
}
const avatarName = authUser?.user_metadata?.full_name || agencyName || 'TF';
const avatarEmail = authUser?.email || '';
const dropStyle = {
position: 'absolute', top: 'calc(100% + 8px)', right: 0, zIndex: 200,
background: 'var(--slate-0)', border: '1px solid var(--border-subtle)',
borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-xl)',
minWidth: 260, overflow: 'hidden',
// Never overflow a phone screen: cap to viewport width. Panels open from the
// right edge (bell/avatar sit far right), so this keeps them fully on-screen.
maxWidth: 'calc(100vw - 20px)', boxSizing: 'border-box',
};
return (
<>
{incomingCall && (
Incoming {incomingCall.type === 'video' ? 'video' : 'voice'} call
{incomingCall.channelName}
)}
{/* Hamburger — mobile only */}
{/* Breadcrumb */}
onNavigate && onNavigate('dashboard')}
style={{ fontSize: 'var(--text-sm)', color: 'var(--text-muted)', cursor: 'pointer' }}
onMouseEnter={e => e.target.style.color = 'var(--blue-600)'}
onMouseLeave={e => e.target.style.color = 'var(--text-muted)'}
>{crumb}
{title}
{searchOpen && setSearchOpen(false)} onNavigate={onNavigate} onOpenAI={onOpenAI} />}
{/* Universal search */}
setSearchOpen(true)} style={{
display: 'flex', alignItems: 'center', gap: 8, width: 280, maxWidth: '32vw', height: 36, padding: '0 12px',
background: 'var(--slate-50)', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-md)',
cursor: 'pointer', fontFamily: 'var(--font-sans)', textAlign: 'left',
}}>
Search or ask AI…
⌘K
{/* Offline / pending-sync indicator — only shows when relevant */}
{(!offlineState.online || offlineState.pending > 0) && (
{ if (offlineState.online && window.API && window.API.offline) window.API.offline.sync(); }}
title={!offlineState.online
? (offlineState.pending > 0 ? `Offline — ${offlineState.pending} change(s) saved, will sync automatically when you reconnect` : 'You are offline — your changes are saved and will sync automatically')
: `Syncing ${offlineState.pending} change(s) automatically…`}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5, height: 30, padding: '0 10px',
borderRadius: 999, flexShrink: 0, cursor: offlineState.online ? 'pointer' : 'default',
border: '1px solid ' + (offlineState.online ? 'var(--amber-300, #fcd34d)' : 'var(--border-default)'),
background: offlineState.online ? 'var(--amber-50, #fffbeb)' : 'var(--slate-50, #f8fafc)',
color: offlineState.online ? 'var(--amber-700, #b45309)' : 'var(--text-muted)',
fontSize: 'var(--text-2xs)', fontWeight: 'var(--fw-semibold)', fontFamily: 'var(--font-sans)',
}}>
{!offlineState.online ? 'Offline' : `Syncing ${offlineState.pending}…`}
)}
{/* Quick add */}
{/* Quick self attendance — check in / out from anywhere */}
{/* Ask AI */}
Ask AI
{/* Bell with dropdown */}
{(unreadCount > 0 || emailUnread > 0) && (
)}
{notifOpen && (
Notifications
{notifs.length > 0 && setNotifs(prev => prev.map(n => ({ ...n, read: true })))}>Mark all read }
{/* One-tap enable for browser/OS push (needed so alerts reach the
user when the app/tab is closed). Hidden once granted/denied. */}
{typeof Notification !== 'undefined' && Notification.permission === 'default' && (
{ if (window.TFEnablePush) { await window.TFEnablePush(); } setNotifOpen(false); }}
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 16px', borderBottom: '1px solid var(--border-subtle)', cursor: 'pointer', background: 'var(--blue-50)', color: 'var(--blue-700)' }}
>
Turn on notifications on this device
)}
{notifs.length === 0 ? (
All caught up!
) : notifs.map(n => {
const typeConfig = {
email: { icon: 'mail', color: 'var(--blue-500)', screen: 'email' },
task_assigned: { icon: 'user-check', color: 'var(--blue-500)', screen: 'tasks' },
task_overdue: { icon: 'alert-circle', color: 'var(--red-500)', screen: 'tasks' },
task_due: { icon: 'clock', color: 'var(--amber-500)', screen: 'tasks' },
task_done: { icon: 'check-circle', color: 'var(--green-500)', screen: 'tasks' },
mention: { icon: 'at-sign', color: 'var(--purple-500)', screen: 'chat' },
approval: { icon: 'clipboard-check', color: 'var(--blue-600)', screen: 'automations' },
project_created: { icon: 'folder-plus', color: 'var(--green-600)', screen: 'projects' },
client_action: { icon: 'user', color: 'var(--orange-500)', screen: 'portal' },
}[n.type] || { icon: 'bell', color: 'var(--slate-400)', screen: 'dashboard' };
return (
{ setNotifOpen(false); openNotification(n, typeConfig.screen); }}
style={{ display: 'flex', alignItems: 'flex-start', gap: 10, padding: '10px 16px', borderBottom: '1px solid var(--border-subtle)', cursor: 'pointer', background: n.read ? 'transparent' : 'var(--blue-50)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--slate-50)'}
onMouseLeave={e => e.currentTarget.style.background = n.read ? 'transparent' : 'var(--blue-50)'}>
{n.title}
{n.body &&
{n.body}
}
{fmtNotifTime(n.created_at)}
{!n.read &&
}
);
})}
{ setNotifOpen(false); onNavigate && onNavigate('tasks'); }} style={{ padding: '10px 14px', fontSize: 'var(--text-xs)', color: 'var(--blue-600)', fontWeight: 'var(--fw-semibold)', cursor: 'pointer', textAlign: 'center', borderTop: '1px solid var(--border-subtle)' }}>
View all tasks →
)}
{/* Avatar with dropdown */}
{ setAvatarOpen(o => !o); setNotifOpen(false); }} style={{ cursor: 'pointer' }}>
{avatarOpen && (
{avatarName}
{avatarEmail || 'Agency account'}
{[
{ label: 'Settings', icon: 'settings', screen: 'settings' },
{ label: 'Team', icon: 'users', screen: 'team' },
{ label: 'Workspace', icon: 'briefcase', screen: 'workspace' },
].map(item => (
{ setAvatarOpen(false); onNavigate && onNavigate(item.screen); }}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 14px', cursor: 'pointer', fontSize: 'var(--text-sm)', color: 'var(--text-body)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--slate-50)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{item.label}
))}
{onSignOut && (
{ setAvatarOpen(false); onSignOut(); }}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 14px', cursor: 'pointer',
fontSize: 'var(--text-sm)', color: '#dc2626', borderTop: '1px solid var(--border-subtle)' }}
onMouseEnter={e => e.currentTarget.style.background = '#fff1f2'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
Sign out
)}
)}
>
);
}
const FULL_HEIGHT_SCREENS = new Set(['chat', 'docs']);
function AppShell({ active, onNavigate, title, crumb, onOpenAI, onQuickAdd, children, authUser, onSignOut, hiddenIds }) {
useLucide();
const [mobileNavOpen, setMobileNavOpen] = React.useState(false);
const fullH = FULL_HEIGHT_SCREENS.has(active);
// Close the mobile drawer automatically if the viewport is resized back to desktop.
React.useEffect(() => {
function onResize() { if (window.innerWidth > 860) setMobileNavOpen(false); }
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return (
setMobileNavOpen(false)} />
setMobileNavOpen(o => !o)} />
{children}
);
}
// ── Universal search — local, instant, zero AI tokens ──────────────────────
function UniversalSearch({ onClose, onNavigate, onOpenAI }) {
const [q, setQ] = React.useState('');
const [data, setData] = React.useState(null);
const inputRef = React.useRef(null);
React.useEffect(() => {
if (inputRef.current) inputRef.current.focus();
const api = window.API; if (!api) { setData({}); return; }
const safe = (c) => Promise.resolve(c && c()).catch(() => ({ data: [] }));
const empty = Promise.resolve({ data: [] });
// Invoices are finance-gated (owner/admin), employees are manager-gated —
// fetching them as a plain member just yields harmless 403 console noise, so
// skip them for roles that can't read them. The server still enforces access.
const role = window.TFMyRole;
const canFinance = !role || ['owner', 'admin'].includes(role);
const canManage = !role || ['owner', 'admin', 'manager'].includes(role);
Promise.all([
safe(() => api.getTasks()), safe(() => api.getProjects()), safe(() => api.getClients()),
canFinance ? safe(() => api.getInvoices()) : empty,
(canManage && api.getEmployees) ? safe(() => api.getEmployees()) : empty,
]).then(([t, p, c, i, e]) => setData({ tasks: t.data||[], projects: p.data||[], clients: c.data||[], invoices: i.data||[], employees: e.data||[] }));
}, []);
const ql = q.trim().toLowerCase();
const match = (s) => s && String(s).toLowerCase().includes(ql);
const go = (screen) => { onClose(); onNavigate && onNavigate(screen); };
const groups = !ql || !data ? [] : [
{ key: 'Tasks', icon: 'circle-check-big', screen: 'tasks', items: (data.tasks||[]).filter(t => match(t.title)).slice(0,6).map(t => ({ id: t.id, label: t.title, sub: t.status })) },
{ key: 'Projects', icon: 'folder-kanban', screen: 'projects', items: (data.projects||[]).filter(p => match(p.name)).slice(0,6).map(p => ({ id: p.id, label: p.name, sub: p.status })) },
{ key: 'Clients', icon: 'contact', screen: 'crm', items: (data.clients||[]).filter(c => match(c.company)||match(c.name)).slice(0,6).map(c => ({ id: c.id, label: c.company||c.name, sub: c.email })) },
{ key: 'Invoices', icon: 'receipt', screen: 'finance', items: (data.invoices||[]).filter(i => match(i.invoice_no)||match(i.number)||match(i.clients?.name)).slice(0,6).map(i => ({ id: i.id, label: (i.invoice_no||i.number||'Invoice'), sub: i.status })) },
{ key: 'Employees', icon: 'id-card', screen: 'employees', items: (data.employees||[]).filter(e => match(e.name)||match(e.email)||match(e.designation)).slice(0,6).map(e => ({ id: e.id, label: e.name, sub: e.designation })) },
].filter(g => g.items.length);
const total = groups.reduce((n,g)=>n+g.items.length,0);
return (
e.stopPropagation()} style={{ width: 'min(560px, 92vw)', maxHeight: '72vh', background: 'var(--slate-0)', borderRadius: 'var(--radius-xl)', boxShadow: 'var(--shadow-2xl)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
setQ(e.target.value)} placeholder="Search tasks, clients, projects, invoices, employees…"
style={{ flex: 1, border: 'none', outline: 'none', fontSize: 'var(--text-md)', fontFamily: 'var(--font-sans)', background: 'transparent', color: 'var(--text-strong)' }} />
{!data &&
Loading…
}
{data && !ql &&
Type to search across your workspace.
}
{data && ql && total === 0 && (
No matches for “{q}”.
{ onClose(); onOpenAI && onOpenAI(); }} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, height: 34, padding: '0 14px', background: 'var(--blue-600)', color: '#fff', border: 'none', borderRadius: 'var(--radius-md)', cursor: 'pointer', fontSize: 'var(--text-sm)', fontWeight: 'var(--fw-semibold)', fontFamily: 'var(--font-sans)' }}> Ask AI instead
)}
{groups.map(g => (
{g.key}
{g.items.map(it => (
go(g.screen)} style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', textAlign: 'left', padding: '9px 10px', background: 'none', border: 'none', borderRadius: 'var(--radius-md)', cursor: 'pointer', fontFamily: 'var(--font-sans)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--slate-50)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{it.label}
{it.sub && {it.sub} }
))}
))}
);
}
Object.assign(window, { AppShell, Sidebar, TopBar });
})();