@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
@@ -13,11 +13,15 @@ import { Separator } from '@/components/ui/separator';
13
13
  import { SidebarTrigger } from '@/components/ui/sidebar';
14
14
  import { Skeleton } from '@/components/ui/skeleton';
15
15
  import { useIsMobile } from '@/components/ui/use-mobile';
16
+ import { useDebounce } from '@/hooks/use-debounce';
17
+ import { useProgress } from '@bprogress/next';
16
18
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
17
- import { IconDeviceFloppy } from '@tabler/icons-react';
19
+ import { IconDeviceFloppy, IconLoader2 } from '@tabler/icons-react';
20
+ import { toBlob } from 'html-to-image';
18
21
  import { useTranslations } from 'next-intl';
19
22
  import { useRouter } from 'next/navigation';
20
- import { useCallback, useEffect, useState } from 'react';
23
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
24
+ import { createPortal } from 'react-dom';
21
25
  import {
22
26
  AddWidgetSelectorDialog,
23
27
  DraggableGrid,
@@ -33,6 +37,21 @@ import { WidgetRenderer } from './widget-renderer';
33
37
 
34
38
  interface DashboardContentProps {
35
39
  dashboardSlug: string;
40
+ showHeader?: boolean;
41
+ headerActionsTargetId?: string;
42
+ openWidgetPickerSignal?: number;
43
+ onOpenWidgetPickerHandled?: () => void;
44
+ }
45
+
46
+ interface DashboardComponentsPage {
47
+ data: DashboardComponent[];
48
+ total: number;
49
+ lastPage: number;
50
+ page: number;
51
+ pageSize: number;
52
+ prev: number | null;
53
+ next: number | null;
54
+ modules?: string[];
36
55
  }
37
56
 
38
57
  const USER_STATS_WIDGETS = new Set([
@@ -48,8 +67,44 @@ const USER_POST_HISTORY_WIDGETS = new Set([
48
67
  ]);
49
68
 
50
69
  const USER_BOTTOM_WIDGETS = new Set(['user-roles', 'activity-timeline']);
70
+ const LAYOUT_AUTOSAVE_DELAY = 1000;
71
+
72
+ const normalizeLayoutForSave = (layout: LayoutItem[]) =>
73
+ layout.map(({ i, x, y, w, h }) => ({
74
+ i,
75
+ x,
76
+ y,
77
+ w,
78
+ h,
79
+ }));
80
+
81
+ const getLayoutSignature = (layout: LayoutItem[]) =>
82
+ JSON.stringify(normalizeLayoutForSave(layout));
83
+
84
+ const getWidgetBaseSlug = (slug: string): string => {
85
+ const parts = slug.split('.');
86
+ return parts[parts.length - 1] || slug;
87
+ };
88
+
89
+ const getWidgetIdentityKey = ({
90
+ slug,
91
+ library_slug,
92
+ }: {
93
+ slug: string;
94
+ library_slug?: string;
95
+ }) => {
96
+ const baseSlug = getWidgetBaseSlug(slug);
97
+
98
+ if (library_slug) {
99
+ return `${library_slug}.${baseSlug}`;
100
+ }
101
+
102
+ return slug.includes('.') ? slug : baseSlug;
103
+ };
51
104
 
52
105
  const normalizeUserDashboardLayout = (item: WidgetLayout): LayoutItem => {
106
+ const baseSlug = getWidgetBaseSlug(item.slug);
107
+
53
108
  const layoutItem: LayoutItem = {
54
109
  i: item.i,
55
110
  x: item.x,
@@ -63,26 +118,26 @@ const normalizeUserDashboardLayout = (item: WidgetLayout): LayoutItem => {
63
118
  static: false,
64
119
  };
65
120
 
66
- if (item.slug === 'profile-card') {
121
+ if (baseSlug === 'profile-card') {
67
122
  layoutItem.h = Math.max(item.h, 4);
68
123
  }
69
124
 
70
- if (USER_STATS_WIDGETS.has(item.slug)) {
125
+ if (USER_STATS_WIDGETS.has(baseSlug)) {
71
126
  layoutItem.y = Math.max(item.y, 4);
72
127
  return layoutItem;
73
128
  }
74
129
 
75
- if (item.slug === 'login-history-chart') {
130
+ if (baseSlug === 'login-history-chart') {
76
131
  layoutItem.y = Math.max(item.y, 5);
77
132
  return layoutItem;
78
133
  }
79
134
 
80
- if (USER_POST_HISTORY_WIDGETS.has(item.slug)) {
135
+ if (USER_POST_HISTORY_WIDGETS.has(baseSlug)) {
81
136
  layoutItem.y = Math.max(item.y, 9);
82
137
  return layoutItem;
83
138
  }
84
139
 
85
- if (USER_BOTTOM_WIDGETS.has(item.slug)) {
140
+ if (USER_BOTTOM_WIDGETS.has(baseSlug)) {
86
141
  layoutItem.y = Math.max(item.y, 14);
87
142
  layoutItem.h = item.h + 1;
88
143
  return layoutItem;
@@ -95,16 +150,47 @@ const normalizeUserDashboardLayout = (item: WidgetLayout): LayoutItem => {
95
150
  return layoutItem;
96
151
  };
97
152
 
98
- export const DashboardContent = ({ dashboardSlug }: DashboardContentProps) => {
153
+ export const DashboardContent = ({
154
+ dashboardSlug,
155
+ showHeader = true,
156
+ headerActionsTargetId,
157
+ openWidgetPickerSignal,
158
+ onOpenWidgetPickerHandled,
159
+ }: DashboardContentProps) => {
99
160
  const t = useTranslations('core.DashboardPage');
100
161
  const { request } = useApp();
162
+ const {
163
+ start: startProgress,
164
+ stop: stopProgress,
165
+ set: setProgress,
166
+ } = useProgress();
101
167
  const router = useRouter();
102
168
  const isMobile = useIsMobile();
169
+ const isDevelopment = process.env.NODE_ENV === 'development';
103
170
 
104
171
  const [layout, setLayout] = useState<LayoutItem[]>([]);
105
172
  const [widgets, setWidgets] = useState<WidgetLayout[]>([]);
106
173
  const [hasChanges, setHasChanges] = useState(false);
107
174
  const [isSaving, setIsSaving] = useState(false);
175
+ const [componentsPage, setComponentsPage] = useState(1);
176
+ const [componentsPageSize, setComponentsPageSize] = useState(12);
177
+ const [componentsSearchQuery, setComponentsSearchQuery] = useState('');
178
+ const [componentsModuleFilter, setComponentsModuleFilter] = useState('all');
179
+ const [headerActionsTarget, setHeaderActionsTarget] =
180
+ useState<HTMLElement | null>(null);
181
+ const lastSavedLayoutSignatureRef = useRef('[]');
182
+ const latestLayoutRef = useRef(layout);
183
+
184
+ const debouncedComponentsSearch = useDebounce(componentsSearchQuery, 400);
185
+ const debouncedLayout = useDebounce(layout, LAYOUT_AUTOSAVE_DELAY);
186
+
187
+ useEffect(() => {
188
+ latestLayoutRef.current = layout;
189
+ }, [layout]);
190
+ const excludedComponentKeys = useMemo(
191
+ () => widgets.map((widget) => getWidgetIdentityKey(widget)),
192
+ [widgets]
193
+ );
108
194
 
109
195
  const { data: dashboardAccess, isLoading: isCheckingAccess } =
110
196
  useQuery<DashboardAccessResponse>({
@@ -128,18 +214,45 @@ export const DashboardContent = ({ dashboardSlug }: DashboardContentProps) => {
128
214
  }
129
215
  }, [dashboardAccess, dashboardSlug, router]);
130
216
 
217
+ useEffect(() => {
218
+ setComponentsPage(1);
219
+ setComponentsSearchQuery('');
220
+ setComponentsModuleFilter('all');
221
+ }, [dashboardSlug]);
222
+
131
223
  const {
132
- data: availableComponents,
224
+ data: availableComponentsResponse,
133
225
  isLoading: isLoadingComponents,
134
226
  refetch: refetchComponents,
135
- } = useQuery<any>({
136
- queryKey: ['dashboard-components'],
227
+ } = useQuery<DashboardComponentsPage>({
228
+ queryKey: [
229
+ 'dashboard-components',
230
+ dashboardSlug,
231
+ componentsPage,
232
+ componentsPageSize,
233
+ debouncedComponentsSearch,
234
+ componentsModuleFilter,
235
+ excludedComponentKeys.join(','),
236
+ ],
137
237
  queryFn: async () => {
138
- const { data } = await request<any>({
238
+ const trimmedSearch = debouncedComponentsSearch.trim();
239
+
240
+ const { data } = await request<DashboardComponentsPage>({
139
241
  url: '/dashboard-component/user',
140
242
  method: 'GET',
243
+ params: {
244
+ page: componentsPage,
245
+ pageSize: componentsPageSize,
246
+ ...(trimmedSearch ? { search: trimmedSearch } : {}),
247
+ ...(componentsModuleFilter !== 'all'
248
+ ? { librarySlug: componentsModuleFilter }
249
+ : {}),
250
+ ...(excludedComponentKeys.length > 0
251
+ ? { exclude: excludedComponentKeys.join(',') }
252
+ : {}),
253
+ },
141
254
  });
142
- return data.data;
255
+ return data;
143
256
  },
144
257
  enabled: dashboardAccess?.hasAccess ?? false,
145
258
  });
@@ -180,25 +293,62 @@ export const DashboardContent = ({ dashboardSlug }: DashboardContentProps) => {
180
293
  }
181
294
  );
182
295
 
296
+ lastSavedLayoutSignatureRef.current = getLayoutSignature(gridLayout);
183
297
  setLayout(gridLayout);
184
298
  setWidgets(userLayout);
185
299
  } else {
300
+ lastSavedLayoutSignatureRef.current = '[]';
186
301
  setLayout([]);
187
302
  setWidgets([]);
188
303
  }
189
304
  setHasChanges(false);
190
305
  }
191
- }, [userLayout]);
306
+ }, [userLayout, dashboardSlug]);
307
+
308
+ const availableComponents = availableComponentsResponse?.data ?? [];
309
+ const totalAvailableComponents = availableComponentsResponse?.total ?? 0;
310
+ const availableComponentModules = availableComponentsResponse?.modules ?? [];
311
+ const layoutSignature = useMemo(() => getLayoutSignature(layout), [layout]);
312
+ const debouncedLayoutSignature = useMemo(
313
+ () => getLayoutSignature(debouncedLayout),
314
+ [debouncedLayout]
315
+ );
316
+ const isAutosavePending =
317
+ hasChanges && !isSaving && layoutSignature !== debouncedLayoutSignature;
318
+
319
+ useEffect(() => {
320
+ if (!isAutosavePending && !isSaving) {
321
+ stopProgress(150);
322
+ return;
323
+ }
192
324
 
193
- const componentsToFilter = availableComponents?.data?.length
194
- ? availableComponents.data
195
- : availableComponents || [];
325
+ startProgress(isSaving ? 0.55 : 0.2, 0, true);
326
+ setProgress(isSaving ? 0.8 : 0.35);
327
+ }, [isAutosavePending, isSaving, setProgress, startProgress, stopProgress]);
196
328
 
197
- const filteredComponents =
198
- componentsToFilter.filter(
199
- (component: DashboardComponent) =>
200
- !widgets.some((widget) => widget.slug === component.slug)
201
- ) || [];
329
+ useEffect(() => {
330
+ const lastAvailablePage = Math.max(
331
+ availableComponentsResponse?.lastPage ?? 1,
332
+ 1
333
+ );
334
+
335
+ if (componentsPage > lastAvailablePage) {
336
+ setComponentsPage(lastAvailablePage);
337
+ }
338
+ }, [availableComponentsResponse?.lastPage, componentsPage]);
339
+
340
+ useEffect(() => {
341
+ if (
342
+ !headerActionsTargetId ||
343
+ showHeader ||
344
+ typeof document === 'undefined'
345
+ ) {
346
+ setHeaderActionsTarget(null);
347
+ return;
348
+ }
349
+
350
+ setHeaderActionsTarget(document.getElementById(headerActionsTargetId));
351
+ }, [headerActionsTargetId, showHeader]);
202
352
 
203
353
  const handleLayoutChange = useCallback((newLayout: LayoutItem[]) => {
204
354
  setLayout((prevLayout) => {
@@ -212,35 +362,69 @@ export const DashboardContent = ({ dashboardSlug }: DashboardContentProps) => {
212
362
  });
213
363
  }, []);
214
364
 
215
- const handleSaveLayout = async () => {
216
- setIsSaving(true);
217
- try {
218
- await request({
219
- url: `/dashboard-core/layout/${dashboardSlug}`,
220
- method: 'POST',
221
- data: { layout },
222
- });
223
- setHasChanges(false);
224
- } catch (error) {
225
- console.error('❌ Erro ao salvar layout:', error);
226
- } finally {
227
- setIsSaving(false);
365
+ const handleSaveLayout = useCallback(
366
+ async (layoutToSave: LayoutItem[] = layout) => {
367
+ const normalizedLayout = normalizeLayoutForSave(layoutToSave);
368
+ const nextLayoutSignature = JSON.stringify(normalizedLayout);
369
+
370
+ if (
371
+ isSaving ||
372
+ nextLayoutSignature === lastSavedLayoutSignatureRef.current
373
+ ) {
374
+ return;
375
+ }
376
+
377
+ setIsSaving(true);
378
+ try {
379
+ await request({
380
+ url: `/dashboard-core/layout/${dashboardSlug}`,
381
+ method: 'POST',
382
+ data: { layout: normalizedLayout },
383
+ });
384
+ lastSavedLayoutSignatureRef.current = nextLayoutSignature;
385
+ setHasChanges(
386
+ getLayoutSignature(latestLayoutRef.current) !== nextLayoutSignature
387
+ );
388
+ } catch (error) {
389
+ console.error('❌ Erro ao salvar layout:', error);
390
+ } finally {
391
+ setIsSaving(false);
392
+ }
393
+ },
394
+ [dashboardSlug, isSaving, layout, request]
395
+ );
396
+
397
+ useEffect(() => {
398
+ if (!hasChanges || isSaving) {
399
+ return;
400
+ }
401
+
402
+ const nextLayoutSignature = getLayoutSignature(debouncedLayout);
403
+
404
+ if (nextLayoutSignature === lastSavedLayoutSignatureRef.current) {
405
+ return;
228
406
  }
229
- };
230
407
 
231
- const handleAddWidget = async (slug: string) => {
408
+ void handleSaveLayout(debouncedLayout);
409
+ }, [debouncedLayout, handleSaveLayout, hasChanges, isSaving]);
410
+
411
+ const handleAddWidget = async (slugs: string[]) => {
412
+ if (!slugs.length) return;
413
+
232
414
  try {
233
- await request({
234
- url: `/dashboard-core/widget/${dashboardSlug}`,
235
- method: 'POST',
236
- data: { componentSlug: slug },
237
- });
415
+ for (const slug of slugs) {
416
+ await request({
417
+ url: `/dashboard-core/widget/${dashboardSlug}`,
418
+ method: 'POST',
419
+ data: { componentSlug: slug },
420
+ });
421
+ }
238
422
 
239
423
  await new Promise((resolve) => setTimeout(resolve, 300));
240
424
  await Promise.all([refetchLayout(), refetchComponents()]);
241
425
  setHasChanges(false);
242
426
  } catch (error) {
243
- console.error('Erro ao adicionar widget:', error);
427
+ console.error('Erro ao adicionar widgets:', error);
244
428
  }
245
429
  };
246
430
 
@@ -257,11 +441,69 @@ export const DashboardContent = ({ dashboardSlug }: DashboardContentProps) => {
257
441
  }
258
442
  };
259
443
 
444
+ const handleCaptureWidgetPreview = async (
445
+ widgetInstanceId: string,
446
+ componentId: number
447
+ ) => {
448
+ if (!isDevelopment) {
449
+ return;
450
+ }
451
+
452
+ try {
453
+ const widgetElement = document.querySelector(
454
+ `[data-widget-instance-id="${widgetInstanceId}"]`
455
+ ) as HTMLElement | null;
456
+
457
+ if (!widgetElement) {
458
+ throw new Error('Widget element not found for screenshot');
459
+ }
460
+
461
+ const screenshot = await toBlob(widgetElement, {
462
+ cacheBust: true,
463
+ pixelRatio: 2,
464
+ backgroundColor: '#ffffff',
465
+ filter: (node) =>
466
+ !(
467
+ node instanceof HTMLElement && node.dataset.widgetAction === 'true'
468
+ ),
469
+ });
470
+
471
+ if (!screenshot) {
472
+ throw new Error('Failed to generate screenshot blob');
473
+ }
474
+
475
+ const formData = new FormData();
476
+ formData.append(
477
+ 'file',
478
+ screenshot,
479
+ `dashboard-widget-${componentId}-${Date.now()}.png`
480
+ );
481
+
482
+ await request({
483
+ url: `/dashboard-component/${componentId}/preview`,
484
+ method: 'POST',
485
+ data: formData,
486
+ headers: {
487
+ 'Content-Type': 'multipart/form-data',
488
+ },
489
+ });
490
+
491
+ await refetchComponents();
492
+ } catch (error) {
493
+ console.error('Erro ao capturar preview do widget:', error);
494
+ }
495
+ };
496
+
260
497
  const renderWidget = (widget: WidgetLayout) => {
261
498
  return (
262
499
  <WidgetRenderer
263
500
  widget={widget}
264
501
  onRemove={() => handleRemoveWidget(widget.i)}
502
+ onCapture={
503
+ isDevelopment
504
+ ? () => handleCaptureWidgetPreview(widget.i, widget.component_id)
505
+ : undefined
506
+ }
265
507
  />
266
508
  );
267
509
  };
@@ -269,28 +511,30 @@ export const DashboardContent = ({ dashboardSlug }: DashboardContentProps) => {
269
511
  if (isCheckingAccess || isLoadingLayout) {
270
512
  return (
271
513
  <>
272
- <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">
273
- <div className="flex w-full items-center justify-between gap-2 px-4">
274
- <div className="flex items-center gap-2">
275
- <SidebarTrigger className="-ml-1" />
276
- <Separator
277
- orientation="vertical"
278
- className="mr-2 data-[orientation=vertical]:h-4"
279
- />
280
- <Breadcrumb>
281
- <BreadcrumbList>
282
- <BreadcrumbItem className="hidden md:block">
283
- <BreadcrumbLink href="#">{t('dashboard')}</BreadcrumbLink>
284
- </BreadcrumbItem>
285
- <BreadcrumbSeparator className="hidden md:block" />
286
- <BreadcrumbItem>
287
- <BreadcrumbPage>{t('overview')}</BreadcrumbPage>
288
- </BreadcrumbItem>
289
- </BreadcrumbList>
290
- </Breadcrumb>
514
+ {showHeader ? (
515
+ <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">
516
+ <div className="flex w-full items-center justify-between gap-2 px-4">
517
+ <div className="flex items-center gap-2">
518
+ <SidebarTrigger className="-ml-1" />
519
+ <Separator
520
+ orientation="vertical"
521
+ className="mr-2 data-[orientation=vertical]:h-4"
522
+ />
523
+ <Breadcrumb>
524
+ <BreadcrumbList>
525
+ <BreadcrumbItem className="hidden md:block">
526
+ <BreadcrumbLink href="#">{t('dashboard')}</BreadcrumbLink>
527
+ </BreadcrumbItem>
528
+ <BreadcrumbSeparator className="hidden md:block" />
529
+ <BreadcrumbItem>
530
+ <BreadcrumbPage>{t('overview')}</BreadcrumbPage>
531
+ </BreadcrumbItem>
532
+ </BreadcrumbList>
533
+ </Breadcrumb>
534
+ </div>
291
535
  </div>
292
- </div>
293
- </header>
536
+ </header>
537
+ ) : null}
294
538
  <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
295
539
  <Skeleton className="h-32 w-full" />
296
540
  <Skeleton className="h-32 w-full" />
@@ -303,28 +547,30 @@ export const DashboardContent = ({ dashboardSlug }: DashboardContentProps) => {
303
547
  if (!dashboardAccess?.hasAccess) {
304
548
  return (
305
549
  <>
306
- <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">
307
- <div className="flex w-full items-center justify-between gap-2 px-4">
308
- <div className="flex items-center gap-2">
309
- <SidebarTrigger className="-ml-1" />
310
- <Separator
311
- orientation="vertical"
312
- className="mr-2 data-[orientation=vertical]:h-4"
313
- />
314
- <Breadcrumb>
315
- <BreadcrumbList>
316
- <BreadcrumbItem className="hidden md:block">
317
- <BreadcrumbLink href="#">{t('dashboard')}</BreadcrumbLink>
318
- </BreadcrumbItem>
319
- <BreadcrumbSeparator className="hidden md:block" />
320
- <BreadcrumbItem>
321
- <BreadcrumbPage>{t('accessDenied')}</BreadcrumbPage>
322
- </BreadcrumbItem>
323
- </BreadcrumbList>
324
- </Breadcrumb>
550
+ {showHeader ? (
551
+ <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">
552
+ <div className="flex w-full items-center justify-between gap-2 px-4">
553
+ <div className="flex items-center gap-2">
554
+ <SidebarTrigger className="-ml-1" />
555
+ <Separator
556
+ orientation="vertical"
557
+ className="mr-2 data-[orientation=vertical]:h-4"
558
+ />
559
+ <Breadcrumb>
560
+ <BreadcrumbList>
561
+ <BreadcrumbItem className="hidden md:block">
562
+ <BreadcrumbLink href="#">{t('dashboard')}</BreadcrumbLink>
563
+ </BreadcrumbItem>
564
+ <BreadcrumbSeparator className="hidden md:block" />
565
+ <BreadcrumbItem>
566
+ <BreadcrumbPage>{t('accessDenied')}</BreadcrumbPage>
567
+ </BreadcrumbItem>
568
+ </BreadcrumbList>
569
+ </Breadcrumb>
570
+ </div>
325
571
  </div>
326
- </div>
327
- </header>
572
+ </header>
573
+ ) : null}
328
574
  <div className="flex flex-1 flex-col items-center justify-center gap-4 p-4 pt-0">
329
575
  <div className="text-center">
330
576
  <h2 className="text-2xl font-bold">{t('accessDenied')}</h2>
@@ -338,65 +584,118 @@ export const DashboardContent = ({ dashboardSlug }: DashboardContentProps) => {
338
584
  }
339
585
 
340
586
  const dashboardName = dashboardAccess?.dashboard?.name || dashboardSlug;
587
+ const autosaveStatusLabel = isSaving
588
+ ? 'Salvando layout...'
589
+ : isAutosavePending
590
+ ? 'Salvando automaticamente...'
591
+ : null;
592
+
593
+ const dashboardActions = (
594
+ <>
595
+ {autosaveStatusLabel ? (
596
+ <div className="text-muted-foreground bg-muted inline-flex items-center gap-2 rounded-md px-2.5 py-1 text-xs">
597
+ <IconLoader2 className="size-3.5 animate-spin" />
598
+ <span>{autosaveStatusLabel}</span>
599
+ </div>
600
+ ) : null}
601
+ {hasChanges ? (
602
+ <Button
603
+ size="sm"
604
+ variant="default"
605
+ className="gap-1 px-2 sm:gap-2 sm:px-3"
606
+ onClick={() => void handleSaveLayout()}
607
+ disabled={isSaving}
608
+ aria-label={isSaving ? t('saving') : t('saveLayout')}
609
+ >
610
+ {isSaving ? (
611
+ <IconLoader2 className="size-4 animate-spin" />
612
+ ) : (
613
+ <IconDeviceFloppy className="size-4" />
614
+ )}
615
+ <span className="hidden sm:inline">
616
+ {isSaving ? t('saving') : t('saveLayout')}
617
+ </span>
618
+ </Button>
619
+ ) : null}
620
+ <AddWidgetSelectorDialog
621
+ availableComponents={availableComponents}
622
+ totalItems={totalAvailableComponents}
623
+ currentPage={availableComponentsResponse?.page ?? componentsPage}
624
+ pageSize={availableComponentsResponse?.pageSize ?? componentsPageSize}
625
+ isLoading={isLoadingComponents}
626
+ searchQuery={componentsSearchQuery}
627
+ onSearchQueryChange={(value) => {
628
+ setComponentsSearchQuery(value);
629
+ setComponentsPage(1);
630
+ }}
631
+ moduleFilter={componentsModuleFilter}
632
+ modules={availableComponentModules}
633
+ onModuleFilterChange={(value) => {
634
+ setComponentsModuleFilter(value);
635
+ setComponentsPage(1);
636
+ }}
637
+ onPageChange={setComponentsPage}
638
+ onPageSizeChange={(nextPageSize) => {
639
+ setComponentsPageSize(nextPageSize);
640
+ setComponentsPage(1);
641
+ }}
642
+ onAdd={handleAddWidget}
643
+ openSignal={openWidgetPickerSignal}
644
+ onOpenSignalHandled={onOpenWidgetPickerHandled}
645
+ />
646
+ </>
647
+ );
648
+
649
+ const hasExternalActionTarget = !showHeader && Boolean(headerActionsTarget);
341
650
 
342
651
  return (
343
652
  <>
344
- <header className="flex min-h-16 shrink-0 items-center gap-2 py-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 sm:h-16 sm:py-0">
345
- <div className="flex w-full flex-col gap-2 px-4 sm:flex-row sm:items-center sm:justify-between">
346
- <div className="flex min-w-0 items-center gap-2">
347
- <SidebarTrigger className="-ml-1" />
348
- <Separator
349
- orientation="vertical"
350
- className="mr-2 data-[orientation=vertical]:h-4"
351
- />
352
- <Breadcrumb className="min-w-0">
353
- <BreadcrumbList>
354
- <BreadcrumbItem className="hidden md:block">
355
- <BreadcrumbLink href="#">{t('dashboard')}</BreadcrumbLink>
356
- </BreadcrumbItem>
357
- <BreadcrumbSeparator className="hidden md:block" />
358
- <BreadcrumbItem>
359
- <BreadcrumbPage className="truncate">
360
- {dashboardName}
361
- </BreadcrumbPage>
362
- </BreadcrumbItem>
363
- </BreadcrumbList>
364
- </Breadcrumb>
365
- </div>
366
- <div className="flex w-full items-center justify-end gap-2 sm:w-auto">
367
- {hasChanges && (
368
- <Button
369
- size="sm"
370
- variant="default"
371
- className="gap-1 px-2 sm:gap-2 sm:px-3"
372
- onClick={handleSaveLayout}
373
- disabled={isSaving}
374
- aria-label={isSaving ? t('saving') : t('saveLayout')}
375
- >
376
- <IconDeviceFloppy className="size-4" />
377
- <span className="hidden sm:inline">
378
- {isSaving ? t('saving') : t('saveLayout')}
379
- </span>
380
- </Button>
381
- )}
382
- <AddWidgetSelectorDialog
383
- availableComponents={filteredComponents}
384
- isLoading={isLoadingComponents}
385
- onAdd={handleAddWidget}
386
- currentSlug={dashboardSlug}
387
- />
653
+ {showHeader ? (
654
+ <header className="flex min-h-16 shrink-0 items-center gap-2 py-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 sm:h-16 sm:py-0">
655
+ <div className="flex w-full flex-col gap-2 px-4 sm:flex-row sm:items-center sm:justify-between">
656
+ <div className="flex min-w-0 items-center gap-2">
657
+ <SidebarTrigger className="-ml-1" />
658
+ <Separator
659
+ orientation="vertical"
660
+ className="mr-2 data-[orientation=vertical]:h-4"
661
+ />
662
+ <Breadcrumb className="min-w-0">
663
+ <BreadcrumbList>
664
+ <BreadcrumbItem className="hidden md:block">
665
+ <BreadcrumbLink href="#">{t('dashboard')}</BreadcrumbLink>
666
+ </BreadcrumbItem>
667
+ <BreadcrumbSeparator className="hidden md:block" />
668
+ <BreadcrumbItem>
669
+ <BreadcrumbPage className="truncate">
670
+ {dashboardName}
671
+ </BreadcrumbPage>
672
+ </BreadcrumbItem>
673
+ </BreadcrumbList>
674
+ </Breadcrumb>
675
+ </div>
676
+ <div className="flex w-full items-center justify-end gap-2 sm:w-auto">
677
+ {dashboardActions}
678
+ </div>
388
679
  </div>
680
+ </header>
681
+ ) : !hasExternalActionTarget ? (
682
+ <div className="flex w-full items-center justify-end gap-2 px-4 pt-2">
683
+ {dashboardActions}
389
684
  </div>
390
- </header>
391
- <div className="flex flex-1 flex-col gap-4 overflow-auto p-4 pt-0">
685
+ ) : null}
686
+ {hasExternalActionTarget && headerActionsTarget
687
+ ? createPortal(dashboardActions, headerActionsTarget)
688
+ : null}
689
+ <div className="flex flex-1 flex-col gap-4 overflow-auto pt-0">
392
690
  {widgets.length > 0 ? (
393
- <div className="min-h-[420px] sm:min-h-[520px] lg:min-h-[600px]">
691
+ <div className="min-h-105 sm:min-h-130 lg:min-h-150">
394
692
  <DraggableGrid
395
693
  className="dashboard-grid"
396
694
  layout={layout}
397
695
  onLayoutChange={handleLayoutChange}
398
696
  cols={12}
399
697
  rowHeight={80}
698
+ compactType={null}
400
699
  preventCollision
401
700
  isDraggable={!isMobile}
402
701
  isResizable={!isMobile}
@@ -408,17 +707,40 @@ export const DashboardContent = ({ dashboardSlug }: DashboardContentProps) => {
408
707
  </DraggableGrid>
409
708
  </div>
410
709
  ) : (
411
- <div className="flex min-h-[400px] flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center">
710
+ <div className="flex min-h-100 flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center">
412
711
  <h3 className="text-lg font-semibold">{t('noWidgetAdded')}</h3>
413
712
  <p className="text-muted-foreground mt-2 text-sm">
414
713
  {t('startAddingWidgets')}
415
714
  </p>
416
715
  <div className="mt-4">
417
716
  <AddWidgetSelectorDialog
418
- availableComponents={filteredComponents}
717
+ availableComponents={availableComponents}
718
+ totalItems={totalAvailableComponents}
719
+ currentPage={
720
+ availableComponentsResponse?.page ?? componentsPage
721
+ }
722
+ pageSize={
723
+ availableComponentsResponse?.pageSize ?? componentsPageSize
724
+ }
419
725
  isLoading={isLoadingComponents}
726
+ searchQuery={componentsSearchQuery}
727
+ onSearchQueryChange={(value) => {
728
+ setComponentsSearchQuery(value);
729
+ setComponentsPage(1);
730
+ }}
731
+ moduleFilter={componentsModuleFilter}
732
+ modules={availableComponentModules}
733
+ onModuleFilterChange={(value) => {
734
+ setComponentsModuleFilter(value);
735
+ setComponentsPage(1);
736
+ }}
737
+ onPageChange={setComponentsPage}
738
+ onPageSizeChange={(nextPageSize) => {
739
+ setComponentsPageSize(nextPageSize);
740
+ setComponentsPage(1);
741
+ }}
420
742
  onAdd={handleAddWidget}
421
- currentSlug={dashboardSlug}
743
+ buttonVariant="default"
422
744
  />
423
745
  </div>
424
746
  </div>