@hed-hog/core 0.0.298 → 0.0.300

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