@hed-hog/core 0.0.185 → 0.0.190

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 (55) hide show
  1. package/hedhog/frontend/app/account/2fa/page.tsx.ejs +5 -0
  2. package/hedhog/frontend/app/account/accounts/page.tsx.ejs +5 -0
  3. package/hedhog/frontend/app/account/components/active-sessions.tsx.ejs +356 -0
  4. package/hedhog/frontend/app/account/components/change-email-form.tsx.ejs +379 -0
  5. package/hedhog/frontend/app/account/components/change-password-form.tsx.ejs +184 -0
  6. package/hedhog/frontend/app/account/components/connected-accounts.tsx.ejs +144 -0
  7. package/hedhog/frontend/app/account/components/email-request-dialog.tsx.ejs +96 -0
  8. package/hedhog/frontend/app/account/components/mfa-add-buttons.tsx.ejs +43 -0
  9. package/hedhog/frontend/app/account/components/mfa-method-card.tsx.ejs +115 -0
  10. package/hedhog/frontend/app/account/components/mfa-setup-dialog.tsx.ejs +236 -0
  11. package/hedhog/frontend/app/account/components/profile-form.tsx.ejs +209 -0
  12. package/hedhog/frontend/app/account/components/recovery-codes-dialog.tsx.ejs +192 -0
  13. package/hedhog/frontend/app/account/components/regenerate-codes-dialog.tsx.ejs +372 -0
  14. package/hedhog/frontend/app/account/components/remove-mfa-dialog.tsx.ejs +337 -0
  15. package/hedhog/frontend/app/account/components/two-factor-auth.tsx.ejs +393 -0
  16. package/hedhog/frontend/app/account/components/verify-before-add-dialog.tsx.ejs +332 -0
  17. package/hedhog/frontend/app/account/email/page.tsx.ejs +5 -0
  18. package/hedhog/frontend/app/account/hooks/use-mfa-methods.ts.ejs +27 -0
  19. package/hedhog/frontend/app/account/hooks/use-mfa-setup.ts.ejs +461 -0
  20. package/hedhog/frontend/app/account/layout.tsx.ejs +105 -0
  21. package/hedhog/frontend/app/account/lib/mfa-utils.tsx.ejs +37 -0
  22. package/hedhog/frontend/app/account/page.tsx.ejs +5 -0
  23. package/hedhog/frontend/app/account/password/page.tsx.ejs +5 -0
  24. package/hedhog/frontend/app/account/profile/page.tsx.ejs +5 -0
  25. package/hedhog/frontend/app/account/sessions/page.tsx.ejs +5 -0
  26. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +490 -0
  27. package/hedhog/frontend/app/configurations/[slug]/page.tsx.ejs +62 -0
  28. package/hedhog/frontend/app/configurations/layout.tsx.ejs +316 -0
  29. package/hedhog/frontend/app/configurations/page.tsx.ejs +35 -0
  30. package/hedhog/frontend/app/dashboard/[slug]/dashboard-content.tsx.ejs +351 -0
  31. package/hedhog/frontend/app/dashboard/[slug]/page.tsx.ejs +11 -0
  32. package/hedhog/frontend/app/dashboard/[slug]/types.ts.ejs +62 -0
  33. package/hedhog/frontend/app/dashboard/[slug]/widget-renderer.tsx.ejs +45 -0
  34. package/hedhog/frontend/app/dashboard/dashboard.css.ejs +196 -0
  35. package/hedhog/frontend/app/dashboard/management/page.tsx.ejs +63 -0
  36. package/hedhog/frontend/app/dashboard/management/tabs/component-roles-tab.tsx.ejs +516 -0
  37. package/hedhog/frontend/app/dashboard/management/tabs/components-tab.tsx.ejs +753 -0
  38. package/hedhog/frontend/app/dashboard/management/tabs/dashboard-roles-tab.tsx.ejs +516 -0
  39. package/hedhog/frontend/app/dashboard/management/tabs/dashboards-tab.tsx.ejs +489 -0
  40. package/hedhog/frontend/app/dashboard/management/tabs/items-tab.tsx.ejs +621 -0
  41. package/hedhog/frontend/app/dashboard/page.tsx.ejs +14 -0
  42. package/hedhog/frontend/app/mail/log/page.tsx.ejs +312 -0
  43. package/hedhog/frontend/app/mail/template/page.tsx.ejs +1177 -0
  44. package/hedhog/frontend/app/preferences/page.tsx.ejs +448 -0
  45. package/hedhog/frontend/app/roles/menus.tsx.ejs +504 -0
  46. package/hedhog/frontend/app/roles/page.tsx.ejs +814 -0
  47. package/hedhog/frontend/app/roles/routes.tsx.ejs +397 -0
  48. package/hedhog/frontend/app/roles/users.tsx.ejs +306 -0
  49. package/hedhog/frontend/app/users/active-session.tsx.ejs +159 -0
  50. package/hedhog/frontend/app/users/identifiers.tsx.ejs +279 -0
  51. package/hedhog/frontend/app/users/page.tsx.ejs +1257 -0
  52. package/hedhog/frontend/app/users/permissions.tsx.ejs +155 -0
  53. package/hedhog/frontend/messages/en.json +1080 -0
  54. package/hedhog/frontend/messages/pt.json +1135 -0
  55. package/package.json +4 -4
@@ -0,0 +1,316 @@
1
+ 'use client';
2
+
3
+ import { PageHeader } from '@/components/entity-list';
4
+ import { Button } from '@/components/ui/button';
5
+ import { Checkbox } from '@/components/ui/checkbox';
6
+ import {
7
+ Dialog,
8
+ DialogClose,
9
+ DialogContent,
10
+ DialogDescription,
11
+ DialogFooter,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ } from '@/components/ui/dialog';
15
+ import {
16
+ DropdownMenu,
17
+ DropdownMenuContent,
18
+ DropdownMenuItem,
19
+ DropdownMenuTrigger,
20
+ } from '@/components/ui/dropdown-menu';
21
+ import { cn } from '@/lib/utils';
22
+ import { PaginatedResult } from '@hed-hog/api-pagination';
23
+ import { SettingGroup } from '@hed-hog/api-types';
24
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
25
+ import { Download, MenuIcon, Upload } from 'lucide-react';
26
+ import { useTranslations } from 'next-intl';
27
+ import Link from 'next/link';
28
+ import { usePathname } from 'next/navigation';
29
+ import { useCallback, useRef, useState } from 'react';
30
+
31
+ interface SettingsValidation {
32
+ totalSettings: number;
33
+ validSettings: number;
34
+ invalidSlugs: string[];
35
+ validSlugs: string[];
36
+ fileData: any[];
37
+ }
38
+
39
+ interface IProps {
40
+ children: React.ReactNode;
41
+ }
42
+
43
+ export default function ConfigurationsLayout({ children }: IProps) {
44
+ const t = useTranslations('core.Configurations');
45
+ const pathname = usePathname();
46
+ const { request, currentLocaleCode, showToastHandler } = useApp();
47
+
48
+ const { data: settingGroups, refetch } = useQuery<
49
+ PaginatedResult<SettingGroup>
50
+ >({
51
+ queryKey: ['setting-groups', currentLocaleCode],
52
+ queryFn: async () => {
53
+ const response = await request<PaginatedResult<SettingGroup>>({
54
+ url: '/setting/group',
55
+ });
56
+ return response.data;
57
+ },
58
+ });
59
+
60
+ const [exportDialogOpen, setExportDialogOpen] = useState(false);
61
+ const [includeSecrets, setIncludeSecrets] = useState(false);
62
+ const [importDialogOpen, setImportDialogOpen] = useState(false);
63
+ const [importData, setImportData] = useState<SettingsValidation | null>(null);
64
+ const fileInputRef = useRef<HTMLInputElement>(null);
65
+
66
+ const handleExport = (includeSecrets: boolean) => {
67
+ request({
68
+ url: `/setting/export?secrets=${includeSecrets}`,
69
+ responseType: 'blob',
70
+ })
71
+ .then((response) => {
72
+ const blob = new Blob([response.data], {
73
+ type: 'application/octet-stream',
74
+ });
75
+ const url = URL.createObjectURL(blob);
76
+ const a = document.createElement('a');
77
+ a.href = url;
78
+ a.download = 'settings.hedhog';
79
+ document.body.appendChild(a);
80
+ a.click();
81
+ document.body.removeChild(a);
82
+ URL.revokeObjectURL(url);
83
+ })
84
+ .catch(() => {
85
+ showToastHandler('error', t('exportFailed'));
86
+ });
87
+ };
88
+
89
+ const handleImportValidate = async (
90
+ event: React.ChangeEvent<HTMLInputElement>
91
+ ) => {
92
+ const file = event.target.files?.[0];
93
+ if (!file) return;
94
+
95
+ event.target.value = '';
96
+ const formData = new FormData();
97
+ formData.append('file', file);
98
+
99
+ try {
100
+ const response = await request<{
101
+ totalSettings: number;
102
+ validSettings: number;
103
+ invalidSlugs: string[];
104
+ validSlugs: string[];
105
+ fileData: any[];
106
+ }>({
107
+ url: '/setting/import',
108
+ method: 'POST',
109
+ data: formData,
110
+ headers: {
111
+ 'Content-Type': 'multipart/form-data',
112
+ },
113
+ });
114
+
115
+ setImportData(response.data);
116
+ setImportDialogOpen(true);
117
+ } catch (error) {
118
+ showToastHandler('error', t('importValidateFailed'));
119
+ }
120
+ };
121
+
122
+ const handleConfirmImport = useCallback(async () => {
123
+ if (!importData) return;
124
+
125
+ try {
126
+ await request({
127
+ url: '/setting/import/confirm',
128
+ method: 'POST',
129
+ data: {
130
+ settings: importData.fileData,
131
+ },
132
+ });
133
+ refetch().then(() => {
134
+ showToastHandler('success', t('importSuccess'));
135
+ setImportDialogOpen(false);
136
+ setImportData(null);
137
+ if (typeof window !== 'undefined') {
138
+ window.location.reload();
139
+ }
140
+ });
141
+ } catch (error) {
142
+ showToastHandler('error', t('importFailed'));
143
+ }
144
+ }, [importData, request, refetch, showToastHandler, t]);
145
+
146
+ return (
147
+ <div className="flex flex-col h-screen px-4">
148
+ <PageHeader
149
+ breadcrumbs={[
150
+ { label: t('breadcrumbHome'), href: '/' },
151
+ { label: t('breadcrumbTitle') },
152
+ ]}
153
+ title={t('title')}
154
+ description={t('description')}
155
+ extraContent={
156
+ <>
157
+ <DropdownMenu>
158
+ <DropdownMenuTrigger asChild>
159
+ <Button variant="outline" size="icon">
160
+ <MenuIcon />
161
+ </Button>
162
+ </DropdownMenuTrigger>
163
+ <DropdownMenuContent align="end">
164
+ <DropdownMenuItem onClick={() => setExportDialogOpen(true)}>
165
+ <span className="flex items-center gap-2">
166
+ <Download size={16} /> {t('menuExport')}
167
+ </span>
168
+ </DropdownMenuItem>
169
+ <DropdownMenuItem onClick={() => fileInputRef.current?.click()}>
170
+ <span className="flex items-center gap-2">
171
+ <Upload size={16} /> {t('menuImport')}
172
+ </span>
173
+ </DropdownMenuItem>
174
+ </DropdownMenuContent>
175
+ </DropdownMenu>
176
+
177
+ <Dialog open={exportDialogOpen} onOpenChange={setExportDialogOpen}>
178
+ <DialogContent className="max-w-sm">
179
+ <DialogHeader>
180
+ <DialogTitle>{t('exportDialogTitle')}</DialogTitle>
181
+ <DialogDescription>
182
+ {t('exportDialogDescription')}
183
+ </DialogDescription>
184
+ </DialogHeader>
185
+ <div className="flex flex-col gap-4 py-4">
186
+ <label className="flex items-center gap-2">
187
+ <Checkbox
188
+ checked={includeSecrets}
189
+ onCheckedChange={(checked) =>
190
+ setIncludeSecrets(checked === true)
191
+ }
192
+ />
193
+ {t('exportIncludeSecrets')}
194
+ </label>
195
+ </div>
196
+ <DialogFooter>
197
+ <DialogClose asChild>
198
+ <Button variant="outline">{t('exportCancel')}</Button>
199
+ </DialogClose>
200
+ <Button
201
+ onClick={() => {
202
+ setExportDialogOpen(false);
203
+ handleExport(includeSecrets);
204
+ }}
205
+ >
206
+ {t('exportButton')}
207
+ </Button>
208
+ </DialogFooter>
209
+ </DialogContent>
210
+ </Dialog>
211
+
212
+ <Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
213
+ <DialogContent className="max-w-md">
214
+ <DialogHeader>
215
+ <DialogTitle>{t('importDialogTitle')}</DialogTitle>
216
+ <DialogDescription>
217
+ {t('importDialogDescription')}
218
+ </DialogDescription>
219
+ </DialogHeader>
220
+ {importData && (
221
+ <div className="flex flex-col gap-4 py-4">
222
+ <div className="rounded-lg border p-4 space-y-2">
223
+ <div className="flex justify-between">
224
+ <span className="text-sm font-medium">
225
+ {t('importTotalSettings')}
226
+ </span>
227
+ <span className="text-sm">
228
+ {importData.totalSettings}
229
+ </span>
230
+ </div>
231
+ <div className="flex justify-between">
232
+ <span className="text-sm font-medium text-green-600">
233
+ {t('importValidSettings')}
234
+ </span>
235
+ <span className="text-sm text-green-600">
236
+ {importData.validSettings}
237
+ </span>
238
+ </div>
239
+ {importData.invalidSlugs.length > 0 && (
240
+ <div className="flex justify-between">
241
+ <span className="text-sm font-medium text-orange-600">
242
+ {t('importInvalidSettings')}
243
+ </span>
244
+ <span className="text-sm text-orange-600">
245
+ {importData.invalidSlugs.length}
246
+ </span>
247
+ </div>
248
+ )}
249
+ </div>
250
+
251
+ {importData.invalidSlugs.length > 0 && (
252
+ <div className="rounded-lg border border-orange-200 bg-orange-50 dark:border-orange-700 dark:bg-orange-900 p-4 space-y-2">
253
+ <p className="text-sm font-medium text-orange-900 dark:text-orange-200">
254
+ {t('importInvalidSettingsLabel')}
255
+ </p>
256
+ <div className="max-h-32 overflow-y-auto">
257
+ <ul className="text-xs text-orange-800 dark:text-orange-100 space-y-1">
258
+ {importData.invalidSlugs.map((slug) => (
259
+ <li key={slug} className="font-mono">
260
+ • {slug}
261
+ </li>
262
+ ))}
263
+ </ul>
264
+ </div>
265
+ </div>
266
+ )}
267
+ </div>
268
+ )}
269
+ <DialogFooter>
270
+ <DialogClose asChild>
271
+ <Button variant="outline">{t('importCancel')}</Button>
272
+ </DialogClose>
273
+ <Button onClick={handleConfirmImport}>
274
+ {t('importButton')}
275
+ </Button>
276
+ </DialogFooter>
277
+ </DialogContent>
278
+ </Dialog>
279
+ </>
280
+ }
281
+ />
282
+
283
+ <div className="border-b border-border mb-6">
284
+ <nav className="flex gap-6" aria-label="Configuration tabs">
285
+ {(settingGroups?.data || []).map((item: SettingGroup) => {
286
+ const isActive = pathname === `/configurations/${item.slug}`;
287
+ return (
288
+ <Link
289
+ key={item.slug}
290
+ href={item.slug}
291
+ className={cn(
292
+ 'pb-3 px-1 text-sm font-medium border-b-2 transition-colors',
293
+ isActive
294
+ ? 'border-primary text-primary'
295
+ : 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground'
296
+ )}
297
+ >
298
+ {item.name}
299
+ </Link>
300
+ );
301
+ })}
302
+ </nav>
303
+ </div>
304
+
305
+ <div className="pb-[100px]">{children}</div>
306
+
307
+ <input
308
+ ref={fileInputRef}
309
+ type="file"
310
+ accept=".hedhog"
311
+ className="hidden"
312
+ onChange={handleImportValidate}
313
+ />
314
+ </div>
315
+ );
316
+ }
@@ -0,0 +1,35 @@
1
+ 'use client';
2
+
3
+ import { Loading } from '@/components/loading';
4
+ import { PaginatedResult } from '@hed-hog/api-pagination';
5
+ import { SettingGroup } from '@hed-hog/api-types';
6
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
7
+ import { useRouter } from 'next/navigation';
8
+ import { useEffect } from 'react';
9
+
10
+ export default function Page() {
11
+ const router = useRouter();
12
+ const { request, currentLocaleCode } = useApp();
13
+
14
+ const { data: settingGroups } = useQuery<PaginatedResult<SettingGroup>>({
15
+ queryKey: ['setting-groups', currentLocaleCode],
16
+ queryFn: async () => {
17
+ const response = await request<PaginatedResult<SettingGroup>>({
18
+ url: '/setting/group',
19
+ });
20
+ return response.data;
21
+ },
22
+ });
23
+
24
+ useEffect(() => {
25
+ if ((settingGroups?.data || []).length > 0) {
26
+ router.push(`/configurations/${settingGroups.data[0].slug}`);
27
+ }
28
+ }, [settingGroups]);
29
+
30
+ return (
31
+ <div className="flex h-full w-full items-center justify-center">
32
+ <Loading />
33
+ </div>
34
+ );
35
+ }
@@ -0,0 +1,351 @@
1
+ 'use client';
2
+
3
+ import {
4
+ AddWidgetSelectorDialog,
5
+ DraggableGrid,
6
+ LayoutItem,
7
+ } from '@/components/dashboard';
8
+ import {
9
+ Breadcrumb,
10
+ BreadcrumbItem,
11
+ BreadcrumbLink,
12
+ BreadcrumbList,
13
+ BreadcrumbPage,
14
+ BreadcrumbSeparator,
15
+ } from '@/components/ui/breadcrumb';
16
+ import { Button } from '@/components/ui/button';
17
+ import { Separator } from '@/components/ui/separator';
18
+ import { SidebarTrigger } from '@/components/ui/sidebar';
19
+ import { Skeleton } from '@/components/ui/skeleton';
20
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
21
+ import { IconDeviceFloppy } from '@tabler/icons-react';
22
+ import { useTranslations } from 'next-intl';
23
+ import { useRouter } from 'next/navigation';
24
+ import { useCallback, useEffect, useState } from 'react';
25
+ import '../dashboard.css';
26
+ import {
27
+ DashboardAccessResponse,
28
+ DashboardComponent,
29
+ WidgetLayout,
30
+ } from './types';
31
+ import { WidgetRenderer } from './widget-renderer';
32
+
33
+ interface DashboardContentProps {
34
+ dashboardSlug: string;
35
+ }
36
+
37
+ export const DashboardContent = ({ dashboardSlug }: DashboardContentProps) => {
38
+ const t = useTranslations('core.DashboardPage');
39
+ const { request } = useApp();
40
+ const router = useRouter();
41
+
42
+ const [layout, setLayout] = useState<LayoutItem[]>([]);
43
+ const [widgets, setWidgets] = useState<WidgetLayout[]>([]);
44
+ const [hasChanges, setHasChanges] = useState(false);
45
+ const [isSaving, setIsSaving] = useState(false);
46
+
47
+ const { data: dashboardAccess, isLoading: isCheckingAccess } =
48
+ useQuery<DashboardAccessResponse>({
49
+ queryKey: ['dashboard-access', dashboardSlug],
50
+ queryFn: async () => {
51
+ const { data } = await request<DashboardAccessResponse>({
52
+ url: `/dashboard-core/access/${dashboardSlug}`,
53
+ method: 'GET',
54
+ });
55
+ return data;
56
+ },
57
+ });
58
+
59
+ useEffect(() => {
60
+ if (dashboardAccess && !dashboardAccess.hasAccess) {
61
+ router.replace('/dashboard/default');
62
+ }
63
+ }, [dashboardAccess, router]);
64
+
65
+ const {
66
+ data: availableComponents,
67
+ isLoading: isLoadingComponents,
68
+ refetch: refetchComponents,
69
+ } = useQuery<any>({
70
+ queryKey: ['dashboard-components'],
71
+ queryFn: async () => {
72
+ const { data } = await request<any>({
73
+ url: '/dashboard-component/user',
74
+ method: 'GET',
75
+ });
76
+ return data.data;
77
+ },
78
+ enabled: dashboardAccess?.hasAccess ?? false,
79
+ });
80
+
81
+ const {
82
+ data: userLayout,
83
+ isLoading: isLoadingLayout,
84
+ refetch: refetchLayout,
85
+ } = useQuery<WidgetLayout[]>({
86
+ queryKey: ['dashboard-layout', dashboardSlug],
87
+ queryFn: async () => {
88
+ const { data } = await request<WidgetLayout[]>({
89
+ url: `/dashboard-core/layout/${dashboardSlug}`,
90
+ method: 'GET',
91
+ });
92
+ return data;
93
+ },
94
+ enabled: dashboardAccess?.hasAccess ?? false,
95
+ });
96
+
97
+ useEffect(() => {
98
+ if (userLayout) {
99
+ if (userLayout.length > 0) {
100
+ const gridLayout = userLayout.map((item) => ({
101
+ i: item.i,
102
+ x: item.x,
103
+ y: item.y,
104
+ w: item.w,
105
+ h: item.h,
106
+ minW: item.minW || 1,
107
+ maxW: item.maxW || 12,
108
+ minH: item.minH || 1,
109
+ maxH: item.maxH || 10,
110
+ static: false,
111
+ }));
112
+
113
+ setLayout(gridLayout);
114
+ setWidgets(userLayout);
115
+ } else {
116
+ setLayout([]);
117
+ setWidgets([]);
118
+ }
119
+ setHasChanges(false);
120
+ }
121
+ }, [userLayout]);
122
+
123
+ const componentsToFilter = availableComponents?.data?.length
124
+ ? availableComponents.data
125
+ : availableComponents || [];
126
+
127
+ const filteredComponents =
128
+ componentsToFilter.filter(
129
+ (component: DashboardComponent) =>
130
+ !widgets.some((widget) => widget.slug === component.slug)
131
+ ) || [];
132
+
133
+ const handleLayoutChange = useCallback((newLayout: LayoutItem[]) => {
134
+ setLayout((prevLayout) => {
135
+ const hasRealChange =
136
+ JSON.stringify(prevLayout) !== JSON.stringify(newLayout);
137
+ if (hasRealChange) {
138
+ setHasChanges(true);
139
+ return newLayout;
140
+ }
141
+ return prevLayout;
142
+ });
143
+ }, []);
144
+
145
+ const handleSaveLayout = async () => {
146
+ setIsSaving(true);
147
+ try {
148
+ await request({
149
+ url: `/dashboard-core/layout/${dashboardSlug}`,
150
+ method: 'POST',
151
+ data: { layout },
152
+ });
153
+ setHasChanges(false);
154
+ } catch (error) {
155
+ console.error('❌ Erro ao salvar layout:', error);
156
+ } finally {
157
+ setIsSaving(false);
158
+ }
159
+ };
160
+
161
+ const handleAddWidget = async (slug: string) => {
162
+ try {
163
+ await request({
164
+ url: `/dashboard-core/widget/${dashboardSlug}`,
165
+ method: 'POST',
166
+ data: { componentSlug: slug },
167
+ });
168
+
169
+ await new Promise((resolve) => setTimeout(resolve, 300));
170
+ await Promise.all([refetchLayout(), refetchComponents()]);
171
+ setHasChanges(false);
172
+ } catch (error) {
173
+ console.error('Erro ao adicionar widget:', error);
174
+ }
175
+ };
176
+
177
+ const handleRemoveWidget = async (widgetId: string) => {
178
+ try {
179
+ await request({
180
+ url: `/dashboard-core/widget/${dashboardSlug}/${widgetId}`,
181
+ method: 'DELETE',
182
+ });
183
+ await Promise.all([refetchLayout(), refetchComponents()]);
184
+ setHasChanges(false);
185
+ } catch (error) {
186
+ console.error('Erro ao remover widget:', error);
187
+ }
188
+ };
189
+
190
+ const renderWidget = (widget: WidgetLayout) => {
191
+ return (
192
+ <WidgetRenderer
193
+ widget={widget}
194
+ onRemove={() => handleRemoveWidget(widget.i)}
195
+ />
196
+ );
197
+ };
198
+
199
+ if (isCheckingAccess || isLoadingLayout) {
200
+ return (
201
+ <>
202
+ <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
203
+ <div className="flex w-full items-center justify-between gap-2 px-4">
204
+ <div className="flex items-center gap-2">
205
+ <SidebarTrigger className="-ml-1" />
206
+ <Separator
207
+ orientation="vertical"
208
+ className="mr-2 data-[orientation=vertical]:h-4"
209
+ />
210
+ <Breadcrumb>
211
+ <BreadcrumbList>
212
+ <BreadcrumbItem className="hidden md:block">
213
+ <BreadcrumbLink href="#">{t('dashboard')}</BreadcrumbLink>
214
+ </BreadcrumbItem>
215
+ <BreadcrumbSeparator className="hidden md:block" />
216
+ <BreadcrumbItem>
217
+ <BreadcrumbPage>{t('overview')}</BreadcrumbPage>
218
+ </BreadcrumbItem>
219
+ </BreadcrumbList>
220
+ </Breadcrumb>
221
+ </div>
222
+ </div>
223
+ </header>
224
+ <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
225
+ <Skeleton className="h-32 w-full" />
226
+ <Skeleton className="h-32 w-full" />
227
+ <Skeleton className="h-64 w-full" />
228
+ </div>
229
+ </>
230
+ );
231
+ }
232
+
233
+ if (!dashboardAccess?.hasAccess) {
234
+ return (
235
+ <>
236
+ <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
237
+ <div className="flex w-full items-center justify-between gap-2 px-4">
238
+ <div className="flex items-center gap-2">
239
+ <SidebarTrigger className="-ml-1" />
240
+ <Separator
241
+ orientation="vertical"
242
+ className="mr-2 data-[orientation=vertical]:h-4"
243
+ />
244
+ <Breadcrumb>
245
+ <BreadcrumbList>
246
+ <BreadcrumbItem className="hidden md:block">
247
+ <BreadcrumbLink href="#">{t('dashboard')}</BreadcrumbLink>
248
+ </BreadcrumbItem>
249
+ <BreadcrumbSeparator className="hidden md:block" />
250
+ <BreadcrumbItem>
251
+ <BreadcrumbPage>{t('accessDenied')}</BreadcrumbPage>
252
+ </BreadcrumbItem>
253
+ </BreadcrumbList>
254
+ </Breadcrumb>
255
+ </div>
256
+ </div>
257
+ </header>
258
+ <div className="flex flex-1 flex-col items-center justify-center gap-4 p-4 pt-0">
259
+ <div className="text-center">
260
+ <h2 className="text-2xl font-bold">{t('accessDenied')}</h2>
261
+ <p className="text-muted-foreground mt-2">
262
+ {t('noAccessToDashboard')}
263
+ </p>
264
+ </div>
265
+ </div>
266
+ </>
267
+ );
268
+ }
269
+
270
+ const dashboardName = dashboardAccess?.dashboard?.name || dashboardSlug;
271
+
272
+ return (
273
+ <>
274
+ <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
275
+ <div className="flex w-full items-center justify-between gap-2 px-4">
276
+ <div className="flex items-center gap-2">
277
+ <SidebarTrigger className="-ml-1" />
278
+ <Separator
279
+ orientation="vertical"
280
+ className="mr-2 data-[orientation=vertical]:h-4"
281
+ />
282
+ <Breadcrumb>
283
+ <BreadcrumbList>
284
+ <BreadcrumbItem className="hidden md:block">
285
+ <BreadcrumbLink href="#">{t('dashboard')}</BreadcrumbLink>
286
+ </BreadcrumbItem>
287
+ <BreadcrumbSeparator className="hidden md:block" />
288
+ <BreadcrumbItem>
289
+ <BreadcrumbPage>{dashboardName}</BreadcrumbPage>
290
+ </BreadcrumbItem>
291
+ </BreadcrumbList>
292
+ </Breadcrumb>
293
+ </div>
294
+ <div className="flex items-center gap-2">
295
+ {hasChanges && (
296
+ <Button
297
+ size="sm"
298
+ variant="default"
299
+ className="gap-2"
300
+ onClick={handleSaveLayout}
301
+ disabled={isSaving}
302
+ >
303
+ <IconDeviceFloppy className="size-4" />
304
+ {isSaving ? t('saving') : t('saveLayout')}
305
+ </Button>
306
+ )}
307
+ <AddWidgetSelectorDialog
308
+ availableComponents={filteredComponents}
309
+ isLoading={isLoadingComponents}
310
+ onAdd={handleAddWidget}
311
+ currentSlug={dashboardSlug}
312
+ />
313
+ </div>
314
+ </div>
315
+ </header>
316
+ <div className="flex flex-1 flex-col gap-4 overflow-auto p-4 pt-0">
317
+ {widgets.length > 0 ? (
318
+ <div className="min-h-[600px]">
319
+ <DraggableGrid
320
+ layout={layout}
321
+ onLayoutChange={handleLayoutChange}
322
+ cols={12}
323
+ rowHeight={80}
324
+ isDraggable={true}
325
+ isResizable={true}
326
+ >
327
+ {widgets.map((widget) => (
328
+ <div key={widget.i}>{renderWidget(widget)}</div>
329
+ ))}
330
+ </DraggableGrid>
331
+ </div>
332
+ ) : (
333
+ <div className="flex min-h-[400px] flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center">
334
+ <h3 className="text-lg font-semibold">{t('noWidgetAdded')}</h3>
335
+ <p className="text-muted-foreground mt-2 text-sm">
336
+ {t('startAddingWidgets')}
337
+ </p>
338
+ <div className="mt-4">
339
+ <AddWidgetSelectorDialog
340
+ availableComponents={filteredComponents}
341
+ isLoading={isLoadingComponents}
342
+ onAdd={handleAddWidget}
343
+ currentSlug={dashboardSlug}
344
+ />
345
+ </div>
346
+ </div>
347
+ )}
348
+ </div>
349
+ </>
350
+ );
351
+ };