@dashadmin/dash-admin 1.3.45 → 1.3.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/i18n/BridgedLocalesMenuButton.js +1 -2
- package/dist/components/menu/DarkToggleMode.js +1 -1
- package/dist/components/subscription/PlanPricesSettings.js +4 -2
- package/dist/components/tenancy/GlobalTenantWrapper.js +64 -0
- package/dist/components/tenancy/TenantSwitcher.js +389 -0
- package/dist/contexts/com/components/EchoNotificationsCenter.js +40 -0
- package/dist/default-theme/menu/AppMenuComponents/expanded/SidebarItem.js +1 -0
- package/dist/index.js +60 -40
- package/dist/pages/Profile.js +32 -38
- package/dist/providers/i18n/en.js +33 -0
- package/dist/providers/i18n/es.js +33 -0
- package/dist/resources/User/components/AvatarUploadField.js +61 -0
- package/dist/resources/User/components/PasswordFields.js +67 -0
- package/dist/resources/User/getProfileMenu.js +36 -0
- package/dist/resources/User/profileResource.js +33 -0
- package/dist/resources/User/userLayout.js +58 -0
- package/dist/resources/User/userResource.js +90 -0
- package/dist/resources/User/userSchema.js +206 -0
- package/dist/resources/User/usersFilters.js +22 -0
- package/dist/systemResources.js +30 -19
- package/package.json +1 -1
- package/src/components/i18n/BridgedLocalesMenuButton.tsx +1 -2
- package/src/components/menu/DarkToggleMode.tsx +1 -1
- package/src/components/subscription/PlanPricesSettings.tsx +2 -2
- package/src/components/tenancy/GlobalTenantWrapper.tsx +84 -0
- package/src/components/tenancy/TenantSwitcher.tsx +485 -0
- package/src/contexts/com/components/EchoNotificationsCenter.tsx +65 -0
- package/src/default-theme/menu/AppMaterialMenu.tsx +1 -1
- package/src/default-theme/menu/AppMenuComponents/expanded/SidebarItem.tsx +1 -0
- package/src/default-theme/menu/SidebarActions.tsx +1 -1
- package/src/index.tsx +15 -0
- package/src/pages/Profile.tsx +69 -107
- package/src/providers/i18n/en.ts +33 -0
- package/src/providers/i18n/es.ts +33 -0
- package/src/resources/User/components/AvatarUploadField.tsx +57 -0
- package/src/resources/User/components/PasswordFields.tsx +73 -0
- package/src/resources/User/getProfileMenu.ts +57 -0
- package/src/resources/User/profileResource.tsx +51 -0
- package/src/resources/User/userLayout.tsx +62 -0
- package/src/resources/User/userResource.tsx +111 -0
- package/src/resources/User/userSchema.tsx +213 -0
- package/src/resources/User/usersFilters.tsx +21 -0
- package/src/systemResources.tsx +31 -20
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TenantSwitcher
|
|
3
|
+
*
|
|
4
|
+
* A dropdown component that allows switching between the Tenancy Account and
|
|
5
|
+
* individual Tenants. It reads the available tenants from the persisted
|
|
6
|
+
* systemValues (populated by the tenancyAuth endpoint) and allows the user
|
|
7
|
+
* to select one, triggering a full environment switch:
|
|
8
|
+
*
|
|
9
|
+
* - Persists the selected tenant_id to dashStorage
|
|
10
|
+
* - Dispatches a 'tenant_switch' CustomEvent so the PrivateAppLoader can
|
|
11
|
+
* reload the correct resources, routes, and auth data
|
|
12
|
+
* - Re-fetches the appropriate getAuth endpoint (tenancyAuth vs getauth)
|
|
13
|
+
* - Updates CSS variables and logos from the new tenant's settings
|
|
14
|
+
*
|
|
15
|
+
* Consolidated 2026-08-03 from four byte-identical app-level copies
|
|
16
|
+
* (kitchntabs-app, kitchntabs-web, vanexa-app, vanexa-web). The only per-app
|
|
17
|
+
* difference was which local brand assets each app fell back to when
|
|
18
|
+
* switching back to the tenancy level — that's now the defaultHorizontalLogo/
|
|
19
|
+
* defaultSquaredLogo/defaultLoginBackground props instead of hardcoded
|
|
20
|
+
* relative imports (a relative image import moved into this package would
|
|
21
|
+
* have resolved against dash-admin's own assets, not each app's brand).
|
|
22
|
+
*/
|
|
23
|
+
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|
24
|
+
import {
|
|
25
|
+
Avatar,
|
|
26
|
+
Box,
|
|
27
|
+
Typography,
|
|
28
|
+
CircularProgress,
|
|
29
|
+
} from '@mui/material';
|
|
30
|
+
import BusinessIcon from '@mui/icons-material/Business';
|
|
31
|
+
import StoreIcon from '@mui/icons-material/Store';
|
|
32
|
+
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
|
|
33
|
+
import ReactDOM from 'react-dom';
|
|
34
|
+
import { dashStorage } from 'dash-utils';
|
|
35
|
+
import { AuthPersistenceService } from 'dash-auth';
|
|
36
|
+
import { DASHAdminSystemConstants, getEnv } from 'dash-constants';
|
|
37
|
+
import { useAxios } from 'dash-axios-hook';
|
|
38
|
+
import { updateDomCssVariables } from 'dash-utils';
|
|
39
|
+
import DASHLayoutSettings from '../../theme/AppLayoutSetting';
|
|
40
|
+
import { useDispatch, useSelector } from 'react-redux';
|
|
41
|
+
import { setPanelSettings } from 'dash-admin-state/redux/actions/Common';
|
|
42
|
+
import { IDASHAppState } from 'dash-admin-state';
|
|
43
|
+
import { useWindowSize } from 'dash-utils';
|
|
44
|
+
import { setAuthEvent } from '../../contexts/auth';
|
|
45
|
+
|
|
46
|
+
export interface TenantSwitcherProps {
|
|
47
|
+
/** Fallback horizontal logo used when switching back to the tenancy level */
|
|
48
|
+
defaultHorizontalLogo?: string;
|
|
49
|
+
/** Fallback squared logo used when switching back to the tenancy level */
|
|
50
|
+
defaultSquaredLogo?: string;
|
|
51
|
+
/** Fallback login background used when switching back to the tenancy level */
|
|
52
|
+
defaultLoginBackground?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Shape of a tenant from systemValues.tenants */
|
|
56
|
+
interface TenantOption {
|
|
57
|
+
id: string;
|
|
58
|
+
name: string;
|
|
59
|
+
slug: string | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The value emitted in the tenant_switch CustomEvent */
|
|
63
|
+
export interface TenantSwitchEventDetail {
|
|
64
|
+
tenantId: string | null; // null = tenancy account level
|
|
65
|
+
tenantName: string;
|
|
66
|
+
isTenanancyLevel: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Dispatch a tenant_switch event so other parts of the app can react.
|
|
71
|
+
*/
|
|
72
|
+
export const dispatchTenantSwitchEvent = (detail: TenantSwitchEventDetail) => {
|
|
73
|
+
window.dispatchEvent(new CustomEvent('tenant_switch', { detail }));
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get the currently active tenant_id from storage (null = tenancy level).
|
|
78
|
+
*/
|
|
79
|
+
export const getActiveTenantId = (): string | null => {
|
|
80
|
+
if (!isTenantImpersonationEnabled()) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return dashStorage.getItem('active_tenant_id') || null;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check whether tenant impersonation is enabled.
|
|
88
|
+
*/
|
|
89
|
+
export const isTenantImpersonationEnabled = (): boolean => {
|
|
90
|
+
const val = DASHAdminSystemConstants.system.ENABLE_TENANT_IMPERSONATION;
|
|
91
|
+
if (typeof val === 'boolean') return val;
|
|
92
|
+
if (typeof val === 'string') return val === 'true' || val === '1';
|
|
93
|
+
return !!val;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
|
|
99
|
+
defaultHorizontalLogo,
|
|
100
|
+
defaultSquaredLogo,
|
|
101
|
+
defaultLoginBackground,
|
|
102
|
+
}) => {
|
|
103
|
+
const axios = useAxios();
|
|
104
|
+
const dispatch = useDispatch();
|
|
105
|
+
const windowSize = useWindowSize();
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
const [open, setOpen] = useState(false);
|
|
110
|
+
const [switching, setSwitching] = useState(false);
|
|
111
|
+
const avatarRef = useRef<HTMLDivElement>(null);
|
|
112
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
113
|
+
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 });
|
|
114
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
115
|
+
const [webView, setWebView] = useState(false);
|
|
116
|
+
|
|
117
|
+
// Current active tenant id
|
|
118
|
+
const [activeTenantId, setActiveTenantId] = useState<string | null>(
|
|
119
|
+
getActiveTenantId()
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// Read panel settings for logos
|
|
123
|
+
const panelSettings = useSelector(
|
|
124
|
+
(state: IDASHAppState<any, any, any>) => state.common?.panelSettings
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Get primary and secondary sidebar positions from Redux panelSettings
|
|
128
|
+
const sidebarPosition = panelSettings?.sidebarPosition || 'left';
|
|
129
|
+
|
|
130
|
+
// Get tenants from persisted systemValues
|
|
131
|
+
const systemValues = AuthPersistenceService.getSystemValues();
|
|
132
|
+
const tenants: TenantOption[] = systemValues?.tenants || [];
|
|
133
|
+
const tenancy = systemValues?.tenancy;
|
|
134
|
+
|
|
135
|
+
// Get current tenant auth data for display
|
|
136
|
+
const auth = AuthPersistenceService.getAuth();
|
|
137
|
+
const currentTenantName = activeTenantId
|
|
138
|
+
? tenants.find(t => t.id === activeTenantId)?.name || 'Tenant'
|
|
139
|
+
: tenancy?.public_name || auth?.tenant?.name || 'Tenancy Account';
|
|
140
|
+
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (document.body.classList.contains('webview')) {
|
|
143
|
+
setWebView(true);
|
|
144
|
+
}
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
// Don't render if impersonation is disabled or no tenants available
|
|
148
|
+
if (!isTenantImpersonationEnabled() || tenants.length === 0) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const calculateMenuPosition = () => {
|
|
153
|
+
if (avatarRef.current && windowSize.width && windowSize.height) {
|
|
154
|
+
const rect = avatarRef.current.getBoundingClientRect();
|
|
155
|
+
const menuWidth = 230; // minWidth from styles
|
|
156
|
+
const menuHeight = 120; // approximate menu height
|
|
157
|
+
|
|
158
|
+
let left = rect.left + window.scrollX;
|
|
159
|
+
let top = rect.top + window.scrollY;
|
|
160
|
+
|
|
161
|
+
// Ensure menu doesn't go off-screen horizontally
|
|
162
|
+
if (left + menuWidth > windowSize.width) {
|
|
163
|
+
left = windowSize.width - menuWidth - 10; // 10px margin
|
|
164
|
+
}
|
|
165
|
+
if (left < 10) {
|
|
166
|
+
left = 10; // 10px margin
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// For bottom sidebar position, open menu above the avatar
|
|
170
|
+
if (sidebarPosition === 'bottom') {
|
|
171
|
+
top = rect.top + window.scrollY - menuHeight - 10; // Open above
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
setMenuPosition({
|
|
175
|
+
top: top,
|
|
176
|
+
left: left + 40
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
182
|
+
e.preventDefault();
|
|
183
|
+
if (timeoutRef.current) {
|
|
184
|
+
clearTimeout(timeoutRef.current);
|
|
185
|
+
timeoutRef.current = null;
|
|
186
|
+
}
|
|
187
|
+
setOpen(true);
|
|
188
|
+
calculateMenuPosition();
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
192
|
+
e.preventDefault();
|
|
193
|
+
setOpen(prev => !prev);
|
|
194
|
+
calculateMenuPosition();
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const handleMouseLeave = () => {
|
|
198
|
+
if (!webView) {
|
|
199
|
+
timeoutRef.current = setTimeout(() => setOpen(false), 150);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const handleMenuMouseEnter = () => {
|
|
204
|
+
if (timeoutRef.current) {
|
|
205
|
+
clearTimeout(timeoutRef.current);
|
|
206
|
+
timeoutRef.current = null;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const handleMenuMouseLeave = () => {
|
|
211
|
+
if (!webView) {
|
|
212
|
+
setOpen(false);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
return () => {
|
|
218
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
219
|
+
};
|
|
220
|
+
}, []);
|
|
221
|
+
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
if (open) calculateMenuPosition();
|
|
224
|
+
}, [open, windowSize.width]);
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Core switching logic.
|
|
228
|
+
* - Always uses tenancyAuth endpoint (preserves tenancy/tenants in response)
|
|
229
|
+
* - Passes X-Tenant-Id header when switching to a specific tenant
|
|
230
|
+
* - Persists auth data
|
|
231
|
+
* - Updates CSS variables, logos
|
|
232
|
+
* - Dispatches tenant_switch event for PrivateAppLoader
|
|
233
|
+
*/
|
|
234
|
+
const handleSwitch = useCallback(async (tenantId: string | null) => {
|
|
235
|
+
if (switching) return;
|
|
236
|
+
if (tenantId === activeTenantId) {
|
|
237
|
+
setOpen(false);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
setSwitching(true);
|
|
242
|
+
setOpen(false);
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
const isTenancyLevel = tenantId === null;
|
|
246
|
+
// Always use tenancyAuth - it returns tenancy/tenants list + respects X-Tenant-Id header
|
|
247
|
+
const authEndpoint = getEnv('APP_GETAUTH_ENDPOINT') || 'auth/tenancyAuth';
|
|
248
|
+
|
|
249
|
+
// If switching to a specific tenant, set the header so backend knows
|
|
250
|
+
const headers: Record<string, string> = {};
|
|
251
|
+
if (!isTenancyLevel && tenantId) {
|
|
252
|
+
headers['X-Tenant-Id'] = tenantId;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Persist active_tenant_id BEFORE the request so the axios interceptor picks it up
|
|
256
|
+
if (tenantId) {
|
|
257
|
+
dashStorage.setItem('active_tenant_id', tenantId);
|
|
258
|
+
dashStorage.setItem('tenant_id', tenantId);
|
|
259
|
+
} else {
|
|
260
|
+
dashStorage.removeItem('active_tenant_id');
|
|
261
|
+
dashStorage.removeItem('tenant_id');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Fetch new auth data from the correct endpoint
|
|
265
|
+
const { data: authData } = await axios.get(authEndpoint, { headers });
|
|
266
|
+
|
|
267
|
+
// Persist auth data using the standard service
|
|
268
|
+
AuthPersistenceService.saveAuth(authData);
|
|
269
|
+
|
|
270
|
+
// Update basic localStorage for react-admin compatibility
|
|
271
|
+
dashStorage.setItem('authenticated', 'true');
|
|
272
|
+
dashStorage.setItem('user', JSON.stringify(authData.user));
|
|
273
|
+
if (authData.user?.roles) {
|
|
274
|
+
dashStorage.setItem('roles', JSON.stringify(authData.user.roles));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Trigger auth context update
|
|
278
|
+
setAuthEvent({
|
|
279
|
+
authenticated: true,
|
|
280
|
+
user: authData.user,
|
|
281
|
+
auth: authData.auth,
|
|
282
|
+
token: dashStorage.getItem('token'),
|
|
283
|
+
roles: authData.user?.roles,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Update CSS variables from new tenant settings
|
|
287
|
+
const tenantSettings = authData.auth?.tenantSettings;
|
|
288
|
+
try {
|
|
289
|
+
|
|
290
|
+
// Read current theme mode instead of hardcoding 'dark'
|
|
291
|
+
const currentThemeMode = document.documentElement.getAttribute('data-theme') || DASHLayoutSettings.THEME_TYPE_DARK;
|
|
292
|
+
// Always call updateDomCssVariables — when colors is undefined,
|
|
293
|
+
// it correctly resets to compiled LESS defaults for tenants without custom colors.
|
|
294
|
+
updateDomCssVariables(currentThemeMode, tenantSettings?.colors, tenantSettings?.values);
|
|
295
|
+
} catch (err) {
|
|
296
|
+
console.error('[TenantSwitcher] Error updating CSS variables:', err);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Update logos in Redux panel settings
|
|
300
|
+
const tenantImages = authData.auth?.tenantImages;
|
|
301
|
+
if (tenantImages) {
|
|
302
|
+
AuthPersistenceService.setTenantImages(tenantImages);
|
|
303
|
+
dispatch(setPanelSettings({
|
|
304
|
+
...(panelSettings || {}),
|
|
305
|
+
horizontalLogo: tenantImages.horizontal_logo?.original || panelSettings?.horizontalLogo,
|
|
306
|
+
squaredLogo: tenantImages.squared_logo?.original || panelSettings?.squaredLogo,
|
|
307
|
+
loginBackground: tenantImages.banner?.original || panelSettings?.loginBackground,
|
|
308
|
+
}));
|
|
309
|
+
} else {
|
|
310
|
+
// Switching to tenancy level — clear stale tenant images
|
|
311
|
+
// and reset Redux logos to system defaults
|
|
312
|
+
AuthPersistenceService.clearTenantImages();
|
|
313
|
+
dispatch(setPanelSettings({
|
|
314
|
+
...(panelSettings || {}),
|
|
315
|
+
horizontalLogo: defaultHorizontalLogo,
|
|
316
|
+
squaredLogo: defaultSquaredLogo,
|
|
317
|
+
loginBackground: defaultLoginBackground,
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Update tenant settings in persistence
|
|
322
|
+
if (tenantSettings) {
|
|
323
|
+
AuthPersistenceService.setTenantSettings(tenantSettings);
|
|
324
|
+
} else {
|
|
325
|
+
// Switching to tenancy level — clear stale tenant settings
|
|
326
|
+
// so DashThemeProvider reads null on remount and uses defaults
|
|
327
|
+
AuthPersistenceService.clearTenantSettings();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
setActiveTenantId(tenantId);
|
|
331
|
+
|
|
332
|
+
// Find tenant name for event
|
|
333
|
+
const tenantName = isTenancyLevel
|
|
334
|
+
? (tenancy?.public_name || 'Tenancy Account')
|
|
335
|
+
: (tenants.find(t => t.id === tenantId)?.name || 'Tenant');
|
|
336
|
+
|
|
337
|
+
// Dispatch switch event so PrivateAppLoader can reload resources
|
|
338
|
+
dispatchTenantSwitchEvent({
|
|
339
|
+
tenantId,
|
|
340
|
+
tenantName,
|
|
341
|
+
isTenanancyLevel: isTenancyLevel,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
console.log(`[TenantSwitcher] Switched to ${isTenancyLevel ? 'Tenancy Account' : tenantName} (${tenantId})`);
|
|
345
|
+
|
|
346
|
+
} catch (error) {
|
|
347
|
+
console.error('[TenantSwitcher] Switch failed:', error);
|
|
348
|
+
// Revert storage on failure
|
|
349
|
+
if (activeTenantId) {
|
|
350
|
+
dashStorage.setItem('active_tenant_id', activeTenantId);
|
|
351
|
+
dashStorage.setItem('tenant_id', activeTenantId);
|
|
352
|
+
} else {
|
|
353
|
+
dashStorage.removeItem('active_tenant_id');
|
|
354
|
+
dashStorage.removeItem('tenant_id');
|
|
355
|
+
}
|
|
356
|
+
} finally {
|
|
357
|
+
setSwitching(false);
|
|
358
|
+
}
|
|
359
|
+
}, [activeTenantId, switching, axios, dispatch, panelSettings, tenants, tenancy, defaultHorizontalLogo, defaultSquaredLogo, defaultLoginBackground]);
|
|
360
|
+
|
|
361
|
+
const isSelected = (id: string | null) => id === activeTenantId;
|
|
362
|
+
|
|
363
|
+
return (
|
|
364
|
+
<>
|
|
365
|
+
<div
|
|
366
|
+
ref={avatarRef}
|
|
367
|
+
className="dash-tenant-switcher-trigger"
|
|
368
|
+
{...(!webView
|
|
369
|
+
? { onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave }
|
|
370
|
+
: { onClick: handleClick }
|
|
371
|
+
)}
|
|
372
|
+
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}
|
|
373
|
+
>
|
|
374
|
+
{switching ? (
|
|
375
|
+
<CircularProgress
|
|
376
|
+
className='dash-icon-button-color dash-icon-button-bg'
|
|
377
|
+
size={30} />
|
|
378
|
+
) : (
|
|
379
|
+
<Avatar
|
|
380
|
+
className='dash-icon-button-color dash-icon-button-bg'
|
|
381
|
+
sx={{
|
|
382
|
+
width: 30,
|
|
383
|
+
height: 30,
|
|
384
|
+
fontSize: '0.8rem',
|
|
385
|
+
}}
|
|
386
|
+
>
|
|
387
|
+
{activeTenantId ? <StoreIcon sx={{ fontSize: 18 }} /> : <BusinessIcon sx={{ fontSize: 18 }} />}
|
|
388
|
+
</Avatar>
|
|
389
|
+
)}
|
|
390
|
+
</div>
|
|
391
|
+
|
|
392
|
+
{open && ReactDOM.createPortal(
|
|
393
|
+
<div
|
|
394
|
+
ref={menuRef}
|
|
395
|
+
className="dash-tenant-switcher-portal"
|
|
396
|
+
onMouseEnter={!webView ? handleMenuMouseEnter : undefined}
|
|
397
|
+
onMouseLeave={!webView ? handleMenuMouseLeave : undefined}
|
|
398
|
+
>
|
|
399
|
+
<div
|
|
400
|
+
className="dash-tenant-switcher-menu"
|
|
401
|
+
style={{
|
|
402
|
+
top: menuPosition.top,
|
|
403
|
+
left: menuPosition.left,
|
|
404
|
+
zIndex: 10000,
|
|
405
|
+
position: 'absolute',
|
|
406
|
+
}}
|
|
407
|
+
>
|
|
408
|
+
{/* Header */}
|
|
409
|
+
<Box sx={{ px: 2, py: 1.5, borderBottom: '1px solid rgba(255,255,255,0.08)' }}>
|
|
410
|
+
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
|
411
|
+
<SwapHorizIcon sx={{ fontSize: 14, mr: 0.5, verticalAlign: 'middle' }} />
|
|
412
|
+
Switch Environment
|
|
413
|
+
</Typography>
|
|
414
|
+
</Box>
|
|
415
|
+
|
|
416
|
+
{/* Tenancy Account option */}
|
|
417
|
+
<div
|
|
418
|
+
onClick={() => handleSwitch(null)}
|
|
419
|
+
style={{
|
|
420
|
+
padding: '10px 16px',
|
|
421
|
+
cursor: 'pointer',
|
|
422
|
+
display: 'flex',
|
|
423
|
+
alignItems: 'center',
|
|
424
|
+
gap: 10,
|
|
425
|
+
backgroundColor: isSelected(null) ? 'rgba(var(--dash-primary-rgb, 25, 118, 210), 0.15)' : 'transparent',
|
|
426
|
+
borderLeft: isSelected(null) ? '3px solid var(--dash-primary, #1976d2)' : '3px solid transparent',
|
|
427
|
+
transition: 'background-color 0.15s ease',
|
|
428
|
+
}}
|
|
429
|
+
onMouseOver={(e) => { if (!isSelected(null)) (e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.05)'); }}
|
|
430
|
+
onMouseOut={(e) => { if (!isSelected(null)) (e.currentTarget.style.backgroundColor = 'transparent'); }}
|
|
431
|
+
>
|
|
432
|
+
<BusinessIcon sx={{ fontSize: 20, color: isSelected(null) ? 'primary.main' : 'text.secondary' }} />
|
|
433
|
+
<Box>
|
|
434
|
+
<Typography variant="body2" sx={{ fontWeight: isSelected(null) ? 600 : 400, color: isSelected(null) ? 'primary.main' : 'text.primary' }}>
|
|
435
|
+
{tenancy?.public_name || 'Tenancy Account'}
|
|
436
|
+
</Typography>
|
|
437
|
+
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
|
438
|
+
Manager Account
|
|
439
|
+
</Typography>
|
|
440
|
+
</Box>
|
|
441
|
+
</div>
|
|
442
|
+
|
|
443
|
+
{/* Divider */}
|
|
444
|
+
<Box sx={{ borderTop: '1px solid rgba(255,255,255,0.06)', mx: 1 }} />
|
|
445
|
+
|
|
446
|
+
{/* Tenant options */}
|
|
447
|
+
{tenants.map((tenant) => (
|
|
448
|
+
<div
|
|
449
|
+
key={tenant.id}
|
|
450
|
+
onClick={() => handleSwitch(tenant.id)}
|
|
451
|
+
style={{
|
|
452
|
+
padding: '10px 16px',
|
|
453
|
+
cursor: 'pointer',
|
|
454
|
+
display: 'flex',
|
|
455
|
+
alignItems: 'center',
|
|
456
|
+
gap: 10,
|
|
457
|
+
backgroundColor: isSelected(tenant.id) ? 'rgba(var(--dash-primary-rgb, 25, 118, 210), 0.15)' : 'transparent',
|
|
458
|
+
borderLeft: isSelected(tenant.id) ? '3px solid var(--dash-primary, #1976d2)' : '3px solid transparent',
|
|
459
|
+
transition: 'background-color 0.15s ease',
|
|
460
|
+
}}
|
|
461
|
+
onMouseOver={(e) => { if (!isSelected(tenant.id)) (e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.05)'); }}
|
|
462
|
+
onMouseOut={(e) => { if (!isSelected(tenant.id)) (e.currentTarget.style.backgroundColor = 'transparent'); }}
|
|
463
|
+
>
|
|
464
|
+
<StoreIcon sx={{ fontSize: 20, color: isSelected(tenant.id) ? 'primary.main' : 'text.secondary' }} />
|
|
465
|
+
<Box>
|
|
466
|
+
<Typography variant="body2" sx={{ fontWeight: isSelected(tenant.id) ? 600 : 400, color: isSelected(tenant.id) ? 'primary.main' : 'text.primary' }}>
|
|
467
|
+
{tenant.name}
|
|
468
|
+
</Typography>
|
|
469
|
+
{tenant.slug && (
|
|
470
|
+
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
|
471
|
+
{tenant.slug}
|
|
472
|
+
</Typography>
|
|
473
|
+
)}
|
|
474
|
+
</Box>
|
|
475
|
+
</div>
|
|
476
|
+
))}
|
|
477
|
+
</div>
|
|
478
|
+
</div>,
|
|
479
|
+
document.body
|
|
480
|
+
)}
|
|
481
|
+
</>
|
|
482
|
+
);
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
export default TenantSwitcher;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EchoNotificationsCenter
|
|
3
|
+
*
|
|
4
|
+
* Wires LaravelEchoContext's real-time "urgency-alert" events into
|
|
5
|
+
* dash-components' generic NotificationsCenter (bell button + menu + toast
|
|
6
|
+
* stack + draggable dialogs). This replaces what used to be an ~500-line
|
|
7
|
+
* NotificationsCenter.tsx hand-copied into every domain app — kitchntabs-app,
|
|
8
|
+
* kitchntabs-system, kitchntabs-web, vanexa-app, vanexa-system, vanexa-web all
|
|
9
|
+
* had byte-identical copies of this exact wiring. Each of those apps now just
|
|
10
|
+
* renders <EchoNotificationsCenter /> instead of maintaining its own copy.
|
|
11
|
+
*
|
|
12
|
+
* kitchntabs-mall intentionally keeps its own NotificationsCenter — it also
|
|
13
|
+
* handles mall-specific "mall_order_status_update" events with event
|
|
14
|
+
* deduplication and translated messages, which is real business logic, not
|
|
15
|
+
* duplicated boilerplate. Folding it into this shared component would need a
|
|
16
|
+
* proper event-transformer API, not a copy/paste consolidation.
|
|
17
|
+
*/
|
|
18
|
+
import React, { useContext, useEffect } from 'react';
|
|
19
|
+
import { NotificationsCenter, useNotificationsCenter, type NotificationsCenterLabels } from 'dash-components';
|
|
20
|
+
import LaravelEchoContext, { ILaravelEchoContext } from '../LaravelEchoContext';
|
|
21
|
+
|
|
22
|
+
// Matches the hardcoded Spanish strings every duplicated app copy used to
|
|
23
|
+
// render (dash-components' defaults are English).
|
|
24
|
+
const spanishLabels: NotificationsCenterLabels = {
|
|
25
|
+
title: 'Notificaciones',
|
|
26
|
+
markAll: 'Marcar todas',
|
|
27
|
+
noNotifications: 'No hay notificaciones',
|
|
28
|
+
close: 'Cerrar',
|
|
29
|
+
acknowledge: 'Entendido',
|
|
30
|
+
customer: 'Cliente:',
|
|
31
|
+
store: 'Tienda:',
|
|
32
|
+
received: 'Recibido:',
|
|
33
|
+
now: 'Ahora',
|
|
34
|
+
minutesAgo: 'm',
|
|
35
|
+
hoursAgo: 'h',
|
|
36
|
+
daysAgo: 'd',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const EchoNotificationsCenter: React.FC = () => {
|
|
40
|
+
const { lastEvent } = useContext<ILaravelEchoContext>(LaravelEchoContext);
|
|
41
|
+
const hook = useNotificationsCenter({ labels: spanishLabels });
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (lastEvent && lastEvent.type === 'urgency-alert') {
|
|
45
|
+
const notification: any = lastEvent?.notificationPayload;
|
|
46
|
+
hook.addNotification({
|
|
47
|
+
title: notification?.title || 'Alerta',
|
|
48
|
+
message: notification?.message || '',
|
|
49
|
+
customer: notification?.customer_info?.name
|
|
50
|
+
? `${notification.customer_info.name} - Mesa ${notification.customer_info.table}`
|
|
51
|
+
: '',
|
|
52
|
+
store: notification?.store_info?.name || '',
|
|
53
|
+
data: notification,
|
|
54
|
+
}, { showDialog: true, showToast: true, toastSeverity: 'warning' });
|
|
55
|
+
}
|
|
56
|
+
// Only re-run when a new event arrives, matching the original per-app
|
|
57
|
+
// implementations — addNotification's own setState calls are all
|
|
58
|
+
// functional updates, so a stale hook reference here is harmless.
|
|
59
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
60
|
+
}, [lastEvent]);
|
|
61
|
+
|
|
62
|
+
return <NotificationsCenter notificationsHook={hook} />;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export default EchoNotificationsCenter;
|
|
@@ -82,7 +82,7 @@ const GenerateItems: React.FC<{ items: IMenuItem[]; navExpanded: boolean, navSiz
|
|
|
82
82
|
/>
|
|
83
83
|
) : (
|
|
84
84
|
|
|
85
|
-
<SidebarItem
|
|
85
|
+
<SidebarItem level={level} navExpanded={navExpanded} navSize={navSize} item={item} key={index} />
|
|
86
86
|
);
|
|
87
87
|
})}
|
|
88
88
|
</List>
|
|
@@ -71,7 +71,7 @@ const SidebarActions: React.FC<SidebarActionsProps> = ({
|
|
|
71
71
|
<HeaderToolBar />
|
|
72
72
|
) : (
|
|
73
73
|
// Default mode: Render all actions
|
|
74
|
-
<>
|
|
74
|
+
<>
|
|
75
75
|
{HeaderToolBar && <HeaderToolBar />}
|
|
76
76
|
{authContext?.authenticated ? <BridgedLocalesMenuButton /> : <LangSwitcher />}
|
|
77
77
|
<DarkToggleMode />
|
package/src/index.tsx
CHANGED
|
@@ -32,6 +32,7 @@ export { default as CacheInvalidatorListenerComponent } from './utils/cache/Cach
|
|
|
32
32
|
export { AuthContext } from './contexts/auth';
|
|
33
33
|
export { NotificationComponent } from './contexts/com/components/NotificationRenderer'
|
|
34
34
|
export { NotificationWrapper } from './contexts/com/components/NotificationsWidget'
|
|
35
|
+
export { default as EchoNotificationsCenter } from './contexts/com/components/EchoNotificationsCenter'
|
|
35
36
|
|
|
36
37
|
export { default as useLaravelEcho } from './contexts/com/useLaravelEcho';
|
|
37
38
|
export { default as WSMessagesManager } from './hooks/notifications/WSMessagesManager';
|
|
@@ -84,7 +85,21 @@ export { default as useFormPersistence } from "./hooks/useFormPersistance";
|
|
|
84
85
|
export { default as AvatarComponent } from './components/user/AvatarComponent';
|
|
85
86
|
export { default as TenantAvatarComponent } from './components/user/TenantAvatarComponent';
|
|
86
87
|
export { default as UserPreferences } from './components/user/UserPreferences';
|
|
88
|
+
export { default as Profile } from './pages/Profile';
|
|
87
89
|
export { default as LanguageSwitcher } from './components/misc/LanguageSwitcher';
|
|
90
|
+
export { default as LangSwitcher } from './components/i18n/LangSwitcher';
|
|
91
|
+
export {
|
|
92
|
+
default as TenantSwitcher,
|
|
93
|
+
dispatchTenantSwitchEvent,
|
|
94
|
+
getActiveTenantId,
|
|
95
|
+
isTenantImpersonationEnabled,
|
|
96
|
+
type TenantSwitcherProps,
|
|
97
|
+
type TenantSwitchEventDetail,
|
|
98
|
+
} from './components/tenancy/TenantSwitcher';
|
|
99
|
+
export {
|
|
100
|
+
default as GlobalTenantWrapper,
|
|
101
|
+
type IGlobalTenantWrapper,
|
|
102
|
+
} from './components/tenancy/GlobalTenantWrapper';
|
|
88
103
|
export { default as DarkModeSwitcher } from './components/misc/DarkModeSwitcher';
|
|
89
104
|
|
|
90
105
|
// Navigation Components
|