@hed-hog/core 0.0.303 → 0.0.305

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 (118) hide show
  1. package/README.md +84 -36
  2. package/dist/auth/auth.controller.d.ts +0 -3
  3. package/dist/auth/auth.controller.d.ts.map +1 -1
  4. package/dist/auth/auth.controller.js +4 -7
  5. package/dist/auth/auth.controller.js.map +1 -1
  6. package/dist/auth/auth.service.d.ts.map +1 -1
  7. package/dist/auth/auth.service.js +6 -3
  8. package/dist/auth/auth.service.js.map +1 -1
  9. package/dist/dashboard/dashboard-core/dashboard-core.controller.d.ts +9 -0
  10. package/dist/dashboard/dashboard-core/dashboard-core.controller.d.ts.map +1 -1
  11. package/dist/dashboard/dashboard-core/dashboard-core.controller.js +12 -0
  12. package/dist/dashboard/dashboard-core/dashboard-core.controller.js.map +1 -1
  13. package/dist/dashboard/dashboard-core/dashboard-core.service.d.ts +7 -0
  14. package/dist/dashboard/dashboard-core/dashboard-core.service.d.ts.map +1 -1
  15. package/dist/dashboard/dashboard-core/dashboard-core.service.js +45 -1
  16. package/dist/dashboard/dashboard-core/dashboard-core.service.js.map +1 -1
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +2 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/mail-sent/dto/query-mail-sent-list.dto.d.ts +9 -0
  22. package/dist/mail-sent/dto/query-mail-sent-list.dto.d.ts.map +1 -0
  23. package/dist/mail-sent/dto/query-mail-sent-list.dto.js +49 -0
  24. package/dist/mail-sent/dto/query-mail-sent-list.dto.js.map +1 -0
  25. package/dist/mail-sent/mail-sent.controller.d.ts +24 -2
  26. package/dist/mail-sent/mail-sent.controller.d.ts.map +1 -1
  27. package/dist/mail-sent/mail-sent.controller.js +5 -3
  28. package/dist/mail-sent/mail-sent.controller.js.map +1 -1
  29. package/dist/mail-sent/mail-sent.service.d.ts +28 -3
  30. package/dist/mail-sent/mail-sent.service.d.ts.map +1 -1
  31. package/dist/mail-sent/mail-sent.service.js +108 -3
  32. package/dist/mail-sent/mail-sent.service.js.map +1 -1
  33. package/dist/oauth/oauth-callback-coordinator.service.d.ts +18 -0
  34. package/dist/oauth/oauth-callback-coordinator.service.d.ts.map +1 -0
  35. package/dist/oauth/oauth-callback-coordinator.service.js +137 -0
  36. package/dist/oauth/oauth-callback-coordinator.service.js.map +1 -0
  37. package/dist/oauth/oauth.controller.d.ts +4 -2
  38. package/dist/oauth/oauth.controller.d.ts.map +1 -1
  39. package/dist/oauth/oauth.controller.js +39 -11
  40. package/dist/oauth/oauth.controller.js.map +1 -1
  41. package/dist/oauth/oauth.errors.d.ts +16 -0
  42. package/dist/oauth/oauth.errors.d.ts.map +1 -0
  43. package/dist/oauth/oauth.errors.js +30 -0
  44. package/dist/oauth/oauth.errors.js.map +1 -0
  45. package/dist/oauth/oauth.module.d.ts.map +1 -1
  46. package/dist/oauth/oauth.module.js +10 -1
  47. package/dist/oauth/oauth.module.js.map +1 -1
  48. package/dist/oauth/oauth.service.d.ts +9 -1
  49. package/dist/oauth/oauth.service.d.ts.map +1 -1
  50. package/dist/oauth/oauth.service.js +97 -24
  51. package/dist/oauth/oauth.service.js.map +1 -1
  52. package/dist/oauth/providers/abstract.provider.d.ts +6 -0
  53. package/dist/oauth/providers/abstract.provider.d.ts.map +1 -1
  54. package/dist/oauth/providers/abstract.provider.js +34 -0
  55. package/dist/oauth/providers/abstract.provider.js.map +1 -1
  56. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +3 -0
  57. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
  58. package/dist/oauth/providers/microsoft-entra-id.provider.js +40 -22
  59. package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
  60. package/dist/security/security.service.d.ts +1 -0
  61. package/dist/security/security.service.d.ts.map +1 -1
  62. package/dist/security/security.service.js +9 -10
  63. package/dist/security/security.service.js.map +1 -1
  64. package/dist/session/session.service.d.ts +5 -1
  65. package/dist/session/session.service.d.ts.map +1 -1
  66. package/dist/session/session.service.js +76 -10
  67. package/dist/session/session.service.js.map +1 -1
  68. package/dist/setting/setting.service.js +4 -4
  69. package/dist/setting/setting.service.js.map +1 -1
  70. package/dist/token/token.service.d.ts +1 -0
  71. package/dist/token/token.service.d.ts.map +1 -1
  72. package/dist/token/token.service.js +23 -12
  73. package/dist/token/token.service.js.map +1 -1
  74. package/dist/user/user.service.d.ts +1 -1
  75. package/dist/user/user.service.d.ts.map +1 -1
  76. package/dist/user/user.service.js +3 -0
  77. package/dist/user/user.service.js.map +1 -1
  78. package/hedhog/data/dashboard.yaml +2 -2
  79. package/hedhog/data/mail.yaml +147 -0
  80. package/hedhog/data/route.yaml +8 -0
  81. package/hedhog/frontend/app/account/components/change-email-form.tsx.ejs +87 -8
  82. package/hedhog/frontend/app/account/components/profile-form.tsx.ejs +90 -10
  83. package/hedhog/frontend/app/ai_agent/page.tsx.ejs +145 -31
  84. package/hedhog/frontend/app/configurations/layout.tsx.ejs +1 -1
  85. package/hedhog/frontend/app/dashboard/[slug]/dashboard-content.tsx.ejs +67 -2
  86. package/hedhog/frontend/app/dashboard/components/add-widget-selector-dialog.tsx.ejs +13 -9
  87. package/hedhog/frontend/app/dashboard/dashboard-home-tabs.tsx.ejs +250 -45
  88. package/hedhog/frontend/app/dashboard/management/tabs/component-roles-tab.tsx.ejs +111 -15
  89. package/hedhog/frontend/app/dashboard/management/tabs/dashboard-roles-tab.tsx.ejs +110 -15
  90. package/hedhog/frontend/app/dashboard/management/tabs/items-tab.tsx.ejs +113 -15
  91. package/hedhog/frontend/app/mail/log/page.tsx.ejs +481 -70
  92. package/hedhog/frontend/app/menu/page.tsx.ejs +202 -30
  93. package/hedhog/frontend/app/roles/menus.tsx.ejs +1 -1
  94. package/hedhog/frontend/app/roles/page.tsx.ejs +288 -104
  95. package/hedhog/frontend/app/users/page.tsx.ejs +168 -23
  96. package/hedhog/frontend/messages/en.json +45 -6
  97. package/hedhog/frontend/messages/pt.json +45 -6
  98. package/package.json +4 -4
  99. package/src/auth/auth.controller.ts +15 -19
  100. package/src/auth/auth.service.ts +13 -8
  101. package/src/dashboard/dashboard-core/dashboard-core.controller.ts +22 -9
  102. package/src/dashboard/dashboard-core/dashboard-core.service.ts +75 -1
  103. package/src/index.ts +10 -7
  104. package/src/mail-sent/dto/query-mail-sent-list.dto.ts +30 -0
  105. package/src/mail-sent/mail-sent.controller.ts +14 -2
  106. package/src/mail-sent/mail-sent.service.ts +159 -4
  107. package/src/oauth/oauth-callback-coordinator.service.ts +158 -0
  108. package/src/oauth/oauth.controller.ts +58 -1
  109. package/src/oauth/oauth.errors.ts +32 -0
  110. package/src/oauth/oauth.module.ts +10 -1
  111. package/src/oauth/oauth.service.ts +178 -28
  112. package/src/oauth/providers/abstract.provider.ts +64 -0
  113. package/src/oauth/providers/microsoft-entra-id.provider.ts +92 -56
  114. package/src/security/security.service.ts +12 -10
  115. package/src/session/session.service.ts +84 -9
  116. package/src/setting/setting.service.ts +10 -10
  117. package/src/token/token.service.ts +29 -11
  118. package/src/user/user.service.ts +10 -6
@@ -42,15 +42,19 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
42
42
  import { useDebounce } from '@/hooks/use-debounce';
43
43
  import { cn } from '@/lib/utils';
44
44
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
45
+ import useEmblaCarousel from 'embla-carousel-react';
45
46
  import * as LucideIcons from 'lucide-react';
46
47
  import {
47
48
  Check,
49
+ ChevronLeft,
50
+ ChevronRight,
48
51
  Home,
49
52
  LayoutDashboard,
50
53
  Loader2,
51
54
  Palette,
52
55
  Pencil,
53
56
  Plus,
57
+ Save,
54
58
  Share2,
55
59
  Trash2,
56
60
  UserPlus,
@@ -58,7 +62,12 @@ import {
58
62
  type LucideIcon,
59
63
  } from 'lucide-react';
60
64
  import { useTranslations } from 'next-intl';
61
- import { useEffect, useMemo, useState } from 'react';
65
+ import {
66
+ useEffect,
67
+ useMemo,
68
+ useState,
69
+ type DragEvent as ReactDragEvent,
70
+ } from 'react';
62
71
  import { toast } from 'sonner';
63
72
  import { DashboardContent } from './[slug]/dashboard-content';
64
73
 
@@ -104,7 +113,22 @@ type DashboardTemplate = {
104
113
  const getDashboardName = (dashboard: DashboardTab) =>
105
114
  dashboard.name || dashboard.dashboard_locale?.[0]?.name || dashboard.slug;
106
115
 
107
- const DASHBOARD_HOME_HEADER_ACTIONS_TARGET_ID = 'dashboard-home-header-actions';
116
+ const reorderDashboards = (
117
+ currentDashboards: DashboardTab[],
118
+ sourceIndex: number,
119
+ targetIndex: number
120
+ ) => {
121
+ const nextDashboards = [...currentDashboards];
122
+ const [movedDashboard] = nextDashboards.splice(sourceIndex, 1);
123
+
124
+ if (!movedDashboard) {
125
+ return currentDashboards;
126
+ }
127
+
128
+ nextDashboards.splice(targetIndex, 0, movedDashboard);
129
+ return nextDashboards;
130
+ };
131
+
108
132
  const EMPTY_SHAREABLE_USERS_PAGE: PaginatedResponse<SharedUser> = {
109
133
  data: [],
110
134
  total: 0,
@@ -289,6 +313,7 @@ const getErrorMessage = (error: unknown, fallback: string) => {
289
313
 
290
314
  export function DashboardHomeTabs() {
291
315
  const t = useTranslations('core.DashboardHomeTabs');
316
+ const tDashboardPage = useTranslations('core.DashboardPage');
292
317
  const { request, currentLocaleCode } = useApp();
293
318
  const [activeSlug, setActiveSlug] = useState('');
294
319
  const [createOpen, setCreateOpen] = useState(false);
@@ -315,6 +340,27 @@ export function DashboardHomeTabs() {
315
340
  const [iconDashboard, setIconDashboard] = useState<DashboardTab | null>(null);
316
341
  const [iconValue, setIconValue] = useState('layout-dashboard');
317
342
  const [isSavingIcon, setIsSavingIcon] = useState(false);
343
+ const [requestSaveLayoutSignal, setRequestSaveLayoutSignal] = useState(0);
344
+ const [runtimeHeaderState, setRuntimeHeaderState] = useState({
345
+ hasChanges: false,
346
+ isSaving: false,
347
+ isAutosavePending: false,
348
+ });
349
+
350
+ const [tabsEmblaRef, tabsEmblaApi] = useEmblaCarousel({
351
+ dragFree: true,
352
+ containScroll: 'keepSnaps',
353
+ align: 'start',
354
+ });
355
+ const [canScrollPrevTabs, setCanScrollPrevTabs] = useState(false);
356
+ const [canScrollNextTabs, setCanScrollNextTabs] = useState(false);
357
+ const [orderedDashboards, setOrderedDashboards] = useState<DashboardTab[]>(
358
+ []
359
+ );
360
+ const [draggingDashboardSlug, setDraggingDashboardSlug] = useState<
361
+ string | null
362
+ >(null);
363
+ const [isReordering, setIsReordering] = useState(false);
318
364
 
319
365
  const debouncedShareSearch = useDebounce(shareSearch, 300);
320
366
 
@@ -349,8 +395,10 @@ export function DashboardHomeTabs() {
349
395
  });
350
396
 
351
397
  const activeDashboard = useMemo(
352
- () => dashboards.find((dashboard) => dashboard.slug === activeSlug) ?? null,
353
- [activeSlug, dashboards]
398
+ () =>
399
+ orderedDashboards.find((dashboard) => dashboard.slug === activeSlug) ??
400
+ null,
401
+ [activeSlug, orderedDashboards]
354
402
  );
355
403
 
356
404
  const selectedTemplate = useMemo(
@@ -372,24 +420,47 @@ export function DashboardHomeTabs() {
372
420
  );
373
421
 
374
422
  useEffect(() => {
375
- if (dashboards.length === 0) {
423
+ setOrderedDashboards(dashboards);
424
+ }, [dashboards]);
425
+
426
+ useEffect(() => {
427
+ if (orderedDashboards.length === 0) {
376
428
  setActiveSlug('');
377
429
  return;
378
430
  }
379
431
 
380
- const hasActiveDashboard = dashboards.some(
432
+ const hasActiveDashboard = orderedDashboards.some(
381
433
  (dashboard) => dashboard.slug === activeSlug
382
434
  );
383
435
 
384
436
  if (!activeSlug || !hasActiveDashboard) {
385
437
  const nextDashboard =
386
- dashboards.find((dashboard) => dashboard.is_home) ?? dashboards[0];
438
+ orderedDashboards.find((dashboard) => dashboard.is_home) ??
439
+ orderedDashboards[0];
387
440
 
388
441
  if (nextDashboard) {
389
442
  setActiveSlug(nextDashboard.slug);
390
443
  }
391
444
  }
392
- }, [activeSlug, dashboards]);
445
+ }, [activeSlug, orderedDashboards]);
446
+
447
+ useEffect(() => {
448
+ if (!tabsEmblaApi) return;
449
+
450
+ const updateScrollButtons = () => {
451
+ setCanScrollPrevTabs(tabsEmblaApi.canScrollPrev());
452
+ setCanScrollNextTabs(tabsEmblaApi.canScrollNext());
453
+ };
454
+
455
+ updateScrollButtons();
456
+ tabsEmblaApi.on('scroll', updateScrollButtons);
457
+ tabsEmblaApi.on('reInit', updateScrollButtons);
458
+
459
+ return () => {
460
+ tabsEmblaApi.off('scroll', updateScrollButtons);
461
+ tabsEmblaApi.off('reInit', updateScrollButtons);
462
+ };
463
+ }, [tabsEmblaApi]);
393
464
 
394
465
  const {
395
466
  data: sharedUsers = [],
@@ -462,6 +533,80 @@ export function DashboardHomeTabs() {
462
533
 
463
534
  const shareableUsers = shareableUsersPage.data ?? [];
464
535
 
536
+ const handleTabDragStart = (
537
+ event: ReactDragEvent<HTMLElement>,
538
+ dashboardSlug: string
539
+ ) => {
540
+ setDraggingDashboardSlug(dashboardSlug);
541
+ event.dataTransfer.effectAllowed = 'move';
542
+ event.dataTransfer.setData('text/plain', dashboardSlug);
543
+ };
544
+
545
+ const handleTabDragOver = (event: ReactDragEvent<HTMLElement>) => {
546
+ event.preventDefault();
547
+ event.dataTransfer.dropEffect = 'move';
548
+ };
549
+
550
+ const handleTabDrop = async (
551
+ event: ReactDragEvent<HTMLElement>,
552
+ targetDashboardSlug: string
553
+ ) => {
554
+ event.preventDefault();
555
+
556
+ const sourceDashboardSlug =
557
+ draggingDashboardSlug || event.dataTransfer.getData('text/plain');
558
+
559
+ if (!sourceDashboardSlug || sourceDashboardSlug === targetDashboardSlug) {
560
+ setDraggingDashboardSlug(null);
561
+ return;
562
+ }
563
+
564
+ const sourceIndex = orderedDashboards.findIndex(
565
+ (dashboard) => dashboard.slug === sourceDashboardSlug
566
+ );
567
+ const targetIndex = orderedDashboards.findIndex(
568
+ (dashboard) => dashboard.slug === targetDashboardSlug
569
+ );
570
+
571
+ if (sourceIndex < 0 || targetIndex < 0) {
572
+ setDraggingDashboardSlug(null);
573
+ return;
574
+ }
575
+
576
+ const previousDashboards = orderedDashboards;
577
+ const nextDashboards = reorderDashboards(
578
+ orderedDashboards,
579
+ sourceIndex,
580
+ targetIndex
581
+ );
582
+
583
+ setOrderedDashboards(nextDashboards);
584
+
585
+ try {
586
+ setIsReordering(true);
587
+ await request({
588
+ url: '/dashboard-core/dashboard/order',
589
+ method: 'PATCH',
590
+ data: {
591
+ slugs: nextDashboards.map((dashboard) => dashboard.slug),
592
+ },
593
+ });
594
+ await refetchDashboards();
595
+ } catch (error) {
596
+ setOrderedDashboards(previousDashboards);
597
+ toast.error(
598
+ getErrorMessage(error, 'Unable to save dashboard tab order.')
599
+ );
600
+ } finally {
601
+ setIsReordering(false);
602
+ setDraggingDashboardSlug(null);
603
+ }
604
+ };
605
+
606
+ const handleTabDragEnd = () => {
607
+ setDraggingDashboardSlug(null);
608
+ };
609
+
465
610
  const handleSelectTemplate = (template: DashboardTemplate | null) => {
466
611
  setSelectedTemplateSlug(template?.slug ?? '');
467
612
  setNewDashboardName(template?.name ?? '');
@@ -738,34 +883,9 @@ export function DashboardHomeTabs() {
738
883
  isSettingHome ||
739
884
  isSavingIcon ||
740
885
  isSharingUsers ||
886
+ isReordering ||
741
887
  revokingUserId !== null;
742
888
 
743
- const headerActions = (
744
- <div className="flex flex-wrap items-center justify-end gap-2">
745
- {activeDashboard ? (
746
- <div
747
- id={DASHBOARD_HOME_HEADER_ACTIONS_TARGET_ID}
748
- className="contents"
749
- />
750
- ) : null}
751
- <Button
752
- variant="outline"
753
- size="sm"
754
- onClick={() =>
755
- activeDashboard && openShareForDashboard(activeDashboard)
756
- }
757
- disabled={!activeDashboard || isBusy}
758
- >
759
- <Share2 className="size-4" />
760
- {t('share')}
761
- </Button>
762
- <Button size="sm" onClick={() => setCreateOpen(true)} disabled={isBusy}>
763
- <Plus className="size-4" />
764
- {t('newDashboard')}
765
- </Button>
766
- </div>
767
- );
768
-
769
889
  return (
770
890
  <Page>
771
891
  <PageHeader
@@ -777,7 +897,51 @@ export function DashboardHomeTabs() {
777
897
  ]}
778
898
  title={t('pageTitle')}
779
899
  description={t('pageDescription')}
780
- actions={headerActions}
900
+ actions={[
901
+ {
902
+ label: t('share'),
903
+ onClick: () =>
904
+ activeDashboard && openShareForDashboard(activeDashboard),
905
+ icon: <Share2 className="size-4" />,
906
+ variant: 'outline',
907
+ size: 'sm',
908
+ disabled: !activeDashboard || isBusy,
909
+ },
910
+ {
911
+ label: t('addWidgets'),
912
+ onClick: () =>
913
+ activeDashboard && openAddWidgetsForDashboard(activeDashboard),
914
+ icon: <Plus className="size-4" />,
915
+ variant: 'outline',
916
+ size: 'sm',
917
+ disabled: !activeDashboard || isBusy,
918
+ },
919
+ {
920
+ label: runtimeHeaderState.isSaving
921
+ ? tDashboardPage('saving')
922
+ : tDashboardPage('saveLayout'),
923
+ onClick: () =>
924
+ setRequestSaveLayoutSignal((previous) => previous + 1),
925
+ icon: runtimeHeaderState.isSaving ? (
926
+ <Loader2 className="size-4 animate-spin" />
927
+ ) : (
928
+ <Save className="size-4" />
929
+ ),
930
+ size: 'sm',
931
+ disabled:
932
+ !activeDashboard ||
933
+ isBusy ||
934
+ runtimeHeaderState.isSaving ||
935
+ !runtimeHeaderState.hasChanges,
936
+ },
937
+ {
938
+ label: t('newDashboard'),
939
+ onClick: () => setCreateOpen(true),
940
+ icon: <Plus className="size-4" />,
941
+ size: 'sm',
942
+ disabled: isBusy,
943
+ },
944
+ ]}
781
945
  />
782
946
 
783
947
  {isLoading ? (
@@ -793,10 +957,20 @@ export function DashboardHomeTabs() {
793
957
  onValueChange={setActiveSlug}
794
958
  className="gap-0"
795
959
  >
796
- <div className="bg-muted dark:bg-muted/40">
797
- <ScrollArea className="w-full whitespace-nowrap">
798
- <TabsList className="dashboard-tabs-list h-auto min-h-0 w-max items-end gap-0 rounded-none bg-transparent p-0 pt-1 pl-1">
799
- {dashboards.map((dashboard) => {
960
+ <div className="bg-muted dark:bg-muted/40 relative">
961
+ {canScrollPrevTabs && (
962
+ <button
963
+ type="button"
964
+ onClick={() => tabsEmblaApi?.scrollPrev()}
965
+ aria-label="Scroll tabs left"
966
+ className="md:hidden absolute left-0 top-0 bottom-0 z-10 flex items-center px-1.5 bg-linear-to-r from-muted dark:from-muted/40 to-transparent"
967
+ >
968
+ <ChevronLeft className="size-4 text-muted-foreground" />
969
+ </button>
970
+ )}
971
+ <div ref={tabsEmblaRef} className="overflow-hidden">
972
+ <TabsList className="dashboard-tabs-list h-auto min-h-0 items-end gap-0 rounded-none bg-transparent p-0 pt-1 pl-1 flex w-max">
973
+ {orderedDashboards.map((dashboard) => {
800
974
  const isRenamingCurrentTab =
801
975
  renamingSlug === dashboard.slug;
802
976
  const isActiveTab = activeSlug === dashboard.slug;
@@ -846,13 +1020,32 @@ export function DashboardHomeTabs() {
846
1020
  <ContextMenu key={dashboard.slug}>
847
1021
  <ContextMenuTrigger asChild>
848
1022
  {isRenamingCurrentTab ? (
849
- <div className="-mb-px flex min-h-9 items-center gap-2 rounded-b-none rounded-t-lg border border-b-0 border-border bg-background px-3 py-2 text-foreground shadow-none">
1023
+ <div
1024
+ onDragOver={handleTabDragOver}
1025
+ onDrop={(event) =>
1026
+ void handleTabDrop(event, dashboard.slug)
1027
+ }
1028
+ 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"
1029
+ >
850
1030
  {tabContent}
851
1031
  </div>
852
1032
  ) : (
853
1033
  <TabsTrigger
854
1034
  value={dashboard.slug}
855
- className="dashboard-tab-trigger -mb-px cursor-pointer gap-2 rounded-b-none rounded-t-lg px-3 py-2 mt-1 border-none shadow-none data-[state=active]:shadow-none"
1035
+ draggable={!isBusy}
1036
+ onDragStart={(event) =>
1037
+ handleTabDragStart(event, dashboard.slug)
1038
+ }
1039
+ onDragOver={handleTabDragOver}
1040
+ onDrop={(event) =>
1041
+ void handleTabDrop(event, dashboard.slug)
1042
+ }
1043
+ onDragEnd={handleTabDragEnd}
1044
+ className={cn(
1045
+ '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',
1046
+ draggingDashboardSlug === dashboard.slug &&
1047
+ 'opacity-70'
1048
+ )}
856
1049
  style={
857
1050
  isActiveTab
858
1051
  ? {
@@ -934,7 +1127,17 @@ export function DashboardHomeTabs() {
934
1127
  <Plus className="size-4" />
935
1128
  </button>
936
1129
  </TabsList>
937
- </ScrollArea>
1130
+ </div>
1131
+ {canScrollNextTabs && (
1132
+ <button
1133
+ type="button"
1134
+ onClick={() => tabsEmblaApi?.scrollNext()}
1135
+ aria-label="Scroll tabs right"
1136
+ className="md:hidden absolute right-0 top-0 bottom-0 z-10 flex items-center px-1.5 bg-linear-to-l from-muted dark:from-muted/40 to-transparent"
1137
+ >
1138
+ <ChevronRight className="size-4 text-muted-foreground" />
1139
+ </button>
1140
+ )}
938
1141
  </div>
939
1142
  </Tabs>
940
1143
  </div>
@@ -952,7 +1155,9 @@ export function DashboardHomeTabs() {
952
1155
  key={activeDashboard.slug}
953
1156
  dashboardSlug={activeDashboard.slug}
954
1157
  showHeader={false}
955
- headerActionsTargetId={DASHBOARD_HOME_HEADER_ACTIONS_TARGET_ID}
1158
+ externalHeaderActions
1159
+ requestSaveSignal={requestSaveLayoutSignal}
1160
+ onRuntimeActionsStateChange={setRuntimeHeaderState}
956
1161
  openWidgetPickerSignal={openAddWidgetSignal}
957
1162
  onOpenWidgetPickerHandled={() => setOpenAddWidgetSignal(0)}
958
1163
  />
@@ -1131,7 +1336,7 @@ export function DashboardHomeTabs() {
1131
1336
 
1132
1337
  <div className="space-y-2">
1133
1338
  <p className="text-sm font-medium">{t('iconSuggestions')}</p>
1134
- <div className="grid gap-2 grid-cols-12">
1339
+ <div className="grid grid-cols-6 gap-2 md:grid-cols-8 lg:grid-cols-12">
1135
1340
  {DASHBOARD_ICON_OPTIONS.map((iconName) => {
1136
1341
  const IconOption = resolveDashboardIcon(iconName);
1137
1342
  const isSelected =
@@ -1241,7 +1446,7 @@ export function DashboardHomeTabs() {
1241
1446
 
1242
1447
  <div className="space-y-2">
1243
1448
  <p className="text-sm font-medium">{t('suggestions')}</p>
1244
- <div className="grid gap-2 grid-cols-12">
1449
+ <div className="grid grid-cols-6 gap-2 md:grid-cols-8 lg:grid-cols-12">
1245
1450
  {DASHBOARD_ICON_OPTIONS.map((iconName) => {
1246
1451
  const IconOption = resolveDashboardIcon(iconName);
1247
1452
  const isSelected =
@@ -45,6 +45,8 @@ import {
45
45
  TableRow,
46
46
  } from '@/components/ui/table';
47
47
  import { useDebounce } from '@/hooks/use-debounce';
48
+ import { useFormDraft } from '@/hooks/use-form-draft';
49
+ import { formatDateTime } from '@/lib/format-date';
48
50
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
49
51
  import { zodResolver } from '@hookform/resolvers/zod';
50
52
  import {
@@ -55,9 +57,11 @@ import {
55
57
  IconPlus,
56
58
  IconTrash,
57
59
  } from '@tabler/icons-react';
60
+ import { formatDistanceToNow } from 'date-fns';
61
+ import { enUS, ptBR } from 'date-fns/locale';
58
62
  import { useTranslations } from 'next-intl';
59
- import { useState } from 'react';
60
- import { useForm } from 'react-hook-form';
63
+ import { useEffect, useMemo, useState } from 'react';
64
+ import { useForm, useWatch } from 'react-hook-form';
61
65
  import { toast } from 'sonner';
62
66
  import { z } from 'zod';
63
67
 
@@ -66,6 +70,13 @@ type RoleFormData = {
66
70
  role_id: number;
67
71
  };
68
72
 
73
+ type ComponentRoleDraftPayload = {
74
+ values: RoleFormData;
75
+ };
76
+
77
+ const COMPONENT_ROLE_DRAFT_STORAGE_KEY =
78
+ 'core-dashboard-component-role-form-draft';
79
+
69
80
  interface ComponentRole {
70
81
  id: number;
71
82
  component_id: number;
@@ -106,9 +117,25 @@ interface Role {
106
117
  }>;
107
118
  }
108
119
 
120
+ type ComponentListResponse = {
121
+ data?: Component[];
122
+ };
123
+
124
+ type RoleListResponse = {
125
+ data?: Role[];
126
+ };
127
+
128
+ type RequestError = {
129
+ response?: {
130
+ data?: {
131
+ message?: string;
132
+ };
133
+ };
134
+ };
135
+
109
136
  export function ComponentRolesTab() {
110
137
  const t = useTranslations('core.DashboardManagement');
111
- const { request, currentLocaleCode } = useApp();
138
+ const { request, currentLocaleCode, getSettingValue } = useApp();
112
139
 
113
140
  const roleSchema = z.object({
114
141
  component_id: z.coerce.number().min(1, t('selectComponentRequired')),
@@ -120,7 +147,7 @@ export function ComponentRolesTab() {
120
147
  const [roleToDelete, setRoleToDelete] = useState<number | null>(null);
121
148
  const [page, setPage] = useState(1);
122
149
  const [pageSize, setPageSize] = useState(10);
123
- const [searchQuery, setSearchQuery] = useState('');
150
+ const searchQuery = '';
124
151
  const debouncedSearch = useDebounce(searchQuery, 500);
125
152
 
126
153
  const {
@@ -153,10 +180,10 @@ export function ComponentRolesTab() {
153
180
  const total = paginatedData?.total ?? 0;
154
181
  const totalPages = Math.ceil(total / pageSize);
155
182
 
156
- const { data: components } = useQuery<any>({
183
+ const { data: components } = useQuery<ComponentListResponse>({
157
184
  queryKey: ['dashboard-components'],
158
185
  queryFn: async () => {
159
- const { data } = await request<Component[]>({
186
+ const { data } = await request<ComponentListResponse>({
160
187
  url: '/dashboard-component',
161
188
  method: 'GET',
162
189
  });
@@ -164,10 +191,10 @@ export function ComponentRolesTab() {
164
191
  },
165
192
  });
166
193
 
167
- const { data: availableRoles } = useQuery<any>({
194
+ const { data: availableRoles } = useQuery<RoleListResponse>({
168
195
  queryKey: ['roles'],
169
196
  queryFn: async () => {
170
- const { data } = await request<Role[]>({
197
+ const { data } = await request<RoleListResponse>({
171
198
  url: '/role',
172
199
  method: 'GET',
173
200
  });
@@ -183,6 +210,67 @@ export function ComponentRolesTab() {
183
210
  },
184
211
  });
185
212
 
213
+ const watchedValues = useWatch({
214
+ control: form.control,
215
+ });
216
+
217
+ const {
218
+ clearDraft,
219
+ loadDraft,
220
+ hasDraft,
221
+ savedAt: draftSavedAt,
222
+ } = useFormDraft<ComponentRoleDraftPayload>({
223
+ storageKey: COMPONENT_ROLE_DRAFT_STORAGE_KEY,
224
+ value: {
225
+ values: {
226
+ component_id: Number(watchedValues.component_id ?? 0),
227
+ role_id: Number(watchedValues.role_id ?? 0),
228
+ },
229
+ },
230
+ hasData:
231
+ Number(watchedValues.component_id ?? 0) > 0 ||
232
+ Number(watchedValues.role_id ?? 0) > 0,
233
+ enabled: open,
234
+ });
235
+
236
+ const draftStatusContent = useMemo(() => {
237
+ if (!hasDraft || !draftSavedAt) {
238
+ return null;
239
+ }
240
+
241
+ const savedDate = new Date(draftSavedAt);
242
+ if (Number.isNaN(savedDate.getTime())) {
243
+ return null;
244
+ }
245
+
246
+ const locale = currentLocaleCode.startsWith('pt') ? ptBR : enUS;
247
+ const relativeLabel = formatDistanceToNow(savedDate, {
248
+ addSuffix: true,
249
+ locale,
250
+ });
251
+ const absoluteLabel = formatDateTime(
252
+ savedDate,
253
+ getSettingValue,
254
+ currentLocaleCode
255
+ );
256
+
257
+ return currentLocaleCode.startsWith('pt')
258
+ ? `Rascunho salvo ${relativeLabel} • Último salvamento: ${absoluteLabel}`
259
+ : `Draft saved ${relativeLabel} • Last saved: ${absoluteLabel}`;
260
+ }, [draftSavedAt, currentLocaleCode, getSettingValue, hasDraft]);
261
+
262
+ useEffect(() => {
263
+ if (!open) {
264
+ return;
265
+ }
266
+
267
+ const storedDraft = loadDraft();
268
+ form.reset({
269
+ component_id: storedDraft?.payload.values.component_id ?? 0,
270
+ role_id: storedDraft?.payload.values.role_id ?? 0,
271
+ });
272
+ }, [form, loadDraft, open]);
273
+
186
274
  const onSubmit = async (values: RoleFormData) => {
187
275
  try {
188
276
  await request({
@@ -194,12 +282,14 @@ export function ComponentRolesTab() {
194
282
  },
195
283
  });
196
284
  toast.success(t('roleAdded'));
285
+ clearDraft();
197
286
  setOpen(false);
198
287
  form.reset();
199
288
  refetch();
200
- } catch (error: any) {
289
+ } catch (error: unknown) {
201
290
  console.error('Erro ao adicionar permissão:', error);
202
- if (error?.response?.data?.message?.includes('already exists')) {
291
+ const message = (error as RequestError)?.response?.data?.message ?? '';
292
+ if (message.includes('already exists')) {
203
293
  toast.error(t('roleAlreadyExists'));
204
294
  } else {
205
295
  toast.error(t('errorAddingRole'));
@@ -273,8 +363,8 @@ export function ComponentRolesTab() {
273
363
  <FormItem>
274
364
  <FormLabel>{t('component')}</FormLabel>
275
365
  <Select
276
- onValueChange={field.onChange}
277
- defaultValue={String(field.value)}
366
+ value={field.value ? String(field.value) : undefined}
367
+ onValueChange={(value) => field.onChange(Number(value))}
278
368
  >
279
369
  <FormControl>
280
370
  <SelectTrigger className="w-full">
@@ -311,8 +401,8 @@ export function ComponentRolesTab() {
311
401
  <FormItem>
312
402
  <FormLabel>{t('role')}</FormLabel>
313
403
  <Select
314
- onValueChange={field.onChange}
315
- defaultValue={String(field.value)}
404
+ value={field.value ? String(field.value) : undefined}
405
+ onValueChange={(value) => field.onChange(Number(value))}
316
406
  >
317
407
  <FormControl>
318
408
  <SelectTrigger className="w-full">
@@ -337,6 +427,12 @@ export function ComponentRolesTab() {
337
427
  )}
338
428
  />
339
429
 
430
+ {draftStatusContent ? (
431
+ <p className="text-xs text-muted-foreground">
432
+ {draftStatusContent}
433
+ </p>
434
+ ) : null}
435
+
340
436
  <DialogFooter>
341
437
  <Button type="submit">{t('save')}</Button>
342
438
  </DialogFooter>
@@ -353,7 +449,7 @@ export function ComponentRolesTab() {
353
449
  <TableHead>ID</TableHead>
354
450
  <TableHead>{t('component')}</TableHead>
355
451
  <TableHead>{t('role')}</TableHead>
356
- <TableHead className="w-[100px]">{t('actions')}</TableHead>
452
+ <TableHead className="w-25">{t('actions')}</TableHead>
357
453
  </TableRow>
358
454
  </TableHeader>
359
455
  <TableBody>