// 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 ( ); } 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) => ( ); return (
{/* Opens a dropdown — never acts on the plain tap. */} {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). */}
)}
); } 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 */} {/* Offline / pending-sync indicator — only shows when relevant */} {(!offlineState.online || offlineState.pending > 0) && ( )} {/* Quick add */} {/* Quick self attendance — check in / out from anywhere */} {/* Ask AI */} {/* Bell with dropdown */}
{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}”.
)} {groups.map(g => (
{g.key}
{g.items.map(it => ( ))}
))}
); } Object.assign(window, { AppShell, Sidebar, TopBar }); })();