@hed-hog/core 0.0.299 → 0.0.301

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.
Files changed (133) hide show
  1. package/dist/ai/ai.service.d.ts +13 -2
  2. package/dist/ai/ai.service.d.ts.map +1 -1
  3. package/dist/ai/ai.service.js +104 -2
  4. package/dist/ai/ai.service.js.map +1 -1
  5. package/dist/dashboard/dashboard/dashboard.controller.d.ts +6 -0
  6. package/dist/dashboard/dashboard/dashboard.controller.d.ts.map +1 -1
  7. package/dist/dashboard/dashboard/dashboard.service.d.ts +6 -0
  8. package/dist/dashboard/dashboard/dashboard.service.d.ts.map +1 -1
  9. package/dist/dashboard/dashboard-component/dashboard-component.controller.d.ts +2 -1
  10. package/dist/dashboard/dashboard-component/dashboard-component.controller.d.ts.map +1 -1
  11. package/dist/dashboard/dashboard-component/dashboard-component.controller.js +6 -3
  12. package/dist/dashboard/dashboard-component/dashboard-component.controller.js.map +1 -1
  13. package/dist/dashboard/dashboard-component/dashboard-component.service.d.ts +7 -1
  14. package/dist/dashboard/dashboard-component/dashboard-component.service.d.ts.map +1 -1
  15. package/dist/dashboard/dashboard-component/dashboard-component.service.js +76 -33
  16. package/dist/dashboard/dashboard-component/dashboard-component.service.js.map +1 -1
  17. package/dist/dashboard/dashboard-core/dashboard-core.controller.d.ts +82 -0
  18. package/dist/dashboard/dashboard-core/dashboard-core.controller.d.ts.map +1 -1
  19. package/dist/dashboard/dashboard-core/dashboard-core.controller.js +117 -0
  20. package/dist/dashboard/dashboard-core/dashboard-core.controller.js.map +1 -1
  21. package/dist/dashboard/dashboard-core/dashboard-core.service.d.ts +93 -0
  22. package/dist/dashboard/dashboard-core/dashboard-core.service.d.ts.map +1 -1
  23. package/dist/dashboard/dashboard-core/dashboard-core.service.js +654 -20
  24. package/dist/dashboard/dashboard-core/dashboard-core.service.js.map +1 -1
  25. package/dist/dashboard/dashboard-item/dashboard-item.controller.d.ts +2 -0
  26. package/dist/dashboard/dashboard-item/dashboard-item.controller.d.ts.map +1 -1
  27. package/dist/dashboard/dashboard-item/dashboard-item.service.d.ts +2 -0
  28. package/dist/dashboard/dashboard-item/dashboard-item.service.d.ts.map +1 -1
  29. package/dist/dashboard/dashboard-role/dashboard-role.controller.d.ts +2 -0
  30. package/dist/dashboard/dashboard-role/dashboard-role.controller.d.ts.map +1 -1
  31. package/dist/dashboard/dashboard-role/dashboard-role.service.d.ts +2 -0
  32. package/dist/dashboard/dashboard-role/dashboard-role.service.d.ts.map +1 -1
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +1 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/mail/mail.service.d.ts +9 -2
  38. package/dist/mail/mail.service.d.ts.map +1 -1
  39. package/dist/mail/mail.service.js +56 -4
  40. package/dist/mail/mail.service.js.map +1 -1
  41. package/dist/setting/setting.service.d.ts +6 -1
  42. package/dist/setting/setting.service.d.ts.map +1 -1
  43. package/dist/setting/setting.service.js +188 -15
  44. package/dist/setting/setting.service.js.map +1 -1
  45. package/hedhog/data/dashboard.yaml +12 -6
  46. package/hedhog/data/dashboard_component_role.yaml +66 -0
  47. package/hedhog/data/dashboard_role.yaml +2 -8
  48. package/hedhog/data/route.yaml +72 -0
  49. package/hedhog/data/setting_group.yaml +28 -0
  50. package/hedhog/frontend/app/dashboard/[slug]/dashboard-content.tsx.ejs +333 -128
  51. package/hedhog/frontend/app/dashboard/[slug]/widget-renderer.tsx.ejs +277 -53
  52. package/hedhog/frontend/app/dashboard/components/add-widget-selector-dialog.tsx.ejs +179 -231
  53. package/hedhog/frontend/app/dashboard/components/draggable-grid.tsx.ejs +64 -18
  54. package/hedhog/frontend/app/dashboard/dashboard-home-tabs.tsx.ejs +1619 -0
  55. package/hedhog/frontend/app/dashboard/dashboard.css.ejs +37 -0
  56. package/hedhog/frontend/app/dashboard/management/page.tsx.ejs +1 -1
  57. package/hedhog/frontend/app/dashboard/management/tabs/components-tab.tsx.ejs +6 -6
  58. package/hedhog/frontend/app/dashboard/management/tabs/dashboards-tab.tsx.ejs +8 -8
  59. package/hedhog/frontend/app/dashboard/management/tabs/items-tab.tsx.ejs +3 -3
  60. package/hedhog/frontend/app/dashboard/page.tsx.ejs +3 -25
  61. package/hedhog/frontend/messages/en.json +124 -2
  62. package/hedhog/frontend/messages/pt.json +123 -1
  63. package/hedhog/frontend/widgets/account-security.tsx.ejs +1 -1
  64. package/hedhog/frontend/widgets/active-users-card.tsx.ejs +2 -2
  65. package/hedhog/frontend/widgets/activity-timeline.tsx.ejs +1 -1
  66. package/hedhog/frontend/widgets/email-notifications.tsx.ejs +1 -1
  67. package/hedhog/frontend/widgets/locale-config.tsx.ejs +1 -1
  68. package/hedhog/frontend/widgets/login-history-chart.tsx.ejs +1 -1
  69. package/hedhog/frontend/widgets/mail-config.tsx.ejs +1 -1
  70. package/hedhog/frontend/widgets/mail-sent-card.tsx.ejs +2 -2
  71. package/hedhog/frontend/widgets/mail-sent-chart.tsx.ejs +1 -1
  72. package/hedhog/frontend/widgets/menus-card.tsx.ejs +2 -2
  73. package/hedhog/frontend/widgets/oauth-config.tsx.ejs +1 -1
  74. package/hedhog/frontend/widgets/permissions-card.tsx.ejs +2 -2
  75. package/hedhog/frontend/widgets/permissions-chart.tsx.ejs +1 -1
  76. package/hedhog/frontend/widgets/profile-card.tsx.ejs +1 -1
  77. package/hedhog/frontend/widgets/routes-card.tsx.ejs +2 -2
  78. package/hedhog/frontend/widgets/session-activity-chart.tsx.ejs +1 -1
  79. package/hedhog/frontend/widgets/sessions-today-card.tsx.ejs +2 -2
  80. package/hedhog/frontend/widgets/stat-access-level.tsx.ejs +1 -1
  81. package/hedhog/frontend/widgets/stat-actions-today.tsx.ejs +1 -1
  82. package/hedhog/frontend/widgets/stat-consecutive-days.tsx.ejs +1 -1
  83. package/hedhog/frontend/widgets/stat-online-time.tsx.ejs +1 -1
  84. package/hedhog/frontend/widgets/storage-config.tsx.ejs +1 -1
  85. package/hedhog/frontend/widgets/theme-config.tsx.ejs +1 -1
  86. package/hedhog/frontend/widgets/user-growth-chart.tsx.ejs +1 -1
  87. package/hedhog/frontend/widgets/user-roles.tsx.ejs +1 -1
  88. package/hedhog/frontend/widgets/user-sessions.tsx.ejs +1 -1
  89. package/hedhog/table/dashboard.yaml +6 -0
  90. package/package.json +3 -3
  91. package/src/ai/ai.service.ts +129 -1
  92. package/src/dashboard/dashboard-component/dashboard-component.controller.ts +15 -2
  93. package/src/dashboard/dashboard-component/dashboard-component.service.ts +107 -43
  94. package/src/dashboard/dashboard-core/dashboard-core.controller.ts +119 -1
  95. package/src/dashboard/dashboard-core/dashboard-core.service.ts +876 -20
  96. package/src/index.ts +7 -6
  97. package/src/mail/mail.service.ts +67 -3
  98. package/src/setting/setting.service.ts +222 -15
  99. package/hedhog/frontend/app/dashboard/components/widgets/core..gitkeep.ejs +0 -11
  100. package/hedhog/frontend/app/dashboard/components/widgets/core.account-security.tsx.ejs +0 -192
  101. package/hedhog/frontend/app/dashboard/components/widgets/core.active-users-card.tsx.ejs +0 -58
  102. package/hedhog/frontend/app/dashboard/components/widgets/core.activity-timeline.tsx.ejs +0 -223
  103. package/hedhog/frontend/app/dashboard/components/widgets/core.email-notifications.tsx.ejs +0 -226
  104. package/hedhog/frontend/app/dashboard/components/widgets/core.locale-config.tsx.ejs +0 -168
  105. package/hedhog/frontend/app/dashboard/components/widgets/core.login-history-chart.tsx.ejs +0 -115
  106. package/hedhog/frontend/app/dashboard/components/widgets/core.mail-config.tsx.ejs +0 -199
  107. package/hedhog/frontend/app/dashboard/components/widgets/core.mail-sent-card.tsx.ejs +0 -58
  108. package/hedhog/frontend/app/dashboard/components/widgets/core.mail-sent-chart.tsx.ejs +0 -149
  109. package/hedhog/frontend/app/dashboard/components/widgets/core.menus-card.tsx.ejs +0 -58
  110. package/hedhog/frontend/app/dashboard/components/widgets/core.oauth-config.tsx.ejs +0 -175
  111. package/hedhog/frontend/app/dashboard/components/widgets/core.permissions-card.tsx.ejs +0 -61
  112. package/hedhog/frontend/app/dashboard/components/widgets/core.permissions-chart.tsx.ejs +0 -156
  113. package/hedhog/frontend/app/dashboard/components/widgets/core.profile-card.tsx.ejs +0 -186
  114. package/hedhog/frontend/app/dashboard/components/widgets/core.routes-card.tsx.ejs +0 -58
  115. package/hedhog/frontend/app/dashboard/components/widgets/core.session-activity-chart.tsx.ejs +0 -183
  116. package/hedhog/frontend/app/dashboard/components/widgets/core.sessions-today-card.tsx.ejs +0 -62
  117. package/hedhog/frontend/app/dashboard/components/widgets/core.stat-access-level.tsx.ejs +0 -57
  118. package/hedhog/frontend/app/dashboard/components/widgets/core.stat-actions-today.tsx.ejs +0 -57
  119. package/hedhog/frontend/app/dashboard/components/widgets/core.stat-consecutive-days.tsx.ejs +0 -57
  120. package/hedhog/frontend/app/dashboard/components/widgets/core.stat-online-time.tsx.ejs +0 -57
  121. package/hedhog/frontend/app/dashboard/components/widgets/core.storage-config.tsx.ejs +0 -196
  122. package/hedhog/frontend/app/dashboard/components/widgets/core.theme-config.tsx.ejs +0 -213
  123. package/hedhog/frontend/app/dashboard/components/widgets/core.user-growth-chart.tsx.ejs +0 -210
  124. package/hedhog/frontend/app/dashboard/components/widgets/core.user-roles.tsx.ejs +0 -132
  125. package/hedhog/frontend/app/dashboard/components/widgets/core.user-sessions.tsx.ejs +0 -236
  126. package/hedhog/frontend/app/dashboard/components/widgets/finance.alerts.tsx.ejs +0 -108
  127. package/hedhog/frontend/app/dashboard/components/widgets/finance.cash-balance-kpi.tsx.ejs +0 -66
  128. package/hedhog/frontend/app/dashboard/components/widgets/finance.cash-flow-chart.tsx.ejs +0 -122
  129. package/hedhog/frontend/app/dashboard/components/widgets/finance.default-kpi.tsx.ejs +0 -63
  130. package/hedhog/frontend/app/dashboard/components/widgets/finance.payable-30d-kpi.tsx.ejs +0 -73
  131. package/hedhog/frontend/app/dashboard/components/widgets/finance.receivable-30d-kpi.tsx.ejs +0 -73
  132. package/hedhog/frontend/app/dashboard/components/widgets/finance.upcoming-payable.tsx.ejs +0 -123
  133. package/hedhog/frontend/app/dashboard/components/widgets/finance.upcoming-receivable.tsx.ejs +0 -118
@@ -0,0 +1,1619 @@
1
+ 'use client';
2
+
3
+ import { Page, PageHeader } from '@/components/entity-list';
4
+ import {
5
+ AlertDialog,
6
+ AlertDialogAction,
7
+ AlertDialogCancel,
8
+ AlertDialogContent,
9
+ AlertDialogDescription,
10
+ AlertDialogFooter,
11
+ AlertDialogHeader,
12
+ AlertDialogTitle,
13
+ } from '@/components/ui/alert-dialog';
14
+ import { Badge } from '@/components/ui/badge';
15
+ import { Button } from '@/components/ui/button';
16
+ import {
17
+ Command,
18
+ CommandEmpty,
19
+ CommandGroup,
20
+ CommandInput,
21
+ CommandItem,
22
+ CommandList,
23
+ } from '@/components/ui/command';
24
+ import {
25
+ ContextMenu,
26
+ ContextMenuContent,
27
+ ContextMenuItem,
28
+ ContextMenuSeparator,
29
+ ContextMenuTrigger,
30
+ } from '@/components/ui/context-menu';
31
+ import { Input } from '@/components/ui/input';
32
+ import { ScrollArea } from '@/components/ui/scroll-area';
33
+ import {
34
+ Sheet,
35
+ SheetContent,
36
+ SheetDescription,
37
+ SheetHeader,
38
+ SheetTitle,
39
+ } from '@/components/ui/sheet';
40
+ import { Skeleton } from '@/components/ui/skeleton';
41
+ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
42
+ import { useDebounce } from '@/hooks/use-debounce';
43
+ import { cn } from '@/lib/utils';
44
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
45
+ import * as LucideIcons from 'lucide-react';
46
+ import {
47
+ Check,
48
+ Home,
49
+ LayoutDashboard,
50
+ Loader2,
51
+ Palette,
52
+ Pencil,
53
+ Plus,
54
+ Share2,
55
+ Trash2,
56
+ UserPlus,
57
+ X,
58
+ type LucideIcon,
59
+ } from 'lucide-react';
60
+ import { useTranslations } from 'next-intl';
61
+ import { useEffect, useMemo, useState } from 'react';
62
+ import { toast } from 'sonner';
63
+ import { DashboardContent } from './[slug]/dashboard-content';
64
+
65
+ type DashboardTab = {
66
+ id: number;
67
+ slug: string;
68
+ name?: string;
69
+ icon?: string | null;
70
+ is_home?: boolean;
71
+ dashboard_locale?: Array<{
72
+ name?: string | null;
73
+ }>;
74
+ };
75
+
76
+ type SharedUser = {
77
+ id: number;
78
+ name: string;
79
+ email: string | null;
80
+ isCurrentUser?: boolean;
81
+ isHome?: boolean;
82
+ hasRequiredRoles?: boolean;
83
+ accessStatus?: 'allowed' | 'missing-roles';
84
+ };
85
+
86
+ type PaginatedResponse<T> = {
87
+ data: T[];
88
+ total: number;
89
+ page: number;
90
+ pageSize: number;
91
+ lastPage: number;
92
+ prev: number | null;
93
+ next: number | null;
94
+ };
95
+
96
+ type DashboardTemplate = {
97
+ id: number;
98
+ slug: string;
99
+ name: string;
100
+ icon?: string | null;
101
+ itemCount: number;
102
+ };
103
+
104
+ const getDashboardName = (dashboard: DashboardTab) =>
105
+ dashboard.name || dashboard.dashboard_locale?.[0]?.name || dashboard.slug;
106
+
107
+ const DASHBOARD_HOME_HEADER_ACTIONS_TARGET_ID = 'dashboard-home-header-actions';
108
+ const EMPTY_SHAREABLE_USERS_PAGE: PaginatedResponse<SharedUser> = {
109
+ data: [],
110
+ total: 0,
111
+ page: 1,
112
+ pageSize: 10,
113
+ lastPage: 1,
114
+ prev: null,
115
+ next: null,
116
+ };
117
+
118
+ const DASHBOARD_ICON_OPTIONS = [
119
+ 'layout-dashboard',
120
+ 'bar-chart-3',
121
+ 'bar-chart-4',
122
+ 'chart-column',
123
+ 'chart-line',
124
+ 'chart-no-axes-combined',
125
+ 'pie-chart',
126
+ 'trending-up',
127
+ 'trending-down',
128
+ 'briefcase',
129
+ 'building-2',
130
+ 'folder-kanban',
131
+ 'graduation-cap',
132
+ 'book-open',
133
+ 'landmark',
134
+ 'wallet',
135
+ 'badge-dollar-sign',
136
+ 'circle-dollar-sign',
137
+ 'banknote',
138
+ 'hand-coins',
139
+ 'credit-card',
140
+ 'receipt',
141
+ 'shopping-cart',
142
+ 'package',
143
+ 'store',
144
+ 'users',
145
+ 'user-round',
146
+ 'contact-round',
147
+ 'message-square',
148
+ 'mail',
149
+ 'file-text',
150
+ 'file-code-2',
151
+ 'circle-help',
152
+ 'tags',
153
+ 'hash',
154
+ 'ticket',
155
+ 'shield',
156
+ 'settings-2',
157
+ 'database',
158
+ 'activity',
159
+ 'calendar',
160
+ 'calendar-days',
161
+ 'clock-3',
162
+ 'timer',
163
+ 'hourglass',
164
+ 'globe',
165
+ 'target',
166
+ 'wrench',
167
+ 'home',
168
+ 'house',
169
+ 'map-pinned',
170
+ 'navigation',
171
+ 'compass',
172
+ 'zap',
173
+ 'flame',
174
+ 'star',
175
+ 'heart',
176
+ 'thumbs-up',
177
+ 'rocket',
178
+ 'sparkles',
179
+ 'bell',
180
+ 'megaphone',
181
+ 'search',
182
+ 'filter',
183
+ 'list',
184
+ 'layout-list',
185
+ 'layout-grid',
186
+ 'kanban',
187
+ 'sliders-horizontal',
188
+ 'cpu',
189
+ 'monitor',
190
+ 'smartphone',
191
+ 'tablet',
192
+ 'wifi',
193
+ 'cloud',
194
+ 'cloud-sun',
195
+ 'sun',
196
+ 'moon',
197
+ 'palette',
198
+ 'brush-cleaning',
199
+ 'camera',
200
+ 'image',
201
+ 'video',
202
+ 'music',
203
+ 'headphones',
204
+ 'mic',
205
+ 'phone',
206
+ 'send',
207
+ 'inbox',
208
+ 'archive',
209
+ 'clipboard-list',
210
+ 'check-circle-2',
211
+ 'x-circle',
212
+ 'alert-triangle',
213
+ 'info',
214
+ 'notebook-text',
215
+ ] as const;
216
+
217
+ const normalizeLucideIconName = (value: string) =>
218
+ value
219
+ .trim()
220
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
221
+ .replace(/([a-zA-Z])(\d)/g, '$1-$2')
222
+ .replace(/(\d)([a-zA-Z])/g, '$1-$2')
223
+ .split(/[\s_-]+/)
224
+ .filter(Boolean)
225
+ .map((part) => part.toLowerCase())
226
+ .join('-');
227
+
228
+ const getLucideComponentName = (value: string) =>
229
+ normalizeLucideIconName(value)
230
+ .split('-')
231
+ .filter(Boolean)
232
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
233
+ .join('');
234
+
235
+ const isLucideIconComponent = (candidate: unknown): candidate is LucideIcon =>
236
+ candidate !== null &&
237
+ candidate !== undefined &&
238
+ (typeof candidate === 'function' || typeof candidate === 'object');
239
+
240
+ const resolveDashboardIcon = (iconName?: string | null): LucideIcon => {
241
+ const componentName = iconName ? getLucideComponentName(iconName) : '';
242
+ const candidate = componentName
243
+ ? LucideIcons[componentName as keyof typeof LucideIcons]
244
+ : undefined;
245
+
246
+ return isLucideIconComponent(candidate)
247
+ ? (candidate as LucideIcon)
248
+ : LayoutDashboard;
249
+ };
250
+
251
+ const isValidLucideIconName = (iconName: string) => {
252
+ const componentName = getLucideComponentName(iconName);
253
+ const candidate = componentName
254
+ ? LucideIcons[componentName as keyof typeof LucideIcons]
255
+ : undefined;
256
+
257
+ return isLucideIconComponent(candidate);
258
+ };
259
+
260
+ const getErrorMessage = (error: unknown, fallback: string) => {
261
+ if (
262
+ typeof error === 'object' &&
263
+ error !== null &&
264
+ 'response' in error &&
265
+ typeof error.response === 'object' &&
266
+ error.response !== null &&
267
+ 'data' in error.response &&
268
+ typeof error.response.data === 'object' &&
269
+ error.response.data !== null &&
270
+ 'message' in error.response.data
271
+ ) {
272
+ const message = error.response.data.message;
273
+
274
+ if (Array.isArray(message)) {
275
+ return message.join(', ');
276
+ }
277
+
278
+ if (typeof message === 'string') {
279
+ return message;
280
+ }
281
+ }
282
+
283
+ if (error instanceof Error && error.message) {
284
+ return error.message;
285
+ }
286
+
287
+ return fallback;
288
+ };
289
+
290
+ export function DashboardHomeTabs() {
291
+ const t = useTranslations('core.DashboardHomeTabs');
292
+ const { request, currentLocaleCode } = useApp();
293
+ const [activeSlug, setActiveSlug] = useState('');
294
+ const [createOpen, setCreateOpen] = useState(false);
295
+ const [shareOpen, setShareOpen] = useState(false);
296
+ const [deleteOpen, setDeleteOpen] = useState(false);
297
+ const [newDashboardName, setNewDashboardName] = useState('');
298
+ const [newDashboardIcon, setNewDashboardIcon] = useState('layout-dashboard');
299
+ const [selectedTemplateSlug, setSelectedTemplateSlug] = useState('');
300
+ const [shareSearch, setShareSearch] = useState('');
301
+ const [sharePage, setSharePage] = useState(1);
302
+ const [selectedShareUsers, setSelectedShareUsers] = useState<SharedUser[]>(
303
+ []
304
+ );
305
+ const [renamingSlug, setRenamingSlug] = useState<string | null>(null);
306
+ const [renameValue, setRenameValue] = useState('');
307
+ const [isCreating, setIsCreating] = useState(false);
308
+ const [isDeleting, setIsDeleting] = useState(false);
309
+ const [isRenaming, setIsRenaming] = useState(false);
310
+ const [isSharingUsers, setIsSharingUsers] = useState(false);
311
+ const [revokingUserId, setRevokingUserId] = useState<number | null>(null);
312
+ const [isSettingHome, setIsSettingHome] = useState(false);
313
+ const [openAddWidgetSignal, setOpenAddWidgetSignal] = useState(0);
314
+ const [iconOpen, setIconOpen] = useState(false);
315
+ const [iconDashboard, setIconDashboard] = useState<DashboardTab | null>(null);
316
+ const [iconValue, setIconValue] = useState('layout-dashboard');
317
+ const [isSavingIcon, setIsSavingIcon] = useState(false);
318
+
319
+ const debouncedShareSearch = useDebounce(shareSearch, 300);
320
+
321
+ const {
322
+ data: dashboards = [],
323
+ isLoading,
324
+ refetch: refetchDashboards,
325
+ } = useQuery<DashboardTab[]>({
326
+ queryKey: ['dashboard-home-tabs', currentLocaleCode],
327
+ queryFn: async () => {
328
+ const response = await request<DashboardTab[]>({
329
+ url: '/dashboard-core/user-dashboards',
330
+ method: 'GET',
331
+ });
332
+
333
+ return response.data ?? [];
334
+ },
335
+ });
336
+
337
+ const { data: dashboardTemplates = [], isLoading: isLoadingTemplates } =
338
+ useQuery<DashboardTemplate[]>({
339
+ queryKey: ['dashboard-templates', currentLocaleCode, createOpen],
340
+ queryFn: async () => {
341
+ const response = await request<DashboardTemplate[]>({
342
+ url: '/dashboard-core/templates',
343
+ method: 'GET',
344
+ });
345
+
346
+ return response.data ?? [];
347
+ },
348
+ enabled: createOpen,
349
+ });
350
+
351
+ const activeDashboard = useMemo(
352
+ () => dashboards.find((dashboard) => dashboard.slug === activeSlug) ?? null,
353
+ [activeSlug, dashboards]
354
+ );
355
+
356
+ const selectedTemplate = useMemo(
357
+ () =>
358
+ dashboardTemplates.find(
359
+ (template) => template.slug === selectedTemplateSlug
360
+ ) ?? null,
361
+ [dashboardTemplates, selectedTemplateSlug]
362
+ );
363
+
364
+ const selectedShareUserIds = useMemo(
365
+ () => selectedShareUsers.map((user) => user.id),
366
+ [selectedShareUsers]
367
+ );
368
+
369
+ const selectedUsersWithoutRequiredRoles = useMemo(
370
+ () => selectedShareUsers.filter((user) => user.hasRequiredRoles === false),
371
+ [selectedShareUsers]
372
+ );
373
+
374
+ useEffect(() => {
375
+ if (dashboards.length === 0) {
376
+ setActiveSlug('');
377
+ return;
378
+ }
379
+
380
+ const hasActiveDashboard = dashboards.some(
381
+ (dashboard) => dashboard.slug === activeSlug
382
+ );
383
+
384
+ if (!activeSlug || !hasActiveDashboard) {
385
+ const nextDashboard =
386
+ dashboards.find((dashboard) => dashboard.is_home) ?? dashboards[0];
387
+
388
+ if (nextDashboard) {
389
+ setActiveSlug(nextDashboard.slug);
390
+ }
391
+ }
392
+ }, [activeSlug, dashboards]);
393
+
394
+ const {
395
+ data: sharedUsers = [],
396
+ isLoading: isLoadingShares,
397
+ refetch: refetchShares,
398
+ } = useQuery<SharedUser[]>({
399
+ queryKey: ['dashboard-shares', activeSlug, shareOpen],
400
+ queryFn: async () => {
401
+ if (!activeSlug) {
402
+ return [];
403
+ }
404
+
405
+ const response = await request<SharedUser[]>({
406
+ url: `/dashboard-core/dashboard/${activeSlug}/shares`,
407
+ method: 'GET',
408
+ });
409
+
410
+ return response.data ?? [];
411
+ },
412
+ enabled: shareOpen && Boolean(activeSlug),
413
+ });
414
+
415
+ const {
416
+ data: shareableUsersPage = EMPTY_SHAREABLE_USERS_PAGE,
417
+ isLoading: isLoadingShareableUsers,
418
+ refetch: refetchShareableUsers,
419
+ } = useQuery<PaginatedResponse<SharedUser>>({
420
+ queryKey: [
421
+ 'dashboard-shareable-users',
422
+ activeSlug,
423
+ debouncedShareSearch,
424
+ sharePage,
425
+ shareOpen,
426
+ ],
427
+ queryFn: async () => {
428
+ if (!activeSlug) {
429
+ return EMPTY_SHAREABLE_USERS_PAGE;
430
+ }
431
+
432
+ const params = new URLSearchParams();
433
+ params.set('page', String(sharePage));
434
+ params.set('pageSize', '10');
435
+
436
+ if (debouncedShareSearch.trim()) {
437
+ params.set('search', debouncedShareSearch.trim());
438
+ }
439
+
440
+ const response = await request<
441
+ PaginatedResponse<SharedUser> | SharedUser[]
442
+ >({
443
+ url: `/dashboard-core/shareable-users/${activeSlug}?${params.toString()}`,
444
+ method: 'GET',
445
+ });
446
+
447
+ const payload = response.data;
448
+
449
+ if (Array.isArray(payload)) {
450
+ return {
451
+ ...EMPTY_SHAREABLE_USERS_PAGE,
452
+ data: payload,
453
+ total: payload.length,
454
+ };
455
+ }
456
+
457
+ return payload ?? EMPTY_SHAREABLE_USERS_PAGE;
458
+ },
459
+ enabled: shareOpen && Boolean(activeSlug),
460
+ placeholderData: (previous) => previous ?? EMPTY_SHAREABLE_USERS_PAGE,
461
+ });
462
+
463
+ const shareableUsers = shareableUsersPage.data ?? [];
464
+
465
+ const handleSelectTemplate = (template: DashboardTemplate | null) => {
466
+ setSelectedTemplateSlug(template?.slug ?? '');
467
+ setNewDashboardName(template?.name ?? '');
468
+ setNewDashboardIcon(template?.icon ?? 'layout-dashboard');
469
+ };
470
+
471
+ const handleCreateDashboard = async () => {
472
+ const trimmedName = newDashboardName.trim();
473
+ const normalizedIcon = newDashboardIcon.trim()
474
+ ? normalizeLucideIconName(newDashboardIcon)
475
+ : 'layout-dashboard';
476
+
477
+ if (!trimmedName) {
478
+ toast.error(t('enterDashboardName'));
479
+ return;
480
+ }
481
+
482
+ if (normalizedIcon && !isValidLucideIconName(normalizedIcon)) {
483
+ toast.error(t('enterValidLucideIcon'));
484
+ return;
485
+ }
486
+
487
+ try {
488
+ setIsCreating(true);
489
+
490
+ const response = await request<DashboardTab>({
491
+ url: '/dashboard-core/dashboard',
492
+ method: 'POST',
493
+ data: {
494
+ name: trimmedName,
495
+ icon: normalizedIcon,
496
+ ...(selectedTemplateSlug
497
+ ? { templateSlug: selectedTemplateSlug }
498
+ : {}),
499
+ },
500
+ });
501
+
502
+ await refetchDashboards();
503
+ setActiveSlug(response.data?.slug ?? activeSlug);
504
+ setNewDashboardName('');
505
+ setNewDashboardIcon('layout-dashboard');
506
+ setSelectedTemplateSlug('');
507
+ setCreateOpen(false);
508
+ toast.success(t('dashboardCreated'));
509
+ } catch (error) {
510
+ toast.error(getErrorMessage(error, t('createDashboardError')));
511
+ } finally {
512
+ setIsCreating(false);
513
+ }
514
+ };
515
+
516
+ const handleSetHomeDashboard = async (dashboard = activeDashboard) => {
517
+ if (!dashboard || dashboard.is_home) {
518
+ return;
519
+ }
520
+
521
+ cancelRenameDashboard();
522
+
523
+ try {
524
+ setIsSettingHome(true);
525
+ await request({
526
+ url: `/dashboard-core/dashboard/${dashboard.slug}/home`,
527
+ method: 'POST',
528
+ });
529
+ await refetchDashboards();
530
+ toast.success(t('setAsHomeSuccess'));
531
+ } catch (error) {
532
+ toast.error(getErrorMessage(error, t('updateHomeError')));
533
+ } finally {
534
+ setIsSettingHome(false);
535
+ }
536
+ };
537
+
538
+ const startRenameDashboard = (dashboard: DashboardTab) => {
539
+ setActiveSlug(dashboard.slug);
540
+ setRenamingSlug(dashboard.slug);
541
+ setRenameValue(getDashboardName(dashboard));
542
+ };
543
+
544
+ const cancelRenameDashboard = () => {
545
+ setRenamingSlug(null);
546
+ setRenameValue('');
547
+ };
548
+
549
+ const handleRenameDashboard = async (dashboard: DashboardTab) => {
550
+ const trimmedName = renameValue.trim();
551
+
552
+ if (!trimmedName) {
553
+ toast.error(t('enterDashboardName'));
554
+ return;
555
+ }
556
+
557
+ if (trimmedName === getDashboardName(dashboard)) {
558
+ cancelRenameDashboard();
559
+ return;
560
+ }
561
+
562
+ try {
563
+ setIsRenaming(true);
564
+ await request({
565
+ url: `/dashboard-core/dashboard/${dashboard.slug}`,
566
+ method: 'PATCH',
567
+ data: { name: trimmedName },
568
+ });
569
+ await refetchDashboards();
570
+ cancelRenameDashboard();
571
+ toast.success(t('dashboardRenamed'));
572
+ } catch (error) {
573
+ toast.error(getErrorMessage(error, t('renameDashboardError')));
574
+ } finally {
575
+ setIsRenaming(false);
576
+ }
577
+ };
578
+
579
+ const openShareForDashboard = (dashboard: DashboardTab) => {
580
+ cancelRenameDashboard();
581
+ setActiveSlug(dashboard.slug);
582
+ setShareSearch('');
583
+ setSharePage(1);
584
+ setSelectedShareUsers([]);
585
+ setShareOpen(true);
586
+ };
587
+
588
+ const openDeleteForDashboard = (dashboard: DashboardTab) => {
589
+ cancelRenameDashboard();
590
+ setActiveSlug(dashboard.slug);
591
+ setDeleteOpen(true);
592
+ };
593
+
594
+ const openAddWidgetsForDashboard = (dashboard: DashboardTab) => {
595
+ cancelRenameDashboard();
596
+ setActiveSlug(dashboard.slug);
597
+ setOpenAddWidgetSignal((previous) => previous + 1);
598
+ };
599
+
600
+ const openIconForDashboard = (dashboard: DashboardTab) => {
601
+ cancelRenameDashboard();
602
+ setActiveSlug(dashboard.slug);
603
+ setIconDashboard(dashboard);
604
+ setIconValue(
605
+ dashboard.icon
606
+ ? normalizeLucideIconName(dashboard.icon)
607
+ : 'layout-dashboard'
608
+ );
609
+ setIconOpen(true);
610
+ };
611
+
612
+ const handleSaveDashboardIcon = async () => {
613
+ if (!iconDashboard) {
614
+ return;
615
+ }
616
+
617
+ const trimmedValue = iconValue.trim();
618
+ const normalizedIcon = trimmedValue
619
+ ? normalizeLucideIconName(trimmedValue)
620
+ : null;
621
+
622
+ if (normalizedIcon && !isValidLucideIconName(normalizedIcon)) {
623
+ toast.error(t('enterValidLucideIcon'));
624
+ return;
625
+ }
626
+
627
+ try {
628
+ setIsSavingIcon(true);
629
+ await request({
630
+ url: `/dashboard-core/dashboard/${iconDashboard.slug}`,
631
+ method: 'PATCH',
632
+ data: {
633
+ icon: normalizedIcon,
634
+ },
635
+ });
636
+ await refetchDashboards();
637
+ setIconOpen(false);
638
+ setIconDashboard(null);
639
+ toast.success(t('dashboardIconUpdated'));
640
+ } catch (error) {
641
+ toast.error(getErrorMessage(error, t('updateDashboardIconError')));
642
+ } finally {
643
+ setIsSavingIcon(false);
644
+ }
645
+ };
646
+
647
+ const handleRemoveDashboard = async () => {
648
+ if (!activeDashboard) {
649
+ return;
650
+ }
651
+
652
+ try {
653
+ setIsDeleting(true);
654
+ await request({
655
+ url: `/dashboard-core/dashboard/${activeDashboard.slug}`,
656
+ method: 'DELETE',
657
+ });
658
+ await refetchDashboards();
659
+ setDeleteOpen(false);
660
+ toast.success(t('tabRemoved'));
661
+ } catch (error) {
662
+ toast.error(getErrorMessage(error, t('removeTabError')));
663
+ } finally {
664
+ setIsDeleting(false);
665
+ }
666
+ };
667
+
668
+ const toggleShareUserSelection = (user: SharedUser) => {
669
+ setSelectedShareUsers((current) => {
670
+ const alreadySelected = current.some((item) => item.id === user.id);
671
+
672
+ if (alreadySelected) {
673
+ return current.filter((item) => item.id !== user.id);
674
+ }
675
+
676
+ return [...current, user];
677
+ });
678
+ };
679
+
680
+ const removeSelectedShareUser = (userId: number) => {
681
+ setSelectedShareUsers((current) =>
682
+ current.filter((user) => user.id !== userId)
683
+ );
684
+ };
685
+
686
+ const handleShareSelectedUsers = async () => {
687
+ if (!activeDashboard || selectedShareUserIds.length === 0) {
688
+ return;
689
+ }
690
+
691
+ const selectionCount = selectedShareUserIds.length;
692
+
693
+ try {
694
+ setIsSharingUsers(true);
695
+ await request({
696
+ url: `/dashboard-core/dashboard/${activeDashboard.slug}/share`,
697
+ method: 'POST',
698
+ data: {
699
+ userIds: selectedShareUserIds,
700
+ },
701
+ });
702
+ await Promise.all([refetchShares(), refetchShareableUsers()]);
703
+ setSelectedShareUsers([]);
704
+ setShareSearch('');
705
+ setSharePage(1);
706
+ toast.success(t('dashboardSharedSelected', { count: selectionCount }));
707
+ } catch (error) {
708
+ toast.error(getErrorMessage(error, t('shareDashboardError')));
709
+ } finally {
710
+ setIsSharingUsers(false);
711
+ }
712
+ };
713
+
714
+ const handleRevokeShare = async (userId: number) => {
715
+ if (!activeDashboard) {
716
+ return;
717
+ }
718
+
719
+ try {
720
+ setRevokingUserId(userId);
721
+ await request({
722
+ url: `/dashboard-core/dashboard/${activeDashboard.slug}/share/${userId}`,
723
+ method: 'DELETE',
724
+ });
725
+ await Promise.all([refetchShares(), refetchShareableUsers()]);
726
+ toast.success(t('shareRemoved'));
727
+ } catch (error) {
728
+ toast.error(getErrorMessage(error, t('revokeShareError')));
729
+ } finally {
730
+ setRevokingUserId(null);
731
+ }
732
+ };
733
+
734
+ const isBusy =
735
+ isCreating ||
736
+ isDeleting ||
737
+ isRenaming ||
738
+ isSettingHome ||
739
+ isSavingIcon ||
740
+ isSharingUsers ||
741
+ revokingUserId !== null;
742
+
743
+ const headerActions = (
744
+ <div className="flex flex-wrap items-center justify-end gap-2">
745
+ {activeDashboard ? (
746
+ <div
747
+ id={DASHBOARD_HOME_HEADER_ACTIONS_TARGET_ID}
748
+ className="contents"
749
+ />
750
+ ) : null}
751
+ <Button
752
+ variant="outline"
753
+ size="sm"
754
+ onClick={() =>
755
+ activeDashboard && openShareForDashboard(activeDashboard)
756
+ }
757
+ disabled={!activeDashboard || isBusy}
758
+ >
759
+ <Share2 className="size-4" />
760
+ {t('share')}
761
+ </Button>
762
+ <Button size="sm" onClick={() => setCreateOpen(true)} disabled={isBusy}>
763
+ <Plus className="size-4" />
764
+ {t('newDashboard')}
765
+ </Button>
766
+ </div>
767
+ );
768
+
769
+ return (
770
+ <Page>
771
+ <PageHeader
772
+ breadcrumbs={[
773
+ { label: t('pageTitle'), href: '/core/dashboard' },
774
+ ...(activeDashboard
775
+ ? [{ label: getDashboardName(activeDashboard) }]
776
+ : [{ label: t('home') }]),
777
+ ]}
778
+ title={t('pageTitle')}
779
+ description={t('pageDescription')}
780
+ actions={headerActions}
781
+ />
782
+
783
+ {isLoading ? (
784
+ <div className="flex gap-2">
785
+ <Skeleton className="h-10 w-32 rounded-lg" />
786
+ <Skeleton className="h-10 w-36 rounded-lg" />
787
+ <Skeleton className="h-10 w-28 rounded-lg" />
788
+ </div>
789
+ ) : dashboards.length > 0 ? (
790
+ <div className="overflow-hidden">
791
+ <Tabs
792
+ value={activeSlug}
793
+ onValueChange={setActiveSlug}
794
+ className="gap-0"
795
+ >
796
+ <div className="bg-muted dark:bg-muted/40">
797
+ <ScrollArea className="w-full whitespace-nowrap">
798
+ <TabsList className="dashboard-tabs-list h-auto min-h-0 w-max items-end gap-0 rounded-none bg-transparent p-0 pt-1 pl-1">
799
+ {dashboards.map((dashboard) => {
800
+ const isRenamingCurrentTab =
801
+ renamingSlug === dashboard.slug;
802
+ const isActiveTab = activeSlug === dashboard.slug;
803
+ const DashboardTabIcon = resolveDashboardIcon(
804
+ dashboard.icon
805
+ );
806
+
807
+ const tabContent = (
808
+ <>
809
+ <DashboardTabIcon
810
+ className={cn('size-4 transition-colors')}
811
+ />
812
+ {isRenamingCurrentTab ? (
813
+ <Input
814
+ value={renameValue}
815
+ onChange={(event) =>
816
+ setRenameValue(event.target.value)
817
+ }
818
+ onClick={(event) => event.stopPropagation()}
819
+ onPointerDown={(event) => event.stopPropagation()}
820
+ onFocus={(event) => event.currentTarget.select()}
821
+ onBlur={() => void handleRenameDashboard(dashboard)}
822
+ onKeyDown={(event) => {
823
+ if (event.key === 'Enter') {
824
+ event.preventDefault();
825
+ void handleRenameDashboard(dashboard);
826
+ }
827
+
828
+ if (event.key === 'Escape') {
829
+ event.preventDefault();
830
+ cancelRenameDashboard();
831
+ }
832
+ }}
833
+ autoFocus
834
+ disabled={isRenaming}
835
+ className="h-7 w-40"
836
+ />
837
+ ) : (
838
+ <span className="max-w-45 truncate">
839
+ {getDashboardName(dashboard)}
840
+ </span>
841
+ )}
842
+ </>
843
+ );
844
+
845
+ return (
846
+ <ContextMenu key={dashboard.slug}>
847
+ <ContextMenuTrigger asChild>
848
+ {isRenamingCurrentTab ? (
849
+ <div className="-mb-px flex min-h-9 items-center gap-2 rounded-b-none rounded-t-lg border border-b-0 border-border bg-background px-3 py-2 text-foreground shadow-none">
850
+ {tabContent}
851
+ </div>
852
+ ) : (
853
+ <TabsTrigger
854
+ value={dashboard.slug}
855
+ className="dashboard-tab-trigger -mb-px cursor-pointer gap-2 rounded-b-none rounded-t-lg px-3 py-2 mt-1 border-none shadow-none data-[state=active]:shadow-none"
856
+ style={
857
+ isActiveTab
858
+ ? {
859
+ backgroundColor: 'var(--background)',
860
+ color: 'var(--primary)',
861
+ }
862
+ : {
863
+ backgroundColor: 'transparent',
864
+ color: 'var(--muted-foreground)',
865
+ }
866
+ }
867
+ onDoubleClick={() =>
868
+ startRenameDashboard(dashboard)
869
+ }
870
+ onContextMenu={() =>
871
+ setActiveSlug(dashboard.slug)
872
+ }
873
+ >
874
+ {tabContent}
875
+ </TabsTrigger>
876
+ )}
877
+ </ContextMenuTrigger>
878
+ <ContextMenuContent className="w-56">
879
+ <ContextMenuItem
880
+ onSelect={() =>
881
+ openAddWidgetsForDashboard(dashboard)
882
+ }
883
+ >
884
+ <Plus className="size-4" />
885
+ {t('addWidgets')}
886
+ </ContextMenuItem>
887
+ {!dashboard.is_home ? (
888
+ <ContextMenuItem
889
+ onSelect={() =>
890
+ void handleSetHomeDashboard(dashboard)
891
+ }
892
+ >
893
+ <Home className="size-4" />
894
+ {t('setAsHome')}
895
+ </ContextMenuItem>
896
+ ) : null}
897
+ <ContextMenuItem
898
+ onSelect={() => startRenameDashboard(dashboard)}
899
+ >
900
+ <Pencil className="size-4" />
901
+ {t('rename')}
902
+ </ContextMenuItem>
903
+ <ContextMenuItem
904
+ onSelect={() => openIconForDashboard(dashboard)}
905
+ >
906
+ <Palette className="size-4" />
907
+ {t('changeIcon')}
908
+ </ContextMenuItem>
909
+ <ContextMenuItem
910
+ onSelect={() => openShareForDashboard(dashboard)}
911
+ >
912
+ <Share2 className="size-4" />
913
+ {t('share')}
914
+ </ContextMenuItem>
915
+ <ContextMenuSeparator />
916
+ <ContextMenuItem
917
+ variant="destructive"
918
+ onSelect={() => openDeleteForDashboard(dashboard)}
919
+ >
920
+ <Trash2 className="size-4" />
921
+ {t('delete')}
922
+ </ContextMenuItem>
923
+ </ContextMenuContent>
924
+ </ContextMenu>
925
+ );
926
+ })}
927
+ <button
928
+ type="button"
929
+ onClick={() => setCreateOpen(true)}
930
+ disabled={isBusy}
931
+ className="my-auto ml-1 flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-background/60 hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
932
+ title={t('newDashboard')}
933
+ >
934
+ <Plus className="size-4" />
935
+ </button>
936
+ </TabsList>
937
+ </ScrollArea>
938
+ </div>
939
+ </Tabs>
940
+ </div>
941
+ ) : (
942
+ <div className="rounded-lg border border-dashed p-6 text-center">
943
+ <p className="text-sm font-medium">{t('emptyTitle')}</p>
944
+ <p className="text-muted-foreground mt-1 text-sm">
945
+ {t('emptyDescription')}
946
+ </p>
947
+ </div>
948
+ )}
949
+
950
+ {activeDashboard ? (
951
+ <DashboardContent
952
+ key={activeDashboard.slug}
953
+ dashboardSlug={activeDashboard.slug}
954
+ showHeader={false}
955
+ headerActionsTargetId={DASHBOARD_HOME_HEADER_ACTIONS_TARGET_ID}
956
+ openWidgetPickerSignal={openAddWidgetSignal}
957
+ onOpenWidgetPickerHandled={() => setOpenAddWidgetSignal(0)}
958
+ />
959
+ ) : !isLoading ? (
960
+ <div className="rounded-xl border border-dashed p-8 text-center text-sm text-muted-foreground">
961
+ {t('createToStart')}
962
+ </div>
963
+ ) : null}
964
+
965
+ <Sheet
966
+ open={createOpen}
967
+ onOpenChange={(open) => {
968
+ setCreateOpen(open);
969
+ if (!open) {
970
+ setNewDashboardName('');
971
+ setNewDashboardIcon('layout-dashboard');
972
+ setSelectedTemplateSlug('');
973
+ }
974
+ }}
975
+ >
976
+ <SheetContent side="right" className="w-full sm:max-w-xl">
977
+ <div className="flex h-full flex-col">
978
+ <SheetHeader className="px-6 py-5 text-left">
979
+ <SheetTitle>{t('newDashboardTitle')}</SheetTitle>
980
+ <SheetDescription>
981
+ {t('newDashboardDescription')}
982
+ </SheetDescription>
983
+ </SheetHeader>
984
+
985
+ <div className="flex-1 space-y-4 overflow-y-auto px-6 py-4">
986
+ <div className="space-y-2">
987
+ <div className="flex items-center justify-between gap-2">
988
+ <label className="text-sm font-medium">
989
+ {t('dashboardTemplateLabel')}
990
+ </label>
991
+ <Badge variant={selectedTemplate ? 'secondary' : 'outline'}>
992
+ {selectedTemplate
993
+ ? t('templateSelectedBadge')
994
+ : t('blankDashboardBadge')}
995
+ </Badge>
996
+ </div>
997
+
998
+ <div className="grid gap-2">
999
+ <button
1000
+ type="button"
1001
+ className={cn(
1002
+ 'flex cursor-pointer items-start gap-3 rounded-lg border p-3 text-left transition-colors hover:border-primary/40 hover:bg-accent/30',
1003
+ !selectedTemplateSlug &&
1004
+ 'border-primary bg-primary/5 text-foreground'
1005
+ )}
1006
+ onClick={() => handleSelectTemplate(null)}
1007
+ >
1008
+ <div className="bg-primary/10 text-primary flex size-9 shrink-0 items-center justify-center rounded-lg">
1009
+ <LayoutDashboard className="size-4" />
1010
+ </div>
1011
+ <div className="min-w-0">
1012
+ <p className="text-sm font-medium">
1013
+ {t('blankDashboardTitle')}
1014
+ </p>
1015
+ <p className="text-muted-foreground text-xs">
1016
+ {t('blankDashboardDescription')}
1017
+ </p>
1018
+ </div>
1019
+ </button>
1020
+
1021
+ {isLoadingTemplates ? (
1022
+ <div className="grid gap-2">
1023
+ <Skeleton className="h-16 rounded-lg" />
1024
+ <Skeleton className="h-16 rounded-lg" />
1025
+ </div>
1026
+ ) : dashboardTemplates.length > 0 ? (
1027
+ dashboardTemplates.map((template) => {
1028
+ const TemplateIcon = resolveDashboardIcon(template.icon);
1029
+ const isSelected = template.slug === selectedTemplateSlug;
1030
+
1031
+ return (
1032
+ <button
1033
+ key={template.slug}
1034
+ type="button"
1035
+ className={cn(
1036
+ 'flex cursor-pointer items-start gap-3 rounded-lg border p-3 text-left transition-colors hover:border-primary/40 hover:bg-accent/30',
1037
+ isSelected &&
1038
+ 'border-primary bg-primary/5 text-foreground'
1039
+ )}
1040
+ onClick={() => handleSelectTemplate(template)}
1041
+ >
1042
+ <div className="bg-primary/10 text-primary flex size-9 shrink-0 items-center justify-center rounded-lg">
1043
+ <TemplateIcon className="size-4" />
1044
+ </div>
1045
+ <div className="min-w-0 flex-1">
1046
+ <div className="flex flex-wrap items-center gap-2">
1047
+ <p className="text-sm font-medium">
1048
+ {template.name}
1049
+ </p>
1050
+ <Badge variant="outline">
1051
+ {t('templateWidgetCount', {
1052
+ count: template.itemCount,
1053
+ })}
1054
+ </Badge>
1055
+ </div>
1056
+ <p className="text-muted-foreground text-xs">
1057
+ {template.slug}
1058
+ </p>
1059
+ </div>
1060
+ </button>
1061
+ );
1062
+ })
1063
+ ) : (
1064
+ <p className="text-muted-foreground text-xs">
1065
+ {t('noTemplatesAvailable')}
1066
+ </p>
1067
+ )}
1068
+ </div>
1069
+ </div>
1070
+
1071
+ <div className="space-y-2">
1072
+ <label className="text-sm font-medium" htmlFor="dashboard-name">
1073
+ {t('dashboardNameLabel')}
1074
+ </label>
1075
+ <Input
1076
+ id="dashboard-name"
1077
+ value={newDashboardName}
1078
+ onChange={(event) => setNewDashboardName(event.target.value)}
1079
+ placeholder={t('dashboardNamePlaceholder')}
1080
+ onKeyDown={(event) => {
1081
+ if (event.key === 'Enter') {
1082
+ event.preventDefault();
1083
+ void handleCreateDashboard();
1084
+ }
1085
+ }}
1086
+ />
1087
+ </div>
1088
+
1089
+ <div className="space-y-2">
1090
+ <label
1091
+ className="text-sm font-medium"
1092
+ htmlFor="new-dashboard-icon-name"
1093
+ >
1094
+ {t('dashboardIconLabel')}
1095
+ </label>
1096
+ <Input
1097
+ id="new-dashboard-icon-name"
1098
+ value={newDashboardIcon}
1099
+ onChange={(event) => setNewDashboardIcon(event.target.value)}
1100
+ placeholder={t('dashboardIconPlaceholder')}
1101
+ />
1102
+ <p className="text-muted-foreground text-xs">
1103
+ {newDashboardIcon.trim() &&
1104
+ !isValidLucideIconName(newDashboardIcon)
1105
+ ? t('invalidIconName')
1106
+ : t('selectIconHint')}
1107
+ </p>
1108
+ </div>
1109
+
1110
+ <div className="rounded-lg border p-4">
1111
+ <div className="flex items-center gap-3">
1112
+ <div className="bg-primary/10 text-primary flex size-10 items-center justify-center rounded-lg">
1113
+ {(() => {
1114
+ const SelectedIcon =
1115
+ resolveDashboardIcon(newDashboardIcon);
1116
+ return <SelectedIcon className="size-5" />;
1117
+ })()}
1118
+ </div>
1119
+ <div className="min-w-0">
1120
+ <p className="truncate text-sm font-medium">
1121
+ {newDashboardIcon.trim()
1122
+ ? normalizeLucideIconName(newDashboardIcon)
1123
+ : 'layout-dashboard'}
1124
+ </p>
1125
+ <p className="text-muted-foreground truncate text-xs">
1126
+ {newDashboardName.trim() || t('newDashboard')}
1127
+ </p>
1128
+ </div>
1129
+ </div>
1130
+ </div>
1131
+
1132
+ <div className="space-y-2">
1133
+ <p className="text-sm font-medium">{t('iconSuggestions')}</p>
1134
+ <div className="grid gap-2 grid-cols-12">
1135
+ {DASHBOARD_ICON_OPTIONS.map((iconName) => {
1136
+ const IconOption = resolveDashboardIcon(iconName);
1137
+ const isSelected =
1138
+ normalizeLucideIconName(newDashboardIcon) === iconName;
1139
+
1140
+ return (
1141
+ <button
1142
+ key={`create-${iconName}`}
1143
+ type="button"
1144
+ title={iconName}
1145
+ aria-label={iconName}
1146
+ className={cn(
1147
+ 'flex aspect-square cursor-pointer items-center justify-center rounded-lg border p-2 text-sm transition-colors hover:border-primary/40 hover:bg-accent/30',
1148
+ isSelected &&
1149
+ 'border-primary bg-primary/10 text-primary'
1150
+ )}
1151
+ onClick={() => setNewDashboardIcon(iconName)}
1152
+ >
1153
+ <IconOption className="size-5" />
1154
+ </button>
1155
+ );
1156
+ })}
1157
+ </div>
1158
+ </div>
1159
+ </div>
1160
+
1161
+ <div className="mt-auto flex justify-end gap-2 border-t px-6 py-4">
1162
+ <Button
1163
+ className="w-full"
1164
+ onClick={handleCreateDashboard}
1165
+ disabled={isCreating}
1166
+ >
1167
+ {isCreating ? (
1168
+ <Loader2 className="size-4 animate-spin" />
1169
+ ) : (
1170
+ <Plus className="size-4" />
1171
+ )}
1172
+ {t('createDashboard')}
1173
+ </Button>
1174
+ </div>
1175
+ </div>
1176
+ </SheetContent>
1177
+ </Sheet>
1178
+
1179
+ <Sheet
1180
+ open={iconOpen}
1181
+ onOpenChange={(open) => {
1182
+ setIconOpen(open);
1183
+ if (!open) {
1184
+ setIconDashboard(null);
1185
+ setIconValue('layout-dashboard');
1186
+ }
1187
+ }}
1188
+ >
1189
+ <SheetContent side="right" className="w-full sm:max-w-xl">
1190
+ <div className="flex h-full flex-col">
1191
+ <SheetHeader className="px-6 py-5 text-left">
1192
+ <SheetTitle>{t('changeDashboardIconTitle')}</SheetTitle>
1193
+ <SheetDescription>
1194
+ {t('changeDashboardIconDescription')}
1195
+ </SheetDescription>
1196
+ </SheetHeader>
1197
+
1198
+ <div className="flex-1 space-y-4 overflow-y-auto px-6 py-4">
1199
+ <div className="space-y-2">
1200
+ <label
1201
+ className="text-sm font-medium"
1202
+ htmlFor="dashboard-icon-name"
1203
+ >
1204
+ {t('iconNameLabel')}
1205
+ </label>
1206
+ <Input
1207
+ id="dashboard-icon-name"
1208
+ value={iconValue}
1209
+ onChange={(event) => setIconValue(event.target.value)}
1210
+ placeholder={t('iconNamePlaceholder')}
1211
+ />
1212
+ <p className="text-muted-foreground text-xs">
1213
+ {iconValue.trim() && !isValidLucideIconName(iconValue)
1214
+ ? t('invalidIconName')
1215
+ : t('selectIconHint')}
1216
+ </p>
1217
+ </div>
1218
+
1219
+ <div className="rounded-lg border p-4">
1220
+ <div className="flex items-center gap-3">
1221
+ <div className="bg-primary/10 text-primary flex size-10 items-center justify-center rounded-lg">
1222
+ {(() => {
1223
+ const SelectedIcon = resolveDashboardIcon(iconValue);
1224
+ return <SelectedIcon className="size-5" />;
1225
+ })()}
1226
+ </div>
1227
+ <div className="min-w-0">
1228
+ <p className="truncate text-sm font-medium">
1229
+ {iconValue.trim()
1230
+ ? normalizeLucideIconName(iconValue)
1231
+ : t('noCustomIcon')}
1232
+ </p>
1233
+ <p className="text-muted-foreground truncate text-xs">
1234
+ {iconDashboard
1235
+ ? getDashboardName(iconDashboard)
1236
+ : t('dashboardLabel')}
1237
+ </p>
1238
+ </div>
1239
+ </div>
1240
+ </div>
1241
+
1242
+ <div className="space-y-2">
1243
+ <p className="text-sm font-medium">{t('suggestions')}</p>
1244
+ <div className="grid gap-2 grid-cols-12">
1245
+ {DASHBOARD_ICON_OPTIONS.map((iconName) => {
1246
+ const IconOption = resolveDashboardIcon(iconName);
1247
+ const isSelected =
1248
+ normalizeLucideIconName(iconValue) === iconName;
1249
+
1250
+ return (
1251
+ <button
1252
+ key={iconName}
1253
+ type="button"
1254
+ title={iconName}
1255
+ aria-label={iconName}
1256
+ className={cn(
1257
+ 'flex aspect-square cursor-pointer items-center justify-center rounded-lg border p-2 text-sm transition-colors hover:border-primary/40 hover:bg-accent/30',
1258
+ isSelected &&
1259
+ 'border-primary bg-primary/10 text-primary'
1260
+ )}
1261
+ onClick={() => setIconValue(iconName)}
1262
+ >
1263
+ <IconOption className="size-5" />
1264
+ </button>
1265
+ );
1266
+ })}
1267
+ </div>
1268
+ </div>
1269
+ </div>
1270
+
1271
+ <div className="mt-auto flex justify-end gap-2 border-t px-6 py-4">
1272
+ <Button
1273
+ className="w-full"
1274
+ onClick={() => void handleSaveDashboardIcon()}
1275
+ disabled={isSavingIcon || Boolean(iconValue.trim()) === false}
1276
+ >
1277
+ {isSavingIcon ? (
1278
+ <Loader2 className="size-4 animate-spin" />
1279
+ ) : (
1280
+ <Palette className="size-4" />
1281
+ )}
1282
+ {t('saveIcon')}
1283
+ </Button>
1284
+ </div>
1285
+ </div>
1286
+ </SheetContent>
1287
+ </Sheet>
1288
+
1289
+ <Sheet
1290
+ open={shareOpen}
1291
+ onOpenChange={(open) => {
1292
+ setShareOpen(open);
1293
+ if (!open) {
1294
+ setShareSearch('');
1295
+ setSharePage(1);
1296
+ setSelectedShareUsers([]);
1297
+ }
1298
+ }}
1299
+ >
1300
+ <SheetContent className="w-full sm:max-w-4xl">
1301
+ <SheetHeader>
1302
+ <SheetTitle>
1303
+ {t('shareDashboardTitle', {
1304
+ name: activeDashboard ? getDashboardName(activeDashboard) : '',
1305
+ })}
1306
+ </SheetTitle>
1307
+ <SheetDescription>
1308
+ {t('shareDashboardDescription')}
1309
+ </SheetDescription>
1310
+ </SheetHeader>
1311
+
1312
+ <div className="flex flex-1 flex-col gap-4 overflow-hidden px-4 pb-4">
1313
+ <div className="rounded-lg border border-dashed bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
1314
+ {t('shareRoleNotice')}
1315
+ </div>
1316
+
1317
+ <div className="grid flex-1 gap-4 overflow-hidden lg:grid-cols-2">
1318
+ <div className="flex min-h-0 flex-col rounded-lg border">
1319
+ <div className="flex items-center justify-between border-b px-3 py-2">
1320
+ <div>
1321
+ <p className="text-sm font-semibold">
1322
+ {t('usersWithAccess')}
1323
+ </p>
1324
+ <p className="text-muted-foreground text-xs">
1325
+ {t('usersWithAccessDescription')}
1326
+ </p>
1327
+ </div>
1328
+ <Badge variant="outline">{sharedUsers.length}</Badge>
1329
+ </div>
1330
+
1331
+ <ScrollArea className="h-80">
1332
+ <div className="space-y-2 p-3">
1333
+ {isLoadingShares ? (
1334
+ <>
1335
+ <Skeleton className="h-16 rounded-lg" />
1336
+ <Skeleton className="h-16 rounded-lg" />
1337
+ </>
1338
+ ) : sharedUsers.length === 0 ? (
1339
+ <p className="text-muted-foreground text-sm">
1340
+ {t('noSharedUsers')}
1341
+ </p>
1342
+ ) : (
1343
+ sharedUsers.map((user) => (
1344
+ <div
1345
+ key={user.id}
1346
+ className="flex items-center justify-between rounded-lg border p-3"
1347
+ >
1348
+ <div className="min-w-0 space-y-1">
1349
+ <div className="flex flex-wrap items-center gap-2">
1350
+ <p className="truncate text-sm font-medium">
1351
+ {user.name}
1352
+ </p>
1353
+ {user.isCurrentUser ? (
1354
+ <Badge variant="secondary">{t('you')}</Badge>
1355
+ ) : null}
1356
+ {user.isHome ? (
1357
+ <Badge variant="outline">{t('home')}</Badge>
1358
+ ) : null}
1359
+ {user.hasRequiredRoles === false ? (
1360
+ <Badge
1361
+ variant="outline"
1362
+ className="border-amber-500/50 text-amber-700"
1363
+ >
1364
+ {t('roleRequiredBadge')}
1365
+ </Badge>
1366
+ ) : null}
1367
+ </div>
1368
+ <p className="text-muted-foreground truncate text-xs">
1369
+ {user.email || t('noPublicEmail')}
1370
+ </p>
1371
+ {user.hasRequiredRoles === false ? (
1372
+ <p className="text-xs text-amber-700">
1373
+ {t('shareBlockedHint')}
1374
+ </p>
1375
+ ) : null}
1376
+ </div>
1377
+
1378
+ {!user.isCurrentUser ? (
1379
+ <Button
1380
+ variant="ghost"
1381
+ size="sm"
1382
+ className="cursor-pointer"
1383
+ onClick={() => void handleRevokeShare(user.id)}
1384
+ disabled={revokingUserId === user.id}
1385
+ >
1386
+ {revokingUserId === user.id ? (
1387
+ <Loader2 className="size-4 animate-spin" />
1388
+ ) : (
1389
+ <Trash2 className="size-4" />
1390
+ )}
1391
+ </Button>
1392
+ ) : null}
1393
+ </div>
1394
+ ))
1395
+ )}
1396
+ </div>
1397
+ </ScrollArea>
1398
+ </div>
1399
+
1400
+ <div className="flex min-h-0 flex-col rounded-lg border">
1401
+ <div className="border-b px-3 py-2">
1402
+ <p className="text-sm font-semibold">{t('addPeople')}</p>
1403
+ <p className="text-muted-foreground text-xs">
1404
+ {t('addPeopleDescription')}
1405
+ </p>
1406
+ </div>
1407
+
1408
+ <div className="flex min-h-0 flex-1 flex-col gap-3 p-3">
1409
+ <div className="rounded-lg border">
1410
+ <Command shouldFilter={false}>
1411
+ <CommandInput
1412
+ value={shareSearch}
1413
+ onValueChange={(value) => {
1414
+ setShareSearch(value);
1415
+ setSharePage(1);
1416
+ }}
1417
+ placeholder={t('searchUserPlaceholder')}
1418
+ />
1419
+ <CommandList>
1420
+ <CommandEmpty>
1421
+ {isLoadingShareableUsers
1422
+ ? t('loadingUsers')
1423
+ : t('noUsersToShare')}
1424
+ </CommandEmpty>
1425
+ <CommandGroup>
1426
+ {shareableUsers.map((user) => {
1427
+ const isSelected = selectedShareUserIds.includes(
1428
+ user.id
1429
+ );
1430
+
1431
+ return (
1432
+ <CommandItem
1433
+ key={user.id}
1434
+ value={`${user.name}-${user.email ?? ''}-${user.id}`}
1435
+ className="cursor-pointer items-start gap-3 px-3 py-3"
1436
+ onSelect={() => toggleShareUserSelection(user)}
1437
+ >
1438
+ <div
1439
+ className={cn(
1440
+ 'mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-sm border',
1441
+ isSelected
1442
+ ? 'border-primary bg-primary text-primary-foreground'
1443
+ : 'border-muted-foreground/30'
1444
+ )}
1445
+ >
1446
+ <Check
1447
+ className={cn(
1448
+ 'size-3',
1449
+ isSelected ? 'opacity-100' : 'opacity-0'
1450
+ )}
1451
+ />
1452
+ </div>
1453
+ <div className="min-w-0 flex-1 space-y-1">
1454
+ <div className="flex flex-wrap items-center gap-2">
1455
+ <p className="truncate text-sm font-medium">
1456
+ {user.name}
1457
+ </p>
1458
+ {user.hasRequiredRoles === false ? (
1459
+ <Badge
1460
+ variant="outline"
1461
+ className="border-amber-500/50 text-amber-700"
1462
+ >
1463
+ {t('roleRequiredBadge')}
1464
+ </Badge>
1465
+ ) : null}
1466
+ </div>
1467
+ <p className="text-muted-foreground truncate text-xs">
1468
+ {user.email || t('noPublicEmail')}
1469
+ </p>
1470
+ </div>
1471
+ </CommandItem>
1472
+ );
1473
+ })}
1474
+ </CommandGroup>
1475
+ </CommandList>
1476
+ </Command>
1477
+ </div>
1478
+
1479
+ {selectedShareUsers.length > 0 ? (
1480
+ <div className="space-y-2 rounded-lg border p-3">
1481
+ <div className="flex items-center justify-between gap-2">
1482
+ <p className="text-sm font-semibold">
1483
+ {t('selectedUsers')}
1484
+ </p>
1485
+ <Badge variant="outline">
1486
+ {selectedShareUsers.length}
1487
+ </Badge>
1488
+ </div>
1489
+
1490
+ <div className="flex flex-wrap gap-2">
1491
+ {selectedShareUsers.map((user) => (
1492
+ <button
1493
+ key={`selected-${user.id}`}
1494
+ type="button"
1495
+ className="inline-flex cursor-pointer items-center gap-2 rounded-full border px-3 py-1 text-xs"
1496
+ onClick={() => removeSelectedShareUser(user.id)}
1497
+ >
1498
+ <span className="max-w-40 truncate">
1499
+ {user.name}
1500
+ </span>
1501
+ <X className="size-3" />
1502
+ </button>
1503
+ ))}
1504
+ </div>
1505
+
1506
+ {selectedUsersWithoutRequiredRoles.length > 0 ? (
1507
+ <p className="text-xs text-amber-700">
1508
+ {t('selectedUsersWarning', {
1509
+ count: selectedUsersWithoutRequiredRoles.length,
1510
+ })}
1511
+ </p>
1512
+ ) : (
1513
+ <p className="text-muted-foreground text-xs">
1514
+ {t('selectedUsersHint')}
1515
+ </p>
1516
+ )}
1517
+ </div>
1518
+ ) : null}
1519
+
1520
+ <div className="mt-auto space-y-3 border-t pt-3">
1521
+ <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
1522
+ <span>
1523
+ {t('sharePageStatus', {
1524
+ page: shareableUsersPage.page,
1525
+ totalPages: shareableUsersPage.lastPage,
1526
+ })}
1527
+ </span>
1528
+ <div className="flex items-center gap-2">
1529
+ <Button
1530
+ type="button"
1531
+ variant="outline"
1532
+ size="sm"
1533
+ className="cursor-pointer"
1534
+ onClick={() =>
1535
+ setSharePage((current) => Math.max(current - 1, 1))
1536
+ }
1537
+ disabled={
1538
+ isLoadingShareableUsers ||
1539
+ shareableUsersPage.prev === null
1540
+ }
1541
+ >
1542
+ {t('previousPage')}
1543
+ </Button>
1544
+ <Button
1545
+ type="button"
1546
+ variant="outline"
1547
+ size="sm"
1548
+ className="cursor-pointer"
1549
+ onClick={() => setSharePage((current) => current + 1)}
1550
+ disabled={
1551
+ isLoadingShareableUsers ||
1552
+ shareableUsersPage.next === null
1553
+ }
1554
+ >
1555
+ {t('nextPage')}
1556
+ </Button>
1557
+ </div>
1558
+ </div>
1559
+
1560
+ <Button
1561
+ className="w-full"
1562
+ onClick={() => void handleShareSelectedUsers()}
1563
+ disabled={
1564
+ isSharingUsers || selectedShareUserIds.length === 0
1565
+ }
1566
+ >
1567
+ {isSharingUsers ? (
1568
+ <Loader2 className="size-4 animate-spin" />
1569
+ ) : (
1570
+ <UserPlus className="size-4" />
1571
+ )}
1572
+ {t('shareSelectedUsers', {
1573
+ count: selectedShareUserIds.length,
1574
+ })}
1575
+ </Button>
1576
+ </div>
1577
+ </div>
1578
+ </div>
1579
+ </div>
1580
+ </div>
1581
+ </SheetContent>
1582
+ </Sheet>
1583
+
1584
+ <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
1585
+ <AlertDialogContent className="w-[calc(100%-2rem)] sm:max-w-md">
1586
+ <AlertDialogHeader>
1587
+ <AlertDialogTitle>{t('removeTabTitle')}</AlertDialogTitle>
1588
+ <AlertDialogDescription>
1589
+ {activeDashboard
1590
+ ? t('removeTabDescription', {
1591
+ name: getDashboardName(activeDashboard),
1592
+ })
1593
+ : t('removeTabDescriptionFallback')}
1594
+ </AlertDialogDescription>
1595
+ </AlertDialogHeader>
1596
+ <AlertDialogFooter>
1597
+ <AlertDialogCancel disabled={isDeleting}>
1598
+ {t('cancel')}
1599
+ </AlertDialogCancel>
1600
+ <AlertDialogAction
1601
+ onClick={(event) => {
1602
+ event.preventDefault();
1603
+ void handleRemoveDashboard();
1604
+ }}
1605
+ disabled={isDeleting}
1606
+ >
1607
+ {isDeleting ? (
1608
+ <Loader2 className="size-4 animate-spin" />
1609
+ ) : (
1610
+ <Trash2 className="size-4" />
1611
+ )}
1612
+ {t('removeTab')}
1613
+ </AlertDialogAction>
1614
+ </AlertDialogFooter>
1615
+ </AlertDialogContent>
1616
+ </AlertDialog>
1617
+ </Page>
1618
+ );
1619
+ }