@hed-hog/core 0.0.302 → 0.0.304

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.
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
 
3
+ import { EmptyState } from '@/components/entity-list';
3
4
  import {
4
5
  Accordion,
5
6
  AccordionContent,
@@ -27,7 +28,7 @@ import {
27
28
  Menu as MenuIcon,
28
29
  } from 'lucide-react';
29
30
  import { useTranslations } from 'next-intl';
30
- import { JSX, useEffect, useState } from 'react';
31
+ import { JSX, useEffect, useMemo, useState, type ComponentType } from 'react';
31
32
  import { toast } from 'sonner';
32
33
 
33
34
  type Menu = {
@@ -51,6 +52,8 @@ type RoleMenusSectionProps = {
51
52
  onMenuChange?: () => void;
52
53
  };
53
54
 
55
+ const EMPTY_MENUS: Menu[] = [];
56
+
54
57
  export function RoleMenusSection({
55
58
  roleId,
56
59
  onMenuChange,
@@ -104,7 +107,7 @@ export function RoleMenusSection({
104
107
  enabled: !!roleId,
105
108
  });
106
109
 
107
- const menus = menusData?.data || [];
110
+ const menus = menusData?.data ?? EMPTY_MENUS;
108
111
  const totalPages = menusData?.lastPage || 1;
109
112
  const totalMenus = menusData?.total || 0;
110
113
 
@@ -189,7 +192,7 @@ export function RoleMenusSection({
189
192
  await refetchAssignedMenus();
190
193
  await refetchMenus();
191
194
  onMenuChange?.();
192
- } catch (error) {
195
+ } catch {
193
196
  toast.error(
194
197
  isAssigned ? t('errorRemovingMenu') : t('errorAssigningMenu')
195
198
  );
@@ -219,19 +222,25 @@ export function RoleMenusSection({
219
222
  match.replace('-', '').toUpperCase()
220
223
  );
221
224
 
225
+ const tablerIcons = TablerIcons as Record<
226
+ string,
227
+ ComponentType<{ className?: string }>
228
+ >;
222
229
  const pascalName = toPascalCase(String(iconName));
223
- let IconComponent = (TablerIcons as any)[`Icon${pascalName}`];
224
- if (!IconComponent) {
225
- IconComponent = (TablerIcons as any)[`Icon${pascalName}Filled`];
226
- }
227
- if (!IconComponent) {
228
- IconComponent = (TablerIcons as any)[`Icon${pascalName}Circle`];
229
- }
230
- if (IconComponent) {
231
- return <IconComponent className="h-5 w-5" />;
232
- }
233
-
234
- return <MenuIcon className="h-5 w-5" />;
230
+ const iconCandidates = [
231
+ `Icon${pascalName}`,
232
+ `Icon${pascalName}Filled`,
233
+ `Icon${pascalName}Circle`,
234
+ ];
235
+ const IconComponent = iconCandidates
236
+ .map((candidate) => tablerIcons[candidate])
237
+ .find(Boolean);
238
+
239
+ return IconComponent ? (
240
+ <IconComponent className="h-5 w-5" />
241
+ ) : (
242
+ <MenuIcon className="h-5 w-5" />
243
+ );
235
244
  };
236
245
 
237
246
  const organizeMenuHierarchy = (menusList: Menu[]): Menu[] => {
@@ -255,15 +264,23 @@ export function RoleMenusSection({
255
264
  return rootMenus;
256
265
  };
257
266
 
258
- const hierarchicalMenus = organizeMenuHierarchy(menus);
267
+ const hierarchicalMenus = useMemo(
268
+ () => organizeMenuHierarchy(menus),
269
+ [menus]
270
+ );
271
+ const defaultExpandedMenus = useMemo(
272
+ () =>
273
+ hierarchicalMenus
274
+ .filter((menu) => menu.children && menu.children.length > 0)
275
+ .map((menu) => `menu-${menu.id}`),
276
+ [hierarchicalMenus]
277
+ );
278
+
259
279
  useEffect(() => {
260
- if (expandedMenus.length === 0 && hierarchicalMenus.length > 0) {
261
- const allParentIds = hierarchicalMenus
262
- .filter((m) => m.children && m.children.length > 0)
263
- .map((m) => `menu-${m.id}`);
264
- setExpandedMenus(allParentIds);
280
+ if (expandedMenus.length === 0 && defaultExpandedMenus.length > 0) {
281
+ setExpandedMenus(defaultExpandedMenus);
265
282
  }
266
- }, [hierarchicalMenus.length]);
283
+ }, [defaultExpandedMenus, expandedMenus.length]);
267
284
 
268
285
  const renderChildMenu = (menu: Menu): JSX.Element => {
269
286
  const isAssigned = isMenuAssigned(menu);
@@ -326,12 +343,12 @@ export function RoleMenusSection({
326
343
 
327
344
  if (!menus || menus.length === 0) {
328
345
  return (
329
- <div className="border border-dashed rounded-lg p-8 flex flex-col items-center justify-center">
330
- <MenuIcon className="h-12 w-12 text-muted-foreground mb-3" />
331
- <p className="text-sm font-medium text-center">
332
- {t('noMenusAvailable')}
333
- </p>
334
- </div>
346
+ <EmptyState
347
+ className="min-h-60 py-10"
348
+ icon={<MenuIcon className="h-12 w-12" />}
349
+ title={t('noMenusAvailable')}
350
+ description={t('menusDescription')}
351
+ />
335
352
  );
336
353
  }
337
354
 
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
 
3
+ import { EmptyState } from '@/components/entity-list';
3
4
  import { Button } from '@/components/ui/button';
4
5
  import { Card, CardContent } from '@/components/ui/card';
5
6
  import { Input } from '@/components/ui/input';
@@ -43,6 +44,8 @@ type RoleRoutesSectionProps = {
43
44
  onRouteChange?: () => void;
44
45
  };
45
46
 
47
+ type SearchType = 'contains' | 'startsWith' | 'endsWith';
48
+
46
49
  export function RoleRoutesSection({
47
50
  roleId,
48
51
  onRouteChange,
@@ -53,12 +56,20 @@ export function RoleRoutesSection({
53
56
  const [page, setPage] = useState(1);
54
57
  const [pageSize, setPageSize] = useState(10);
55
58
  const [searchTerm, setSearchTerm] = useState('');
56
- const [searchType, setSearchType] = useState<
57
- 'contains' | 'startsWith' | 'endsWith'
58
- >('contains');
59
+ const [searchType, setSearchType] = useState<SearchType>('contains');
59
60
  const [methodFilter, setMethodFilter] = useState<string>('all');
60
61
  const debouncedSearch = useDebounce(searchTerm);
61
62
 
63
+ const handleSearchTypeChange = (value: string) => {
64
+ if (
65
+ value === 'contains' ||
66
+ value === 'startsWith' ||
67
+ value === 'endsWith'
68
+ ) {
69
+ setSearchType(value);
70
+ }
71
+ };
72
+
62
73
  const {
63
74
  data: assignedRoutesData,
64
75
  isLoading: isLoadingAssigned,
@@ -143,7 +154,7 @@ export function RoleRoutesSection({
143
154
  await refetchAssignedRoutes();
144
155
  await refetchRoutes();
145
156
  onRouteChange?.();
146
- } catch (error) {
157
+ } catch {
147
158
  toast.error(
148
159
  isAssigned ? t('errorRemovingRoute') : t('errorAssigningRoute')
149
160
  );
@@ -203,10 +214,7 @@ export function RoleRoutesSection({
203
214
  />
204
215
  </div>
205
216
  <div className="w-full flex gap-2">
206
- <Select
207
- value={searchType}
208
- onValueChange={(value: any) => setSearchType(value)}
209
- >
217
+ <Select value={searchType} onValueChange={handleSearchTypeChange}>
210
218
  <SelectTrigger className="w-full">
211
219
  <SelectValue placeholder={t('selectSearchType')} />
212
220
  </SelectTrigger>
@@ -299,14 +307,11 @@ export function RoleRoutesSection({
299
307
  );
300
308
  })
301
309
  ) : (
302
- <Card className="border-dashed">
303
- <CardContent className="flex flex-col items-center justify-center py-8">
304
- <RouteIcon className="h-12 w-12 text-muted-foreground mb-3" />
305
- <p className="text-sm font-medium text-center">
306
- {t('noRoutesAvailable')}
307
- </p>
308
- </CardContent>
309
- </Card>
310
+ <EmptyState
311
+ className="min-h-60 py-10"
312
+ icon={<RouteIcon className="h-12 w-12" />}
313
+ title={t('noRoutesAvailable')}
314
+ />
310
315
  )}
311
316
  </div>
312
317
 
@@ -1,5 +1,7 @@
1
1
  'use client';
2
2
 
3
+ import { EmptyState } from '@/components/entity-list';
4
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
3
5
  import { Button } from '@/components/ui/button';
4
6
  import { Card, CardContent } from '@/components/ui/card';
5
7
  import { Input } from '@/components/ui/input';
@@ -120,7 +122,7 @@ export function RoleUsersSection({
120
122
  await refetchAssignedUsers();
121
123
  await refetchAllUsers();
122
124
  onUserChange?.();
123
- } catch (error) {
125
+ } catch {
124
126
  toast.error(
125
127
  isAssigned ? t('errorRemovingUser') : t('errorAssigningUser')
126
128
  );
@@ -184,11 +186,20 @@ export function RoleUsersSection({
184
186
  >
185
187
  <CardContent className="flex items-center justify-between">
186
188
  <div className="flex items-center gap-3 flex-1">
187
- <img
188
- src={getPhotoUrl(user.photo_id)}
189
- alt={user.name}
190
- className="h-10 w-10 rounded-full object-cover"
191
- />
189
+ <Avatar className="h-10 w-10">
190
+ <AvatarImage
191
+ src={getPhotoUrl(user.photo_id)}
192
+ alt={user.name}
193
+ />
194
+ <AvatarFallback>
195
+ {user.name
196
+ ?.split(' ')
197
+ .filter(Boolean)
198
+ .slice(0, 2)
199
+ .map((part) => part[0]?.toUpperCase() ?? '')
200
+ .join('') || 'U'}
201
+ </AvatarFallback>
202
+ </Avatar>
192
203
  <div className="flex-1">
193
204
  <Label
194
205
  htmlFor={`user-${user.id}`}
@@ -215,14 +226,11 @@ export function RoleUsersSection({
215
226
  );
216
227
  })
217
228
  ) : (
218
- <Card className="border-dashed">
219
- <CardContent className="flex flex-col items-center justify-center py-8">
220
- <UserCircle className="h-12 w-12 text-muted-foreground mb-3" />
221
- <p className="text-sm font-medium text-center">
222
- {t('noUsersAvailable')}
223
- </p>
224
- </CardContent>
225
- </Card>
229
+ <EmptyState
230
+ className="min-h-60 py-10"
231
+ icon={<UserCircle className="h-12 w-12" />}
232
+ title={t('noUsersAvailable')}
233
+ />
226
234
  )}
227
235
  </div>
228
236
 
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
 
3
+ import { EmptyState } from '@/components/entity-list';
3
4
  import { Card, CardContent } from '@/components/ui/card';
4
5
  import { Label } from '@/components/ui/label';
5
6
  import { Switch } from '@/components/ui/switch';
@@ -61,7 +62,7 @@ export function PermissionsSection({
61
62
  }
62
63
  await refetchUserRoles();
63
64
  onRoleChange?.();
64
- } catch (error) {
65
+ } catch {
65
66
  toast.error(
66
67
  isAssigned ? t('errorRemovingRole') : t('errorAssigningRole')
67
68
  );
@@ -83,14 +84,11 @@ export function PermissionsSection({
83
84
 
84
85
  if (!userRoles || userRoles.length === 0) {
85
86
  return (
86
- <Card className="border-dashed">
87
- <CardContent className="flex flex-col items-center justify-center py-8">
88
- <ShieldCheck className="h-12 w-12 text-muted-foreground mb-3" />
89
- <p className="text-sm font-medium text-center">
90
- {t('noRolesAvailable')}
91
- </p>
92
- </CardContent>
93
- </Card>
87
+ <EmptyState
88
+ className="min-h-60 py-10"
89
+ icon={<ShieldCheck className="h-12 w-12" />}
90
+ title={t('noRolesAvailable')}
91
+ />
94
92
  );
95
93
  }
96
94
 
@@ -1484,6 +1484,33 @@
1484
1484
  "treeOrderSaved": "Menu order saved successfully.",
1485
1485
  "treeOrderError": "Error saving menu order.",
1486
1486
  "treeChild": "child",
1487
- "treeChildren": "children"
1487
+ "treeChildren": "children",
1488
+ "refresh": "Refresh",
1489
+ "treeWorkspaceTitle": "Structure and permissions",
1490
+ "treeWorkspaceDescription": "Click to select, drag to reorganize, and use the context menu for quick actions.",
1491
+ "treeEmptySearch": "No menu matches the current search.",
1492
+ "selectedMenuTitle": "Selected menu",
1493
+ "selectedMenuDescription": "Edit the item, organize its hierarchy, and define which roles unlock access.",
1494
+ "noMenuSelectedTitle": "Select a menu to get started",
1495
+ "noMenuSelectedDescription": "Choose an item in the tree to edit its data and manage permissions without leaving the page.",
1496
+ "duplicateMenu": "Duplicate menu",
1497
+ "duplicateSuccess": "Menu duplicated successfully.",
1498
+ "duplicateError": "Unable to duplicate the menu.",
1499
+ "moveToRoot": "Move to root",
1500
+ "moveToRootSuccess": "Menu moved to the root.",
1501
+ "addSubmenu": "Add submenu",
1502
+ "rootMenuLabel": "Root",
1503
+ "childrenCount": "Children",
1504
+ "parentMenuSummary": "Parent menu",
1505
+ "selectedLocaleLabel": "Language",
1506
+ "openTreeOnMobile": "Open tree",
1507
+ "totalSubmenus": "Submenus",
1508
+ "searchResults": "Results",
1509
+ "usersWithAccessTitle": "Users with access",
1510
+ "usersWithAccessDescription": "Preview of who can open this menu through the assigned roles.",
1511
+ "noAssignedRolesPreview": "Assign at least one role to preview who will gain access.",
1512
+ "noUsersWithAccess": "No users are currently linked to the assigned roles.",
1513
+ "grantedByRole": "Granted by",
1514
+ "moreUsers": "+{count} more users"
1488
1515
  }
1489
1516
  }
@@ -1539,6 +1539,33 @@
1539
1539
  "treeOrderSaved": "Ordem dos menus salva com sucesso.",
1540
1540
  "treeOrderError": "Erro ao salvar a ordem dos menus.",
1541
1541
  "treeChild": "filho",
1542
- "treeChildren": "filhos"
1542
+ "treeChildren": "filhos",
1543
+ "refresh": "Atualizar",
1544
+ "treeWorkspaceTitle": "Estrutura e permissões",
1545
+ "treeWorkspaceDescription": "Clique para selecionar, arraste para reorganizar e use o menu de contexto para ações rápidas.",
1546
+ "treeEmptySearch": "Nenhum menu corresponde à busca atual.",
1547
+ "selectedMenuTitle": "Menu selecionado",
1548
+ "selectedMenuDescription": "Edite o item, organize sua hierarquia e defina quais cargos liberam o acesso.",
1549
+ "noMenuSelectedTitle": "Selecione um menu para começar",
1550
+ "noMenuSelectedDescription": "Escolha um item na árvore para editar seus dados e controlar suas permissões sem sair da tela.",
1551
+ "duplicateMenu": "Duplicar menu",
1552
+ "duplicateSuccess": "Menu duplicado com sucesso.",
1553
+ "duplicateError": "Não foi possível duplicar o menu.",
1554
+ "moveToRoot": "Mover para raiz",
1555
+ "moveToRootSuccess": "Menu movido para a raiz.",
1556
+ "addSubmenu": "Adicionar submenu",
1557
+ "rootMenuLabel": "Raiz",
1558
+ "childrenCount": "Filhos",
1559
+ "parentMenuSummary": "Menu pai",
1560
+ "selectedLocaleLabel": "Idioma",
1561
+ "openTreeOnMobile": "Abrir árvore",
1562
+ "totalSubmenus": "Submenus",
1563
+ "searchResults": "Resultados",
1564
+ "usersWithAccessTitle": "Usuários com acesso",
1565
+ "usersWithAccessDescription": "Prévia de quem poderá abrir este menu por causa dos cargos atribuídos.",
1566
+ "noAssignedRolesPreview": "Associe pelo menos um cargo para visualizar quem ganhará acesso.",
1567
+ "noUsersWithAccess": "Nenhum usuário está vinculado aos cargos atribuídos no momento.",
1568
+ "grantedByRole": "Liberado por",
1569
+ "moreUsers": "+{count} usuários"
1543
1570
  }
1544
1571
  }
@@ -2,14 +2,12 @@ columns:
2
2
  - type: pk
3
3
  - name: role_id
4
4
  type: fk
5
- isUnique: true
6
5
  references:
7
6
  table: role
8
7
  column: id
9
8
  onDelete: CASCADE
10
9
  - name: menu_id
11
10
  type: fk
12
- isUnique: true
13
11
  references:
14
12
  table: menu
15
13
  column: id
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hed-hog/core",
3
- "version": "0.0.302",
3
+ "version": "0.0.304",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "dependencies": {
@@ -32,10 +32,10 @@
32
32
  "uuid": "^11.1.0",
33
33
  "@hed-hog/api": "0.0.6",
34
34
  "@hed-hog/api-mail": "0.0.9",
35
+ "@hed-hog/api-pagination": "0.0.7",
35
36
  "@hed-hog/api-types": "0.0.1",
36
- "@hed-hog/api-prisma": "0.0.6",
37
37
  "@hed-hog/api-locale": "0.0.14",
38
- "@hed-hog/api-pagination": "0.0.7"
38
+ "@hed-hog/api-prisma": "0.0.6"
39
39
  },
40
40
  "exports": {
41
41
  ".": {
package/src/index.ts CHANGED
@@ -19,13 +19,13 @@ export * from './screen/screen.service';
19
19
  export * from './user/constants/user.constants';
20
20
  export * from './user/user.service';
21
21
 
22
- export * from './mail/mail.module';
23
- export * from './mail/mail.service';
24
-
25
- export * from './file/file.module';
26
- export * from './file/file.service';
27
- export * from './setting/setting.module';
28
- export * from './setting/setting.service';
22
+ export * from './mail/mail.module';
23
+ export * from './mail/mail.service';
24
+
25
+ export * from './file/file.module';
26
+ export * from './file/file.service';
27
+ export * from './setting/setting.module';
28
+ export * from './setting/setting.service';
29
29
 
30
30
  export * from './ai/ai.module';
31
31
  export * from './ai/ai.service';
@@ -50,3 +50,6 @@ export * from './validators/is-email-with-settings.validator';
50
50
  export * from './validators/is-pin-code-with-setting.validator';
51
51
  export * from './validators/is-strong-password-with-settings.validator';
52
52
 
53
+ // Utilities
54
+ export * from './utils/locale-context';
55
+
@@ -3,11 +3,11 @@ import { getLocaleText } from '@hed-hog/api-locale';
3
3
  import { PaginationDTO, PaginationService } from '@hed-hog/api-pagination';
4
4
  import { PrismaService } from '@hed-hog/api-prisma';
5
5
  import {
6
- BadRequestException,
7
- Inject,
8
- Injectable,
9
- NotFoundException,
10
- forwardRef,
6
+ BadRequestException,
7
+ Inject,
8
+ Injectable,
9
+ NotFoundException,
10
+ forwardRef,
11
11
  } from '@nestjs/common';
12
12
  import { DeleteDTO } from '../dto/delete.dto';
13
13
  import { UpdateIdsDTO } from '../dto/update-ids.dto';
@@ -24,6 +24,67 @@ export class MenuService {
24
24
  private readonly paginationService: PaginationService,
25
25
  ) {}
26
26
 
27
+ private async ensureValidParent(
28
+ locale: string,
29
+ currentMenuId: number | null,
30
+ parentId?: number | null,
31
+ ): Promise<void> {
32
+ if (parentId == null) {
33
+ return;
34
+ }
35
+
36
+ const parent = await this.prismaService.menu.findUnique({
37
+ where: { id: parentId },
38
+ select: { id: true, menu_id: true },
39
+ });
40
+
41
+ if (!parent) {
42
+ throw new BadRequestException(
43
+ getLocaleText('menuNotFound', locale, 'Menu not found.'),
44
+ );
45
+ }
46
+
47
+ if (currentMenuId != null && parentId === currentMenuId) {
48
+ throw new BadRequestException(
49
+ getLocaleText(
50
+ 'menuInvalidParent',
51
+ locale,
52
+ 'A menu cannot be its own parent.',
53
+ ),
54
+ );
55
+ }
56
+
57
+ if (currentMenuId == null) {
58
+ return;
59
+ }
60
+
61
+ const visited = new Set<number>([currentMenuId]);
62
+ let cursor: { id: number; menu_id: number | null } | null = parent;
63
+
64
+ while (cursor) {
65
+ if (visited.has(cursor.id)) {
66
+ throw new BadRequestException(
67
+ getLocaleText(
68
+ 'menuInvalidParent',
69
+ locale,
70
+ 'You cannot move a menu inside itself or one of its descendants.',
71
+ ),
72
+ );
73
+ }
74
+
75
+ visited.add(cursor.id);
76
+
77
+ if (cursor.menu_id == null) {
78
+ break;
79
+ }
80
+
81
+ cursor = await this.prismaService.menu.findUnique({
82
+ where: { id: cursor.menu_id },
83
+ select: { id: true, menu_id: true },
84
+ });
85
+ }
86
+ }
87
+
27
88
  async updateScreens(locale:string,menuId: number, data: UpdateIdsDTO): Promise<{count:number}> {
28
89
 
29
90
  const menuExists = await this.prismaService.menu.count({
@@ -170,6 +231,26 @@ export class MenuService {
170
231
  menu_id: true,
171
232
  },
172
233
  },
234
+ role_user: {
235
+ select: {
236
+ user_id: true,
237
+ user: {
238
+ select: {
239
+ id: true,
240
+ name: true,
241
+ user_identifier: {
242
+ where: {
243
+ type: 'email',
244
+ },
245
+ select: {
246
+ value: true,
247
+ },
248
+ take: 1,
249
+ },
250
+ },
251
+ },
252
+ },
253
+ },
173
254
  },
174
255
  },
175
256
  'role_locale',
@@ -252,6 +333,11 @@ export class MenuService {
252
333
  where: { locale: { code: locale } },
253
334
  select: { name: true },
254
335
  },
336
+ _count: {
337
+ select: {
338
+ role_menu: true,
339
+ },
340
+ },
255
341
  },
256
342
  });
257
343
  return menus.map((m: any) => itemTranslations('menu_locale', m));
@@ -293,6 +379,11 @@ export class MenuService {
293
379
  name: true,
294
380
  },
295
381
  },
382
+ _count: {
383
+ select: {
384
+ role_menu: true,
385
+ },
386
+ },
296
387
  },
297
388
  },
298
389
  'menu_locale',
@@ -323,6 +414,8 @@ export class MenuService {
323
414
  }
324
415
 
325
416
  async create(_locale: string, { slug, url, icon, order, menu_id, locale }: CreateDTO) {
417
+ await this.ensureValidParent(_locale, null, menu_id);
418
+
326
419
  const created = await this.prismaService.menu.create({
327
420
  data: { slug, url, icon, order, menu_id },
328
421
  });
@@ -355,6 +448,8 @@ export class MenuService {
355
448
  },
356
449
  });
357
450
 
451
+ await this.ensureValidParent(locale, id, data.menu_id);
452
+
358
453
  if (!menuExists) {
359
454
  throw new BadRequestException(
360
455
  getLocaleText('menuNotFound', locale, 'Menu not found.'),