EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
MainLayout.jsx
Go to the documentation of this file.
1import React, { useState, useEffect } from 'react';
2import { apiFetch } from '../../lib/api';
3import TenantSelector from '../TenantSelector';
4import { useNavigate, useLocation } from 'react-router-dom';
5import './MainLayout.css';
6import '../../styles/themes.css';
7import '../../styles/navigation.css';
8import ThemeSelector from '../ThemeSelector';
9
10// -------------------------
11// JWT Helper
12// -------------------------
13function parseJwt(token) {
14 try {
15 const payload = token.split('.')[1];
16 return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
17 } catch {
18 return null;
19 }
20}
21
22function MainLayout({ children }) {
23 // -------------------------
24 // Main Content Handler (reloads on tenant change)
25 // -------------------------
26 const token = localStorage.getItem('token');
27 const jwtPayload = token ? parseJwt(token) : null;
28 const tenantId = jwtPayload?.tenant_id || '';
29
30
31
32 // -------------------------
33 // JWT + Root Detection
34 // -------------------------
35 // token and jwtPayload already declared above
36
37 const jwtIsRoot =
38 jwtPayload &&
39 (jwtPayload.role === 'root' || jwtPayload.role === 'msp');
40 const isImpersonated = jwtPayload && jwtPayload.impersonated === true;
41 const [isRoot, setIsRoot] = useState(jwtIsRoot);
42
43 // Validate backend root status if not JWT-root
44 useEffect(() => {
45 async function checkRootStatus() {
46 if (!token || jwtIsRoot) return;
47 try {
48 const res = await apiFetch('/tenants', {
49 headers: {
50 Authorization: `Bearer ${token}`,
51 'X-Tenant-Subdomain': 'admin'
52 }
53 });
54 setIsRoot(res.ok);
55 } catch {
56 setIsRoot(false);
57 }
58 }
59 checkRootStatus();
60 }, [token, jwtIsRoot]);
61
62 // -------------------------
63 // Tenant Impersonation
64 // -------------------------
65
66 const handleTenantChange = async (tid) => {
67 try {
68 const res = await apiFetch(`/auth/impersonate`, {
69 method: 'POST',
70 headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
71 body: JSON.stringify({ tenant_id: tid })
72 });
73 if (res.ok) {
74 const data = await res.json();
75 if (data.token) {
76 localStorage.setItem('token', data.token);
77 window.location.reload();
78 }
79 } else {
80 alert('Failed to impersonate tenant');
81 }
82 } catch (err) {
83 alert('Error impersonating tenant');
84 }
85 };
86
87 // -------------------------
88 // Navigation + Layout State
89 // -------------------------
90 const navigate = useNavigate();
91 const location = useLocation();
92 const [activeTab, setActiveTab] = useState('dashboard');
93 const [showNotifications, setShowNotifications] = useState(false);
94
95 const [companyName, setCompanyName] = useState('RMM+PSA');
96
97 const navItems = [
98 { id: 'dashboard', label: 'Dashboard', icon: 'dashboard', path: '/dashboard' },
99 { id: 'tickets', label: 'Tickets', icon: 'confirmation_number', path: '/tickets' },
100 { id: 'customers', label: 'Customers', icon: 'groups', path: '/customers' },
101 { id: 'monitoring', label: 'Monitoring', icon: 'monitoring', path: '/monitoring' },
102 { id: 'knowledge-base', label: 'Knowledge Base', icon: 'description', path: '/knowledge-base' },
103 { id: 'contracts', label: 'Contracts', icon: 'contract_edit', path: '/contracts' },
104 { id: 'products', label: 'Products', icon: 'inventory_2', path: '/products' },
105 { id: 'services', label: 'Services', icon: 'cloud_circle', path: '/services' },
106 { id: 'orders', label: 'Orders', icon: 'shopping_cart', path: '/orders' },
107 { id: 'purchase-orders', label: 'Purchase Orders', icon: 'local_shipping', path: '/purchase-orders' },
108 { id: 'invoices', label: 'Invoices', icon: 'receipt_long', path: '/invoices' },
109 { id: 'reports', label: 'Reports', icon: 'analytics', path: '/reports' },
110 { id: 'integrations', label: 'Integrations', icon: 'extension', path: '/integrations' },
111 { id: 'settings', label: 'Settings', icon: 'settings', path: '/settings' }
112 ];
113
114 // Only show Tenants menu if root
115 const navItemsWithTenants = isRoot
116 ? [...navItems.slice(0, 12), { id: 'tenants', label: 'Tenants', icon: 'domain', path: '/tenants' }, ...navItems.slice(12)]
117 : navItems;
118
119 // -------------------------
120 // Notifications
121 // -------------------------
122 const [notifications, setNotifications] = useState([]);
123
124 const loadNotifications = async () => {
125 if (!token) return;
126 try {
127 const res = await apiFetch('/notifications', {
128 headers: { Authorization: `Bearer ${token}` }
129 });
130 if (res.ok) {
131 const data = await res.json();
132 setNotifications(data.slice(0, 10));
133 }
134 } catch { }
135 };
136
137 useEffect(() => {
138 loadNotifications();
139
140 // Listen for new notifications
141 const handleNotificationCreated = () => {
142 loadNotifications();
143 };
144
145 window.addEventListener('notificationCreated', handleNotificationCreated);
146
147 return () => {
148 window.removeEventListener('notificationCreated', handleNotificationCreated);
149 };
150 }, [token]);
151
152 // -------------------------
153 // Active Tab Detection
154 // -------------------------
155 useEffect(() => {
156 const current = navItems.find((n) =>
157 location.pathname.startsWith(n.path)
158 );
159 setActiveTab(current ? current.id : 'dashboard');
160 }, [location.pathname, navItems]);
161
162 // -------------------------
163 // Fetch Company Name
164 // -------------------------
165 useEffect(() => {
166 async function fetchCompanyName() {
167 if (!token) return;
168 try {
169 const res = await apiFetch('/settings', {
170 headers: { Authorization: `Bearer ${token}` }
171 });
172 if (!res.ok) return;
173 const settings = await res.json();
174 if (settings?.general?.companyName) {
175 setCompanyName(settings.general.companyName);
176 }
177 } catch (err) {
178 console.error('Failed to fetch settings:', err);
179 }
180 }
181 fetchCompanyName();
182 const handleSettingsUpdate = (event) => {
183 if (event.detail?.general?.companyName) {
184 setCompanyName(event.detail.general.companyName);
185 }
186 };
187 const handleTenantChangeEvent = () => fetchCompanyName();
188 window.addEventListener('settingsUpdated', handleSettingsUpdate);
189 window.addEventListener('tenantChanged', handleTenantChangeEvent);
190 return () => {
191 window.removeEventListener('settingsUpdated', handleSettingsUpdate);
192 window.removeEventListener('tenantChanged', handleTenantChangeEvent);
193 };
194 }, [token, tenantId]);
195
196 // -------------------------
197 // Logout
198 // -------------------------
199 const handleLogout = () => {
200 localStorage.removeItem('token');
201 navigate('/');
202 };
203
204 // -------------------------
205 // MAIN CONTENT RENDER
206 // -------------------------
207 const renderContent = () => (
208 <div className={`layout${isRoot ? ' with-tenant-selector' : ''}`}>
209 <header className="header">
210 <div className="logo">
211 <span className="logo-text">{companyName}</span>
212 </div>
213
214 <div className="header-right">
215 <ThemeSelector />
216
217 {/* Notifications */}
218 <div className="notifications">
219 <button
220 className="notification-btn"
221 onClick={() => setShowNotifications(!showNotifications)}
222 >
223 <span className="material-symbols-outlined">notifications</span>
224 {notifications.filter((n) => !n.is_read).length > 0 && (
225 <span className="badge">
226 {notifications.filter((n) => !n.is_read).length}
227 </span>
228 )}
229 </button>
230
231 {showNotifications && (
232 <div className="notifications-dropdown">
233 {notifications.map((note) => (
234 <div
235 key={note.notification_id}
236 className={`notification-item ${note.type}`}
237 >
238 <div style={{ display: 'flex', justifyContent: 'space-between' }}>
239 <div>{note.title}</div>
240 <div style={{ fontSize: 12 }}>
241 {new Date(note.created_at).toLocaleString()}
242 </div>
243 </div>
244 <div style={{ marginTop: 6 }}>{note.message}</div>
245
246 {!note.is_read && (
247 <button
248 onClick={async () => {
249 try {
250 await apiFetch(
251 `/notifications/${note.notification_id}/mark-read`,
252 {
253 method: 'PUT',
254 headers: { Authorization: `Bearer ${token}` }
255 }
256 );
257 setNotifications((prev) =>
258 prev.map((n) =>
259 n.notification_id === note.notification_id
260 ? { ...n, is_read: true }
261 : n
262 )
263 );
264 } catch (err) {
265 console.error(err);
266 }
267 }}
268 >
269 Mark read
270 </button>
271 )}
272 </div>
273 ))}
274 <div style={{ padding: 8, textAlign: 'center' }}>
275 <a href="/notifications">View all</a>
276 </div>
277 </div>
278 )}
279 </div>
280
281 {/* User Info */}
282 <div className="user-info">
283 <span className="user-name">
284 {jwtPayload?.name || `User ${jwtPayload?.user_id || ''}`}
285 {jwtPayload?.role && ` (${jwtPayload.role})`}
286 </span>
287 <button onClick={handleLogout} className="logout-btn">
288 Logout
289 </button>
290 </div>
291 </div>
292 </header>
293
294 <div className="main">
295 {/* Sidebar Navigation */}
296 <nav className="sidebar">
297 {navItemsWithTenants.map((item) => (
298 <button
299 key={item.id}
300 className={`nav-item ${activeTab === item.id ? 'active' : ''}`}
301 onClick={() => {
302 setActiveTab(item.id);
303 navigate(item.path);
304 }}
305 >
306 <span className="nav-icon material-symbols-outlined">{item.icon}</span>
307 <span className="nav-label">{item.label}</span>
308 </button>
309 ))}
310 </nav>
311
312 {/* Page Content */}
313 <main className="content" key={tenantId}>
314 {typeof children === 'function'
315 ? children({ tenantId, onTenantChange: handleTenantChange })
316 : children}
317 </main>
318 </div>
319 </div>
320 );
321
322 // -------------------------
323 // RENDER WRAPPER
324 // -------------------------
325 return (
326 <div className="main-layout-container">
327 {(isRoot || isImpersonated) && (
328 <div className="tenant-selector-bar">
329 <TenantSelector
330 selectedTenantId={tenantId}
331 onTenantChange={handleTenantChange}
332 />
333 </div>
334 )}
335 {renderContent()}
336 </div>
337 );
338}
339
340export default MainLayout;